@saccolabs/pi-claude-cli 0.4.12 → 0.4.14
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 +6 -0
- package/index.ts +38 -0
- package/package.json +1 -1
- package/src/event-bridge.ts +8 -0
- package/src/process-manager.ts +10 -0
- package/src/provider.ts +145 -0
- package/src/task-tracker.ts +284 -0
- package/src/types.ts +82 -0
package/README.md
CHANGED
|
@@ -48,6 +48,12 @@ Requires the `claude` binary on your login-shell PATH (`npm install -g @anthropi
|
|
|
48
48
|
- Native tool execution: the CLI runs its own tools; guards are injected as Claude Code PreToolUse hooks via `PI_CLAUDE_CLI_SETTINGS`
|
|
49
49
|
- Reports account rate-limit state (window, reset, overage) to the front-end
|
|
50
50
|
on the `claude-rate-limit` status key — never mixed into turn content
|
|
51
|
+
- Surfaces sub-agent fan-outs: one marker when a `Task` agent starts and one
|
|
52
|
+
when it reports, plus live per-agent progress on the `claude-subagents`
|
|
53
|
+
status key — so a fan-out is no longer a blank pane
|
|
54
|
+
- Background sub-agents get to finish: a `result` while agents are still
|
|
55
|
+
running ends a cycle, not the turn, so their reports reach the model
|
|
56
|
+
instead of dying with the subprocess
|
|
51
57
|
- Configurable thinking effort across the full ladder (low to max), mapped 1:1 for every model: the level the host asks for is the level the CLI gets
|
|
52
58
|
- Cross-platform subprocess management (Windows, macOS, Linux)
|
|
53
59
|
- Inactivity timeout and process registry for cleanup
|
package/index.ts
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
import { getCustomToolDefs, writeMcpConfig } from "./src/mcp-config.js";
|
|
18
18
|
import { rewriteOverflowMessage } from "./src/overflow.js";
|
|
19
19
|
import { buildRateLimitPayload, rateLimitIdentity } from "./src/rate-limit.js";
|
|
20
|
+
import type { TaskTrackerState } from "./src/types.js";
|
|
20
21
|
|
|
21
22
|
// Kill all active Claude subprocesses on process exit to prevent orphans
|
|
22
23
|
process.on("exit", killAllProcesses);
|
|
@@ -58,6 +59,41 @@ function publishRateLimit(info: Record<string, unknown>): void {
|
|
|
58
59
|
}
|
|
59
60
|
}
|
|
60
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Live sub-agent state, on its own status key.
|
|
64
|
+
*
|
|
65
|
+
* Same reasoning as the rate-limit channel: this is state ABOUT the turn, not
|
|
66
|
+
* content OF it. `task_progress` fires once per sub-agent tool call (roughly
|
|
67
|
+
* 700 times in the incident that motivated #23), so folding it into the
|
|
68
|
+
* transcript would bury the turn and cost context on every later replay. The
|
|
69
|
+
* durable half — one marker when a sub-agent starts, one when it finishes —
|
|
70
|
+
* goes in the turn instead, and needs no host change to render.
|
|
71
|
+
*/
|
|
72
|
+
const SUBAGENTS_STATUS_KEY = "claude-subagents";
|
|
73
|
+
/** Last payload pushed, so an unchanged snapshot does not rewrite the status. */
|
|
74
|
+
let lastSubagentsJson: string | undefined;
|
|
75
|
+
|
|
76
|
+
function publishTaskProgress(state: TaskTrackerState): void {
|
|
77
|
+
const setStatus = uiContext?.ui?.setStatus;
|
|
78
|
+
if (typeof setStatus !== "function") return;
|
|
79
|
+
const json = JSON.stringify(state);
|
|
80
|
+
if (json === lastSubagentsJson) return;
|
|
81
|
+
lastSubagentsJson = json;
|
|
82
|
+
try {
|
|
83
|
+
// An empty snapshot means the episode is over: CLEAR the key rather than
|
|
84
|
+
// pushing `{"tasks":[],...}`. A host reads a present status as live
|
|
85
|
+
// state, so an empty one left standing is a strip that still claims
|
|
86
|
+
// agents when there are none.
|
|
87
|
+
setStatus.call(
|
|
88
|
+
uiContext!.ui,
|
|
89
|
+
SUBAGENTS_STATUS_KEY,
|
|
90
|
+
state.tasks.length === 0 ? undefined : json,
|
|
91
|
+
);
|
|
92
|
+
} catch {
|
|
93
|
+
/* never break a turn over a status push */
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
61
97
|
let mcpConfigPath: string | undefined;
|
|
62
98
|
let mcpConfigResolved = false;
|
|
63
99
|
|
|
@@ -130,6 +166,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
130
166
|
pi.on("session_start", async (_event: unknown, ctx: unknown) => {
|
|
131
167
|
uiContext = ctx as typeof uiContext;
|
|
132
168
|
lastRateLimitJson = undefined;
|
|
169
|
+
lastSubagentsJson = undefined;
|
|
133
170
|
const allTools = pi.getAllTools();
|
|
134
171
|
if (Array.isArray(allTools)) {
|
|
135
172
|
pi.setActiveTools(allTools.map((t: any) => t.name));
|
|
@@ -146,6 +183,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
146
183
|
...options,
|
|
147
184
|
mcpConfigPath: configPath,
|
|
148
185
|
onRateLimit: publishRateLimit,
|
|
186
|
+
onTaskProgress: publishTaskProgress,
|
|
149
187
|
});
|
|
150
188
|
};
|
|
151
189
|
|
package/package.json
CHANGED
package/src/event-bridge.ts
CHANGED
|
@@ -56,6 +56,13 @@ export interface EventBridge {
|
|
|
56
56
|
* otherwise be invisible — each becomes a one-line marker text block.
|
|
57
57
|
*/
|
|
58
58
|
handleAssistantEnvelope(envelope: ClaudeAssistantEnvelope): void;
|
|
59
|
+
/**
|
|
60
|
+
* Append a pre-built marker text block. Used for sub-agent lifecycle, whose
|
|
61
|
+
* events arrive as top-level `system` envelopes rather than content blocks
|
|
62
|
+
* (`src/task-tracker.ts`). The bridge stays the only writer of
|
|
63
|
+
* `output.content`.
|
|
64
|
+
*/
|
|
65
|
+
appendMarker(text: string): void;
|
|
59
66
|
/**
|
|
60
67
|
* The final `result` envelope: authoritative cumulative usage for the
|
|
61
68
|
* whole episode, plus a safety net that appends the final answer text if
|
|
@@ -649,6 +656,7 @@ export function createEventBridge(
|
|
|
649
656
|
return {
|
|
650
657
|
handleEvent,
|
|
651
658
|
handleAssistantEnvelope,
|
|
659
|
+
appendMarker: appendTextBlock,
|
|
652
660
|
applyResult,
|
|
653
661
|
getOutput: () => output,
|
|
654
662
|
};
|
package/src/process-manager.ts
CHANGED
|
@@ -58,6 +58,16 @@ export function spawnClaude(
|
|
|
58
58
|
modelId,
|
|
59
59
|
"--permission-prompt-tool",
|
|
60
60
|
"stdio",
|
|
61
|
+
// `AskUserQuestion` renders a picker in the CLI's own TUI, and `-p` has
|
|
62
|
+
// no TUI. The call therefore cannot be answered by anyone: it returns
|
|
63
|
+
// "The user did not answer the questions." after a full round trip, and
|
|
64
|
+
// the model reads that as a person who declined and falls back to asking
|
|
65
|
+
// in prose. Captured 2026-08-28 on claude-sonnet-5, which then wrote
|
|
66
|
+
// "happy to discuss in plain text instead". Take the tool away so the
|
|
67
|
+
// model asks in prose the first time — the host's UI shows prose, and
|
|
68
|
+
// the user can answer it.
|
|
69
|
+
"--disallowedTools",
|
|
70
|
+
"AskUserQuestion",
|
|
61
71
|
];
|
|
62
72
|
|
|
63
73
|
// Hermetic mode: keep the user's Claude Code environment out of pi turns.
|
package/src/provider.ts
CHANGED
|
@@ -40,6 +40,8 @@ import {
|
|
|
40
40
|
} from "./process-manager.js";
|
|
41
41
|
import { parseLine } from "./stream-parser.js";
|
|
42
42
|
import { createEventBridge } from "./event-bridge.js";
|
|
43
|
+
import { createTaskTracker, isTaskSubtype } from "./task-tracker.js";
|
|
44
|
+
import type { TaskTrackerState } from "./types.js";
|
|
43
45
|
import { handleControlRequest } from "./control-handler.js";
|
|
44
46
|
import { mapThinkingEffort } from "./thinking-config.js";
|
|
45
47
|
import { isHandoffClaudeTool } from "./tool-mapping.js";
|
|
@@ -60,12 +62,61 @@ const INACTIVITY_TIMEOUT_MS =
|
|
|
60
62
|
? Number(process.env.PI_CLAUDE_CLI_TIMEOUT_MS)
|
|
61
63
|
: 300_000;
|
|
62
64
|
|
|
65
|
+
/**
|
|
66
|
+
* BACKGROUND SUB-AGENTS REPORT BACK, AND THIS IS WHY THEY DIDN'T.
|
|
67
|
+
*
|
|
68
|
+
* The CLI answers a background `Agent` call in milliseconds with "Async agent
|
|
69
|
+
* launched successfully… you will be notified when it completes", and the
|
|
70
|
+
* model — correctly, per its own tool contract — ends its turn to wait. The
|
|
71
|
+
* CLI then emits `result` for that turn WHILE the agents keep running.
|
|
72
|
+
* Killing on that first result is what made every fan-out a dead end: the
|
|
73
|
+
* agents died mid-tool-call, their reports were never written, and the turn
|
|
74
|
+
* closed on "Now waiting on the three investigation agents".
|
|
75
|
+
*
|
|
76
|
+
* The loop the host was assumed to owe the CLI turned out to be the CLI's
|
|
77
|
+
* own. Captured 2026-08-28 on claude 2.1.231, holding the process open past
|
|
78
|
+
* `result`: the sub-agent finished at +25s, `task_notification` carried its
|
|
79
|
+
* report, the CLI re-invoked the model unprompted, and it answered with the
|
|
80
|
+
* findings and emitted a SECOND `result`. Nothing was written to stdin to
|
|
81
|
+
* make that happen.
|
|
82
|
+
*
|
|
83
|
+
* So the fix is to stop killing: while `pendingAgents()` is non-zero, a
|
|
84
|
+
* `result` is a cycle boundary, not the end of the episode. Two bounds keep a
|
|
85
|
+
* runaway fan-out from holding a turn open forever — the inactivity timer
|
|
86
|
+
* (which `task_progress` keeps resetting for as long as agents do real work)
|
|
87
|
+
* and the wall clock below.
|
|
88
|
+
*
|
|
89
|
+
* `applyResult` is safe to call per cycle: the CLI's `modelUsage` is
|
|
90
|
+
* cumulative for the session, verified across a two-result episode
|
|
91
|
+
* (cache-read 68,718 → 161,295), so the last one wins rather than summing.
|
|
92
|
+
*/
|
|
93
|
+
const WAIT_FOR_AGENTS = process.env.PI_CLAUDE_CLI_NO_AGENT_WAIT !== "1";
|
|
94
|
+
|
|
95
|
+
/** Hard ceiling on holding a turn open for sub-agents. */
|
|
96
|
+
const AGENT_WAIT_TIMEOUT_MS =
|
|
97
|
+
Number(process.env.PI_CLAUDE_CLI_AGENT_WAIT_MS) > 0
|
|
98
|
+
? Number(process.env.PI_CLAUDE_CLI_AGENT_WAIT_MS)
|
|
99
|
+
: 900_000;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Backstop on continuation cycles. Each completing agent costs one, and an
|
|
103
|
+
* agent may launch more; this only exists so a pathological loop cannot spin
|
|
104
|
+
* without end.
|
|
105
|
+
*/
|
|
106
|
+
const MAX_AGENT_CONTINUATIONS = 32;
|
|
107
|
+
|
|
63
108
|
/** Extended stream options: pi's SimpleStreamOptions plus optional cwd and mcpConfigPath */
|
|
64
109
|
type StreamViaCLiOptions = SimpleStreamOptions & {
|
|
65
110
|
cwd?: string;
|
|
66
111
|
mcpConfigPath?: string;
|
|
67
112
|
/** Called with account rate-limit state as the CLI reports it. */
|
|
68
113
|
onRateLimit?: (info: Record<string, unknown>) => void;
|
|
114
|
+
/**
|
|
115
|
+
* Called with live sub-agent state as the CLI reports it. Ephemeral: this is
|
|
116
|
+
* progress, not transcript, and must never be folded into turn content. The
|
|
117
|
+
* durable half (start/finish) rides in the turn as markers instead.
|
|
118
|
+
*/
|
|
119
|
+
onTaskProgress?: (state: TaskTrackerState) => void;
|
|
69
120
|
};
|
|
70
121
|
|
|
71
122
|
/**
|
|
@@ -193,6 +244,9 @@ export function streamViaCli(
|
|
|
193
244
|
|
|
194
245
|
// Create event bridge (before endStreamWithError so bridge is in scope)
|
|
195
246
|
const bridge = createEventBridge(stream, model);
|
|
247
|
+
// Per-attempt: a resume-miss retry replays the episode, and its
|
|
248
|
+
// sub-agents must not be counted twice.
|
|
249
|
+
const taskTracker = createTaskTracker();
|
|
196
250
|
|
|
197
251
|
// Guard against double stream.end() and double error events.
|
|
198
252
|
// First error path wins; subsequent ones are no-ops.
|
|
@@ -229,9 +283,41 @@ export function streamViaCli(
|
|
|
229
283
|
// Inactivity timeout: kill subprocess if no stdout for INACTIVITY_TIMEOUT_MS
|
|
230
284
|
let inactivityTimer: ReturnType<typeof setTimeout> | undefined;
|
|
231
285
|
|
|
286
|
+
// Sub-agent wait state (see WAIT_FOR_AGENTS): how many `result`
|
|
287
|
+
// envelopes have been treated as cycle boundaries, and the wall-clock
|
|
288
|
+
// backstop that stops waiting no matter what the agents are doing.
|
|
289
|
+
let agentContinuations = 0;
|
|
290
|
+
let agentWaitTimer: ReturnType<typeof setTimeout> | undefined;
|
|
291
|
+
let agentWaitExpired = false;
|
|
292
|
+
let waitingForAgents = false;
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* End the episode on what it already has: kill the CLI and close the
|
|
296
|
+
* reader without pushing an error.
|
|
297
|
+
*
|
|
298
|
+
* Only correct once a `result` has been seen. Before that, silence means
|
|
299
|
+
* a wedged CLI and the turn has nothing — which is what the inactivity
|
|
300
|
+
* timeout's error is for.
|
|
301
|
+
*/
|
|
302
|
+
function endTurnOnPartialWork() {
|
|
303
|
+
agentWaitExpired = true;
|
|
304
|
+
clearTimeout(inactivityTimer);
|
|
305
|
+
clearTimeout(agentWaitTimer);
|
|
306
|
+
cleanupProcess(proc!);
|
|
307
|
+
rl.close();
|
|
308
|
+
}
|
|
309
|
+
|
|
232
310
|
function resetInactivityTimer() {
|
|
233
311
|
if (inactivityTimer !== undefined) clearTimeout(inactivityTimer);
|
|
234
312
|
inactivityTimer = setTimeout(() => {
|
|
313
|
+
// Waiting on sub-agents is the one state where silence is not a
|
|
314
|
+
// failed turn: the model already spoke and the result already
|
|
315
|
+
// landed. An agent that dies without notifying must not convert
|
|
316
|
+
// that into an error and throw the content away.
|
|
317
|
+
if (waitingForAgents) {
|
|
318
|
+
endTurnOnPartialWork();
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
235
321
|
forceKillProcess(proc!);
|
|
236
322
|
endStreamWithError(
|
|
237
323
|
`Claude CLI subprocess timed out: no output for ${INACTIVITY_TIMEOUT_MS / 1000} seconds`,
|
|
@@ -278,6 +364,7 @@ export function streamViaCli(
|
|
|
278
364
|
// Handle subprocess close -- surface crashes with stderr and exit code
|
|
279
365
|
proc.on("close", (code: number | null, _signal: string | null) => {
|
|
280
366
|
clearTimeout(inactivityTimer);
|
|
367
|
+
clearTimeout(agentWaitTimer);
|
|
281
368
|
if (broken) return; // resume-miss retry owns the stream
|
|
282
369
|
if (code !== 0 && code !== null) {
|
|
283
370
|
const stderr = getStderr();
|
|
@@ -352,6 +439,23 @@ export function streamViaCli(
|
|
|
352
439
|
/* a status push must never break a turn */
|
|
353
440
|
}
|
|
354
441
|
}
|
|
442
|
+
} else if (
|
|
443
|
+
msg.type === "system" &&
|
|
444
|
+
isTaskSubtype((msg as any).subtype)
|
|
445
|
+
) {
|
|
446
|
+
// Sub-agent lifecycle. The agents' own envelopes carry
|
|
447
|
+
// parent_tool_use_id and stay internal to the CLI, but these arrive
|
|
448
|
+
// at top level even for deeply nested agents — the one channel that
|
|
449
|
+
// makes a fan-out visible at all (#23).
|
|
450
|
+
if (!selfInterrupted) {
|
|
451
|
+
const marker = taskTracker.apply(msg as any);
|
|
452
|
+
if (marker) bridge.appendMarker(marker);
|
|
453
|
+
try {
|
|
454
|
+
options?.onTaskProgress?.(taskTracker.snapshot());
|
|
455
|
+
} catch {
|
|
456
|
+
/* a status push must never break a turn */
|
|
457
|
+
}
|
|
458
|
+
}
|
|
355
459
|
} else if (msg.type === "assistant") {
|
|
356
460
|
// Complete-block envelopes: marker text for the CLI's own tool
|
|
357
461
|
// executions (built-ins, WebSearch, user MCP, …), which in observer
|
|
@@ -396,8 +500,39 @@ export function streamViaCli(
|
|
|
396
500
|
// handoff toolCall) is already accumulated; usage still applies.
|
|
397
501
|
bridge.applyResult(r);
|
|
398
502
|
}
|
|
503
|
+
|
|
504
|
+
// Sub-agents still working: this result ends a CYCLE, not the
|
|
505
|
+
// episode. Leave the CLI alive and keep reading — it re-invokes the
|
|
506
|
+
// model itself once they report, and emits another result. See
|
|
507
|
+
// WAIT_FOR_AGENTS for the capture this is built on.
|
|
508
|
+
if (
|
|
509
|
+
WAIT_FOR_AGENTS &&
|
|
510
|
+
!isError &&
|
|
511
|
+
!selfInterrupted &&
|
|
512
|
+
!aborted &&
|
|
513
|
+
!agentWaitExpired &&
|
|
514
|
+
agentContinuations < MAX_AGENT_CONTINUATIONS &&
|
|
515
|
+
taskTracker.pendingAgents() > 0
|
|
516
|
+
) {
|
|
517
|
+
agentContinuations++;
|
|
518
|
+
waitingForAgents = true;
|
|
519
|
+
if (agentWaitTimer === undefined) {
|
|
520
|
+
agentWaitTimer = setTimeout(() => {
|
|
521
|
+
// Give up waiting, but let the turn end on its own content:
|
|
522
|
+
// the launch markers and whatever the model already said are
|
|
523
|
+
// real, and an error here would throw them away.
|
|
524
|
+
endTurnOnPartialWork();
|
|
525
|
+
}, AGENT_WAIT_TIMEOUT_MS);
|
|
526
|
+
// A pending wait must never hold the host process open.
|
|
527
|
+
agentWaitTimer.unref?.();
|
|
528
|
+
}
|
|
529
|
+
resetInactivityTimer();
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
|
|
399
533
|
// For success, handoff and error alike: clean up the subprocess
|
|
400
534
|
clearTimeout(inactivityTimer);
|
|
535
|
+
clearTimeout(agentWaitTimer);
|
|
401
536
|
cleanupProcess(proc!);
|
|
402
537
|
rl.close();
|
|
403
538
|
}
|
|
@@ -467,6 +602,16 @@ export function streamViaCli(
|
|
|
467
602
|
stream.end();
|
|
468
603
|
} finally {
|
|
469
604
|
cleanupSystemPromptFile();
|
|
605
|
+
// The sub-agent channel is state ABOUT a turn, so it must not outlive
|
|
606
|
+
// one. Left standing, the last snapshot pins whatever the agents were
|
|
607
|
+
// doing when the episode ended — a host then shows "running" for
|
|
608
|
+
// agents that finished, or for agents that died, until the next turn
|
|
609
|
+
// happens to publish something else.
|
|
610
|
+
try {
|
|
611
|
+
options?.onTaskProgress?.({ tasks: [], active: 0, completed: 0 });
|
|
612
|
+
} catch {
|
|
613
|
+
/* a status push must never break a turn */
|
|
614
|
+
}
|
|
470
615
|
}
|
|
471
616
|
})();
|
|
472
617
|
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sub-agent (Task) visibility.
|
|
3
|
+
*
|
|
4
|
+
* The CLI runs sub-agents inside its own process. Their stream envelopes carry
|
|
5
|
+
* `parent_tool_use_id` and are deliberately not forwarded — they are the CLI's
|
|
6
|
+
* internal loop, and forwarding hundreds of nested tool calls would bury the
|
|
7
|
+
* turn. But dropping them left a host with nothing at all: a single
|
|
8
|
+
* `[Claude Code · Task {…}]` marker and then silence for as long as the
|
|
9
|
+
* fan-out ran. One pidex turn sat blank for eight minutes behind 14 nested
|
|
10
|
+
* agents and was killed as hung (issue #23).
|
|
11
|
+
*
|
|
12
|
+
* The CLI already publishes a purpose-built feed for exactly this, and the
|
|
13
|
+
* provider was not reading it. `system` envelopes with a `task_*` subtype
|
|
14
|
+
* arrive at TOP level (`parent_tool_use_id` is null) even for agents nested
|
|
15
|
+
* several deep, verified on claude 2.1.231 at spawn depth 2. They are low
|
|
16
|
+
* volume and carry description, sub-agent type, tool count, tokens and
|
|
17
|
+
* duration.
|
|
18
|
+
*
|
|
19
|
+
* Two channels, split by durability:
|
|
20
|
+
*
|
|
21
|
+
* - **Lifecycle goes in the turn** as marker text. `task_started` and the
|
|
22
|
+
* terminal `task_notification` are durable facts about what the turn did,
|
|
23
|
+
* and belong in the transcript beside the CLI's other tool markers. Two
|
|
24
|
+
* lines per sub-agent, so a 14-agent fan-out costs 28.
|
|
25
|
+
* - **Progress goes out of band**, like `rate_limit_event` before it.
|
|
26
|
+
* `task_progress` fires once per sub-agent tool call — ~700 times in the
|
|
27
|
+
* incident above — which is live state, not transcript. It must never be
|
|
28
|
+
* folded into turn content.
|
|
29
|
+
*
|
|
30
|
+
* What this deliberately does NOT do: build a tree. No task envelope names its
|
|
31
|
+
* parent task, so a nested agent is indistinguishable from a top-level one
|
|
32
|
+
* here. The list is flat, and honestly so.
|
|
33
|
+
*
|
|
34
|
+
* TWO THINGS THIS CHANNEL IS NOT. Both were shipped as sub-agents once, and
|
|
35
|
+
* both put rows in pidex that named work no agent ever did:
|
|
36
|
+
*
|
|
37
|
+
* - **Not every task is an agent.** `task_started` carries `task_type`, and
|
|
38
|
+
* the CLI auto-backgrounds a slow `Bash` into a `local_bash` task with the
|
|
39
|
+
* tool's own `description`. Captured 2026-08-28 on claude 2.1.231: a
|
|
40
|
+
* sub-agent's internal `find` surfaced as `[Claude Code · Task
|
|
41
|
+
* {"status":"started","description":"Search for local source checkout of
|
|
42
|
+
* pi-claude-cli"}]` — a fourth "agent" in a three-agent fan-out. Only
|
|
43
|
+
* `local_agent` and `remote_agent` are sub-agents.
|
|
44
|
+
* - **Not every notification belongs to this episode.** `task_notification`
|
|
45
|
+
* carries no `description` and no `task_type` — only `task_id`. A task
|
|
46
|
+
* started in an EARLIER turn notifies in a later one, and a tracker that
|
|
47
|
+
* invents a placeholder from the id emits `{"status":"stopped",
|
|
48
|
+
* "description":"a8de7d982d824b56a"}`. An id this tracker never saw start
|
|
49
|
+
* is not this episode's business, so it is dropped.
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
import type {
|
|
53
|
+
ClaudeTaskEvent,
|
|
54
|
+
TaskSnapshot,
|
|
55
|
+
TaskTrackerState,
|
|
56
|
+
} from "./types.js";
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Marker argument previews are truncated to keep one row on one line.
|
|
60
|
+
*
|
|
61
|
+
* Raised from 120 when `task_id` joined the payload: at 120 the id pushed
|
|
62
|
+
* `subagent_type` off the end, so a fan-out stopped saying WHICH kind of
|
|
63
|
+
* agent it launched — the one fact the row exists to carry.
|
|
64
|
+
*/
|
|
65
|
+
export const ARGS_PREVIEW_LIMIT = 200;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Per-field cap on the description, applied BEFORE the whole-payload cap.
|
|
69
|
+
*
|
|
70
|
+
* Ordering fields human-first is not enough on its own: one long description
|
|
71
|
+
* eats the entire preview and takes `subagent_type` and `task_id` with it,
|
|
72
|
+
* which is how a 300-character task name could cost a host the identity it
|
|
73
|
+
* needs to collapse the row. Clip the one unbounded field instead, so the
|
|
74
|
+
* small structural ones always survive.
|
|
75
|
+
*/
|
|
76
|
+
export const DESCRIPTION_PREVIEW_LIMIT = 120;
|
|
77
|
+
|
|
78
|
+
function clipDescription(value: string): string {
|
|
79
|
+
return value.length > DESCRIPTION_PREVIEW_LIMIT
|
|
80
|
+
? `${value.slice(0, DESCRIPTION_PREVIEW_LIMIT)}…`
|
|
81
|
+
: value;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* `task_type` values that mean "a sub-agent". Everything else the CLI tracks
|
|
86
|
+
* as a task — `local_bash`, `local_shell`, `local_workflow`, `main_session` —
|
|
87
|
+
* is plumbing this channel must not report as an agent.
|
|
88
|
+
*
|
|
89
|
+
* An event with NO `task_type` is treated as an agent: older CLIs omit the
|
|
90
|
+
* field, and going silent on them would be a worse regression than the stray
|
|
91
|
+
* row this filter exists to remove.
|
|
92
|
+
*/
|
|
93
|
+
const AGENT_TASK_TYPES = new Set(["local_agent", "remote_agent"]);
|
|
94
|
+
|
|
95
|
+
export function isAgentTaskType(taskType: unknown): boolean {
|
|
96
|
+
return taskType === undefined || taskType === null
|
|
97
|
+
? true
|
|
98
|
+
: typeof taskType === "string" && AGENT_TASK_TYPES.has(taskType);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Build a `[Claude Code · Task …]` marker.
|
|
103
|
+
*
|
|
104
|
+
* WIRE CONTRACT — the same shape `event-bridge.ts` emits for CLI-side tool
|
|
105
|
+
* executions, and front-ends parse it with
|
|
106
|
+
* `/^\[Claude Code · ([^\s\]]+)(?:\s+([\s\S]*))?\]$/`. The tool name must stay
|
|
107
|
+
* space-free; everything else rides in the argument JSON, which is truncated
|
|
108
|
+
* here and must never be parsed as JSON by a consumer.
|
|
109
|
+
*/
|
|
110
|
+
function taskMarker(args: Record<string, unknown>): string {
|
|
111
|
+
let preview = "";
|
|
112
|
+
try {
|
|
113
|
+
const json = JSON.stringify(args);
|
|
114
|
+
preview =
|
|
115
|
+
json === "{}"
|
|
116
|
+
? ""
|
|
117
|
+
: ` ${json.slice(0, ARGS_PREVIEW_LIMIT)}${json.length > ARGS_PREVIEW_LIMIT ? "…" : ""}`;
|
|
118
|
+
} catch {
|
|
119
|
+
/* unserializable — the marker still names the tool */
|
|
120
|
+
}
|
|
121
|
+
return `[Claude Code · Task${preview}]`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface TaskTracker {
|
|
125
|
+
/**
|
|
126
|
+
* Fold one `task_*` envelope in. Returns a marker string when the event is a
|
|
127
|
+
* durable lifecycle transition (start, finish) and nothing when it is live
|
|
128
|
+
* progress. Unknown subtypes and events without a `task_id` are ignored.
|
|
129
|
+
*/
|
|
130
|
+
apply(event: ClaudeTaskEvent): string | undefined;
|
|
131
|
+
/** Current state of every sub-agent seen this episode. */
|
|
132
|
+
snapshot(): TaskTrackerState;
|
|
133
|
+
/**
|
|
134
|
+
* Sub-agents that started this episode and have not reported yet.
|
|
135
|
+
*
|
|
136
|
+
* The provider waits on this before it tears the CLI down: the CLI emits
|
|
137
|
+
* its turn `result` the moment the model stops talking, which for a
|
|
138
|
+
* background fan-out is long before the agents finish.
|
|
139
|
+
*/
|
|
140
|
+
pendingAgents(): number;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function createTaskTracker(): TaskTracker {
|
|
144
|
+
/** Insertion-ordered, so the snapshot reads in launch order. */
|
|
145
|
+
const tasks = new Map<string, TaskSnapshot>();
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Patch a task this tracker already knows. Returns undefined for an id it
|
|
149
|
+
* never saw start — a task from an earlier episode, or one filtered out as
|
|
150
|
+
* not-an-agent. Inventing a placeholder here is what named rows after raw
|
|
151
|
+
* task ids; see the header.
|
|
152
|
+
*/
|
|
153
|
+
function patch(
|
|
154
|
+
id: string,
|
|
155
|
+
changes: Partial<TaskSnapshot>,
|
|
156
|
+
): TaskSnapshot | undefined {
|
|
157
|
+
const existing = tasks.get(id);
|
|
158
|
+
if (!existing) return undefined;
|
|
159
|
+
Object.assign(existing, changes);
|
|
160
|
+
return existing;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
apply(event: ClaudeTaskEvent): string | undefined {
|
|
165
|
+
const id = event.task_id;
|
|
166
|
+
if (!id) return undefined;
|
|
167
|
+
|
|
168
|
+
switch (event.subtype) {
|
|
169
|
+
case "task_started": {
|
|
170
|
+
// Plumbing, not a sub-agent: an auto-backgrounded Bash arrives here
|
|
171
|
+
// wearing the tool's own description. See the header.
|
|
172
|
+
if (!isAgentTaskType(event.task_type)) return undefined;
|
|
173
|
+
const task: TaskSnapshot = {
|
|
174
|
+
taskId: id,
|
|
175
|
+
description: event.description ?? id,
|
|
176
|
+
subagentType: event.subagent_type,
|
|
177
|
+
taskType: event.task_type,
|
|
178
|
+
toolUseId: event.tool_use_id,
|
|
179
|
+
status: "running",
|
|
180
|
+
};
|
|
181
|
+
tasks.set(id, task);
|
|
182
|
+
// `skip_transcript` suppresses the ROW, never the tracking: a
|
|
183
|
+
// hidden sub-agent still holds the turn open, and the provider
|
|
184
|
+
// reads `pendingAgents()` to decide when the turn may end.
|
|
185
|
+
if (event.skip_transcript === true) return undefined;
|
|
186
|
+
return taskMarker({
|
|
187
|
+
status: "started",
|
|
188
|
+
description: clipDescription(task.description),
|
|
189
|
+
subagent_type: task.subagentType,
|
|
190
|
+
task_id: task.taskId,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
case "task_progress": {
|
|
195
|
+
patch(id, {
|
|
196
|
+
// `description` on a progress event is the CURRENT step ("Running
|
|
197
|
+
// …"), not the task's own description. Keep them apart: the task
|
|
198
|
+
// name was set at start and must not be overwritten by a step.
|
|
199
|
+
currentStep: event.description,
|
|
200
|
+
subagentType: event.subagent_type ?? tasks.get(id)?.subagentType,
|
|
201
|
+
lastToolName: event.last_tool_name,
|
|
202
|
+
toolUses: event.usage?.tool_uses,
|
|
203
|
+
totalTokens: event.usage?.total_tokens,
|
|
204
|
+
durationMs: event.usage?.duration_ms,
|
|
205
|
+
});
|
|
206
|
+
return undefined;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
case "task_updated": {
|
|
210
|
+
const status = event.patch?.status;
|
|
211
|
+
if (status) patch(id, { status });
|
|
212
|
+
return undefined;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
case "task_notification": {
|
|
216
|
+
const task = patch(id, {
|
|
217
|
+
status: event.status ?? "completed",
|
|
218
|
+
outputFile: event.output_file,
|
|
219
|
+
summary: event.summary,
|
|
220
|
+
toolUses: event.usage?.tool_uses ?? tasks.get(id)?.toolUses,
|
|
221
|
+
totalTokens:
|
|
222
|
+
event.usage?.total_tokens ?? tasks.get(id)?.totalTokens,
|
|
223
|
+
durationMs: event.usage?.duration_ms ?? tasks.get(id)?.durationMs,
|
|
224
|
+
currentStep: undefined,
|
|
225
|
+
});
|
|
226
|
+
// Not ours: a task from an earlier episode, or one filtered out at
|
|
227
|
+
// start. A notification carries no description, so the only row
|
|
228
|
+
// this could produce would be named after a raw task id.
|
|
229
|
+
if (!task) return undefined;
|
|
230
|
+
// The sub-agent's full report reaches the model as the Task tool's
|
|
231
|
+
// own result. Repeating it here would duplicate kilobytes into the
|
|
232
|
+
// transcript, so the marker carries the shape of the work, not its
|
|
233
|
+
// output.
|
|
234
|
+
return taskMarker({
|
|
235
|
+
status: task.status,
|
|
236
|
+
description: clipDescription(task.description),
|
|
237
|
+
task_id: task.taskId,
|
|
238
|
+
tool_uses: task.toolUses,
|
|
239
|
+
total_tokens: task.totalTokens,
|
|
240
|
+
duration_ms: task.durationMs,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
default:
|
|
245
|
+
return undefined;
|
|
246
|
+
}
|
|
247
|
+
},
|
|
248
|
+
|
|
249
|
+
snapshot(): TaskTrackerState {
|
|
250
|
+
const list = [...tasks.values()].map((t) => ({ ...t }));
|
|
251
|
+
return {
|
|
252
|
+
tasks: list,
|
|
253
|
+
active: list.filter((t) => t.status === "running").length,
|
|
254
|
+
completed: list.filter((t) => t.status !== "running").length,
|
|
255
|
+
};
|
|
256
|
+
},
|
|
257
|
+
|
|
258
|
+
pendingAgents(): number {
|
|
259
|
+
let pending = 0;
|
|
260
|
+
for (const task of tasks.values()) {
|
|
261
|
+
if (task.status === "running") pending++;
|
|
262
|
+
}
|
|
263
|
+
return pending;
|
|
264
|
+
},
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** The `system` subtypes this module handles. */
|
|
269
|
+
const TASK_SUBTYPES = new Set([
|
|
270
|
+
"task_started",
|
|
271
|
+
"task_progress",
|
|
272
|
+
"task_updated",
|
|
273
|
+
"task_notification",
|
|
274
|
+
]);
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Whether a `system` envelope's subtype is sub-agent lifecycle.
|
|
278
|
+
*
|
|
279
|
+
* `system` also carries init/status/summary envelopes that have nothing to do
|
|
280
|
+
* with sub-agents, so the provider narrows before handing anything over.
|
|
281
|
+
*/
|
|
282
|
+
export function isTaskSubtype(subtype: unknown): boolean {
|
|
283
|
+
return typeof subtype === "string" && TASK_SUBTYPES.has(subtype);
|
|
284
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -106,6 +106,87 @@ export interface ClaudeSystemMessage {
|
|
|
106
106
|
tools?: unknown[];
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
/**
|
|
110
|
+
* Sub-agent lifecycle, emitted by the CLI as `system` envelopes.
|
|
111
|
+
*
|
|
112
|
+
* These arrive at TOP level — `parent_tool_use_id` is null — even for agents
|
|
113
|
+
* nested several deep, verified on claude 2.1.231 at spawn depth 2. That is
|
|
114
|
+
* what makes them usable: the sub-agents' own `assistant` envelopes are
|
|
115
|
+
* tagged with `parent_tool_use_id` and stay internal to the CLI, but their
|
|
116
|
+
* lifecycle is published here in the open.
|
|
117
|
+
*
|
|
118
|
+
* `description` means two different things by subtype. On `task_started` it
|
|
119
|
+
* names the task; on `task_progress` it is the step running right now
|
|
120
|
+
* ("Running …"). `src/task-tracker.ts` keeps them apart.
|
|
121
|
+
*/
|
|
122
|
+
export interface ClaudeTaskEvent {
|
|
123
|
+
type: "system";
|
|
124
|
+
subtype:
|
|
125
|
+
| "task_started"
|
|
126
|
+
| "task_progress"
|
|
127
|
+
| "task_updated"
|
|
128
|
+
| "task_notification"
|
|
129
|
+
| string;
|
|
130
|
+
task_id?: string;
|
|
131
|
+
tool_use_id?: string;
|
|
132
|
+
description?: string;
|
|
133
|
+
subagent_type?: string;
|
|
134
|
+
task_type?: string;
|
|
135
|
+
/** Terminal status on `task_notification`. */
|
|
136
|
+
status?: string;
|
|
137
|
+
/** Where the CLI wrote the sub-agent's full report. */
|
|
138
|
+
output_file?: string;
|
|
139
|
+
/** Partial state change on `task_updated`. */
|
|
140
|
+
patch?: { status?: string; end_time?: number };
|
|
141
|
+
last_tool_name?: string;
|
|
142
|
+
/**
|
|
143
|
+
* The CLI's own "do not put this in a transcript" hint. Set on tasks it
|
|
144
|
+
* considers plumbing; honoured for markers, not for tracking, because a
|
|
145
|
+
* hidden sub-agent still holds the turn open.
|
|
146
|
+
*/
|
|
147
|
+
skip_transcript?: boolean;
|
|
148
|
+
/** The sub-agent's report, on `task_notification`. Live state, not content. */
|
|
149
|
+
summary?: string;
|
|
150
|
+
usage?: {
|
|
151
|
+
total_tokens?: number;
|
|
152
|
+
tool_uses?: number;
|
|
153
|
+
duration_ms?: number;
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** One sub-agent's state, as the host sees it. */
|
|
158
|
+
export interface TaskSnapshot {
|
|
159
|
+
taskId: string;
|
|
160
|
+
/** Names the task. Set at `task_started`, never overwritten by a step. */
|
|
161
|
+
description: string;
|
|
162
|
+
subagentType?: string;
|
|
163
|
+
/**
|
|
164
|
+
* The CLI's task kind — `local_agent` / `remote_agent` for a sub-agent.
|
|
165
|
+
* Undefined on a CLI too old to send it, which is read as "agent" so the
|
|
166
|
+
* tracker keeps working rather than going silent.
|
|
167
|
+
*/
|
|
168
|
+
taskType?: string;
|
|
169
|
+
/** The `Agent` tool call that launched this task; the join key a host needs. */
|
|
170
|
+
toolUseId?: string;
|
|
171
|
+
status: string;
|
|
172
|
+
/** The step running right now, cleared when the task ends. */
|
|
173
|
+
currentStep?: string;
|
|
174
|
+
lastToolName?: string;
|
|
175
|
+
toolUses?: number;
|
|
176
|
+
totalTokens?: number;
|
|
177
|
+
durationMs?: number;
|
|
178
|
+
outputFile?: string;
|
|
179
|
+
/** The sub-agent's own report, once it finishes. */
|
|
180
|
+
summary?: string;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Every sub-agent seen this episode, in launch order. */
|
|
184
|
+
export interface TaskTrackerState {
|
|
185
|
+
tasks: TaskSnapshot[];
|
|
186
|
+
active: number;
|
|
187
|
+
completed: number;
|
|
188
|
+
}
|
|
189
|
+
|
|
109
190
|
export interface ClaudeControlRequest {
|
|
110
191
|
type: "control_request";
|
|
111
192
|
request_id: string;
|
|
@@ -120,6 +201,7 @@ export type NdjsonMessage =
|
|
|
120
201
|
| ClaudeStreamEventMessage
|
|
121
202
|
| ClaudeResultMessage
|
|
122
203
|
| ClaudeSystemMessage
|
|
204
|
+
| ClaudeTaskEvent
|
|
123
205
|
| ClaudeControlRequest
|
|
124
206
|
| ClaudeAssistantEnvelope
|
|
125
207
|
| ClaudeUserEnvelope
|