@turns/dev 0.0.30 → 0.0.32
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-exit.js +1 -1
- package/dist/harness-update.js +236 -0
- package/dist/local-session-sources.js +59 -27
- package/dist/path-resolution.js +5 -1
- package/dist/queue-worker.js +70 -31
- package/dist/upload-sessions.js +81 -20
- package/package.json +1 -1
- package/dist/codex-update.js +0 -162
|
@@ -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-exit.js
CHANGED
|
@@ -38,7 +38,7 @@ export function harnessExitError(harness, code, signal, stderr) {
|
|
|
38
38
|
harnessExit: { harness: String(harness), code: code ?? null, signal: signal ?? null, stderr },
|
|
39
39
|
});
|
|
40
40
|
}
|
|
41
|
-
function harnessLabel(harness) {
|
|
41
|
+
export function harnessLabel(harness) {
|
|
42
42
|
const name = String(harness || 'agent');
|
|
43
43
|
return name.charAt(0).toUpperCase() + name.slice(1);
|
|
44
44
|
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/** Machine-local harness CLI upgrades, callable by the Turns daemon. */
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { executablePath } from "./executable-path.js";
|
|
7
|
+
import { harnessLabel } from "./harness-exit.js";
|
|
8
|
+
import { ACP_PROFILES, codexCommandIsAppManaged, HARNESS_IDS, RPC_PROFILES, resolveHarnessCommand, } from "./harness-registry.js";
|
|
9
|
+
const VERSION_TIMEOUT_MS = 15_000;
|
|
10
|
+
const UPDATE_TIMEOUT_MS = 5 * 60_000;
|
|
11
|
+
const OUTPUT_LIMIT = 64 * 1024;
|
|
12
|
+
/**
|
|
13
|
+
* Native installers whose CLI updates itself without prompting. Each entry was
|
|
14
|
+
* checked against the CLI's own `--help`; a harness absent here is upgradable
|
|
15
|
+
* only when a package manager owns its executable. Some updaters ask before
|
|
16
|
+
* installing (Devin) or rewrite a source checkout (Hermes), so they are left
|
|
17
|
+
* out rather than run unattended.
|
|
18
|
+
*/
|
|
19
|
+
const SELF_UPDATE_ARGS = {
|
|
20
|
+
claude: ['update'],
|
|
21
|
+
codex: ['update'],
|
|
22
|
+
cursor: ['update'],
|
|
23
|
+
antigravity: ['update'],
|
|
24
|
+
omp: ['update'],
|
|
25
|
+
grok: ['update'],
|
|
26
|
+
goose: ['update'],
|
|
27
|
+
fx: ['upgrade'],
|
|
28
|
+
kimi: ['upgrade'],
|
|
29
|
+
opencode: ['upgrade'],
|
|
30
|
+
kilo: ['upgrade'],
|
|
31
|
+
};
|
|
32
|
+
/** Run the resolved executable directly—never through a shell—so update
|
|
33
|
+
* requests cannot become arbitrary command execution. */
|
|
34
|
+
function runCommand(command, args, timeoutMs) {
|
|
35
|
+
return new Promise((resolve) => {
|
|
36
|
+
const child = spawn(command, args, {
|
|
37
|
+
cwd: os.homedir(),
|
|
38
|
+
// npm and npm-installed CLIs are `#!/usr/bin/env node` scripts; a
|
|
39
|
+
// service manager's PATH may not include the daemon's own Node.
|
|
40
|
+
env: { ...process.env, PATH: executablePath() },
|
|
41
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
42
|
+
});
|
|
43
|
+
let output = '';
|
|
44
|
+
let settled = false;
|
|
45
|
+
const settle = (result) => {
|
|
46
|
+
if (settled)
|
|
47
|
+
return;
|
|
48
|
+
settled = true;
|
|
49
|
+
clearTimeout(timer);
|
|
50
|
+
resolve(result);
|
|
51
|
+
};
|
|
52
|
+
const timer = setTimeout(() => {
|
|
53
|
+
try {
|
|
54
|
+
child.kill('SIGKILL');
|
|
55
|
+
}
|
|
56
|
+
catch { }
|
|
57
|
+
settle({ output, exitCode: null, timedOut: true });
|
|
58
|
+
}, timeoutMs);
|
|
59
|
+
timer.unref?.();
|
|
60
|
+
const collect = (chunk) => {
|
|
61
|
+
if (output.length < OUTPUT_LIMIT)
|
|
62
|
+
output += String(chunk).slice(0, OUTPUT_LIMIT - output.length);
|
|
63
|
+
};
|
|
64
|
+
child.stdout.on('data', collect);
|
|
65
|
+
child.stderr.on('data', collect);
|
|
66
|
+
child.on('error', () => settle({ output, exitCode: null, failed: true }));
|
|
67
|
+
child.on('close', (exitCode) => settle({ output, exitCode }));
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
/** The first dotted version in a CLI's `--version` output, whatever its
|
|
71
|
+
* prefix (`codex-cli 0.164.0`, `omp/18.0.4`, `GitHub Copilot CLI 1.0.80.`). */
|
|
72
|
+
export function parseHarnessVersion(output) {
|
|
73
|
+
const text = String(output || '').replace(/\u001b\[[0-9;?]*[A-Za-z]/gu, '');
|
|
74
|
+
const version = text.match(/\d+(?:\.\d+)+(?:-[0-9A-Za-z.]+)?/u)?.[0] || '';
|
|
75
|
+
return version.replace(/\.$/u, '').slice(0, 120);
|
|
76
|
+
}
|
|
77
|
+
function commandFailure(result, label, fallback) {
|
|
78
|
+
if (result.timedOut)
|
|
79
|
+
return new Error(`The ${label} update timed out.`);
|
|
80
|
+
const lines = result.output
|
|
81
|
+
.replace(/\u001b\[[0-9;?]*[A-Za-z]/gu, '')
|
|
82
|
+
.split('\n')
|
|
83
|
+
.map((line) => line.trim())
|
|
84
|
+
// npm ends every failure by pointing at its debug log.
|
|
85
|
+
.filter((line) => line && !/A complete log of this run can be found/iu.test(line));
|
|
86
|
+
return new Error(lines.at(-1) || fallback);
|
|
87
|
+
}
|
|
88
|
+
function realpathOrSelf(file) {
|
|
89
|
+
try {
|
|
90
|
+
return fs.realpathSync(file);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return file;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
function findExecutable(name, preferred) {
|
|
97
|
+
for (const directory of [...preferred, ...executablePath().split(path.delimiter)]) {
|
|
98
|
+
const candidate = path.join(directory, name);
|
|
99
|
+
try {
|
|
100
|
+
fs.accessSync(candidate, fs.constants.X_OK);
|
|
101
|
+
return candidate;
|
|
102
|
+
}
|
|
103
|
+
catch { }
|
|
104
|
+
}
|
|
105
|
+
return '';
|
|
106
|
+
}
|
|
107
|
+
export function harnessUpgradeCommand(harness, command, lookup = {}) {
|
|
108
|
+
const resolved = (lookup.realpath || realpathOrSelf)(command).replaceAll('\\', '/');
|
|
109
|
+
const find = lookup.findExecutable || findExecutable;
|
|
110
|
+
const platform = lookup.platform || process.platform;
|
|
111
|
+
// `npm i -g <pkg>` links <prefix>/bin/<cli> into <prefix>/lib/node_modules.
|
|
112
|
+
// Requiring the `lib/` layout keeps pnpm and Bun global stores, which also
|
|
113
|
+
// contain node_modules, away from an npm that would install a second copy.
|
|
114
|
+
// Windows npm is a .cmd shim, which cannot be spawned without a shell.
|
|
115
|
+
const npm = resolved.match(/^(.+?)\/lib\/node_modules\/((?:@[^/]+\/)?[^/@][^/]*)\//u);
|
|
116
|
+
if (npm && platform !== 'win32') {
|
|
117
|
+
const [, prefix, name] = npm;
|
|
118
|
+
const binary = find('npm', [path.join(prefix, 'bin')]);
|
|
119
|
+
if (binary) {
|
|
120
|
+
return {
|
|
121
|
+
manager: 'npm',
|
|
122
|
+
command: binary,
|
|
123
|
+
args: ['install', '-g', '--prefix', prefix, `${name}@latest`],
|
|
124
|
+
check: {
|
|
125
|
+
args: ['view', name, 'version'],
|
|
126
|
+
failure: `${name} is not published on the npm registry, so this installation must be updated on the machine.`,
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const brew = resolved.match(/^(.+?)\/(Cellar|Caskroom)\/([^/]+)\//u);
|
|
132
|
+
if (brew) {
|
|
133
|
+
const binary = find('brew', [path.join(brew[1], 'bin')]);
|
|
134
|
+
if (binary)
|
|
135
|
+
return {
|
|
136
|
+
manager: 'brew',
|
|
137
|
+
command: binary,
|
|
138
|
+
args: ['upgrade', ...(brew[2] === 'Caskroom' ? ['--cask'] : []), brew[3]],
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
const uv = resolved.match(/\/uv\/tools\/([^/]+)\//u);
|
|
142
|
+
if (uv) {
|
|
143
|
+
const binary = find('uv', []);
|
|
144
|
+
if (binary)
|
|
145
|
+
return { manager: 'uv', command: binary, args: ['tool', 'upgrade', uv[1]] };
|
|
146
|
+
}
|
|
147
|
+
const pipx = resolved.match(/\/pipx\/venvs\/([^/]+)\//u);
|
|
148
|
+
if (pipx) {
|
|
149
|
+
const binary = find('pipx', []);
|
|
150
|
+
if (binary)
|
|
151
|
+
return { manager: 'pipx', command: binary, args: ['upgrade', pipx[1]] };
|
|
152
|
+
}
|
|
153
|
+
const self = SELF_UPDATE_ARGS[harness];
|
|
154
|
+
return self ? { manager: 'self', command, args: self } : null;
|
|
155
|
+
}
|
|
156
|
+
/** The executable the agent anchor launches for a harness. */
|
|
157
|
+
function harnessExecutable(harness) {
|
|
158
|
+
return resolveHarnessCommand(harness, ACP_PROFILES[harness] || RPC_PROFILES[harness] || {});
|
|
159
|
+
}
|
|
160
|
+
export function createHarnessUpdateManager(dependencies = {}) {
|
|
161
|
+
const harnesses = dependencies.harnesses || HARNESS_IDS;
|
|
162
|
+
const resolveCommand = dependencies.resolveCommand || harnessExecutable;
|
|
163
|
+
const isAppManaged = dependencies.isAppManaged ||
|
|
164
|
+
((harness, command) => harness === 'codex' && codexCommandIsAppManaged(command));
|
|
165
|
+
const upgradeCommand = dependencies.upgradeCommand || ((harness, command) => harnessUpgradeCommand(harness, command));
|
|
166
|
+
const run = dependencies.run || runCommand;
|
|
167
|
+
const active = new Map();
|
|
168
|
+
const plan = (harness) => {
|
|
169
|
+
const label = harnessLabel(harness);
|
|
170
|
+
if (!harnesses.includes(harness))
|
|
171
|
+
throw new Error(`Turns cannot update ${label}.`);
|
|
172
|
+
const command = resolveCommand(harness);
|
|
173
|
+
if (isAppManaged(harness, command)) {
|
|
174
|
+
throw new Error(`${label} is managed by the macOS app. Update the ${label} app to update its bundled CLI.`);
|
|
175
|
+
}
|
|
176
|
+
const upgrade = upgradeCommand(harness, command);
|
|
177
|
+
if (!upgrade)
|
|
178
|
+
throw new Error(`Turns does not know how to update this ${label} installation. Update it on the machine.`);
|
|
179
|
+
return { label, command, upgrade };
|
|
180
|
+
};
|
|
181
|
+
const readVersion = async (command, label, fallback) => {
|
|
182
|
+
const result = await run(command, ['--version'], VERSION_TIMEOUT_MS);
|
|
183
|
+
if (result.failed || result.timedOut || result.exitCode !== 0)
|
|
184
|
+
throw commandFailure(result, label, fallback);
|
|
185
|
+
return parseHarnessVersion(result.output);
|
|
186
|
+
};
|
|
187
|
+
const perform = async (harness) => {
|
|
188
|
+
const { label, command, upgrade } = plan(harness);
|
|
189
|
+
const previousVersion = await readVersion(command, label, `${label} is not installed or its version could not be read.`);
|
|
190
|
+
if (upgrade.check) {
|
|
191
|
+
const checked = await run(upgrade.command, upgrade.check.args, VERSION_TIMEOUT_MS);
|
|
192
|
+
if (checked.timedOut)
|
|
193
|
+
throw new Error(`The ${label} update timed out.`);
|
|
194
|
+
if (checked.failed || checked.exitCode !== 0)
|
|
195
|
+
throw new Error(upgrade.check.failure);
|
|
196
|
+
}
|
|
197
|
+
const updateRun = await run(upgrade.command, upgrade.args, UPDATE_TIMEOUT_MS);
|
|
198
|
+
if (updateRun.failed || updateRun.timedOut || updateRun.exitCode !== 0) {
|
|
199
|
+
const manual = [path.basename(upgrade.command), ...upgrade.args].join(' ');
|
|
200
|
+
throw commandFailure(updateRun, label, upgrade.manager === 'self'
|
|
201
|
+
? `${label} could not update itself. Run \`${manual}\` in a terminal on the machine.`
|
|
202
|
+
: `${upgrade.manager} could not upgrade ${label}. Run \`${manual}\` in a terminal on the machine.`);
|
|
203
|
+
}
|
|
204
|
+
// Resolve again because the updater may replace a shim or install path.
|
|
205
|
+
const currentVersion = await readVersion(resolveCommand(harness), label, `${label} updated, but its new version could not be read.`);
|
|
206
|
+
return {
|
|
207
|
+
...(previousVersion ? { previousVersion } : {}),
|
|
208
|
+
...(currentVersion ? { currentVersion } : {}),
|
|
209
|
+
updated: Boolean(previousVersion && currentVersion && previousVersion !== currentVersion),
|
|
210
|
+
};
|
|
211
|
+
};
|
|
212
|
+
return {
|
|
213
|
+
/** Harnesses installed here in a way this daemon can upgrade. */
|
|
214
|
+
available() {
|
|
215
|
+
return harnesses.filter((harness) => {
|
|
216
|
+
try {
|
|
217
|
+
plan(harness);
|
|
218
|
+
return true;
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
},
|
|
225
|
+
/** Concurrent clicks for one harness share one installer process instead
|
|
226
|
+
* of racing writes to the same installation. */
|
|
227
|
+
update(harness) {
|
|
228
|
+
let pending = active.get(harness);
|
|
229
|
+
if (!pending) {
|
|
230
|
+
pending = perform(harness).finally(() => active.delete(harness));
|
|
231
|
+
active.set(harness, pending);
|
|
232
|
+
}
|
|
233
|
+
return pending;
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
}
|
|
@@ -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/path-resolution.js
CHANGED
|
@@ -87,7 +87,11 @@ export function pathCandidates(row) {
|
|
|
87
87
|
continue;
|
|
88
88
|
if (found.includes(candidate))
|
|
89
89
|
continue;
|
|
90
|
-
|
|
90
|
+
// A regex match is a slice of the row it came from, and a slice keeps the
|
|
91
|
+
// whole row alive for as long as it is remembered. The resolver remembers
|
|
92
|
+
// candidates per session for the life of the daemon, so each one has to
|
|
93
|
+
// be its own small string rather than a window onto kilobytes of transcript.
|
|
94
|
+
found.push(Buffer.from(candidate).toString());
|
|
91
95
|
if (found.length >= MAX_PATH_CANDIDATES_PER_ROW)
|
|
92
96
|
break;
|
|
93
97
|
}
|
package/dist/queue-worker.js
CHANGED
|
@@ -6,8 +6,8 @@ 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";
|
|
10
|
-
import {
|
|
9
|
+
import { anchorId, connectExistingAgentAnchors, launchAgentAnchor, STATUS_SNAPSHOT, waitForAgentTurn, } from "./agent-anchor-client.js";
|
|
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";
|
|
13
13
|
import { browseMachinePath, streamMachineFileRange } from "./file-browsing.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
|
|
@@ -341,6 +341,14 @@ export function runtimeEvent(anchorName, messageId, event, redactor = DEFAULT_RE
|
|
|
341
341
|
}
|
|
342
342
|
return null;
|
|
343
343
|
}
|
|
344
|
+
/** Registration capabilities for harness upgrades. `codexUpdate` remains for
|
|
345
|
+
* backends that only know how to route Codex upgrades. */
|
|
346
|
+
export function updateCapabilities(harnesses) {
|
|
347
|
+
return {
|
|
348
|
+
...(harnesses.length ? { harnessUpdates: harnesses } : {}),
|
|
349
|
+
...(harnesses.includes('codex') ? { codexUpdate: true } : {}),
|
|
350
|
+
};
|
|
351
|
+
}
|
|
344
352
|
export function resultError(result) {
|
|
345
353
|
if (!result)
|
|
346
354
|
return new Error('The anchored agent returned no result.');
|
|
@@ -371,7 +379,7 @@ export function queueFailureIsRetryable(error, promptStarted) {
|
|
|
371
379
|
export async function actualHarnessStatus(entry) {
|
|
372
380
|
if (!entry || entry.connection.closed)
|
|
373
381
|
return 'stopped';
|
|
374
|
-
const snapshot = await entry.connection.request('snapshot',
|
|
382
|
+
const snapshot = await entry.connection.request('snapshot', STATUS_SNAPSHOT, 5_000);
|
|
375
383
|
entry.snapshot = snapshot;
|
|
376
384
|
return snapshot.status === 'running' ? 'running' : 'idle';
|
|
377
385
|
}
|
|
@@ -586,7 +594,7 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
|
|
|
586
594
|
const usableAnchor = async (sessionId, source, agentSessionId, modelSettings = false) => {
|
|
587
595
|
let entry = anchors.get(sessionId);
|
|
588
596
|
if (entry && !entry.connection.closed) {
|
|
589
|
-
entry.snapshot = await entry.connection.request('snapshot',
|
|
597
|
+
entry.snapshot = await entry.connection.request('snapshot', STATUS_SNAPSHOT, 5_000).catch(() => entry.snapshot);
|
|
590
598
|
if (String(entry.snapshot.source || '') === source &&
|
|
591
599
|
String(entry.snapshot.agentSessionId || '') === agentSessionId &&
|
|
592
600
|
(!modelSettings || entry.snapshot.capabilities?.includes('model_settings')))
|
|
@@ -835,7 +843,7 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
|
|
|
835
843
|
idleTimeoutMs: QUEUE_WARM_MS,
|
|
836
844
|
}, 30_000);
|
|
837
845
|
});
|
|
838
|
-
entry.snapshot = await entry.connection.request('snapshot',
|
|
846
|
+
entry.snapshot = await entry.connection.request('snapshot', STATUS_SNAPSHOT, 5_000).catch(() => entry.snapshot);
|
|
839
847
|
// Registration after acceptance is safe: the connection retains recent
|
|
840
848
|
// events and the snapshot carries a turn that completed immediately.
|
|
841
849
|
result = await waitForAgentTurn(entry.connection, claim.prompt.id, QUEUE_TURN_TIMEOUT_MS, entry.snapshot, eventCursor);
|
|
@@ -844,7 +852,7 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
|
|
|
844
852
|
await request({ type: 'session.native.bind', sessionId, agentSessionId: createdIdentity });
|
|
845
853
|
}
|
|
846
854
|
}
|
|
847
|
-
entry.snapshot = await entry.connection.request('snapshot',
|
|
855
|
+
entry.snapshot = await entry.connection.request('snapshot', STATUS_SNAPSHOT, 5_000).catch(() => ({
|
|
848
856
|
...entry.snapshot,
|
|
849
857
|
status: 'idle',
|
|
850
858
|
}));
|
|
@@ -880,7 +888,7 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
|
|
|
880
888
|
stopClaimRenewal(held);
|
|
881
889
|
if (held?.entry && !held.entry.connection.closed) {
|
|
882
890
|
held.entry.snapshot = await held.entry.connection
|
|
883
|
-
.request('snapshot',
|
|
891
|
+
.request('snapshot', STATUS_SNAPSHOT, 2_000)
|
|
884
892
|
.catch(() => held.entry.snapshot);
|
|
885
893
|
await reportHarness(sessionId, held.entry.snapshot?.status === 'running' ? 'running' : 'idle', held.entry).catch(() => { });
|
|
886
894
|
}
|
|
@@ -907,8 +915,15 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
|
|
|
907
915
|
};
|
|
908
916
|
const recoverAnchors = async () => {
|
|
909
917
|
for (const [sessionId, entry] of anchors) {
|
|
910
|
-
|
|
911
|
-
|
|
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: [] };
|
|
912
927
|
await reportHarness(sessionId, snapshot.status === 'running' ? 'running' : 'idle', entry).catch(() => { });
|
|
913
928
|
const turn = snapshot.currentTurn;
|
|
914
929
|
// A warm anchor retains its terminal turn for diagnostics. Probe the
|
|
@@ -1039,7 +1054,7 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
|
|
|
1039
1054
|
try {
|
|
1040
1055
|
if (!entry || entry.connection.closed)
|
|
1041
1056
|
throw new Error('The detached harness is offline.');
|
|
1042
|
-
entry.snapshot = await entry.connection.request('snapshot',
|
|
1057
|
+
entry.snapshot = await entry.connection.request('snapshot', STATUS_SNAPSHOT, 5_000);
|
|
1043
1058
|
if (entry.snapshot?.status === 'running')
|
|
1044
1059
|
throw new Error('The detached harness is actively running.');
|
|
1045
1060
|
if (action === 'compact' && entry.snapshot?.pendingInteractions?.length) {
|
|
@@ -1048,7 +1063,7 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
|
|
|
1048
1063
|
await entry.connection.request(action, {}, action === 'compact' ? QUEUE_TURN_TIMEOUT_MS : 5_000);
|
|
1049
1064
|
if (action === 'compact') {
|
|
1050
1065
|
await flushRuntime(sessionId);
|
|
1051
|
-
entry.snapshot = await entry.connection.request('snapshot',
|
|
1066
|
+
entry.snapshot = await entry.connection.request('snapshot', STATUS_SNAPSHOT, 5_000).catch(() => entry.snapshot);
|
|
1052
1067
|
await reportHarness(sessionId, 'idle', entry);
|
|
1053
1068
|
}
|
|
1054
1069
|
else {
|
|
@@ -1339,18 +1354,22 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
|
|
|
1339
1354
|
});
|
|
1340
1355
|
}
|
|
1341
1356
|
};
|
|
1342
|
-
/** Upgrade the machine's
|
|
1343
|
-
*
|
|
1344
|
-
*
|
|
1345
|
-
|
|
1346
|
-
|
|
1357
|
+
/** Upgrade one of the machine's harness CLIs. Only a fixed command chosen
|
|
1358
|
+
* from the resolved executable's install location is run; the browser names
|
|
1359
|
+
* a harness but cannot supply a binary, arguments, or shell input. Errors
|
|
1360
|
+
* are redacted before crossing the machine boundary. The message keeps its
|
|
1361
|
+
* Codex-era name; a request without a harness is from a Codex-only backend. */
|
|
1362
|
+
const harnessUpdate = createHarnessUpdateManager();
|
|
1363
|
+
const handleHarnessUpdate = async (message, replyRpc) => {
|
|
1347
1364
|
const sessionId = String(message.sessionId || '');
|
|
1365
|
+
const harness = String(message.harness || 'codex');
|
|
1348
1366
|
try {
|
|
1349
|
-
const answer = await
|
|
1367
|
+
const answer = await harnessUpdate.update(harness);
|
|
1350
1368
|
replyRpc.notify({
|
|
1351
1369
|
type: 'session.codex.update.result',
|
|
1352
1370
|
requestId: message.requestId,
|
|
1353
1371
|
sessionId,
|
|
1372
|
+
harness,
|
|
1354
1373
|
ok: true,
|
|
1355
1374
|
answer,
|
|
1356
1375
|
});
|
|
@@ -1360,6 +1379,7 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
|
|
|
1360
1379
|
type: 'session.codex.update.result',
|
|
1361
1380
|
requestId: message.requestId,
|
|
1362
1381
|
sessionId,
|
|
1382
|
+
harness,
|
|
1363
1383
|
ok: false,
|
|
1364
1384
|
error: redactor.redactText(error?.message || String(error)),
|
|
1365
1385
|
});
|
|
@@ -1424,33 +1444,44 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
|
|
|
1424
1444
|
}
|
|
1425
1445
|
}, HARNESS_HEARTBEAT_MS);
|
|
1426
1446
|
heartbeat.unref?.();
|
|
1447
|
+
// A browser request is answered over the same socket it arrived on. When
|
|
1448
|
+
// that socket drops mid-request, the handler's reply throws, and so does the
|
|
1449
|
+
// error reply in its catch block. Nothing awaits these handlers, so that
|
|
1450
|
+
// second throw used to surface as an unhandled rejection and take the whole
|
|
1451
|
+
// daemon down with every running session's watcher. The browser has already
|
|
1452
|
+
// been told its connection closed; there is nobody left to answer.
|
|
1453
|
+
const answer = (handler, message) => {
|
|
1454
|
+
void Promise.resolve()
|
|
1455
|
+
.then(() => handler(message, rpc))
|
|
1456
|
+
.catch((error) => logError(`Could not answer ${message.type}: ${error?.message || error}\n`));
|
|
1457
|
+
};
|
|
1427
1458
|
rpc.onEvent((message) => {
|
|
1428
1459
|
if (lifecycle.stopping)
|
|
1429
1460
|
return;
|
|
1430
1461
|
if (message.type === 'queue.ready')
|
|
1431
1462
|
void tryClaim(message.sessionId);
|
|
1432
1463
|
if (message.type === 'session.steer')
|
|
1433
|
-
|
|
1464
|
+
answer(handleSteer, message);
|
|
1434
1465
|
if (message.type === 'session.interrupt.request')
|
|
1435
|
-
|
|
1466
|
+
answer(handleInterrupt, message);
|
|
1436
1467
|
if (message.type === 'session.anchor.action.request')
|
|
1437
|
-
|
|
1468
|
+
answer(handleAnchorAction, message);
|
|
1438
1469
|
if (message.type === 'session.interaction.answer.request')
|
|
1439
|
-
|
|
1470
|
+
answer(handleInteractionAnswer, message);
|
|
1440
1471
|
if (message.type === 'session.file.browse.request')
|
|
1441
|
-
|
|
1472
|
+
answer(handleFileBrowse, message);
|
|
1442
1473
|
if (message.type === 'session.file.range.request')
|
|
1443
|
-
|
|
1474
|
+
answer(handleFileRange, message);
|
|
1444
1475
|
if (message.type === 'session.file.search.request')
|
|
1445
|
-
|
|
1476
|
+
answer(handleFileSearch, message);
|
|
1446
1477
|
if (message.type === 'session.git.read.request')
|
|
1447
|
-
|
|
1478
|
+
answer(handleGitRead, message);
|
|
1448
1479
|
if (message.type === 'session.harness.auth.request')
|
|
1449
|
-
|
|
1480
|
+
answer(handleHarnessAuth, message);
|
|
1450
1481
|
if (message.type === 'session.codex.update.request')
|
|
1451
|
-
|
|
1482
|
+
answer(handleHarnessUpdate, message);
|
|
1452
1483
|
if (message.type === 'session.file.edit.request')
|
|
1453
|
-
|
|
1484
|
+
answer(handleFileEdit, message);
|
|
1454
1485
|
});
|
|
1455
1486
|
const registered = new WeakSet();
|
|
1456
1487
|
const registering = new WeakSet();
|
|
@@ -1469,7 +1500,7 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
|
|
|
1469
1500
|
git: true,
|
|
1470
1501
|
fileRanges: true,
|
|
1471
1502
|
harnessAuth: true,
|
|
1472
|
-
...(
|
|
1503
|
+
...updateCapabilities(harnessUpdate.available()),
|
|
1473
1504
|
},
|
|
1474
1505
|
workerId,
|
|
1475
1506
|
workerInstanceId,
|
|
@@ -1490,7 +1521,15 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
|
|
|
1490
1521
|
rpc.onConnect((control) => {
|
|
1491
1522
|
void registerControl(control);
|
|
1492
1523
|
});
|
|
1493
|
-
|
|
1524
|
+
// Starting offline must not end this function: the loop below is the only
|
|
1525
|
+
// thing that sends heartbeats, probes for a half-open socket and re-polls
|
|
1526
|
+
// for missed queue events, and it already tolerates a failed connection.
|
|
1527
|
+
try {
|
|
1528
|
+
await registerControl(await rpc.current());
|
|
1529
|
+
}
|
|
1530
|
+
catch (error) {
|
|
1531
|
+
logError(`Queue worker could not connect yet: ${error?.message || error}\n`);
|
|
1532
|
+
}
|
|
1494
1533
|
while (!lifecycle.stopping) {
|
|
1495
1534
|
await backgroundWait(MACHINE_HEARTBEAT_MS);
|
|
1496
1535
|
if (lifecycle.stopping)
|
package/dist/upload-sessions.js
CHANGED
|
@@ -404,6 +404,37 @@ export function linkNativeSessionParents(machine, descriptors) {
|
|
|
404
404
|
];
|
|
405
405
|
});
|
|
406
406
|
}
|
|
407
|
+
/**
|
|
408
|
+
* The row the server has reached, when a rejected ingest batch was in fact
|
|
409
|
+
* already stored in full; null when the rejection is a real one.
|
|
410
|
+
*/
|
|
411
|
+
export function ingestOffsetAcknowledges(error, startRow, rowCount) {
|
|
412
|
+
const offset = /^ROW_OFFSET:(\d+)$/u.exec(String(error?.code || ''));
|
|
413
|
+
if (!offset)
|
|
414
|
+
return null;
|
|
415
|
+
const reached = Number(offset[1]);
|
|
416
|
+
return reached >= startRow + rowCount ? reached : null;
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Bring a descriptor up to the file as it is now.
|
|
420
|
+
*
|
|
421
|
+
* The inventory scan stats each transcript in a worker and the result is
|
|
422
|
+
* recorded here some time later. An agent that wrote in between leaves the
|
|
423
|
+
* descriptor describing a shorter file than the one on disk. Recording the
|
|
424
|
+
* fresh stat as "already seen" while uploading only to the descriptor's stale
|
|
425
|
+
* size strands those rows: nothing looks changed until the next append or the
|
|
426
|
+
* five-minute audit. A database projection's file is its whole store, not the
|
|
427
|
+
* session, so its size says nothing about the session and is left alone.
|
|
428
|
+
*/
|
|
429
|
+
export function adoptGrowthSinceInventory(descriptor, stat) {
|
|
430
|
+
if (String(descriptor.sourcePath || '').includes('.db#'))
|
|
431
|
+
return false;
|
|
432
|
+
if (Number(stat.size) === Number(descriptor.size))
|
|
433
|
+
return false;
|
|
434
|
+
descriptor.size = stat.size;
|
|
435
|
+
descriptor.mtimeMs = stat.mtimeMs;
|
|
436
|
+
return true;
|
|
437
|
+
}
|
|
407
438
|
export function nativeDescriptorFingerprint(descriptor) {
|
|
408
439
|
return JSON.stringify([
|
|
409
440
|
Number(descriptor.size) || 0,
|
|
@@ -450,32 +481,39 @@ function nativeTimestamp(raw, descriptor, rowIndex) {
|
|
|
450
481
|
const base = Date.parse(descriptor.createdAt) || descriptor.mtimeMs || Date.now();
|
|
451
482
|
return new Date(base + rowIndex).toISOString();
|
|
452
483
|
}
|
|
453
|
-
async function* completeNativeFileLines(file, startOffset, endOffset) {
|
|
484
|
+
export async function* completeNativeFileLines(file, startOffset, endOffset) {
|
|
454
485
|
if (endOffset < startOffset)
|
|
455
486
|
return;
|
|
456
|
-
|
|
457
|
-
|
|
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;
|
|
458
493
|
const input = fs.createReadStream(file, { start: startOffset, end: endOffset });
|
|
459
494
|
try {
|
|
460
495
|
for await (const chunk of input) {
|
|
461
|
-
const buffer = carry.length ? Buffer.concat([carry, chunk]) : chunk;
|
|
462
|
-
const bufferOffset = carryOffset;
|
|
463
496
|
let lineStart = 0;
|
|
464
497
|
for (;;) {
|
|
465
|
-
|
|
498
|
+
// Only the new chunk can contain the newline that ends the carry.
|
|
499
|
+
const newline = chunk.indexOf(0x0a, lineStart);
|
|
466
500
|
if (newline < 0)
|
|
467
501
|
break;
|
|
468
|
-
|
|
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;
|
|
469
507
|
if (line.at(-1) === 0x0d)
|
|
470
508
|
line = line.subarray(0, -1);
|
|
471
|
-
yield {
|
|
472
|
-
raw: line.toString('utf8'),
|
|
473
|
-
endOffset: bufferOffset + newline + 1,
|
|
474
|
-
};
|
|
509
|
+
yield { raw: line.toString('utf8'), endOffset: consumed };
|
|
475
510
|
lineStart = newline + 1;
|
|
476
511
|
}
|
|
477
|
-
|
|
478
|
-
|
|
512
|
+
if (lineStart < chunk.length) {
|
|
513
|
+
const rest = chunk.subarray(lineStart);
|
|
514
|
+
carried.push(rest);
|
|
515
|
+
carriedBytes += rest.length;
|
|
516
|
+
}
|
|
479
517
|
}
|
|
480
518
|
}
|
|
481
519
|
finally {
|
|
@@ -638,6 +676,10 @@ export class RpcSocket {
|
|
|
638
676
|
this.socket.on('message', (data) => this.onMessage(String(data), (ready) => settle(resolve, ready), (error) => settle(reject, error)));
|
|
639
677
|
this.socket.on('close', (code, reason) => {
|
|
640
678
|
const error = socketError(`WebSocket closed (${code}): ${String(reason)}`);
|
|
679
|
+
// A server that accepts the upgrade and closes before saying "ready"
|
|
680
|
+
// (a deploy, a restarting object) has answered. Without this the
|
|
681
|
+
// connect waited out the whole ready deadline before it could retry.
|
|
682
|
+
settle(reject, error);
|
|
641
683
|
for (const request of this.pending.values()) {
|
|
642
684
|
clearTimeout(request.timer);
|
|
643
685
|
request.reject(error);
|
|
@@ -1087,13 +1129,27 @@ export async function uploadNativeSession(rpc, machine, descriptor, cursor = {},
|
|
|
1087
1129
|
if (!held.length)
|
|
1088
1130
|
return;
|
|
1089
1131
|
const rows = held.map((entry) => entry.event);
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1132
|
+
let acknowledgement;
|
|
1133
|
+
try {
|
|
1134
|
+
acknowledgement = await rpc.request({
|
|
1135
|
+
type: 'ingest',
|
|
1136
|
+
sessionId: uploadSessionId,
|
|
1137
|
+
startRow: rows[0].index,
|
|
1138
|
+
rows,
|
|
1139
|
+
...(initialUpload ? { deferReduction: true } : {}),
|
|
1140
|
+
});
|
|
1141
|
+
}
|
|
1142
|
+
catch (error) {
|
|
1143
|
+
// The connection resends a request whose socket died. When only the
|
|
1144
|
+
// acknowledgement was lost, the server already holds these rows and
|
|
1145
|
+
// answers the resend with the offset it has reached. That is an
|
|
1146
|
+
// acknowledgement: treating it as a failure put the session into backoff
|
|
1147
|
+
// and then re-read its whole transcript from the first byte.
|
|
1148
|
+
const acknowledged = ingestOffsetAcknowledges(error, rows[0].index, rows.length);
|
|
1149
|
+
if (acknowledged === null)
|
|
1150
|
+
throw error;
|
|
1151
|
+
acknowledgement = { nextRow: acknowledged };
|
|
1152
|
+
}
|
|
1097
1153
|
nextRow = Number(acknowledgement.nextRow);
|
|
1098
1154
|
sent += rows.length;
|
|
1099
1155
|
batchesSent += 1;
|
|
@@ -1624,6 +1680,7 @@ async function uploadNative(options, machine, redactor = DEFAULT_REDACTOR) {
|
|
|
1624
1680
|
try {
|
|
1625
1681
|
const stat = fs.statSync(descriptor.file);
|
|
1626
1682
|
physicalStats.set(sessionId, { size: stat.size, mtimeMs: stat.mtimeMs });
|
|
1683
|
+
adoptGrowthSinceInventory(descriptor, stat);
|
|
1627
1684
|
}
|
|
1628
1685
|
catch { }
|
|
1629
1686
|
const saved = state.get(sessionId);
|
|
@@ -2018,6 +2075,10 @@ async function uploadNative(options, machine, redactor = DEFAULT_REDACTOR) {
|
|
|
2018
2075
|
String(descriptor.parentSessionId || ''),
|
|
2019
2076
|
NATIVE_DESCRIPTOR_VERSION,
|
|
2020
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)] : []),
|
|
2021
2082
|
]);
|
|
2022
2083
|
}
|
|
2023
2084
|
}
|
package/package.json
CHANGED
package/dist/codex-update.js
DELETED
|
@@ -1,162 +0,0 @@
|
|
|
1
|
-
/** Machine-local Codex CLI self-update, callable by the Turns daemon. */
|
|
2
|
-
import { spawn } from 'node:child_process';
|
|
3
|
-
import fs from 'node:fs';
|
|
4
|
-
import os from 'node:os';
|
|
5
|
-
import path from 'node:path';
|
|
6
|
-
import { executablePath } from "./executable-path.js";
|
|
7
|
-
import { codexCommandIsAppManaged, resolveHarnessCommand } from "./harness-registry.js";
|
|
8
|
-
const VERSION_TIMEOUT_MS = 15_000;
|
|
9
|
-
const UPDATE_TIMEOUT_MS = 5 * 60_000;
|
|
10
|
-
const OUTPUT_LIMIT = 64 * 1024;
|
|
11
|
-
/** Run the resolved executable directly—never through a shell—so update
|
|
12
|
-
* requests cannot become arbitrary command execution. */
|
|
13
|
-
function runCommand(command, args, timeoutMs) {
|
|
14
|
-
return new Promise((resolve) => {
|
|
15
|
-
const child = spawn(command, args, {
|
|
16
|
-
cwd: os.homedir(),
|
|
17
|
-
// npm and Codex's npm launcher are `#!/usr/bin/env node` scripts; a
|
|
18
|
-
// service manager's PATH may not include the daemon's own Node.
|
|
19
|
-
env: { ...process.env, PATH: executablePath() },
|
|
20
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
21
|
-
});
|
|
22
|
-
let output = '';
|
|
23
|
-
let settled = false;
|
|
24
|
-
const settle = (result) => {
|
|
25
|
-
if (settled)
|
|
26
|
-
return;
|
|
27
|
-
settled = true;
|
|
28
|
-
clearTimeout(timer);
|
|
29
|
-
resolve(result);
|
|
30
|
-
};
|
|
31
|
-
const timer = setTimeout(() => {
|
|
32
|
-
try {
|
|
33
|
-
child.kill('SIGKILL');
|
|
34
|
-
}
|
|
35
|
-
catch { }
|
|
36
|
-
settle({ output, exitCode: null, timedOut: true });
|
|
37
|
-
}, timeoutMs);
|
|
38
|
-
timer.unref?.();
|
|
39
|
-
const collect = (chunk) => {
|
|
40
|
-
if (output.length < OUTPUT_LIMIT)
|
|
41
|
-
output += String(chunk).slice(0, OUTPUT_LIMIT - output.length);
|
|
42
|
-
};
|
|
43
|
-
child.stdout.on('data', collect);
|
|
44
|
-
child.stderr.on('data', collect);
|
|
45
|
-
child.on('error', () => settle({ output, exitCode: null, failed: true }));
|
|
46
|
-
child.on('close', (exitCode) => settle({ output, exitCode }));
|
|
47
|
-
});
|
|
48
|
-
}
|
|
49
|
-
export function parseCodexVersion(output) {
|
|
50
|
-
const text = String(output || '').trim();
|
|
51
|
-
const prefixed = text.match(/codex(?:-cli)?\s+([^\s]+)/iu);
|
|
52
|
-
return (prefixed?.[1] || text.split(/\s+/u).at(-1) || '').slice(0, 120);
|
|
53
|
-
}
|
|
54
|
-
function commandFailure(result, fallback) {
|
|
55
|
-
if (result.timedOut)
|
|
56
|
-
return new Error('The Codex update timed out.');
|
|
57
|
-
const lines = result.output
|
|
58
|
-
.replace(/\u001b\[[0-9;?]*[A-Za-z]/gu, '')
|
|
59
|
-
.split('\n')
|
|
60
|
-
.map((line) => line.trim())
|
|
61
|
-
.filter(Boolean);
|
|
62
|
-
return new Error(lines.at(-1) || fallback);
|
|
63
|
-
}
|
|
64
|
-
function realpathOrSelf(file) {
|
|
65
|
-
try {
|
|
66
|
-
return fs.realpathSync(file);
|
|
67
|
-
}
|
|
68
|
-
catch {
|
|
69
|
-
return file;
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
function findExecutable(name, preferred) {
|
|
73
|
-
for (const directory of [...preferred, ...executablePath().split(path.delimiter)]) {
|
|
74
|
-
const candidate = path.join(directory, name);
|
|
75
|
-
try {
|
|
76
|
-
fs.accessSync(candidate, fs.constants.X_OK);
|
|
77
|
-
return candidate;
|
|
78
|
-
}
|
|
79
|
-
catch { }
|
|
80
|
-
}
|
|
81
|
-
return '';
|
|
82
|
-
}
|
|
83
|
-
export function codexUpgradeCommand(command, lookup = {}) {
|
|
84
|
-
const resolved = (lookup.realpath || realpathOrSelf)(command).replaceAll('\\', '/');
|
|
85
|
-
const find = lookup.findExecutable || findExecutable;
|
|
86
|
-
// `npm i -g @openai/codex` links <prefix>/bin/codex to the package's launcher.
|
|
87
|
-
const npm = resolved.match(/^(.*?)\/(?:lib\/)?node_modules\/@openai\/codex\//u);
|
|
88
|
-
// Windows npm is a .cmd shim, which cannot be spawned without a shell.
|
|
89
|
-
if (npm && process.platform !== 'win32') {
|
|
90
|
-
const prefix = npm[1];
|
|
91
|
-
const binary = find('npm', [path.join(prefix, 'bin')]);
|
|
92
|
-
if (binary)
|
|
93
|
-
return { manager: 'npm', command: binary, args: ['install', '-g', '--prefix', prefix, '@openai/codex@latest'] };
|
|
94
|
-
}
|
|
95
|
-
const brew = resolved.match(/^(.*?)\/(Cellar|Caskroom)\/codex\//u);
|
|
96
|
-
if (brew) {
|
|
97
|
-
const binary = find('brew', [path.join(brew[1], 'bin')]);
|
|
98
|
-
if (binary)
|
|
99
|
-
return {
|
|
100
|
-
manager: 'brew',
|
|
101
|
-
command: binary,
|
|
102
|
-
args: ['upgrade', ...(brew[2] === 'Caskroom' ? ['--cask'] : []), 'codex'],
|
|
103
|
-
};
|
|
104
|
-
}
|
|
105
|
-
return { manager: 'codex', command, args: ['update'] };
|
|
106
|
-
}
|
|
107
|
-
export function createCodexUpdateManager(dependencies = {}) {
|
|
108
|
-
const resolveCommand = dependencies.resolveCommand || (() => resolveHarnessCommand('codex'));
|
|
109
|
-
const isAppManaged = dependencies.isAppManaged || codexCommandIsAppManaged;
|
|
110
|
-
const run = dependencies.run || runCommand;
|
|
111
|
-
const upgradeCommand = dependencies.upgradeCommand || ((command) => codexUpgradeCommand(command));
|
|
112
|
-
let active = null;
|
|
113
|
-
const updateCommand = () => {
|
|
114
|
-
const command = resolveCommand();
|
|
115
|
-
if (isAppManaged(command)) {
|
|
116
|
-
throw new Error('Codex is managed by the macOS app. Update the Codex app to update its bundled CLI.');
|
|
117
|
-
}
|
|
118
|
-
return command;
|
|
119
|
-
};
|
|
120
|
-
const perform = async () => {
|
|
121
|
-
const beforeRun = await run(updateCommand(), ['--version'], VERSION_TIMEOUT_MS);
|
|
122
|
-
if (beforeRun.failed || beforeRun.timedOut || beforeRun.exitCode !== 0) {
|
|
123
|
-
throw commandFailure(beforeRun, 'Codex is not installed or its version could not be read.');
|
|
124
|
-
}
|
|
125
|
-
const previousVersion = parseCodexVersion(beforeRun.output);
|
|
126
|
-
const upgrade = upgradeCommand(updateCommand());
|
|
127
|
-
const updateRun = await run(upgrade.command, upgrade.args, UPDATE_TIMEOUT_MS);
|
|
128
|
-
if (updateRun.failed || updateRun.timedOut || updateRun.exitCode !== 0) {
|
|
129
|
-
throw commandFailure(updateRun, upgrade.manager === 'codex'
|
|
130
|
-
? 'Codex could not update itself. Run `codex update` in a terminal on the machine.'
|
|
131
|
-
: `${upgrade.manager} could not upgrade Codex. Run \`${[path.basename(upgrade.command), ...upgrade.args].join(' ')}\` in a terminal on the machine.`);
|
|
132
|
-
}
|
|
133
|
-
// Resolve again because the updater may replace a shim or install path.
|
|
134
|
-
const afterRun = await run(updateCommand(), ['--version'], VERSION_TIMEOUT_MS);
|
|
135
|
-
if (afterRun.failed || afterRun.timedOut || afterRun.exitCode !== 0) {
|
|
136
|
-
throw commandFailure(afterRun, 'Codex updated, but its new version could not be read.');
|
|
137
|
-
}
|
|
138
|
-
const currentVersion = parseCodexVersion(afterRun.output);
|
|
139
|
-
return {
|
|
140
|
-
...(previousVersion ? { previousVersion } : {}),
|
|
141
|
-
...(currentVersion ? { currentVersion } : {}),
|
|
142
|
-
updated: Boolean(previousVersion && currentVersion && previousVersion !== currentVersion),
|
|
143
|
-
};
|
|
144
|
-
};
|
|
145
|
-
return {
|
|
146
|
-
/** App-bundled Codex is updated with the app, not by its CLI command. */
|
|
147
|
-
available() {
|
|
148
|
-
try {
|
|
149
|
-
return !isAppManaged(resolveCommand());
|
|
150
|
-
}
|
|
151
|
-
catch {
|
|
152
|
-
return false;
|
|
153
|
-
}
|
|
154
|
-
},
|
|
155
|
-
/** Concurrent clicks share one installer process instead of racing writes. */
|
|
156
|
-
update() {
|
|
157
|
-
if (!active)
|
|
158
|
-
active = perform().finally(() => (active = null));
|
|
159
|
-
return active;
|
|
160
|
-
},
|
|
161
|
-
};
|
|
162
|
-
}
|