@turns/dev 0.0.14 → 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/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 +2 -1
- package/dist/path-resolution.js +28 -0
- package/dist/queue-worker.js +41 -15
- 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/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('&', '&')
|
|
@@ -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
|
+
}
|
package/dist/file-browsing.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/harness-auth.js
CHANGED
|
@@ -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
|
|
18
|
-
*
|
|
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 {
|
|
27
|
-
|
|
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 =
|
|
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)
|
|
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')
|
package/dist/harness-registry.js
CHANGED
|
@@ -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
|
|
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;
|
package/dist/path-resolution.js
CHANGED
|
@@ -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;
|
package/dist/queue-worker.js
CHANGED
|
@@ -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
|
-
//
|
|
731
|
-
//
|
|
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
|
-
|
|
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',
|