agent-relay 10.1.0 → 10.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,37 +11,50 @@
11
11
  * mode, and leaves the agent running under the broker — `drive` never kills the
12
12
  * worker.
13
13
  *
14
- * Sequence of operations on attach:
14
+ * Sequence of operations on attach (subscribe-first, so no output around
15
+ * attach time is lost and none is double-painted):
15
16
  *
16
17
  * 1. Discover broker connection (CLI flag → env → connection.json).
17
18
  * 2. `GET /api/spawned/{name}/delivery-mode` → remember the previous mode.
18
19
  * 3. `PUT /api/spawned/{name}/delivery-mode` → switch to `manual_flush`.
19
- * 4. `captureAndRenderSnapshot` repaint the agent's current screen.
20
- * 5. `GET /api/spawned/{name}/pending` → seed the status-line counter.
21
- * 6. Open `/ws`, subscribe to events for this worker.
22
- * 7. Open the SDK PTY input stream, then switch local stdin to raw
23
- * mode and forward bytes through that stream.
20
+ * 4. `GET /api/events/replay` capture the durable-event `sinceSeq` cutoff,
21
+ * then `GET /api/spawned/{name}/pending` → seed the status-line counter
22
+ * and the set of already-queued `event_id`s. Cutoff-first + id-dedupe
23
+ * keeps the counter exact across the attach race (no under/over-count).
24
+ * 5. Open `/ws?sinceSeq=<cutoff>`, subscribe, and buffer live output. The
25
+ * cutoff stops the broker replaying historical durable events that would
26
+ * inflate the pending counter; any replayed `delivery_queued` already in
27
+ * the seed is deduped by `event_id`.
28
+ * 6. On subscribe: `captureAndRenderSnapshot` repaints the agent's current
29
+ * screen; buffered chunks are reconciled against the snapshot's stream
30
+ * offset (drop what the snapshot already shows, apply the rest).
31
+ * 7. Forward the initial terminal size (resize now lands in the live stream,
32
+ * not a dead zone), then open the SDK PTY input stream and switch local
33
+ * stdin to raw mode.
24
34
  *
25
35
  * On detach (clean or abnormal), best-effort `PUT .../delivery-mode` restores the
26
36
  * previous mode so the queue doesn't fill up indefinitely.
27
37
  */
28
38
  import { Buffer } from 'node:buffer';
39
+ import { randomUUID } from 'node:crypto';
40
+ import { StringDecoder } from 'node:string_decoder';
29
41
  import WebSocket from 'ws';
30
- import { captureAndRenderSnapshot, captureInitialSnapshot, pickInitialTerminalRows, prepareAttachTarget, switchInboundDeliveryModeOrAbort, syncInitialPtySize, } from '../lib/attach.js';
42
+ import { captureAndRenderSnapshot, createBackpressureAwareWriter, DETACH_CLEANUP_DEADLINE_MS, pickInitialTerminalRows, prepareAttachTarget, resetLocalTerminalOnDetach, restoreInboundDeliveryModeOnDetach, StatusLineController, StreamSyncBuffer, switchInboundDeliveryModeOrAbort, syncInitialPtySize, } from '../lib/attach.js';
31
43
  import { defaultStateDir, readConnectionFileFromDisk, toWsUrl, } from '../lib/broker-connection.js';
32
44
  import { defaultExit, runSignalHandler } from '../lib/exit.js';
33
45
  import { createBrokerClient, mapBrokerSdkFailure, } from '../lib/attach-broker.js';
34
46
  import { createPredictiveEcho } from './predictive-echo-screen.js';
