Skip to content

feat(resume): offer to re-run an agent session in a cold-restored pane - #364

Open
dormouse-bot wants to merge 13 commits into
mainfrom
fix/resume-patterns-most-recent
Open

feat(resume): offer to re-run an agent session in a cold-restored pane#364
dormouse-bot wants to merge 13 commits into
mainfrom
fix/resume-patterns-most-recent

Conversation

@dormouse-bot

@dormouse-bot dormouse-bot commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

Scope note: this started as a one-function fix to detectResumeCommand (return the newest resume hint, not the oldest). Chasing it turned up that the field it fixed, PersistedPane.resumeCommand, was write-only — detected at save, validated in the schema, and read by nothing. So the PR grew into the feature that field was always for, plus the detection and safety work that feature needs to be trustworthy.

The feature

A cold restore replays a Session's saved scrollback into a fresh shell, so the pane shows an agent's parting resume hint with no process behind it. The restored pane now offers to run it: Run <invocation> / Dismiss, bottom-right of the pane.

  • Seeded by restore only. Live resume never seeds it — there the process is still Live and has nothing to resume.
  • Retired by taking it, dismissing it, the pane's process exiting, session dispose, or the user's first input into the pane — counted across every path that reaches the PTY: xterm keystrokes, direct clipboard paste, file-drop path insertion, a Pocket client's remote keystrokes, and dor send. Only the xterm path goes through onData, so each direct platform write retires the offer itself.
  • untouched is deliberately not the gate. A Session that ran an agent is touched by definition, so isUntouched would suppress the offer in exactly the case it exists for.
  • Hidden, not retired, while a command is running — the offer types into the shell, and a shell with a foreground process isn't listening.
  • Taking it selects the pane, enters passthrough, revalidates the command, seeds commandLine + commandStart(user_input) (the direct platform write bypasses xterm's keystroke fallback, so non-integrated shells would otherwise never register the run), then writes <command>\r — not a bracketed paste, since bracketing exists to stop an embedded newline from executing, which is the opposite of the intent.
  • The button carries the invocation, not the command. Run claude --resume, never Run claude --resume <uuid> — the id is already on screen in the replayed scrollback directly above, and keeping it out of chrome means a long uuid can't change the button's width. Full command is the tooltip.

Also fixes the VS Code save path, which rewrote scrollback at deactivate while carrying the previous save's resumeCommand through a spread — the field drifted from the buffer it was derived from, on the host that restore reads. Both writers now go through one terminalPersistedContent() helper, so trim-and-detect can't be split across the package boundary again.

Detection

The original bug and everything it was hiding:

  • Recency. String.match with a non-global regex returns the first — chronologically oldest — occurrence. A pane that resumed more than once persisted a stale session id. Now scans the last 50 LF-delimited raw segments newest-first, and takes the rightmost match within a segment so carriage-return-only redraws still select the newest visible hint. Pattern order no longer outranks recency either (codex was checked before claude unconditionally).
  • Prose punctuation. The trailing lookahead required whitespace or EOL after the invocation, so Resume with `claude --resume abc`. detected null — as did quoted and parenthesized hints. Backticking a command is the standard way a CLI prints one mid-sentence. Narrowed to a word break, which is the only part that was load-bearing (claude --continuex must not match as a prefix) and is a no-op for the id-taking patterns, since RESUME_ID is greedy.
  • Unterminated string controls. A chunk split or a scrollback-trim cut landing mid-OSC left only the \x1b] introducer to the ESC catch-all and promoted the payload to visible text — \x1b]0;claude --resume evil\n was detected as a runnable command. An unterminated OSC/DCS now swallows the rest of its input.
  • Window, not segment. Stripping each raw LF segment independently handed back the second half of any control whose payload spans a newline. The 50-line window is now stripped once and split afterwards, so \x1b]0;title\nclaude --resume evil\x07 is removed as a unit — and an unterminated control swallows the rest of the window, failing toward no offer. A payload whose introducer fell off the front of the window stays unrecoverable at this layer; transport.md says that rather than claiming "can never".
  • One stripper. terminal-state-store.ts carried a private stripTerminalControls with five of these seven steps verbatim, so none of the hardening above reached it. Both now use lib/src/lib/terminal-controls.ts. That closed a live false positive in the keystroke prompt fallback, whose 1024-char tail slice cuts mid-sequence routinely: a buffer ending in a half-arrived title OSC left 0;user@host:~/repo$ as the last visible line, which detectReturnedShellPrompt read as a returned prompt and used to flip a running command back to idle.
  • Perf. The three patterns compile once at module scope instead of three RegExp allocations per line scanned (up to 150 per pane per save, on the debounced save path).

