@turns/dev 0.0.14 → 0.0.16

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/README.md CHANGED
@@ -101,3 +101,53 @@ Against local Wrangler development, use the configured `DEV_USER` directly:
101
101
  turns daemon run --server http://127.0.0.1:8787 \
102
102
  --dev-user antimatter15 --directory /tmp/turns-driver-workspaces
103
103
  ```
104
+
105
+ ### Agent memory isolation and prompt recovery
106
+
107
+ On Linux with systemd, each newly launched anchor runs in its own
108
+ `turns-agent-*.scope`, including all of its harness and tool subprocesses.
109
+ The installer also writes `turns-agents.slice`, which contains these scopes
110
+ and enables systemd-oomd pressure monitoring at 40%. This targets an individual
111
+ agent scope before pressure reaches the user manager's usual 50% threshold.
112
+ Foreground launches still get individual scopes, but the
113
+ parent slice's pressure policy requires running `turns install`.
114
+ `OOMPolicy=kill` terminates that entire scope when a member is OOM-killed;
115
+ other anchors and the uploader live in different cgroups. Turns imposes no
116
+ per-agent RAM or swap limit: workloads share available host resources, and
117
+ pressure handling selects an individual scope for termination. Scope creation requires an
118
+ accessible systemd user manager, cgroup v2 memory control, and systemd 254 or
119
+ newer. A launch failure is reported instead of silently running without
120
+ isolation. macOS and Linux without systemd retain detached process launching.
121
+
122
+ The Linux service records the installation PATH. Executable discovery and
123
+ anchor environments also add the CLI's Node directory, `~/.local/bin`,
124
+ `~/bin`, `~/.npm-global/bin`, `NPM_CONFIG_PREFIX/bin` when configured, and
125
+ standard system/Homebrew directories. Explicit PATH entries retain priority;
126
+ `TURNS_<HARNESS>_BIN` overrides and macOS app-bundled Codex preference remain
127
+ in effect. Empty and relative PATH entries are excluded. Keeping Node on the
128
+ anchor PATH also supports CLI wrappers using `#!/usr/bin/env node`.
129
+
130
+ Queue dispatch now distinguishes optimistic transcript acknowledgement from
131
+ durable delivery. New daemons request `dispatchTracking` when claiming and
132
+ wait for `queue.renew` with `dispatchStarted: true` to be acknowledged before
133
+ sending to the local anchor. The backend stores `pending` or `started` in
134
+ `prompt_queue.dispatch_state`. A failure, lost anchor, or expired lease consumes
135
+ a started queue entry; its transcript remains, and a user can submit a new
136
+ follow-up to continue. Unsent prompts retain the existing paused/retry behavior.
137
+ Surviving anchors retain their claims. A crash between the durable marker and
138
+ local delivery is deliberately treated as uncertain delivery and is not
139
+ replayed automatically.
140
+
141
+ Deploy the backend before upgrading daemons. User DO schema version 20 adds
142
+ `dispatch_state` without changing existing columns. Existing rows and claims
143
+ from older daemons use `legacy`: acknowledged legacy claims are conservatively
144
+ treated as started, since old daemons did not record a separate delivery
145
+ boundary. This can consume a legacy optimistic-but-unsent prompt on failure;
146
+ it prevents replay of work with unknown delivery state. New daemons refuse to
147
+ send against backends that do not advertise dispatch tracking. Downgrade
148
+ new daemons and drain active claims before rolling back the backend; an old
149
+ backend ignores the new column and cannot provide the no-replay guarantee.
150
+ Reinstall the Linux service with `turns install` after upgrading to persist its
151
+ PATH. Existing warm anchors keep their old executable, environment, and cgroup
152
+ until they exit; isolation applies to newly launched anchors. Previously
153
+ requeued prompts are not automatically deleted by this migration.
@@ -6,6 +6,8 @@ import path from 'node:path';
6
6
  import readline from 'node:readline';
7
7
  import { spawn } from 'node:child_process';
8
8
  import { fileURLToPath } from 'node:url';
9
+ import { executablePath } from "./executable-path.js";
10
+ import { anchorLaunchCommand } from "./agent-isolation.js";
9
11
  const HERE = path.dirname(fileURLToPath(import.meta.url));
