pi-famulus 0.1.2 → 0.1.4

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.
@@ -15,15 +15,17 @@
15
15
  */
16
16
  import { Type } from "typebox";
17
17
  import type { AgentToolResult, ToolDefinition } from "@earendil-works/pi-coding-agent";
18
+ import { VALID_THINKING_LEVELS } from "../thinking-levels";
18
19
  import { formatSubagentHandover, formatSubagentNotification, truncateTail } from "../format";
19
20
  import { realClock, type Clock, type ClockTimer } from "../clock";
20
21
 
21
22
  import type { NotifyCenter } from "../notify";
22
23
  import { statusGlyph, toolComponent } from "../tui/tool-component";
23
24
  import type { WorkIndex } from "../work-index";
25
+ import { fill, PROMPTS } from "../prompts.generated";
24
26
  import { runChain, runTasks, validateChainSteps } from "./pool";
25
27
  import type { RunRecord, SubagentRegistry } from "./registry";
26
- import type { AgentDefinition, ChildHandle, ChildResult, ChildRunRequest } from "./types";
28
+ import { AGENT_WORK_KINDS, type AgentDefinition, type AgentWorkKind, type ChildHandle, type ChildResult, type ChildRunRequest } from "./types";
27
29
 
28
30
 
29
31
  /** Overall result text cap (§4.6: truncateTail 512 lines / 48KB). */
@@ -37,49 +39,48 @@ const MIN_TIMEOUT_MS = 1_000;
37
39
  // Schema (§4.6 field-name contract)
38
40
  // ---------------------------------------------------------------------------
39
41
 
42
+ const workKindSchema = Type.Union(AGENT_WORK_KINDS.map((kind) => Type.Literal(kind)), {
43
+ description: PROMPTS["tools.subagent.param.work_kind"],
44
+ });
45
+
40
46
  const taskItem = Type.Object({
41
- agent: Type.Optional(Type.String({ description: "Agent definition name (default: worker)" })),
42
- prompt: Type.String({ description: "Task prompt for this subagent" }),
43
- name: Type.Optional(Type.String({ description: "Display name (default: agent name + ordinal)" })),
47
+ agent: Type.Optional(Type.String({ description: PROMPTS["tools.subagent.param.tasks.agent"] })),
48
+ prompt: Type.String({ description: PROMPTS["tools.subagent.param.tasks.prompt"] }),
49
+ name: Type.Optional(Type.String({ description: PROMPTS["tools.subagent.param.tasks.name"] })),
50
+ work_kind: Type.Optional(workKindSchema),
44
51
  });
45
52
 
46
53
  const chainItem = Type.Object({
47
- agent: Type.Optional(Type.String({ description: "Agent definition name (default: worker)" })),
48
- prompt: Type.String({
49
- description: "Prompt for this step; {previous} and {outputs.<label>} interpolate earlier results",
50
- }),
51
- label: Type.Optional(Type.String({ description: "Label for referencing this step's output later" })),
54
+ agent: Type.Optional(Type.String({ description: PROMPTS["tools.subagent.param.chain.agent"] })),
55
+ prompt: Type.String({ description: PROMPTS["tools.subagent.param.chain.prompt"] }),
56
+ label: Type.Optional(Type.String({ description: PROMPTS["tools.subagent.param.chain.label"] })),
57
+ work_kind: Type.Optional(workKindSchema),
52
58
  });
53
59
 