Safety

The captured value is later executed, so: RESUME_ID is deliberately narrower than a shell word (alphanumeric, hyphen, underscore), and the command is always rebuilt as label + captured id — never lifted as a raw substring. claude --resume $(touch${IFS}/tmp/pwn) detects null; codex resume safe; touch /tmp/pwn detects codex resume safe and none of the tail. Restore seeding and Run both revalidate through normalizeResumeCommand, as defense against snapshots written by an older detector.

Two liveness bugs found in review, both now fixed with regression tests:

  • The offer survived PTY exit. A cold restore whose saved cwd is gone spawns a shell that exits immediately, leaving a live Run button over [Process exited]. Clicking it wrote into a dead PTY — a no-op — but still seeded a command start that nothing would ever finish, so the pane counted as running forever: a spurious quit confirmation, a phantom running header, and dor ensure matching a dead surface.
  • Retirement was wired to two of the direct-write sites rather than all of them, so a laptop kept offering to resume a shell a Pocket client had already driven elsewhere.

Design

The offer needed a filled primary button in pane-overlay chrome. It had borrowed modalActionButton — the modal-footer recipe, whose five other users are all actual modals — then patched it back toward pane chrome with border border-transparent and bg-surface-raised. Both patches were re-deriving PopupButtonRow, which every other in-pane overlay already uses, including MouseOverrideBanner one corner away. So popupButton gains the one thing it lacked — a primary tone on the existing accent pair, no new tokens — and the offer becomes a PopupButtonRow, deleting both patches.

Specs

New "Resume offer" section in layout.md; transport.md documents resumeCommand for the first time (detection grammar, recency rule, the single-helper requirement); terminal-state.md covers the seeded command state; mouse-and-clipboard.md and vscode.md updated — the latter's "and resume commands are saved and restored" claim is finally true. DESIGN.md gets the Popup Button primary tone.

Tests

110 lib test files / 1364 tests, spec-lint OK, tsc --noEmit clean. New coverage: resume-offers.test.ts, terminal-controls.test.ts, ResumeBanner.test.tsx + stories, and additions to resume-patterns, terminal-state-store, session-restore, clipboard, and terminal-registry.alert.

…llback

detectResumeCommand matched each pattern against the whole scrollback with
a non-global regex, which returns the first (oldest) occurrence. A pane that
resumed more than once prints a fresh resume hint each time, so the persisted
resumeCommand pointed at a stale session id. Scan lines newest-first instead,
returning the most recent match across all patterns.

Adds regression tests for repeated same-command hints and for most-recent-wins
across pattern types; both fail against the old first-match logic.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 10, 2026

Copy link
Copy Markdown

Deploying mouseterm with  Cloudflare Pages  Cloudflare Pages

Latest commit: fe8d5a2
Status: ✅  Deploy successful!
Preview URL: https://7c9d9f09.mouseterm.pages.dev
Branch Preview URL: https://fix-resume-patterns-most-rec.mouseterm.pages.dev

View logs

nedtwigg and others added 9 commits August 15, 2026 16:49
PersistedPane.resumeCommand was write-only: detected at save time, validated
in the schema, and read by nothing. A cold restore replays a Session's saved
scrollback into a *fresh* shell, so the pane shows an agent's parting resume
hint with no process behind it. Turn that dead field into the obvious
affordance — two buttons bottom-right of the restored pane, `Run <invocation>`
and `Dismiss`.

The offer is seeded only by restoreSession; the live-resume path never reaches
it, because there the process is still Live and has nothing to resume. It is
retired by taking it, dismissing it, session dispose, or the user's first input
into the pane. Note that `untouched` is NOT the gate: a Session that ran an
agent is touched by definition, so isUntouched would suppress the offer in
exactly the case it exists for. Retirement keys off post-restore input instead.
Hidden (not retired) while a command is running, since the offer types into a
shell that is not listening.

Taking it writes `<command>\r` straight to the PTY rather than as a bracketed
paste — bracketing exists to stop an embedded newline from executing, which is
the opposite of the intent here.

The button carries the invocation, never the session id: `Run claude --resume`,
with the full command as its tooltip. The id is already on screen in the
replayed scrollback directly above, and keeping it out of chrome means a long
uuid can't change the button's width.

