@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.
Files changed (48) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +81 -2
  3. package/bin/daemon.mjs +204 -0
  4. package/bin/host.mjs +170 -0
  5. package/bin/pd.mjs +139 -0
  6. package/docs/PROGRAM.md +310 -0
  7. package/docs/SPEC.md +427 -0
  8. package/docs/WORKBENCH.md +165 -0
  9. package/package.json +62 -4
  10. package/src/agents/agents.ts +882 -0
  11. package/src/agents/registry.ts +257 -0
  12. package/src/agents/scheduler.ts +204 -0
  13. package/src/agents/search.ts +131 -0
  14. package/src/agents/tool.ts +205 -0
  15. package/src/daemon/client.mjs +84 -0
  16. package/src/daemon/router.mjs +103 -0
  17. package/src/daemon/sessions.mjs +129 -0
  18. package/src/daemon/version.mjs +19 -0
  19. package/src/host/client.mjs +139 -0
  20. package/src/host/extension.ts +74 -0
  21. package/src/host/frames.mjs +34 -0
  22. package/src/host/keys.mjs +19 -0
  23. package/src/index.ts +208 -0
  24. package/src/output.ts +56 -0
  25. package/src/params.ts +4 -0
  26. package/src/pi.mjs +16 -0
  27. package/src/programs/codemode.ts +74 -0
  28. package/src/programs/execute.ts +131 -0
  29. package/src/programs/host-patches.ts +128 -0
  30. package/src/programs/loadout.ts +99 -0
  31. package/src/programs/programs.ts +284 -0
  32. package/src/programs/sandbox.ts +90 -0
  33. package/src/programs/scope.ts +94 -0
  34. package/src/programs/tool.ts +198 -0
  35. package/src/render-call.ts +90 -0
  36. package/src/roots/discovery.mjs +113 -0
  37. package/src/roots/guard.mjs +227 -0
  38. package/src/roots/lock-extension.mjs +167 -0
  39. package/src/roots/locks.mjs +167 -0
  40. package/src/roots/paths.mjs +66 -0
  41. package/src/roots/report.ts +156 -0
  42. package/src/roots/runtime.ts +174 -0
  43. package/src/roots/snapshot.mjs +46 -0
  44. package/src/roots/socket-recovery.mjs +21 -0
  45. package/src/roots/transport.mjs +341 -0
  46. package/src/ui/panel.ts +96 -0
  47. package/src/ui/viewer.ts +445 -0
  48. package/src/workbench.mjs +587 -0
