fix(orchestration): give an OpenCode worker its task only once OpenCode can submit it (#25101)

* fix(orchestration): keep pressing Enter until an OpenCode worker's task starts

OpenCode 2 draws its input box before it has loaded its agent list from its
server, and silently drops an Enter that arrives in that gap. On a loaded
machine the gap outlasts the 0.5 s Orca waits after pasting, so a worker's
task sat unsent in the box while worker-start reported it ready.

OpenCode's turn start is now observed through its status hooks, like Codex
and Claude. While no turn has started, the prompt verifier presses Enter
again on an interval (per the agent row, opencode and opencode2) for as
long as the pane's hooks are reporting, never past an approval or question
prompt or a replaced pane, and at most a fixed number of times. A pane
whose hooks never report keeps today's single Enter and unobserved receipt.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(opencode): give a worker its task only once OpenCode can submit it

OpenCode 2 draws its input box, and shows its cursor there, before its agent
list has loaded from its server, and it silently drops an Enter that arrives in
that gap. Orca pasted a worker's task on the box's cursor and pressed Enter half
a second later, so on a loaded machine the task stayed unsent in the box while
the worker read as started.

OpenCode's paste readiness now also waits for the row under the input box
(`<agent> · <model>`), which OpenCode paints only once the agent list exists.
The rule keys on the row's separator inside OpenCode's alternate screen, not on
names or colours. Both the opencode and opencode2 rows use it, worker start and
the renderer's draft paste alike. OpenCode 1 paints the row in the box's own
frame, so it is ready on the same read as before.

Recorded OpenCode 2.0.21 boots (cold, hidden pane, four at once, attached to a
running server) are the evidence; two more show an Enter after the row
submitting and one at the box being dropped. The earlier resubmit loop and the
settlement-provider changes are removed.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(opencode): prove the agent-row signal with natural captures and never paste a draft blind

Replaces three recordings made by launching four OpenCode boots at once with
natural single-run evidence: one cold start that pasted on the agent row and
pressed Enter on Orca's real 500 ms timing (submitted), and one cold start
recorded under heavy load from other sessions, where the paste on the box's
cursor and Orca's Enter came before the agent row and the brief stayed unsent.
Adds a 2.0.14 cold start as a cross-version check.

The renderer's draft paste no longer falls back to a blind paste when the
OpenCode agent-row signal never arrives: OpenCode would drop the Enter and
leave the draft in its box, so the caller gets its timeout notice instead.
Other agents keep the fallback.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(opencode): type the single-signal predicate against the public signals

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(opencode): read the agent row by its place on screen and wait for it only before Enter

- The agent-row signal now keys on structure: the `·` must sit on the line
  directly above the input box's bottom-left corner, so a `·` in the footer
  path or a session tab title no longer counts. Leaving the alternate screen or
  turning bracketed paste off withdraws what each part established.
- Only deliveries that press Enter wait for the row (new
  `submitPasteReadySignal`); drafts keep the box-cursor rule, as on main.
  Worker start always submits.
- When the box cursor has shown but the row never comes (OpenCode 2 drops the
  agent below 44 columns), both waiters proceed after a 5 s grace, as main
  did. A natural 40-column capture proves that start submits.
- The renderer's blind-paste fallback is back to main's behaviour.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(opencode): take the box at the readiness deadline while the agent-row grace is pending

- Both paste waiters resolve ready, not timed out, when the hard deadline
  lands while the 5 s grace is pending: the box was seen, so this is the
  verdict the box rule gave before.
- Tab-bound draft paste is tested to wait on the agent row only when Enter
  follows.
- Scanner: a separator pairs with its corner only inside one synchronized
  frame, row 0 clamps to 1, and the grace comment names the transparent-theme
  case. The draft signal type no longer admits the agent-row signal, whose
  grace only the paste waiters honour.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(opencode): withdraw the agent-row grace when OpenCode leaves the alternate screen

If OpenCode exited after drawing its box but before its agent row, the grace
still ran out and the task was pasted into the shell. Leaving the alternate
screen now clears the box cursor, and once an app has left it a cursor the
shell shows outside it no longer starts a grace. A terminal that never
forwards the alternate screen still gets one.

Co-Authored-By: Claude <noreply@anthropic.com>

* test(serialize): register the new OpenCode captures' pre-existing grid-restore counts

The six OpenCode 2 captures this PR adds hit the serializer limitations the
1.18.32 and 2.0.18 captures are already registered for: the live pen's
background (and once its bold bit) on restored cells, rows kept below the
source after a shrink, and the cursor one column short after a shrink. No
I1 or I3 failure; the serializer is unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(opencode): bound the readiness deadline's last settle check

With a grace pending, the deadline runs one more settle check, and over SSH
its foreground read could hold a past-due wait for the channel's 30 s
timeout. It now gives up after 2 s. Tests cover a hand-off, a failure and an
abort during that check, a refused grace settled at the deadline, the bound,
and the grace restarting at a hand-off.

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
OrcaWin
2026-10-05 15:09:56 -07:00
committed by GitHub
co-authored by Claude m4air
parent 10bea3a8fa
commit 990b0ed5cd
54 changed files with 1455 additions and 82 deletions
@@ -40,6 +40,15 @@ const KNOWN_PREEXISTING_I2_FAILURES: Record<string, number> = {
'opencode-1-18-32-timed-boot-slow': 16,
'opencode-1-18-32-timed-first-launch': 16,
'opencode-2-0-18-timed-boot-hidden-pane': 14,
// STA-9359 OpenCode 2 captures, serializer untouched: every background diff is the live pen's
// panel colour on restored cells, plus the rows kept below the source after a shrink.
'opencode-2-0-14-timed-cold-standalone': 4,
'opencode-2-0-21-timed-cold-standalone': 36,
'opencode-2-0-21-timed-cold-standalone-hidden-pane': 6,
'opencode-2-0-21-timed-natural-load-enter-dropped': 12,
'opencode-cmd-2-0-21-timed-warm-server': 21,
// The same, plus the cursor one column short after a shrink and the pen's bold bit on a glyph.
'opencode-2-0-21-timed-narrow-pane': 8,
// Shrink leaves the cursor one column short; also present in the pre-Qoder serializer.
'qoder-no-account': 2,
'qoder-ready': 2,
@@ -0,0 +1,9 @@
{
"capturedAt": "2026-10-04T05:18:50.064162Z",
"platform": "darwin",
"command": ["opencode", "--standalone"],
"cols": 120,
"rows": 40,
"note": "opencode v2.0.14 (Homebrew opencode-v2) on a macOS laptop, 120x40, alternate screen; a cross-version check for the agent-row signal. Recorded on the laptop with a Python PTY recorder that stamps each read with its ms since spawn; --standalone private server (cold), model opencode/muse-spark-1.3-contributor-free via OPENCODE_CONFIG_CONTENT, every ORCA_* variable stripped, no input. Box cursor at 1366 ms, agent/model row at 1408 ms. The footer path was replaced on the laptop with the same-length ~/projects/demo-01/... . <name>.timing.json holds each PTY read as [ms since spawn, UTF-16 length] and recordedUntilMs (the last read; the recorder stopped there).",
"exitCode": null
}
@@ -0,0 +1 @@
{"recordedUntilMs":1561,"chunks":[[307,280],[307,37],[307,8],[609,1024],[609,1024],[609,1024],[609,1024],[609,1024],[609,577],[1319,1024],[1319,1024],[1319,1024],[1319,1024],[1319,1024],[1320,817],[1325,13],[1366,900],[1366,900],[1366,358],[1376,100],[1408,388],[1425,527],[1442,663],[1459,666],[1476,696],[1493,661],[1510,627],[1527,486],[1544,412],[1561,378]]}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,9 @@
{
"capturedAt": "2026-10-04T05:13:03.308914Z",
"platform": "darwin",
"command": ["opencode", "--standalone"],
"cols": 120,
"rows": 40,
"note": "opencode v2.0.21 (Homebrew opencode-v2) on macOS 26 arm64, 120x40, alternate screen. Recorded with the timed Python PTY driver (each read stamped with ms since spawn; terminal queries answered like Orca's pane), env -i with an isolated HOME and XDG dirs (no credentials; OpenCode Zen's free models), OPENCODE_DISABLE_AUTOUPDATE=1, OPENCODE_CONFIG_CONTENT {\"autoupdate\":false}, no ORCA_* vars; version and binary sha256 unchanged before and after. <name>.timing.json holds each PTY read as [ms since spawn, UTF-16 length] and recordedUntilMs. Why this run: the same cold --standalone start answered like a hidden Orca pane (responder 'headless': silent on OSC colour queries). No input typed.",
"exitCode": null
}
@@ -0,0 +1 @@
{"recordedUntilMs":11423.8,"chunks":[[241.4,312],[242.6,37],[242.8,1024],[243.1,1024],[243.3,1024],[243.6,1024],[243.9,1024],[244.1,1024],[244.4,141],[261.2,1024],[261.6,1024],[261.9,1024],[262.2,1024],[262.6,1024],[263.0,937],[1267.3,1024],[1268.0,1024],[1268.6,1024],[1269.2,1024],[1269.7,1024],[1270.1,1024],[1270.6,353],[1275.2,13],[1313.7,934],[1314.2,938],[1314.6,835],[1331.9,173],[1347.7,476],[1364.9,476],[1382.0,481],[1399.7,490],[1418.0,491],[1435.8,497],[1453.5,497],[1470.7,497],[1489.3,497]]}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,9 @@
{
"capturedAt": "2026-10-04T05:12:27.181737Z",
"platform": "darwin",
"command": ["opencode", "--standalone"],
"cols": 120,
"rows": 40,
"note": "opencode v2.0.21 (Homebrew opencode-v2) on macOS 26 arm64, 120x40, alternate screen. Recorded with the timed Python PTY driver (each read stamped with ms since spawn; terminal queries answered like Orca's pane), env -i with an isolated HOME and XDG dirs (no credentials; OpenCode Zen's free models), OPENCODE_DISABLE_AUTOUPDATE=1, OPENCODE_CONFIG_CONTENT {\"autoupdate\":false}, no ORCA_* vars; version and binary sha256 unchanged before and after. <name>.timing.json holds each PTY read as [ms since spawn, UTF-16 length] and recordedUntilMs. Why this run: a cold start of a fresh private server (--standalone, as the opencode2 row launches); the agent/model row under the input box ('<agent> \u00b7 <model>') is painted 47 ms after the box. Responder 'xterm' (Orca's visible pane). No input typed.",
"exitCode": null
}
@@ -0,0 +1 @@
{"recordedUntilMs":15442.9,"chunks":[[294.0,280],[295.2,37],[296.0,16],[296.1,16],[296.2,16],[296.2,32],[296.2,24],[296.2,24],[296.3,24],[296.3,36],[296.4,72],[304.5,1024],[304.8,1024],[305.1,1024],[305.4,1024],[305.7,1024],[306.3,1024],[306.5,353],[308.7,13],[341.4,934],[341.8,938],[342.1,835],[356.6,173],[389.4,218],[407.9,314],[429.0,314],[447.9,319],[466.4,325],[484.7,326],[502.8,326],[520.8,326],[538.9,326],[746.5,265]]}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,9 @@
{
"capturedAt": "2026-10-04T05:49:55.163902Z",
"platform": "darwin",
"command": ["opencode", "--standalone"],
"cols": 120,
"rows": 40,
"note": "opencode v2.0.21 (Homebrew opencode-v2) on macOS 26 arm64, 120x40, alternate screen. Recorded with the timed Python PTY driver (each read stamped with ms since spawn; terminal queries answered like Orca's pane). One natural cold start, no other capture running; load average at start 2.76 / 4.63 / 7.28. --standalone with the user's own login and plugins read in place and the model opencode-go/deepseek-v4.1-flash set through OPENCODE_CONFIG_CONTENT (nothing on disk written by the recorder); OPENCODE_DISABLE_AUTOUPDATE=1, no ORCA_* vars; version and binary sha256 unchanged before and after. An 82-line, 6.6 KB brief was bracketed-pasted the moment the agent/model row painted (393 ms; box cursor at 273 ms) and Enter was sent 537 ms later (promptSentAtMs), Orca's submit delay of 500 ms plus paste time. OpenCode submitted it: the composer cleared and the brief and the reply were painted. <name>.timing.json holds each PTY read as [ms since spawn, UTF-16 length] and recordedUntilMs.",
"exitCode": null
}
@@ -0,0 +1 @@
{"recordedUntilMs":15995.5,"chunks":[[211.0,280],[212.6,69],[221.4,1024],[221.7,1024],[222.0,1024],[222.3,1024],[222.5,1024],[222.8,1024],[223.0,581],[226.5,13],[239.8,1024],[240.4,1024],[240.7,1024],[241.0,1024],[241.2,1024],[241.5,1024],[242.2,353],[271.1,1024],[271.6,1024],[271.8,952],[272.1,928],[272.3,888],[272.5,412],[328.5,338],[358.1,1016],[358.6,1018],[359.0,935],[374.5,1016],[375.1,1018],[375.4,979],[392.9,1016],[393.2,1018],[393.4,1017],[393.8,236],[409.7,1016],[410.0,1018],[410.2,1016],[410.4,413],[426.4,1018],[426.7,1018],[426.9,1018],[427.1,672],[443.5,1018],[443.7,1018],[444.0,1018],[444.2,677],[460.1,1018],[460.4,1018],[460.6,1018],[460.8,683],[477.6,1016],[477.9,1018],[478.2,1016],[478.4,249],[495.5,1014],[495.8,1014],[496.1,363],[514.6,326],[532.2,326],[549.4,326],[583.7,369],[601.1,1014],[601.4,1014],[601.7,77],[623.0,1016],[623.4,1016],[623.7,542],[640.8,1016],[641.1,1016],[641.3,542],[657.5,1014],[657.8,1014],[658.1,77],[675.1,1016],[675.6,1018],[675.9,979],[693.8,1016],[694.1,1018],[694.3,979],[712.9,1016],[713.2,1016],[713.8,542],[729.6,1014],[729.9,1014],[730.2,77],[749.7,1016],[750.0,1018],[750.2,979],[768.1,1016],[768.4,1016],[768.6,512],[785.8,1016],[786.0,1016],[786.3,542],[804.2,1016],[804.5,1016],[804.8,542],[822.8,1012],[823.1,624],[840.6,1016],[840.9,1016],[841.2,542],[859.1,1014],[859.5,1014],[859.8,47],[877.9,1012],[878.4,624],[955.0,119],[966.9,13],[993.0,1016],[993.3,1018],[993.6,1016],[993.9,1018],[994.1,1018],[994.4,1016],[994.6,1018],[994.9,1016],[995.1,1018],[995.4,1015],[995.6,791],[995.8,87],[998.8,285],[1018.6,254],[1035.4,297],[1039.3,13],[1054.4,992],[1054.7,976],[1055.0,106],[1072.4,259],[1089.7,86],[1107.9,180],[1141.6,225],[1158.9,86],[1177.2,269],[1219.6,346],[1238.2,259],[1255.3,119],[1282.4,357],[1291.1,226],[1309.7,482],[1326.7,226],[1344.1,438],[1362.7,259],[1381.6,152],[1399.0,193],[1416.9,226],[1435.3,426],[1453.7,192],[1472.2,432],[1490.1,309],[1507.5,412],[1525.9,221],[1542.3,195],[1560.8,445],[1579.8,241],[1596.9,287],[1614.8,357],[1633.1,276],[1650.5,239],[1667.7,290],[1684.2,409],[1701.8,225],[1719.8,276],[1738.4,240],[1756.5,416],[1774.9,240],[1792.3,495],[1809.0,241],[1826.9,326],[1845.7,558],[1863.8,279],[1881.0,653],[1897.2,275],[1913.9,593],[1930.5,428],[1947.3,275],[1965.8,722],[1983.3,410],[2000.4,630],[2023.4,392],[2037.2,294],[2055.2,341],[2073.1,345],[2091.6,680],[2110.3,392],[2127.2,569],[2146.2,477],[2164.4,609],[2183.0,378],[2200.0,480],[2217.1,446],[2234.2,397],[2251.7,541],[2268.3,277],[2286.2,535],[2304.0,446],[2320.6,399],[2338.8,329],[2357.2,434],[2374.4,432],[2393.1,430],[2412.4,381],[2430.8,434],[2448.8,450],[2465.5,429],[2484.2,365],[2502.4,312],[2521.1,451],[2538.7,398],[2552.9,32],[2555.5,845],[2573.6,315],[2591.3,452],[2608.3,1024],[2609.0,1024],[2609.6,628],[2625.6,380],[2644.2,314],[2660.7,436],[2678.3,379],[2691.5,32],[2695.6,1022],[2695.9,1024],[2696.3,1023],[2696.8,261],[2713.8,773],[2729.8,929],[2747.9,722],[2764.2,806],[2782.8,722],[2801.5,755],[2820.0,634],[2838.8,315],[2857.3,608],[2875.9,429],[2894.5,555],[2913.7,484],[2930.8,513],[2948.7,412],[2967.4,599],[2985.8,433],[3004.5,414],[3028.3,410],[3042.8,414],[3060.6,313],[3079.4,313],[3098.1,263],[3133.6,263],[3170.8,263],[3189.4,263],[3208.3,263],[3225.7,263],[3245.0,263],[3262.9,263],[3282.4,263],[3297.2,263],[3315.3,263],[3334.1,263],[3353.0,263],[3371.1,263],[3387.3,263],[3405.7,263],[3422.4,263],[3439.8,263],[3458.2,263],[3476.7,263],[3495.2,263],[3512.6,263],[3531.0,263],[3548.0,263],[3566.2,263],[3584.6,263],[3602.8,263],[3621.3,263],[3639.8,263],[3658.1,263],[3694.1,263],[3730.1,263],[3747.1,263],[3763.8,263],[3891.9,182]],"promptSentAtMs":938.3}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,9 @@
{
"capturedAt": "2026-10-04T06:50:40.510833Z",
"platform": "darwin",
"command": ["opencode", "--standalone"],
"cols": 40,
"rows": 40,
"note": "opencode v2.0.21 (Homebrew opencode-v2) on macOS 26 arm64, 40x40, alternate screen. Recorded with the timed Python PTY driver (each read stamped with ms since spawn; terminal queries answered like Orca's pane). One natural cold start, no other capture running; load average at start 59.44 / 56.29 / 68.90 from other work on the machine. --standalone, env -i with an isolated HOME and XDG dirs (no credentials; OpenCode Zen's free models), OPENCODE_DISABLE_AUTOUPDATE=1, OPENCODE_CONFIG_CONTENT {\"autoupdate\":false}, no ORCA_* vars; version and binary sha256 unchanged before and after. Why this run: below 44 columns OpenCode 2 leaves the agent, and so the ' \u00b7 ' separator, out of the row under its input box, which shows the model alone. The box cursor showed at 11338 ms; the one-line task was bracketed-pasted 5136 ms later, at the agent-row signal's grace, and Enter sent 592 ms after that (promptSentAtMs). OpenCode submitted it: the reply was painted. The only separators are in the reply's footer, never on the row above the box's bottom corner. <name>.timing.json holds each PTY read as [ms since spawn, UTF-16 length] and recordedUntilMs.",
"exitCode": null
}
@@ -0,0 +1 @@
{"recordedUntilMs":43535.8,"chunks":[[8942.4,317],[9527.3,1024],[9533.4,1024],[9539.5,1024],[9548.5,377],[9554.7,1024],[9611.7,1024],[9667.3,1024],[9684.9,261],[9742.1,24],[9744.4,1024],[9745.4,1024],[9758.2,1024],[9774.9,273],[9903.5,13],[11327.2,904],[11337.0,966],[11337.9,298],[12105.2,319],[12132.3,319],[12133.9,332],[12205.5,332],[12339.2,332],[12345.6,332],[16906.0,711],[17823.9,526],[18296.1,13],[19345.3,1016],[19359.4,1018],[19380.4,471],[19537.0,451],[19582.4,13],[20156.6,720],[20180.8,259],[20226.8,259],[20440.4,518],[20645.0,259],[20809.6,455],[20883.7,350],[20942.3,453],[21007.1,854],[21033.0,502],[21047.9,320],[21107.7,484],[21113.8,435],[21128.5,466],[21205.7,567],[21279.8,1020],[21339.9,100],[21446.7,1020],[21448.4,492],[21467.8,646],[21525.3,537],[21590.6,609],[21607.0,638],[21656.9,861],[21658.1,354],[21676.7,385],[21750.4,387],[21790.4,635],[21792.8,599],[21809.9,335],[21930.4,517],[21947.1,512],[21954.5,314],[21992.7,365],[21998.1,431],[22198.4,1020],[22201.1,212],[22209.3,399],[22377.1,582],[22398.5,632],[22419.2,588],[22443.5,601],[22480.0,453],[22531.5,417],[22578.2,600],[22607.7,665],[22617.8,567],[22633.6,435],[22655.4,601],[22716.9,587],[22723.5,721],[22871.2,372],[22886.5,681],[22983.1,737],[23002.9,639],[23178.7,609],[23186.5,465],[23210.5,368],[23222.3,348],[23260.7,447],[23266.8,381],[23289.4,315],[23307.7,282],[23324.8,413],[23341.7,314],[23362.2,249],[23468.6,614],[23544.4,531],[23566.4,600],[23973.6,1012],[23974.4,255],[23978.2,656],[24019.9,435],[24022.8,786],[24119.2,550],[24145.2,1006],[24162.9,28],[24218.8,952],[24253.0,661],[24300.5,1010],[24321.7,798],[24618.9,750],[24696.0,13],[25258.6,321],[25314.6,263],[25358.8,526],[25423.5,263],[25483.4,263],[25524.9,263],[25615.5,263],[25626.6,263],[25699.6,263],[25806.1,195],[26082.6,31],[26083.4,158],[26467.6,86]],"promptSentAtMs":17066.1}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,9 @@
{
"capturedAt": "2026-10-03T22:30:39Z",
"platform": "darwin",
"command": ["opencode", "--standalone"],
"cols": 160,
"rows": 48,
"note": "opencode v2.0.21 (Homebrew opencode-v2) on macOS 26 arm64, 160x48, alternate screen. NOT the timed Python driver: a node-pty probe script (each read kept with its ms since spawn; it answers no terminal queries) used while investigating this bug. Recorded at natural load: other sessions on the machine had the load average at 4.39 / 30.38 / 44.19 (read 11 minutes later; this capture ran alone). The user's own login and plugins in place, OPENCODE_DISABLE_AUTOUPDATE=1, no ORCA_* vars. Orca's worker-start timing was replayed: an 82-line brief was bracketed-pasted at the box's show-cursor (3157 ms) and Enter sent 502 ms later (3659 ms, promptSentAtMs). The agent/model row only painted at 4141 ms; OpenCode dropped the Enter and the brief stayed in the composer as [Pasted ~82 lines]; OpenCode painted nothing more from 4164 ms until the probe stopped it at 40 s (its exit frames are cut). Scrubbed with same-length placeholders: checkout path and ticket id, and two earlier session titles in the tab strip. <name>.timing.json holds each PTY read as [ms since spawn, UTF-16 length] and recordedUntilMs.",
"exitCode": null
}
@@ -0,0 +1 @@
{"recordedUntilMs":40000,"promptSentAtMs":3659,"chunks":[[1766,280],[1768,37],[2810,1024],[2811,1024],[2811,1024],[2812,1024],[2812,1024],[2813,1024],[2814,1024],[2815,1024],[2815,1024],[2816,497],[2824,13],[3153,1024],[3153,1024],[3154,1024],[3155,952],[3156,928],[3157,886],[3157,422],[3247,200],[3358,297],[3657,1016],[3657,1018],[3658,1018],[3658,1016],[3659,558],[3781,1016],[3783,1016],[3784,1016],[3785,771],[3790,1012],[3791,1010],[3791,422],[3825,1012],[3826,1010],[3826,598],[3830,747],[4141,1021],[4164,326]]}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,9 @@
{
"capturedAt": "2026-10-04T05:14:16.961893Z",
"platform": "darwin",
"command": ["opencode", "--server", "http://127.0.0.1:49917"],
"cols": 120,
"rows": 40,
"note": "opencode v2.0.21 (Homebrew opencode-v2) on macOS 26 arm64, 120x40, alternate screen. Recorded with the timed Python PTY driver (each read stamped with ms since spawn; terminal queries answered like Orca's pane), env -i with an isolated HOME and XDG dirs (no credentials; OpenCode Zen's free models), OPENCODE_DISABLE_AUTOUPDATE=1, OPENCODE_CONFIG_CONTENT {\"autoupdate\":false}, no ORCA_* vars; version and binary sha256 unchanged before and after. <name>.timing.json holds each PTY read as [ms since spawn, UTF-16 length] and recordedUntilMs. Why this run: the plain opencode command attaching to a server that already has its agent list loaded: an unregistered 'opencode serve' started for the capture (throwaway password, stopped by PID after; the registered background service was never used) and attached with --server after one priming attach, standing in for the shared service the opencode row attaches to. The agent/model row trails the box by 28 ms. No input typed.",
"exitCode": null
}
@@ -0,0 +1 @@
{"recordedUntilMs":8188.3,"chunks":[[57.0,280],[58.0,101],[64.6,1024],[64.9,1024],[65.2,1024],[65.4,1024],[65.6,1024],[65.9,1024],[66.2,549],[69.3,13],[101.7,1024],[102.3,1024],[103.1,958],[103.5,986],[103.8,958],[104.0,1016],[104.3,874],[104.5,1024],[104.7,1024],[105.0,486],[117.4,173],[133.3,476],[149.9,476],[166.6,481],[188.1,490],[205.9,491],[223.9,497],[241.9,497],[259.9,497],[278.0,497]]}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,13 @@
{
"description": "opencode2 recording at 120x40 replayed on the agent pane, one entry per chunk",
"observations": {
"clocked": [
"0-14: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending",
"15-29: now=ready-weak edge=ready-weak quiet=ready-weak wait=ready@start"
],
"clockless": [
"0-14: verdict=pending:closed wait=pending",
"15-29: verdict=ready-weak wait=ready@start"
]
}
}
@@ -0,0 +1,13 @@
{
"description": "opencode2 recording at 120x40 replayed on the unknown pane, one entry per chunk",
"observations": {
"clocked": [
"0-14: now=pending:open edge=pending:open quiet=pending:open wait=ready@poll",
"15-29: now=ready-weak edge=ready-weak quiet=ready-weak wait=ready@start"
],
"clockless": [
"0-14: verdict=pending:open wait=ready@poll",
"15-29: verdict=ready-weak wait=ready@start"
]
}
}
@@ -0,0 +1,13 @@
{
"description": "opencode2 recording at 120x40 replayed on the agent pane, one entry per chunk",
"observations": {
"clocked": [
"0-21: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending",
"22-35: now=ready-weak edge=ready-weak quiet=ready-weak wait=ready@start"
],
"clockless": [
"0-21: verdict=pending:closed wait=pending",
"22-35: verdict=ready-weak wait=ready@start"
]
}
}
@@ -0,0 +1,13 @@
{
"description": "opencode2 recording at 120x40 replayed on the unknown pane, one entry per chunk",
"observations": {
"clocked": [
"0-21: now=pending:open edge=pending:open quiet=pending:open wait=ready@poll",
"22-35: now=ready-weak edge=ready-weak quiet=ready-weak wait=ready@start"
],
"clockless": [
"0-21: verdict=pending:open wait=ready@poll",
"22-35: verdict=ready-weak wait=ready@start"
]
}
}
@@ -0,0 +1,13 @@
{
"description": "opencode2 recording at 120x40 replayed on the agent pane, one entry per chunk",
"observations": {
"clocked": [
"0-17: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending",
"18-32: now=ready-weak edge=ready-weak quiet=ready-weak wait=ready@start"
],
"clockless": [
"0-17: verdict=pending:closed wait=pending",
"18-32: verdict=ready-weak wait=ready@start"
]
}
}
@@ -0,0 +1,13 @@
{
"description": "opencode2 recording at 120x40 replayed on the unknown pane, one entry per chunk",
"observations": {
"clocked": [
"0-17: now=pending:open edge=pending:open quiet=pending:open wait=ready@poll",
"18-32: now=ready-weak edge=ready-weak quiet=ready-weak wait=ready@start"
],
"clockless": [
"0-17: verdict=pending:open wait=ready@poll",
"18-32: verdict=ready-weak wait=ready@start"
]
}
}
@@ -0,0 +1,15 @@
{
"description": "opencode2 recording at 120x40 replayed on the agent pane, one entry per chunk",
"observations": {
"clocked": [
"0-8: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending",
"9-208: now=ready-weak edge=ready-weak quiet=ready-weak wait=ready@start",
"209-280: now=ready-strong edge=ready-strong quiet=ready-strong wait=ready@start"
],
"clockless": [
"0-8: verdict=pending:closed wait=pending",
"9-208: verdict=ready-weak wait=ready@start",
"209-280: verdict=ready-strong wait=ready@start"
]
}
}
@@ -0,0 +1,15 @@
{
"description": "opencode2 recording at 120x40 replayed on the unknown pane, one entry per chunk",
"observations": {
"clocked": [
"0-8: now=pending:open edge=pending:open quiet=pending:open wait=ready@poll",
"9-208: now=ready-weak edge=ready-weak quiet=ready-weak wait=ready@start",
"209-280: now=ready-strong edge=ready-strong quiet=ready-strong wait=ready@start"
],
"clockless": [
"0-8: verdict=pending:open wait=ready@poll",
"9-208: verdict=ready-weak wait=ready@start",
"209-280: verdict=ready-strong wait=ready@start"
]
}
}
@@ -0,0 +1,15 @@
{
"description": "opencode2 recording at 40x40 replayed on the agent pane, one entry per chunk",
"observations": {
"clocked": [
"0-13: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending",
"14-124: now=ready-weak edge=ready-weak quiet=ready-weak wait=ready@start",
"125-127: now=ready-strong edge=ready-strong quiet=ready-strong wait=ready@start"
],
"clockless": [
"0-13: verdict=pending:closed wait=pending",
"14-124: verdict=ready-weak wait=ready@start",
"125-127: verdict=ready-strong wait=ready@start"
]
}
}
@@ -0,0 +1,15 @@
{
"description": "opencode2 recording at 40x40 replayed on the unknown pane, one entry per chunk",
"observations": {
"clocked": [
"0-13: now=pending:open edge=pending:open quiet=pending:open wait=ready@poll",
"14-124: now=ready-weak edge=ready-weak quiet=ready-weak wait=ready@start",
"125-127: now=ready-strong edge=ready-strong quiet=ready-strong wait=ready@start"
],
"clockless": [
"0-13: verdict=pending:open wait=ready@poll",
"14-124: verdict=ready-weak wait=ready@start",
"125-127: verdict=ready-strong wait=ready@start"
]
}
}
@@ -0,0 +1,13 @@
{
"description": "opencode2 recording at 160x48 replayed on the agent pane, one entry per chunk",
"observations": {
"clocked": [
"0-11: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending",
"12-39: now=ready-weak edge=ready-weak quiet=ready-weak wait=ready@start"
],
"clockless": [
"0-11: verdict=pending:closed wait=pending",
"12-39: verdict=ready-weak wait=ready@start"
]
}
}
@@ -0,0 +1,13 @@
{
"description": "opencode2 recording at 160x48 replayed on the unknown pane, one entry per chunk",
"observations": {
"clocked": [
"0-11: now=pending:open edge=pending:open quiet=pending:open wait=ready@poll",
"12-39: now=ready-weak edge=ready-weak quiet=ready-weak wait=ready@start"
],
"clockless": [
"0-11: verdict=pending:open wait=ready@poll",
"12-39: verdict=ready-weak wait=ready@start"
]
}
}
@@ -0,0 +1,13 @@
{
"description": "opencode recording at 120x40 replayed on the agent pane, one entry per chunk",
"observations": {
"clocked": [
"0-8: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending",
"9-29: now=ready-weak edge=ready-weak quiet=ready-weak wait=ready@start"
],
"clockless": [
"0-8: verdict=pending:closed wait=pending",
"9-29: verdict=ready-weak wait=ready@start"
]
}
}
@@ -0,0 +1,13 @@
{
"description": "opencode recording at 120x40 replayed on the unknown pane, one entry per chunk",
"observations": {
"clocked": [
"0-8: now=pending:open edge=pending:open quiet=pending:open wait=ready@poll",
"9-29: now=ready-weak edge=ready-weak quiet=ready-weak wait=ready@start"
],
"clockless": [
"0-8: verdict=pending:open wait=ready@poll",
"9-29: verdict=ready-weak wait=ready@start"
]
}
}
@@ -4,6 +4,8 @@
* in its recorded reads (where timed) and in seeded random chunkings, and each must turn ready on
* the read holding the offset a plain string walk finds. A regression guard for grok, DSH and
* ZCode (their markers are one char, so the seam fix cannot move them), and the proof for OpenCode.
* OpenCode's agent-row signal reads screen structure no string walk expresses, so its reference is
* the scanner fed one char at a time; the transcript suites prove where that lands.
*/
import { readFileSync } from 'node:fs'
@@ -31,15 +33,22 @@ const WALKS: Partial<Record<DraftPasteReadySignal, Walk>> = {
}
// Recorded zsh shape: the prompt enables bracketed paste and accept-line disables it before exec.
// The launcher's cursor toggle after that is synthetic, standing in for any spinner.
// The launcher's cursor toggle after that is synthetic, standing in for any spinner, and so is the
// `·` in the prompt, standing in for a theme that draws OpenCode's agent-row separator.
const ZSH_LAUNCH_PROLOGUE =
'\x1b[?2004h% opencode\x1b[?2004l\r\n\x1b]2;opencode\x07\x1b[?25lresolving\x1b[?25h\r\n'
'\x1b[?2004h~ \u00b7 main % opencode\x1b[?2004l\r\n\x1b]2;opencode\x07\x1b[?25lresolving\x1b[?25h\r\n'
const OPENCODE_TIMED = [
'opencode-1-18-32-timed-boot-slow',
'opencode-1-18-32-timed-boot-hidden-pane',
'opencode-1-18-32-timed-first-launch',
'opencode-2-0-18-timed-boot-hidden-pane'
'opencode-2-0-18-timed-boot-hidden-pane',
'opencode-2-0-21-timed-cold-standalone',
'opencode-2-0-21-timed-cold-standalone-hidden-pane',
'opencode-2-0-21-timed-natural-load-enter-dropped',
'opencode-2-0-21-timed-enter-after-agent-row',
'opencode-2-0-14-timed-cold-standalone',
'opencode-cmd-2-0-21-timed-warm-server'
]
type Case = { name: string; signal: DraftPasteReadySignal; data: string; reads?: string[] }
@@ -74,18 +83,20 @@ const CASES: Case[] = [
signal: 'render-cursor-after-bracketed-paste',
data: readPtyTranscript('opencode.txt')
},
...OPENCODE_TIMED.flatMap((name): Case[] => {
const { chunks } = readTimedRuntimeFixture(name)
return [
{ name, signal: 'render-cursor-after-bracketed-paste', data: chunks.join(''), reads: chunks },
{
name: `${name} behind a zsh launch`,
signal: 'render-cursor-after-bracketed-paste',
data: ZSH_LAUNCH_PROLOGUE + chunks.join(''),
reads: [ZSH_LAUNCH_PROLOGUE, ...chunks]
}
]
})
...(['render-cursor-after-bracketed-paste', 'opencode-agent-row'] as const).flatMap((signal) =>
OPENCODE_TIMED.flatMap((name): Case[] => {
const { chunks } = readTimedRuntimeFixture(name)
return [
{ name: `${name} (${signal})`, signal, data: chunks.join(''), reads: chunks },
{
name: `${name} behind a zsh launch (${signal})`,
signal,
data: ZSH_LAUNCH_PROLOGUE + chunks.join(''),
reads: [ZSH_LAUNCH_PROLOGUE, ...chunks]
}
]
})
)
]
/** End offset of the first marker seen while the anchor is held, walking the whole string. */
@@ -162,10 +173,16 @@ function chunkings(testCase: Case): Map<string, string[]> {
describe('draft-paste readiness does not depend on how the stream is chunked', () => {
it.each(CASES.map((testCase) => [testCase.name, testCase] as const))('%s', (_, testCase) => {
const walk = WALKS[testCase.signal]
const expected = walk
? walkReadyEnd(testCase.data, walk)
: (scanReadyRead(
testCase.signal,
splitAt(testCase.data, () => 1)
)?.[1] ?? null)
if (!walk) {
throw new Error(`no reference walk for ${testCase.signal}`)
expect(testCase.signal).toBe('opencode-agent-row')
expect(expected, 'every OpenCode capture paints its agent row').not.toBeNull()
}
const expected = walkReadyEnd(testCase.data, walk)
for (const [label, reads] of chunkings(testCase)) {
const readyRead = scanReadyRead(testCase.signal, reads)
if (expected === null) {
@@ -187,5 +204,13 @@ describe('draft-paste readiness does not depend on how the stream is chunked', (
const alone = walkReadyEnd(data, walk)
expect(alone).not.toBeNull()
expect(walkReadyEnd(ZSH_LAUNCH_PROLOGUE + data, walk)).toBe(ZSH_LAUNCH_PROLOGUE.length + alone!)
const agentRowEnd = (stream: string): number | undefined =>
scanReadyRead(
'opencode-agent-row',
splitAt(stream, () => 1)
)?.[1]
expect(agentRowEnd(ZSH_LAUNCH_PROLOGUE + data)).toBe(
ZSH_LAUNCH_PROLOGUE.length + agentRowEnd(data)!
)
})
})
@@ -1,7 +1,10 @@
import { existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createDraftPasteReadyScanner } from '../../shared/draft-paste-ready-scanner'
import {
createDraftPasteReadyScanner,
resolvePasteReadySignal
} from '../../shared/draft-paste-ready-scanner'
import type { TuiAgent } from '../../shared/tui-agent'
import { TUI_AGENT_CONFIG } from '../../shared/tui-agent-config'
import type { RuntimeTerminalWait } from '../../shared/runtime-terminal-contracts'
@@ -77,7 +80,8 @@ const EXPECTED_LANES: Record<TuiAgent, LaunchedAgentReadinessLane> = {
const FIXTURES = join(__dirname, '__fixtures__')
const OPENCODE_PLACEHOLDER = 'Ask anything'
const SYNCHRONIZED_UPDATE_END = '\x1b[?2026l'
const AGENT_ROW = /\u00b7 \S/
const BOX_BOTTOM_LEFT = '\u2579'
const CITED_CAPTURES = Object.entries(TUI_AGENT_CONFIG).flatMap(([agent, row]) =>
(row.composerReadyCaptures ?? []).map((capture) => [agent, capture] as const)
@@ -91,11 +95,11 @@ function readSignal(agent: string) {
if (!isTuiAgent(agent)) {
throw new Error(`${agent} is not a TuiAgent`)
}
const signal = TUI_AGENT_CONFIG[agent].draftPasteReadySignal
if (!signal) {
if (!TUI_AGENT_CONFIG[agent].draftPasteReadySignal) {
throw new Error(`${agent} cites composer-ready captures but has no draftPasteReadySignal`)
}
return signal
// Worker start always presses Enter after its paste.
return resolvePasteReadySignal(TUI_AGENT_CONFIG[agent], true)
}
/** Index of the first read the scanner reports ready on, or -1. */
@@ -163,35 +167,39 @@ describe('every capture a row cites proves its input-box marker', () => {
expect(firstReadyRead(agent, readReads(capture))).toBeGreaterThanOrEqual(0)
})
it.each(
CITED_CAPTURES.filter(([agent]) => readSignal(agent) === 'render-cursor-after-bracketed-paste')
)(
'%s: the first ready read in %s is the one that shows the input box',
it.each(CITED_CAPTURES.filter(([agent]) => readSignal(agent) === 'opencode-agent-row'))(
'%s: the first ready read in %s is no earlier than the agent row under the box',
async (agent, capture) => {
const meta: { cols: number; rows: number } = JSON.parse(
readFileSync(join(FIXTURES, `${capture}.meta.json`), 'utf8')
)
const { chunks } = readTimedRuntimeFixture(capture)
const data = chunks.join('')
// OpenCode paints its box in one synchronized update, shown when that update ends.
const boxEnd = data.indexOf(SYNCHRONIZED_UPDATE_END, data.indexOf(OPENCODE_PLACEHOLDER))
let boxRead = -1
for (let index = 0, end = 0; index < chunks.length && boxRead === -1; index += 1) {
end += chunks[index].length
boxRead = end >= boxEnd + SYNCHRONIZED_UPDATE_END.length ? index : -1
}
let placeholderRead = -1
let rowRead = -1
let read = 0
for await (const frame of replayTranscript(chunks, meta.cols, meta.rows)) {
if (frame.screenLines.some((line) => line.includes(OPENCODE_PLACEHOLDER))) {
placeholderRead = read
// A brief pasted before the row replaces the placeholder in the box.
const box = frame.screenLines.findIndex(
(line) => line.includes(OPENCODE_PLACEHOLDER) || line.includes('[Pasted ~')
)
if (box !== -1 && boxRead === -1) {
boxRead = read
}
// The row inside the box's last line reads `<agent> · <model>`; a read can paint the
// separator and model before the agent.
const corner = frame.screenLines.findIndex((line) => line.includes(BOX_BOTTOM_LEFT))
if (box !== -1 && corner > box && AGENT_ROW.test(frame.screenLines[corner - 1])) {
rowRead = read
break
}
read += 1
}
expect(placeholderRead).toBeGreaterThanOrEqual(0)
expect(boxRead).toBeGreaterThanOrEqual(placeholderRead)
expect(firstReadyRead(agent, chunks)).toBe(boxRead)
expect(boxRead).toBeGreaterThanOrEqual(0)
expect(rowRead).toBeGreaterThanOrEqual(boxRead)
// OpenCode 1 paints the row just before the box's cursor in one frame; 2 paints it later.
const cursorRead = createDraftPasteReadyScanner('render-cursor-after-bracketed-paste')
const boxCursorRead = chunks.findIndex((chunk) => cursorRead.observe(chunk).ready)
expect(firstReadyRead(agent, chunks)).toBe(Math.max(rowRead, boxCursorRead))
}
)
})
@@ -0,0 +1,73 @@
/**
* Proof that the agent-row signal is the moment OpenCode 2 can submit, from two natural cold
* `--standalone` starts that replayed Orca's worker-start timing (paste at readiness, Enter 500 ms
* plus paste time later, at promptSentAtMs): one pasted on the agent row, one on the box's cursor
* while a loaded machine kept the agent list from arriving. A third, in a pane too narrow for the
* row, pasted at the signal's grace instead.
*/
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { createDraftPasteReadyScanner } from '../../shared/draft-paste-ready-scanner'
import type { DraftPasteReadySignal } from '../../shared/tui-agent-config'
import { TUI_AGENT_CONFIG } from '../../shared/tui-agent-config'
import { readTimedRuntimeFixture, replayTranscript } from './agent-transcript-replay-test-harness'
const HELD_PASTE = '[Pasted ~'
const AGENT_ROW_SIGNAL = TUI_AGENT_CONFIG.opencode2.submitPasteReadySignal!
// The box's show-cursor, the rule OpenCode used before the agent-row signal.
const BOX_CURSOR_SIGNAL: DraftPasteReadySignal = 'render-cursor-after-bracketed-paste'
function readyAtMs(name: string, signal: DraftPasteReadySignal): number {
const { chunks, times } = readTimedRuntimeFixture(name)
const scanner = createDraftPasteReadyScanner(signal)
return times[chunks.findIndex((chunk) => scanner.observe(chunk).ready)]
}
async function finalScreen(name: string): Promise<string> {
const meta: { cols: number; rows: number } = JSON.parse(
readFileSync(join(__dirname, '__fixtures__', `${name}.meta.json`), 'utf8')
)
let lastScreen: string[] = []
for await (const frame of replayTranscript(
readTimedRuntimeFixture(name).chunks,
meta.cols,
meta.rows
)) {
lastScreen = frame.screenLines
}
return lastScreen.join('\n')
}
describe('OpenCode 2 submits an Enter sent after the agent row and drops one sent before it', () => {
it('submits the brief when Orca pastes on the agent row', async () => {
const name = 'opencode-2-0-21-timed-enter-after-agent-row'
const { promptSentAtMs } = readTimedRuntimeFixture(name)
expect(readyAtMs(name, AGENT_ROW_SIGNAL)).toBeLessThan(promptSentAtMs!)
const screen = await finalScreen(name)
expect(screen).not.toContain(HELD_PASTE)
expect(screen).toContain('Task: reply with the single word')
})
it('drops the Enter of a brief pasted on the box cursor while the agent list is late', async () => {
const name = 'opencode-2-0-21-timed-natural-load-enter-dropped'
const { promptSentAtMs } = readTimedRuntimeFixture(name)
const boxCursorAt = readyAtMs(name, BOX_CURSOR_SIGNAL)
const agentRowAt = readyAtMs(name, AGENT_ROW_SIGNAL)
// The old rule fired over half a second before the row, so Orca's Enter landed in the gap.
expect(agentRowAt - boxCursorAt).toBeGreaterThanOrEqual(500)
expect(promptSentAtMs!).toBeGreaterThan(boxCursorAt)
expect(promptSentAtMs!).toBeLessThan(agentRowAt)
expect(await finalScreen(name)).toContain(HELD_PASTE)
})
it('submits the brief Orca pastes at the grace in a pane too narrow for the agent row', async () => {
const name = 'opencode-2-0-21-timed-narrow-pane'
const { promptSentAtMs } = readTimedRuntimeFixture(name)
expect(readyAtMs(name, BOX_CURSOR_SIGNAL)).toBeLessThan(promptSentAtMs!)
const screen = await finalScreen(name)
expect(screen).not.toContain(HELD_PASTE)
expect(screen).toContain('Task: reply with the single word')
})
})
@@ -0,0 +1,87 @@
/**
* The agent-row signal keys on where OpenCode paints its separator, not on the character: a `·` in
* the footer path under the box or in a session tab title is drawn before the row in these real
* starts, and must not move the read the signal fires on. Each is written into a recorded capture
* in place of characters of the same length, so every read keeps its recorded size.
*/
import { describe, expect, it } from 'vitest'
import { createDraftPasteReadyScanner } from '../../shared/draft-paste-ready-scanner'
import { readTimedRuntimeFixture } from './agent-transcript-replay-test-harness'
const OPENCODE_2_STARTS = [
'opencode-2-0-14-timed-cold-standalone',
'opencode-2-0-18-timed-boot-hidden-pane',
'opencode-2-0-21-timed-cold-standalone',
'opencode-2-0-21-timed-cold-standalone-hidden-pane',
'opencode-2-0-21-timed-natural-load-enter-dropped',
'opencode-2-0-21-timed-enter-after-agent-row',
'opencode-cmd-2-0-21-timed-warm-server'
]
// Only these starts paint a session tab strip before the row.
const TAB_STRIP_STARTS = [
'opencode-2-0-21-timed-natural-load-enter-dropped',
'opencode-2-0-21-timed-enter-after-agent-row'
]
const ALT_SCREEN_ENTER = '\x1b[?1049h'
const SGR = '(?:\\x1b\\[[0-9;:]*m)*'
const FOOTER_DIRECTORY = 'col·lecció'
function readyRead(chunks: readonly string[]): number {
const scanner = createDraftPasteReadyScanner('opencode-agent-row')
return chunks.findIndex((chunk) => scanner.observe(chunk).ready)
}
/** Overwrites `data` at `offset` with `text`, then cuts it back into the recorded read sizes. */
function overwrite(chunks: readonly string[], offset: number, text: string): string[] {
const data = chunks.join('')
const edited = data.slice(0, offset) + text + data.slice(offset + text.length)
let start = 0
return chunks.map((chunk) => edited.slice(start, (start += chunk.length)))
}
function readAt(chunks: readonly string[], offset: number): number {
let end = 0
return chunks.findIndex((chunk) => (end += chunk.length) > offset)
}
/** Offset of the footer path's first directory, on the line under the box's bottom corner. */
function footerDirectoryOffset(data: string): number {
const alt = data.indexOf(ALT_SCREEN_ENTER)
const corner = new RegExp(`\\x1b\\[(\\d+);\\d+H${SGR}\\u2579`, 'g')
corner.lastIndex = alt
const cornerRow = Number(corner.exec(data)![1])
const path = new RegExp(`\\x1b\\[${cornerRow + 1};\\d+H${SGR}(?:~|/[^/\\x1b]+)/`, 'g')
path.lastIndex = alt
const match = path.exec(data)!
return match.index + match[0].length
}
/** Offset of the first character a session tab title paints on the top line. */
function tabTitleCharOffset(data: string): number {
const title = new RegExp(`\\x1b\\[1;\\d+H${SGR}(?=[A-Za-z])`, 'g')
title.lastIndex = data.indexOf(ALT_SCREEN_ENTER)
const match = title.exec(data)!
return match.index + match[0].length
}
describe('a separator OpenCode draws off the agent row does not make it ready', () => {
it.each(OPENCODE_2_STARTS)('%s: a footer path with a `·` in it', (name) => {
const { chunks } = readTimedRuntimeFixture(name)
const data = chunks.join('')
const at = footerDirectoryOffset(data)
// Only painted path text is replaced.
expect(data.slice(at, at + FOOTER_DIRECTORY.length)).toMatch(/^[\w./-]+$/)
const edited = overwrite(chunks, at, FOOTER_DIRECTORY)
expect(readAt(edited, at)).toBeLessThan(readyRead(chunks))
expect(readyRead(edited)).toBe(readyRead(chunks))
})
it.each(TAB_STRIP_STARTS)('%s: a session tab title with a `·` in it', (name) => {
const { chunks } = readTimedRuntimeFixture(name)
const at = tabTitleCharOffset(chunks.join(''))
const edited = overwrite(chunks, at, '·')
expect(readAt(edited, at)).toBeLessThan(readyRead(chunks))
expect(readyRead(edited)).toBe(readyRead(chunks))
})
})
@@ -4,10 +4,14 @@
* so a wait that trusts a bare-name title has its answer before OpenCode draws anything.
*/
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createDraftPasteReadyScanner } from '../../shared/draft-paste-ready-scanner'
import { OPENCODE_AGENT_ROW_GRACE_MS } from '../../shared/opencode-agent-row-scanner'
import type { TuiAgent } from '../../shared/tui-agent'
import { createTranscriptPane, TRANSCRIPT_PANE_PTY_ID } from './agent-transcript-pane-test-harness'
import { readTimedRuntimeFixture } from './agent-transcript-replay-test-harness'
import { readTimedRuntimeFixture, replayTranscript } from './agent-transcript-replay-test-harness'
import { waitForLaunchedAgentComposer } from './launched-agent-composer-readiness'
vi.mock('electron', () => ({
@@ -28,8 +32,16 @@ const RUNS: [string, TuiAgent][] = [
['opencode-1-18-32-timed-boot-slow', 'opencode'],
['opencode-1-18-32-timed-boot-hidden-pane', 'opencode'],
['opencode-1-18-32-timed-first-launch', 'opencode'],
['opencode-2-0-18-timed-boot-hidden-pane', 'opencode2']
['opencode-cmd-2-0-21-timed-warm-server', 'opencode'],
['opencode-2-0-18-timed-boot-hidden-pane', 'opencode2'],
['opencode-2-0-21-timed-cold-standalone', 'opencode2'],
['opencode-2-0-21-timed-cold-standalone-hidden-pane', 'opencode2'],
['opencode-2-0-21-timed-natural-load-enter-dropped', 'opencode2'],
['opencode-2-0-14-timed-cold-standalone', 'opencode2']
]
const OPENCODE_1_RUNS = RUNS.filter(([name]) => name.startsWith('opencode-1-'))
const AGENT_ROW = /\u00b7 \S/
const BOX_BOTTOM_LEFT = '\u2579'
/** The read that ends the synchronized update drawing OpenCode's input box. */
function boxRead(chunks: string[]): number {
@@ -41,6 +53,22 @@ function boxRead(chunks: string[]): number {
return chunks.findIndex((chunk) => (end += chunk.length) >= boxEnd)
}
/** The first read whose screen shows `· <model>` on the box's last line, above its corner. */
async function agentRowRead(name: string, chunks: string[]): Promise<number> {
const meta: { cols: number; rows: number } = JSON.parse(
readFileSync(join(__dirname, '__fixtures__', `${name}.meta.json`), 'utf8')
)
let read = 0
for await (const { screenLines } of replayTranscript(chunks, meta.cols, meta.rows)) {
const corner = screenLines.findIndex((line) => line.includes(BOX_BOTTOM_LEFT))
if (corner > 0 && AGENT_ROW.test(screenLines[corner - 1])) {
return read
}
read += 1
}
return -1
}
async function replay(name: string, agent: TuiAgent) {
const { chunks, times } = readTimedRuntimeFixture(name)
const { runtime, handle } = await createTranscriptPane({
@@ -52,13 +80,15 @@ async function replay(name: string, agent: TuiAgent) {
})
vi.useFakeTimers()
runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, ZSH_LAUNCH, Date.now())
const settledAt: { composer: number | null; tuiIdle: number | null } = {
composer: null,
tuiIdle: null
}
const settledAt: { composer: number | null; composerMs: number | null; tuiIdle: number | null } =
{ composer: null, composerMs: null, tuiIdle: null }
let read = -1
const startedAt = Date.now()
const composer = waitForLaunchedAgentComposer(runtime, handle, agent, 60_000)
void composer.then(() => (settledAt.composer ??= read))
void composer.then(() => {
settledAt.composer ??= read
settledAt.composerMs ??= Date.now() - startedAt
})
const tuiIdle = runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 60_000 })
void tuiIdle.then(
() => (settledAt.tuiIdle ??= read),
@@ -73,23 +103,61 @@ async function replay(name: string, agent: TuiAgent) {
runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, chunk, Date.now())
await vi.advanceTimersByTimeAsync(0)
}
return { settledAt, composer, boxRead: boxRead(chunks) }
return {
settledAt,
composer,
boxRead: boxRead(chunks),
agentRowRead: await agentRowRead(name, chunks)
}
}
describe('an OpenCode worker gets its task only once its input box exists', () => {
describe('an OpenCode worker gets its task only once OpenCode can submit it', () => {
afterEach(() => {
vi.useRealTimers()
})
it.each(RUNS)(
'%s: the worker-start lane settles on the read that shows the box',
'%s: the worker-start lane settles once the box and the agent row are both painted',
async (name, agent) => {
const { settledAt, composer, boxRead } = await replay(name, agent)
const { settledAt, composer, boxRead, agentRowRead } = await replay(name, agent)
await expect(composer).resolves.toMatchObject({ satisfied: true })
// OpenCode 1 paints the row inside the box's frame, so the box read is the later one there.
expect(settledAt.composer).toBe(Math.max(boxRead, agentRowRead))
}
)
it.each(OPENCODE_1_RUNS)(
'%s: OpenCode 1 paints the agent row with the box, so it waits no longer than before',
async (name, agent) => {
const { settledAt, boxRead } = await replay(name, agent)
expect(settledAt.composer).toBe(boxRead)
}
)
it('never settles on the box while a slow agent list leaves the row unpainted', async () => {
const { settledAt, boxRead } = await replay(
'opencode-2-0-21-timed-natural-load-enter-dropped',
'opencode2'
)
expect(settledAt.composer).toBeGreaterThan(boxRead)
})
it('takes the box after the grace in a pane too narrow for the agent row', async () => {
const name = 'opencode-2-0-21-timed-narrow-pane'
const { chunks, times, promptSentAtMs } = readTimedRuntimeFixture(name)
const cursor = createDraftPasteReadyScanner('render-cursor-after-bracketed-paste')
const boxCursorAt = times[chunks.findIndex((chunk) => cursor.observe(chunk).ready)]
const { settledAt, composer, agentRowRead } = await replay(name, 'opencode2')
expect(agentRowRead).toBe(-1)
await expect(composer).resolves.toMatchObject({ satisfied: true })
// Fake timers drop the recorded times' fraction of a millisecond.
expect(
Math.abs(settledAt.composerMs! - (boxCursorAt + OPENCODE_AGENT_ROW_GRACE_MS))
).toBeLessThan(1)
// The recorder pasted at the grace and pressed Enter after it, and OpenCode took it.
expect(promptSentAtMs!).toBeGreaterThan(settledAt.composerMs!)
})
it.each(RUNS)(
'%s: the bare-name tui-idle wait main used would have settled before the box',
async (name, agent) => {
@@ -213,6 +213,8 @@ export class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntimeWithListM
{
timeoutMs,
requireComposerMarker,
// Every caller pastes and then presses Enter: a worker brief or a launch prompt.
submit: true,
signal: stop.signal,
isShellInFront: async (ownerPtyId) =>
(await this.readLaunchedAgentForeground(ownerPtyId, agent)) === 'shell',
@@ -45,6 +45,8 @@ const RUNTIME_RECORDERS: readonly (readonly [string, Recorder])[] = [
['hermes-', { agent: 'hermes', foregroundProcess: 'hermes' }],
['muse-', { agent: 'muse', foregroundProcess: 'muse' }],
['omp-', { agent: 'omp', foregroundProcess: 'omp' }],
// The plain `opencode` command running an OpenCode 2 binary, as the opencode row launches it.
['opencode-cmd-', { agent: 'opencode', foregroundProcess: 'opencode' }],
['opencode-2-', { agent: 'opencode2', foregroundProcess: 'opencode' }],
['opencode-1-', { agent: 'opencode', foregroundProcess: 'opencode' }],
['prime-agent-', { agent: 'prime-agent', foregroundProcess: 'prime-agent' }],
@@ -1,6 +1,7 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { OPENCODE_AGENT_ROW_GRACE_MS } from '../../shared/opencode-agent-row-scanner'
import {
waitForWorktreeStartupDraft,
type WorktreeStartupReadinessHost
@@ -69,3 +70,302 @@ describe('fresh worker composer readiness', () => {
expect(h.unsubscribe).toHaveBeenCalledOnce()
})
})
describe('OpenCode submit readiness', () => {
afterEach(() => vi.useRealTimers())
// OpenCode 2.0.21's box with its cursor and bottom corner, then the agent row directly above it.
const BOX = '\x1b[?1049h\x1b[?2004h\x1b[24;24H┃\x1b[25;24H╹\x1b[22;27H\x1b[?25h'
const AGENT_ROW = '\x1b[24;27HBuild\x1b[24;33H\u00b7\x1b[24;35HSome Model'
function fixture() {
let listener = (_data: string): void => {}
const host: WorktreeStartupReadinessHost = {
getPtyId: () => 'pty-1',
getForegroundProcess: async () => 'opencode',
subscribeToData: (_ptyId, onData) => {
listener = onData
return () => {}
},
readRecentOutput: () => undefined,
write: vi.fn()
}
return { host, emit: (data: string) => listener(data) }
}
function settle(pending: Promise<string | null>): { value: string | null | undefined } {
const result: { value: string | null | undefined } = { value: undefined }
void pending.then((value) => (result.value = value))
return result
}
it('a draft that is not submitted is pasted on the box cursor, as before', async () => {
vi.useFakeTimers()
const h = fixture()
const result = settle(waitForWorktreeStartupDraft(h.host, 'term-1', 'opencode2'))
h.emit(BOX)
await vi.advanceTimersByTimeAsync(0)
expect(result.value).toBe('pty-1')
})
it('a submitted task waits for the agent row', async () => {
vi.useFakeTimers()
const h = fixture()
const result = settle(
waitForWorktreeStartupDraft(h.host, 'term-1', 'opencode2', {
timeoutMs: 60_000,
requireComposerMarker: true,
submit: true
})
)
h.emit(BOX)
await vi.advanceTimersByTimeAsync(1000)
expect(result.value).toBeUndefined()
h.emit(AGENT_ROW)
await vi.advanceTimersByTimeAsync(0)
expect(result.value).toBe('pty-1')
expect(vi.getTimerCount()).toBe(0)
})
it('takes the box after the grace when the row never comes, whatever repaints', async () => {
vi.useFakeTimers()
const h = fixture()
const result = settle(
waitForWorktreeStartupDraft(h.host, 'term-1', 'opencode2', {
timeoutMs: 60_000,
requireComposerMarker: true,
submit: true
})
)
h.emit(BOX)
await vi.advanceTimersByTimeAsync(OPENCODE_AGENT_ROW_GRACE_MS - 1)
h.emit('\x1b[22;27H\x1b[?25h')
expect(result.value).toBeUndefined()
await vi.advanceTimersByTimeAsync(1)
expect(result.value).toBe('pty-1')
expect(vi.getTimerCount()).toBe(0)
})
it('takes the box at the deadline when the grace would outlast the budget', async () => {
vi.useFakeTimers()
const h = fixture()
const result = settle(
waitForWorktreeStartupDraft(h.host, 'term-1', 'opencode2', {
timeoutMs: 60_000,
requireComposerMarker: true,
submit: true
})
)
await vi.advanceTimersByTimeAsync(59_000)
h.emit(BOX)
await vi.advanceTimersByTimeAsync(999)
expect(result.value).toBeUndefined()
await vi.advanceTimersByTimeAsync(1)
expect(result.value).toBe('pty-1')
expect(vi.getTimerCount()).toBe(0)
})
it('withdraws the grace when OpenCode turns bracketed paste off', async () => {
vi.useFakeTimers()
const h = fixture()
const result = settle(
waitForWorktreeStartupDraft(h.host, 'term-1', 'opencode2', {
timeoutMs: 60_000,
requireComposerMarker: true,
submit: true
})
)
h.emit(BOX)
h.emit('\x1b[?2004l\x1b[?1049l')
await vi.advanceTimersByTimeAsync(59_999)
expect(result.value).toBeUndefined()
await vi.advanceTimersByTimeAsync(1)
expect(result.value).toBeNull()
})
describe('through the settle checks a ready signal gets', () => {
function waitWith(
h: ReturnType<typeof fixture>,
checks: {
accept?: () => boolean
isShellInFront?: () => Promise<boolean>
timeoutMs?: number
signal?: AbortSignal
}
) {
return settle(
waitForWorktreeStartupDraft(h.host, 'term-1', 'opencode2', {
timeoutMs: checks.timeoutMs ?? 60_000,
requireComposerMarker: true,
submit: true,
...(checks.accept ? { accept: checks.accept } : {}),
...(checks.isShellInFront ? { isShellInFront: checks.isShellInFront } : {}),
...(checks.signal ? { signal: checks.signal } : {})
})
)
}
it('does not settle a grace while a shell is proven in front', async () => {
vi.useFakeTimers()
const h = fixture()
let shellInFront = true
const result = waitWith(h, { isShellInFront: async () => shellInFront })
h.emit(BOX)
await vi.advanceTimersByTimeAsync(OPENCODE_AGENT_ROW_GRACE_MS)
expect(result.value).toBeUndefined()
// The next read that still shows the box asks again, and now OpenCode is in front.
shellInFront = false
h.emit('\x1b[22;27H\x1b[?25h')
await vi.advanceTimersByTimeAsync(0)
expect(result.value).toBe('pty-1')
})
it('lets a dialog on screen veto a grace', async () => {
vi.useFakeTimers()
const h = fixture()
const accept = vi.fn(() => false)
const result = waitWith(h, { accept })
h.emit(BOX)
await vi.advanceTimersByTimeAsync(OPENCODE_AGENT_ROW_GRACE_MS)
expect(accept).toHaveBeenCalledWith('pty-1')
expect(result.value).toBeUndefined()
})
it('refuses the deadline inside a pending grace while a shell is in front', async () => {
vi.useFakeTimers()
const h = fixture()
const result = waitWith(h, { isShellInFront: async () => true, timeoutMs: 3000 })
h.emit(BOX)
await vi.advanceTimersByTimeAsync(3000)
expect(result.value).toBeNull()
expect(vi.getTimerCount()).toBe(0)
})
it('takes the box at the deadline inside a pending grace with the agent in front', async () => {
vi.useFakeTimers()
const h = fixture()
const result = waitWith(h, { isShellInFront: async () => false, timeoutMs: 3000 })
h.emit(BOX)
await vi.advanceTimersByTimeAsync(3000)
expect(result.value).toBe('pty-1')
expect(vi.getTimerCount()).toBe(0)
})
it('withdraws a pending grace at a shell hand-off', async () => {
vi.useFakeTimers()
const h = fixture()
const result = waitWith(h, { isShellInFront: async () => false, timeoutMs: 8000 })
h.emit(BOX)
// The agent exits to its shell, which turns bracketed paste off as it runs a command.
h.emit('\x1b[?2004l')
await vi.advanceTimersByTimeAsync(OPENCODE_AGENT_ROW_GRACE_MS)
expect(result.value).toBeUndefined()
await vi.advanceTimersByTimeAsync(8000 - OPENCODE_AGENT_ROW_GRACE_MS)
expect(result.value).toBeNull()
})
// A foreground read the test answers by hand, as an SSH host would.
function pendingRead() {
let answer = (_inFront: boolean): void => {}
let fail = (_error: Error): void => {}
const read = vi.fn(
() =>
new Promise<boolean>((resolve, reject) => {
answer = resolve
fail = reject
})
)
return { read, answer: (inFront: boolean) => answer(inFront), fail: (e: Error) => fail(e) }
}
it('refuses the deadline when a shell hand-off lands during its check', async () => {
vi.useFakeTimers()
const h = fixture()
const check = pendingRead()
const result = waitWith(h, { isShellInFront: check.read, timeoutMs: 3000 })
h.emit(BOX)
await vi.advanceTimersByTimeAsync(3000)
h.emit('\x1b[?2004l')
check.answer(false)
await vi.advanceTimersByTimeAsync(0)
expect(result.value).toBeNull()
})
it('refuses the deadline when its check fails', async () => {
vi.useFakeTimers()
const h = fixture()
const check = pendingRead()
const result = waitWith(h, { isShellInFront: check.read, timeoutMs: 3000 })
h.emit(BOX)
await vi.advanceTimersByTimeAsync(3000)
check.fail(new Error('ssh channel closed'))
await vi.advanceTimersByTimeAsync(0)
expect(result.value).toBeNull()
})
it('resolves once, with null, when aborted during the deadline check', async () => {
vi.useFakeTimers()
const h = fixture()
const check = pendingRead()
const abort = new AbortController()
const pending = waitForWorktreeStartupDraft(h.host, 'term-1', 'opencode2', {
timeoutMs: 3000,
requireComposerMarker: true,
submit: true,
isShellInFront: check.read,
signal: abort.signal
})
const resolved = vi.fn()
void pending.then(resolved)
h.emit(BOX)
await vi.advanceTimersByTimeAsync(3000)
abort.abort()
check.answer(false)
await vi.advanceTimersByTimeAsync(0)
expect(resolved.mock.calls).toEqual([[null]])
expect(vi.getTimerCount()).toBe(0)
})
it('takes the box at the deadline after a refused grace once the agent is in front', async () => {
vi.useFakeTimers()
const h = fixture()
let shellInFront = true
const result = waitWith(h, { isShellInFront: async () => shellInFront, timeoutMs: 8000 })
h.emit(BOX)
await vi.advanceTimersByTimeAsync(OPENCODE_AGENT_ROW_GRACE_MS)
expect(result.value).toBeUndefined()
shellInFront = false
await vi.advanceTimersByTimeAsync(8000 - OPENCODE_AGENT_ROW_GRACE_MS)
expect(result.value).toBe('pty-1')
})
it('gives up when the deadline check never answers', async () => {
vi.useFakeTimers()
const h = fixture()
const check = pendingRead()
const result = waitWith(h, { isShellInFront: check.read, timeoutMs: 3000 })
h.emit(BOX)
await vi.advanceTimersByTimeAsync(3000)
expect(check.read).toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1999)
expect(result.value).toBeUndefined()
await vi.advanceTimersByTimeAsync(1)
expect(result.value).toBeNull()
expect(vi.getTimerCount()).toBe(0)
})
it('restarts the grace at a hand-off rather than keeping the one from before it', async () => {
vi.useFakeTimers()
const h = fixture()
const result = waitWith(h, { isShellInFront: async () => false })
h.emit(BOX)
await vi.advanceTimersByTimeAsync(3000)
// A hand-off whose chunk goes on to show a cursor under bracketed paste again.
h.emit('\x1b[?2004l\x1b[?2004h\x1b[?25h')
await vi.advanceTimersByTimeAsync(OPENCODE_AGENT_ROW_GRACE_MS - 1)
expect(result.value).toBeUndefined()
await vi.advanceTimersByTimeAsync(1)
expect(result.value).toBe('pty-1')
})
})
})
@@ -1,6 +1,9 @@
import { isShellProcess } from '../../shared/agent-detection'
import { isExpectedAgentProcess } from '../../shared/agent-process-recognition'
import { createDraftPasteReadyScanner } from '../../shared/draft-paste-ready-scanner'
import {
createDraftPasteReadyScanner,
resolvePasteReadySignal
} from '../../shared/draft-paste-ready-scanner'
import { resolveDraftPasteReadyTimeoutMs } from '../../shared/draft-paste-ready-timeout'
import { TUI_AGENT_CONFIG } from '../../shared/tui-agent-config'
import type { TuiAgent } from '../../shared/tui-agent'
@@ -16,6 +19,9 @@ const BRACKETED_PASTE_QUIET_MS = 1500
// Why: an interactive shell turns bracketed paste on at its prompt and off when it runs the typed
// command (`zsh-prompt-runs-command.txt`), so a 2004 before the last `?2004l` is the shell's.
const DECRST_BRACKETED_PASTE = '\x1b[?2004l'
// Why: the deadline's last settle check is one foreground read, which over SSH could otherwise hold
// a past-due wait for the channel's 30 s timeout.
const DEADLINE_SETTLE_CHECK_MS = 2_000
export type WorktreeStartupReadinessHost = {
getPtyId: (handle: string) => string | null
@@ -103,6 +109,8 @@ export type StartupDraftReadinessOptions = {
* proven in front: the launch line has not run yet, or the agent exited.
*/
isShellInFront?: (ptyId: string) => Promise<boolean>
/** Enter follows the paste, so the agent's submit signal is waited for instead of its draft one. */
submit?: boolean
}
export function waitForWorktreeStartupDraft(
@@ -115,14 +123,18 @@ export function waitForWorktreeStartupDraft(
if (!ptyId || options.signal?.aborted) {
return Promise.resolve(null)
}
const signal =
TUI_AGENT_CONFIG[agent].draftPasteReadySignal ?? 'render-quiet-after-bracketed-paste'
const signal = resolvePasteReadySignal(TUI_AGENT_CONFIG[agent], options.submit === true)
const isShellInFront = options.isShellInFront
return new Promise((resolve) => {
let settled = false
let scanner = createDraftPasteReadyScanner(signal)
let quietTimer: NodeJS.Timeout | null = null
// A submit signal's fallback: once its grace elapses, every read that still asks for it is a
// ready signal, settled like any other.
let graceTimer: NodeJS.Timeout | null = null
let graceElapsed = false
let hardTimer: NodeJS.Timeout | null = null
let deadlineCheckTimer: NodeJS.Timeout | null = null
let unsubscribe: (() => void) | null = null
// Bumped at each shell hand-off: a check begun before one must not settle the wait after it.
let handoffs = 0
@@ -139,21 +151,33 @@ export function waitForWorktreeStartupDraft(
if (quietTimer) {
clearTimeout(quietTimer)
}
clearGrace()
if (hardTimer) {
clearTimeout(hardTimer)
}
if (deadlineCheckTimer) {
clearTimeout(deadlineCheckTimer)
}
unsubscribe?.()
options.signal?.removeEventListener('abort', onAbort)
resolve(value)
}
const clearGrace = (): void => {
if (graceTimer) {
clearTimeout(graceTimer)
graceTimer = null
}
graceElapsed = false
}
/** The screen holds nothing the input must not answer and no shell is proven in front. */
const passesSettleChecks = async (): Promise<boolean> =>
(!options.accept || (await options.accept(ptyId))) &&
!(isShellInFront && (await isShellInFront(ptyId)))
/** Settles a fired signal once its screen is clear and no shell is proven in front. */
const settleSignal = async (signalHandoffs: number): Promise<void> => {
checking = true
try {
if (options.accept && !(await options.accept(ptyId))) {
return
}
if (isShellInFront && (await isShellInFront(ptyId))) {
if (!(await passesSettleChecks())) {
return
}
if (signalHandoffs === handoffs) {
@@ -192,6 +216,7 @@ export function waitForWorktreeStartupDraft(
clearTimeout(quietTimer)
quietTimer = null
}
clearGrace()
return window.slice(handoff + DECRST_BRACKETED_PASTE.length)
}
const observe = (chunk: string): void => {
@@ -203,6 +228,19 @@ export function waitForWorktreeStartupDraft(
if (result.ready) {
return onSignal()
}
if (result.readyAfterMs === null) {
clearGrace()
} else if (typeof result.readyAfterMs === 'number') {
if (graceElapsed) {
onSignal()
} else if (!graceTimer) {
graceTimer = setTimeout(() => {
graceTimer = null
graceElapsed = true
onSignal()
}, result.readyAfterMs)
}
}
if (result.armQuietTimer && !options.requireComposerMarker) {
if (quietTimer) {
clearTimeout(quietTimer)
@@ -212,10 +250,19 @@ export function waitForWorktreeStartupDraft(
}
options.signal?.addEventListener('abort', onAbort)
unsubscribe = host.subscribeToData(ptyId, observe)
hardTimer = setTimeout(
() => finish(null),
options.timeoutMs ?? resolveDraftPasteReadyTimeoutMs(agent)
)
// A deadline inside a pending grace is the box rule's verdict, so it gets one last settle check.
const onDeadline = (): void => {
if (!graceTimer && !graceElapsed) {
return finish(null)
}
const deadlineHandoffs = handoffs
deadlineCheckTimer = setTimeout(() => finish(null), DEADLINE_SETTLE_CHECK_MS)
void passesSettleChecks().then(
(passes) => finish(passes && deadlineHandoffs === handoffs ? ptyId : null),
() => finish(null)
)
}
hardTimer = setTimeout(onDeadline, options.timeoutMs ?? resolveDraftPasteReadyTimeoutMs(agent))
const replay = host.readRecentOutput(ptyId)
if (replay) {
observe(replay)
@@ -4,6 +4,7 @@ import {
clearPreHandlerPtyState,
drainPreHandlerPtyData
} from '@/components/terminal-pane/pty-pre-handler-buffer'
import { OPENCODE_AGENT_ROW_GRACE_MS } from '../../../shared/opencode-agent-row-scanner'
import { waitForAgentDraftInputReady } from './agent-draft-readiness'
const testState = vi.hoisted(() => ({
@@ -25,12 +26,15 @@ vi.mock('@/runtime/runtime-terminal-inspection', () => ({
const PTY_ID = 'pty-buffered-codex'
const CODEX_COMPOSER = '\x1b[?1049h\x1b[1m›\x1b[0m Implement {feature}'
const DECSET_BRACKETED_PASTE = '\x1b[?2004h'
// OpenCode's box with its cursor and bottom corner, before the agent row under it (2.0.21 shape).
const OPENCODE_BOX = '\x1b[?1049h\x1b[?2004h\x1b[24;24H┃\x1b[25;24H╹\x1b[22;27H\x1b[?25h'
describe('waitForAgentDraftInputReady', () => {
afterEach(() => {
clearPreHandlerPtyState(PTY_ID)
testState.observer = null
testState.unsubscribe.mockReset()
vi.unstubAllGlobals()
vi.useRealTimers()
})
@@ -52,4 +56,52 @@ describe('waitForAgentDraftInputReady', () => {
])
expect(vi.getTimerCount()).toBe(0)
})
it('falls back to the box after the grace when OpenCode never paints its agent row', async () => {
vi.useFakeTimers()
vi.stubGlobal('window', globalThis)
let settled: boolean | null = null
void waitForAgentDraftInputReady(PTY_ID, 20_000, 'opencode-agent-row', {}).then(
(ready) => (settled = ready)
)
testState.observer!(OPENCODE_BOX)
await vi.advanceTimersByTimeAsync(OPENCODE_AGENT_ROW_GRACE_MS - 1)
// More box frames do not restart the grace.
testState.observer!('\x1b[22;27H\x1b[?25h')
expect(settled).toBeNull()
await vi.advanceTimersByTimeAsync(1)
expect(settled).toBe(true)
expect(vi.getTimerCount()).toBe(0)
})
it('takes the box at the deadline when the grace would outlast the budget', async () => {
vi.useFakeTimers()
vi.stubGlobal('window', globalThis)
let settled: boolean | null = null
void waitForAgentDraftInputReady(PTY_ID, 20_000, 'opencode-agent-row', {}).then(
(ready) => (settled = ready)
)
await vi.advanceTimersByTimeAsync(19_000)
testState.observer!(OPENCODE_BOX)
await vi.advanceTimersByTimeAsync(999)
expect(settled).toBeNull()
await vi.advanceTimersByTimeAsync(1)
expect(settled).toBe(true)
expect(vi.getTimerCount()).toBe(0)
})
it('withdraws the grace when OpenCode turns bracketed paste off', async () => {
vi.useFakeTimers()
vi.stubGlobal('window', globalThis)
let settled: boolean | null = null
void waitForAgentDraftInputReady(PTY_ID, 20_000, 'opencode-agent-row', {}).then(
(ready) => (settled = ready)
)
testState.observer!(OPENCODE_BOX)
testState.observer!('\x1b[?2004l')
await vi.advanceTimersByTimeAsync(19_999)
expect(settled).toBeNull()
await vi.advanceTimersByTimeAsync(1)
expect(settled).toBe(false)
})
})
+13 -2
View File
@@ -28,6 +28,7 @@ export function waitForAgentDraftInputReady(
let settled = false
const scanner = createDraftPasteReadyScanner(readySignal)
let quietTimer: number | null = null
let graceTimer: number | null = null
let hardTimer: number | null = null
let unsubscribe: (() => void) | null = null
@@ -42,6 +43,9 @@ export function waitForAgentDraftInputReady(
if (quietTimer !== null) {
window.clearTimeout(quietTimer)
}
if (graceTimer !== null) {
window.clearTimeout(graceTimer)
}
unsubscribe?.()
resolve(value)
}
@@ -54,11 +58,17 @@ export function waitForAgentDraftInputReady(
}
const observeData = (data: string): void => {
const { ready, armQuietTimer: shouldArm } = scanner.observe(data)
const { ready, armQuietTimer: shouldArm, readyAfterMs } = scanner.observe(data)
if (ready) {
finish(true)
return
}
if (readyAfterMs === null && graceTimer !== null) {
window.clearTimeout(graceTimer)
graceTimer = null
} else if (typeof readyAfterMs === 'number' && graceTimer === null) {
graceTimer = window.setTimeout(() => finish(true), readyAfterMs)
}
if (shouldArm) {
armQuietTimer()
}
@@ -87,7 +97,8 @@ export function waitForAgentDraftInputReady(
}
if (!settled) {
hardTimer = window.setTimeout(() => finish(false), timeoutMs)
// A pending grace means the box was seen: take it, as the box rule would have.
hardTimer = window.setTimeout(() => finish(graceTimer !== null), timeoutMs)
}
})
}
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { pasteDraftToAgentPtyWhenReady } from './agent-paste-draft'
import { pasteDraftToAgentPtyWhenReady, pasteDraftWhenAgentReady } from './agent-paste-draft'
const testState = vi.hoisted(() => ({
waitForReady: vi.fn(),
@@ -9,7 +9,8 @@ const testState = vi.hoisted(() => ({
vi.mock('@/store', () => ({
useAppStore: {
getState: () => ({ settings: {}, tabsByWorktree: {} })
getState: () => ({ settings: {}, tabsByWorktree: {}, ptyIdsByTabId: { 'tab-1': ['pty-1'] } }),
subscribe: () => () => {}
}
}))
@@ -95,6 +96,45 @@ describe('pty-bound agent draft readiness budget', () => {
expect(onUnconfirmedDelivery).not.toHaveBeenCalled()
})
it.each(['opencode', 'opencode2'] as const)(
'waits for the %s agent row only when Enter follows the paste',
async (agent) => {
const promise = pasteDraftToAgentPtyWhenReady({
tabId: 'tab-1',
ptyId: 'pty-1',
content: 'task',
agent,
submit: true,
forcePaste: true
})
await vi.advanceTimersByTimeAsync(12_000)
await expect(promise).resolves.toBe(true)
expect(testState.waitForReady).toHaveBeenCalledWith('pty-1', 20_000, 'opencode-agent-row', {})
}
)
it.each([
['opencode', true, 'opencode-agent-row'],
['opencode2', true, 'opencode-agent-row'],
['opencode', false, 'render-cursor-after-bracketed-paste'],
['opencode2', false, 'render-cursor-after-bracketed-paste']
] as const)('a tab-bound %s paste with submit %s waits on %s', async (agent, submit, signal) => {
const promise = pasteDraftWhenAgentReady({
tabId: 'tab-1',
content: 'task',
agent,
submit,
forcePaste: true
})
await vi.advanceTimersByTimeAsync(12_000)
await expect(promise).resolves.toBe(true)
expect(testState.waitForReady).toHaveBeenCalledWith('pty-1', 20_000, signal, {})
})
it('flags a blind paste when only the opencode process, not its composer, was seen', async () => {
// Regression (#22479): ConPTY never forwards DECSET 2004, so on Windows this is the only
// path opencode can take. Callers must be able to tell it apart from a real delivery.
+6 -5
View File
@@ -4,6 +4,7 @@ import type { TerminalInputKind } from '../../../shared/terminal-input-kind'
import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config'
import { AGENT_PROMPT_POST_PASTE_SUBMIT_DELAY_MS } from '../../../shared/agent-prompt-injection'
import { resolveDraftPasteReadyTimeoutMs } from '../../../shared/draft-paste-ready-timeout'
import { resolvePasteReadySignal } from '../../../shared/draft-paste-ready-scanner'
import { useAppStore } from '@/store'
import {
inspectRuntimeTerminalProcess,
@@ -99,7 +100,7 @@ export async function pasteDraftWhenAgentReady(args: {
return false
}
const readySignal = agentConfig?.draftPasteReadySignal ?? 'render-quiet-after-bracketed-paste'
const readySignal = resolvePasteReadySignal(agentConfig, submit === true)
const settings = getSettingsForAgentTabRuntimeOwner(tabId)
const readinessTimeoutMs = resolveDraftPasteReadyTimeoutMs(agent, timeoutMs)
const readiness = await waitForAgentDraftInputReadyOnTab({
@@ -127,9 +128,9 @@ export async function pasteDraftWhenAgentReady(args: {
onTimeout?.()
return false
}
// Why: the process merely exists -- its composer was never observed. On Windows this is
// the ONLY path: ConPTY does not forward DECSET 2004, so no 2004-anchored ready signal
// can ever fire. Callers must be able to tell this blind write apart from a real delivery.
// Why: the process merely exists -- its composer was never observed (e.g. the readiness
// budget expired mid-startup, #22479). Callers must be able to tell this blind write apart
// from a real delivery.
onUnconfirmedDelivery?.()
}
@@ -173,7 +174,7 @@ export async function pasteDraftToAgentPtyWhenReady(args: {
}
const settings = getSettingsForAgentTabRuntimeOwner(tabId)
const readySignal = agentConfig?.draftPasteReadySignal ?? 'render-quiet-after-bracketed-paste'
const readySignal = resolvePasteReadySignal(agentConfig, submit === true)
const budget = resolveDraftPasteReadyTimeoutMs(agent, timeoutMs)
const ready = await waitForAgentDraftInputReady(ptyId, budget, readySignal, settings)
if (!ready) {
+149 -1
View File
@@ -1,5 +1,7 @@
import { describe, expect, it } from 'vitest'
import { createDraftPasteReadyScanner } from './draft-paste-ready-scanner'
import { createDraftPasteReadyScanner, resolvePasteReadySignal } from './draft-paste-ready-scanner'
import { OPENCODE_AGENT_ROW_GRACE_MS } from './opencode-agent-row-scanner'
import { TUI_AGENT_CONFIG } from './tui-agent-config'
const DECSET_BRACKETED_PASTE = '\x1b[?2004h'
const DECRST_BRACKETED_PASTE = '\x1b[?2004l'
@@ -120,6 +122,135 @@ describe('createDraftPasteReadyScanner', () => {
})
})
describe('opencode-agent-row', () => {
// Frame shapes from the OpenCode 2.0.21 cold-start capture: the box with its bottom-left
// corner `╹` on row 25, then a later frame painting `<agent> · <model>` on row 24 above it.
const frame = (body: string): string =>
`\x1b[?2026h${HIDE_CURSOR}${body}\x1b[22;27H${SHOW_CURSOR}\x1b[?2026l`
const BOX = `${ALT_SCREEN_ENTER}${DECSET_BRACKETED_PASTE}${frame(
'\x1b[21;24H┃\x1b[22;24H┃\x1b[23;24H┃\x1b[24;24H┃\x1b[25;24H╹'
)}`
const AGENT_ROW = frame('\x1b[24;27HBuild\x1b[24;33H\u00b7\x1b[24;35HSome Model')
const GRACE = { ready: false, armQuietTimer: false, readyAfterMs: OPENCODE_AGENT_ROW_GRACE_MS }
it('is not ready on the input box and its cursor alone, and asks for the grace timer', () => {
const scanner = createDraftPasteReadyScanner('opencode-agent-row')
expect(scanner.observe(BOX)).toEqual(GRACE)
})
it('is ready once the row directly above the box corner paints its separator', () => {
const scanner = createDraftPasteReadyScanner('opencode-agent-row')
scanner.observe(BOX)
expect(scanner.observe(AGENT_ROW)).toEqual({
ready: true,
armQuietTimer: false,
readyAfterMs: null
})
})
it('is ready when OpenCode 1 paints the row before the corner in the same frame', () => {
const scanner = createDraftPasteReadyScanner('opencode-agent-row')
const opencode1Box = frame('\x1b[23;27HBuild\x1b[23;33H\u00b7\x1b[24;24H╹')
expect(
scanner.observe(`${ALT_SCREEN_ENTER}${DECSET_BRACKETED_PASTE}${opencode1Box}`).ready
).toBe(true)
})
it('pairs a separator with a later corner only inside the same synchronized frame', () => {
const scanner = createDraftPasteReadyScanner('opencode-agent-row')
const separatorFrame = frame('\x1b[23;33H\u00b7')
const cornerFrame = frame('\x1b[24;24H╹')
expect(
scanner.observe(
`${ALT_SCREEN_ENTER}${DECSET_BRACKETED_PASTE}${separatorFrame}${cornerFrame}`
).ready
).toBe(false)
})
it('ignores a separator in the footer path under the box', () => {
const scanner = createDraftPasteReadyScanner('opencode-agent-row')
scanner.observe(BOX)
expect(scanner.observe(frame('\x1b[26;24H~/col\u00b7lecció/work'))).toEqual(GRACE)
})
it('ignores a separator in a session tab title', () => {
const scanner = createDraftPasteReadyScanner('opencode-agent-row')
scanner.observe(BOX)
expect(scanner.observe(frame('\x1b[1;4Hx\x1b[1;5H\u00b7\x1b[1;6Hy'))).toEqual(GRACE)
})
it('ignores a separator a shell prompt draws before OpenCode enters the alternate screen', () => {
const scanner = createDraftPasteReadyScanner('opencode-agent-row')
expect(
scanner.observe(
`${DECSET_BRACKETED_PASTE}~ \u00b7 main % opencode${DECRST_BRACKETED_PASTE}`
)
).toEqual({ ready: false, armQuietTimer: false, readyAfterMs: null })
expect(scanner.observe(BOX).ready).toBe(false)
})
it('does not keep a row from an earlier alternate-screen session', () => {
const scanner = createDraftPasteReadyScanner('opencode-agent-row')
expect(scanner.observe(BOX + AGENT_ROW).ready).toBe(true)
expect(scanner.observe(ALT_SCREEN_LEAVE).ready).toBe(false)
expect(scanner.observe(BOX)).toEqual(GRACE)
})
it('does not latch a part an earlier session established and then revoked', () => {
// The review probe's shapes: another alternate-screen app drew a separator and left; a shell
// showed its cursor under bracketed paste and turned it off.
const leftApp = createDraftPasteReadyScanner('opencode-agent-row')
leftApp.observe(`${ALT_SCREEN_ENTER}x \u00b7 y${ALT_SCREEN_LEAVE}`)
expect(leftApp.observe(BOX)).toEqual(GRACE)
const shell = createDraftPasteReadyScanner('opencode-agent-row')
shell.observe(`${DECSET_BRACKETED_PASTE}% ${SHOW_CURSOR}${DECRST_BRACKETED_PASTE}`)
expect(
shell.observe(`${ALT_SCREEN_ENTER}${DECSET_BRACKETED_PASTE}footer \u00b7 path`).ready
).toBe(false)
})
it('withdraws the grace when OpenCode leaves the alternate screen before its row', () => {
const scanner = createDraftPasteReadyScanner('opencode-agent-row')
expect(scanner.observe(BOX)).toEqual(GRACE)
const shellPrompt = `${DECSET_BRACKETED_PASTE}% ${SHOW_CURSOR}`
expect(scanner.observe(`${ALT_SCREEN_LEAVE}${shellPrompt}`)).toEqual({
ready: false,
armQuietTimer: false,
readyAfterMs: null
})
// A later OpenCode start in the same pane gets the grace again.
expect(scanner.observe(BOX)).toEqual(GRACE)
})
it('withdraws readiness and the grace timer when bracketed paste is turned off', () => {
const scanner = createDraftPasteReadyScanner('opencode-agent-row')
expect(scanner.observe(BOX + AGENT_ROW).ready).toBe(true)
expect(scanner.observe(DECRST_BRACKETED_PASTE)).toEqual({
ready: false,
armQuietTimer: false,
readyAfterMs: null
})
const graced = createDraftPasteReadyScanner('opencode-agent-row')
expect(graced.observe(BOX).readyAfterMs).toBe(OPENCODE_AGENT_ROW_GRACE_MS)
expect(graced.observe(DECRST_BRACKETED_PASTE).readyAfterMs).toBeNull()
})
it('needs the box cursor shown while bracketed paste is held, not one a shell showed', () => {
const scanner = createDraftPasteReadyScanner('opencode-agent-row')
scanner.observe(`${DECSET_BRACKETED_PASTE}% ${SHOW_CURSOR}${DECRST_BRACKETED_PASTE}`)
const rowWithoutCursor = '\x1b[25;24H╹\x1b[24;33H\u00b7'
expect(
scanner.observe(`${ALT_SCREEN_ENTER}${DECSET_BRACKETED_PASTE}${rowWithoutCursor}`).ready
).toBe(false)
})
it('never arms the quiet window', () => {
const scanner = createDraftPasteReadyScanner('opencode-agent-row')
expect(scanner.observe(BOX).armQuietTimer).toBe(false)
expect(scanner.observe('more frames').armQuietTimer).toBe(false)
})
})
describe('codex-composer-prompt', () => {
it('is ready on the composer glyph after bracketed paste and never arms the quiet timer', () => {
const scanner = createDraftPasteReadyScanner('codex-composer-prompt')
@@ -344,3 +475,20 @@ describe('createDraftPasteReadyScanner', () => {
})
})
})
describe('resolvePasteReadySignal', () => {
it.each(['opencode', 'opencode2'] as const)(
'%s: only a paste that Enter follows waits for the agent row',
(agent) => {
expect(resolvePasteReadySignal(TUI_AGENT_CONFIG[agent], false)).toBe(
'render-cursor-after-bracketed-paste'
)
expect(resolvePasteReadySignal(TUI_AGENT_CONFIG[agent], true)).toBe('opencode-agent-row')
}
)
it('falls back to the draft signal, then the quiet window', () => {
expect(resolvePasteReadySignal(TUI_AGENT_CONFIG.codex, true)).toBe('codex-composer-prompt')
expect(resolvePasteReadySignal(null, true)).toBe('render-quiet-after-bracketed-paste')
})
})
+53 -4
View File
@@ -1,4 +1,5 @@
import type { DraftPasteReadySignal } from './tui-agent-config'
import type { DraftPasteReadySignal, TuiAgentConfig } from './tui-agent-config'
import { createOpenCodeAgentRowScanner } from './opencode-agent-row-scanner'
// Why: agents enable bracketed paste (DECSET 2004) before their composer is
// actually mounted/focused. These markers let the scanner detect the real
@@ -40,7 +41,18 @@ type DraftPasteReadySignalSpec = {
quietAnchor: string | null
}
const DRAFT_PASTE_READY_SIGNALS: Record<DraftPasteReadySignal, DraftPasteReadySignalSpec> = {
// Signals that read the screen's structure, not one marker; a Record so none lacks its scanner.
type StructuralSignal = 'opencode-agent-row'
const STRUCTURAL_SCANNERS: Record<
StructuralSignal,
() => { observe: (data: string) => { ready: boolean; readyAfterMs: number | null } }
> = {
'opencode-agent-row': createOpenCodeAgentRowScanner
}
type SingleSignal = Exclude<DraftPasteReadySignal, StructuralSignal>
const DRAFT_PASTE_READY_SIGNALS: Record<SingleSignal, DraftPasteReadySignalSpec> = {
'codex-composer-prompt': {
markerAnchor: DECSET_BRACKETED_PASTE,
markerAnchorEnd: null,
@@ -111,6 +123,22 @@ export type DraftPasteReadyScanResult = {
ready: boolean
/** Caller should (re)arm the quiet-window fallback timer for this chunk. */
armQuietTimer: boolean
/** Ready after this many ms unless the signal fires first; null withdraws an armed one. */
readyAfterMs?: number | null
}
export type DraftPasteReadyScanner = { observe: (data: string) => DraftPasteReadyScanResult }
/** The signal to wait for before a paste; one that Enter follows waits for the submit signal. */
export function resolvePasteReadySignal(
agentConfig: Pick<TuiAgentConfig, 'draftPasteReadySignal' | 'submitPasteReadySignal'> | null,
submit: boolean
): DraftPasteReadySignal {
return (
(submit ? agentConfig?.submitPasteReadySignal : undefined) ??
agentConfig?.draftPasteReadySignal ??
'render-quiet-after-bracketed-paste'
)
}
/**
@@ -156,13 +184,34 @@ export type DraftPasteReadyScanResult = {
* mounted — so the quiet window alone never settles and a launch draft would wait out
* the whole hard timeout, exactly as grok did. Same alt-screen anchoring and
* revocation as grok, because a powerline shell prompt can draw `╭` too.
* - `opencode-agent-row`: OpenCode's submit signal (see opencode-agent-row-scanner.ts): the
* box's show-cursor as above plus the agent/model row painted directly above the box's
* bottom corner. OpenCode 2 draws the box before its agent list loads and drops an Enter
* sent in between. No quiet window; while the box shows without the row it asks for a
* grace timer (`readyAfterMs`) instead.
* - `render-quiet-after-bracketed-paste` (default): no signal marker; arms the
* quiet window once DECSET 2004 is seen.
*
* A 512-byte ring (`recent` / `postAnchorRecent`) covers escape sequences
* split across chunk boundaries without retaining terminal scrollback.
*/
export function createDraftPasteReadyScanner(readySignal: DraftPasteReadySignal): {
export function createDraftPasteReadyScanner(
readySignal: DraftPasteReadySignal
): DraftPasteReadyScanner {
if (isSingleSignal(readySignal)) {
return createSingleSignalScanner(readySignal)
}
const scanner = STRUCTURAL_SCANNERS[readySignal]()
return {
observe: (data) => ({ ...scanner.observe(data), armQuietTimer: false })
}
}
function isSingleSignal(signal: DraftPasteReadySignal): signal is SingleSignal {
return !(signal in STRUCTURAL_SCANNERS)
}
function createSingleSignalScanner(readySignal: SingleSignal): {
observe: (data: string) => DraftPasteReadyScanResult
} {
let recent = ''
@@ -298,7 +347,7 @@ export function createDraftPasteReadyScanner(readySignal: DraftPasteReadySignal)
}
// Why: the Codex glyph and opencode show-cursor signals must NOT arm the
// quiet window (they carry no quiet anchor). opencode goes silent for
// Up to ~3.9s between enabling bracketed paste and mounting its composer, so a
// up to ~3.9s between enabling bracketed paste and mounting its composer, so a
// quiet window would fire during that gap — before the composer exists —
// and pre-empt the marker. Those signals wait for their marker, bounded
// only by the caller's hard timeout (and its best-effort
+167
View File
@@ -0,0 +1,167 @@
/**
* OpenCode's submit readiness: the `<agent> · <model>` row along the bottom of its input box is
* painted only once its agent list has loaded from its server, and until then it drops an Enter.
*
* Read off the stream by structure, never by names or colours: the row's separator `·` (U+00B7)
* sits on the line directly above the box's bottom-left corner `╹` (U+2579), both placed by
* absolute cursor addressing inside OpenCode's alternate screen. The footer path below the box and
* the session tab strip above it can hold `·` too; neither is on that line. OpenCode 2 paints the
* corner first and the row in a later frame; OpenCode 1 paints the row just before the corner in
* the same synchronized frame, so a separator waits for its corner until that frame ends.
*
* Ready once the row exists and the box's cursor is shown while bracketed paste is held (the
* earlier box rule), so neither version is delivered to sooner than before. Leaving the alternate
* screen or turning bracketed paste off withdraws what it established.
*/
const ESC = '\x1b'
const AGENT_ROW_SEPARATOR = '\u00b7'
const BOX_BOTTOM_LEFT = '\u2579'
// Why: a narrow pane never paints the row (OpenCode 2 drops the agent below 44 columns), and nor
// does a terminal that does not forward the alternate screen; a theme whose raised background is
// transparent paints no `╹`, so its starts always wait out the grace. Once the box's cursor has shown, the
// earlier box rule takes over after this grace, so those starts still get their task, as before.
// 5 s because the row trailed the box by at most 1.8 s in every recorded start, loaded or not.
export const OPENCODE_AGENT_ROW_GRACE_MS = 5_000
// Longest unfinished escape carried into the next read; anything longer is noise, not a split.
const MAX_CARRIED_ESCAPE_CHARS = 512
/* oxlint-disable no-control-regex -- these match terminal escape sequences, which start with ESC */
const CSI_RE = /^\x1b\[([?>=<]?)([0-9;]*)([ -/]*[@-~])/
const UNFINISHED_CSI_RE = /^\x1b\[[?>=<]?[0-9;]*[ -/]*$/
// OSC and DCS strings (titles, queries) end at BEL or ST; their text is never painted.
const STRING_RE = /^\x1b[\]P][^\x07\x1b]*(?:\x07|\x1b\\)/
const STRING_START_RE = /^\x1b[\]P]/
const STRING_END_RE = /\x07|\x1b\\/
export type OpenCodeAgentRowScan = {
/** The row exists and the box's cursor is shown: Enter will be taken. */
ready: boolean
/** Box shown, no row yet: ready after this many ms unless the row comes first; null withdraws. */
readyAfterMs: number | null
}
export function createOpenCodeAgentRowScanner(): {
observe: (data: string) => OpenCodeAgentRowScan
} {
let carry = ''
let altScreen = false
// Once a full-screen app has left, a box cursor outside the alternate screen is the shell's.
let leftAltScreen = false
let bracketedPaste = false
let boxCursorShown = false
let row: number | null = null
let boxBottomRow: number | null = null
let rowPainted = false
// Separator rows seen in the open synchronized frame, for OpenCode 1's row-before-corner order.
let framePendingRows = new Set<number>()
const resetLayout = (): void => {
row = null
boxBottomRow = null
rowPainted = false
framePendingRows = new Set()
}
const onPrivateMode = (mode: string, set: boolean): void => {
if (mode === '1049') {
altScreen = set
resetLayout()
if (!set) {
leftAltScreen = true
boxCursorShown = false
}
} else if (mode === '2004') {
bracketedPaste = set
if (!set) {
boxCursorShown = false
}
} else if (mode === '25' && set && bracketedPaste) {
boxCursorShown = true
} else if (mode === '2026') {
framePendingRows = new Set()
}
}
const onCsi = (privateMarker: string, params: string, final: string): void => {
if (privateMarker === '?' && (final === 'h' || final === 'l')) {
for (const mode of params.split(';')) {
onPrivateMode(mode, final === 'h')
}
} else if (final === 'H' || final === 'f') {
row = Math.max(1, Number(params.split(';')[0] || '1'))
} else if (final === 'J' && params === '2') {
boxBottomRow = null
framePendingRows = new Set()
}
}
const onText = (text: string): void => {
if (!altScreen || row === null) {
return
}
for (const char of text) {
if (char === '\n') {
row += 1
} else if (char === AGENT_ROW_SEPARATOR) {
if (boxBottomRow === row + 1) {
rowPainted = true
} else {
framePendingRows.add(row)
}
} else if (char === BOX_BOTTOM_LEFT) {
boxBottomRow = row
if (framePendingRows.has(row - 1)) {
rowPainted = true
}
}
}
}
return {
observe(data: string): OpenCodeAgentRowScan {
const input = carry + data
carry = ''
let index = 0
while (index < input.length) {
const escapeAt = input.indexOf(ESC, index)
if (escapeAt === -1) {
onText(input.slice(index))
break
}
onText(input.slice(index, escapeAt))
const rest = input.slice(escapeAt)
const sequence = CSI_RE.exec(rest) ?? STRING_RE.exec(rest)
if (sequence) {
if (sequence[0][1] === '[') {
onCsi(sequence[1], sequence[2], sequence[3])
}
index = escapeAt + sequence[0].length
continue
}
if (isUnfinishedEscape(rest)) {
carry = rest
break
}
index = escapeAt + 2
}
// Leaving the alternate screen clears rowPainted, so it only holds while OpenCode is drawn.
const graceApplies = boxCursorShown && !rowPainted && (altScreen || !leftAltScreen)
return {
ready: rowPainted && boxCursorShown,
readyAfterMs: graceApplies ? OPENCODE_AGENT_ROW_GRACE_MS : null
}
}
}
}
function isUnfinishedEscape(rest: string): boolean {
if (rest.length > MAX_CARRIED_ESCAPE_CHARS) {
return false
}
return (
rest === ESC ||
UNFINISHED_CSI_RE.test(rest) ||
(STRING_START_RE.test(rest) && !STRING_END_RE.test(rest))
)
}
+9 -3
View File
@@ -6,7 +6,8 @@ export type AgentPromptInjectionMode =
| 'hermes-query'
| 'stdin-after-start'
export type DraftPasteReadySignal =
/** Signals a single marker settles; draft paste and the serializer read only these. */
export type DraftPasteMarkerSignal =
| 'render-quiet-after-bracketed-paste'
| 'codex-composer-prompt'
| 'render-cursor-after-bracketed-paste'
@@ -14,6 +15,9 @@ export type DraftPasteReadySignal =
| 'dsh-composer-prompt'
| 'zcode-composer-prompt'
/** Submit signals may also ask for a grace (`readyAfterMs`); only the paste waiters honour it. */
export type DraftPasteReadySignal = DraftPasteMarkerSignal | 'opencode-agent-row'
export type TuiAgentDetectionRuntime = NodeJS.Platform | 'wsl'
export type TuiAgentConfig = {
@@ -40,10 +44,12 @@ export type TuiAgentConfig = {
/** Trust Orca pre-writes at PTY spawn (agent-workspace-trust.ts) so the agent's first-launch "trust this folder?" menu doesn't consume the bracketed paste. */
preflightTrust?: 'claude' | 'cursor' | 'copilot' | 'codex' | 'antigravity' | 'qoder' | 'qoder-cn'
/** Agent-specific signal that the composer is ready for paste, stronger than the default quiet-render window. */
draftPasteReadySignal?: DraftPasteReadySignal
draftPasteReadySignal?: DraftPasteMarkerSignal
/** Signal before a paste that Enter follows, for agents that show their composer before it can submit; defaults to `draftPasteReadySignal`. */
submitPasteReadySignal?: DraftPasteReadySignal
/** Hard deadline for the agent's composer readiness signal. */
draftPasteReadyTimeoutMs?: number
/** Captured boots proving the composer marker used for fresh worker dispatch. */
/** Captured boots proving the composer marker used for fresh worker dispatch (the submit signal where set). */
composerReadyCaptures?: readonly string[]
/** Delay before one extra blind submit Enter, for agents that render their composer before Enter is live (codex); a no-op if the first Enter landed. */
submitRetryDelayMs?: number
+15 -4
View File
@@ -4,6 +4,7 @@ import type { TuiAgentConfig } from './tui-agent-config-types'
export type {
AgentPromptInjectionMode,
DraftPasteMarkerSignal,
DraftPasteReadySignal,
TuiAgentConfig,
TuiAgentDetectionRuntime
@@ -93,6 +94,9 @@ const TUI_AGENT_CONFIG_SOURCE: Record<TuiAgent, TuiAgentConfigSource> = {
promptInjectionMode: 'flag-prompt',
// Why: opencode enables bracketed paste before its composer mounts; wait for the post-\x1b[?2004h show-cursor so paste lands.
draftPasteReadySignal: 'render-cursor-after-bracketed-paste',
// Why: OpenCode 2 draws its input box before its agent list loads and drops an Enter sent
// in between; the agent/model row under the box is the first moment it can submit.
submitPasteReadySignal: 'opencode-agent-row',
// Why 20s: measured on two Windows hosts (ConPTY dll backend, as pinned by
// local-pty-utils), opencode does not enable bracketed paste until ~4.8s and its
// composer is not ready until ~10s — so the 8s default expired first and the draft
@@ -102,19 +106,26 @@ const TUI_AGENT_CONFIG_SOURCE: Record<TuiAgent, TuiAgentConfigSource> = {
composerReadyCaptures: [
'opencode-1-18-32-timed-boot-slow',
'opencode-1-18-32-timed-boot-hidden-pane',
'opencode-1-18-32-timed-first-launch'
'opencode-1-18-32-timed-first-launch',
'opencode-cmd-2-0-21-timed-warm-server'
]
},
// Why: opencode2 installs as a separate binary and uses the same prompt flags.
// Its @opentui composer keeps the same cursor-gated paste signal.
// Why: opencode2 installs as a separate binary and uses the same prompt flags and paste signals.
opencode2: {
detectCmd: 'opencode2',
// The private server inherits this pane's hook endpoint and identity.
launchCmd: 'opencode2 --standalone',
promptInjectionMode: 'flag-prompt',
draftPasteReadySignal: 'render-cursor-after-bracketed-paste',
submitPasteReadySignal: 'opencode-agent-row',
draftPasteReadyTimeoutMs: 20_000,
composerReadyCaptures: ['opencode-2-0-18-timed-boot-hidden-pane']
composerReadyCaptures: [
'opencode-2-0-18-timed-boot-hidden-pane',
'opencode-2-0-21-timed-cold-standalone',
'opencode-2-0-21-timed-cold-standalone-hidden-pane',
'opencode-2-0-21-timed-natural-load-enter-dropped',
'opencode-2-0-14-timed-cold-standalone'
]
},
'mimo-code': {
detectCmd: 'mimo',