@turns/dev 0.0.16 → 0.0.18
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 +17 -0
- package/dist/agent-anchor.js +60 -19
- package/dist/background-task-state.js +471 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -38,6 +38,23 @@ process to stay resident. A run narrowed to a directory — watching or `--once`
|
|
|
38
38
|
never prunes saved upload cursors, so it cannot make the sessions it filtered
|
|
39
39
|
out replay from their first byte the next time the daemon starts.
|
|
40
40
|
|
|
41
|
+
Native background tasks retain their detached agent anchor after the foreground
|
|
42
|
+
prompt finishes, including while the daemon client is disconnected. Codex child
|
|
43
|
+
agents, Claude/Letta tasks, ACP task tools, Prime/OMP jobs, and DeepSeek jobs
|
|
44
|
+
suspend the normal five-minute idle cleanup. Follow-up prompts remain available;
|
|
45
|
+
the anchor starts a fresh idle countdown after the last known task finishes or
|
|
46
|
+
its transport exits. Explicit shutdown, interruption, transport replacement, and
|
|
47
|
+
turn timeouts retain their existing semantics.
|
|
48
|
+
|
|
49
|
+
Retention uses native lifecycle events and task-tool results. Assistant prose,
|
|
50
|
+
a completed launch tool, or a failed status query does not prove that a task has
|
|
51
|
+
finished. Copilot, Hermes, and OpenCode captures lack reliable terminal evidence
|
|
52
|
+
in their live streams, so their anchors can stay resident until a later native
|
|
53
|
+
status result, transport exit, or explicit shutdown. No time-based fallback kills
|
|
54
|
+
quiet tasks. One-shot harnesses such as Muse and Antigravity already remain
|
|
55
|
+
foreground-running until their native process exits; there is no warm child
|
|
56
|
+
process for idle cleanup to kill afterward.
|
|
57
|
+
|
|
41
58
|
The server is selected with `--server URL` or `TURNS_ORIGIN`. Its normalized
|
|
42
59
|
origin is saved and reused by later invocations and daemon starts. On Linux,
|
|
43
60
|
Turns uses the XDG config/state directories and installs this user service:
|
package/dist/agent-anchor.js
CHANGED
|
@@ -12,6 +12,7 @@ import { ACP_PROFILES, lettaHeadlessEnvironment, lettaSessionIdentity, PROCESS_H
|
|
|
12
12
|
import { asyncQuestionInteractions, asyncQuestionReplyText, isAsyncQuestionInteraction, parseAsyncQuestionReplies, } from "./codex-async-questions.js";
|
|
13
13
|
import { recordLettaSessionContext } from "./letta-session-context.js";
|
|
14
14
|
import { recoverAsyncQuestion } from "./codex-async-question-recovery.js";
|
|
15
|
+
import { BackgroundTaskState } from "./background-task-state.js";
|
|
15
16
|
const socketIndex = process.argv.indexOf('--socket');
|
|
16
17
|
const socketPath = socketIndex >= 0 ? path.resolve(process.argv[socketIndex + 1] || '') : '';
|
|
17
18
|
if (!socketPath)
|
|
@@ -98,6 +99,7 @@ function childResult(child, timeoutMs) {
|
|
|
98
99
|
return result.promise;
|
|
99
100
|
}
|
|
100
101
|
class LineTransport {
|
|
102
|
+
backgroundTasks;
|
|
101
103
|
anchor;
|
|
102
104
|
harness;
|
|
103
105
|
nextId;
|
|
@@ -107,6 +109,7 @@ class LineTransport {
|
|
|
107
109
|
constructor(anchor, harness, command, args, cwd, env = {}) {
|
|
108
110
|
this.anchor = anchor;
|
|
109
111
|
this.harness = harness;
|
|
112
|
+
this.backgroundTasks = new BackgroundTaskState(anchor.source);
|
|
110
113
|
this.nextId = 1;
|
|
111
114
|
this.pending = new Map();
|
|
112
115
|
this.lastEventAt = Date.now();
|
|
@@ -135,6 +138,9 @@ class LineTransport {
|
|
|
135
138
|
for (const waiter of this.pending.values())
|
|
136
139
|
waiter.reject(error);
|
|
137
140
|
this.pending.clear();
|
|
141
|
+
this.backgroundTasks.clear();
|
|
142
|
+
if (this.anchor.transport === this && this.anchor.status === 'idle')
|
|
143
|
+
this.anchor.scheduleExit();
|
|
138
144
|
this.onClose?.(error);
|
|
139
145
|
});
|
|
140
146
|
}
|
|
@@ -157,6 +163,11 @@ class LineTransport {
|
|
|
157
163
|
json = JSON.parse(line);
|
|
158
164
|
}
|
|
159
165
|
catch { }
|
|
166
|
+
const wasActive = this.backgroundTasks.active;
|
|
167
|
+
this.backgroundTasks.observe({ json }, this.anchor.agentSessionId);
|
|
168
|
+
if (wasActive !== this.backgroundTasks.active && this.anchor.transport === this && this.anchor.status === 'idle') {
|
|
169
|
+
this.anchor.scheduleExit();
|
|
170
|
+
}
|
|
160
171
|
this.anchor.emitLive({
|
|
161
172
|
direction: 'in',
|
|
162
173
|
source: `${this.harness}.stdout`,
|
|
@@ -188,7 +199,6 @@ class LineTransport {
|
|
|
188
199
|
}
|
|
189
200
|
}
|
|
190
201
|
class ClaudeTransport extends LineTransport {
|
|
191
|
-
backgroundTasks = new Set();
|
|
192
202
|
sessionId;
|
|
193
203
|
permissionMode;
|
|
194
204
|
turnWaiter;
|
|
@@ -228,18 +238,6 @@ class ClaudeTransport extends LineTransport {
|
|
|
228
238
|
onFrame(frame) {
|
|
229
239
|
if (!frame || typeof frame !== 'object')
|
|
230
240
|
return;
|
|
231
|
-
if (frame.type === 'system' && typeof frame.task_id === 'string') {
|
|
232
|
-
if (frame.subtype === 'task_started' && frame.is_backgrounded !== false) {
|
|
233
|
-
this.backgroundTasks.add(frame.task_id);
|
|
234
|
-
}
|
|
235
|
-
else if (frame.subtype === 'task_notification') {
|
|
236
|
-
this.backgroundTasks.delete(frame.task_id);
|
|
237
|
-
}
|
|
238
|
-
// A result completes the foreground prompt, not the tasks it launched.
|
|
239
|
-
// Keep accepting new prompts while those tasks retain the warm process.
|
|
240
|
-
if (this.anchor.status === 'idle')
|
|
241
|
-
this.anchor.scheduleExit();
|
|
242
|
-
}
|
|
243
241
|
if (frame.type === 'system' && frame.subtype === 'init' && frame.session_id) {
|
|
244
242
|
this.sessionId = String(frame.session_id);
|
|
245
243
|
if (this.sessionId !== this.anchor.agentSessionId)
|
|
@@ -269,9 +267,6 @@ class ClaudeTransport extends LineTransport {
|
|
|
269
267
|
void this.answerReverse(frame);
|
|
270
268
|
}
|
|
271
269
|
onClose(error) {
|
|
272
|
-
this.backgroundTasks.clear();
|
|
273
|
-
if (this.anchor.status === 'idle')
|
|
274
|
-
this.anchor.scheduleExit();
|
|
275
270
|
if (this.turnWaiter) {
|
|
276
271
|
this.turnWaiter.reject(error);
|
|
277
272
|
this.turnWaiter = null;
|
|
@@ -335,6 +330,28 @@ class ClaudeTransport extends LineTransport {
|
|
|
335
330
|
timer.unref?.();
|
|
336
331
|
return waiter.promise.finally(() => clearTimeout(timer));
|
|
337
332
|
}
|
|
333
|
+
/**
|
|
334
|
+
* Claude Code folds a stdin `user` frame that arrives during a turn into that
|
|
335
|
+
* turn: the model reads it after the tool calls in flight finish, within the
|
|
336
|
+
* same `result`, and the transcript records it as a `queued_command`
|
|
337
|
+
* attachment rather than a new user row (verified on 2.1.280). `priority`
|
|
338
|
+
* selects what happens to the running work. `next` is the fold described
|
|
339
|
+
* above and matches what the interactive TUI does when the user types while
|
|
340
|
+
* Claude works. `now` would end the running turn with `aborted_tools` and
|
|
341
|
+
* answer the message as a fresh turn, which is the interrupt fallback the
|
|
342
|
+
* queue already offers, so it is not used here.
|
|
343
|
+
*/
|
|
344
|
+
steer(text, _messageId = '') {
|
|
345
|
+
if (!this.turnWaiter)
|
|
346
|
+
throw new Error('Claude has no active turn to steer.');
|
|
347
|
+
this.emit({
|
|
348
|
+
type: 'user',
|
|
349
|
+
session_id: this.sessionId,
|
|
350
|
+
message: { role: 'user', content: [{ type: 'text', text }] },
|
|
351
|
+
parent_tool_use_id: null,
|
|
352
|
+
priority: 'next',
|
|
353
|
+
});
|
|
354
|
+
}
|
|
338
355
|
async interrupt() {
|
|
339
356
|
try {
|
|
340
357
|
await this.controlRequest('interrupt');
|
|
@@ -1184,6 +1201,7 @@ class OmpRpcTransport extends RpcTransport {
|
|
|
1184
1201
|
}
|
|
1185
1202
|
}
|
|
1186
1203
|
class DeepseekTransport {
|
|
1204
|
+
backgroundTasks;
|
|
1187
1205
|
anchor;
|
|
1188
1206
|
sessionId;
|
|
1189
1207
|
waiter;
|
|
@@ -1191,6 +1209,7 @@ class DeepseekTransport {
|
|
|
1191
1209
|
base;
|
|
1192
1210
|
constructor(anchor) {
|
|
1193
1211
|
this.anchor = anchor;
|
|
1212
|
+
this.backgroundTasks = new BackgroundTaskState('deepseek');
|
|
1194
1213
|
this.sessionId = anchor.agentSessionId;
|
|
1195
1214
|
this.waiter = null;
|
|
1196
1215
|
this.socket = null;
|
|
@@ -1200,6 +1219,13 @@ class DeepseekTransport {
|
|
|
1200
1219
|
this.base = base;
|
|
1201
1220
|
this.socket = new WebSocket(`${base.replace(/^http/, 'ws')}/api/events.mux`);
|
|
1202
1221
|
this.socket.on('message', (bytes) => this.receive(String(bytes)));
|
|
1222
|
+
this.socket.once('close', () => {
|
|
1223
|
+
this.backgroundTasks.clear();
|
|
1224
|
+
this.waiter?.reject(new Error('DeepSeek transport closed.'));
|
|
1225
|
+
this.waiter = null;
|
|
1226
|
+
if (this.anchor.transport === this && this.anchor.status === 'idle')
|
|
1227
|
+
this.anchor.scheduleExit();
|
|
1228
|
+
});
|
|
1203
1229
|
await new Promise((resolve, reject) => {
|
|
1204
1230
|
this.socket.once('open', resolve);
|
|
1205
1231
|
this.socket.once('error', reject);
|
|
@@ -1223,6 +1249,11 @@ class DeepseekTransport {
|
|
|
1223
1249
|
}
|
|
1224
1250
|
if (String(frame?.payload?.sessionId || '') !== this.sessionId)
|
|
1225
1251
|
return;
|
|
1252
|
+
const wasActive = this.backgroundTasks.active;
|
|
1253
|
+
this.backgroundTasks.observe({ json: frame });
|
|
1254
|
+
if (wasActive !== this.backgroundTasks.active && this.anchor.transport === this && this.anchor.status === 'idle') {
|
|
1255
|
+
this.anchor.scheduleExit();
|
|
1256
|
+
}
|
|
1226
1257
|
this.anchor.emitLive({
|
|
1227
1258
|
direction: 'in',
|
|
1228
1259
|
source: 'deepseek.websocket',
|
|
@@ -1291,10 +1322,20 @@ class DeepseekTransport {
|
|
|
1291
1322
|
async prompt(text, timeoutMs) {
|
|
1292
1323
|
const waiter = deferred();
|
|
1293
1324
|
this.waiter = waiter;
|
|
1294
|
-
await this.rpc('session.prompt', { sessionId: this.sessionId, mode: 'queue', content: [{ type: 'text', text }] });
|
|
1295
1325
|
const timer = setTimeout(() => waiter.reject(new Error('DeepSeek turn timed out.')), timeoutMs);
|
|
1296
1326
|
timer.unref?.();
|
|
1297
|
-
|
|
1327
|
+
// Observe both promises immediately: the event socket can close before the
|
|
1328
|
+
// HTTP acknowledgement arrives, and must not produce an unhandled rejection.
|
|
1329
|
+
return Promise.all([
|
|
1330
|
+
this.rpc('session.prompt', { sessionId: this.sessionId, mode: 'queue', content: [{ type: 'text', text }] }),
|
|
1331
|
+
waiter.promise,
|
|
1332
|
+
])
|
|
1333
|
+
.then(([, result]) => result)
|
|
1334
|
+
.finally(() => {
|
|
1335
|
+
clearTimeout(timer);
|
|
1336
|
+
if (this.waiter === waiter)
|
|
1337
|
+
this.waiter = null;
|
|
1338
|
+
});
|
|
1298
1339
|
}
|
|
1299
1340
|
interrupt() {
|
|
1300
1341
|
return this.rpc('session.cancel', { sessionId: this.sessionId });
|
|
@@ -1574,7 +1615,7 @@ class Anchor {
|
|
|
1574
1615
|
}
|
|
1575
1616
|
scheduleExit() {
|
|
1576
1617
|
clearTimeout(this.idleTimer);
|
|
1577
|
-
if (this.status === 'running' || this.pendingInteractions.size || this.transport?.backgroundTasks?.
|
|
1618
|
+
if (this.status === 'running' || this.pendingInteractions.size || this.transport?.backgroundTasks?.active) {
|
|
1578
1619
|
this.idleTimer = null;
|
|
1579
1620
|
return;
|
|
1580
1621
|
}
|
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
/** Native background-work leases owned by the detached anchor, not its client.
|
|
2
|
+
* This intentionally consumes protocol envelopes rather than searching stdout,
|
|
3
|
+
* user prompts, assistant prose, or command output for apparent task statuses. */
|
|
4
|
+
const RUNNING = new Set([
|
|
5
|
+
'accepted',
|
|
6
|
+
'active',
|
|
7
|
+
'created',
|
|
8
|
+
'dispatched',
|
|
9
|
+
'in_progress',
|
|
10
|
+
'inprogress',
|
|
11
|
+
'pending',
|
|
12
|
+
'queued',
|
|
13
|
+
'running',
|
|
14
|
+
'started',
|
|
15
|
+
'working',
|
|
16
|
+
]);
|
|
17
|
+
const TERMINAL = new Set([
|
|
18
|
+
'aborted',
|
|
19
|
+
'cancelled',
|
|
20
|
+
'canceled',
|
|
21
|
+
'completed',
|
|
22
|
+
'complete',
|
|
23
|
+
'done',
|
|
24
|
+
'error',
|
|
25
|
+
'errored',
|
|
26
|
+
'exited',
|
|
27
|
+
'failed',
|
|
28
|
+
'failure',
|
|
29
|
+
'finished',
|
|
30
|
+
'idle',
|
|
31
|
+
'killed',
|
|
32
|
+
'rejected',
|
|
33
|
+
'result_ready',
|
|
34
|
+
'stopped',
|
|
35
|
+
'succeeded',
|
|
36
|
+
'success',
|
|
37
|
+
'terminated',
|
|
38
|
+
]);
|
|
39
|
+
const STATUS_TOOLS = /^(?:taskoutput|task_output|tasklist|task_list|taskstop|task_stop|list_agents|wait_agent|read_agent|subagent_status|subagent_wait|subagent_read_result|read_subagent|list_background_processes|read_background_output|job_list|job_output|job_kill|hub|shell_status|shell_wait|shell_list|manage_subagents|get_command_or_subagent_output)$/u;
|
|
40
|
+
const ID_KEYS = [
|
|
41
|
+
'task_id',
|
|
42
|
+
'taskId',
|
|
43
|
+
'agent_id',
|
|
44
|
+
'agentId',
|
|
45
|
+
'subagent_id',
|
|
46
|
+
'subagentId',
|
|
47
|
+
'agent_path',
|
|
48
|
+
'agentPath',
|
|
49
|
+
'delegation_id',
|
|
50
|
+
'delegationId',
|
|
51
|
+
'job_id',
|
|
52
|
+
'jobId',
|
|
53
|
+
'shell_id',
|
|
54
|
+
'shellId',
|
|
55
|
+
'rlm_child_id',
|
|
56
|
+
'rlmChildId',
|
|
57
|
+
'backgroundTaskId',
|
|
58
|
+
];
|
|
59
|
+
const MAX_HISTORY = 512;
|
|
60
|
+
const object = (value) => (value && typeof value === 'object' && !Array.isArray(value) ? value : null);
|
|
61
|
+
const string = (value) => (typeof value === 'string' ? value : '');
|
|
62
|
+
const normalized = (value) => string(value)
|
|
63
|
+
.toLowerCase()
|
|
64
|
+
.replace(/[^a-z0-9]+/gu, '_')
|
|
65
|
+
.replace(/^_|_$/gu, '');
|
|
66
|
+
function decode(value) {
|
|
67
|
+
if (typeof value !== 'string')
|
|
68
|
+
return value;
|
|
69
|
+
try {
|
|
70
|
+
return JSON.parse(value);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return value;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function status(value) {
|
|
77
|
+
if (object(value))
|
|
78
|
+
value = Object.keys(value).find((key) => RUNNING.has(normalized(key)) || TERMINAL.has(normalized(key)));
|
|
79
|
+
const key = normalized(value);
|
|
80
|
+
if (RUNNING.has(key))
|
|
81
|
+
return 'running';
|
|
82
|
+
if (TERMINAL.has(key) || /^exited_\d+$/u.test(key))
|
|
83
|
+
return 'terminal';
|
|
84
|
+
}
|
|
85
|
+
function background(value) {
|
|
86
|
+
const input = object(decode(value));
|
|
87
|
+
return Boolean(input &&
|
|
88
|
+
(input.run_in_background === true ||
|
|
89
|
+
input.runInBackground === true ||
|
|
90
|
+
input.background === true ||
|
|
91
|
+
input.is_background === true ||
|
|
92
|
+
input.isBackground === true ||
|
|
93
|
+
input.async === true ||
|
|
94
|
+
input.mode === 'background'));
|
|
95
|
+
}
|
|
96
|
+
function nativeIds(value) {
|
|
97
|
+
const record = object(value);
|
|
98
|
+
if (!record)
|
|
99
|
+
return [];
|
|
100
|
+
return ID_KEYS.flatMap((key) => typeof record[key] === 'string' || typeof record[key] === 'number' ? [String(record[key])] : []).slice(0, 1);
|
|
101
|
+
}
|
|
102
|
+
function textIds(value) {
|
|
103
|
+
const ids = [];
|
|
104
|
+
for (const pattern of [
|
|
105
|
+
/(?:task|agent|subagent|delegation|job|shell)[ _-]?id\s*[:=]\s*["'`]?([A-Za-z0-9_./:-]+)/giu,
|
|
106
|
+
/(?:background with ID:|Backgrounded as job|started background job)\s*([A-Za-z0-9_./:-]+)/giu,
|
|
107
|
+
/(?:background\s*\(PID:\s*|\[PID\s+)(\d+)/giu,
|
|
108
|
+
/\bShell\s+([A-Za-z0-9_.:-]+)(?:,|\n)/gu,
|
|
109
|
+
/\brlm_child_id=['"]([^'"]+)['"]/gu,
|
|
110
|
+
/\/delegation\/live\/([^/\s"']+)/gu,
|
|
111
|
+
])
|
|
112
|
+
for (const match of value.matchAll(pattern))
|
|
113
|
+
ids.push(match[1].replace(/[.,;]+$/u, ''));
|
|
114
|
+
return [...new Set(ids)];
|
|
115
|
+
}
|
|
116
|
+
function contents(value, depth = 0) {
|
|
117
|
+
if (depth > 10)
|
|
118
|
+
return '';
|
|
119
|
+
if (typeof value === 'string')
|
|
120
|
+
return value;
|
|
121
|
+
if (Array.isArray(value))
|
|
122
|
+
return value.map((child) => contents(child, depth + 1)).join('\n');
|
|
123
|
+
return object(value)
|
|
124
|
+
? contents(value.text ?? value.content ?? value.output ?? value.observation ?? '', depth + 1)
|
|
125
|
+
: '';
|
|
126
|
+
}
|
|
127
|
+
function textStatus(value) {
|
|
128
|
+
// Only provider status headers, not words inside a shell command or its output.
|
|
129
|
+
const reported = value.match(/(?:^|\n|,\s*)(?:status|state|final_sub_status)\s*[:=]\s*["']?([\w -]+)/iu)?.[1];
|
|
130
|
+
if (reported)
|
|
131
|
+
return status(reported.split(/[,\n]/u)[0].trim());
|
|
132
|
+
if (/^(?:Subagent completed:|Agent (?:completed|finished)|<task-notification>)/u.test(value))
|
|
133
|
+
return 'terminal';
|
|
134
|
+
if (/\b(?:Command moved to background|Command running in background with ID:|Agent started in background|Backgrounded as job|started background job)\b/u.test(value))
|
|
135
|
+
return 'running';
|
|
136
|
+
}
|
|
137
|
+
export class BackgroundTaskState {
|
|
138
|
+
activeIds = new Set();
|
|
139
|
+
calls = new Map();
|
|
140
|
+
aliases = new Map();
|
|
141
|
+
terminalIds = new Set();
|
|
142
|
+
children = new Set();
|
|
143
|
+
harness;
|
|
144
|
+
sessionId;
|
|
145
|
+
constructor(harness, sessionId = '') {
|
|
146
|
+
this.harness = harness;
|
|
147
|
+
this.sessionId = sessionId;
|
|
148
|
+
}
|
|
149
|
+
get active() {
|
|
150
|
+
return this.activeIds.size > 0;
|
|
151
|
+
}
|
|
152
|
+
clear() {
|
|
153
|
+
this.activeIds.clear();
|
|
154
|
+
this.calls.clear();
|
|
155
|
+
this.aliases.clear();
|
|
156
|
+
this.terminalIds.clear();
|
|
157
|
+
this.children.clear();
|
|
158
|
+
}
|
|
159
|
+
update(id, state, restart = false) {
|
|
160
|
+
if (typeof id !== 'string' || !id || id.length > 512)
|
|
161
|
+
return;
|
|
162
|
+
const key = this.aliases.get(id) || id;
|
|
163
|
+
const next = status(state);
|
|
164
|
+
if (next === 'terminal') {
|
|
165
|
+
this.activeIds.delete(key);
|
|
166
|
+
this.terminalIds.add(key);
|
|
167
|
+
while (this.terminalIds.size > MAX_HISTORY) {
|
|
168
|
+
const oldest = this.terminalIds.values().next().value;
|
|
169
|
+
this.terminalIds.delete(oldest);
|
|
170
|
+
this.children.delete(oldest);
|
|
171
|
+
for (const [alias, target] of this.aliases)
|
|
172
|
+
if (target === oldest)
|
|
173
|
+
this.aliases.delete(alias);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
else if (next === 'running' && (restart || !this.terminalIds.has(key))) {
|
|
177
|
+
if (restart)
|
|
178
|
+
this.terminalIds.delete(key);
|
|
179
|
+
this.activeIds.add(key);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
bind(callId, ids) {
|
|
183
|
+
if (!ids.length)
|
|
184
|
+
return;
|
|
185
|
+
this.activeIds.delete(`call:${callId}`);
|
|
186
|
+
for (const id of ids)
|
|
187
|
+
this.update(id, 'running');
|
|
188
|
+
}
|
|
189
|
+
tool(id, name, input, output, failed = false) {
|
|
190
|
+
if (typeof id !== 'string' || !id)
|
|
191
|
+
return;
|
|
192
|
+
const args = object(decode(input));
|
|
193
|
+
const previous = this.calls.get(id);
|
|
194
|
+
const toolName = normalized(name).slice(0, 512) || previous?.name || '';
|
|
195
|
+
const launch = previous?.launch ||
|
|
196
|
+
background(args) ||
|
|
197
|
+
/^(?:spawn_agent|spawn_subagent|subagent_spawn|invoke_subagent)$/u.test(toolName);
|
|
198
|
+
const targets = args
|
|
199
|
+
? [
|
|
200
|
+
...nativeIds(args),
|
|
201
|
+
...(Array.isArray(args.task_ids) ? args.task_ids : []),
|
|
202
|
+
...(Array.isArray(args.ids) ? args.ids : []),
|
|
203
|
+
...(this.harness === 'poolside' && args.name ? [args.name] : []),
|
|
204
|
+
].filter((value) => typeof value === 'string')
|
|
205
|
+
: previous?.targets || [];
|
|
206
|
+
const delegation = previous?.delegation || (this.harness === 'hermes' && /\/delegation\/live\//u.test(string(name)));
|
|
207
|
+
if (delegation)
|
|
208
|
+
targets.push(...textIds(string(name)));
|
|
209
|
+
const call = { name: toolName, launch, targets, delegation };
|
|
210
|
+
this.calls.delete(id);
|
|
211
|
+
this.calls.set(id, call);
|
|
212
|
+
if (this.calls.size > MAX_HISTORY) {
|
|
213
|
+
for (const key of this.calls.keys()) {
|
|
214
|
+
// Keep pending launch correlation even during a burst of tool traffic.
|
|
215
|
+
if (key !== id && !this.activeIds.has(`call:${key}`))
|
|
216
|
+
this.calls.delete(key);
|
|
217
|
+
if (this.calls.size <= MAX_HISTORY)
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
// Provider-native lifecycle supplies Devin and Prime identities directly.
|
|
222
|
+
const nativeLaunch = (this.harness === 'devin' && toolName === 'run_subagent') || (this.harness === 'prime' && toolName === 'ipython');
|
|
223
|
+
if (launch && !nativeLaunch && !previous?.launch)
|
|
224
|
+
this.update(`call:${id}`, 'running');
|
|
225
|
+
if (failed && launch)
|
|
226
|
+
this.update(`call:${id}`, 'failed');
|
|
227
|
+
if (output === undefined || failed)
|
|
228
|
+
return;
|
|
229
|
+
const text = contents(output);
|
|
230
|
+
const outputLaunch = /^(?:bash|shell|run_shell_command)$/u.test(toolName) &&
|
|
231
|
+
/^(?:Command moved to background|Command running in background with ID:|Backgrounded as job|started background job)/u.test(text);
|
|
232
|
+
const hermes = this.harness === 'hermes' && (/^delegate/u.test(toolName) || delegation);
|
|
233
|
+
if (!(launch ||
|
|
234
|
+
outputLaunch ||
|
|
235
|
+
STATUS_TOOLS.test(toolName) ||
|
|
236
|
+
hermes ||
|
|
237
|
+
(this.harness === 'muse' && toolName.startsWith('subagent'))))
|
|
238
|
+
return;
|
|
239
|
+
const found = new Set();
|
|
240
|
+
const visit = (value, depth = 0) => {
|
|
241
|
+
if (depth > 10)
|
|
242
|
+
return;
|
|
243
|
+
let parsed = decode(value);
|
|
244
|
+
if (Array.isArray(parsed)) {
|
|
245
|
+
for (const child of parsed)
|
|
246
|
+
visit(child, depth + 1);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (object(parsed)) {
|
|
250
|
+
const ids = nativeIds(parsed);
|
|
251
|
+
// Native list rows use an unqualified id, unlike enclosing tool calls.
|
|
252
|
+
if (typeof parsed.id === 'string' && (parsed.status || parsed.state))
|
|
253
|
+
ids.push(parsed.id);
|
|
254
|
+
if (this.harness === 'antigravity' && typeof parsed.conversationId === 'string')
|
|
255
|
+
ids.push(parsed.conversationId);
|
|
256
|
+
for (const task of ids) {
|
|
257
|
+
found.add(task);
|
|
258
|
+
this.update(task, this.harness === 'muse' ? parsed.roster?.[0]?.state || parsed.status : (parsed.status ?? parsed.state));
|
|
259
|
+
}
|
|
260
|
+
if (!ids.length && !parsed.jobs && !parsed.agents && STATUS_TOOLS.test(toolName)) {
|
|
261
|
+
for (const task of targets)
|
|
262
|
+
this.update(task, parsed.status ?? parsed.state);
|
|
263
|
+
}
|
|
264
|
+
for (const [key, child] of Object.entries(parsed)) {
|
|
265
|
+
if (/prompt$|description$/iu.test(key) ||
|
|
266
|
+
['command', 'label', 'detailedContent', 'stdout', 'stderr'].includes(key))
|
|
267
|
+
continue;
|
|
268
|
+
// A task row's output is arbitrary program/agent text, not another status record.
|
|
269
|
+
if (status(parsed.status ?? parsed.state) && ['output', 'message', 'result'].includes(key))
|
|
270
|
+
continue;
|
|
271
|
+
visit(child, depth + 1);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
else if (typeof parsed === 'string') {
|
|
275
|
+
parsed = parsed
|
|
276
|
+
.replace(/<output>[\s\S]*?(?:<\/output>|$)/gu, '')
|
|
277
|
+
.split(/\n(?:output|stdout|stderr):\s*\n/iu)[0];
|
|
278
|
+
const xmlId = /<task_id>([^<]+)<\/task_id>/u.exec(parsed)?.[1];
|
|
279
|
+
const xmlStatus = /<status>([^<]+)<\/status>/u.exec(parsed)?.[1];
|
|
280
|
+
if (xmlId && xmlStatus) {
|
|
281
|
+
found.add(xmlId);
|
|
282
|
+
this.update(xmlId, xmlStatus);
|
|
283
|
+
}
|
|
284
|
+
for (const match of parsed.matchAll(/<task\s+([^>]+)>/gu)) {
|
|
285
|
+
const task = /\bid=["']([^"']+)["']/u.exec(match[1])?.[1];
|
|
286
|
+
const state = /\bstate=["']([^"']+)["']/u.exec(match[1])?.[1];
|
|
287
|
+
if (task) {
|
|
288
|
+
found.add(task);
|
|
289
|
+
this.update(task, state);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
if (this.harness === 'antigravity' && toolName === 'manage_subagents') {
|
|
293
|
+
const list = parsed.slice(parsed.indexOf('['));
|
|
294
|
+
if (Array.isArray(decode(list)))
|
|
295
|
+
visit(decode(list), depth + 1);
|
|
296
|
+
}
|
|
297
|
+
const ids = textIds(parsed);
|
|
298
|
+
for (const task of ids)
|
|
299
|
+
found.add(task);
|
|
300
|
+
// PID and job listings expose one status per line, not a global status.
|
|
301
|
+
for (const line of parsed.split('\n')) {
|
|
302
|
+
const pid = /\[PID\s+(\d+)\]\s+(RUNNING|EXITED)/u.exec(line);
|
|
303
|
+
const job = /^([\w.-]+)\s+\[[^\]]+\]\s+(\w+)/u.exec(line);
|
|
304
|
+
if (pid)
|
|
305
|
+
this.update(pid[1], pid[2]);
|
|
306
|
+
if (job)
|
|
307
|
+
this.update(job[1], job[2]);
|
|
308
|
+
}
|
|
309
|
+
const state = textStatus(parsed);
|
|
310
|
+
const addressed = ids.length ? ids : targets;
|
|
311
|
+
if (state)
|
|
312
|
+
for (const task of addressed)
|
|
313
|
+
this.update(task, state === 'terminal' ? 'completed' : 'running');
|
|
314
|
+
if (hermes && /\bresult\s+\|\s+terminal ok\b/u.test(parsed)) {
|
|
315
|
+
for (const task of [...ids, ...targets])
|
|
316
|
+
this.update(task, 'completed');
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
visit(output);
|
|
321
|
+
if (launch || outputLaunch) {
|
|
322
|
+
this.bind(id, [...found]);
|
|
323
|
+
// A failed native launch may complete the tool with an error payload.
|
|
324
|
+
if (!found.size && object(output)?.isError === true)
|
|
325
|
+
this.update(`call:${id}`, 'failed');
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
observe(event, sessionId = this.sessionId) {
|
|
329
|
+
if (event?.direction === 'out' || String(event?.source || '').endsWith('.stderr'))
|
|
330
|
+
return;
|
|
331
|
+
const frame = object(event?.json) || object(decode(event?.raw));
|
|
332
|
+
if (!frame)
|
|
333
|
+
return;
|
|
334
|
+
if (this.harness === 'codex') {
|
|
335
|
+
const params = frame.params || {};
|
|
336
|
+
const thread = params.threadId;
|
|
337
|
+
if (thread && thread !== sessionId && this.children.has(thread)) {
|
|
338
|
+
if (frame.method === 'turn/started')
|
|
339
|
+
this.update(thread, 'running', true);
|
|
340
|
+
if (['turn/completed', 'turn/failed', 'turn/aborted'].includes(frame.method))
|
|
341
|
+
this.update(thread, 'completed');
|
|
342
|
+
if (frame.method === 'thread/status/changed')
|
|
343
|
+
this.update(thread, params.status?.type);
|
|
344
|
+
}
|
|
345
|
+
if (thread && sessionId && thread !== sessionId && !this.children.has(thread))
|
|
346
|
+
return;
|
|
347
|
+
const item = params.item;
|
|
348
|
+
if (!['item/started', 'item/completed'].includes(frame.method) || !object(item))
|
|
349
|
+
return;
|
|
350
|
+
if (item.type === 'subAgentActivity' || item.type === 'sub_agent_activity') {
|
|
351
|
+
const id = item.agentThreadId || item.agent_thread_id || item.agentPath || item.agent_path;
|
|
352
|
+
if (typeof id === 'string') {
|
|
353
|
+
this.children.add(id);
|
|
354
|
+
const alias = item.agentPath || item.agent_path;
|
|
355
|
+
if (alias)
|
|
356
|
+
this.aliases.set(alias, id);
|
|
357
|
+
this.update(id, item.kind, frame.method === 'item/started' && item.kind === 'started');
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
if (item.type === 'collabAgentToolCall') {
|
|
361
|
+
for (const id of Array.isArray(item.receiverThreadIds) ? item.receiverThreadIds : []) {
|
|
362
|
+
this.children.add(id);
|
|
363
|
+
if (['spawnAgent', 'spawn_agent', 'resumeAgent', 'resume_agent', 'sendInput', 'send_input'].includes(item.tool) &&
|
|
364
|
+
item.status !== 'failed')
|
|
365
|
+
this.update(id, 'running', frame.method === 'item/started');
|
|
366
|
+
if (['closeAgent', 'close_agent'].includes(item.tool) &&
|
|
367
|
+
frame.method === 'item/completed' &&
|
|
368
|
+
item.status === 'completed')
|
|
369
|
+
this.update(id, 'completed');
|
|
370
|
+
}
|
|
371
|
+
for (const [id, state] of Object.entries(item.agentsStates || item.agentsStatus || {})) {
|
|
372
|
+
this.children.add(id);
|
|
373
|
+
this.update(id, object(state)?.status ?? state);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
if (frame.type === 'system' && ['claude', 'letta'].includes(this.harness)) {
|
|
379
|
+
if (frame.subtype === 'task_started' && frame.is_backgrounded !== false)
|
|
380
|
+
this.update(frame.task_id, 'running', true);
|
|
381
|
+
if (frame.subtype === 'task_notification')
|
|
382
|
+
this.update(frame.task_id, 'completed');
|
|
383
|
+
}
|
|
384
|
+
const update = frame.params?.update;
|
|
385
|
+
if (object(update) && ['session/update', '_x.ai/session_notification'].includes(frame.method)) {
|
|
386
|
+
if (sessionId && frame.params.sessionId && frame.params.sessionId !== sessionId)
|
|
387
|
+
return;
|
|
388
|
+
const meta = update._meta || {};
|
|
389
|
+
const started = meta['cognition.ai/subagent_started'];
|
|
390
|
+
const ended = meta['cognition.ai/subagent_completed'];
|
|
391
|
+
if (started?.isBackground)
|
|
392
|
+
this.update(started.agentId, 'running', true);
|
|
393
|
+
if (ended)
|
|
394
|
+
this.update(ended.agentId, 'completed');
|
|
395
|
+
if (['tool_call', 'tool_call_update'].includes(update.sessionUpdate)) {
|
|
396
|
+
const known = this.calls.get(update.toolCallId);
|
|
397
|
+
const toolName = meta.toolName ||
|
|
398
|
+
meta.tool_name ||
|
|
399
|
+
meta['codebuddy.ai/toolName'] ||
|
|
400
|
+
meta['cognition.ai/inferenceToolName'] ||
|
|
401
|
+
meta['x.ai/tool']?.name ||
|
|
402
|
+
known?.name ||
|
|
403
|
+
update.title ||
|
|
404
|
+
string(update.toolCallId).split('__call_')[0];
|
|
405
|
+
const out = update.status !== 'pending' &&
|
|
406
|
+
(update.status === 'completed' ||
|
|
407
|
+
update.rawOutput !== undefined ||
|
|
408
|
+
(this.harness === 'hermes' && known?.delegation))
|
|
409
|
+
? this.harness === 'qwen'
|
|
410
|
+
? // Qwen's rawOutput is often only 'Listed N background agents';
|
|
411
|
+
// the native IDs and per-agent statuses live in content.
|
|
412
|
+
[update.rawOutput, update.content]
|
|
413
|
+
: (update.rawOutput ?? update.content)
|
|
414
|
+
: undefined;
|
|
415
|
+
this.tool(update.toolCallId, toolName, update.rawInput, out, update.status === 'failed');
|
|
416
|
+
}
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
if (this.harness === 'gemini' && frame.method === 'session/request_permission' && frame.params?.toolCall) {
|
|
420
|
+
const call = frame.params.toolCall;
|
|
421
|
+
this.tool(call.toolCallId, string(call.toolCallId).split('__call_')[0], call.rawInput);
|
|
422
|
+
}
|
|
423
|
+
if (['claude', 'letta'].includes(this.harness)) {
|
|
424
|
+
const message = frame.message || frame;
|
|
425
|
+
for (const block of Array.isArray(message.content) ? message.content : []) {
|
|
426
|
+
if (block.type === 'tool_use')
|
|
427
|
+
this.tool(block.id, block.name, block.input);
|
|
428
|
+
if (block.type === 'tool_result')
|
|
429
|
+
this.tool(block.tool_use_id, '', undefined, block.content, block.is_error === true);
|
|
430
|
+
}
|
|
431
|
+
if (frame.message_type === 'tool_call_message')
|
|
432
|
+
this.tool(frame.tool_call?.tool_call_id, frame.tool_call?.name, frame.tool_call?.arguments);
|
|
433
|
+
if (frame.message_type === 'tool_return_message')
|
|
434
|
+
this.tool(frame.tool_call_id, '', undefined, frame.tool_return, frame.status === 'error');
|
|
435
|
+
}
|
|
436
|
+
if (['omp', 'prime', 'pi'].includes(this.harness)) {
|
|
437
|
+
if (frame.type === 'tool_execution_start')
|
|
438
|
+
this.tool(frame.toolCallId, frame.toolName, frame.args);
|
|
439
|
+
if (frame.type === 'tool_execution_end')
|
|
440
|
+
this.tool(frame.toolCallId, frame.toolName, undefined, frame.result, frame.isError);
|
|
441
|
+
if (frame.type === 'rlm_child_update' && this.harness === 'prime')
|
|
442
|
+
this.update(frame.child?.id, frame.child?.status);
|
|
443
|
+
}
|
|
444
|
+
if (this.harness === 'deepseek' && frame.method === 'session/event') {
|
|
445
|
+
const event = frame.payload?.event;
|
|
446
|
+
const data = event?.data || {};
|
|
447
|
+
if (event?.type === 'tool/call')
|
|
448
|
+
this.tool(data.callId, data.name, data.arguments);
|
|
449
|
+
if (event?.type === 'tool/result')
|
|
450
|
+
for (const part of Array.isArray(data.message?.content) ? data.message.content : []) {
|
|
451
|
+
if (part.type === 'tool-result')
|
|
452
|
+
this.tool(part.toolCallId, '', undefined, part.content, part.isError);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
if (this.harness === 'antigravity' && frame.event === 'step_update') {
|
|
456
|
+
const step = frame.step_update;
|
|
457
|
+
for (const child of Array.isArray(step?.subagent_info?.subagents) ? step.subagent_info.subagents : [])
|
|
458
|
+
this.update(child.conversation_id, 'running');
|
|
459
|
+
const tool = step?.tool_info;
|
|
460
|
+
if (tool)
|
|
461
|
+
this.tool(String(step.step_index), tool.name, tool.parameters, tool.output);
|
|
462
|
+
}
|
|
463
|
+
if (this.harness === 'muse' && frame.payload?.kind === 'task_lifecycle') {
|
|
464
|
+
const event = frame.payload.event;
|
|
465
|
+
if (event?.kind === 'proposed')
|
|
466
|
+
this.tool(event.task_id, string(event.task_kind).replace(/^tool\./u, ''), undefined);
|
|
467
|
+
if (event?.kind === 'output')
|
|
468
|
+
this.tool(event.task_id, '', undefined, event.chunk);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|