agent-relay 10.1.0 → 10.2.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.
- package/dist/cli/lib/attach-drive.d.ts +72 -9
- package/dist/cli/lib/attach-drive.d.ts.map +1 -1
- package/dist/cli/lib/attach-drive.js +390 -94
- package/dist/cli/lib/attach-drive.js.map +1 -1
- package/dist/cli/lib/attach-passthrough.d.ts +22 -0
- package/dist/cli/lib/attach-passthrough.d.ts.map +1 -1
- package/dist/cli/lib/attach-passthrough.js +260 -58
- package/dist/cli/lib/attach-passthrough.js.map +1 -1
- package/dist/cli/lib/attach-view.d.ts +23 -4
- package/dist/cli/lib/attach-view.d.ts.map +1 -1
- package/dist/cli/lib/attach-view.js +77 -33
- package/dist/cli/lib/attach-view.js.map +1 -1
- package/dist/cli/lib/attach.d.ts +292 -10
- package/dist/cli/lib/attach.d.ts.map +1 -1
- package/dist/cli/lib/attach.js +496 -15
- package/dist/cli/lib/attach.js.map +1 -1
- package/package.json +7 -7
|
@@ -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. `
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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,
|
|
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:
|
|
43
|
-
|
|
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
|
-
/**
|
|
103
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
236
|
-
* `
|
|
237
|
-
*
|
|
238
|
-
*
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
265
|
-
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
307
|
-
//
|
|
308
|
-
//
|
|
309
|
-
//
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
//
|
|
403
|
-
//
|
|
404
|
-
|
|
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
|
-
|
|
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 (
|
|
453
|
-
|
|
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
|
-
//
|
|
510
|
-
//
|
|
511
|
-
//
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
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
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
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
|
-
|
|
529
|
-
|
|
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
|
-
|
|
830
|
+
seededEventIds,
|
|
536
831
|
initialLocalSize,
|
|
537
|
-
|
|
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`. */
|