@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,205 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { StringEnum } from "@earendil-works/pi-ai";
3
+ import { Type } from "typebox";
4
+ import { boundBlocks, boundText } from "../output.ts";
5
+ import { rejectFields } from "../params.ts";
6
+ import { renderTextResult, renderToolCall, startDuration, type DurationState } from "../render-call.ts";
7
+ import type { RootRuntime } from "../roots/runtime.ts";
8
+ import {
9
+ THINKING_LEVELS,
10
+ type Limits,
11
+ type WaitOutcome,
12
+ type WaitResult,
13
+ } from "./agents.ts";
14
+ import {
15
+ deliver,
16
+ label,
17
+ nodes,
18
+ rememberRoots,
19
+ shortId,
20
+ resolveIn,
21
+ treeEntries,
22
+ type AgentNode,
23
+ type Entry,
24
+ } from "./registry.ts";
25
+ import { listLine, searchEntries } from "./search.ts";
26
+
27
+ function resultBlock({ agent, input, history }: WaitResult, ids: string[]): string {
28
+ return `<agent-result name="${agent.name}" id="${shortId(agent.id, ids)}" status="${input.state}"${history ? ' history="true"' : ""}>\n${input.result ?? ""}\n</agent-result>`;
29
+ }
30
+
31
+ function resultDetails({ agent, input, history }: WaitResult, result?: string) {
32
+ return { id: agent.id, name: agent.name, state: input.state, history, ...(result === undefined ? {} : { result }) };
33
+ }
34
+
35
+ /** Renders wait results; returns the text and the results the caller has now read. */
36
+ function renderWait(outcome: WaitOutcome, ids: string[]): { text: string; read: WaitResult[]; details: Record<string, unknown> } {
37
+ const { results, pending } = outcome;
38
+ const pendingLine = pending.length > 0 ? `Still pending: ${pending.map((agent) => label(agent, ids)).join(", ")}` : "";
39
+ if (results.length === 0) return { text: pendingLine || "No results.", read: [], details: { results: [], pending: pending.map((agent) => agent.id) } };
40
+ const only = results[0]!;
41
+ if (results.length === 1 && pending.length === 0 && !only.history && only.input.state === "completed") {
42
+ const bounded = boundText(only.input.result ?? "", "pi-agents-wait");
43
+ return { text: bounded.text, read: results, details: { results: [resultDetails(only, bounded.kept)], pending: [], ...bounded.details } };
44
+ }
45
+ const blocks = results.map((result) => resultBlock(result, ids));
46
+ const bounded = boundBlocks(blocks, "agent-result", "pi-agents-wait");
47
+ let offset = 0;
48
+ const details = results.map((result, index) => {
49
+ const block = blocks[index]!;
50
+ const available = Math.max(0, bounded.kept.length - offset - block.indexOf("\n") - 1);
51
+ offset += block.length + 2;
52
+ return resultDetails(result, index < bounded.shown ? (result.input.result ?? "").slice(0, available) : undefined);
53
+ });
54
+ let text = bounded.text;
55
+ const read = results.slice(0, bounded.shown);
56
+ const omitted = results.slice(bounded.shown);
57
+ if (omitted.length > 0) text += `\n\n[Results omitted: ${omitted.map((result) => label(result.agent, ids)).join(", ")}. Use wait with fewer targets.]`;
58
+ if (pendingLine) text += `\n\n${pendingLine}`;
59
+ return { text, read, details: { results: details, pending: pending.map((agent) => agent.id), ...bounded.details } };
60
+ }
61
+
62
+ /** Registers the `agent` tool. */
63
+ export function registerAgentTool(
64
+ pi: ExtensionAPI,
65
+ { limits, roots, requireNode }: { limits: Limits; roots: RootRuntime; requireNode: (current: ExtensionContext) => AgentNode },
66
+ ): void {
67
+ /** Agents visible to a node: its tree, and for a root Agent also the other root Agents. */
68
+ const visible = async (self: AgentNode, signal?: AbortSignal): Promise<Entry[]> => {
69
+ const tree = treeEntries(self.scopeId);
70
+ if (self.ownerId !== undefined) return tree;
71
+ const others = (await roots.roots(signal))
72
+ .filter((root) => !tree.some((entry) => entry.id === root.id))
73
+ .map((root) => ({ ...root, remote: !nodes.has(root.id) }));
74
+ rememberRoots(others.map((root) => root.id));
75
+ return [...tree, ...others];
76
+ };
77
+
78
+ const { maxConcurrent, maxOutstanding } = limits;
79
+ const slotsBusy = `all ${maxConcurrent} slots are busy`;
80
+
81
+ pi.registerTool({
82
+ name: "agent",
83
+ label: "Agent",
84
+ renderCall: (args, theme, context) => {
85
+ startDuration(context.state as DurationState, args.action === "wait", context.executionStarted);
86
+ return renderToolCall("agent", args, theme, context.lastComponent);
87
+ },
88
+ // `wait` shows how long it has waited.
89
+ renderResult: (result, options, theme, context) => renderTextResult(result, options, theme, context),
90
+ 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.`,
91
+ promptSnippet: "Create, message, and coordinate Agents",
92
+ promptGuidelines: [
93
+ "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.",
94
+ "For independent work, start the Agents before waiting and partition tasks into non-overlapping responsibilities.",
95
+ "Use `agent(action='wait', ...)` when the next step depends on Agent results. Prefer one longer wait over repeated short polling.",
96
+ ],
97
+ parameters: Type.Object({
98
+ 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)`." }),
99
+ name: Type.Optional(Type.String({ description: "Name of the new Agent for `spawn`; unique among the caller's Agents." })),
100
+ message: Type.Optional(Type.String({ description: "Non-empty text for `spawn` or `send`." })),
101
+ cwd: Type.Optional(Type.String({ description: "Working directory for `spawn` (default: the caller's current working directory)." })),
102
+ context: Type.Optional(StringEnum(["fresh", "fork"] as const, { description: "Context for `spawn` (default: fresh). fresh starts without the caller's conversation; fork snapshots it." })),
103
+ model: Type.Optional(Type.String({ description: "Model for `spawn` as provider/modelId (default: the caller's model)." })),
104
+ thinkingLevel: Type.Optional(StringEnum(THINKING_LEVELS, { description: "Thinking level for `spawn`: off, minimal, low, medium, high, or xhigh (default: the caller's level)." })),
105
+ 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." })),
106
+ 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." })),
107
+ history: Type.Optional(Type.Integer({ minimum: 0, description: "Number of most recent read results to return again per selected Agent for `wait` (default: 0)." })),
108
+ 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." })),
109
+ 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." })),
110
+ state: Type.Optional(StringEnum(["running", "waiting", "queued", "idle"] as const, { description: "State for `list`: running, waiting, queued, or idle." })),
111
+ limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 200, description: "Maximum Agents returned by `list` (default: 20, max: 200)." })),
112
+ offset: Type.Optional(Type.Integer({ minimum: 0, description: "Number of Agents to skip for `list` (default: 0)." })),
113
+ }),
114
+ async execute(_toolCallId, params, signal, onUpdate, current) {
115
+ const self = requireNode(current);
116
+ const agents = self.agents;
117
+ const caller = { id: self.id, name: self.name() };
118
+ const ids = () => agents.ids();
119
+ const targets = params.target === undefined ? undefined : Array.isArray(params.target) ? params.target : [params.target];
120
+ switch (params.action) {
121
+ case "spawn": {
122
+ rejectFields(params, "spawn", ["target", "deliverAs", "history", "timeout", "query", "state", "limit", "offset"]);
123
+ if (!params.name?.trim() || !params.message?.trim()) throw new Error("spawn requires name and message");
124
+ const { record, queued } = agents.spawn({
125
+ name: params.name,
126
+ message: params.message,
127
+ cwd: params.cwd,
128
+ context: params.context,
129
+ model: params.model,
130
+ thinkingLevel: params.thinkingLevel,
131
+ });
132
+ return {
133
+ content: [{ type: "text", text: `Agent ${label(record, ids())} ${queued ? `queued: ${slotsBusy}` : "started"}.` }],
134
+ details: { id: record.id, name: record.name, queued },
135
+ };
136
+ }
137
+ case "send": {
138
+ rejectFields(params, "send", ["name", "cwd", "context", "model", "thinkingLevel", "history", "timeout", "query", "state", "limit", "offset"]);
139
+ const delivery = params.deliverAs ?? "followUp";
140
+ if (!targets?.length || !params.message?.trim()) throw new Error("send requires target and message");
141
+ if (targets.length > 1 && delivery !== "write") throw new Error("Only a write can go to several Agents.");
142
+ const tree = treeEntries(self.scopeId);
143
+ const candidates = targets.every((target) => tree.some((entry) => entry.id === target)) ? tree : await visible(self, signal);
144
+ const entries = targets.map((target) => resolveIn(candidates, target));
145
+ if (entries.some((entry) => entry.id === self.id)) throw new Error("An Agent cannot send to itself.");
146
+ const body = params.message;
147
+ const send = async (entry: Entry) => {
148
+ if (!entry.remote) return deliver(caller, entry, delivery, body);
149
+ await roots.send({ id: entry.id, label: label(entry, ids()) }, delivery, body, signal);
150
+ return { queued: false };
151
+ };
152
+ if (delivery === "write") {
153
+ for (const entry of entries) await send(entry);
154
+ return {
155
+ content: [{ type: "text", text: `Write accepted by ${entries.map((entry) => label(entry, ids())).join(", ")}.` }],
156
+ details: { ids: entries.map((entry) => entry.id) },
157
+ };
158
+ }
159
+ const entry = entries[0]!;
160
+ const { queued } = await send(entry);
161
+ return {
162
+ content: [{ type: "text", text: `Input accepted by ${label(entry, ids())}${queued ? `, queued: ${slotsBusy}` : ""}.` }],
163
+ details: { id: entry.id, queued },
164
+ };
165
+ }
166
+ case "wait": {
167
+ rejectFields(params, "wait", ["name", "message", "cwd", "context", "model", "thinkingLevel", "deliverAs", "query", "state", "limit", "offset"]);
168
+ const selected = targets?.map((target) => agents.ownedTarget(target));
169
+ // An empty partial result lets the row show the elapsed time.
170
+ onUpdate?.({ content: [], details: undefined });
171
+ const outcome = await agents.wait(selected, params.history ?? 0, params.timeout ?? 30, signal);
172
+ const rendered = renderWait(outcome, ids());
173
+ agents.markRead(rendered.read);
174
+ return { content: [{ type: "text", text: rendered.text }], details: rendered.details };
175
+ }
176
+ case "list": {
177
+ rejectFields(params, "list", ["name", "message", "cwd", "context", "model", "thinkingLevel", "target", "deliverAs", "history", "timeout"]);
178
+ const offset = params.offset ?? 0;
179
+ const limit = params.limit ?? 20;
180
+ const all = await visible(self, signal);
181
+ const entries = all.filter((entry) => entry.id !== self.id && (!params.state || entry.state === params.state));
182
+ const items = searchEntries(entries, params.query);
183
+ const page = items.slice(offset, offset + limit);
184
+ const agents = page.map(({ entry: { id, name, ownerId, state } }) => ({ id, name, ownerId, state }));
185
+ if (page.length === 0) return { content: [{ type: "text", text: "No matching Agents." }], details: { total: items.length, agents } };
186
+ const lines = page.map((item) => listLine(item, all.map((entry) => entry.id)));
187
+ const remaining = items.length - offset - page.length;
188
+ if (remaining > 0) lines.push(`[${remaining} more results. Use offset=${offset + page.length} to continue.]`);
189
+ const text = boundText(lines.join("\n"), "pi-agents-list");
190
+ return { content: [{ type: "text", text: text.text }], details: { total: items.length, agents, ...text.details } };
191
+ }
192
+ case "abort": {
193
+ rejectFields(params, "abort", ["name", "message", "cwd", "context", "model", "thinkingLevel", "deliverAs", "history", "timeout", "query", "state", "limit", "offset"]);
194
+ if (targets?.length !== 1) throw new Error("abort requires one target");
195
+ const agent = agents.ownedTarget(targets[0]!);
196
+ const aborted = await agents.abort(agent, { silent: true });
197
+ return {
198
+ content: [{ type: "text", text: `Agent ${label(agent.record, ids())} ${aborted ? "aborted" : "has no turn or queued inputs"}.` }],
199
+ details: { id: agent.record.id, aborted },
200
+ };
201
+ }
202
+ }
203
+ },
204
+ });
205
+ }
@@ -0,0 +1,84 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { constants } from 'node:fs';
3
+ import { open } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { setTimeout as delay } from 'node:timers/promises';
7
+ import { prepareDirectory } from '../roots/paths.mjs';
8
+ import { request } from '../roots/transport.mjs';
9
+
10
+ const RETRY = ['SERVER_CLOSED', 'CONNECTION_CLOSED', 'ECONNRESET', 'INVALID_FRAME'];
11
+
12
+ /**
13
+ * Waits until the daemon answers, starting it when none runs.
14
+ * @param {ReturnType<typeof import('../roots/paths.mjs').statePaths>} paths
15
+ * @param {{ sessionDir: string, signal?: AbortSignal }} options
16
+ */
17
+ export async function ensureDaemon(paths, { sessionDir, signal }) {
18
+ await prepareDirectory(paths.runtime);
19
+ for (;;) {
20
+ try {
21
+ return await request(paths.daemon, { action: 'status' }, { signal, timeoutMs: 1000 });
22
+ } catch (error) {
23
+ if (RETRY.includes(error.code)) {
24
+ await delay(50, undefined, { signal });
25
+ continue;
26
+ }
27
+ if (!['ENOENT', 'ECONNREFUSED'].includes(error.code)) throw error;
28
+ }
29
+ await prepareDirectory(paths.state);
30
+ const log = await open(paths.daemonLog, constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW, 0o600);
31
+ let child;
32
+ try {
33
+ const info = await log.stat();
34
+ if (!info.isFile() || info.uid !== process.getuid() || (info.mode & 0o077)) throw new Error('Daemon log must be a private regular file');
35
+ child = spawn(process.execPath, [fileURLToPath(new URL('../../bin/daemon.mjs', import.meta.url)), sessionDir], {
36
+ cwd: paths.runtime, detached: true, stdio: ['ignore', log.fd, log.fd],
37
+ });
38
+ } finally { await log.close(); }
39
+ let launchError;
40
+ child.on('error', error => { launchError = error; });
41
+ child.once('exit', code => {
42
+ if (code !== 0) launchError = new Error(`Daemon exited (${code}). See ${paths.daemonLog}`);
43
+ });
44
+ child.unref();
45
+ for (;;) {
46
+ signal?.throwIfAborted();
47
+ if (launchError) throw launchError;
48
+ try {
49
+ return await request(paths.daemon, { action: 'status' }, { signal, timeoutMs: 1000 });
50
+ } catch (failure) {
51
+ if (!['ENOENT', 'ECONNREFUSED', ...RETRY].includes(failure.code)) throw failure;
52
+ // Another daemon holds the lock; it answers once its socket is up.
53
+ if (child.exitCode === 0) break;
54
+ }
55
+ await delay(50, undefined, { signal });
56
+ }
57
+ }
58
+ }
59
+
60
+ export async function sendViaDaemon(paths, message, { sessionDir, launch, signal, timeoutMs = 10000 }) {
61
+ const deadline = AbortSignal.timeout(timeoutMs);
62
+ const stop = signal ? AbortSignal.any([signal, deadline]) : deadline;
63
+ try {
64
+ for (;;) {
65
+ await ensureDaemon(paths, { sessionDir, signal: stop });
66
+ try {
67
+ return await request(paths.daemon, { action: 'deliver', message, launch }, { signal: stop, timeoutMs });
68
+ } catch (error) {
69
+ // Replay only when the transport guarantees the handler was not invoked.
70
+ if (error.uncertainDelivery || !(['ENOENT', 'ECONNREFUSED'].includes(error.code)
71
+ || (error.code === 'SERVER_CLOSED' && error.uncertainDelivery === false))) throw error;
72
+ await delay(50, undefined, { signal: stop });
73
+ }
74
+ }
75
+ } catch (error) {
76
+ if (deadline.aborted && stop.reason === deadline.reason) {
77
+ throw Object.assign(new Error('Acknowledgement timed out', { cause: error }), {
78
+ code: 'ETIMEDOUT',
79
+ ...(typeof error.uncertainDelivery === 'boolean' ? { uncertainDelivery: error.uncertainDelivery } : {}),
80
+ });
81
+ }
82
+ throw error;
83
+ }
84
+ }
@@ -0,0 +1,103 @@
1
+ function fault(code, message, uncertainDelivery = false) {
2
+ return Object.assign(new Error(message), { code, uncertainDelivery });
3
+ }
4
+
5
+ // Bound the wait even when an injected operation ignores its signal. Aborting
6
+ // this signal stops observation/retries, not work already accepted by a Session.
7
+ function bounded(run, { signal, timeoutMs }, interruption) {
8
+ return new Promise((resolve, reject) => {
9
+ const controller = new AbortController();
10
+ let settled = false, timer;
11
+ function finish(error, value) {
12
+ if (settled) return;
13
+ settled = true;
14
+ clearTimeout(timer);
15
+ signal?.removeEventListener('abort', abort);
16
+ if (error) reject(error); else resolve(value);
17
+ }
18
+ function interrupt(code) {
19
+ const error = interruption(code);
20
+ finish(error);
21
+ controller.abort(error);
22
+ }
23
+ const abort = () => interrupt('ABORT_ERR');
24
+ if (signal?.aborted) { abort(); return; }
25
+ signal?.addEventListener('abort', abort, { once: true });
26
+ timer = setTimeout(() => interrupt('ETIMEDOUT'), timeoutMs);
27
+ try {
28
+ Promise.resolve(run({ signal: controller.signal, timeoutMs })).then(
29
+ value => finish(null, value), error => finish(error),
30
+ );
31
+ } catch (error) { finish(error); }
32
+ });
33
+ }
34
+
35
+ function validateAck(value, message) {
36
+ if (value?.accepted !== true || value.messageId !== message.id) {
37
+ throw fault('INVALID_ACK', `Invalid acknowledgement for Message ${message.id}; the recipient may have received it`, true);
38
+ }
39
+ return value;
40
+ }
41
+
42
+ const WAKE_TIMEOUT_MS = 10000;
43
+
44
+ export function createRouter({ deliverToSession, wake }) {
45
+ const waking = new Map();
46
+ function wakeOnce(recipient, { signal, launch }) {
47
+ signal.throwIfAborted();
48
+ let shared = waking.get(recipient);
49
+ if (!shared) {
50
+ shared = { controller: new AbortController(), waiters: 0, settled: false };
51
+ // Register waiters before invoking wake, which may itself cancel a caller.
52
+ // Each caller bounds its own wait; all waiters own the launch lifetime.
53
+ shared.pending = bounded(options => Promise.resolve().then(() => {
54
+ options.signal.throwIfAborted();
55
+ return wake(recipient, { ...options, launch });
56
+ }), { signal: shared.controller.signal, timeoutMs: WAKE_TIMEOUT_MS },
57
+ code => fault(code, `Wake ${code === 'ABORT_ERR' ? 'cancelled' : 'timed out'} for Agent ${recipient}`))
58
+ .finally(() => {
59
+ shared.settled = true;
60
+ if (waking.get(recipient) === shared) waking.delete(recipient);
61
+ });
62
+ waking.set(recipient, shared);
63
+ }
64
+ shared.waiters++;
65
+ return new Promise((resolve, reject) => {
66
+ let finished = false;
67
+ function finish(error) {
68
+ if (finished) return;
69
+ finished = true;
70
+ signal.removeEventListener('abort', abort);
71
+ shared.waiters--;
72
+ if (!shared.waiters && !shared.settled) {
73
+ if (waking.get(recipient) === shared) waking.delete(recipient);
74
+ shared.controller.abort();
75
+ }
76
+ if (error) reject(error); else resolve();
77
+ }
78
+ const abort = () => finish(signal.reason);
79
+ signal.addEventListener('abort', abort, { once: true });
80
+ shared.pending.then(() => finish(), error => finish(error));
81
+ });
82
+ }
83
+ return {
84
+ async accept(message, { signal, timeoutMs = 10000, launch } = {}) {
85
+ let delivering = false;
86
+ return bounded(async options => {
87
+ delivering = true;
88
+ try {
89
+ return validateAck(await deliverToSession(message, options), message);
90
+ } catch (error) {
91
+ options.signal.throwIfAborted();
92
+ if (error?.uncertainDelivery || !['ENOENT', 'ECONNREFUSED'].includes(error?.code)) throw error;
93
+ }
94
+ delivering = false;
95
+ await wakeOnce(message.recipient, { signal: options.signal, launch });
96
+ options.signal.throwIfAborted();
97
+ delivering = true;
98
+ return validateAck(await deliverToSession(message, options), message);
99
+ }, { signal, timeoutMs }, code => fault(code,
100
+ `${code === 'ABORT_ERR' ? 'Send cancelled' : 'Send timed out'}${delivering ? '; the recipient may have received it' : ''}`, delivering));
101
+ },
102
+ };
103
+ }
@@ -0,0 +1,129 @@
1
+ import { constants } from 'node:fs';
2
+ import { open, readFile, realpath, rename } from 'node:fs/promises';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { relative } from 'node:path';
5
+ import { discoverRoots } from '../roots/discovery.mjs';
6
+ import { isLocked } from '../roots/locks.mjs';
7
+
8
+ const MARKS_VERSION = 1;
9
+
10
+ async function readMarks(file) {
11
+ try {
12
+ const value = JSON.parse(await readFile(file, 'utf8'));
13
+ if (value?.version === MARKS_VERSION && value.sessions && typeof value.sessions === 'object') return value.sessions;
14
+ } catch (error) { if (error.code !== 'ENOENT' && !(error instanceof SyntaxError)) throw error; }
15
+ return {};
16
+ }
17
+
18
+ /** Session files outside the session directory that the daemon has seen running. */
19
+ export async function markedFiles(paths) {
20
+ return Object.values(await readMarks(paths.workbench)).flatMap(mark => mark.sessionFile ?? []);
21
+ }
22
+
23
+ /**
24
+ * The daemon's view of root Sessions: live state reported by running Sessions, and per Session
25
+ * its last finished turn, the last turn the user has seen, and whether the workbench lists it,
26
+ * and where its file is when outside `sessionDir`, kept in `paths.workbench`. A Session with no
27
+ * marks counts as seen.
28
+ */
29
+ export async function createSessions(paths, sessionDir) {
30
+ const marks = await readMarks(paths.workbench);
31
+ const live = new Map();
32
+ let saving = Promise.resolve();
33
+
34
+ function save() {
35
+ const data = JSON.stringify({ version: MARKS_VERSION, sessions: marks });
36
+ saving = saving.then(async () => {
37
+ const temporary = `${paths.workbench}.${randomUUID()}.tmp`;
38
+ const file = await open(temporary, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600);
39
+ try { await file.writeFile(data); } finally { await file.close(); }
40
+ await rename(temporary, paths.workbench);
41
+ }).catch(error => console.error(`[pi-agents] Saving seen marks failed: ${error.message}`));
42
+ return saving;
43
+ }
44
+
45
+ function files() {
46
+ return Object.values(marks).flatMap(mark => mark.sessionFile ?? []);
47
+ }
48
+
49
+ function finished(id, turn) {
50
+ const mark = marks[id] ??= {};
51
+ if (mark.last && mark.last.at >= turn.at) return false;
52
+ mark.last = { at: turn.at, failed: turn.failed === true };
53
+ return true;
54
+ }
55
+
56
+ return {
57
+ /**
58
+ * Records the state a running Session reports; a turn that ends while attached is seen.
59
+ * A Session that runs without a terminal, as the workbench and wakes start it or after a detach, stays listed.
60
+ */
61
+ update(state) {
62
+ // Pi saves a new Session only after its first reply; until then the first report dates it.
63
+ live.set(state.id, { ...state, since: live.get(state.id)?.since ?? new Date().toISOString() });
64
+ let changed = state.turn ? finished(state.id, state.turn) : false;
65
+ if (state.sessionFile && relative(sessionDir, state.sessionFile).startsWith('..') && marks[state.id]?.sessionFile !== state.sessionFile) {
66
+ (marks[state.id] ??= {}).sessionFile = state.sessionFile;
67
+ changed = true;
68
+ }
69
+ if (!state.attached && !marks[state.id]?.listed) {
70
+ (marks[state.id] ??= {}).listed = true;
71
+ changed = true;
72
+ }
73
+ const mark = marks[state.id];
74
+ if (state.attached && mark?.last && (mark.seen ?? 0) < mark.last.at) {
75
+ mark.seen = mark.last.at;
76
+ changed = true;
77
+ }
78
+ if (changed) save();
79
+ },
80
+ /** Forgets a Session that stopped; one that stopped without saying so has failed. */
81
+ end(id, { abnormal = false } = {}) {
82
+ live.delete(id);
83
+ if (abnormal && finished(id, { at: Date.now(), failed: true })) save();
84
+ },
85
+ /** Lists a Session in the workbench, as when the user opens it there. */
86
+ show(id) {
87
+ (marks[id] ??= {}).listed = true;
88
+ save();
89
+ },
90
+ /** Removes a Session from the workbench list and clears its unseen turn; its file stays. */
91
+ hide(id) {
92
+ const mark = marks[id];
93
+ if (!mark) return;
94
+ delete mark.listed;
95
+ if (mark.last) mark.seen = mark.last.at;
96
+ else delete marks[id];
97
+ save();
98
+ },
99
+ files,
100
+ /** Root Sessions the workbench lists: running ones, listed ones, and ones with an unseen turn. */
101
+ async list({ signal } = {}) {
102
+ const ids = new Set(live.keys());
103
+ for (const [id, mark] of Object.entries(marks)) {
104
+ if (mark.listed || (mark.last && (mark.seen ?? 0) < mark.last.at)) ids.add(id);
105
+ }
106
+ const records = await discoverRoots(sessionDir, { extraFiles: files(), ids, signal });
107
+ const rows = new Map();
108
+ for (const record of records) {
109
+ let status = 'idle';
110
+ if (!live.has(record.id)) {
111
+ try { if (isLocked(paths.locks, await realpath(record.sessionFile), record.id)) status = 'unknown'; }
112
+ catch (error) { if (error.code === 'ENOENT') continue; throw error; }
113
+ }
114
+ rows.set(record.id, { id: record.id, name: record.name, title: record.title, reply: record.reply, cwd: record.cwd, sessionFile: record.sessionFile, updatedAt: record.updatedAt, status });
115
+ }
116
+ for (const state of live.values()) {
117
+ const saved = rows.get(state.id);
118
+ rows.set(state.id, { ...saved, ...state, title: state.title ?? saved?.title, reply: state.reply ?? saved?.reply,
119
+ status: state.blocked ? 'blocked' : state.working ? 'working' : 'idle', running: true, updatedAt: saved?.updatedAt ?? state.since });
120
+ }
121
+ return [...rows.values()].map(row => {
122
+ const mark = marks[row.id];
123
+ const attention = mark?.last && (mark.seen ?? 0) < mark.last.at ? (mark.last.failed ? 'failed' : 'done') : undefined;
124
+ return { ...row, ...(attention ? { attention } : {}), ...(mark?.last ? { finishedAt: mark.last.at } : {}) };
125
+ });
126
+ },
127
+ saved: () => saving,
128
+ };
129
+ }
@@ -0,0 +1,19 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readdir, readFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ const ROOT = fileURLToPath(new URL('../../', import.meta.url));
7
+
8
+ /** A hash of the package's code on disk, which changes when pi-agents is updated. */
9
+ export async function codeVersion() {
10
+ const hash = createHash('sha256');
11
+ const files = ['package.json'];
12
+ for (const directory of ['bin', 'src']) {
13
+ for (const entry of await readdir(join(ROOT, directory), { recursive: true, withFileTypes: true })) {
14
+ if (entry.isFile()) files.push(join(entry.parentPath.slice(ROOT.length), entry.name));
15
+ }
16
+ }
17
+ for (const file of files.sort()) hash.update(file).update('\0').update(await readFile(join(ROOT, file))).update('\0');
18
+ return hash.digest('hex').slice(0, 16);
19
+ }