Also fixes the VS Code save path, which rewrote `scrollback` at deactivate while
carrying the previous save's `resumeCommand` through a spread — the field drifted
from the buffer it was derived from on the host that restore reads. Both writers
now go through one terminalPersistedContent() helper, so trim-and-detect can no
longer be split across the package boundary.

Specs: new "Resume offer" section in layout.md (seeding, retirement, the
untouched trap, the running gate, the raw-write rationale); transport.md
documents resumeCommand for the first time; vscode.md's "and resume commands are
saved and restored" claim is finally true.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ffer on it

The resume offer borrowed modalActionButton — the modal-footer recipe, whose
five other users are all actual modals — and then patched it back toward pane
chrome: `border border-transparent` to height-match the bordered secondary, and
`bg-surface-raised` so terminal output did not show through. Both patches were
re-deriving PopupButtonRow, which every other in-pane overlay already uses,
including MouseOverrideBanner one corner away.

Add the one thing popupButton lacked: a `primary` tone carrying the same accent
pair as a modal's primary button (bg-header-active-bg / text-header-active-fg),
no new tokens. Its compound variant drops the default unflashed hover — that
wash sets *background*, so on a filled tone it replaces the accent instead of
sitting over it; the fill holds, matching modalActionButton's primary, which
likewise has no hover treatment.

The offer becomes a PopupButtonRow with two popupButton segments. Both patch
classes and both comments explaining them delete: items-stretch gives the height
parity, and the row's own surface gives the opacity. Type scale moves from the
modal's text-xs to the popup row's text-sm.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tripping

Code review of the resume-offer work found two live defects and a cluster of
cleanups.

The offer survived PTY exit. A cold restore whose saved cwd is gone spawns a
shell that exits immediately, and the pane kept a live Run button over
`[Process exited]`. Clicking it wrote into a dead PTY — a no-op — but
`seedLaunchedCommand` still set currentCommand + activity 'running', and no exit
event was left to clear it. The pane then counted as running forever:
countRunningSessions() raises a spurious quit confirmation, the header shows a
phantom agent, and `dor ensure` matches a dead surface. handleExit now retires
the offer, and runResumeCommand bails when the entry is missing or exited.

Retirement was bolted onto two of the direct-write sites rather than all of
them. A Pocket client's keystrokes (remote-api #write) and `dor send` both reach
the PTY without passing xterm's onData, so the laptop kept offering to resume a
shell the phone had already driven elsewhere. Both clear the offer now, and
layout.md enumerates every path that reaches the PTY instead of listing three.

stripTerminalControls surrendered an unterminated string control's payload: only
the `\x1b]` introducer matched the ESC catch-all, so a chunk split or a
trimPersistedScrollback cut landing mid-OSC promoted a window title to visible
text — `\x1b]0;claude --resume evil\n` was detected as a runnable command. An
unterminated OSC/DCS now swallows the rest of its input.

Cleanups: the three patterns compile once at module scope with the `g` flag
instead of three RegExp allocations per line scanned (up to 150 per pane per
save); matchAll scans a clone, so the shared lastIndex is untouched, but
resumeCommandLabel switches to an exact label-prefix test because `regex.test`
on a shared global regex would alternate. normalizeResumeCommand strips once
rather than twice. ResumeBanner reads getTerminalPaneState instead of
re-deriving it from the snapshot Map.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The trailing lookahead required whitespace or end-of-line after the invocation,
so every hint an agent rendered mid-sentence was dropped: "Resume with
`claude --resume abc`." detected null, as did 'codex resume abc' in quotes and
(codex resume abc) in parens. Backticking a command is the standard way a CLI
prints one in prose, so those panes persisted resumeCommand: null and never
offered to resume.

Nothing about safety rested on that lookahead. The persisted command is rebuilt
as label + captured id, never lifted as a raw substring, and RESUME_ID stays
narrower than a shell word — so what trails the id is dropped rather than
carried into executable state. `claude --resume $(touch${IFS}/tmp/pwn)` still
detects null, because `$` cannot start an id; `codex resume safe; touch /tmp/pwn`
now detects `codex resume safe`, which is exactly the resume and none of the
tail. That second case was the "whole line must be clean" stance; it costs
recall and buys nothing the id grammar does not already provide.

