@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,156 @@
1
+ import { type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { onTreeChange, treeMetadata } from "../agents/registry.ts";
3
+ import { ensureDaemon } from "../daemon/client.mjs";
4
+ import { statePaths, sessionDir as resolveSessionDir } from "./paths.mjs";
5
+ import { openStream } from "./transport.mjs";
6
+
7
+ const RECONNECT_MS = 1000;
8
+ const SEND_DELAY_MS = 50;
9
+ const TEXT_LIMIT = 200;
10
+
11
+ export interface ReportHooks {
12
+ /** Whether the tree has no live work. */
13
+ treeIdle(): boolean;
14
+ /** The tree's running owned Agents and background Programs. */
15
+ liveWork(): number;
16
+ /** The host this Pi runs in, if any. */
17
+ host?: { socket: string; attached(): boolean; onChange(listener: () => void): void };
18
+ }
19
+
20
+ function firstLine(text: string, limit = TEXT_LIMIT): string {
21
+ const line = text.trim().split("\n")[0] ?? "";
22
+ return line.length > limit ? `${line.slice(0, limit - 1)}…` : line;
23
+ }
24
+
25
+ function textOf(content: unknown): string {
26
+ if (typeof content === "string") return content;
27
+ if (!Array.isArray(content)) return "";
28
+ return content.filter((block) => block?.type === "text").map((block) => block.text).join("\n");
29
+ }
30
+
31
+ function toolLabel(name: string, args: unknown): string {
32
+ const detail = args && typeof args === "object" ? Object.values(args).find((value) => typeof value === "string" && value.trim()) : undefined;
33
+ return detail ? `${name} ${firstLine(detail as string, 80)}` : name;
34
+ }
35
+
36
+ /**
37
+ * Reports this root Session's state to the daemon while it is loaded: whether it works, its
38
+ * running tool, its last reply and finished turn, and whether a terminal shows it. The daemon
39
+ * forwards interrupts from the workbench.
40
+ */
41
+ export function createReporter(pi: ExtensionAPI, hooks: ReportHooks): void {
42
+ const paths = statePaths();
43
+ const sessionDir = resolveSessionDir();
44
+ let ctx: ExtensionContext | undefined;
45
+ let stream: { send(message: object): void; close(): void } | undefined;
46
+ let generation = 0;
47
+ let timer: ReturnType<typeof setTimeout> | undefined;
48
+ let dialogs = 0;
49
+ let tools = new Map<string, string>();
50
+ let turn: { at: number; failed: boolean } | undefined;
51
+ let reply: string | undefined;
52
+ let title: string | undefined;
53
+ let unsubscribe: (() => void) | undefined;
54
+
55
+ function state() {
56
+ const current = ctx!;
57
+ const manager = current.sessionManager;
58
+ if (title === undefined) {
59
+ const first = manager.getEntries().find((entry) => entry.type === "message" && entry.message.role === "user");
60
+ if (first?.type === "message") title = firstLine(textOf((first.message as { content?: unknown }).content));
61
+ }
62
+ return {
63
+ id: manager.getSessionId(),
64
+ sessionFile: manager.getSessionFile(),
65
+ cwd: current.cwd,
66
+ name: pi.getSessionName(),
67
+ title,
68
+ host: hooks.host?.socket,
69
+ attached: hooks.host ? hooks.host.attached() : true,
70
+ working: !current.isIdle() || current.hasPendingMessages() || !hooks.treeIdle() || dialogs > 0,
71
+ blocked: dialogs > 0,
72
+ tool: [...tools.values()].at(-1),
73
+ reply,
74
+ agents: hooks.liveWork(),
75
+ turn,
76
+ };
77
+ }
78
+
79
+ function changed(): void {
80
+ if (timer || !ctx) return;
81
+ timer = setTimeout(() => {
82
+ timer = undefined;
83
+ if (ctx && stream) stream.send({ type: "state", state: state() });
84
+ }, SEND_DELAY_MS);
85
+ timer.unref();
86
+ }
87
+
88
+ async function connect(current: number): Promise<void> {
89
+ try {
90
+ await ensureDaemon(paths, { sessionDir });
91
+ const opened = await openStream(paths.daemon, { action: "stream", role: "session" }, {
92
+ onMessage: (message: { type?: string; id?: string }) => {
93
+ if (message?.type === "interrupt" && ctx && message.id === ctx.sessionManager.getSessionId()) ctx.abort();
94
+ },
95
+ onClose: () => {
96
+ if (stream !== opened) return;
97
+ stream = undefined;
98
+ retry(current);
99
+ },
100
+ });
101
+ opened.unref();
102
+ if (current !== generation) return opened.close();
103
+ stream = opened;
104
+ changed();
105
+ } catch {
106
+ retry(current);
107
+ }
108
+ }
109
+
110
+ // A daemon that exits, for example to update, is replaced by the next connection.
111
+ function retry(current: number): void {
112
+ if (current === generation) setTimeout(() => { if (current === generation) void connect(current); }, RECONNECT_MS).unref();
113
+ }
114
+
115
+ pi.on("session_start", (_event, current) => {
116
+ if (!current.sessionManager.getSessionFile() || treeMetadata(current)) return;
117
+ ctx = current;
118
+ unsubscribe ??= onTreeChange(() => changed());
119
+ void connect(++generation);
120
+ });
121
+ pi.on("session_shutdown", () => {
122
+ if (!ctx) return;
123
+ stream?.send({ type: "end", id: ctx.sessionManager.getSessionId() });
124
+ stream?.close();
125
+ stream = undefined;
126
+ generation++;
127
+ ctx = undefined;
128
+ unsubscribe?.();
129
+ unsubscribe = undefined;
130
+ dialogs = 0;
131
+ tools = new Map();
132
+ turn = reply = title = undefined;
133
+ });
134
+ pi.on("session_info_changed", () => changed());
135
+ pi.on("agent_start", () => changed());
136
+ pi.on("tool_execution_start", (event) => {
137
+ if (event.parentToolCallId) return;
138
+ tools.set(event.toolCallId, toolLabel(event.toolName, event.args));
139
+ changed();
140
+ });
141
+ pi.on("tool_execution_end", (event) => {
142
+ if (tools.delete(event.toolCallId)) changed();
143
+ });
144
+ pi.on("ui_prompt_start", () => { dialogs += 1; changed(); });
145
+ pi.on("ui_prompt_end", () => { dialogs = Math.max(0, dialogs - 1); changed(); });
146
+ pi.on("agent_settled", (event) => {
147
+ if (!ctx) return;
148
+ const last = ctx.sessionManager.getBranch().findLast((entry) => entry.type === "message" && entry.message.role === "assistant");
149
+ const message = last?.type === "message" && last.message.role === "assistant" ? last.message : undefined;
150
+ reply = message ? firstLine(textOf(message.content)) || firstLine(message.errorMessage ?? "") : undefined;
151
+ turn = { at: Date.now(), failed: !event.aborted && message?.stopReason === "error" };
152
+ tools = new Map();
153
+ changed();
154
+ });
155
+ hooks.host?.onChange(() => changed());
156
+ }
@@ -0,0 +1,174 @@
1
+ import { buildSessionContext, getPackageDir, hasTrustRequiringProjectResources, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { randomUUID } from "node:crypto";
3
+ import { existsSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { trackTreeWork, treeMetadata } from "../agents/registry.ts";
6
+ import { sendViaDaemon } from "../daemon/client.mjs";
7
+ import { prepareDirectory, statePaths, sessionDir as resolveSessionDir } from "./paths.mjs";
8
+ import { rootSnapshot } from "./snapshot.mjs";
9
+ import { recoverSocket } from "./socket-recovery.mjs";
10
+ import { listen, request } from "./transport.mjs";
11
+
12
+ export type RootDelivery = "followUp" | "steer" | "write";
13
+
14
+ export interface RootMessage {
15
+ id: string;
16
+ /** Absent on a message from the user, which `user` marks. */
17
+ sender?: { id: string; name?: string };
18
+ user?: true;
19
+ recipient: string;
20
+ body: string;
21
+ deliverAs?: RootDelivery;
22
+ }
23
+
24
+ export interface RootEntry {
25
+ id: string;
26
+ name?: string;
27
+ cwd: string;
28
+ state: "running" | "idle";
29
+ sessionFile: string;
30
+ }
31
+
32
+ export interface RootHooks {
33
+ /** Adds a message from another root to this Session. */
34
+ receive(message: RootMessage): void | Promise<void>;
35
+ /** Whether this root's tree has no inputs that have not ended. */
36
+ treeIdle(): boolean;
37
+ /** IDs of messages from other roots already in this Session. */
38
+ receivedIds(ctx: ExtensionContext): Iterable<string>;
39
+ }
40
+
41
+ function piCli(): string {
42
+ const dir = getPackageDir();
43
+ const bundled = join(dir, "dist/bundle/cli.js");
44
+ return existsSync(bundled) ? bundled : join(dir, "dist/cli.js");
45
+ }
46
+
47
+ /**
48
+ * Makes this root Agent reachable from other processes: it answers status requests,
49
+ * and accepts messages from other roots.
50
+ */
51
+ export function createRootRuntime(pi: ExtensionAPI, hooks: RootHooks) {
52
+ const paths = statePaths();
53
+ const sessionDir = resolveSessionDir();
54
+ let active: ExtensionContext | undefined;
55
+ let server: Awaited<ReturnType<typeof listen>> | undefined;
56
+ let ready = false;
57
+ let seen = new Set<string>();
58
+
59
+ function availabilityError(): string | undefined {
60
+ const ctx = active!;
61
+ if (!ctx.model) return "The recipient has no available model. Open the Session interactively and select a model.";
62
+ const saved = buildSessionContext(ctx.sessionManager.getEntries(), ctx.sessionManager.getLeafId()).model;
63
+ if (saved && (saved.provider !== ctx.model.provider || saved.modelId !== ctx.model.id)) {
64
+ return `Saved model ${saved.provider}/${saved.modelId} is unavailable; refusing model fallback.`;
65
+ }
66
+ if (hasTrustRequiringProjectResources(ctx.cwd) && !ctx.isProjectTrusted()) {
67
+ return `Interactive project authorization is required in ${ctx.cwd}. Open the Session interactively first.`;
68
+ }
69
+ }
70
+
71
+ function idle(): boolean {
72
+ return active !== undefined && active.isIdle() && !active.hasPendingMessages() && hooks.treeIdle();
73
+ }
74
+
75
+ function current() {
76
+ const ctx = active!;
77
+ return {
78
+ id: ctx.sessionManager.getSessionId(),
79
+ name: pi.getSessionName(),
80
+ cwd: ctx.cwd,
81
+ sessionFile: ctx.sessionManager.getSessionFile()!,
82
+ state: idle() ? "idle" : "running",
83
+ ready,
84
+ pid: process.pid,
85
+ model: ctx.model ? { provider: ctx.model.provider, modelId: ctx.model.id } : undefined,
86
+ availabilityError: availabilityError(),
87
+ };
88
+ }
89
+
90
+ async function accept(message: RootMessage, { signal }: { signal: AbortSignal }) {
91
+ if (!ready || !active) throw new Error("The recipient is not ready.");
92
+ if (message.recipient !== active.sessionManager.getSessionId()) throw new Error("Message recipient does not match this Agent.");
93
+ const error = availabilityError();
94
+ if (error) throw new Error(error);
95
+ signal.throwIfAborted();
96
+ if (!seen.has(message.id)) {
97
+ seen.add(message.id);
98
+ // The tree stays busy until the message is in the Session.
99
+ const release = trackTreeWork(message.recipient);
100
+ try {
101
+ await hooks.receive(message);
102
+ } finally {
103
+ release();
104
+ }
105
+ }
106
+ return { accepted: true, messageId: message.id };
107
+ }
108
+
109
+ pi.on("resources_discover", () => {
110
+ if (!active || !server) return;
111
+ ready = true;
112
+ });
113
+
114
+ return {
115
+ /** Other root Agents with their state. Only a root Agent sees them. */
116
+ async roots(signal?: AbortSignal): Promise<RootEntry[]> {
117
+ if (!active || !ready) return [];
118
+ const self = current();
119
+ const records = await rootSnapshot(paths, sessionDir, { signal, current: self });
120
+ return records
121
+ .filter((root: { id: string }) => root.id !== self.id)
122
+ .map(({ id, name, cwd, state, sessionFile }: RootEntry) => ({ id, name, cwd, state, sessionFile }));
123
+ },
124
+
125
+ /** Sends to another root: inputs start it in a host through the daemon, writes reach it only while it runs. */
126
+ async send(target: { id: string; label: string }, delivery: RootDelivery, body: string, signal?: AbortSignal): Promise<void> {
127
+ if (!active || !ready) throw new Error("Root messaging is not ready; only persistent root Sessions participate.");
128
+ const sender = { id: active.sessionManager.getSessionId(), ...(pi.getSessionName() ? { name: pi.getSessionName() } : {}) };
129
+ const message: RootMessage = { id: randomUUID(), sender, recipient: target.id, body, deliverAs: delivery };
130
+ let receipt: { accepted?: boolean; messageId?: string };
131
+ if (delivery === "write") {
132
+ try {
133
+ receipt = await request(paths.session(target.id), { action: "deliver", message }, { signal });
134
+ } catch (error) {
135
+ const code = (error as NodeJS.ErrnoException).code;
136
+ if (code === "ENOENT" || code === "ECONNREFUSED") throw new Error(`${target.label} is offline.`);
137
+ throw error;
138
+ }
139
+ } else {
140
+ // The recipient runs with this Session's Pi, environment, and pi-agents, which Pi loads once.
141
+ const extension = pi.getAllTools().find((tool) => tool.name === "agent")!.sourceInfo.path;
142
+ const env = Object.fromEntries(Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined));
143
+ const launch = { command: process.execPath, args: [piCli(), "-e", extension], env };
144
+ receipt = await sendViaDaemon(paths, message, { sessionDir, launch, signal });
145
+ }
146
+ if (receipt?.accepted !== true || receipt.messageId !== message.id) {
147
+ throw new Error(`Invalid acknowledgement from ${target.label}; it may have received the message.`);
148
+ }
149
+ },
150
+
151
+ async start(ctx: ExtensionContext): Promise<void> {
152
+ if (server && active?.sessionManager === ctx.sessionManager) {
153
+ active = ctx;
154
+ return;
155
+ }
156
+ if (!ctx.sessionManager.getSessionFile() || treeMetadata(ctx)) return;
157
+ active = ctx;
158
+ seen = new Set(hooks.receivedIds(ctx));
159
+ await prepareDirectory(paths.runtime);
160
+ const socket = paths.session(ctx.sessionManager.getSessionId());
161
+ await recoverSocket(socket);
162
+ server = await listen(socket, { status: current, accept });
163
+ },
164
+
165
+ async stop(): Promise<void> {
166
+ ready = false;
167
+ await server?.close();
168
+ server = undefined;
169
+ active = undefined;
170
+ },
171
+ };
172
+ }
173
+
174
+ export type RootRuntime = ReturnType<typeof createRootRuntime>;
@@ -0,0 +1,46 @@
1
+ import { readdir, realpath } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { discoverRoots } from './discovery.mjs';
4
+ import { isLocked } from './locks.mjs';
5
+ import { markedFiles } from '../daemon/sessions.mjs';
6
+ import { request } from './transport.mjs';
7
+
8
+ /**
9
+ * Root Agents on disk with their state: `running` or `idle` when their runtime answers,
10
+ * `running` when another runtime holds the Session without answering, `idle` otherwise.
11
+ */
12
+ export async function rootSnapshot(paths, sessionDir, { signal, current } = {}) {
13
+ let sockets;
14
+ try { sockets = (await readdir(paths.runtime)).filter(name => /^s-[a-f0-9]{24}\.sock$/.test(name)); }
15
+ catch (error) { if (error.code === 'ENOENT') sockets = []; else throw error; }
16
+ const online = new Map();
17
+ for (const socket of sockets) {
18
+ signal?.throwIfAborted();
19
+ try {
20
+ const root = await request(join(paths.runtime, socket), { action: 'status' }, { signal, timeoutMs: 1000 });
21
+ if (root?.ready === true && typeof root.id === 'string' && typeof root.cwd === 'string' && typeof root.sessionFile === 'string'
22
+ && ['idle', 'running'].includes(root.state) && paths.session(root.id) === join(paths.runtime, socket)) online.set(root.id, root);
23
+ } catch (error) {
24
+ signal?.throwIfAborted();
25
+ if (!['ENOENT', 'ECONNREFUSED', 'ETIMEDOUT', 'CONNECTION_CLOSED', 'INVALID_FRAME'].includes(error.code)) throw error;
26
+ }
27
+ }
28
+ if (current) online.set(current.id, current);
29
+ // Online roots report their own metadata; skip reparsing their growing Session files.
30
+ const skip = new Set([...online.values()].map(root => root.sessionFile));
31
+ const records = await discoverRoots(sessionDir, { extraFiles: await markedFiles(paths), skip, signal });
32
+ const roots = [];
33
+ for (const record of records) {
34
+ signal?.throwIfAborted();
35
+ if (online.has(record.id)) continue;
36
+ try {
37
+ record.state = isLocked(paths.locks, await realpath(record.sessionFile), record.id) ? 'running' : 'idle';
38
+ } catch (error) {
39
+ if (error.code === 'ENOENT') continue;
40
+ throw error;
41
+ }
42
+ roots.push(record);
43
+ }
44
+ roots.push(...online.values());
45
+ return roots;
46
+ }
@@ -0,0 +1,21 @@
1
+ import { lstat, unlink } from 'node:fs/promises';
2
+ import { request } from './transport.mjs';
3
+
4
+ // Call only while holding the endpoint's lock. A missing registry
5
+ // entry or stale PID alone never authorizes removing a socket.
6
+ export async function recoverSocket(path) {
7
+ let before;
8
+ try { before = await lstat(path); }
9
+ catch (error) { if (error.code === 'ENOENT') return; throw error; }
10
+ if (!before.isSocket() || before.uid !== process.getuid()) throw new Error(`Refusing to replace a non-owned socket: ${path}`);
11
+ try {
12
+ await request(path, { action: 'status' }, { timeoutMs: 1000 });
13
+ throw new Error(`Another runtime is responding at ${path}`);
14
+ } catch (error) {
15
+ if (error.code !== 'ECONNREFUSED') throw error;
16
+ }
17
+ const after = await lstat(path);
18
+ if (after.dev !== before.dev || after.ino !== before.ino) throw new Error(`Socket changed during recovery: ${path}`);
19
+ await unlink(path);
20
+ }
21
+