54
60
  const subagentParameters = Type.Object({
55
61
  tasks: Type.Optional(
56
- Type.Array(taskItem, { minItems: 1, maxItems: 10, description: "Subagents to run in parallel" }),
62
+ Type.Array(taskItem, { minItems: 1, maxItems: 10, description: PROMPTS["tools.subagent.param.tasks"] }),
57
63
  ),
58
64
  chain: Type.Optional(
59
- Type.Array(chainItem, { minItems: 1, description: "Steps to run sequentially (always awaited)" }),
65
+ Type.Array(chainItem, { minItems: 1, description: PROMPTS["tools.subagent.param.chain"] }),
60
66
  ),
61
67
  async: Type.Optional(
62
- Type.Boolean({ description: "Return immediately with a run_id; completion arrives via notification" }),
68
+ Type.Boolean({ description: PROMPTS["tools.subagent.param.async"] }),
63
69
  ),
64
70
  concurrency: Type.Optional(
65
- Type.Number({ minimum: 1, maximum: 8, description: "Max parallel subagents for tasks (default 4)" }),
71
+ Type.Number({ minimum: 1, maximum: 8, description: PROMPTS["tools.subagent.param.concurrency"] }),
66
72
  ),
67
73
  fail_fast: Type.Optional(
68
- Type.Boolean({
69
- description: "Cancel not-yet-started subagents on first failure (already-started ones finish)",
70
- }),
74
+ Type.Boolean({ description: PROMPTS["tools.subagent.param.fail_fast"] }),
71
75
  ),
72
76
  model: Type.Optional(
73
77
  Type.String({
74
- description:
75
- 'Model override for all subagents: fuzzy ("haiku"), qualified ("provider/id"), ' +
76
- 'optionally with ":<thinking>" suffix. Default: current model. ' +
77
- 'Use action:"models" to list selectable values.',
78
+ description: fill("tools.subagent.param.model", { thinkingLevels: VALID_THINKING_LEVELS.join(", ") }),
78
79
  }),
79
80
  ),
80
81
  timeout_ms: Type.Optional(
81
82
  Type.Number({
82
- description: `Hard timeout per subagent in ms (default 1800000, max ${MAX_TIMEOUT_MS})`,
83
+ description: fill("tools.subagent.param.timeout_ms", { maxMs: MAX_TIMEOUT_MS }),
83
84
  maximum: MAX_TIMEOUT_MS,
84
85
  }),
85
86
  ),
@@ -92,25 +93,28 @@ const subagentParameters = Type.Object({
92
93
  Type.Literal("interrupt"),
93
94
  Type.Literal("resume"),
94
95
  Type.Literal("steer"),
96
+ Type.Literal("extend"),
95
97
  Type.Literal("models"),
96
98
  ],
97
- { description: "Manage an existing run (or list selectable models) instead of starting a new one" },
99
+ { description: PROMPTS["tools.subagent.param.action"] },
98
100
  ),
99
101
  ),
100
- run_id: Type.Optional(Type.String({ description: "Target run for action" })),
101
- child_id: Type.Optional(Type.String({ description: "Target child (id or name) for steer/interrupt/resume" })),
102
- message: Type.Optional(Type.String({ description: "Message content for steer/resume" })),
102
+ run_id: Type.Optional(Type.String({ description: PROMPTS["tools.subagent.param.run_id"] })),
103
+ child_id: Type.Optional(
104
+ Type.String({ description: PROMPTS["tools.subagent.param.child_id"] }),
105
+ ),
106
+ message: Type.Optional(Type.String({ description: PROMPTS["tools.subagent.param.message"] })),
103
107
  });
104
108
 
105
109
  type SubagentParams = {
106
- tasks?: { agent?: string; prompt: string; name?: string }[];
107
- chain?: { agent?: string; prompt: string; label?: string }[];
110
+ tasks?: { agent?: string; prompt: string; name?: string; work_kind?: AgentWorkKind }[];
111
+ chain?: { agent?: string; prompt: string; label?: string; work_kind?: AgentWorkKind }[];
108
112
  async?: boolean;
109
113
  concurrency?: number;
110
114
  fail_fast?: boolean;
111
115
  model?: string;
112
116
  timeout_ms?: number;
113
- action?: "list" | "get" | "status" | "interrupt" | "resume" | "steer" | "models";
117
+ action?: "list" | "get" | "status" | "interrupt" | "resume" | "steer" | "extend" | "models";
114
118
  run_id?: string;
115
119
  child_id?: string;
116
120
  message?: string;
@@ -147,8 +151,7 @@ export interface SubagentToolDeps {
147
151
  /** M3 stopgap resolver until M5 wires the real agent loader (§4.8). */
148
152
  const BUILTIN_WORKER: AgentDefinition = {
149
153
  name: "worker",
150
- description: "General-purpose subagent with the full default tool set",
151
- tools: ["read", "bash", "edit", "write"],
154
+ description: "General-purpose subagent with user-configured tools",
152
155
  systemPrompt: "",
153
156
  source: "builtin",
154
157
  };
@@ -222,6 +225,7 @@ function toNotificationInfo(record: RunRecord, now: number) {
222
225
  status: c.status,
223
226
  text: c.result?.text ?? "",
224
227
  error: c.result?.error,
228
+ ...(c.result?.warning !== undefined ? { warning: c.result.warning } : {}),
225
229
  ...(c.prompt !== undefined ? { prompt: c.prompt } : {}),
226
230
  })),
227
231
  };
@@ -285,9 +289,23 @@ export function createSubagentTool(
285
289
  return registry;
286
290
  };
287
291
 