What does still need to hold is a word break, so an invocation cannot match as
the prefix of a longer one: `claude --continuex` and `claude --continue-session`
are not offers to continue. The lookahead narrows to exactly that. It is a no-op
for the two id-taking patterns — RESUME_ID is greedy, so the next character is
never an id character — which also means it can never truncate an id, only
reject a longer word.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@nedtwigg nedtwigg changed the title fix(resume-patterns): return the most recent resume command from scrollback feat(resume): offer to re-run an agent session in a cold-restored pane Aug 16, 2026

@dormouse-bot dormouse-bot left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two findings, both about stripTerminalControls — one duplication, one gap between what the code does and what transport.md claims it does. Neither is a correctness bug in the feature itself; the offer lifecycle, the retirement paths, the dead-PTY guard, and the seedLaunchedCommand ordering all trace clean, and the recency rewrite of detectResumeCommand does what the tests say (I ran the new patterns standalone against redraw, prose-punctuation, and shell-syntax inputs and got the documented answers).

Not approving because this is a self-authored PR — GitHub rejects self-approval, so this is a COMMENT either way.

What I traced and did not flag
  • Retirement coverage: every writePty call site in lib/src is accounted for. restartSurfaceInPlace in use-dor-control.ts is the one direct write with no clearResumeOffer, but it is unreachable with a pending offer — it only fires on a surface already matched by surfaceRunsCommand, and a restored pane can't match one without the user first typing (which retires the offer).
  • clearResumeOffer in wireXtermHandlers sits under !isReplayTerminalReport, which is the right gate: inputIsReplayTerminalReport covers CPR/DA/DECRQM/focus/OSC/DCS answerbacks, so a program's query reply doesn't retire the offer.
  • runResumeCommand writes with id while disposeSession kills with entry.ptyId. Today ptyId: id at creation so they're the same, and remote-api.ts keying clearResumeOffer off attachment.ptyId is equally fine — worth remembering if those ever diverge.
  • The VS Code path: browser panes short-circuit to browserPersistedPane before terminalPersistedContent, and the !ptys.has(pane.id) branch keeps scrollback and resumeCommand as a consistent pair. The drift the PR describes is genuinely closed.
  • session-types.ts gaining a runtime dependency on resume-patterns / scrollback-trim doesn't reach any constrained consumer — its importers are all in lib/ and vscode-ext/.

Comment thread lib/src/lib/resume-patterns.ts Outdated
Comment thread lib/src/lib/resume-patterns.ts
… window once

The prompt detector in terminal-state-store.ts carried a private
stripTerminalControls with five of these seven steps verbatim, so the
hardening added for resume detection — the unterminated string-control
swallow and the C0/C1 discard — never reached it. Its input is a
1024-char tail slice, which cuts mid-sequence routinely: a buffer ending
in a half-arrived title OSC left the payload as the last visible line,
and a title carrying the prompt string read as a returned prompt.

Both now use lib/src/lib/terminal-controls.ts. The prompt detector also
picks up the wider CSI parameter class (<, =, >, : are legal parameter
bytes).

detectResumeCommand strips the whole 50-line window once and splits the
stripped text, instead of stripping each raw segment independently — an
OSC whose payload spans an LF handed its second half back as visible
text. An unterminated control now swallows the rest of the window rather
than the rest of its segment, which fails toward no offer. A payload
whose introducer fell off the front of the window stays unrecoverable at
this layer; transport.md said 'can never read as terminal output' and now
says what actually holds.

@dormouse-bot dormouse-bot left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both findings from the previous pass are genuinely closed, and I checked the substance rather than the claim: there is now exactly one stripTerminalControls in lib/src, both call sites import it, and detectResumeCommand computes the 50-line window start and strips once before splitting (I re-derived the window loop against the old one — same windowStart for every input, including the ends-with-LF and fewer-than-50-lines edges). transport.md no longer claims "can never." The two regression tests in terminal-state-store.test.ts assert the right pair: the half-arrived title OSC no longer flips a running command to idle, and a real prompt trailed by a cut-off OSC is still seen — the swallow removes from the introducer forward, so nothing before the cut is lost.

One new finding, inline: the shared helper covers OSC and DCS but not the other three ST-terminated string controls (APC, PM, SOS), whose payloads still reach both detectors as visible text. Same leak class the swallow closes for OSC, and now it's a gap in the file whose whole point is that a hardening step can't miss one consumer. A \x9c (8-bit ST) note rides along in the same suggestion.

Not approving because this is a self-authored PR — GitHub rejects self-approval, so this is a COMMENT either way. CI is green on 6cb47d6.

