pi-famulus 0.1.0
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/LICENSE +21 -0
- package/README.md +36 -0
- package/bin/pi-famulus.js +29 -0
- package/package.json +49 -0
- package/src/agents/builtins.ts +41 -0
- package/src/agents/definition.ts +221 -0
- package/src/agents/loader.ts +119 -0
- package/src/bash-override.ts +372 -0
- package/src/behavior-guidelines.ts +42 -0
- package/src/clock.ts +169 -0
- package/src/comms/comms.ts +199 -0
- package/src/comms/mailbox.ts +156 -0
- package/src/comms/registry-host.ts +73 -0
- package/src/comms/reply-command.ts +52 -0
- package/src/comms/routing.ts +23 -0
- package/src/comms/tools.ts +339 -0
- package/src/comms/types.ts +68 -0
- package/src/config.ts +266 -0
- package/src/events.ts +61 -0
- package/src/exit-notify-gate.ts +69 -0
- package/src/exit-watchdog.ts +60 -0
- package/src/file-tail.ts +48 -0
- package/src/format.ts +276 -0
- package/src/index.ts +700 -0
- package/src/manager-client.ts +839 -0
- package/src/monitor-batching.ts +191 -0
- package/src/monitor.ts +517 -0
- package/src/native-manager.d.ts +9 -0
- package/src/native-manager.js +39 -0
- package/src/notify.ts +231 -0
- package/src/shell-exec.ts +145 -0
- package/src/subagent/agent-records.ts +168 -0
- package/src/subagent/child-bash.ts +183 -0
- package/src/subagent/conversation.ts +76 -0
- package/src/subagent/fleet-widget.ts +176 -0
- package/src/subagent/model-spec.ts +108 -0
- package/src/subagent/pi-runtime.ts +295 -0
- package/src/subagent/pool.ts +190 -0
- package/src/subagent/registry.ts +575 -0
- package/src/subagent/runner.ts +709 -0
- package/src/subagent/tool.ts +741 -0
- package/src/subagent/transcript.ts +71 -0
- package/src/subagent/types.ts +125 -0
- package/src/task-exit-notify.ts +21 -0
- package/src/task-tools.ts +330 -0
- package/src/tui/message-renderers.ts +183 -0
- package/src/tui/pi-tui-load.ts +186 -0
- package/src/tui/scroll-detail-view.ts +190 -0
- package/src/tui/task-output-paths.ts +46 -0
- package/src/tui/tasks-command.ts +515 -0
- package/src/tui/tool-component.ts +29 -0
- package/src/wake.ts +219 -0
- package/src/work-index.ts +182 -0
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bash tool override (design doc §4.2).
|
|
3
|
+
*
|
|
4
|
+
* Routes bash commands through pi-famulus:
|
|
5
|
+
* - foreground commands wait up to `foregroundBudgetMs` (default 20000,
|
|
6
|
+
* configurable) and are then moved to the background instead of blocking;
|
|
7
|
+
* - bare sleep / idle-loop commands are rejected with guidance;
|
|
8
|
+
* - when the manager is unavailable, execution falls back to a local
|
|
9
|
+
* child_process implementation that mimics the built-in bash tool
|
|
10
|
+
* (degraded but never broken).
|
|
11
|
+
*
|
|
12
|
+
* Result details stay compatible with BashToolDetails
|
|
13
|
+
* ({ truncation?, fullOutputPath? }); extension fields are added alongside.
|
|
14
|
+
*/
|
|
15
|
+
import { spawn } from "node:child_process";
|
|
16
|
+
import { randomUUID } from "node:crypto";
|
|
17
|
+
import { writeFileSync } from "node:fs";
|
|
18
|
+
import { tmpdir } from "node:os";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { Type } from "typebox";
|
|
21
|
+
import type {
|
|
22
|
+
AgentToolResult,
|
|
23
|
+
BashToolDetails,
|
|
24
|
+
ExtensionContext,
|
|
25
|
+
ToolDefinition,
|
|
26
|
+
} from "@earendil-works/pi-coding-agent";
|
|
27
|
+
import { taskOutputPath, type FamulusConfig } from "./config";
|
|
28
|
+
import { realClock, type Clock, type ClockTimer } from "./clock";
|
|
29
|
+
import { backgroundRowText, formatBackgroundNotice, truncateTail } from "./format";
|
|
30
|
+
import { toolComponent } from "./tui/tool-component";
|
|
31
|
+
import type { WorkIndex } from "./work-index";
|
|
32
|
+
import type { ManagerClient } from "./manager-client";
|
|
33
|
+
import {
|
|
34
|
+
appendStatus,
|
|
35
|
+
bareSleepError,
|
|
36
|
+
collectOutput,
|
|
37
|
+
formatFinishedOutput,
|
|
38
|
+
killedBy,
|
|
39
|
+
killedStatus,
|
|
40
|
+
timedOutStatus,
|
|
41
|
+
SHELL_MAX_BYTES,
|
|
42
|
+
SHELL_MAX_LINES,
|
|
43
|
+
withAbort,
|
|
44
|
+
} from "./shell-exec";
|
|
45
|
+
|
|
46
|
+
/** Same limits as the built-in bash tool. */
|
|
47
|
+
const MAX_LINES = SHELL_MAX_LINES;
|
|
48
|
+
const MAX_BYTES = SHELL_MAX_BYTES;
|
|
49
|
+
|
|
50
|
+
const bashParameters = Type.Object({
|
|
51
|
+
command: Type.String({ description: "The bash command to execute" }),
|
|
52
|
+
timeout: Type.Optional(
|
|
53
|
+
Type.Number({ description: "Hard kill timeout in seconds (optional, no default timeout)" }),
|
|
54
|
+
),
|
|
55
|
+
run_in_background: Type.Optional(
|
|
56
|
+
Type.Boolean({
|
|
57
|
+
description:
|
|
58
|
+
"Start the command in the background and return immediately. You will be notified when it completes.",
|
|
59
|
+
}),
|
|
60
|
+
),
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
type BashParams = { command: string; timeout?: number; run_in_background?: boolean };
|
|
64
|
+
|
|
65
|
+
/** Details shape returned by this override; superset of BashToolDetails. */
|
|
66
|
+
export interface FamulusBashDetails extends BashToolDetails {
|
|
67
|
+
backgrounded?: boolean;
|
|
68
|
+
task_id?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface BashOverrideDeps {
|
|
72
|
+
getClient: () => ManagerClient | null;
|
|
73
|
+
config: FamulusConfig;
|
|
74
|
+
home: string;
|
|
75
|
+
sessionId: () => string;
|
|
76
|
+
/** Extra environment injected into managed child processes (PI_* vars). */
|
|
77
|
+
sessionEnv: (ctx: ExtensionContext) => Record<string, string>;
|
|
78
|
+
/** Register task metadata so exit notifications can describe the task. */
|
|
79
|
+
trackTask: (taskId: string, meta: { kind: string; command: string; cwd?: string }) => void;
|
|
80
|
+
/**
|
|
81
|
+
* Mark a task so its task_exited event becomes a parent <pi-famulus-wake kind="task">.
|
|
82
|
+
* Only backgrounded parent bash should call this — sync waits (foreground
|
|
83
|
+
* budget hit, child-bash) must not wake the parent session.
|
|
84
|
+
*/
|
|
85
|
+
markNotifyOnExit: (taskId: string) => void;
|
|
86
|
+
clock?: Clock;
|
|
87
|
+
/** Live background work, so a backgrounded row can show its final status. */
|
|
88
|
+
getIndex?: () => WorkIndex | null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Same collapsed preview as pi's default tool-result view. */
|
|
92
|
+
const PREVIEW_LINES = 10;
|
|
93
|
+
|
|
94
|
+
function isActiveStatus(status: string | undefined): boolean {
|
|
95
|
+
return status === undefined || status === "running" || status === "pending";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const BARE_SLEEP_GUIDANCE =
|
|
99
|
+
"Do not sleep to wait for background work; completion is delivered via notification. Use the monitor tool to watch for a condition, or run_in_background for long commands.";
|
|
100
|
+
|
|
101
|
+
function rejectBareSleep(command: string): string | null {
|
|
102
|
+
return bareSleepError(command, BARE_SLEEP_GUIDANCE);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function resolveTimeoutMs(timeoutSeconds: number | undefined): number | null {
|
|
106
|
+
if (timeoutSeconds === undefined) return null;
|
|
107
|
+
if (!Number.isFinite(timeoutSeconds) || timeoutSeconds <= 0) {
|
|
108
|
+
throw new Error("Invalid timeout: must be a finite number of seconds");
|
|
109
|
+
}
|
|
110
|
+
return Math.round(timeoutSeconds * 1000);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function fullEnv(ctx: ExtensionContext, deps: BashOverrideDeps): Record<string, string> {
|
|
114
|
+
const env: Record<string, string> = {};
|
|
115
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
116
|
+
if (value !== undefined) env[key] = value;
|
|
117
|
+
}
|
|
118
|
+
Object.assign(env, deps.sessionEnv(ctx));
|
|
119
|
+
return env;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
// Local fallback (manager unavailable): mimics the built-in bash tool.
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
async function executeLocal(
|
|
127
|
+
params: BashParams,
|
|
128
|
+
signal: AbortSignal | undefined,
|
|
129
|
+
ctx: ExtensionContext,
|
|
130
|
+
clock: Clock,
|
|
131
|
+
): Promise<AgentToolResult<FamulusBashDetails | undefined>> {
|
|
132
|
+
const timeoutMs = resolveTimeoutMs(params.timeout);
|
|
133
|
+
const shell = process.env.SHELL && process.env.SHELL.length > 0 ? process.env.SHELL : "/bin/bash";
|
|
134
|
+
|
|
135
|
+
const output = await new Promise<{ text: string; exitCode: number | null; signal: string | null; timedOut: boolean; aborted: boolean }>(
|
|
136
|
+
(resolve, reject) => {
|
|
137
|
+
const child = spawn(shell, ["-c", params.command], {
|
|
138
|
+
cwd: ctx.cwd,
|
|
139
|
+
env: process.env,
|
|
140
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
141
|
+
});
|
|
142
|
+
const chunks: Buffer[] = [];
|
|
143
|
+
let settled = false;
|
|
144
|
+
let timedOut = false;
|
|
145
|
+
let aborted = false;
|
|
146
|
+
let timer: ClockTimer | null = null;
|
|
147
|
+
const finish = (exitCode: number | null, killSignal: string | null) => {
|
|
148
|
+
if (settled) return;
|
|
149
|
+
settled = true;
|
|
150
|
+
if (timer !== null) clock.clearTimeout(timer);
|
|
151
|
+
signal?.removeEventListener("abort", onAbort);
|
|
152
|
+
resolve({ text: Buffer.concat(chunks).toString("utf8"), exitCode, signal: killSignal, timedOut, aborted });
|
|
153
|
+
};
|
|
154
|
+
const kill = () => {
|
|
155
|
+
try {
|
|
156
|
+
child.kill("SIGTERM");
|
|
157
|
+
} catch {
|
|
158
|
+
// already dead
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
const onAbort = () => {
|
|
162
|
+
aborted = true;
|
|
163
|
+
kill();
|
|
164
|
+
};
|
|
165
|
+
timer =
|
|
166
|
+
timeoutMs !== null
|
|
167
|
+
? clock.setTimeout(() => {
|
|
168
|
+
timedOut = true;
|
|
169
|
+
kill();
|
|
170
|
+
}, timeoutMs)
|
|
171
|
+
: null;
|
|
172
|
+
if (timer !== null) clock.unref?.(timer);
|
|
173
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
174
|
+
child.stdout?.on("data", (d: Buffer) => chunks.push(d));
|
|
175
|
+
child.stderr?.on("data", (d: Buffer) => chunks.push(d));
|
|
176
|
+
child.on("error", (err) => {
|
|
177
|
+
if (timer !== null) clock.clearTimeout(timer);
|
|
178
|
+
signal?.removeEventListener("abort", onAbort);
|
|
179
|
+
if (!settled) {
|
|
180
|
+
settled = true;
|
|
181
|
+
reject(new Error(`Failed to start shell: ${err.message}`));
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
child.on("close", (code, killSignal) => finish(code, killSignal));
|
|
185
|
+
},
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
const t = truncateTail(output.text, MAX_LINES, MAX_BYTES);
|
|
189
|
+
let text = t.text || "(no output)";
|
|
190
|
+
let details: FamulusBashDetails | undefined;
|
|
191
|
+
if (t.truncated) {
|
|
192
|
+
const fullOutputPath = join(tmpdir(), `pi-famulus-bash-${randomUUID()}.log`);
|
|
193
|
+
writeFileSync(fullOutputPath, output.text, "utf8");
|
|
194
|
+
const outputLines = t.text.length === 0 ? 0 : t.text.split("\n").length;
|
|
195
|
+
const startLine = t.totalLines - outputLines + 1;
|
|
196
|
+
details = {
|
|
197
|
+
truncation: {
|
|
198
|
+
content: t.text,
|
|
199
|
+
truncated: true,
|
|
200
|
+
truncatedBy: t.totalLines > MAX_LINES ? "lines" : "bytes",
|
|
201
|
+
totalLines: t.totalLines,
|
|
202
|
+
totalBytes: t.totalBytes,
|
|
203
|
+
outputLines,
|
|
204
|
+
outputBytes: Buffer.byteLength(t.text, "utf8"),
|
|
205
|
+
lastLinePartial: false,
|
|
206
|
+
firstLineExceedsLimit: false,
|
|
207
|
+
maxLines: MAX_LINES,
|
|
208
|
+
maxBytes: MAX_BYTES,
|
|
209
|
+
},
|
|
210
|
+
fullOutputPath,
|
|
211
|
+
};
|
|
212
|
+
text += `\n\n[Showing lines ${startLine}-${t.totalLines} of ${t.totalLines}. Full output: ${fullOutputPath}]`;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (output.aborted) throw new Error(appendStatus(text, "Command aborted"));
|
|
216
|
+
if (output.timedOut && params.timeout !== undefined) {
|
|
217
|
+
throw new Error(appendStatus(text, timedOutStatus(params.timeout)));
|
|
218
|
+
}
|
|
219
|
+
if (output.exitCode !== 0 && output.exitCode !== null) {
|
|
220
|
+
throw new Error(appendStatus(text, `Command exited with code ${output.exitCode}`));
|
|
221
|
+
}
|
|
222
|
+
if (output.exitCode === null) throw new Error(appendStatus(text, killedBy(output.signal)));
|
|
223
|
+
return { content: [{ type: "text", text }], details };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ---------------------------------------------------------------------------
|
|
227
|
+
// Tool definition
|
|
228
|
+
// ---------------------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
export function createBashOverride(
|
|
231
|
+
deps: BashOverrideDeps,
|
|
232
|
+
): ToolDefinition<typeof bashParameters, FamulusBashDetails | undefined> {
|
|
233
|
+
return {
|
|
234
|
+
name: "bash",
|
|
235
|
+
label: "Bash",
|
|
236
|
+
description:
|
|
237
|
+
"Execute a bash command in the current working directory. Returns stdout and stderr. " +
|
|
238
|
+
`Output is truncated to last ${MAX_LINES} lines or ${MAX_BYTES / 1024}KB (whichever is hit first). ` +
|
|
239
|
+
"Foreground commands that exceed the foreground budget are automatically moved to the background; " +
|
|
240
|
+
"you will be notified when they complete. Optionally provide a timeout in seconds (hard kill limit), " +
|
|
241
|
+
"or run_in_background to background immediately.",
|
|
242
|
+
promptSnippet: "Execute bash commands (ls, grep, find, etc.)",
|
|
243
|
+
promptGuidelines: [
|
|
244
|
+
"You can inspect PI_* environment variables for current model and session details.",
|
|
245
|
+
"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
|
+
],
|
|
247
|
+
parameters: bashParameters,
|
|
248
|
+
renderResult(result, { expanded }, theme, context) {
|
|
249
|
+
const details = result.details as FamulusBashDetails | undefined;
|
|
250
|
+
if (details?.backgrounded && details.task_id) {
|
|
251
|
+
const taskId = details.task_id;
|
|
252
|
+
const index = deps.getIndex?.() ?? null;
|
|
253
|
+
const state = context.state as { unsubscribe?: () => void };
|
|
254
|
+
const item = index?.get(taskId);
|
|
255
|
+
if (index && !state.unsubscribe && isActiveStatus(item?.status)) {
|
|
256
|
+
// Redraw this row when the task finishes, then stop listening.
|
|
257
|
+
state.unsubscribe = index.onChange(() => {
|
|
258
|
+
if (!isActiveStatus(index.get(taskId)?.status)) {
|
|
259
|
+
state.unsubscribe?.();
|
|
260
|
+
state.unsubscribe = () => {};
|
|
261
|
+
}
|
|
262
|
+
context.invalidate();
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
const row = backgroundRowText(taskId, item, (deps.clock ?? realClock).now());
|
|
266
|
+
return toolComponent([`${theme.fg(row.color as never, row.glyph)} ${theme.fg("muted", row.text)}`]) as never;
|
|
267
|
+
}
|
|
268
|
+
const text = result.content
|
|
269
|
+
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
270
|
+
.map((c) => c.text)
|
|
271
|
+
.join("\n");
|
|
272
|
+
const lines = text.split("\n");
|
|
273
|
+
const shown = expanded ? lines : lines.slice(0, PREVIEW_LINES);
|
|
274
|
+
const out = shown.map((l) => theme.fg("toolOutput", l));
|
|
275
|
+
if (shown.length < lines.length) {
|
|
276
|
+
out.push(theme.fg("muted", `... (${lines.length - shown.length} more lines, ctrl+o to expand)`));
|
|
277
|
+
}
|
|
278
|
+
return toolComponent(out) as never;
|
|
279
|
+
},
|
|
280
|
+
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
281
|
+
const input = params as BashParams;
|
|
282
|
+
|
|
283
|
+
const sleepError = rejectBareSleep(input.command);
|
|
284
|
+
if (sleepError) throw new Error(sleepError);
|
|
285
|
+
|
|
286
|
+
const client = deps.getClient();
|
|
287
|
+
const managed = client !== null && (await client.ensureAvailable());
|
|
288
|
+
if (!managed || client === null) {
|
|
289
|
+
// Degraded mode: run locally like the built-in bash tool.
|
|
290
|
+
return executeLocal(input, signal, ctx, deps.clock ?? realClock);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const timeoutMs = resolveTimeoutMs(input.timeout);
|
|
294
|
+
let start;
|
|
295
|
+
try {
|
|
296
|
+
start = await client.start({
|
|
297
|
+
kind: "shell",
|
|
298
|
+
command: input.command,
|
|
299
|
+
cwd: ctx.cwd,
|
|
300
|
+
env: fullEnv(ctx, deps),
|
|
301
|
+
run_in_background: input.run_in_background === true,
|
|
302
|
+
timeout_ms: timeoutMs,
|
|
303
|
+
origin: { via: input.run_in_background === true ? "bash-bg" : "bash-fg" },
|
|
304
|
+
});
|
|
305
|
+
} catch {
|
|
306
|
+
// Manager request failed mid-session; degrade to local execution.
|
|
307
|
+
return executeLocal(input, signal, ctx, deps.clock ?? realClock);
|
|
308
|
+
}
|
|
309
|
+
deps.trackTask(start.task_id, { kind: "shell", command: input.command, cwd: ctx.cwd });
|
|
310
|
+
const outputPath = taskOutputPath(deps.home, deps.sessionId(), start.task_id);
|
|
311
|
+
|
|
312
|
+
if (input.run_in_background === true) {
|
|
313
|
+
deps.markNotifyOnExit(start.task_id);
|
|
314
|
+
return {
|
|
315
|
+
content: [
|
|
316
|
+
{ type: "text", text: formatBackgroundNotice(start.task_id, input.command, outputPath) },
|
|
317
|
+
],
|
|
318
|
+
details: { fullOutputPath: outputPath, backgrounded: true, task_id: start.task_id },
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Foreground: wait up to the budget, then move to background.
|
|
323
|
+
let waitResult;
|
|
324
|
+
try {
|
|
325
|
+
waitResult = await withAbort(client.wait(start.task_id, deps.config.foregroundBudgetMs), signal, () => {
|
|
326
|
+
client.stop(start.task_id, "tool").catch(() => {});
|
|
327
|
+
});
|
|
328
|
+
} catch (err) {
|
|
329
|
+
if ((err as Error).message === "aborted") {
|
|
330
|
+
throw new Error("Command aborted (background task stopped)");
|
|
331
|
+
}
|
|
332
|
+
// Lost contact with the manager while waiting; the task may still run.
|
|
333
|
+
deps.markNotifyOnExit(start.task_id);
|
|
334
|
+
return {
|
|
335
|
+
content: [
|
|
336
|
+
{
|
|
337
|
+
type: "text",
|
|
338
|
+
text:
|
|
339
|
+
`Lost contact with pi-famulus while waiting for task ${start.task_id}. ` +
|
|
340
|
+
`The command may still be running. Output: ${outputPath}. ` +
|
|
341
|
+
"Use task_list/task_output to check on it once the manager is back.",
|
|
342
|
+
},
|
|
343
|
+
],
|
|
344
|
+
details: { fullOutputPath: outputPath, backgrounded: true, task_id: start.task_id },
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (!waitResult.done) {
|
|
349
|
+
deps.markNotifyOnExit(start.task_id);
|
|
350
|
+
return {
|
|
351
|
+
content: [
|
|
352
|
+
{ type: "text", text: formatBackgroundNotice(start.task_id, input.command, outputPath) },
|
|
353
|
+
],
|
|
354
|
+
details: { fullOutputPath: outputPath, backgrounded: true, task_id: start.task_id },
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const collected = await collectOutput(client, start.task_id);
|
|
359
|
+
const { text, details } = formatFinishedOutput(collected, outputPath);
|
|
360
|
+
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
|
+
// No exit code: killed (timeout, stop, crash), unless the manager
|
|
365
|
+
// finished it as completed with its runner status unobservable.
|
|
366
|
+
if (exitCode === null && collected.status !== "completed") {
|
|
367
|
+
throw new Error(appendStatus(text, await killedStatus(client, start.task_id, input.timeout)));
|
|
368
|
+
}
|
|
369
|
+
return { content: [{ type: "text", text }], details };
|
|
370
|
+
},
|
|
371
|
+
};
|
|
372
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistent system-prompt section for background-task behavior.
|
|
3
|
+
*
|
|
4
|
+
* Returning `systemPrompt` from before_agent_start forces the whole prompt for
|
|
5
|
+
* that user-prompt run only (and clobbers other extensions). Wake turns started
|
|
6
|
+
* by sendMessage({ triggerTurn: true }) do not call before_agent_start again.
|
|
7
|
+
* A named section is diffed into the transcript and stays visible on those turns.
|
|
8
|
+
*/
|
|
9
|
+
export const BEHAVIOR_GUIDELINES_SECTION = "pi-famulus";
|
|
10
|
+
|
|
11
|
+
/** Child-only instructions injected through the contact_supervisor tool guideline. */
|
|
12
|
+
export const CHILD_BEHAVIOR_GUIDELINES = `## Child agent behavior (pi-famulus)
|
|
13
|
+
|
|
14
|
+
- Do not background work; bash runs to completion.
|
|
15
|
+
- When blocked, use contact_supervisor with need_decision; use progress_update for status.
|
|
16
|
+
- Use agent_message only to communicate with siblings in this same run.
|
|
17
|
+
- Your final message is your result.`;
|
|
18
|
+
|
|
19
|
+
export const BEHAVIOR_GUIDELINES = `## Background tasks and notifications (pi-famulus)
|
|
20
|
+
|
|
21
|
+
- Long-running bash commands are automatically moved to the background. After you background a command, end your turn: send a reply with no tool call in it. Saying you will wait while calling another tool does not end it. Do not poll with task_output/task_list, and never sleep to wait. Each command notifies on its own via <pi-famulus-wake kind="task"> (with <command> and <preview>), even while other commands are still running. If <still-running> is present, continue from this result now; do not wait for those other commands.
|
|
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
|
+
- Never fabricate or assume a background task's result before its notification arrives.
|
|
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
|
+
- 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
|
+
export interface GuidelinePromptOptions {
|
|
28
|
+
sections?: Record<string, string>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Mutate prompt sections in place. Does not replace the whole system prompt. */
|
|
32
|
+
export function applyBehaviorGuidelines(options: GuidelinePromptOptions): void {
|
|
33
|
+
if (!options.sections) options.sections = {};
|
|
34
|
+
options.sections[BEHAVIOR_GUIDELINES_SECTION] = BEHAVIOR_GUIDELINES;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** What a later wake turn sees: persisted sections, no before_agent_start. */
|
|
38
|
+
export function wakePromptFromSections(sections: Record<string, string>): string {
|
|
39
|
+
return Object.entries(sections)
|
|
40
|
+
.map(([name, content]) => `<${name}>\n${content}\n</${name}>`)
|
|
41
|
+
.join("\n");
|
|
42
|
+
}
|
package/src/clock.ts
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
export type ClockTimer = unknown;
|
|
2
|
+
|
|
3
|
+
/** The timer/time source used by extension services. */
|
|
4
|
+
export interface Clock {
|
|
5
|
+
now(): number;
|
|
6
|
+
setTimeout(callback: () => void, delayMs: number): ClockTimer;
|
|
7
|
+
clearTimeout(timer: ClockTimer): void;
|
|
8
|
+
setInterval(callback: () => void, intervalMs: number): ClockTimer;
|
|
9
|
+
clearInterval(timer: ClockTimer): void;
|
|
10
|
+
unref?(timer: ClockTimer): void;
|
|
11
|
+
sleep(delayMs: number): Promise<void>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const realClock: Clock = {
|
|
15
|
+
now: () => Date.now(),
|
|
16
|
+
setTimeout: (callback, delayMs) => globalThis.setTimeout(callback, delayMs),
|
|
17
|
+
clearTimeout: (timer) => globalThis.clearTimeout(timer as ReturnType<typeof setTimeout>),
|
|
18
|
+
setInterval: (callback, intervalMs) => globalThis.setInterval(callback, intervalMs),
|
|
19
|
+
clearInterval: (timer) => globalThis.clearInterval(timer as ReturnType<typeof setInterval>),
|
|
20
|
+
unref: (timer) => (timer as { unref?: () => void }).unref?.(),
|
|
21
|
+
sleep: (delayMs) => new Promise((resolve) => globalThis.setTimeout(resolve, delayMs)),
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
type ScheduledTimer = {
|
|
25
|
+
readonly id: number;
|
|
26
|
+
readonly order: number;
|
|
27
|
+
due: number;
|
|
28
|
+
readonly interval: number | null;
|
|
29
|
+
readonly callback: () => void;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** Deterministic scheduler. Advancing runs every due callback before returning. */
|
|
33
|
+
export class ManualClock implements Clock {
|
|
34
|
+
private currentTime: number;
|
|
35
|
+
private nextId = 1;
|
|
36
|
+
private readonly timers = new Map<number, ScheduledTimer>();
|
|
37
|
+
|
|
38
|
+
constructor(startAt = 0) {
|
|
39
|
+
this.currentTime = startAt;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
now(): number {
|
|
43
|
+
return this.currentTime;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
setTimeout(callback: () => void, delayMs: number): ClockTimer {
|
|
47
|
+
return this.schedule(callback, delayMs, null);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
clearTimeout(timer: ClockTimer): void {
|
|
51
|
+
this.cancel(timer);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
setInterval(callback: () => void, intervalMs: number): ClockTimer {
|
|
55
|
+
return this.schedule(callback, Math.max(1, intervalMs), Math.max(1, intervalMs));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
clearInterval(timer: ClockTimer): void {
|
|
59
|
+
this.cancel(timer);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
sleep(delayMs: number): Promise<void> {
|
|
63
|
+
return new Promise((resolve) => this.setTimeout(resolve, delayMs));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
advanceBy(deltaMs: number): void {
|
|
67
|
+
if (!Number.isFinite(deltaMs) || deltaMs < 0) {
|
|
68
|
+
throw new RangeError("ManualClock.advanceBy requires a finite non-negative duration");
|
|
69
|
+
}
|
|
70
|
+
this.advanceTo(this.currentTime + deltaMs);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
advanceTo(targetTime: number): void {
|
|
74
|
+
if (!Number.isFinite(targetTime) || targetTime < this.currentTime) {
|
|
75
|
+
throw new RangeError("ManualClock.advanceTo cannot move backwards or to a non-finite time");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
while (true) {
|
|
79
|
+
const next = [...this.timers.values()]
|
|
80
|
+
.filter((timer) => timer.due <= targetTime)
|
|
81
|
+
.sort((a, b) => a.due - b.due || a.order - b.order)[0];
|
|
82
|
+
if (!next) break;
|
|
83
|
+
|
|
84
|
+
// It may have been cancelled by an earlier callback since selection.
|
|
85
|
+
if (!this.timers.has(next.id)) continue;
|
|
86
|
+
this.currentTime = next.due;
|
|
87
|
+
if (next.interval === null) this.timers.delete(next.id);
|
|
88
|
+
else next.due += next.interval;
|
|
89
|
+
next.callback();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
this.currentTime = targetTime;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private schedule(callback: () => void, delayMs: number, interval: number | null): ClockTimer {
|
|
96
|
+
const delay = Number.isFinite(delayMs) ? Math.max(0, delayMs) : 0;
|
|
97
|
+
const id = this.nextId++;
|
|
98
|
+
this.timers.set(id, {
|
|
99
|
+
id,
|
|
100
|
+
order: id,
|
|
101
|
+
due: this.currentTime + delay,
|
|
102
|
+
interval,
|
|
103
|
+
callback,
|
|
104
|
+
});
|
|
105
|
+
return id;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private cancel(timer: ClockTimer): void {
|
|
109
|
+
if (typeof timer === "number") this.timers.delete(timer);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Owns timer handles for one lifecycle scope and cancels them together. */
|
|
114
|
+
export class TimerScope {
|
|
115
|
+
private readonly owned = new Map<ClockTimer, () => void>();
|
|
116
|
+
private disposed = false;
|
|
117
|
+
|
|
118
|
+
constructor(private readonly clock: Clock) {}
|
|
119
|
+
|
|
120
|
+
get size(): number {
|
|
121
|
+
return this.owned.size;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
setTimeout(callback: () => void, delayMs: number): ClockTimer {
|
|
125
|
+
this.assertActive();
|
|
126
|
+
let timer: ClockTimer;
|
|
127
|
+
timer = this.clock.setTimeout(() => {
|
|
128
|
+
this.owned.delete(timer);
|
|
129
|
+
if (!this.disposed) callback();
|
|
130
|
+
}, delayMs);
|
|
131
|
+
this.owned.set(timer, () => this.clock.clearTimeout(timer));
|
|
132
|
+
return timer;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
clearTimeout(timer: ClockTimer): void {
|
|
136
|
+
this.clear(timer);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
setInterval(callback: () => void, intervalMs: number): ClockTimer {
|
|
140
|
+
this.assertActive();
|
|
141
|
+
const timer = this.clock.setInterval(() => {
|
|
142
|
+
if (!this.disposed) callback();
|
|
143
|
+
}, intervalMs);
|
|
144
|
+
this.owned.set(timer, () => this.clock.clearInterval(timer));
|
|
145
|
+
return timer;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
clearInterval(timer: ClockTimer): void {
|
|
149
|
+
this.clear(timer);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
dispose(): void {
|
|
153
|
+
if (this.disposed) return;
|
|
154
|
+
this.disposed = true;
|
|
155
|
+
for (const clear of this.owned.values()) clear();
|
|
156
|
+
this.owned.clear();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private clear(timer: ClockTimer): void {
|
|
160
|
+
const cancel = this.owned.get(timer);
|
|
161
|
+
if (!cancel) return;
|
|
162
|
+
cancel();
|
|
163
|
+
this.owned.delete(timer);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private assertActive(): void {
|
|
167
|
+
if (this.disposed) throw new Error("TimerScope is disposed");
|
|
168
|
+
}
|
|
169
|
+
}
|