292
+ /** Last terminal state a subagent-done was sent for, per run. */
293
+ const runDoneSent = new Map<string, string>();
288
294
  const notifyRunCompleted = (registry: SubagentRegistry, runId: string): void => {
289
295
  const record = registry.get(runId);
290
- if (!record) return;
296
+ if (!record) {
297
+ runDoneSent.delete(runId);
298
+ return;
299
+ }
300
+ // A resumed child can outlive the run's first-launch completion. The
301
+ // run is not done while it runs; its own settle sends this wake.
302
+ if (record.children.some((c) => c.status === "pending" || c.status === "running")) return;
303
+ // Several observers (first-launch completion, each resumed turn's settle)
304
+ // can see the same all-terminal state. One subagent-done per terminal
305
+ // state, identified by every child's turn number.
306
+ const terminalKey = record.children.map((c) => `${c.childId}:${c.turn ?? 1}`).join("|");
307
+ if (runDoneSent.get(runId) === terminalKey) return;
308
+ runDoneSent.set(runId, terminalKey);
291
309
  deps.getNotifyCenter()?.notify(formatSubagentNotification(toNotificationInfo(record, clock.now())));
292
310
  };
293
311
 
@@ -320,6 +338,7 @@ export function createSubagentTool(
320
338
  prompt: child.prompt ?? "",
321
339
  text: child.result?.text ?? "",
322
340
  ...(child.result?.error !== undefined ? { error: child.result.error } : {}),
341
+ ...(child.result?.warning !== undefined ? { warning: child.result.warning } : {}),
323
342
  stillRunning,
324
343
  }),
325
344
  );
@@ -348,12 +367,12 @@ export function createSubagentTool(
348
367
  const label = "name" in item ? item.name : undefined;
349
368
  const chainLabel = "label" in item ? item.label : undefined;
350
369
  const name = label ?? chainLabel ?? `${agent.name}-${i + 1}`;
351
- return { agent, name, prompt: item.prompt };
370
+ return { agent, name, prompt: item.prompt, workKind: item.work_kind ?? "other" };
352
371
  });
353
372
 
354
373
  const run = registry.createRun(kind);
355
374
  const childIds = resolved.map((r) =>
356
- registry.addChild(run.runId, { name: r.name, agent: r.agent.name }),
375
+ registry.addChild(run.runId, { name: r.name, agent: r.agent.name, workKind: r.workKind }),
357
376
  );
358
377
 