What I traced and did not flag
  • Window arithmetic. New loop sets windowStart = lastIndexOf('\n', cursor - 1) + 1 then cursor = windowStart - 1; old loop's start/end walk produces the identical sequence, so the 50th-from-last line boundary is unchanged. cursor > 0 stops correctly on a leading-\n buffer, and a fully-swallowed window yields [''], which matches nothing.
  • Segment joining after the strip. Removing a terminated control that spans an LF now merges the text either side into one segment, which can surface a hint the per-segment strip missed (y\x07claude --resume abc used to fail the \b boundary). That's the rendered-text answer, so it's the right direction, not a new false positive.
  • CSI class widening. [0-9;?][0-?] is exactly the CSI parameter-byte range 0x30–0x3F; strictly more sequences removed, nothing previously matched now escapes.
  • C0/C1 discard reaching the prompt detector. LF/CR/TAB are excluded from the class, so detectReturnedShellPrompt's line splitting and precedingLineHasPromptContext are untouched; the bytes it starts dropping (BEL, BS, DEL) were never part of any prompt shape it matches.
  • Rolling-buffer blindness. promptOutputBuffers is cumulative (prev + output sliced to 1024) and only cleared when a prompt is found, so an OSC split across chunks is terminated in the buffer by the time the terminator arrives — the swallow costs at most the one evaluation before that, not a stuck pane.
  • normalizeResumeCommand inherits the swallow too; a persisted command carrying an introducer now shortens visible and fails the detected === visible identity check, which fails toward null. Right direction.
  • Spec fold. terminal-state.md gained the new file in its Files table and a Control-stripping bullet; transport.md has no Files table to update. terminal-escapes.md isn't in scope — it registers what's parsed or answered at the PTY data boundary, not content-level stripping downstream of it. pnpm lint:specs passes (23 specs, 24 files).

Comment thread lib/src/lib/terminal-controls.ts Outdated
stripTerminalControls covered OSC and DCS but not the other three
ST-terminated string controls. APC/PM/SOS fell through to the ESC
catch-all, which removed the introducer and the terminator and left
the payload standing as text — the exact leak the unterminated-swallow
was written to prevent. kitty's graphics protocol is an APC, so any
pane running an image-capable tool emits one.

Also accept ST's 8-bit form (\x9c) as a terminator: an OSC closed that
way otherwise read as unterminated and the swallow ate the prompt
behind it.

@dormouse-bot dormouse-bot left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The APC/PM/SOS gap from the last pass is closed, and I checked the substance rather than the claim: [PX^_] covers all four ST-terminated introducers in both the terminated form and the swallow (^ sits mid-class, so it's a literal, not a negation), the three new shapes strip to user$ , and the \x9c case returns the prompt instead of eating it. Full lib suite is 1368/1368 here.

I also went and read the pinned parser rather than reasoning from ECMA-48, which turned up one more terminator gap of exactly the same class — inline. Short version: \x9c was the right call and xterm agrees with it, but xterm ends a string control on two more bytes and on a bare ESC, and this file still treats all three as "unterminated" and swallows the visible text behind them. Low severity, same failure mode as the one this commit just fixed.

Not approving because this is a self-authored PR — GitHub rejects self-approval, so this is a COMMENT either way. CI is green on d8f6a59d (Build & Test, Standalone Smoketest, Visual Regression Tests, Storybook Publish, UI Tests, Cloudflare Pages).

What I traced and did not flag
  • \x9c matches the renderer. Verified against the pinned @xterm/xterm parser table rather than the spec: table.add(0x9c, state, ParserAction.IGNORE, ParserState.GROUND) // ST as terminator in the global-anywhere loop, plus per-state entries for OSC (OSC_END), SOS/PM, APC (APC_END) and DCS (DCS_UNHOOK). So accepting it is not merely defensible, it's what the pane does.
  • 8-bit introducers. Your reasoning for leaving \x9d/\x90/\x9e/\x9f on the C1 rule holds — pulling them into the swallow makes a stray C1 byte destructive, and the asymmetry with \x9c is real (accepting a terminator can only ever un-swallow).
  • Regex-ordering hazards. The OSC rule runs first and globally, so it can consume an ST belonging to a later APC. Every shape I tried lands on over-strip, never on a leak: \x1b_G…\x1b]…\x1b\\text ends up fully swallowed rather than promoting the payload.
  • terminal-report-filter.ts carries near-identical REPORT_OSC / REPORT_DCS shapes and was not widened — correct, and worth not "fixing" later. It classifies replayed reports in input; widening what counts as a report there means more keystroke-path input bypasses clearResumeOffer, which is the wrong direction.
  • Specs. transport.md names the full family now; terminal-state.md already says "unterminated string control" generically. terminal-escapes.md genuinely doesn't need a row — it registers sequences parsed or answered at the PTY data boundary, and nothing in it references this helper.

