pi-famulus 0.1.2 → 0.1.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-famulus",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Subagents, monitoring, and auto-backgrounding bash for pi",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -9,19 +9,36 @@
9
9
  "url": "https://github.com/Leechael/pi-famulus",
10
10
  "directory": "extension"
11
11
  },
12
- "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org" },
13
- "os": ["linux", "darwin"],
14
- "cpu": ["x64", "arm64"],
15
- "files": ["src", "bin", "README.md"],
16
- "bin": { "pi-famulus": "./bin/pi-famulus.js" },
12
+ "publishConfig": {
13
+ "access": "public",
14
+ "registry": "https://registry.npmjs.org"
15
+ },
16
+ "os": [
17
+ "linux",
18
+ "darwin"
19
+ ],
20
+ "cpu": [
21
+ "x64",
22
+ "arm64"
23
+ ],
24
+ "files": [
25
+ "src",
26
+ "bin",
27
+ "README.md"
28
+ ],
29
+ "bin": {
30
+ "pi-famulus": "./bin/pi-famulus.js"
31
+ },
17
32
  "optionalDependencies": {
18
- "pi-famulus-linux-x64": "0.1.2",
19
- "pi-famulus-linux-arm64": "0.1.2",
20
- "pi-famulus-darwin-x64": "0.1.2",
21
- "pi-famulus-darwin-arm64": "0.1.2"
33
+ "pi-famulus-linux-x64": "0.1.3",
34
+ "pi-famulus-linux-arm64": "0.1.3",
35
+ "pi-famulus-darwin-x64": "0.1.3",
36
+ "pi-famulus-darwin-arm64": "0.1.3"
22
37
  },
23
38
  "pi": {
24
- "extensions": ["./src/index.ts"]
39
+ "extensions": [
40
+ "./src/index.ts"
41
+ ]
25
42
  },