359
378
  const makeRequest = (ordinal: number, prompt: string): ChildRunRequest => ({
@@ -364,6 +383,7 @@ export function createSubagentTool(
364
383
  taskPrompt: prompt,
365
384
  agent: resolved[ordinal].agent,
366
385
  model: params.model,
386
+ workKind: resolved[ordinal].workKind,
367
387
  timeoutMs,
368
388
  depth: 1,
369
389
  });
@@ -427,13 +447,7 @@ export function createSubagentTool(
427
447
  content: [
428
448
  {
429
449
  type: "text",
430
- text:
431
- `Started ${items.length} subagent(s) in run ${run.runId}. ${reason}\n` +
432
- `While others are still running, each finished subagent arrives as <pi-famulus-wake kind="subagent-handover"> ` +
433
- `with that child's prompt and result. Read it and continue: subagent({action:"resume", run_id, child_id, message}) for that child, ` +
434
- `or agent_message to steer the ones still running. Do not wait for the whole run. Do not poll. ` +
435
- `<pi-famulus-wake kind="subagent-done"> arrives when every subagent in the run has finished. ` +
436
- `Use subagent({action:"get", run_id:"${run.runId}"}) if you need the full record.`,
450
+ text: fill("tools.subagent.result.backgrounded", { count: items.length, runId: run.runId, reason }),
437
451
  },
438
452
  ],
439
453
  details: { run_id: run.runId, status: "backgrounded" },
@@ -590,12 +604,44 @@ export function createSubagentTool(
590
604
  };
591
605
  }
592
606
 
607
+ if (action === "extend") {
608
+ const handle = resolveSingleActiveChild(registry, record, params.child_id, "extend");
609
+ const child = record.children.find((c) => c.childId === handle.childId);
610
+ const ms = params.timeout_ms === undefined ? undefined : clampTimeout(params.timeout_ms, deps.defaultTimeoutMs);
611
+ const { deadlineAt, hardDeadlineAt } = handle.extend(ms);
612
+ const now = clock.now();
613
+ const hard =
614
+ hardDeadlineAt === null
615
+ ? ""
616
+ : ` ${fill("tools.subagent.result.extended.hard-ceiling", { remaining: formatDurationMs(Math.max(0, hardDeadlineAt - now)) })}`;
617
+ return {
618
+ content: [
619
+ {
620
+ type: "text",
621
+ text:
622
+ fill("tools.subagent.result.extended", {
623
+ name: child?.name ?? handle.childId,
624
+ childId: handle.childId,
625
+ runId: record.runId,
626
+ next: formatDurationMs(deadlineAt - now),
627
+ }) + hard,
628
+ },
629
+ ],
630
+ details: { run_id: record.runId, child_id: handle.childId, deadline_at: deadlineAt, hard_deadline_at: hardDeadlineAt },
631
+ };
632
+ }
633
+
593
634
  // resume
594
635
  if (!params.message) throw new Error("message is required for resume");
595
636
  const child = resolveSingleTerminalChild(record, params.child_id);
596
- const handle = registry.handle(child.childId);
597
- if (!handle) throw new Error(`subagent ${child.childId} has no live session to resume`);
598
- await handle.resume(params.message);
637
+ // A resume is a new turn with its own soft budget: timeout_ms when given,
638
+ // else the child's spawn budget (the runner's default). Returns once the
639
+ // request is accepted; it never waits for an admission slot.
640
+ const { queuedBehind } = await registry.resumeChild(
641
+ child.childId,
642
+ params.message,
643
+ params.timeout_ms === undefined ? {} : { timeoutMs: clampTimeout(params.timeout_ms, deps.defaultTimeoutMs) },
644
+ );
599
645
  // Resume is asynchronous. If siblings are still running, hand this child
600
646
  // back as soon as it finishes; otherwise the run-complete wake covers it.
601
647
  const handedOver = new Set<string>();
@@ -608,32 +654,24 @@ export function createSubagentTool(
608
654
  content: [
609
655
  {
610
656
  type: "text",
611
- text:
612
- `Resumed subagent ${child.name} (${child.childId}) in run ${record.runId}. ` +
613
- "You will be notified via <pi-famulus-wake kind=\"subagent-handover\"> if others are still running, " +
614
- "otherwise via <pi-famulus-wake kind=\"subagent-done\"> when it completes. Do not poll.",
657
+ text: fill("tools.subagent.result.resumed", {
658
+ name: child.name,
659
+ childId: child.childId,
660
+ runId: record.runId,
661
+ queued: queuedText(queuedBehind),
662
+ }),
615
663
  },
616
664
  ],
617
- details: { run_id: record.runId, child_id: child.childId },
665
+ details: { run_id: record.runId, child_id: child.childId, queued_behind: queuedBehind },
618
666
  };
619
667
  };
620
668
 
