pi-famulus 0.1.3 → 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.
@@ -22,9 +22,10 @@ import { realClock, type Clock, type ClockTimer } from "../clock";
22
22
  import type { NotifyCenter } from "../notify";
23
23
  import { statusGlyph, toolComponent } from "../tui/tool-component";
24
24
  import type { WorkIndex } from "../work-index";
25
+ import { fill, PROMPTS } from "../prompts.generated";
25
26
  import { runChain, runTasks, validateChainSteps } from "./pool";
26
27
  import type { RunRecord, SubagentRegistry } from "./registry";
27
- 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";
28
29
 
29
30
 
30
31
  /** Overall result text cap (§4.6: truncateTail 512 lines / 48KB). */
@@ -38,54 +39,48 @@ const MIN_TIMEOUT_MS = 1_000;
38
39
  // Schema (§4.6 field-name contract)
39
40
  // ---------------------------------------------------------------------------
40
41
 
42
+ const workKindSchema = Type.Union(AGENT_WORK_KINDS.map((kind) => Type.Literal(kind)), {
43
+ description: PROMPTS["tools.subagent.param.work_kind"],
44
+ });
45
+
41
46
  const taskItem = Type.Object({
42
- agent: Type.Optional(Type.String({ description: "Agent definition name (default: worker)" })),
43
- prompt: Type.String({ description: "Task prompt for this subagent" }),
44
- 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),
45
51
  });
46
52
 
47
53
  const chainItem = Type.Object({
48
- agent: Type.Optional(Type.String({ description: "Agent definition name (default: worker)" })),
49
- prompt: Type.String({
50
- description: "Prompt for this step; {previous} and {outputs.<label>} interpolate earlier results",
51
- }),
52
- 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),
53
58
  });
54
59
 
55
60
  const subagentParameters = Type.Object({
56
61
  tasks: Type.Optional(
57
- 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"] }),
58
63
  ),
59
64
  chain: Type.Optional(
60
- Type.Array(chainItem, { minItems: 1, description: "Steps to run sequentially (always awaited)" }),
65
+ Type.Array(chainItem, { minItems: 1, description: PROMPTS["tools.subagent.param.chain"] }),
61
66
  ),
62
67
  async: Type.Optional(
63
- Type.Boolean({ description: "Return immediately with a run_id; completion arrives via notification" }),
68
+ Type.Boolean({ description: PROMPTS["tools.subagent.param.async"] }),
64
69
  ),
65
70
  concurrency: Type.Optional(
66
- 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"] }),
67
72
  ),
68
73
  fail_fast: Type.Optional(
69
- Type.Boolean({
70
- description: "Cancel not-yet-started subagents on first failure (already-started ones finish)",
71
- }),
74
+ Type.Boolean({ description: PROMPTS["tools.subagent.param.fail_fast"] }),
72
75
  ),
73
76
  model: Type.Optional(
74
77
  Type.String({
75
- description:
76
- 'Model override for all subagents: fuzzy ("haiku"), qualified ("provider/id"), ' +
77
- `optionally with ":<thinking>" suffix (${VALID_THINKING_LEVELS.join(", ")}; ` +
78
- 'an unknown suffix is dropped with a warning when the base resolves; ' +
79
- 'an unknown base still errors). Default: current model. ' +
80
- 'Use action:"models" to list selectable values.',
78
+ description: fill("tools.subagent.param.model", { thinkingLevels: VALID_THINKING_LEVELS.join(", ") }),
81
79
  }),
82
80
  ),
83
81
  timeout_ms: Type.Optional(
84
82
  Type.Number({
85
- description:
86
- `Time budget per subagent turn in ms (default 1800000, max ${MAX_TIMEOUT_MS}). ` +
87
- 'Passing it does not stop the subagent: you get <pi-famulus-wake kind="subagent-overrun"> ' +
88
- "and choose extend, steer, or interrupt. With resume: the resumed turn's budget. With extend: the new budget from now.",
83
+ description: fill("tools.subagent.param.timeout_ms", { maxMs: MAX_TIMEOUT_MS }),
89
84
  maximum: MAX_TIMEOUT_MS,
90
85
  }),
91
86
  ),
@@ -101,19 +96,19 @@ const subagentParameters = Type.Object({
101
96
  Type.Literal("extend"),
102
97
  Type.Literal("models"),
103
98
  ],
104
- { description: "Manage an existing run (or list selectable models) instead of starting a new one" },
99
+ { description: PROMPTS["tools.subagent.param.action"] },
105
100
  ),
106
101
  ),
107
- run_id: Type.Optional(Type.String({ description: "Target run for action" })),
102
+ run_id: Type.Optional(Type.String({ description: PROMPTS["tools.subagent.param.run_id"] })),
108
103
  child_id: Type.Optional(
109
- Type.String({ description: "Target child (id or name) for steer/interrupt/resume/extend" }),
104
+ Type.String({ description: PROMPTS["tools.subagent.param.child_id"] }),
110
105
  ),
111
- message: Type.Optional(Type.String({ description: "Message content for steer/resume" })),
106
+ message: Type.Optional(Type.String({ description: PROMPTS["tools.subagent.param.message"] })),
112
107
  });
