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 +32 -16
- package/src/agents/definition.ts +12 -6
- package/src/bash-override.ts +89 -9
- package/src/behavior-guidelines.ts +1 -0
- package/src/clock.ts +5 -0
- package/src/config.ts +25 -1
- package/src/format.ts +62 -1
- package/src/index.ts +36 -1
- package/src/manager-client.ts +1 -1
- package/src/notify.ts +7 -7
- package/src/subagent/child-bash.ts +59 -42
- package/src/subagent/fleet-widget.ts +46 -6
- package/src/subagent/model-spec.ts +134 -16
- package/src/subagent/overrun.ts +176 -0
- package/src/subagent/pi-runtime.ts +7 -5
- package/src/subagent/registry.ts +81 -3
- package/src/subagent/runner.ts +242 -51
- package/src/subagent/tool.ts +83 -10
- package/src/subagent/types.ts +11 -2
- package/src/thinking-levels.ts +26 -0
- package/src/tui/message-renderers.ts +36 -2
- package/src/tui/tasks-command.ts +36 -6
- package/src/wake-delivery.ts +137 -0
- package/src/wake.ts +238 -4
- package/src/work-index.ts +16 -0
package/src/subagent/tool.ts
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
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
|
|
|
@@ -73,13 +74,18 @@ const subagentParameters = Type.Object({
|
|
|
73
74
|
Type.String({
|
|
74
75
|
description:
|
|
75
76
|
'Model override for all subagents: fuzzy ("haiku"), qualified ("provider/id"), ' +
|
|
76
|
-
|
|
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. ' +
|
|
77
80
|
'Use action:"models" to list selectable values.',
|
|
78
81
|
}),
|
|
79
82
|
),
|
|
80
83
|
timeout_ms: Type.Optional(
|
|
81
84
|
Type.Number({
|
|
82
|
-
description:
|
|
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
89
|
maximum: MAX_TIMEOUT_MS,
|
|
84
90
|
}),
|
|
85
91
|
),
|
|
@@ -92,13 +98,16 @@ const subagentParameters = Type.Object({
|
|
|
92
98
|
Type.Literal("interrupt"),
|
|
93
99
|
Type.Literal("resume"),
|
|
94
100
|
Type.Literal("steer"),
|
|
101
|
+
Type.Literal("extend"),
|
|
95
102
|
Type.Literal("models"),
|
|
96
103
|
],
|
|
97
104
|
{ description: "Manage an existing run (or list selectable models) instead of starting a new one" },
|
|
98
105
|
),
|
|
99
106
|
),
|
|
100
107
|
run_id: Type.Optional(Type.String({ description: "Target run for action" })),
|
|
101
|
-
child_id: Type.Optional(
|
|
108
|
+
child_id: Type.Optional(
|
|
109
|
+
Type.String({ description: "Target child (id or name) for steer/interrupt/resume/extend" }),
|
|
110
|
+
),
|
|
102
111
|
message: Type.Optional(Type.String({ description: "Message content for steer/resume" })),
|
|
103
112
|
});
|
|
104
113
|
|
|
@@ -110,7 +119,7 @@ type SubagentParams = {
|
|
|
110
119
|
fail_fast?: boolean;
|
|
111
120
|
model?: string;
|
|
112
121
|
timeout_ms?: number;
|
|
113
|
-
action?: "list" | "get" | "status" | "interrupt" | "resume" | "steer" | "models";
|
|
122
|
+
action?: "list" | "get" | "status" | "interrupt" | "resume" | "steer" | "extend" | "models";
|
|
114
123
|
run_id?: string;
|
|
115
124
|
child_id?: string;
|
|
116
125
|
message?: string;
|
|
@@ -222,6 +231,7 @@ function toNotificationInfo(record: RunRecord, now: number) {
|
|
|
222
231
|
status: c.status,
|
|
223
232
|
text: c.result?.text ?? "",
|
|
224
233
|
error: c.result?.error,
|
|
234
|
+
...(c.result?.warning !== undefined ? { warning: c.result.warning } : {}),
|
|
225
235
|
...(c.prompt !== undefined ? { prompt: c.prompt } : {}),
|
|
226
236
|
})),
|
|
227
237
|
};
|
|
@@ -285,9 +295,23 @@ export function createSubagentTool(
|
|
|
285
295
|
return registry;
|
|
286
296
|
};
|
|
287
297
|
|
|
298
|
+
/** Last terminal state a subagent-done was sent for, per run. */
|
|
299
|
+
const runDoneSent = new Map<string, string>();
|
|
288
300
|
const notifyRunCompleted = (registry: SubagentRegistry, runId: string): void => {
|
|
289
301
|
const record = registry.get(runId);
|
|
290
|
-
if (!record)
|
|
302
|
+
if (!record) {
|
|
303
|
+
runDoneSent.delete(runId);
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
// A resumed child can outlive the run's first-launch completion. The
|
|
307
|
+
// run is not done while it runs; its own settle sends this wake.
|
|
308
|
+
if (record.children.some((c) => c.status === "pending" || c.status === "running")) return;
|
|
309
|
+
// Several observers (first-launch completion, each resumed turn's settle)
|
|
310
|
+
// can see the same all-terminal state. One subagent-done per terminal
|
|
311
|
+
// state, identified by every child's turn number.
|
|
312
|
+
const terminalKey = record.children.map((c) => `${c.childId}:${c.turn ?? 1}`).join("|");
|
|
313
|
+
if (runDoneSent.get(runId) === terminalKey) return;
|
|
314
|
+
runDoneSent.set(runId, terminalKey);
|
|
291
315
|
deps.getNotifyCenter()?.notify(formatSubagentNotification(toNotificationInfo(record, clock.now())));
|
|
292
316
|
};
|
|
293
317
|
|
|
@@ -320,6 +344,7 @@ export function createSubagentTool(
|
|
|
320
344
|
prompt: child.prompt ?? "",
|
|
321
345
|
text: child.result?.text ?? "",
|
|
322
346
|
...(child.result?.error !== undefined ? { error: child.result.error } : {}),
|
|
347
|
+
...(child.result?.warning !== undefined ? { warning: child.result.warning } : {}),
|
|
323
348
|
stillRunning,
|
|
324
349
|
}),
|
|
325
350
|
);
|
|
@@ -590,12 +615,41 @@ export function createSubagentTool(
|
|
|
590
615
|
};
|
|
591
616
|
}
|
|
592
617
|
|
|
618
|
+
if (action === "extend") {
|
|
619
|
+
const handle = resolveSingleActiveChild(registry, record, params.child_id, "extend");
|
|
620
|
+
const child = record.children.find((c) => c.childId === handle.childId);
|
|
621
|
+
const ms = params.timeout_ms === undefined ? undefined : clampTimeout(params.timeout_ms, deps.defaultTimeoutMs);
|
|
622
|
+
const { deadlineAt, hardDeadlineAt } = handle.extend(ms);
|
|
623
|
+
const now = clock.now();
|
|
624
|
+
const hard =
|
|
625
|
+
hardDeadlineAt === null
|
|
626
|
+
? ""
|
|
627
|
+
: ` The configured hard ceiling (hardTimeoutMs) is not moved: it stops this subagent in ${formatDurationMs(Math.max(0, hardDeadlineAt - now))}.`;
|
|
628
|
+
return {
|
|
629
|
+
content: [
|
|
630
|
+
{
|
|
631
|
+
type: "text",
|
|
632
|
+
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}`,
|
|
636
|
+
},
|
|
637
|
+
],
|
|
638
|
+
details: { run_id: record.runId, child_id: handle.childId, deadline_at: deadlineAt, hard_deadline_at: hardDeadlineAt },
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
|
|
593
642
|
// resume
|
|
594
643
|
if (!params.message) throw new Error("message is required for resume");
|
|
595
644
|
const child = resolveSingleTerminalChild(record, params.child_id);
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
645
|
+
// A resume is a new turn with its own soft budget: timeout_ms when given,
|
|
646
|
+
// else the child's spawn budget (the runner's default). Returns once the
|
|
647
|
+
// request is accepted; it never waits for an admission slot.
|
|
648
|
+
const { queuedBehind } = await registry.resumeChild(
|
|
649
|
+
child.childId,
|
|
650
|
+
params.message,
|
|
651
|
+
params.timeout_ms === undefined ? {} : { timeoutMs: clampTimeout(params.timeout_ms, deps.defaultTimeoutMs) },
|
|
652
|
+
);
|
|
599
653
|
// Resume is asynchronous. If siblings are still running, hand this child
|
|
600
654
|
// back as soon as it finishes; otherwise the run-complete wake covers it.
|
|
601
655
|
const handedOver = new Set<string>();
|
|
@@ -610,11 +664,12 @@ export function createSubagentTool(
|
|
|
610
664
|
type: "text",
|
|
611
665
|
text:
|
|
612
666
|
`Resumed subagent ${child.name} (${child.childId}) in run ${record.runId}. ` +
|
|
667
|
+
queuedText(queuedBehind) +
|
|
613
668
|
"You will be notified via <pi-famulus-wake kind=\"subagent-handover\"> if others are still running, " +
|
|
614
669
|
"otherwise via <pi-famulus-wake kind=\"subagent-done\"> when it completes. Do not poll.",
|
|
615
670
|
},
|
|
616
671
|
],
|
|
617
|
-
details: { run_id: record.runId, child_id: child.childId },
|
|
672
|
+
details: { run_id: record.runId, child_id: child.childId, queued_behind: queuedBehind },
|
|
618
673
|
};
|
|
619
674
|
};
|
|
620
675
|
|
|
@@ -626,7 +681,10 @@ export function createSubagentTool(
|
|
|
626
681
|
"interpolation). By default the call waits up to a foreground budget (default 45s); longer runs " +
|
|
627
682
|
"continue in the background. Each child that finishes while others are still running wakes you with " +
|
|
628
683
|
"<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.
|
|
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.",
|
|
630
688
|
promptSnippet: "Fan out subagents in parallel or sequence them in a chain",
|
|
631
689
|
promptGuidelines: [
|
|
632
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.',
|
|
@@ -694,6 +752,13 @@ export function createSubagentTool(
|
|
|
694
752
|
};
|
|
695
753
|
}
|
|
696
754
|
|
|
755
|
+
/** Resume result clause for a turn waiting for an admission slot ("" when it started at once). */
|
|
756
|
+
function queuedText(queuedBehind: number | null): string {
|
|
757
|
+
if (queuedBehind === null) return "";
|
|
758
|
+
const ahead = queuedBehind === 0 ? "" : ` and ${queuedBehind} subagent(s) are waiting ahead of it`;
|
|
759
|
+
return `Every subagent slot is busy${ahead}, so it is queued and starts when a slot frees. `;
|
|
760
|
+
}
|
|
761
|
+
|
|
697
762
|
function resolveSingleActiveChild(
|
|
698
763
|
registry: SubagentRegistry,
|
|
699
764
|
record: RunRecord,
|
|
@@ -728,6 +793,14 @@ function resolveSingleTerminalChild(record: RunRecord, childIdOrName: string | u
|
|
|
728
793
|
const known = record.children.map((c) => `${c.name} (${c.childId})`).join(", ");
|
|
729
794
|
throw new Error(`no child "${childIdOrName}" in run ${record.runId} (children: ${known})`);
|
|
730
795
|
}
|
|
796
|
+
if (child.status === "pending") {
|
|
797
|
+
// Queued for an admission slot (a launch or an earlier resume): steer
|
|
798
|
+
// 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
|
+
);
|
|
803
|
+
}
|
|
731
804
|
if (!matches(child)) {
|
|
732
805
|
throw new Error(`subagent ${child.name} (${child.childId}) is still ${child.status}; use steer instead`);
|
|
733
806
|
}
|
package/src/subagent/types.ts
CHANGED
|
@@ -16,7 +16,9 @@ export interface AgentDefinition {
|
|
|
16
16
|
description: string; // required, non-empty
|
|
17
17
|
tools: string[]; // default ["read","bash","edit","write"]
|
|
18
18
|
model?: string; // "provider:id" | bare id
|
|
19
|
-
|
|
19
|
+
// Mirror of src/agents/definition.ts — keep in sync with
|
|
20
|
+
// src/thinking-levels.ts (VALID_THINKING_LEVELS), the single source.
|
|
21
|
+
thinking?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
|
|
20
22
|
systemPrompt: string; // frontmatter body, trimmed
|
|
21
23
|
source: "builtin" | "user" | "project";
|
|
22
24
|
path?: string; // builtins have no path
|
|
@@ -58,6 +60,7 @@ export interface ChildRunRequest {
|
|
|
58
60
|
taskPrompt?: string;
|
|
59
61
|
agent: AgentDefinition; // already resolved
|
|
60
62
|
model?: string; // subagent() parameter-level override
|
|
63
|
+
/** Soft budget per turn: reaching it wakes the parent; it does not abort. */
|
|
61
64
|
timeoutMs: number;
|
|
62
65
|
depth: number; // main session = 0, child = 1
|
|
63
66
|
}
|
|
@@ -70,7 +73,13 @@ export interface ChildHandle {
|
|
|
70
73
|
// current generation's promise).
|
|
71
74
|
steer(message: string): Promise<void>; // while running; terminal -> throw
|
|
72
75
|
followUp(message: string): Promise<void>; // same, queued delivery
|
|
73
|
-
|
|
76
|
+
/** terminal -> continue running; opts.timeoutMs is the new turn's soft budget (default: spawn budget). */
|
|
77
|
+
resume(message: string, opts?: { timeoutMs?: number }): Promise<void>;
|
|
78
|
+
/**
|
|
79
|
+
* running -> soft deadline = now + timeoutMs (default: spawn budget);
|
|
80
|
+
* otherwise throws. Returns the new deadline and the unmoved hard ceiling.
|
|
81
|
+
*/
|
|
82
|
+
extend(timeoutMs?: number): { deadlineAt: number; hardDeadlineAt: number | null };
|
|
74
83
|
interrupt(): Promise<void>; // abort; result resolves as interrupted
|
|
75
84
|
status(): ChildStatus;
|
|
76
85
|
lastEventAt(): number; // for the stall watchdog / status display
|
|
@@ -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
|
+
}
|
|
@@ -98,6 +98,12 @@ function collapsedText(details: FamulusWake, theme: Theme): string {
|
|
|
98
98
|
const { color, glyph } = statusGlyph(bad ? "failed" : details.status);
|
|
99
99
|
return `${theme.fg(color, glyph)} ${theme.fg("muted", "subagent")} ${countStatuses(details.children.map((child) => child.status))}`;
|
|
100
100
|
}
|
|
101
|
+
case "subagent-overrun": {
|
|
102
|
+
const shell = details.shell
|
|
103
|
+
? ` · shell ${formatDuration(details.shell.elapsedMs)}${details.shell.growing === null ? "" : details.shell.growing ? " (output growing)" : " (output idle)"}`
|
|
104
|
+
: "";
|
|
105
|
+
return `${theme.fg("warning", "!")} ${theme.fg("muted", "overrun")} ${details.name} ${formatDuration(details.elapsedMs)} / ${formatDuration(details.budgetMs)} budget · still running${shell}`;
|
|
106
|
+
}
|
|
101
107
|
case "supervisor-request":
|
|
102
108
|
return [
|
|
103
109
|
`${theme.fg("warning", "?")} ${theme.fg("muted", `decision for ${details.name}`)} ${details.message.slice(0, 100)}`,
|
|
@@ -133,9 +139,37 @@ export function expandedWakeText(details: FamulusWake | undefined, content: stri
|
|
|
133
139
|
case "monitor":
|
|
134
140
|
return [`Monitor: ${details.description}`, `Task: ${details.id}`, ...(details.status ? [`Status: ${details.status}`] : []), `Event: ${details.event}`, ...(details.eventCount ? [`Events: ${details.eventCount}`] : []), ...(details.droppedLines ? [`Dropped lines: ${details.droppedLines}`] : [])].join("\n");
|
|
135
141
|
case "subagent-handover":
|
|
136
|
-
return [`Subagent handover: ${details.name} (${details.status})`, `Run: ${details.runId}`, `Child: ${details.childId}`, `Task prompt: ${details.prompt}`, `Result: ${details.result}`, ...(details.error ? [`Error: ${details.error}`] : []), ...(details.stillRunning.length ? [`Still running: ${details.stillRunning.map((item) => `${item.id} ${item.title}`).join(", ")}`] : [])].join("\n\n");
|
|
142
|
+
return [`Subagent handover: ${details.name} (${details.status})`, `Run: ${details.runId}`, `Child: ${details.childId}`, `Task prompt: ${details.prompt}`, `Result: ${details.result}`, ...(details.error ? [`Error: ${details.error}`] : []), ...(details.warning ? [`Warning: ${details.warning}`] : []), ...(details.stillRunning.length ? [`Still running: ${details.stillRunning.map((item) => `${item.id} ${item.title}`).join(", ")}`] : [])].join("\n\n");
|
|
137
143
|
case "subagent-done":
|
|
138
|
-
return [`Subagent run: ${details.runId} (${details.status})`, `Duration: ${formatDuration(details.durationMs)}`, ...details.children.map((child) => [`${child.name} (${child.childId}) · ${child.status}`, `Task prompt: ${child.prompt}`, `Result: ${child.result}`, ...(child.error ? [`Error: ${child.error}`] : [])].join("\n"))].join("\n\n");
|
|
144
|
+
return [`Subagent run: ${details.runId} (${details.status})`, `Duration: ${formatDuration(details.durationMs)}`, ...details.children.map((child) => [`${child.name} (${child.childId}) · ${child.status}`, `Task prompt: ${child.prompt}`, `Result: ${child.result}`, ...(child.error ? [`Error: ${child.error}`] : []), ...(child.warning ? [`Warning: ${child.warning}`] : [])].join("\n"))].join("\n\n");
|
|
145
|
+
case "subagent-overrun": {
|
|
146
|
+
const shell = details.shell;
|
|
147
|
+
return [
|
|
148
|
+
`Subagent past its budget: ${details.name} (still running, reminder ${details.reminder})`,
|
|
149
|
+
`Run: ${details.runId}`,
|
|
150
|
+
`Child: ${details.childId}`,
|
|
151
|
+
`Elapsed this turn: ${formatDuration(details.elapsedMs)} of ${formatDuration(details.budgetMs)}`,
|
|
152
|
+
`Last activity (${formatDuration(details.lastActivity.agoMs)} ago): ${details.lastActivity.text}`,
|
|
153
|
+
...(shell
|
|
154
|
+
? [
|
|
155
|
+
[
|
|
156
|
+
`Shell ${shell.taskId} · ${formatDuration(shell.elapsedMs)}`,
|
|
157
|
+
`$ ${shell.command}`,
|
|
158
|
+
`Output: ${shell.outputPath}${shell.outputBytes !== null ? ` (${shell.outputBytes} bytes)` : ""}`,
|
|
159
|
+
...(shell.outputIdleMs !== null ? [`Last output: ${formatDuration(shell.outputIdleMs)} ago`] : []),
|
|
160
|
+
].join("\n"),
|
|
161
|
+
]
|
|
162
|
+
: []),
|
|
163
|
+
...(details.hardCeilingMs !== undefined
|
|
164
|
+
? [`Hard ceiling stops it in ${formatDuration(details.hardCeilingMs)} (extend does not move it).`]
|
|
165
|
+
: []),
|
|
166
|
+
// Same condition as overrunOptions in wake.ts: do not promise a
|
|
167
|
+
// reminder that the hard ceiling will stop the child before.
|
|
168
|
+
...(details.hardCeilingMs === undefined || details.nextReminderMs < details.hardCeilingMs
|
|
169
|
+
? [`Next reminder in ${formatDuration(details.nextReminderMs)} unless you extend, steer, or interrupt it.`]
|
|
170
|
+
: []),
|
|
171
|
+
].join("\n\n");
|
|
172
|
+
}
|
|
139
173
|
case "supervisor-request":
|
|
140
174
|
return [`Decision requested by ${details.name} (${details.from})`, `Request: ${details.message}`, `Reply with /reply ${details.from} <decision>`].join("\n");
|
|
141
175
|
case "supervisor-update":
|
package/src/tui/tasks-command.ts
CHANGED
|
@@ -10,13 +10,12 @@ import type { ManagerClient, TaskRecord } from "../manager-client";
|
|
|
10
10
|
import { taskOutputPath } from "../config";
|
|
11
11
|
import { formatConversation } from "../subagent/conversation";
|
|
12
12
|
import type { SubagentRegistry } from "../subagent/registry";
|
|
13
|
-
import { formatAge, type WorkIndex, type WorkItem } from "../work-index";
|
|
13
|
+
import { formatAge, nextAgeBoundary, type WorkIndex, type WorkItem } from "../work-index";
|
|
14
14
|
import { notifyPlainFallback, showScrollDetail } from "./scroll-detail-view";
|
|
15
15
|
import { readTaskFileTailCached, stderrPathFor } from "./task-output-paths";
|
|
16
16
|
import { fitLines, loadPiTui, truncateToWidth } from "./pi-tui-load";
|
|
17
17
|
import { statusGlyph } from "./tool-component";
|
|
18
18
|
|
|
19
|
-
const AGE_TICK_MS = 1000;
|
|
20
19
|
|
|
21
20
|
function isLive(item: WorkItem): boolean {
|
|
22
21
|
return item.status === "pending" || item.status === "running";
|
|
@@ -261,9 +260,36 @@ async function showTaskList(
|
|
|
261
260
|
let loadingAll = false;
|
|
262
261
|
let confirmId: string | undefined;
|
|
263
262
|
let hint = "";
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
263
|
+
// formatAge rounds (now - startedAt) to the nearest second, so each
|
|
264
|
+
// live row's visible age flips on half-second boundaries relative to
|
|
265
|
+
// its own startedAt. Schedule the earliest pending flip and
|
|
266
|
+
// re-evaluate after every tick and every index change.
|
|
267
|
+
let ageTimer: unknown = null;
|
|
268
|
+
const clearAgeTimer = () => {
|
|
269
|
+
if (ageTimer === null) return;
|
|
270
|
+
clock.clearTimeout(ageTimer);
|
|
271
|
+
ageTimer = null;
|
|
272
|
+
};
|
|
273
|
+
const scheduleAgeTick = () => {
|
|
274
|
+
clearAgeTimer();
|
|
275
|
+
const now = clock.now();
|
|
276
|
+
let next = Infinity;
|
|
277
|
+
for (const item of visibleItems()) {
|
|
278
|
+
if (!isLive(item)) continue;
|
|
279
|
+
next = Math.min(next, nextAgeBoundary(now, item.startedAt));
|
|
280
|
+
}
|
|
281
|
+
if (!Number.isFinite(next)) return;
|
|
282
|
+
ageTimer = clock.setTimeout(() => {
|
|
283
|
+
ageTimer = null;
|
|
284
|
+
tui.requestRender();
|
|
285
|
+
scheduleAgeTick();
|
|
286
|
+
}, next - now);
|
|
287
|
+
clock.unref?.(ageTimer);
|
|
288
|
+
};
|
|
289
|
+
const unsub = subscribe(() => {
|
|
290
|
+
tui.requestRender();
|
|
291
|
+
scheduleAgeTick();
|
|
292
|
+
});
|
|
267
293
|
const border = new DynamicBorder((text) => theme.fg("border", text));
|
|
268
294
|
const pageSize = () => Math.max(1, (tui.terminal?.rows ?? 24) - 8);
|
|
269
295
|
const matches = (data: string, id: "tui.select.up" | "tui.select.down" | "tui.select.confirm" | "tui.select.cancel") => {
|
|
@@ -273,6 +299,7 @@ async function showTaskList(
|
|
|
273
299
|
scope === "all" ? mergeItems(getItems(), allItems) : getItems(),
|
|
274
300
|
filter,
|
|
275
301
|
);
|
|
302
|
+
scheduleAgeTick();
|
|
276
303
|
const choose = (id: string | undefined) => {
|
|
277
304
|
selectedId = id;
|
|
278
305
|
hint = "";
|
|
@@ -394,7 +421,10 @@ async function showTaskList(
|
|
|
394
421
|
tui.requestRender();
|
|
395
422
|
}
|
|
396
423
|
},
|
|
397
|
-
dispose() {
|
|
424
|
+
dispose() {
|
|
425
|
+
clearAgeTimer();
|
|
426
|
+
unsub();
|
|
427
|
+
},
|
|
398
428
|
};
|
|
399
429
|
},
|
|
400
430
|
// Bottom sheet over the editor, where the user typed /tasks; the list is
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The moment a wake enters the model's context (design.md §4.5).
|
|
3
|
+
*
|
|
4
|
+
* NotifyCenter hands a wake to pi.sendMessage; pi may hold it for minutes
|
|
5
|
+
* (a steer waits for the current tool calls and turn to end). pi emits the
|
|
6
|
+
* extension `message_end` event for a custom message right before it is
|
|
7
|
+
* pushed into the agent's context: in agent-core's loop for steered
|
|
8
|
+
* messages, and in the prompt for a triggerTurn message. That is where the
|
|
9
|
+
* real delivery is logged (`wake.inject`).
|
|
10
|
+
*
|
|
11
|
+
* Passive wakes (`triggerTurn: false`) never reach extension `message_end`:
|
|
12
|
+
* pi appends them through a path that only notifies its own listeners. The
|
|
13
|
+
* NotifyCenter sends them only when the agent is idle, so they are appended
|
|
14
|
+
* at send time and `wake.deliver mode=passive` is the proxy for them.
|
|
15
|
+
*
|
|
16
|
+
* Zero pi dependency: the handler takes the message object pi passes.
|
|
17
|
+
*/
|
|
18
|
+
import {
|
|
19
|
+
FAMULUS_WAKE_CUSTOM_TYPE,
|
|
20
|
+
renderWakeXml,
|
|
21
|
+
wakeIds,
|
|
22
|
+
wakeRootIndex,
|
|
23
|
+
type FamulusWake,
|
|
24
|
+
type WakeChildStatus,
|
|
25
|
+
} from "./wake";
|
|
26
|
+
import type { RunRegistry } from "./subagent/registry";
|
|
27
|
+
|
|
28
|
+
export interface WakeDeliveryDeps {
|
|
29
|
+
now: () => number;
|
|
30
|
+
logEvent?: (type: string, fields?: Record<string, unknown>) => void;
|
|
31
|
+
/** Current subagent statuses; null/absent when no registry is live. */
|
|
32
|
+
lookup?: () => WakeStatusLookup | null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface CustomMessageLike {
|
|
36
|
+
role?: string;
|
|
37
|
+
customType?: string;
|
|
38
|
+
content?: unknown;
|
|
39
|
+
details?: unknown;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The wake's details when `message` is a pi-famulus-wake custom message. */
|
|
43
|
+
export function famulusWakeDetails(message: unknown): FamulusWake | undefined {
|
|
44
|
+
const m = message as CustomMessageLike | null | undefined;
|
|
45
|
+
if (!m || m.role !== "custom" || m.customType !== FAMULUS_WAKE_CUSTOM_TYPE) return undefined;
|
|
46
|
+
const details = m.details as FamulusWake | undefined;
|
|
47
|
+
return details && typeof details === "object" && typeof details.kind === "string" ? details : undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Current statuses, read from the subagent registry at injection. */
|
|
51
|
+
export interface WakeStatusLookup {
|
|
52
|
+
childStatus: (runId: string, childId: string) => WakeChildStatus | undefined;
|
|
53
|
+
runStatus: (runId: string) => string | undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Lookup over the run records of a subagent registry. */
|
|
57
|
+
export function registryStatusLookup(registry: Pick<RunRegistry, "get">): WakeStatusLookup {
|
|
58
|
+
return {
|
|
59
|
+
childStatus: (runId, childId) => registry.get(runId)?.children.find((c) => c.childId === childId)?.status,
|
|
60
|
+
runStatus: (runId) => registry.get(runId)?.status,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Subagent wakes are snapshots. Re-check each child's status against the
|
|
66
|
+
* registry: a changed child shows its current status, with the snapshot's
|
|
67
|
+
* status kept as statusAsOf (rendered status-as-of, plus a
|
|
68
|
+
* <changed-since-as-of> line). Policy: mark, never drop: the snapshot's
|
|
69
|
+
* results stay useful (design.md §4.5).
|
|
70
|
+
*/
|
|
71
|
+
function recheckStatuses(details: FamulusWake, lookup: WakeStatusLookup): FamulusWake {
|
|
72
|
+
if (details.kind === "subagent-handover") {
|
|
73
|
+
const now = lookup.childStatus(details.runId, details.childId);
|
|
74
|
+
if (now === undefined || now === details.status) return details;
|
|
75
|
+
return { ...details, status: now, statusAsOf: details.status };
|
|
76
|
+
}
|
|
77
|
+
if (details.kind === "subagent-done") {
|
|
78
|
+
let changed = false;
|
|
79
|
+
const children = details.children.map((child) => {
|
|
80
|
+
const now = lookup.childStatus(details.runId, child.childId);
|
|
81
|
+
if (now === undefined || now === child.status) return child;
|
|
82
|
+
changed = true;
|
|
83
|
+
return { ...child, status: now, statusAsOf: child.status };
|
|
84
|
+
});
|
|
85
|
+
if (!changed) return details;
|
|
86
|
+
const runNow = lookup.runStatus(details.runId);
|
|
87
|
+
return { ...details, children, ...(runNow && runNow !== details.status ? { runStatusNow: runNow } : {}) };
|
|
88
|
+
}
|
|
89
|
+
return details;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The wake as the model should see it on entering the context: the root
|
|
94
|
+
* gains age-ms (how old its snapshot is now), and subagent statuses are
|
|
95
|
+
* re-checked when a lookup is given. Returns undefined when nothing
|
|
96
|
+
* changes: not a wake, not stamped, or content that is not the canonical
|
|
97
|
+
* rendering of its details (never rewrite text this module did not make).
|
|
98
|
+
*/
|
|
99
|
+
export function wakeAtInjection<M extends CustomMessageLike>(
|
|
100
|
+
message: M,
|
|
101
|
+
now: number,
|
|
102
|
+
lookup?: WakeStatusLookup,
|
|
103
|
+
): M | undefined {
|
|
104
|
+
const details = famulusWakeDetails(message);
|
|
105
|
+
if (!details || details.asOf === undefined || typeof message.content !== "string") return undefined;
|
|
106
|
+
const root = wakeRootIndex(message.content);
|
|
107
|
+
if (root < 0 || message.content.slice(root) !== renderWakeXml(details)) return undefined;
|
|
108
|
+
let next: FamulusWake = { ...details, ageMs: Math.max(0, now - details.asOf) };
|
|
109
|
+
if (lookup) next = recheckStatuses(next, lookup);
|
|
110
|
+
return { ...message, content: message.content.slice(0, root) + renderWakeXml(next), details: next };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* pi `message_end` handler body: log the injection and return the
|
|
115
|
+
* replacement message (same role), if any.
|
|
116
|
+
*/
|
|
117
|
+
export function onWakeMessageEnd<M extends CustomMessageLike>(message: M, deps: WakeDeliveryDeps): M | undefined {
|
|
118
|
+
try {
|
|
119
|
+
logWakeInjected(message, deps);
|
|
120
|
+
return wakeAtInjection(message, deps.now(), deps.lookup?.() ?? undefined);
|
|
121
|
+
} catch {
|
|
122
|
+
// Never break pi's message handling over a wake annotation.
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Log `wake.inject` for a wake entering the context. Non-wake messages are ignored. */
|
|
128
|
+
export function logWakeInjected(message: unknown, deps: WakeDeliveryDeps): void {
|
|
129
|
+
const details = famulusWakeDetails(message);
|
|
130
|
+
if (!details) return;
|
|
131
|
+
const now = deps.now();
|
|
132
|
+
deps.logEvent?.("wake.inject", {
|
|
133
|
+
kind: details.kind,
|
|
134
|
+
ids: wakeIds(details),
|
|
135
|
+
...(details.asOf !== undefined ? { as_of: details.asOf, lag_ms: Math.max(0, now - details.asOf) } : {}),
|
|
136
|
+
});
|
|
137
|
+
}
|