10
12
  function withTimeout(promise, timeoutMs, label) {
11
13
  let timer;
@@ -188,17 +190,24 @@ export async function launchAgentAnchor(sessionId, timeoutMs = 15_000) {
188
190
  const logPath = path.join(logDirectory, `${path.basename(socketPath, '.sock')}.log`);
189
191
  const logFd = fs.openSync(logPath, 'a', 0o600);
190
192
  const entry = anchorEntryPoint();
191
- const child = spawn(process.execPath, [entry, '--socket', socketPath], {
193
+ const launch = anchorLaunchCommand(entry, socketPath);
194
+ const child = spawn(launch.command, launch.args, {
192
195
  cwd: path.dirname(entry),
193
- env: { ...process.env, TURNS_AGENT_ANCHOR_DIR: directory },
196
+ env: { ...process.env, PATH: executablePath(), TURNS_AGENT_ANCHOR_DIR: directory },
194
197
  detached: true,
195
198
  stdio: ['ignore', logFd, logFd],
196
199
  });
197
200
  fs.closeSync(logFd);
201
+ let spawnError;
202
+ child.once('error', (error) => {
203
+ spawnError = error;
204
+ });
198
205
  child.unref();
199
206
  const deadline = Date.now() + timeoutMs;
200
207
  let lastError;
201
208
  while (Date.now() < deadline) {
209
+ if (spawnError)
210
+ throw new Error(`Agent isolation launch failed: ${spawnError.message}. See ${logPath}`);
202
211
  if (child.exitCode !== null || child.signalCode !== null) {
203
212
  throw new Error(`Agent anchor exited before opening its socket (${child.signalCode || child.exitCode}). See ${logPath}`);
204
213
  }
@@ -9,7 +9,9 @@ import { DatabaseSync } from 'node:sqlite';
9
9
  import { fileURLToPath } from 'node:url';
10
10
  import { WebSocket } from 'ws';
11
11
  import { ACP_PROFILES, lettaHeadlessEnvironment, lettaSessionIdentity, PROCESS_HARNESSES, parseLettaSessionIdentity, processEventSessionId, processInvocation, RPC_PROFILES, resolveHarnessCommand, rpcFrameSessionIdentity, rpcUiRequestIsInformational, } from "./harness-registry.js";
12
+ import { asyncQuestionInteractions, asyncQuestionReplyText, isAsyncQuestionInteraction, parseAsyncQuestionReplies, } from "./codex-async-questions.js";
12
13
  import { recordLettaSessionContext } from "./letta-session-context.js";
14
+ import { recoverAsyncQuestion } from "./codex-async-question-recovery.js";
13
15
  const socketIndex = process.argv.indexOf('--socket');
14
16
  const socketPath = socketIndex >= 0 ? path.resolve(process.argv[socketIndex + 1] || '') : '';
15
17
  if (!socketPath)
@@ -186,6 +188,7 @@ class LineTransport {
186
188
  }
187
189
  }
188
190
  class ClaudeTransport extends LineTransport {
191
+ backgroundTasks = new Set();
189
192
  sessionId;
190
193
  permissionMode;
191
194
  turnWaiter;
@@ -225,6 +228,18 @@ class ClaudeTransport extends LineTransport {
225
228
  onFrame(frame) {
226
229
  if (!frame || typeof frame !== 'object')
227
230
  return;
231
+ if (frame.type === 'system' && typeof frame.task_id === 'string') {
232
+ if (frame.subtype === 'task_started' && frame.is_backgrounded !== false) {
233
+ this.backgroundTasks.add(frame.task_id);
234
+ }
235
+ else if (frame.subtype === 'task_notification') {
236
+ this.backgroundTasks.delete(frame.task_id);
237
+ }
238
+ // A result completes the foreground prompt, not the tasks it launched.
239
+ // Keep accepting new prompts while those tasks retain the warm process.
240
+ if (this.anchor.status === 'idle')
241
+ this.anchor.scheduleExit();
242
+ }
228
243
  if (frame.type === 'system' && frame.subtype === 'init' && frame.session_id) {
229
244
  this.sessionId = String(frame.session_id);
230
245
  if (this.sessionId !== this.anchor.agentSessionId)
@@ -254,6 +269,9 @@ class ClaudeTransport extends LineTransport {
254
269
  void this.answerReverse(frame);
255
270
  }
256
271
  onClose(error) {
272
+ this.backgroundTasks.clear();
273
+ if (this.anchor.status === 'idle')
274
+ this.anchor.scheduleExit();
257
275
  if (this.turnWaiter) {
258
276
  this.turnWaiter.reject(error);
259
277
  this.turnWaiter = null;
@@ -500,6 +518,7 @@ class CodexAppServerTransport extends LineTransport {
500
518
  turnId;
501
519
  turnIdWaiter;
502
520
  turnWaiter;
521
+ asyncQuestionsSeen = new Set();
503
522
  constructor(anchor) {
504
523
  const profile = { command: 'codex' };
505
524
  const mcp = `mcp_servers.turns={command=${JSON.stringify(process.execPath)},args=[${JSON.stringify(questionMcpPath)}],env={TURNS_AGENT_ANCHOR_SOCKET=${JSON.stringify(socketPath)}}}`;
@@ -516,6 +535,32 @@ class CodexAppServerTransport extends LineTransport {
516
535
  this.turnId = String(turnId);
517
536
  this.turnIdWaiter?.resolve(this.turnId);
518
537
  }
538
+ registerAsyncQuestion(interaction) {
539
+ if (this.asyncQuestionsSeen.has(interaction.id))
540
+ return;
541
+ this.asyncQuestionsSeen.add(interaction.id);
542
+ void this.anchor
543
+ .askInteraction(interaction.request, {
544
+ id: interaction.id,
545
+ onAnswer: async (response) => {
546
+ const text = asyncQuestionReplyText(interaction, response);
547
+ if (this.turnWaiter)
548
+ await this.steer(text);
549
+ else
550
+ await this.anchor.startAsyncQuestionReply(text);
551
+ },
552
+ })
553
+ .catch(() => { });
554
+ }
555
+ async recoverAsyncQuestion(interactionId) {
556
+ if (this.asyncQuestionsSeen.has(interactionId))
557
+ return;
558
+ const interaction = await recoverAsyncQuestion(this.anchor.transcriptPath, interactionId);
559
+ // Registration checks again after the read: a live question or answer may
560
+ // have arrived while the transcript was being scanned.
561
+ if (interaction)
562
+ this.registerAsyncQuestion(interaction);
563
+ }
519
564
  onFrame(frame) {
520
565
  if (!frame || typeof frame !== 'object')
521
566
  return;
@@ -534,6 +579,24 @@ class CodexAppServerTransport extends LineTransport {
534
579
  return;
535
580
  }
536
581
  const ours = !frame.params?.threadId || frame.params.threadId === this.threadId;
582
+ if (ours && frame.method === 'item/completed') {
583
+ const item = frame.params?.item;
584
+ if (item?.type === 'agentMessage' && item.delivery === 'async') {
585
+ for (const interaction of asyncQuestionInteractions(item.id, item.questions)) {
586
+ this.registerAsyncQuestion(interaction);
587
+ }
588
+ }
589
+ if (item?.type === 'userMessage') {
590
+ const text = (item.content || [])
591
+ .filter((part) => part.type === 'text')
592
+ .map((part) => part.text)
593
+ .join('\n');
594
+ for (const reply of parseAsyncQuestionReplies(text)?.replies || []) {
595
+ this.asyncQuestionsSeen.add(reply.questionItemId);
596
+ this.anchor.resolveInteraction(reply.questionItemId, { answers: { [reply.questionItemId]: [reply.answer] } });
597
+ }
598
+ }
599
+ }
537
600
  if (frame.method === 'turn/started' && ours)
538
601
  this.setTurnId(frame.params?.turnId || frame.params?.turn?.id);
539
602
  if (['turn/completed', 'turn/failed', 'turn/aborted'].includes(frame.method) && ours && this.turnWaiter) {
@@ -628,8 +691,15 @@ class CodexAppServerTransport extends LineTransport {
628
691
  this.threadId = threadId;
629
692
  if (threadId !== this.anchor.agentSessionId)
630
693
  this.anchor.updateIdentity(threadId);
694
+ // Resume returns persisted thread items, including questions asked by a
695
+ // previous app-server connection. Rebuild only the still-unanswered items.
696
+ for (const turn of opened?.thread?.turns || []) {
697
+ for (const item of turn.items || []) {
698
+ this.onFrame({ method: 'item/completed', params: { threadId, item } });
699
+ }
700
+ }
631
701
  }
632
- async prompt(text, timeoutMs) {
702
+ async prompt(text, timeoutMs, onAccepted = () => { }) {
633
703
  if (this.turnWaiter)
634
704
  throw new Error('Codex already has an active turn.');
635
705
  const waiter = deferred();
@@ -641,10 +711,10 @@ class CodexAppServerTransport extends LineTransport {
641
711
  this.turnWaiter = null;
642
712
  this.turnIdWaiter?.resolve('');
643
713
  this.turnIdWaiter = null;
644
- waiter.reject(error);
645
714
  throw error;
646
715
  });
647
716
  this.setTurnId(started?.turn?.id || started?.turnId);
717
+ onAccepted();
648
718
  const timer = setTimeout(() => {
649
719
  if (this.turnWaiter === waiter)
650
720
  this.turnWaiter = null;
@@ -1389,20 +1459,45 @@ class Anchor {
1389
1459
  }
1390
1460
  askInteraction(request, options = {}) {
1391
1461
  const interaction = {
1392
- id: crypto.randomUUID(),
1462
+ id: options.id || crypto.randomUUID(),
1393
1463
  harness: this.source,
1394
1464
  request,
1395
1465
  };
1396
1466
  const waiter = deferred();
1397
1467
  clearTimeout(this.idleTimer);
1398
- this.pendingInteractions.set(interaction.id, { interaction, waiter, onExpire: options.onExpire });
1468
+ this.pendingInteractions.set(interaction.id, {
1469
+ interaction,
1470
+ waiter,
1471
+ onExpire: options.onExpire,
1472
+ onAnswer: options.onAnswer,
1473
+ answering: false,
1474
+ });
1399
1475
  this.emitLive({ event: 'user.interaction', agent: this.source, phase: 'queued', interaction });
1400
1476
  return waiter.promise;
1401
1477
  }
1402
- answerInteraction(interactionId, response) {
1478
+ async answerInteraction(interactionId, response) {
1479
+ if (!this.pendingInteractions.has(interactionId) && this.transport instanceof CodexAppServerTransport) {
1480
+ await this.transport.recoverAsyncQuestion(interactionId);
1481
+ }
1403
1482
  const pending = this.pendingInteractions.get(interactionId);
1404
1483
  if (!pending)
1405
1484
  throw new Error('The interaction is no longer waiting in this harness.');
1485
+ if (pending.answering)
1486
+ throw new Error('This answer is already being submitted.');
1487
+ pending.answering = true;
1488
+ try {
1489
+ await pending.onAnswer?.(response);
1490
+ this.resolveInteraction(interactionId, response);
1491
+ return { answered: true, interactionId };
1492
+ }
1493
+ finally {
1494
+ pending.answering = false;
1495
+ }
1496
+ }
1497
+ resolveInteraction(interactionId, response) {
1498
+ const pending = this.pendingInteractions.get(interactionId);
1499
+ if (!pending)
1500
+ return;
1406
1501
  this.pendingInteractions.delete(interactionId);
1407
1502
  this.emitLive({
1408
1503
  event: 'user.interaction_answer',
@@ -1416,6 +1511,49 @@ class Anchor {
1416
1511
  this.scheduleExit();
1417
1512
  return { answered: true, interactionId };
1418
1513
  }
1514
+ async startAsyncQuestionReply(text) {
1515
+ if (this.status === 'running')
1516
+ throw new Error('The previous turn is still finishing; submit the answer again.');
1517
+ const previousTurn = this.currentTurn;
1518
+ const messageId = crypto.randomUUID();
1519
+ const accepted = deferred();
1520
+ let started = false;
1521
+ this.currentTurn = {
1522
+ messageId,
1523
+ claimToken: '',
1524
+ prompt: text,
1525
+ expectedText: text,
1526
+ status: 'running',
1527
+ accepted: true,
1528
+ startedAt: now(),
1529
+ completedAt: null,
1530
+ result: null,
1531
+ };
1532
+ this.setStatus('running');
1533
+ void this.transport
1534
+ .prompt(text, this.turnTimeoutMs, () => {
1535
+ started = true;
1536
+ this.emitLive({
1537
+ event: 'turn.start',
1538
+ agent: this.source,
1539
+ phase: 'queued',
1540
+ messageId,
1541
+ displayPrompt: text,
1542
+ prompt: text,
1543
+ });
1544
+ accepted.resolve();
1545
+ })
1546
+ .then(() => this.finish({ exitCode: 0, signal: null, timedOut: false, error: null }), (error) => {
1547
+ if (started)
1548
+ this.finish({ exitCode: 1, error: { message: errorText(error) } });
1549
+ else {
1550
+ this.currentTurn = previousTurn;
1551
+ this.setStatus('idle');
1552
+ accepted.reject(error);
1553
+ }
1554
+ });
1555
+ await accepted.promise;
1556
+ }
1419
1557
  expirePendingInteractions(reason) {
1420
1558
  const error = new Error(reason);
1421
1559
  for (const [interactionId, pending] of this.pendingInteractions) {
@@ -1436,7 +1574,7 @@ class Anchor {
1436
1574
  }
1437
1575
  scheduleExit() {
1438
1576
  clearTimeout(this.idleTimer);
1439
- if (this.pendingInteractions.size) {
1577
+ if (this.status === 'running' || this.pendingInteractions.size || this.transport?.backgroundTasks?.size) {
1440
1578
  this.idleTimer = null;
1441
1579
  return;
1442
1580
  }
@@ -1469,7 +1607,7 @@ class Anchor {
1469
1607
  }
1470
1608
  if (this.status === 'running')
1471
1609
  throw new Error('The anchored agent is already running a turn.');
1472
- if (this.pendingInteractions.size)
1610
+ if ([...this.pendingInteractions.values()].some((pending) => !isAsyncQuestionInteraction(pending.interaction)))
1473
1611
  throw new Error('The anchored agent is awaiting user input.');
1474
1612
  for (const key of ['source', 'sessionId', 'agentSessionId', 'messageId'])
1475
1613
  if (!request[key])
@@ -0,0 +1,29 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ export const AGENT_SLICE_NAME = 'turns-agents.slice';
4
+ /** A detached process group still belongs to its parent's memory cgroup. */
5
+ export function anchorLaunchCommand(entry, socketPath, { platform = process.platform, systemd = fs.existsSync('/run/systemd/system'), execPath = process.execPath, } = {}) {
6
+ const args = [entry, '--socket', socketPath];
7
+ if (platform !== 'linux' || !systemd)
8
+ return { command: execPath, args };
9
+ // A scope preserves the caller's environment and working directory. Failure
10
+ // must not silently fall back to putting an agent in the daemon's cgroup.
11
+ return {
12
+ command: '/usr/bin/systemd-run',
13
+ args: [
14
+ '--user',
15
+ '--scope',
16
+ `--slice=${AGENT_SLICE_NAME}`,
17
+ '--quiet',
18
+ '--no-ask-password',
19
+ '--collect',
20
+ '--expand-environment=no',
21
+ `--unit=turns-agent-${path.basename(socketPath, '.sock')}`,
22
+ '--property=OOMPolicy=kill',
23
+ '--property=MemoryAccounting=yes',
24
+ '--',
25
+ execPath,
26
+ ...args,
27
+ ],
28
+ };
29
+ }
@@ -0,0 +1,86 @@
1
+ import { spawn } from 'node:child_process';
2
+ import os from 'node:os';
3
+ /** Only copy public account metadata; never forward credentials or raw output. */
4
+ export function parseCodexAccount(result) {
5
+ if (!result || typeof result !== 'object' || !('account' in result))
6
+ return null;
7
+ const account = result.account;
8
+ if (account === null)
9
+ return { authenticated: false };
10
+ if (!account || typeof account !== 'object' || !('type' in account))
11
+ return null;
12
+ if (account.type === 'apiKey')
13
+ return { authenticated: true, account: 'API key' };
14
+ if (account.type !== 'chatgpt')
15
+ return null;
16
+ const email = 'email' in account && typeof account.email === 'string' ? account.email.trim().slice(0, 400) : '';
17
+ const plan = 'planType' in account && typeof account.planType === 'string' ? account.planType.trim().slice(0, 80) : '';
18
+ return {
19
+ authenticated: true,
20
+ account: [email || 'ChatGPT', plan ? `(${plan})` : ''].filter(Boolean).join(' '),
21
+ };
22
+ }
23
+ /** A short-lived, read-only app-server connection, using Codex's own credential
24
+ * store (including keychain). No thread or turn is created, and no token refresh
25
+ * is requested. Failure falls back to login status in the auth manager.
26
+ * https://developers.openai.com/codex/app-server#1-check-auth-state */
27
+ export function readCodexAccountStatus(command, timeoutMs = 10_000) {
28
+ return new Promise((resolve) => {
29
+ const child = spawn(command, ['app-server', '--listen', 'stdio://'], {
30
+ cwd: os.homedir(),
31
+ env: process.env,
32
+ stdio: ['pipe', 'pipe', 'ignore'],
33
+ });
34
+ let settled = false;
35
+ let initialized = false;
36
+ let buffer = '';
37
+ const finish = (answer = null) => {
38
+ if (settled)
39
+ return;
40
+ settled = true;
41
+ clearTimeout(timer);
42
+ child.stdin.destroy();
43
+ child.kill('SIGKILL');
44
+ resolve(answer);
45
+ };
46
+ const timer = setTimeout(finish, timeoutMs);
47
+ timer.unref?.();
48
+ const send = (frame) => child.stdin.write(`${JSON.stringify(frame)}\n`);
49
+ child.on('error', () => finish());
50
+ child.on('close', () => finish());
51
+ child.stdin.on('error', () => finish());
52
+ child.stdout.setEncoding('utf8');
53
+ child.stdout.on('data', (chunk) => {
54
+ buffer += chunk;
55
+ if (buffer.length > 64 * 1024)
56
+ return finish();
57
+ while (!settled) {
58
+ const newline = buffer.indexOf('\n');
59
+ if (newline < 0)
60
+ break;
61
+ const line = buffer.slice(0, newline);
62
+ buffer = buffer.slice(newline + 1);
63
+ let frame;
64
+ try {
65
+ frame = JSON.parse(line);
66
+ }
67
+ catch {
68
+ continue;
69
+ }
70
+ if (!frame || frame.method)
71
+ continue;
72
+ if (frame.id === 1 && !initialized) {
73
+ if (frame.error || !frame.result)
74
+ return finish();
75
+ initialized = true;
76
+ send({ method: 'initialized', params: {} });
77
+ send({ id: 2, method: 'account/read', params: { refreshToken: false } });
78
+ }
79
+ else if (frame.id === 2 && initialized) {
80
+ finish(frame.error ? null : parseCodexAccount(frame.result));
81
+ }
82
+ }
83
+ });
84
+ send({ id: 1, method: 'initialize', params: { clientInfo: { name: 'turns.dev', version: '1' } } });
85
+ });
86
+ }
@@ -0,0 +1,98 @@
1
+ import fs from 'node:fs';
2
+ import readline from 'node:readline';
3
+ import { ASYNC_QUESTION_TOOL, asyncQuestionInteractions, parseAsyncQuestionReplies } from "./codex-async-questions.js";
4
+ export function isAsyncQuestionId(value) {
5
+ if (typeof value !== 'string')
6
+ return false;
7
+ try {
8
+ const id = JSON.parse(value);
9
+ return (Array.isArray(id) &&
10
+ id.length === 3 &&
11
+ id[0] === ASYNC_QUESTION_TOOL &&
12
+ typeof id[1] === 'string' &&
13
+ !!id[1] &&
14
+ Number.isSafeInteger(id[2]) &&
15
+ id[2] >= 0 &&
16
+ JSON.stringify(id) === value);
17
+ }
18
+ catch {
19
+ return false;
20
+ }
21
+ }
22
+ /** Recover only delivered, unanswered questions from this anchor's own rollout.
23
+ * A question can be absent from the live/resumed item projection while the
24
+ * viewer reconstructs it from the native transcript. Never trust a browser-supplied
25
+ * question or revive a blocking tool request that has already finished.
26
+ */
27
+ export async function recoverAsyncQuestion(transcriptPath, interactionId) {
28
+ if (!isAsyncQuestionId(interactionId))
29
+ return null;
30
+ const [, callId] = JSON.parse(interactionId);
31
+ let candidate = null;
32
+ let delivered = false;
33
+ const stream = fs.createReadStream(transcriptPath, { encoding: 'utf8' });
34
+ const lines = readline.createInterface({ input: stream, crlfDelay: Infinity });
35
+ try {
36
+ for await (const line of lines) {
37
+ let doc;
38
+ try {
39
+ doc = JSON.parse(line);
40
+ }
41
+ catch {
42
+ continue;
43
+ }
44
+ const payload = doc?.payload;
45
+ if (!payload)
46
+ continue;
47
+ const item = doc.type === 'event_msg' && payload.type === 'item_completed' ? payload.item : null;
48
+ if (item?.type === 'AgentMessage' && item.delivery === 'async' && item.id === callId) {
49
+ candidate =
50
+ asyncQuestionInteractions(item.id, item.questions).find((question) => question.id === interactionId) || null;
51
+ delivered = true;
52
+ }
53
+ if (doc.type === 'response_item' && payload.call_id === callId) {
54
+ if (['function_call', 'custom_tool_call'].includes(payload.type) &&
55
+ [ASYNC_QUESTION_TOOL, `functions.${ASYNC_QUESTION_TOOL}`].includes(payload.name)) {
56
+ try {
57
+ const input = JSON.parse(payload.arguments ?? payload.input);
58
+ candidate =
59
+ asyncQuestionInteractions(callId, input?.questions).find((question) => question.id === interactionId) ||
60
+ null;
61
+ }
62
+ catch { }
63
+ }
64
+ if (['function_call_output', 'custom_tool_call_output'].includes(payload.type)) {
65
+ try {
66
+ delivered = JSON.parse(payload.output)?.accepted === true;
67
+ }
68
+ catch {
69
+ delivered = false;
70
+ }
71
+ }
72
+ }
73
+ const content = item?.type === 'UserMessage'
74
+ ? item.content
75
+ : doc.type === 'response_item' && payload.type === 'message' && payload.role === 'user'
76
+ ? payload.content
77
+ : null;
78
+ if (Array.isArray(content)) {
79
+ const text = content
80
+ .filter((part) => ['text', 'input_text'].includes(part?.type))
81
+ .map((part) => part.text)
82
+ .join('\n');
83
+ if (parseAsyncQuestionReplies(text)?.replies.some((reply) => reply.questionItemId === interactionId))
84
+ return null;
85
+ }
86
+ }
87
+ }
88
+ catch (error) {
89
+ if (error?.code === 'ENOENT')
90
+ return null;
91
+ throw error;
92
+ }
93
+ finally {
94
+ lines.close();
95
+ stream.destroy();
96
+ }
97
+ return delivered ? candidate : null;
98
+ }
@@ -0,0 +1,84 @@
1
+ // Pure wire helpers shared by the anchor, transcript reducer, and viewer.
2
+ export const ASYNC_QUESTION_TOOL = 'request_user_input_async';
3
+ export function asyncQuestionInteractions(callId, value) {
4
+ if (typeof callId !== 'string' || !callId || !Array.isArray(value))
5
+ return [];
6
+ return value.flatMap((question, index) => {
7
+ if (!question || typeof question.title !== 'string' || !question.title.trim())
8
+ return [];
9
+ const id = JSON.stringify([ASYNC_QUESTION_TOOL, callId, index]);
10
+ return [
11
+ {
12
+ id,
13
+ harness: 'codex',
14
+ request: {
15
+ method: ASYNC_QUESTION_TOOL,
16
+ params: {
17
+ questions: [
18
+ {
19
+ id,
20
+ title: question.title,
21
+ ...(Array.isArray(question.options)
22
+ ? { options: question.options.filter((option) => typeof option === 'string') }
23
+ : {}),
24
+ },
25
+ ],
26
+ },
27
+ },
28
+ },
29
+ ];
30
+ });
31
+ }
32
+ export function isAsyncQuestionInteraction(value) {
33
+ const interaction = value;
34
+ return interaction?.harness === 'codex' && interaction.request?.method === ASYNC_QUESTION_TOOL;
35
+ }
36
+ export function parseAsyncQuestionReplies(text) {
37
+ const pattern = /<send_user_message_question_reply>\s*([\s\S]*?)\s*<\/send_user_message_question_reply>/g;
38
+ const replies = [];
39
+ const remainingText = text
40
+ .replace(pattern, (block, body) => {
41
+ try {
42
+ const rows = JSON.parse(body);
43
+ if (!Array.isArray(rows) || !rows.length)
44
+ return block;
45
+ const valid = rows.every((row) => {
46
+ if (typeof row?.questionItemId !== 'string' ||
47
+ typeof row.question !== 'string' ||
48
+ typeof row.answer !== 'string')
49
+ return false;
50
+ const id = JSON.parse(row.questionItemId);
51
+ return (Array.isArray(id) &&
52
+ id.length === 3 &&
53
+ id[0] === ASYNC_QUESTION_TOOL &&
54
+ typeof id[1] === 'string' &&
55
+ !!id[1] &&
56
+ Number.isSafeInteger(id[2]) &&
57
+ id[2] >= 0);
58
+ });
59
+ if (!valid)
60
+ return block;
61
+ replies.push(...rows);
62
+ return '';
63
+ }
64
+ catch {
65
+ return block;
66
+ }
67
+ })
68
+ .trim();
69
+ return replies.length ? { replies, remainingText } : null;
70
+ }
71
+ export function asyncQuestionReplyText(interaction, response) {
72
+ const answers = response?.answers;
73
+ const question = interaction.request.params.questions[0];
74
+ const values = answers?.[question.id];
75
+ if (!Array.isArray(values) || values.length !== 1 || typeof values[0] !== 'string' || !values[0].trim())
76
+ throw new Error('Choose an option or enter an answer before submitting.');
77
+ return `<send_user_message_question_reply>\n${JSON.stringify([
78
+ {
79
+ questionItemId: question.id,
80
+ question: question.title,
81
+ answer: values[0],
82
+ },
83
+ ])}\n</send_user_message_question_reply>`;
84
+ }
package/dist/daemon.js CHANGED
@@ -4,6 +4,8 @@ import path from 'node:path';
4
4
  import process from 'node:process';
5
5
  import { spawnSync } from 'node:child_process';
6
6
  import { fileURLToPath } from 'node:url';
7
+ import { AGENT_SLICE_NAME } from "./agent-isolation.js";
8
+ import { executablePath } from "./executable-path.js";
7
9
  import { applicationPaths } from "./auth.js";
8
10
  const HERE = path.dirname(fileURLToPath(import.meta.url));
9
11
  /** Resolve linked-worktree installs back to the primary checkout. A service
@@ -41,7 +43,7 @@ export const MAC_LAUNCH_AGENT_LABEL = 'dev.turns.cli';
41
43
  function systemdQuote(value) {
42
44
  return `"${String(value).replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('%', '%%')}"`;
43
45
  }
44
- export function linuxServiceUnit({ cliEntry = canonicalDaemonCliEntry(), execPath = process.execPath } = {}) {
46
+ export function linuxServiceUnit({ cliEntry = canonicalDaemonCliEntry(), execPath = process.execPath, pathEnvironment = executablePath(), } = {}) {
45
47
  return `[Unit]
46
48
  Description=Continuously upload local AI sessions to turns.dev
47
49
  Wants=network-online.target
@@ -56,11 +58,23 @@ ExecStart=${systemdQuote(execPath)} --max-old-space-size=256 --max-semi-space-si
56
58
  Restart=on-failure
57
59
  RestartSec=5
58
60
  Environment=NODE_NO_WARNINGS=1
61
+ Environment=${systemdQuote(`PATH=${pathEnvironment}`)}
59
62
 
60
63
  [Install]
61
64
  WantedBy=default.target
62
65
  `;
63
66
  }
67
+ /** Monitor agents separately, before pressure reaches the user-manager limit. */
68
+ export function linuxAgentSliceUnit() {
69
+ return `[Unit]
70
+ Description=Turns agent workloads
71
+
72
+ [Slice]
73
+ MemoryAccounting=yes
74
+ ManagedOOMMemoryPressure=kill
75
+ ManagedOOMMemoryPressureLimit=40%
76
+ `;
77
+ }
64
78
  function xmlEscape(value) {
65
79
  return String(value)
66
80
  .replaceAll('&', '&amp;')
@@ -140,6 +154,10 @@ export function installLinuxDaemon({ paths = applicationPaths(), spawnSyncImpl =
140
154
  const temporary = `${paths.linuxService}.${process.pid}.tmp`;
141
155
  fs.writeFileSync(temporary, unit, { mode: 0o644 });
142
156
  fs.renameSync(temporary, paths.linuxService);
157
+ const slicePath = path.join(path.dirname(paths.linuxService), AGENT_SLICE_NAME);
158
+ const sliceTemporary = `${slicePath}.${process.pid}.tmp`;
159
+ fs.writeFileSync(sliceTemporary, linuxAgentSliceUnit(), { mode: 0o644 });
160
+ fs.renameSync(sliceTemporary, slicePath);
143
161
  systemctl(['disable', '--now', LEGACY_SYSTEMD_SERVICE_NAME], { spawnSyncImpl, allowFailure: true });
144
162
  const unitDirectory = path.dirname(paths.linuxService);
145
163
  for (const legacyLink of [
@@ -174,6 +192,7 @@ export function uninstallLinuxDaemon({ paths = applicationPaths(), spawnSyncImpl
174
192
  if (error?.code !== 'ENOENT')
175
193
  throw error;
176
194
  }
195
+ fs.rmSync(path.join(path.dirname(paths.linuxService), AGENT_SLICE_NAME), { force: true });
177
196
  systemctl(['daemon-reload'], { spawnSyncImpl, allowFailure: true });
178
197
  }
179
198
  export function linuxDaemonStatus({ spawnSyncImpl = spawnSync } = {}) {
@@ -0,0 +1,19 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+ /** Preserve explicit PATH precedence and add installations absent from service environments. */
4
+ export function executablePath(env = process.env, home = os.homedir(), execPath = process.execPath) {
5
+ return [
6
+ ...new Set([
7
+ ...String(env.PATH || '').split(path.delimiter),
8
+ path.dirname(execPath),
9
+ path.join(home, '.local', 'bin'),
10
+ path.join(home, 'bin'),
11
+ path.join(home, '.npm-global', 'bin'),
12
+ ...(env.NPM_CONFIG_PREFIX ? [path.join(env.NPM_CONFIG_PREFIX, 'bin')] : []),
13
+ '/opt/homebrew/bin',
14
+ '/usr/local/bin',
15
+ '/usr/bin',
16
+ '/bin',
17
+ ].filter((directory) => path.isAbsolute(directory))),
18
+ ].join(path.delimiter);
19
+ }
@@ -3,7 +3,7 @@ import fs from 'node:fs';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { workingDirectoryIsAllowed } from "./directory-policy.js";
6
- import { uploadFileStream } from "./path-resolution.js";
6
+ import { storedFileMetadata, uploadFileStream } from "./path-resolution.js";
7
7
  /**
8
8
  * Answering "what is in that directory?" from the machine that knows.
9
9
  *
@@ -248,6 +248,22 @@ export async function browseMachinePath({ requested, machine, baseUrl, options,
248
248
  if (resolved.stat.size > MAX_BROWSE_FILE_BYTES) {
249
249
  throw new Error(`File is larger than the ${BROWSE_FILE_LIMIT_LABEL} browse limit.`);
250
250
  }
251
+ // Compare metadata attached to the R2 object, not the directory index:
252
+ // directory observations can already describe newer, unuploaded bytes.
253
+ // Post-edit hashes always force an upload, even when mtime was preserved.
254
+ if (resolved.stat.size > 0 && !contentEtag) {
255
+ const cached = await storedFileMetadata({ baseUrl, options, machine, absolutePath: resolved.path, fetchImpl });
256
+ // The file can change while its stored metadata is being fetched.
257
+ resolved.stat = fs.statSync(resolved.path);
258
+ if (!resolved.stat.isFile())
259
+ throw new Error('The file changed while its stored metadata was being checked.');
260
+ if (resolved.stat.size > MAX_BROWSE_FILE_BYTES)
261
+ throw new Error(`File is larger than the ${BROWSE_FILE_LIMIT_LABEL} browse limit.`);
262
+ const modifiedAt = isoTime(resolved.stat.mtimeMs);
263
+ if (cached && cached.bytes === resolved.stat.size && cached.modifiedAt === modifiedAt) {
264
+ return { path: resolved.path, kind: 'file', bytes: cached.bytes, contentType: cached.contentType, modifiedAt };
265
+ }
266
+ }
251
267
  const sample = readFileTypeSample(resolved.path, resolved.stat.size);
252
268
  const contentType = browseContentType(resolved.path, sample, sample.byteLength === resolved.stat.size);
253
269
  const modifiedAt = isoTime(resolved.stat.mtimeMs);
@@ -0,0 +1,299 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ /** Fixed, read-only CLI commands. Never invoke a prompt, login, or config dump. */
5
+ export const NATIVE_ACCOUNT_COMMANDS = {
6
+ cursor: ['status', '--format', 'json'],
7
+ devin: ['auth', 'status'],
8
+ kilo: ['profile', '--json'],
9
+ };
10
+ function text(value, limit = 160) {
11
+ if (typeof value !== 'string')
12
+ return '';
13
+ const result = value.trim();
14
+ if (result.length > limit || /[\x00-\x1f]|(?:sk-|gh[pousr]_|github_pat_|eyJ)[A-Za-z0-9_-]{12}/u.test(result))
15
+ return '';
16
+ return result;
17
+ }
18
+ /** Allowlisted display metadata, never serialized credentials or arbitrary messages. */
19
+ export function accountSummary(value) {
20
+ if (!value || typeof value !== 'object')
21
+ return '';
22
+ const identity = text(value.email) || text(value.login) || text(value.username) || text(value.name);
23
+ const plan = text(value.planType ?? value.subscriptionType ?? value.plan_name ?? value.plan, 80);
24
+ return [identity, plan ? `(${plan})` : ''].filter(Boolean).join(' ');
25
+ }
26
+ function jsonDocument(output) {
27
+ try {
28
+ // Some CLIs put warnings before their JSON response.
29
+ return JSON.parse(output.slice(output.indexOf('{'), output.lastIndexOf('}') + 1));
30
+ }
31
+ catch {
32
+ return null;
33
+ }
34
+ }
35
+ export function parseNativeHarnessAccount(harness, output) {
36
+ const clean = output.replace(/\u001b\[[0-9;?]*[A-Za-z]/g, '');
37
+ if (harness === 'devin') {
38
+ if (/not logged in|not authenticated/iu.test(clean))
39
+ return { authenticated: false };
40
+ if (!/^Logged in/mu.test(clean))
41
+ return null;
42
+ const email = clean.match(/^\s*Email:\s*(.+)$/mu)?.[1];
43
+ const plan = clean.match(/^\s*Plan:\s*(.+)$/mu)?.[1] || clean.match(/^\s*Tier:\s*(.+)$/mu)?.[1];
44
+ const account = accountSummary({ email, plan });
45
+ return { authenticated: true, ...(account ? { account } : {}) };
46
+ }
47
+ const data = jsonDocument(clean);
48
+ if (!data)
49
+ return null;
50
+ if (harness === 'cursor') {
51
+ if (typeof data.isAuthenticated !== 'boolean')
52
+ return null;
53
+ const email = data.email ||
54
+ data.user?.email ||
55
+ clean.match(/[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/u)?.[0];
56
+ const account = accountSummary({ email, plan: data.membershipType || data.plan });
57
+ return { authenticated: data.isAuthenticated, ...(data.isAuthenticated && account ? { account } : {}) };
58
+ }
59
+ if (harness === 'kilo') {
60
+ const account = accountSummary(data.user ?? data.profile ?? data);
61
+ return account ? { authenticated: true, account } : null;
62
+ }
63
+ return null;
64
+ }
65
+ /** JSONC support for Copilot, preserving quoted URLs and trailing-comma strings. */
66
+ function readJson(file) {
67
+ try {
68
+ if (fs.statSync(file).size > 1024 * 1024)
69
+ return null;
70
+ const content = fs
71
+ .readFileSync(file, 'utf8')
72
+ .replace(/("(?:\\.|[^"\\])*")|\/\/[^\n]*|\/\*[\s\S]*?\*\//gu, (_match, quoted) => quoted || '')
73
+ .replace(/("(?:\\.|[^"\\])*")|,\s*(?=[}\]])/gu, (_match, quoted) => quoted || '');
74
+ return JSON.parse(content);
75
+ }
76
+ catch {
77
+ return null;
78
+ }
79
+ }
80
+ /** Decode only display claims locally. They are NOT proof that a grant is valid. */
81
+ function tokenAccount(value) {
82
+ if (typeof value !== 'string' || value.length > 32_768)
83
+ return '';
84
+ try {
85
+ const claims = JSON.parse(Buffer.from(value.split('.')[1], 'base64url').toString('utf8'));
86
+ return accountSummary({
87
+ email: claims.email || claims['https://api.openai.com/profile']?.email,
88
+ username: claims.preferred_username,
89
+ plan: claims['https://api.openai.com/auth']?.chatgpt_plan_type,
90
+ });
91
+ }
92
+ catch {
93
+ return '';
94
+ }
95
+ }
96
+ function credentialSummary(value) {
97
+ if (!value || typeof value !== 'object')
98
+ return '';
99
+ const tokens = value.tokens ?? value;
100
+ return (accountSummary(value.user) ||
101
+ accountSummary(value.account) ||
102
+ accountSummary(value) ||
103
+ tokenAccount(tokens.id_token ?? tokens.idToken) ||
104
+ tokenAccount(tokens.access_token ?? tokens.accessToken ?? tokens.access ?? tokens.token));
105
+ }
106
+ function stored(account) {
107
+ return account ? { authenticated: null, account: account.slice(0, 512) } : null;
108
+ }
109
+ const PROVIDER_LABELS = {
110
+ 'openai-codex': 'ChatGPT',
111
+ anthropic: 'Claude',
112
+ 'github-copilot': 'Copilot',
113
+ 'google-gemini-cli': 'Gemini',
114
+ 'google-antigravity': 'Antigravity',
115
+ opencode: 'OpenCode Zen',
116
+ 'opencode-go': 'OpenCode Go',
117
+ kilo: 'Kilo',
118
+ };
119
+ /** Multiple configured providers stay explicitly labelled; never pick an arbitrary
120
+ * saved account and present it as the selected model's subscription. */
121
+ function providerAccounts(providers) {
122
+ if (!providers || typeof providers !== 'object' || Array.isArray(providers))
123
+ return null;
124
+ const entries = Object.entries(providers)
125
+ .slice(0, 8)
126
+ .flatMap(([id, credential]) => {
127
+ if (!credential || typeof credential !== 'object')
128
+ return [];
129
+ const label = PROVIDER_LABELS[id] || text(id, 60);
130
+ if (!label)
131
+ return [];
132
+ const identity = credentialSummary(credential);
133
+ const method = credential.type === 'oauth' || credential.tokens
134
+ ? 'OAuth configured'
135
+ : credential.type === 'api' || credential.type === 'api_key'
136
+ ? 'API key configured'
137
+ : '';
138
+ return identity || method ? [`${label}: ${identity || method}`] : [];
139
+ });
140
+ return stored(entries.join('; '));
141
+ }
142
+ /** Local metadata is a saved account hint, not a live auth/entitlement check.
143
+ * Read only each harness's own store, honour home overrides, and never send keys,
144
+ * access/refresh tokens, or a whole configuration over the socket. Missing,
145
+ * opaque, encrypted, or unfamiliar stores report unknown rather than signed out. */
146
+ export function readLocalHarnessAccount(harness, { home = os.homedir(), env = process.env } = {}) {
147
+ const dataHome = env.XDG_DATA_HOME || path.join(home, '.local/share');
148
+ const configHome = env.XDG_CONFIG_HOME || path.join(home, '.config');
149
+ const read = (...parts) => readJson(path.join(...parts));
150
+ if (harness === 'copilot') {
151
+ if (env.COPILOT_GITHUB_TOKEN || env.GH_TOKEN || env.GITHUB_TOKEN)
152
+ return stored('GitHub token configured');
153
+ const config = read(env.COPILOT_HOME || path.join(home, '.copilot'), 'config.json');
154
+ const user = config?.lastLoggedInUser;
155
+ const login = text(user?.login);
156
+ return stored(login ? `@${login}` : '');
157
+ }
158
+ if (harness === 'gemini' || harness === 'qwen') {
159
+ const root = path.join(harness === 'gemini' ? env.GEMINI_CLI_HOME || home : home, `.${harness}`);
160
+ const config = read(root, 'settings.json');
161
+ const authType = config?.security?.auth?.selectedType;
162
+ if (authType && !['oauth-personal', 'qwen-oauth'].includes(authType)) {
163
+ return stored(authType === 'vertex-ai' ? 'Vertex AI configured' : 'API key configured');
164
+ }
165
+ if (harness === 'gemini') {
166
+ const email = text(read(root, 'google_accounts.json')?.active);
167
+ if (email)
168
+ return stored(email);
169
+ }
170
+ return stored(credentialSummary(read(root, 'oauth_creds.json')));
171
+ }
172
+ if (harness === 'grok') {
173
+ if (env.XAI_API_KEY || env.GROK_API_KEY)
174
+ return stored('API key configured');
175
+ const auth = read(env.GROK_HOME || path.join(home, '.grok'), 'auth.json');
176
+ const accounts = Object.values(auth ?? {})
177
+ .map(credentialSummary)
178
+ .filter(Boolean);
179
+ return stored([...new Set(accounts)].join('; '));
180
+ }
181
+ if (harness === 'kimi') {
182
+ const root = env.KIMI_SHARE_DIR || path.join(home, '.kimi');
183
+ // The Python and newer standalone CLIs use separate credential roots.
184
+ for (const directory of [path.join(root, 'credentials'), path.join(home, '.kimi-code/credentials')]) {
185
+ try {
186
+ const accounts = fs
187
+ .readdirSync(directory)
188
+ .filter((name) => name.endsWith('.json'))
189
+ .slice(0, 8)
190
+ .map((name) => credentialSummary(read(directory, name)))
191
+ .filter(Boolean);
192
+ if (accounts.length)
193
+ return stored([...new Set(accounts)].join('; '));
194
+ }
195
+ catch { }
196
+ }
197
+ return null;
198
+ }
199
+ if (harness === 'opencode' || harness === 'kilo')
200
+ return providerAccounts(read(dataHome, harness, 'auth.json'));
201
+ if (harness === 'pi' || harness === 'prime') {
202
+ return providerAccounts(read(harness === 'pi'
203
+ ? env.PI_CODING_AGENT_DIR || path.join(home, '.pi/agent')
204
+ : env.PRIME_AGENT_CODING_AGENT_DIR || path.join(home, '.prime/agent'), 'auth.json'));
205
+ }
206
+ if (harness === 'hermes') {
207
+ const config = read(env.HERMES_HOME || path.join(home, '.hermes'), 'auth.json');
208
+ const active = config?.active_provider;
209
+ return providerAccounts(active && config?.providers?.[active] ? { [active]: config.providers[active] } : config?.providers);
210
+ }
211
+ if (harness === 'openclaw') {
212
+ const config = read(env.OPENCLAW_STATE_DIR || path.join(home, '.openclaw'), 'agents/main/agent/auth-profiles.json');
213
+ return providerAccounts(config?.profiles);
214
+ }
215
+ if (harness === 'letta') {
216
+ if (env.LETTA_API_KEY)
217
+ return stored('Letta API key configured');
218
+ const settings = read(path.join(home, '.letta'), 'settings.json');
219
+ const identity = credentialSummary(settings);
220
+ return stored(identity || tokenAccount(settings?.env?.LETTA_API_KEY));
221
+ }
222
+ if (harness === 'codebuddy') {
223
+ if (env.CODEBUDDY_API_KEY)
224
+ return stored('CodeBuddy API key configured');
225
+ // CodeBuddy encrypts credential fields but leaves its account metadata in
226
+ // the shared auth session. Do not inspect the historical `accounts` array.
227
+ const roots = [
228
+ path.join(dataHome, 'CodeBuddyExtension'),
229
+ path.join(home, 'Library/Application Support/CodeBuddyExtension'),
230
+ ];
231
+ for (const root of roots) {
232
+ const directory = path.join(root, 'Data/Public/auth');
233
+ try {
234
+ const accounts = fs
235
+ .readdirSync(directory)
236
+ .filter((name) => name.endsWith('.info'))
237
+ .slice(0, 8)
238
+ .map((name) => read(directory, name)?.account)
239
+ .map((account) => accountSummary({ name: account?.nickname, email: account?.email, plan: account?.enterpriseName }))
240
+ .filter(Boolean);
241
+ if (accounts.length)
242
+ return stored([...new Set(accounts)].join('; '));
243
+ }
244
+ catch { }
245
+ }
246
+ return null;
247
+ }
248
+ if (harness === 'poolside') {
249
+ const credentials = read(configHome, 'poolside/credentials.json');
250
+ if (!Array.isArray(credentials))
251
+ return null;
252
+ return stored(credentials.map(credentialSummary).filter(Boolean).join('; '));
253
+ }
254
+ return null;
255
+ }
256
+ /** Vibe's own account endpoint supplies its subscription plan without a model
257
+ * request. Mirror the installed CLI's /api/vibe/whoami contract; restrict this
258
+ * fallback to the standard Mistral host rather than sending a tenant key there.
259
+ * https://github.com/mistralai/mistral-vibe */
260
+ export async function readVibeAccount({ home = os.homedir(), env = process.env, fetchImpl = fetch, } = {}) {
261
+ const root = env.VIBE_HOME || path.join(home, '.vibe');
262
+ let config = '';
263
+ let dotenv = '';
264
+ try {
265
+ config = fs.readFileSync(path.join(root, 'config.toml'), 'utf8');
266
+ }
267
+ catch { }
268
+ try {
269
+ dotenv = fs.readFileSync(path.join(root, '.env'), 'utf8');
270
+ }
271
+ catch { }
272
+ const consoleOverride = env.VIBE_CONSOLE_BASE_URL || config.match(/^\s*console_base_url\s*=\s*["']([^"']+)["']/mu)?.[1];
273
+ if (consoleOverride && consoleOverride.replace(/\/$/u, '') !== 'https://console.mistral.ai')
274
+ return null;
275
+ const raw = dotenv.match(/^\s*(?:export\s+)?MISTRAL_API_KEY\s*=\s*(.+)\s*$/mu)?.[1]?.trim() || '';
276
+ const key = env.MISTRAL_API_KEY || raw.replace(/^(["'])(.*)\1$/u, '$2');
277
+ if (!key)
278
+ return null;
279
+ try {
280
+ const response = await fetchImpl('https://console.mistral.ai/api/vibe/whoami', {
281
+ headers: { Authorization: `Bearer ${key}` },
282
+ redirect: 'error',
283
+ signal: AbortSignal.timeout(8_000),
284
+ });
285
+ if (response.status === 401)
286
+ return { authenticated: false };
287
+ if (!response.ok)
288
+ return null;
289
+ const data = (await response.json());
290
+ const plan = data.plan_type === 'chat'
291
+ ? { INDIVIDUAL: 'Pro', TEAM: 'Team', EDU: 'Student', FREE: 'Free' }[data.plan_name || ''] || data.plan_name
292
+ : data.plan_name;
293
+ const account = accountSummary({ email: data.email || 'Mistral', plan });
294
+ return data.plan_name ? { authenticated: true, account } : null;
295
+ }
296
+ catch {
297
+ return null;
298
+ }
299
+ }
@@ -14,8 +14,8 @@
14
14
  * scraping is deliberately loose — the first https URL is the approval
15
15
  * link, a short hyphenated all-caps token is the device code — so cosmetic
16
16
  * output changes in either CLI degrade to "no URL yet" rather than to a
17
- * crash. Auth *status*, by contrast, is read from the commands both CLIs
18
- * commit to: `claude auth status --json` and `codex login status`.
17
+ * crash. Auth status comes from `claude auth status --json` or Codex's
18
+ * structured `account/read`, with `codex login status` as a fallback.
19
19
  *
20
20
  * One flow per harness runs at a time. Flow state lives in this process and
21
21
  * dies with it; the credentials themselves live where the harness put them,
@@ -23,10 +23,12 @@
23
23
  */
24
24
  import { spawn } from 'node:child_process';
25
25
  import os from 'node:os';
26
- import { resolveHarnessCommand } from "./harness-registry.js";
27
- /** Harnesses whose sign-in this module can run. Mirrors the shared
26
+ import { readCodexAccountStatus } from "./codex-account.js";
27
+ import { readVibeAccount, readLocalHarnessAccount, parseNativeHarnessAccount, NATIVE_ACCOUNT_COMMANDS, } from "./harness-account.js";
28
+ import { ACP_PROFILES, HARNESS_IDS, resolveHarnessCommand } from "./harness-registry.js";
29
+ /** Harnesses whose account status this module can inspect. Mirrors the shared
28
30
  * HARNESS_AUTH_HARNESSES list; cli/ cannot import cloudflare/shared. */
29
- export const HARNESS_AUTH_HARNESSES = ['claude', 'codex'];
31
+ export const HARNESS_AUTH_HARNESSES = HARNESS_IDS;
30
32
  const STATUS_TIMEOUT_MS = 30_000;
31
33
  const STATUS_CACHE_MS = 10_000;
32
34
  const FLOW_TIMEOUT_MS = 15 * 60_000;
@@ -140,7 +142,32 @@ export function createHarnessAuthManager() {
140
142
  return cached.answer;
141
143
  let answer = { authenticated: null };
142
144
  try {
145
+ if (harness !== 'claude' && harness !== 'codex') {
146
+ const local = readLocalHarnessAccount(harness);
147
+ const args = NATIVE_ACCOUNT_COMMANDS[harness];
148
+ let native = harness === 'vibe' ? await readVibeAccount() : null;
149
+ if (args) {
150
+ try {
151
+ const run = await runCommand(resolveHarnessCommand(harness, ACP_PROFILES[harness]), args, STATUS_TIMEOUT_MS);
152
+ if (!run.failed && !run.timedOut)
153
+ native = parseNativeHarnessAccount(harness, run.output);
154
+ }
155
+ catch {
156
+ // Keep saved metadata when this CLI version cannot answer status.
157
+ }
158
+ }
159
+ const result = native?.authenticated === false ? native : native?.account ? native : (local ?? native ?? answer);
160
+ statusCache.set(harness, { at: Date.now(), answer: result });
161
+ return result;
162
+ }
143
163
  const command = resolveHarnessCommand(harness);
164
+ // The structured account report includes the email, unlike login status.
165
+ // Older CLIs can still answer the existing human-readable status command.
166
+ const account = harness === 'codex' ? await readCodexAccountStatus(command) : null;
167
+ if (account) {
168
+ statusCache.set(harness, { at: Date.now(), answer: account });
169
+ return account;
170
+ }
144
171
  const args = harness === 'claude' ? ['auth', 'status', '--json'] : ['login', 'status'];
145
172
  const run = await runCommand(command, args, STATUS_TIMEOUT_MS);
146
173
  if (!run.failed && !run.timedOut) {
@@ -228,7 +255,10 @@ export function createHarnessAuthManager() {
228
255
  else {
229
256
  // The last output line is usually the CLI's own explanation. It has
230
257
  // secrets stripped by the caller like every other error string.
231
- const lines = stripAnsi(flow.tail).split('\n').map((line) => line.trim()).filter(Boolean);
258
+ const lines = stripAnsi(flow.tail)
259
+ .split('\n')
260
+ .map((line) => line.trim())
261
+ .filter(Boolean);
232
262
  finishFlow(harness, flow, 'failed', lines.at(-1) || 'The sign-in flow ended without succeeding.');
233
263
  }
234
264
  });
@@ -265,6 +295,9 @@ export function createHarnessAuthManager() {
265
295
  if (!HARNESS_AUTH_HARNESSES.includes(harness)) {
266
296
  throw new Error(`Sign-in is not supported for harness "${harness}".`);
267
297
  }
298
+ if (action !== 'status' && harness !== 'claude' && harness !== 'codex') {
299
+ throw new Error('Use this harness’s terminal sign-in; account status is read-only here.');
300
+ }
268
301
  if (action === 'login')
269
302
  startFlow(harness);
270
303
  else if (action === 'code')
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
+ import { executablePath } from "./executable-path.js";
4
5
  const HOME = os.homedir();
5
6
  // Turns owns this catalog. It mirrors the transports verified by
6
7
  // turns/scaffolding; queue execution must never depend on another product's
@@ -304,7 +305,7 @@ export function resolveHarnessCommand(harness, profile = {}, dependencies = {})
304
305
  }
305
306
  }
306
307
  const command = profile.command || ({ cursor: 'cursor-agent', antigravity: 'agy' }[harness] ?? harness);
307
- for (const directory of String(env.PATH || '').split(path.delimiter)) {
308
+ for (const directory of executablePath(env, home).split(path.delimiter)) {
308
309
  const found = executable(path.join(directory, command));
309
310
  if (found)
310
311
  return found;
@@ -181,6 +181,34 @@ function fileUploadUrl(baseUrl, machine, absolutePath) {
181
181
  : encoded;
182
182
  return new URL(`/api/file/${encodeURIComponent(machine)}/${encodedPath}`, String(baseUrl));
183
183
  }
184
+ /** Read only the R2 object's upload metadata. Older servers, cache misses,
185
+ * and unavailable metadata conservatively retain the normal upload path. */
186
+ export async function storedFileMetadata({ baseUrl, options, machine, absolutePath, fetchImpl = fetch, }) {
187
+ const url = fileUploadUrl(baseUrl, machine, absolutePath);
188
+ url.pathname = url.pathname.replace('/api/file/', '/api/file-metadata/');
189
+ try {
190
+ const response = await fetchImpl(url, {
191
+ method: 'HEAD', headers: uploadHeaders(options), signal: AbortSignal.timeout(5000),
192
+ });
193
+ try {
194
+ if (!response.ok)
195
+ return null;
196
+ const size = response.headers.get('x-turns-file-size');
197
+ const bytes = Number(size);
198
+ const modifiedAt = response.headers.get('x-turns-file-modified-at') || '';
199
+ const contentType = response.headers.get('content-type') || '';
200
+ if (size === null || !Number.isSafeInteger(bytes) || bytes < 0 || !Number.isFinite(Date.parse(modifiedAt)) || !contentType)
201
+ return null;
202
+ return { bytes, modifiedAt: new Date(modifiedAt).toISOString(), contentType };
203
+ }
204
+ finally {
205
+ await response.body?.cancel();
206
+ }
207
+ }
208
+ catch {
209
+ return null;
210
+ }
211
+ }
184
212
  /** Kept in step with the explicit-browse transport constants in shared/files.ts. */
185
213
  export const MAX_SINGLE_BROWSE_UPLOAD_BYTES = 100 * 1024 * 1024;
186
214
  export const BROWSE_MULTIPART_PART_BYTES = 32 * 1024 * 1024;
@@ -6,11 +6,13 @@ import { Readable } from 'node:stream';
6
6
  import { pipeline } from 'node:stream/promises';
7
7
  import { anchorId, connectExistingAgentAnchors, launchAgentAnchor, waitForAgentTurn } from "./agent-anchor-client.js";
8
8
  import { createCodexUpdateManager } from "./codex-update.js";
9
+ import { isAsyncQuestionId } from "./codex-async-question-recovery.js";
9
10
  import { assertAllowedWorkingDirectory, workingDirectoryIsAllowed } from "./directory-policy.js";
10
11
  import { browseMachinePath, streamMachineFileRange } from "./file-browsing.js";
11
12
  import { applyFileEdit } from "./file-edits.js";
12
13
  import { searchMachinePaths } from "./file-search.js";
13
14
  import { readMachineGitBaseBlob, readMachineGitPatch, readMachineGitStatus } from "./git-status.js";
15
+ import { resolveHarnessCommand } from "./harness-registry.js";
14
16
  import { createHarnessAuthManager } from "./harness-auth.js";
15
17
  import { nativeDescriptorAgentSessionId, nativeDescriptorWorkingDirectory } from "./local-session-sources.js";
16
18
  import { compactRuntimeValue } from "./native-record-compaction.js";
@@ -24,6 +26,27 @@ const MACHINE_HEARTBEAT_TIMEOUT_MS = 15_000;
24
26
  const RUNTIME_BATCH_SIZE = 100;
25
27
  const MAX_RUNTIME_FLUSH_FAILURES = 10;
26
28
  const DEFAULT_REDACTOR = createSecretRedactor();
29
+ export async function answerAnchorInteraction(entry, interactionId, response) {
30
+ if (!entry || entry.connection.closed)
31
+ throw new Error('The detached harness is offline.');
32
+ entry.snapshot = await entry.connection.request('snapshot', {}, 5_000).catch(() => entry.snapshot);
33
+ const pending = entry.snapshot?.pendingInteractions?.some((interaction) => interaction.id === interactionId);
34
+ // Async questions can be reconstructed from the native transcript without
35
+ // appearing in the app-server's live/resumed item projection. Let the anchor
36
+ // verify and recover them instead of declaring the viewer's question stale.
37
+ if (!pending && !(entry.snapshot?.source === 'codex' && isAsyncQuestionId(interactionId))) {
38
+ throw new Error('The detached harness is no longer waiting for this interaction.');
39
+ }
40
+ try {
41
+ await entry.connection.request('answer_interaction', { interactionId, response }, 30_000);
42
+ }
43
+ catch (error) {
44
+ if (error?.message === 'The interaction is no longer waiting in this harness.') {
45
+ throw new Error('The detached harness is no longer waiting for this interaction.');
46
+ }
47
+ throw error;
48
+ }
49
+ }
27
50
  function wait(milliseconds) {
28
51
  return new Promise((resolve) => setTimeout(resolve, milliseconds));
29
52
  }
@@ -316,7 +339,7 @@ export function dispatchErrorIsRetryable(error) {
316
339
  // OpenCode emits this only after its provider stream has exhausted its own
317
340
  // backoff. Re-queueing the turn starts that entire retry sequence again and
318
341
  // also persists another copy of the user's prompt in the native session.
319
- return !(/(?:not supported|does not support|no resumable native session|session key.*unavailable|no local transcript descriptor|working directory (?:is (?:unavailable|not a directory|inaccessible)|must be|could not be created)|outside the daemon allowlist|agent executable not found|provider finish_reason:\s*network_error)/iu.test(message) || /attachment .* (?:size mismatch|download failed \(4\d\d\))/iu.test(message));
342
+ return !(/(?:not supported|does not support|no resumable native session|session key.*unavailable|no local transcript descriptor|working directory (?:is (?:unavailable|not a directory|inaccessible)|must be|could not be created)|outside the daemon allowlist|agent executable not found|backend must be upgraded|agent (?:isolation launch failed|anchor exited before opening)|provider finish_reason:\s*network_error)/iu.test(message) || /attachment .* (?:size mismatch|download failed \(4\d\d\))/iu.test(message));
320
343
  }
321
344
  export function queueFailureIsRetryable(error, promptStarted) {
322
345
  return !promptStarted && dispatchErrorIsRetryable(error);
@@ -657,6 +680,10 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
657
680
  : path.dirname(descriptor.file), options.allowedDirectories);
658
681
  if (!fs.statSync(cwd).isDirectory())
659
682
  throw new Error(`Working directory is unavailable: ${cwd}`);
683
+ // Check discovery before the durable delivery boundary, so an absent
684
+ // Codex installation leaves a recoverable unsent prompt in the queue.
685
+ if (descriptor.harness === 'codex')
686
+ resolveHarnessCommand('codex');
660
687
  entry = await usableAnchor(sessionId, descriptor.harness, agentSessionId);
661
688
  }
662
689
  if (!entry)
@@ -727,10 +754,16 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
727
754
  // boundary before starting this attempt so an earlier turn_error with
728
755
  // that ID cannot immediately settle the new turn.
729
756
  const eventCursor = entry.connection.eventCursor();
730
- // A local request can lose its response after the anchor has accepted
731
- // the prompt, so crossing this boundary permanently disables automatic
732
- // replay even when the exact delivery outcome is unknown.
757
+ // Persist before local delivery: either acknowledgement can be lost
758
+ // after acceptance. Uncertain delivery must never trigger automatic replay.
733
759
  queued.promptStarted = true;
760
+ await request({
761
+ type: 'queue.renew',
762
+ sessionId,
763
+ promptId: claim.prompt.id,
764
+ claimToken: claim.claimToken,
765
+ dispatchStarted: true,
766
+ });
734
767
  await entry.connection.request('send_message', {
735
768
  source: descriptor.harness,
736
769
  sessionId,
@@ -771,10 +804,12 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
771
804
  claimStarts.add(sessionId);
772
805
  let claim;
773
806
  try {
774
- const response = await request({ type: 'queue.claim', sessionId });
807
+ const response = await request({ type: 'queue.claim', sessionId, dispatchTracking: true });
775
808
  claim = response.claim;
776
809
  if (!claim)
777
810
  return;
811
+ if (!response.dispatchTracking)
812
+ throw new Error('The backend must be upgraded before dispatching prompts with this daemon.');
778
813
  await runClaim(sessionId, claim);
779
814
  // A completion can make the next item ready before its unsolicited event
780
815
  // crosses the socket. Claim once more immediately for zero-gap draining.
@@ -986,16 +1021,7 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
986
1021
  const sessionId = String(message.sessionId || '');
987
1022
  const entry = anchors.get(sessionId);
988
1023
  try {
989
- if (!entry || entry.connection.closed)
990
- throw new Error('The detached harness is offline.');
991
- entry.snapshot = await entry.connection.request('snapshot', {}, 5_000).catch(() => entry.snapshot);
992
- if (!entry.snapshot?.pendingInteractions?.some((interaction) => interaction.id === message.interactionId)) {
993
- throw new Error('The detached harness is no longer waiting for this interaction.');
994
- }
995
- await entry.connection.request('answer_interaction', {
996
- interactionId: String(message.interactionId || ''),
997
- response: message.response,
998
- }, 30_000);
1024
+ await answerAnchorInteraction(entry, String(message.interactionId || ''), message.response);
999
1025
  await flushRuntime(sessionId);
1000
1026
  replyRpc.notify({
1001
1027
  type: 'session.interaction.answer.result',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@turns/dev",
3
- "version": "0.0.14",
3
+ "version": "0.0.16",
4
4
  "description": "Upload local AI agent sessions to turns.dev",
5
5
  "license": "MIT",
6
6
  "type": "module",