@tshu-w/pi-agents 0.3.0 → 0.4.1
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/CHANGELOG.md +94 -0
- package/bin/pd.mjs +21 -31
- package/docs/HOSTS.md +2 -2
- package/docs/PROGRAM.md +17 -28
- package/docs/SPEC.md +35 -39
- package/package.json +3 -2
- package/src/agents/agents.ts +154 -101
- package/src/agents/registry.ts +40 -7
- package/src/agents/tool.ts +31 -87
- package/src/index.ts +5 -3
- package/src/main-agent.md +5 -7
- package/src/main-agent.ts +53 -0
- package/src/output.ts +0 -18
- package/src/programs/loadout.ts +14 -19
- package/src/programs/programs.ts +39 -115
- package/src/programs/scope.ts +1 -1
- package/src/programs/tool.ts +16 -12
- package/src/roots/guard.mjs +5 -0
- package/src/roots/paths.mjs +1 -1
- package/src/ui/message.ts +41 -7
package/src/agents/tool.ts
CHANGED
|
@@ -1,25 +1,17 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
3
3
|
import { Type } from "typebox";
|
|
4
|
-
import {
|
|
4
|
+
import { boundText } from "../output.ts";
|
|
5
5
|
import { rejectFields } from "../params.ts";
|
|
6
|
-
import {
|
|
6
|
+
import { renderTextResult, renderToolCall, startDuration, type DurationState } from "../render-call.ts";
|
|
7
7
|
import { agentSession } from "../roots/rpc.ts";
|
|
8
8
|
import type { RootRuntime } from "../roots/runtime.ts";
|
|
9
|
-
import {
|
|
10
|
-
steerQueued,
|
|
11
|
-
THINKING_LEVELS,
|
|
12
|
-
WAIT_INTERRUPTED,
|
|
13
|
-
type Limits,
|
|
14
|
-
type WaitOutcome,
|
|
15
|
-
type WaitResult,
|
|
16
|
-
} from "./agents.ts";
|
|
9
|
+
import { steerQueued, THINKING_LEVELS, waitText, type Limits } from "./agents.ts";
|
|
17
10
|
import {
|
|
18
11
|
deliver,
|
|
19
12
|
label,
|
|
20
13
|
nodes,
|
|
21
14
|
rememberRoots,
|
|
22
|
-
shortId,
|
|
23
15
|
resolveIn,
|
|
24
16
|
treeEntries,
|
|
25
17
|
type AgentNode,
|
|
@@ -27,55 +19,6 @@ import {
|
|
|
27
19
|
} from "./registry.ts";
|
|
28
20
|
import { listLine, searchEntries } from "./search.ts";
|
|
29
21
|
|
|
30
|
-
function resultBlock({ agent, input, history }: WaitResult, ids: string[]): string {
|
|
31
|
-
return `<agent-result name="${agent.name}" id="${shortId(agent.id, ids)}" status="${input.state}"${history ? ' history="true"' : ""}>\n${input.result ?? ""}\n</agent-result>`;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function resultDetails({ agent, input, history }: WaitResult, result?: string) {
|
|
35
|
-
return { id: agent.id, name: agent.name, state: input.state, history, ...(result === undefined ? {} : { result }) };
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
type Named = { id: string; name?: string };
|
|
39
|
-
|
|
40
|
-
/** The line naming the results left out to keep within Pi's output limits. */
|
|
41
|
-
function omittedLine(omitted: Named[], ids: string[]): string {
|
|
42
|
-
return omitted.length > 0 ? `[Results omitted: ${omitted.map((agent) => label(agent, ids)).join(", ")}. Use wait with fewer targets.]` : "";
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/** The line naming the Agents still pending, and that a steer ended the wait if one did. */
|
|
46
|
-
function pendingLine(pending: Named[], interrupted: boolean | undefined, ids: string[]): string {
|
|
47
|
-
return pending.length > 0 ? `Still pending: ${pending.map((agent) => label(agent, ids)).join(", ")}${interrupted ? `\n${WAIT_INTERRUPTED}` : ""}` : "";
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/** Renders wait results; returns the text and the results the caller has now read. */
|
|
51
|
-
function renderWait(outcome: WaitOutcome, ids: string[]): { text: string; read: WaitResult[]; details: Record<string, unknown> } {
|
|
52
|
-
const { results, pending, interrupted } = outcome;
|
|
53
|
-
const pendingText = pendingLine(pending, interrupted, ids);
|
|
54
|
-
const flag = interrupted ? { interrupted } : {};
|
|
55
|
-
const named = pending.map(({ id, name }) => ({ id, name }));
|
|
56
|
-
if (results.length === 0) return { text: pendingText || "No results.", read: [], details: { results: [], pending: named, ...flag } };
|
|
57
|
-
const only = results[0]!;
|
|
58
|
-
if (results.length === 1 && pending.length === 0 && !only.history && only.input.state === "completed") {
|
|
59
|
-
const bounded = boundText(only.input.result ?? "", "pi-agents-wait");
|
|
60
|
-
return { text: bounded.text, read: results, details: { results: [resultDetails(only, bounded.kept)], pending: [], ...bounded.details } };
|
|
61
|
-
}
|
|
62
|
-
const blocks = results.map((result) => resultBlock(result, ids));
|
|
63
|
-
const bounded = boundBlocks(blocks, "agent-result", "pi-agents-wait");
|
|
64
|
-
let offset = 0;
|
|
65
|
-
const details = results.map((result, index) => {
|
|
66
|
-
const block = blocks[index]!;
|
|
67
|
-
const available = Math.max(0, bounded.kept.length - offset - block.indexOf("\n") - 1);
|
|
68
|
-
offset += block.length + 2;
|
|
69
|
-
return resultDetails(result, index < bounded.shown ? (result.input.result ?? "").slice(0, available) : undefined);
|
|
70
|
-
});
|
|
71
|
-
let text = bounded.text;
|
|
72
|
-
const read = results.slice(0, bounded.shown);
|
|
73
|
-
const omittedText = omittedLine(results.slice(bounded.shown).map((result) => result.agent), ids);
|
|
74
|
-
if (omittedText) text += `\n\n${omittedText}`;
|
|
75
|
-
if (pendingText) text += `\n\n${pendingText}`;
|
|
76
|
-
return { text, read, details: { results: details, pending: named, ...flag, ...bounded.details } };
|
|
77
|
-
}
|
|
78
|
-
|
|
79
22
|
/** Registers the `agent` tool. */
|
|
80
23
|
export function registerAgentTool(
|
|
81
24
|
pi: ExtensionAPI,
|
|
@@ -102,36 +45,25 @@ export function registerAgentTool(
|
|
|
102
45
|
startDuration(context.state as DurationState, args.action === "wait", context.executionStarted);
|
|
103
46
|
return renderToolCall("agent", args, theme, context.lastComponent);
|
|
104
47
|
},
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
? () => {
|
|
108
|
-
const { results = [], pending = [], interrupted, ...truncated } = (result.details ?? {}) as { results?: ReturnType<typeof resultDetails>[]; pending?: Named[]; interrupted?: boolean } & Partial<Truncation>;
|
|
109
|
-
const only = results[0];
|
|
110
|
-
// As `renderWait` returns them, no results, or a single completed result alone, show as they are.
|
|
111
|
-
if (!only || (results.length === 1 && pending.length === 0 && !only.history && only.state === "completed")) return undefined;
|
|
112
|
-
const ids = [...results.map((entry) => entry.id), ...pending.map((agent) => agent.id)];
|
|
113
|
-
const notes = [omittedLine(results.filter((entry) => entry.result === undefined), ids), pendingLine(pending, interrupted, ids)];
|
|
114
|
-
return renderResultList(results.flatMap((entry) => entry.result === undefined ? [] : [{ line: `${label(entry, ids)} ${entry.state}`, body: entry.result }]), notes, truncated, theme);
|
|
115
|
-
}
|
|
116
|
-
: undefined),
|
|
117
|
-
description: `Create, message, and coordinate Agents. \`spawn\` creates an owned Agent and sends its first input; \`send\` sends an input or a write to a visible Agent; \`wait\` waits for owned Agents and returns unread results; \`list\` finds visible Agents; \`abort\` stops an owned Agent's current turn and queued inputs while keeping it available. When an owned Agent finishes an input while its owner is not waiting for it, the owner receives a notification naming the Agent and outcome; \`wait\` returns the result. Owned Agents under the same root share \`${maxConcurrent}\` execution slots and a limit of \`${maxOutstanding}\` inputs that have not ended. Inputs that start a new turn queue when all slots are busy; new inputs are rejected at the input limit.`,
|
|
48
|
+
renderResult: (result, options, theme, context) => renderTextResult(result, options, theme, context),
|
|
49
|
+
description: `Create, message, and coordinate Agents. \`spawn\` creates an Agent and sends its first input; \`send\` sends an input or a write to a visible Agent; \`wait\` waits for owned Agents to finish; \`list\` finds visible Agents; \`abort\` stops an owned Agent's current turn and queued inputs while keeping it available. When an owned Agent finishes an input, its owner receives a notification with the result. Owned Agents under the same root share \`${maxConcurrent}\` execution slots and a limit of \`${maxOutstanding}\` inputs that have not ended. Inputs that start a new turn queue when all slots are busy; new inputs are rejected at the input limit.`,
|
|
118
50
|
promptSnippet: "Create, message, and coordinate Agents",
|
|
119
51
|
promptGuidelines: [
|
|
120
52
|
"Use `agent(action='spawn', name=..., message=...)` for concrete, bounded work with a clear expected result. Keep the first message self-contained; use `context='fork'` only when the Agent needs the caller's conversation.",
|
|
121
53
|
"For independent work, start the Agents before waiting and partition tasks into non-overlapping responsibilities.",
|
|
122
|
-
"Use `agent(action='wait', ...)` when
|
|
54
|
+
"Use `agent(action='wait', ...)` when your next step or answer depends on Agent results. Prefer one longer wait over repeated short polling.",
|
|
123
55
|
],
|
|
124
56
|
parameters: Type.Object({
|
|
125
|
-
action: StringEnum(["spawn", "send", "wait", "list", "abort"] as const, { description: "Operation and applicable parameters: `spawn(name, message, cwd?, context?, model?, thinkingLevel?)`, `send(target, message, deliverAs?)`, `wait(target?,
|
|
57
|
+
action: StringEnum(["spawn", "send", "wait", "list", "abort"] as const, { description: "Operation and applicable parameters: `spawn(name, message, cwd?, context?, model?, thinkingLevel?, disown?)`, `send(target, message, deliverAs?)`, `wait(target?, timeout?)`, `list(query?, state?, limit?, offset?)`, or `abort(target)`." }),
|
|
126
58
|
name: Type.Optional(Type.String({ description: "Name of the new Agent for `spawn`; unique among the caller's Agents." })),
|
|
127
59
|
message: Type.Optional(Type.String({ description: "Non-empty text for `spawn` or `send`." })),
|
|
128
60
|
cwd: Type.Optional(Type.String({ description: "Working directory for `spawn` (default: the caller's current working directory)." })),
|
|
129
61
|
context: Type.Optional(StringEnum(["fresh", "fork"] as const, { description: "Context for `spawn` (default: fresh). fresh starts without the caller's conversation; fork snapshots it." })),
|
|
130
62
|
model: Type.Optional(Type.String({ description: "Model for `spawn` as provider/modelId (default: the caller's model)." })),
|
|
131
63
|
thinkingLevel: Type.Optional(StringEnum(THINKING_LEVELS, { description: "Thinking level for `spawn`: off, minimal, low, medium, high, xhigh, or max (default: the caller's level)." })),
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
64
|
+
disown: Type.Optional(Type.Boolean({ description: "For `spawn`, start a root Agent the caller does not own; it replies with send instead of returning a result (default: false)." })),
|
|
65
|
+
target: Type.Optional(Type.Union([Type.String(), Type.Array(Type.String())], { description: "Agent ID, name, or unique ID prefix for `send`, `wait`, or `abort`. `wait` and `send` with `deliverAs='write'` also accept an array. When omitted for `wait`, selects all owned Agents with pending inputs." })),
|
|
66
|
+
deliverAs: Type.Optional(StringEnum(["followUp", "steer", "write"] as const, { description: "Delivery for `send` (default: followUp). followUp waits until the recipient's current turn ends; steer delivers after its current tool calls, before the next model call; write delivers like steer but does not wake an idle recipient and has no result." })),
|
|
135
67
|
timeout: Type.Optional(Type.Number({ minimum: 10, maximum: 3600, description: "Maximum seconds for `wait` (default: 30, min: 10, max: 3600). Timeout does not abort Agents." })),
|
|
136
68
|
query: Type.Optional(Type.String({ description: "Search query for `list`, matching ID, name, cwd, summaries, and user messages. Case-insensitive; spaces mean AND; `|` means OR." })),
|
|
137
69
|
state: Type.Optional(StringEnum(["running", "waiting", "queued", "idle"] as const, { description: "State for `list`: running, waiting, queued, or idle." })),
|
|
@@ -146,8 +78,18 @@ export function registerAgentTool(
|
|
|
146
78
|
const targets = params.target === undefined ? undefined : Array.isArray(params.target) ? params.target : [params.target];
|
|
147
79
|
switch (params.action) {
|
|
148
80
|
case "spawn": {
|
|
149
|
-
rejectFields(params, "spawn", ["target", "deliverAs", "
|
|
81
|
+
rejectFields(params, "spawn", ["target", "deliverAs", "timeout", "query", "state", "limit", "offset"]);
|
|
150
82
|
if (!params.name?.trim() || !params.message?.trim()) throw new Error("spawn requires name and message");
|
|
83
|
+
if (params.disown) {
|
|
84
|
+
if (params.context === "fork") throw new Error("Only an owned Agent can fork the caller's context.");
|
|
85
|
+
if (self.ownerId !== undefined) throw new Error("Only a root Agent can disown an Agent.");
|
|
86
|
+
const root = agents.disown({ name: params.name, cwd: params.cwd, model: params.model, thinkingLevel: params.thinkingLevel });
|
|
87
|
+
await roots.send({ id: root.id, label: root.name }, "followUp", params.message, signal);
|
|
88
|
+
return {
|
|
89
|
+
content: [{ type: "text", text: `Agent ${label(root, ids())} started.` }],
|
|
90
|
+
details: { id: root.id, name: root.name, queued: false },
|
|
91
|
+
};
|
|
92
|
+
}
|
|
151
93
|
const { record, queued } = agents.spawn({
|
|
152
94
|
name: params.name,
|
|
153
95
|
message: params.message,
|
|
@@ -162,7 +104,7 @@ export function registerAgentTool(
|
|
|
162
104
|
};
|
|
163
105
|
}
|
|
164
106
|
case "send": {
|
|
165
|
-
rejectFields(params, "send", ["name", "cwd", "context", "model", "thinkingLevel", "
|
|
107
|
+
rejectFields(params, "send", ["name", "cwd", "context", "model", "thinkingLevel", "timeout", "query", "state", "limit", "offset", "disown"]);
|
|
166
108
|
const delivery = params.deliverAs ?? "followUp";
|
|
167
109
|
if (!targets?.length || !params.message?.trim()) throw new Error("send requires target and message");
|
|
168
110
|
if (targets.length > 1 && delivery !== "write") throw new Error("Only a write can go to several Agents.");
|
|
@@ -191,18 +133,20 @@ export function registerAgentTool(
|
|
|
191
133
|
};
|
|
192
134
|
}
|
|
193
135
|
case "wait": {
|
|
194
|
-
rejectFields(params, "wait", ["name", "message", "cwd", "context", "model", "thinkingLevel", "deliverAs", "query", "state", "limit", "offset"]);
|
|
136
|
+
rejectFields(params, "wait", ["name", "message", "cwd", "context", "model", "thinkingLevel", "deliverAs", "query", "state", "limit", "offset", "disown"]);
|
|
195
137
|
const selected = targets?.map((target) => agents.ownedTarget(target));
|
|
196
138
|
// An empty partial result lets the row show the elapsed time.
|
|
197
139
|
onUpdate?.({ content: [], details: undefined });
|
|
198
140
|
const session = agentSession(current.sessionManager);
|
|
199
|
-
const
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
141
|
+
const { end, pending } = await agents.wait(selected, params.timeout ?? 30, signal, () => steerQueued(session?.agent));
|
|
142
|
+
const text = waitText(end, `Still pending: ${pending.map((agent) => label(agent, ids())).join(", ")}`);
|
|
143
|
+
return {
|
|
144
|
+
content: [{ type: "text", text }],
|
|
145
|
+
details: { pending: pending.map(({ id, name }) => ({ id, name })), ...(end === "timedOut" && { timedOut: true }), ...(end === "interrupted" && { interrupted: true }) },
|
|
146
|
+
};
|
|
203
147
|
}
|
|
204
148
|
case "list": {
|
|
205
|
-
rejectFields(params, "list", ["name", "message", "cwd", "context", "model", "thinkingLevel", "target", "deliverAs", "
|
|
149
|
+
rejectFields(params, "list", ["name", "message", "cwd", "context", "model", "thinkingLevel", "target", "deliverAs", "timeout", "disown"]);
|
|
206
150
|
const offset = params.offset ?? 0;
|
|
207
151
|
const limit = params.limit ?? 20;
|
|
208
152
|
const all = await visible(self, signal);
|
|
@@ -218,7 +162,7 @@ export function registerAgentTool(
|
|
|
218
162
|
return { content: [{ type: "text", text: text.text }], details: { total: items.length, agents, ...text.details } };
|
|
219
163
|
}
|
|
220
164
|
case "abort": {
|
|
221
|
-
rejectFields(params, "abort", ["name", "message", "cwd", "context", "model", "thinkingLevel", "deliverAs", "
|
|
165
|
+
rejectFields(params, "abort", ["name", "message", "cwd", "context", "model", "thinkingLevel", "deliverAs", "timeout", "query", "state", "limit", "offset", "disown"]);
|
|
222
166
|
if (targets?.length !== 1) throw new Error("abort requires one target");
|
|
223
167
|
const agent = agents.ownedTarget(targets[0]!);
|
|
224
168
|
const aborted = await agents.abort(agent, { silent: true });
|
package/src/index.ts
CHANGED
|
@@ -41,6 +41,7 @@ import { createReporter } from "./roots/report.ts";
|
|
|
41
41
|
import { installQueuePatches } from "./roots/queue.ts";
|
|
42
42
|
import { agentSession, installRpcPatches } from "./roots/rpc.ts";
|
|
43
43
|
import { createRootRuntime } from "./roots/runtime.ts";
|
|
44
|
+
import { registerMainAgent } from "./main-agent.ts";
|
|
44
45
|
import { renderAgentMessage } from "./ui/message.ts";
|
|
45
46
|
import { createPanel } from "./ui/panel.ts";
|
|
46
47
|
import { showQueue } from "./ui/queue.ts";
|
|
@@ -134,6 +135,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
134
135
|
};
|
|
135
136
|
|
|
136
137
|
pi.registerFlag(PROGRAM_ONLY_FLAG, { type: "boolean", description: "Present tools as with codemode.mode \"only\": scripts reach them through program" });
|
|
138
|
+
registerMainAgent(pi);
|
|
137
139
|
|
|
138
140
|
let warnedBothScriptTools = false;
|
|
139
141
|
pi.on("session_start", (_event, current) => {
|
|
@@ -155,9 +157,9 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
155
157
|
cwd: () => ctx?.cwd ?? current.cwd,
|
|
156
158
|
busy: () => !(ctx?.isIdle() ?? true),
|
|
157
159
|
sessionFile: () => ctx?.sessionManager.getSessionFile(),
|
|
158
|
-
receive: (text, delivery, messageId, from) => {
|
|
160
|
+
receive: (text, delivery, messageId, from, notice) => {
|
|
159
161
|
const as = created.stopped ? "write" : delivery;
|
|
160
|
-
pi.sendMessage(customMessage(text, as, { messageId, sender: from }), as === "write"
|
|
162
|
+
pi.sendMessage(customMessage(text, as, { messageId, sender: from, notice }), as === "write"
|
|
161
163
|
? { deliverAs: "steer" }
|
|
162
164
|
: { deliverAs: as, triggerTurn: true });
|
|
163
165
|
queue.update();
|
|
@@ -167,7 +169,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
167
169
|
nodes.set(id, node);
|
|
168
170
|
programs = new Programs(rootId, {
|
|
169
171
|
appendEntry: (customType, data) => pi.appendEntry(customType, data),
|
|
170
|
-
notify: (text) => agents.notify(text),
|
|
172
|
+
notify: (text, notice) => agents.notify(text, notice),
|
|
171
173
|
changed: () => panel.update(),
|
|
172
174
|
});
|
|
173
175
|
created.programs = programs;
|
package/src/main-agent.md
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
|
-
|
|
1
|
+
You are the user's main Agent.
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
-
|
|
6
|
-
-
|
|
7
|
-
- Spawn Agents in the directory the work belongs to, or `~` if none; never in your working directory.
|
|
8
|
-
- Relay an Agent's questions to the user, and its results as a short conclusion.
|
|
3
|
+
- Do quick read-only checks yourself; give other work to Agents.
|
|
4
|
+
- Reuse the Agent already on a topic, or spawn one where the work is (`disown` for lasting topics).
|
|
5
|
+
- Don't wait for Agents; end your turn so the user can talk to you.
|
|
6
|
+
- Pass on their questions as asked and their results briefly.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { treeMetadata } from "./agents/registry.ts";
|
|
5
|
+
import { statePaths } from "./roots/paths.mjs";
|
|
6
|
+
|
|
7
|
+
export const MAIN_FLAG = "main";
|
|
8
|
+
const instructions = readFileSync(new URL("./main-agent.md", import.meta.url), "utf8").trim();
|
|
9
|
+
|
|
10
|
+
/** The main Agents' Session files by entry name, as `main.json` maps them; an old `{ sessionFile }` is the entry `main`. */
|
|
11
|
+
function mainEntries(): Record<string, string> {
|
|
12
|
+
try {
|
|
13
|
+
const entries = JSON.parse(readFileSync(statePaths().mainSession, "utf8")) as Record<string, string>;
|
|
14
|
+
return typeof entries.sessionFile === "string" ? { main: entries.sessionFile } : entries;
|
|
15
|
+
} catch {
|
|
16
|
+
return {};
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A main Agent is a Session an entry of `main.json` names: `pd main --name <entry>` starts one with `--main=<entry>`,
|
|
22
|
+
* and `/new` in it starts the next for the same entry. It gets the main Agent's instructions.
|
|
23
|
+
*/
|
|
24
|
+
export function registerMainAgent(pi: ExtensionAPI): void {
|
|
25
|
+
pi.registerFlag(MAIN_FLAG, { type: "string", description: "Start the main Agent of this entry (used by pd main)" });
|
|
26
|
+
let main = false;
|
|
27
|
+
pi.on("session_start", (event, ctx) => {
|
|
28
|
+
main = false;
|
|
29
|
+
if (treeMetadata(ctx)) return;
|
|
30
|
+
// Absolute, as `pd main` compares it from other directories; a session directory may be relative.
|
|
31
|
+
const current = ctx.sessionManager.getSessionFile();
|
|
32
|
+
const file = current && path.resolve(current);
|
|
33
|
+
const mains = mainEntries();
|
|
34
|
+
const flag = pi.getFlag(MAIN_FLAG);
|
|
35
|
+
const previous = event.reason === "new" && event.previousSessionFile !== undefined ? path.resolve(event.previousSessionFile) : undefined;
|
|
36
|
+
const names = event.reason === "startup" && typeof flag === "string"
|
|
37
|
+
? [flag]
|
|
38
|
+
: Object.keys(mains).filter((name) => previous !== undefined && mains[name] === previous);
|
|
39
|
+
if (file && names.length > 0) {
|
|
40
|
+
for (const name of names) mains[name] = file;
|
|
41
|
+
const record = statePaths().mainSession;
|
|
42
|
+
mkdirSync(path.dirname(record), { recursive: true, mode: 0o700 });
|
|
43
|
+
// Replaced at once, so that a reader never sees it half written.
|
|
44
|
+
const temporary = `${record}.${process.pid}`;
|
|
45
|
+
writeFileSync(temporary, JSON.stringify(mains), { mode: 0o600 });
|
|
46
|
+
renameSync(temporary, record);
|
|
47
|
+
}
|
|
48
|
+
main = file !== undefined && Object.values(mains).includes(file);
|
|
49
|
+
});
|
|
50
|
+
pi.on("before_agent_start", (event) => {
|
|
51
|
+
if (main) event.systemPromptOptions.sections["main-agent"] = instructions;
|
|
52
|
+
});
|
|
53
|
+
}
|
package/src/output.ts
CHANGED
|
@@ -40,21 +40,3 @@ export function boundText(text: string, prefix: string): BoundedText {
|
|
|
40
40
|
details: { truncation, fullOutputPath },
|
|
41
41
|
};
|
|
42
42
|
}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Bounds result blocks joined by blank lines. The block that crosses the limit keeps its closing
|
|
46
|
-
* tag; `shown` counts the blocks that appear, and the caller names the omitted ones.
|
|
47
|
-
*/
|
|
48
|
-
export function boundBlocks(blocks: string[], tag: string, prefix: string): BoundedText & { shown: number } {
|
|
49
|
-
const bounded = boundText(blocks.join("\n\n"), prefix);
|
|
50
|
-
if (!bounded.details) return { ...bounded, shown: blocks.length };
|
|
51
|
-
const kept = bounded.kept;
|
|
52
|
-
let start = 0;
|
|
53
|
-
const shown = blocks.filter((block) => {
|
|
54
|
-
const visible = start < kept.length;
|
|
55
|
-
start += block.length + 2;
|
|
56
|
-
return visible;
|
|
57
|
-
}).length;
|
|
58
|
-
const open = kept.lastIndexOf(`<${tag} `) > kept.lastIndexOf(`</${tag}>`);
|
|
59
|
-
return { ...bounded, text: `${kept}${open ? `\n</${tag}>` : ""}${bounded.text.slice(kept.length)}`, shown };
|
|
60
|
-
}
|
package/src/programs/loadout.ts
CHANGED
|
@@ -3,9 +3,7 @@ import { Text } from "@earendil-works/pi-tui";
|
|
|
3
3
|
import { CODEMODE_TOOL_NAME, type PiCodemode } from "./codemode.ts";
|
|
4
4
|
import { PROGRAM_TOOL_NAME } from "./execute.ts";
|
|
5
5
|
import { shortId } from "../agents/registry.ts";
|
|
6
|
-
import {
|
|
7
|
-
import type { Truncation } from "../output.ts";
|
|
8
|
-
import { omittedLine, runningLine, type ProgramResultDetails } from "./programs.ts";
|
|
6
|
+
import { formatToolCall, moreLines, renderTextResult, startDuration, type DurationState } from "../render-call.ts";
|
|
9
7
|
|
|
10
8
|
/** The first line of the `codemode` description, which the `program` intro replaces. */
|
|
11
9
|
const CODEMODE_FIRST_LINE = "Run JavaScript code to orchestrate/compose tool calls\n";
|
|
@@ -17,6 +15,11 @@ type Changes = ReturnType<NonNullable<ToolDefinition["prepareLoadout"]>>;
|
|
|
17
15
|
type Renderers = Pick<ToolDefinition, "renderCall" | "renderResult">;
|
|
18
16
|
|
|
19
17
|
const CODE_PREVIEW_LINES = 10;
|
|
18
|
+
// The colors Pi highlights JavaScript with; renderers get the same theme object after a theme change, so these tell it apart.
|
|
19
|
+
const SYNTAX_COLORS = ["text", "muted", "syntaxComment", "syntaxKeyword", "syntaxFunction", "syntaxVariable", "syntaxString", "syntaxNumber", "syntaxType", "syntaxOperator", "syntaxPunctuation"] as const;
|
|
20
|
+
|
|
21
|
+
/** A call row's state: its duration, and its script's highlighting, kept across redraws. */
|
|
22
|
+
type CallState = DurationState & { script?: { code: string; colors: string; lines: string[] } };
|
|
20
23
|
|
|
21
24
|
export const PROGRAM_ONLY_FLAG = "program-only";
|
|
22
25
|
// Owned Agents run in their owner's process without its CLI flags, so the flag holds for the process.
|
|
@@ -82,10 +85,14 @@ export function programRenderers(piCodemode: PiCodemode): Renderers {
|
|
|
82
85
|
return {
|
|
83
86
|
renderCall(args, theme, context) {
|
|
84
87
|
const { code, ...rest } = (args ?? {}) as Record<string, unknown>;
|
|
85
|
-
|
|
88
|
+
const state = context.state as CallState;
|
|
89
|
+
startDuration(state, rest.action === "wait", context.executionStarted);
|
|
86
90
|
let text = formatToolCall(PROGRAM_TOOL_NAME, rest, theme);
|
|
87
91
|
if (typeof code === "string" && code) {
|
|
88
|
-
const
|
|
92
|
+
const source = code.replace(/\r/g, "").replace(/\t/g, " ").trimEnd();
|
|
93
|
+
const colors = SYNTAX_COLORS.map((color) => theme.fg(color, "")).join("");
|
|
94
|
+
if (state.script?.code !== source || state.script.colors !== colors) state.script = { code: source, colors, lines: highlightCode(source, "javascript") };
|
|
95
|
+
const { lines } = state.script;
|
|
89
96
|
const shown = context.expanded ? lines : lines.slice(0, CODE_PREVIEW_LINES);
|
|
90
97
|
text += `\n${shown.join("\n")}`;
|
|
91
98
|
if (shown.length < lines.length) text += `\n${moreLines(lines.length - shown.length, theme)}`;
|
|
@@ -94,21 +101,9 @@ export function programRenderers(piCodemode: PiCodemode): Renderers {
|
|
|
94
101
|
component.setText(text);
|
|
95
102
|
return component;
|
|
96
103
|
},
|
|
97
|
-
// `wait` shows how long it has waited
|
|
98
|
-
// the other results render like `codemode`.
|
|
104
|
+
// `wait` shows how long it has waited; the other results render like `codemode`.
|
|
99
105
|
renderResult: (result, options, theme, context) => (context.args as { action?: string }).action === "wait"
|
|
100
|
-
? renderTextResult(result, options, theme, context
|
|
101
|
-
const { results = [], running = [], interrupted, ...truncated } = (result.details ?? {}) as { results?: ProgramResultDetails[]; running?: string[]; interrupted?: boolean } & Partial<Truncation>;
|
|
102
|
-
const only = results[0];
|
|
103
|
-
// As `renderProgramWait` returns them, no results, or a single completed result alone, show as they are.
|
|
104
|
-
if (!only || (results.length === 1 && running.length === 0 && only.state === "completed")) return undefined;
|
|
105
|
-
const ids = [...results.map((entry) => entry.id), ...running];
|
|
106
|
-
const notes = [omittedLine(results.flatMap((entry) => entry.output === undefined ? [entry.id] : []), ids), runningLine(running, interrupted, ids)];
|
|
107
|
-
return renderResultList(results.flatMap(({ id, state, wallTimeSeconds, output }) => output === undefined ? [] : [{
|
|
108
|
-
line: [`Program ${shortId(id, ids)}`, state, ...(wallTimeSeconds === undefined ? [] : [formatDuration(wallTimeSeconds * 1000)])].join(" "),
|
|
109
|
-
body: output,
|
|
110
|
-
}]), notes, truncated, theme);
|
|
111
|
-
})
|
|
106
|
+
? renderTextResult(result, options, theme, context)
|
|
112
107
|
: piCodemode.renderResult(result, options, theme, context) as Text,
|
|
113
108
|
};
|
|
114
109
|
}
|