From 8fa67eba1a26a6722cb7e669a7728f3044e94199 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sun, 26 Apr 2026 20:00:58 +0000 Subject: [PATCH 01/14] feat: add run completion event plumbing --- src/export/asciicast.ts | 81 ++++++++++++-------- src/host/eventLog.ts | 13 ++++ src/protocol/schemas.ts | 20 +++++ src/renderer/ghosttyWeb/backend.ts | 2 + src/renderer/libghosttyVt/backend.ts | 1 + src/renderer/types.ts | 11 +++ test/unit/export/asciicast.test.ts | 37 +++++++++ test/unit/host/eventLog.test.ts | 55 +++++++++++++ test/unit/host/replay.test.ts | 48 ++++++++++++ test/unit/protocol/messages.test.ts | 48 ++++++++++++ test/unit/renderer/ghosttyWebBackend.test.ts | 63 +++++++++++++++ test/unit/renderer/types.test.ts | 28 +++++-- 12 files changed, 367 insertions(+), 40 deletions(-) diff --git a/src/export/asciicast.ts b/src/export/asciicast.ts index 3ca1e05..7d918cf 100644 --- a/src/export/asciicast.ts +++ b/src/export/asciicast.ts @@ -1,7 +1,7 @@ import type { EventRecord, SessionRecord } from '../protocol/schemas.js'; import { DEFAULT_TERM } from '../config/defaults.js'; -import { invariant } from '../util/assert.js'; +import { invariant, unreachable } from '../util/assert.js'; export interface AsciicastHeader { version: 2; @@ -117,39 +117,52 @@ export function generateAsciicast( ); previousTimestampMs = eventTimestampMs; - if (event.type === 'output') { - outputEventCount += 1; - lines.push( - JSON.stringify([ - relativeSeconds(eventTimestampMs, firstTimestampMs), - 'o', - event.payload.data, - ]), - ); - continue; - } - - if (event.type === 'resize') { - resizeEventCount += 1; - lines.push( - JSON.stringify([ - relativeSeconds(eventTimestampMs, firstTimestampMs), - 'r', - `${String(event.payload.cols)}x${String(event.payload.rows)}`, - ]), - ); - continue; - } - - if (event.type === 'marker') { - markerCount += 1; - lines.push( - JSON.stringify([ - relativeSeconds(eventTimestampMs, firstTimestampMs), - 'm', - event.payload.label, - ]), - ); + switch (event.type) { + case 'output': { + outputEventCount += 1; + lines.push( + JSON.stringify([ + relativeSeconds(eventTimestampMs, firstTimestampMs), + 'o', + event.payload.data, + ]), + ); + break; + } + case 'resize': { + resizeEventCount += 1; + lines.push( + JSON.stringify([ + relativeSeconds(eventTimestampMs, firstTimestampMs), + 'r', + `${String(event.payload.cols)}x${String(event.payload.rows)}`, + ]), + ); + break; + } + case 'marker': { + markerCount += 1; + lines.push( + JSON.stringify([ + relativeSeconds(eventTimestampMs, firstTimestampMs), + 'm', + event.payload.label, + ]), + ); + break; + } + case 'input_text': + case 'input_paste': + case 'input_keys': + case 'input_run': + case 'run_complete': + case 'signal': + case 'exit': { + break; + } + default: { + unreachable(event, 'unsupported asciicast event type'); + } } } diff --git a/src/host/eventLog.ts b/src/host/eventLog.ts index 01a8133..e6bf13f 100644 --- a/src/host/eventLog.ts +++ b/src/host/eventLog.ts @@ -9,9 +9,11 @@ import { EventRecordSchema, InputRunEventPayloadSchema, MarkerEventPayloadSchema, + RunCompleteEventPayloadSchema, type EventRecord, type InputRunEventPayload, type MarkerEventPayload, + type RunCompleteEventPayload, } from '../protocol/schemas.js'; import { invariant } from '../util/assert.js'; @@ -79,6 +81,7 @@ type EventLogEventType = | 'input_paste' | 'input_keys' | 'input_run' + | 'run_complete' | 'resize' | 'signal' | 'exit' @@ -89,6 +92,7 @@ type EventLogPayload = | InputPasteEventPayload | InputKeysEventPayload | InputRunEventPayload + | RunCompleteEventPayload | ResizeEventPayload | SignalEventPayload | ExitEventPayload @@ -137,6 +141,11 @@ function validatePayload( invariant(result.success, 'input_run payload must match schema'); return result.data; } + case 'run_complete': { + const result = RunCompleteEventPayloadSchema.safeParse(payload); + invariant(result.success, 'run_complete payload must match schema'); + return result.data; + } case 'resize': { const result = ResizeEventPayloadSchema.safeParse(payload); invariant(result.success, 'resize payload must match schema'); @@ -320,6 +329,10 @@ export class EventLog { type: 'input_run', payload: InputRunEventPayload, ): Promise; + async append( + type: 'run_complete', + payload: RunCompleteEventPayload, + ): Promise; async append(type: 'resize', payload: ResizeEventPayload): Promise; async append(type: 'signal', payload: SignalEventPayload): Promise; async append(type: 'exit', payload: ExitEventPayload): Promise; diff --git a/src/protocol/schemas.ts b/src/protocol/schemas.ts index e2d3244..0f47f30 100644 --- a/src/protocol/schemas.ts +++ b/src/protocol/schemas.ts @@ -128,6 +128,16 @@ export const InputRunEventPayloadSchema = z }); export type InputRunEventPayload = z.infer; +export const RunCompleteEventPayloadSchema = z + .object({ + marker: z.string(), + inputRunSeq: NonNegativeIntSchema.optional(), + }) + .strict(); +export type RunCompleteEventPayload = z.infer< + typeof RunCompleteEventPayloadSchema +>; + export const ResizeEventPayloadSchema = z .object({ cols: PositiveIntSchema, @@ -166,6 +176,7 @@ export const EventTypeSchema = z.enum([ 'input_paste', 'input_keys', 'input_run', + 'run_complete', 'resize', 'signal', 'exit', @@ -218,6 +229,14 @@ export const InputRunEventRecordSchema = z }) .strict(); +export const RunCompleteEventRecordSchema = z + .object({ + ...EventRecordBaseShape, + type: z.literal('run_complete'), + payload: RunCompleteEventPayloadSchema, + }) + .strict(); + export const ResizeEventRecordSchema = z .object({ ...EventRecordBaseShape, @@ -256,6 +275,7 @@ export const EventRecordSchema = z.discriminatedUnion('type', [ InputPasteEventRecordSchema, InputKeysEventRecordSchema, InputRunEventRecordSchema, + RunCompleteEventRecordSchema, ResizeEventRecordSchema, SignalEventRecordSchema, ExitEventRecordSchema, diff --git a/src/renderer/ghosttyWeb/backend.ts b/src/renderer/ghosttyWeb/backend.ts index 01a3c3d..d667582 100644 --- a/src/renderer/ghosttyWeb/backend.ts +++ b/src/renderer/ghosttyWeb/backend.ts @@ -1464,6 +1464,7 @@ export class GhosttyWebBackend implements VideoCapableRendererBackend { case 'input_paste': case 'input_keys': case 'input_run': + case 'run_complete': case 'signal': case 'exit': { await flushOutputBatch(); @@ -1722,6 +1723,7 @@ export class GhosttyWebBackend implements VideoCapableRendererBackend { case 'input_paste': case 'input_keys': case 'input_run': + case 'run_complete': case 'signal': case 'exit': { await flushOutputBatch(); diff --git a/src/renderer/libghosttyVt/backend.ts b/src/renderer/libghosttyVt/backend.ts index 6a065b0..8c734b1 100644 --- a/src/renderer/libghosttyVt/backend.ts +++ b/src/renderer/libghosttyVt/backend.ts @@ -464,6 +464,7 @@ export class LibghosttyVtBackend implements RendererBackend { case 'input_paste': case 'input_keys': case 'input_run': + case 'run_complete': case 'signal': case 'exit': break; diff --git a/src/renderer/types.ts b/src/renderer/types.ts index 989e9ad..421a2ef 100644 --- a/src/renderer/types.ts +++ b/src/renderer/types.ts @@ -3,6 +3,7 @@ import { z } from 'zod'; import { MarkerEventPayloadSchema, RichSnapshotLineSchema, + RunCompleteEventPayloadSchema, VisibleLineSchema, type VisibleLine, } from '../protocol/schemas.js'; @@ -95,6 +96,15 @@ const InputRunReplayEventSchema = z }) .strict(); +const RunCompleteReplayEventSchema = z + .object({ + seq: NonNegativeIntSchema, + ts: z.iso.datetime(), + type: z.literal('run_complete'), + payload: RunCompleteEventPayloadSchema, + }) + .strict(); + const ResizeReplayEventSchema = z .object({ seq: NonNegativeIntSchema, @@ -151,6 +161,7 @@ export const ReplayEventSchema = z.discriminatedUnion('type', [ InputPasteReplayEventSchema, InputKeysReplayEventSchema, InputRunReplayEventSchema, + RunCompleteReplayEventSchema, ResizeReplayEventSchema, MarkerReplayEventSchema, SignalReplayEventSchema, diff --git a/test/unit/export/asciicast.test.ts b/test/unit/export/asciicast.test.ts index 756a341..86ddd44 100644 --- a/test/unit/export/asciicast.test.ts +++ b/test/unit/export/asciicast.test.ts @@ -170,6 +170,43 @@ describe('generateAsciicast', () => { expect(result.durationMs).toBe(2000); }); + it('skips run_complete events without emitting asciicast frames', () => { + const manifest = createManifest(); + const events: EventRecord[] = [ + { + seq: 0, + ts: '2026-03-19T12:00:01.000Z', + type: 'output', + payload: { data: 'before' }, + }, + { + seq: 1, + ts: '2026-03-19T12:00:01.500Z', + type: 'run_complete', + payload: { marker: '__AT_MARKER_done__', inputRunSeq: 0 }, + }, + { + seq: 2, + ts: '2026-03-19T12:00:02.000Z', + type: 'output', + payload: { data: 'after' }, + }, + ]; + + const result = generateAsciicast('session-01', manifest, events); + + expect(parseAsciicastLines(result.contents)).toEqual([ + result.header, + [0, 'o', 'before'], + [1, 'o', 'after'], + ]); + expect(result.outputEventCount).toBe(2); + expect(result.resizeEventCount).toBe(0); + expect(result.markerCount).toBe(0); + expect(result.capturedAtSeq).toBe(2); + expect(result.durationMs).toBe(1000); + }); + it('produces a header-only cast for empty event logs', () => { const manifest = createManifest({ createdAt: '2026-03-19T12:34:56.000Z', diff --git a/test/unit/host/eventLog.test.ts b/test/unit/host/eventLog.test.ts index fd4a440..3c127e8 100644 --- a/test/unit/host/eventLog.test.ts +++ b/test/unit/host/eventLog.test.ts @@ -119,6 +119,61 @@ describe('EventLog', () => { } }); + it('appends run_complete events and round-trips them from JSONL', async () => { + const eventLog = await EventLog.open(eventLogPath); + + try { + const firstSeq = await eventLog.append('run_complete', { + marker: '__AT_MARKER_first__', + }); + const secondSeq = await eventLog.append('run_complete', { + marker: '__AT_MARKER_second__', + inputRunSeq: 12, + }); + + expect([firstSeq, secondSeq]).toEqual([0, 1]); + expect(await eventLog.readAll()).toEqual([ + expect.objectContaining({ + seq: 0, + type: 'run_complete', + payload: { marker: '__AT_MARKER_first__' }, + }), + expect.objectContaining({ + seq: 1, + type: 'run_complete', + payload: { marker: '__AT_MARKER_second__', inputRunSeq: 12 }, + }), + ]); + } finally { + await eventLog.close(); + } + + const reopenedEventLog = await EventLog.open(eventLogPath); + + try { + expect(reopenedEventLog.getEvents()).toEqual([ + expect.objectContaining({ + seq: 0, + type: 'run_complete', + payload: { marker: '__AT_MARKER_first__' }, + }), + expect.objectContaining({ + seq: 1, + type: 'run_complete', + payload: { marker: '__AT_MARKER_second__', inputRunSeq: 12 }, + }), + ]); + + const logLines = (await readFile(eventLogPath, 'utf8')) + .trim() + .split('\n') + .map((line) => JSON.parse(line) as unknown); + expect(logLines).toEqual(reopenedEventLog.getEvents()); + } finally { + await reopenedEventLog.close(); + } + }); + it('returns buffered events without rereading the log file', async () => { const eventLog = await EventLog.open(eventLogPath); diff --git a/test/unit/host/replay.test.ts b/test/unit/host/replay.test.ts index 26432a6..65d4aa4 100644 --- a/test/unit/host/replay.test.ts +++ b/test/unit/host/replay.test.ts @@ -221,6 +221,54 @@ describe('replay helpers', () => { ); }); + it('readEventLogRecords parses legacy JSONL logs without run_complete events', async () => { + const legacyEvents: EventRecord[] = [ + { + seq: 0, + ts: '2026-03-19T12:00:02.000Z', + type: 'output', + payload: { data: 'legacy output' }, + }, + { + seq: 1, + ts: '2026-03-19T12:00:03.000Z', + type: 'input_run', + payload: { + command: 'echo done', + marker: '__AT_MARKER_legacy__', + noWait: false, + }, + }, + { + seq: 2, + ts: '2026-03-19T12:00:04.000Z', + type: 'exit', + payload: { exitCode: 0, exitSignal: null }, + }, + ]; + await writeFile( + eventLogPath, + legacyEvents + .map((event) => JSON.stringify(event)) + .concat('') + .join('\n'), + 'utf8', + ); + + await expect(readEventLogRecords(eventLogPath)).resolves.toEqual( + legacyEvents, + ); + expect( + buildReplayInput('session-01', createManifest(), legacyEvents), + ).toEqual({ + sessionId: 'session-01', + initialCols: 80, + initialRows: 24, + events: legacyEvents, + targetSeq: 2, + }); + }); + it('readEventLogRecords parses and validates JSONL event logs', async () => { await writeFile( eventLogPath, diff --git a/test/unit/protocol/messages.test.ts b/test/unit/protocol/messages.test.ts index 07b1a55..beb6048 100644 --- a/test/unit/protocol/messages.test.ts +++ b/test/unit/protocol/messages.test.ts @@ -33,6 +33,8 @@ import { import { EventRecordSchema, MarkerEventRecordSchema, + RunCompleteEventPayloadSchema, + RunCompleteEventRecordSchema, SessionRecordSchema, } from '../../../src/protocol/schemas.js'; @@ -142,6 +144,52 @@ describe('protocol schemas', () => { payload: { label: 'Step 1' }, }); }); + + it('strictly validates run_complete event payloads and records', () => { + expect( + RunCompleteEventPayloadSchema.parse({ + marker: '__AT_MARKER_123__', + inputRunSeq: 7, + }), + ).toEqual({ + marker: '__AT_MARKER_123__', + inputRunSeq: 7, + }); + expect( + RunCompleteEventPayloadSchema.parse({ marker: '__AT_MARKER_456__' }), + ).toEqual({ marker: '__AT_MARKER_456__' }); + expect( + RunCompleteEventRecordSchema.parse({ + seq: 2, + ts: '2026-03-19T12:00:04.000Z', + type: 'run_complete', + payload: { marker: '__AT_MARKER_789__', inputRunSeq: 1 }, + }), + ).toEqual({ + seq: 2, + ts: '2026-03-19T12:00:04.000Z', + type: 'run_complete', + payload: { marker: '__AT_MARKER_789__', inputRunSeq: 1 }, + }); + + expect( + RunCompleteEventPayloadSchema.safeParse({ + marker: '__AT_MARKER_extra__', + extra: true, + }).success, + ).toBe(false); + expect( + RunCompleteEventPayloadSchema.safeParse({ + marker: '__AT_MARKER_bad_seq__', + inputRunSeq: -1, + }).success, + ).toBe(false); + expect( + RunCompleteEventPayloadSchema.safeParse({ + marker: 123, + }).success, + ).toBe(false); + }); }); describe('CapabilityEntrySchema', () => { diff --git a/test/unit/renderer/ghosttyWebBackend.test.ts b/test/unit/renderer/ghosttyWebBackend.test.ts index 3163b1d..5f518a9 100644 --- a/test/unit/renderer/ghosttyWebBackend.test.ts +++ b/test/unit/renderer/ghosttyWebBackend.test.ts @@ -5,6 +5,7 @@ import { join } from 'node:path'; import { describe, expect, it, vi } from 'vitest'; +import type { ReplayInput } from '../../../src/renderer/types.js'; import { BUNDLED_FONT_ASSETS } from '../../../src/renderer/bundledFont.js'; import { hashProfile, resolveProfile } from '../../../src/renderer/profiles.js'; import { GhosttyWebBackend } from '../../../src/renderer/ghosttyWeb/index.js'; @@ -70,6 +71,68 @@ describe('GhosttyWebBackend unit guards', () => { expect(recordedChunks).toEqual(chunks); }); + it('flushes and skips run_complete events during replay', async () => { + const backend = createBackend(); + const flushedChunks: string[][] = []; + const resizeBridge = vi.fn(() => Promise.resolve()); + const page = { isClosed: () => false }; + + Object.assign(backend as object, { + isBooted: true, + page, + resizeBridge, + flushOutputBatch: (_page: object, dataChunks: string[]) => { + flushedChunks.push([...dataChunks]); + return Promise.resolve(); + }, + readHarnessSnapshot: () => + Promise.resolve( + createHarnessSnapshotPayload({ + cols: 80, + rows: 24, + cursorRow: 0, + cursorCol: 0, + visibleLines: [{ row: 0, text: 'after' }], + }), + ), + }); + + const input: ReplayInput = { + sessionId: 'renderer-unit-session', + initialCols: 80, + initialRows: 24, + targetSeq: 2, + events: [ + { + seq: 0, + ts: '2026-03-19T12:00:00.000Z', + type: 'output', + payload: { data: 'before' }, + }, + { + seq: 1, + ts: '2026-03-19T12:00:01.000Z', + type: 'run_complete', + payload: { marker: '__AT_MARKER_done__', inputRunSeq: 0 }, + }, + { + seq: 2, + ts: '2026-03-19T12:00:02.000Z', + type: 'output', + payload: { data: 'after' }, + }, + ], + }; + + await expect(backend.replayTo(input)).resolves.toMatchObject({ + lastSeq: 2, + cols: 80, + rows: 24, + }); + expect(resizeBridge).toHaveBeenCalledWith(page, 80, 24); + expect(flushedChunks).toEqual([['before'], ['after']]); + }); + it('rejects oversized bridge batches before page evaluation', async () => { const backend = createBackend(); const evaluate = vi.fn(); diff --git a/test/unit/renderer/types.test.ts b/test/unit/renderer/types.test.ts index 7d0962e..d6debd2 100644 --- a/test/unit/renderer/types.test.ts +++ b/test/unit/renderer/types.test.ts @@ -68,24 +68,40 @@ describe('renderer schemas', () => { { seq: 4, ts: '2026-03-19T12:00:04.000Z', - type: 'resize', - payload: { cols: 120, rows: 40 }, + type: 'input_run', + payload: { + command: 'echo ready', + marker: '__AT_MARKER_ready__', + noWait: false, + }, }, { seq: 5, ts: '2026-03-19T12:00:05.000Z', - type: 'marker', - payload: { label: '' }, + type: 'run_complete', + payload: { marker: '__AT_MARKER_ready__', inputRunSeq: 4 }, }, { seq: 6, ts: '2026-03-19T12:00:06.000Z', - type: 'signal', - payload: { signal: 'SIGINT' }, + type: 'resize', + payload: { cols: 120, rows: 40 }, }, { seq: 7, ts: '2026-03-19T12:00:07.000Z', + type: 'marker', + payload: { label: '' }, + }, + { + seq: 8, + ts: '2026-03-19T12:00:08.000Z', + type: 'signal', + payload: { signal: 'SIGINT' }, + }, + { + seq: 9, + ts: '2026-03-19T12:00:09.000Z', type: 'exit', payload: { exitCode: 0, exitSignal: null }, }, From 5f23d3866dc777e8cd010f2d55d57a777a4fe116 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sun, 26 Apr 2026 20:01:48 +0000 Subject: [PATCH 02/14] feat: add run completion sentinel scanner --- src/host/runCompletionSentinel.ts | 253 ++++++++++++++++ test/unit/host/runCompletionSentinel.test.ts | 286 +++++++++++++++++++ 2 files changed, 539 insertions(+) create mode 100644 src/host/runCompletionSentinel.ts create mode 100644 test/unit/host/runCompletionSentinel.test.ts diff --git a/src/host/runCompletionSentinel.ts b/src/host/runCompletionSentinel.ts new file mode 100644 index 0000000..df9740a --- /dev/null +++ b/src/host/runCompletionSentinel.ts @@ -0,0 +1,253 @@ +/** + * Run-completion sentinels are framed as APC control strings: + * ESC _ agent-tty:run-complete: ESC \ + * + * APC gives agent-tty a private ESC-based control string whose bytes are easy to + * recognize before PTY output reaches the event log, while the ST terminator + * (ESC backslash) makes the frame boundary explicit. Phase 3 will verify the + * live ghostty-web renderer behavior; this scanner does not rely on renderer + * filtering and removes only exact active frames. + * + * The scanner walks input left-to-right. If multiple active sentinels could + * match at the same byte offset, the longest complete frame wins; if that + * complete frame is a strict prefix of a longer active frame and more bytes + * could still arrive, the scanner waits for the next chunk. Equal frames are + * collapsed by idempotent marker registration. + */ + +import { invariant } from '../util/assert.js'; + +export const RUN_COMPLETE_SENTINEL_PREFIX = '\x1b_agent-tty:run-complete:'; +export const RUN_COMPLETE_SENTINEL_SUFFIX = '\x1b\\'; + +export type SentinelPiece = + | { type: 'output'; data: string } + | { type: 'run_complete'; marker: string }; + +interface ActiveSentinel { + marker: string; + sentinel: string; + order: number; +} + +function assertNonEmptyString(value: string, label: string): void { + invariant( + typeof value === 'string' && value.length > 0, + `${label} must be a non-empty string`, + ); +} + +function pushOutputPiece(pieces: SentinelPiece[], data: string): void { + if (data.length > 0) { + pieces.push({ type: 'output', data }); + } +} + +export function buildRunCompleteSentinel(marker: string): string { + assertNonEmptyString(marker, 'marker'); + + return `${RUN_COMPLETE_SENTINEL_PREFIX}${marker}${RUN_COMPLETE_SENTINEL_SUFFIX}`; +} + +export class RunCompletionSentinelScanner { + readonly #activeSentinels = new Map(); + #nextOrder = 0; + #pendingTail = ''; + + /** + * Registers a marker as active. Re-registering an already-active marker is a + * no-op; after the marker completes and deactivates, a later register() call + * activates it again for a future run. + */ + public register(marker: string): void { + assertNonEmptyString(marker, 'marker'); + + if (this.#activeSentinels.has(marker)) { + return; + } + + this.#activeSentinels.set(marker, { + marker, + sentinel: buildRunCompleteSentinel(marker), + order: this.#nextOrder, + }); + this.#nextOrder += 1; + this.#assertPendingTailBound(); + } + + public feed(chunk: string): SentinelPiece[] { + invariant(typeof chunk === 'string', 'chunk must be a string'); + + if (chunk.length === 0) { + return []; + } + + if (!this.hasActiveMarkers()) { + invariant( + this.#pendingTail.length === 0, + 'pending tail must be empty when no run-completion markers are active', + ); + return [{ type: 'output', data: chunk }]; + } + + const buffer = `${this.#pendingTail}${chunk}`; + this.#pendingTail = ''; + return this.#scanBuffer(buffer, false); + } + + public flush(): SentinelPiece[] { + if (this.#pendingTail.length === 0) { + return []; + } + + const buffer = this.#pendingTail; + this.#pendingTail = ''; + return this.#scanBuffer(buffer, true); + } + + public hasActiveMarkers(): boolean { + return this.#activeSentinels.size > 0; + } + + #scanBuffer(buffer: string, isFinal: boolean): SentinelPiece[] { + if (buffer.length === 0) { + return []; + } + + if (!this.hasActiveMarkers()) { + invariant( + this.#pendingTail.length === 0, + 'pending tail must stay empty when no run-completion markers are active', + ); + return [{ type: 'output', data: buffer }]; + } + + const pieces: SentinelPiece[] = []; + let outputStart = 0; + let index = 0; + + while (index < buffer.length) { + if (!this.hasActiveMarkers()) { + pushOutputPiece(pieces, buffer.slice(outputStart)); + outputStart = buffer.length; + break; + } + + const candidates = this.#sortedActiveSentinels(); + const completeMatches = candidates.filter(({ sentinel }) => + buffer.startsWith(sentinel, index), + ); + + if (completeMatches.length > 0) { + const matched = completeMatches[0]; + invariant(matched !== undefined, 'complete match must exist'); + + if ( + !isFinal && + this.#hasLongerPossibleMatch(candidates, matched, buffer, index) + ) { + pushOutputPiece(pieces, buffer.slice(outputStart, index)); + this.#setPendingTail(buffer.slice(index)); + return pieces; + } + + pushOutputPiece(pieces, buffer.slice(outputStart, index)); + pieces.push({ type: 'run_complete', marker: matched.marker }); + + const deleted = this.#activeSentinels.delete(matched.marker); + invariant(deleted, 'matched run-completion marker must be active'); + + index += matched.sentinel.length; + outputStart = index; + continue; + } + + const remaining = buffer.slice(index); + const hasPartialMatch = + !isFinal && + candidates.some( + ({ sentinel }) => + remaining.length < sentinel.length && + sentinel.startsWith(remaining), + ); + + if (hasPartialMatch) { + pushOutputPiece(pieces, buffer.slice(outputStart, index)); + this.#setPendingTail(remaining); + return pieces; + } + + index += 1; + } + + pushOutputPiece(pieces, buffer.slice(outputStart)); + this.#assertPendingTailBound(); + return pieces; + } + + #hasLongerPossibleMatch( + candidates: ActiveSentinel[], + matched: ActiveSentinel, + buffer: string, + index: number, + ): boolean { + const remaining = buffer.slice(index); + + return candidates.some( + ({ sentinel }) => + sentinel.length > matched.sentinel.length && + remaining.length < sentinel.length && + sentinel.startsWith(remaining), + ); + } + + #sortedActiveSentinels(): ActiveSentinel[] { + return [...this.#activeSentinels.values()].sort((left, right) => { + const lengthDiff = right.sentinel.length - left.sentinel.length; + if (lengthDiff !== 0) { + return lengthDiff; + } + return left.order - right.order; + }); + } + + #setPendingTail(tail: string): void { + invariant(tail.length > 0, 'pending tail must not be empty'); + invariant( + this.hasActiveMarkers(), + 'pending tail requires active run-completion markers', + ); + + this.#pendingTail = tail; + this.#assertPendingTailBound(); + } + + #maxActiveSentinelLength(): number { + let maxLength = 0; + + for (const { sentinel } of this.#activeSentinels.values()) { + maxLength = Math.max(maxLength, sentinel.length); + } + + invariant( + maxLength > 0, + 'max active sentinel length requires active run-completion markers', + ); + return maxLength; + } + + #assertPendingTailBound(): void { + if (!this.hasActiveMarkers()) { + invariant( + this.#pendingTail.length === 0, + 'pending tail must be empty without active run-completion markers', + ); + return; + } + + invariant( + this.#pendingTail.length < this.#maxActiveSentinelLength(), + 'pending tail must be shorter than the longest active sentinel', + ); + } +} diff --git a/test/unit/host/runCompletionSentinel.test.ts b/test/unit/host/runCompletionSentinel.test.ts new file mode 100644 index 0000000..cb3ea49 --- /dev/null +++ b/test/unit/host/runCompletionSentinel.test.ts @@ -0,0 +1,286 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildRunCompleteSentinel, + RUN_COMPLETE_SENTINEL_PREFIX, + RUN_COMPLETE_SENTINEL_SUFFIX, + RunCompletionSentinelScanner, +} from '../../../src/host/runCompletionSentinel.js'; +import type { SentinelPiece } from '../../../src/host/runCompletionSentinel.js'; + +function feedChunks( + scanner: RunCompletionSentinelScanner, + chunks: string[], +): SentinelPiece[] { + return chunks.flatMap((chunk) => scanner.feed(chunk)); +} + +function outputData(pieces: SentinelPiece[]): string { + return pieces + .filter( + (piece): piece is Extract => + piece.type === 'output', + ) + .map((piece) => piece.data) + .join(''); +} + +function oneCodeUnitChunks(data: string): string[] { + const chunks: string[] = []; + + for (let index = 0; index < data.length; index += 1) { + chunks.push(data.charAt(index)); + } + + return chunks; +} + +describe('buildRunCompleteSentinel', () => { + it('returns the expected APC-framed sentinel bytes', () => { + expect(buildRunCompleteSentinel('__AT_MARKER_123')).toBe( + '\x1b_agent-tty:run-complete:__AT_MARKER_123\x1b\\', + ); + expect(RUN_COMPLETE_SENTINEL_PREFIX).toBe('\x1b_agent-tty:run-complete:'); + expect(RUN_COMPLETE_SENTINEL_SUFFIX).toBe('\x1b\\'); + }); + + it('rejects empty markers', () => { + expect(() => buildRunCompleteSentinel('')).toThrow( + 'marker must be a non-empty string', + ); + }); +}); + +describe('RunCompletionSentinelScanner', () => { + it('matches a sentinel fully contained in one chunk with output around it', () => { + const scanner = new RunCompletionSentinelScanner(); + const marker = '__AT_MARKER_one_chunk'; + scanner.register(marker); + + expect( + scanner.feed(`before${buildRunCompleteSentinel(marker)}after`), + ).toEqual([ + { type: 'output', data: 'before' }, + { type: 'run_complete', marker }, + { type: 'output', data: 'after' }, + ]); + expect(scanner.hasActiveMarkers()).toBe(false); + expect(scanner.flush()).toEqual([]); + }); + + it.each([ + ['inside prefix', 1], + [ + 'inside marker payload', + RUN_COMPLETE_SENTINEL_PREFIX.length + '__AT_MARKER_split'.length - 3, + ], + [ + 'inside suffix', + RUN_COMPLETE_SENTINEL_PREFIX.length + '__AT_MARKER_split'.length + 1, + ], + ])( + 'matches a sentinel split across two chunks with boundary %s', + (_, split) => { + const scanner = new RunCompletionSentinelScanner(); + const marker = '__AT_MARKER_split'; + const sentinel = buildRunCompleteSentinel(marker); + scanner.register(marker); + + expect(scanner.feed(sentinel.slice(0, split))).toEqual([]); + expect(scanner.feed(sentinel.slice(split))).toEqual([ + { type: 'run_complete', marker }, + ]); + expect(scanner.hasActiveMarkers()).toBe(false); + }, + ); + + it('matches a sentinel split one byte at a time across the full frame', () => { + const scanner = new RunCompletionSentinelScanner(); + const marker = '__AT_MARKER_bytewise'; + scanner.register(marker); + + const pieces = [ + ...scanner.feed('before-'), + ...feedChunks( + scanner, + oneCodeUnitChunks(buildRunCompleteSentinel(marker)), + ), + ...scanner.feed('-after'), + ]; + + expect(pieces).toEqual([ + { type: 'output', data: 'before-' }, + { type: 'run_complete', marker }, + { type: 'output', data: '-after' }, + ]); + expect(scanner.hasActiveMarkers()).toBe(false); + }); + + it('completes multiple active markers in input order without cross-matching', () => { + const scanner = new RunCompletionSentinelScanner(); + const firstMarker = '__AT_MARKER_A'; + const secondMarker = '__AT_MARKER_AB'; + scanner.register(firstMarker); + scanner.register(secondMarker); + + expect( + scanner.feed( + [ + 'start', + buildRunCompleteSentinel(secondMarker), + 'middle', + buildRunCompleteSentinel(firstMarker), + 'end', + ].join(''), + ), + ).toEqual([ + { type: 'output', data: 'start' }, + { type: 'run_complete', marker: secondMarker }, + { type: 'output', data: 'middle' }, + { type: 'run_complete', marker: firstMarker }, + { type: 'output', data: 'end' }, + ]); + expect(scanner.hasActiveMarkers()).toBe(false); + }); + + it('keeps inactive or unknown sentinel-like bytes in output', () => { + const scanner = new RunCompletionSentinelScanner(); + const marker = '__AT_MARKER_known'; + const unknownSentinel = buildRunCompleteSentinel('__AT_MARKER_unknown'); + const strayApc = '\x1b_random text that is not an active sentinel'; + scanner.register(marker); + + const pieces = scanner.feed( + `pre${unknownSentinel}mid${strayApc}${buildRunCompleteSentinel( + marker, + )}post`, + ); + + expect(pieces).toEqual([ + { type: 'output', data: `pre${unknownSentinel}mid${strayApc}` }, + { type: 'run_complete', marker }, + { type: 'output', data: 'post' }, + ]); + }); + + it('passes all data through unchanged when no markers are active', () => { + const scanner = new RunCompletionSentinelScanner(); + const data = [ + 'before', + buildRunCompleteSentinel('__AT_MARKER_inactive'), + '\x1b_random', + 'after', + ].join(''); + + expect(scanner.feed(data)).toEqual([{ type: 'output', data }]); + expect(scanner.flush()).toEqual([]); + }); + + it('does not leak active sentinel bytes into output pieces', () => { + const scanner = new RunCompletionSentinelScanner(); + const marker = '__AT_MARKER_secret'; + const sentinel = buildRunCompleteSentinel(marker); + scanner.register(marker); + + const pieces = feedChunks(scanner, [ + 'visible-before', + sentinel.slice(0, RUN_COMPLETE_SENTINEL_PREFIX.length + 4), + sentinel.slice(RUN_COMPLETE_SENTINEL_PREFIX.length + 4), + 'visible-after', + ]); + + expect(pieces).toEqual([ + { type: 'output', data: 'visible-before' }, + { type: 'run_complete', marker }, + { type: 'output', data: 'visible-after' }, + ]); + + for (const piece of pieces) { + if (piece.type !== 'output') { + continue; + } + expect(piece.data).not.toContain(RUN_COMPLETE_SENTINEL_PREFIX); + expect(piece.data).not.toContain('agent-tty:run-complete:'); + expect(piece.data).not.toContain('__AT_MARKER_'); + expect(piece.data).not.toContain(marker); + } + }); + + it('flushes a pending non-sentinel tail once', () => { + const scanner = new RunCompletionSentinelScanner(); + const marker = '__AT_MARKER_flush'; + scanner.register(marker); + + expect( + scanner.feed(`visible${RUN_COMPLETE_SENTINEL_PREFIX.slice(0, 4)}`), + ).toEqual([{ type: 'output', data: 'visible' }]); + expect(scanner.flush()).toEqual([ + { type: 'output', data: RUN_COMPLETE_SENTINEL_PREFIX.slice(0, 4) }, + ]); + expect(scanner.flush()).toEqual([]); + }); + + it('passes the same sentinel through as output after deactivation', () => { + const scanner = new RunCompletionSentinelScanner(); + const marker = '__AT_MARKER_once'; + const sentinel = buildRunCompleteSentinel(marker); + scanner.register(marker); + + expect(scanner.feed(sentinel)).toEqual([{ type: 'run_complete', marker }]); + expect(scanner.feed(sentinel)).toEqual([ + { type: 'output', data: sentinel }, + ]); + }); + + it('waits for a longer active sentinel when one frame prefixes another', () => { + const scanner = new RunCompletionSentinelScanner(); + const shortMarker = 'prefix'; + const longMarker = `prefix${RUN_COMPLETE_SENTINEL_SUFFIX}tail`; + scanner.register(shortMarker); + scanner.register(longMarker); + + expect(scanner.feed(buildRunCompleteSentinel(shortMarker))).toEqual([]); + expect(scanner.feed(`tail${RUN_COMPLETE_SENTINEL_SUFFIX}`)).toEqual([ + { type: 'run_complete', marker: longMarker }, + ]); + expect(scanner.hasActiveMarkers()).toBe(true); + }); + + it('emits a shorter complete sentinel on flush if no longer frame arrives', () => { + const scanner = new RunCompletionSentinelScanner(); + const shortMarker = 'prefix'; + const longMarker = `prefix${RUN_COMPLETE_SENTINEL_SUFFIX}tail`; + scanner.register(shortMarker); + scanner.register(longMarker); + + expect(scanner.feed(buildRunCompleteSentinel(shortMarker))).toEqual([]); + expect(scanner.flush()).toEqual([ + { type: 'run_complete', marker: shortMarker }, + ]); + expect(scanner.hasActiveMarkers()).toBe(true); + }); + + it('reports whether active markers remain', () => { + const scanner = new RunCompletionSentinelScanner(); + const marker = '__AT_MARKER_active'; + + expect(scanner.hasActiveMarkers()).toBe(false); + scanner.register(marker); + scanner.register(marker); + expect(scanner.hasActiveMarkers()).toBe(true); + expect(scanner.feed(buildRunCompleteSentinel(marker))).toEqual([ + { type: 'run_complete', marker }, + ]); + expect(scanner.hasActiveMarkers()).toBe(false); + }); + + it('preserves unknown output data when an active marker remains registered', () => { + const scanner = new RunCompletionSentinelScanner(); + const marker = '__AT_MARKER_registered'; + const unknownData = `${RUN_COMPLETE_SENTINEL_PREFIX}not-${marker}`; + scanner.register(marker); + + expect(outputData(scanner.feed(unknownData))).toBe(unknownData); + expect(scanner.hasActiveMarkers()).toBe(true); + }); +}); From 15868fa55fc4ff9ec6e09f53a43bdc0f83959cd8 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sun, 26 Apr 2026 20:35:12 +0000 Subject: [PATCH 03/14] feat: wait for structured run completion sentinels --- src/host/hostMain.ts | 427 ++++++++++++++++++++++++----------- test/integration/run.test.ts | 178 +++++++++++++-- 2 files changed, 463 insertions(+), 142 deletions(-) diff --git a/src/host/hostMain.ts b/src/host/hostMain.ts index e75ffdb..7173f68 100644 --- a/src/host/hostMain.ts +++ b/src/host/hostMain.ts @@ -9,6 +9,11 @@ import { EventLog } from './eventLog.js'; import { buildReplayInput } from './replay.js'; import { HostRendererManager } from './renderer.js'; import { RpcServer, type MethodHandler } from './rpcServer.js'; +import { + buildRunCompleteSentinel, + RunCompletionSentinelScanner, + type SentinelPiece, +} from './runCompletionSentinel.js'; import { SessionState } from './sessionState.js'; import { createPty } from '../pty/createPty.js'; import { encodeKey } from '../pty/keyEncoder.js'; @@ -83,6 +88,26 @@ type WaitOutcome = { timedOut: boolean; }; +interface ActiveRunCompletion { + inputRunSeq?: number; + sentinel: string; +} + +type RunCompletionWaitResult = + | { kind: 'completed'; seq: number } + | { kind: 'exited' }; + +interface RunCompletionWaiter { + reject: (error: unknown) => void; + resolve: (result: RunCompletionWaitResult) => void; +} + +type TimedRunCompletionWaitResult = + | RunCompletionWaitResult + | { kind: 'timeout' }; + +const RUN_MARKER_PATTERN = /^__AT_MARKER_([0-9a-f]{32})__$/u; + function normalizeExitSignal(signal: number | null): string | null { invariant( signal === null || (Number.isInteger(signal) && signal >= 0), @@ -102,6 +127,57 @@ function rethrowAsync(error: unknown): void { }); } +function shellOctalEscapedBytes(value: string): string { + invariant(typeof value === 'string', 'value must be a string'); + + return [...Buffer.from(value, 'utf8')] + .map((byte) => `\\${byte.toString(8).padStart(3, '0')}`) + .join(''); +} + +function buildRunCompletePostamble(marker: string): string { + const markerMatch = RUN_MARKER_PATTERN.exec(marker); + invariant(markerMatch !== null, 'run marker must match expected format'); + + const markerPayload = markerMatch[1]; + invariant( + markerPayload !== undefined && markerPayload.length === 32, + 'run marker payload must be 32 lowercase hex characters', + ); + + const markerPayloadPart1 = markerPayload.slice(0, 16); + const markerPayloadPart2 = markerPayload.slice(16); + invariant( + markerPayloadPart1.length > 0 && markerPayloadPart2.length > 0, + 'run marker payload must split into non-empty pieces', + ); + + const sentinelPayload = `agent-tty:run-complete:__AT_MARKER_${markerPayloadPart1}${markerPayloadPart2}__`; + invariant( + `\x1b_${sentinelPayload}\x1b\\` === buildRunCompleteSentinel(marker), + 'run-completion postamble pieces must reconstruct the expected sentinel', + ); + + const hideEchoedPostambleLine = '\x1b[1A\r\x1b[2K'; + const postamble = `printf '${shellOctalEscapedBytes( + `${hideEchoedPostambleLine}${buildRunCompleteSentinel(marker)}`, + )}'`; + invariant( + !postamble.includes('agent-tty:run-complete:'), + 'run-completion postamble must not echo the complete sentinel label', + ); + invariant( + !postamble.includes('__AT_MARKER_'), + 'run-completion postamble must not echo the complete marker prefix', + ); + invariant( + !postamble.includes(markerPayload), + 'run-completion postamble must not echo the complete marker payload', + ); + + return `${postamble}\n`; +} + function resolveHostRendererName(input: string | undefined): RendererName { try { return resolveRendererName( @@ -257,9 +333,14 @@ export async function runHost(sessionId: string): Promise { createRendererBackend(rendererName, sid, profile), }); - const loadReplayInput = () => { + const loadReplayInput = (targetSeq?: number) => { const events = [...eventLog.getEvents()]; - const replayInput = buildReplayInput(sessionId, state.snapshot(), events); + const replayInput = buildReplayInput( + sessionId, + state.snapshot(), + events, + targetSeq, + ); return replayInput.targetSeq === -1 ? null : replayInput; }; @@ -275,6 +356,11 @@ export async function runHost(sessionId: string): Promise { invariant(false, 'PTY exit resolver must be initialized'); }; + const sentinelScanner = new RunCompletionSentinelScanner(); + const activeRunCompletions = new Map(); + const runCompletionWaiters = new Map(); + let ptyIngestionQueue: Promise = Promise.resolve(); + const ptyExitPromise = new Promise((resolve) => { markPtyExited = (): void => { if (ptyHasExited) { @@ -301,6 +387,165 @@ export async function runHost(sessionId: string): Promise { ); state.setChildPid(pty.pid); + const resolveRunCompletionWaiter = (marker: string, seq: number): void => { + const waiter = runCompletionWaiters.get(marker); + if (waiter === undefined) { + return; + } + + runCompletionWaiters.delete(marker); + waiter.resolve({ kind: 'completed', seq }); + }; + + const rejectRunCompletionWaiter = (marker: string, error: unknown): void => { + const waiter = runCompletionWaiters.get(marker); + if (waiter === undefined) { + return; + } + + runCompletionWaiters.delete(marker); + waiter.reject(error); + }; + + const resolveRunCompletionWaitersForExit = (): void => { + for (const [marker, waiter] of runCompletionWaiters) { + runCompletionWaiters.delete(marker); + waiter.resolve({ kind: 'exited' }); + } + }; + + const subscribeRunCompletion = ( + marker: string, + ): Promise => { + invariant( + !runCompletionWaiters.has(marker), + 'run completion waiter must be unique per marker', + ); + + return new Promise((resolve, reject) => { + runCompletionWaiters.set(marker, { reject, resolve }); + }); + }; + + const waitForRunCompletion = async ( + marker: string, + completionPromise: Promise, + timeoutMs: number, + ): Promise => { + invariant( + Number.isInteger(timeoutMs) && timeoutMs > 0, + 'timeoutMs must be positive', + ); + + return await new Promise( + (resolve, reject) => { + let resolved = false; + const timeoutHandle = setTimeout(() => { + if (resolved) { + return; + } + + resolved = true; + runCompletionWaiters.delete(marker); + resolve({ kind: 'timeout' }); + }, timeoutMs); + + void completionPromise.then( + (result) => { + if (resolved) { + return; + } + + resolved = true; + clearTimeout(timeoutHandle); + resolve(result); + }, + (error: unknown) => { + if (resolved) { + return; + } + + resolved = true; + clearTimeout(timeoutHandle); + reject(error instanceof Error ? error : new Error(String(error))); + }, + ); + }, + ); + }; + + const replayRendererThroughSeq = async (targetSeq: number): Promise => { + invariant( + Number.isInteger(targetSeq) && targetSeq >= 0, + 'targetSeq must be a non-negative integer', + ); + + const replayInput = loadReplayInput(targetSeq); + invariant(replayInput !== null, 'run-complete replay input must exist'); + + const rendererName = resolveHostRendererName(undefined); + const profile = resolveProfile(DEFAULT_RENDER_PROFILE_NAME); + const backend = await rendererManager.getBackend( + rendererName, + profile, + replayInput, + ); + const snapshot = await backend.snapshot(); + invariant( + snapshot.capturedAtSeq >= targetSeq, + 'renderer snapshot must include the run-complete event sequence', + ); + }; + + const appendSentinelPieces = async ( + pieces: SentinelPiece[], + ): Promise => { + for (const piece of pieces) { + if (piece.type === 'output') { + if (piece.data.length > 0) { + await eventLog.append('output', { data: piece.data }); + } + continue; + } + + const activeCompletion = activeRunCompletions.get(piece.marker); + invariant( + activeCompletion !== undefined, + 'run-completion sentinel must correspond to an active run marker', + ); + invariant( + activeCompletion.sentinel === buildRunCompleteSentinel(piece.marker), + 'active run-completion sentinel must match the completed marker', + ); + + try { + const seq = await eventLog.append('run_complete', { + marker: piece.marker, + ...(activeCompletion.inputRunSeq === undefined + ? {} + : { inputRunSeq: activeCompletion.inputRunSeq }), + }); + const deleted = activeRunCompletions.delete(piece.marker); + invariant( + deleted, + 'active run completion must be deleted after append succeeds', + ); + resolveRunCompletionWaiter(piece.marker, seq); + } catch (error) { + rejectRunCompletionWaiter(piece.marker, error); + throw error; + } + } + }; + + const enqueuePtyIngestion = ( + operation: () => Promise, + ): Promise => { + const queuedOperation = ptyIngestionQueue.then(operation, operation); + ptyIngestionQueue = queuedOperation.catch(() => undefined); + return queuedOperation; + }; + const clearIdleTimeout = (): void => { // Idempotent: safe to call multiple times during shutdown and PTY exit. if (idleTimeoutHandle === null) { @@ -399,6 +644,7 @@ export async function runHost(sessionId: string): Promise { void (async () => { try { await eventLog.append('exit', { exitCode, exitSignal }); + resolveRunCompletionWaitersForExit(); } finally { try { await writeManifest(mPath, state.snapshot()); @@ -684,146 +930,65 @@ export async function runHost(sessionId: string): Promise { } const shouldWait = !noWait; - let marker: string | undefined; - if (shouldWait) { - marker = `__AT_MARKER_${crypto.randomUUID().replace(/-/g, '')}__`; - } - if (shouldWait) { - invariant(marker !== undefined, 'run marker must exist when waiting'); - } - const injectedText = shouldWait - ? (() => { - const waitMarker = marker as string; - const half = Math.ceil(waitMarker.length / 2); - const markerPart1 = waitMarker.slice(0, half); - const markerPart2 = waitMarker.slice(half); - return `${command}\nprintf '%s%s\\n' '${markerPart1}' '${markerPart2}'\n`; - })() - : `${command}\n`; - pty.write(injectedText); - lastActivityAt = Date.now(); + if (!shouldWait) { + pty.write(`${command}\n`); + lastActivityAt = Date.now(); - const seq = await eventLog.append('input_run', { - command, - ...(marker === undefined ? {} : { marker }), - noWait, - }); + const seq = await eventLog.append('input_run', { + command, + noWait, + }); - if (!shouldWait) { return { accepted: true as const, seq, } satisfies RunResult; } - const effectiveTimeoutMs = timeoutMs ?? 30_000; - const startTime = Date.now(); - const rendererName = resolveHostRendererName(undefined); - const profile = resolveProfile(DEFAULT_RENDER_PROFILE_NAME); - const pollIntervalMs = 200; - const waitMarker = marker; - invariant(waitMarker !== undefined, 'run wait marker must be defined'); - let clearWaitPoll: (() => void) | null = null; - - const pollCondition = new Promise<{ - matched: boolean; - exited: boolean; - }>((resolve) => { - let pollInFlight = false; - let consecutiveFailures = 0; - - const checkInterval = setInterval(() => { - if (pollInFlight) { - return; - } - - pollInFlight = true; - void (async () => { - try { - const replayInput = loadReplayInput(); - const backend = await rendererManager.getBackend( - rendererName, - profile, - replayInput, - ); - const snapshot = await backend.snapshot(); - const visibleText = snapshot.visibleLines - .map((line) => line.text) - .join('\n'); - consecutiveFailures = 0; - - if (visibleText.includes(waitMarker)) { - clearInterval(checkInterval); - resolve({ matched: true, exited: false }); - return; - } - - if (!isSessionRunning(state)) { - clearInterval(checkInterval); - resolve({ matched: false, exited: true }); - return; - } - } catch (pollError) { - void pollError; - consecutiveFailures += 1; - - if (!isSessionRunning(state)) { - clearInterval(checkInterval); - resolve({ matched: false, exited: true }); - return; - } - - if (consecutiveFailures >= MAX_CONSECUTIVE_POLL_FAILURES) { - clearInterval(checkInterval); - resolve({ matched: false, exited: false }); - return; - } - } finally { - pollInFlight = false; - } - })(); - }, pollIntervalMs); - - clearWaitPoll = (): void => { - clearInterval(checkInterval); - }; + const marker = `__AT_MARKER_${crypto.randomUUID().replace(/-/g, '')}__`; + invariant( + RUN_MARKER_PATTERN.test(marker), + 'generated run marker must match expected format', + ); + const sentinel = buildRunCompleteSentinel(marker); + const seq = await eventLog.append('input_run', { + command, + marker, + noWait, }); - const pollResult = await new Promise<{ - matched: boolean; - exited: boolean; - }>((resolve) => { - let resolved = false; - const timeoutHandle = setTimeout(() => { - if (resolved) { - return; - } - resolved = true; - clearWaitPoll?.(); - resolve({ matched: false, exited: false }); - }, effectiveTimeoutMs); - - void pollCondition.then((result) => { - if (resolved) { - return; - } - resolved = true; - clearTimeout(timeoutHandle); - clearWaitPoll?.(); - resolve(result); - }); - }); + invariant( + !activeRunCompletions.has(marker), + 'generated run marker must be unique among active completions', + ); + activeRunCompletions.set(marker, { inputRunSeq: seq, sentinel }); + sentinelScanner.register(marker); + const completionPromise = subscribeRunCompletion(marker); + const injectedText = `${command}\n${buildRunCompletePostamble(marker)}`; + const effectiveTimeoutMs = timeoutMs ?? 30_000; + const startTime = Date.now(); + pty.write(injectedText); + lastActivityAt = Date.now(); + const waitResult = await waitForRunCompletion( + marker, + completionPromise, + effectiveTimeoutMs, + ); const durationMs = Date.now() - startTime; + if (waitResult.kind === 'completed') { + await replayRendererThroughSeq(waitResult.seq); + } + return { accepted: true as const, - completed: pollResult.matched, - timedOut: !pollResult.matched && !pollResult.exited, + completed: waitResult.kind === 'completed', + timedOut: waitResult.kind === 'timeout', seq, durationMs, - marker: waitMarker, + marker, } satisfies RunResult; }, sendKeys: async (params: unknown) => { @@ -1275,13 +1440,19 @@ export async function runHost(sessionId: string): Promise { // A session actively producing output (e.g., a running build, log tail) // is "in use" and should not be killed for inactivity. lastActivityAt = lastOutputAt; - void eventLog.append('output', { data }).catch(() => { - // Best-effort logging; shutdown should not fail on transient append errors. - }); + void enqueuePtyIngestion(async () => { + await appendSentinelPieces(sentinelScanner.feed(data)); + }).catch(rethrowAsync); }); pty.onExit(({ exitCode, signal }) => { - handlePtyExit(exitCode, signal ?? null); + void enqueuePtyIngestion(async () => { + await appendSentinelPieces(sentinelScanner.flush()); + }) + .then(() => { + handlePtyExit(exitCode, signal ?? null); + }) + .catch(rethrowAsync); }); startIdlePolling(); diff --git a/test/integration/run.test.ts b/test/integration/run.test.ts index 61d4899..c97bf5a 100644 --- a/test/integration/run.test.ts +++ b/test/integration/run.test.ts @@ -1,4 +1,9 @@ -import { mkdtempSync, realpathSync, writeFileSync } from 'node:fs'; +import { + mkdtempSync, + readFileSync, + realpathSync, + writeFileSync, +} from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -11,10 +16,70 @@ import { readEvents, runCli, sleep, + type EventRecord, type SuccessEnvelope, } from '../helpers.js'; import type { CommandErrorEnvelope } from '../../src/protocol/envelope.js'; +const RUN_MARKER_PATTERN = /^__AT_MARKER_([0-9a-f]{32})__$/u; + +function expectRunMarker(marker: string): string { + const match = RUN_MARKER_PATTERN.exec(marker); + expect(match).not.toBeNull(); + + const markerPayload = match?.[1]; + if (markerPayload === undefined) { + throw new Error('expected run marker payload to be captured'); + } + + return markerPayload; +} + +function collectOutputText(events: EventRecord[]): string { + return events + .filter((event) => event.type === 'output') + .map((event) => { + const data = event.payload.data; + if (typeof data !== 'string') { + throw new Error('output event payload data must be a string'); + } + return data; + }) + .join(''); +} + +function expectCompletionArtifactsClean(text: string, marker: string): void { + const markerPayload = expectRunMarker(marker); + const markerPayloadPart1 = markerPayload.slice(0, 16); + const markerPayloadPart2 = markerPayload.slice(16); + + expect(text).not.toContain('__AT_MARKER_'); + expect(text).not.toContain('__AT_'); + expect(text).not.toContain('MARKER_'); + expect(text).not.toContain('agent-tty:run-complete:'); + expect(text).not.toContain(markerPayload); + expect(text).not.toContain(markerPayloadPart1); + expect(text).not.toContain(markerPayloadPart2); + expect(text).not.toContain('\x1b_agent-tty'); + expect(text).not.toContain(`\x1b_agent-tty:run-complete:${marker}\x1b\\`); +} + +function collectAsciicastOutputFrameText(contents: string): string { + return contents + .trim() + .split('\n') + .slice(1) + .map((line) => JSON.parse(line) as unknown) + .filter( + (frame): frame is [number, 'o', string] => + Array.isArray(frame) && + frame[1] === 'o' && + typeof frame[2] === 'string', + ) + .map((frame) => frame[2]) + .join(''); +} + let testHome = ''; let sessionId = ''; @@ -198,17 +263,12 @@ describe('run command integration', { timeout: 45_000 }, () => { }); }); - it('returns timedOut when marker is not found within timeout', async () => { - // Disable terminal echo so the injected marker does not appear in visible output. - sessionId = createSession(testHome, [ - '/bin/sh', - '-c', - 'stty -echo; exec sleep 60', - ]); + it('returns timedOut when run completion is not observed within timeout', async () => { + sessionId = createSession(testHome, ['/bin/bash']); await sleep(500); const result = runCli( - ['run', sessionId, 'echo delayed', '--timeout', '2000', '--json'], + ['run', sessionId, 'sleep 5', '--timeout', '300', '--json'], testEnv(), 30_000, ); @@ -228,8 +288,17 @@ describe('run command integration', { timeout: 45_000 }, () => { expect(envelope.result.accepted).toBe(true); expect(envelope.result.timedOut).toBe(true); expect(envelope.result.completed).toBe(false); - expect(envelope.result.durationMs).toBeGreaterThanOrEqual(1500); - expect(envelope.result.marker).toMatch(/^__AT_MARKER_/); + expect(envelope.result.durationMs).toBeGreaterThanOrEqual(250); + const marker = envelope.result.marker; + expectRunMarker(marker); + + const events = await readEvents(testHome, sessionId); + expect( + events.some( + (event) => + event.type === 'run_complete' && event.payload.marker === marker, + ), + ).toBe(false); }); it('detects session exit during wait before timing out', async () => { @@ -264,12 +333,19 @@ describe('run command integration', { timeout: 45_000 }, () => { expect(envelope.result.durationMs).toBeLessThan(10_000); }); - it('completes when marker is found in rendered output', async () => { + it('records structured run completion without leaking sentinel text to artifacts', async () => { sessionId = createSession(testHome, ['/bin/bash']); await sleep(1000); const result = runCli( - ['run', sessionId, 'echo hello', '--timeout', '15000', '--json'], + [ + 'run', + sessionId, + "printf 'before-clean-marker-proof\\n'; printf 'after-clean-marker-proof\\n'", + '--timeout', + '15000', + '--json', + ], testEnv(), 30_000, ); @@ -290,6 +366,80 @@ describe('run command integration', { timeout: 45_000 }, () => { expect(envelope.result.completed).toBe(true); expect(envelope.result.timedOut).toBe(false); expect(envelope.result.durationMs).toBeTypeOf('number'); - expect(envelope.result.marker).toMatch(/^__AT_MARKER_/); + const marker = envelope.result.marker; + expectRunMarker(marker); + + const events = await readEvents(testHome, sessionId); + const runCompleteEvents = events.filter( + (event) => + event.type === 'run_complete' && event.payload.marker === marker, + ); + expect(runCompleteEvents).toHaveLength(1); + + const [runCompleteEvent] = runCompleteEvents; + if (runCompleteEvent === undefined) { + throw new Error('expected run_complete event to exist'); + } + const inputRunSeq = runCompleteEvent.payload.inputRunSeq; + if (inputRunSeq !== undefined) { + expect(inputRunSeq).toBeTypeOf('number'); + const inputRunEvent = events.find((event) => event.seq === inputRunSeq); + expect(inputRunEvent?.type).toBe('input_run'); + expect(inputRunEvent?.payload).toMatchObject({ marker }); + } + + const outputText = collectOutputText(events); + expect(outputText).toContain('before-clean-marker-proof'); + expect(outputText).toContain('after-clean-marker-proof'); + expectCompletionArtifactsClean(outputText, marker); + + const snapshotResult = runCli( + [ + 'snapshot', + sessionId, + '--format', + 'text', + '--include-scrollback', + '--json', + ], + testEnv(), + 30_000, + ); + expect(snapshotResult.status).toBe(0); + expect(snapshotResult.stderr).toBe(''); + const snapshotEnvelope = JSON.parse( + snapshotResult.stdout, + ) as SuccessEnvelope<{ + text: string; + }>; + expect(snapshotEnvelope.ok).toBe(true); + expect(snapshotEnvelope.result.text).toContain('before-clean-marker-proof'); + expect(snapshotEnvelope.result.text).toContain('after-clean-marker-proof'); + expectCompletionArtifactsClean(snapshotEnvelope.result.text, marker); + + const asciicastPath = join(testHome, 'run-cleanliness.cast'); + const exportResult = runCli( + [ + 'record', + 'export', + sessionId, + '--format', + 'asciicast', + '--out', + asciicastPath, + '--json', + ], + testEnv(), + 30_000, + ); + expect(exportResult.status).toBe(0); + expect(exportResult.stderr).toBe(''); + + const asciicastOutputText = collectAsciicastOutputFrameText( + readFileSync(asciicastPath, 'utf8'), + ); + expect(asciicastOutputText).toContain('before-clean-marker-proof'); + expect(asciicastOutputText).toContain('after-clean-marker-proof'); + expectCompletionArtifactsClean(asciicastOutputText, marker); }); }); From d38e6ad761eb4cf12084e42d8f3b20b867fb1c1a Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sun, 26 Apr 2026 20:41:42 +0000 Subject: [PATCH 04/14] fix: scrub echoed run completion postamble --- src/host/hostMain.ts | 32 ++++++++++++++++++++++++++++++-- test/integration/run.test.ts | 1 + 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/host/hostMain.ts b/src/host/hostMain.ts index 7173f68..811745c 100644 --- a/src/host/hostMain.ts +++ b/src/host/hostMain.ts @@ -106,6 +106,7 @@ type TimedRunCompletionWaitResult = | RunCompletionWaitResult | { kind: 'timeout' }; +const RUN_COMPLETION_POSTAMBLE_ECHO_PREFIX = String.raw`printf '\033\133\061\101`; const RUN_MARKER_PATTERN = /^__AT_MARKER_([0-9a-f]{32})__$/u; function normalizeExitSignal(signal: number | null): string | null { @@ -162,6 +163,10 @@ function buildRunCompletePostamble(marker: string): string { const postamble = `printf '${shellOctalEscapedBytes( `${hideEchoedPostambleLine}${buildRunCompleteSentinel(marker)}`, )}'`; + invariant( + postamble.startsWith(RUN_COMPLETION_POSTAMBLE_ECHO_PREFIX), + 'run-completion postamble echo prefix must stay in sync with sanitizer', + ); invariant( !postamble.includes('agent-tty:run-complete:'), 'run-completion postamble must not echo the complete sentinel label', @@ -359,6 +364,7 @@ export async function runHost(sessionId: string): Promise { const sentinelScanner = new RunCompletionSentinelScanner(); const activeRunCompletions = new Map(); const runCompletionWaiters = new Map(); + let suppressRunCompletionEcho = false; let ptyIngestionQueue: Promise = Promise.resolve(); const ptyExitPromise = new Promise((resolve) => { @@ -497,13 +503,34 @@ export async function runHost(sessionId: string): Promise { ); }; + const sanitizeRunCompletionEchoOutput = (data: string): string => { + invariant(typeof data === 'string', 'output data must be a string'); + + if (suppressRunCompletionEcho) { + return ''; + } + + if (activeRunCompletions.size === 0) { + return data; + } + + const echoIndex = data.indexOf(RUN_COMPLETION_POSTAMBLE_ECHO_PREFIX); + if (echoIndex === -1) { + return data; + } + + suppressRunCompletionEcho = true; + return data.slice(0, echoIndex); + }; + const appendSentinelPieces = async ( pieces: SentinelPiece[], ): Promise => { for (const piece of pieces) { if (piece.type === 'output') { - if (piece.data.length > 0) { - await eventLog.append('output', { data: piece.data }); + const outputData = sanitizeRunCompletionEchoOutput(piece.data); + if (outputData.length > 0) { + await eventLog.append('output', { data: outputData }); } continue; } @@ -525,6 +552,7 @@ export async function runHost(sessionId: string): Promise { ? {} : { inputRunSeq: activeCompletion.inputRunSeq }), }); + suppressRunCompletionEcho = false; const deleted = activeRunCompletions.delete(piece.marker); invariant( deleted, diff --git a/test/integration/run.test.ts b/test/integration/run.test.ts index c97bf5a..f764014 100644 --- a/test/integration/run.test.ts +++ b/test/integration/run.test.ts @@ -56,6 +56,7 @@ function expectCompletionArtifactsClean(text: string, marker: string): void { expect(text).not.toContain('__AT_MARKER_'); expect(text).not.toContain('__AT_'); expect(text).not.toContain('MARKER_'); + expect(text).not.toContain("printf '\\033"); expect(text).not.toContain('agent-tty:run-complete:'); expect(text).not.toContain(markerPayload); expect(text).not.toContain(markerPayloadPart1); From 6adea19e43fd79a0ea55a96494644117ba91ce4d Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sun, 26 Apr 2026 20:53:19 +0000 Subject: [PATCH 05/14] test(dogfood): add issue-21 run-completion-clean proof bundle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Captures reviewer-facing evidence that `run --wait` no longer leaks the internal completion marker into snapshot, screenshot, asciicast, or WebM artifacts, while preserving the public CLI JSON envelope (still includes `marker`, `completed`, `durationMs`, etc.). Verification highlights (in README.md): - snapshot.visibleLines, asciicast frames, WebM bytes, and events.jsonl `output` payloads contain ZERO occurrences of `__AT_MARKER_`, `agent-tty:run-complete:`, or the marker UUID. - The new structured `run_complete` event is appended to events.jsonl with `{ marker, inputRunSeq }`. - Old-shape consumers see no behavior change — `marker` is still in the run result and `input_run` payload for correlation. Generated under an isolated AGENT_TTY_HOME under /tmp; commands.sh records the exact reproduction. --- .../01-create.json | 12 ++ .../issue-21-run-completion-clean/02-run.json | 13 ++ .../03-snapshot.json | 113 ++++++++++++++++++ .../04-screenshot.json | 20 ++++ .../04-screenshot.png | Bin 0 -> 8884 bytes .../05-asciicast.json | 23 ++++ .../05-recording.cast | 8 ++ .../06-recording.webm | Bin 0 -> 26432 bytes .../06-webm.json | 24 ++++ .../07-events.jsonl | 9 ++ .../08-destroy.json | 9 ++ .../issue-21-run-completion-clean/README.md | 72 +++++++++++ .../issue-21-run-completion-clean/commands.sh | 37 ++++++ 13 files changed, 340 insertions(+) create mode 100644 dogfood/issue-21-run-completion-clean/01-create.json create mode 100644 dogfood/issue-21-run-completion-clean/02-run.json create mode 100644 dogfood/issue-21-run-completion-clean/03-snapshot.json create mode 100644 dogfood/issue-21-run-completion-clean/04-screenshot.json create mode 100644 dogfood/issue-21-run-completion-clean/04-screenshot.png create mode 100644 dogfood/issue-21-run-completion-clean/05-asciicast.json create mode 100644 dogfood/issue-21-run-completion-clean/05-recording.cast create mode 100644 dogfood/issue-21-run-completion-clean/06-recording.webm create mode 100644 dogfood/issue-21-run-completion-clean/06-webm.json create mode 100644 dogfood/issue-21-run-completion-clean/07-events.jsonl create mode 100644 dogfood/issue-21-run-completion-clean/08-destroy.json create mode 100644 dogfood/issue-21-run-completion-clean/README.md create mode 100755 dogfood/issue-21-run-completion-clean/commands.sh diff --git a/dogfood/issue-21-run-completion-clean/01-create.json b/dogfood/issue-21-run-completion-clean/01-create.json new file mode 100644 index 0000000..642de19 --- /dev/null +++ b/dogfood/issue-21-run-completion-clean/01-create.json @@ -0,0 +1,12 @@ +{ + "ok": true, + "command": "create", + "timestamp": "2026-04-26T20:49:52.589Z", + "result": { + "sessionId": "01KQ5RWH177CRZKPXSW91K16RG", + "createdAt": "2026-04-26T20:49:51.659Z", + "cols": 80, + "rows": 24, + "shell": "/bin/bash" + } +} diff --git a/dogfood/issue-21-run-completion-clean/02-run.json b/dogfood/issue-21-run-completion-clean/02-run.json new file mode 100644 index 0000000..5ddb825 --- /dev/null +++ b/dogfood/issue-21-run-completion-clean/02-run.json @@ -0,0 +1,13 @@ +{ + "ok": true, + "command": "run", + "timestamp": "2026-04-26T20:50:07.184Z", + "result": { + "accepted": true, + "completed": true, + "timedOut": false, + "seq": 2, + "durationMs": 208, + "marker": "__AT_MARKER_739e240b0762477e833bf3fd8b0dfd5f__" + } +} diff --git a/dogfood/issue-21-run-completion-clean/03-snapshot.json b/dogfood/issue-21-run-completion-clean/03-snapshot.json new file mode 100644 index 0000000..23ea0a4 --- /dev/null +++ b/dogfood/issue-21-run-completion-clean/03-snapshot.json @@ -0,0 +1,113 @@ +{ + "ok": true, + "command": "snapshot", + "timestamp": "2026-04-26T20:50:19.514Z", + "result": { + "format": "structured", + "sessionId": "01KQ5RWH177CRZKPXSW91K16RG", + "capturedAtSeq": 8, + "cols": 80, + "rows": 24, + "cursorRow": 4, + "cursorCol": 20, + "isAltScreen": false, + "visibleLines": [ + { + "row": 0, + "text": "bash-5.1$ printf \"before-clean-marker-proof\\n\"; sleep 0.2; printf \"after-clean-m" + }, + { + "row": 1, + "text": "arker-proof\\n\"" + }, + { + "row": 2, + "text": "before-clean-marker-proof" + }, + { + "row": 3, + "text": "after-clean-marker-proof" + }, + { + "row": 4, + "text": "bash-5.1$ bash-5.1$" + }, + { + "row": 5, + "text": "" + }, + { + "row": 6, + "text": "" + }, + { + "row": 7, + "text": "" + }, + { + "row": 8, + "text": "" + }, + { + "row": 9, + "text": "" + }, + { + "row": 10, + "text": "" + }, + { + "row": 11, + "text": "" + }, + { + "row": 12, + "text": "" + }, + { + "row": 13, + "text": "" + }, + { + "row": 14, + "text": "" + }, + { + "row": 15, + "text": "" + }, + { + "row": 16, + "text": "" + }, + { + "row": 17, + "text": "" + }, + { + "row": 18, + "text": "" + }, + { + "row": 19, + "text": "" + }, + { + "row": 20, + "text": "" + }, + { + "row": 21, + "text": "" + }, + { + "row": 22, + "text": "" + }, + { + "row": 23, + "text": "" + } + ] + } +} diff --git a/dogfood/issue-21-run-completion-clean/04-screenshot.json b/dogfood/issue-21-run-completion-clean/04-screenshot.json new file mode 100644 index 0000000..ce58b8e --- /dev/null +++ b/dogfood/issue-21-run-completion-clean/04-screenshot.json @@ -0,0 +1,20 @@ +{ + "ok": true, + "command": "screenshot", + "timestamp": "2026-04-26T20:50:29.854Z", + "result": { + "sessionId": "01KQ5RWH177CRZKPXSW91K16RG", + "capturedAtSeq": 8, + "profileName": "reference-dark", + "cols": 80, + "rows": 24, + "artifactPath": "/tmp/agent-tty-issue21-dogfood-aC31nw/sessions/01KQ5RWH177CRZKPXSW91K16RG/artifacts/screenshot-8-reference-dark.png", + "pngSizeBytes": 8884, + "cursorVisible": false, + "rendererBackend": "ghostty-web", + "pixelWidth": 640, + "pixelHeight": 384, + "sha256": "5d25d81377d59dfa79928b7ce1824ab521328eba74f73b53abd02f58d4658ccf", + "renderProfileHash": "8ffed6af301ec7c0e6b69599c3be0d1d12096f9fcdfc59d0bbb4cc474d64c53d" + } +} diff --git a/dogfood/issue-21-run-completion-clean/04-screenshot.png b/dogfood/issue-21-run-completion-clean/04-screenshot.png new file mode 100644 index 0000000000000000000000000000000000000000..a9ef1af2ad902b02e050ee024ee0e31d52a05996 GIT binary patch literal 8884 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

oJl1;^v8objJY=*d(&cEB=f3+xfG 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 literal 0 HcmV?d00001 diff --git a/dogfood/issue-21-run-completion-clean/06-webm.json b/dogfood/issue-21-run-completion-clean/06-webm.json new file mode 100644 index 0000000..a990dcd --- /dev/null +++ b/dogfood/issue-21-run-completion-clean/06-webm.json @@ -0,0 +1,24 @@ +{ + "ok": true, + "command": "record export", + "timestamp": "2026-04-26T20:50:41.609Z", + "result": { + "sessionId": "01KQ5RWH177CRZKPXSW91K16RG", + "format": "webm", + "artifactPath": "/home/coder/.mux/src/agent-terminal/agent-tty-4d32/dogfood/issue-21-run-completion-clean/06-recording.webm", + "bytes": 26432, + "sha256": "27384d1fafaeee27c0e8511f3df659c3b195c4fccf56262fdb5605caa7b7dcee", + "capturedAtSeq": 8, + "durationMs": 14205, + "metadata": { + "width": 80, + "height": 24, + "profileName": "reference-dark", + "renderProfileHash": "8ffed6af301ec7c0e6b69599c3be0d1d12096f9fcdfc59d0bbb4cc474d64c53d", + "timingMode": "accelerated", + "rendererBackend": "ghostty-web", + "outputEventCount": 7, + "resizeEventCount": 0 + } + } +} diff --git a/dogfood/issue-21-run-completion-clean/07-events.jsonl b/dogfood/issue-21-run-completion-clean/07-events.jsonl new file mode 100644 index 0000000..89de225 --- /dev/null +++ b/dogfood/issue-21-run-completion-clean/07-events.jsonl @@ -0,0 +1,9 @@ +{"seq":0,"ts":"2026-04-26T20:49:52.489Z","type":"output","payload":{"data":"\u001b[?2004h"}} +{"seq":1,"ts":"2026-04-26T20:49:52.490Z","type":"output","payload":{"data":"bash-5.1$ "}} +{"seq":2,"ts":"2026-04-26T20:50:06.485Z","type":"input_run","payload":{"command":"printf \"before-clean-marker-proof\\n\"; sleep 0.2; printf \"after-clean-marker-proof\\n\"","marker":"__AT_MARKER_739e240b0762477e833bf3fd8b0dfd5f__","noWait":false}} +{"seq":3,"ts":"2026-04-26T20:50:06.487Z","type":"output","payload":{"data":"printf \"before-clean-marker-proof\\n\"; sleep 0.2; printf \"after-clean-ma\rarker-proof\\n\"\r\n\u001b[?2004l\r"}} +{"seq":4,"ts":"2026-04-26T20:50:06.488Z","type":"output","payload":{"data":"before-clean-marker-proof\r\n"}} +{"seq":5,"ts":"2026-04-26T20:50:06.690Z","type":"output","payload":{"data":"after-clean-marker-proof\r\n\u001b[?2004h"}} +{"seq":6,"ts":"2026-04-26T20:50:06.690Z","type":"output","payload":{"data":"bash-5.1$ "}} +{"seq":7,"ts":"2026-04-26T20:50:06.693Z","type":"run_complete","payload":{"marker":"__AT_MARKER_739e240b0762477e833bf3fd8b0dfd5f__","inputRunSeq":2}} +{"seq":8,"ts":"2026-04-26T20:50:06.694Z","type":"output","payload":{"data":"\u001b[?2004hbash-5.1$ "}} diff --git a/dogfood/issue-21-run-completion-clean/08-destroy.json b/dogfood/issue-21-run-completion-clean/08-destroy.json new file mode 100644 index 0000000..088a733 --- /dev/null +++ b/dogfood/issue-21-run-completion-clean/08-destroy.json @@ -0,0 +1,9 @@ +{ + "ok": true, + "command": "destroy", + "timestamp": "2026-04-26T20:51:54.509Z", + "result": { + "sessionId": "01KQ5RWH177CRZKPXSW91K16RG", + "destroyed": true + } +} diff --git a/dogfood/issue-21-run-completion-clean/README.md b/dogfood/issue-21-run-completion-clean/README.md new file mode 100644 index 0000000..fcbec39 --- /dev/null +++ b/dogfood/issue-21-run-completion-clean/README.md @@ -0,0 +1,72 @@ +# Issue #21 — Run completion markers stay out of rendered artifacts + +This bundle proves that `run --wait` no longer leaks the internal completion +marker into reviewer-facing artifacts (snapshot, screenshot, asciicast, WebM) +while still preserving the public CLI JSON envelope. + +Generated under an isolated `AGENT_TTY_HOME` (`/tmp/agent-tty-issue21-dogfood-…`, +removed after capture). See `commands.sh` for the exact reproduction. + +## What was exercised + +The waited `run` command was: +```sh +printf "before-clean-marker-proof\n"; sleep 0.2; printf "after-clean-marker-proof\n" +``` + +Run marker returned in the JSON envelope: `__AT_MARKER_739e240b0762477e833bf3fd8b0dfd5f__` +(UUID portion: `739e240b0762477e833bf3fd8b0dfd5f`). + +## Verification matrix + +| Artifact | User output present | `__AT_MARKER_` count | `agent-tty:run-complete:` count | Marker UUID count | +| --- | --- | --- | --- | --- | +| `03-snapshot.json` (visibleLines text) | ✅ both lines | 0 | 0 | 0 | +| `05-recording.cast` (asciicast frames) | ✅ both lines | 0 | 0 | 0 | +| `06-recording.webm` (binary, byte scan) | n/a (encoded video) | 0 | 0 | 0 | +| `07-events.jsonl` `output` events | ✅ visible bytes only | 0 | 0 | 0 | + +Allowed and expected: the marker text appears only in the structured metadata +events that never reach renderer/export — `input_run.payload.marker` and +`run_complete.payload.marker`. + +## Event-log highlight + +Single waited run produced 9 events: + +- 1 × `input_run` (carries marker as correlation metadata only) +- 7 × `output` (visible PTY bytes; marker-free) +- 1 × `run_complete` (new structured non-rendered event with `{ marker, inputRunSeq: 2 }`) + +`run_complete` event payload: +```json +{ "marker": "__AT_MARKER_739e240b0762477e833bf3fd8b0dfd5f__", "inputRunSeq": 2 } +``` + +## Public envelope preserved + +`02-run.json` still includes: + +```json +{ "accepted": true, "completed": true, "timedOut": false, + "seq": 2, "durationMs": 208, "marker": "__AT_MARKER_739e240b0762477e833bf3fd8b0dfd5f__" } +``` + +## Files + +- `01-create.json` — `create` JSON envelope +- `02-run.json` — `run --wait` JSON envelope (still exposes `marker`, `completed`, `durationMs`, …) +- `03-snapshot.json` — semantic snapshot +- `04-screenshot.{json,png}` — rendered screenshot result + PNG (640 × 384, ghostty-web) +- `05-recording.cast` + `05-asciicast.json` — exported asciicast and result envelope +- `06-recording.webm` + `06-webm.json` — exported WebM (accelerated timing) and result envelope +- `07-events.jsonl` — canonical event log copy +- `08-destroy.json` — session teardown envelope +- `commands.sh` — exact reproduction script + +## Suggested review order + +1. `02-run.json` to confirm the public envelope is unchanged. +2. `07-events.jsonl` to see the new `run_complete` event and confirm `output` events are marker-free. +3. `03-snapshot.json` and `04-screenshot.png` to confirm the rendered terminal state contains user output but no marker. +4. `05-recording.cast` and `06-recording.webm` to confirm exported recordings are also marker-free. diff --git a/dogfood/issue-21-run-completion-clean/commands.sh b/dogfood/issue-21-run-completion-clean/commands.sh new file mode 100755 index 0000000..d07532e --- /dev/null +++ b/dogfood/issue-21-run-completion-clean/commands.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Reproduce the issue-21 run-completion-clean dogfood bundle. +# All paths use an isolated AGENT_TTY_HOME under /tmp; nothing writes to ~/.agent-tty. +set -euo pipefail + +DOGFOOD_HOME=$(mktemp -d -t agent-tty-issue21-dogfood-XXXXXX) +ARTIFACTS_DIR="$(pwd)/dogfood/issue-21-run-completion-clean" +mkdir -p "$ARTIFACTS_DIR" + +CLI=(npx tsx src/cli/main.ts --home "$DOGFOOD_HOME") + +# 1. Create an interactive bash session +"${CLI[@]}" create --json -- bash --noprofile --norc \ + | tee "$ARTIFACTS_DIR/01-create.json" +SESSION_ID=$(jq -r '.result.sessionId' "$ARTIFACTS_DIR/01-create.json") + +# 2. Run a waited command with recognizable user output +"${CLI[@]}" run --json --timeout 10000 "$SESSION_ID" \ + 'printf "before-clean-marker-proof\n"; sleep 0.2; printf "after-clean-marker-proof\n"' \ + | tee "$ARTIFACTS_DIR/02-run.json" + +# 3. Capture artifacts +"${CLI[@]}" snapshot --json "$SESSION_ID" > "$ARTIFACTS_DIR/03-snapshot.json" +"${CLI[@]}" screenshot --json "$SESSION_ID" > "$ARTIFACTS_DIR/04-screenshot.json" +cp "$(jq -r '.result.artifactPath' "$ARTIFACTS_DIR/04-screenshot.json")" \ + "$ARTIFACTS_DIR/04-screenshot.png" +"${CLI[@]}" record export --json "$SESSION_ID" --format asciicast \ + --out "$ARTIFACTS_DIR/05-recording.cast" > "$ARTIFACTS_DIR/05-asciicast.json" +"${CLI[@]}" record export --json "$SESSION_ID" --format webm --timing accelerated \ + --out "$ARTIFACTS_DIR/06-recording.webm" > "$ARTIFACTS_DIR/06-webm.json" + +# 4. Copy the canonical event log for cleanliness inspection +cp "$DOGFOOD_HOME/sessions/$SESSION_ID/events.jsonl" "$ARTIFACTS_DIR/07-events.jsonl" + +# 5. Tear down +"${CLI[@]}" destroy --json "$SESSION_ID" > "$ARTIFACTS_DIR/08-destroy.json" +rm -rf "$DOGFOOD_HOME" From 0cd451a2ed8530972d5a9234fddaeb98c4f9013c Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sun, 26 Apr 2026 21:04:04 +0000 Subject: [PATCH 06/14] style: format issue-21 dogfood README --- .../issue-21-run-completion-clean/README.md | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/dogfood/issue-21-run-completion-clean/README.md b/dogfood/issue-21-run-completion-clean/README.md index fcbec39..f2b0133 100644 --- a/dogfood/issue-21-run-completion-clean/README.md +++ b/dogfood/issue-21-run-completion-clean/README.md @@ -10,6 +10,7 @@ removed after capture). See `commands.sh` for the exact reproduction. ## What was exercised The waited `run` command was: + ```sh printf "before-clean-marker-proof\n"; sleep 0.2; printf "after-clean-marker-proof\n" ``` @@ -19,12 +20,12 @@ Run marker returned in the JSON envelope: `__AT_MARKER_739e240b0762477e833bf3fd8 ## Verification matrix -| Artifact | User output present | `__AT_MARKER_` count | `agent-tty:run-complete:` count | Marker UUID count | -| --- | --- | --- | --- | --- | -| `03-snapshot.json` (visibleLines text) | ✅ both lines | 0 | 0 | 0 | -| `05-recording.cast` (asciicast frames) | ✅ both lines | 0 | 0 | 0 | -| `06-recording.webm` (binary, byte scan) | n/a (encoded video) | 0 | 0 | 0 | -| `07-events.jsonl` `output` events | ✅ visible bytes only | 0 | 0 | 0 | +| Artifact | User output present | `__AT_MARKER_` count | `agent-tty:run-complete:` count | Marker UUID count | +| --------------------------------------- | --------------------- | -------------------- | ------------------------------- | ----------------- | +| `03-snapshot.json` (visibleLines text) | ✅ both lines | 0 | 0 | 0 | +| `05-recording.cast` (asciicast frames) | ✅ both lines | 0 | 0 | 0 | +| `06-recording.webm` (binary, byte scan) | n/a (encoded video) | 0 | 0 | 0 | +| `07-events.jsonl` `output` events | ✅ visible bytes only | 0 | 0 | 0 | Allowed and expected: the marker text appears only in the structured metadata events that never reach renderer/export — `input_run.payload.marker` and @@ -39,6 +40,7 @@ Single waited run produced 9 events: - 1 × `run_complete` (new structured non-rendered event with `{ marker, inputRunSeq: 2 }`) `run_complete` event payload: + ```json { "marker": "__AT_MARKER_739e240b0762477e833bf3fd8b0dfd5f__", "inputRunSeq": 2 } ``` @@ -48,8 +50,14 @@ Single waited run produced 9 events: `02-run.json` still includes: ```json -{ "accepted": true, "completed": true, "timedOut": false, - "seq": 2, "durationMs": 208, "marker": "__AT_MARKER_739e240b0762477e833bf3fd8b0dfd5f__" } +{ + "accepted": true, + "completed": true, + "timedOut": false, + "seq": 2, + "durationMs": 208, + "marker": "__AT_MARKER_739e240b0762477e833bf3fd8b0dfd5f__" +} ``` ## Files From ce601be81ce40a3eea6e59d8a8696e5f571c3211 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 27 Apr 2026 12:44:02 +0000 Subject: [PATCH 07/14] fix: address run completion review feedback Remove the cursor-clearing prefix from the run-completion postamble so echo-disabled shells do not persist control bytes into output events, and add integration coverage for the stty -echo path. Ensure PTY exit handling runs even if final sentinel flushing fails, document the intentional fail-fast policy for serialized PTY ingestion errors, and share the canonical run marker pattern with integration tests. --- src/host/hostMain.ts | 44 +++++++++-------- src/host/runCompletionSentinel.ts | 1 + test/integration/run.test.ts | 81 ++++++++++++++++++++++++++++++- 3 files changed, 104 insertions(+), 22 deletions(-) diff --git a/src/host/hostMain.ts b/src/host/hostMain.ts index 811745c..a488e26 100644 --- a/src/host/hostMain.ts +++ b/src/host/hostMain.ts @@ -11,6 +11,7 @@ import { HostRendererManager } from './renderer.js'; import { RpcServer, type MethodHandler } from './rpcServer.js'; import { buildRunCompleteSentinel, + RUN_MARKER_PATTERN, RunCompletionSentinelScanner, type SentinelPiece, } from './runCompletionSentinel.js'; @@ -106,8 +107,7 @@ type TimedRunCompletionWaitResult = | RunCompletionWaitResult | { kind: 'timeout' }; -const RUN_COMPLETION_POSTAMBLE_ECHO_PREFIX = String.raw`printf '\033\133\061\101`; -const RUN_MARKER_PATTERN = /^__AT_MARKER_([0-9a-f]{32})__$/u; +const RUN_COMPLETION_POSTAMBLE_ECHO_PREFIX = String.raw`printf '\033\137`; function normalizeExitSignal(signal: number | null): string | null { invariant( @@ -146,22 +146,8 @@ function buildRunCompletePostamble(marker: string): string { 'run marker payload must be 32 lowercase hex characters', ); - const markerPayloadPart1 = markerPayload.slice(0, 16); - const markerPayloadPart2 = markerPayload.slice(16); - invariant( - markerPayloadPart1.length > 0 && markerPayloadPart2.length > 0, - 'run marker payload must split into non-empty pieces', - ); - - const sentinelPayload = `agent-tty:run-complete:__AT_MARKER_${markerPayloadPart1}${markerPayloadPart2}__`; - invariant( - `\x1b_${sentinelPayload}\x1b\\` === buildRunCompleteSentinel(marker), - 'run-completion postamble pieces must reconstruct the expected sentinel', - ); - - const hideEchoedPostambleLine = '\x1b[1A\r\x1b[2K'; const postamble = `printf '${shellOctalEscapedBytes( - `${hideEchoedPostambleLine}${buildRunCompleteSentinel(marker)}`, + buildRunCompleteSentinel(marker), )}'`; invariant( postamble.startsWith(RUN_COMPLETION_POSTAMBLE_ECHO_PREFIX), @@ -1470,15 +1456,33 @@ export async function runHost(sessionId: string): Promise { lastActivityAt = lastOutputAt; void enqueuePtyIngestion(async () => { await appendSentinelPieces(sentinelScanner.feed(data)); - }).catch(rethrowAsync); + }).catch((error: unknown) => { + // Run-completion sentinels make serialized PTY ingestion part of the + // canonical event-log contract: if appending output/control events fails, + // the log can no longer be trusted to drive waits or replay artifacts. + rethrowAsync(error); + }); }); pty.onExit(({ exitCode, signal }) => { + let ingestionError: unknown; + void enqueuePtyIngestion(async () => { await appendSentinelPieces(sentinelScanner.flush()); }) - .then(() => { - handlePtyExit(exitCode, signal ?? null); + .catch((error: unknown) => { + ingestionError = error; + }) + .finally(() => { + try { + handlePtyExit(exitCode, signal ?? null); + } finally { + if (ingestionError !== undefined) { + // Still record PTY exit state first; the ingestion failure is + // surfaced asynchronously after exit handling has run. + rethrowAsync(ingestionError); + } + } }) .catch(rethrowAsync); }); diff --git a/src/host/runCompletionSentinel.ts b/src/host/runCompletionSentinel.ts index df9740a..1b4de91 100644 --- a/src/host/runCompletionSentinel.ts +++ b/src/host/runCompletionSentinel.ts @@ -19,6 +19,7 @@ import { invariant } from '../util/assert.js'; export const RUN_COMPLETE_SENTINEL_PREFIX = '\x1b_agent-tty:run-complete:'; export const RUN_COMPLETE_SENTINEL_SUFFIX = '\x1b\\'; +export const RUN_MARKER_PATTERN = /^__AT_MARKER_([0-9a-f]{32})__$/u; export type SentinelPiece = | { type: 'output'; data: string } diff --git a/test/integration/run.test.ts b/test/integration/run.test.ts index f764014..3defde7 100644 --- a/test/integration/run.test.ts +++ b/test/integration/run.test.ts @@ -19,10 +19,9 @@ import { type EventRecord, type SuccessEnvelope, } from '../helpers.js'; +import { RUN_MARKER_PATTERN } from '../../src/host/runCompletionSentinel.js'; import type { CommandErrorEnvelope } from '../../src/protocol/envelope.js'; -const RUN_MARKER_PATTERN = /^__AT_MARKER_([0-9a-f]{32})__$/u; - function expectRunMarker(marker: string): string { const match = RUN_MARKER_PATTERN.exec(marker); expect(match).not.toBeNull(); @@ -334,6 +333,84 @@ describe('run command integration', { timeout: 45_000 }, () => { expect(envelope.result.durationMs).toBeLessThan(10_000); }); + it('does not log postamble cursor controls when shell echo is disabled', async () => { + sessionId = createSession(testHome, ['/bin/bash', '--noprofile', '--norc']); + await sleep(1000); + + const disableEchoResult = runCli( + ['run', sessionId, 'stty -echo', '--timeout', '15000', '--json'], + testEnv(), + 30_000, + ); + expect(disableEchoResult.status).toBe(0); + expect(disableEchoResult.stderr).toBe(''); + + const result = runCli( + [ + 'run', + sessionId, + "printf 'noecho-before-proof\\n'; printf 'noecho-after-proof\\n'", + '--timeout', + '15000', + '--json', + ], + testEnv(), + 30_000, + ); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(''); + + const envelope = JSON.parse(result.stdout) as SuccessEnvelope<{ + accepted: true; + completed: boolean; + timedOut: boolean; + seq: number; + durationMs: number; + marker: string; + }>; + expect(envelope.ok).toBe(true); + expect(envelope.result.accepted).toBe(true); + expect(envelope.result.completed).toBe(true); + expect(envelope.result.timedOut).toBe(false); + const marker = envelope.result.marker; + expectRunMarker(marker); + + const events = await readEvents(testHome, sessionId); + const outputText = collectOutputText(events); + expect(outputText).toContain('noecho-before-proof'); + expect(outputText).toContain('noecho-after-proof'); + expect(outputText).not.toContain('\x1b[1A'); + expect(outputText).not.toContain('\x1b[2K'); + expectCompletionArtifactsClean(outputText, marker); + + const snapshotResult = runCli( + [ + 'snapshot', + sessionId, + '--format', + 'text', + '--include-scrollback', + '--json', + ], + testEnv(), + 30_000, + ); + expect(snapshotResult.status).toBe(0); + expect(snapshotResult.stderr).toBe(''); + const snapshotEnvelope = JSON.parse( + snapshotResult.stdout, + ) as SuccessEnvelope<{ + text: string; + }>; + expect(snapshotEnvelope.ok).toBe(true); + expect(snapshotEnvelope.result.text).toContain('noecho-before-proof'); + expect(snapshotEnvelope.result.text).toContain('noecho-after-proof'); + expect(snapshotEnvelope.result.text).not.toContain('\x1b[1A'); + expect(snapshotEnvelope.result.text).not.toContain('\x1b[2K'); + expectCompletionArtifactsClean(snapshotEnvelope.result.text, marker); + }); + it('records structured run completion without leaking sentinel text to artifacts', async () => { sessionId = createSession(testHome, ['/bin/bash']); await sleep(1000); From 5f91c30d86a007c71b30bd86ce9e259266432267 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 28 Apr 2026 10:34:19 +0000 Subject: [PATCH 08/14] fix: harden run completion echo handling --- src/host/hostMain.ts | 120 +++-- src/host/runCompletionSentinel.ts | 462 +++++++++++++++--- test/integration/run.test.ts | 91 ++++ test/unit/host/runCompletionSentinel.test.ts | 170 +++++-- .../unit/renderer/libghosttyVtBackend.test.ts | 45 ++ 5 files changed, 732 insertions(+), 156 deletions(-) diff --git a/src/host/hostMain.ts b/src/host/hostMain.ts index a488e26..3c6f024 100644 --- a/src/host/hostMain.ts +++ b/src/host/hostMain.ts @@ -12,6 +12,7 @@ import { RpcServer, type MethodHandler } from './rpcServer.js'; import { buildRunCompleteSentinel, RUN_MARKER_PATTERN, + RunCompletionPostambleEchoSanitizer, RunCompletionSentinelScanner, type SentinelPiece, } from './runCompletionSentinel.js'; @@ -348,9 +349,9 @@ export async function runHost(sessionId: string): Promise { }; const sentinelScanner = new RunCompletionSentinelScanner(); + const postambleEchoSanitizer = new RunCompletionPostambleEchoSanitizer(); const activeRunCompletions = new Map(); const runCompletionWaiters = new Map(); - let suppressRunCompletionEcho = false; let ptyIngestionQueue: Promise = Promise.resolve(); const ptyExitPromise = new Promise((resolve) => { @@ -414,9 +415,10 @@ export async function runHost(sessionId: string): Promise { 'run completion waiter must be unique per marker', ); - return new Promise((resolve, reject) => { - runCompletionWaiters.set(marker, { reject, resolve }); - }); + const { promise, reject, resolve } = + Promise.withResolvers(); + runCompletionWaiters.set(marker, { reject, resolve }); + return promise; }; const waitForRunCompletion = async ( @@ -429,41 +431,43 @@ export async function runHost(sessionId: string): Promise { 'timeoutMs must be positive', ); - return await new Promise( - (resolve, reject) => { - let resolved = false; - const timeoutHandle = setTimeout(() => { - if (resolved) { - return; - } + const { promise, reject, resolve } = + Promise.withResolvers(); + let resolved = false; + const timeoutHandle = setTimeout(() => { + if (resolved) { + return; + } - resolved = true; - runCompletionWaiters.delete(marker); - resolve({ kind: 'timeout' }); - }, timeoutMs); + resolved = true; + // Keep sentinel/postamble registrations active after timeout so the + // eventual internal completion bytes are still hidden from artifacts. + runCompletionWaiters.delete(marker); + resolve({ kind: 'timeout' }); + }, timeoutMs); - void completionPromise.then( - (result) => { - if (resolved) { - return; - } + void completionPromise.then( + (result) => { + if (resolved) { + return; + } - resolved = true; - clearTimeout(timeoutHandle); - resolve(result); - }, - (error: unknown) => { - if (resolved) { - return; - } + resolved = true; + clearTimeout(timeoutHandle); + resolve(result); + }, + (error: unknown) => { + if (resolved) { + return; + } - resolved = true; - clearTimeout(timeoutHandle); - reject(error instanceof Error ? error : new Error(String(error))); - }, - ); + resolved = true; + clearTimeout(timeoutHandle); + reject(error instanceof Error ? error : new Error(String(error))); }, ); + + return await promise; }; const replayRendererThroughSeq = async (targetSeq: number): Promise => { @@ -489,24 +493,20 @@ export async function runHost(sessionId: string): Promise { ); }; - const sanitizeRunCompletionEchoOutput = (data: string): string => { + const appendOutput = async (data: string): Promise => { invariant(typeof data === 'string', 'output data must be a string'); - if (suppressRunCompletionEcho) { - return ''; - } - - if (activeRunCompletions.size === 0) { - return data; + const outputData = postambleEchoSanitizer.feed(data); + if (outputData.length > 0) { + await eventLog.append('output', { data: outputData }); } + }; - const echoIndex = data.indexOf(RUN_COMPLETION_POSTAMBLE_ECHO_PREFIX); - if (echoIndex === -1) { - return data; + const appendFlushedPostambleEchoOutput = async (): Promise => { + const outputData = postambleEchoSanitizer.flush(); + if (outputData.length > 0) { + await eventLog.append('output', { data: outputData }); } - - suppressRunCompletionEcho = true; - return data.slice(0, echoIndex); }; const appendSentinelPieces = async ( @@ -514,10 +514,7 @@ export async function runHost(sessionId: string): Promise { ): Promise => { for (const piece of pieces) { if (piece.type === 'output') { - const outputData = sanitizeRunCompletionEchoOutput(piece.data); - if (outputData.length > 0) { - await eventLog.append('output', { data: outputData }); - } + await appendOutput(piece.data); continue; } @@ -532,13 +529,19 @@ export async function runHost(sessionId: string): Promise { ); try { + const trailingEchoOutput = postambleEchoSanitizer.deregister( + piece.marker, + ); + if (trailingEchoOutput.length > 0) { + await eventLog.append('output', { data: trailingEchoOutput }); + } + const seq = await eventLog.append('run_complete', { marker: piece.marker, ...(activeCompletion.inputRunSeq === undefined ? {} : { inputRunSeq: activeCompletion.inputRunSeq }), }); - suppressRunCompletionEcho = false; const deleted = activeRunCompletions.delete(piece.marker); invariant( deleted, @@ -658,8 +661,8 @@ export async function runHost(sessionId: string): Promise { void (async () => { try { await eventLog.append('exit', { exitCode, exitSignal }); - resolveRunCompletionWaitersForExit(); } finally { + resolveRunCompletionWaitersForExit(); try { await writeManifest(mPath, state.snapshot()); } finally { @@ -966,6 +969,7 @@ export async function runHost(sessionId: string): Promise { 'generated run marker must match expected format', ); const sentinel = buildRunCompleteSentinel(marker); + const postamble = buildRunCompletePostamble(marker); const seq = await eventLog.append('input_run', { command, marker, @@ -978,8 +982,9 @@ export async function runHost(sessionId: string): Promise { ); activeRunCompletions.set(marker, { inputRunSeq: seq, sentinel }); sentinelScanner.register(marker); + postambleEchoSanitizer.register(marker, postamble); const completionPromise = subscribeRunCompletion(marker); - const injectedText = `${command}\n${buildRunCompletePostamble(marker)}`; + const injectedText = `${command}\n${postamble}`; const effectiveTimeoutMs = timeoutMs ?? 30_000; const startTime = Date.now(); pty.write(injectedText); @@ -993,7 +998,13 @@ export async function runHost(sessionId: string): Promise { const durationMs = Date.now() - startTime; if (waitResult.kind === 'completed') { - await replayRendererThroughSeq(waitResult.seq); + try { + await replayRendererThroughSeq(waitResult.seq); + } catch { + // The run already completed and was committed to the event log. Do not + // turn a best-effort renderer catch-up failure into a command retry + // hazard; replay-driven snapshots can catch up on the next request. + } } return { @@ -1469,6 +1480,7 @@ export async function runHost(sessionId: string): Promise { void enqueuePtyIngestion(async () => { await appendSentinelPieces(sentinelScanner.flush()); + await appendFlushedPostambleEchoOutput(); }) .catch((error: unknown) => { ingestionError = error; diff --git a/src/host/runCompletionSentinel.ts b/src/host/runCompletionSentinel.ts index 1b4de91..33d04a0 100644 --- a/src/host/runCompletionSentinel.ts +++ b/src/host/runCompletionSentinel.ts @@ -4,15 +4,10 @@ * * APC gives agent-tty a private ESC-based control string whose bytes are easy to * recognize before PTY output reaches the event log, while the ST terminator - * (ESC backslash) makes the frame boundary explicit. Phase 3 will verify the - * live ghostty-web renderer behavior; this scanner does not rely on renderer - * filtering and removes only exact active frames. - * - * The scanner walks input left-to-right. If multiple active sentinels could - * match at the same byte offset, the longest complete frame wins; if that - * complete frame is a strict prefix of a longer active frame and more bytes - * could still arrive, the scanner waits for the next chunk. Equal frames are - * collapsed by idempotent marker registration. + * (ESC backslash) makes the frame boundary explicit. The scanner does not rely + * on renderer filtering and removes only exact active frames for production run + * markers. Production markers have a fixed length, so two active sentinels + * cannot prefix each other. */ import { invariant } from '../util/assert.js'; @@ -21,6 +16,10 @@ export const RUN_COMPLETE_SENTINEL_PREFIX = '\x1b_agent-tty:run-complete:'; export const RUN_COMPLETE_SENTINEL_SUFFIX = '\x1b\\'; export const RUN_MARKER_PATTERN = /^__AT_MARKER_([0-9a-f]{32})__$/u; +const MIN_TOLERANT_ECHO_PREFIX_LENGTH = String.raw`printf '\033\137`.length; +const ESC_CODE = 0x1b; +const POSTAMBLE_ECHO_START_CODE = 'p'.charCodeAt(0); + export type SentinelPiece = | { type: 'output'; data: string } | { type: 'run_complete'; marker: string }; @@ -28,7 +27,29 @@ export type SentinelPiece = interface ActiveSentinel { marker: string; sentinel: string; - order: number; +} + +interface TolerantEchoCandidate { + marker: string; + echo: string; + index: number; +} + +interface TolerantEchoStripState { + candidates: TolerantEchoCandidate[]; + dropControl: 'escape' | 'csi' | null; +} + +interface ActivePostambleEcho { + echoes: readonly string[]; +} + +function assertRunMarker(marker: string): void { + invariant(typeof marker === 'string', 'marker must be a string'); + invariant( + RUN_MARKER_PATTERN.test(marker), + 'run marker must match expected format', + ); } function assertNonEmptyString(value: string, label: string): void { @@ -38,21 +59,357 @@ function assertNonEmptyString(value: string, label: string): void { ); } +function commonPrefixLength(left: string, right: string): number { + const maxLength = Math.min(left.length, right.length); + let index = 0; + + while ( + index < maxLength && + left.charCodeAt(index) === right.charCodeAt(index) + ) { + index += 1; + } + + return index; +} + function pushOutputPiece(pieces: SentinelPiece[], data: string): void { if (data.length > 0) { pieces.push({ type: 'output', data }); } } +function postambleEchoVariants(postamble: string): readonly string[] { + assertNonEmptyString(postamble, 'postamble'); + invariant( + postamble.endsWith('\n'), + 'run-completion postamble must end with newline', + ); + + const crlfEcho = `${postamble.slice(0, -1)}\r\n`; + return crlfEcho === postamble ? [postamble] : [crlfEcho, postamble]; +} + export function buildRunCompleteSentinel(marker: string): string { - assertNonEmptyString(marker, 'marker'); + assertRunMarker(marker); return `${RUN_COMPLETE_SENTINEL_PREFIX}${marker}${RUN_COMPLETE_SENTINEL_SUFFIX}`; } +/** + * Removes the shell's echo of agent-tty's injected completion postamble while + * preserving command output that can arrive between echoed postamble bytes. + * Canonical TTY echo and readline repainting can interleave command output or + * cursor controls into the echoed postamble, so after a long exact prefix match + * this sanitizer drops only the remaining expected postamble bytes and known + * CSI repaint controls; nonmatching bytes continue through as user output. + */ +export class RunCompletionPostambleEchoSanitizer { + readonly #activeEchoes = new Map(); + #tolerantStripState: TolerantEchoStripState | null = null; + #pendingTail = ''; + + public register(marker: string, postamble: string): void { + assertRunMarker(marker); + + this.#activeEchoes.set(marker, { + echoes: postambleEchoVariants(postamble), + }); + this.#assertPendingTailBound(); + } + + public deregister(marker: string): string { + assertRunMarker(marker); + this.#activeEchoes.delete(marker); + + if (this.#tolerantStripState !== null) { + const candidates = this.#tolerantStripState.candidates.filter( + (candidate) => candidate.marker !== marker, + ); + this.#tolerantStripState = + candidates.length === 0 + ? null + : { candidates, dropControl: this.#tolerantStripState.dropControl }; + } + + if (this.#pendingTail.length === 0) { + return ''; + } + + const pendingTail = this.#pendingTail; + this.#pendingTail = ''; + return this.#stripBuffer(pendingTail, false); + } + + public feed(chunk: string): string { + invariant(typeof chunk === 'string', 'chunk must be a string'); + + if (chunk.length === 0) { + return ''; + } + + if (!this.hasActiveEchoes() && this.#tolerantStripState === null) { + invariant( + this.#pendingTail.length === 0, + 'postamble echo pending tail must be empty when no echoes are active', + ); + return chunk; + } + + const buffer = `${this.#pendingTail}${chunk}`; + this.#pendingTail = ''; + return this.#stripBuffer(buffer, false); + } + + public flush(): string { + if (this.#pendingTail.length === 0) { + return ''; + } + + const pendingTail = this.#pendingTail; + this.#pendingTail = ''; + return this.#stripBuffer(pendingTail, true); + } + + public hasActiveEchoes(): boolean { + return this.#activeEchoes.size > 0; + } + + #stripBuffer(buffer: string, isFinal: boolean): string { + if (buffer.length === 0) { + return ''; + } + + if (!this.hasActiveEchoes() && this.#tolerantStripState === null) { + invariant( + this.#pendingTail.length === 0, + 'postamble echo pending tail must stay empty without active echoes', + ); + return buffer; + } + + let output = ''; + let outputStart = 0; + let index = 0; + + while (index < buffer.length) { + if (this.#tolerantStripState !== null) { + output += buffer.slice(outputStart, index); + const result = this.#consumeTolerantEchoByte(buffer, index); + if (result.output.length > 0) { + output += result.output; + } + index = result.nextIndex; + outputStart = index; + continue; + } + + if (!this.hasActiveEchoes()) { + output += buffer.slice(outputStart); + outputStart = buffer.length; + break; + } + + if (buffer.charCodeAt(index) !== POSTAMBLE_ECHO_START_CODE) { + index += 1; + continue; + } + + const matchedEcho = this.#findCompleteEchoMatch(buffer, index); + if (matchedEcho !== undefined) { + output += buffer.slice(outputStart, index); + index += matchedEcho.length; + outputStart = index; + continue; + } + + const remaining = buffer.slice(index); + const tolerantMatch = this.#findTolerantEchoPrefixMatch(remaining); + if (tolerantMatch !== undefined) { + output += buffer.slice(outputStart, index); + this.#tolerantStripState = { + candidates: tolerantMatch.candidates, + dropControl: null, + }; + index += tolerantMatch.prefixLength; + outputStart = index; + continue; + } + + if (!isFinal && this.#hasPartialEchoMatch(remaining)) { + output += buffer.slice(outputStart, index); + this.#setPendingTail(remaining); + return output; + } + + index += 1; + } + + output += buffer.slice(outputStart); + this.#assertPendingTailBound(); + return output; + } + + #consumeTolerantEchoByte( + buffer: string, + index: number, + ): { nextIndex: number; output: string } { + const state = this.#tolerantStripState; + invariant(state !== null, 'tolerant postamble echo strip state must exist'); + + const char = buffer.charAt(index); + invariant(char.length === 1, 'tolerant strip must consume one code unit'); + + if (state.dropControl === 'escape') { + state.dropControl = char === '[' ? 'csi' : null; + return { nextIndex: index + 1, output: '' }; + } + + if (state.dropControl === 'csi') { + if (/^[\x40-\x7e]$/u.test(char)) { + state.dropControl = null; + } + return { nextIndex: index + 1, output: '' }; + } + + if (char === '\x1b') { + state.dropControl = 'escape'; + return { nextIndex: index + 1, output: '' }; + } + + const advancedCandidates = state.candidates + .filter(({ echo, index: candidateIndex }) => + echo.startsWith(char, candidateIndex), + ) + .map((candidate) => ({ + ...candidate, + index: candidate.index + 1, + })); + + if (advancedCandidates.length === 0) { + return { nextIndex: index + 1, output: char }; + } + + const completed = advancedCandidates.some( + ({ echo, index: candidateIndex }) => candidateIndex === echo.length, + ); + this.#tolerantStripState = completed + ? null + : { candidates: advancedCandidates, dropControl: null }; + return { nextIndex: index + 1, output: '' }; + } + + #findTolerantEchoPrefixMatch( + remaining: string, + ): { candidates: TolerantEchoCandidate[]; prefixLength: number } | undefined { + let bestPrefixLength = 0; + let candidates: TolerantEchoCandidate[] = []; + + for (const [marker, { echoes }] of this.#activeEchoes) { + for (const echo of echoes) { + const prefixLength = commonPrefixLength(remaining, echo); + if (prefixLength < MIN_TOLERANT_ECHO_PREFIX_LENGTH) { + continue; + } + if (prefixLength === echo.length) { + continue; + } + + if (prefixLength > bestPrefixLength) { + bestPrefixLength = prefixLength; + candidates = []; + } + if (prefixLength === bestPrefixLength) { + candidates.push({ echo, index: prefixLength, marker }); + } + } + } + + if (bestPrefixLength === 0) { + return undefined; + } + + invariant( + candidates.length > 0, + 'tolerant postamble echo prefix match must have candidates', + ); + return { candidates, prefixLength: bestPrefixLength }; + } + + #findCompleteEchoMatch(buffer: string, index: number): string | undefined { + let matchedEcho: string | undefined; + + for (const { echoes } of this.#activeEchoes.values()) { + for (const echo of echoes) { + if (!buffer.startsWith(echo, index)) { + continue; + } + + invariant( + matchedEcho === undefined || matchedEcho === echo, + 'postamble echo matches must be unambiguous', + ); + matchedEcho = echo; + } + } + + return matchedEcho; + } + + #hasPartialEchoMatch(remaining: string): boolean { + for (const { echoes } of this.#activeEchoes.values()) { + for (const echo of echoes) { + if (remaining.length < echo.length && echo.startsWith(remaining)) { + return true; + } + } + } + + return false; + } + + #setPendingTail(tail: string): void { + invariant(tail.length > 0, 'postamble echo pending tail must not be empty'); + invariant( + this.hasActiveEchoes(), + 'postamble echo pending tail requires active echoes', + ); + + this.#pendingTail = tail; + this.#assertPendingTailBound(); + } + + #maxActiveEchoLength(): number { + let maxLength = 0; + + for (const { echoes } of this.#activeEchoes.values()) { + for (const echo of echoes) { + maxLength = Math.max(maxLength, echo.length); + } + } + + invariant(maxLength > 0, 'max active echo length requires active echoes'); + return maxLength; + } + + #assertPendingTailBound(): void { + if (!this.hasActiveEchoes()) { + invariant( + this.#pendingTail.length === 0, + 'postamble echo pending tail must be empty without active echoes', + ); + return; + } + + invariant( + this.#pendingTail.length < this.#maxActiveEchoLength(), + 'postamble echo pending tail must be shorter than the longest active echo', + ); + } +} + export class RunCompletionSentinelScanner { readonly #activeSentinels = new Map(); - #nextOrder = 0; #pendingTail = ''; /** @@ -61,7 +418,7 @@ export class RunCompletionSentinelScanner { * activates it again for a future run. */ public register(marker: string): void { - assertNonEmptyString(marker, 'marker'); + assertRunMarker(marker); if (this.#activeSentinels.has(marker)) { return; @@ -70,9 +427,7 @@ export class RunCompletionSentinelScanner { this.#activeSentinels.set(marker, { marker, sentinel: buildRunCompleteSentinel(marker), - order: this.#nextOrder, }); - this.#nextOrder += 1; this.#assertPendingTailBound(); } @@ -134,24 +489,13 @@ export class RunCompletionSentinelScanner { break; } - const candidates = this.#sortedActiveSentinels(); - const completeMatches = candidates.filter(({ sentinel }) => - buffer.startsWith(sentinel, index), - ); - - if (completeMatches.length > 0) { - const matched = completeMatches[0]; - invariant(matched !== undefined, 'complete match must exist'); - - if ( - !isFinal && - this.#hasLongerPossibleMatch(candidates, matched, buffer, index) - ) { - pushOutputPiece(pieces, buffer.slice(outputStart, index)); - this.#setPendingTail(buffer.slice(index)); - return pieces; - } + if (buffer.charCodeAt(index) !== ESC_CODE) { + index += 1; + continue; + } + const matched = this.#findCompleteSentinelMatch(buffer, index); + if (matched !== undefined) { pushOutputPiece(pieces, buffer.slice(outputStart, index)); pieces.push({ type: 'run_complete', marker: matched.marker }); @@ -164,15 +508,7 @@ export class RunCompletionSentinelScanner { } const remaining = buffer.slice(index); - const hasPartialMatch = - !isFinal && - candidates.some( - ({ sentinel }) => - remaining.length < sentinel.length && - sentinel.startsWith(remaining), - ); - - if (hasPartialMatch) { + if (!isFinal && this.#hasPartialSentinelMatch(remaining)) { pushOutputPiece(pieces, buffer.slice(outputStart, index)); this.#setPendingTail(remaining); return pieces; @@ -186,30 +522,38 @@ export class RunCompletionSentinelScanner { return pieces; } - #hasLongerPossibleMatch( - candidates: ActiveSentinel[], - matched: ActiveSentinel, + #findCompleteSentinelMatch( buffer: string, index: number, - ): boolean { - const remaining = buffer.slice(index); + ): ActiveSentinel | undefined { + let matched: ActiveSentinel | undefined; - return candidates.some( - ({ sentinel }) => - sentinel.length > matched.sentinel.length && - remaining.length < sentinel.length && - sentinel.startsWith(remaining), - ); + for (const activeSentinel of this.#activeSentinels.values()) { + if (!buffer.startsWith(activeSentinel.sentinel, index)) { + continue; + } + + invariant( + matched === undefined, + 'fixed-length run sentinels must match at most one active marker', + ); + matched = activeSentinel; + } + + return matched; } - #sortedActiveSentinels(): ActiveSentinel[] { - return [...this.#activeSentinels.values()].sort((left, right) => { - const lengthDiff = right.sentinel.length - left.sentinel.length; - if (lengthDiff !== 0) { - return lengthDiff; + #hasPartialSentinelMatch(remaining: string): boolean { + for (const { sentinel } of this.#activeSentinels.values()) { + if ( + remaining.length < sentinel.length && + sentinel.startsWith(remaining) + ) { + return true; } - return left.order - right.order; - }); + } + + return false; } #setPendingTail(tail: string): void { diff --git a/test/integration/run.test.ts b/test/integration/run.test.ts index 3defde7..0c8eb9b 100644 --- a/test/integration/run.test.ts +++ b/test/integration/run.test.ts @@ -301,6 +301,97 @@ describe('run command integration', { timeout: 45_000 }, () => { ).toBe(false); }); + it('preserves command output in line-discipline echo shells', async () => { + sessionId = createSession(testHome, ['/bin/sh']); + await sleep(1000); + + const result = runCli( + [ + 'run', + sessionId, + "printf 'dash-before-proof\\n'; sleep 0.1; printf 'dash-after-proof\\n'", + '--timeout', + '15000', + '--json', + ], + testEnv(), + 30_000, + ); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(''); + + const envelope = JSON.parse(result.stdout) as SuccessEnvelope<{ + accepted: true; + completed: boolean; + timedOut: boolean; + seq: number; + durationMs: number; + marker: string; + }>; + expect(envelope.ok).toBe(true); + expect(envelope.result.accepted).toBe(true); + expect(envelope.result.completed).toBe(true); + expect(envelope.result.timedOut).toBe(false); + const marker = envelope.result.marker; + expectRunMarker(marker); + + const events = await readEvents(testHome, sessionId); + const outputText = collectOutputText(events); + expect(outputText).toContain('dash-before-proof'); + expect(outputText).toContain('dash-after-proof'); + expectCompletionArtifactsClean(outputText, marker); + }); + + it('keeps later output visible after a timed-out line-discipline echo run', async () => { + sessionId = createSession(testHome, ['/bin/sh']); + await sleep(1000); + + const result = runCli( + ['run', sessionId, 'cat', '--timeout', '300', '--json'], + testEnv(), + 30_000, + ); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(''); + + const envelope = JSON.parse(result.stdout) as SuccessEnvelope<{ + accepted: true; + completed: boolean; + timedOut: boolean; + seq: number; + durationMs: number; + marker: string; + }>; + expect(envelope.ok).toBe(true); + expect(envelope.result.accepted).toBe(true); + expect(envelope.result.completed).toBe(false); + expect(envelope.result.timedOut).toBe(true); + const marker = envelope.result.marker; + expectRunMarker(marker); + + const typeResult = runCli( + [ + 'type', + sessionId, + 'timeout-still-visible', + '--append-newline', + '--json', + ], + testEnv(), + 30_000, + ); + expect(typeResult.status).toBe(0); + expect(typeResult.stderr).toBe(''); + await sleep(500); + + const events = await readEvents(testHome, sessionId); + const outputText = collectOutputText(events); + expect(outputText).toContain('timeout-still-visible'); + expectCompletionArtifactsClean(outputText, marker); + }); + it('detects session exit during wait before timing out', async () => { sessionId = createSession(testHome, [ '/bin/sh', diff --git a/test/unit/host/runCompletionSentinel.test.ts b/test/unit/host/runCompletionSentinel.test.ts index cb3ea49..d7c5964 100644 --- a/test/unit/host/runCompletionSentinel.test.ts +++ b/test/unit/host/runCompletionSentinel.test.ts @@ -4,10 +4,15 @@ import { buildRunCompleteSentinel, RUN_COMPLETE_SENTINEL_PREFIX, RUN_COMPLETE_SENTINEL_SUFFIX, + RunCompletionPostambleEchoSanitizer, RunCompletionSentinelScanner, } from '../../../src/host/runCompletionSentinel.js'; import type { SentinelPiece } from '../../../src/host/runCompletionSentinel.js'; +function runMarker(value: number): string { + return `__AT_MARKER_${value.toString(16).padStart(32, '0')}__`; +} + function feedChunks( scanner: RunCompletionSentinelScanner, chunks: string[], @@ -35,26 +40,125 @@ function oneCodeUnitChunks(data: string): string[] { return chunks; } +function postamble(marker: string): string { + return `printf '${marker}'\n`; +} + describe('buildRunCompleteSentinel', () => { it('returns the expected APC-framed sentinel bytes', () => { - expect(buildRunCompleteSentinel('__AT_MARKER_123')).toBe( - '\x1b_agent-tty:run-complete:__AT_MARKER_123\x1b\\', + const marker = runMarker(1); + + expect(buildRunCompleteSentinel(marker)).toBe( + `\x1b_agent-tty:run-complete:${marker}\x1b\\`, ); expect(RUN_COMPLETE_SENTINEL_PREFIX).toBe('\x1b_agent-tty:run-complete:'); expect(RUN_COMPLETE_SENTINEL_SUFFIX).toBe('\x1b\\'); }); - it('rejects empty markers', () => { + it('rejects non-production marker formats', () => { expect(() => buildRunCompleteSentinel('')).toThrow( - 'marker must be a non-empty string', + 'run marker must match expected format', + ); + expect(() => buildRunCompleteSentinel('__AT_MARKER_123__')).toThrow( + 'run marker must match expected format', ); }); }); +describe('RunCompletionPostambleEchoSanitizer', () => { + it('removes an exact CRLF postamble echo without suppressing later output', () => { + const sanitizer = new RunCompletionPostambleEchoSanitizer(); + const marker = runMarker(10); + const echo = postamble(marker); + sanitizer.register(marker, echo); + + expect( + sanitizer.feed( + `command echo\r\n${echo.replace(/\n$/u, '\r\n')}user output\n`, + ), + ).toBe('command echo\r\nuser output\n'); + expect(sanitizer.feed('more output\n')).toBe('more output\n'); + }); + + it('removes an exact LF postamble echo', () => { + const sanitizer = new RunCompletionPostambleEchoSanitizer(); + const marker = runMarker(11); + const echo = postamble(marker); + sanitizer.register(marker, echo); + + expect(sanitizer.feed(`before${echo}after`)).toBe('beforeafter'); + }); + + it('removes postamble echoes split across chunks', () => { + const sanitizer = new RunCompletionPostambleEchoSanitizer(); + const marker = runMarker(12); + const echo = postamble(marker).replace(/\n$/u, '\r\n'); + sanitizer.register(marker, postamble(marker)); + + const split = "printf '".length + 8; + expect(sanitizer.feed(`before${echo.slice(0, split)}`)).toBe('before'); + expect(sanitizer.feed(`${echo.slice(split)}after`)).toBe('after'); + }); + + it('preserves interleaved command output while stripping postamble echo bytes', () => { + const sanitizer = new RunCompletionPostambleEchoSanitizer(); + const marker = runMarker(120); + const echo = postamble(marker).replace(/\n$/u, '\r\n'); + sanitizer.register(marker, postamble(marker)); + + const split = 24; + expect( + sanitizer.feed( + `${echo.slice(0, split)}visible-output\n${echo.slice(split)}`, + ), + ).toBe('visible-output\n'); + }); + + it('drops line-editor control sequences interleaved into postamble echo', () => { + const sanitizer = new RunCompletionPostambleEchoSanitizer(); + const marker = runMarker(121); + const echo = postamble(marker).replace(/\n$/u, '\r\n'); + sanitizer.register(marker, postamble(marker)); + + const split = 24; + expect( + sanitizer.feed(`${echo.slice(0, split)}\x1b[A\x1b[K${echo.slice(split)}`), + ).toBe(''); + }); + + it('removes repeated exact postamble text while the marker remains active', () => { + const sanitizer = new RunCompletionPostambleEchoSanitizer(); + const marker = runMarker(13); + const echo = postamble(marker).replace(/\n$/u, '\r\n'); + sanitizer.register(marker, postamble(marker)); + + expect(sanitizer.feed(`${echo}visible${echo}`)).toBe('visible'); + }); + + it('flushes a pending partial postamble when its marker is deregistered', () => { + const sanitizer = new RunCompletionPostambleEchoSanitizer(); + const marker = runMarker(14); + const echo = postamble(marker); + sanitizer.register(marker, echo); + + expect(sanitizer.feed(`visible${echo.slice(0, 7)}`)).toBe('visible'); + expect(sanitizer.deregister(marker)).toBe(echo.slice(0, 7)); + expect(sanitizer.hasActiveEchoes()).toBe(false); + }); + + it('passes data through unchanged when no postamble echoes are active', () => { + const sanitizer = new RunCompletionPostambleEchoSanitizer(); + const data = `before${postamble(runMarker(15))}after`; + + expect(sanitizer.feed(data)).toBe(data); + expect(sanitizer.flush()).toBe(''); + }); +}); + describe('RunCompletionSentinelScanner', () => { it('matches a sentinel fully contained in one chunk with output around it', () => { const scanner = new RunCompletionSentinelScanner(); - const marker = '__AT_MARKER_one_chunk'; + const marker = runMarker(20); scanner.register(marker); expect( @@ -72,17 +176,17 @@ describe('RunCompletionSentinelScanner', () => { ['inside prefix', 1], [ 'inside marker payload', - RUN_COMPLETE_SENTINEL_PREFIX.length + '__AT_MARKER_split'.length - 3, + RUN_COMPLETE_SENTINEL_PREFIX.length + runMarker(21).length - 3, ], [ 'inside suffix', - RUN_COMPLETE_SENTINEL_PREFIX.length + '__AT_MARKER_split'.length + 1, + RUN_COMPLETE_SENTINEL_PREFIX.length + runMarker(21).length + 1, ], ])( 'matches a sentinel split across two chunks with boundary %s', (_, split) => { const scanner = new RunCompletionSentinelScanner(); - const marker = '__AT_MARKER_split'; + const marker = runMarker(21); const sentinel = buildRunCompleteSentinel(marker); scanner.register(marker); @@ -96,7 +200,7 @@ describe('RunCompletionSentinelScanner', () => { it('matches a sentinel split one byte at a time across the full frame', () => { const scanner = new RunCompletionSentinelScanner(); - const marker = '__AT_MARKER_bytewise'; + const marker = runMarker(22); scanner.register(marker); const pieces = [ @@ -118,8 +222,8 @@ describe('RunCompletionSentinelScanner', () => { it('completes multiple active markers in input order without cross-matching', () => { const scanner = new RunCompletionSentinelScanner(); - const firstMarker = '__AT_MARKER_A'; - const secondMarker = '__AT_MARKER_AB'; + const firstMarker = runMarker(23); + const secondMarker = runMarker(24); scanner.register(firstMarker); scanner.register(secondMarker); @@ -145,8 +249,8 @@ describe('RunCompletionSentinelScanner', () => { it('keeps inactive or unknown sentinel-like bytes in output', () => { const scanner = new RunCompletionSentinelScanner(); - const marker = '__AT_MARKER_known'; - const unknownSentinel = buildRunCompleteSentinel('__AT_MARKER_unknown'); + const marker = runMarker(25); + const unknownSentinel = buildRunCompleteSentinel(runMarker(26)); const strayApc = '\x1b_random text that is not an active sentinel'; scanner.register(marker); @@ -167,7 +271,7 @@ describe('RunCompletionSentinelScanner', () => { const scanner = new RunCompletionSentinelScanner(); const data = [ 'before', - buildRunCompleteSentinel('__AT_MARKER_inactive'), + buildRunCompleteSentinel(runMarker(27)), '\x1b_random', 'after', ].join(''); @@ -178,7 +282,7 @@ describe('RunCompletionSentinelScanner', () => { it('does not leak active sentinel bytes into output pieces', () => { const scanner = new RunCompletionSentinelScanner(); - const marker = '__AT_MARKER_secret'; + const marker = runMarker(28); const sentinel = buildRunCompleteSentinel(marker); scanner.register(marker); @@ -208,7 +312,7 @@ describe('RunCompletionSentinelScanner', () => { it('flushes a pending non-sentinel tail once', () => { const scanner = new RunCompletionSentinelScanner(); - const marker = '__AT_MARKER_flush'; + const marker = runMarker(29); scanner.register(marker); expect( @@ -222,7 +326,7 @@ describe('RunCompletionSentinelScanner', () => { it('passes the same sentinel through as output after deactivation', () => { const scanner = new RunCompletionSentinelScanner(); - const marker = '__AT_MARKER_once'; + const marker = runMarker(30); const sentinel = buildRunCompleteSentinel(marker); scanner.register(marker); @@ -232,37 +336,17 @@ describe('RunCompletionSentinelScanner', () => { ]); }); - it('waits for a longer active sentinel when one frame prefixes another', () => { + it('rejects non-production markers so prefix-overlap cases are impossible', () => { const scanner = new RunCompletionSentinelScanner(); - const shortMarker = 'prefix'; - const longMarker = `prefix${RUN_COMPLETE_SENTINEL_SUFFIX}tail`; - scanner.register(shortMarker); - scanner.register(longMarker); - - expect(scanner.feed(buildRunCompleteSentinel(shortMarker))).toEqual([]); - expect(scanner.feed(`tail${RUN_COMPLETE_SENTINEL_SUFFIX}`)).toEqual([ - { type: 'run_complete', marker: longMarker }, - ]); - expect(scanner.hasActiveMarkers()).toBe(true); - }); - it('emits a shorter complete sentinel on flush if no longer frame arrives', () => { - const scanner = new RunCompletionSentinelScanner(); - const shortMarker = 'prefix'; - const longMarker = `prefix${RUN_COMPLETE_SENTINEL_SUFFIX}tail`; - scanner.register(shortMarker); - scanner.register(longMarker); - - expect(scanner.feed(buildRunCompleteSentinel(shortMarker))).toEqual([]); - expect(scanner.flush()).toEqual([ - { type: 'run_complete', marker: shortMarker }, - ]); - expect(scanner.hasActiveMarkers()).toBe(true); + expect(() => scanner.register('prefix')).toThrow( + 'run marker must match expected format', + ); }); it('reports whether active markers remain', () => { const scanner = new RunCompletionSentinelScanner(); - const marker = '__AT_MARKER_active'; + const marker = runMarker(31); expect(scanner.hasActiveMarkers()).toBe(false); scanner.register(marker); @@ -276,7 +360,7 @@ describe('RunCompletionSentinelScanner', () => { it('preserves unknown output data when an active marker remains registered', () => { const scanner = new RunCompletionSentinelScanner(); - const marker = '__AT_MARKER_registered'; + const marker = runMarker(32); const unknownData = `${RUN_COMPLETE_SENTINEL_PREFIX}not-${marker}`; scanner.register(marker); diff --git a/test/unit/renderer/libghosttyVtBackend.test.ts b/test/unit/renderer/libghosttyVtBackend.test.ts index 9630eac..1de9c7c 100644 --- a/test/unit/renderer/libghosttyVtBackend.test.ts +++ b/test/unit/renderer/libghosttyVtBackend.test.ts @@ -254,6 +254,51 @@ describe('LibghosttyVtBackend', () => { }); }); + it('skips run_complete events during replay', async () => { + const fixture = createNativeFixture(); + const backend = createBackend(fixture); + + await backend.boot(); + const state = await backend.replayTo( + createReplayInput({ + targetSeq: 3, + events: [ + { + seq: 0, + ts: '2026-03-20T12:00:00.000Z', + type: 'output', + payload: { data: 'hello' }, + }, + { + seq: 1, + ts: '2026-03-20T12:00:00.100Z', + type: 'run_complete', + payload: { + marker: '__AT_MARKER_00000000000000000000000000000001__', + }, + }, + { + seq: 2, + ts: '2026-03-20T12:00:00.200Z', + type: 'resize', + payload: { cols: 12, rows: 5 }, + }, + { + seq: 3, + ts: '2026-03-20T12:00:00.300Z', + type: 'output', + payload: { data: ' world' }, + }, + ], + }), + ); + + expect(fixture.feed).toHaveBeenCalledTimes(2); + expect(fixture.feed).toHaveBeenNthCalledWith(1, 'hello'); + expect(fixture.feed).toHaveBeenNthCalledWith(2, ' world'); + expect(state.lastSeq).toBe(3); + }); + it('maps native snapshots into semantic snapshots', async () => { const fixture = createNativeFixture(); const backend = createBackend(fixture); From 95ae7b4b293b935ebcf4ccde334e1fac1762b709 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 28 Apr 2026 11:40:33 +0000 Subject: [PATCH 09/14] fix: tolerate readline controls in run echo sanitizer --- src/host/runCompletionSentinel.ts | 190 ++++++++++++++++--- test/unit/host/runCompletionSentinel.test.ts | 44 +++++ 2 files changed, 205 insertions(+), 29 deletions(-) diff --git a/src/host/runCompletionSentinel.ts b/src/host/runCompletionSentinel.ts index 33d04a0..f9bd19c 100644 --- a/src/host/runCompletionSentinel.ts +++ b/src/host/runCompletionSentinel.ts @@ -17,9 +17,14 @@ export const RUN_COMPLETE_SENTINEL_SUFFIX = '\x1b\\'; export const RUN_MARKER_PATTERN = /^__AT_MARKER_([0-9a-f]{32})__$/u; const MIN_TOLERANT_ECHO_PREFIX_LENGTH = String.raw`printf '\033\137`.length; +const MAX_SKIPPABLE_ECHO_CONTROL_LENGTH = 64; const ESC_CODE = 0x1b; const POSTAMBLE_ECHO_START_CODE = 'p'.charCodeAt(0); +type SkippableControlMatch = + | { kind: 'complete'; length: number } + | { kind: 'partial' }; + export type SentinelPiece = | { type: 'output'; data: string } | { type: 'run_complete'; marker: string }; @@ -59,18 +64,106 @@ function assertNonEmptyString(value: string, label: string): void { ); } -function commonPrefixLength(left: string, right: string): number { - const maxLength = Math.min(left.length, right.length); - let index = 0; +interface EchoPrefixScanResult { + complete: boolean; + consumedLength: number; + echoIndex: number; + pending: boolean; +} + +interface TolerantEchoPrefixMatch { + candidates: TolerantEchoCandidate[]; + completed: boolean; + consumedLength: number; +} + +function isCsiFinalByte(code: number): boolean { + return code >= 0x40 && code <= 0x7e; +} + +function findSkippableEchoControl( + buffer: string, + index: number, +): SkippableControlMatch | undefined { + invariant(typeof buffer === 'string', 'buffer must be a string'); + invariant( + Number.isInteger(index) && index >= 0 && index < buffer.length, + 'control scan index must point inside the buffer', + ); + + if (buffer.charCodeAt(index) !== ESC_CODE) { + return undefined; + } + + if (index + 1 >= buffer.length) { + return { kind: 'partial' }; + } + + if (buffer.charAt(index + 1) !== '[') { + return { kind: 'complete', length: 2 }; + } + + const controlEnd = Math.min( + buffer.length, + index + MAX_SKIPPABLE_ECHO_CONTROL_LENGTH, + ); + for (let cursor = index + 2; cursor < controlEnd; cursor += 1) { + if (isCsiFinalByte(buffer.charCodeAt(cursor))) { + return { kind: 'complete', length: cursor - index + 1 }; + } + } + + if (controlEnd < buffer.length) { + return undefined; + } + + return { kind: 'partial' }; +} + +function scanEchoPrefixToleratingControls( + remaining: string, + echo: string, +): EchoPrefixScanResult { + invariant(typeof remaining === 'string', 'remaining buffer must be a string'); + assertNonEmptyString(echo, 'echo'); + + let bufferIndex = 0; + let echoIndex = 0; + + while (bufferIndex < remaining.length && echoIndex < echo.length) { + const control = findSkippableEchoControl(remaining, bufferIndex); + if (control?.kind === 'partial') { + return { + complete: false, + consumedLength: bufferIndex, + echoIndex, + pending: true, + }; + } + if (control?.kind === 'complete') { + bufferIndex += control.length; + continue; + } + + if (remaining.charCodeAt(bufferIndex) !== echo.charCodeAt(echoIndex)) { + return { + complete: false, + consumedLength: bufferIndex, + echoIndex, + pending: false, + }; + } - while ( - index < maxLength && - left.charCodeAt(index) === right.charCodeAt(index) - ) { - index += 1; + bufferIndex += 1; + echoIndex += 1; } - return index; + return { + complete: echoIndex === echo.length, + consumedLength: bufferIndex, + echoIndex, + pending: bufferIndex === remaining.length && echoIndex < echo.length, + }; } function pushOutputPiece(pieces: SentinelPiece[], data: string): void { @@ -100,9 +193,10 @@ export function buildRunCompleteSentinel(marker: string): string { * Removes the shell's echo of agent-tty's injected completion postamble while * preserving command output that can arrive between echoed postamble bytes. * Canonical TTY echo and readline repainting can interleave command output or - * cursor controls into the echoed postamble, so after a long exact prefix match - * this sanitizer drops only the remaining expected postamble bytes and known - * CSI repaint controls; nonmatching bytes continue through as user output. + * cursor controls into the echoed postamble, so after a long active-postamble + * prefix match (tolerating known CSI repaint controls) this sanitizer drops only + * the remaining expected postamble bytes and controls; nonmatching bytes + * continue through as user output. */ export class RunCompletionPostambleEchoSanitizer { readonly #activeEchoes = new Map(); @@ -227,12 +321,14 @@ export class RunCompletionPostambleEchoSanitizer { const tolerantMatch = this.#findTolerantEchoPrefixMatch(remaining); if (tolerantMatch !== undefined) { output += buffer.slice(outputStart, index); - this.#tolerantStripState = { - candidates: tolerantMatch.candidates, - dropControl: null, - }; - index += tolerantMatch.prefixLength; + index += tolerantMatch.consumedLength; outputStart = index; + if (!tolerantMatch.completed) { + this.#tolerantStripState = { + candidates: tolerantMatch.candidates, + dropControl: null, + }; + } continue; } @@ -301,31 +397,57 @@ export class RunCompletionPostambleEchoSanitizer { #findTolerantEchoPrefixMatch( remaining: string, - ): { candidates: TolerantEchoCandidate[]; prefixLength: number } | undefined { - let bestPrefixLength = 0; + ): TolerantEchoPrefixMatch | undefined { + let completedConsumedLength: number | undefined; + let bestEchoIndex = 0; + let bestConsumedLength = 0; let candidates: TolerantEchoCandidate[] = []; for (const [marker, { echoes }] of this.#activeEchoes) { for (const echo of echoes) { - const prefixLength = commonPrefixLength(remaining, echo); - if (prefixLength < MIN_TOLERANT_ECHO_PREFIX_LENGTH) { + const scan = scanEchoPrefixToleratingControls(remaining, echo); + if (scan.complete) { + invariant( + scan.echoIndex === echo.length, + 'complete postamble echo scan must consume the full echo', + ); + completedConsumedLength = Math.max( + completedConsumedLength ?? 0, + scan.consumedLength, + ); continue; } - if (prefixLength === echo.length) { + if (scan.echoIndex < MIN_TOLERANT_ECHO_PREFIX_LENGTH) { continue; } - if (prefixLength > bestPrefixLength) { - bestPrefixLength = prefixLength; + if ( + scan.echoIndex > bestEchoIndex || + (scan.echoIndex === bestEchoIndex && + scan.consumedLength > bestConsumedLength) + ) { + bestEchoIndex = scan.echoIndex; + bestConsumedLength = scan.consumedLength; candidates = []; } - if (prefixLength === bestPrefixLength) { - candidates.push({ echo, index: prefixLength, marker }); + if ( + scan.echoIndex === bestEchoIndex && + scan.consumedLength === bestConsumedLength + ) { + candidates.push({ echo, index: scan.echoIndex, marker }); } } } - if (bestPrefixLength === 0) { + if (completedConsumedLength !== undefined) { + return { + candidates: [], + completed: true, + consumedLength: completedConsumedLength, + }; + } + + if (bestEchoIndex === 0) { return undefined; } @@ -333,7 +455,11 @@ export class RunCompletionPostambleEchoSanitizer { candidates.length > 0, 'tolerant postamble echo prefix match must have candidates', ); - return { candidates, prefixLength: bestPrefixLength }; + return { + candidates, + completed: false, + consumedLength: bestConsumedLength, + }; } #findCompleteEchoMatch(buffer: string, index: number): string | undefined { @@ -357,9 +483,15 @@ export class RunCompletionPostambleEchoSanitizer { } #hasPartialEchoMatch(remaining: string): boolean { + const maxEchoLength = this.#maxActiveEchoLength(); + if (remaining.length >= maxEchoLength) { + return false; + } + for (const { echoes } of this.#activeEchoes.values()) { for (const echo of echoes) { - if (remaining.length < echo.length && echo.startsWith(remaining)) { + const scan = scanEchoPrefixToleratingControls(remaining, echo); + if (scan.pending && scan.echoIndex > 0) { return true; } } diff --git a/test/unit/host/runCompletionSentinel.test.ts b/test/unit/host/runCompletionSentinel.test.ts index d7c5964..5a3c3e9 100644 --- a/test/unit/host/runCompletionSentinel.test.ts +++ b/test/unit/host/runCompletionSentinel.test.ts @@ -44,6 +44,16 @@ function postamble(marker: string): string { return `printf '${marker}'\n`; } +function shellOctalEscapedBytes(value: string): string { + return [...Buffer.from(value, 'utf8')] + .map((byte) => `\\${byte.toString(8).padStart(3, '0')}`) + .join(''); +} + +function productionLikePostamble(marker: string): string { + return `printf '${shellOctalEscapedBytes(buildRunCompleteSentinel(marker))}'\n`; +} + describe('buildRunCompleteSentinel', () => { it('returns the expected APC-framed sentinel bytes', () => { const marker = runMarker(1); @@ -126,6 +136,40 @@ describe('RunCompletionPostambleEchoSanitizer', () => { ).toBe(''); }); + it('drops line-editor control sequences inserted before the old tolerant prefix threshold', () => { + const sanitizer = new RunCompletionPostambleEchoSanitizer(); + const marker = runMarker(122); + const postambleText = productionLikePostamble(marker); + const echo = postambleText.replace(/\n$/u, '\r\n'); + sanitizer.register(marker, postambleText); + + const split = 'pri'.length; + expect( + sanitizer.feed(`${echo.slice(0, split)}\x1b[K${echo.slice(split)}`), + ).toBe(''); + }); + + it('drops line-editor control sequences split across chunks before the tolerant prefix threshold', () => { + const sanitizer = new RunCompletionPostambleEchoSanitizer(); + const marker = runMarker(123); + const postambleText = productionLikePostamble(marker); + const echo = postambleText.replace(/\n$/u, '\r\n'); + sanitizer.register(marker, postambleText); + + const split = "printf '".length; + expect(sanitizer.feed(`${echo.slice(0, split)}\x1b[`)).toBe(''); + expect(sanitizer.feed(`K${echo.slice(split)}`)).toBe(''); + }); + + it('preserves printf-like output that diverges before the tolerant prefix threshold', () => { + const sanitizer = new RunCompletionPostambleEchoSanitizer(); + const marker = runMarker(124); + sanitizer.register(marker, productionLikePostamble(marker)); + + const output = "printf 'hello'\r\n"; + expect(sanitizer.feed(output)).toBe(output); + }); + it('removes repeated exact postamble text while the marker remains active', () => { const sanitizer = new RunCompletionPostambleEchoSanitizer(); const marker = runMarker(13); From db5b578ced73e9341809d931afae67b1a298ca2f Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 28 Apr 2026 11:54:18 +0000 Subject: [PATCH 10/14] fix: handle wrapped run postamble echoes --- src/host/runCompletionSentinel.ts | 15 ++++++++++--- test/unit/host/runCompletionSentinel.test.ts | 22 ++++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/src/host/runCompletionSentinel.ts b/src/host/runCompletionSentinel.ts index f9bd19c..3b8432b 100644 --- a/src/host/runCompletionSentinel.ts +++ b/src/host/runCompletionSentinel.ts @@ -18,6 +18,7 @@ export const RUN_MARKER_PATTERN = /^__AT_MARKER_([0-9a-f]{32})__$/u; const MIN_TOLERANT_ECHO_PREFIX_LENGTH = String.raw`printf '\033\137`.length; const MAX_SKIPPABLE_ECHO_CONTROL_LENGTH = 64; +const CARRIAGE_RETURN_CODE = 0x0d; const ESC_CODE = 0x1b; const POSTAMBLE_ECHO_START_CODE = 'p'.charCodeAt(0); @@ -91,6 +92,10 @@ function findSkippableEchoControl( 'control scan index must point inside the buffer', ); + if (buffer.charCodeAt(index) === CARRIAGE_RETURN_CODE) { + return { kind: 'complete', length: 1 }; + } + if (buffer.charCodeAt(index) !== ESC_CODE) { return undefined; } @@ -194,9 +199,9 @@ export function buildRunCompleteSentinel(marker: string): string { * preserving command output that can arrive between echoed postamble bytes. * Canonical TTY echo and readline repainting can interleave command output or * cursor controls into the echoed postamble, so after a long active-postamble - * prefix match (tolerating known CSI repaint controls) this sanitizer drops only - * the remaining expected postamble bytes and controls; nonmatching bytes - * continue through as user output. + * prefix match (tolerating known CSI repaint controls and line-wrap carriage + * returns) this sanitizer drops only the remaining expected postamble bytes and + * controls; nonmatching bytes continue through as user output. */ export class RunCompletionPostambleEchoSanitizer { readonly #activeEchoes = new Map(); @@ -368,6 +373,10 @@ export class RunCompletionPostambleEchoSanitizer { return { nextIndex: index + 1, output: '' }; } + if (char === '\r') { + return { nextIndex: index + 1, output: '' }; + } + if (char === '\x1b') { state.dropControl = 'escape'; return { nextIndex: index + 1, output: '' }; diff --git a/test/unit/host/runCompletionSentinel.test.ts b/test/unit/host/runCompletionSentinel.test.ts index 5a3c3e9..5042355 100644 --- a/test/unit/host/runCompletionSentinel.test.ts +++ b/test/unit/host/runCompletionSentinel.test.ts @@ -161,6 +161,28 @@ describe('RunCompletionPostambleEchoSanitizer', () => { expect(sanitizer.feed(`K${echo.slice(split)}`)).toBe(''); }); + it('drops terminal line-wrap carriage returns inserted into the postamble echo', () => { + const sanitizer = new RunCompletionPostambleEchoSanitizer(); + const marker = runMarker(125); + const postambleText = productionLikePostamble(marker); + const echo = postambleText.replace(/\n$/u, '\r\n'); + sanitizer.register(marker, postambleText); + + const split = String.raw`printf '\03`.length; + expect( + sanitizer.feed(`${echo.slice(0, split)}\r${echo.slice(split)}`), + ).toBe(''); + }); + + it('preserves printf-like output with carriage returns that diverges before the tolerant prefix threshold', () => { + const sanitizer = new RunCompletionPostambleEchoSanitizer(); + const marker = runMarker(126); + sanitizer.register(marker, productionLikePostamble(marker)); + + const output = "pri\rntf 'hello'\r\n"; + expect(sanitizer.feed(output)).toBe(output); + }); + it('preserves printf-like output that diverges before the tolerant prefix threshold', () => { const sanitizer = new RunCompletionPostambleEchoSanitizer(); const marker = runMarker(124); From d5ff50f6143f328becedfa9d775c3847a9d98b8b Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 28 Apr 2026 12:12:31 +0000 Subject: [PATCH 11/14] fix: shorten run completion postamble echo --- src/host/hostMain.ts | 48 ++++++++++++++++---- src/host/runCompletionSentinel.ts | 19 +++++++- test/unit/host/runCompletionSentinel.test.ts | 28 ++++++++++++ 3 files changed, 83 insertions(+), 12 deletions(-) diff --git a/src/host/hostMain.ts b/src/host/hostMain.ts index 3c6f024..c8d837d 100644 --- a/src/host/hostMain.ts +++ b/src/host/hostMain.ts @@ -10,7 +10,7 @@ import { buildReplayInput } from './replay.js'; import { HostRendererManager } from './renderer.js'; import { RpcServer, type MethodHandler } from './rpcServer.js'; import { - buildRunCompleteSentinel, + buildRunCompleteSignalSentinel, RUN_MARKER_PATTERN, RunCompletionPostambleEchoSanitizer, RunCompletionSentinelScanner, @@ -110,6 +110,9 @@ type TimedRunCompletionWaitResult = const RUN_COMPLETION_POSTAMBLE_ECHO_PREFIX = String.raw`printf '\033\137`; +const RUN_COMPLETION_SIGNAL_TOKEN_BYTES = 4; +const MAX_RUN_COMPLETION_POSTAMBLE_ECHO_LENGTH = 64; + function normalizeExitSignal(signal: number | null): string | null { invariant( signal === null || (Number.isInteger(signal) && signal >= 0), @@ -137,9 +140,25 @@ function shellOctalEscapedBytes(value: string): string { .join(''); } -function buildRunCompletePostamble(marker: string): string { +function generateRunCompleteSignalSentinel(): string { + const token = crypto + .randomBytes(RUN_COMPLETION_SIGNAL_TOKEN_BYTES) + .toString('base64url'); + invariant( + token.length === 6, + 'run-completion signal token must encode to six base64url characters', + ); + + return buildRunCompleteSignalSentinel(token); +} + +function buildRunCompletePostamble(marker: string, sentinel: string): string { const markerMatch = RUN_MARKER_PATTERN.exec(marker); invariant(markerMatch !== null, 'run marker must match expected format'); + invariant( + typeof sentinel === 'string' && sentinel.length > 0, + 'sentinel must be non-empty', + ); const markerPayload = markerMatch[1]; invariant( @@ -147,9 +166,11 @@ function buildRunCompletePostamble(marker: string): string { 'run marker payload must be 32 lowercase hex characters', ); - const postamble = `printf '${shellOctalEscapedBytes( - buildRunCompleteSentinel(marker), - )}'`; + const postamble = `printf '${shellOctalEscapedBytes(sentinel)}'`; + invariant( + postamble.length <= MAX_RUN_COMPLETION_POSTAMBLE_ECHO_LENGTH, + 'run-completion postamble echo must stay short enough to avoid terminal wrapping', + ); invariant( postamble.startsWith(RUN_COMPLETION_POSTAMBLE_ECHO_PREFIX), 'run-completion postamble echo prefix must stay in sync with sanitizer', @@ -524,8 +545,8 @@ export async function runHost(sessionId: string): Promise { 'run-completion sentinel must correspond to an active run marker', ); invariant( - activeCompletion.sentinel === buildRunCompleteSentinel(piece.marker), - 'active run-completion sentinel must match the completed marker', + activeCompletion.sentinel.length > 0, + 'active run-completion sentinel must be non-empty', ); try { @@ -968,8 +989,15 @@ export async function runHost(sessionId: string): Promise { RUN_MARKER_PATTERN.test(marker), 'generated run marker must match expected format', ); - const sentinel = buildRunCompleteSentinel(marker); - const postamble = buildRunCompletePostamble(marker); + let sentinel = generateRunCompleteSignalSentinel(); + while ( + [...activeRunCompletions.values()].some( + (completion) => completion.sentinel === sentinel, + ) + ) { + sentinel = generateRunCompleteSignalSentinel(); + } + const postamble = buildRunCompletePostamble(marker, sentinel); const seq = await eventLog.append('input_run', { command, marker, @@ -981,7 +1009,7 @@ export async function runHost(sessionId: string): Promise { 'generated run marker must be unique among active completions', ); activeRunCompletions.set(marker, { inputRunSeq: seq, sentinel }); - sentinelScanner.register(marker); + sentinelScanner.register(marker, sentinel); postambleEchoSanitizer.register(marker, postamble); const completionPromise = subscribeRunCompletion(marker); const injectedText = `${command}\n${postamble}`; diff --git a/src/host/runCompletionSentinel.ts b/src/host/runCompletionSentinel.ts index 3b8432b..d7d8fbd 100644 --- a/src/host/runCompletionSentinel.ts +++ b/src/host/runCompletionSentinel.ts @@ -14,6 +14,8 @@ import { invariant } from '../util/assert.js'; export const RUN_COMPLETE_SENTINEL_PREFIX = '\x1b_agent-tty:run-complete:'; export const RUN_COMPLETE_SENTINEL_SUFFIX = '\x1b\\'; +const RUN_COMPLETE_SHORT_SENTINEL_PREFIX = '\x1b_at'; +const RUN_COMPLETE_SHORT_SENTINEL_TOKEN_PATTERN = /^[A-Za-z0-9_-]{6}$/u; export const RUN_MARKER_PATTERN = /^__AT_MARKER_([0-9a-f]{32})__$/u; const MIN_TOLERANT_ECHO_PREFIX_LENGTH = String.raw`printf '\033\137`.length; @@ -188,6 +190,15 @@ function postambleEchoVariants(postamble: string): readonly string[] { return crlfEcho === postamble ? [postamble] : [crlfEcho, postamble]; } +export function buildRunCompleteSignalSentinel(token: string): string { + invariant( + RUN_COMPLETE_SHORT_SENTINEL_TOKEN_PATTERN.test(token), + 'run-completion signal token must be six base64url characters', + ); + + return `${RUN_COMPLETE_SHORT_SENTINEL_PREFIX}${token}${RUN_COMPLETE_SENTINEL_SUFFIX}`; +} + export function buildRunCompleteSentinel(marker: string): string { assertRunMarker(marker); @@ -558,8 +569,12 @@ export class RunCompletionSentinelScanner { * no-op; after the marker completes and deactivates, a later register() call * activates it again for a future run. */ - public register(marker: string): void { + public register( + marker: string, + sentinel = buildRunCompleteSentinel(marker), + ): void { assertRunMarker(marker); + assertNonEmptyString(sentinel, 'run-completion sentinel'); if (this.#activeSentinels.has(marker)) { return; @@ -567,7 +582,7 @@ export class RunCompletionSentinelScanner { this.#activeSentinels.set(marker, { marker, - sentinel: buildRunCompleteSentinel(marker), + sentinel, }); this.#assertPendingTailBound(); } diff --git a/test/unit/host/runCompletionSentinel.test.ts b/test/unit/host/runCompletionSentinel.test.ts index 5042355..d1d0974 100644 --- a/test/unit/host/runCompletionSentinel.test.ts +++ b/test/unit/host/runCompletionSentinel.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { buildRunCompleteSentinel, + buildRunCompleteSignalSentinel, RUN_COMPLETE_SENTINEL_PREFIX, RUN_COMPLETE_SENTINEL_SUFFIX, RunCompletionPostambleEchoSanitizer, @@ -75,6 +76,20 @@ describe('buildRunCompleteSentinel', () => { }); }); +describe('buildRunCompleteSignalSentinel', () => { + it('returns a short APC-framed signal sentinel', () => { + expect(buildRunCompleteSignalSentinel('abc123')).toBe( + '\x1b_atabc123\x1b\\', + ); + }); + + it('rejects invalid signal tokens', () => { + expect(() => buildRunCompleteSignalSentinel('short')).toThrow( + 'run-completion signal token must be six base64url characters', + ); + }); +}); + describe('RunCompletionPostambleEchoSanitizer', () => { it('removes an exact CRLF postamble echo without suppressing later output', () => { const sanitizer = new RunCompletionPostambleEchoSanitizer(); @@ -264,6 +279,19 @@ describe('RunCompletionSentinelScanner', () => { }, ); + it('matches a custom short sentinel registered for a marker', () => { + const scanner = new RunCompletionSentinelScanner(); + const marker = runMarker(210); + const sentinel = buildRunCompleteSignalSentinel('Abc-12'); + scanner.register(marker, sentinel); + + expect(scanner.feed(`before${sentinel}after`)).toEqual([ + { type: 'output', data: 'before' }, + { type: 'run_complete', marker }, + { type: 'output', data: 'after' }, + ]); + }); + it('matches a sentinel split one byte at a time across the full frame', () => { const scanner = new RunCompletionSentinelScanner(); const marker = runMarker(22); From 43e1cc526cd34df362099b453ec88b216489d5f4 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 28 Apr 2026 12:34:16 +0000 Subject: [PATCH 12/14] test: widen run artifact proof sessions --- test/helpers.ts | 17 ++++++++++++++++- test/integration/run.test.ts | 22 ++++++++++++++++++---- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/test/helpers.ts b/test/helpers.ts index c3b45bd..1ad4ea2 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -116,11 +116,26 @@ export async function cleanupHome(home: string): Promise { await rm(home, { recursive: true, force: true }); } +interface CreateSessionOptions { + cols?: number; + rows?: number; +} + export function createSession( testHome: string, command: string[] = ['/bin/sh', '-c', 'exec cat'], + options: CreateSessionOptions = {}, ): string { - const result = runCli(['create', '--json', '--', ...command], { + const args = ['create', '--json']; + if (options.cols !== undefined) { + args.push('--cols', String(options.cols)); + } + if (options.rows !== undefined) { + args.push('--rows', String(options.rows)); + } + args.push('--', ...command); + + const result = runCli(args, { AGENT_TTY_HOME: testHome, }); expect(result.status).toBe(0); diff --git a/test/integration/run.test.ts b/test/integration/run.test.ts index 0c8eb9b..fb10bb0 100644 --- a/test/integration/run.test.ts +++ b/test/integration/run.test.ts @@ -80,6 +80,8 @@ function collectAsciicastOutputFrameText(contents: string): string { .join(''); } +const WIDE_ARTIFACT_PROOF_COLS = 200; + let testHome = ''; let sessionId = ''; @@ -302,7 +304,9 @@ describe('run command integration', { timeout: 45_000 }, () => { }); it('preserves command output in line-discipline echo shells', async () => { - sessionId = createSession(testHome, ['/bin/sh']); + sessionId = createSession(testHome, ['/bin/sh'], { + cols: WIDE_ARTIFACT_PROOF_COLS, + }); await sleep(1000); const result = runCli( @@ -344,7 +348,9 @@ describe('run command integration', { timeout: 45_000 }, () => { }); it('keeps later output visible after a timed-out line-discipline echo run', async () => { - sessionId = createSession(testHome, ['/bin/sh']); + sessionId = createSession(testHome, ['/bin/sh'], { + cols: WIDE_ARTIFACT_PROOF_COLS, + }); await sleep(1000); const result = runCli( @@ -425,7 +431,13 @@ describe('run command integration', { timeout: 45_000 }, () => { }); it('does not log postamble cursor controls when shell echo is disabled', async () => { - sessionId = createSession(testHome, ['/bin/bash', '--noprofile', '--norc']); + sessionId = createSession( + testHome, + ['/bin/bash', '--noprofile', '--norc'], + { + cols: WIDE_ARTIFACT_PROOF_COLS, + }, + ); await sleep(1000); const disableEchoResult = runCli( @@ -503,7 +515,9 @@ describe('run command integration', { timeout: 45_000 }, () => { }); it('records structured run completion without leaking sentinel text to artifacts', async () => { - sessionId = createSession(testHome, ['/bin/bash']); + sessionId = createSession(testHome, ['/bin/bash'], { + cols: WIDE_ARTIFACT_PROOF_COLS, + }); await sleep(1000); const result = runCli( From f1dee46c6ff4d936a8f5329907aa7c880324b791 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 28 Apr 2026 13:06:51 +0000 Subject: [PATCH 13/14] test: assert short run sentinel stays out of artifacts --- test/integration/run.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/integration/run.test.ts b/test/integration/run.test.ts index fb10bb0..c75af6d 100644 --- a/test/integration/run.test.ts +++ b/test/integration/run.test.ts @@ -60,6 +60,7 @@ function expectCompletionArtifactsClean(text: string, marker: string): void { expect(text).not.toContain(markerPayload); expect(text).not.toContain(markerPayloadPart1); expect(text).not.toContain(markerPayloadPart2); + expect(text).not.toContain('\x1b_at'); expect(text).not.toContain('\x1b_agent-tty'); expect(text).not.toContain(`\x1b_agent-tty:run-complete:${marker}\x1b\\`); } From e9d950eff66e3fb47f61f5d78f07111ecfb470c6 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 28 Apr 2026 13:34:32 +0000 Subject: [PATCH 14/14] test: require run completion input reference --- test/integration/run.test.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/test/integration/run.test.ts b/test/integration/run.test.ts index c75af6d..d0edc5e 100644 --- a/test/integration/run.test.ts +++ b/test/integration/run.test.ts @@ -565,12 +565,14 @@ describe('run command integration', { timeout: 45_000 }, () => { throw new Error('expected run_complete event to exist'); } const inputRunSeq = runCompleteEvent.payload.inputRunSeq; - if (inputRunSeq !== undefined) { - expect(inputRunSeq).toBeTypeOf('number'); - const inputRunEvent = events.find((event) => event.seq === inputRunSeq); - expect(inputRunEvent?.type).toBe('input_run'); - expect(inputRunEvent?.payload).toMatchObject({ marker }); + expect(inputRunSeq).toBeDefined(); + if (inputRunSeq === undefined) { + throw new Error('run_complete inputRunSeq must be defined'); } + expect(inputRunSeq).toBeTypeOf('number'); + const inputRunEvent = events.find((event) => event.seq === inputRunSeq); + expect(inputRunEvent?.type).toBe('input_run'); + expect(inputRunEvent?.payload).toMatchObject({ marker }); const outputText = collectOutputText(events); expect(outputText).toContain('before-clean-marker-proof');