@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 +50 -0
- package/dist/agent-anchor-client.js +11 -2
- package/dist/agent-anchor.js +128 -6
- package/dist/agent-isolation.js +29 -0
- package/dist/codex-account.js +86 -0
- package/dist/codex-async-question-recovery.js +98 -0
- package/dist/codex-async-questions.js +84 -0
- package/dist/codex-update.js +21 -4
- package/dist/daemon.js +20 -1
- package/dist/executable-path.js +19 -0
- package/dist/file-browsing.js +17 -1
- package/dist/harness-account.js +299 -0
- package/dist/harness-auth.js +39 -6
- package/dist/harness-registry.js +32 -6
- package/dist/path-resolution.js +28 -0
- package/dist/queue-worker.js +48 -16
- package/package.json +1 -1
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
|
|
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
|
}
|
package/dist/agent-anchor.js
CHANGED
|
@@ -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, {
|
|
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.
|
|
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
|
+
}
|
package/dist/codex-update.js
CHANGED
|
@@ -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(
|
|
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(
|
|
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(
|
|
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)
|