@turns/dev 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +74 -0
- package/dist/agent-anchor-client.js +319 -0
- package/dist/agent-anchor.js +1685 -0
- package/dist/auth.js +258 -0
- package/dist/daemon.js +134 -0
- package/dist/deepseek-trajectories.js +196 -0
- package/dist/directory-policy.js +41 -0
- package/dist/file-browsing.js +241 -0
- package/dist/git-association.js +136 -0
- package/dist/harness-registry.js +323 -0
- package/dist/index.js +300 -0
- package/dist/local-session-sources.js +1448 -0
- package/dist/native-record-compaction.js +84 -0
- package/dist/path-resolution.js +298 -0
- package/dist/question-mcp.js +126 -0
- package/dist/queue-worker.js +1053 -0
- package/dist/secret-redaction.js +181 -0
- package/dist/upload-sessions.js +2016 -0
- package/package.json +35 -0
|
@@ -0,0 +1,1685 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import net from 'node:net';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import readline from 'node:readline';
|
|
7
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { WebSocket } from 'ws';
|
|
10
|
+
import { ACP_PROFILES, PROCESS_HARNESSES, processEventSessionId, processInvocation, RPC_PROFILES, resolveHarnessCommand, rpcFrameSessionIdentity, rpcUiRequestIsInformational, } from "./harness-registry.js";
|
|
11
|
+
const socketIndex = process.argv.indexOf('--socket');
|
|
12
|
+
const socketPath = socketIndex >= 0 ? path.resolve(process.argv[socketIndex + 1] || '') : '';
|
|
13
|
+
if (!socketPath)
|
|
14
|
+
throw new Error('--socket <path> is required');
|
|
15
|
+
const questionMcpPath = path.join(import.meta.dirname, `question-mcp${path.extname(fileURLToPath(import.meta.url))}`);
|
|
16
|
+
const MAX_EVENTS = 2_000;
|
|
17
|
+
const MAX_EVENT_BYTES = 8 * 1024 * 1024;
|
|
18
|
+
const now = () => new Date().toISOString();
|
|
19
|
+
const pause = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
20
|
+
const errorText = (error) => error?.message || String(error);
|
|
21
|
+
const PERMISSION_MODES = new Set(['auto', 'default', 'acceptEdits', 'plan', 'bypassPermissions']);
|
|
22
|
+
function recordHermesWorkingDirectory(sessionId, cwd) {
|
|
23
|
+
if (!sessionId || !path.isAbsolute(cwd || ''))
|
|
24
|
+
return;
|
|
25
|
+
const file = path.join(process.env.HERMES_HOME || path.join(process.env.HOME || '', '.hermes'), 'state.db');
|
|
26
|
+
try {
|
|
27
|
+
const database = new DatabaseSync(file);
|
|
28
|
+
try {
|
|
29
|
+
database.prepare("UPDATE sessions SET cwd = ? WHERE id = ? AND (cwd IS NULL OR cwd = '')").run(cwd, sessionId);
|
|
30
|
+
}
|
|
31
|
+
finally {
|
|
32
|
+
database.close();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
// Session operation still succeeds if an older Hermes schema lacks the
|
|
37
|
+
// column; native discovery will simply omit that transcript as before.
|
|
38
|
+
process.stderr.write(`Hermes cwd promotion failed: ${errorText(error)}\n`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function requestedPermissionMode(value) {
|
|
42
|
+
if (value === undefined || value === null || value === '')
|
|
43
|
+
return 'bypassPermissions';
|
|
44
|
+
if (!PERMISSION_MODES.has(value))
|
|
45
|
+
throw new Error(`Invalid permission mode: ${String(value)}`);
|
|
46
|
+
return value;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The Promise executor runs synchronously, so both handles are assigned before
|
|
50
|
+
* this returns. The annotations state that guarantee, which the checker cannot
|
|
51
|
+
* infer from the assignments alone.
|
|
52
|
+
* @returns {{ promise: Promise<any>, resolve: (value?: any) => void, reject: (error?: any) => void }}
|
|
53
|
+
*/
|
|
54
|
+
function deferred() {
|
|
55
|
+
/** @type {(value?: any) => void} */
|
|
56
|
+
let resolve;
|
|
57
|
+
/** @type {(error?: any) => void} */
|
|
58
|
+
let reject;
|
|
59
|
+
const promise = new Promise((yes, no) => {
|
|
60
|
+
resolve = yes;
|
|
61
|
+
reject = no;
|
|
62
|
+
});
|
|
63
|
+
return { promise, resolve, reject };
|
|
64
|
+
}
|
|
65
|
+
function writeLine(socket, value) {
|
|
66
|
+
if (!socket.destroyed && socket.writable)
|
|
67
|
+
socket.write(`${JSON.stringify(value)}\n`);
|
|
68
|
+
}
|
|
69
|
+
function childResult(child, timeoutMs) {
|
|
70
|
+
const result = deferred();
|
|
71
|
+
let settled = false;
|
|
72
|
+
const finish = (value) => {
|
|
73
|
+
if (!settled) {
|
|
74
|
+
settled = true;
|
|
75
|
+
clearTimeout(timer);
|
|
76
|
+
result.resolve(value);
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
child.once('error', (error) => finish({ exitCode: null, signal: null, timedOut: false, error: { message: errorText(error) } }));
|
|
80
|
+
child.once('close', (code, signal) => finish({ exitCode: code, signal, timedOut: false, error: null }));
|
|
81
|
+
const timer = setTimeout(() => {
|
|
82
|
+
try {
|
|
83
|
+
process.kill(-child.pid, 'SIGTERM');
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
try {
|
|
87
|
+
child.kill('SIGTERM');
|
|
88
|
+
}
|
|
89
|
+
catch { }
|
|
90
|
+
}
|
|
91
|
+
finish({ exitCode: null, signal: 'SIGTERM', timedOut: true, error: { message: 'Agent turn timed out.' } });
|
|
92
|
+
}, timeoutMs);
|
|
93
|
+
timer.unref?.();
|
|
94
|
+
return result.promise;
|
|
95
|
+
}
|
|
96
|
+
class LineTransport {
|
|
97
|
+
anchor;
|
|
98
|
+
harness;
|
|
99
|
+
nextId;
|
|
100
|
+
pending;
|
|
101
|
+
lastEventAt;
|
|
102
|
+
child;
|
|
103
|
+
constructor(anchor, harness, command, args, cwd, env = {}) {
|
|
104
|
+
this.anchor = anchor;
|
|
105
|
+
this.harness = harness;
|
|
106
|
+
this.nextId = 1;
|
|
107
|
+
this.pending = new Map();
|
|
108
|
+
this.lastEventAt = Date.now();
|
|
109
|
+
this.child = spawn(command, args, {
|
|
110
|
+
cwd,
|
|
111
|
+
env: {
|
|
112
|
+
...process.env,
|
|
113
|
+
...env,
|
|
114
|
+
TURNS_QUEUE_WORKER_ID: anchor.workerId,
|
|
115
|
+
TURNS_QUEUE_SESSION_ID: anchor.sessionId,
|
|
116
|
+
TURNS_TRANSCRIPT_PATH: anchor.transcriptPath,
|
|
117
|
+
},
|
|
118
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
119
|
+
detached: process.platform !== 'win32',
|
|
120
|
+
});
|
|
121
|
+
anchor.child = this.child;
|
|
122
|
+
readline.createInterface({ input: this.child.stdout }).on('line', (line) => this.receive(line));
|
|
123
|
+
readline.createInterface({ input: this.child.stderr }).on('line', (line) => anchor.emitLive({
|
|
124
|
+
event: 'process.line',
|
|
125
|
+
source: `${harness}.stderr`,
|
|
126
|
+
raw: line,
|
|
127
|
+
phase: 'queued',
|
|
128
|
+
}));
|
|
129
|
+
this.child.once('close', (code, signal) => {
|
|
130
|
+
const error = new Error(`${harness} transport exited with ${signal || code}`);
|
|
131
|
+
for (const waiter of this.pending.values())
|
|
132
|
+
waiter.reject(error);
|
|
133
|
+
this.pending.clear();
|
|
134
|
+
this.onClose?.(error);
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
emit(value, meta = {}) {
|
|
138
|
+
const raw = JSON.stringify(value);
|
|
139
|
+
this.anchor.emitLive({
|
|
140
|
+
direction: 'out',
|
|
141
|
+
source: `${this.harness}.stdin`,
|
|
142
|
+
raw,
|
|
143
|
+
json: value,
|
|
144
|
+
phase: 'queued',
|
|
145
|
+
...meta,
|
|
146
|
+
});
|
|
147
|
+
this.child.stdin.write(`${raw}\n`);
|
|
148
|
+
}
|
|
149
|
+
receive(line) {
|
|
150
|
+
this.lastEventAt = Date.now();
|
|
151
|
+
let json = null;
|
|
152
|
+
try {
|
|
153
|
+
json = JSON.parse(line);
|
|
154
|
+
}
|
|
155
|
+
catch { }
|
|
156
|
+
this.anchor.emitLive({
|
|
157
|
+
direction: 'in',
|
|
158
|
+
source: `${this.harness}.stdout`,
|
|
159
|
+
raw: line,
|
|
160
|
+
json,
|
|
161
|
+
phase: 'queued',
|
|
162
|
+
method: json?.method,
|
|
163
|
+
});
|
|
164
|
+
this.onFrame(json);
|
|
165
|
+
}
|
|
166
|
+
// Subclass hooks. The base no-ops keep the contract visible for the transports
|
|
167
|
+
// that do not need them, and for the call sites that invoke them unconditionally.
|
|
168
|
+
onFrame(_frame) { }
|
|
169
|
+
onClose(_error) { }
|
|
170
|
+
stop() {
|
|
171
|
+
try {
|
|
172
|
+
this.child.stdin.end();
|
|
173
|
+
}
|
|
174
|
+
catch { }
|
|
175
|
+
try {
|
|
176
|
+
process.kill(-this.child.pid, 'SIGTERM');
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
try {
|
|
180
|
+
this.child.kill('SIGTERM');
|
|
181
|
+
}
|
|
182
|
+
catch { }
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
class ClaudeTransport extends LineTransport {
|
|
187
|
+
sessionId;
|
|
188
|
+
permissionMode;
|
|
189
|
+
turnWaiter;
|
|
190
|
+
control;
|
|
191
|
+
constructor(anchor, createSession) {
|
|
192
|
+
const profile = { command: 'claude' };
|
|
193
|
+
const nativePermissionMode = anchor.permissionMode === 'default' ? 'manual' : anchor.permissionMode;
|
|
194
|
+
const args = [
|
|
195
|
+
'-p',
|
|
196
|
+
'--input-format',
|
|
197
|
+
'stream-json',
|
|
198
|
+
'--output-format',
|
|
199
|
+
'stream-json',
|
|
200
|
+
'--verbose',
|
|
201
|
+
'--include-partial-messages',
|
|
202
|
+
'--permission-mode',
|
|
203
|
+
nativePermissionMode,
|
|
204
|
+
...(anchor.permissionMode === 'bypassPermissions' ? [] : ['--permission-prompt-tool', 'stdio']),
|
|
205
|
+
'--mcp-config',
|
|
206
|
+
JSON.stringify({
|
|
207
|
+
mcpServers: {
|
|
208
|
+
turns: { command: process.execPath, args: [questionMcpPath], env: { TURNS_AGENT_ANCHOR_SOCKET: socketPath } },
|
|
209
|
+
},
|
|
210
|
+
}),
|
|
211
|
+
...(anchor.model && anchor.model !== 'auto' ? ['--model', anchor.model] : []),
|
|
212
|
+
...(anchor.thinkingLevel ? ['--effort', anchor.thinkingLevel] : []),
|
|
213
|
+
...(createSession ? ['--session-id', anchor.agentSessionId] : ['--resume', anchor.agentSessionId]),
|
|
214
|
+
];
|
|
215
|
+
super(anchor, 'claude', resolveHarnessCommand('claude', profile), args, anchor.cwd, {
|
|
216
|
+
CLAUDE_CODE_ENTRYPOINT: 'turns-agent-anchor',
|
|
217
|
+
});
|
|
218
|
+
this.sessionId = anchor.agentSessionId;
|
|
219
|
+
this.permissionMode = anchor.permissionMode;
|
|
220
|
+
this.turnWaiter = null;
|
|
221
|
+
this.control = new Map();
|
|
222
|
+
}
|
|
223
|
+
onFrame(frame) {
|
|
224
|
+
if (!frame || typeof frame !== 'object')
|
|
225
|
+
return;
|
|
226
|
+
if (frame.type === 'system' && frame.subtype === 'init' && frame.session_id) {
|
|
227
|
+
this.sessionId = String(frame.session_id);
|
|
228
|
+
if (this.sessionId !== this.anchor.agentSessionId)
|
|
229
|
+
this.anchor.updateIdentity(this.sessionId);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
if (frame.type === 'result' && this.turnWaiter) {
|
|
233
|
+
const waiter = this.turnWaiter;
|
|
234
|
+
this.turnWaiter = null;
|
|
235
|
+
frame.is_error
|
|
236
|
+
? waiter.reject(new Error(frame.result || frame.subtype || 'Claude turn failed.'))
|
|
237
|
+
: waiter.resolve(frame);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
if (frame.type === 'control_response') {
|
|
241
|
+
const requestId = frame.response?.request_id;
|
|
242
|
+
const waiter = this.control.get(requestId);
|
|
243
|
+
if (!waiter)
|
|
244
|
+
return;
|
|
245
|
+
this.control.delete(requestId);
|
|
246
|
+
frame.response?.subtype === 'error'
|
|
247
|
+
? waiter.reject(new Error(frame.response.error || 'Claude control request failed.'))
|
|
248
|
+
: waiter.resolve(frame.response?.response ?? frame.response);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
if (frame.type === 'control_request')
|
|
252
|
+
void this.answerReverse(frame);
|
|
253
|
+
}
|
|
254
|
+
onClose(error) {
|
|
255
|
+
if (this.turnWaiter) {
|
|
256
|
+
this.turnWaiter.reject(error);
|
|
257
|
+
this.turnWaiter = null;
|
|
258
|
+
}
|
|
259
|
+
for (const waiter of this.control.values())
|
|
260
|
+
waiter.reject(error);
|
|
261
|
+
this.control.clear();
|
|
262
|
+
}
|
|
263
|
+
controlRequest(subtype, extra = {}, timeoutMs = 20_000) {
|
|
264
|
+
const requestId = `turns_${subtype}_${crypto.randomUUID()}`;
|
|
265
|
+
const waiter = deferred();
|
|
266
|
+
this.control.set(requestId, waiter);
|
|
267
|
+
this.emit({ type: 'control_request', request_id: requestId, request: { subtype, ...extra } });
|
|
268
|
+
const timer = setTimeout(() => {
|
|
269
|
+
if (this.control.delete(requestId))
|
|
270
|
+
waiter.reject(new Error(`Claude control request timed out: ${subtype}`));
|
|
271
|
+
}, timeoutMs);
|
|
272
|
+
timer.unref?.();
|
|
273
|
+
return waiter.promise.finally(() => clearTimeout(timer));
|
|
274
|
+
}
|
|
275
|
+
async open() {
|
|
276
|
+
await this.controlRequest('initialize', { hooks: {} }).catch(() => { });
|
|
277
|
+
}
|
|
278
|
+
async answerReverse(frame) {
|
|
279
|
+
const request = frame.request || {};
|
|
280
|
+
let response;
|
|
281
|
+
if (request.subtype === 'hook_callback')
|
|
282
|
+
response = {
|
|
283
|
+
behavior: 'allow',
|
|
284
|
+
updatedInput: request.input,
|
|
285
|
+
hookSpecificOutput: { hookEventName: 'PreToolUse', updatedInput: request.input },
|
|
286
|
+
};
|
|
287
|
+
else if (request.subtype === 'mcp_message')
|
|
288
|
+
response = { jsonrpc: '2.0', id: request.message?.id, result: {} };
|
|
289
|
+
else {
|
|
290
|
+
try {
|
|
291
|
+
response = await this.anchor.askInteraction(frame);
|
|
292
|
+
}
|
|
293
|
+
catch {
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
this.emit({ type: 'control_response', response: { subtype: 'success', request_id: frame.request_id, response } });
|
|
298
|
+
}
|
|
299
|
+
prompt(text, timeoutMs) {
|
|
300
|
+
if (this.turnWaiter)
|
|
301
|
+
throw new Error('Claude already has an active turn.');
|
|
302
|
+
const waiter = deferred();
|
|
303
|
+
this.turnWaiter = waiter;
|
|
304
|
+
this.emit({
|
|
305
|
+
type: 'user',
|
|
306
|
+
session_id: this.sessionId,
|
|
307
|
+
message: { role: 'user', content: [{ type: 'text', text }] },
|
|
308
|
+
parent_tool_use_id: null,
|
|
309
|
+
});
|
|
310
|
+
const timer = setTimeout(() => {
|
|
311
|
+
if (this.turnWaiter === waiter)
|
|
312
|
+
this.turnWaiter = null;
|
|
313
|
+
waiter.reject(new Error('Claude turn timed out.'));
|
|
314
|
+
}, timeoutMs);
|
|
315
|
+
timer.unref?.();
|
|
316
|
+
return waiter.promise.finally(() => clearTimeout(timer));
|
|
317
|
+
}
|
|
318
|
+
async interrupt() {
|
|
319
|
+
try {
|
|
320
|
+
await this.controlRequest('interrupt');
|
|
321
|
+
}
|
|
322
|
+
catch (error) {
|
|
323
|
+
try {
|
|
324
|
+
process.kill(-this.child.pid, 'SIGINT');
|
|
325
|
+
}
|
|
326
|
+
catch {
|
|
327
|
+
try {
|
|
328
|
+
this.child.kill('SIGINT');
|
|
329
|
+
}
|
|
330
|
+
catch { }
|
|
331
|
+
}
|
|
332
|
+
throw error;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
class CodexAppServerTransport extends LineTransport {
|
|
337
|
+
threadId;
|
|
338
|
+
permissionMode;
|
|
339
|
+
turnId;
|
|
340
|
+
turnIdWaiter;
|
|
341
|
+
turnWaiter;
|
|
342
|
+
constructor(anchor) {
|
|
343
|
+
const profile = { command: 'codex' };
|
|
344
|
+
const mcp = `mcp_servers.turns={command=${JSON.stringify(process.execPath)},args=[${JSON.stringify(questionMcpPath)}],env={TURNS_AGENT_ANCHOR_SOCKET=${JSON.stringify(socketPath)}}}`;
|
|
345
|
+
super(anchor, 'app-server', resolveHarnessCommand('codex', profile), ['-c', mcp, 'app-server', '--listen', 'stdio://'], anchor.cwd);
|
|
346
|
+
this.threadId = anchor.agentSessionId;
|
|
347
|
+
this.permissionMode = anchor.permissionMode;
|
|
348
|
+
this.turnId = null;
|
|
349
|
+
this.turnIdWaiter = null;
|
|
350
|
+
this.turnWaiter = null;
|
|
351
|
+
}
|
|
352
|
+
setTurnId(turnId) {
|
|
353
|
+
if (!turnId)
|
|
354
|
+
return;
|
|
355
|
+
this.turnId = String(turnId);
|
|
356
|
+
this.turnIdWaiter?.resolve(this.turnId);
|
|
357
|
+
}
|
|
358
|
+
onFrame(frame) {
|
|
359
|
+
if (!frame || typeof frame !== 'object')
|
|
360
|
+
return;
|
|
361
|
+
if (frame.id !== undefined && frame.method === undefined) {
|
|
362
|
+
const waiter = this.pending.get(String(frame.id));
|
|
363
|
+
if (!waiter)
|
|
364
|
+
return;
|
|
365
|
+
this.pending.delete(String(frame.id));
|
|
366
|
+
frame.error
|
|
367
|
+
? waiter.reject(new Error(frame.error.message || JSON.stringify(frame.error)))
|
|
368
|
+
: waiter.resolve(frame.result);
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
if (frame.id !== undefined && frame.method) {
|
|
372
|
+
void this.answerReverse(frame);
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
const ours = !frame.params?.threadId || frame.params.threadId === this.threadId;
|
|
376
|
+
if (frame.method === 'turn/started' && ours)
|
|
377
|
+
this.setTurnId(frame.params?.turnId || frame.params?.turn?.id);
|
|
378
|
+
if (['turn/completed', 'turn/failed', 'turn/aborted'].includes(frame.method) && ours && this.turnWaiter) {
|
|
379
|
+
const waiter = this.turnWaiter;
|
|
380
|
+
this.turnWaiter = null;
|
|
381
|
+
this.turnId = null;
|
|
382
|
+
this.turnIdWaiter?.resolve('');
|
|
383
|
+
this.turnIdWaiter = null;
|
|
384
|
+
frame.method === 'turn/failed'
|
|
385
|
+
? waiter.reject(new Error(frame.params?.error?.message || 'Codex turn failed.'))
|
|
386
|
+
: waiter.resolve(frame);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
async answerReverse(frame) {
|
|
390
|
+
let result;
|
|
391
|
+
try {
|
|
392
|
+
result = await this.anchor.askInteraction(frame);
|
|
393
|
+
}
|
|
394
|
+
catch {
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
this.emit({ jsonrpc: '2.0', id: frame.id, result }, { method: frame.method });
|
|
398
|
+
}
|
|
399
|
+
onClose(error) {
|
|
400
|
+
this.turnIdWaiter?.resolve('');
|
|
401
|
+
this.turnIdWaiter = null;
|
|
402
|
+
if (this.turnWaiter) {
|
|
403
|
+
this.turnWaiter.reject(error);
|
|
404
|
+
this.turnWaiter = null;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
request(method, params, timeoutMs = 60_000) {
|
|
408
|
+
const id = String(this.nextId++);
|
|
409
|
+
const waiter = deferred();
|
|
410
|
+
this.pending.set(id, waiter);
|
|
411
|
+
this.emit({ jsonrpc: '2.0', id, method, params }, { method });
|
|
412
|
+
const timer = setTimeout(() => {
|
|
413
|
+
if (this.pending.delete(id))
|
|
414
|
+
waiter.reject(new Error(`Codex app-server timeout: ${method}`));
|
|
415
|
+
}, timeoutMs);
|
|
416
|
+
timer.unref?.();
|
|
417
|
+
return waiter.promise.finally(() => clearTimeout(timer));
|
|
418
|
+
}
|
|
419
|
+
notify(method, params) {
|
|
420
|
+
this.emit({ jsonrpc: '2.0', method, params }, { method });
|
|
421
|
+
}
|
|
422
|
+
async open(createSession) {
|
|
423
|
+
await this.request('initialize', {
|
|
424
|
+
clientInfo: { name: 'turns.dev', version: '1' },
|
|
425
|
+
capabilities: { experimentalApi: true },
|
|
426
|
+
});
|
|
427
|
+
this.notify('initialized', {});
|
|
428
|
+
const configuration = {
|
|
429
|
+
cwd: this.anchor.cwd,
|
|
430
|
+
sandbox: this.anchor.permissionMode === 'bypassPermissions'
|
|
431
|
+
? 'danger-full-access'
|
|
432
|
+
: this.anchor.permissionMode === 'plan'
|
|
433
|
+
? 'read-only'
|
|
434
|
+
: 'workspace-write',
|
|
435
|
+
approvalPolicy: this.anchor.permissionMode === 'bypassPermissions' ? 'never' : 'on-request',
|
|
436
|
+
};
|
|
437
|
+
const opened = createSession
|
|
438
|
+
? await this.request('thread/start', {
|
|
439
|
+
...configuration,
|
|
440
|
+
...(this.anchor.model && this.anchor.model !== 'auto' ? { model: this.anchor.model } : {}),
|
|
441
|
+
...(this.anchor.thinkingLevel ? { effort: this.anchor.thinkingLevel } : {}),
|
|
442
|
+
...(this.anchor.codexHistoryMode === 'paginated' ? { historyMode: 'paginated' } : {}),
|
|
443
|
+
}, 90_000)
|
|
444
|
+
: await this.request('thread/resume', {
|
|
445
|
+
threadId: this.threadId,
|
|
446
|
+
...configuration,
|
|
447
|
+
...(this.anchor.model && this.anchor.model !== 'auto' ? { model: this.anchor.model } : {}),
|
|
448
|
+
...(this.anchor.thinkingLevel ? { effort: this.anchor.thinkingLevel } : {}),
|
|
449
|
+
}, 90_000);
|
|
450
|
+
const threadId = String(opened?.thread?.id || opened?.threadId || opened?.id || '');
|
|
451
|
+
if (!threadId)
|
|
452
|
+
throw new Error(`Codex ${createSession ? 'thread/start' : 'thread/resume'} returned no thread id.`);
|
|
453
|
+
if (!createSession && threadId !== this.threadId)
|
|
454
|
+
throw new Error(`Codex thread/resume returned ${threadId}, expected ${this.threadId}.`);
|
|
455
|
+
this.threadId = threadId;
|
|
456
|
+
if (threadId !== this.anchor.agentSessionId)
|
|
457
|
+
this.anchor.updateIdentity(threadId);
|
|
458
|
+
}
|
|
459
|
+
async prompt(text, timeoutMs) {
|
|
460
|
+
if (this.turnWaiter)
|
|
461
|
+
throw new Error('Codex already has an active turn.');
|
|
462
|
+
const waiter = deferred();
|
|
463
|
+
this.turnId = null;
|
|
464
|
+
this.turnIdWaiter = deferred();
|
|
465
|
+
this.turnWaiter = waiter;
|
|
466
|
+
const started = await this.request('turn/start', { threadId: this.threadId, input: [{ type: 'text', text }] }, timeoutMs).catch((error) => {
|
|
467
|
+
if (this.turnWaiter === waiter)
|
|
468
|
+
this.turnWaiter = null;
|
|
469
|
+
this.turnIdWaiter?.resolve('');
|
|
470
|
+
this.turnIdWaiter = null;
|
|
471
|
+
waiter.reject(error);
|
|
472
|
+
throw error;
|
|
473
|
+
});
|
|
474
|
+
this.setTurnId(started?.turn?.id || started?.turnId);
|
|
475
|
+
const timer = setTimeout(() => {
|
|
476
|
+
if (this.turnWaiter === waiter)
|
|
477
|
+
this.turnWaiter = null;
|
|
478
|
+
this.turnId = null;
|
|
479
|
+
this.turnIdWaiter?.resolve('');
|
|
480
|
+
this.turnIdWaiter = null;
|
|
481
|
+
waiter.reject(new Error('Codex turn timed out.'));
|
|
482
|
+
}, timeoutMs);
|
|
483
|
+
timer.unref?.();
|
|
484
|
+
return waiter.promise.finally(() => clearTimeout(timer));
|
|
485
|
+
}
|
|
486
|
+
interrupt() {
|
|
487
|
+
if (!this.turnId)
|
|
488
|
+
throw new Error('Codex has not reported an active turn id.');
|
|
489
|
+
return this.request('turn/interrupt', { threadId: this.threadId, turnId: this.turnId }, 30_000);
|
|
490
|
+
}
|
|
491
|
+
async steer(text, messageId = '') {
|
|
492
|
+
const deadline = Date.now() + 30_000;
|
|
493
|
+
while (!this.turnId && !this.turnIdWaiter && Date.now() < deadline)
|
|
494
|
+
await pause(10);
|
|
495
|
+
const turnId = this.turnId || (await this.turnIdWaiter?.promise);
|
|
496
|
+
if (!turnId)
|
|
497
|
+
throw new Error('Codex has not reported an active turn id.');
|
|
498
|
+
return this.request('turn/steer', {
|
|
499
|
+
threadId: this.threadId,
|
|
500
|
+
expectedTurnId: turnId,
|
|
501
|
+
input: [{ type: 'text', text }],
|
|
502
|
+
...(messageId ? { clientUserMessageId: messageId } : {}),
|
|
503
|
+
}, 30_000);
|
|
504
|
+
}
|
|
505
|
+
async compact(timeoutMs) {
|
|
506
|
+
if (this.turnWaiter)
|
|
507
|
+
throw new Error('Codex already has an active turn.');
|
|
508
|
+
const waiter = deferred();
|
|
509
|
+
this.turnWaiter = waiter;
|
|
510
|
+
const timer = setTimeout(() => {
|
|
511
|
+
if (this.turnWaiter === waiter)
|
|
512
|
+
this.turnWaiter = null;
|
|
513
|
+
waiter.reject(new Error('Codex compaction timed out.'));
|
|
514
|
+
}, timeoutMs);
|
|
515
|
+
timer.unref?.();
|
|
516
|
+
try {
|
|
517
|
+
const [response] = await Promise.all([
|
|
518
|
+
this.request('thread/compact/start', { threadId: this.threadId }, timeoutMs),
|
|
519
|
+
waiter.promise,
|
|
520
|
+
]);
|
|
521
|
+
return response;
|
|
522
|
+
}
|
|
523
|
+
finally {
|
|
524
|
+
if (this.turnWaiter === waiter)
|
|
525
|
+
this.turnWaiter = null;
|
|
526
|
+
clearTimeout(timer);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
class AcpTransport extends LineTransport {
|
|
531
|
+
profile;
|
|
532
|
+
sessionId;
|
|
533
|
+
permissionMode;
|
|
534
|
+
ready;
|
|
535
|
+
activeRunId;
|
|
536
|
+
constructor(anchor, harness, profile) {
|
|
537
|
+
const command = resolveHarnessCommand(harness, profile);
|
|
538
|
+
const configuredProfile = {
|
|
539
|
+
...profile,
|
|
540
|
+
args: typeof profile.args === 'function' ? profile.args(anchor.permissionMode) : profile.args,
|
|
541
|
+
mode: typeof profile.mode === 'function' ? profile.mode(anchor.permissionMode) : profile.mode,
|
|
542
|
+
options: typeof profile.options === 'function' ? profile.options(anchor.permissionMode) : profile.options,
|
|
543
|
+
env: typeof profile.env === 'function' ? profile.env(anchor.permissionMode, anchor.cwd) : profile.env,
|
|
544
|
+
};
|
|
545
|
+
super(anchor, harness, command, configuredProfile.args, anchor.cwd, configuredProfile.env);
|
|
546
|
+
this.profile = configuredProfile;
|
|
547
|
+
this.sessionId = anchor.agentSessionId;
|
|
548
|
+
this.permissionMode = anchor.permissionMode;
|
|
549
|
+
this.ready = false;
|
|
550
|
+
this.activeRunId = '';
|
|
551
|
+
}
|
|
552
|
+
onFrame(frame) {
|
|
553
|
+
if (!frame || typeof frame !== 'object')
|
|
554
|
+
return;
|
|
555
|
+
const gooseRunId = frame.params?.update?._meta?.goose?.activeRunId;
|
|
556
|
+
if (this.harness === 'goose' && gooseRunId !== undefined) {
|
|
557
|
+
this.activeRunId = typeof gooseRunId === 'string' ? gooseRunId : '';
|
|
558
|
+
}
|
|
559
|
+
if (frame.id !== undefined && frame.method === undefined) {
|
|
560
|
+
const waiter = this.pending.get(frame.id);
|
|
561
|
+
if (!waiter)
|
|
562
|
+
return;
|
|
563
|
+
this.pending.delete(frame.id);
|
|
564
|
+
frame.error
|
|
565
|
+
? waiter.reject(new Error(frame.error.message || JSON.stringify(frame.error)))
|
|
566
|
+
: waiter.resolve(frame.result);
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
if (frame.id !== undefined && frame.method)
|
|
570
|
+
void this.answerReverse(frame);
|
|
571
|
+
}
|
|
572
|
+
async answerReverse(frame) {
|
|
573
|
+
// Qwen uses this extension as an internal turn-boundary callback. It is
|
|
574
|
+
// protocol plumbing rather than a request for the user, and historical
|
|
575
|
+
// Qwen captures establish the empty object as the successful response.
|
|
576
|
+
if (frame.method === 'craft/drainMidTurnQueue') {
|
|
577
|
+
this.emit({ jsonrpc: '2.0', id: frame.id, result: {} }, { method: frame.method });
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
let result;
|
|
581
|
+
try {
|
|
582
|
+
result = await this.anchor.askInteraction(frame, {
|
|
583
|
+
onExpire: () => this.emit({ jsonrpc: '2.0', id: frame.id, result: { outcome: { outcome: 'cancelled' } } }, { method: frame.method }),
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
catch {
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
this.emit({ jsonrpc: '2.0', id: frame.id, result }, { method: frame.method });
|
|
590
|
+
}
|
|
591
|
+
request(method, params, timeoutMs = 120_000) {
|
|
592
|
+
const id = this.nextId++;
|
|
593
|
+
const waiter = deferred();
|
|
594
|
+
this.pending.set(id, waiter);
|
|
595
|
+
this.emit({ jsonrpc: '2.0', id, method, params }, { method });
|
|
596
|
+
const timer = setTimeout(() => {
|
|
597
|
+
if (this.pending.delete(id))
|
|
598
|
+
waiter.reject(new Error(`ACP request timed out: ${method}`));
|
|
599
|
+
}, timeoutMs);
|
|
600
|
+
timer.unref?.();
|
|
601
|
+
return waiter.promise.finally(() => clearTimeout(timer));
|
|
602
|
+
}
|
|
603
|
+
notify(method, params) {
|
|
604
|
+
this.emit({ jsonrpc: '2.0', method, params }, { method });
|
|
605
|
+
}
|
|
606
|
+
async open(createSession) {
|
|
607
|
+
await this.request('initialize', {
|
|
608
|
+
protocolVersion: 1,
|
|
609
|
+
clientCapabilities: {},
|
|
610
|
+
clientInfo: { name: 'turns.dev', version: '1' },
|
|
611
|
+
}, 90_000);
|
|
612
|
+
const mcpServers = this.profile.mcp
|
|
613
|
+
? [
|
|
614
|
+
{
|
|
615
|
+
name: 'turns',
|
|
616
|
+
command: process.execPath,
|
|
617
|
+
args: [questionMcpPath],
|
|
618
|
+
env: [{ name: 'TURNS_AGENT_ANCHOR_SOCKET', value: socketPath }],
|
|
619
|
+
},
|
|
620
|
+
]
|
|
621
|
+
: [];
|
|
622
|
+
let opened;
|
|
623
|
+
let openedFresh = Boolean(createSession);
|
|
624
|
+
const grokPermissionMeta = this.harness === 'grok'
|
|
625
|
+
? {
|
|
626
|
+
_meta: {
|
|
627
|
+
// An explicit false is important: otherwise Grok inherits the
|
|
628
|
+
// interactive user's persisted always-approve setting.
|
|
629
|
+
yoloMode: this.anchor.permissionMode === 'bypassPermissions',
|
|
630
|
+
autoMode: this.anchor.permissionMode === 'auto',
|
|
631
|
+
},
|
|
632
|
+
}
|
|
633
|
+
: {};
|
|
634
|
+
if (!createSession) {
|
|
635
|
+
let lastError;
|
|
636
|
+
for (const method of this.profile.resumeMethods || ['session/resume', 'session/load']) {
|
|
637
|
+
try {
|
|
638
|
+
opened = await this.request(method, { sessionId: this.sessionId, cwd: this.anchor.cwd, mcpServers, ...grokPermissionMeta }, 90_000);
|
|
639
|
+
lastError = null;
|
|
640
|
+
break;
|
|
641
|
+
}
|
|
642
|
+
catch (error) {
|
|
643
|
+
lastError = error;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
if (lastError && this.profile.resumeFallbackToNew) {
|
|
647
|
+
opened = await this.request('session/new', {
|
|
648
|
+
cwd: this.anchor.cwd,
|
|
649
|
+
mcpServers,
|
|
650
|
+
...grokPermissionMeta,
|
|
651
|
+
}, 120_000);
|
|
652
|
+
openedFresh = true;
|
|
653
|
+
lastError = null;
|
|
654
|
+
}
|
|
655
|
+
if (lastError)
|
|
656
|
+
throw new Error(`${this.harness} could not resume native session ${this.sessionId}: ${errorText(lastError)}`);
|
|
657
|
+
}
|
|
658
|
+
else {
|
|
659
|
+
opened = await this.request('session/new', {
|
|
660
|
+
cwd: this.anchor.cwd,
|
|
661
|
+
mcpServers,
|
|
662
|
+
...grokPermissionMeta,
|
|
663
|
+
}, 120_000);
|
|
664
|
+
}
|
|
665
|
+
if (openedFresh) {
|
|
666
|
+
this.sessionId = String(opened?.sessionId || opened?.session?.id || opened?.id || '');
|
|
667
|
+
if (!this.sessionId)
|
|
668
|
+
throw new Error(`${this.harness} session/new returned no session id.`);
|
|
669
|
+
this.anchor.updateIdentity(this.sessionId);
|
|
670
|
+
}
|
|
671
|
+
if (this.harness === 'hermes')
|
|
672
|
+
recordHermesWorkingDirectory(this.sessionId, this.anchor.cwd);
|
|
673
|
+
for (const option of this.profile.options || []) {
|
|
674
|
+
await this.request('session/set_config_option', { sessionId: this.sessionId, configId: option.id, value: option.value }, 30_000).catch(() => { });
|
|
675
|
+
}
|
|
676
|
+
if (this.profile.mode) {
|
|
677
|
+
const availableModes = opened?.modes?.availableModes || opened?.availableModes || [];
|
|
678
|
+
const availableIds = availableModes.map((mode) => String(mode?.id ?? mode?.modeId ?? mode));
|
|
679
|
+
if (availableIds.length && !availableIds.includes(this.profile.mode)) {
|
|
680
|
+
throw new Error(`${this.harness} does not advertise permission mode "${this.profile.mode}" (available: ${availableIds.join(', ')}).`);
|
|
681
|
+
}
|
|
682
|
+
await this.request('session/set_mode', { sessionId: this.sessionId, modeId: this.profile.mode }, 30_000);
|
|
683
|
+
}
|
|
684
|
+
const model = this.anchor.model && this.anchor.model !== 'auto' ? this.anchor.model : this.profile.model;
|
|
685
|
+
if (model)
|
|
686
|
+
await this.request('session/set_model', { sessionId: this.sessionId, modelId: model }, 30_000).catch(() => { });
|
|
687
|
+
this.ready = true;
|
|
688
|
+
}
|
|
689
|
+
prompt(text, timeoutMs) {
|
|
690
|
+
return this.request('session/prompt', { sessionId: this.sessionId, prompt: [{ type: 'text', text }] }, timeoutMs);
|
|
691
|
+
}
|
|
692
|
+
interrupt() {
|
|
693
|
+
this.notify('session/cancel', { sessionId: this.sessionId });
|
|
694
|
+
}
|
|
695
|
+
steer(text) {
|
|
696
|
+
if (this.harness === 'goose') {
|
|
697
|
+
if (!this.activeRunId)
|
|
698
|
+
throw new Error('Goose did not advertise an active run for steering.');
|
|
699
|
+
return this.request('_goose/unstable/session/steer', {
|
|
700
|
+
sessionId: this.sessionId,
|
|
701
|
+
expectedRunId: this.activeRunId,
|
|
702
|
+
prompt: [{ type: 'text', text }],
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
return this.prompt(`/steer ${text}`, 12 * 60 * 60_000);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
class RpcTransport extends LineTransport {
|
|
709
|
+
permissionMode;
|
|
710
|
+
responses;
|
|
711
|
+
events;
|
|
712
|
+
constructor(anchor, harness, profile, createSession = false) {
|
|
713
|
+
super(anchor, harness, resolveHarnessCommand(harness, profile), profile.args(anchor, createSession), anchor.cwd);
|
|
714
|
+
this.permissionMode = anchor.permissionMode;
|
|
715
|
+
this.responses = new Map();
|
|
716
|
+
this.events = 0;
|
|
717
|
+
}
|
|
718
|
+
onFrame(frame) {
|
|
719
|
+
if (!frame)
|
|
720
|
+
return;
|
|
721
|
+
if (frame.type === 'response' && frame.id !== undefined) {
|
|
722
|
+
const identity = rpcFrameSessionIdentity(frame);
|
|
723
|
+
if (identity.sessionId &&
|
|
724
|
+
(identity.sessionId !== this.anchor.agentSessionId ||
|
|
725
|
+
(identity.transcriptPath && path.resolve(identity.transcriptPath) !== this.anchor.transcriptPath))) {
|
|
726
|
+
this.anchor.updateIdentity(identity.sessionId, identity.transcriptPath);
|
|
727
|
+
}
|
|
728
|
+
const waiter = this.pending.get(String(frame.id));
|
|
729
|
+
if (waiter) {
|
|
730
|
+
this.pending.delete(String(frame.id));
|
|
731
|
+
frame.success === false || frame.error
|
|
732
|
+
? waiter.reject(new Error(frame.error || `${frame.command || 'RPC'} failed`))
|
|
733
|
+
: waiter.resolve(frame);
|
|
734
|
+
}
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
if (frame.type === 'extension_ui_request') {
|
|
738
|
+
void this.answerUiQuestion(frame);
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
this.events += 1;
|
|
742
|
+
}
|
|
743
|
+
async answerUiQuestion(frame) {
|
|
744
|
+
if (rpcUiRequestIsInformational(frame)) {
|
|
745
|
+
this.emit({ type: 'extension_ui_response', id: frame.id, ok: true });
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
let response;
|
|
749
|
+
try {
|
|
750
|
+
response = await this.anchor.askInteraction(frame);
|
|
751
|
+
}
|
|
752
|
+
catch {
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
const fields = response && typeof response === 'object' && !Array.isArray(response) ? response : { value: response };
|
|
756
|
+
this.emit({ type: 'extension_ui_response', id: frame.id, ...fields });
|
|
757
|
+
}
|
|
758
|
+
request(type, payload = {}, timeoutMs = 60_000) {
|
|
759
|
+
const id = String(this.nextId++);
|
|
760
|
+
const waiter = deferred();
|
|
761
|
+
this.pending.set(id, waiter);
|
|
762
|
+
this.emit({ type, id, ...payload });
|
|
763
|
+
const timer = setTimeout(() => {
|
|
764
|
+
if (this.pending.delete(id))
|
|
765
|
+
waiter.reject(new Error(`${this.harness} RPC timeout: ${type}`));
|
|
766
|
+
}, timeoutMs);
|
|
767
|
+
timer.unref?.();
|
|
768
|
+
return waiter.promise.finally(() => clearTimeout(timer));
|
|
769
|
+
}
|
|
770
|
+
async prompt(text, timeoutMs) {
|
|
771
|
+
this.events = 0;
|
|
772
|
+
await this.request('prompt', { message: text, streamingBehavior: 'steer' }, 60_000);
|
|
773
|
+
const deadline = Date.now() + timeoutMs;
|
|
774
|
+
while (Date.now() < deadline) {
|
|
775
|
+
await new Promise((resolve) => setTimeout(resolve, 1_000));
|
|
776
|
+
if (this.events === 0 || Date.now() - this.lastEventAt < 2_500)
|
|
777
|
+
continue;
|
|
778
|
+
const state = await this.request('get_state', {}, 20_000).catch(() => null);
|
|
779
|
+
const data = state?.data || state?.state || {};
|
|
780
|
+
const running = [
|
|
781
|
+
data.running,
|
|
782
|
+
data.isRunning,
|
|
783
|
+
data.busy,
|
|
784
|
+
data.isBusy,
|
|
785
|
+
data.active,
|
|
786
|
+
data.isStreaming,
|
|
787
|
+
data.isCompacting,
|
|
788
|
+
data.turnActive,
|
|
789
|
+
].some(Boolean) ||
|
|
790
|
+
(typeof data.status === 'string' && !/idle|ready|done|needs_input/i.test(data.status));
|
|
791
|
+
if (!running)
|
|
792
|
+
return { stopReason: 'completed' };
|
|
793
|
+
}
|
|
794
|
+
throw new Error(`${this.harness} turn timed out.`);
|
|
795
|
+
}
|
|
796
|
+
interrupt() {
|
|
797
|
+
return this.request('abort', {}, 30_000);
|
|
798
|
+
}
|
|
799
|
+
steer(text) {
|
|
800
|
+
return this.request('steer', { message: text }, 30_000);
|
|
801
|
+
}
|
|
802
|
+
compact(timeoutMs) {
|
|
803
|
+
return this.request('compact', { customInstructions: 'Keep the automated phase markers.' }, timeoutMs);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
class OmpRpcTransport extends RpcTransport {
|
|
807
|
+
readyWaiter;
|
|
808
|
+
openTurns;
|
|
809
|
+
eventsSincePrompt;
|
|
810
|
+
constructor(anchor, profile, createSession = false) {
|
|
811
|
+
super(anchor, 'omp', { ...profile, args: (value) => profile.args(value, createSession) });
|
|
812
|
+
this.readyWaiter = deferred();
|
|
813
|
+
this.openTurns = 0;
|
|
814
|
+
this.eventsSincePrompt = 0;
|
|
815
|
+
}
|
|
816
|
+
onFrame(frame) {
|
|
817
|
+
if (!frame || typeof frame !== 'object')
|
|
818
|
+
return;
|
|
819
|
+
if (frame.type === 'ready') {
|
|
820
|
+
this.readyWaiter?.resolve(frame);
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
if (frame.type === 'turn_start') {
|
|
824
|
+
this.openTurns += 1;
|
|
825
|
+
this.eventsSincePrompt += 1;
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
828
|
+
if (frame.type === 'turn_end') {
|
|
829
|
+
this.openTurns = Math.max(0, this.openTurns - 1);
|
|
830
|
+
this.eventsSincePrompt += 1;
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
if (frame.type === 'message_update' ||
|
|
834
|
+
frame.type === 'tool_execution_update' ||
|
|
835
|
+
frame.type === 'host_tool_call' ||
|
|
836
|
+
String(frame.type || '').includes('subagent'))
|
|
837
|
+
this.eventsSincePrompt += 1;
|
|
838
|
+
if (frame.type === 'host_tool_call') {
|
|
839
|
+
if (String(frame.name ?? frame.toolName ?? '') === 'turns_ask_user_question')
|
|
840
|
+
void this.answerHostQuestion(frame);
|
|
841
|
+
else
|
|
842
|
+
this.answerHostEcho(frame);
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
super.onFrame(frame);
|
|
846
|
+
}
|
|
847
|
+
async answerHostQuestion(frame) {
|
|
848
|
+
let result;
|
|
849
|
+
try {
|
|
850
|
+
result = await this.anchor.askInteraction(frame);
|
|
851
|
+
}
|
|
852
|
+
catch {
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
this.emit({
|
|
856
|
+
type: 'host_tool_result',
|
|
857
|
+
id: frame.id,
|
|
858
|
+
result,
|
|
859
|
+
isError: false,
|
|
860
|
+
});
|
|
861
|
+
}
|
|
862
|
+
answerHostEcho(frame) {
|
|
863
|
+
const args = frame.arguments || frame.parameters || frame.input || {};
|
|
864
|
+
this.emit({
|
|
865
|
+
type: 'host_tool_result',
|
|
866
|
+
id: frame.id,
|
|
867
|
+
result: { content: [{ type: 'text', text: String(args.text || '') }], details: {} },
|
|
868
|
+
isError: false,
|
|
869
|
+
});
|
|
870
|
+
}
|
|
871
|
+
async open() {
|
|
872
|
+
const timer = setTimeout(() => this.readyWaiter.reject(new Error('OMP did not emit a ready frame within 60s.')), 60_000);
|
|
873
|
+
timer.unref?.();
|
|
874
|
+
try {
|
|
875
|
+
await this.readyWaiter.promise;
|
|
876
|
+
}
|
|
877
|
+
finally {
|
|
878
|
+
clearTimeout(timer);
|
|
879
|
+
}
|
|
880
|
+
await this.request('get_state', {}, 30_000).catch(() => null);
|
|
881
|
+
await this.request('set_host_tools', {
|
|
882
|
+
tools: [
|
|
883
|
+
{
|
|
884
|
+
name: 'turns_echo',
|
|
885
|
+
label: 'Turns Echo',
|
|
886
|
+
description: 'Echo text back to the agent for end-to-end tool verification.',
|
|
887
|
+
parameters: {
|
|
888
|
+
type: 'object',
|
|
889
|
+
properties: { text: { type: 'string' } },
|
|
890
|
+
required: ['text'],
|
|
891
|
+
},
|
|
892
|
+
},
|
|
893
|
+
{
|
|
894
|
+
name: 'turns_ask_user_question',
|
|
895
|
+
label: 'Ask Turns Viewer',
|
|
896
|
+
description: 'Ask the viewer a multiple-choice question and return the answer.',
|
|
897
|
+
parameters: {
|
|
898
|
+
type: 'object',
|
|
899
|
+
properties: {
|
|
900
|
+
question: { type: 'string' },
|
|
901
|
+
header: { type: 'string' },
|
|
902
|
+
options: { type: 'array', items: { type: 'object' } },
|
|
903
|
+
},
|
|
904
|
+
required: ['question'],
|
|
905
|
+
},
|
|
906
|
+
},
|
|
907
|
+
],
|
|
908
|
+
}, 30_000);
|
|
909
|
+
await this.request('set_subagent_subscription', { level: 'events' }, 20_000).catch(() => null);
|
|
910
|
+
}
|
|
911
|
+
async prompt(text, timeoutMs) {
|
|
912
|
+
this.openTurns = 0;
|
|
913
|
+
this.eventsSincePrompt = 0;
|
|
914
|
+
await this.request('prompt', { message: text }, 60_000);
|
|
915
|
+
const startedAt = Date.now();
|
|
916
|
+
const deadline = startedAt + timeoutMs;
|
|
917
|
+
this.lastEventAt = Date.now();
|
|
918
|
+
while (Date.now() < deadline) {
|
|
919
|
+
await new Promise((resolve) => setTimeout(resolve, 2_000));
|
|
920
|
+
if (Date.now() - this.lastEventAt < 6_000 || this.openTurns > 0)
|
|
921
|
+
continue;
|
|
922
|
+
if (this.eventsSincePrompt === 0 && Date.now() - startedAt < 45_000)
|
|
923
|
+
continue;
|
|
924
|
+
const state = await this.request('get_state', {}, 20_000).catch(() => null);
|
|
925
|
+
const data = state?.data || state?.state || {};
|
|
926
|
+
const running = [data.running, data.isRunning, data.busy, data.isBusy, data.active, data.turnActive].some(Boolean) ||
|
|
927
|
+
(typeof (data.status || data.agentStatus || data.state) === 'string' &&
|
|
928
|
+
!/idle|ready|done/i.test(data.status || data.agentStatus || data.state));
|
|
929
|
+
if (!running)
|
|
930
|
+
return { stopReason: 'completed' };
|
|
931
|
+
}
|
|
932
|
+
throw new Error('OMP turn timed out.');
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
class DeepseekTransport {
|
|
936
|
+
anchor;
|
|
937
|
+
sessionId;
|
|
938
|
+
waiter;
|
|
939
|
+
socket;
|
|
940
|
+
base;
|
|
941
|
+
constructor(anchor) {
|
|
942
|
+
this.anchor = anchor;
|
|
943
|
+
this.sessionId = anchor.agentSessionId;
|
|
944
|
+
this.waiter = null;
|
|
945
|
+
this.socket = null;
|
|
946
|
+
}
|
|
947
|
+
async open(createSession = false) {
|
|
948
|
+
const base = String(process.env.TURNS_DEEPSEEK_URL || 'http://127.0.0.1:3080').replace(/\/$/, '');
|
|
949
|
+
this.base = base;
|
|
950
|
+
this.socket = new WebSocket(`${base.replace(/^http/, 'ws')}/api/events.mux`);
|
|
951
|
+
this.socket.on('message', (bytes) => this.receive(String(bytes)));
|
|
952
|
+
await new Promise((resolve, reject) => {
|
|
953
|
+
this.socket.once('open', resolve);
|
|
954
|
+
this.socket.once('error', reject);
|
|
955
|
+
});
|
|
956
|
+
if (createSession) {
|
|
957
|
+
const opened = await this.rpc('session.create', { cwd: this.anchor.cwd });
|
|
958
|
+
const sessionId = String(opened?.sessionId || '');
|
|
959
|
+
if (!sessionId)
|
|
960
|
+
throw new Error('DeepSeek session.create returned no session id.');
|
|
961
|
+
this.sessionId = sessionId;
|
|
962
|
+
this.anchor.updateIdentity(sessionId);
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
receive(raw) {
|
|
966
|
+
let frame;
|
|
967
|
+
try {
|
|
968
|
+
frame = JSON.parse(raw);
|
|
969
|
+
}
|
|
970
|
+
catch {
|
|
971
|
+
return;
|
|
972
|
+
}
|
|
973
|
+
if (String(frame?.payload?.sessionId || '') !== this.sessionId)
|
|
974
|
+
return;
|
|
975
|
+
this.anchor.emitLive({
|
|
976
|
+
direction: 'in',
|
|
977
|
+
source: 'deepseek.websocket',
|
|
978
|
+
raw,
|
|
979
|
+
json: frame,
|
|
980
|
+
phase: 'queued',
|
|
981
|
+
method: frame.method,
|
|
982
|
+
});
|
|
983
|
+
if (frame.method === 'question/requested') {
|
|
984
|
+
void this.answerQuestion(frame);
|
|
985
|
+
return;
|
|
986
|
+
}
|
|
987
|
+
// DSH emits the underlying ask_user_question tool call as a normal
|
|
988
|
+
// session/event before its authoritative question/requested frame. Treating
|
|
989
|
+
// both as interactions creates two different local IDs and leaves the
|
|
990
|
+
// tool-call-shaped copy waiting forever after the real question is answered.
|
|
991
|
+
if (frame.method === 'session/event' && frame.payload?.event?.type === 'turn/end' && this.waiter) {
|
|
992
|
+
const waiter = this.waiter;
|
|
993
|
+
this.waiter = null;
|
|
994
|
+
waiter.resolve({ stopReason: frame.payload.event.data?.reason?.kind || 'completed' });
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
async answerQuestion(frame) {
|
|
998
|
+
let result;
|
|
999
|
+
try {
|
|
1000
|
+
result = await this.anchor.askInteraction(frame);
|
|
1001
|
+
}
|
|
1002
|
+
catch {
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
1005
|
+
const body = { type: 'client-response', rpcId: frame.rpcId, result };
|
|
1006
|
+
this.anchor.emitLive({
|
|
1007
|
+
direction: 'out',
|
|
1008
|
+
source: 'deepseek.http',
|
|
1009
|
+
raw: JSON.stringify(body),
|
|
1010
|
+
json: body,
|
|
1011
|
+
phase: 'queued',
|
|
1012
|
+
method: 'respond',
|
|
1013
|
+
});
|
|
1014
|
+
await fetch(`${this.base}/api/respond`, {
|
|
1015
|
+
method: 'POST',
|
|
1016
|
+
headers: { 'content-type': 'application/json' },
|
|
1017
|
+
body: JSON.stringify(body),
|
|
1018
|
+
});
|
|
1019
|
+
}
|
|
1020
|
+
async rpc(method, payload) {
|
|
1021
|
+
const value = { type: 'client-request', rpcId: crypto.randomUUID(), method, payload };
|
|
1022
|
+
this.anchor.emitLive({
|
|
1023
|
+
direction: 'out',
|
|
1024
|
+
source: 'deepseek.http',
|
|
1025
|
+
raw: JSON.stringify(value),
|
|
1026
|
+
json: value,
|
|
1027
|
+
phase: 'queued',
|
|
1028
|
+
method,
|
|
1029
|
+
});
|
|
1030
|
+
const response = await fetch(`${this.base}/api/${method}`, {
|
|
1031
|
+
method: 'POST',
|
|
1032
|
+
headers: { 'content-type': 'application/json' },
|
|
1033
|
+
body: JSON.stringify(value),
|
|
1034
|
+
});
|
|
1035
|
+
const json = (await response.json());
|
|
1036
|
+
if (!response.ok || !json?.result?.ok)
|
|
1037
|
+
throw new Error(`${method}: ${json?.result?.error?.message || response.status}`);
|
|
1038
|
+
return json.result.value;
|
|
1039
|
+
}
|
|
1040
|
+
async prompt(text, timeoutMs) {
|
|
1041
|
+
const waiter = deferred();
|
|
1042
|
+
this.waiter = waiter;
|
|
1043
|
+
await this.rpc('session.prompt', { sessionId: this.sessionId, mode: 'queue', content: [{ type: 'text', text }] });
|
|
1044
|
+
const timer = setTimeout(() => waiter.reject(new Error('DeepSeek turn timed out.')), timeoutMs);
|
|
1045
|
+
timer.unref?.();
|
|
1046
|
+
return waiter.promise.finally(() => clearTimeout(timer));
|
|
1047
|
+
}
|
|
1048
|
+
interrupt() {
|
|
1049
|
+
return this.rpc('session.cancel', { sessionId: this.sessionId });
|
|
1050
|
+
}
|
|
1051
|
+
steer(text) {
|
|
1052
|
+
return this.rpc('session.prompt', { sessionId: this.sessionId, mode: 'steer', content: [{ type: 'text', text }] });
|
|
1053
|
+
}
|
|
1054
|
+
stop() {
|
|
1055
|
+
this.socket?.close();
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
/**
|
|
1059
|
+
* Whichever transport the anchor's harness needs. They share the frame plumbing,
|
|
1060
|
+
* but not their extras: only some can steer a running turn, only some need an
|
|
1061
|
+
* explicit open handshake, and DeepseekTransport talks over a socket instead of a
|
|
1062
|
+
* child process. The optional members are the ones guarded at runtime by
|
|
1063
|
+
* `instanceof` checks that the checker cannot correlate with a later access.
|
|
1064
|
+
* @typedef {(LineTransport | DeepseekTransport) &
|
|
1065
|
+
* Partial<{
|
|
1066
|
+
* harness: string;
|
|
1067
|
+
* child: import('node:child_process').ChildProcessWithoutNullStreams;
|
|
1068
|
+
* ready: boolean;
|
|
1069
|
+
* sessionId: string;
|
|
1070
|
+
* permissionMode: string;
|
|
1071
|
+
* steer: (text: string) => any;
|
|
1072
|
+
* open: (createSession?: any) => Promise<void>;
|
|
1073
|
+
* interrupt: () => any;
|
|
1074
|
+
* compact: (timeoutMs: number) => Promise<any>;
|
|
1075
|
+
* prompt: (text: string, timeoutMs?: number) => Promise<any>;
|
|
1076
|
+
* stop: () => void;
|
|
1077
|
+
* }>} AnchorTransport
|
|
1078
|
+
*/
|
|
1079
|
+
class Anchor {
|
|
1080
|
+
clients;
|
|
1081
|
+
status;
|
|
1082
|
+
source;
|
|
1083
|
+
sessionId;
|
|
1084
|
+
agentSessionId;
|
|
1085
|
+
cwd;
|
|
1086
|
+
transcriptPath;
|
|
1087
|
+
workerId;
|
|
1088
|
+
model;
|
|
1089
|
+
thinkingLevel;
|
|
1090
|
+
permissionMode;
|
|
1091
|
+
codexHistoryMode;
|
|
1092
|
+
currentTurn;
|
|
1093
|
+
transport;
|
|
1094
|
+
child;
|
|
1095
|
+
events;
|
|
1096
|
+
eventBytes;
|
|
1097
|
+
sequence;
|
|
1098
|
+
pendingInteractions;
|
|
1099
|
+
startedAt;
|
|
1100
|
+
lastActivityAt;
|
|
1101
|
+
idleTimeoutMs;
|
|
1102
|
+
turnTimeoutMs;
|
|
1103
|
+
idleTimer;
|
|
1104
|
+
constructor() {
|
|
1105
|
+
this.clients = new Set();
|
|
1106
|
+
this.status = 'idle';
|
|
1107
|
+
this.source = '';
|
|
1108
|
+
this.sessionId = '';
|
|
1109
|
+
this.agentSessionId = '';
|
|
1110
|
+
this.cwd = '';
|
|
1111
|
+
this.transcriptPath = '';
|
|
1112
|
+
this.workerId = '';
|
|
1113
|
+
this.model = '';
|
|
1114
|
+
this.thinkingLevel = '';
|
|
1115
|
+
this.permissionMode = 'bypassPermissions';
|
|
1116
|
+
this.codexHistoryMode = '';
|
|
1117
|
+
this.currentTurn = null;
|
|
1118
|
+
/** @type {AnchorTransport | null} */
|
|
1119
|
+
this.transport = null;
|
|
1120
|
+
this.child = null;
|
|
1121
|
+
this.events = [];
|
|
1122
|
+
this.eventBytes = 0;
|
|
1123
|
+
this.sequence = 0;
|
|
1124
|
+
this.pendingInteractions = new Map();
|
|
1125
|
+
this.startedAt = now();
|
|
1126
|
+
this.lastActivityAt = this.startedAt;
|
|
1127
|
+
this.idleTimeoutMs = 300_000;
|
|
1128
|
+
this.turnTimeoutMs = 12 * 60 * 60_000;
|
|
1129
|
+
this.idleTimer = null;
|
|
1130
|
+
}
|
|
1131
|
+
snapshot() {
|
|
1132
|
+
return {
|
|
1133
|
+
protocol: 'turns.agent-anchor.v1',
|
|
1134
|
+
owner: 'turns.dev',
|
|
1135
|
+
capabilities: [
|
|
1136
|
+
'interrupt',
|
|
1137
|
+
'shutdown',
|
|
1138
|
+
'answer_interaction',
|
|
1139
|
+
...(this.transport?.steer ? ['steer'] : []),
|
|
1140
|
+
...(this.transport?.compact ? ['compact'] : []),
|
|
1141
|
+
],
|
|
1142
|
+
pid: process.pid,
|
|
1143
|
+
socketPath,
|
|
1144
|
+
source: this.source,
|
|
1145
|
+
sessionId: this.sessionId,
|
|
1146
|
+
agentSessionId: this.agentSessionId,
|
|
1147
|
+
cwd: this.cwd,
|
|
1148
|
+
transcriptPath: this.transcriptPath,
|
|
1149
|
+
workerId: this.workerId,
|
|
1150
|
+
model: this.model,
|
|
1151
|
+
thinkingLevel: this.thinkingLevel,
|
|
1152
|
+
permissionMode: this.permissionMode,
|
|
1153
|
+
codexHistoryMode: this.codexHistoryMode,
|
|
1154
|
+
status: this.status,
|
|
1155
|
+
startedAt: this.startedAt,
|
|
1156
|
+
lastActivityAt: this.lastActivityAt,
|
|
1157
|
+
currentTurn: this.currentTurn ? { ...this.currentTurn } : null,
|
|
1158
|
+
pendingInteractions: [...this.pendingInteractions.values()].map((item) => item.interaction),
|
|
1159
|
+
lastEventSequence: this.sequence,
|
|
1160
|
+
liveEvents: this.events.map((event) => ({ ...event })),
|
|
1161
|
+
};
|
|
1162
|
+
}
|
|
1163
|
+
broadcast(value) {
|
|
1164
|
+
for (const client of this.clients) {
|
|
1165
|
+
if (client.destroyed || !client.writable)
|
|
1166
|
+
this.clients.delete(client);
|
|
1167
|
+
else
|
|
1168
|
+
writeLine(client, value);
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
setStatus(status) {
|
|
1172
|
+
this.status = status;
|
|
1173
|
+
this.lastActivityAt = now();
|
|
1174
|
+
this.broadcast({
|
|
1175
|
+
type: 'session_status',
|
|
1176
|
+
sessionId: this.sessionId,
|
|
1177
|
+
agentSessionId: this.agentSessionId,
|
|
1178
|
+
messageId: this.currentTurn?.messageId || '',
|
|
1179
|
+
status,
|
|
1180
|
+
});
|
|
1181
|
+
}
|
|
1182
|
+
emitLive(event) {
|
|
1183
|
+
const entry = { capturedAt: now(), ...event, anchorEventSequence: ++this.sequence };
|
|
1184
|
+
const bytes = Buffer.byteLength(JSON.stringify(entry));
|
|
1185
|
+
this.events.push(entry);
|
|
1186
|
+
this.eventBytes += bytes;
|
|
1187
|
+
while (this.events.length > MAX_EVENTS || this.eventBytes > MAX_EVENT_BYTES)
|
|
1188
|
+
this.eventBytes -= Buffer.byteLength(JSON.stringify(this.events.shift()));
|
|
1189
|
+
this.broadcast({
|
|
1190
|
+
type: 'live_event',
|
|
1191
|
+
sessionId: this.sessionId,
|
|
1192
|
+
agentSessionId: this.agentSessionId,
|
|
1193
|
+
messageId: this.currentTurn?.messageId || '',
|
|
1194
|
+
event: entry,
|
|
1195
|
+
});
|
|
1196
|
+
}
|
|
1197
|
+
updateIdentity(agentSessionId, transcriptPath = '') {
|
|
1198
|
+
this.agentSessionId = agentSessionId;
|
|
1199
|
+
if (transcriptPath)
|
|
1200
|
+
this.transcriptPath = path.resolve(transcriptPath);
|
|
1201
|
+
this.broadcast({
|
|
1202
|
+
type: 'session_identity',
|
|
1203
|
+
sessionId: this.sessionId,
|
|
1204
|
+
agentSessionId,
|
|
1205
|
+
transcriptPath: this.transcriptPath,
|
|
1206
|
+
messageId: this.currentTurn?.messageId || '',
|
|
1207
|
+
});
|
|
1208
|
+
}
|
|
1209
|
+
askInteraction(request, options = {}) {
|
|
1210
|
+
const interaction = {
|
|
1211
|
+
id: crypto.randomUUID(),
|
|
1212
|
+
harness: this.source,
|
|
1213
|
+
request,
|
|
1214
|
+
};
|
|
1215
|
+
const waiter = deferred();
|
|
1216
|
+
clearTimeout(this.idleTimer);
|
|
1217
|
+
this.pendingInteractions.set(interaction.id, { interaction, waiter, onExpire: options.onExpire });
|
|
1218
|
+
this.emitLive({ event: 'user.interaction', agent: this.source, phase: 'queued', interaction });
|
|
1219
|
+
return waiter.promise;
|
|
1220
|
+
}
|
|
1221
|
+
answerInteraction(interactionId, response) {
|
|
1222
|
+
const pending = this.pendingInteractions.get(interactionId);
|
|
1223
|
+
if (!pending)
|
|
1224
|
+
throw new Error('The interaction is no longer waiting in this harness.');
|
|
1225
|
+
this.pendingInteractions.delete(interactionId);
|
|
1226
|
+
this.emitLive({
|
|
1227
|
+
event: 'user.interaction_answer',
|
|
1228
|
+
agent: this.source,
|
|
1229
|
+
phase: 'queued',
|
|
1230
|
+
interaction: pending.interaction,
|
|
1231
|
+
response,
|
|
1232
|
+
});
|
|
1233
|
+
pending.waiter.resolve(response);
|
|
1234
|
+
if (this.status === 'idle')
|
|
1235
|
+
this.scheduleExit();
|
|
1236
|
+
return { answered: true, interactionId };
|
|
1237
|
+
}
|
|
1238
|
+
expirePendingInteractions(reason) {
|
|
1239
|
+
const error = new Error(reason);
|
|
1240
|
+
for (const [interactionId, pending] of this.pendingInteractions) {
|
|
1241
|
+
this.pendingInteractions.delete(interactionId);
|
|
1242
|
+
this.emitLive({
|
|
1243
|
+
event: 'user.interaction_expired',
|
|
1244
|
+
agent: this.source,
|
|
1245
|
+
phase: 'queued',
|
|
1246
|
+
interaction: pending.interaction,
|
|
1247
|
+
reason,
|
|
1248
|
+
});
|
|
1249
|
+
try {
|
|
1250
|
+
pending.onExpire?.();
|
|
1251
|
+
}
|
|
1252
|
+
catch { }
|
|
1253
|
+
pending.waiter.reject(error);
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
scheduleExit() {
|
|
1257
|
+
clearTimeout(this.idleTimer);
|
|
1258
|
+
if (this.pendingInteractions.size) {
|
|
1259
|
+
this.idleTimer = null;
|
|
1260
|
+
return;
|
|
1261
|
+
}
|
|
1262
|
+
this.idleTimer = setTimeout(() => this.close(), this.idleTimeoutMs);
|
|
1263
|
+
this.idleTimer.unref?.();
|
|
1264
|
+
}
|
|
1265
|
+
async start(request) {
|
|
1266
|
+
const messageId = String(request.messageId || '');
|
|
1267
|
+
const claimToken = String(request.claimToken || '');
|
|
1268
|
+
const sameMessage = this.currentTurn?.messageId === messageId;
|
|
1269
|
+
const sameClaim = sameMessage && this.currentTurn?.claimToken === claimToken;
|
|
1270
|
+
// send_message crosses a local socket after the queue claim crosses a
|
|
1271
|
+
// WebSocket, so either acknowledgement can be lost after the agent has
|
|
1272
|
+
// already accepted the turn. A replacement claim for the same prompt must
|
|
1273
|
+
// adopt a running/successful turn instead of executing it again. Failed or
|
|
1274
|
+
// interrupted turns remain retryable when the server issues a new claim.
|
|
1275
|
+
if (sameClaim || (sameMessage && ['running', 'completed'].includes(this.currentTurn?.status))) {
|
|
1276
|
+
if (claimToken && claimToken !== this.currentTurn.claimToken) {
|
|
1277
|
+
this.currentTurn = { ...this.currentTurn, claimToken };
|
|
1278
|
+
}
|
|
1279
|
+
return {
|
|
1280
|
+
accepted: true,
|
|
1281
|
+
messageId,
|
|
1282
|
+
pid: process.pid,
|
|
1283
|
+
adopted: true,
|
|
1284
|
+
status: this.currentTurn.status,
|
|
1285
|
+
};
|
|
1286
|
+
}
|
|
1287
|
+
if (this.status === 'running')
|
|
1288
|
+
throw new Error('The anchored agent is already running a turn.');
|
|
1289
|
+
if (this.pendingInteractions.size)
|
|
1290
|
+
throw new Error('The anchored agent is awaiting user input.');
|
|
1291
|
+
for (const key of ['source', 'sessionId', 'agentSessionId', 'messageId'])
|
|
1292
|
+
if (!request[key])
|
|
1293
|
+
throw new Error(`${key} is required.`);
|
|
1294
|
+
const codexHistoryMode = String(request.codexHistoryMode || '');
|
|
1295
|
+
if (codexHistoryMode && (request.source !== 'codex' || codexHistoryMode !== 'paginated'))
|
|
1296
|
+
throw new Error('Paginated history mode is supported only by Codex.');
|
|
1297
|
+
clearTimeout(this.idleTimer);
|
|
1298
|
+
Object.assign(this, {
|
|
1299
|
+
source: String(request.source),
|
|
1300
|
+
sessionId: String(request.sessionId),
|
|
1301
|
+
agentSessionId: String(request.agentSessionId),
|
|
1302
|
+
cwd: path.resolve(request.cwd),
|
|
1303
|
+
transcriptPath: path.resolve(request.transcriptPath),
|
|
1304
|
+
workerId: String(request.workerId || ''),
|
|
1305
|
+
model: String(request.model || ''),
|
|
1306
|
+
thinkingLevel: String(request.thinkingLevel || ''),
|
|
1307
|
+
permissionMode: requestedPermissionMode(request.permissionMode),
|
|
1308
|
+
codexHistoryMode,
|
|
1309
|
+
idleTimeoutMs: Math.max(1_000, Number(request.idleTimeoutMs) || 300_000),
|
|
1310
|
+
turnTimeoutMs: Math.max(60_000, Number(request.timeoutMs) || 12 * 60 * 60_000),
|
|
1311
|
+
});
|
|
1312
|
+
this.events = [];
|
|
1313
|
+
this.eventBytes = 0;
|
|
1314
|
+
this.sequence = 0;
|
|
1315
|
+
this.currentTurn = {
|
|
1316
|
+
messageId,
|
|
1317
|
+
claimToken,
|
|
1318
|
+
prompt: String(request.prompt || ''),
|
|
1319
|
+
expectedText: String(request.expectedText || request.prompt || ''),
|
|
1320
|
+
status: 'running',
|
|
1321
|
+
accepted: true,
|
|
1322
|
+
startedAt: now(),
|
|
1323
|
+
completedAt: null,
|
|
1324
|
+
result: null,
|
|
1325
|
+
};
|
|
1326
|
+
this.setStatus('running');
|
|
1327
|
+
void this.run(Boolean(request.createSession), this.turnTimeoutMs).then((result) => this.finish(result), (error) => this.finish({ exitCode: 1, signal: null, timedOut: false, error: { message: errorText(error) } }));
|
|
1328
|
+
return { accepted: true, messageId: this.currentTurn.messageId, pid: process.pid };
|
|
1329
|
+
}
|
|
1330
|
+
async run(createSession, timeoutMs) {
|
|
1331
|
+
this.emitLive({
|
|
1332
|
+
event: 'turn.start',
|
|
1333
|
+
agent: this.source,
|
|
1334
|
+
phase: 'queued',
|
|
1335
|
+
messageId: this.currentTurn.messageId,
|
|
1336
|
+
displayPrompt: this.currentTurn.expectedText,
|
|
1337
|
+
prompt: this.currentTurn.prompt,
|
|
1338
|
+
});
|
|
1339
|
+
const interruptedBeforePrompt = () => this.currentTurn?.interruptRequestedAt
|
|
1340
|
+
? { exitCode: 0, signal: null, timedOut: false, error: null, interrupted: true }
|
|
1341
|
+
: null;
|
|
1342
|
+
if (interruptedBeforePrompt())
|
|
1343
|
+
return interruptedBeforePrompt();
|
|
1344
|
+
if (this.source === 'claude') {
|
|
1345
|
+
if (!(this.transport instanceof ClaudeTransport) ||
|
|
1346
|
+
this.transport.child.exitCode !== null ||
|
|
1347
|
+
this.transport.sessionId !== this.agentSessionId ||
|
|
1348
|
+
this.transport.permissionMode !== this.permissionMode) {
|
|
1349
|
+
this.transport?.stop?.();
|
|
1350
|
+
this.transport = new ClaudeTransport(this, createSession);
|
|
1351
|
+
await this.transport.open();
|
|
1352
|
+
}
|
|
1353
|
+
if (interruptedBeforePrompt())
|
|
1354
|
+
return interruptedBeforePrompt();
|
|
1355
|
+
const result = await this.transport.prompt(this.currentTurn.prompt, timeoutMs);
|
|
1356
|
+
return { exitCode: 0, signal: null, timedOut: false, error: null, stopReason: result?.subtype || 'completed' };
|
|
1357
|
+
}
|
|
1358
|
+
if (this.source === 'codex') {
|
|
1359
|
+
if (!(this.transport instanceof CodexAppServerTransport) ||
|
|
1360
|
+
this.transport.child.exitCode !== null ||
|
|
1361
|
+
this.transport.threadId !== this.agentSessionId ||
|
|
1362
|
+
this.transport.permissionMode !== this.permissionMode) {
|
|
1363
|
+
this.transport?.stop?.();
|
|
1364
|
+
this.transport = new CodexAppServerTransport(this);
|
|
1365
|
+
await this.transport.open(createSession);
|
|
1366
|
+
}
|
|
1367
|
+
if (interruptedBeforePrompt())
|
|
1368
|
+
return interruptedBeforePrompt();
|
|
1369
|
+
const result = await this.transport.prompt(this.currentTurn.prompt, timeoutMs);
|
|
1370
|
+
return {
|
|
1371
|
+
exitCode: 0,
|
|
1372
|
+
signal: null,
|
|
1373
|
+
timedOut: false,
|
|
1374
|
+
error: null,
|
|
1375
|
+
stopReason: result?.method || 'turn/completed',
|
|
1376
|
+
};
|
|
1377
|
+
}
|
|
1378
|
+
if (PROCESS_HARNESSES.has(this.source)) {
|
|
1379
|
+
const invocation = processInvocation({
|
|
1380
|
+
harness: this.source,
|
|
1381
|
+
sessionId: this.agentSessionId,
|
|
1382
|
+
transcriptPath: this.transcriptPath,
|
|
1383
|
+
cwd: this.cwd,
|
|
1384
|
+
prompt: this.currentTurn.prompt,
|
|
1385
|
+
createSession,
|
|
1386
|
+
model: this.model,
|
|
1387
|
+
thinkingLevel: this.thinkingLevel,
|
|
1388
|
+
timeoutMs,
|
|
1389
|
+
});
|
|
1390
|
+
const child = spawn(invocation.command, invocation.args, {
|
|
1391
|
+
cwd: this.cwd,
|
|
1392
|
+
env: {
|
|
1393
|
+
...process.env,
|
|
1394
|
+
TURNS_QUEUE_WORKER_ID: this.workerId,
|
|
1395
|
+
TURNS_QUEUE_SESSION_ID: this.sessionId,
|
|
1396
|
+
TURNS_TRANSCRIPT_PATH: this.transcriptPath,
|
|
1397
|
+
},
|
|
1398
|
+
stdio: [invocation.stdin ? 'pipe' : 'ignore', 'pipe', 'pipe'],
|
|
1399
|
+
detached: process.platform !== 'win32',
|
|
1400
|
+
});
|
|
1401
|
+
this.child = child;
|
|
1402
|
+
if (invocation.stdin)
|
|
1403
|
+
child.stdin.end(`${invocation.stdin}\n`);
|
|
1404
|
+
for (const [stream, source] of [
|
|
1405
|
+
[child.stdout, `${this.source}.stdout`],
|
|
1406
|
+
[child.stderr, `${this.source}.stderr`],
|
|
1407
|
+
])
|
|
1408
|
+
readline.createInterface({ input: stream }).on('line', (line) => {
|
|
1409
|
+
let json;
|
|
1410
|
+
try {
|
|
1411
|
+
json = JSON.parse(line);
|
|
1412
|
+
}
|
|
1413
|
+
catch { }
|
|
1414
|
+
const processSessionId = processEventSessionId(this.source, json);
|
|
1415
|
+
if (processSessionId && processSessionId !== this.agentSessionId)
|
|
1416
|
+
this.updateIdentity(processSessionId);
|
|
1417
|
+
this.emitLive({ event: 'process.line', source, raw: line, json, phase: 'queued' });
|
|
1418
|
+
});
|
|
1419
|
+
return childResult(child, timeoutMs);
|
|
1420
|
+
}
|
|
1421
|
+
if (ACP_PROFILES[this.source]) {
|
|
1422
|
+
if (!(this.transport instanceof AcpTransport) ||
|
|
1423
|
+
this.transport.harness !== this.source ||
|
|
1424
|
+
!this.transport.child ||
|
|
1425
|
+
this.transport.child.exitCode !== null ||
|
|
1426
|
+
!this.transport.ready ||
|
|
1427
|
+
this.transport.sessionId !== this.agentSessionId ||
|
|
1428
|
+
this.transport.permissionMode !== this.permissionMode) {
|
|
1429
|
+
this.transport?.stop?.();
|
|
1430
|
+
this.transport = new AcpTransport(this, this.source, ACP_PROFILES[this.source]);
|
|
1431
|
+
await this.transport.open(createSession);
|
|
1432
|
+
}
|
|
1433
|
+
if (interruptedBeforePrompt())
|
|
1434
|
+
return interruptedBeforePrompt();
|
|
1435
|
+
const result = await this.transport.prompt(this.currentTurn.prompt, timeoutMs);
|
|
1436
|
+
return { exitCode: 0, signal: null, timedOut: false, error: null, stopReason: result?.stopReason };
|
|
1437
|
+
}
|
|
1438
|
+
if (RPC_PROFILES[this.source]) {
|
|
1439
|
+
const specializedOmp = this.source === 'omp';
|
|
1440
|
+
const valid = specializedOmp
|
|
1441
|
+
? this.transport instanceof OmpRpcTransport
|
|
1442
|
+
: this.transport instanceof RpcTransport && !(this.transport instanceof OmpRpcTransport);
|
|
1443
|
+
if (!valid || this.transport.harness !== this.source || this.transport.child.exitCode !== null) {
|
|
1444
|
+
this.transport?.stop?.();
|
|
1445
|
+
this.transport = specializedOmp
|
|
1446
|
+
? new OmpRpcTransport(this, RPC_PROFILES.omp, createSession)
|
|
1447
|
+
: new RpcTransport(this, this.source, RPC_PROFILES[this.source], createSession);
|
|
1448
|
+
if (specializedOmp)
|
|
1449
|
+
await this.transport.open();
|
|
1450
|
+
}
|
|
1451
|
+
if (interruptedBeforePrompt())
|
|
1452
|
+
return interruptedBeforePrompt();
|
|
1453
|
+
await this.transport.prompt(this.currentTurn.prompt, timeoutMs);
|
|
1454
|
+
return { exitCode: 0, signal: null, timedOut: false, error: null };
|
|
1455
|
+
}
|
|
1456
|
+
if (this.source === 'deepseek') {
|
|
1457
|
+
if (!(this.transport instanceof DeepseekTransport)) {
|
|
1458
|
+
this.transport = new DeepseekTransport(this);
|
|
1459
|
+
await this.transport.open(createSession);
|
|
1460
|
+
}
|
|
1461
|
+
if (interruptedBeforePrompt())
|
|
1462
|
+
return interruptedBeforePrompt();
|
|
1463
|
+
await this.transport.prompt(this.currentTurn.prompt, timeoutMs);
|
|
1464
|
+
return { exitCode: 0, signal: null, timedOut: false, error: null };
|
|
1465
|
+
}
|
|
1466
|
+
throw new Error(`Turns has no anchor transport for harness "${this.source}".`);
|
|
1467
|
+
}
|
|
1468
|
+
finish(result) {
|
|
1469
|
+
if (!this.currentTurn || this.currentTurn.status !== 'running')
|
|
1470
|
+
return;
|
|
1471
|
+
const interrupted = Boolean(result?.interrupted || this.currentTurn.interruptRequestedAt);
|
|
1472
|
+
if (interrupted) {
|
|
1473
|
+
result = { ...result, exitCode: 0, signal: null, timedOut: false, error: null, interrupted: true };
|
|
1474
|
+
}
|
|
1475
|
+
const failed = !interrupted && Boolean(result?.error || result?.timedOut || result?.exitCode);
|
|
1476
|
+
// Some ACP servers reject the pending prompt on session/cancel but leave
|
|
1477
|
+
// their native turn alive. Reusing that connection makes the next Turns
|
|
1478
|
+
// prompt a harness-internal queued turn instead of a new active turn. A
|
|
1479
|
+
// reconnect is the portable cancellation boundary; the next prompt resumes
|
|
1480
|
+
// the same durable native session through session/load. Gemini is the
|
|
1481
|
+
// exception: its ACP server settles cancellation on the existing
|
|
1482
|
+
// connection, but an interrupted session is not yet discoverable by
|
|
1483
|
+
// session/load, so reconnecting strands every later queued prompt.
|
|
1484
|
+
if (interrupted && this.transport instanceof AcpTransport && this.source !== 'gemini') {
|
|
1485
|
+
this.transport.stop?.();
|
|
1486
|
+
this.transport = null;
|
|
1487
|
+
}
|
|
1488
|
+
if (interrupted || failed)
|
|
1489
|
+
this.expirePendingInteractions(interrupted
|
|
1490
|
+
? 'The turn was interrupted before this interaction was answered.'
|
|
1491
|
+
: 'The turn ended before this interaction was answered.');
|
|
1492
|
+
this.currentTurn = {
|
|
1493
|
+
...this.currentTurn,
|
|
1494
|
+
status: interrupted ? 'interrupted' : failed ? 'error' : 'completed',
|
|
1495
|
+
completedAt: now(),
|
|
1496
|
+
result,
|
|
1497
|
+
};
|
|
1498
|
+
this.emitLive({
|
|
1499
|
+
event: 'turn.complete',
|
|
1500
|
+
agent: this.source,
|
|
1501
|
+
phase: 'queued',
|
|
1502
|
+
messageId: this.currentTurn.messageId,
|
|
1503
|
+
result,
|
|
1504
|
+
});
|
|
1505
|
+
this.setStatus('idle');
|
|
1506
|
+
this.broadcast({
|
|
1507
|
+
type: failed ? 'turn_error' : 'turn_complete',
|
|
1508
|
+
sessionId: this.sessionId,
|
|
1509
|
+
agentSessionId: this.agentSessionId,
|
|
1510
|
+
messageId: this.currentTurn.messageId,
|
|
1511
|
+
result,
|
|
1512
|
+
});
|
|
1513
|
+
this.scheduleExit();
|
|
1514
|
+
}
|
|
1515
|
+
async interrupt(messageId) {
|
|
1516
|
+
if (this.status !== 'running' || (messageId && messageId !== this.currentTurn?.messageId))
|
|
1517
|
+
throw new Error('No matching turn is running.');
|
|
1518
|
+
this.currentTurn.interruptRequestedAt = now();
|
|
1519
|
+
// A reverse request can block an ACP server from consuming session/cancel.
|
|
1520
|
+
// Resolve every pending interaction as cancelled first so interruption is a
|
|
1521
|
+
// complete, protocol-independent boundary even when the user never answers.
|
|
1522
|
+
this.expirePendingInteractions('The turn was interrupted before this interaction was answered.');
|
|
1523
|
+
try {
|
|
1524
|
+
if (this.transport?.interrupt)
|
|
1525
|
+
await this.transport.interrupt();
|
|
1526
|
+
else if (this.child) {
|
|
1527
|
+
try {
|
|
1528
|
+
process.kill(-this.child.pid, 'SIGINT');
|
|
1529
|
+
}
|
|
1530
|
+
catch {
|
|
1531
|
+
this.child.kill('SIGINT');
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
finally {
|
|
1536
|
+
this.emitLive({
|
|
1537
|
+
event: 'interrupt.issued',
|
|
1538
|
+
agent: this.source,
|
|
1539
|
+
phase: 'queued',
|
|
1540
|
+
messageId: this.currentTurn.messageId,
|
|
1541
|
+
});
|
|
1542
|
+
}
|
|
1543
|
+
return { interrupted: true, messageId: this.currentTurn.messageId };
|
|
1544
|
+
}
|
|
1545
|
+
async steer(prompt, messageId = '') {
|
|
1546
|
+
if (this.status !== 'running')
|
|
1547
|
+
throw new Error('No turn is running.');
|
|
1548
|
+
// A harness without an in-turn steering primitive still honours the
|
|
1549
|
+
// correction rather than rejecting it: interrupt the running turn so the
|
|
1550
|
+
// queue can dispatch this prompt as the very next turn. The prompt stays
|
|
1551
|
+
// queued on the server, so nothing is emitted for it here.
|
|
1552
|
+
if (!this.transport?.steer) {
|
|
1553
|
+
await this.interrupt(String(this.currentTurn?.messageId || ''));
|
|
1554
|
+
return { steered: true, mode: 'interrupt' };
|
|
1555
|
+
}
|
|
1556
|
+
await this.transport.steer(prompt, messageId);
|
|
1557
|
+
this.emitLive({
|
|
1558
|
+
event: 'turn.steer',
|
|
1559
|
+
agent: this.source,
|
|
1560
|
+
phase: 'queued',
|
|
1561
|
+
messageId,
|
|
1562
|
+
displayPrompt: prompt,
|
|
1563
|
+
prompt,
|
|
1564
|
+
});
|
|
1565
|
+
return { steered: true, mode: 'steer' };
|
|
1566
|
+
}
|
|
1567
|
+
async compact() {
|
|
1568
|
+
if (this.status === 'running')
|
|
1569
|
+
throw new Error('The anchored agent is already running a turn.');
|
|
1570
|
+
if (this.pendingInteractions.size) {
|
|
1571
|
+
throw new Error('The anchored agent is awaiting user input.');
|
|
1572
|
+
}
|
|
1573
|
+
if (!this.transport?.compact)
|
|
1574
|
+
throw new Error(`${this.source} does not support programmatic compaction.`);
|
|
1575
|
+
clearTimeout(this.idleTimer);
|
|
1576
|
+
this.setStatus('running');
|
|
1577
|
+
this.emitLive({ event: 'compaction.started', agent: this.source, phase: 'queued' });
|
|
1578
|
+
try {
|
|
1579
|
+
const result = await this.transport.compact(this.turnTimeoutMs);
|
|
1580
|
+
this.emitLive({ event: 'compaction.complete', agent: this.source, phase: 'queued' });
|
|
1581
|
+
return { compacted: true, result };
|
|
1582
|
+
}
|
|
1583
|
+
catch (error) {
|
|
1584
|
+
this.emitLive({
|
|
1585
|
+
event: 'compaction.failed',
|
|
1586
|
+
agent: this.source,
|
|
1587
|
+
phase: 'queued',
|
|
1588
|
+
error: { message: errorText(error) },
|
|
1589
|
+
});
|
|
1590
|
+
throw error;
|
|
1591
|
+
}
|
|
1592
|
+
finally {
|
|
1593
|
+
this.setStatus('idle');
|
|
1594
|
+
this.scheduleExit();
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
close() {
|
|
1598
|
+
clearTimeout(this.idleTimer);
|
|
1599
|
+
const error = new Error('The agent anchor closed.');
|
|
1600
|
+
for (const pending of this.pendingInteractions.values())
|
|
1601
|
+
pending.waiter.reject(error);
|
|
1602
|
+
this.pendingInteractions.clear();
|
|
1603
|
+
this.transport?.stop?.();
|
|
1604
|
+
if (this.child?.exitCode === null) {
|
|
1605
|
+
try {
|
|
1606
|
+
process.kill(-this.child.pid, 'SIGTERM');
|
|
1607
|
+
}
|
|
1608
|
+
catch { }
|
|
1609
|
+
}
|
|
1610
|
+
for (const client of this.clients)
|
|
1611
|
+
client.destroy();
|
|
1612
|
+
server.close(() => {
|
|
1613
|
+
try {
|
|
1614
|
+
fs.unlinkSync(socketPath);
|
|
1615
|
+
}
|
|
1616
|
+
catch { }
|
|
1617
|
+
process.exit(0);
|
|
1618
|
+
});
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
const anchor = new Anchor();
|
|
1622
|
+
try {
|
|
1623
|
+
fs.unlinkSync(socketPath);
|
|
1624
|
+
}
|
|
1625
|
+
catch { }
|
|
1626
|
+
fs.mkdirSync(path.dirname(socketPath), { recursive: true, mode: 0o700 });
|
|
1627
|
+
const server = net.createServer((socket) => {
|
|
1628
|
+
anchor.clients.add(socket);
|
|
1629
|
+
socket.setEncoding('utf8');
|
|
1630
|
+
writeLine(socket, { type: 'anchor_hello', anchor: anchor.snapshot() });
|
|
1631
|
+
const lines = readline.createInterface({ input: socket });
|
|
1632
|
+
const detach = () => {
|
|
1633
|
+
anchor.clients.delete(socket);
|
|
1634
|
+
try {
|
|
1635
|
+
lines.close();
|
|
1636
|
+
}
|
|
1637
|
+
catch { }
|
|
1638
|
+
};
|
|
1639
|
+
// A daemon or diagnostic client may disappear between two live events. Both
|
|
1640
|
+
// the socket and readline interface surface that reset; consume it locally
|
|
1641
|
+
// so one observer cannot take down the detached agent it was observing.
|
|
1642
|
+
socket.on('error', detach);
|
|
1643
|
+
socket.on('close', detach);
|
|
1644
|
+
lines.on('error', detach);
|
|
1645
|
+
lines.on('line', (line) => {
|
|
1646
|
+
let request;
|
|
1647
|
+
try {
|
|
1648
|
+
request = JSON.parse(line);
|
|
1649
|
+
}
|
|
1650
|
+
catch {
|
|
1651
|
+
return;
|
|
1652
|
+
}
|
|
1653
|
+
const respond = (ok, result) => writeLine(socket, ok ? { id: request.id, ok, result } : { id: request.id, ok, error: errorText(result) });
|
|
1654
|
+
Promise.resolve()
|
|
1655
|
+
.then(async () => {
|
|
1656
|
+
if (request.type === 'snapshot')
|
|
1657
|
+
return anchor.snapshot();
|
|
1658
|
+
if (request.type === 'send_message')
|
|
1659
|
+
return anchor.start(request);
|
|
1660
|
+
if (request.type === 'interrupt')
|
|
1661
|
+
return anchor.interrupt(String(request.messageId || ''));
|
|
1662
|
+
if (request.type === 'steer')
|
|
1663
|
+
return anchor.steer(String(request.prompt || ''), String(request.messageId || ''));
|
|
1664
|
+
if (request.type === 'compact')
|
|
1665
|
+
return anchor.compact();
|
|
1666
|
+
if (request.type === 'ask_interaction')
|
|
1667
|
+
return anchor.askInteraction({
|
|
1668
|
+
type: 'turns_mcp_tool_call',
|
|
1669
|
+
toolName: String(request.toolName || 'turns_ask_user_question'),
|
|
1670
|
+
input: request.input || {},
|
|
1671
|
+
});
|
|
1672
|
+
if (request.type === 'answer_interaction')
|
|
1673
|
+
return anchor.answerInteraction(String(request.interactionId || ''), request.response);
|
|
1674
|
+
if (request.type === 'shutdown') {
|
|
1675
|
+
setImmediate(() => anchor.close());
|
|
1676
|
+
return { closing: true };
|
|
1677
|
+
}
|
|
1678
|
+
throw new Error(`Unknown anchor request: ${request.type}`);
|
|
1679
|
+
})
|
|
1680
|
+
.then((result) => respond(true, result), (error) => respond(false, error));
|
|
1681
|
+
});
|
|
1682
|
+
});
|
|
1683
|
+
server.listen(socketPath);
|
|
1684
|
+
process.on('SIGTERM', () => anchor.close());
|
|
1685
|
+
process.on('SIGINT', () => anchor.close());
|