pi-famulus 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/package.json +31 -12
- package/src/agents/builtins.ts +5 -22
- package/src/agents/definition.ts +16 -12
- package/src/bash-override.ts +8 -21
- package/src/behavior-guidelines.ts +40 -15
- package/src/clock.ts +5 -0
- package/src/comms/comms.ts +7 -3
- package/src/comms/tools.ts +11 -36
- package/src/config.ts +25 -1
- package/src/format.ts +83 -5
- package/src/index.ts +155 -30
- package/src/manager-client.ts +265 -114
- package/src/monitor.ts +14 -33
- package/src/notify.ts +7 -7
- package/src/prompts.generated.ts +183 -0
- package/src/shell-exec.ts +6 -2
- package/src/subagent/admission.ts +224 -0
- package/src/subagent/agent-records.ts +66 -3
- package/src/subagent/child-bash.ts +65 -56
- package/src/subagent/model-spec.ts +134 -16
- package/src/subagent/overrun.ts +191 -0
- package/src/subagent/pi-runtime.ts +118 -33
- package/src/subagent/registry.ts +235 -34
- package/src/subagent/runner.ts +565 -99
- package/src/subagent/tool.ts +111 -61
- package/src/subagent/types.ts +71 -7
- package/src/subagent/usage.ts +48 -0
- package/src/task-tools.ts +15 -25
- package/src/thinking-levels.ts +26 -0
- package/src/tui/message-renderers.ts +52 -2
- package/src/wake-delivery.ts +137 -0
- package/src/wake.ts +270 -7
- package/src/work-index.ts +4 -0
|
@@ -98,6 +98,13 @@ 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
|
+
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}`;
|
|
107
|
+
}
|
|
101
108
|
case "supervisor-request":
|
|
102
109
|
return [
|
|
103
110
|
`${theme.fg("warning", "?")} ${theme.fg("muted", `decision for ${details.name}`)} ${details.message.slice(0, 100)}`,
|
|
@@ -133,9 +140,52 @@ export function expandedWakeText(details: FamulusWake | undefined, content: stri
|
|
|
133
140
|
case "monitor":
|
|
134
141
|
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
142
|
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");
|
|
143
|
+
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
144
|
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");
|
|
145
|
+
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");
|
|
146
|
+
case "subagent-overrun": {
|
|
147
|
+
const shell = details.shell;
|
|
148
|
+
return [
|
|
149
|
+
`Subagent past its budget: ${details.name} (still running, reminder ${details.reminder})`,
|
|
150
|
+
`Run: ${details.runId}`,
|
|
151
|
+
`Child: ${details.childId}`,
|
|
152
|
+
`Elapsed this turn: ${formatDuration(details.elapsedMs)} of ${formatDuration(details.budgetMs)}`,
|
|
153
|
+
`Last activity (${formatDuration(details.lastActivity.agoMs)} ago): ${details.lastActivity.text}`,
|
|
154
|
+
...(shell
|
|
155
|
+
? [
|
|
156
|
+
[
|
|
157
|
+
`Shell ${shell.taskId} · ${formatDuration(shell.elapsedMs)}`,
|
|
158
|
+
`$ ${shell.command}`,
|
|
159
|
+
`Output: ${shell.outputPath}${shell.outputBytes !== null ? ` (${shell.outputBytes} bytes)` : ""}`,
|
|
160
|
+
...(shell.outputIdleMs !== null ? [`Last output: ${formatDuration(shell.outputIdleMs)} ago`] : []),
|
|
161
|
+
].join("\n"),
|
|
162
|
+
]
|
|
163
|
+
: []),
|
|
164
|
+
...(details.hardCeilingMs !== undefined
|
|
165
|
+
? [`Hard ceiling stops it in ${formatDuration(details.hardCeilingMs)} (extend does not move it).`]
|
|
166
|
+
: []),
|
|
167
|
+
// Same condition as overrunOptions in wake.ts: do not promise a
|
|
168
|
+
// reminder that the hard ceiling will stop the child before.
|
|
169
|
+
...(details.hardCeilingMs === undefined || details.nextReminderMs < details.hardCeilingMs
|
|
170
|
+
? [`Next reminder in ${formatDuration(details.nextReminderMs)} unless you extend, steer, or interrupt it.`]
|
|
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
|
+
: []),
|
|
187
|
+
].join("\n\n");
|
|
188
|
+
}
|
|
139
189
|
case "supervisor-request":
|
|
140
190
|
return [`Decision requested by ${details.name} (${details.from})`, `Request: ${details.message}`, `Reply with /reply ${details.from} <decision>`].join("\n");
|
|
141
191
|
case "supervisor-update":
|
|
@@ -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
|
+
}
|
package/src/wake.ts
CHANGED
|
@@ -5,10 +5,13 @@
|
|
|
5
5
|
* discriminator the renderer and the eval adapter both read.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
// .ts suffix: eval/ loads this module with Node's own TypeScript support, which
|
|
9
|
+
// needs explicit extensions (it imports wake.ts and behavior-guidelines.ts).
|
|
10
|
+
import { fill, PROMPTS } from "./prompts.generated.ts";
|
|
11
|
+
|
|
8
12
|
export const FAMULUS_WAKE_CUSTOM_TYPE = "pi-famulus-wake";
|
|
9
13
|
|
|
10
|
-
export const FAMULUS_WAKE_LEAD_IN =
|
|
11
|
-
"System wake — not a new user message. Handle this <pi-famulus-wake> before other work.";
|
|
14
|
+
export const FAMULUS_WAKE_LEAD_IN = PROMPTS["wakes.lead-in"];
|
|
12
15
|
|
|
13
16
|
export interface WakeItem {
|
|
14
17
|
id: string;
|
|
@@ -29,16 +32,71 @@ export interface TaskWake {
|
|
|
29
32
|
signal?: string;
|
|
30
33
|
}
|
|
31
34
|
|
|
35
|
+
export type WakeChildStatus = "pending" | "running" | "completed" | "failed" | "interrupted";
|
|
36
|
+
|
|
32
37
|
export interface SubagentDoneChild {
|
|
33
38
|
childId: string;
|
|
34
39
|
name: string;
|
|
35
|
-
|
|
40
|
+
/** Status when the model sees the wake (re-checked at injection). */
|
|
41
|
+
status: WakeChildStatus;
|
|
42
|
+
/** Set when the status changed after as-of: the status in the snapshot. */
|
|
43
|
+
statusAsOf?: WakeChildStatus;
|
|
36
44
|
prompt: string;
|
|
37
45
|
result: string;
|
|
38
46
|
error?: string;
|
|
47
|
+
/** Non-fatal caveat from the child run (e.g. an adapted model spec). */
|
|
48
|
+
warning?: string;
|
|
39
49
|
}
|
|
40
50
|
|
|
41
|
-
|
|
51
|
+
/** The foreground shell a child is blocked on when its overrun wake is built. */
|
|
52
|
+
export interface OverrunShell {
|
|
53
|
+
taskId: string;
|
|
54
|
+
/** One line, capped (shellWakeTitle). */
|
|
55
|
+
command: string;
|
|
56
|
+
elapsedMs: number;
|
|
57
|
+
outputPath: string;
|
|
58
|
+
/** Output file size; null when the file could not be read. */
|
|
59
|
+
outputBytes: number | null;
|
|
60
|
+
/** Time since the output file last changed; null when unknown. */
|
|
61
|
+
outputIdleMs: number | null;
|
|
62
|
+
/** Output grew since the previous reminder, or changed in the last minute; null when unknown. */
|
|
63
|
+
growing: boolean | null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Context retained for every secondary child in a batched overrun wake. */
|
|
67
|
+
export interface SubagentOverrunChild {
|
|
68
|
+
runId: string;
|
|
69
|
+
childId: string;
|
|
70
|
+
name: string;
|
|
71
|
+
elapsedMs: number;
|
|
72
|
+
budgetMs: number;
|
|
73
|
+
reminder: number;
|
|
74
|
+
nextReminderMs: number;
|
|
75
|
+
/** Time left until the opt-in hard ceiling aborts this child. */
|
|
76
|
+
hardCeilingMs?: number;
|
|
77
|
+
lastActivity: { agoMs: number; text: string };
|
|
78
|
+
shell?: OverrunShell;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* When the wake's content was generated (design.md §4.5): stamped by the
|
|
83
|
+
* NotifyCenter when it receives or builds the wake. A steered wake can wait
|
|
84
|
+
* minutes before the model sees it; the model needs to know how old the
|
|
85
|
+
* snapshot is.
|
|
86
|
+
*/
|
|
87
|
+
export interface WakeTiming {
|
|
88
|
+
/** Epoch ms. Rendered as the root attribute as-of (UTC, seconds). */
|
|
89
|
+
asOf?: number;
|
|
90
|
+
/**
|
|
91
|
+
* How old the wake was when it entered the model's context (ms). Set at
|
|
92
|
+
* injection (wake-delivery.ts), rendered as the root attribute age-ms.
|
|
93
|
+
*/
|
|
94
|
+
ageMs?: number;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export type FamulusWake = FamulusWakeBody & WakeTiming;
|
|
98
|
+
|
|
99
|
+
type FamulusWakeBody =
|
|
42
100
|
| { kind: "task"; stillRunning: WakeItem[]; tasks: TaskWake[] }
|
|
43
101
|
| {
|
|
44
102
|
kind: "monitor";
|
|
@@ -54,21 +112,47 @@ export type FamulusWake =
|
|
|
54
112
|
runId: string;
|
|
55
113
|
childId: string;
|
|
56
114
|
name: string;
|
|
57
|
-
|
|
115
|
+
/** The settle this wake reports; pending/running only when re-checked at injection. */
|
|
116
|
+
status: WakeChildStatus;
|
|
117
|
+
/** Set when the status changed after as-of: the status in the snapshot. */
|
|
118
|
+
statusAsOf?: WakeChildStatus;
|
|
58
119
|
stillRunning: WakeItem[];
|
|
59
120
|
summary: string;
|
|
60
121
|
prompt: string;
|
|
61
122
|
result: string;
|
|
62
123
|
error?: string;
|
|
124
|
+
warning?: string;
|
|
63
125
|
}
|
|
64
126
|
| {
|
|
65
127
|
kind: "subagent-done";
|
|
66
128
|
runId: string;
|
|
67
129
|
status: "completed" | "partial" | "failed" | "interrupted";
|
|
130
|
+
/** Set at injection when a child is pending or running again: the run's status then. */
|
|
131
|
+
runStatusNow?: string;
|
|
68
132
|
durationMs: number;
|
|
69
133
|
summary: string;
|
|
70
134
|
children: SubagentDoneChild[];
|
|
71
135
|
}
|
|
136
|
+
| {
|
|
137
|
+
kind: "subagent-overrun";
|
|
138
|
+
runId: string;
|
|
139
|
+
childId: string;
|
|
140
|
+
name: string;
|
|
141
|
+
/** Time in this user turn (since admission of the launch or resume). */
|
|
142
|
+
elapsedMs: number;
|
|
143
|
+
/** The turn's soft budget (timeout_ms), extensions included. */
|
|
144
|
+
budgetMs: number;
|
|
145
|
+
/** 1 for the first wake of this turn, then 2, 3, … */
|
|
146
|
+
reminder: number;
|
|
147
|
+
/** When the next reminder is due if the parent does nothing. */
|
|
148
|
+
nextReminderMs: number;
|
|
149
|
+
/** Time left until the opt-in hard ceiling aborts the child; absent when none is set. */
|
|
150
|
+
hardCeilingMs?: number;
|
|
151
|
+
summary: string;
|
|
152
|
+
lastActivity: { agoMs: number; text: string };
|
|
153
|
+
shell?: OverrunShell;
|
|
154
|
+
additional?: SubagentOverrunChild[];
|
|
155
|
+
}
|
|
72
156
|
| { kind: "supervisor-request"; from: string; name: string; message: string }
|
|
73
157
|
| { kind: "supervisor-update"; from: string; name: string; message: string };
|
|
74
158
|
|
|
@@ -99,7 +183,80 @@ export function formatFamulusWake(details: FamulusWake, leadIn: string = FAMULUS
|
|
|
99
183
|
return { customType: FAMULUS_WAKE_CUSTOM_TYPE, content, details };
|
|
100
184
|
}
|
|
101
185
|
|
|
186
|
+
/** Task/child/monitor ids a wake is about (event log `ids`). */
|
|
187
|
+
export function wakeIds(details: { kind?: string }): string[] {
|
|
188
|
+
const d = details as {
|
|
189
|
+
kind?: string;
|
|
190
|
+
id?: string;
|
|
191
|
+
taskId?: string;
|
|
192
|
+
tasks?: { id: string }[];
|
|
193
|
+
children?: { childId: string }[];
|
|
194
|
+
childId?: string;
|
|
195
|
+
from?: string;
|
|
196
|
+
};
|
|
197
|
+
if (d.kind === "task") return (d.tasks ?? []).map((task) => task.id);
|
|
198
|
+
if (d.kind === "subagent-done") return (d.children ?? []).map((child) => child.childId);
|
|
199
|
+
if (d.kind === "subagent-overrun") return [d.childId, ...((d as { additional?: { childId: string }[] }).additional ?? []).map((child) => child.childId)].filter((id): id is string => Boolean(id));
|
|
200
|
+
return [d.id ?? d.taskId ?? d.childId ?? d.from].filter((id): id is string => Boolean(id));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** UTC, second precision: the model needs how old a snapshot is, not milliseconds. */
|
|
204
|
+
export function wakeTimestamp(ms: number): string {
|
|
205
|
+
return new Date(ms).toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Append attributes to the root <pi-famulus-wake …> tag of rendered content. */
|
|
209
|
+
function addRootAttrs(content: string, attrs: string[]): string {
|
|
210
|
+
if (attrs.length === 0) return content;
|
|
211
|
+
// `\s`: the lead-in mentions a bare <pi-famulus-wake>; the root tag has attributes.
|
|
212
|
+
const root = /<pi-famulus-wake\s[^>]*>/.exec(content);
|
|
213
|
+
if (!root) return content;
|
|
214
|
+
const end = root.index + root[0].length - 1;
|
|
215
|
+
return `${content.slice(0, end)} ${attrs.join(" ")}${content.slice(end)}`;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function timingAttrs(details: WakeTiming): string[] {
|
|
219
|
+
const attrs: string[] = [];
|
|
220
|
+
if (details.asOf !== undefined) attrs.push(`as-of="${wakeTimestamp(details.asOf)}"`);
|
|
221
|
+
if (details.ageMs !== undefined) attrs.push(`age-ms="${Math.round(details.ageMs)}"`);
|
|
222
|
+
return attrs;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** The <pi-famulus-wake> element for `details` (no lead-in). */
|
|
226
|
+
export function renderWakeXml(details: FamulusWake): string {
|
|
227
|
+
return renderWake(details);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Index of the root <pi-famulus-wake …> tag in content, or -1. */
|
|
231
|
+
export function wakeRootIndex(content: string): number {
|
|
232
|
+
return content.search(/<pi-famulus-wake\s/);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Stamp a wake with the time it was generated. Leaves non-wake messages and
|
|
237
|
+
* already-stamped wakes alone, so a wake held for later delivery keeps the
|
|
238
|
+
* time it was built.
|
|
239
|
+
*/
|
|
240
|
+
export function stampWakeAsOf<M extends { customType: string; content: string; details?: unknown }>(
|
|
241
|
+
message: M,
|
|
242
|
+
asOf: number,
|
|
243
|
+
): M {
|
|
244
|
+
if (message.customType !== FAMULUS_WAKE_CUSTOM_TYPE) return message;
|
|
245
|
+
const details = message.details as (WakeTiming & { kind?: string }) | undefined;
|
|
246
|
+
if (!details || typeof details !== "object" || !details.kind || details.asOf !== undefined) return message;
|
|
247
|
+
const stamped = { ...details, asOf };
|
|
248
|
+
const content = addRootAttrs(message.content, timingAttrs(stamped));
|
|
249
|
+
// addRootAttrs is a no-op when the root tag is missing; keep details in
|
|
250
|
+
// sync with content so wakeAtInjection does not reject a phantom stamp.
|
|
251
|
+
if (content === message.content) return message;
|
|
252
|
+
return { ...message, content, details: stamped };
|
|
253
|
+
}
|
|
254
|
+
|
|
102
255
|
function renderWake(details: FamulusWake): string {
|
|
256
|
+
return addRootAttrs(renderWakeBody(details), timingAttrs(details));
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function renderWakeBody(details: FamulusWake): string {
|
|
103
260
|
switch (details.kind) {
|
|
104
261
|
case "task":
|
|
105
262
|
return renderTask(details);
|
|
@@ -109,6 +266,8 @@ function renderWake(details: FamulusWake): string {
|
|
|
109
266
|
return renderHandover(details);
|
|
110
267
|
case "subagent-done":
|
|
111
268
|
return renderDone(details);
|
|
269
|
+
case "subagent-overrun":
|
|
270
|
+
return renderOverrun(details);
|
|
112
271
|
case "supervisor-request":
|
|
113
272
|
return renderRequest(details);
|
|
114
273
|
case "supervisor-update":
|
|
@@ -168,12 +327,23 @@ function renderHandover(details: Extract<FamulusWake, { kind: "subagent-handover
|
|
|
168
327
|
`name="${escapeXmlAttr(details.name)}"`,
|
|
169
328
|
`status="${escapeXmlAttr(details.status)}"`,
|
|
170
329
|
];
|
|
330
|
+
if (details.statusAsOf) attrs.push(`status-as-of="${escapeXmlAttr(details.statusAsOf)}"`);
|
|
171
331
|
const parts = [`<pi-famulus-wake ${attrs.join(" ")}>`];
|
|
172
332
|
const still = stillRunningXml(details.stillRunning);
|
|
173
333
|
if (still) parts.push(still);
|
|
174
334
|
parts.push(` <summary>${escapeXml(details.summary)}</summary>`);
|
|
335
|
+
if (details.statusAsOf) {
|
|
336
|
+
const active = details.status === "pending" || details.status === "running";
|
|
337
|
+
parts.push(
|
|
338
|
+
` <changed-since-as-of>${escapeXml(
|
|
339
|
+
`${details.name} (${details.childId}): ${details.statusAsOf} → ${details.status}.` +
|
|
340
|
+
(active ? ` ${PROMPTS["wakes.handover.changed-active"]}` : ""),
|
|
341
|
+
)}</changed-since-as-of>`,
|
|
342
|
+
);
|
|
343
|
+
}
|
|
175
344
|
parts.push(` <prompt>${escapeXml(details.prompt)}</prompt>`);
|
|
176
345
|
if (details.error) parts.push(` <error>${escapeXml(details.error)}</error>`);
|
|
346
|
+
if (details.warning) parts.push(` <warning>${escapeXml(details.warning)}</warning>`);
|
|
177
347
|
parts.push(` <result>${escapeXml(details.result)}</result>`);
|
|
178
348
|
parts.push("</pi-famulus-wake>");
|
|
179
349
|
return parts.join("\n");
|
|
@@ -186,13 +356,26 @@ function renderDone(details: Extract<FamulusWake, { kind: "subagent-done" }>): s
|
|
|
186
356
|
`status="${escapeXmlAttr(details.status)}"`,
|
|
187
357
|
`duration-ms="${Math.round(details.durationMs)}"`,
|
|
188
358
|
];
|
|
359
|
+
if (details.runStatusNow) attrs.push(`status-now="${escapeXmlAttr(details.runStatusNow)}"`);
|
|
189
360
|
const parts = [`<pi-famulus-wake ${attrs.join(" ")}>`, ` <summary>${escapeXml(details.summary)}</summary>`];
|
|
361
|
+
const changed = details.children.filter((child) => child.statusAsOf !== undefined);
|
|
362
|
+
if (changed.length > 0) {
|
|
363
|
+
const list = changed.map((c) => `${c.name} (${c.childId}): ${c.statusAsOf} → ${c.status}`).join("; ");
|
|
364
|
+
const active = details.children.some((c) => c.status === "pending" || c.status === "running");
|
|
365
|
+
parts.push(
|
|
366
|
+
` <changed-since-as-of>${escapeXml(
|
|
367
|
+
`${list}.` + (active ? ` ${PROMPTS["wakes.done.changed-active"]}` : ""),
|
|
368
|
+
)}</changed-since-as-of>`,
|
|
369
|
+
);
|
|
370
|
+
}
|
|
190
371
|
for (const child of details.children) {
|
|
372
|
+
const asOf = child.statusAsOf ? ` status-as-of="${escapeXmlAttr(child.statusAsOf)}"` : "";
|
|
191
373
|
parts.push(
|
|
192
|
-
` <child id="${escapeXmlAttr(child.childId)}" name="${escapeXmlAttr(child.name)}" status="${escapeXmlAttr(child.status)}">`,
|
|
374
|
+
` <child id="${escapeXmlAttr(child.childId)}" name="${escapeXmlAttr(child.name)}" status="${escapeXmlAttr(child.status)}"${asOf}>`,
|
|
193
375
|
);
|
|
194
376
|
parts.push(` <prompt>${escapeXml(child.prompt)}</prompt>`);
|
|
195
377
|
if (child.error) parts.push(` <error>${escapeXml(child.error)}</error>`);
|
|
378
|
+
if (child.warning) parts.push(` <warning>${escapeXml(child.warning)}</warning>`);
|
|
196
379
|
parts.push(` <result>${escapeXml(child.result)}</result>`);
|
|
197
380
|
parts.push(" </child>");
|
|
198
381
|
}
|
|
@@ -200,8 +383,88 @@ function renderDone(details: Extract<FamulusWake, { kind: "subagent-done" }>): s
|
|
|
200
383
|
return parts.join("\n");
|
|
201
384
|
}
|
|
202
385
|
|
|
386
|
+
/** Coarse human duration for model-facing text: "45s", "12m", "1h05m". */
|
|
387
|
+
export function wakeDuration(ms: number): string {
|
|
388
|
+
const s = Math.max(0, Math.round(ms / 1000));
|
|
389
|
+
if (s < 60) return `${s}s`;
|
|
390
|
+
const m = Math.round(s / 60);
|
|
391
|
+
if (m < 60) return `${m}m`;
|
|
392
|
+
return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, "0")}m`;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function renderOverrun(details: Extract<FamulusWake, { kind: "subagent-overrun" }>): string {
|
|
396
|
+
const attrs = [
|
|
397
|
+
'kind="subagent-overrun"',
|
|
398
|
+
`run-id="${escapeXmlAttr(details.runId)}"`,
|
|
399
|
+
`child-id="${escapeXmlAttr(details.childId)}"`,
|
|
400
|
+
`name="${escapeXmlAttr(details.name)}"`,
|
|
401
|
+
`elapsed-ms="${Math.round(details.elapsedMs)}"`,
|
|
402
|
+
`budget-ms="${Math.round(details.budgetMs)}"`,
|
|
403
|
+
`reminder="${details.reminder}"`,
|
|
404
|
+
];
|
|
405
|
+
if (details.hardCeilingMs !== undefined) attrs.push(`hard-ceiling-ms="${Math.round(details.hardCeilingMs)}"`);
|
|
406
|
+
const parts = [`<pi-famulus-wake ${attrs.join(" ")}>`, ` <summary>${escapeXml(details.summary)}</summary>`];
|
|
407
|
+
parts.push(
|
|
408
|
+
` <last-activity ago-ms="${Math.round(details.lastActivity.agoMs)}">${escapeXml(details.lastActivity.text)}</last-activity>`,
|
|
409
|
+
);
|
|
410
|
+
if (details.additional?.length) {
|
|
411
|
+
parts.push(" <also-overdue>");
|
|
412
|
+
for (const child of details.additional) {
|
|
413
|
+
const childAttrs = [
|
|
414
|
+
`run-id="${escapeXmlAttr(child.runId)}"`,
|
|
415
|
+
`child-id="${escapeXmlAttr(child.childId)}"`,
|
|
416
|
+
`name="${escapeXmlAttr(child.name)}"`,
|
|
417
|
+
`elapsed-ms="${Math.round(child.elapsedMs)}"`,
|
|
418
|
+
`budget-ms="${Math.round(child.budgetMs)}"`,
|
|
419
|
+
`reminder="${child.reminder}"`,
|
|
420
|
+
`next-reminder-ms="${Math.round(child.nextReminderMs)}"`,
|
|
421
|
+
];
|
|
422
|
+
if (child.hardCeilingMs !== undefined) childAttrs.push(`hard-ceiling-ms="${Math.round(child.hardCeilingMs)}"`);
|
|
423
|
+
parts.push(` <child ${childAttrs.join(" ")}>`);
|
|
424
|
+
parts.push(
|
|
425
|
+
` <last-activity ago-ms="${Math.round(child.lastActivity.agoMs)}">${escapeXml(child.lastActivity.text)}</last-activity>`,
|
|
426
|
+
);
|
|
427
|
+
if (child.shell) parts.push(...renderOverrunShell(child.shell, " "));
|
|
428
|
+
parts.push(" </child>");
|
|
429
|
+
}
|
|
430
|
+
parts.push(" </also-overdue>");
|
|
431
|
+
}
|
|
432
|
+
if (details.shell) parts.push(...renderOverrunShell(details.shell, " "));
|
|
433
|
+
parts.push(` <options>${escapeXml(overrunOptions(details))}</options>`);
|
|
434
|
+
parts.push("</pi-famulus-wake>");
|
|
435
|
+
return parts.join("\n");
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** The parent's three actions and what happens if it takes none. */
|
|
439
|
+
function overrunOptions(details: Extract<FamulusWake, { kind: "subagent-overrun" }>): string {
|
|
440
|
+
const actions = fill("wakes.overrun.actions", { runId: details.runId, childId: details.childId });
|
|
441
|
+
const next = wakeDuration(details.nextReminderMs);
|
|
442
|
+
const hard = details.hardCeilingMs;
|
|
443
|
+
if (hard === undefined) return fill("wakes.overrun.options", { actions, next });
|
|
444
|
+
// Opt-in hard ceiling: never promise "keeps running" past it.
|
|
445
|
+
const ceiling = wakeDuration(hard);
|
|
446
|
+
const noAction =
|
|
447
|
+
details.nextReminderMs < hard
|
|
448
|
+
? fill("wakes.overrun.ceiling-no-action-reminder", { next, ceiling })
|
|
449
|
+
: PROMPTS["wakes.overrun.ceiling-no-action"];
|
|
450
|
+
return fill("wakes.overrun.options-ceiling", { ceiling, actions, noAction });
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function renderOverrunShell(shell: OverrunShell, indent: string): string[] {
|
|
454
|
+
const shellAttrs = [`task-id="${escapeXmlAttr(shell.taskId)}"`, `elapsed-ms="${Math.round(shell.elapsedMs)}"`];
|
|
455
|
+
if (shell.outputBytes !== null) shellAttrs.push(`output-bytes="${shell.outputBytes}"`);
|
|
456
|
+
if (shell.outputIdleMs !== null) shellAttrs.push(`output-idle-ms="${Math.round(shell.outputIdleMs)}"`);
|
|
457
|
+
if (shell.growing !== null) shellAttrs.push(`growing="${shell.growing ? "yes" : "no"}"`);
|
|
458
|
+
return [
|
|
459
|
+
`${indent}<shell ${shellAttrs.join(" ")}>`,
|
|
460
|
+
`${indent} <command>${escapeXml(shell.command)}</command>`,
|
|
461
|
+
`${indent} <output-file>${escapeXml(shell.outputPath)}</output-file>`,
|
|
462
|
+
`${indent}</shell>`,
|
|
463
|
+
];
|
|
464
|
+
}
|
|
465
|
+
|
|
203
466
|
function renderRequest(details: Extract<FamulusWake, { kind: "supervisor-request" }>): string {
|
|
204
|
-
const recipe =
|
|
467
|
+
const recipe = fill("wakes.request.reply-with", { from: details.from });
|
|
205
468
|
return [
|
|
206
469
|
`<pi-famulus-wake kind="supervisor-request" from="${escapeXmlAttr(details.from)}" name="${escapeXmlAttr(details.name)}">`,
|
|
207
470
|
` <message>${escapeXml(details.message)}</message>`,
|
package/src/work-index.ts
CHANGED
|
@@ -14,6 +14,10 @@ export const WORK_FINISHED_CAP = 50;
|
|
|
14
14
|
export interface WorkItem {
|
|
15
15
|
id: string;
|
|
16
16
|
kind: WorkKind;
|
|
17
|
+
/** Daemon work-kind label for agents; shell commands are classified separately. */
|
|
18
|
+
workKind?: string;
|
|
19
|
+
/** Time queued for local/machine admission before the agent started. */
|
|
20
|
+
queueMs?: number;
|
|
17
21
|
status: string;
|
|
18
22
|
title: string;
|
|
19
23
|
command?: string;
|