@tshu-w/pi-agents 0.2.2 → 0.4.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.
@@ -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 { boundBlocks, boundText, type Truncation } from "../output.ts";
4
+ import { boundText } from "../output.ts";
5
5
  import { rejectFields } from "../params.ts";
6
- import { renderResultList, renderTextResult, renderToolCall, startDuration, type DurationState } from "../render-call.ts";
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
- // `wait` shows how long it has waited, and when collapsed, each result as a line with the first lines of its body.
106
- renderResult: (result, options, theme, context) => renderTextResult(result, options, theme, context, (context.args as { action?: string }).action === "wait"
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 the next step depends on Agent results. Prefer one longer wait over repeated short polling.",
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?, history?, timeout?)`, `list(query?, state?, limit?, offset?)`, or `abort(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
- 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 or unread results." })),
133
- 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 never starts a turn and has no result." })),
134
- history: Type.Optional(Type.Integer({ minimum: 0, description: "Number of most recent read results to return again per selected Agent for `wait` (default: 0)." })),
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", "history", "timeout", "query", "state", "limit", "offset"]);
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", "history", "timeout", "query", "state", "limit", "offset"]);
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 outcome = await agents.wait(selected, params.history ?? 0, params.timeout ?? 30, signal, () => steerQueued(session?.agent));
200
- const rendered = renderWait(outcome, ids());
201
- agents.markRead(rendered.read);
202
- return { content: [{ type: "text", text: rendered.text }], details: rendered.details };
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", "history", "timeout"]);
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", "history", "timeout", "query", "state", "limit", "offset"]);
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 });
@@ -15,6 +15,35 @@ const RESET = '\x1b[<u\x1b[>4;0m\x1b[?2004l\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b
15
15
  const ALT_SCREEN = /\x1b\[\?1049([hl])/g;
16
16
  // Splits terminal input into key sequences: CSI and SS3 sequences, Alt+key, or a single character.
17
17
  const KEYS = /\x1b\[[0-?]*[ -/]*[@-~]|\x1bO[\s\S]|\x1b?[\s\S]/g;
18
+ // The terminal's reply to the device status query (`\x1b[5n`), which pi-tui does not send, unlike `\x1b[c`.
19
+ const STATUS_QUERY = '\x1b[5n';
20
+ const STATUS_OK = /\x1b\[0n/;
21
+ const DRAIN_TIMEOUT_MS = 1000;
22
+
23
+ /**
24
+ * Reads and drops the input the terminal sent before it took the output written so far, such as the release of the
25
+ * detach key in Pi's keyboard mode, so the shell does not get it. Over a slow link, such as SSH, it arrives late; the
26
+ * terminal answers a query after it, or the wait ends after `DRAIN_TIMEOUT_MS`.
27
+ */
28
+ export function drain(stdin, stdout) {
29
+ return new Promise(resolve => {
30
+ let input = '';
31
+ const done = () => {
32
+ clearTimeout(timer);
33
+ stdin.off('data', onData);
34
+ resolve();
35
+ };
36
+ const onData = data => {
37
+ input += data.toString('latin1');
38
+ if (STATUS_OK.test(input)) done();
39
+ // A reply split across chunks starts in the kept tail.
40
+ else input = input.slice(-64);
41
+ };
42
+ const timer = setTimeout(done, DRAIN_TIMEOUT_MS);
43
+ stdin.on('data', onData);
44
+ stdout.write(STATUS_QUERY);
45
+ });
46
+ }
18
47
 
19
48
  export function hostStatus(path) {
20
49
  return new Promise((resolve, reject) => {
@@ -107,9 +136,10 @@ export async function startHost(paths, { pi, args, cwd, cols = 120, rows = 40, e
107
136
  * Attaches this process's terminal to a host until the host detaches it or Pi exits.
108
137
  * The detach key detaches here, before Pi sees it, so it works whatever Pi shows.
109
138
  * `onAttach` runs once the host has attached the terminal and told Pi.
139
+ * With `drainInput`, late input is dropped before returning (see `drain`); a caller that reads the terminal next turns it off.
110
140
  * Resolves to { reason: 'detach' | 'takeover' | 'exited' | 'closed', session }.
111
141
  */
112
- export function attach(socketPath, { isDetach, onAttach, stdin = process.stdin, stdout = process.stdout }) {
142
+ export function attach(socketPath, { isDetach, onAttach, drainInput = true, stdin = process.stdin, stdout = process.stdout }) {
113
143
  return new Promise((resolve, reject) => {
114
144
  const socket = net.connect(socketPath);
115
145
  const size = () => ({ cols: stdout.columns, rows: stdout.rows });
@@ -124,16 +154,19 @@ export function attach(socketPath, { isDetach, onAttach, stdin = process.stdin,
124
154
  socket.write(frame(FRAME.detached));
125
155
  };
126
156
  let result, connected = false, altScreen = false, attached = false;
127
- function finish() {
157
+ async function finish() {
128
158
  stdout.off('resize', onResize);
129
159
  stdin.off('data', onInput);
130
160
  if (connected) {
131
- stdin.setRawMode(false);
132
- stdin.pause();
133
161
  // An exiting Pi restores the terminal itself.
134
162
  // Leaving the alternate screen returns the cursor to the line below the command; otherwise it ends a line.
135
163
  // Terminals keep a keyboard mode stack per screen, so the reset comes before leaving it.
136
- if (result?.reason !== 'exited') stdout.write(altScreen ? `${RESET}\x1b[?1049l` : `${RESET}\r\n`);
164
+ // The drain comes before leaving, so the screen changes once, when it ends.
165
+ if (result?.reason !== 'exited') stdout.write(RESET);
166
+ if (drainInput) await drain(stdin, stdout);
167
+ if (result?.reason !== 'exited') stdout.write(altScreen ? '\x1b[?1049l' : '\r\n');
168
+ stdin.setRawMode(false);
169
+ stdin.pause();
137
170
  }
138
171
  resolve(result ?? { reason: 'closed' });
139
172
  }
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,9 +169,10 @@ 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
  });
175
+ created.programs = programs;
173
176
  programs.restore(current);
174
177
  panel.start(current);
175
178
  });
@@ -227,7 +230,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
227
230
  if (!node) return;
228
231
  browsing = true;
229
232
  try {
230
- await openTaskViewer(current, requireNode(current), programs, pi.getSettings().editorPaddingX ?? 0);
233
+ await openTaskViewer(current, requireNode(current), pi.getSettings().editorPaddingX ?? 0);
231
234
  } finally {
232
235
  browsing = false;
233
236
  }
@@ -0,0 +1,6 @@
1
+ You are the user's main Agent.
2
+
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,47 @@
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 Agent's Session file, as `main.json` names it. */
11
+ function mainSessionFile(): string | undefined {
12
+ try {
13
+ return (JSON.parse(readFileSync(statePaths().mainSession, "utf8")) as { sessionFile?: string }).sessionFile;
14
+ } catch {
15
+ return undefined;
16
+ }
17
+ }
18
+
19
+ /**
20
+ * The main Agent is the Session `main.json` names: `pd main` starts one with `--main`, and `/new` in it starts the
21
+ * next. It gets the main Agent's instructions.
22
+ */
23
+ export function registerMainAgent(pi: ExtensionAPI): void {
24
+ pi.registerFlag(MAIN_FLAG, { type: "boolean", description: "Start the main Agent (used by pd main)" });
25
+ let main = false;
26
+ pi.on("session_start", (event, ctx) => {
27
+ main = false;
28
+ if (treeMetadata(ctx)) return;
29
+ // Absolute, as `pd main` compares it from other directories; a session directory may be relative.
30
+ const current = ctx.sessionManager.getSessionFile();
31
+ const file = current && path.resolve(current);
32
+ const started = event.reason === "startup" && pi.getFlag(MAIN_FLAG) === true;
33
+ const renewed = event.reason === "new" && event.previousSessionFile !== undefined && path.resolve(event.previousSessionFile) === mainSessionFile();
34
+ if (file && (started || renewed)) {
35
+ const record = statePaths().mainSession;
36
+ mkdirSync(path.dirname(record), { recursive: true, mode: 0o700 });
37
+ // Replaced at once, so that a reader never sees it half written.
38
+ const temporary = `${record}.${process.pid}`;
39
+ writeFileSync(temporary, JSON.stringify({ sessionFile: file }));
40
+ renameSync(temporary, record);
41
+ }
42
+ main = file !== undefined && file === mainSessionFile();
43
+ });
44
+ pi.on("before_agent_start", (event) => {
45
+ if (main) event.systemPromptOptions.sections["main-agent"] = instructions;
46
+ });
47
+ }
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
- }
@@ -105,9 +105,9 @@ const sandboxExtras = new AsyncLocalStorage<SandboxExtras>();
105
105
  let sandboxPatch: Promise<void> | undefined;
106
106
 
107
107
  /**
108
- * Since Pi 1.3.0, its executor admits at most two codemode workers per process, which background
109
- * Programs waiting on Agents use up. A Program's worker is admitted on its own terms instead: any
110
- * number may run, and none starts while this many are still exiting after their scripts ended.
108
+ * Since Pi 1.3.0, its executor admits a limited number of codemode workers per process (two by default),
109
+ * which background Programs waiting on Agents use up. A Program's worker is admitted on its own terms
110
+ * instead: any number may run, and none starts while this many are still exiting after their scripts ended.
111
111
  */
112
112
  const MAX_EXITING_WORKERS = 2;
113
113
  const exitingWorkers = new Set<{ exited: boolean }>();
@@ -143,6 +143,26 @@ function programWorker(codemode: Awaited<ReturnType<typeof loadCodemode>>, wasmP
143
143
  };
144
144
  }
145
145
 
146
+ const WORKER_LIMIT_ENV = "PI_CODEMODE_MAX_WORKERS";
147
+
148
+ /**
149
+ * Since Pi 1.4.0, its executor also reserves one of its worker slots before a script starts and holds
150
+ * it until the script ends, so Programs waiting on Agents make later Programs wait. Its limit is raised
151
+ * while `start` calls the executor, which reserves before its first `await`, and restored as soon as the
152
+ * call returns. The Program still holds its slot, which only Programs share unless the host runs scripts
153
+ * through the same executor, as Pi from source or an SDK host does.
154
+ */
155
+ function admitAtOnce<T>(start: () => T): T {
156
+ const limit = process.env[WORKER_LIMIT_ENV];
157
+ process.env[WORKER_LIMIT_ENV] = String(Number.MAX_SAFE_INTEGER);
158
+ try {
159
+ return start();
160
+ } finally {
161
+ if (limit === undefined) delete process.env[WORKER_LIMIT_ENV];
162
+ else process.env[WORKER_LIMIT_ENV] = limit;
163
+ }
164
+ }
165
+
146
166
  async function patchSandbox(): Promise<void> {
147
167
  const [codemode, source] = await Promise.all([loadCodemode(), loadPiWorkerSource()]);
148
168
  const wasmPath = source.getQuickJSWasmPath();
@@ -269,11 +289,11 @@ export async function executeProgram(
269
289
  };
270
290
  let result: Awaited<ReturnType<typeof executeCodemode>>;
271
291
  try {
272
- result = await withSandboxExtras(extras, () => executeCodemode(toolCallId, { code: source }, signal, onUpdate, programCtx, {
292
+ result = await withSandboxExtras(extras, () => admitAtOnce(() => executeCodemode(toolCallId, { code: source }, signal, onUpdate, programCtx, {
273
293
  models: true,
274
294
  appendEntry: options.appendEntry,
275
295
  getToolNamespace: options.getToolNamespace,
276
- }));
296
+ })));
277
297
  } finally {
278
298
  if (!scriptEnded) extras.ended();
279
299
  }
@@ -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 { formatDuration, formatToolCall, moreLines, renderResultList, renderTextResult, startDuration, type DurationState } from "../render-call.ts";
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
- startDuration(context.state as DurationState, rest.action === "wait", context.executionStarted);
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 lines = highlightCode(code.replace(/\r/g, "").replace(/\t/g, " ").trimEnd(), "javascript");
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, and when collapsed, each result as a line with the first lines of its output;
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
  }