Comment thread lib/src/lib/terminal-controls.ts Outdated
xterm's parser aborts a string control on CAN (0x18) or SUB (0x1a) and ends
one on a bare ESC, so the text behind any of the three is rendered output.
stripTerminalControls read them as an unclosed payload and let the swallow
eat the rest of the input — the same failure the 8-bit-ST fix just closed:
a prompt printed after an aborted title OSC vanished, so the keystroke
fallback missed a returned prompt and left a finished command marked
running.

`\x1b\\` is matched before the bare-ESC lookahead so a 7-bit ST is consumed
whole rather than leaving a stray backslash as text; the lookahead (rather
than a consuming ESC) leaves a following CSI intact for the rules below.

@dormouse-bot dormouse-bot left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CAN/SUB/bare-ESC gap from the last pass is closed, and I checked the behaviour rather than the claim: I ran both alternations — old and new — over the whole existing corpus plus the realistic string-control shapes that were most at risk of collateral damage (tmux and screen DCS passthrough with doubled ESC, sixel, OSC 8 hyperlinks, OSC 133 prompt marks, bare ESC ESC). Every one of those produces byte-identical output before and after; the only two inputs that change are the two the commit is for — an unterminated OSC or APC followed by an SGR, which used to swallow the prompt behind it and now returns it. All 21 assertions in the file pass, and the three swallow tests still swallow, because none of them has an ESC after the introducer.

No new findings. This is a self-authored PR, so it's a COMMENT either way — GitHub rejects self-approval. CI is green on fe8d5a2d (Build & Test, Standalone Smoketest, Visual Regression Tests, Storybook Publish, UI Tests, Cloudflare Pages).

What I traced and did not flag
  • Alternation ordering. \x1b\\ sits before (?=\x1b) in both rules, so a 7-bit ST is consumed whole rather than leaving the \ as text — the comment's claim, and the \x1bP+q544e\x1b\\user$ / \x1b_G…\x1b\\user$ cases confirm it. [\x18\x1a\x9c] before \x1b\\ is disjoint, so its position doesn't matter. The lookahead is zero-width but the overall match has a two-char minimum (\x1b]), so there's no empty-match loop.
  • The swallow narrowed, but only where the renderer agrees. Rule 3 now fires only when nothing after the stranded introducer contains an ESC. In every case where it no longer fires, rule 1/2 has still removed the payload from the introducer up to that ESC — the payload is never promoted, only the text xterm would have drawn after the abort is returned. \x1b]0;claude --resume EVIL\x1b[0m strips to ''; …\x1b[0mprompt$ strips to prompt$ .
  • Cross-rule consumption. Rule 1 runs globally before rule 2, so an OSC can still eat an ST belonging to a later APC. With the lookahead added, every shape I tried lands on over-strip (\x1b_G…\x1b]…\x1b\\text → fully swallowed), never on a leak — same as the previous pass.
  • Detector-level effect. resume-patterns.test.ts's "never promotes an unterminated string control payload" case has no ESC after the introducer, so it's untouched. The realistic variant that is newly affected — an unterminated title OSC followed by \x1b[0m and a prompt — strips to the prompt and yields null, and the mirror case (\x1b]0;title\x1b[0mclaude --resume abc\n) now correctly reads as an offer, which is what the pane renders.
  • Remaining C1 terminators. The parser row quoted in the resolved thread is [0x9c, 0x1b, 0x18, 0x1a, 0x07] for OSC_STRING — the alternation now matches it exactly, so there's no fifth byte left to add here. Pulling the 8-bit introducers (\x9d/\x90/\x9e/\x9f) in as terminators would be actively wrong: it would end the outer control and then let the C1 rule delete the new introducer while promoting its payload to text. Leaving them on the C1 rule is the right resting point.
  • Specs. transport.md now names the terminated set and says why it tracks the renderer; terminal-state.md says "unterminated string control" generically and needs no change. terminal-escapes.md stays out of scope — it registers what's parsed or answered at the PTY data boundary, not content-level stripping downstream of it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants