@pi-archimedes/subagent 2.10.4 → 2.11.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.
Files changed (47) hide show
  1. package/README.md +2 -2
  2. package/package.json +10 -10
  3. package/src/agent-panel.mcp.test.ts +76 -0
  4. package/src/agent-panel.tools.test.ts +162 -0
  5. package/src/agent-panel.ts +37 -8
  6. package/src/agent-panel.width-audit.test.ts +258 -0
  7. package/src/agents-parse.test.ts +252 -0
  8. package/src/agents.ts +72 -5
  9. package/src/compact.test.ts +136 -57
  10. package/src/compact.ts +57 -37
  11. package/src/dispatch.test.ts +73 -1
  12. package/src/dispatch.ts +20 -2
  13. package/src/execute.test.ts +9 -1
  14. package/src/execute.ts +26 -29
  15. package/src/expanded.test.ts +472 -0
  16. package/src/expanded.ts +202 -82
  17. package/src/format.test.ts +71 -29
  18. package/src/format.ts +71 -28
  19. package/src/frontmatter-io.test.ts +62 -0
  20. package/src/frontmatter-io.ts +16 -2
  21. package/src/handlers.test.ts +21 -3
  22. package/src/handlers.ts +22 -3
  23. package/src/index.output-schema.test.ts +317 -0
  24. package/src/index.test.ts +566 -0
  25. package/src/index.ts +169 -16
  26. package/src/render.ts +46 -36
  27. package/src/row-block.test.ts +244 -0
  28. package/src/row-block.ts +209 -0
  29. package/src/run-outcome.test.ts +71 -0
  30. package/src/run-outcome.ts +84 -0
  31. package/src/spawn.test.ts +204 -19
  32. package/src/spawn.ts +49 -17
  33. package/src/stream.test.ts +215 -17
  34. package/src/stream.ts +47 -9
  35. package/src/thinking-validation.test.ts +96 -0
  36. package/src/thinking-validation.ts +78 -0
  37. package/src/tool-declarations.test.ts +58 -0
  38. package/src/tool-row.width-audit.test.ts +720 -0
  39. package/src/tool-schema.test.ts +154 -0
  40. package/src/tool-schema.ts +106 -4
  41. package/src/tool-validation.test.ts +321 -0
  42. package/src/tool-validation.ts +262 -0
  43. package/src/types.ts +86 -0
  44. package/src/usage.test.ts +95 -0
  45. package/src/usage.ts +87 -0
  46. package/src/cost.test.ts +0 -62
  47. package/src/cost.ts +0 -8
package/src/dispatch.ts CHANGED
@@ -32,6 +32,7 @@
32
32
 
33
33
  import { dispatch, BridgeTransportError, BridgeCancelledError } from "@pi-archimedes/core/bridge";
34
34
  import { resolveModel } from "./spawn.js";
35
+ import { resolveThinkingLevel } from "./thinking-validation.js";
35
36
  import type { ExecuteOptions } from "./execute.js";
36
37
  import type { SubagentProgress, SubagentResult } from "./types.js";
37
38
 
@@ -101,7 +102,9 @@ export function dispatchViaBridge(
101
102
  task: options.task,
102
103
  systemPrompt: options.agentConfig?.systemPrompt?.trim() || null,
103
104
  model: model ?? null,
104
- thinking: options.agentConfig?.thinking ?? null,
105
+ // The same rule the fork path applies: a level pi's `--thinking` would warn
106
+ // about and drop is not handed to the runner either (`thinking-validation.ts`).
107
+ thinking: resolveThinkingLevel(options.agentConfig?.thinking).level ?? null,
105
108
  tools: options.agentConfig?.tools?.length ? options.agentConfig.tools : null,
106
109
  };
107
110
 
