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/wake.ts
CHANGED
|
@@ -29,16 +29,56 @@ export interface TaskWake {
|
|
|
29
29
|
signal?: string;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
export type WakeChildStatus = "pending" | "running" | "completed" | "failed" | "interrupted";
|
|
33
|
+
|
|
32
34
|
export interface SubagentDoneChild {
|
|
33
35
|
childId: string;
|
|
34
36
|
name: string;
|
|
35
|
-
|
|
37
|
+
/** Status when the model sees the wake (re-checked at injection). */
|
|
38
|
+
status: WakeChildStatus;
|
|
39
|
+
/** Set when the status changed after as-of: the status in the snapshot. */
|
|
40
|
+
statusAsOf?: WakeChildStatus;
|
|
36
41
|
prompt: string;
|
|
37
42
|
result: string;
|
|
38
43
|
error?: string;
|
|
44
|
+
/** Non-fatal caveat from the child run (e.g. an adapted model spec). */
|
|
45
|
+
warning?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The foreground shell a child is blocked on when its overrun wake is built. */
|
|
49
|
+
export interface OverrunShell {
|
|
50
|
+
taskId: string;
|
|
51
|
+
/** One line, capped (shellWakeTitle). */
|
|
52
|
+
command: string;
|
|
53
|
+
elapsedMs: number;
|
|
54
|
+
outputPath: string;
|
|
55
|
+
/** Output file size; null when the file could not be read. */
|
|
56
|
+
outputBytes: number | null;
|
|
57
|
+
/** Time since the output file last changed; null when unknown. */
|
|
58
|
+
outputIdleMs: number | null;
|
|
59
|
+
/** Output grew since the previous reminder, or changed in the last minute; null when unknown. */
|
|
60
|
+
growing: boolean | null;
|
|
39
61
|
}
|
|
40
62
|
|
|
41
|
-
|
|
63
|
+
/**
|
|
64
|
+
* When the wake's content was generated (design.md §4.5): stamped by the
|
|
65
|
+
* NotifyCenter when it receives or builds the wake. A steered wake can wait
|
|
66
|
+
* minutes before the model sees it; the model needs to know how old the
|
|
67
|
+
* snapshot is.
|
|
68
|
+
*/
|
|
69
|
+
export interface WakeTiming {
|
|
70
|
+
/** Epoch ms. Rendered as the root attribute as-of (UTC, seconds). */
|
|
71
|
+
asOf?: number;
|
|
72
|
+
/**
|
|
73
|
+
* How old the wake was when it entered the model's context (ms). Set at
|
|
74
|
+
* injection (wake-delivery.ts), rendered as the root attribute age-ms.
|
|
75
|
+
*/
|
|
76
|
+
ageMs?: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export type FamulusWake = FamulusWakeBody & WakeTiming;
|
|
80
|
+
|
|
81
|
+
type FamulusWakeBody =
|
|
42
82
|
| { kind: "task"; stillRunning: WakeItem[]; tasks: TaskWake[] }
|
|
43
83
|
| {
|
|
44
84
|
kind: "monitor";
|
|
@@ -54,21 +94,46 @@ export type FamulusWake =
|
|
|
54
94
|
runId: string;
|
|
55
95
|
childId: string;
|
|
56
96
|
name: string;
|
|
57
|
-
|
|
97
|
+
/** The settle this wake reports; pending/running only when re-checked at injection. */
|
|
98
|
+
status: WakeChildStatus;
|
|
99
|
+
/** Set when the status changed after as-of: the status in the snapshot. */
|
|
100
|
+
statusAsOf?: WakeChildStatus;
|
|
58
101
|
stillRunning: WakeItem[];
|
|
59
102
|
summary: string;
|
|
60
103
|
prompt: string;
|
|
61
104
|
result: string;
|
|
62
105
|
error?: string;
|
|
106
|
+
warning?: string;
|
|
63
107
|
}
|
|
64
108
|
| {
|
|
65
109
|
kind: "subagent-done";
|
|
66
110
|
runId: string;
|
|
67
111
|
status: "completed" | "partial" | "failed" | "interrupted";
|
|
112
|
+
/** Set at injection when a child is pending or running again: the run's status then. */
|
|
113
|
+
runStatusNow?: string;
|
|
68
114
|
durationMs: number;
|
|
69
115
|
summary: string;
|
|
70
116
|
children: SubagentDoneChild[];
|
|
71
117
|
}
|
|
118
|
+
| {
|
|
119
|
+
kind: "subagent-overrun";
|
|
120
|
+
runId: string;
|
|
121
|
+
childId: string;
|
|
122
|
+
name: string;
|
|
123
|
+
/** Time in this user turn (since admission of the launch or resume). */
|
|
124
|
+
elapsedMs: number;
|
|
125
|
+
/** The turn's soft budget (timeout_ms), extensions included. */
|
|
126
|
+
budgetMs: number;
|
|
127
|
+
/** 1 for the first wake of this turn, then 2, 3, … */
|
|
128
|
+
reminder: number;
|
|
129
|
+
/** When the next reminder is due if the parent does nothing. */
|
|
130
|
+
nextReminderMs: number;
|
|
131
|
+
/** Time left until the opt-in hard ceiling aborts the child; absent when none is set. */
|
|
132
|
+
hardCeilingMs?: number;
|
|
133
|
+
summary: string;
|
|
134
|
+
lastActivity: { agoMs: number; text: string };
|
|
135
|
+
shell?: OverrunShell;
|
|
136
|
+
}
|
|
72
137
|
| { kind: "supervisor-request"; from: string; name: string; message: string }
|
|
73
138
|
| { kind: "supervisor-update"; from: string; name: string; message: string };
|
|
74
139
|
|
|
@@ -99,7 +164,79 @@ export function formatFamulusWake(details: FamulusWake, leadIn: string = FAMULUS
|
|
|
99
164
|
return { customType: FAMULUS_WAKE_CUSTOM_TYPE, content, details };
|
|
100
165
|
}
|
|
101
166
|
|
|
167
|
+
/** Task/child/monitor ids a wake is about (event log `ids`). */
|
|
168
|
+
export function wakeIds(details: { kind?: string }): string[] {
|
|
169
|
+
const d = details as {
|
|
170
|
+
kind?: string;
|
|
171
|
+
id?: string;
|
|
172
|
+
taskId?: string;
|
|
173
|
+
tasks?: { id: string }[];
|
|
174
|
+
children?: { childId: string }[];
|
|
175
|
+
childId?: string;
|
|
176
|
+
from?: string;
|
|
177
|
+
};
|
|
178
|
+
if (d.kind === "task") return (d.tasks ?? []).map((task) => task.id);
|
|
179
|
+
if (d.kind === "subagent-done") return (d.children ?? []).map((child) => child.childId);
|
|
180
|
+
return [d.id ?? d.taskId ?? d.childId ?? d.from].filter((id): id is string => Boolean(id));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** UTC, second precision: the model needs how old a snapshot is, not milliseconds. */
|
|
184
|
+
export function wakeTimestamp(ms: number): string {
|
|
185
|
+
return new Date(ms).toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Append attributes to the root <pi-famulus-wake …> tag of rendered content. */
|
|
189
|
+
function addRootAttrs(content: string, attrs: string[]): string {
|
|
190
|
+
if (attrs.length === 0) return content;
|
|
191
|
+
// `\s`: the lead-in mentions a bare <pi-famulus-wake>; the root tag has attributes.
|
|
192
|
+
const root = /<pi-famulus-wake\s[^>]*>/.exec(content);
|
|
193
|
+
if (!root) return content;
|
|
194
|
+
const end = root.index + root[0].length - 1;
|
|
195
|
+
return `${content.slice(0, end)} ${attrs.join(" ")}${content.slice(end)}`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function timingAttrs(details: WakeTiming): string[] {
|
|
199
|
+
const attrs: string[] = [];
|
|
200
|
+
if (details.asOf !== undefined) attrs.push(`as-of="${wakeTimestamp(details.asOf)}"`);
|
|
201
|
+
if (details.ageMs !== undefined) attrs.push(`age-ms="${Math.round(details.ageMs)}"`);
|
|
202
|
+
return attrs;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** The <pi-famulus-wake> element for `details` (no lead-in). */
|
|
206
|
+
export function renderWakeXml(details: FamulusWake): string {
|
|
207
|
+
return renderWake(details);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Index of the root <pi-famulus-wake …> tag in content, or -1. */
|
|
211
|
+
export function wakeRootIndex(content: string): number {
|
|
212
|
+
return content.search(/<pi-famulus-wake\s/);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Stamp a wake with the time it was generated. Leaves non-wake messages and
|
|
217
|
+
* already-stamped wakes alone, so a wake held for later delivery keeps the
|
|
218
|
+
* time it was built.
|
|
219
|
+
*/
|
|
220
|
+
export function stampWakeAsOf<M extends { customType: string; content: string; details?: unknown }>(
|
|
221
|
+
message: M,
|
|
222
|
+
asOf: number,
|
|
223
|
+
): M {
|
|
224
|
+
if (message.customType !== FAMULUS_WAKE_CUSTOM_TYPE) return message;
|
|
225
|
+
const details = message.details as (WakeTiming & { kind?: string }) | undefined;
|
|
226
|
+
if (!details || typeof details !== "object" || !details.kind || details.asOf !== undefined) return message;
|
|
227
|
+
const stamped = { ...details, asOf };
|
|
228
|
+
const content = addRootAttrs(message.content, timingAttrs(stamped));
|
|
229
|
+
// addRootAttrs is a no-op when the root tag is missing; keep details in
|
|
230
|
+
// sync with content so wakeAtInjection does not reject a phantom stamp.
|
|
231
|
+
if (content === message.content) return message;
|
|
232
|
+
return { ...message, content, details: stamped };
|
|
233
|
+
}
|
|
234
|
+
|
|
102
235
|
function renderWake(details: FamulusWake): string {
|
|
236
|
+
return addRootAttrs(renderWakeBody(details), timingAttrs(details));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function renderWakeBody(details: FamulusWake): string {
|
|
103
240
|
switch (details.kind) {
|
|
104
241
|
case "task":
|
|
105
242
|
return renderTask(details);
|
|
@@ -109,6 +246,8 @@ function renderWake(details: FamulusWake): string {
|
|
|
109
246
|
return renderHandover(details);
|
|
110
247
|
case "subagent-done":
|
|
111
248
|
return renderDone(details);
|
|
249
|
+
case "subagent-overrun":
|
|
250
|
+
return renderOverrun(details);
|
|
112
251
|
case "supervisor-request":
|
|
113
252
|
return renderRequest(details);
|
|
114
253
|
case "supervisor-update":
|
|
@@ -168,12 +307,23 @@ function renderHandover(details: Extract<FamulusWake, { kind: "subagent-handover
|
|
|
168
307
|
`name="${escapeXmlAttr(details.name)}"`,
|
|
169
308
|
`status="${escapeXmlAttr(details.status)}"`,
|
|
170
309
|
];
|
|
310
|
+
if (details.statusAsOf) attrs.push(`status-as-of="${escapeXmlAttr(details.statusAsOf)}"`);
|
|
171
311
|
const parts = [`<pi-famulus-wake ${attrs.join(" ")}>`];
|
|
172
312
|
const still = stillRunningXml(details.stillRunning);
|
|
173
313
|
if (still) parts.push(still);
|
|
174
314
|
parts.push(` <summary>${escapeXml(details.summary)}</summary>`);
|
|
315
|
+
if (details.statusAsOf) {
|
|
316
|
+
const active = details.status === "pending" || details.status === "running";
|
|
317
|
+
parts.push(
|
|
318
|
+
` <changed-since-as-of>${escapeXml(
|
|
319
|
+
`${details.name} (${details.childId}): ${details.statusAsOf} → ${details.status}.` +
|
|
320
|
+
(active ? " Its result arrives as a new wake when it finishes." : ""),
|
|
321
|
+
)}</changed-since-as-of>`,
|
|
322
|
+
);
|
|
323
|
+
}
|
|
175
324
|
parts.push(` <prompt>${escapeXml(details.prompt)}</prompt>`);
|
|
176
325
|
if (details.error) parts.push(` <error>${escapeXml(details.error)}</error>`);
|
|
326
|
+
if (details.warning) parts.push(` <warning>${escapeXml(details.warning)}</warning>`);
|
|
177
327
|
parts.push(` <result>${escapeXml(details.result)}</result>`);
|
|
178
328
|
parts.push("</pi-famulus-wake>");
|
|
179
329
|
return parts.join("\n");
|
|
@@ -186,13 +336,26 @@ function renderDone(details: Extract<FamulusWake, { kind: "subagent-done" }>): s
|
|
|
186
336
|
`status="${escapeXmlAttr(details.status)}"`,
|
|
187
337
|
`duration-ms="${Math.round(details.durationMs)}"`,
|
|
188
338
|
];
|
|
339
|
+
if (details.runStatusNow) attrs.push(`status-now="${escapeXmlAttr(details.runStatusNow)}"`);
|
|
189
340
|
const parts = [`<pi-famulus-wake ${attrs.join(" ")}>`, ` <summary>${escapeXml(details.summary)}</summary>`];
|
|
341
|
+
const changed = details.children.filter((child) => child.statusAsOf !== undefined);
|
|
342
|
+
if (changed.length > 0) {
|
|
343
|
+
const list = changed.map((c) => `${c.name} (${c.childId}): ${c.statusAsOf} → ${c.status}`).join("; ");
|
|
344
|
+
const active = details.children.some((c) => c.status === "pending" || c.status === "running");
|
|
345
|
+
parts.push(
|
|
346
|
+
` <changed-since-as-of>${escapeXml(
|
|
347
|
+
`${list}.` + (active ? " The run is active again; another subagent-done arrives when it finishes." : ""),
|
|
348
|
+
)}</changed-since-as-of>`,
|
|
349
|
+
);
|
|
350
|
+
}
|
|
190
351
|
for (const child of details.children) {
|
|
352
|
+
const asOf = child.statusAsOf ? ` status-as-of="${escapeXmlAttr(child.statusAsOf)}"` : "";
|
|
191
353
|
parts.push(
|
|
192
|
-
` <child id="${escapeXmlAttr(child.childId)}" name="${escapeXmlAttr(child.name)}" status="${escapeXmlAttr(child.status)}">`,
|
|
354
|
+
` <child id="${escapeXmlAttr(child.childId)}" name="${escapeXmlAttr(child.name)}" status="${escapeXmlAttr(child.status)}"${asOf}>`,
|
|
193
355
|
);
|
|
194
356
|
parts.push(` <prompt>${escapeXml(child.prompt)}</prompt>`);
|
|
195
357
|
if (child.error) parts.push(` <error>${escapeXml(child.error)}</error>`);
|
|
358
|
+
if (child.warning) parts.push(` <warning>${escapeXml(child.warning)}</warning>`);
|
|
196
359
|
parts.push(` <result>${escapeXml(child.result)}</result>`);
|
|
197
360
|
parts.push(" </child>");
|
|
198
361
|
}
|
|
@@ -200,6 +363,77 @@ function renderDone(details: Extract<FamulusWake, { kind: "subagent-done" }>): s
|
|
|
200
363
|
return parts.join("\n");
|
|
201
364
|
}
|
|
202
365
|
|
|
366
|
+
/** Coarse human duration for model-facing text: "45s", "12m", "1h05m". */
|
|
367
|
+
export function wakeDuration(ms: number): string {
|
|
368
|
+
const s = Math.max(0, Math.round(ms / 1000));
|
|
369
|
+
if (s < 60) return `${s}s`;
|
|
370
|
+
const m = Math.round(s / 60);
|
|
371
|
+
if (m < 60) return `${m}m`;
|
|
372
|
+
return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, "0")}m`;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function renderOverrun(details: Extract<FamulusWake, { kind: "subagent-overrun" }>): string {
|
|
376
|
+
const attrs = [
|
|
377
|
+
'kind="subagent-overrun"',
|
|
378
|
+
`run-id="${escapeXmlAttr(details.runId)}"`,
|
|
379
|
+
`child-id="${escapeXmlAttr(details.childId)}"`,
|
|
380
|
+
`name="${escapeXmlAttr(details.name)}"`,
|
|
381
|
+
`elapsed-ms="${Math.round(details.elapsedMs)}"`,
|
|
382
|
+
`budget-ms="${Math.round(details.budgetMs)}"`,
|
|
383
|
+
`reminder="${details.reminder}"`,
|
|
384
|
+
];
|
|
385
|
+
if (details.hardCeilingMs !== undefined) attrs.push(`hard-ceiling-ms="${Math.round(details.hardCeilingMs)}"`);
|
|
386
|
+
const parts = [`<pi-famulus-wake ${attrs.join(" ")}>`, ` <summary>${escapeXml(details.summary)}</summary>`];
|
|
387
|
+
parts.push(
|
|
388
|
+
` <last-activity ago-ms="${Math.round(details.lastActivity.agoMs)}">${escapeXml(details.lastActivity.text)}</last-activity>`,
|
|
389
|
+
);
|
|
390
|
+
const shell = details.shell;
|
|
391
|
+
if (shell) {
|
|
392
|
+
const shellAttrs = [`task-id="${escapeXmlAttr(shell.taskId)}"`, `elapsed-ms="${Math.round(shell.elapsedMs)}"`];
|
|
393
|
+
if (shell.outputBytes !== null) shellAttrs.push(`output-bytes="${shell.outputBytes}"`);
|
|
394
|
+
if (shell.outputIdleMs !== null) shellAttrs.push(`output-idle-ms="${Math.round(shell.outputIdleMs)}"`);
|
|
395
|
+
if (shell.growing !== null) shellAttrs.push(`growing="${shell.growing ? "yes" : "no"}"`);
|
|
396
|
+
parts.push(` <shell ${shellAttrs.join(" ")}>`);
|
|
397
|
+
parts.push(` <command>${escapeXml(shell.command)}</command>`);
|
|
398
|
+
parts.push(` <output-file>${escapeXml(shell.outputPath)}</output-file>`);
|
|
399
|
+
parts.push(" </shell>");
|
|
400
|
+
}
|
|
401
|
+
parts.push(` <options>${escapeXml(overrunOptions(details))}</options>`);
|
|
402
|
+
parts.push("</pi-famulus-wake>");
|
|
403
|
+
return parts.join("\n");
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** The parent's three actions and what happens if it takes none. */
|
|
407
|
+
function overrunOptions(details: Extract<FamulusWake, { kind: "subagent-overrun" }>): string {
|
|
408
|
+
const ids = `run_id: "${details.runId}", child_id: "${details.childId}"`;
|
|
409
|
+
const actions =
|
|
410
|
+
`give it more time with subagent({ action: "extend", ${ids}, timeout_ms: <ms from now> }); ` +
|
|
411
|
+
`redirect it with agent_message({ action: "send", to: "${details.childId}", message: "<instruction>" }); ` +
|
|
412
|
+
`or stop it with subagent({ action: "interrupt", ${ids} }). `;
|
|
413
|
+
const hard = details.hardCeilingMs;
|
|
414
|
+
if (hard === undefined) {
|
|
415
|
+
return (
|
|
416
|
+
"It has not been stopped. Choose one: " +
|
|
417
|
+
actions +
|
|
418
|
+
`If you do none of these, it keeps running and the next reminder is scheduled in ${wakeDuration(details.nextReminderMs)}; ` +
|
|
419
|
+
"its result arrives as usual when it finishes."
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
// Opt-in hard ceiling: never promise "keeps running" past it.
|
|
423
|
+
const ceiling = wakeDuration(hard);
|
|
424
|
+
const noAction =
|
|
425
|
+
details.nextReminderMs < hard
|
|
426
|
+
? `If you do none of these, it keeps running and the next reminder is scheduled in ${wakeDuration(details.nextReminderMs)}, until the hard ceiling stops it in ${ceiling}.`
|
|
427
|
+
: "If you do none of these, it keeps running until the hard ceiling stops it.";
|
|
428
|
+
return (
|
|
429
|
+
`It has not been stopped yet, but the configured hard ceiling stops it in ${ceiling}, and extend does not move that ceiling. ` +
|
|
430
|
+
"Choose one: " +
|
|
431
|
+
actions +
|
|
432
|
+
noAction +
|
|
433
|
+
" Its result, or the interruption, arrives as a wake."
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
|
|
203
437
|
function renderRequest(details: Extract<FamulusWake, { kind: "supervisor-request" }>): string {
|
|
204
438
|
const recipe = `agent_message { action: "reply", to: "${details.from}", message: "<your decision>" }`;
|
|
205
439
|
return [
|
package/src/work-index.ts
CHANGED
|
@@ -180,3 +180,19 @@ export function formatAge(startedAt: number, endedAt: number | undefined, now: n
|
|
|
180
180
|
if (ms < 3_600_000) return `${Math.floor(ms / 60_000)}m${Math.round((ms % 60_000) / 1000)}s`;
|
|
181
181
|
return `${Math.floor(ms / 3_600_000)}h${Math.floor((ms % 3_600_000) / 60_000)}m`;
|
|
182
182
|
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Next time the age displayed by {@link formatAge} for an item started at
|
|
186
|
+
* `startedAt` changes, given at most one render per second.
|
|
187
|
+
*
|
|
188
|
+
* Below 1s formatAge shows raw milliseconds, so a sub-second item's first
|
|
189
|
+
* scheduled update is the transition to "1s" at the 1s mark. At 1s and above
|
|
190
|
+
* the value rounds to the nearest second and flips on half-second boundaries
|
|
191
|
+
* relative to `startedAt`.
|
|
192
|
+
*/
|
|
193
|
+
export function nextAgeBoundary(now: number, startedAt: number): number {
|
|
194
|
+
const age = now - startedAt;
|
|
195
|
+
if (age < 1000) return now + (1000 - age);
|
|
196
|
+
const offset = age % 1000;
|
|
197
|
+
return now + (offset < 500 ? 500 - offset : 1500 - offset);
|
|
198
|
+
}
|