@fradser/pi-agent-teams 0.5.2 → 0.5.3
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/.memory/follow-up-queue.md +17 -0
- package/.memory/node-terminal-followups.md +17 -0
- package/README.md +2 -1
- package/package.json +1 -1
- package/src/console-viewport.ts +2 -1
- package/src/follow-up-queue.ts +170 -0
- package/src/guidance.ts +12 -9
- package/src/index.ts +33 -16
- package/src/run-machine.ts +95 -62
- package/src/spawner.ts +132 -152
- package/src/state.ts +40 -42
- package/src/tools.ts +63 -4
- package/src/types.ts +48 -10
- package/src/ui.ts +56 -73
- package/src/worker.ts +4 -1
package/src/run-machine.ts
CHANGED
|
@@ -6,13 +6,13 @@ import { resolveAgent } from "./agents";
|
|
|
6
6
|
import { isWorkerEvent } from "./worker";
|
|
7
7
|
import {
|
|
8
8
|
cancelBlockedDependents, cancelNode, cancelRun, clearWorkerRunEvents, clearStateDirty, isStateDirty,
|
|
9
|
-
deliverToLeader,
|
|
9
|
+
deliverToLeader, findSharedWorkspaceWriteConflict, getNode,
|
|
10
10
|
getRun, getState, listNodes, listRuns, markNodeRunning, markStateDirty,
|
|
11
11
|
readyPendingNodes, receiveWorkerMessage, runningNodeCount, settleRun, setNodeSpawnInfo,
|
|
12
|
-
SUMMARY_NODE_ID, updateNodeSpawnProgress, updateNodeStatus,
|
|
12
|
+
SUMMARY_NODE_ID, updateNodeSpawnProgress, updateNodeStatus, validateStructuredOutput,
|
|
13
13
|
} from "./state";
|
|
14
14
|
import {
|
|
15
|
-
CancellationIntents, buildAutonomousPrompt, finishReportedWorker, isCompletedWorkerExit,
|
|
15
|
+
CancellationIntents, buildAutonomousPrompt, DEFAULT_TURN_BUDGET, finishReportedWorker, isCompletedWorkerExit,
|
|
16
16
|
POST_REPORT_GRACE_MS, spawnPiWorker, terminateWorker,
|
|
17
17
|
type WorkerProcessResult,
|
|
18
18
|
} from "./spawner";
|
|
@@ -27,25 +27,21 @@ export interface DispatchCtx {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
export const MAX_SESSION_WORKERS = 8;
|
|
30
|
-
const DEFAULT_NODE_TIMEOUT_MS = 30 * 60 * 1000;
|
|
31
30
|
const LIVE_POLL_MS = 500;
|
|
32
31
|
const cancellationIntents = new CancellationIntents();
|
|
33
32
|
const reportedWorkerShutdowns = new Set<string>();
|
|
34
33
|
let liveStateFile: string | undefined;
|
|
35
34
|
let livePollTimer: ReturnType<typeof setInterval> | undefined;
|
|
36
|
-
let machineCtx: DispatchCtx | undefined;
|
|
37
35
|
let sendUpdate: (subject: string, body: string, runId?: string) => void = () => {};
|
|
38
36
|
let notifyChange: () => void = () => {};
|
|
39
37
|
|
|
40
|
-
export function initRunMachine(
|
|
41
|
-
machineCtx = ctx;
|
|
38
|
+
export function initRunMachine(_ctx: DispatchCtx, stateFile: string, hooks: { sendUpdate: typeof sendUpdate; notifyChange: () => void }): void {
|
|
42
39
|
liveStateFile = stateFile;
|
|
43
40
|
sendUpdate = hooks.sendUpdate;
|
|
44
41
|
notifyChange = hooks.notifyChange;
|
|
45
42
|
}
|
|
46
43
|
|
|
47
44
|
export function ensureRunContext(ctx: DispatchCtx): void {
|
|
48
|
-
machineCtx = ctx;
|
|
49
45
|
if (!liveStateFile && ctx.sessionManager) {
|
|
50
46
|
liveStateFile = stateFilePath(ctx.sessionManager.getSessionFile(), ctx.cwd ?? process.cwd());
|
|
51
47
|
}
|
|
@@ -55,16 +51,47 @@ export function shutdownRunMachine(): void {
|
|
|
55
51
|
if (livePollTimer) clearInterval(livePollTimer);
|
|
56
52
|
livePollTimer = undefined;
|
|
57
53
|
liveStateFile = undefined;
|
|
58
|
-
machineCtx = undefined;
|
|
59
54
|
sendUpdate = () => {};
|
|
60
55
|
notifyChange = () => {};
|
|
61
56
|
}
|
|
62
57
|
|
|
58
|
+
function resolveInputBinding(run: import("./types").Run, node: import("./types").Node, source: string): string {
|
|
59
|
+
const separator = source.indexOf("#");
|
|
60
|
+
const dependencyId = separator >= 0 ? source.slice(0, separator) : source;
|
|
61
|
+
if (!node.dependsOn.includes(dependencyId)) return "(rejected: source is not a dependency)";
|
|
62
|
+
const dependency = run.nodes[dependencyId];
|
|
63
|
+
if (!dependency) return "(rejected: dependency not found)";
|
|
64
|
+
const value = dependency.structuredOutput ?? dependency.namedOutputs ?? dependency.result;
|
|
65
|
+
if (separator < 0) return JSON.stringify(value);
|
|
66
|
+
const pointer = source.slice(separator + 1).replace(/^\/json/, "");
|
|
67
|
+
if (!pointer || pointer === "") return JSON.stringify(value);
|
|
68
|
+
let current: unknown = value;
|
|
69
|
+
for (const part of pointer.split("/").filter(Boolean).map((item) => item.replaceAll("~1", "/").replaceAll("~0", "~"))) {
|
|
70
|
+
if (!current || typeof current !== "object") return "(missing)";
|
|
71
|
+
current = (current as Record<string, unknown>)[part];
|
|
72
|
+
}
|
|
73
|
+
return JSON.stringify(current);
|
|
74
|
+
}
|
|
75
|
+
|
|
63
76
|
function currentStateFile(): string {
|
|
64
77
|
if (!liveStateFile) throw new Error("Agent Teams session state is unavailable.");
|
|
65
78
|
return liveStateFile;
|
|
66
79
|
}
|
|
67
80
|
|
|
81
|
+
function sendNodeFollowUp(run: import("./types").Run, node: import("./types").Node, subject: string, body: string): void {
|
|
82
|
+
if (!run.background || node.id === SUMMARY_NODE_ID || node.nodeFollowUpSent) return;
|
|
83
|
+
node.nodeFollowUpSent = true;
|
|
84
|
+
markStateDirty();
|
|
85
|
+
sendUpdate(`Teammate ${node.id} ${terminalVerb(subject)}`, body, run.id);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function terminalVerb(subject: string): string {
|
|
89
|
+
if (subject === "Node completed") return "completed";
|
|
90
|
+
if (subject === "Node failed") return "failed";
|
|
91
|
+
if (subject === "Node cancelled") return "was cancelled";
|
|
92
|
+
return subject.toLowerCase();
|
|
93
|
+
}
|
|
94
|
+
|
|
68
95
|
function flushStateSnapshot(): void {
|
|
69
96
|
if (!liveStateFile || !isStateDirty()) return;
|
|
70
97
|
try {
|
|
@@ -106,13 +133,25 @@ export function applyWorkerEvents(): void {
|
|
|
106
133
|
type: "message",
|
|
107
134
|
subject: event.subject,
|
|
108
135
|
body: event.body,
|
|
136
|
+
status: event.status,
|
|
137
|
+
data: event.data,
|
|
109
138
|
});
|
|
139
|
+
if (event.data?.kind === "named_output" && event.data.name && typeof event.data.value === "string") {
|
|
140
|
+
node.namedOutputs = { ...node.namedOutputs, [event.data.name]: event.data.value };
|
|
141
|
+
markStateDirty();
|
|
142
|
+
}
|
|
143
|
+
if (event.data?.kind === "output" && event.data.output !== undefined && !validateStructuredOutput(event.data.output)) {
|
|
144
|
+
node.structuredOutput = event.data.output;
|
|
145
|
+
markStateDirty();
|
|
146
|
+
}
|
|
110
147
|
if (event.status && !["completed", "failed", "cancelled"].includes(node.status)) {
|
|
111
148
|
if (event.status === "completed") {
|
|
112
149
|
updateNodeStatus(run.id, node.id, "completed", event.body, undefined);
|
|
150
|
+
sendNodeFollowUp(run, node, "Node completed", event.body);
|
|
113
151
|
requestReportedWorkerShutdown(node.workerKey, spawnId);
|
|
114
152
|
} else if (event.status === "failed") {
|
|
115
153
|
updateNodeStatus(run.id, node.id, "failed", undefined, event.body);
|
|
154
|
+
sendNodeFollowUp(run, node, "Node failed", event.body);
|
|
116
155
|
requestReportedWorkerShutdown(node.workerKey, spawnId);
|
|
117
156
|
}
|
|
118
157
|
}
|
|
@@ -156,7 +195,6 @@ export function ensureLivePoll(): void {
|
|
|
156
195
|
livePollTimer = setInterval(() => {
|
|
157
196
|
try {
|
|
158
197
|
applyWorkerEvents();
|
|
159
|
-
enforceRunTimeouts();
|
|
160
198
|
flushStateSnapshot();
|
|
161
199
|
notifyChange();
|
|
162
200
|
ensureLivePoll();
|
|
@@ -170,26 +208,6 @@ export function ensureLivePoll(): void {
|
|
|
170
208
|
}
|
|
171
209
|
}
|
|
172
210
|
|
|
173
|
-
/** Fail runs whose run-level hard wall-clock cap was exceeded. */
|
|
174
|
-
function enforceRunTimeouts(): void {
|
|
175
|
-
for (const run of listRuns()) {
|
|
176
|
-
if (run.status !== "running" || !run.deadlineAt || Date.now() < run.deadlineAt) continue;
|
|
177
|
-
const failed = failRunTimeout(run.id, `Run timed out after ${Math.round((run.timeoutMs ?? 0) / 1000)}s.`);
|
|
178
|
-
if (!failed.ok) continue;
|
|
179
|
-
for (const nodeId of failed.runningNodeIds) {
|
|
180
|
-
// Spawn was cleared by failRunTimeout, so terminate by the stable workerKey
|
|
181
|
-
// (a no-op when the child already closed).
|
|
182
|
-
const node = getNode(run.id, nodeId);
|
|
183
|
-
if (node) void terminateWorker(node.workerKey).catch(() => false);
|
|
184
|
-
}
|
|
185
|
-
onRunSettled(run.id);
|
|
186
|
-
// failRunTimeout clears spawns, so those nodes' close events skip their
|
|
187
|
-
// finalize path — release write-deferred nodes in OTHER runs here.
|
|
188
|
-
if (machineCtx) scheduleAllRuns(machineCtx);
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
|
|
193
211
|
// ── Run dispatch machinery ────────────────────────────────────────
|
|
194
212
|
|
|
195
213
|
/** Compact run summary for tool returns and follow-ups. When the run has a
|
|
@@ -203,11 +221,8 @@ export function buildRunSummary(runId: string): string {
|
|
|
203
221
|
acc[node.status] = (acc[node.status] ?? 0) + 1;
|
|
204
222
|
return acc;
|
|
205
223
|
}, {});
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
`${Object.keys(run.nodes).length} node(s): ${Object.entries(counts).map(([status, n]) => `${status} ${n}`).join(", ")}`,
|
|
209
|
-
"",
|
|
210
|
-
];
|
|
224
|
+
const countsStr = Object.entries(counts).map(([status, n]) => `${status} ${n}`).join(", ");
|
|
225
|
+
const lines = [`Run [${run.id}] ${countsStr}`, ""];
|
|
211
226
|
if (run.summary) {
|
|
212
227
|
lines.push(run.summary, "");
|
|
213
228
|
} else if (nodes.length === 1) {
|
|
@@ -222,9 +237,6 @@ export function buildRunSummary(runId: string): string {
|
|
|
222
237
|
}
|
|
223
238
|
}
|
|
224
239
|
}
|
|
225
|
-
for (const node of nodes) {
|
|
226
|
-
lines.push(`- [${node.id}] ${node.status} (${node.agent})`);
|
|
227
|
-
}
|
|
228
240
|
return lines.join("\n");
|
|
229
241
|
}
|
|
230
242
|
|
|
@@ -326,9 +338,6 @@ export async function cancelRunAndTerminate(
|
|
|
326
338
|
if (run.status !== "running") return { ok: false, error: `Run "${runId}" is already ${run.status}.` };
|
|
327
339
|
const cancellation = cancelRun(runId);
|
|
328
340
|
if (!cancellation.ok) return { ok: false, error: cancellation.error ?? "Failed to cancel run." };
|
|
329
|
-
run.status = "cancelled";
|
|
330
|
-
run.finishedAt = Date.now();
|
|
331
|
-
run.updatedAt = Date.now();
|
|
332
341
|
run.completionNotified = true;
|
|
333
342
|
markStateDirty();
|
|
334
343
|
for (const nodeId of cancellation.runningNodeIds) {
|
|
@@ -339,7 +348,13 @@ export async function cancelRunAndTerminate(
|
|
|
339
348
|
cancellationIntents.resolve(spawnId, terminated);
|
|
340
349
|
}
|
|
341
350
|
publishStateSnapshot();
|
|
342
|
-
|
|
351
|
+
if (!cancellation.runningNodeIds.some((nodeId) => getNode(runId, nodeId)?.status === "running")) {
|
|
352
|
+
run.status = "cancelled";
|
|
353
|
+
run.finishedAt = Date.now();
|
|
354
|
+
run.updatedAt = Date.now();
|
|
355
|
+
markStateDirty();
|
|
356
|
+
onRunSettled(runId);
|
|
357
|
+
}
|
|
343
358
|
scheduleAllRuns(ctx);
|
|
344
359
|
notifyChange();
|
|
345
360
|
return { ok: true };
|
|
@@ -353,10 +368,18 @@ export function startNode(runId: string, nodeId: string, ctx: DispatchCtx): void
|
|
|
353
368
|
if (!run || !node || node.status !== "pending") return;
|
|
354
369
|
const agent = resolveAgent(node.agent, run.cwd);
|
|
355
370
|
const stateFile = liveStateFile ?? (ctx.sessionManager ? stateFilePath(ctx.sessionManager.getSessionFile(), run.cwd) : "");
|
|
356
|
-
|
|
357
|
-
updateNodeStatus(runId, nodeId, "failed", undefined,
|
|
371
|
+
const failBeforeSpawn = (error: string): void => {
|
|
372
|
+
updateNodeStatus(runId, nodeId, "failed", undefined, error);
|
|
373
|
+
const failedNode = getNode(runId, nodeId);
|
|
374
|
+
const subject = "Node failed";
|
|
375
|
+
const body = `Node [${runId}/${nodeId}] could not start.\nError: ${error}`;
|
|
376
|
+
deliverToLeader({ from: node.workerKey, subject, body, runId });
|
|
377
|
+
if (failedNode) sendNodeFollowUp(run, failedNode, subject, body);
|
|
358
378
|
cancelBlockedDependents(runId, nodeId);
|
|
359
379
|
scheduleRun(runId, ctx);
|
|
380
|
+
};
|
|
381
|
+
if (!agent || !stateFile) {
|
|
382
|
+
failBeforeSpawn(agent ? "Shared state file unavailable." : `Agent "${node.agent}" not found.`);
|
|
360
383
|
return;
|
|
361
384
|
}
|
|
362
385
|
|
|
@@ -368,9 +391,7 @@ export function startNode(runId: string, nodeId: string, ctx: DispatchCtx): void
|
|
|
368
391
|
if (run.worktree) {
|
|
369
392
|
worktree = createWorktree(run.cwd, `${runId}-${nodeId}`);
|
|
370
393
|
if ("error" in worktree) {
|
|
371
|
-
|
|
372
|
-
cancelBlockedDependents(runId, nodeId);
|
|
373
|
-
scheduleRun(runId, ctx);
|
|
394
|
+
failBeforeSpawn(`Cannot isolate node: ${worktree.error}`);
|
|
374
395
|
return;
|
|
375
396
|
}
|
|
376
397
|
}
|
|
@@ -389,15 +410,24 @@ export function startNode(runId: string, nodeId: string, ctx: DispatchCtx): void
|
|
|
389
410
|
status: "running",
|
|
390
411
|
startedAt: Date.now(),
|
|
391
412
|
isolation: run.worktree ? "worktree" : "none",
|
|
413
|
+
mode: node.mode,
|
|
392
414
|
});
|
|
393
415
|
|
|
394
|
-
const
|
|
416
|
+
const turnBudget = node.turnBudget ?? DEFAULT_TURN_BUDGET;
|
|
417
|
+
const bindingLines = node.inputBindings
|
|
418
|
+
? Object.entries(node.inputBindings).map(([name, source]) => `Input ${name}: ${resolveInputBinding(run, node, source)}`)
|
|
419
|
+
: [];
|
|
395
420
|
const upstream = node.dependsOn
|
|
396
421
|
.map((depId) => run.nodes[depId])
|
|
397
422
|
.filter((dep): dep is NonNullable<typeof dep> => Boolean(dep))
|
|
423
|
+
.filter((dep) => !node.forkContext || node.forkContext.includes(dep.id))
|
|
398
424
|
.map((dep) => {
|
|
399
425
|
const body = dep.result?.trim() || dep.errorMessage?.trim() || `${dep.status} with no written result.`;
|
|
400
|
-
|
|
426
|
+
const named = dep.namedOutputs && Object.keys(dep.namedOutputs).length > 0
|
|
427
|
+
? `\nNamed outputs: ${JSON.stringify(dep.namedOutputs)}`
|
|
428
|
+
: "";
|
|
429
|
+
const structured = dep.structuredOutput === undefined ? "" : `\nStructured output: ${JSON.stringify(dep.structuredOutput)}`;
|
|
430
|
+
return `--- ${dep.id} (${dep.agent}, ${dep.status}) ---\n${body}${named}${structured}`;
|
|
401
431
|
});
|
|
402
432
|
const description = [
|
|
403
433
|
buildAutonomousPrompt({
|
|
@@ -405,7 +435,7 @@ export function startNode(runId: string, nodeId: string, ctx: DispatchCtx): void
|
|
|
405
435
|
role: node.agent,
|
|
406
436
|
prompt: agent.prompt,
|
|
407
437
|
taskId: nodeId,
|
|
408
|
-
|
|
438
|
+
turnBudget,
|
|
409
439
|
}),
|
|
410
440
|
"",
|
|
411
441
|
"=== TASK ===",
|
|
@@ -413,6 +443,7 @@ export function startNode(runId: string, nodeId: string, ctx: DispatchCtx): void
|
|
|
413
443
|
`Paths: ${node.paths.join(", ")}`,
|
|
414
444
|
node.prompt,
|
|
415
445
|
...(upstream.length > 0 ? ["", "=== UPSTREAM HANDOFF ===", ...upstream] : []),
|
|
446
|
+
...(bindingLines.length > 0 ? ["", "=== NAMED INPUTS ===", ...bindingLines] : []),
|
|
416
447
|
].join("\n");
|
|
417
448
|
|
|
418
449
|
const finalizeNode = (result: WorkerProcessResult, cancelled = false) => {
|
|
@@ -438,7 +469,7 @@ export function startNode(runId: string, nodeId: string, ctx: DispatchCtx): void
|
|
|
438
469
|
const completedAfterFinalResponse = nodeNow?.spawn?.finalResponse === true;
|
|
439
470
|
const workerReportedFailure = reportedTerminalStatus === "failed";
|
|
440
471
|
const completedAfterShutdown = (reportedTerminalStatus === "completed" || completedAfterFinalResponse)
|
|
441
|
-
&&
|
|
472
|
+
&& result.signal === "SIGTERM";
|
|
442
473
|
const ok = isCompletedWorkerExit(
|
|
443
474
|
result,
|
|
444
475
|
reportedTerminalStatus === "completed" || completedAfterFinalResponse,
|
|
@@ -453,12 +484,11 @@ export function startNode(runId: string, nodeId: string, ctx: DispatchCtx): void
|
|
|
453
484
|
stdout: (result.stdout + patchText).trim() ? result.stdout + patchText : undefined,
|
|
454
485
|
stderr: ok ? undefined : result.stderr,
|
|
455
486
|
usage: result.usage,
|
|
456
|
-
timedOut: result.timedOut,
|
|
457
487
|
isolation: run.worktree ? "worktree" : "none",
|
|
458
488
|
error: ok
|
|
459
489
|
? undefined
|
|
460
|
-
: result.
|
|
461
|
-
? `Worker
|
|
490
|
+
: result.turnBudgetExceeded
|
|
491
|
+
? `Worker exceeded its turn budget of ${turnBudget} turn(s).`
|
|
462
492
|
: result.signal
|
|
463
493
|
? `Worker was terminated by ${result.signal}.`
|
|
464
494
|
: workerReportedFailure
|
|
@@ -479,7 +509,7 @@ export function startNode(runId: string, nodeId: string, ctx: DispatchCtx): void
|
|
|
479
509
|
runId,
|
|
480
510
|
nodeId,
|
|
481
511
|
agent: node.agent,
|
|
482
|
-
result,
|
|
512
|
+
result: { ...result, timedOut: result.turnBudgetExceeded ?? false },
|
|
483
513
|
nodeResult: nodeNow?.result,
|
|
484
514
|
nodeError: nodeNow?.errorMessage,
|
|
485
515
|
cancelled,
|
|
@@ -487,6 +517,8 @@ export function startNode(runId: string, nodeId: string, ctx: DispatchCtx): void
|
|
|
487
517
|
patchText,
|
|
488
518
|
});
|
|
489
519
|
deliverToLeader({ from: workerKey, subject: terminalSubject, body: terminalBody, runId: runId });
|
|
520
|
+
const settledNode = getNode(runId, nodeId);
|
|
521
|
+
if (!cancelled && settledNode) sendNodeFollowUp(run, settledNode, terminalSubject, terminalBody);
|
|
490
522
|
if (cancelled) {
|
|
491
523
|
// A cancelled node keeps its process outcome but not a misleading error.
|
|
492
524
|
const cleared = getNode(runId, nodeId);
|
|
@@ -518,12 +550,11 @@ export function startNode(runId: string, nodeId: string, ctx: DispatchCtx): void
|
|
|
518
550
|
error: typeof error === "string" ? error : error.message,
|
|
519
551
|
});
|
|
520
552
|
reportedWorkerShutdowns.delete(spawnId);
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
});
|
|
553
|
+
const subject = "Node failed";
|
|
554
|
+
const body = `Node [${runId}/${nodeId}] could not start.\nError: ${typeof error === "string" ? error : error.message}`;
|
|
555
|
+
deliverToLeader({ from: workerKey, subject, body, runId: runId });
|
|
556
|
+
const failedNode = getNode(runId, nodeId);
|
|
557
|
+
if (failedNode) sendNodeFollowUp(run, failedNode, subject, body);
|
|
527
558
|
cancelBlockedDependents(runId, nodeId);
|
|
528
559
|
compactFinishedNodeRun(stateFile, workerKey, spawnId);
|
|
529
560
|
if (worktree && !("error" in worktree)) discardWorktree(worktree);
|
|
@@ -533,12 +564,13 @@ export function startNode(runId: string, nodeId: string, ctx: DispatchCtx): void
|
|
|
533
564
|
|
|
534
565
|
const started = spawnPiWorker({
|
|
535
566
|
workerName: workerKey,
|
|
567
|
+
mode: node.mode,
|
|
536
568
|
description,
|
|
537
569
|
model: node.model ?? agent.model,
|
|
538
570
|
tools: agent.tools,
|
|
539
571
|
cwd: workerCwd,
|
|
540
572
|
env: workerEnv,
|
|
541
|
-
|
|
573
|
+
turnBudget,
|
|
542
574
|
onUpdate: (progress) => {
|
|
543
575
|
updateNodeSpawnProgress(runId, nodeId, spawnId, {
|
|
544
576
|
liveText: progress.text,
|
|
@@ -565,6 +597,7 @@ export function startNode(runId: string, nodeId: string, ctx: DispatchCtx): void
|
|
|
565
597
|
status: "running",
|
|
566
598
|
startedAt: node.spawn?.startedAt ?? Date.now(),
|
|
567
599
|
isolation: run.worktree ? "worktree" : "none",
|
|
600
|
+
mode: node.mode,
|
|
568
601
|
});
|
|
569
602
|
ensureLivePoll();
|
|
570
603
|
publishStateSnapshot();
|