113
108
 
114
109
  type SubagentParams = {
115
- tasks?: { agent?: string; prompt: string; name?: string }[];
116
- 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 }[];
117
112
  async?: boolean;
118
113
  concurrency?: number;
119
114
  fail_fast?: boolean;
@@ -156,8 +151,7 @@ export interface SubagentToolDeps {
156
151
  /** M3 stopgap resolver until M5 wires the real agent loader (§4.8). */
157
152
  const BUILTIN_WORKER: AgentDefinition = {
158
153
  name: "worker",
159
- description: "General-purpose subagent with the full default tool set",
160
- tools: ["read", "bash", "edit", "write"],
154
+ description: "General-purpose subagent with user-configured tools",
161
155
  systemPrompt: "",
162
156
  source: "builtin",
163
157
  };
@@ -373,12 +367,12 @@ export function createSubagentTool(
373
367
  const label = "name" in item ? item.name : undefined;
374
368
  const chainLabel = "label" in item ? item.label : undefined;
375
369
  const name = label ?? chainLabel ?? `${agent.name}-${i + 1}`;
376
- return { agent, name, prompt: item.prompt };
370
+ return { agent, name, prompt: item.prompt, workKind: item.work_kind ?? "other" };
377
371
  });
378
372
 
379
373
  const run = registry.createRun(kind);
380
374
  const childIds = resolved.map((r) =>
381
- 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 }),
382
376
  );
383
377
 
