@turns/dev 0.0.13 → 0.0.15

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)
@@ -500,6 +502,7 @@ class CodexAppServerTransport extends LineTransport {
500
502
  turnId;
501
503
  turnIdWaiter;
502
504
  turnWaiter;
505
+ asyncQuestionsSeen = new Set();
503
506
  constructor(anchor) {
504
507
  const profile = { command: 'codex' };
505
508
  const mcp = `mcp_servers.turns={command=${JSON.stringify(process.execPath)},args=[${JSON.stringify(questionMcpPath)}],env={TURNS_AGENT_ANCHOR_SOCKET=${JSON.stringify(socketPath)}}}`;
@@ -516,6 +519,32 @@ class CodexAppServerTransport extends LineTransport {
516
519
  this.turnId = String(turnId);
517
520
  this.turnIdWaiter?.resolve(this.turnId);
518
521
  }
522
+ registerAsyncQuestion(interaction) {
523
+ if (this.asyncQuestionsSeen.has(interaction.id))
524
+ return;
525
+ this.asyncQuestionsSeen.add(interaction.id);
526
+ void this.anchor
527
+ .askInteraction(interaction.request, {
528
+ id: interaction.id,
529
+ onAnswer: async (response) => {
530
+ const text = asyncQuestionReplyText(interaction, response);
531
+ if (this.turnWaiter)
532
+ await this.steer(text);
533
+ else
534
+ await this.anchor.startAsyncQuestionReply(text);
535
+ },
536
+ })
537
+ .catch(() => { });
538
+ }
539
+ async recoverAsyncQuestion(interactionId) {
540
+ if (this.asyncQuestionsSeen.has(interactionId))
541
+ return;
542
+ const interaction = await recoverAsyncQuestion(this.anchor.transcriptPath, interactionId);
543
+ // Registration checks again after the read: a live question or answer may
544
+ // have arrived while the transcript was being scanned.
545
+ if (interaction)
546
+ this.registerAsyncQuestion(interaction);
547
+ }
519
548
  onFrame(frame) {
520
549
  if (!frame || typeof frame !== 'object')
521
550
  return;
@@ -534,6 +563,24 @@ class CodexAppServerTransport extends LineTransport {
534
563
  return;
535
564
  }
536
565
  const ours = !frame.params?.threadId || frame.params.threadId === this.threadId;
566
+ if (ours && frame.method === 'item/completed') {
567
+ const item = frame.params?.item;
568
+ if (item?.type === 'agentMessage' && item.delivery === 'async') {
569
+ for (const interaction of asyncQuestionInteractions(item.id, item.questions)) {
570
+ this.registerAsyncQuestion(interaction);
571
+ }
572
+ }
573
+ if (item?.type === 'userMessage') {
574
+ const text = (item.content || [])
575
+ .filter((part) => part.type === 'text')
576
+ .map((part) => part.text)
577
+ .join('\n');
578
+ for (const reply of parseAsyncQuestionReplies(text)?.replies || []) {
579
+ this.asyncQuestionsSeen.add(reply.questionItemId);
580
+ this.anchor.resolveInteraction(reply.questionItemId, { answers: { [reply.questionItemId]: [reply.answer] } });
581
+ }
582
+ }
583
+ }
537
584
  if (frame.method === 'turn/started' && ours)
538
585
  this.setTurnId(frame.params?.turnId || frame.params?.turn?.id);
539
586
  if (['turn/completed', 'turn/failed', 'turn/aborted'].includes(frame.method) && ours && this.turnWaiter) {
@@ -628,8 +675,15 @@ class CodexAppServerTransport extends LineTransport {
628
675
  this.threadId = threadId;
629
676
  if (threadId !== this.anchor.agentSessionId)
630
677
  this.anchor.updateIdentity(threadId);
678
+ // Resume returns persisted thread items, including questions asked by a
679
+ // previous app-server connection. Rebuild only the still-unanswered items.
680
+ for (const turn of opened?.thread?.turns || []) {
681
+ for (const item of turn.items || []) {
682
+ this.onFrame({ method: 'item/completed', params: { threadId, item } });
683
+ }
684
+ }
631
685
  }
632
- async prompt(text, timeoutMs) {
686
+ async prompt(text, timeoutMs, onAccepted = () => { }) {
633
687
  if (this.turnWaiter)
634
688
  throw new Error('Codex already has an active turn.');
635
689
  const waiter = deferred();
@@ -641,10 +695,10 @@ class CodexAppServerTransport extends LineTransport {
641
695
  this.turnWaiter = null;
642
696
  this.turnIdWaiter?.resolve('');
643
697
  this.turnIdWaiter = null;
644
- waiter.reject(error);
645
698
  throw error;
646
699
  });
647
700
  this.setTurnId(started?.turn?.id || started?.turnId);
701
+ onAccepted();
648
702
  const timer = setTimeout(() => {
649
703
  if (this.turnWaiter === waiter)
650
704
  this.turnWaiter = null;
@@ -1389,20 +1443,45 @@ class Anchor {
1389
1443
  }
1390
1444
  askInteraction(request, options = {}) {
1391
1445
  const interaction = {
1392
- id: crypto.randomUUID(),
1446
+ id: options.id || crypto.randomUUID(),
1393
1447
  harness: this.source,
1394
1448
  request,
1395
1449
  };
1396
1450
  const waiter = deferred();
1397
1451
  clearTimeout(this.idleTimer);
1398
- this.pendingInteractions.set(interaction.id, { interaction, waiter, onExpire: options.onExpire });
1452
+ this.pendingInteractions.set(interaction.id, {
1453
+ interaction,
1454
+ waiter,
1455
+ onExpire: options.onExpire,
1456
+ onAnswer: options.onAnswer,
1457
+ answering: false,
1458
+ });
1399
1459
  this.emitLive({ event: 'user.interaction', agent: this.source, phase: 'queued', interaction });
1400
1460
  return waiter.promise;
1401
1461
  }
1402
- answerInteraction(interactionId, response) {
1462
+ async answerInteraction(interactionId, response) {
1463
+ if (!this.pendingInteractions.has(interactionId) && this.transport instanceof CodexAppServerTransport) {
1464
+ await this.transport.recoverAsyncQuestion(interactionId);
1465
+ }
1403
1466
  const pending = this.pendingInteractions.get(interactionId);
1404
1467
  if (!pending)
1405
1468
  throw new Error('The interaction is no longer waiting in this harness.');
1469
+ if (pending.answering)
1470
+ throw new Error('This answer is already being submitted.');
1471
+ pending.answering = true;
1472
+ try {
1473
+ await pending.onAnswer?.(response);
1474
+ this.resolveInteraction(interactionId, response);
1475
+ return { answered: true, interactionId };
1476
+ }
1477
+ finally {
1478
+ pending.answering = false;
1479
+ }
1480
+ }
1481
+ resolveInteraction(interactionId, response) {
1482
+ const pending = this.pendingInteractions.get(interactionId);
1483
+ if (!pending)
1484
+ return;
1406
1485
  this.pendingInteractions.delete(interactionId);
1407
1486
  this.emitLive({
1408
1487
  event: 'user.interaction_answer',
@@ -1416,6 +1495,49 @@ class Anchor {
1416
1495
  this.scheduleExit();
1417
1496
  return { answered: true, interactionId };
1418
1497
  }
1498
+ async startAsyncQuestionReply(text) {
1499
+ if (this.status === 'running')
1500
+ throw new Error('The previous turn is still finishing; submit the answer again.');
1501
+ const previousTurn = this.currentTurn;
1502
+ const messageId = crypto.randomUUID();
1503
+ const accepted = deferred();
1504
+ let started = false;
1505
+ this.currentTurn = {
1506
+ messageId,
1507
+ claimToken: '',
1508
+ prompt: text,
1509
+ expectedText: text,
1510
+ status: 'running',
1511
+ accepted: true,
1512
+ startedAt: now(),
1513
+ completedAt: null,
1514
+ result: null,
1515
+ };
1516
+ this.setStatus('running');
1517
+ void this.transport
1518
+ .prompt(text, this.turnTimeoutMs, () => {
1519
+ started = true;
1520
+ this.emitLive({
1521
+ event: 'turn.start',
1522
+ agent: this.source,
1523
+ phase: 'queued',
1524
+ messageId,
1525
+ displayPrompt: text,
1526
+ prompt: text,
1527
+ });
1528
+ accepted.resolve();
1529
+ })
1530
+ .then(() => this.finish({ exitCode: 0, signal: null, timedOut: false, error: null }), (error) => {
1531
+ if (started)
1532
+ this.finish({ exitCode: 1, error: { message: errorText(error) } });
1533
+ else {
1534
+ this.currentTurn = previousTurn;
1535
+ this.setStatus('idle');
1536
+ accepted.reject(error);
1537
+ }
1538
+ });
1539
+ await accepted.promise;
1540
+ }
1419
1541
  expirePendingInteractions(reason) {
1420
1542
  const error = new Error(reason);
1421
1543
  for (const [interactionId, pending] of this.pendingInteractions) {
@@ -1469,7 +1591,7 @@ class Anchor {
1469
1591
  }
1470
1592
  if (this.status === 'running')
1471
1593
  throw new Error('The anchored agent is already running a turn.');
1472
- if (this.pendingInteractions.size)
1594
+ if ([...this.pendingInteractions.values()].some((pending) => !isAsyncQuestionInteraction(pending.interaction)))
1473
1595
  throw new Error('The anchored agent is awaiting user input.');
1474
1596
  for (const key of ['source', 'sessionId', 'agentSessionId', 'messageId'])
1475
1597
  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
+ }
@@ -1,7 +1,7 @@
1
1
  /** Machine-local Codex CLI self-update, callable by the Turns daemon. */
2
2
  import { spawn } from 'node:child_process';
3
3
  import os from 'node:os';
4
- import { resolveHarnessCommand } from "./harness-registry.js";
4
+ import { codexCommandIsAppManaged, resolveHarnessCommand } from "./harness-registry.js";
5
5
  const VERSION_TIMEOUT_MS = 15_000;
6
6
  const UPDATE_TIMEOUT_MS = 5 * 60_000;
7
7
  const OUTPUT_LIMIT = 64 * 1024;
@@ -58,20 +58,28 @@ function commandFailure(result, fallback) {
58
58
  }
59
59
  export function createCodexUpdateManager(dependencies = {}) {
60
60
  const resolveCommand = dependencies.resolveCommand || (() => resolveHarnessCommand('codex'));
61
+ const isAppManaged = dependencies.isAppManaged || codexCommandIsAppManaged;
61
62
  const run = dependencies.run || runCommand;
62
63
  let active = null;
64
+ const updateCommand = () => {
65
+ const command = resolveCommand();
66
+ if (isAppManaged(command)) {
67
+ throw new Error('Codex is managed by the macOS app. Update the Codex app to update its bundled CLI.');
68
+ }
69
+ return command;
70
+ };
63
71
  const perform = async () => {
64
- const beforeRun = await run(resolveCommand(), ['--version'], VERSION_TIMEOUT_MS);
72
+ const beforeRun = await run(updateCommand(), ['--version'], VERSION_TIMEOUT_MS);
65
73
  if (beforeRun.failed || beforeRun.timedOut || beforeRun.exitCode !== 0) {
66
74
  throw commandFailure(beforeRun, 'Codex is not installed or its version could not be read.');
67
75
  }
68
76
  const previousVersion = parseCodexVersion(beforeRun.output);
69
- const updateRun = await run(resolveCommand(), ['update'], UPDATE_TIMEOUT_MS);
77
+ const updateRun = await run(updateCommand(), ['update'], UPDATE_TIMEOUT_MS);
70
78
  if (updateRun.failed || updateRun.timedOut || updateRun.exitCode !== 0) {
71
79
  throw commandFailure(updateRun, 'Codex could not update itself. Run `codex update` in a terminal on the machine.');
72
80
  }
73
81
  // Resolve again because the updater may replace a shim or install path.
74
- const afterRun = await run(resolveCommand(), ['--version'], VERSION_TIMEOUT_MS);
82
+ const afterRun = await run(updateCommand(), ['--version'], VERSION_TIMEOUT_MS);
75
83
  if (afterRun.failed || afterRun.timedOut || afterRun.exitCode !== 0) {
76
84
  throw commandFailure(afterRun, 'Codex updated, but its new version could not be read.');
77
85
  }
@@ -83,6 +91,15 @@ export function createCodexUpdateManager(dependencies = {}) {
83
91
  };
84
92
  };
85
93
  return {
94
+ /** App-bundled Codex is updated with the app, not by its CLI command. */
95
+ available() {
96
+ try {
97
+ return !isAppManaged(resolveCommand());
98
+ }
99
+ catch {
100
+ return false;
101
+ }
102
+ },
86
103
  /** Concurrent clicks share one installer process instead of racing writes. */
87
104
  update() {
88
105
  if (!active)