pi-famulus 0.1.1 → 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.1",
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,36 +9,52 @@
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.1",
19
- "pi-famulus-linux-arm64": "0.1.1",
20
- "pi-famulus-darwin-x64": "0.1.1",
21
- "pi-famulus-darwin-arm64": "0.1.1"
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",
28
45
  "typecheck": "tsc --noEmit"
29
46
  },
30
- "dependencies": {
31
- "typebox": "^1.3.7"
32
- },
33
47
  "devDependencies": {
34
- "@earendil-works/pi-coding-agent": "0.87.1",
35
- "@earendil-works/pi-tui": "^0.87.1",
48
+ "@earendil-works/pi-coding-agent": "1.0.0",
49
+ "@earendil-works/pi-tui": "^1.0.0",
36
50
  "@types/node": "^24.0.0",
51
+ "typebox": "1.3.27",
37
52
  "typescript": "7.0.2",
38
53
  "vitest": "^3.0.0"
39
54
  },
40
55
  "peerDependencies": {
41
56
  "@earendil-works/pi-coding-agent": "*",
57
+ "typebox": "*",
42
58
  "@earendil-works/pi-tui": "*"
43
59
  },
44
60
  "peerDependenciesMeta": {
@@ -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 {
@@ -17,7 +17,7 @@ import { randomUUID } from "node:crypto";
17
17
  import { writeFileSync } from "node:fs";
18
18
  import { tmpdir } from "node:os";
19
19
  import { join } from "node:path";
20
- import { Type } from "typebox";
20
+ import { Type, type Static } from "typebox";
21
21
  import type {
22
22
  AgentToolResult,
23
23
  BashToolDetails,
@@ -62,6 +62,46 @@ const bashParameters = Type.Object({
62
62
 
63
63
  type BashParams = { command: string; timeout?: number; run_in_background?: boolean };
64
64
 
65
+ /**
66
+ * Mirrors the built-in bash outputSchema (pi ≥0.99) so codemode scripts calling
67
+ * `bash()` resolve to the same structured shape the built-in tool returns.
68
+ * pi does not export `bashOutputSchema`, so keep this in sync with BashToolOutput.
69
+ */
70
+ const bashOutputSchema = Type.Object({
71
+ output: Type.String(),
72
+ truncated: Type.Boolean(),
73
+ full_output_path: Type.Optional(Type.String()),
74
+ exit_code: Type.Number(),
75
+ wall_time_seconds: Type.Number(),
76
+ });
77
+ type BashToolOutput = Static<typeof bashOutputSchema>;
78
+
79
+ /** Same cap as the built-in structured output (1 MiB). */
80
+ const STRUCTURED_OUTPUT_MAX_BYTES = 1024 * 1024;
81
+
82
+ /** Full-output slice for structuredContent, keeping the tail like the built-in. */
83
+ function structuredOutput(fullText: string): { output: string; truncated: boolean } {
84
+ if (Buffer.byteLength(fullText, "utf8") <= STRUCTURED_OUTPUT_MAX_BYTES) {
85
+ return { output: fullText, truncated: false };
86
+ }
87
+ // Keep the largest tail that fits the cap without materializing the whole
88
+ // output as a second buffer: search a UTF-16 index near the end (each code
89
+ // unit encodes to at least one byte, so the tail starts within `cap` units
90
+ // of the end) and only encode that slice.
91
+ let lo = Math.max(0, fullText.length - STRUCTURED_OUTPUT_MAX_BYTES);
92
+ let hi = fullText.length;
93
+ while (lo < hi) {
94
+ const mid = (lo + hi) >> 1;
95
+ if (Buffer.byteLength(fullText.slice(mid), "utf8") <= STRUCTURED_OUTPUT_MAX_BYTES) hi = mid;
96
+ else lo = mid + 1;
97
+ }
98
+ return { output: fullText.slice(lo), truncated: true };
99
+ }
100
+
101
+ function wallSeconds(startedAtMs: number, endedAtMs: number): number {
102
+ return Math.round((endedAtMs - startedAtMs) / 100) / 10;
103
+ }
104
+
65
105
  /** Details shape returned by this override; superset of BashToolDetails. */
66
106
  export interface FamulusBashDetails extends BashToolDetails {
67
107
  backgrounded?: boolean;
@@ -130,6 +170,7 @@ async function executeLocal(
130
170
  clock: Clock,
131
171
  ): Promise<AgentToolResult<FamulusBashDetails | undefined>> {
132
172
  const timeoutMs = resolveTimeoutMs(params.timeout);
173
+ const startedAtMs = clock.now();
133
174
  const shell = process.env.SHELL && process.env.SHELL.length > 0 ? process.env.SHELL : "/bin/bash";
134
175
 
135
176
  const output = await new Promise<{ text: string; exitCode: number | null; signal: string | null; timedOut: boolean; aborted: boolean }>(
@@ -216,11 +257,26 @@ async function executeLocal(
216
257
  if (output.timedOut && params.timeout !== undefined) {
217
258
  throw new Error(appendStatus(text, timedOutStatus(params.timeout)));
218
259
  }
219
- if (output.exitCode !== 0 && output.exitCode !== null) {
220
- throw new Error(appendStatus(text, `Command exited with code ${output.exitCode}`));
221
- }
222
260
  if (output.exitCode === null) throw new Error(appendStatus(text, killedBy(output.signal)));
223
- return { content: [{ type: "text", text }], details };
261
+ const { output: structuredText, truncated: structuredTruncated } = structuredOutput(output.text);
262
+ const structuredContent: BashToolOutput = {
263
+ output: structuredText,
264
+ truncated: structuredTruncated,
265
+ ...(details?.fullOutputPath ? { full_output_path: details.fullOutputPath } : {}),
266
+ exit_code: output.exitCode,
267
+ wall_time_seconds: wallSeconds(startedAtMs, clock.now()),
268
+ };
269
+ if (output.exitCode !== 0) {
270
+ // Mirror the built-in bash tool: non-zero exits resolve to an isError
271
+ // result carrying structuredContent instead of rejecting.
272
+ return {
273
+ content: [{ type: "text", text: appendStatus(text, `Command exited with code ${output.exitCode}`) }],
274
+ details,
275
+ structuredContent,
276
+ isError: true,
277
+ };
278
+ }
279
+ return { content: [{ type: "text", text }], details, structuredContent };
224
280
  }
225
281
 
226
282
  // ---------------------------------------------------------------------------
@@ -245,6 +301,7 @@ export function createBashOverride(
245
301
  "Long-running bash commands are moved to the background automatically; do not poll or sleep to wait for them. End your turn (a reply with no tool call) and resume from the task wake when it arrives.",
246
302
  ],
247
303
  parameters: bashParameters,
304
+ outputSchema: bashOutputSchema,
248
305
  renderResult(result, { expanded }, theme, context) {
249
306
  const details = result.details as FamulusBashDetails | undefined;
250
307
  if (details?.backgrounded && details.task_id) {
@@ -291,6 +348,7 @@ export function createBashOverride(
291
348
  }
292
349
 
293
350
  const timeoutMs = resolveTimeoutMs(input.timeout);
351
+ const startedAtMs = (deps.clock ?? realClock).now();
294
352
  let start;
295
353
  try {
296
354
  start = await client.start({
@@ -358,15 +416,37 @@ export function createBashOverride(
358
416
  const collected = await collectOutput(client, start.task_id);
359
417
  const { text, details } = formatFinishedOutput(collected, outputPath);
360
418
  const exitCode = waitResult.exit_code ?? null;
361
- if (exitCode !== 0 && exitCode !== null) {
362
- throw new Error(appendStatus(text, `Command exited with code ${exitCode}`));
363
- }
364
419
  // No exit code: killed (timeout, stop, crash), unless the manager
365
420
  // finished it as completed with its runner status unobservable.
366
421
  if (exitCode === null && collected.status !== "completed") {
367
422
  throw new Error(appendStatus(text, await killedStatus(client, start.task_id, input.timeout)));
368
423
  }
369
- return { content: [{ type: "text", text }], details };
424
+ if (exitCode === null) {
425
+ // Completed but the exit code was unobservable: no honest
426
+ // structured shape (exit_code is required, and a sentinel like -1
427
+ // would misclassify success as failure), so omit structuredContent
428
+ // and let codemode scripts fall back to the text content.
429
+ return { content: [{ type: "text", text }], details };
430
+ }
431
+ const { output: structuredText, truncated: structuredTruncated } = structuredOutput(collected.text);
432
+ const structuredContent: BashToolOutput = {
433
+ output: structuredText,
434
+ truncated: structuredTruncated || collected.windowed,
435
+ full_output_path: outputPath,
436
+ exit_code: exitCode,
437
+ wall_time_seconds: wallSeconds(startedAtMs, (deps.clock ?? realClock).now()),
438
+ };
439
+ if (exitCode !== 0) {
440
+ // Mirror the built-in bash tool: non-zero exits resolve to an isError
441
+ // result carrying structuredContent instead of rejecting.
442
+ return {
443
+ content: [{ type: "text", text: appendStatus(text, `Command exited with code ${exitCode}`) }],
444
+ details,
445
+ structuredContent,
446
+ isError: true,
447
+ };
448
+ }
449
+ return { content: [{ type: "text", text }], details, structuredContent };
370
450
  },
371
451
  };
372
452
  }
@@ -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.1";
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,