621
669
  return {
622
670
  name: "subagent",
623
671
  label: "Subagent",
624
- description:
625
- "Run subagents in parallel (tasks) or sequentially (chain with {previous}/{outputs.<label>} " +
626
- "interpolation). By default the call waits up to a foreground budget (default 45s); longer runs " +
627
- "continue in the background. Each child that finishes while others are still running wakes you with " +
628
- "<pi-famulus-wake kind=\"subagent-handover\"> (its prompt and result). The whole run wakes you with <pi-famulus-wake kind=\"subagent-done\">. " +
629
- "Never poll or sleep to wait. Use action=list/get/status/interrupt/resume/steer to manage existing runs.",
630
- promptSnippet: "Fan out subagents in parallel or sequence them in a chain",
631
- promptGuidelines: [
632
- 'When a <pi-famulus-wake kind="subagent-handover"> arrives, read <prompt> and <result> immediately and continue: subagent({action:"resume", run_id, child_id, message}) for that child, or agent_message to steer children that are still running. Do not wait for the rest of the run.',
633
- "Subagent runs that exceed the foreground budget continue in the background; you are notified per finished child and again when the run completes — do not poll.",
634
- "A failed subagent does not fail the whole run; inspect per-subagent sections in the result.",
635
- "<pi-famulus-wake> is a system wake, not a user reply. kind=subagent-handover is one child; kind=subagent-done is the whole run.",
636
- ],
672
+ description: PROMPTS["tools.subagent.description"],
673
+ promptSnippet: PROMPTS["tools.subagent.snippet"],
674
+ promptGuidelines: [...PROMPTS["tools.subagent.rules"]],
637
675
  parameters: subagentParameters,
638
676
  renderResult(result, { expanded }, theme, context) {
639
677
  const details = result.details as { run_id?: string; status?: string } | undefined;
@@ -694,6 +732,13 @@ export function createSubagentTool(
694
732
  };
695
733
  }
696
734
 
735
+ /** Resume result clause for a turn waiting for an admission slot ("" when it started at once). */
736
+ function queuedText(queuedBehind: number | null): string {
737
+ if (queuedBehind === null) return "";
738
+ const ahead = queuedBehind === 0 ? "" : ` and ${queuedBehind} subagent(s) are waiting ahead of it`;
739
+ return `Every subagent slot is busy${ahead}, so it is queued and starts when a slot frees. `;
740
+ }
741
+
697
742
  function resolveSingleActiveChild(
698
743
  registry: SubagentRegistry,
699
744
  record: RunRecord,
@@ -728,6 +773,11 @@ function resolveSingleTerminalChild(record: RunRecord, childIdOrName: string | u
728
773
  const known = record.children.map((c) => `${c.name} (${c.childId})`).join(", ");
729
774
  throw new Error(`no child "${childIdOrName}" in run ${record.runId} (children: ${known})`);
730
775
  }
776
+ if (child.status === "pending") {
777
+ // Queued for an admission slot (a launch or an earlier resume): steer
778
+ // would fail too, and a second resume adds nothing.
779
+ throw new Error(fill("tools.subagent.error.resume-queued", { name: child.name, childId: child.childId }));
780
+ }
731
781
  if (!matches(child)) {
732
782
  throw new Error(`subagent ${child.name} (${child.childId}) is still ${child.status}; use steer instead`);
733
783
  }
@@ -6,6 +6,8 @@
6
6
  * consumer stays testable with plain fakes.
7
7
  */
8
8
 
9
+ import type { OverrunTick } from "./overrun";
10
+
9
11
  /**
10
12
  * Mirror of the M5 `AgentDefinition` (design doc Appendix B, src/agents/).
11
13
  * Structurally compatible with the M5 definition, so the real agent loader
@@ -14,9 +16,11 @@
14
16
  export interface AgentDefinition {
15
17
  name: string; // ^[a-z][a-z0-9-]*$
16
18
  description: string; // required, non-empty
17
- tools: string[]; // default ["read","bash","edit","write"]
19
+ tools?: string[]; // omitted: inherit user config; explicit list: allowlist; []: no user-configured tools (injected communication tools remain)
18
20
  model?: string; // "provider:id" | bare id
19
- thinking?: "minimal" | "low" | "medium" | "high" | "xhigh";
21
+ // Mirror of src/agents/definition.ts — keep in sync with
22
+ // src/thinking-levels.ts (VALID_THINKING_LEVELS), the single source.
23
+ thinking?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
20
24
  systemPrompt: string; // frontmatter body, trimmed
21
25
  source: "builtin" | "user" | "project";
22
26
  path?: string; // builtins have no path
@@ -24,6 +28,17 @@ export interface AgentDefinition {
24
28
 
25
29
  export type ChildStatus = "pending" | "running" | "completed" | "failed" | "interrupted";
26
30
 
31
+ export const AGENT_WORK_KINDS = [
32
+ "test-suite",
33
+ "test",
34
+ "build",
35
+ "lint/type",
36
+ "other",
37
+ "git",
38
+ "read/search",
39
+ ] as const;
40
+ export type AgentWorkKind = (typeof AGENT_WORK_KINDS)[number];
41
+
27
42
  export interface ChildResult {
28
43
  status: "completed" | "failed" | "interrupted";
29
44
  text: string; // getLastAssistantText() or "(no output)"
@@ -41,6 +56,8 @@ export interface ChildResult {
41
56
  /** Stall detections in the user turn that produced this result. */
42
57
  stalls?: number;
43
58
  durationMs: number;
59
+ /** Time waiting for local and machine-wide admission in this turn. */
60
+ queueMs?: number;
44
61
  }
45
62
 
46
63
  /** One turn of a child session, for the /tasks conversation view. */
@@ -58,10 +75,36 @@ export interface ChildRunRequest {
58
75
  taskPrompt?: string;
59
76
  agent: AgentDefinition; // already resolved
60
77
  model?: string; // subagent() parameter-level override
78
+ /** Explicit planned workload class; natural-language prompts are not classified. */
79
+ workKind?: AgentWorkKind;
80
+ /** Soft budget per turn: reaching it wakes the parent; it does not abort. */
61
81
  timeoutMs: number;
62
82
  depth: number; // main session = 0, child = 1
63
83
  }
64
84
 
85
+ export interface ChildTokenUsage {
86
+ input: number;
87
+ output: number;
88
+ cacheRead: number;
89
+ cacheWrite: number;
90
+ }
91
+
92
+ export interface ChildWallUsage {
93
+ llmMs: number;
94
+ toolMs: number;
95
+ queueMs: number;
96
+ otherMs: number;
97
+ approximate: boolean;
98
+ }
99
+
100
+ export interface ChildSessionEvent {
101
+ type: string;
102
+ role?: string;
103
+ /** Pi's stable tool-call id, used to pair execution start/end events. */
104
+ toolCallId?: string;
105
+ usage?: ChildTokenUsage;
106
+ }
107
+
65
108
  export interface ChildHandle {
66
109
  readonly childId: string;
67
110
  readonly result: Promise<ChildResult>; // resolves exactly once per generation;
@@ -70,14 +113,28 @@ export interface ChildHandle {
70
113
  // current generation's promise).
71
114
  steer(message: string): Promise<void>; // while running; terminal -> throw
72
115
  followUp(message: string): Promise<void>; // same, queued delivery
73
- resume(message: string): Promise<void>; // terminal -> continue running
116
+ /** terminal -> continue running; opts.timeoutMs is the new turn's soft budget (default: spawn budget). */
117
+ resume(message: string, opts?: { timeoutMs?: number }): Promise<void>;
118
+ /**
119
+ * running -> soft deadline = now + timeoutMs (default: spawn budget);
120
+ * otherwise throws. Returns the new deadline and the unmoved hard ceiling.
121
+ */
122
+ extend(timeoutMs?: number): { deadlineAt: number; hardDeadlineAt: number | null };
74
123
  interrupt(): Promise<void>; // abort; result resolves as interrupted
75
124
  status(): ChildStatus;
76
125
  lastEventAt(): number; // for the stall watchdog / status display
77
126
  /** Resolved `provider/id` once the child session has been constructed. */
78
127
  resolvedModel(): string | undefined;
128
+ /** Effective thinking level applied when the child session was constructed. */
129
+ effectiveThinkingLevel?(): string | undefined;
130
+ /** Collect and reschedule a reminder if this child is overdue at the given time. */
131
+ collectDueOverrun?(now: number): OverrunTick | undefined;
79
132
  /** Live child transcript. Empty when the session never started. */
80
133
  conversation(): ConversationTurn[];
134
+ /** Cumulative provider input/output and cache counts for this child. */
135
+ tokenUsage(): ChildTokenUsage;
136
+ /** Cumulative observed LLM, tool, admission-queue and approximate wall time. */
137
+ wallUsage(): ChildWallUsage;
81
138
  }
82
139
 
83
140
  /**
@@ -92,6 +149,8 @@ export interface ChildSessionAdapter {
92
149
  * Used for fleet/ls persistence and so the child prompt can name its model.
93
150
  */
94
151
  readonly resolvedModel?: string;
152
+ /** Effective thinking level passed to createAgentSession, after model fallbacks. */
153
+ readonly effectiveThinkingLevel?: string;
95
154
  prompt(text: string): Promise<void>;
96
155
  steer(text: string): Promise<void>;
97
156
  followUp(text: string): Promise<void>;
@@ -101,18 +160,22 @@ export interface ChildSessionAdapter {
101
160
  /** Provider/model failure outcome of the last assistant turn, when any. */
102
161
  getLastAssistantFailure?(): { stopReason: "error" | "aborted"; errorMessage?: string } | undefined;
103
162
  getConversation(): ConversationTurn[];
163
+ tokenUsage(): ChildTokenUsage;
104
164
  /** Introspection for child-session orchestration and contract tests. */
105
165
  getActiveToolNames?(): string[];
106
166
  getSystemPrompt?(): string;
107
167
  isStreaming(): boolean;
108
- subscribe(listener: (event: { type: string }) => void): () => void;
109
- dispose(): void;
168
+ /** Current queued steering drain mode, when supported by the runtime. */
169
+ getSteeringMode?(): "all" | "one-at-a-time";
170
+ subscribe(listener: (event: ChildSessionEvent) => void): () => void;
171
+ /** Await extension cleanup before releasing the underlying session. */
172
+ dispose(): void | Promise<void>;
110
173
  }
111
174
 
112
175
  export type CreateSessionFn = (req: ChildRunRequest) => Promise<ChildSessionAdapter>;
113
176
 
114
177
  export interface ChildRunner {
115
- start(req: ChildRunRequest): Promise<ChildHandle>;
178
+ start(req: ChildRunRequest, onHandle?: (handle: ChildHandle) => void): Promise<ChildHandle>;
116
179
  }
117
180
 
118
181
  /**
@@ -121,5 +184,6 @@ export interface ChildRunner {
121
184
  * duck-types this to release sessions in disposeRun().
122
185
  */
123
186
  export interface DisposableChildHandle extends ChildHandle {
124
- dispose(): void;
187
+ /** Settles immediately; an optional promise tracks asynchronous session cleanup. */
188
+ dispose(): void | Promise<void>;
125
189
  }
@@ -0,0 +1,48 @@
1
+ export interface TokenUsage {
2
+ input: number;
3
+ output: number;
4
+ cacheRead: number;
5
+ cacheWrite: number;
6
+ }
7
+
8
+ /** Normalize provider usage counts while keeping missing values at zero. */
9
+ export function normalizedTokenUsage(usage: {
10
+ input?: unknown;
11
+ output?: unknown;
12
+ cacheRead?: unknown;
13
+ cacheWrite?: unknown;
14
+ }): TokenUsage {
15
+ const count = (value: unknown): number => {
16
+ const n = Number(value);
17
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0;
18
+ };
19
+ return {
20
+ input: count(usage.input),
21
+ output: count(usage.output),
22
+ cacheRead: count(usage.cacheRead),
23
+ cacheWrite: count(usage.cacheWrite),
24
+ };
25
+ }
26
+
27
+ /** Add a provider message's usage to the child's cumulative totals. */
28
+ export function accumulateTokenUsage(total: TokenUsage, delta: TokenUsage): TokenUsage {
29
+ return {
30
+ input: total.input + delta.input,
31
+ output: total.output + delta.output,
32
+ cacheRead: total.cacheRead + delta.cacheRead,
33
+ cacheWrite: total.cacheWrite + delta.cacheWrite,
34
+ };
35
+ }
36
+
37
+ export function createTokenUsageAccumulator() {
38
+ let total: TokenUsage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
39
+ return {
40
+ add(usage: { input?: unknown; output?: unknown; cacheRead?: unknown; cacheWrite?: unknown }): TokenUsage {
41
+ total = accumulateTokenUsage(total, normalizedTokenUsage(usage));
42
+ return { ...total };
43
+ },
44
+ snapshot(): TokenUsage {
45
+ return { ...total };
46
+ },
47
+ };
48
+ }
package/src/task-tools.ts CHANGED
@@ -5,6 +5,7 @@ import { Type } from "typebox";
5
5
  import { realClock, type Clock } from "./clock";
6
6
  import type { ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding-agent";
7
7
  import type { ManagerClient, TaskRecord } from "./manager-client";
8
+ import { fill, PROMPTS } from "./prompts.generated";
8
9
  import {
9
10
  formatAgentCommand,
10
11
  isAgentStatusActive,
@@ -85,14 +86,12 @@ function formatTaskLine(task: TaskRecord, now: number): string {
85
86
  );
86
87
  }
87
88
 
88
- const END_TURN_HINT =
89
- 'The task is still running. Reply to the user now with no tool call; a <pi-famulus-wake> arrives when it finishes.';
90
- const LIST_END_TURN_HINT =
91
- "Running work wakes you with a <pi-famulus-wake> when it finishes. Reply to the user now with no tool call instead of checking again.";
89
+ const END_TURN_HINT = PROMPTS["tools.task_output.hint.still-running"];
90
+ const LIST_END_TURN_HINT = PROMPTS["tools.task_list.hint.running"];
92
91
 
93
92
  const taskListParameters = Type.Object({
94
93
  all: Type.Optional(
95
- Type.Boolean({ description: "Also include finished work (default: running only). Always limited to this session." }),
94
+ Type.Boolean({ description: PROMPTS["tools.task_list.param.all"] }),
96
95
  ),
97
96
  });
98
97
 
@@ -102,10 +101,8 @@ export function createTaskListTool(
102
101
  return {
103
102
  name: "task_list",
104
103
  label: "Task List",
105
- description:
106
- "List background work this session started: shell/monitor tasks and subagent children. " +
107
- "Work from other pi sessions is never shown.",
108
- promptSnippet: "List background shell/monitor tasks and subagents",
104
+ description: PROMPTS["tools.task_list.description"],
105
+ promptSnippet: PROMPTS["tools.task_list.snippet"],
109
106
  parameters: taskListParameters,
110
107
  async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
111
108
  const client = await requireClient(deps);
@@ -180,15 +177,12 @@ export function createTaskListTool(
180
177
  }
181
178
 
182
179
  const taskOutputParameters = Type.Object({
183
- task_id: Type.String({ description: "Task id, e.g. sh_a1b2c3d4" }),
180
+ task_id: Type.String({ description: PROMPTS["tools.task_output.param.task_id"] }),
184
181
  cursor: Type.Optional(
185
- Type.Number({
186
- description:
187
- "Byte offset to read from (for incremental reads). Omit to read the tail of the output.",
188
- }),
182
+ Type.Number({ description: PROMPTS["tools.task_output.param.cursor"] }),
189
183
  ),
190
184
  max_bytes: Type.Optional(
191
- Type.Number({ description: "Maximum bytes to return (default 65536)" }),
185
+ Type.Number({ description: PROMPTS["tools.task_output.param.max_bytes"] }),
192
186
  ),
193
187
  });
194
188
 
@@ -211,7 +205,7 @@ export function createTaskOutputTool(
211
205
  const lastRunningSize = new Map<string, number>();
212
206
  const guardPoll = (id: string, running: boolean, size: number) => {
213
207
  if (running && lastRunningSize.get(id) === size) {
214
- throw new Error(`No new output from ${id} since your last read. ${END_TURN_HINT}`);
208
+ throw new Error(fill("tools.task_output.error.no-new-output", { taskId: id, hint: END_TURN_HINT }));
215
209
  }
216
210
  if (running) lastRunningSize.set(id, size);
217
211
  else lastRunningSize.delete(id);
@@ -219,11 +213,8 @@ export function createTaskOutputTool(
219
213
  return {
220
214
  name: "task_output",
221
215
  label: "Task Output",
222
- description:
223
- "Read output of a background task. Without a cursor, returns the tail of the output " +
224
- "plus the current file pointer; pass the returned next_cursor as cursor for incremental reads. " +
225
- "Not a way to wait: a running task wakes you when it finishes, so reply with no tool call instead of calling this again.",
226
- promptSnippet: "Read background task output",
216
+ description: PROMPTS["tools.task_output.description"],
217
+ promptSnippet: PROMPTS["tools.task_output.snippet"],
227
218
  parameters: taskOutputParameters,
228
219
  async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
229
220
  const agentText = agentTextFor(deps, params.task_id);
@@ -280,7 +271,7 @@ export function createTaskOutputTool(
280
271
  }
281
272
 
282
273
  const taskStopParameters = Type.Object({
283
- task_id: Type.String({ description: "Task id to stop, e.g. sh_a1b2c3d4" }),
274
+ task_id: Type.String({ description: PROMPTS["tools.task_stop.param.task_id"] }),
284
275
  });
285
276
 
286
277
  export function createTaskStopTool(
@@ -289,9 +280,8 @@ export function createTaskStopTool(
289
280
  return {
290
281
  name: "task_stop",
291
282
  label: "Task Stop",
292
- description:
293
- "Stop a running background task (SIGTERM to the process group, SIGKILL after a 2s grace).",
294
- promptSnippet: "Stop a background task",
283
+ description: PROMPTS["tools.task_stop.description"],
284
+ promptSnippet: PROMPTS["tools.task_stop.snippet"],
295
285
  parameters: taskStopParameters,
296
286
  async execute(_toolCallId, params, _signal, _onUpdate, _ctx: ExtensionContext) {
297
287
  const agent = deps.getRegistry?.()?.handle(params.task_id);
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Thinking levels pi core accepts — the single source of truth for the
3
+ * extension. The pi SDK does not export its list (pi's internal
4
+ * VALID_THINKING_LEVELS), so this is a maintained copy; parity tests pin
5
+ * every level (impl-model-spec.test.ts, impl-agents.test.ts).
6
+ *
7
+ * Pure module: zero pi dependencies, zero npm dependencies. Both src/agents/
8
+ * and src/subagent/ consume this — neither layer imports the other (see
9
+ * design doc Appendix B).
10
+ */
11
+
12
+ export const VALID_THINKING_LEVELS = [
13
+ "off",
14
+ "minimal",
15
+ "low",
16
+ "medium",
17
+ "high",
18
+ "xhigh",
19
+ "max",
20
+ ] as const;
21
+
22
+ export type ThinkingLevel = (typeof VALID_THINKING_LEVELS)[number];
23
+
24
+ export function isValidThinkingLevel(level: string): level is ThinkingLevel {
25
+ return (VALID_THINKING_LEVELS as readonly string[]).includes(level);
26
+ }