@d3ara1n/pi-subagent 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -260,7 +260,7 @@ When a provider error (429, quota, timeout, ...) kills a run and the whole task
260
260
 
261
261
  ### Run history
262
262
 
263
- Every completed delegate run is written (best-effort) to `~/.pi/subagent/history/{sessionId}/{toolCallId}.json`, recording role, task, usage, activity log, the **full raw output** (even when the main model saw a compressed/truncated version), and the `fallbackFrom` snapshot when the run was retried on the fallback role. Useful for auditing what subagents did and how much they cost. Disable with `history.enabled: false`.
263
+ Every **spawned** delegate run is written (best-effort) to `~/.pi/subagent/history/{sessionId}/{toolCallId}.json` finished, failed, and aborted alike (an aborted run already consumed tokens, so its partial activity and cost stay auditable). Records cover role, task, usage, activity log, the **full raw output** (even when the main model saw a compressed/truncated version), and the `fallbackFrom` snapshot when the run was retried on the fallback role. Runs that never spawned (cancelled while queued, role/model resolution failures) are not recorded. Useful for auditing what subagents did and how much they cost. Disable with `history.enabled: false`.
264
264
 
265
265
  ## License
266
266
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@d3ara1n/pi-subagent",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "type": "module",
5
5
  "description": "Role-based subagent orchestration for pi — delegates tasks to specialized pi child processes with configurable model roles",
6
6
  "main": "src/index.ts",
package/src/history.ts CHANGED
@@ -1,9 +1,13 @@
1
1
  /**
2
2
  * History persistence for pi-subagent delegate runs.
3
3
  *
4
- * Best-effort audit log: writes one JSON record per delegate run under
5
- * ~/.pi/subagent/history/{sessionId}/{toolCallId}.json. Never throws persistence
6
- * must not fail the delegation. Privacy parity with pi's own session files.
4
+ * Best-effort audit log: writes one JSON record per *spawned* delegate run
5
+ * finished, failed, and aborted alike (an aborted run already consumed
6
+ * tokens, so its partial activity and cost stay auditable). Pre-run failures
7
+ * that never spawned (queued-cancel, role/model resolution) are not recorded.
8
+ * Records land under ~/.pi/subagent/history/{sessionId}/{toolCallId}.json.
9
+ * Never throws — persistence must not fail the delegation. Privacy parity
10
+ * with pi's own session files.
7
11
  */
8
12
 
9
13
  import * as os from "node:os";