@@ -0,0 +1,445 @@
1
+ import {
2
+ AssistantMessageComponent,
3
+ CompactionSummaryMessageComponent,
4
+ CustomEditor,
5
+ CustomMessageComponent,
6
+ DynamicBorder,
7
+ FooterComponent,
8
+ getMarkdownTheme,
9
+ getSelectListTheme,
10
+ keyHint,
11
+ keyText,
12
+ ToolExecutionComponent,
13
+ UserMessageComponent,
14
+ type AgentSession,
15
+ type AgentSessionEvent,
16
+ type ExtensionContext,
17
+ type ReadonlyFooterDataProvider,
18
+ type Theme,
19
+ } from "@earendil-works/pi-coding-agent";
20
+ import {
21
+ Container,
22
+ fuzzyFilter,
23
+ Input,
24
+ Loader,
25
+ matchesKey,
26
+ SelectList,
27
+ Spacer,
28
+ Text,
29
+ truncateToWidth,
30
+ visibleWidth,
31
+ type Component,
32
+ type Focusable,
33
+ type KeybindingsManager,
34
+ type SelectItem,
35
+ type TUI,
36
+ } from "@earendil-works/pi-tui";
37
+ import { MESSAGE_TYPE, nodes, onTreeChange, programLabel, programsOf, type AgentNode, type Entry } from "../agents/registry.ts";
38
+ import { latestActivity } from "../agents/search.ts";
39
+ import { execFileSync } from "node:child_process";
40
+
41
+ type Message = AgentSession["messages"][number];
42
+ type ToolResult = Parameters<ToolExecutionComponent["updateResult"]>[0];
43
+ type StatusIndicator = Parameters<CustomEditor["setWorkingStatusIndicator"]>[0];
44
+
45
+ /** A spinner and message for the editor's top border, like Pi's working status. */
46
+ class WorkingStatus extends Loader {
47
+ renderInBorder(width: number): string {
48
+ const line = super.render(width + 2)[1] ?? "";
49
+ return truncateToWidth(line.startsWith(" ") ? line.slice(1).trimEnd() : line.trimEnd(), width, "");
50
+ }
51
+
52
+ renderSpinnerInBorder(width: number): string {
53
+ return truncateToWidth(this.getRenderedIndicator(), width, "");
54
+ }
55
+
56
+ dispose(): void {
57
+ this.stop();
58
+ }
59
+ }
60
+
61
+ /** What Pi's footer needs beyond the Session: the Agent's git branch, and no extension statuses. */
62
+ function footerData(cwd: string, ctx: ExtensionContext): ReadonlyFooterDataProvider {
63
+ let branch: string | null;
64
+ try {
65
+ branch = execFileSync("git", ["-C", cwd, "branch", "--show-current"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || null;
66
+ } catch {
67
+ branch = null;
68
+ }
69
+ const providers = new Set(ctx.modelRegistry.getAvailable().map((model) => model.provider)).size;
70
+ return {
71
+ getGitBranch: () => branch,
72
+ getExtensionStatuses: () => new Map(),
73
+ getAvailableProviderCount: () => providers,
74
+ onBranchChange: () => () => {},
75
+ };
76
+ }
77
+
78
+ /** The user's view of one owned Agent: its conversation, and an editor whose text goes to it. */
79
+ class AgentViewer implements Component, Focusable {
80
+ private readonly editor: CustomEditor;
81
+ private readonly transcript = new Container();
82
+ private readonly footer: FooterComponent;
83
+ private session?: AgentSession;
84
+ private streaming?: Message;
85
+ /** The streaming message's component and tool calls from the last rebuild, which its updates change in place. */
86
+ private live?: { component: AssistantMessageComponent; calls: string };
87
+ /** Tool components without a result from the last rebuild. */
88
+ private tools = new Map<string, ToolExecutionComponent>();
89
+ private running = new Map<string, { name: string; partial?: ToolResult }>();
90
+ private expanded = false;
91
+ /** Lines scrolled up from the end. */
92
+ private scroll = 0;
93
+ private dirty = true;
94
+ private status = "";
95
+ private indicator?: WorkingStatus;
96
+ /** Inputs sent from the viewer that the Agent has not received yet, as Pi shows queued messages. */
97
+ private outbox: Array<{ text: string; delivery: "followUp" | "steer"; after: number }> = [];
98
+ private disposers: Array<() => void> = [];
99
+ private _focused = false;
100
+
101
+ constructor(
102
+ private tui: TUI,
103
+ private theme: Theme,
104
+ keys: KeybindingsManager,
105
+ private ctx: ExtensionContext,
106
+ private owner: AgentNode,
107
+ private entry: Entry,
108
+ session: AgentSession,
109
+ paddingX: number,
110
+ private done: () => void,
111
+ ) {
112
+ this.footer = new FooterComponent(session, footerData(entry.cwd, ctx));
113
+ this.editor = new CustomEditor(tui, { borderColor: (text) => theme.fg("border", text), selectList: getSelectListTheme() }, keys as never, {
114
+ paddingX,
115
+ embedWorkingStatus: true,
116
+ });
117
+ this.editor.onSubmit = (text) => this.send(text, "steer");
118
+ this.editor.onEscape = () => void this.owner.agents.abortAgent(this.entry.id).catch((error: unknown) =>
119
+ this.ctx.ui.notify(error instanceof Error ? error.message : String(error), "error"));
120
+ this.editor.onAction("app.clear", () => {
121
+ if (this.editor.getText()) this.editor.setText("");
122
+ else this.done();
123
+ });
124
+ this.editor.onAction("app.message.followUp", () => this.send(this.editor.getText(), "followUp"));
125
+ this.editor.onAction("app.tools.expand", () => {
126
+ this.expanded = !this.expanded;
127
+ this.dirty = true;
128
+ this.tui.requestRender();
129
+ });
130
+ this.disposers.push(onTreeChange(() => this.refresh()));
131
+ this.attach();
132
+ }
133
+
134
+ get focused(): boolean {
135
+ return this._focused;
136
+ }
137
+
138
+ set focused(value: boolean) {
139
+ this._focused = value;
140
+ this.editor.focused = value;
141
+ }
142
+
143
+ handleInput(data: string): void {
144
+ const page = Math.max(1, this.tui.terminal.rows - 8);
145
+ if (matchesKey(data, "pageUp")) this.scroll += page;
146
+ else if (matchesKey(data, "pageDown")) this.scroll = Math.max(0, this.scroll - page);
147
+ else this.editor.handleInput(data);
148
+ this.tui.requestRender();
149
+ }
150
+
151
+ render(width: number): string[] {
152
+ const conversation = this.owner.agents.conversation(this.entry.id);
153
+ if (this.dirty) this.rebuild(conversation);
154
+ this.syncStatus(conversation);
155
+ const queue = this.outbox.map(({ text, delivery }) =>
156
+ truncateToWidth(this.theme.fg("dim", ` ${delivery === "steer" ? "Steering" : "Follow-up"}: ${text.replace(/\s+/g, " ")}`), width));
157
+ const editor = this.editor.render(width);
158
+ const [cwd = "", ...footer] = this.footer.render(width);
159
+ // The way back sits at the right of the footer's first line, when it fits.
160
+ const back = keyHint("app.clear", "back");
161
+ const gap = width - visibleWidth(cwd) - visibleWidth(back);
162
+ footer.unshift(gap >= 2 ? cwd + " ".repeat(gap) + back : cwd);
163
+ const budget = Math.max(1, this.tui.terminal.rows - queue.length - editor.length - footer.length - 1);
164
+ const lines = this.transcript.render(width);
165
+ this.scroll = Math.min(this.scroll, Math.max(0, lines.length - budget));
166
+ const end = lines.length - this.scroll;
167
+ const shown = lines.slice(Math.max(0, end - budget), end);
168
+ // Fill the screen so the Session behind the viewer does not show through.
169
+ const blank = " ".repeat(width);
170
+ const padding = Array.from({ length: budget - shown.length }, () => blank);
171
+ return [...padding, ...shown, blank, ...queue, ...editor, ...footer];
172
+ }
173
+
174
+ invalidate(): void {
175
+ this.dirty = true;
176
+ this.editor.invalidate();
177
+ }
178
+
179
+ dispose(): void {
180
+ this.indicator?.dispose();
181
+ this.footer.dispose();
182
+ for (const dispose of this.disposers.splice(0)) dispose();
183
+ }
184
+
185
+ private send(text: string, delivery: "followUp" | "steer"): void {
186
+ const trimmed = text.trim();
187
+ if (!trimmed) return;
188
+ try {
189
+ const after = this.owner.agents.conversation(this.entry.id)?.messages.length ?? 0;
190
+ this.owner.agents.prompt(this.entry.id, trimmed, delivery);
191
+ this.outbox.push({ text: trimmed, delivery, after });
192
+ this.editor.addToHistory(trimmed);
193
+ this.editor.setText("");
194
+ this.scroll = 0;
195
+ this.dirty = true;
196
+ } catch (error) {
197
+ this.ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
198
+ }
199
+ }
200
+
201
+ /** Follows the Agent's Session once it is loaded. */
202
+ private attach(): void {
203
+ const session = this.owner.agents.conversation(this.entry.id)?.session;
204
+ if (!session || session === this.session) return;
205
+ this.session = session;
206
+ this.disposers.push(session.subscribe((event) => {
207
+ if (event.type === "message_start" || event.type === "message_update") this.streaming = event.message;
208
+ else if (event.type === "message_end") this.streaming = undefined;
209
+ else if (event.type === "tool_execution_start") this.running.set(event.toolCallId, { name: event.toolName });
210
+ else if (event.type === "tool_execution_update") {
211
+ const tool = this.running.get(event.toolCallId);
212
+ if (tool) tool.partial = event.partialResult as ToolResult;
213
+ } else if (event.type === "tool_execution_end") this.running.delete(event.toolCallId);
214
+ if (!this.update(event)) this.dirty = true;
215
+ this.tui.requestRender();
216
+ }));
217
+ }
218
+
219
+ /** Applies a streaming update to the shown components, as Pi does; false when the transcript needs a rebuild. */
220
+ private update(event: AgentSessionEvent): boolean {
221
+ if (this.dirty) return false;
222
+ if (event.type === "message_update") {
223
+ if (!this.live || event.message.role !== "assistant") return false;
224
+ const calls = event.message.content.flatMap((part) => part.type === "toolCall" ? [part] : []);
225
+ if (calls.map((call) => call.id).join("\n") !== this.live.calls) return false;
226
+ this.live.component.updateContent(event.message);
227
+ for (const call of calls) this.tools.get(call.id)?.updateArgs(call.arguments);
228
+ return true;
229
+ }
230
+ if (event.type === "tool_execution_start" || event.type === "tool_execution_update") {
231
+ const tool = this.tools.get(event.toolCallId);
232
+ if (!tool) return false;
233
+ if (event.type === "tool_execution_start") tool.markExecutionStarted();
234
+ else tool.updateResult(event.partialResult as ToolResult, true);
235
+ return true;
236
+ }
237
+ return false;
238
+ }
239
+
240
+ private refresh(): void {
241
+ this.attach();
242
+ this.dirty = true;
243
+ this.tui.requestRender();
244
+ }
245
+
246
+ /** The working status in the editor's border, and its color from the Agent's thinking level, as in Pi. */
247
+ private syncStatus(conversation: ReturnType<AgentNode["agents"]["conversation"]>): void {
248
+ this.editor.borderColor = this.theme.getThinkingBorderColor((conversation?.thinkingLevel ?? "off") as never);
249
+ const state = this.owner.agents.counts(this.entry.id)?.state;
250
+ const busy = state === "running" || state === "waiting";
251
+ const tool = [...this.running.values()].at(-1);
252
+ const abort = `${keyText("app.interrupt")} to abort`;
253
+ const text = !busy ? "" : tool ? `Running ${tool.name}... (${abort})` : `Working... (${abort})`;
254
+ if (text === this.status) return;
255
+ this.status = text;
256
+ this.indicator?.dispose();
257
+ this.indicator = text
258
+ ? new WorkingStatus(this.tui, (part) => this.editor.borderColor(part), (part) => this.theme.fg("muted", part), text)
259
+ : undefined;
260
+ this.editor.setWorkingStatusIndicator(this.indicator as unknown as StatusIndicator);
261
+ }
262
+
263
+ private rebuild(conversation: ReturnType<AgentNode["agents"]["conversation"]>): void {
264
+ this.dirty = false;
265
+ this.transcript.clear();
266
+ this.live = undefined;
267
+ this.tools.clear();
268
+ if (!conversation) return;
269
+ const messages = [...conversation.messages];
270
+ // An input leaves the outbox once it is in the conversation, or when the Agent ends its work without it.
271
+ const state = this.owner.agents.counts(this.entry.id)?.state;
272
+ const working = state !== undefined && state !== "idle";
273
+ this.outbox = this.outbox.filter(({ text, after }) => working && !messages.slice(after).some((message) =>
274
+ message.role === "custom" && message.content === text && (message.details as { user?: boolean } | undefined)?.user));
275
+ if (this.streaming && !messages.includes(this.streaming)) messages.push(this.streaming);
276
+ const markdown = getMarkdownTheme();
277
+ for (const message of messages) {
278
+ if (message.role === "assistant") {
279
+ const component = new AssistantMessageComponent(message, false, markdown);
280
+ if (message === this.streaming) {
281
+ const calls = message.content.flatMap((part) => part.type === "toolCall" ? [part.id] : []);
282
+ this.live = { component, calls: calls.join("\n") };
283
+ }
284
+ this.transcript.addChild(component);
285
+ for (const part of message.content) {
286
+ if (part.type !== "toolCall") continue;
287
+ const tool = new ToolExecutionComponent(part.name, part.id, part.arguments, { showImages: false },
288
+ conversation.session?.getToolDefinition(part.name), this.tui, conversation.cwd);
289
+ tool.setExpanded(this.expanded);
290
+ this.transcript.addChild(tool);
291
+ if (message.stopReason === "aborted" || message.stopReason === "error") {
292
+ tool.updateResult({ content: [{ type: "text", text: message.errorMessage || "Operation aborted" }], isError: true });
293
+ } else {
294
+ this.tools.set(part.id, tool);
295
+ }
296
+ }
297
+ } else if (message.role === "toolResult") {
298
+ this.tools.get(message.toolCallId)?.updateResult(message);
299
+ this.tools.delete(message.toolCallId);
300
+ } else if (message.role === "user") {
301
+ const text = typeof message.content === "string"
302
+ ? message.content
303
+ : message.content.flatMap((part) => part.type === "text" ? [part.text] : []).join("\n");
304
+ if (!text) continue;
305
+ this.transcript.addChild(new Spacer(1));
306
+ this.transcript.addChild(new UserMessageComponent(text, markdown));
307
+ } else if (message.role === "custom" && message.display) {
308
+ const user = message.customType === MESSAGE_TYPE && (message.details as { user?: boolean } | undefined)?.user;
309
+ this.transcript.addChild(new Spacer(1));
310
+ if (user && typeof message.content === "string") {
311
+ this.transcript.addChild(new UserMessageComponent(message.content, markdown));
312
+ } else {
313
+ const component = new CustomMessageComponent(message, undefined, markdown);
314
+ component.setExpanded(this.expanded);
315
+ this.transcript.addChild(component);
316
+ }
317
+ } else if (message.role === "compactionSummary") {
318
+ this.transcript.addChild(new Spacer(1));
319
+ this.transcript.addChild(new CompactionSummaryMessageComponent(message, markdown));
320
+ }
321
+ }
322
+ for (const [id, tool] of this.tools) {
323
+ const running = this.running.get(id);
324
+ if (!running) continue;
325
+ tool.markExecutionStarted();
326
+ if (running.partial) tool.updateResult(running.partial, true);
327
+ }
328
+ }
329
+ }
330
+
331
+ /** A filterable list in place of the editor, like Pi Durable's conversation switcher. */
332
+ class ListSelector extends Container implements Focusable {
333
+ private readonly input = new Input();
334
+ private readonly listContainer = new Container();
335
+ private list: SelectList;
336
+ private _focused = false;
337
+
338
+ constructor(
339
+ title: string,
340
+ private items: SelectItem[],
341
+ private theme: Theme,
342
+ private keys: KeybindingsManager,
343
+ private onSelect: (value: string) => void,
344
+ private onCancel: () => void,
345
+ ) {
346
+ super();
347
+ this.list = this.build(items);
348
+ this.addChild(new DynamicBorder());
349
+ this.addChild(new Spacer(1));
350
+ this.addChild(new Text(theme.fg("accent", theme.bold(title)), 1, 0));
351
+ this.addChild(this.input);
352
+ this.addChild(new Spacer(1));
353
+ this.addChild(this.listContainer);
354
+ this.addChild(new DynamicBorder());
355
+ }
356
+
357
+ get focused(): boolean {
358
+ return this._focused;
359
+ }
360
+
361
+ set focused(value: boolean) {
362
+ this._focused = value;
363
+ this.input.focused = value;
364
+ }
365
+
366
+ handleInput(data: string): void {
367
+ const forwarded = ["tui.select.up", "tui.select.down", "tui.select.confirm", "tui.select.cancel"] as const;
368
+ if (forwarded.some((action) => this.keys.matches(data, action))) {
369
+ this.list.handleInput(data);
370
+ return;
371
+ }
372
+ this.input.handleInput(data);
373
+ const query = this.input.getValue();
374
+ this.list = this.build(query.length === 0 ? this.items : fuzzyFilter(this.items, query, (item) => `${item.label} ${item.value}`));
375
+ }
376
+
377
+ private build(items: SelectItem[]): SelectList {
378
+ const list = new SelectList(items, 10, {
379
+ ...getSelectListTheme(),
380
+ selectedPrefix: (text) => this.theme.fg("accent", text),
381
+ selectedText: (text) => this.theme.fg("accent", text),
382
+ description: (text) => this.theme.fg("muted", text),
383
+ });
384
+ list.onSelect = (item) => this.onSelect(item.value);
385
+ list.onCancel = this.onCancel;
386
+ this.listContainer.clear();
387
+ this.listContainer.addChild(list);
388
+ return list;
389
+ }
390
+ }
391
+
392
+ /** The Agents in the current Agent's tree, including Programs' Agents, those not idle first and newest first within each group. */
393
+ export function ownedEntries(self: AgentNode): Entry[] {
394
+ const entries: Entry[] = [];
395
+ const visit = (node: AgentNode) => {
396
+ for (const agent of node.agents.owned()) {
397
+ entries.push({ ...agent, ownerId: node.id });
398
+ const loaded = nodes.get(agent.id);
399
+ if (loaded) visit(loaded);
400
+ }
401
+ for (const program of programsOf(node)) visit(program);
402
+ };
403
+ visit(self);
404
+ return entries.sort((a, b) => Number(b.state !== "idle") - Number(a.state !== "idle") || (b.createdAt ?? "").localeCompare(a.createdAt ?? ""));
405
+ }
406
+
407
+ /** `/agents`: picks an owned Agent and opens it in the viewer. */
408
+ /** `paddingX` is Pi's editor padding, so the viewer's editor lines up as Pi's does. */
409
+ export async function openAgentViewer(ctx: ExtensionContext, self: AgentNode, paddingX: number): Promise<void> {
410
+ const entries = ownedEntries(self);
411
+ if (entries.length === 0) {
412
+ ctx.ui.notify("No owned Agents.", "info");
413
+ return;
414
+ }
415
+ const byId = new Map(entries.map((entry) => [entry.id, entry]));
416
+ // An Agent under another owned Agent or a Program is named with its owners up to the current Agent.
417
+ const owners = (ownerId: string | undefined): string => {
418
+ const owner = ownerId === undefined ? undefined : byId.get(ownerId);
419
+ if (owner) return ` ‹ ${path(owner)}`;
420
+ const program = ownerId === undefined ? undefined : nodes.get(ownerId);
421
+ return program?.program ? ` ‹ ${programLabel(program)}${owners(program.ownerId)}` : "";
422
+ };
423
+ const path = (entry: Entry): string => `${entry.name ?? entry.id}${owners(entry.ownerId)}`;
424
+ const items: SelectItem[] = entries.map((entry) => ({
425
+ value: entry.id,
426
+ label: path(entry),
427
+ description: [entry.state, latestActivity(entry.sessionFile) ?? ""].filter(Boolean).join(" "),
428
+ }));
429
+ const id = await ctx.ui.custom<string | undefined>((_tui, theme, keys, done) =>
430
+ new ListSelector("Open Agent:", items, theme, keys, (value) => done(value), () => done(undefined)));
431
+ const entry = id === undefined ? undefined : byId.get(id);
432
+ const owner = entry?.ownerId === undefined ? undefined : nodes.get(entry.ownerId);
433
+ if (!entry || !owner) return;
434
+ let session: AgentSession;
435
+ try {
436
+ session = await owner.agents.open(entry.id);
437
+ } catch (error) {
438
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
439
+ return;
440
+ }
441
+ await ctx.ui.custom<void>(
442
+ (tui, theme, keys, done) => new AgentViewer(tui, theme, keys, ctx, owner, entry, session, paddingX, () => done()),
443
+ { overlay: true, overlayOptions: { width: "100%", maxHeight: "100%", anchor: "bottom-center" } },
444
+ );
445
+ }