@turns/dev 0.0.1

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.
@@ -0,0 +1,1053 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { Readable } from 'node:stream';
6
+ import { pipeline } from 'node:stream/promises';
7
+ import { anchorId, connectExistingAgentAnchors, launchAgentAnchor, waitForAgentTurn } from "./agent-anchor-client.js";
8
+ import { assertAllowedWorkingDirectory, workingDirectoryIsAllowed } from "./directory-policy.js";
9
+ import { browseMachinePath } from "./file-browsing.js";
10
+ import { nativeDescriptorAgentSessionId, nativeDescriptorWorkingDirectory } from "./local-session-sources.js";
11
+ import { compactRuntimeValue } from "./native-record-compaction.js";
12
+ import { createSecretRedactor } from "./secret-redaction.js";
13
+ const QUEUE_WARM_MS = Math.max(1_000, Number(process.env.TURNS_QUEUE_WARM_MS) || 5 * 60_000);
14
+ const QUEUE_TURN_TIMEOUT_MS = 12 * 60 * 60_000;
15
+ const CLAIM_RENEW_MS = 60_000;
16
+ const HARNESS_HEARTBEAT_MS = 30_000;
17
+ const MACHINE_HEARTBEAT_MS = 10_000;
18
+ const MACHINE_HEARTBEAT_TIMEOUT_MS = 15_000;
19
+ const RUNTIME_BATCH_SIZE = 100;
20
+ const MAX_RUNTIME_FLUSH_FAILURES = 10;
21
+ const DEFAULT_REDACTOR = createSecretRedactor();
22
+ function wait(milliseconds) {
23
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
24
+ }
25
+ function backgroundWait(milliseconds) {
26
+ return new Promise((resolve) => {
27
+ const timer = setTimeout(resolve, milliseconds);
28
+ timer.unref?.();
29
+ });
30
+ }
31
+ /** Keep the server-side queue claim leased until every completion side effect
32
+ * has settled. A Worker deployment can make the final runtime flush wait for a
33
+ * replacement socket; cancelling renewal before that wait requeues a turn that
34
+ * already ran when its five-minute claim lease expires. */
35
+ export async function withActiveClaimRenewal(renewTimer, operation) {
36
+ try {
37
+ return await operation();
38
+ }
39
+ finally {
40
+ clearInterval(renewTimer);
41
+ }
42
+ }
43
+ export async function reserveCreatingNativeSession(request, creating, sessionId, agentSessionId) {
44
+ if (!creating)
45
+ return;
46
+ await request({ type: 'session.native.reserve', sessionId, agentSessionId });
47
+ }
48
+ /** The origin serving the same deployment as a `wss://…/api/socket` endpoint. */
49
+ export function httpBaseUrl(websocketUrl) {
50
+ const url = new URL(websocketUrl);
51
+ url.protocol = url.protocol === 'wss:' ? 'https:' : 'http:';
52
+ url.pathname = '/';
53
+ url.search = '';
54
+ return url;
55
+ }
56
+ function safeAttachmentName(value) {
57
+ return (String(value || 'attachment')
58
+ .replace(/[\\/\0-\x1f]/gu, '_')
59
+ .slice(0, 180) || 'attachment');
60
+ }
61
+ export function attachmentMarkup({ id, name, type, size, path, sessionId }) {
62
+ return `<attachment>${JSON.stringify({
63
+ id: String(id || ''),
64
+ name: String(name || 'attachment'),
65
+ type: String(type || 'application/octet-stream'),
66
+ size: Number(size) || 0,
67
+ path: String(path || ''),
68
+ ...(sessionId ? { sessionId: String(sessionId) } : {}),
69
+ })}</attachment>`;
70
+ }
71
+ /**
72
+ * Spool one attachment body onto disk, and report how much of it arrived.
73
+ *
74
+ * The prompt only ever names the file it wrote, so the bytes have no reason to
75
+ * exist in this process at all — and an attachment may now run to hundreds of
76
+ * megabytes, which is more than a daemon minding several sessions at once can
77
+ * afford to hold just to check a number it is about to throw away. Piping keeps
78
+ * the cost flat and lets `stat` answer the size question afterwards.
79
+ *
80
+ * A failed pipe leaves a partial file behind; it is cleared here so a later
81
+ * attempt writes to a name nothing is squatting on.
82
+ */
83
+ export async function writeAttachmentFile(response, file) {
84
+ if (!response.body)
85
+ throw new Error('Attachment response had no body.');
86
+ try {
87
+ await pipeline(Readable.fromWeb(response.body), fs.createWriteStream(file, { mode: 0o600 }));
88
+ }
89
+ catch (error) {
90
+ fs.rmSync(file, { force: true });
91
+ throw error;
92
+ }
93
+ return fs.statSync(file).size;
94
+ }
95
+ async function preparePrompt({ prompt, sessionId, token, baseUrl }) {
96
+ const text = String(prompt.text || '').trim();
97
+ const attachments = Array.isArray(prompt.attachments) ? prompt.attachments : [];
98
+ if (!attachments.length)
99
+ return text;
100
+ const directory = path.join(os.homedir(), '.local', 'state', 'turns-dev', 'queue-attachments', sessionId, String(prompt.id));
101
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
102
+ const used = new Set();
103
+ const notices = [];
104
+ for (const attachment of attachments) {
105
+ const requested = safeAttachmentName(attachment.name);
106
+ const dot = requested.lastIndexOf('.');
107
+ const stem = dot > 0 ? requested.slice(0, dot) : requested;
108
+ const extension = dot > 0 ? requested.slice(dot) : '';
109
+ let name = requested;
110
+ for (let suffix = 2; used.has(name); suffix += 1)
111
+ name = `${stem}-${suffix}${extension}`;
112
+ used.add(name);
113
+ const url = new URL(`/api/attachments/${encodeURIComponent(sessionId)}/${encodeURIComponent(attachment.id)}`, baseUrl);
114
+ const response = await fetch(url, { headers: { authorization: `Bearer ${token}` } });
115
+ if (!response.ok)
116
+ throw new Error(`Attachment ${requested} download failed (${response.status}).`);
117
+ const file = path.join(directory, name);
118
+ const written = await writeAttachmentFile(response, file);
119
+ if (written !== Number(attachment.size)) {
120
+ fs.rmSync(file, { force: true });
121
+ throw new Error(`Attachment ${requested} size mismatch.`);
122
+ }
123
+ notices.push(attachmentMarkup({
124
+ id: attachment.id,
125
+ name,
126
+ type: attachment.type || 'application/octet-stream',
127
+ size: written,
128
+ path: file,
129
+ sessionId,
130
+ }));
131
+ }
132
+ return [text, ...notices].filter(Boolean).join('\n\n').trim();
133
+ }
134
+ export function runtimeEvent(anchorName, messageId, event, redactor = DEFAULT_REDACTOR) {
135
+ const sequence = Number(event?.anchorEventSequence || 0);
136
+ const interactionScope = `${anchorName}:${messageId || event?.messageId || 'turn'}`;
137
+ const key = `anchor:${interactionScope}:${sequence || crypto.randomUUID()}`;
138
+ const ts = String(event?.capturedAt || new Date().toISOString());
139
+ if (['user.interaction', 'user.interaction_answer', 'user.interaction_expired'].includes(event?.event)) {
140
+ const interactionId = String(event.interaction?.id || '');
141
+ if (!interactionId)
142
+ return null;
143
+ const suffix = event.event === 'user.interaction'
144
+ ? 'interaction'
145
+ : event.event === 'user.interaction_answer'
146
+ ? 'interaction-answer'
147
+ : 'interaction-expired';
148
+ return {
149
+ eventKey: `${suffix}:${interactionScope}:${interactionId}`,
150
+ kind: event.event === 'user.interaction'
151
+ ? 'session.interaction_request'
152
+ : event.event === 'user.interaction_answer'
153
+ ? 'session.interaction_answer'
154
+ : 'session.interaction_expired',
155
+ phase: String(event.phase || 'queued'),
156
+ ts,
157
+ source: 'agent-anchor',
158
+ value: redactor.redactJsonLine(JSON.stringify({
159
+ id: interactionId,
160
+ interaction: event.interaction,
161
+ ...(Object.hasOwn(event, 'response') ? { response: event.response } : {}),
162
+ ...(event.reason ? { reason: event.reason } : {}),
163
+ })),
164
+ };
165
+ }
166
+ if (event?.direction === 'in' || event?.direction === 'out') {
167
+ const value = typeof event.raw === 'string' ? event.raw : JSON.stringify(event.json || {});
168
+ if (!value)
169
+ return null;
170
+ return {
171
+ eventKey: key,
172
+ kind: event.direction === 'out' ? 'stream.in' : 'stream.out',
173
+ phase: String(event.phase || 'queued'),
174
+ ts,
175
+ source: String(event.source || `${event.agent || 'agent'}.stdout`),
176
+ ...(event.method ? { method: String(event.method) } : {}),
177
+ value: compactRuntimeValue(redactor.redactText(value)),
178
+ };
179
+ }
180
+ if (event?.event === 'process.line') {
181
+ // CodexAppServerClient emits a decoded jsonrpc.response/notification before
182
+ // captureStream emits the same stdout bytes as process.line. Uploading both
183
+ // makes every short text delta arrive twice; retain process.line only for
184
+ // non-protocol output (and stderr), where there is no decoded counterpart.
185
+ if (event.source === 'app-server.stdout' && event.json && (event.json.id !== undefined || event.json.method))
186
+ return null;
187
+ const value = typeof event.raw === 'string' ? event.raw : JSON.stringify(event.json || {});
188
+ if (!value)
189
+ return null;
190
+ return {
191
+ eventKey: key,
192
+ kind: String(event.source || '').endsWith('.stderr') ? 'stream.err' : 'stream.out',
193
+ phase: String(event.phase || 'queued'),
194
+ ts,
195
+ source: String(event.source || `${event.agent || 'agent'}.stdout`),
196
+ value: compactRuntimeValue(redactor.redactText(value)),
197
+ };
198
+ }
199
+ if (event?.event === 'interrupt.issued' || event?.event === 'interrupt.settled') {
200
+ return {
201
+ eventKey: key,
202
+ kind: 'scenario.interrupt.issued',
203
+ phase: 'interrupted',
204
+ ts,
205
+ source: 'agent-anchor',
206
+ status: event.event === 'interrupt.settled' ? 'confirmed' : 'unconfirmed',
207
+ value: redactor.redactJsonLine(JSON.stringify(event)),
208
+ };
209
+ }
210
+ if (event?.event === 'turn.steer') {
211
+ return {
212
+ eventKey: key,
213
+ kind: 'session.queue_steer',
214
+ phase: 'queued',
215
+ ts,
216
+ source: 'agent-anchor',
217
+ value: redactor.redactJsonLine(JSON.stringify({
218
+ type: 'turns.session.queue_steer',
219
+ messageId: String(event.messageId || ''),
220
+ displayPrompt: String(event.displayPrompt || event.prompt || ''),
221
+ prompt: String(event.prompt || ''),
222
+ anchorId: String(event.anchorId || anchorName),
223
+ })),
224
+ };
225
+ }
226
+ return null;
227
+ }
228
+ export function resultError(result) {
229
+ if (!result)
230
+ return new Error('The anchored agent returned no result.');
231
+ // Interrupt-capable harnesses commonly report cancellation through their
232
+ // error channel. Once the anchor confirms that the user requested it, the
233
+ // queue item is consumed rather than retried.
234
+ if (result.interrupted)
235
+ return null;
236
+ if (result.error)
237
+ return new Error(result.error.message || String(result.error));
238
+ if (result.timedOut)
239
+ return new Error('The anchored agent turn timed out.');
240
+ if (result.exitCode !== undefined && result.exitCode !== null && result.exitCode !== 0) {
241
+ return new Error(`The anchored agent exited with ${result.signal || result.exitCode}.`);
242
+ }
243
+ return null;
244
+ }
245
+ export function dispatchErrorIsRetryable(error) {
246
+ const message = String(error?.message || error || '');
247
+ return !(/(?:not supported|does not support|no resumable native session|session key.*unavailable|no local transcript descriptor|working directory is unavailable|outside the daemon allowlist|agent executable not found)/iu.test(message) || /attachment .* (?:size mismatch|download failed \(4\d\d\))/iu.test(message));
248
+ }
249
+ export async function actualHarnessStatus(entry) {
250
+ if (!entry || entry.connection.closed)
251
+ return 'stopped';
252
+ const snapshot = await entry.connection.request('snapshot', {}, 5_000);
253
+ entry.snapshot = snapshot;
254
+ return snapshot.status === 'running' ? 'running' : 'idle';
255
+ }
256
+ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors, stableSessionId, redactor = DEFAULT_REDACTOR, }) {
257
+ const workerId = `${machine}:turns-queue-worker`;
258
+ const workerInstanceId = crypto.randomUUID();
259
+ const baseUrl = httpBaseUrl(options.url);
260
+ const anchors = new Map();
261
+ const claims = new Map();
262
+ const claimStarts = new Set();
263
+ const recoveredTurns = new Set();
264
+ const runtimeQueues = new Map();
265
+ const descriptors = () => {
266
+ const byId = new Map();
267
+ for (const descriptor of getDescriptors()) {
268
+ byId.set(stableSessionId(machine, descriptor.harness, descriptor.sourcePath), descriptor);
269
+ }
270
+ return byId;
271
+ };
272
+ const request = (message, timeoutMs) => rpc.request(message, timeoutMs);
273
+ const flushRuntime = async (sessionId) => {
274
+ const state = runtimeQueues.get(sessionId);
275
+ if (!state || state.flushing)
276
+ return state?.promise;
277
+ state.flushing = true;
278
+ state.promise = (async () => {
279
+ while (state.events.length) {
280
+ const events = state.events.slice(0, RUNTIME_BATCH_SIZE);
281
+ try {
282
+ await request({ type: 'session.runtime.append', sessionId, events });
283
+ state.events.splice(0, events.length);
284
+ state.failures = 0;
285
+ }
286
+ catch (error) {
287
+ const message = String(error?.message || error);
288
+ state.failures = (state.failures || 0) + 1;
289
+ process.stderr.write(`Runtime event append failed for ${sessionId}: ${message}\n`);
290
+ // Socket failures are retried inside request(). Keep valid anchor
291
+ // events across other transient Worker/R2 failures too. Only discard
292
+ // a batch when the session is gone or the internally constructed row
293
+ // is permanently invalid; the anchor journal can still be inspected.
294
+ //
295
+ // A batch that keeps failing is discarded rather than retried without
296
+ // end: this queue is what a queued turn's dispatch and completion wait
297
+ // behind, so an undeliverable frame must cost live rows, never the
298
+ // turn itself. The durable transcript still carries the content.
299
+ if (/(?:session not found|not assigned to the registered machine|invalid runtime event|is too large|too big)/iu.test(message)) {
300
+ state.events.splice(0, events.length);
301
+ state.failures = 0;
302
+ }
303
+ else if (state.failures >= MAX_RUNTIME_FLUSH_FAILURES) {
304
+ process.stderr.write(`Dropping ${events.length} undeliverable runtime events for ${sessionId} after ` +
305
+ `${state.failures} attempts: ${message}\n`);
306
+ state.events.splice(0, events.length);
307
+ state.failures = 0;
308
+ }
309
+ else {
310
+ await wait(1_000);
311
+ }
312
+ }
313
+ }
314
+ })().finally(() => {
315
+ state.flushing = false;
316
+ state.promise = null;
317
+ });
318
+ return state.promise;
319
+ };
320
+ const enqueueRuntime = (sessionId, event, immediate = false) => {
321
+ if (!event)
322
+ return Promise.resolve();
323
+ let state = runtimeQueues.get(sessionId);
324
+ if (!state) {
325
+ state = { events: [], flushing: false, promise: null, timer: null, failures: 0 };
326
+ runtimeQueues.set(sessionId, state);
327
+ }
328
+ state.events.push(event);
329
+ if (immediate || state.events.length >= RUNTIME_BATCH_SIZE) {
330
+ clearTimeout(state.timer);
331
+ state.timer = null;
332
+ return flushRuntime(sessionId);
333
+ }
334
+ if (!state.timer) {
335
+ state.timer = setTimeout(() => {
336
+ state.timer = null;
337
+ void flushRuntime(sessionId);
338
+ }, 20);
339
+ state.timer.unref?.();
340
+ }
341
+ return Promise.resolve();
342
+ };
343
+ const reportHarness = async (sessionId, state, entry) => {
344
+ await request({
345
+ type: 'session.harness.update',
346
+ statuses: [
347
+ {
348
+ sessionId,
349
+ state,
350
+ observedAt: new Date().toISOString(),
351
+ ...(entry ? { anchorId: entry.id } : {}),
352
+ },
353
+ ],
354
+ });
355
+ };
356
+ const stopClaimRenewal = (claim) => {
357
+ if (!claim?.renewTimer)
358
+ return;
359
+ clearInterval(claim.renewTimer);
360
+ claim.renewTimer = null;
361
+ };
362
+ const reportActualHarness = async (sessionId, entry) => {
363
+ await reportHarness(sessionId, await actualHarnessStatus(entry), entry);
364
+ };
365
+ const bindAnchor = (connection, snapshot, expectedSessionId = '') => {
366
+ // A newly launched anchor is intentionally identity-less until its first
367
+ // send_message. Bind it to the claimed turns.dev session immediately so
368
+ // early status/live events and Cmd-. interrupts cannot fall into the empty
369
+ // session key before startTurn fills the anchor snapshot.
370
+ const sessionId = String(snapshot.sessionId || expectedSessionId || '');
371
+ const entry = {
372
+ id: anchorId(connection),
373
+ connection,
374
+ snapshot,
375
+ stop: null,
376
+ };
377
+ const previous = anchors.get(sessionId);
378
+ if (previous && previous.connection !== connection)
379
+ previous.connection.close();
380
+ anchors.set(sessionId, entry);
381
+ entry.stop = connection.onEvent((message) => {
382
+ if (message.type === 'anchor_hello' && message.anchor)
383
+ entry.snapshot = message.anchor;
384
+ if (message.type === 'session_identity') {
385
+ entry.snapshot = {
386
+ ...entry.snapshot,
387
+ agentSessionId: message.agentSessionId || entry.snapshot.agentSessionId,
388
+ transcriptPath: message.transcriptPath || entry.snapshot.transcriptPath,
389
+ };
390
+ if (message.agentSessionId) {
391
+ void request({
392
+ type: 'session.native.bind',
393
+ sessionId,
394
+ agentSessionId: String(message.agentSessionId),
395
+ }).catch(() => { });
396
+ }
397
+ }
398
+ if (message.type === 'session_status') {
399
+ entry.snapshot = { ...entry.snapshot, status: message.status };
400
+ const state = message.status === 'running' ? 'running' : 'idle';
401
+ // Anchor messages are ordered, but runtime rows are uploaded in small
402
+ // asynchronous batches. Flush every preceding live frame before the
403
+ // idle boundary so the canonical log cannot close a Claude turn between
404
+ // its deltas and assembled assistant frame.
405
+ void (async () => {
406
+ if (state === 'idle')
407
+ await flushRuntime(sessionId);
408
+ await reportHarness(sessionId, state, entry);
409
+ })().catch((error) => process.stderr.write(`Harness status upload failed: ${error?.message || error}\n`));
410
+ }
411
+ if (message.type === 'live_event') {
412
+ const turnMessageId = String(message.messageId || entry.snapshot.currentTurn?.messageId || '');
413
+ if (message.event?.event === 'user.interaction') {
414
+ entry.snapshot = {
415
+ ...entry.snapshot,
416
+ pendingInteractions: [
417
+ ...(entry.snapshot.pendingInteractions || []).filter((interaction) => interaction.id !== message.event.interaction?.id),
418
+ message.event.interaction,
419
+ ].filter(Boolean),
420
+ };
421
+ }
422
+ else if (['user.interaction_answer', 'user.interaction_expired'].includes(message.event?.event)) {
423
+ entry.snapshot = {
424
+ ...entry.snapshot,
425
+ pendingInteractions: (entry.snapshot.pendingInteractions || []).filter((interaction) => interaction.id !== message.event.interaction?.id),
426
+ };
427
+ }
428
+ const event = runtimeEvent(entry.id, turnMessageId, message.event, redactor);
429
+ void enqueueRuntime(sessionId, event);
430
+ }
431
+ if (message.type === 'anchor_disconnect') {
432
+ if (anchors.get(sessionId) === entry)
433
+ anchors.delete(sessionId);
434
+ const claim = claims.get(sessionId);
435
+ if (claim?.entry === entry)
436
+ stopClaimRenewal(claim);
437
+ entry.stop?.();
438
+ void (async () => {
439
+ const messageId = String(entry.snapshot.currentTurn?.messageId || '');
440
+ const reason = 'The detached harness stopped before this interaction was answered.';
441
+ for (const interaction of entry.snapshot.pendingInteractions || []) {
442
+ await enqueueRuntime(sessionId, runtimeEvent(entry.id, messageId, {
443
+ event: 'user.interaction_expired',
444
+ phase: 'queued',
445
+ interaction,
446
+ reason,
447
+ }, redactor));
448
+ }
449
+ await flushRuntime(sessionId);
450
+ await reportHarness(sessionId, 'stopped', entry);
451
+ })().catch(() => { });
452
+ }
453
+ });
454
+ return entry;
455
+ };
456
+ const existing = await connectExistingAgentAnchors();
457
+ for (const { connection, snapshot } of existing) {
458
+ if (!workingDirectoryIsAllowed(String(snapshot.cwd || ''), options.allowedDirectories)) {
459
+ connection.close();
460
+ continue;
461
+ }
462
+ bindAnchor(connection, snapshot);
463
+ }
464
+ const usableAnchor = async (sessionId, source, agentSessionId) => {
465
+ let entry = anchors.get(sessionId);
466
+ if (entry && !entry.connection.closed) {
467
+ entry.snapshot = await entry.connection.request('snapshot', {}, 5_000).catch(() => entry.snapshot);
468
+ if (String(entry.snapshot.source || '') === source &&
469
+ String(entry.snapshot.agentSessionId || '') === agentSessionId)
470
+ return entry;
471
+ if (entry.snapshot.status === 'running')
472
+ throw new Error('The surviving anchor is running a different session identity.');
473
+ await entry.connection.request('shutdown', {}, 2_000).catch(() => { });
474
+ entry.connection.close();
475
+ anchors.delete(sessionId);
476
+ }
477
+ const connection = await launchAgentAnchor(sessionId);
478
+ entry = bindAnchor(connection, connection.snapshot || {}, sessionId);
479
+ await reportHarness(sessionId, 'idle', entry);
480
+ return entry;
481
+ };
482
+ const queuePromptBoundary = (entry, claim) => redactor.redactValue({
483
+ eventKey: `queue:${claim.prompt.id}:prompt`,
484
+ kind: 'session.queue_prompt',
485
+ phase: 'queued',
486
+ ts: claim.prompt.createdAt || new Date().toISOString(),
487
+ source: 'turns-queue',
488
+ value: JSON.stringify({
489
+ type: 'turns.session.queue_prompt',
490
+ messageId: claim.prompt.id,
491
+ displayPrompt: claim.prompt.text,
492
+ prompt: claim.prompt.text,
493
+ anchorId: entry?.id,
494
+ }),
495
+ });
496
+ const finishClaim = async (sessionId, claim, entry, result) => {
497
+ await withActiveClaimRenewal(claim.renewTimer, async () => {
498
+ await flushRuntime(sessionId);
499
+ await reportHarness(sessionId, entry.connection.closed ? 'stopped' : 'idle', entry).catch(() => { });
500
+ const error = resultError(result);
501
+ if (error) {
502
+ await request({
503
+ type: 'queue.fail',
504
+ sessionId,
505
+ promptId: claim.prompt.id,
506
+ claimToken: claim.claimToken,
507
+ error: redactor.redactText(error.message),
508
+ retryable: dispatchErrorIsRetryable(error),
509
+ });
510
+ }
511
+ else {
512
+ await request({
513
+ type: 'queue.complete',
514
+ sessionId,
515
+ promptId: claim.prompt.id,
516
+ claimToken: claim.claimToken,
517
+ });
518
+ }
519
+ });
520
+ claims.delete(sessionId);
521
+ };
522
+ const runClaim = async (sessionId, claim, recovery = null) => {
523
+ if (claims.has(sessionId))
524
+ return;
525
+ // Publish the optimistic user turn before native-session lookup, attachment
526
+ // download, or a cold anchor launch. The stable event key makes recovery
527
+ // idempotent, and the outbound protocol frame will replace this placeholder.
528
+ // Dispatch deliberately does not wait for that publish: the queue preserves
529
+ // order on its own, and a frame the Worker will not accept must not be able
530
+ // to hold a claimed prompt back from the agent.
531
+ void enqueueRuntime(sessionId, queuePromptBoundary(recovery?.entry || anchors.get(sessionId), claim), true).catch((error) => process.stderr.write(`Queue prompt boundary publish failed for ${sessionId}: ${error?.message || error}\n`));
532
+ let descriptor;
533
+ let agentSessionId = '';
534
+ let creating = false;
535
+ let cwd = '';
536
+ let entry = recovery?.entry;
537
+ if (!recovery) {
538
+ const descriptorMap = descriptors();
539
+ let discovered = descriptorMap.get(sessionId);
540
+ const claimedAgentSessionId = String(claim.session?.agentSessionId || '');
541
+ if (!discovered && claimedAgentSessionId) {
542
+ for (const candidate of descriptorMap.values()) {
543
+ if ((await nativeDescriptorAgentSessionId(candidate)) === claimedAgentSessionId) {
544
+ discovered = candidate;
545
+ break;
546
+ }
547
+ }
548
+ }
549
+ const pendingNativeSession = !String(claim.session?.sourcePath || '');
550
+ creating = Boolean(claim.session?.createSession);
551
+ descriptor =
552
+ discovered ||
553
+ (pendingNativeSession
554
+ ? {
555
+ harness: claim.session.harness,
556
+ sourcePath: claim.session.sourcePath,
557
+ file: path.join(os.tmpdir(), `turns-new-session-${sessionId}`),
558
+ }
559
+ : null);
560
+ if (!descriptor)
561
+ throw new Error(`No local transcript descriptor exists for ${sessionId}.`);
562
+ agentSessionId =
563
+ claimedAgentSessionId || (creating ? crypto.randomUUID() : await nativeDescriptorAgentSessionId(descriptor));
564
+ if (!agentSessionId)
565
+ throw new Error(`The ${descriptor.harness} transcript has no resumable native session key.`);
566
+ // Claude is given this UUID on its command line, so reserve the mapping
567
+ // before the anchor can create a transcript that the native watcher sees.
568
+ // The reservation is distinct from a completed bind: if launch fails, a
569
+ // retry still creates the same native session instead of trying to resume
570
+ // one that never started.
571
+ await reserveCreatingNativeSession(request, creating, sessionId, agentSessionId);
572
+ const workingDirectory = pendingNativeSession
573
+ ? String(claim.session?.workingDirectory || '')
574
+ : await nativeDescriptorWorkingDirectory(descriptor);
575
+ const fallbackDirectory = path.dirname(descriptor.file);
576
+ const selectedDirectory = workingDirectory && path.isAbsolute(workingDirectory) && fs.existsSync(workingDirectory)
577
+ ? workingDirectory
578
+ : fallbackDirectory;
579
+ cwd = assertAllowedWorkingDirectory(selectedDirectory, options.allowedDirectories);
580
+ if (!fs.statSync(cwd).isDirectory())
581
+ throw new Error(`Working directory is unavailable: ${cwd}`);
582
+ entry = await usableAnchor(sessionId, descriptor.harness, agentSessionId);
583
+ }
584
+ if (!entry)
585
+ throw new Error(`No surviving agent anchor exists for ${sessionId}.`);
586
+ const queued = {
587
+ ...claim,
588
+ entry,
589
+ renewTimer: null,
590
+ };
591
+ claims.set(sessionId, queued);
592
+ const renewClaim = () => request({
593
+ type: 'queue.renew',
594
+ sessionId,
595
+ promptId: claim.prompt.id,
596
+ claimToken: claim.claimToken,
597
+ });
598
+ const beginRenewal = () => {
599
+ if (queued.renewTimer)
600
+ return;
601
+ queued.renewTimer = setInterval(() => {
602
+ if (entry.connection.closed) {
603
+ stopClaimRenewal(queued);
604
+ void reportHarness(sessionId, 'stopped', entry).catch(() => { });
605
+ return;
606
+ }
607
+ void renewClaim().catch((error) => process.stderr.write(`Queue lease renewal failed: ${error?.message || error}\n`));
608
+ // Never renew lifecycle state from an old in-memory snapshot. The
609
+ // anchor may have disconnected since the claim began, which would
610
+ // otherwise resurrect a canonical stopped edge as running.
611
+ void reportActualHarness(sessionId, entry).catch(async () => {
612
+ if (entry.connection.closed) {
613
+ stopClaimRenewal(queued);
614
+ await reportHarness(sessionId, 'stopped', entry).catch(() => { });
615
+ }
616
+ });
617
+ }, CLAIM_RENEW_MS);
618
+ queued.renewTimer.unref?.();
619
+ };
620
+ beginRenewal();
621
+ // Verify and extend the inherited lease before waiting on a recovered
622
+ // anchor. If the server has already replaced it, normal claiming plus the
623
+ // anchor's prompt idempotency will reconcile the surviving turn instead.
624
+ if (recovery)
625
+ await renewClaim();
626
+ const prompt = recovery?.prompt ||
627
+ (await preparePrompt({
628
+ prompt: claim.prompt,
629
+ sessionId,
630
+ token: options.token,
631
+ baseUrl,
632
+ }));
633
+ for (const event of recovery?.events || []) {
634
+ await enqueueRuntime(sessionId, runtimeEvent(entry.id, claim.prompt.id, event, redactor));
635
+ }
636
+ await reportHarness(sessionId, recovery ? (recovery.snapshot?.status === 'running' ? 'running' : 'idle') : 'idle', entry);
637
+ let result;
638
+ if (recovery) {
639
+ const eventCursor = entry.connection.eventCursor();
640
+ result = await waitForAgentTurn(entry.connection, claim.prompt.id, QUEUE_TURN_TIMEOUT_MS, recovery.snapshot, eventCursor);
641
+ }
642
+ else {
643
+ await reportHarness(sessionId, 'running', entry);
644
+ // Queue retries deliberately reuse the prompt ID. Remember the event
645
+ // boundary before starting this attempt so an earlier turn_error with
646
+ // that ID cannot immediately settle the new turn.
647
+ const eventCursor = entry.connection.eventCursor();
648
+ await entry.connection.request('send_message', {
649
+ source: descriptor.harness,
650
+ sessionId,
651
+ agentSessionId,
652
+ cwd,
653
+ transcriptPath: descriptor.file,
654
+ workerId,
655
+ messageId: claim.prompt.id,
656
+ claimToken: claim.claimToken,
657
+ prompt,
658
+ expectedText: claim.prompt.text,
659
+ createSession: creating,
660
+ permissionMode: String(claim.session?.permissionMode || 'bypassPermissions'),
661
+ codexHistoryMode: String(claim.session?.codexHistoryMode || ''),
662
+ timeoutMs: QUEUE_TURN_TIMEOUT_MS,
663
+ idleTimeoutMs: QUEUE_WARM_MS,
664
+ }, 30_000);
665
+ entry.snapshot = await entry.connection.request('snapshot', {}, 5_000).catch(() => entry.snapshot);
666
+ // Registration after acceptance is safe: the connection retains recent
667
+ // events and the snapshot carries a turn that completed immediately.
668
+ result = await waitForAgentTurn(entry.connection, claim.prompt.id, QUEUE_TURN_TIMEOUT_MS, entry.snapshot, eventCursor);
669
+ if (creating) {
670
+ const createdIdentity = String(entry.snapshot?.agentSessionId || agentSessionId);
671
+ await request({ type: 'session.native.bind', sessionId, agentSessionId: createdIdentity });
672
+ }
673
+ }
674
+ entry.snapshot = await entry.connection.request('snapshot', {}, 5_000).catch(() => ({
675
+ ...entry.snapshot,
676
+ status: 'idle',
677
+ }));
678
+ await finishClaim(sessionId, queued, entry, result);
679
+ };
680
+ const tryClaim = async (sessionId) => {
681
+ if (claims.has(sessionId) || claimStarts.has(sessionId))
682
+ return;
683
+ claimStarts.add(sessionId);
684
+ let claim;
685
+ try {
686
+ const response = await request({ type: 'queue.claim', sessionId });
687
+ claim = response.claim;
688
+ if (!claim)
689
+ return;
690
+ await runClaim(sessionId, claim);
691
+ // A completion can make the next item ready before its unsolicited event
692
+ // crosses the socket. Claim once more immediately for zero-gap draining.
693
+ queueMicrotask(() => void tryClaim(sessionId));
694
+ }
695
+ catch (error) {
696
+ process.stderr.write(`Queue dispatch failed for ${sessionId}: ${error?.message || error}\n`);
697
+ const held = claims.get(sessionId);
698
+ stopClaimRenewal(held);
699
+ if (held?.entry && !held.entry.connection.closed) {
700
+ held.entry.snapshot = await held.entry.connection
701
+ .request('snapshot', {}, 2_000)
702
+ .catch(() => held.entry.snapshot);
703
+ await reportHarness(sessionId, held.entry.snapshot?.status === 'running' ? 'running' : 'idle', held.entry).catch(() => { });
704
+ }
705
+ else if (held?.entry) {
706
+ await reportHarness(sessionId, 'stopped', held.entry).catch(() => { });
707
+ }
708
+ if (claim) {
709
+ await request({
710
+ type: 'queue.fail',
711
+ sessionId,
712
+ promptId: claim.prompt.id,
713
+ claimToken: claim.claimToken,
714
+ error: redactor.redactText(error?.message || String(error)),
715
+ retryable: dispatchErrorIsRetryable(error),
716
+ }).catch(() => { });
717
+ }
718
+ claims.delete(sessionId);
719
+ }
720
+ finally {
721
+ claimStarts.delete(sessionId);
722
+ }
723
+ };
724
+ const recoverAnchors = async () => {
725
+ for (const [sessionId, entry] of anchors) {
726
+ const snapshot = await entry.connection.request('snapshot', {}, 5_000).catch(() => entry.snapshot);
727
+ entry.snapshot = snapshot;
728
+ await reportHarness(sessionId, snapshot.status === 'running' ? 'running' : 'idle', entry).catch(() => { });
729
+ const turn = snapshot.currentTurn;
730
+ // A warm anchor retains its terminal turn for diagnostics. Probe the
731
+ // saved claim during recovery too: if completion acknowledgement was
732
+ // lost, the queue row still needs to be consumed; otherwise renewal
733
+ // rejects the stale token before any prompt can be sent.
734
+ if (!turn?.messageId || !turn.claimToken)
735
+ continue;
736
+ const recoveryKey = `${entry.id}:${turn.messageId}`;
737
+ if (recoveredTurns.has(recoveryKey) || claims.has(sessionId))
738
+ continue;
739
+ recoveredTurns.add(recoveryKey);
740
+ const prompt = {
741
+ id: String(turn.messageId),
742
+ sessionId,
743
+ text: String(turn.expectedText || turn.prompt || ''),
744
+ attachments: [],
745
+ position: 0,
746
+ paused: false,
747
+ claimed: true,
748
+ createdAt: String(turn.startedAt || new Date().toISOString()),
749
+ updatedAt: String(turn.startedAt || new Date().toISOString()),
750
+ };
751
+ const claim = {
752
+ prompt,
753
+ claimToken: String(turn.claimToken),
754
+ expiresAt: '',
755
+ session: null,
756
+ };
757
+ void runClaim(sessionId, claim, {
758
+ entry,
759
+ snapshot,
760
+ events: Array.isArray(snapshot.liveEvents) ? snapshot.liveEvents : [],
761
+ prompt: String(turn.prompt || turn.expectedText || ''),
762
+ }).catch(async (error) => {
763
+ const held = claims.get(sessionId);
764
+ stopClaimRenewal(held);
765
+ claims.delete(sessionId);
766
+ await reportActualHarness(sessionId, held?.entry || entry).catch(async () => {
767
+ if ((held?.entry || entry).connection.closed) {
768
+ await reportHarness(sessionId, 'stopped', held?.entry || entry).catch(() => { });
769
+ }
770
+ });
771
+ process.stderr.write(`Anchor recovery failed for ${sessionId}: ${error?.message || error}\n`);
772
+ });
773
+ }
774
+ };
775
+ const handleSteer = async (message, replyRpc) => {
776
+ const sessionId = String(message.sessionId || '');
777
+ const entry = anchors.get(sessionId);
778
+ try {
779
+ if (!entry || entry.connection.closed || entry.snapshot?.status !== 'running') {
780
+ throw new Error('The detached harness is not actively running.');
781
+ }
782
+ const prompt = await preparePrompt({
783
+ prompt: message.prompt,
784
+ sessionId,
785
+ token: options.token,
786
+ baseUrl,
787
+ });
788
+ const steered = await entry.connection.request('steer', {
789
+ prompt,
790
+ messageId: message.prompt.id,
791
+ }, 30_000);
792
+ await flushRuntime(sessionId);
793
+ // Harnesses without a steering primitive interrupt the running turn
794
+ // instead of accepting the prompt mid-turn. Report which happened so the
795
+ // server keeps the prompt queued for the turn that follows.
796
+ const mode = steered?.mode === 'interrupt' ? 'interrupt' : 'steer';
797
+ replyRpc.notify({
798
+ type: 'session.steer.result',
799
+ requestId: message.requestId,
800
+ sessionId,
801
+ promptId: message.prompt.id,
802
+ ok: true,
803
+ mode,
804
+ });
805
+ }
806
+ catch (error) {
807
+ await reportActualHarness(sessionId, entry).catch(async () => {
808
+ if (!entry || entry.connection.closed) {
809
+ await reportHarness(sessionId, 'stopped', entry).catch(() => { });
810
+ }
811
+ });
812
+ replyRpc.notify({
813
+ type: 'session.steer.result',
814
+ requestId: message.requestId,
815
+ sessionId,
816
+ promptId: message.prompt?.id || '',
817
+ ok: false,
818
+ error: redactor.redactText(error?.message || String(error)),
819
+ });
820
+ }
821
+ };
822
+ const handleInterrupt = async (message, replyRpc) => {
823
+ const sessionId = String(message.sessionId || '');
824
+ const entry = anchors.get(sessionId);
825
+ try {
826
+ if (!entry || entry.connection.closed || entry.snapshot?.status !== 'running') {
827
+ throw new Error('The detached harness is not actively running.');
828
+ }
829
+ await entry.connection.request('interrupt', {
830
+ messageId: String(entry.snapshot?.currentTurn?.messageId || ''),
831
+ }, 30_000);
832
+ replyRpc.notify({
833
+ type: 'session.interrupt.result',
834
+ requestId: message.requestId,
835
+ sessionId,
836
+ ok: true,
837
+ });
838
+ }
839
+ catch (error) {
840
+ await flushRuntime(sessionId).catch(() => { });
841
+ await reportHarness(sessionId, !entry || entry.connection.closed ? 'stopped' : 'idle', entry).catch(() => { });
842
+ replyRpc.notify({
843
+ type: 'session.interrupt.result',
844
+ requestId: message.requestId,
845
+ sessionId,
846
+ ok: false,
847
+ error: redactor.redactText(error?.message || String(error)),
848
+ });
849
+ }
850
+ };
851
+ const handleAnchorAction = async (message, replyRpc) => {
852
+ const sessionId = String(message.sessionId || '');
853
+ const action = message.action === 'compact' ? 'compact' : 'shutdown';
854
+ const entry = anchors.get(sessionId);
855
+ try {
856
+ if (!entry || entry.connection.closed)
857
+ throw new Error('The detached harness is offline.');
858
+ entry.snapshot = await entry.connection.request('snapshot', {}, 5_000);
859
+ if (entry.snapshot?.status === 'running')
860
+ throw new Error('The detached harness is actively running.');
861
+ if (action === 'compact' && entry.snapshot?.pendingInteractions?.length) {
862
+ throw new Error('The detached harness is awaiting user input.');
863
+ }
864
+ await entry.connection.request(action, {}, action === 'compact' ? QUEUE_TURN_TIMEOUT_MS : 5_000);
865
+ if (action === 'compact') {
866
+ await flushRuntime(sessionId);
867
+ entry.snapshot = await entry.connection.request('snapshot', {}, 5_000).catch(() => entry.snapshot);
868
+ await reportHarness(sessionId, 'idle', entry);
869
+ }
870
+ else {
871
+ entry.connection.close();
872
+ if (anchors.get(sessionId) === entry)
873
+ anchors.delete(sessionId);
874
+ await reportHarness(sessionId, 'stopped', entry);
875
+ }
876
+ replyRpc.notify({
877
+ type: 'session.anchor.action.result',
878
+ requestId: message.requestId,
879
+ sessionId,
880
+ action,
881
+ ok: true,
882
+ });
883
+ }
884
+ catch (error) {
885
+ await flushRuntime(sessionId).catch(() => { });
886
+ await reportHarness(sessionId, !entry || entry.connection.closed ? 'stopped' : 'idle', entry).catch(() => { });
887
+ replyRpc.notify({
888
+ type: 'session.anchor.action.result',
889
+ requestId: message.requestId,
890
+ sessionId,
891
+ action,
892
+ ok: false,
893
+ error: redactor.redactText(error?.message || String(error)),
894
+ });
895
+ }
896
+ };
897
+ const handleInteractionAnswer = async (message, replyRpc) => {
898
+ const sessionId = String(message.sessionId || '');
899
+ const entry = anchors.get(sessionId);
900
+ try {
901
+ if (!entry || entry.connection.closed)
902
+ throw new Error('The detached harness is offline.');
903
+ entry.snapshot = await entry.connection.request('snapshot', {}, 5_000).catch(() => entry.snapshot);
904
+ if (!entry.snapshot?.pendingInteractions?.some((interaction) => interaction.id === message.interactionId)) {
905
+ throw new Error('The detached harness is no longer waiting for this interaction.');
906
+ }
907
+ await entry.connection.request('answer_interaction', {
908
+ interactionId: String(message.interactionId || ''),
909
+ response: message.response,
910
+ }, 30_000);
911
+ await flushRuntime(sessionId);
912
+ replyRpc.notify({
913
+ type: 'session.interaction.answer.result',
914
+ requestId: message.requestId,
915
+ sessionId,
916
+ interactionId: message.interactionId,
917
+ ok: true,
918
+ });
919
+ }
920
+ catch (error) {
921
+ await reportHarness(sessionId, !entry || entry.connection.closed ? 'stopped' : entry.snapshot?.status === 'running' ? 'running' : 'idle', entry).catch(() => { });
922
+ replyRpc.notify({
923
+ type: 'session.interaction.answer.result',
924
+ requestId: message.requestId,
925
+ sessionId,
926
+ interactionId: message.interactionId,
927
+ ok: false,
928
+ error: redactor.redactText(error?.message || String(error)),
929
+ });
930
+ }
931
+ };
932
+ /**
933
+ * Where a browse starts when the reader named nothing, or named it loosely.
934
+ *
935
+ * A running anchor knows its own working directory; a session without one is
936
+ * described by the transcript it came from. Either answer only decides what a
937
+ * bare or relative path means — an absolute path resolves to itself, and the
938
+ * daemon's allowlist is what actually bounds where a browse can reach.
939
+ */
940
+ const browseWorkingDirectory = async (sessionId) => {
941
+ const cwd = String(anchors.get(sessionId)?.snapshot?.cwd || '');
942
+ if (cwd)
943
+ return cwd;
944
+ const descriptor = descriptors().get(sessionId);
945
+ if (!descriptor)
946
+ return '';
947
+ let recorded = '';
948
+ try {
949
+ recorded = nativeDescriptorWorkingDirectory(descriptor);
950
+ }
951
+ catch { }
952
+ return recorded || path.dirname(descriptor.file);
953
+ };
954
+ /**
955
+ * Store one path this machine holds, so a reader can open it.
956
+ *
957
+ * Unlike the rest of these handlers this touches no harness: browsing is a
958
+ * property of the machine, so it answers for a stopped session as readily as
959
+ * for a running one.
960
+ */
961
+ const handleFileBrowse = async (message, replyRpc) => {
962
+ const sessionId = String(message.sessionId || '');
963
+ try {
964
+ const stored = await browseMachinePath({
965
+ requested: String(message.path || ''),
966
+ machine,
967
+ baseUrl,
968
+ options,
969
+ workingDirectory: await browseWorkingDirectory(sessionId),
970
+ });
971
+ replyRpc.notify({
972
+ type: 'session.file.browse.result',
973
+ requestId: message.requestId,
974
+ sessionId,
975
+ ok: true,
976
+ ...stored,
977
+ });
978
+ }
979
+ catch (error) {
980
+ replyRpc.notify({
981
+ type: 'session.file.browse.result',
982
+ requestId: message.requestId,
983
+ sessionId,
984
+ ok: false,
985
+ error: redactor.redactText(error?.message || String(error)),
986
+ });
987
+ }
988
+ };
989
+ const heartbeat = setInterval(() => {
990
+ for (const [sessionId, entry] of anchors) {
991
+ if (entry.connection.closed)
992
+ continue;
993
+ void reportHarness(sessionId, entry.snapshot?.status === 'running' ? 'running' : 'idle', entry).catch(() => { });
994
+ }
995
+ }, HARNESS_HEARTBEAT_MS);
996
+ heartbeat.unref?.();
997
+ rpc.onEvent((message) => {
998
+ if (message.type === 'queue.ready')
999
+ void tryClaim(message.sessionId);
1000
+ if (message.type === 'session.steer')
1001
+ void handleSteer(message, rpc);
1002
+ if (message.type === 'session.interrupt.request')
1003
+ void handleInterrupt(message, rpc);
1004
+ if (message.type === 'session.anchor.action.request')
1005
+ void handleAnchorAction(message, rpc);
1006
+ if (message.type === 'session.interaction.answer.request')
1007
+ void handleInteractionAnswer(message, rpc);
1008
+ if (message.type === 'session.file.browse.request')
1009
+ void handleFileBrowse(message, rpc);
1010
+ });
1011
+ const registered = new WeakSet();
1012
+ const registering = new WeakSet();
1013
+ const registerControl = async (control) => {
1014
+ if (registered.has(control) || registering.has(control))
1015
+ return;
1016
+ registering.add(control);
1017
+ try {
1018
+ const ready = await control.request({
1019
+ type: 'machine.register',
1020
+ machineId: machine,
1021
+ workerId,
1022
+ workerInstanceId,
1023
+ anchorSessionIds: [...anchors.keys()],
1024
+ });
1025
+ registered.add(control);
1026
+ await recoverAnchors();
1027
+ for (const sessionId of ready.sessionIds || [])
1028
+ void tryClaim(sessionId);
1029
+ }
1030
+ catch (error) {
1031
+ process.stderr.write(`Queue registration failed: ${error?.message || error}\n`);
1032
+ }
1033
+ finally {
1034
+ registering.delete(control);
1035
+ }
1036
+ };
1037
+ rpc.onConnect((control) => {
1038
+ void registerControl(control);
1039
+ });
1040
+ await registerControl(await rpc.current());
1041
+ for (;;) {
1042
+ await backgroundWait(MACHINE_HEARTBEAT_MS);
1043
+ try {
1044
+ await registerControl(await rpc.current());
1045
+ const status = await request({ type: 'machine.heartbeat' }, MACHINE_HEARTBEAT_TIMEOUT_MS);
1046
+ for (const sessionId of status.sessionIds || [])
1047
+ void tryClaim(sessionId);
1048
+ }
1049
+ catch (error) {
1050
+ process.stderr.write(`Machine heartbeat failed: ${error?.message || error}\n`);
1051
+ }
1052
+ }
1053
+ }