package/src/index.ts CHANGED
@@ -345,14 +345,6 @@ export default function subagentExtension(pi: ExtensionAPI) {
345
345
  try {
346
346
  const result = await run.promise;
347
347
 
348
- // Pipeline throws (abort, spawn crash) surface as tool errors. The
349
- // empty-results frame keeps the TUI on the plain-content fallback.
350
- if (run.thrown) {
351
- const errorText = `Subagent (${params.role}) error: ${run.thrown.message || run.thrown}`;
352
- emit([], errorText);
353
- throw new Error(errorText);
354
- }
355
-
356
348
  // Fallback note: the main model must know the answer came from the
357
349
  // fallback model, not the role's primary — on success AND failure.
358
350
  // Budget note: budget stops are intentional successes, but the model
@@ -360,6 +352,10 @@ export default function subagentExtension(pi: ExtensionAPI) {
360
352
  const fallbackNote = formatFallbackNote(result);
361
353
  const budgetNote = formatBudgetNote(result);
362
354
 
355
+ // Aborts and spawn crashes arrive here too: the engine resolves them
356
+ // into failed results that keep the partial frame (task, activity,
357
+ // output, usage), so the TUI renders them like any failure instead
358
+ // of collapsing to a bare error line.
363
359
  if (isFailedResult(result)) {
364
360
  const failedText =
365
361
  `Subagent (${params.role}) failed: ${result.errorMessage || result.stderr || "unknown error"}\n\nPartial output:\n${result.output}` +
package/src/render.ts CHANGED
@@ -45,11 +45,11 @@ export const renderDelegateResult: RenderResultFn = (result, { expanded }, theme
45
45
  const isRunning = !!details?.results[0] && details.results[0].exitCode === -1;
46
46
 
47
47
  // Tick elapsed time every second while running; stop once terminal.
48
- // Placed BEFORE the empty-results early return so every terminal path
49
- // (abort, model-resolution failure, catch) still clears the timer
50
- // otherwise the interval leaks a permanent 1 Hz re-render per aborted run.
51
- // The timer calls context.invalidate() so the render recomputes elapsed
52
- // time fresh from Date.now() without dirtying the data layer.
48
+ // Placed BEFORE the missing-details early return so every terminal path
49
+ // still clears the timer otherwise the interval leaks a permanent
50
+ // 1 Hz re-render per row. The timer calls context.invalidate() so the
51
+ // render recomputes elapsed time fresh from Date.now() without dirtying
52
+ // the data layer.
53
53
  if (isRunning) {
54
54
  ensureElapsedTimer(context);
55
55
  } else {
package/src/run.test.ts CHANGED
@@ -123,7 +123,11 @@ test("non-zero exit yields state failed without thrown", async () => {
123
123
  });
124
124
 
125
125
  test("a throwing spawn resolves the promise with a failed result carrying the error", async () => {
126
- const spawnImpl: SpawnImpl = async () => {
126
+ const spawnImpl: SpawnImpl = async (_m, _t, options) => {
127
+ options.onProgress?.({
128
+ output: "partial",
129
+ activityLog: [{ kind: "toolCall", id: "t1", status: "done", toolName: "read", args: {} }],
130
+ });
127
131
  throw new Error("Subagent was aborted");
128
132
  };
129
133
 
@@ -134,6 +138,67 @@ test("a throwing spawn resolves the promise with a failed result carrying the er
134
138
  assert.ok(run.thrown instanceof Error);
135
139
  assert.strictEqual(run.thrown.message, "Subagent was aborted");
136
140
  assert.strictEqual(result.errorMessage, "Subagent was aborted");
141
+ // The partial frame survives — the foreground path renders aborts like any
142
+ // failure (task line + activity + result line) instead of a bare error.
143
+ assert.strictEqual(result.output, "partial");
144
+ assert.strictEqual(result.activityLog.length, 1);
145
+ });
146
+
147
+ test("spawned runs persist to history on every terminal path; pre-run failures do not", async () => {
148
+ const persisted: SubagentResult[] = [];
149
+ const persistImpl = (
150
+ _sessionId: string | undefined,
151
+ _toolCallId: string,
152
+ _role: string,
153
+ _task: string,
154
+ r: SubagentResult,
155
+ ) => {
156
+ persisted.push(r);
157
+ };
158
+ const historyConfig = { ...testConfig, history: { enabled: true } };
159
+
160
+ // Abort mid-run: the run spawned, so it must be audited.
161
+ const aborted = startSubagentRun(
162
+ makeDeps({
163
+ config: historyConfig,
164
+ spawnImpl: async (_m, _t, options) => {
165
+ options.onProgress?.({
166
+ output: "partial",
167
+ activityLog: [{ kind: "toolCall", id: "t1", status: "running", toolName: "bash", args: {} }],
168
+ });
169
+ throw new Error("Subagent was aborted");
170
+ },
171
+ persistImpl,
172
+ }),
173
+ );
174
+ await aborted.promise;
175
+ assert.equal(persisted.length, 1);
176
+ assert.match(persisted[0].errorMessage!, /aborted/);
177
+ assert.equal(persisted[0].activityLog.length, 1);
178
+
179
+ // Pre-run failure (roles api unavailable): never spawned, not audited.
180
+ const prerun = startSubagentRun(
181
+ makeDeps({
182
+ config: historyConfig,
183
+ getRolesApi: () => {
184
+ throw new Error("not initialized");
185
+ },
186
+ persistImpl,
187
+ }),
188
+ );
189
+ await prerun.promise;
190
+ assert.equal(persisted.length, 1);
191
+
192
+ // Normal success is audited too.
193
+ const ok = startSubagentRun(
194
+ makeDeps({
195
+ config: historyConfig,
196
+ spawnImpl: async () => makeResult({ output: "done" }),
197
+ persistImpl,
198
+ }),
199
+ );
200
+ await ok.promise;
201
+ assert.equal(persisted.length, 2);
137
202
  });
138
203
 
139
204
  test("provider error on first attempt retries on the fallback role", async () => {
package/src/run.ts CHANGED
@@ -48,7 +48,7 @@ export interface RunHandle {
48
48
  readonly snapshot: SubagentResult;
49
49
  /** Terminal result; undefined while queued/running. */
50
50
  readonly result: SubagentResult | undefined;
51
- /** Set when the pipeline threw (abort, spawn crash). Foreground callers rethrow; wait/check only see state "failed". */
51
+ /** Set when the pipeline threw (abort, spawn crash). The terminal result still carries the partial frame — callers report it as an ordinary failed result; wait/check only see state "failed". */
52
52
  readonly thrown: Error | undefined;
53
53
  /** Resolves with the terminal result once the run finishes (always succeeds). */
54
54
  readonly promise: Promise<SubagentResult>;
@@ -81,6 +81,8 @@ export interface StartRunOptions {
81
81
  getSessionId?: () => string | undefined;
82
82
  /** @internal — injectable spawn for tests. */
83
83
  spawnImpl?: typeof spawnSubagent;
84
+ /** @internal — injectable history persistence for tests. */
85
+ persistImpl?: typeof persistSubagentHistory;
84
86
  }
85
87
 
86
88
  export function startSubagentRun(opts: StartRunOptions): RunHandle {
@@ -164,11 +166,27 @@ export function startSubagentRun(opts: StartRunOptions): RunHandle {
164
166
  try {
165
167
  await opts.gate.acquire(opts.signal);
166
168
  } catch {
167
- const msg = `Subagent (${opts.role}) was cancelled while queued.`;
168
- finish({ ...inputFrame(1, false), errorMessage: msg }, new Error("cancelled while queued"));
169
+ const msg = "cancelled while queued for a concurrency slot";
170
+ finish({ ...inputFrame(1, false), errorMessage: msg }, new Error(msg));
169
171
  return;
170
172
  }
171
173
 
174
+ // Audit every spawned run — finished, failed, and aborted alike: an
175
+ // aborted run already consumed tokens, so its cost must stay in the
176
+ // audit log. Pre-run failures (queued-cancel, role/model resolution)
177
+ // never spawned and are not recorded.
178
+ const persist = opts.persistImpl ?? persistSubagentHistory;
179
+ const persistHistory = (terminal: SubagentResult, rawOutput?: string): void => {
180
+ if (!opts.config.history.enabled) return;
181
+ let sessionId: string | undefined;
182
+ try {
183
+ sessionId = opts.getSessionId?.();
184
+ } catch {
185
+ /* ignore */
186
+ }
187
+ persist(sessionId, opts.toolCallId, opts.role, opts.task, terminal, rawOutput);
188
+ };
189
+
172
190
  try {
173
191
  // Resolve the model AFTER acquiring so the queued period stays zero-cost.
174
192
  let rolesApi: ModelRolesAPI;
@@ -312,37 +330,30 @@ export function startSubagentRun(opts: StartRunOptions): RunHandle {
312
330
  runResult.summary = await generateSummary(rolesApi, runResult.output, opts.config.summary);
313
331
  }
314
332
 
315
- // Persist audit record (best-effort; covers both success and failure).
316
- // History keeps the raw original output even when LLM/TUI saw a compressed/truncated version.
317
- if (opts.config.history.enabled) {
318
- let sessionId: string | undefined;
319
- try {
320
- sessionId = opts.getSessionId?.();
321
- } catch {
322
- /* ignore */
323
- }
324
- persistSubagentHistory(sessionId, opts.toolCallId, opts.role, opts.task, runResult, rawOutput);
325
- }
333
+ // Best-effort audit record. The raw original output is kept even when
334
+ // the LLM/TUI saw a compressed/truncated version.
335
+ persistHistory(runResult, rawOutput);
326
336
 
327
337
  finish(runResult);
328
338
  } catch (err: any) {
329
339
  // Keep whatever the last live frame gathered so aborted/crashed runs
330
340
  // still show their partial activity and usage.
331
341
  const partial = snapshot;
332
- finish(
333
- {
334
- ...inputFrame(1, false),
335
- output: partial.output,
336
- usage: partial.usage,
337
- model: partial.model,
338
- stopReason: partial.stopReason,
339
- activityLog: partial.activityLog,
340
- budgetMs: partial.budgetMs,
341
- elapsedMs: partial.startTime ? Date.now() - partial.startTime : undefined,
342
- errorMessage: err?.message || String(err),
343
- },
344
- err instanceof Error ? err : new Error(String(err)),
345
- );
342
+ const terminal: SubagentResult = {
343
+ ...inputFrame(1, false),
344
+ output: partial.output,
345
+ usage: partial.usage,
346
+ model: partial.model,
347
+ stopReason: partial.stopReason,
348
+ activityLog: partial.activityLog,
349
+ budgetMs: partial.budgetMs,
350
+ elapsedMs: partial.startTime ? Date.now() - partial.startTime : undefined,
351
+ errorMessage: err?.message || String(err),
352
+ };
353
+ // The run spawned before throwing — audit it like any terminal state.
354
+ // The partial output is raw (compression never ran on it).
355
+ persistHistory(terminal);
356
+ finish(terminal, err instanceof Error ? err : new Error(String(err)));
346
357
  } finally {
347
358
  opts.gate.release();
348
359
  }
package/src/types.ts CHANGED
@@ -12,7 +12,7 @@ export interface SubagentConfig {
12
12
  maxTurns: number;
13
13
  /** Default cumulative cost budget in USD. `0` means unlimited; negative values are normalized to `0`. Per-role maxCost overrides this. */
14
14
  maxCost: number;
15
- /** Persist each delegate run to ~/.pi/subagent/history/{sessionId}/{toolCallId}.json for auditing. */
15
+ /** Persist every spawned delegate run (finished/failed/aborted alike) to ~/.pi/subagent/history/{sessionId}/{toolCallId}.json for auditing. Pre-run failures that never spawned are not recorded. */
16
16
  history: SubagentHistoryConfig;
17
17
  summary: SubagentSummaryConfig;
18
18
  /**