@saccolabs/pi-claude-cli 0.4.13 → 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 CHANGED
@@ -51,6 +51,9 @@ Requires the `claude` binary on your login-shell PATH (`npm install -g @anthropi
51
51
  - Surfaces sub-agent fan-outs: one marker when a `Task` agent starts and one
52
52
  when it reports, plus live per-agent progress on the `claude-subagents`
53
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
54
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
55
58
  - Cross-platform subprocess management (Windows, macOS, Linux)
56
59
  - Inactivity timeout and process registry for cleanup
package/index.ts CHANGED
@@ -80,7 +80,15 @@ function publishTaskProgress(state: TaskTrackerState): void {
80
80
  if (json === lastSubagentsJson) return;
81
81
  lastSubagentsJson = json;
82
82
  try {
83
- setStatus.call(uiContext!.ui, SUBAGENTS_STATUS_KEY, json);
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
+ );
84
92
  } catch {
85
93
  /* never break a turn over a status push */
86
94
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saccolabs/pi-claude-cli",
3
- "version": "0.4.13",
3
+ "version": "0.4.14",
4
4
  "description": "Pi coding agent extension that routes LLM calls through the Claude Code CLI",
5
5
  "main": "index.ts",
6
6
  "keywords": [
@@ -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
@@ -62,6 +62,49 @@ const INACTIVITY_TIMEOUT_MS =
62
62
  ? Number(process.env.PI_CLAUDE_CLI_TIMEOUT_MS)
63
63
  : 300_000;
64
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
+
65
108
  /** Extended stream options: pi's SimpleStreamOptions plus optional cwd and mcpConfigPath */
66
109
  type StreamViaCLiOptions = SimpleStreamOptions & {
67
110
  cwd?: string;
@@ -240,9 +283,41 @@ export function streamViaCli(
240
283
  // Inactivity timeout: kill subprocess if no stdout for INACTIVITY_TIMEOUT_MS
241
284
  let inactivityTimer: ReturnType<typeof setTimeout> | undefined;
242
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
+
243
310
  function resetInactivityTimer() {
244
311
  if (inactivityTimer !== undefined) clearTimeout(inactivityTimer);
245
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
+ }
246
321
  forceKillProcess(proc!);
247
322
  endStreamWithError(
248
323
  `Claude CLI subprocess timed out: no output for ${INACTIVITY_TIMEOUT_MS / 1000} seconds`,
@@ -289,6 +364,7 @@ export function streamViaCli(
289
364
  // Handle subprocess close -- surface crashes with stderr and exit code
290
365
  proc.on("close", (code: number | null, _signal: string | null) => {
291
366
  clearTimeout(inactivityTimer);
367
+ clearTimeout(agentWaitTimer);
292
368
  if (broken) return; // resume-miss retry owns the stream
293
369
  if (code !== 0 && code !== null) {
294
370
  const stderr = getStderr();
@@ -424,8 +500,39 @@ export function streamViaCli(
424
500
  // handoff toolCall) is already accumulated; usage still applies.
425
501
  bridge.applyResult(r);
426
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
+
427
533
  // For success, handoff and error alike: clean up the subprocess
428
534
  clearTimeout(inactivityTimer);
535
+ clearTimeout(agentWaitTimer);
429
536
  cleanupProcess(proc!);
430
537
  rl.close();
431
538
  }
@@ -495,6 +602,16 @@ export function streamViaCli(
495
602
  stream.end();
496
603
  } finally {
497
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
+ }
498
615
  }
499
616
  })();
500
617
 
@@ -30,6 +30,23 @@
30
30
  * What this deliberately does NOT do: build a tree. No task envelope names its
31
31
  * parent task, so a nested agent is indistinguishable from a top-level one
32
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.
33
50
  */
34
51
 
35
52
  import type {
@@ -38,8 +55,48 @@ import type {
38
55
  TaskTrackerState,
39
56
  } from "./types.js";
40
57
 
41
- /** Marker argument previews are truncated to keep one row on one line. */
42
- const ARGS_PREVIEW_LIMIT = 120;
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
+ }
43
100
 
44
101
  /**
45
102
  * Build a `[Claude Code · Task …]` marker.
@@ -73,24 +130,34 @@ export interface TaskTracker {
73
130
  apply(event: ClaudeTaskEvent): string | undefined;
74
131
  /** Current state of every sub-agent seen this episode. */
75
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;
76
141
  }
77
142
 
78
143
  export function createTaskTracker(): TaskTracker {
79
144
  /** Insertion-ordered, so the snapshot reads in launch order. */
80
145
  const tasks = new Map<string, TaskSnapshot>();
81
146
 
82
- function upsert(id: string, patch: Partial<TaskSnapshot>): TaskSnapshot {
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 {
83
157
  const existing = tasks.get(id);
84
- const next: TaskSnapshot = existing ?? {
85
- taskId: id,
86
- // A progress event can arrive before the start it belongs to if the CLI
87
- // reorders; the id is a truthful placeholder until the start names it.
88
- description: id,
89
- status: "running",
90
- };
91
- Object.assign(next, patch);
92
- tasks.set(id, next);
93
- return next;
158
+ if (!existing) return undefined;
159
+ Object.assign(existing, changes);
160
+ return existing;
94
161
  }
95
162
 
96
163
  return {
@@ -100,20 +167,32 @@ export function createTaskTracker(): TaskTracker {
100
167
 
101
168
  switch (event.subtype) {
102
169
  case "task_started": {
103
- const task = upsert(id, {
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,
104
175
  description: event.description ?? id,
105
176
  subagentType: event.subagent_type,
177
+ taskType: event.task_type,
178
+ toolUseId: event.tool_use_id,
106
179
  status: "running",
107
- });
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;
108
186
  return taskMarker({
109
187
  status: "started",
110
- description: task.description,
188
+ description: clipDescription(task.description),
111
189
  subagent_type: task.subagentType,
190
+ task_id: task.taskId,
112
191
  });
113
192
  }
114
193
 
115
194
  case "task_progress": {
116
- upsert(id, {
195
+ patch(id, {
117
196
  // `description` on a progress event is the CURRENT step ("Running
118
197
  // …"), not the task's own description. Keep them apart: the task
119
198
  // name was set at start and must not be overwritten by a step.
@@ -129,27 +208,33 @@ export function createTaskTracker(): TaskTracker {
129
208
 
130
209
  case "task_updated": {
131
210
  const status = event.patch?.status;
132
- upsert(id, status ? { status } : {});
211
+ if (status) patch(id, { status });
133
212
  return undefined;
134
213
  }
135
214
 
136
215
  case "task_notification": {
137
- const task = upsert(id, {
216
+ const task = patch(id, {
138
217
  status: event.status ?? "completed",
139
218
  outputFile: event.output_file,
219
+ summary: event.summary,
140
220
  toolUses: event.usage?.tool_uses ?? tasks.get(id)?.toolUses,
141
221
  totalTokens:
142
222
  event.usage?.total_tokens ?? tasks.get(id)?.totalTokens,
143
223
  durationMs: event.usage?.duration_ms ?? tasks.get(id)?.durationMs,
144
224
  currentStep: undefined,
145
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;
146
230
  // The sub-agent's full report reaches the model as the Task tool's
147
231
  // own result. Repeating it here would duplicate kilobytes into the
148
232
  // transcript, so the marker carries the shape of the work, not its
149
233
  // output.
150
234
  return taskMarker({
151
235
  status: task.status,
152
- description: task.description,
236
+ description: clipDescription(task.description),
237
+ task_id: task.taskId,
153
238
  tool_uses: task.toolUses,
154
239
  total_tokens: task.totalTokens,
155
240
  duration_ms: task.durationMs,
@@ -169,6 +254,14 @@ export function createTaskTracker(): TaskTracker {
169
254
  completed: list.filter((t) => t.status !== "running").length,
170
255
  };
171
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
+ },
172
265
  };
173
266
  }
174
267
 
package/src/types.ts CHANGED
@@ -139,6 +139,14 @@ export interface ClaudeTaskEvent {
139
139
  /** Partial state change on `task_updated`. */
140
140
  patch?: { status?: string; end_time?: number };
141
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;
142
150
  usage?: {
143
151
  total_tokens?: number;
144
152
  tool_uses?: number;
@@ -152,6 +160,14 @@ export interface TaskSnapshot {
152
160
  /** Names the task. Set at `task_started`, never overwritten by a step. */
153
161
  description: string;
154
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;
155
171
  status: string;
156
172
  /** The step running right now, cleared when the task ends. */
157
173
  currentStep?: string;
@@ -160,6 +176,8 @@ export interface TaskSnapshot {
160
176
  totalTokens?: number;
161
177
  durationMs?: number;
162
178
  outputFile?: string;
179
+ /** The sub-agent's own report, once it finishes. */
180
+ summary?: string;
163
181
  }
164
182
 
165
183
  /** Every sub-agent seen this episode, in launch order. */