@tshu-w/pi-agents 0.0.0-stage → 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 +661 -0
- package/README.md +81 -2
- package/bin/daemon.mjs +204 -0
- package/bin/host.mjs +170 -0
- package/bin/pd.mjs +139 -0
- package/docs/PROGRAM.md +310 -0
- package/docs/SPEC.md +427 -0
- package/docs/WORKBENCH.md +165 -0
- package/package.json +62 -4
- package/src/agents/agents.ts +882 -0
- package/src/agents/registry.ts +257 -0
- package/src/agents/scheduler.ts +204 -0
- package/src/agents/search.ts +131 -0
- package/src/agents/tool.ts +205 -0
- package/src/daemon/client.mjs +84 -0
- package/src/daemon/router.mjs +103 -0
- package/src/daemon/sessions.mjs +129 -0
- package/src/daemon/version.mjs +19 -0
- package/src/host/client.mjs +139 -0
- package/src/host/extension.ts +74 -0
- package/src/host/frames.mjs +34 -0
- package/src/host/keys.mjs +19 -0
- package/src/index.ts +208 -0
- package/src/output.ts +56 -0
- package/src/params.ts +4 -0
- package/src/pi.mjs +16 -0
- package/src/programs/codemode.ts +74 -0
- package/src/programs/execute.ts +131 -0
- package/src/programs/host-patches.ts +128 -0
- package/src/programs/loadout.ts +99 -0
- package/src/programs/programs.ts +284 -0
- package/src/programs/sandbox.ts +90 -0
- package/src/programs/scope.ts +94 -0
- package/src/programs/tool.ts +198 -0
- package/src/render-call.ts +90 -0
- package/src/roots/discovery.mjs +113 -0
- package/src/roots/guard.mjs +227 -0
- package/src/roots/lock-extension.mjs +167 -0
- package/src/roots/locks.mjs +167 -0
- package/src/roots/paths.mjs +66 -0
- package/src/roots/report.ts +156 -0
- package/src/roots/runtime.ts +174 -0
- package/src/roots/snapshot.mjs +46 -0
- package/src/roots/socket-recovery.mjs +21 -0
- package/src/roots/transport.mjs +341 -0
- package/src/ui/panel.ts +96 -0
- package/src/ui/viewer.ts +445 -0
- package/src/workbench.mjs +587 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import type { CodemodeTool } from "@earendil-works/pi-codemode";
|
|
3
|
+
|
|
4
|
+
export interface AgentOptions {
|
|
5
|
+
name?: string;
|
|
6
|
+
cwd?: string;
|
|
7
|
+
context?: "fresh" | "fork";
|
|
8
|
+
model?: string;
|
|
9
|
+
thinkingLevel?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface SendOptions {
|
|
13
|
+
deliverAs?: "followUp" | "steer" | "write";
|
|
14
|
+
schema?: unknown;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** What `agent()` and its handles do on the host. */
|
|
18
|
+
export interface ProgramAgentHost {
|
|
19
|
+
create(id: string, name: string, options: AgentOptions): Promise<void>;
|
|
20
|
+
send(id: string, message: string, options: SendOptions): Promise<unknown>;
|
|
21
|
+
abort(id: string): Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// One line, so line numbers in errors match the script as written. The ID is
|
|
25
|
+
// a UUIDv4 made in the script because `agent()` returns its handle before the
|
|
26
|
+
// host has created the Agent. QuickJS seeds Math.random from the clock, so
|
|
27
|
+
// the random bits come from the host; a counter, spread by an odd multiplier,
|
|
28
|
+
// gives each Agent of the Program a distinct first group.
|
|
29
|
+
function agentPrefix(): string {
|
|
30
|
+
const bytes = randomBytes(16);
|
|
31
|
+
bytes[6] = 0x40 | (bytes[6]! & 0x0f);
|
|
32
|
+
bytes[8] = 0x80 | (bytes[8]! & 0x3f);
|
|
33
|
+
const hex = bytes.toString("hex");
|
|
34
|
+
const head = bytes.readUInt32BE(0);
|
|
35
|
+
const rest = `${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
36
|
+
return [
|
|
37
|
+
"const agent = ((create, send, abort) => {",
|
|
38
|
+
"const names = new Set(); let count = 0; let serial = 0;",
|
|
39
|
+
`const newId = () => \`\${((${head} + ++serial * 2654435761) % 2 ** 32).toString(16).padStart(8, '0')}-${rest}\`;`,
|
|
40
|
+
"return (options = {}) => {",
|
|
41
|
+
"let name = options.name;",
|
|
42
|
+
"if (name === undefined) { do name = `agent-${++count}`; while (names.has(name)); }",
|
|
43
|
+
"names.add(name);",
|
|
44
|
+
"const id = newId();",
|
|
45
|
+
"create(id, name, options).catch(() => {});",
|
|
46
|
+
"return Object.freeze({ id, name, send: (message, sendOptions) => send(id, message, sendOptions), abort: () => abort(id) });",
|
|
47
|
+
"};",
|
|
48
|
+
"})(__agent.create, __agent.send, __agent.abort);",
|
|
49
|
+
].join(" ");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function withAgentPrefix(code: string): string {
|
|
53
|
+
return `${agentPrefix()}${code}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Globals behind `agent()`. A handle's calls wait for its Agent to be created. */
|
|
57
|
+
export function agentGlobals(host: ProgramAgentHost): CodemodeTool[] {
|
|
58
|
+
const created = new Map<string, Promise<void>>();
|
|
59
|
+
const ready = (id: string) => created.get(id) ?? Promise.reject(new Error(`Unknown Agent ${id}`));
|
|
60
|
+
return [
|
|
61
|
+
{
|
|
62
|
+
name: "__agent.create",
|
|
63
|
+
spread: true,
|
|
64
|
+
execute: (args) => {
|
|
65
|
+
const [id, name, options] = args as [string, string, AgentOptions];
|
|
66
|
+
const creation = host.create(id, name, options ?? {});
|
|
67
|
+
created.set(id, creation);
|
|
68
|
+
return creation;
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
name: "__agent.send",
|
|
73
|
+
spread: true,
|
|
74
|
+
execute: async (args) => {
|
|
75
|
+
const [id, message, options] = args as [string, string, SendOptions | undefined];
|
|
76
|
+
await ready(id);
|
|
77
|
+
return host.send(id, message, options ?? {});
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
name: "__agent.abort",
|
|
82
|
+
spread: true,
|
|
83
|
+
execute: async (args) => {
|
|
84
|
+
const [id] = args as [string];
|
|
85
|
+
await ready(id);
|
|
86
|
+
await host.abort(id);
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
];
|
|
90
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Agents, settlesBy, SUBMIT_RESULT_TOOL_NAME, THINKING_LEVELS, type Limits } from "../agents/agents.ts";
|
|
3
|
+
import { label, messageText, nodes, treeChanged, visibleIds, type AgentNode } from "../agents/registry.ts";
|
|
4
|
+
import type { AgentOptions, ProgramAgentHost, SendOptions } from "./sandbox.ts";
|
|
5
|
+
|
|
6
|
+
const CLEANUP_TIMEOUT_MS = 15_000;
|
|
7
|
+
const DELIVERIES = ["followUp", "steer", "write"];
|
|
8
|
+
|
|
9
|
+
export interface ProgramScope {
|
|
10
|
+
host: ProgramAgentHost;
|
|
11
|
+
/** Aborts unfinished inputs and takes the Agents offline; work not stopped within the timeout is abandoned, and this throws. */
|
|
12
|
+
close(): Promise<void>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The Agents a Program creates with `agent()`. They count toward the caller's tree, each sees
|
|
17
|
+
* only itself and the Agents under it, and they are never persisted: when the Program ends they
|
|
18
|
+
* go offline for good.
|
|
19
|
+
*/
|
|
20
|
+
export function programScope(
|
|
21
|
+
id: string,
|
|
22
|
+
caller: AgentNode,
|
|
23
|
+
pi: ExtensionAPI,
|
|
24
|
+
ctx: ExtensionContext,
|
|
25
|
+
options: { agentDir: string; limits: Limits; extensions: string[]; background: boolean },
|
|
26
|
+
): ProgramScope {
|
|
27
|
+
const self = { id, rootId: caller.rootId, scopeId: id, ownerId: caller.id, name: () => "program" };
|
|
28
|
+
const agents = new Agents(pi, self, options.agentDir, options.limits, options.extensions, true);
|
|
29
|
+
agents.setContext(ctx);
|
|
30
|
+
nodes.set(id, {
|
|
31
|
+
...self,
|
|
32
|
+
program: true,
|
|
33
|
+
background: options.background,
|
|
34
|
+
createdAt: new Date().toISOString(),
|
|
35
|
+
agents,
|
|
36
|
+
cwd: () => ctx.cwd,
|
|
37
|
+
busy: () => true,
|
|
38
|
+
sessionFile: () => undefined,
|
|
39
|
+
receive: () => { throw new Error("A Program does not receive messages."); },
|
|
40
|
+
});
|
|
41
|
+
treeChanged();
|
|
42
|
+
const from = { id, name: self.name() };
|
|
43
|
+
const host: ProgramAgentHost = {
|
|
44
|
+
create: async (agentId, name, agentOptions: AgentOptions) => {
|
|
45
|
+
const { cwd, context, model, thinkingLevel } = agentOptions;
|
|
46
|
+
if (typeof name !== "string" || !name.trim()) throw new Error("agent() name must be a non-empty string");
|
|
47
|
+
if (cwd !== undefined && typeof cwd !== "string") throw new Error("agent() cwd must be a string");
|
|
48
|
+
if (context !== undefined && context !== "fresh" && context !== "fork") throw new Error('agent() context must be "fresh" or "fork"');
|
|
49
|
+
if (model !== undefined && typeof model !== "string") throw new Error("agent() model must be provider/modelId");
|
|
50
|
+
if (thinkingLevel !== undefined && !(THINKING_LEVELS as readonly unknown[]).includes(thinkingLevel)) {
|
|
51
|
+
throw new Error(`agent() thinkingLevel must be one of ${THINKING_LEVELS.join(", ")}`);
|
|
52
|
+
}
|
|
53
|
+
agents.create({ name, cwd, context, model, thinkingLevel: thinkingLevel as (typeof THINKING_LEVELS)[number] | undefined }, agentId);
|
|
54
|
+
},
|
|
55
|
+
send: async (agentId, message, sendOptions: SendOptions) => {
|
|
56
|
+
const delivery = sendOptions.deliverAs ?? "followUp";
|
|
57
|
+
const { schema } = sendOptions;
|
|
58
|
+
if (typeof message !== "string" || !message.trim()) throw new Error("send() requires a non-empty message");
|
|
59
|
+
if (!DELIVERIES.includes(delivery)) throw new Error('send() deliverAs must be "followUp", "steer", or "write"');
|
|
60
|
+
if (schema !== undefined) {
|
|
61
|
+
if (delivery !== "followUp") throw new Error('send() accepts schema only with deliverAs "followUp"');
|
|
62
|
+
if (typeof schema !== "boolean" && (typeof schema !== "object" || schema === null || Array.isArray(schema))) {
|
|
63
|
+
throw new Error("send() schema must be a JSON Schema object or boolean");
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const agent = agents.ownedTarget(agentId).record;
|
|
67
|
+
const body = schema === undefined ? message : `${message}\n\nWhen done, call \`${SUBMIT_RESULT_TOOL_NAME}\` with a \`value\` that matches this JSON Schema:\n${JSON.stringify(schema)}`;
|
|
68
|
+
const text = messageText(from, true, delivery, body, visibleIds(agentId, false));
|
|
69
|
+
if (delivery === "write") {
|
|
70
|
+
agents.accept(agentId, text, "write", { fromOwner: true, notification: false });
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
const record = await agents.request(agentId, text, delivery, schema);
|
|
74
|
+
if (record.state === "completed") return schema === undefined ? record.result : record.value;
|
|
75
|
+
throw new Error(record.state === "failed" ? `Agent ${label(agent, agents.ids())} failed: ${record.result}` : `Agent ${label(agent, agents.ids())} was aborted.`);
|
|
76
|
+
},
|
|
77
|
+
abort: async (agentId) => {
|
|
78
|
+
await agents.abort(agents.ownedTarget(agentId));
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
return {
|
|
82
|
+
host,
|
|
83
|
+
close: async () => {
|
|
84
|
+
try {
|
|
85
|
+
if (!await settlesBy(agents.shutdown(), Date.now() + CLEANUP_TIMEOUT_MS)) {
|
|
86
|
+
throw new Error(`The Program's Agents did not stop within the timeout and may still be running.`);
|
|
87
|
+
}
|
|
88
|
+
} finally {
|
|
89
|
+
nodes.delete(id);
|
|
90
|
+
treeChanged();
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
3
|
+
import { Type } from "typebox";
|
|
4
|
+
import { SUBMIT_RESULT_TOOL_NAME, type Limits } from "../agents/agents.ts";
|
|
5
|
+
import { nodes, type AgentNode } from "../agents/registry.ts";
|
|
6
|
+
import { rejectFields } from "../params.ts";
|
|
7
|
+
import { programScope } from "./scope.ts";
|
|
8
|
+
import type { PiCodemode } from "./codemode.ts";
|
|
9
|
+
import { executeProgram, PROGRAM_TOOL_NAME, type ProgramRunOptions } from "./execute.ts";
|
|
10
|
+
import { programDescription, programLoadout, programRenderers } from "./loadout.ts";
|
|
11
|
+
import { randomUUID } from "node:crypto";
|
|
12
|
+
import { CLEANUP_ERROR, programListLine, Programs, renderProgramWait } from "./programs.ts";
|
|
13
|
+
import { agentGlobals, withAgentPrefix } from "./sandbox.ts";
|
|
14
|
+
|
|
15
|
+
const PROGRAM_DESCRIPTION = `Run JavaScript that composes tool calls and Agents; only its output and return value reach the caller. \`run\` runs a Program in the foreground, or in the background with \`background\`; \`wait\` waits for background Programs and returns their results; \`list\` lists background Programs; \`stop\` stops a running Program. When a background Program ends while its caller is not waiting for it, the caller receives a notification; \`wait\` returns the result.
|
|
16
|
+
|
|
17
|
+
Program code is the body of an async function. It calls the caller's tools through \`tools.*\`, which excludes \`program\`, and creates Agents through \`agent()\`. These Agents belong to the Program, are isolated from other Agents, and go offline when it ends.
|
|
18
|
+
|
|
19
|
+
\`program\` replaces the \`codemode\` tool; wherever \`codemode\` is mentioned, use \`program\`.`;
|
|
20
|
+
|
|
21
|
+
const AGENT_API = `Agent API:
|
|
22
|
+
\`\`\`ts
|
|
23
|
+
type JsonSchema = boolean | Record<string, unknown>;
|
|
24
|
+
interface AgentOptions {
|
|
25
|
+
/** Default: generated, unique within the Program. */
|
|
26
|
+
name?: string;
|
|
27
|
+
/** Default: the caller's cwd. */
|
|
28
|
+
cwd?: string;
|
|
29
|
+
/** \`fresh\` starts without the caller's conversation; \`fork\` snapshots it. Default: \`fresh\`. */
|
|
30
|
+
context?: "fresh" | "fork";
|
|
31
|
+
/** provider/modelId. Default: the caller's model. */
|
|
32
|
+
model?: string;
|
|
33
|
+
/** Default: the caller's level. */
|
|
34
|
+
thinkingLevel?: string;
|
|
35
|
+
}
|
|
36
|
+
interface SendOptions {
|
|
37
|
+
/** Default: \`followUp\`. */
|
|
38
|
+
deliverAs?: "followUp" | "steer" | "write";
|
|
39
|
+
/** \`followUp\` only. The Agent must submit a value matching it. */
|
|
40
|
+
schema?: JsonSchema;
|
|
41
|
+
}
|
|
42
|
+
interface AgentHandle {
|
|
43
|
+
readonly id: string;
|
|
44
|
+
readonly name: string;
|
|
45
|
+
/** Resolves with the answer, or with the submitted value when \`schema\` is set; a write resolves with undefined once accepted. Throws if the input fails or is aborted. */
|
|
46
|
+
send(message: string, options?: SendOptions): Promise<unknown>;
|
|
47
|
+
/** Aborts the Agent's current turn and queued inputs; the Agent stays usable. */
|
|
48
|
+
abort(): Promise<void>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Creates an idle Agent that belongs to the Program. */
|
|
52
|
+
declare function agent(options?: AgentOptions): AgentHandle;
|
|
53
|
+
\`\`\``;
|
|
54
|
+
|
|
55
|
+
/** Registers the `program` tool and `submit_result`, which Agents of Programs use. */
|
|
56
|
+
export function registerProgramTools(
|
|
57
|
+
pi: ExtensionAPI,
|
|
58
|
+
{ agentDir, settings, piCodemode, requireNode, programs: getPrograms }: {
|
|
59
|
+
agentDir: string;
|
|
60
|
+
settings: Limits & { extensions: string[] };
|
|
61
|
+
piCodemode: PiCodemode;
|
|
62
|
+
requireNode: (current: ExtensionContext) => AgentNode;
|
|
63
|
+
programs: () => Programs | undefined;
|
|
64
|
+
},
|
|
65
|
+
): void {
|
|
66
|
+
/** Runs a Program whose Agents go offline when it ends. */
|
|
67
|
+
const runProgram = async (
|
|
68
|
+
id: string,
|
|
69
|
+
caller: AgentNode,
|
|
70
|
+
toolCallId: string,
|
|
71
|
+
code: string,
|
|
72
|
+
signal: AbortSignal,
|
|
73
|
+
current: Parameters<typeof executeProgram>[3],
|
|
74
|
+
options: Pick<ProgramRunOptions, "timeout" | "onUpdate" | "background" | "abort">,
|
|
75
|
+
) => {
|
|
76
|
+
const scope = programScope(id, caller, pi, current, { agentDir, limits: settings, extensions: settings.extensions, background: options.background === true });
|
|
77
|
+
let run: Awaited<ReturnType<typeof executeProgram>> | undefined;
|
|
78
|
+
try {
|
|
79
|
+
run = await executeProgram(toolCallId, code, signal, current, {
|
|
80
|
+
...options,
|
|
81
|
+
globals: agentGlobals(scope.host),
|
|
82
|
+
prepare: withAgentPrefix,
|
|
83
|
+
appendEntry: (customType, data) => pi.appendEntry(customType, data),
|
|
84
|
+
getToolNamespace: (name) => pi.getAllTools().find((tool) => tool.name === name)?.namespace,
|
|
85
|
+
});
|
|
86
|
+
} finally {
|
|
87
|
+
// A cleanup failure ends the result, which keeps the Program's output and calls.
|
|
88
|
+
await scope.close().catch(() => {
|
|
89
|
+
if (!run) return;
|
|
90
|
+
run.result.content.push({ type: "text", text: CLEANUP_ERROR });
|
|
91
|
+
run.result.isError = true;
|
|
92
|
+
run.abandoned = true;
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
return run;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
pi.registerTool({
|
|
99
|
+
name: SUBMIT_RESULT_TOOL_NAME,
|
|
100
|
+
label: "Submit Result",
|
|
101
|
+
description: "Submit the result that the current input asks for. `value` must match the JSON Schema given in the input; an invalid value is rejected with the reasons.",
|
|
102
|
+
promptSnippet: "Submit the result that the current input asks for",
|
|
103
|
+
exposure: "model-only",
|
|
104
|
+
defaultActive: false,
|
|
105
|
+
parameters: Type.Object({
|
|
106
|
+
value: Type.Unknown({ description: "The result, matching the JSON Schema given in the input." }),
|
|
107
|
+
}),
|
|
108
|
+
async execute(_toolCallId, params, _signal, _onUpdate, current) {
|
|
109
|
+
const self = requireNode(current);
|
|
110
|
+
const owner = self.ownerId === undefined ? undefined : nodes.get(self.ownerId);
|
|
111
|
+
if (!owner) throw new Error("No current input asks for a result.");
|
|
112
|
+
owner.agents.submit(self.id, params.value);
|
|
113
|
+
return { content: [{ type: "text", text: "Result submitted." }], details: {} };
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
pi.registerTool({
|
|
118
|
+
name: PROGRAM_TOOL_NAME,
|
|
119
|
+
label: "Program",
|
|
120
|
+
// Programs must not start Programs.
|
|
121
|
+
exposure: "model-only",
|
|
122
|
+
defaultActive: false,
|
|
123
|
+
prepareLoadout: programLoadout(pi, piCodemode, PROGRAM_DESCRIPTION, AGENT_API),
|
|
124
|
+
...programRenderers(piCodemode),
|
|
125
|
+
description: programDescription(piCodemode, PROGRAM_DESCRIPTION, AGENT_API),
|
|
126
|
+
promptSnippet: "Run JavaScript that composes tool calls and Agents",
|
|
127
|
+
promptGuidelines: [
|
|
128
|
+
"Use `program(action='run', code=...)` to call tools or Agents several times without a model turn between the calls, for example to read many files, filter large tool output, fan out Agents, or loop until a condition holds.",
|
|
129
|
+
"Use `Promise.allSettled` for independent calls. Return only what the caller needs; use `text()` for progress worth reading.",
|
|
130
|
+
"Use `background=true` for long Programs, and `program(action='wait', ...)` when the next step depends on their results.",
|
|
131
|
+
],
|
|
132
|
+
parameters: Type.Object({
|
|
133
|
+
action: StringEnum(["run", "wait", "list", "stop"] as const, { description: "Operation and applicable parameters: `run(code, background?, timeout?)`, `wait(target?, timeout?)`, `list(limit?, offset?)`, or `stop(target)`." }),
|
|
134
|
+
code: Type.Optional(Type.String({ description: "JavaScript async-function body for `run`." })),
|
|
135
|
+
background: Type.Optional(Type.Boolean({ description: "Whether `run` returns the Program ID at once (default: false)." })),
|
|
136
|
+
timeout: Type.Optional(Type.Number({ exclusiveMinimum: 0, description: "Maximum seconds. For `run` (default: none), expiry stops the Program. For `wait` (default: 30, min: 10, max: 3600), expiry does not stop Programs." })),
|
|
137
|
+
target: Type.Optional(Type.Union([Type.String(), Type.Array(Type.String())], { description: "Program ID or unique ID prefix for `wait` or `stop`. `wait` also accepts an array. When omitted for `wait`, selects running Programs and ended Programs whose result has not been returned." })),
|
|
138
|
+
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 200, description: "Maximum Programs returned by `list` (default: 20, max: 200)." })),
|
|
139
|
+
offset: Type.Optional(Type.Integer({ minimum: 0, description: "Number of Programs to skip for `list` (default: 0)." })),
|
|
140
|
+
}),
|
|
141
|
+
async execute(toolCallId, params, signal, onUpdate, current) {
|
|
142
|
+
const self = requireNode(current);
|
|
143
|
+
const programs = getPrograms();
|
|
144
|
+
if (!programs) throw new Error("pi-agents is not initialized");
|
|
145
|
+
const targets = params.target === undefined ? undefined : Array.isArray(params.target) ? params.target : [params.target];
|
|
146
|
+
switch (params.action) {
|
|
147
|
+
case "run": {
|
|
148
|
+
rejectFields(params, "run", ["target", "limit", "offset"]);
|
|
149
|
+
if (!params.code?.trim()) throw new Error("run requires code");
|
|
150
|
+
const code = params.code;
|
|
151
|
+
if (params.background) {
|
|
152
|
+
const record = programs.start((id, programSignal, abort) => runProgram(id, self, toolCallId, code, programSignal, current, { timeout: params.timeout, background: true, abort }));
|
|
153
|
+
return { content: [{ type: "text", text: `Program ${programs.label(record)} started.` }], details: { id: record.id } };
|
|
154
|
+
}
|
|
155
|
+
const { result } = await self.agents.whileSuspended(() => runProgram(randomUUID(), self, toolCallId, code, signal ?? new AbortController().signal, current, {
|
|
156
|
+
timeout: params.timeout,
|
|
157
|
+
onUpdate,
|
|
158
|
+
}), signal);
|
|
159
|
+
return result;
|
|
160
|
+
}
|
|
161
|
+
case "wait": {
|
|
162
|
+
rejectFields(params, "wait", ["code", "background", "limit", "offset"]);
|
|
163
|
+
const timeout = params.timeout ?? 30;
|
|
164
|
+
if (timeout < 10 || timeout > 3600) throw new Error("wait timeout must be between 10 and 3600 seconds");
|
|
165
|
+
const selected = programs.select(targets);
|
|
166
|
+
onUpdate?.({ content: [], details: undefined });
|
|
167
|
+
const waitFor = () => programs!.wait(selected, timeout, signal);
|
|
168
|
+
const outcome = programs.running(selected) ? await self.agents.whileSuspended(waitFor, signal, true) : await waitFor();
|
|
169
|
+
const rendered = renderProgramWait(outcome, programs.ids());
|
|
170
|
+
programs.markReturned(rendered.returned);
|
|
171
|
+
return { content: rendered.content, details: { running: outcome.running.map((record) => record.id) } };
|
|
172
|
+
}
|
|
173
|
+
case "list": {
|
|
174
|
+
rejectFields(params, "list", ["code", "background", "timeout", "target"]);
|
|
175
|
+
const offset = params.offset ?? 0;
|
|
176
|
+
const limit = params.limit ?? 20;
|
|
177
|
+
const records = programs.list();
|
|
178
|
+
if (records.length === 0) return { content: [{ type: "text", text: "No Programs." }], details: { total: 0 } };
|
|
179
|
+
const page = records.slice(offset, offset + limit);
|
|
180
|
+
const lines = page.map((record) => programListLine(record, programs!.ids()));
|
|
181
|
+
const remaining = records.length - offset - page.length;
|
|
182
|
+
if (remaining > 0) lines.push(`[${remaining} more results. Use offset=${offset + page.length} to continue.]`);
|
|
183
|
+
return { content: [{ type: "text", text: lines.join("\n") }], details: { total: records.length } };
|
|
184
|
+
}
|
|
185
|
+
case "stop": {
|
|
186
|
+
rejectFields(params, "stop", ["code", "background", "timeout", "limit", "offset"]);
|
|
187
|
+
if (targets?.length !== 1) throw new Error("stop requires one target");
|
|
188
|
+
const program = programs.target(targets[0]!);
|
|
189
|
+
const stopped = await programs.stop(program);
|
|
190
|
+
return {
|
|
191
|
+
content: [{ type: "text", text: `Program ${programs.label(program.record)} ${stopped ? "stopped" : "has already ended"}.` }],
|
|
192
|
+
details: { id: program.record.id, stopped },
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { keyHint } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
3
|
+
|
|
4
|
+
type ToolCallTheme = {
|
|
5
|
+
bold(text: string): string;
|
|
6
|
+
fg(color: "toolTitle" | "text", text: string): string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
/** A call in function-call form, like `agent(action="wait", target="w1")`, as in pi-control. */
|
|
10
|
+
export function formatToolCall(name: string, args: unknown, theme: ToolCallTheme): string {
|
|
11
|
+
const entries = Object.entries((args ?? {}) as Record<string, unknown>).filter(([, value]) => value !== undefined);
|
|
12
|
+
const fields = entries.map(([key, value]) => `${key}=${JSON.stringify(value) ?? String(value)}`).join(", ");
|
|
13
|
+
return `${theme.fg("toolTitle", theme.bold(name))}${theme.fg("text", `(${fields})`)}`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function renderToolCall(name: string, args: unknown, theme: ToolCallTheme, lastComponent?: unknown): Text {
|
|
17
|
+
const component = lastComponent instanceof Text ? lastComponent : new Text("", 0, 0);
|
|
18
|
+
component.setText(formatToolCall(name, args, theme));
|
|
19
|
+
return component;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const RESULT_PREVIEW_LINES = 10;
|
|
23
|
+
|
|
24
|
+
/** The notice below a collapsed preview: the number of hidden lines and the key that expands it. */
|
|
25
|
+
export function moreLines(hidden: number, theme: { fg(color: "muted", text: string): string }): string {
|
|
26
|
+
return `${theme.fg("muted", `... (${hidden} more lines,`)} ${keyHint("app.tools.expand", "to expand")}${theme.fg("muted", ")")}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Per-row state of a timed call: when it started executing and when its result arrived. */
|
|
30
|
+
export interface DurationState {
|
|
31
|
+
startedAt?: number;
|
|
32
|
+
endedAt?: number;
|
|
33
|
+
interval?: ReturnType<typeof setInterval>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function startDuration(state: DurationState, enabled: boolean, executionStarted: boolean): void {
|
|
37
|
+
if (enabled && executionStarted && state.startedAt === undefined) state.startedAt = Date.now();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function formatDuration(ms: number): string {
|
|
41
|
+
const seconds = ms / 1000;
|
|
42
|
+
if (seconds < 60) return `${seconds.toFixed(1)}s`;
|
|
43
|
+
const totalSeconds = Math.floor(seconds);
|
|
44
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
45
|
+
if (minutes < 60) return `${minutes}m ${totalSeconds % 60}s`;
|
|
46
|
+
return `${Math.floor(minutes / 60)}h ${minutes % 60}m ${totalSeconds % 60}s`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** "Elapsed …" while the call runs, refreshed every second, then "Took …" from Pi's recorded `durationMs` when present. */
|
|
50
|
+
function updateDuration(state: DurationState, isPartial: boolean, durationMs: number | undefined, invalidate: () => void): string | undefined {
|
|
51
|
+
if (state.startedAt === undefined) return undefined;
|
|
52
|
+
if (isPartial && !state.interval) state.interval = setInterval(invalidate, 1000);
|
|
53
|
+
if (!isPartial) {
|
|
54
|
+
state.endedAt ??= Date.now();
|
|
55
|
+
clearInterval(state.interval);
|
|
56
|
+
state.interval = undefined;
|
|
57
|
+
}
|
|
58
|
+
const ms = !isPartial && durationMs !== undefined ? durationMs : (state.endedAt ?? Date.now()) - state.startedAt;
|
|
59
|
+
return `${isPartial ? "Elapsed" : "Took"} ${formatDuration(ms)}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface ResultTheme {
|
|
63
|
+
fg(color: "toolOutput" | "muted", text: string): string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The text output like Pi's default result, with the duration of a timed call below it.
|
|
68
|
+
* Like Pi's `bash` result, a blank line precedes the output and the duration.
|
|
69
|
+
*/
|
|
70
|
+
export function renderTextResult(
|
|
71
|
+
result: { content: Array<{ type: string; text?: string }> },
|
|
72
|
+
options: { expanded: boolean; isPartial: boolean },
|
|
73
|
+
theme: ResultTheme,
|
|
74
|
+
context: { state: unknown; invalidate: () => void; lastComponent?: unknown; durationMs?: number },
|
|
75
|
+
): Text {
|
|
76
|
+
const output = result.content.flatMap((part) => part.type === "text" && part.text ? [part.text] : []).join("\n").trim();
|
|
77
|
+
const lines = output ? output.split("\n") : [];
|
|
78
|
+
const shown = options.expanded ? lines : lines.slice(0, RESULT_PREVIEW_LINES);
|
|
79
|
+
const sections: string[] = [];
|
|
80
|
+
if (shown.length > 0) {
|
|
81
|
+
let text = shown.map((line) => theme.fg("toolOutput", line)).join("\n");
|
|
82
|
+
if (shown.length < lines.length) text += `\n${moreLines(lines.length - shown.length, theme)}`;
|
|
83
|
+
sections.push(text);
|
|
84
|
+
}
|
|
85
|
+
const duration = updateDuration(context.state as DurationState, options.isPartial, context.durationMs, context.invalidate);
|
|
86
|
+
if (duration) sections.push(theme.fg("muted", duration));
|
|
87
|
+
const component = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
|
|
88
|
+
component.setText(sections.map((section) => `\n${section}`).join("\n"));
|
|
89
|
+
return component;
|
|
90
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { createReadStream } from 'node:fs';
|
|
2
|
+
import { readdir, stat } from 'node:fs/promises';
|
|
3
|
+
import { createInterface } from 'node:readline';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
const cache = new Map();
|
|
7
|
+
|
|
8
|
+
/** The first line of a message's text, for the workbench row. */
|
|
9
|
+
function firstLine(content) {
|
|
10
|
+
const text = typeof content === 'string' ? content
|
|
11
|
+
: Array.isArray(content) ? content.filter(block => block?.type === 'text').map(block => block.text).join('\n') : '';
|
|
12
|
+
const line = text.trim().split('\n')[0];
|
|
13
|
+
return line.length > 200 ? `${line.slice(0, 199)}…` : line;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function isOwnedSession(entries) {
|
|
17
|
+
return entries.some(entry => entry.type === 'custom' && entry.customType === 'pi-agents-tree'
|
|
18
|
+
&& typeof entry.data?.rootId === 'string');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Read-only discovery must never open a SessionManager: opening can migrate or
|
|
22
|
+
// repair the very session another runtime is currently writing.
|
|
23
|
+
export async function readRootFile(sessionFile, { signal } = {}) {
|
|
24
|
+
signal?.throwIfAborted();
|
|
25
|
+
let metadata;
|
|
26
|
+
try { metadata = await stat(sessionFile); }
|
|
27
|
+
catch (error) { if (error.code === 'ENOENT') { cache.delete(sessionFile); return; } throw error; }
|
|
28
|
+
const stamp = `${metadata.dev}:${metadata.ino}:${metadata.size}:${metadata.mtimeMs}:${metadata.ctimeMs}`;
|
|
29
|
+
const previous = cache.get(sessionFile);
|
|
30
|
+
if (previous?.stamp === stamp) return previous.root ? { ...previous.root } : undefined;
|
|
31
|
+
function remember(root) {
|
|
32
|
+
cache.set(sessionFile, { stamp, root });
|
|
33
|
+
return root ? { ...root } : undefined;
|
|
34
|
+
}
|
|
35
|
+
const input = createReadStream(sessionFile, { encoding: 'utf8', signal });
|
|
36
|
+
const lines = createInterface({ input, crlfDelay: Infinity });
|
|
37
|
+
let header, name, summary, title, reply;
|
|
38
|
+
try {
|
|
39
|
+
for await (const line of lines) {
|
|
40
|
+
signal?.throwIfAborted();
|
|
41
|
+
let entry;
|
|
42
|
+
try { entry = JSON.parse(line); } catch { continue; }
|
|
43
|
+
if (!entry || typeof entry !== 'object') continue;
|
|
44
|
+
if (!header) {
|
|
45
|
+
if (entry.type !== 'session' || typeof entry.id !== 'string' || !entry.id || typeof entry.cwd !== 'string') return remember();
|
|
46
|
+
header = entry;
|
|
47
|
+
}
|
|
48
|
+
if (isOwnedSession([entry])) return remember();
|
|
49
|
+
if (entry.type === 'session_info') name = typeof entry.name === 'string' ? entry.name : undefined;
|
|
50
|
+
if (entry.type === 'compaction' && typeof entry.summary === 'string') summary = entry.summary;
|
|
51
|
+
if (entry.type === 'message' && entry.message?.role === 'user') title ??= firstLine(entry.message.content);
|
|
52
|
+
if (entry.type === 'message' && entry.message?.role === 'assistant') reply = firstLine(entry.message.content) || firstLine(entry.message.errorMessage) || reply;
|
|
53
|
+
}
|
|
54
|
+
} catch (error) {
|
|
55
|
+
if (error.code === 'ENOENT') { cache.delete(sessionFile); return; }
|
|
56
|
+
throw error;
|
|
57
|
+
} finally {
|
|
58
|
+
lines.close();
|
|
59
|
+
input.destroy();
|
|
60
|
+
}
|
|
61
|
+
if (!header) return remember();
|
|
62
|
+
return remember({ id: header.id, name, title: title || undefined, reply, cwd: header.cwd, summary, sessionFile, updatedAt: metadata.mtime.toISOString(), state: 'unknown' });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function sessionFiles(sessionDir, extraFiles, signal) {
|
|
66
|
+
signal?.throwIfAborted();
|
|
67
|
+
let directories;
|
|
68
|
+
try { directories = await readdir(sessionDir, { withFileTypes: true }); }
|
|
69
|
+
catch (error) { if (error.code === 'ENOENT') directories = []; else throw error; }
|
|
70
|
+
const files = new Set(extraFiles);
|
|
71
|
+
for (const directory of directories) {
|
|
72
|
+
signal?.throwIfAborted();
|
|
73
|
+
// A configured sessionDir holds roots directly, and child runtimes in subagents/.
|
|
74
|
+
if (directory.isFile() && directory.name.endsWith('.jsonl')) files.add(join(sessionDir, directory.name));
|
|
75
|
+
if ((!directory.isDirectory() && !directory.isSymbolicLink()) || directory.name === 'subagents') continue;
|
|
76
|
+
const path = join(sessionDir, directory.name);
|
|
77
|
+
let entries;
|
|
78
|
+
try { entries = await readdir(path, { withFileTypes: true }); }
|
|
79
|
+
catch (error) { if (error.code === 'ENOENT' || error.code === 'ENOTDIR') continue; throw error; }
|
|
80
|
+
// Pi's default layout keeps roots one level below sessions/. Child runtimes use nested
|
|
81
|
+
// directories; parentSession alone cannot distinguish children from forks.
|
|
82
|
+
for (const entry of entries) {
|
|
83
|
+
if ((entry.isFile() || entry.isSymbolicLink()) && entry.name.endsWith('.jsonl')) files.add(join(path, entry.name));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return files;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Root Sessions in `sessionDir` and `extraFiles`; with `ids`, only those. A file Pi named
|
|
91
|
+
* `<timestamp>_<id>.jsonl` is skipped by its name; others are read for their ID.
|
|
92
|
+
*/
|
|
93
|
+
export async function discoverRoots(sessionDir, { extraFiles = [], ids, skip, signal } = {}) {
|
|
94
|
+
const roots = [];
|
|
95
|
+
for (const file of await sessionFiles(sessionDir, extraFiles, signal)) {
|
|
96
|
+
const named = /_([0-9a-f-]{36})\.jsonl$/.exec(file)?.[1];
|
|
97
|
+
if (skip?.has(file) || (ids && named && !ids.has(named))) continue;
|
|
98
|
+
const root = await readRootFile(file, { signal });
|
|
99
|
+
if (root && (!ids || ids.has(root.id))) roots.push(root);
|
|
100
|
+
}
|
|
101
|
+
return roots;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Pi names Session files `<timestamp>_<id>.jsonl`; read those first and scan every
|
|
105
|
+
// Session only when no such file is the root.
|
|
106
|
+
export async function findRoot(sessionDir, id, { extraFiles = [], signal } = {}) {
|
|
107
|
+
for (const file of await sessionFiles(sessionDir, extraFiles, signal)) {
|
|
108
|
+
if (!file.endsWith(`_${id}.jsonl`)) continue;
|
|
109
|
+
const root = await readRootFile(file, { signal });
|
|
110
|
+
if (root?.id === id) return root;
|
|
111
|
+
}
|
|
112
|
+
return (await discoverRoots(sessionDir, { extraFiles, signal })).find(entry => entry.id === id);
|
|
113
|
+
}
|