384
378
  const makeRequest = (ordinal: number, prompt: string): ChildRunRequest => ({
@@ -389,6 +383,7 @@ export function createSubagentTool(
389
383
  taskPrompt: prompt,
390
384
  agent: resolved[ordinal].agent,
391
385
  model: params.model,
386
+ workKind: resolved[ordinal].workKind,
392
387
  timeoutMs,
393
388
  depth: 1,
394
389
  });
@@ -452,13 +447,7 @@ export function createSubagentTool(
452
447
  content: [
453
448
  {
454
449
  type: "text",
455
- text:
456
- `Started ${items.length} subagent(s) in run ${run.runId}. ${reason}\n` +
457
- `While others are still running, each finished subagent arrives as <pi-famulus-wake kind="subagent-handover"> ` +
458
- `with that child's prompt and result. Read it and continue: subagent({action:"resume", run_id, child_id, message}) for that child, ` +
459
- `or agent_message to steer the ones still running. Do not wait for the whole run. Do not poll. ` +
460
- `<pi-famulus-wake kind="subagent-done"> arrives when every subagent in the run has finished. ` +
461
- `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 }),
462
451
  },
463
452
  ],
464
453
  details: { run_id: run.runId, status: "backgrounded" },
@@ -624,15 +613,18 @@ export function createSubagentTool(
624
613
  const hard =
625
614
  hardDeadlineAt === null
626
615
  ? ""
627
- : ` The configured hard ceiling (hardTimeoutMs) is not moved: it stops this subagent in ${formatDurationMs(Math.max(0, hardDeadlineAt - now))}.`;
616
+ : ` ${fill("tools.subagent.result.extended.hard-ceiling", { remaining: formatDurationMs(Math.max(0, hardDeadlineAt - now)) })}`;
628
617
  return {
629
618
  content: [
630
619
  {
631
620
  type: "text",
632
621
  text:
633
- `Extended subagent ${child?.name ?? handle.childId} (${handle.childId}) in run ${record.runId}: ` +
634
- `the next <pi-famulus-wake kind="subagent-overrun"> comes in ${formatDurationMs(deadlineAt - now)} if it is still running. ` +
635
- `Its result arrives as a wake when it finishes; do not poll.${hard}`,
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,
636
628
  },
637
629
  ],
638
630
  details: { run_id: record.runId, child_id: handle.childId, deadline_at: deadlineAt, hard_deadline_at: hardDeadlineAt },
@@ -662,11 +654,12 @@ export function createSubagentTool(
662
654
  content: [
663
655
  {
664
656
  type: "text",
665
- text:
666
- `Resumed subagent ${child.name} (${child.childId}) in run ${record.runId}. ` +
667
- queuedText(queuedBehind) +
668
- "You will be notified via <pi-famulus-wake kind=\"subagent-handover\"> if others are still running, " +
669
- "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
+ }),
670
663
  },
671
664
  ],
672
665
  details: { run_id: record.runId, child_id: child.childId, queued_behind: queuedBehind },
@@ -676,22 +669,9 @@ export function createSubagentTool(
676
669
  return {
677
670
  name: "subagent",
678
671
  label: "Subagent",
679
- description:
680
- "Run subagents in parallel (tasks) or sequentially (chain with {previous}/{outputs.<label>} " +
681
- "interpolation). By default the call waits up to a foreground budget (default 45s); longer runs " +
682
- "continue in the background. Each child that finishes while others are still running wakes you with " +
683
- "<pi-famulus-wake kind=\"subagent-handover\"> (its prompt and result). The whole run wakes you with <pi-famulus-wake kind=\"subagent-done\">. " +
684
- "Never poll or sleep to wait. " +
685
- "A subagent still running past its timeout_ms is not stopped; it wakes you with " +
686
- "<pi-famulus-wake kind=\"subagent-overrun\"> so you can extend, steer, or interrupt it. " +
687
- "Use action=list/get/status/interrupt/resume/steer/extend to manage existing runs.",
688
- promptSnippet: "Fan out subagents in parallel or sequence them in a chain",
689
- promptGuidelines: [
690
- '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.',
691
- "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.",
692
- "A failed subagent does not fail the whole run; inspect per-subagent sections in the result.",
693
- "<pi-famulus-wake> is a system wake, not a user reply. kind=subagent-handover is one child; kind=subagent-done is the whole run.",
694
- ],
672
+ description: PROMPTS["tools.subagent.description"],
673
+ promptSnippet: PROMPTS["tools.subagent.snippet"],
674
+ promptGuidelines: [...PROMPTS["tools.subagent.rules"]],
695
675
  parameters: subagentParameters,
696
676
  renderResult(result, { expanded }, theme, context) {
697
677
  const details = result.details as { run_id?: string; status?: string } | undefined;
@@ -796,10 +776,7 @@ function resolveSingleTerminalChild(record: RunRecord, childIdOrName: string | u
796
776
  if (child.status === "pending") {
797
777
  // Queued for an admission slot (a launch or an earlier resume): steer
798
778
  // would fail too, and a second resume adds nothing.
799
- throw new Error(
800
- `subagent ${child.name} (${child.childId}) is already queued and starts when a subagent slot frees; ` +
801
- "its result arrives as a wake when it finishes. Do not resume it again.",
802
- );
779
+ throw new Error(fill("tools.subagent.error.resume-queued", { name: child.name, childId: child.childId }));
803
780
  }
804
781
  if (!matches(child)) {
805
782
  throw new Error(`subagent ${child.name} (${child.childId}) is still ${child.status}; use steer instead`);
@@ -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,7 +16,7 @@
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
21
  // Mirror of src/agents/definition.ts — keep in sync with
20
22
  // src/thinking-levels.ts (VALID_THINKING_LEVELS), the single source.
@@ -26,6 +28,17 @@ export interface AgentDefinition {
26
28
 
27
29
  export type ChildStatus = "pending" | "running" | "completed" | "failed" | "interrupted";
28
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
+
29
42
  export interface ChildResult {
30
43
  status: "completed" | "failed" | "interrupted";
31
44
  text: string; // getLastAssistantText() or "(no output)"
@@ -43,6 +56,8 @@ export interface ChildResult {
43
56
  /** Stall detections in the user turn that produced this result. */
44
57
  stalls?: number;
45
58
  durationMs: number;
59
+ /** Time waiting for local and machine-wide admission in this turn. */
60
+ queueMs?: number;
46
61
  }
47
62
 
48
63
  /** One turn of a child session, for the /tasks conversation view. */
@@ -60,11 +75,36 @@ export interface ChildRunRequest {
60
75
  taskPrompt?: string;
61
76
  agent: AgentDefinition; // already resolved
62
77
  model?: string; // subagent() parameter-level override
78
+ /** Explicit planned workload class; natural-language prompts are not classified. */
79
+ workKind?: AgentWorkKind;
63
80
  /** Soft budget per turn: reaching it wakes the parent; it does not abort. */
64
81
  timeoutMs: number;
65
82
  depth: number; // main session = 0, child = 1
66
83
  }
67
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
+
68
108
  export interface ChildHandle {
69
109
  readonly childId: string;
70
110
  readonly result: Promise<ChildResult>; // resolves exactly once per generation;
@@ -85,8 +125,16 @@ export interface ChildHandle {
85
125
  lastEventAt(): number; // for the stall watchdog / status display
86
126
  /** Resolved `provider/id` once the child session has been constructed. */
87
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;
88
132
  /** Live child transcript. Empty when the session never started. */
89
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;
90
138
  }
91
139
 
92
140
  /**
@@ -101,6 +149,8 @@ export interface ChildSessionAdapter {
101
149
  * Used for fleet/ls persistence and so the child prompt can name its model.
102
150
  */
103
151
  readonly resolvedModel?: string;
152
+ /** Effective thinking level passed to createAgentSession, after model fallbacks. */
153
+ readonly effectiveThinkingLevel?: string;
104
154
  prompt(text: string): Promise<void>;
105
155
  steer(text: string): Promise<void>;
106
156
  followUp(text: string): Promise<void>;
@@ -110,18 +160,22 @@ export interface ChildSessionAdapter {
110
160
  /** Provider/model failure outcome of the last assistant turn, when any. */
111
161
  getLastAssistantFailure?(): { stopReason: "error" | "aborted"; errorMessage?: string } | undefined;
112
162
  getConversation(): ConversationTurn[];
163
+ tokenUsage(): ChildTokenUsage;
113
164
  /** Introspection for child-session orchestration and contract tests. */
114
165
  getActiveToolNames?(): string[];
115
166
  getSystemPrompt?(): string;
116
167
  isStreaming(): boolean;
117
- subscribe(listener: (event: { type: string }) => void): () => void;
118
- 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>;
119
173
  }
120
174
 
121
175
  export type CreateSessionFn = (req: ChildRunRequest) => Promise<ChildSessionAdapter>;
122
176
 
123
177
  export interface ChildRunner {
124
- start(req: ChildRunRequest): Promise<ChildHandle>;
178
+ start(req: ChildRunRequest, onHandle?: (handle: ChildHandle) => void): Promise<ChildHandle>;
125
179
  }
126
180
 
127
181
  /**
@@ -130,5 +184,6 @@ export interface ChildRunner {
130
184
  * duck-types this to release sessions in disposeRun().
131
185
  */
132
186
  export interface DisposableChildHandle extends ChildHandle {
133
- dispose(): void;
187
+ /** Settles immediately; an optional promise tracks asynchronous session cleanup. */
188
+ dispose(): void | Promise<void>;
134
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);
@@ -102,7 +102,8 @@ function collapsedText(details: FamulusWake, theme: Theme): string {
102
102
  const shell = details.shell
103
103
  ? ` · shell ${formatDuration(details.shell.elapsedMs)}${details.shell.growing === null ? "" : details.shell.growing ? " (output growing)" : " (output idle)"}`
104
104
  : "";
105
- return `${theme.fg("warning", "!")} ${theme.fg("muted", "overrun")} ${details.name} ${formatDuration(details.elapsedMs)} / ${formatDuration(details.budgetMs)} budget · still running${shell}`;
105
+ const additional = details.additional?.length ? ` · +${details.additional.length} more overdue` : "";
106
+ return `${theme.fg("warning", "!")} ${theme.fg("muted", "overrun")} ${details.name} ${formatDuration(details.elapsedMs)} / ${formatDuration(details.budgetMs)} budget · still running${additional}${shell}`;
106
107
  }
107
108
  case "supervisor-request":
108
109
  return [
@@ -168,6 +169,21 @@ export function expandedWakeText(details: FamulusWake | undefined, content: stri
168
169
  ...(details.hardCeilingMs === undefined || details.nextReminderMs < details.hardCeilingMs
169
170
  ? [`Next reminder in ${formatDuration(details.nextReminderMs)} unless you extend, steer, or interrupt it.`]
170
171
  : []),
172
+ ...(details.additional?.length
173
+ ? [
174
+ `Also overdue (${details.additional.length}):`,
175
+ ...details.additional.map((child) => [
176
+ `${child.name} (${child.childId}) · run ${child.runId}`,
177
+ `Elapsed this turn: ${formatDuration(child.elapsedMs)} of ${formatDuration(child.budgetMs)}`,
178
+ `Last activity (${formatDuration(child.lastActivity.agoMs)} ago): ${child.lastActivity.text}`,
179
+ ...(child.shell ? [`Shell ${child.shell.taskId} · ${formatDuration(child.shell.elapsedMs)}\n$ ${child.shell.command}\nOutput: ${child.shell.outputPath}`] : []),
180
+ ...(child.hardCeilingMs !== undefined ? [`Hard ceiling stops it in ${formatDuration(child.hardCeilingMs)}.`] : []),
181
+ ...(child.hardCeilingMs === undefined || child.nextReminderMs < child.hardCeilingMs
182
+ ? [`Next reminder in ${formatDuration(child.nextReminderMs)}.`]
183
+ : []),
184
+ ].join("\n")),
185
+ ]
186
+ : []),
171
187
  ].join("\n\n");
172
188
  }
173
189
  case "supervisor-request":