@turns/dev 0.0.31 → 0.0.33
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/dist/agent-anchor-client.js +20 -3
- package/dist/agent-anchor.js +97 -28
- package/dist/harness-auth.js +10 -2
- package/dist/harness-registry.js +12 -0
- package/dist/local-session-sources.js +59 -27
- package/dist/queue-worker.js +18 -11
- package/dist/upload-sessions.js +24 -13
- package/package.json +1 -1
|
@@ -58,7 +58,7 @@ export class AgentAnchorConnection {
|
|
|
58
58
|
socket.once('connect', resolve);
|
|
59
59
|
socket.once('error', reject);
|
|
60
60
|
}), timeoutMs, `Agent anchor connection ${this.socketPath}`);
|
|
61
|
-
this.snapshot = await this.request('snapshot',
|
|
61
|
+
this.snapshot = await this.request('snapshot', STATUS_SNAPSHOT, timeoutMs);
|
|
62
62
|
return this;
|
|
63
63
|
}
|
|
64
64
|
handleLine(line) {
|
|
@@ -228,6 +228,21 @@ export async function launchAgentAnchor(sessionId, timeoutMs = 15_000) {
|
|
|
228
228
|
catch { }
|
|
229
229
|
throw lastError || new Error(`Timed out starting agent anchor. See ${logPath}`);
|
|
230
230
|
}
|
|
231
|
+
/** Ask an anchor for its state without its buffered live events. Anchors
|
|
232
|
+
* older than this option ignore it and answer in full, as before. */
|
|
233
|
+
export const STATUS_SNAPSHOT = { liveEvents: false };
|
|
234
|
+
/**
|
|
235
|
+
* Whether a failed connection proves nothing is listening on an anchor socket.
|
|
236
|
+
*
|
|
237
|
+
* Only then may the socket file be removed. A timeout proves the opposite of
|
|
238
|
+
* nothing: a busy daemon or a slow, swapped or heavily loaded anchor answers
|
|
239
|
+
* late, and unlinking the path of an anchor that is alive orphans the agent
|
|
240
|
+
* running behind it. It can never be adopted again, its turn's result is lost,
|
|
241
|
+
* and the next prompt starts a second harness on the same session.
|
|
242
|
+
*/
|
|
243
|
+
export function anchorSocketIsAbandoned(error) {
|
|
244
|
+
return ['ECONNREFUSED', 'ENOENT', 'ENOTSOCK'].includes(String(error?.code || ''));
|
|
245
|
+
}
|
|
231
246
|
export async function connectExistingAgentAnchors() {
|
|
232
247
|
const directory = agentAnchorDirectory();
|
|
233
248
|
let paths = [];
|
|
@@ -250,8 +265,10 @@ export async function connectExistingAgentAnchors() {
|
|
|
250
265
|
}
|
|
251
266
|
anchors.push({ connection, snapshot });
|
|
252
267
|
}
|
|
253
|
-
catch {
|
|
268
|
+
catch (error) {
|
|
254
269
|
connection.close();
|
|
270
|
+
if (!anchorSocketIsAbandoned(error))
|
|
271
|
+
return;
|
|
255
272
|
try {
|
|
256
273
|
fs.unlinkSync(socketPath);
|
|
257
274
|
}
|
|
@@ -316,7 +333,7 @@ export function waitForAgentTurn(connection, messageId, timeoutMs = 12 * 60 * 60
|
|
|
316
333
|
settle(initial);
|
|
317
334
|
if (!settled)
|
|
318
335
|
connection
|
|
319
|
-
.request('snapshot',
|
|
336
|
+
.request('snapshot', STATUS_SNAPSHOT, 5_000)
|
|
320
337
|
.then((latest) => {
|
|
321
338
|
const result = resultFromSnapshot(latest);
|
|
322
339
|
if (result)
|
package/dist/agent-anchor.js
CHANGED
|
@@ -111,6 +111,19 @@ function childResult(child, timeoutMs, harness, stderrTail) {
|
|
|
111
111
|
timer.unref?.();
|
|
112
112
|
return result.promise;
|
|
113
113
|
}
|
|
114
|
+
/** Run a transport operation, failing fast when its harness process has died. */
|
|
115
|
+
function untilHarnessExit(transport, start) {
|
|
116
|
+
if (transport?.exited)
|
|
117
|
+
return Promise.reject(transport.exited);
|
|
118
|
+
let work;
|
|
119
|
+
try {
|
|
120
|
+
work = Promise.resolve(start());
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
work = Promise.reject(error);
|
|
124
|
+
}
|
|
125
|
+
return typeof transport?.untilExit === 'function' ? transport.untilExit(work) : work;
|
|
126
|
+
}
|
|
114
127
|
class LineTransport {
|
|
115
128
|
backgroundTasks;
|
|
116
129
|
anchor;
|
|
@@ -120,6 +133,9 @@ class LineTransport {
|
|
|
120
133
|
lastEventAt;
|
|
121
134
|
child;
|
|
122
135
|
stderrTail = new StderrTail();
|
|
136
|
+
/** Why the harness process is gone, once it is. */
|
|
137
|
+
exited = null;
|
|
138
|
+
exitSignal = deferred();
|
|
123
139
|
constructor(anchor, harness, command, args, cwd, env = {}) {
|
|
124
140
|
this.anchor = anchor;
|
|
125
141
|
this.harness = harness;
|
|
@@ -150,8 +166,10 @@ class LineTransport {
|
|
|
150
166
|
phase: 'queued',
|
|
151
167
|
});
|
|
152
168
|
});
|
|
153
|
-
|
|
154
|
-
|
|
169
|
+
const ended = (error) => {
|
|
170
|
+
if (this.exited)
|
|
171
|
+
return;
|
|
172
|
+
this.exited = error;
|
|
155
173
|
for (const waiter of this.pending.values())
|
|
156
174
|
waiter.reject(error);
|
|
157
175
|
this.pending.clear();
|
|
@@ -159,7 +177,38 @@ class LineTransport {
|
|
|
159
177
|
if (this.anchor.transport === this && this.anchor.status === 'idle')
|
|
160
178
|
this.anchor.scheduleExit();
|
|
161
179
|
this.onClose?.(error);
|
|
162
|
-
|
|
180
|
+
// After the turn's own handling: a result that arrived with the exit wins.
|
|
181
|
+
setImmediate(() => this.exitSignal.resolve(error));
|
|
182
|
+
};
|
|
183
|
+
this.child.once('close', (code, signal) => ended(harnessExitError(anchor.source, code, signal, this.stderrTail.lines())));
|
|
184
|
+
// A process that never started (a working directory deleted since it was
|
|
185
|
+
// checked, no memory, a broken interpreter) reports `error`, not `close`.
|
|
186
|
+
// Unheard, that event is an uncaught exception that takes the anchor down
|
|
187
|
+
// and leaves the browser with "Agent anchor disconnected" for an answer.
|
|
188
|
+
this.child.once('error', (error) => ended(new Error(`${anchor.source} could not be started: ${error?.message || error}`)));
|
|
189
|
+
// A write racing the exit fails with EPIPE; the exit itself is the report.
|
|
190
|
+
this.child.stdin?.on('error', () => { });
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Settle with `work`, or fail as soon as the harness process is gone.
|
|
194
|
+
*
|
|
195
|
+
* Requests already waiting when the process exits are rejected there. A
|
|
196
|
+
* request made afterwards writes to a closed pipe, which fails silently, and
|
|
197
|
+
* then waits for a reply that cannot come: a prompt sat "running" for the
|
|
198
|
+
* twelve-hour turn limit, and nothing the browser could send (interrupt,
|
|
199
|
+
* shutdown) got through either.
|
|
200
|
+
*/
|
|
201
|
+
untilExit(work) {
|
|
202
|
+
if (this.exited) {
|
|
203
|
+
work.catch(() => { });
|
|
204
|
+
return Promise.reject(this.exited);
|
|
205
|
+
}
|
|
206
|
+
return Promise.race([
|
|
207
|
+
work,
|
|
208
|
+
this.exitSignal.promise.then((error) => {
|
|
209
|
+
throw error;
|
|
210
|
+
}),
|
|
211
|
+
]);
|
|
163
212
|
}
|
|
164
213
|
emit(value, meta = {}) {
|
|
165
214
|
const raw = JSON.stringify(value);
|
|
@@ -1136,6 +1185,9 @@ class RpcTransport extends LineTransport {
|
|
|
1136
1185
|
const deadline = Date.now() + timeoutMs;
|
|
1137
1186
|
while (Date.now() < deadline) {
|
|
1138
1187
|
await new Promise((resolve) => setTimeout(resolve, 1_000));
|
|
1188
|
+
// A dead process answers no state query; silence is not "idle".
|
|
1189
|
+
if (this.exited)
|
|
1190
|
+
throw this.exited;
|
|
1139
1191
|
if (this.events === 0 || Date.now() - this.lastEventAt < 2_500)
|
|
1140
1192
|
continue;
|
|
1141
1193
|
const state = await this.request('get_state', {}, 20_000).catch(() => null);
|
|
@@ -1280,6 +1332,8 @@ class OmpRpcTransport extends RpcTransport {
|
|
|
1280
1332
|
this.lastEventAt = Date.now();
|
|
1281
1333
|
while (Date.now() < deadline) {
|
|
1282
1334
|
await new Promise((resolve) => setTimeout(resolve, 2_000));
|
|
1335
|
+
if (this.exited)
|
|
1336
|
+
throw this.exited;
|
|
1283
1337
|
if (Date.now() - this.lastEventAt < 6_000 || this.openTurns > 0)
|
|
1284
1338
|
continue;
|
|
1285
1339
|
if (this.eventsSincePrompt === 0 && Date.now() - startedAt < 45_000)
|
|
@@ -1515,7 +1569,16 @@ class Anchor {
|
|
|
1515
1569
|
this.turnTimeoutMs = 12 * 60 * 60_000;
|
|
1516
1570
|
this.idleTimer = null;
|
|
1517
1571
|
}
|
|
1518
|
-
|
|
1572
|
+
/**
|
|
1573
|
+
* Everything a client needs to know about this anchor.
|
|
1574
|
+
*
|
|
1575
|
+
* The buffered live events (up to 2,000 of them, 8 MB) are only wanted by a
|
|
1576
|
+
* daemon recovering a turn it did not watch. Status checks ask for the
|
|
1577
|
+
* snapshot without them: serializing, sending and parsing that buffer every
|
|
1578
|
+
* minute for each running session stalled the daemon's event loop for tens
|
|
1579
|
+
* of milliseconds a time and kept a copy on its heap per anchor.
|
|
1580
|
+
*/
|
|
1581
|
+
snapshot({ liveEvents = true } = {}) {
|
|
1519
1582
|
return {
|
|
1520
1583
|
protocol: 'turns.agent-anchor.v1',
|
|
1521
1584
|
owner: 'turns.dev',
|
|
@@ -1545,7 +1608,7 @@ class Anchor {
|
|
|
1545
1608
|
currentTurn: this.currentTurn ? { ...this.currentTurn } : null,
|
|
1546
1609
|
pendingInteractions: [...this.pendingInteractions.values()].map((item) => item.interaction),
|
|
1547
1610
|
lastEventSequence: this.sequence,
|
|
1548
|
-
liveEvents: this.events.map((event) => ({ ...event })),
|
|
1611
|
+
liveEvents: liveEvents ? this.events.map((event) => ({ ...event })) : [],
|
|
1549
1612
|
};
|
|
1550
1613
|
}
|
|
1551
1614
|
broadcast(value) {
|
|
@@ -1811,32 +1874,32 @@ class Anchor {
|
|
|
1811
1874
|
return interruptedBeforePrompt();
|
|
1812
1875
|
if (this.source === 'claude') {
|
|
1813
1876
|
if (!(this.transport instanceof ClaudeTransport) ||
|
|
1814
|
-
this.transport.child.exitCode !== null ||
|
|
1877
|
+
this.transport.exited || this.transport.child.exitCode !== null ||
|
|
1815
1878
|
this.transport.sessionId !== this.agentSessionId ||
|
|
1816
1879
|
this.transport.model !== this.model ||
|
|
1817
1880
|
this.transport.thinkingLevel !== this.thinkingLevel ||
|
|
1818
1881
|
this.transport.permissionMode !== this.permissionMode) {
|
|
1819
1882
|
this.transport?.stop?.();
|
|
1820
1883
|
this.transport = new ClaudeTransport(this, createSession);
|
|
1821
|
-
await this.transport.open();
|
|
1884
|
+
await untilHarnessExit(this.transport, () => this.transport.open());
|
|
1822
1885
|
}
|
|
1823
1886
|
if (interruptedBeforePrompt())
|
|
1824
1887
|
return interruptedBeforePrompt();
|
|
1825
|
-
const result = await this.transport.prompt(this.currentTurn.prompt, timeoutMs);
|
|
1888
|
+
const result = await untilHarnessExit(this.transport, () => this.transport.prompt(this.currentTurn.prompt, timeoutMs));
|
|
1826
1889
|
return { exitCode: 0, signal: null, timedOut: false, error: null, stopReason: result?.subtype || 'completed' };
|
|
1827
1890
|
}
|
|
1828
1891
|
if (this.source === 'codex') {
|
|
1829
1892
|
if (!(this.transport instanceof CodexAppServerTransport) ||
|
|
1830
|
-
this.transport.child.exitCode !== null ||
|
|
1893
|
+
this.transport.exited || this.transport.child.exitCode !== null ||
|
|
1831
1894
|
this.transport.threadId !== this.agentSessionId ||
|
|
1832
1895
|
this.transport.permissionMode !== this.permissionMode) {
|
|
1833
1896
|
this.transport?.stop?.();
|
|
1834
1897
|
this.transport = new CodexAppServerTransport(this);
|
|
1835
|
-
await this.transport.open(createSession);
|
|
1898
|
+
await untilHarnessExit(this.transport, () => this.transport.open(createSession));
|
|
1836
1899
|
}
|
|
1837
1900
|
if (interruptedBeforePrompt())
|
|
1838
1901
|
return interruptedBeforePrompt();
|
|
1839
|
-
const result = await this.transport.prompt(this.currentTurn.prompt, timeoutMs);
|
|
1902
|
+
const result = await untilHarnessExit(this.transport, () => this.transport.prompt(this.currentTurn.prompt, timeoutMs));
|
|
1840
1903
|
return {
|
|
1841
1904
|
exitCode: 0,
|
|
1842
1905
|
signal: null,
|
|
@@ -1847,16 +1910,16 @@ class Anchor {
|
|
|
1847
1910
|
}
|
|
1848
1911
|
if (this.source === 'letta') {
|
|
1849
1912
|
if (!(this.transport instanceof LettaTransport) ||
|
|
1850
|
-
this.transport.child.exitCode !== null ||
|
|
1913
|
+
this.transport.exited || this.transport.child.exitCode !== null ||
|
|
1851
1914
|
lettaSessionIdentity(this.transport.agentId, this.transport.conversationId) !== this.agentSessionId ||
|
|
1852
1915
|
this.transport.permissionMode !== this.permissionMode) {
|
|
1853
1916
|
this.transport?.stop?.();
|
|
1854
1917
|
this.transport = new LettaTransport(this, createSession);
|
|
1855
|
-
await this.transport.open();
|
|
1918
|
+
await untilHarnessExit(this.transport, () => this.transport.open());
|
|
1856
1919
|
}
|
|
1857
1920
|
if (interruptedBeforePrompt())
|
|
1858
1921
|
return interruptedBeforePrompt();
|
|
1859
|
-
const result = await this.transport.prompt(this.currentTurn.prompt, timeoutMs);
|
|
1922
|
+
const result = await untilHarnessExit(this.transport, () => this.transport.prompt(this.currentTurn.prompt, timeoutMs));
|
|
1860
1923
|
return {
|
|
1861
1924
|
exitCode: 0,
|
|
1862
1925
|
signal: null,
|
|
@@ -1889,8 +1952,12 @@ class Anchor {
|
|
|
1889
1952
|
detached: process.platform !== 'win32',
|
|
1890
1953
|
});
|
|
1891
1954
|
this.child = child;
|
|
1892
|
-
if (invocation.stdin)
|
|
1955
|
+
if (invocation.stdin) {
|
|
1956
|
+
// The harness may exit before reading its prompt; that exit is reported
|
|
1957
|
+
// below, and the broken pipe must not crash the anchor first.
|
|
1958
|
+
child.stdin.on('error', () => { });
|
|
1893
1959
|
child.stdin.end(`${invocation.stdin}\n`);
|
|
1960
|
+
}
|
|
1894
1961
|
const stderrTail = new StderrTail();
|
|
1895
1962
|
for (const [stream, source] of [
|
|
1896
1963
|
[child.stdout, `${this.source}.stdout`],
|
|
@@ -1915,17 +1982,17 @@ class Anchor {
|
|
|
1915
1982
|
if (!(this.transport instanceof AcpTransport) ||
|
|
1916
1983
|
this.transport.harness !== this.source ||
|
|
1917
1984
|
!this.transport.child ||
|
|
1918
|
-
this.transport.child.exitCode !== null ||
|
|
1985
|
+
this.transport.exited || this.transport.child.exitCode !== null ||
|
|
1919
1986
|
!this.transport.ready ||
|
|
1920
1987
|
this.transport.sessionId !== this.agentSessionId ||
|
|
1921
1988
|
this.transport.permissionMode !== this.permissionMode) {
|
|
1922
1989
|
this.transport?.stop?.();
|
|
1923
1990
|
this.transport = new AcpTransport(this, this.source, ACP_PROFILES[this.source]);
|
|
1924
|
-
await this.transport.open(createSession);
|
|
1991
|
+
await untilHarnessExit(this.transport, () => this.transport.open(createSession));
|
|
1925
1992
|
}
|
|
1926
1993
|
if (interruptedBeforePrompt())
|
|
1927
1994
|
return interruptedBeforePrompt();
|
|
1928
|
-
const result = await this.transport.prompt(this.currentTurn.prompt, timeoutMs);
|
|
1995
|
+
const result = await untilHarnessExit(this.transport, () => this.transport.prompt(this.currentTurn.prompt, timeoutMs));
|
|
1929
1996
|
return { exitCode: 0, signal: null, timedOut: false, error: null, stopReason: result?.stopReason };
|
|
1930
1997
|
}
|
|
1931
1998
|
if (RPC_PROFILES[this.source]) {
|
|
@@ -1933,27 +2000,27 @@ class Anchor {
|
|
|
1933
2000
|
const valid = specializedOmp
|
|
1934
2001
|
? this.transport instanceof OmpRpcTransport
|
|
1935
2002
|
: this.transport instanceof RpcTransport && !(this.transport instanceof OmpRpcTransport);
|
|
1936
|
-
if (!valid || this.transport.harness !== this.source || this.transport.child.exitCode !== null) {
|
|
2003
|
+
if (!valid || this.transport.harness !== this.source || this.transport.exited || this.transport.child.exitCode !== null) {
|
|
1937
2004
|
this.transport?.stop?.();
|
|
1938
2005
|
this.transport = specializedOmp
|
|
1939
2006
|
? new OmpRpcTransport(this, RPC_PROFILES.omp, createSession)
|
|
1940
2007
|
: new RpcTransport(this, this.source, RPC_PROFILES[this.source], createSession);
|
|
1941
2008
|
if (specializedOmp)
|
|
1942
|
-
await this.transport.open();
|
|
2009
|
+
await untilHarnessExit(this.transport, () => this.transport.open());
|
|
1943
2010
|
}
|
|
1944
2011
|
if (interruptedBeforePrompt())
|
|
1945
2012
|
return interruptedBeforePrompt();
|
|
1946
|
-
await this.transport.prompt(this.currentTurn.prompt, timeoutMs);
|
|
2013
|
+
await untilHarnessExit(this.transport, () => this.transport.prompt(this.currentTurn.prompt, timeoutMs));
|
|
1947
2014
|
return { exitCode: 0, signal: null, timedOut: false, error: null };
|
|
1948
2015
|
}
|
|
1949
2016
|
if (this.source === 'deepseek') {
|
|
1950
2017
|
if (!(this.transport instanceof DeepseekTransport)) {
|
|
1951
2018
|
this.transport = new DeepseekTransport(this);
|
|
1952
|
-
await this.transport.open(createSession);
|
|
2019
|
+
await untilHarnessExit(this.transport, () => this.transport.open(createSession));
|
|
1953
2020
|
}
|
|
1954
2021
|
if (interruptedBeforePrompt())
|
|
1955
2022
|
return interruptedBeforePrompt();
|
|
1956
|
-
await this.transport.prompt(this.currentTurn.prompt, timeoutMs);
|
|
2023
|
+
await untilHarnessExit(this.transport, () => this.transport.prompt(this.currentTurn.prompt, timeoutMs));
|
|
1957
2024
|
return { exitCode: 0, signal: null, timedOut: false, error: null };
|
|
1958
2025
|
}
|
|
1959
2026
|
throw new Error(`Turns has no anchor transport for harness "${this.source}".`);
|
|
@@ -2015,7 +2082,7 @@ class Anchor {
|
|
|
2015
2082
|
this.expirePendingInteractions('The turn was interrupted before this interaction was answered.');
|
|
2016
2083
|
try {
|
|
2017
2084
|
if (this.transport?.interrupt)
|
|
2018
|
-
await this.transport.interrupt();
|
|
2085
|
+
await untilHarnessExit(this.transport, () => this.transport.interrupt());
|
|
2019
2086
|
else if (this.child) {
|
|
2020
2087
|
try {
|
|
2021
2088
|
process.kill(-this.child.pid, 'SIGINT');
|
|
@@ -2046,7 +2113,7 @@ class Anchor {
|
|
|
2046
2113
|
await this.interrupt(String(this.currentTurn?.messageId || ''));
|
|
2047
2114
|
return { steered: true, mode: 'interrupt' };
|
|
2048
2115
|
}
|
|
2049
|
-
await this.transport.steer(prompt, messageId);
|
|
2116
|
+
await untilHarnessExit(this.transport, () => this.transport.steer(prompt, messageId));
|
|
2050
2117
|
this.emitLive({
|
|
2051
2118
|
event: 'turn.steer',
|
|
2052
2119
|
agent: this.source,
|
|
@@ -2069,7 +2136,7 @@ class Anchor {
|
|
|
2069
2136
|
this.setStatus('running');
|
|
2070
2137
|
this.emitLive({ event: 'compaction.started', agent: this.source, phase: 'queued' });
|
|
2071
2138
|
try {
|
|
2072
|
-
const result = await this.transport.compact(this.turnTimeoutMs);
|
|
2139
|
+
const result = await untilHarnessExit(this.transport, () => this.transport.compact(this.turnTimeoutMs));
|
|
2073
2140
|
this.emitLive({ event: 'compaction.complete', agent: this.source, phase: 'queued' });
|
|
2074
2141
|
return { compacted: true, result };
|
|
2075
2142
|
}
|
|
@@ -2120,7 +2187,9 @@ fs.mkdirSync(path.dirname(socketPath), { recursive: true, mode: 0o700 });
|
|
|
2120
2187
|
const server = net.createServer((socket) => {
|
|
2121
2188
|
anchor.clients.add(socket);
|
|
2122
2189
|
socket.setEncoding('utf8');
|
|
2123
|
-
|
|
2190
|
+
// The greeting identifies the anchor. A client that wants the buffered
|
|
2191
|
+
// events asks for a snapshot, as every client already does on connecting.
|
|
2192
|
+
writeLine(socket, { type: 'anchor_hello', anchor: anchor.snapshot({ liveEvents: false }) });
|
|
2124
2193
|
const lines = readline.createInterface({ input: socket });
|
|
2125
2194
|
const detach = () => {
|
|
2126
2195
|
anchor.clients.delete(socket);
|
|
@@ -2147,7 +2216,7 @@ const server = net.createServer((socket) => {
|
|
|
2147
2216
|
Promise.resolve()
|
|
2148
2217
|
.then(async () => {
|
|
2149
2218
|
if (request.type === 'snapshot')
|
|
2150
|
-
return anchor.snapshot();
|
|
2219
|
+
return anchor.snapshot({ liveEvents: request.liveEvents !== false });
|
|
2151
2220
|
if (request.type === 'send_message')
|
|
2152
2221
|
return anchor.start(request);
|
|
2153
2222
|
if (request.type === 'interrupt')
|
package/dist/harness-auth.js
CHANGED
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
import { spawn } from 'node:child_process';
|
|
25
25
|
import os from 'node:os';
|
|
26
26
|
import { readCodexAccountStatus } from "./codex-account.js";
|
|
27
|
+
import { executablePath } from "./executable-path.js";
|
|
27
28
|
import { readVibeAccount, readLocalHarnessAccount, parseNativeHarnessAccount, NATIVE_ACCOUNT_COMMANDS, } from "./harness-account.js";
|
|
28
29
|
import { ACP_PROFILES, HARNESS_IDS, resolveHarnessCommand } from "./harness-registry.js";
|
|
29
30
|
/** Harnesses whose account status this module can inspect. Mirrors the shared
|
|
@@ -84,12 +85,19 @@ export function parseCodexLoginStatus(output) {
|
|
|
84
85
|
return { authenticated: false };
|
|
85
86
|
return { authenticated: null };
|
|
86
87
|
}
|
|
88
|
+
/**
|
|
89
|
+
* Match the PATH agent anchors launch with, so a Node-backed CLI found
|
|
90
|
+
* outside the daemon's inherited PATH can still locate its interpreter.
|
|
91
|
+
*/
|
|
92
|
+
function harnessEnvironment() {
|
|
93
|
+
return { ...process.env, PATH: executablePath() };
|
|
94
|
+
}
|
|
87
95
|
/** Both streams interleaved: codex reports status on stderr, claude on stdout. */
|
|
88
96
|
function runCommand(command, args, timeoutMs) {
|
|
89
97
|
return new Promise((resolve) => {
|
|
90
98
|
const child = spawn(command, args, {
|
|
91
99
|
cwd: os.homedir(),
|
|
92
|
-
env:
|
|
100
|
+
env: harnessEnvironment(),
|
|
93
101
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
94
102
|
});
|
|
95
103
|
let output = '';
|
|
@@ -210,7 +218,7 @@ export function createHarnessAuthManager() {
|
|
|
210
218
|
flows.set(harness, flow);
|
|
211
219
|
const child = spawn(command, args, {
|
|
212
220
|
cwd: os.homedir(),
|
|
213
|
-
env:
|
|
221
|
+
env: harnessEnvironment(),
|
|
214
222
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
215
223
|
});
|
|
216
224
|
flow.child = child;
|
package/dist/harness-registry.js
CHANGED
|
@@ -264,6 +264,9 @@ export const HARNESS_IDS = [
|
|
|
264
264
|
function executable(candidate) {
|
|
265
265
|
try {
|
|
266
266
|
fs.accessSync(candidate, fs.constants.X_OK);
|
|
267
|
+
// X_OK also passes for directories, which cannot be launched.
|
|
268
|
+
if (!fs.statSync(candidate).isFile())
|
|
269
|
+
return '';
|
|
267
270
|
return candidate;
|
|
268
271
|
}
|
|
269
272
|
catch {
|
|
@@ -325,6 +328,15 @@ export function resolveHarnessCommand(harness, profile = {}, dependencies = {})
|
|
|
325
328
|
return found;
|
|
326
329
|
}
|
|
327
330
|
}
|
|
331
|
+
// Claude Code's older "local" installer puts a wrapper script here and
|
|
332
|
+
// relies on a shell alias rather than PATH, so a daemon (which never reads
|
|
333
|
+
// the user's shell config) would otherwise miss it. Living in the shared
|
|
334
|
+
// resolver covers agent launch, auth status, and login alike.
|
|
335
|
+
if (harness === 'claude') {
|
|
336
|
+
const found = executable(path.join(home, '.claude', 'local', 'claude'));
|
|
337
|
+
if (found)
|
|
338
|
+
return found;
|
|
339
|
+
}
|
|
328
340
|
throw new Error(`Agent executable not found for harness "${harness}" (${command}).`);
|
|
329
341
|
}
|
|
330
342
|
export function processInvocation({ harness, sessionId, transcriptPath, cwd, prompt, createSession, model, thinkingLevel, timeoutMs, }) {
|
|
@@ -1490,7 +1490,6 @@ function nativeProcessSnapshots(procRoot) {
|
|
|
1490
1490
|
continue;
|
|
1491
1491
|
let argv = [];
|
|
1492
1492
|
let environ = '';
|
|
1493
|
-
let fdTargets = [];
|
|
1494
1493
|
try {
|
|
1495
1494
|
argv = fs
|
|
1496
1495
|
.readFileSync(path.join(procRoot, entry.name, 'cmdline'), 'utf8')
|
|
@@ -1502,22 +1501,39 @@ function nativeProcessSnapshots(procRoot) {
|
|
|
1502
1501
|
environ = fs.readFileSync(path.join(procRoot, entry.name, 'environ'), 'utf8');
|
|
1503
1502
|
}
|
|
1504
1503
|
catch { }
|
|
1505
|
-
|
|
1506
|
-
|
|
1504
|
+
if (!argv.length && !environ)
|
|
1505
|
+
continue;
|
|
1506
|
+
// Open files are wanted only for the few processes that turn out to be a
|
|
1507
|
+
// harness, and listing them is the expensive part of this scan (a readlink
|
|
1508
|
+
// per descriptor of every process on the machine), so they are read on
|
|
1509
|
+
// first use.
|
|
1510
|
+
let fdTargets = null;
|
|
1511
|
+
processes.push({
|
|
1512
|
+
pid: entry.name,
|
|
1513
|
+
argv,
|
|
1514
|
+
cmdline: argv.join(' '),
|
|
1515
|
+
environ,
|
|
1516
|
+
get fdTargets() {
|
|
1517
|
+
if (fdTargets)
|
|
1518
|
+
return fdTargets;
|
|
1519
|
+
fdTargets = [];
|
|
1507
1520
|
try {
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1521
|
+
fdTargets = fs.readdirSync(path.join(procRoot, entry.name, 'fd')).flatMap((descriptor) => {
|
|
1522
|
+
try {
|
|
1523
|
+
const target = fs
|
|
1524
|
+
.readlinkSync(path.join(procRoot, entry.name, 'fd', descriptor))
|
|
1525
|
+
.replace(/ \(deleted\)$/u, '');
|
|
1526
|
+
return path.isAbsolute(target) ? [target] : [];
|
|
1527
|
+
}
|
|
1528
|
+
catch {
|
|
1529
|
+
return [];
|
|
1530
|
+
}
|
|
1531
|
+
});
|
|
1512
1532
|
}
|
|
1513
|
-
catch {
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
}
|
|
1518
|
-
catch { }
|
|
1519
|
-
if (argv.length || environ)
|
|
1520
|
-
processes.push({ pid: entry.name, argv, cmdline: argv.join(' '), environ, fdTargets });
|
|
1533
|
+
catch { }
|
|
1534
|
+
return fdTargets;
|
|
1535
|
+
},
|
|
1536
|
+
});
|
|
1521
1537
|
}
|
|
1522
1538
|
return processes;
|
|
1523
1539
|
}
|
|
@@ -1625,6 +1641,22 @@ function registeredClaudeSessionIds(processes, procRoot, sessionsRoot) {
|
|
|
1625
1641
|
}
|
|
1626
1642
|
return sessionIds;
|
|
1627
1643
|
}
|
|
1644
|
+
/**
|
|
1645
|
+
* A descriptor's transcript path with symlinks resolved, remembered with the
|
|
1646
|
+
* descriptor. This scan runs every ten seconds on the daemon's main thread over
|
|
1647
|
+
* every known session, and resolving thousands of paths each time (twice) was
|
|
1648
|
+
* most of a stall that delayed live rows. Descriptors are replaced wholesale by
|
|
1649
|
+
* each discovery, so a path that is re-pointed is seen at the next one.
|
|
1650
|
+
*/
|
|
1651
|
+
const canonicalDescriptorFiles = new WeakMap();
|
|
1652
|
+
function descriptorCanonicalFile(descriptor) {
|
|
1653
|
+
const held = canonicalDescriptorFiles.get(descriptor);
|
|
1654
|
+
if (held !== undefined)
|
|
1655
|
+
return held;
|
|
1656
|
+
const file = canonicalProcessFile(descriptor.file);
|
|
1657
|
+
canonicalDescriptorFiles.set(descriptor, file);
|
|
1658
|
+
return file;
|
|
1659
|
+
}
|
|
1628
1660
|
function queueManagedKeysFromProcesses(descriptors, processes) {
|
|
1629
1661
|
const transcriptFiles = new Set();
|
|
1630
1662
|
for (const process of processes) {
|
|
@@ -1644,17 +1676,16 @@ function queueManagedKeysFromProcesses(descriptors, processes) {
|
|
|
1644
1676
|
transcriptFiles.add(path.resolve(environment.TURNS_TRANSCRIPT_PATH));
|
|
1645
1677
|
}
|
|
1646
1678
|
}
|
|
1679
|
+
// No queue-managed harness is running: nothing to match, and no reason to
|
|
1680
|
+
// resolve every known transcript's path to find that out.
|
|
1681
|
+
if (!transcriptFiles.size)
|
|
1682
|
+
return new Set();
|
|
1647
1683
|
return new Set(descriptors.flatMap((descriptor) => {
|
|
1648
1684
|
if (!descriptor?.file)
|
|
1649
1685
|
return [];
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
}
|
|
1654
|
-
catch {
|
|
1655
|
-
file = path.resolve(descriptor.file);
|
|
1656
|
-
}
|
|
1657
|
-
return transcriptFiles.has(file) ? [`${descriptor.harness}\0${descriptor.sourcePath}`] : [];
|
|
1686
|
+
return transcriptFiles.has(descriptorCanonicalFile(descriptor))
|
|
1687
|
+
? [`${descriptor.harness}\0${descriptor.sourcePath}`]
|
|
1688
|
+
: [];
|
|
1658
1689
|
}));
|
|
1659
1690
|
}
|
|
1660
1691
|
export function queueManagedNativeDescriptorKeys(descriptors, procRoot = '/proc') {
|
|
@@ -1670,14 +1701,13 @@ export function externallyManagedNativeDescriptorKeys(descriptors, procRoot = '/
|
|
|
1670
1701
|
if (queueManaged.has(key))
|
|
1671
1702
|
continue;
|
|
1672
1703
|
const session = descriptorNativeSessionId(descriptor);
|
|
1673
|
-
const file = descriptor?.file ? canonicalProcessFile(descriptor.file) : '';
|
|
1674
1704
|
// A process with no session key is evidence only that a harness is
|
|
1675
1705
|
// running, not that it owns every historical transcript for that harness.
|
|
1676
1706
|
// An exact open transcript, however, identifies that one descriptor.
|
|
1677
|
-
if (!session && !file)
|
|
1707
|
+
if (!session && !descriptor?.file)
|
|
1678
1708
|
continue;
|
|
1679
1709
|
const group = byHarness.get(descriptor.harness) || [];
|
|
1680
|
-
group.push({ key, session,
|
|
1710
|
+
group.push({ key, session, descriptor });
|
|
1681
1711
|
byHarness.set(descriptor.harness, group);
|
|
1682
1712
|
}
|
|
1683
1713
|
const managed = new Set();
|
|
@@ -1697,7 +1727,9 @@ export function externallyManagedNativeDescriptorKeys(descriptors, procRoot = '/
|
|
|
1697
1727
|
const openFiles = new Set(process.fdTargets.map(canonicalProcessFile));
|
|
1698
1728
|
for (const candidate of candidates) {
|
|
1699
1729
|
if ((candidate.session && process.cmdline.includes(candidate.session)) ||
|
|
1700
|
-
(
|
|
1730
|
+
(openFiles.size > 0 &&
|
|
1731
|
+
candidate.descriptor?.file &&
|
|
1732
|
+
openFiles.has(descriptorCanonicalFile(candidate.descriptor)))) {
|
|
1701
1733
|
managed.add(candidate.key);
|
|
1702
1734
|
}
|
|
1703
1735
|
}
|
package/dist/queue-worker.js
CHANGED
|
@@ -6,7 +6,7 @@ import { Readable } from 'node:stream';
|
|
|
6
6
|
import { pipeline } from 'node:stream/promises';
|
|
7
7
|
import { logError } from "./logging.js";
|
|
8
8
|
import { QueueDispatchLifecycle, QUEUE_RESTART_MESSAGE } from "./queue-lifecycle.js";
|
|
9
|
-
import { anchorId, connectExistingAgentAnchors, launchAgentAnchor, waitForAgentTurn } from "./agent-anchor-client.js";
|
|
9
|
+
import { anchorId, connectExistingAgentAnchors, launchAgentAnchor, STATUS_SNAPSHOT, waitForAgentTurn, } from "./agent-anchor-client.js";
|
|
10
10
|
import { createHarnessUpdateManager } from "./harness-update.js";
|
|
11
11
|
import { isAsyncQuestionId } from "./codex-async-question-recovery.js";
|
|
12
12
|
import { assertAllowedWorkingDirectory, workingDirectoryIsAllowed } from "./directory-policy.js";
|
|
@@ -31,7 +31,7 @@ const DEFAULT_REDACTOR = createSecretRedactor();
|
|
|
31
31
|
export async function answerAnchorInteraction(entry, interactionId, response) {
|
|
32
32
|
if (!entry || entry.connection.closed)
|
|
33
33
|
throw new Error('The detached harness is offline.');
|
|
34
|
-
entry.snapshot = await entry.connection.request('snapshot',
|
|
34
|
+
entry.snapshot = await entry.connection.request('snapshot', STATUS_SNAPSHOT, 5_000).catch(() => entry.snapshot);
|
|
35
35
|
const pending = entry.snapshot?.pendingInteractions?.some((interaction) => interaction.id === interactionId);
|
|
36
36
|
// Async questions can be reconstructed from the native transcript without
|
|
37
37
|
// appearing in the app-server's live/resumed item projection. Let the anchor
|
|
@@ -379,7 +379,7 @@ export function queueFailureIsRetryable(error, promptStarted) {
|
|
|
379
379
|
export async function actualHarnessStatus(entry) {
|
|
380
380
|
if (!entry || entry.connection.closed)
|
|
381
381
|
return 'stopped';
|
|
382
|
-
const snapshot = await entry.connection.request('snapshot',
|
|
382
|
+
const snapshot = await entry.connection.request('snapshot', STATUS_SNAPSHOT, 5_000);
|
|
383
383
|
entry.snapshot = snapshot;
|
|
384
384
|
return snapshot.status === 'running' ? 'running' : 'idle';
|
|
385
385
|
}
|
|
@@ -594,7 +594,7 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
|
|
|
594
594
|
const usableAnchor = async (sessionId, source, agentSessionId, modelSettings = false) => {
|
|
595
595
|
let entry = anchors.get(sessionId);
|
|
596
596
|
if (entry && !entry.connection.closed) {
|
|
597
|
-
entry.snapshot = await entry.connection.request('snapshot',
|
|
597
|
+
entry.snapshot = await entry.connection.request('snapshot', STATUS_SNAPSHOT, 5_000).catch(() => entry.snapshot);
|
|
598
598
|
if (String(entry.snapshot.source || '') === source &&
|
|
599
599
|
String(entry.snapshot.agentSessionId || '') === agentSessionId &&
|
|
600
600
|
(!modelSettings || entry.snapshot.capabilities?.includes('model_settings')))
|
|
@@ -843,7 +843,7 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
|
|
|
843
843
|
idleTimeoutMs: QUEUE_WARM_MS,
|
|
844
844
|
}, 30_000);
|
|
845
845
|
});
|
|
846
|
-
entry.snapshot = await entry.connection.request('snapshot',
|
|
846
|
+
entry.snapshot = await entry.connection.request('snapshot', STATUS_SNAPSHOT, 5_000).catch(() => entry.snapshot);
|
|
847
847
|
// Registration after acceptance is safe: the connection retains recent
|
|
848
848
|
// events and the snapshot carries a turn that completed immediately.
|
|
849
849
|
result = await waitForAgentTurn(entry.connection, claim.prompt.id, QUEUE_TURN_TIMEOUT_MS, entry.snapshot, eventCursor);
|
|
@@ -852,7 +852,7 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
|
|
|
852
852
|
await request({ type: 'session.native.bind', sessionId, agentSessionId: createdIdentity });
|
|
853
853
|
}
|
|
854
854
|
}
|
|
855
|
-
entry.snapshot = await entry.connection.request('snapshot',
|
|
855
|
+
entry.snapshot = await entry.connection.request('snapshot', STATUS_SNAPSHOT, 5_000).catch(() => ({
|
|
856
856
|
...entry.snapshot,
|
|
857
857
|
status: 'idle',
|
|
858
858
|
}));
|
|
@@ -888,7 +888,7 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
|
|
|
888
888
|
stopClaimRenewal(held);
|
|
889
889
|
if (held?.entry && !held.entry.connection.closed) {
|
|
890
890
|
held.entry.snapshot = await held.entry.connection
|
|
891
|
-
.request('snapshot',
|
|
891
|
+
.request('snapshot', STATUS_SNAPSHOT, 2_000)
|
|
892
892
|
.catch(() => held.entry.snapshot);
|
|
893
893
|
await reportHarness(sessionId, held.entry.snapshot?.status === 'running' ? 'running' : 'idle', held.entry).catch(() => { });
|
|
894
894
|
}
|
|
@@ -915,8 +915,15 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
|
|
|
915
915
|
};
|
|
916
916
|
const recoverAnchors = async () => {
|
|
917
917
|
for (const [sessionId, entry] of anchors) {
|
|
918
|
-
|
|
919
|
-
|
|
918
|
+
// Recovery is the one reader of an anchor's buffered events, so it is the
|
|
919
|
+
// one request that asks for them; they are not kept past this pass.
|
|
920
|
+
// A full buffer can be megabytes; give a busy anchor a second, longer
|
|
921
|
+
// chance before recovering the turn without its buffered events.
|
|
922
|
+
const snapshot = await entry.connection
|
|
923
|
+
.request('snapshot', {}, 5_000)
|
|
924
|
+
.catch(() => entry.connection.request('snapshot', {}, 20_000))
|
|
925
|
+
.catch(() => entry.snapshot);
|
|
926
|
+
entry.snapshot = { ...snapshot, liveEvents: [] };
|
|
920
927
|
await reportHarness(sessionId, snapshot.status === 'running' ? 'running' : 'idle', entry).catch(() => { });
|
|
921
928
|
const turn = snapshot.currentTurn;
|
|
922
929
|
// A warm anchor retains its terminal turn for diagnostics. Probe the
|
|
@@ -1047,7 +1054,7 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
|
|
|
1047
1054
|
try {
|
|
1048
1055
|
if (!entry || entry.connection.closed)
|
|
1049
1056
|
throw new Error('The detached harness is offline.');
|
|
1050
|
-
entry.snapshot = await entry.connection.request('snapshot',
|
|
1057
|
+
entry.snapshot = await entry.connection.request('snapshot', STATUS_SNAPSHOT, 5_000);
|
|
1051
1058
|
if (entry.snapshot?.status === 'running')
|
|
1052
1059
|
throw new Error('The detached harness is actively running.');
|
|
1053
1060
|
if (action === 'compact' && entry.snapshot?.pendingInteractions?.length) {
|
|
@@ -1056,7 +1063,7 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
|
|
|
1056
1063
|
await entry.connection.request(action, {}, action === 'compact' ? QUEUE_TURN_TIMEOUT_MS : 5_000);
|
|
1057
1064
|
if (action === 'compact') {
|
|
1058
1065
|
await flushRuntime(sessionId);
|
|
1059
|
-
entry.snapshot = await entry.connection.request('snapshot',
|
|
1066
|
+
entry.snapshot = await entry.connection.request('snapshot', STATUS_SNAPSHOT, 5_000).catch(() => entry.snapshot);
|
|
1060
1067
|
await reportHarness(sessionId, 'idle', entry);
|
|
1061
1068
|
}
|
|
1062
1069
|
else {
|
package/dist/upload-sessions.js
CHANGED
|
@@ -481,32 +481,39 @@ function nativeTimestamp(raw, descriptor, rowIndex) {
|
|
|
481
481
|
const base = Date.parse(descriptor.createdAt) || descriptor.mtimeMs || Date.now();
|
|
482
482
|
return new Date(base + rowIndex).toISOString();
|
|
483
483
|
}
|
|
484
|
-
async function* completeNativeFileLines(file, startOffset, endOffset) {
|
|
484
|
+
export async function* completeNativeFileLines(file, startOffset, endOffset) {
|
|
485
485
|
if (endOffset < startOffset)
|
|
486
486
|
return;
|
|
487
|
-
|
|
488
|
-
|
|
487
|
+
// The unfinished line so far, as the chunks it arrived in. Joining them on
|
|
488
|
+
// every read and searching the whole thing again made one very long row
|
|
489
|
+
// (inline images, a giant tool output) quadratic: 80 MB took half a minute.
|
|
490
|
+
let carried = [];
|
|
491
|
+
let carriedBytes = 0;
|
|
492
|
+
let consumed = startOffset;
|
|
489
493
|
const input = fs.createReadStream(file, { start: startOffset, end: endOffset });
|
|
490
494
|
try {
|
|
491
495
|
for await (const chunk of input) {
|
|
492
|
-
const buffer = carry.length ? Buffer.concat([carry, chunk]) : chunk;
|
|
493
|
-
const bufferOffset = carryOffset;
|
|
494
496
|
let lineStart = 0;
|
|
495
497
|
for (;;) {
|
|
496
|
-
|
|
498
|
+
// Only the new chunk can contain the newline that ends the carry.
|
|
499
|
+
const newline = chunk.indexOf(0x0a, lineStart);
|
|
497
500
|
if (newline < 0)
|
|
498
501
|
break;
|
|
499
|
-
|
|
502
|
+
const tail = chunk.subarray(lineStart, newline);
|
|
503
|
+
let line = carried.length ? Buffer.concat([...carried, tail], carriedBytes + tail.length) : tail;
|
|
504
|
+
consumed += line.length + 1;
|
|
505
|
+
carried = [];
|
|
506
|
+
carriedBytes = 0;
|
|
500
507
|
if (line.at(-1) === 0x0d)
|
|
501
508
|
line = line.subarray(0, -1);
|
|
502
|
-
yield {
|
|
503
|
-
raw: line.toString('utf8'),
|
|
504
|
-
endOffset: bufferOffset + newline + 1,
|
|
505
|
-
};
|
|
509
|
+
yield { raw: line.toString('utf8'), endOffset: consumed };
|
|
506
510
|
lineStart = newline + 1;
|
|
507
511
|
}
|
|
508
|
-
|
|
509
|
-
|
|
512
|
+
if (lineStart < chunk.length) {
|
|
513
|
+
const rest = chunk.subarray(lineStart);
|
|
514
|
+
carried.push(rest);
|
|
515
|
+
carriedBytes += rest.length;
|
|
516
|
+
}
|
|
510
517
|
}
|
|
511
518
|
}
|
|
512
519
|
finally {
|
|
@@ -2068,6 +2075,10 @@ async function uploadNative(options, machine, redactor = DEFAULT_REDACTOR) {
|
|
|
2068
2075
|
String(descriptor.parentSessionId || ''),
|
|
2069
2076
|
NATIVE_DESCRIPTOR_VERSION,
|
|
2070
2077
|
String(previousFingerprint?.[5] || nativeDescriptorMetadataFingerprint(descriptor)),
|
|
2078
|
+
// The same optional slot nativeDescriptorFingerprint appends. Without
|
|
2079
|
+
// it a continued session's fingerprint lost the slot on every append,
|
|
2080
|
+
// and each discovery reopened the session to put it back.
|
|
2081
|
+
...(descriptor.supersedesSessionId ? [String(descriptor.supersedesSessionId)] : []),
|
|
2071
2082
|
]);
|
|
2072
2083
|
}
|
|
2073
2084
|
}
|