@@ -114,6 +117,11 @@ export function dispatchViaBridge(
114
117
  const progress: SubagentProgress = {
115
118
  agent: agentName,
116
119
  status: "completed",
120
+ // The Client ran the session and reported it finished; that is a
121
+ // completed run in this vocabulary. It has no stopReason to expose and
122
+ // no cancellation of its own to report — a local cancel settles below
123
+ // with BridgeCancelledError, which is a failure by contract.
124
+ outcome: "completed",
117
125
  task: options.task,
118
126
  currentTool: undefined,
119
127
  currentToolArgs: undefined,
@@ -134,6 +142,7 @@ export function dispatchViaBridge(
134
142
  agent: agentName,
135
143
  task: options.task,
136
144
  exitCode: 0,
145
+ outcome: "completed",
137
146
  usage: {
138
147
  input: res.metrics.inputTokens,
139
148
  output: res.metrics.outputTokens,
@@ -142,6 +151,9 @@ export function dispatchViaBridge(
142
151
  cost: res.metrics.cost,
143
152
  turns: 0,
144
153
  },
154
+ // The Client ran this as a session of its own and books its usage there;
155
+ // this session must not also report it. See `SubagentResult`.
156
+ usageAccountedElsewhere: true,
145
157
  model,
146
158
  finalOutput: res.output,
147
159
  error: undefined,
@@ -169,7 +181,13 @@ export function dispatchViaBridge(
169
181
  const isLocalCancel = err instanceof BridgeCancelledError;
170
182
  const errorMessage = isLocalCancel ? "cancelled" : err instanceof Error ? err.message : String(err);
171
183
  const durationMs = Date.now() - startTime;
172
- return buildFailedResult(agentName, options.task, errorMessage, model, durationMs);
184
+ // Same ownership rule as the success path above: this settled from a
185
+ // desktop-side session (or a cancel of one), so its usage is that
186
+ // session's to report even if it ended badly.
187
+ return {
188
+ ...buildFailedResult(agentName, options.task, errorMessage, model, durationMs),
189
+ usageAccountedElsewhere: true,
190
+ };
173
191
  },
174
192
  );
175
193
  }
@@ -43,7 +43,9 @@ function activateBridge(socketPath: string): void {
43
43
  process.env.PI_ARCHIMEDES_BRIDGE_SESSION = "test-desktop-session";
44
44
  process.env.PI_ARCHIMEDES_BRIDGE_SERVER_PID = "12345";
45
45
  const onSpy = vi.fn();
46
- registerBridge({ on: onSpy } as unknown as ExtensionAPI);
46
+ // The handle the relay subscribes on — inert here (these tests are about fork
47
+ // vs dispatch, not about the relay), but a real session always hands one over.
48
+ registerBridge({ on: onSpy, events: { emit: () => {}, on: () => () => {} } } as unknown as ExtensionAPI);
47
49
  const calls = onSpy.mock.calls as Array<[string, (e: unknown, ctx: unknown) => void]>;
48
50
  const startCall = calls.find((c) => c[0] === "session_start");
49
51
  expect(startCall).toBeTruthy();
@@ -75,6 +77,9 @@ function makeOptions(overrides: Partial<ExecuteOptions> = {}): ExecuteOptions {
75
77
  cwd: undefined,
76
78
  signal: undefined,
77
79
  onUpdate: undefined,
80
+ // These tests never fork, so the handle is inert — present because a real
81
+ // ExecuteOptions always carries one.
82
+ events: { emit: () => {}, on: () => () => {} },
78
83
  toolCallId: "tc-1",
79
84
  ...overrides,
80
85
  };
@@ -168,6 +173,9 @@ describe("executeSubagent (bridge branch)", () => {
168
173
  expect(result.error).toBe("cancelled");
169
174
  expect(result.progress!.status).toBe("failed");
170
175
  expect(result.progress!.error).toBe("cancelled");
176
+ // Bridge-mode bookkeeping: whatever the desktop did with the task, its
177
+ // session owns the usage — the parent must not fold it into its own totals.
178
+ expect(result.usageAccountedElsewhere).toBe(true);
171
179
  expect(spawnSpy).not.toHaveBeenCalled();
172
180
  // Exactly ONE update: the final-failure progress (no "dispatched"
173
181
  // placeholder — a cancelled task does not claim it was dispatched).
package/src/execute.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import { getBridge } from "@pi-archimedes/core/bridge";
2
+ import type { EventBus } from "@earendil-works/pi-coding-agent";
2
3
  import { spawnSubagent, resolveModel } from "./spawn.js";
3
4
  import { dispatchViaBridge, buildFailedResult } from "./dispatch.js";
4
5
  import { streamEvents } from "./stream.js";
5
- import { emitCostUpdate } from "./cost.js";
6
6
  import type { AgentConfig } from "./agents.js";
7
7
  import type { SubagentProgress, SubagentResult } from "./types.js";
8
8
 
@@ -15,6 +15,10 @@ export interface ExecuteOptions {
15
15
  cwd: string | undefined;
16
16
  signal: AbortSignal | undefined;
17
17
  onUpdate: ((progress: SubagentProgress) => void) | undefined;
18
+ /** The parent's host handle (`pi.events`) — forwarded to the ask bridge and to
19
+ * the todo announcements in `streamEvents`. Neither is an extension, so
20
+ * neither can reach a handle of its own. */
21
+ events: EventBus;
18
22
  /** The subagent tool-call id (the tool's `_id`) — rides the dispatch frame
19
23
  * for Client correlation; `undefined` → the frame's `toolCallId` is absent.
20
24
  * In `executeParallel` all N tasks share the single subagent tool-call's id. */
@@ -49,7 +53,11 @@ export async function executeSubagent(options: ExecuteOptions): Promise<Subagent
49
53
  // so executeParallel's progress slot updates from the pending placeholder
50
54
  // to "failed" instead of a stale "Starting...".
51
55
  if (options.signal?.aborted) {
52
- const result = buildFailedResult(agentName, options.task, "cancelled", model, Date.now() - startTime);
56
+ const result = {
57
+ ...buildFailedResult(agentName, options.task, "cancelled", model, Date.now() - startTime),
58
+ // Bridge mode: any usage this run ever had belongs to a desktop session.
59
+ usageAccountedElsewhere: true,
60
+ };
53
61
  options.onUpdate?.(result.progress!);
54
62
  return result;
55
63
  }
@@ -81,23 +89,21 @@ export async function executeSubagent(options: ExecuteOptions): Promise<Subagent
81
89
  if (!("fallback" in outcome)) {
82
90
  // Bridge used (success or a response-carrying error, incl. the terminal
83
91
  // "cancelled" frame — the desktop is alive, its outcome is authoritative):
84
- // return it — NO fork fallback, and NO emitCostUpdate here — the PARENT
85
- // never pushes the subagent's usage on the PARENT's bus (the fork-delta
86
- // semantics: only the fork path reports a child's usage, source
87
- // `subagent:<name>`). The subagent process self-emits its own usage to
88
- // its OWN bridge (the core self-usage emitter,
89
- // `subagent-metrics-cost-push` Task 1) — a different bus, a different
90
- // session; no double-count.
92
+ // return it, with no cost report of its own. The PARENT never reports a
93
+ // child's usage for a run it did not perform: the subagent process emits
94
+ // its OWN usage to its OWN bridge (the core self-usage emitter), which is a
95
+ // different session's total. Every result `dispatchViaBridge` settles
96
+ // carries `usageAccountedElsewhere`, which is how the tool knows to leave
97
+ // it off the tool result — pi folds that `usage` into THIS session's
98
+ // totals, so copying it across would bill the parent twice. The fork path
99
+ // below does report, through the tool result's native `usage`.
91
100
  return outcome;
92
101
  }
93
102
  // { fallback: true } — unreachable bridge: fall through to the fork path.
94
103
  }
95
104
 
96
- // Track previously emitted values to only emit deltas
97
- let lastEmittedInput = 0;
98
- let lastEmittedOutput = 0;
99
- let lastEmittedCost = 0;
100
-
105
+ // Progress is passed straight through: cost is reported once, on the tool
106
+ // result's native `usage`, so there is no delta bookkeeping left to do here.
101
107
  try {
102
108
  const child = spawnSubagent({
103
109
  task: options.task,
@@ -106,26 +112,14 @@ export async function executeSubagent(options: ExecuteOptions): Promise<Subagent
106
112
  cwd: options.cwd,
107
113
  signal: options.signal,
108
114
  agent: options.agentConfig,
115
+ events: options.events,
109
116
  });
110
117
 
111
118
  const result = await streamEvents(child, {
112
119
  agent: agentName,
113
120
  task: options.task,
121
+ events: options.events,
114
122
  onProgress: (progress: SubagentProgress) => {
115
- // Emit only deltas to avoid double-counting in CostAccumulator
116
- const deltaInput = progress.inputTokens - lastEmittedInput;
117
- const deltaOutput = progress.outputTokens - lastEmittedOutput;
118
- const deltaCost = progress.cost - lastEmittedCost;
119
- if (deltaInput > 0 || deltaOutput > 0 || deltaCost > 0) {
120
- emitCostUpdate(agentName, {
121
- inputTokens: deltaInput,
122
- outputTokens: deltaOutput,
123
- cost: deltaCost,
124
- });
125
- lastEmittedInput = progress.inputTokens;
126
- lastEmittedOutput = progress.outputTokens;
127
- lastEmittedCost = progress.cost;
128
- }
129
123
  options.onUpdate?.(progress);
130
124
  },
131
125
  });
@@ -142,7 +136,8 @@ export async function executeSubagent(options: ExecuteOptions): Promise<Subagent
142
136
  // synthesize one so the parallel renderer stays aligned with results.
143
137
  {
144
138
  agent: agentName,
145
- status: result.exitCode === 0 ? "completed" : "failed",
139
+ status: result.outcome || result.exitCode === 0 ? "completed" : "failed",
140
+ ...(result.outcome ? { outcome: result.outcome } : {}),
146
141
  task: options.task,
147
142
  currentTool: undefined,
148
143
  currentToolArgs: undefined,
@@ -187,6 +182,7 @@ export async function executeParallel(options: {
187
182
  tasks: Array<{ agent: string | undefined; agentConfig: AgentConfig | undefined; task: string; model: string | undefined; activeModel: string | undefined; cwd: string | undefined; toolCallId?: string }>;
188
183
  signal: AbortSignal | undefined;
189
184
  onUpdate: ((progress: SubagentProgress[]) => void) | undefined;
185
+ events: EventBus;
190
186
  }): Promise<SubagentResult[]> {
191
187
  // Pre-fill one pending slot per task, keyed by task index (NOT agent name).
192
188
  // This keeps all N lines stacked from t=0 in stable task order, with no
@@ -218,6 +214,7 @@ export async function executeParallel(options: {
218
214
  executeSubagent({
219
215
  ...taskDef,
220
216
  signal: options.signal,
217
+ events: options.events,
221
218
  onUpdate: (progress: SubagentProgress) => {
222
219
  // Store latest progress in this task's stable slot (by index).
223
220
  latestProgress[index] = progress;