35
47
  function withDefaults(overrides = {}) {
36
48
  const fetchFn = overrides.fetch ?? ((input, init) => fetch(input, init));
49
+ const writer = createBackpressureAwareWriter(process.stdout);
37
50
  return {
38
51
  readConnectionFile: readConnectionFileFromDisk,
39
52
  getDefaultStateDir: defaultStateDir,
40
53
  env: process.env,
41
54
  createWebSocket: (url, headers) => new WebSocket(url, { headers }),
42
- writeChunk: (chunk) => {
43
- process.stdout.write(chunk);
44
- },
55
+ writeChunk: writer.write,
56
+ disposeWriter: writer.dispose,
57
+ statusRepaintCoalesceMs: 40,
45
58
  onSignal: (signal, handler) => {
46
59
  const listener = () => runSignalHandler(handler);
47
60
  process.on(signal, listener);
@@ -99,10 +112,37 @@ export async function setInboundDeliveryMode(connection, name, mode, fetchFn) {
99
112
  return { ok: false, status: failure.status, message: failure.message };
100
113
  }
101
114
  }
102
- /** `GET /api/spawned/{name}/pending` → count, or `0` on failure (best-effort). */
103
- export async function getPendingCount(connection, name, fetchFn) {
115
+ /**
116
+ * `GET /api/spawned/{name}/pending` → `{ count, eventIds }`, or an empty seed
117
+ * on failure (best-effort). The `eventIds` set carries every pending
118
+ * delivery's `event_id` (deliveries without one are still counted but can't
119
+ * be deduped) so a replayed `delivery_queued` frame for an already-seeded
120
+ * delivery doesn't inflate the counter.
121
+ */
122
+ export async function getPendingSeed(connection, name, fetchFn) {
123
+ try {
124
+ const pending = await createBrokerClient(connection, fetchFn).getPending(name);
125
+ const eventIds = new Set();
126
+ for (const message of pending) {
127
+ if (typeof message.event_id === 'string')
128
+ eventIds.add(message.event_id);
129
+ }
130
+ return { count: pending.length, eventIds };
131
+ }
132
+ catch {
133
+ return { count: 0, eventIds: new Set() };
134
+ }
135
+ }
136
+ /**
137
+ * Current durable-event sequence cutoff, used as the event WS `sinceSeq` so
138
+ * the broker does not replay historical durable events (old `delivery_queued`
139
+ * frames) that would otherwise inflate the freshly-seeded pending counter.
140
+ * Returns `0` on failure (best-effort) — the caller then omits `sinceSeq`
141
+ * and behaves as before.
142
+ */
143
+ export async function getCurrentEventSeq(connection, fetchFn) {
104
144
  try {
105
- return (await createBrokerClient(connection, fetchFn).getPending(name)).length;
145
+ return await createBrokerClient(connection, fetchFn).currentEventSeq();
106
146
  }
107
147
  catch {
108
148
  return 0;
@@ -140,9 +180,9 @@ export function openPtyInputStream(connection, name, fetchFn, options) {
140
180
  * running in it) sees the size the human is actually looking at.
141
181
  * Called once on attach and again on every local-terminal resize.
142
182
  */
143
- export async function resizeWorker(connection, name, rows, cols, fetchFn) {
183
+ export async function resizeWorker(connection, name, rows, cols, fetchFn, options) {
144
184
  try {
145
- await createBrokerClient(connection, fetchFn).resizePty(name, rows, cols);
185
+ await createBrokerClient(connection, fetchFn).resizePty(name, rows, cols, options);
146
186
  return { ok: true };
147
187
  }
148
188
  catch (err) {
@@ -150,6 +190,24 @@ export async function resizeWorker(connection, name, rows, cols, fetchFn) {
150
190
  return { ok: false, message: failure.message };
151
191
  }
152
192
  }
193
+ /**
194
+ * Release this session's PTY resize ownership on detach (single-resizer
195
+ * policy, #1247), so the next client that attaches can resize the shared PTY.
196
+ * Best-effort: the broker also supersedes a crashed owner after an idle window.
197
+ */
198
+ export async function releaseResizeOwnership(connection, name, sessionId, fetchFn) {
199
+ try {
200
+ // A pure release carries no dimensions — the broker skips the resize and
201
+ // only drops ownership, so there are no placeholder sizes to invent.
202
+ await createBrokerClient(connection, fetchFn).resizePty(name, undefined, undefined, {
203
+ sessionId,
204
+ release: true,
205
+ });
206
+ }
207
+ catch {
208
+ // Best-effort — ownership falls back to the broker's idle-takeover net.
209
+ }
210
+ }
153
211
  /** ----- WS message classification ----- */
154
212
  function isStringObject(value) {
155
213
  return typeof value === 'object' && value !== null && !Array.isArray(value);
@@ -178,10 +236,16 @@ export function classifyWsEvent(rawMessage, name) {
178
236
  const chunk = parsed.chunk;
179
237
  if (typeof chunk !== 'string')
180
238
  return { kind: 'other' };
181
- return { kind: 'worker_stream', chunk };
239
+ const offset = typeof parsed.offset === 'number' ? parsed.offset : undefined;
240
+ return { kind: 'worker_stream', chunk, offset };
182
241
  }
183
242
  if (parsed.kind === 'delivery_queued') {
184
- return { kind: 'delivery_queued' };
243
+ // `event_id` correlates a replayed frame with the pending seed so the
244
+ // counter isn't double-incremented for a delivery already reflected in
245
+ // the seed (see the seed-dedup note in `runDriveSession`). Absent on
246
+ // legacy/mixed frames — treated as "not in the seed" (counted).
247
+ const eventId = typeof parsed.event_id === 'string' ? parsed.event_id : undefined;
248
+ return { kind: 'delivery_queued', eventId };
185
249
  }
186
250
  if (parsed.kind === 'agent_pending_drained') {
187
251
  const count = typeof parsed.count === 'number' ? parsed.count : undefined;
@@ -232,14 +296,19 @@ export function renderStatusLine(opts) {
232
296
  return `\x1b7\x1b[${row};1H\x1b[2K\x1b[7m${text}\x1b[0m\x1b8`;
233
297
  }
234
298
  /**
235
- * Run the interactive session: opens the WS, takes over stdin on
236
- * `open`, drives resize/status-line handling, and restores the
237
- * worker's previous mode on any exit path. Resolves with the exit
238
- * code the CLI should propagate.
299
+ * Run the interactive session. Subscribe-first: opens the event WS, buffers
300
+ * live `worker_stream` chunks, then (on subscribe) paints the snapshot,
301
+ * reconciles the buffer against the snapshot offset, forwards the initial
302
+ * resize, and takes over stdin. Restores the worker's previous mode on any
303
+ * exit path. Resolves with the exit code the CLI should propagate.
239
304
  */
240
305
  function runDriveSessionLoop(state, deps) {
241
- const { connection, name, previousMode } = state;
242
- const wsUrl = toWsUrl(connection.url);
306
+ const { connection, name, previousMode, sessionRevision, seededEventIds } = state;
307
+ // Connect with a `sinceSeq` cutoff so the broker replays only events after
308
+ // attach — historical durable events must not inflate the pending counter.
309
+ // Omit it when the cutoff is 0 (no durable events yet / lookup failed) so
310
+ // the URL and behaviour match the pre-cutoff default.
311
+ const wsUrl = state.cutoffSeq > 0 ? `${toWsUrl(connection.url)}?sinceSeq=${state.cutoffSeq}` : toWsUrl(connection.url);
243
312
  const headers = {};
244
313
  if (connection.apiKey) {
245
314
  headers['X-API-Key'] = connection.apiKey;
@@ -248,31 +317,86 @@ function runDriveSessionLoop(state, deps) {
248
317
  let settled = false;
249
318
  let rawModeWasSet = false;
250
319
  let unsubscribeResize = null;
320
+ // Stable per-attach id for the broker's single-resizer policy (#1247): all
321
+ // of this session's resizes carry it so we own the shared PTY size while
322
+ // driving, and we release it on detach.
323
+ const resizeSessionId = randomUUID();
324
+ // In-flight resize requests. Detach awaits these before releasing ownership
325
+ // so a late-resolving SIGWINCH resize can't re-claim the PTY *after* the
326
+ // release lands (single-resizer detach race, #1247).
327
+ const outstandingResizes = new Set();
328
+ const trackResize = (p) => {
329
+ outstandingResizes.add(p);
330
+ void p.finally(() => outstandingResizes.delete(p));
331
+ };
332
+ // Periodic ownership re-assert timer (see `ownershipReassertMs`).
333
+ let reassertTimer = null;
251
334
  let pending = state.initialPending;
252
- let terminalRows = state.initialTerminalRows;
335
+ let terminalRows = pickInitialTerminalRows(state.initialLocalSize, undefined);
253
336
  const parser = new KeybindParser();
337
+ // Stateful UTF-8 decoder for forwarded stdin. Decoding each raw stdin chunk
338
+ // independently would turn a multi-byte character split across `data`
339
+ // events (routine in large pastes / IME) into U+FFFD; the StringDecoder
340
+ // buffers a trailing incomplete sequence until the next chunk completes it.
341
+ // Detach scanning still runs on raw bytes upstream (0x03 can't appear inside
342
+ // a multi-byte sequence), so this only touches the forwarded payload.
343
+ const inputDecoder = new StringDecoder('utf8');
254
344
  let inputStream = null;
255
345
  const cleanupSignals = [];
346
+ // Skip the status line entirely when stdout is not a TTY (e.g. piped to
347
+ // `tee`) — a fabricated row-24 repaint would corrupt the captured log.
348
+ const statusLineEnabled = state.initialLocalSize !== null;
349
+ // Subscribe-first: buffer live `worker_stream` chunks until the snapshot
350
+ // is painted and reconciled against its per-worker offset.
351
+ const sync = new StreamSyncBuffer();
256
352
  // Adaptive predictive echo masks round-trip latency on remote brokers.
257
- // Seeded with the snapshot so its confirmed model matches the screen.
353
+ // Seeded with the snapshot (once painted) so its confirmed model matches
354
+ // the screen.
258
355
  const predictiveEcho = deps.createPredictiveEcho?.({
259
356
  cols: state.initialLocalSize?.cols ?? 0,
260
357
  rows: state.initialLocalSize?.rows ?? 0,
261
358
  write: deps.writeChunk,
262
359
  getInputSrtt: () => inputStream?.srttMs ?? null,
263
360
  }) ?? null;
264
- if (predictiveEcho) {
265
- void predictiveEcho.seed(state.snapshotBytes);
266
- }
361
+ // Tee the snapshot's painted bytes so we can seed the predictive-echo
362
+ // model with them — its cursor must match the real screen before we
363
+ // optimistically echo, or predicted glyphs land at the wrong position.
364
+ let snapshotBytes = '';
365
+ // Guard the snapshot paint on `settled`: a Ctrl+C during the snapshot HTTP
366
+ // fetch would otherwise paint the snapshot after teardown began (the render
367
+ // runs inside the awaited `captureAndRenderSnapshot`, past the WS guard).
368
+ const captureWrite = (chunk) => {
369
+ if (settled)
370
+ return;
371
+ deps.writeChunk(chunk);
372
+ snapshotBytes += chunk;
373
+ };
374
+ // Boundary-held + coalesced status painter. Holds repaints while the agent
375
+ // is mid escape-sequence (no splicing into a half-sent CSI), rate-limits
376
+ // per-chunk repaints, and skips painting entirely on a non-TTY stdout.
377
+ const statusController = new StatusLineController({
378
+ render: () => renderStatusLine({ name, mode: 'manual_flush', pending, rows: terminalRows }),
379
+ write: deps.writeChunk,
380
+ enabled: statusLineEnabled,
381
+ coalesceMs: deps.statusRepaintCoalesceMs ?? 40,
382
+ });
267
383
  const paintStatus = () => {
268
- deps.writeChunk(renderStatusLine({
269
- name,
270
- mode: 'manual_flush',
271
- pending,
272
- rows: terminalRows,
273
- }));
384
+ statusController.request();
385
+ };
386
+ // Route server output through the predictive-echo engine (which owns
387
+ // cursor save/restore) or straight to stdout, then repaint the status.
388
+ // Feed every chunk to the status controller for boundary tracking so the
389
+ // repaint holds off until the stream is back at a sequence boundary.
390
+ const applyServerOutput = (chunk) => {
391
+ statusController.observeOutput(chunk);
392
+ if (predictiveEcho) {
393
+ void predictiveEcho.onServerOutput(chunk).then(paintStatus, paintStatus);
394
+ }
395
+ else {
396
+ deps.writeChunk(chunk);
397
+ paintStatus();
398
+ }
274
399
  };
275
- paintStatus();
276
400
  // Local-terminal resize handler. Forwards to the broker and
277
401
  // repaints the status line at the new bottom-row index. Registered
278
402
  // on `socket.on('open')` (same point we take over stdin) so a
@@ -284,11 +408,13 @@ function runDriveSessionLoop(state, deps) {
284
408
  return;
285
409
  terminalRows = size.rows;
286
410
  predictiveEcho?.onResize(size.cols, size.rows);
287
- void resizeWorker(connection, name, size.rows, size.cols, deps.fetch).then((res) => {
411
+ trackResize(resizeWorker(connection, name, size.rows, size.cols, deps.fetch, {
412
+ sessionId: resizeSessionId,
413
+ }).then((res) => {
288
414
  if (!res.ok) {
289
415
  deps.log(`[drive] resize forward failed: ${res.message ?? 'unknown error'}`);
290
416
  }
291
- });
417
+ }));
292
418
  // Repaint regardless of fetch outcome — the local terminal has
293
419
  // already moved, so the status line position needs to move with
294
420
  // it whether or not the broker accepted the resize.
@@ -303,22 +429,24 @@ function runDriveSessionLoop(state, deps) {
303
429
  deps.log('[drive] input stream is not ready');
304
430
  return;
305
431
  }
306
- // Fire-and-forget; surface errors via log but don't block the
307
- // event loop on every keystroke.
308
- // UTF-8, not latin1 the broker input stream forwards string
309
- // payload bytes verbatim to the PTY.
310
- // 'binary' would map bytes ≥ 0x80 to Latin-1 code points,
311
- // which then get UTF-8 re-encoded on the wire, doubling
312
- // multi-byte characters (e.g. `é` `é` on the agent's side).
313
- void stream.send(outcome.forward.toString('utf-8')).catch((err) => {
314
- if (settled)
315
- return;
316
- const message = err instanceof Error ? err.message : String(err);
317
- deps.log(`[drive] input stream send failed: ${message}`);
318
- // The keystroke never reached the PTY — drop any optimistic echo
319
- // for it so the screen doesn't show input the agent didn't get.
320
- predictiveEcho?.rollback();
321
- });
432
+ // Decode through the stateful UTF-8 decoder so a multi-byte character
433
+ // split across stdin chunks is forwarded intact rather than as U+FFFD.
434
+ // An incomplete trailing sequence decodes to '' and is held until the
435
+ // next chunk completes it.
436
+ const decoded = inputDecoder.write(outcome.forward);
437
+ if (decoded.length > 0) {
438
+ // Fire-and-forget; surface errors via log but don't block the
439
+ // event loop on every keystroke.
440
+ void stream.send(decoded).catch((err) => {
441
+ if (settled)
442
+ return;
443
+ const message = err instanceof Error ? err.message : String(err);
444
+ deps.log(`[drive] input stream send failed: ${message}`);
445
+ // The keystroke never reached the PTY drop any optimistic echo
446
+ // for it so the screen doesn't show input the agent didn't get.
447
+ predictiveEcho?.rollback();
448
+ });
449
+ }
322
450
  predictiveEcho?.onUserInput(outcome.forward);
323
451
  }
324
452
  for (const action of outcome.actions) {
@@ -349,6 +477,15 @@ function runDriveSessionLoop(state, deps) {
349
477
  catch {
350
478
  // best effort
351
479
  }
480
+ try {
481
+ // Heal the local terminal: the snapshot + live stream may have left it
482
+ // in app-cursor / mouse / bracketed-paste / alt-screen mode. Gate on a
483
+ // TTY stdout (same signal that gates the status line).
484
+ resetLocalTerminalOnDetach(deps.writeChunk, statusLineEnabled);
485
+ }
486
+ catch {
487
+ // best effort
488
+ }
352
489
  try {
353
490
  deps.stdin.pause();
354
491
  }
@@ -364,6 +501,15 @@ function runDriveSessionLoop(state, deps) {
364
501
  catch {
365
502
  // best effort
366
503
  }
504
+ try {
505
+ if (reassertTimer) {
506
+ clearInterval(reassertTimer);
507
+ reassertTimer = null;
508
+ }
509
+ }
510
+ catch {
511
+ // best effort
512
+ }
367
513
  rawModeWasSet = false;
368
514
  };
369
515
  const closeInputStream = () => {
@@ -382,6 +528,9 @@ function runDriveSessionLoop(state, deps) {
382
528
  if (settled)
383
529
  return;
384
530
  settled = true;
531
+ // Stop the status painter first so no queued repaint fires after we
532
+ // restore cooked mode (output would otherwise spray past detach).
533
+ statusController.dispose();
385
534
  for (const cleanup of cleanupSignals.splice(0)) {
386
535
  try {
387
536
  cleanup();
@@ -393,15 +542,55 @@ function runDriveSessionLoop(state, deps) {
393
542
  teardownStdin();
394
543
  predictiveEcho?.reset();
395
544
  closeInputStream();
545
+ // Release resize ownership so the next client can size the shared PTY.
546
+ // Await any in-flight resizes first: the release must be ordered *after*
547
+ // the last SIGWINCH resize resolves, or that resize could re-claim the
548
+ // PTY just after the release lands (detach race, #1247). `teardownStdin`
549
+ // has already stopped the resize handler and re-assert timer above, so
550
+ // no new resizes are enqueued past this point.
551
+ const releasePromise = (async () => {
552
+ try {
553
+ if (outstandingResizes.size > 0) {
554
+ await Promise.allSettled([...outstandingResizes]);
555
+ }
556
+ await releaseResizeOwnership(connection, name, resizeSessionId, deps.fetch);
557
+ }
558
+ catch {
559
+ // Best-effort — the broker's idle-takeover net still frees ownership.
560
+ }
561
+ })();
396
562
  try {
397
563
  socket.close(1000, 'drive client exiting');
398
564
  }
399
565
  catch {
400
566
  // best effort
401
567
  }
402
- // Best-effort: restore the worker's previous mode so we don't
403
- // leave it stuck in manual_flush and silently piling up queued messages.
404
- void setInboundDeliveryMode(connection, name, previousMode ?? 'auto_inject', deps.fetch).finally(() => {
568
+ // Drop the writer's pending queue and unhook its drain listener so no
569
+ // buffered chunk flushes to stdout after detach.
570
+ deps.disposeWriter?.();
571
+ // Best-effort restore: re-read the mode and only revert if it's still
572
+ // what this session set, so we don't leave the worker stuck in
573
+ // manual_flush and don't clobber a change another session made.
574
+ //
575
+ // Await the release alongside the restore before resolving: `resolve`
576
+ // typically ends the process, which aborts any still-pending fetch. The
577
+ // release awaits `outstandingResizes` first, so it would otherwise lose
578
+ // the race to the restore and be aborted, defeating the detach-race fix.
579
+ //
580
+ // Bound the wait: both are best-effort HTTP round-trips that can stall if
581
+ // the broker is down, and terminal exit must not hang on them. Resolve
582
+ // once they settle or after DETACH_CLEANUP_DEADLINE_MS, whichever first.
583
+ const cleanup = Promise.allSettled([
584
+ releasePromise,
585
+ restoreInboundDeliveryModeOnDetach(connection, name, previousMode, 'manual_flush', sessionRevision, 'drive', deps),
586
+ ]);
587
+ let deadlineTimer;
588
+ const deadline = new Promise((res) => {
589
+ deadlineTimer = setTimeout(res, DETACH_CLEANUP_DEADLINE_MS);
590
+ });
591
+ void Promise.race([cleanup, deadline]).finally(() => {
592
+ if (deadlineTimer !== undefined)
593
+ clearTimeout(deadlineTimer);
405
594
  resolve(code);
406
595
  });
407
596
  };
@@ -424,6 +613,26 @@ function runDriveSessionLoop(state, deps) {
424
613
  // we take over stdin so the lifecycles match — both go away in
425
614
  // `teardownStdin` on any exit path.
426
615
  unsubscribeResize = deps.terminal.onResize(resizeHandler);
616
+ // Start the periodic ownership re-assert so an idle-but-live session
617
+ // keeps the single-resizer lease past the broker's stale window. The
618
+ // broker no-ops a same-size re-assert (no SIGWINCH/repaint).
619
+ const reassertMs = deps.ownershipReassertMs ?? 60_000;
620
+ if (reassertMs > 0) {
621
+ reassertTimer = setInterval(() => {
622
+ const size = deps.terminal.getSize() ?? state.initialLocalSize;
623
+ if (!size)
624
+ return;
625
+ trackResize(resizeWorker(connection, name, size.rows, size.cols, deps.fetch, {
626
+ sessionId: resizeSessionId,
627
+ }).then((res) => {
628
+ if (!res.ok) {
629
+ deps.log(`[drive] resize ownership re-assert failed: ${res.message ?? 'unknown error'}`);
630
+ }
631
+ }));
632
+ }, reassertMs);
633
+ // Don't let the keep-alive timer hold the process open on its own.
634
+ reassertTimer.unref?.();
635
+ }
427
636
  }
428
637
  catch (err) {
429
638
  if (settled)
@@ -433,36 +642,88 @@ function runDriveSessionLoop(state, deps) {
433
642
  finish(1);
434
643
  }
435
644
  };
645
+ // Runs once the event WS is subscribed: paint the snapshot, seed
646
+ // predictive echo, reconcile buffered live output against the snapshot
647
+ // offset, forward the initial resize (now that we're subscribed, so its
648
+ // SIGWINCH repaint lands in the live stream instead of a dead zone before
649
+ // subscription), then open the input stream and take over stdin.
650
+ const onSubscribed = async () => {
651
+ const snapshot = await deps.captureAndRenderSnapshot({ url: connection.url, apiKey: connection.apiKey }, name, { fetch: deps.fetch, writeChunk: captureWrite });
652
+ if (settled)
653
+ return;
654
+ switch (snapshot.status) {
655
+ case 'ok':
656
+ break;
657
+ case 'not_found':
658
+ deps.error(`Error: ${snapshot.message ?? `no agent named '${name}'`}`);
659
+ finish(1);
660
+ return;
661
+ case 'no_pty':
662
+ deps.error(`Error: ${snapshot.message ?? `agent '${name}' has no PTY to drive`}`);
663
+ finish(1);
664
+ return;
665
+ case 'unavailable':
666
+ case 'transport_error':
667
+ deps.log(`[drive] could not capture initial screen (${snapshot.message ?? snapshot.status}); streaming live output only`);
668
+ break;
669
+ }
670
+ if (predictiveEcho)
671
+ void predictiveEcho.seed(snapshotBytes);
672
+ terminalRows = pickInitialTerminalRows(state.initialLocalSize, snapshot.rows);
673
+ // Track the snapshot bytes for boundary state before the first repaint.
674
+ statusController.observeOutput(snapshotBytes);
675
+ paintStatus();
676
+ // Reconcile buffered chunks. On `ok`, drop what the snapshot already
677
+ // reflects (by offset); with no offset this drops the pre-snapshot
678
+ // buffer (snapshot-authoritative, matching the legacy behaviour). On a
679
+ // transient snapshot failure nothing was painted, so apply everything.
680
+ const pendingChunks = snapshot.status === 'ok' ? sync.reconcile(snapshot.offset) : sync.flushAll();
681
+ for (const chunk of pendingChunks)
682
+ applyServerOutput(chunk);
683
+ const initialResize = syncInitialPtySize(connection, name, state.initialLocalSize, 'drive', deps, {
684
+ sessionId: resizeSessionId,
685
+ });
686
+ trackResize(initialResize);
687
+ await initialResize;
688
+ if (settled)
689
+ return;
690
+ // Open the SDK input stream before taking over stdin. A failed stream
691
+ // should not leave the user's terminal in raw mode with nowhere to
692
+ // send bytes.
693
+ await openInputStreamAndTakeStdin();
694
+ };
695
+ // Hand off from the early restore handlers (installed before the awaited
696
+ // setup) to the fuller session-loop handlers — no double restore.
697
+ state.disposeEarlySignals();
436
698
  for (const signal of ['SIGINT', 'SIGTERM']) {
437
699
  const cleanup = deps.onSignal(signal, () => finish(0));
438
700
  if (typeof cleanup === 'function')
439
701
  cleanupSignals.push(cleanup);
440
702
  }
441
703
  socket.on('open', () => {
442
- // Now that the event WS is up, open the SDK input stream before
443
- // taking over stdin. A failed stream should not leave the user's
444
- // terminal in raw mode with nowhere to send bytes.
445
- void openInputStreamAndTakeStdin();
704
+ void onSubscribed();
446
705
  });
447
706
  socket.on('message', (data) => {
707
+ // Once teardown has begun, drop inbound frames so we don't write output
708
+ // or repaint the status line after cooked mode is restored.
709
+ if (settled)
710
+ return;
448
711
  const text = typeof data === 'string' ? data : Buffer.isBuffer(data) ? data.toString('utf-8') : String(data);
449
712
  const event = classifyWsEvent(text, name);
450
713
  switch (event.kind) {
451
714
  case 'worker_stream':
452
- if (predictiveEcho) {
453
- // The engine owns pass-through (it must restore the cursor
454
- // before writing server bytes when predictions are live);
455
- // repaint the status line once it has flushed.
456
- void predictiveEcho.onServerOutput(event.chunk).then(paintStatus, paintStatus);
457
- }
458
- else {
459
- deps.writeChunk(event.chunk);
460
- // Repaint the status line so the worker's writes don't
461
- // obscure it. Cheap — it's just an ANSI escape sequence.
462
- paintStatus();
463
- }
715
+ if (sync.push(event.chunk, event.offset))
716
+ applyServerOutput(event.chunk);
464
717
  break;
465
718
  case 'delivery_queued':
719
+ // A replayed frame (seq > cutoff) can still be for a delivery
720
+ // already reflected in the seeded pending count when it raced the
721
+ // cutoff/seed capture. Dedupe by `event_id`: count it once, then
722
+ // forget the id so a genuine re-queue of the same event later still
723
+ // registers.
724
+ if (event.eventId !== undefined && seededEventIds.delete(event.eventId)) {
725
+ break;
726
+ }
466
727
  pending += 1;
467
728
  paintStatus();
468
729
  break;
@@ -505,36 +766,71 @@ export async function runDriveSession(agentName, options, deps) {
505
766
  const flipResult = await switchInboundDeliveryModeOrAbort(connection, name, 'manual_flush', `switch '${name}' to manual_flush mode`, deps);
506
767
  if (!flipResult)
507
768
  return 1;
508
- const { previousMode } = flipResult;
509
- // Tee the snapshot's painted bytes so we can seed the predictive-echo
510
- // model with them its cursor must match the real screen before we
511
- // optimistically echo, or predicted glyphs land at the wrong position.
512
- let snapshotBytes = '';
513
- const captureWrite = (chunk) => {
514
- deps.writeChunk(chunk);
515
- snapshotBytes += chunk;
769
+ const { previousMode, sessionRevision } = flipResult;
770
+ // The mode is now flipped to `manual_flush`, but the terminal is still cooked
771
+ // and we have several awaited HTTP round-trips (pending, cutoff, snapshot,
772
+ // input stream) before the session loop installs its signal handlers. Ctrl+C
773
+ // in that window would otherwise kill the process with the worker stranded in
774
+ // `manual_flush`, silently queueing every later relay message. Register early
775
+ // restore-and-exit handlers immediately; the loop disposes them once its own
776
+ // handlers are ready (no double restore — see `disposeEarlySignals`).
777
+ let earlyHandled = false;
778
+ const earlyCleanups = [];
779
+ const earlyRestore = async () => {
780
+ if (earlyHandled)
781
+ return;
782
+ earlyHandled = true;
783
+ for (const cleanup of earlyCleanups.splice(0)) {
784
+ try {
785
+ cleanup();
786
+ }
787
+ catch {
788
+ // best effort
789
+ }
790
+ }
791
+ await restoreInboundDeliveryModeOnDetach(connection, name, previousMode, 'manual_flush', sessionRevision, 'drive', deps);
792
+ deps.exit(0);
516
793
  };
517
- const snapshotResult = await captureInitialSnapshot(connection, name, previousMode, 'drive', 'drive', {
518
- fetch: deps.fetch,
519
- writeChunk: captureWrite,
520
- log: deps.log,
521
- error: deps.error,
522
- captureAndRenderSnapshot: deps.captureAndRenderSnapshot,
523
- });
524
- if (!snapshotResult)
525
- return 1;
526
- const initialPending = await getPendingCount(connection, name, deps.fetch);
794
+ const disposeEarlySignals = () => {
795
+ earlyHandled = true;
796
+ for (const cleanup of earlyCleanups.splice(0)) {
797
+ try {
798
+ cleanup();
799
+ }
800
+ catch {
801
+ // best effort
802
+ }
803
+ }
804
+ };
805
+ for (const signal of ['SIGINT', 'SIGTERM']) {
806
+ const cleanup = deps.onSignal(signal, earlyRestore);
807
+ if (typeof cleanup === 'function')
808
+ earlyCleanups.push(cleanup);
809
+ }
527
810
  const initialLocalSize = deps.terminal.getSize();
528
- const initialTerminalRows = pickInitialTerminalRows(initialLocalSize, snapshotResult.snapshotRows);
529
- await syncInitialPtySize(connection, name, initialLocalSize, 'drive', deps);
811
+ // Capture the durable-event cutoff *first*, then seed the pending counter.
812
+ //
813
+ // The event WS replays durable `delivery_queued` frames with `seq >
814
+ // cutoffSeq`. Reading the cutoff before the seed guarantees every delivery
815
+ // NOT captured in the seed has `seq > cutoffSeq` (it was queued after the
816
+ // cutoff snapshot), so it is replayed and counted — closing the
817
+ // undercount hole where a delivery racing between the two reads was in
818
+ // neither the seed nor the replay. The flip side (a delivery that IS in the
819
+ // seed and also replays because its `seq > cutoffSeq`) is handled by
820
+ // deduping replayed frames against the seed's `event_id`s (see
821
+ // `seededEventIds` in the WS handler), so it's counted exactly once.
822
+ const cutoffSeq = await getCurrentEventSeq(connection, deps.fetch);
823
+ const { count: initialPending, eventIds: seededEventIds } = await getPendingSeed(connection, name, deps.fetch);
530
824
  return runDriveSessionLoop({
531
825
  connection,
532
826
  name,
533
827
  previousMode,
828
+ sessionRevision,
534
829
  initialPending,
535
- initialTerminalRows,
830
+ seededEventIds,
536
831
  initialLocalSize,
537
- snapshotBytes,
832
+ cutoffSeq,
833
+ disposeEarlySignals,
538
834
  }, deps);
539
835
  }
540
836
  /** Run a drive session with default dependencies. Used by `runtime agent attach --mode drive`. */