26
43
  "scripts": {
27
44
  "test": "vitest run",
@@ -15,6 +15,12 @@
15
15
  * Body becomes the system prompt (trimmed; may be empty).
16
16
  */
17
17
 
18
+ import {
19
+ isValidThinkingLevel,
20
+ VALID_THINKING_LEVELS,
21
+ type ThinkingLevel,
22
+ } from "../thinking-levels";
23
+
18
24
  export interface AgentDefinition {
19
25
  /** Must match ^[a-z][a-z0-9-]*$ */
20
26
  name: string;
@@ -24,7 +30,8 @@ export interface AgentDefinition {
24
30
  tools: string[];
25
31
  /** "provider:id" or bare id. */
26
32
  model?: string;
27
- thinking?: "minimal" | "low" | "medium" | "high" | "xhigh";
33
+ /** Thinking level (pi core parity; src/thinking-levels.ts). */
34
+ thinking?: ThinkingLevel;
28
35
  /** Frontmatter body, trimmed. Empty body → "". */
29
36
  systemPrompt: string;
30
37
  source: "builtin" | "user" | "project";
@@ -35,8 +42,6 @@ export interface AgentDefinition {
35
42
  export const DEFAULT_AGENT_TOOLS: readonly string[] = ["read", "bash", "edit", "write"];
36
43
 
37
44
  const NAME_PATTERN = /^[a-z][a-z0-9-]*$/;
38
- const THINKING_LEVELS = ["minimal", "low", "medium", "high", "xhigh"] as const;
39
- type ThinkingLevel = (typeof THINKING_LEVELS)[number];
40
45
 
41
46
  type Fail = (reason: string) => never;
42
47
 
@@ -178,12 +183,13 @@ export function parseAgentMarkdown(
178
183
  let thinking: ThinkingLevel | undefined;
179
184
  if (fields.thinking !== undefined) {
180
185
  const value = parseScalar(fields.thinking, "thinking", fail);
181
- if (!(THINKING_LEVELS as readonly string[]).includes(value)) {
186
+ if (isValidThinkingLevel(value)) {
187
+ thinking = value;
188
+ } else {
182
189
  fail(
183
- `invalid thinking level "${value}" (expected one of: ${THINKING_LEVELS.join(", ")})`,
190
+ `invalid thinking level "${value}" (expected one of: ${VALID_THINKING_LEVELS.join(", ")})`,
184
191
  );
185
192
  }
186
- thinking = value as ThinkingLevel;
187
193
  }
188
194
 
189
195
  return {
@@ -22,6 +22,7 @@ export const BEHAVIOR_GUIDELINES = `## Background tasks and notifications (pi-fa
22
22
  - When you receive <pi-famulus-wake>: it looks like a user message but is a system wake, not a new user request and not an answer to an unrelated question. Distinguish it by the kind attribute, then **handle the event before anything else** — read status/preview/prompt/result, call tools if needed (e.g. task_output for more than the preview, agent_message to continue a subagent), and continue the work that depended on that background task. Do not wait for further user input when the next step is clear; do not merely acknowledge and stop.
23
23
  - Never fabricate or assume a background task's result before its notification arrives.
24
24
  - Subagent runs that exceed the foreground budget continue in the background. Do not poll run status. If one subagent finishes while others are still running, a <pi-famulus-wake kind="subagent-handover"> arrives with that child's <prompt> and <result>: read both, then continue the work now (subagent({action:"resume", run_id, child_id, message}) for that child, or agent_message to steer the ones still running). Do not wait for the rest of the run. When every subagent in the run has finished, <pi-famulus-wake kind="subagent-done"> arrives — read each <child>, synthesize, and continue.
25
+ - A subagent that runs past its timeout_ms is not stopped: <pi-famulus-wake kind="subagent-overrun"> arrives instead, with its <last-activity> and, if it is waiting on a foreground shell, a <shell> element. Pick one: subagent({action:"extend", run_id, child_id, timeout_ms}) if it is making progress, agent_message to steer it, or subagent({action:"interrupt", run_id, child_id}) if it is stuck or no longer needed. If you do none of these it keeps running and the reminder comes again.
25
26
  - Use agent_message to steer running subagents and to reply to <pi-famulus-wake kind="supervisor-request"> (action:"reply"; the <reply-with> child is the call shape). Do not use agent_message to resume a finished child — that is subagent({action:"resume"}). Supervisor wakes are not user messages — still act on them.`;
26
27
 
27
28
  export interface GuidelinePromptOptions {
package/src/clock.ts CHANGED
@@ -43,6 +43,11 @@ export class ManualClock implements Clock {
43
43
  return this.currentTime;
44
44
  }
45
45
 
46
+ /** Timers still scheduled: lets tests assert that settle left nothing armed. */
47
+ get pendingTimers(): number {
48
+ return this.timers.size;
49
+ }
50
+
46
51
  setTimeout(callback: () => void, delayMs: number): ClockTimer {
47
52
  return this.schedule(callback, delayMs, null);
48
53
  }
package/src/config.ts CHANGED
@@ -25,8 +25,15 @@ export interface FamulusConfig {
25
25
  export interface FamulusSubagentConfig {
26
26
  /** Sync-wait budget before a run is moved to background (ms). Overrides top-level subagentBudgetMs. */
27
27
  budgetMs?: number;
28
- /** Default per-child hard timeout (ms). */
28
+ /**
29
+ * Default per-child soft budget per turn (ms). Reaching it wakes the parent
30
+ * (subagent-overrun); the child keeps running.
31
+ */
29
32
  timeoutMs?: number;
33
+ /** Repeat interval of the overrun wake while a child stays past its budget (ms). Default 10 min. */
34
+ overrunRepeatMs?: number;
35
+ /** Opt-in ceiling per turn that aborts the child (ms). 0 or absent = off (default). */
36
+ hardTimeoutMs?: number;
30
37
  /** Stall watchdog: abort a child with no events for this long (ms). Paused during tools and need_decision. */
31
38
  stallMs?: number;
32
39
  /** Auto-resume attempts after a stall before the run settles failed (stalled). Default 1. */
@@ -47,6 +54,9 @@ export interface FamulusSubagentConfig {
47
54
  export interface ResolvedSubagentConfig {
48
55
  budgetMs: number;
49
56
  timeoutMs: number;
57
+ overrunRepeatMs: number;
58
+ /** 0 = no hard ceiling. */
59
+ hardTimeoutMs: number;
50
60
  stallMs: number;
51
61
  stallRetries: number;
52
62
  stallRetryDelayMs: number;
@@ -69,6 +79,8 @@ function capTimerDelay(value: number): number {
69
79
  export const DEFAULT_SUBAGENT_CONFIG: ResolvedSubagentConfig = {
70
80
  budgetMs: 45000,
71
81
  timeoutMs: 1_800_000,
82
+ overrunRepeatMs: 600_000,
83
+ hardTimeoutMs: 0,
72
84
  stallMs: 300_000,
73
85
  stallRetries: 1,
74
86
  stallRetryDelayMs: 5_000,
@@ -85,6 +97,12 @@ export function resolveSubagentConfig(config: FamulusConfig): ResolvedSubagentCo
85
97
  resolved.budgetMs = config.subagentBudgetMs > 0 ? config.subagentBudgetMs : resolved.budgetMs;
86
98
  if (typeof section.budgetMs === "number" && section.budgetMs > 0) resolved.budgetMs = section.budgetMs;
87
99
  if (typeof section.timeoutMs === "number" && section.timeoutMs > 0) resolved.timeoutMs = section.timeoutMs;
100
+ if (typeof section.overrunRepeatMs === "number" && section.overrunRepeatMs > 0) {
101
+ resolved.overrunRepeatMs = capTimerDelay(section.overrunRepeatMs);
102
+ }
103
+ if (typeof section.hardTimeoutMs === "number" && section.hardTimeoutMs >= 0) {
104
+ resolved.hardTimeoutMs = capTimerDelay(section.hardTimeoutMs);
105
+ }
88
106
  if (typeof section.stallMs === "number" && section.stallMs > 0) {
89
107
  resolved.stallMs = capTimerDelay(section.stallMs);
90
108
  }
@@ -175,6 +193,12 @@ export function loadConfig(home: string = getFamulusHome()): FamulusConfig {
175
193
  const subagent: FamulusSubagentConfig = {};
176
194
  if (typeof section.budgetMs === "number" && section.budgetMs > 0) subagent.budgetMs = section.budgetMs;
177
195
  if (typeof section.timeoutMs === "number" && section.timeoutMs > 0) subagent.timeoutMs = section.timeoutMs;
196
+ if (typeof section.overrunRepeatMs === "number" && section.overrunRepeatMs > 0) {
197
+ subagent.overrunRepeatMs = capTimerDelay(section.overrunRepeatMs);
198
+ }
199
+ if (typeof section.hardTimeoutMs === "number" && section.hardTimeoutMs >= 0) {
200
+ subagent.hardTimeoutMs = capTimerDelay(section.hardTimeoutMs);
201
+ }
178
202
  if (typeof section.stallMs === "number" && section.stallMs > 0) {
179
203
  subagent.stallMs = capTimerDelay(section.stallMs);
180
204
  }
package/src/format.ts CHANGED
@@ -4,7 +4,14 @@
4
4
  * These functions are the shared contract between implementation and tests.
5
5
  * Signatures must match Appendix A exactly.
6
6
  */
7
- import { formatFamulusWake, shellWakeTitle, type FormattedWake, type WakeItem } from "./wake";
7
+ import {
8
+ formatFamulusWake,
9
+ shellWakeTitle,
10
+ wakeDuration,
11
+ type FormattedWake,
12
+ type OverrunShell,
13
+ type WakeItem,
14
+ } from "./wake";
8
15
 
9
16
  export { FAMULUS_WAKE_LEAD_IN } from "./wake";
10
17
  export type { FormattedWake, WakeItem } from "./wake";
@@ -195,6 +202,8 @@ export interface SubagentChildInfo {
195
202
  status: "pending" | "running" | "completed" | "failed" | "interrupted";
196
203
  text: string;
197
204
  error?: string;
205
+ /** Non-fatal caveat from the child run (e.g. an adapted model spec). */
206
+ warning?: string;
198
207
  /** Task prompt that was sent to this child. */
199
208
  prompt?: string;
200
209
  }
@@ -230,6 +239,7 @@ export interface SubagentHandoverInfo {
230
239
  prompt: string;
231
240
  text: string;
232
241
  error?: string;
242
+ warning?: string;
233
243
  /** Children that are still pending or running. Title is the agent name. */
234
244
  stillRunning: WakeItem[];
235
245
  }
@@ -251,6 +261,7 @@ export function formatSubagentNotification(info: SubagentNotificationInfo): Form
251
261
  prompt: capPrompt(child.prompt ?? ""),
252
262
  result: capTail(child.text),
253
263
  ...(child.error ? { error: child.error } : {}),
264
+ ...(child.warning ? { warning: child.warning } : {}),
254
265
  })),
255
266
  });
256
267
  }
@@ -272,5 +283,55 @@ export function formatSubagentHandover(info: SubagentHandoverInfo): FormattedWak
272
283
  prompt: capPrompt(info.prompt),
273
284
  result: capTail(info.text),
274
285
  ...(info.error ? { error: info.error } : {}),
286
+ ...(info.warning ? { warning: info.warning } : {}),
287
+ });
288
+ }
289
+
290
+ // ---------------------------------------------------------------------------
291
+ // Soft deadline: a running child passed its budget (design doc §4.6 lifecycle)
292
+ // ---------------------------------------------------------------------------
293
+
294
+ export interface SubagentOverrunInfo {
295
+ runId: string;
296
+ childId: string;
297
+ name: string;
298
+ elapsedMs: number;
299
+ budgetMs: number;
300
+ reminder: number;
301
+ nextReminderMs: number;
302
+ /** Time left until the opt-in hard ceiling aborts the child. Absent when none is set. */
303
+ hardCeilingMs?: number;
304
+ lastActivity: { agoMs: number; text: string };
305
+ shell?: OverrunShell;
306
+ }
307
+
308
+ /** Last-activity text cap: enough to see what the child was doing. */
309
+ const OVERRUN_ACTIVITY_CHARS = 300;
310
+
311
+ /**
312
+ * A child is past its soft budget and still running. The parent decides:
313
+ * extend, steer, or interrupt; doing nothing leaves it running.
314
+ */
315
+ export function formatSubagentOverrun(info: SubagentOverrunInfo): FormattedWake {
316
+ const shellBit = info.shell ? `; it is waiting on a shell command that has run ${wakeDuration(info.shell.elapsedMs)}` : "";
317
+ const summary =
318
+ `${info.name} has run ${wakeDuration(info.elapsedMs)} in this turn, past its ` +
319
+ `${wakeDuration(info.budgetMs)} budget, and is still running${shellBit}.`;
320
+ const oneLine = info.lastActivity.text.replace(/\s+/g, " ").trim();
321
+ const text =
322
+ oneLine.length > OVERRUN_ACTIVITY_CHARS ? `…${oneLine.slice(-OVERRUN_ACTIVITY_CHARS)}` : oneLine;
323
+ return formatFamulusWake({
324
+ kind: "subagent-overrun",
325
+ runId: info.runId,
326
+ childId: info.childId,
327
+ name: info.name,
328
+ elapsedMs: info.elapsedMs,
329
+ budgetMs: info.budgetMs,
330
+ reminder: info.reminder,
331
+ nextReminderMs: info.nextReminderMs,
332
+ ...(info.hardCeilingMs !== undefined ? { hardCeilingMs: info.hardCeilingMs } : {}),
333
+ summary,
334
+ lastActivity: { agoMs: info.lastActivity.agoMs, text: text || "(no output yet)" },
335
+ ...(info.shell ? { shell: { ...info.shell, command: shellWakeTitle(info.shell.command) } } : {}),
275
336
  });
276
337
  }
package/src/index.ts CHANGED
@@ -11,6 +11,7 @@ import { ExitNotifyGate } from "./exit-notify-gate";
11
11
  import { ExitWatchdog } from "./exit-watchdog";
12
12
  import { createExtensionEventLog } from "./events";
13
13
  import { readFileTail } from "./file-tail";
14
+ import { statSync } from "node:fs";
14
15
  import { readdir, readFile, stat } from "node:fs/promises";
15
16
  import { homedir } from "node:os";
16
17
  import { join } from "node:path";
@@ -27,7 +28,9 @@ import type { TaskExitInfo } from "./format";
27
28
  import { ManagerClient, type ManagerEvent, type TaskRecord } from "./manager-client";
28
29
  import { createMonitorTool, exitEventFromRecord, MonitorRegistry } from "./monitor";
29
30
  import { NotifyCenter } from "./notify";
31
+ import { onWakeMessageEnd, registryStatusLookup } from "./wake-delivery";
30
32
  import { createChildBashTool } from "./subagent/child-bash";
33
+ import { ChildShellTracker, createOverrunNotifier } from "./subagent/overrun";
31
34
  import {
32
35
  agentEndReason,
33
36
  headOf,
@@ -504,6 +507,8 @@ export default function (pi: ExtensionAPI): void {
504
507
  "warning",
505
508
  );
506
509
  }
510
+ // The foreground shell each child is blocked on, for its overrun wake.
511
+ const childShells = new ChildShellTracker();
507
512
  const createSession = createPiSessionFn({
508
513
  getModelRegistry: () => ctx?.modelRegistry ?? null,
509
514
  getModelRuntime: () => {
@@ -533,6 +538,7 @@ export default function (pi: ExtensionAPI): void {
533
538
  childId: req.childId,
534
539
  runId: req.runId,
535
540
  clock,
541
+ shells: childShells,
536
542
  }),
537
543
  );
538
544
  }
@@ -551,7 +557,9 @@ export default function (pi: ExtensionAPI): void {
551
557
  stallMs: subagentConfig.stallMs,
552
558
  stallRetries: subagentConfig.stallRetries,
553
559
  stallRetryDelayMs: subagentConfig.stallRetryDelayMs,
554
- acquire: (req) => registry.admitChild(req.childId),
560
+ overrunRepeatMs: subagentConfig.overrunRepeatMs,
561
+ hardTimeoutMs: subagentConfig.hardTimeoutMs,
562
+ acquire: (req, ticket) => registry.admitChild(req.childId, ticket),
555
563
  onActivity: (childId) => {
556
564
  syncTranscript(childId);
557
565
  },
@@ -560,6 +568,22 @@ export default function (pi: ExtensionAPI): void {
560
568
  // retry budget (stallRetries) is already spent.
561
569
  logEvent("agent.stall", { child_id: childId, attempt });
562
570
  },
571
+ // Soft deadline: the child keeps running; the parent decides.
572
+ onOverrun: createOverrunNotifier({
573
+ now: () => clock.now(),
574
+ registry,
575
+ shells: childShells,
576
+ stat: (path) => {
577
+ try {
578
+ const st = statSync(path);
579
+ return { size: st.size, mtimeMs: st.mtimeMs };
580
+ } catch {
581
+ return null;
582
+ }
583
+ },
584
+ notify: (wake) => notifyCenter?.notify(wake),
585
+ logEvent,
586
+ }),
563
587
  });
564
588
  registry.setRunner(runner);
565
589
  subagentRegistry = registry;
@@ -581,6 +605,7 @@ export default function (pi: ExtensionAPI): void {
581
605
  }
582
606
  if (["completed", "failed", "interrupted"].includes(c.status) && previousStatus !== c.status) {
583
607
  const error = c.result?.error;
608
+ // Only the opt-in hard ceiling settles with error=timeout now.
584
609
  if (error === "timeout") logEvent("agent.timeout", { child_id: c.childId });
585
610
  logEvent("agent.settle", {
586
611
  child_id: c.childId,
@@ -694,6 +719,16 @@ export default function (pi: ExtensionAPI): void {
694
719
  notifyCenter?.settled();
695
720
  });
696
721
 
722
+ // A wake entering the model's context (steer / triggerTurn); see wake-delivery.ts.
723
+ pi.on("message_end", async (event) => {
724
+ const message = onWakeMessageEnd(event.message, {
725
+ now: () => clock.now(),
726
+ logEvent,
727
+ lookup: () => (subagentRegistry ? registryStatusLookup(subagentRegistry) : null),
728
+ });
729
+ return message ? { message } : undefined;
730
+ });
731
+
697
732
  pi.on("before_agent_start", async (event) => {
698
733
  applyBehaviorGuidelines(event.systemPromptOptions as { sections?: Record<string, string> });
699
734
  });
@@ -16,7 +16,7 @@ import { famulusPaths } from "./config";
16
16
  import { realClock, type Clock, type ClockTimer } from "./clock";
17
17
 
18
18
  const MAX_FRAME_BYTES = 4 * 1024 * 1024; // 4 MiB (§3.3)
19
- const EXTENSION_VERSION = "0.1.2";
19
+ const EXTENSION_VERSION = "0.1.3";
20
20
  // 3: speaks in-place upgrade (resends on reconnect, start keys).
21
21
  const PROTOCOL = 3;
22
22
  const HELLO_TIMEOUT_MS = 5000;
package/src/notify.ts CHANGED
@@ -14,7 +14,7 @@
14
14
  * ever delivered once.
15
15
  */
16
16
  import { formatMonitorEvent, formatTaskNotification, type TaskExitInfo } from "./format";
17
- import { FAMULUS_WAKE_CUSTOM_TYPE, type WakeItem } from "./wake";
17
+ import { FAMULUS_WAKE_CUSTOM_TYPE, stampWakeAsOf, wakeIds, type WakeItem } from "./wake";
18
18
  import { realClock, type Clock, type ClockTimer } from "./clock";
19
19
 
20
20
 
@@ -88,6 +88,8 @@ export class NotifyCenter {
88
88
  */
89
89
  notify(message: NotifyMessage, opts: { passive?: boolean; quietEvents?: boolean } = {}): void {
90
90
  if (this.disposed) return;
91
+ // Stamped on arrival: a held passive notice keeps the time it was built.
92
+ message = stampWakeAsOf(message, this.clock.now());
91
93
  const wake = message.details as { kind?: string; id?: string } | undefined;
92
94
  if (wake?.kind === "monitor" && typeof wake.id === "string") this.flushMonitor(wake.id, opts.quietEvents);
93
95
  if (!opts.passive) {
@@ -203,14 +205,12 @@ export class NotifyCenter {
203
205
  }
204
206
 
205
207
  private deliver(message: NotifyMessage, passive = false): void {
208
+ // Wakes built here (task batches, monitor events) are stamped now.
209
+ message = stampWakeAsOf(message, this.clock.now());
206
210
  const msg = { ...message, display: true };
207
- const details = message.details as { kind?: string; id?: string; taskId?: string; tasks?: { id: string }[]; children?: { childId: string }[]; childId?: string; from?: string; eventCount?: number } | undefined;
211
+ const details = message.details as { kind?: string; eventCount?: number } | undefined;
208
212
  if (details?.kind) {
209
- const ids = details.kind === "task"
210
- ? (details.tasks ?? []).map((task) => task.id)
211
- : details.kind === "subagent-done"
212
- ? (details.children ?? []).map((child) => child.childId)
213
- : [details.id ?? details.taskId ?? details.childId ?? details.from].filter((id): id is string => Boolean(id));
213
+ const ids = wakeIds(details);
214
214
  this.deps.logEvent?.("wake.emit", {
215
215
  kind: details.kind,
216
216
  ids,
@@ -25,6 +25,7 @@ import type {
25
25
  import { taskOutputPath } from "../config";
26
26
  import { realClock, type Clock } from "../clock";
27
27
  import type { ManagerClient } from "../manager-client";
28
+ import type { ChildShellTracker } from "./overrun";
28
29
  import {
29
30
  appendStatus,
30
31
  bareSleepError,
@@ -66,6 +67,8 @@ export interface ChildBashDeps {
66
67
  childId?: string;
67
68
  runId?: string;
68
69
  clock?: Clock;
70
+ /** Records the shell this child is blocked on, for its overrun wake. */
71
+ shells?: Pick<ChildShellTracker, "start" | "end">;
69
72
  }
70
73
 
71
74
  const CHILD_SLEEP_GUIDANCE =
@@ -132,52 +135,66 @@ export function createChildBashTool(
132
135
  const outputPath = taskOutputPath(deps.home, deps.sessionId(), start.task_id);
133
136
 
134
137
  const clock = deps.clock ?? realClock;
135
- const deadline = timeoutMs !== null ? clock.now() + timeoutMs : null;
136
- let waitResult: { done: boolean; exit_code?: number | null } | null = null;
137
- for (;;) {
138
- const budget =
139
- deadline === null
140
- ? WAIT_SLICE_MS
141
- : Math.min(WAIT_SLICE_MS, Math.max(1, deadline - clock.now()));
142
- try {
143
- waitResult = await withAbort(client.wait(start.task_id, budget), signal, () => {
144
- client.stop(start.task_id, "tool").catch(() => {});
145
- });
146
- } catch (err) {
147
- if ((err as Error).message === "aborted") {
148
- throw new Error("Command aborted (task stopped)");
138
+ const childId = deps.childId;
139
+ if (childId) {
140
+ deps.shells?.start(childId, {
141
+ taskId: start.task_id,
142
+ command: input.command,
143
+ startedAt: clock.now(),
144
+ outputPath,
145
+ });
146
+ }
147
+ try {
148
+ const deadline = timeoutMs !== null ? clock.now() + timeoutMs : null;
149
+ let waitResult: { done: boolean; exit_code?: number | null } | null = null;
150
+ for (;;) {
151
+ const budget =
152
+ deadline === null
153
+ ? WAIT_SLICE_MS
154
+ : Math.min(WAIT_SLICE_MS, Math.max(1, deadline - clock.now()));
155
+ try {
156
+ waitResult = await withAbort(client.wait(start.task_id, budget), signal, () => {
157
+ client.stop(start.task_id, "tool").catch(() => {});
158
+ });
159
+ } catch (err) {
160
+ if ((err as Error).message === "aborted") {
161
+ throw new Error("Command aborted (task stopped)");
162
+ }
163
+ throw new Error(
164
+ `Lost contact with pi-famulus while waiting for task ${start.task_id}: ` +
165
+ `${(err as Error).message}. Output so far: ${outputPath}.`,
166
+ );
167
+ }
168
+ if (waitResult.done) break;
169
+ if (deadline !== null && clock.now() >= deadline) {
170
+ await client.stop(start.task_id, "timeout").catch(() => {});
171
+ const collected = await collectOutput(client, start.task_id).catch(() => null);
172
+ const text = collected ? formatFinishedOutput(collected, outputPath).text : "";
173
+ throw new Error(
174
+ appendStatus(text, timedOutStatus(input.timeout!)),
175
+ );
149
176
  }
150
- throw new Error(
151
- `Lost contact with pi-famulus while waiting for task ${start.task_id}: ` +
152
- `${(err as Error).message}. Output so far: ${outputPath}.`,
153
- );
154
- }
155
- if (waitResult.done) break;
156
- if (deadline !== null && clock.now() >= deadline) {
157
- await client.stop(start.task_id, "timeout").catch(() => {});
158
- const collected = await collectOutput(client, start.task_id).catch(() => null);
159
- const text = collected ? formatFinishedOutput(collected, outputPath).text : "";
160
- throw new Error(
161
- appendStatus(text, timedOutStatus(input.timeout!)),
162
- );
163
177
  }
164
- }
165
178
 
166
- const collected = await collectOutput(client, start.task_id);
167
- const { text, details } = formatFinishedOutput(collected, outputPath);
168
- const exitCode = waitResult.exit_code ?? null;
169
- if (exitCode !== 0 && exitCode !== null) {
170
- throw new Error(appendStatus(text, `Command exited with code ${exitCode}`));
171
- }
172
- // No exit code: killed (timeout, stop, crash), unless the manager
173
- // finished it as completed with its runner status unobservable.
174
- if (exitCode === null && collected.status !== "completed") {
175
- throw new Error(appendStatus(text, await killedStatus(client, start.task_id, input.timeout)));
179
+ const collected = await collectOutput(client, start.task_id);
180
+ const { text, details } = formatFinishedOutput(collected, outputPath);
181
+ const exitCode = waitResult.exit_code ?? null;
182
+ if (exitCode !== 0 && exitCode !== null) {
183
+ throw new Error(appendStatus(text, `Command exited with code ${exitCode}`));
184
+ }
185
+ // No exit code: killed (timeout, stop, crash), unless the manager
186
+ // finished it as completed with its runner status unobservable.
187
+ if (exitCode === null && collected.status !== "completed") {
188
+ throw new Error(appendStatus(text, await killedStatus(client, start.task_id, input.timeout)));
189
+ }
190
+ const finalDetails = details
191
+ ? { ...details, task_id: start.task_id }
192
+ : ({ task_id: start.task_id } as FamulusChildBashDetails);
193
+ return { content: [{ type: "text", text }], details: finalDetails };
194
+ } finally {
195
+ // The overrun wake must not name a shell that already returned.
196
+ if (childId) deps.shells?.end(childId, start.task_id);
176
197
  }
177
- const finalDetails = details
178
- ? { ...details, task_id: start.task_id }
179
- : ({ task_id: start.task_id } as FamulusChildBashDetails);
180
- return { content: [{ type: "text", text }], details: finalDetails };
181
198
  },
182
199
  };
183
200
  }