@hheei/pi-subagents 0.7.0 → 0.8.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 (59) hide show
  1. package/LICENSES/Apache-2.0.txt +202 -0
  2. package/NOTICE +10 -0
  3. package/README.md +13 -13
  4. package/dist/child-control.d.ts +0 -8
  5. package/dist/child-control.js +40 -8
  6. package/dist/commands.js +1 -1
  7. package/dist/controller-ipc.d.ts +38 -0
  8. package/dist/controller-ipc.js +292 -0
  9. package/dist/controller-main.d.ts +1 -0
  10. package/dist/controller-main.js +26 -0
  11. package/dist/controller-role.d.ts +40 -0
  12. package/dist/controller-role.js +37 -0
  13. package/dist/controller-socket.d.ts +6 -0
  14. package/dist/controller-socket.js +43 -0
  15. package/dist/controller.d.ts +25 -0
  16. package/dist/controller.js +217 -0
  17. package/dist/delegation.d.ts +73 -0
  18. package/dist/delegation.js +71 -0
  19. package/dist/domain.d.ts +20 -18
  20. package/dist/domain.js +6 -1
  21. package/dist/extension.js +43 -26
  22. package/dist/host-adapter.js +1 -1
  23. package/dist/launch-spec.d.ts +1 -3
  24. package/dist/launch-spec.js +38 -15
  25. package/dist/lease.d.ts +0 -1
  26. package/dist/lease.js +8 -11
  27. package/dist/mailbox.js +110 -34
  28. package/dist/manager.d.ts +52 -6
  29. package/dist/manager.js +568 -32
  30. package/dist/mcp/app.html +948 -0
  31. package/dist/mcp/delegate.d.ts +30 -0
  32. package/dist/mcp/delegate.js +44 -0
  33. package/dist/mcp/views.d.ts +5 -0
  34. package/dist/mcp/views.js +12 -0
  35. package/dist/mcp-server.d.ts +6 -0
  36. package/dist/mcp-server.js +287 -0
  37. package/dist/mcp-stdio.d.ts +2 -0
  38. package/dist/mcp-stdio.js +111 -0
  39. package/dist/process-lock.d.ts +1 -1
  40. package/dist/process-lock.js +4 -2
  41. package/dist/protocol.d.ts +5 -5
  42. package/dist/protocol.js +3 -4
  43. package/dist/receipts.d.ts +80 -0
  44. package/dist/receipts.js +326 -0
  45. package/dist/runtime.d.ts +5 -26
  46. package/dist/runtime.js +15 -57
  47. package/dist/session-bootstrap.js +2 -6
  48. package/dist/state-db.d.ts +11 -4
  49. package/dist/state-db.js +102 -24
  50. package/dist/state.js +12 -3
  51. package/dist/tool-activation.d.ts +2 -2
  52. package/dist/tool-activation.js +5 -5
  53. package/dist/tool-presentation.d.ts +2 -6
  54. package/dist/tool-presentation.js +75 -122
  55. package/dist/tools.d.ts +0 -3
  56. package/dist/tools.js +104 -132
  57. package/package.json +12 -5
  58. package/dist/json-lines.d.ts +0 -14
  59. package/dist/json-lines.js +0 -95
@@ -0,0 +1,292 @@
1
+ import { chmodSync, unlinkSync } from "node:fs";
2
+ import { createConnection, createServer } from "node:net";
3
+ import { historyEntry } from "./delegation.js";
4
+ import { parseDelegateInput } from "./mcp/delegate.js";
5
+ import { listReceipts } from "./receipts.js";
6
+ import { defaultRegistryPath } from "./registry.js";
7
+ const openSockets = new WeakMap();
8
+ /** Bumped whenever this call surface changes: a client never talks to a controller from another build. */
9
+ export const CONTROLLER_PROTOCOL = 2;
10
+ export async function serveControllerIpc(options) {
11
+ try {
12
+ unlinkSync(options.socketPath);
13
+ }
14
+ catch {
15
+ // A leftover socket is removed only when this owner holds the workspace lock.
16
+ }
17
+ const waiting = { count: 0 };
18
+ const filePath = options.statePath ?? defaultRegistryPath(options.parentSessionId);
19
+ const sockets = new Set();
20
+ const server = createServer((socket) => {
21
+ if (sockets.size >= MAX_CONNECTIONS) {
22
+ socket.destroy();
23
+ return;
24
+ }
25
+ sockets.add(socket);
26
+ socket.on("error", () => { });
27
+ const abort = new AbortController();
28
+ socket.once("close", () => {
29
+ sockets.delete(socket);
30
+ abort.abort();
31
+ });
32
+ socket.setEncoding("utf8");
33
+ let buffer = "";
34
+ socket.on("data", (chunk) => {
35
+ buffer += chunk;
36
+ if (Buffer.byteLength(buffer) > MAX_FRAME) {
37
+ socket.destroy();
38
+ return;
39
+ }
40
+ let newline = buffer.indexOf("\n");
41
+ while (newline !== -1) {
42
+ const line = buffer.slice(0, newline);
43
+ buffer = buffer.slice(newline + 1);
44
+ newline = buffer.indexOf("\n");
45
+ void handleLine(socket, line, options.parentSessionId, options.manager, abort.signal, waiting, filePath);
46
+ }
47
+ });
48
+ });
49
+ openSockets.set(server, sockets);
50
+ await new Promise((resolve, reject) => {
51
+ server.once("error", reject);
52
+ server.listen(options.socketPath, () => {
53
+ server.off("error", reject);
54
+ resolve();
55
+ });
56
+ });
57
+ chmodSync(options.socketPath, 0o600);
58
+ return server;
59
+ }
60
+ /** Stops listening and drops open calls. `server.close()` alone waits until every wait:true socket ends. */
61
+ export function closeControllerIpc(server) {
62
+ const sockets = openSockets.get(server);
63
+ return new Promise((resolve, reject) => {
64
+ server.close((error) => {
65
+ openSockets.delete(server);
66
+ if (error === undefined)
67
+ resolve();
68
+ else
69
+ reject(error);
70
+ });
71
+ if (sockets !== undefined) {
72
+ for (const socket of sockets)
73
+ socket.destroy();
74
+ }
75
+ });
76
+ }
77
+ /** The call surface of the controller listening here. Only an actual answer can be foreign. */
78
+ export async function controllerProtocol(socketPath) {
79
+ try {
80
+ const reply = await callController(socketPath, { op: "protocol", input: {} }, AbortSignal.timeout(2_000));
81
+ const result = reply.result;
82
+ if (reply.ok !== true || typeof result !== "object" || result === null)
83
+ return { state: "foreign" };
84
+ const protocol = "protocol" in result ? result.protocol : undefined;
85
+ return typeof protocol === "number" ? { state: "ready", protocol } : { state: "foreign" };
86
+ }
87
+ catch {
88
+ return { state: "silent" };
89
+ }
90
+ }
91
+ export function callController(socketPath, call, signal) {
92
+ return new Promise((resolve, reject) => {
93
+ if (signal?.aborted) {
94
+ reject(signal.reason instanceof Error ? signal.reason : new Error("Controller call aborted"));
95
+ return;
96
+ }
97
+ const socket = createConnection(socketPath);
98
+ let buffer = "";
99
+ let settled = false;
100
+ const finish = (outcome) => {
101
+ if (settled)
102
+ return;
103
+ settled = true;
104
+ signal?.removeEventListener("abort", onAbort);
105
+ socket.removeAllListeners();
106
+ socket.on("error", () => { });
107
+ socket.destroy();
108
+ if (outcome.error !== undefined)
109
+ reject(outcome.error);
110
+ else if (outcome.reply !== undefined)
111
+ resolve(outcome.reply);
112
+ else
113
+ reject(new Error("Controller closed the connection without a reply"));
114
+ };
115
+ const onAbort = () => {
116
+ const reason = signal?.reason;
117
+ finish({ error: reason instanceof Error ? reason : new Error("Controller call aborted") });
118
+ };
119
+ socket.setEncoding("utf8");
120
+ socket.on("error", (error) => finish({ error }));
121
+ socket.on("data", (chunk) => {
122
+ buffer += chunk;
123
+ const newline = buffer.indexOf("\n");
124
+ if (newline === -1)
125
+ return;
126
+ try {
127
+ finish({ reply: parseReply(JSON.parse(buffer.slice(0, newline))) });
128
+ }
129
+ catch (error) {
130
+ finish({ error: error instanceof Error ? error : new Error(String(error)) });
131
+ }
132
+ });
133
+ socket.on("end", () => finish({}));
134
+ socket.on("close", () => finish({}));
135
+ if (signal !== undefined)
136
+ signal.addEventListener("abort", onAbort, { once: true });
137
+ socket.write(`${JSON.stringify(call)}\n`);
138
+ });
139
+ }
140
+ async function handleLine(socket, line, parentSessionId, manager, signal, waiting, filePath) {
141
+ let reply;
142
+ try {
143
+ const call = parseCall(JSON.parse(line));
144
+ reply = {
145
+ ok: true,
146
+ result: await dispatch(call, parentSessionId, manager, signal, waiting, filePath),
147
+ };
148
+ }
149
+ catch (error) {
150
+ reply = {
151
+ ok: false,
152
+ error: error instanceof Error ? error.message : String(error),
153
+ };
154
+ }
155
+ if (socket.destroyed || !socket.writable)
156
+ return;
157
+ socket.write(`${JSON.stringify(reply)}\n`, (error) => {
158
+ if (error !== undefined && error !== null)
159
+ socket.destroy();
160
+ });
161
+ }
162
+ const MAX_FRAME = 1024 * 1024;
163
+ const MAX_CONNECTIONS = 16;
164
+ const MAX_WAITING = 12;
165
+ async function dispatch(call, parentSessionId, manager, signal, waiting, filePath) {
166
+ switch (call.op) {
167
+ case "protocol":
168
+ return { protocol: CONTROLLER_PROTOCOL };
169
+ case "receipts": {
170
+ const page = listReceipts(filePath, parentSessionId, numberField(call.input, "cursor"), numberField(call.input, "limit", 50) || 20);
171
+ return {
172
+ receipts: page.receipts.flatMap((receipt) => {
173
+ const entry = historyEntry(receipt);
174
+ return entry === undefined ? [] : [entry];
175
+ }),
176
+ ...(page.nextCursor === undefined ? {} : { nextCursor: page.nextCursor }),
177
+ };
178
+ }
179
+ case "get": {
180
+ const revision = numberField(call.input, "revision");
181
+ return manager.inspect({
182
+ id: stringField(call.input, "id"),
183
+ ...(revision === 0 ? {} : { revision }),
184
+ includeOutput: booleanField(call.input, "includeOutput"),
185
+ });
186
+ }
187
+ case "list":
188
+ return manager.dashboard();
189
+ case "stop":
190
+ return manager.stopAgent(stringField(call.input, "id"));
191
+ case "transcript":
192
+ return transcriptPage(manager, stringField(call.input, "id"), call.input);
193
+ case "delegate": {
194
+ const input = parseDelegateInput(call.input);
195
+ return runDelegation(manager, input, signal, waiting);
196
+ }
197
+ default:
198
+ throw new Error(`Unknown controller operation ${call.op}`);
199
+ }
200
+ }
201
+ async function runDelegation(manager, input, signal, waiting) {
202
+ const wait = input.wait !== false;
203
+ if (wait && waiting.count >= MAX_WAITING) {
204
+ throw new Error("Controller is busy with waiting delegations");
205
+ }
206
+ if (wait)
207
+ waiting.count += 1;
208
+ try {
209
+ return await manager.delegate(input, signal);
210
+ }
211
+ finally {
212
+ if (wait)
213
+ waiting.count -= 1;
214
+ }
215
+ }
216
+ function parseCall(value) {
217
+ if (typeof value !== "object" || value === null)
218
+ throw new Error("Invalid controller call");
219
+ const record = value;
220
+ if (!isOp(record.op))
221
+ throw new Error("Invalid controller call");
222
+ return { op: record.op, input: record.input };
223
+ }
224
+ const OPS = [
225
+ "protocol",
226
+ "delegate",
227
+ "receipts",
228
+ "get",
229
+ "list",
230
+ "stop",
231
+ "transcript",
232
+ ];
233
+ function isOp(value) {
234
+ return typeof value === "string" && OPS.includes(value);
235
+ }
236
+ function parseReply(value) {
237
+ if (typeof value !== "object" || value === null)
238
+ throw new Error("Invalid controller reply");
239
+ const record = value;
240
+ if (typeof record.ok !== "boolean")
241
+ throw new Error("Invalid controller reply");
242
+ return {
243
+ ok: record.ok,
244
+ ...(record.result === undefined ? {} : { result: record.result }),
245
+ ...(typeof record.error === "string" ? { error: record.error } : {}),
246
+ };
247
+ }
248
+ async function transcriptPage(manager, id, input) {
249
+ const cursor = numberField(input, "cursor");
250
+ const limit = numberField(input, "limit", 50) || 20;
251
+ const page = await manager.getEntries(id, { cursor, limit });
252
+ if (page === undefined)
253
+ throw new Error(`Unknown child ${id}`);
254
+ return {
255
+ id,
256
+ entries: page.entries.map(boundedEntry),
257
+ ...(page.nextCursor === undefined ? {} : { nextCursor: page.nextCursor }),
258
+ };
259
+ }
260
+ function boundedEntry(entry) {
261
+ try {
262
+ const text = JSON.stringify(entry);
263
+ if (text === undefined)
264
+ return null;
265
+ if (text.length <= 4000)
266
+ return JSON.parse(text);
267
+ return { truncated: true, text: text.slice(0, 4000) };
268
+ }
269
+ catch {
270
+ return String(entry);
271
+ }
272
+ }
273
+ function record(input) {
274
+ return typeof input === "object" && input !== null
275
+ ? input
276
+ : undefined;
277
+ }
278
+ function stringField(input, key) {
279
+ const value = record(input)?.[key];
280
+ if (typeof value !== "string" || value === "")
281
+ throw new Error(`${key} is required`);
282
+ return value;
283
+ }
284
+ function numberField(input, key, cap) {
285
+ const value = record(input)?.[key];
286
+ if (typeof value !== "number" || !Number.isInteger(value) || value <= 0)
287
+ return 0;
288
+ return cap === undefined ? value : Math.min(value, cap);
289
+ }
290
+ function booleanField(input, key) {
291
+ return record(input)?.[key] === true;
292
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,26 @@
1
+ import { StandaloneControllerError, startStandaloneController } from "./controller.js";
2
+ import { STANDALONE_CONTROLLER_ENV_KEY } from "./domain.js";
3
+ process.env[STANDALONE_CONTROLLER_ENV_KEY] = "1";
4
+ const workspace = process.argv[2];
5
+ const agentDir = process.argv[3];
6
+ if (workspace === undefined || agentDir === undefined) {
7
+ console.error("usage: controller <workspace> <agent-dir>");
8
+ process.exit(2);
9
+ }
10
+ try {
11
+ const controller = await startStandaloneController({ workspace, agentDir });
12
+ console.log(`ready ${controller.socketPath}`);
13
+ const stop = () => {
14
+ void controller.dispose().then(() => process.exit(0), (error) => {
15
+ console.error(error instanceof Error ? error.message : String(error));
16
+ process.exit(1);
17
+ });
18
+ };
19
+ process.on("SIGTERM", stop);
20
+ process.on("SIGINT", stop);
21
+ }
22
+ catch (error) {
23
+ const message = error instanceof Error ? error.message : String(error);
24
+ console.error(error instanceof StandaloneControllerError ? `${error.code} ${message}` : message);
25
+ process.exit(1);
26
+ }
@@ -0,0 +1,40 @@
1
+ import type { SkillCatalogEntry } from "./agent-resolver.js";
2
+ import type { DashboardView, DelegationView } from "./delegation.js";
3
+ import type { DelegateSubagentInput, OperationError, PublicSubagent } from "./domain.js";
4
+ import type { ParentChannel } from "./manager.js";
5
+ export interface ControllerManager {
6
+ delegate(input: DelegateSubagentInput & {
7
+ readonly wait?: boolean;
8
+ }, signal?: AbortSignal): Promise<DelegationView | OperationError>;
9
+ inspect(input: {
10
+ readonly id: string;
11
+ readonly revision?: number;
12
+ readonly includeOutput?: boolean;
13
+ }): Promise<DelegationView | OperationError>;
14
+ dashboard(): Promise<DashboardView>;
15
+ stopAgent(id: string): Promise<DelegationView | OperationError>;
16
+ get(id: string): Promise<PublicSubagent | OperationError>;
17
+ list(): Promise<readonly PublicSubagent[]>;
18
+ stop(id: string): Promise<PublicSubagent | OperationError>;
19
+ getEntries(id: string, range?: {
20
+ readonly cursor?: number;
21
+ readonly limit?: number;
22
+ }): Promise<{
23
+ readonly entries: readonly unknown[];
24
+ readonly nextCursor?: number;
25
+ } | undefined>;
26
+ }
27
+ /** In-process ownership for one standalone controller. Print mode is not this signal. */
28
+ export interface ControllerRole extends ParentChannel {
29
+ readonly skills: readonly SkillCatalogEntry[];
30
+ applied: boolean;
31
+ modelId?: string;
32
+ provider?: string;
33
+ credential: "present" | "missing";
34
+ readonly manager: Promise<ControllerManager>;
35
+ bindManager(manager: ControllerManager): void;
36
+ dispose(): void;
37
+ }
38
+ export declare function beginControllerRole(skills: readonly SkillCatalogEntry[]): ControllerRole;
39
+ export declare function activeControllerRole(): ControllerRole | undefined;
40
+ export declare function endControllerRole(role: ControllerRole): void;
@@ -0,0 +1,37 @@
1
+ let current;
2
+ export function beginControllerRole(skills) {
3
+ if (current !== undefined)
4
+ throw new Error("Standalone controller role is already active");
5
+ let closed = false;
6
+ let resolveManager;
7
+ const manager = new Promise((resolve) => {
8
+ resolveManager = resolve;
9
+ });
10
+ const role = {
11
+ skills,
12
+ applied: false,
13
+ credential: "missing",
14
+ manager,
15
+ bindManager(value) {
16
+ resolveManager?.(value);
17
+ },
18
+ deliver() {
19
+ if (closed)
20
+ return Promise.reject(new Error("Standalone controller report sink is closed"));
21
+ return Promise.resolve();
22
+ },
23
+ dispose() {
24
+ closed = true;
25
+ },
26
+ };
27
+ current = role;
28
+ return role;
29
+ }
30
+ export function activeControllerRole() {
31
+ return current;
32
+ }
33
+ export function endControllerRole(role) {
34
+ role.dispose();
35
+ if (current === role)
36
+ current = undefined;
37
+ }
@@ -0,0 +1,6 @@
1
+ export declare function createControllerSocket(options: {
2
+ readonly locate: () => string;
3
+ readonly ready: (socketPath: string) => Promise<boolean>;
4
+ readonly start: () => Promise<string>;
5
+ }): () => Promise<string>;
6
+ export declare function retryableControllerStart(error: Error): boolean;
@@ -0,0 +1,43 @@
1
+ export function createControllerSocket(options) {
2
+ let opening;
3
+ return () => {
4
+ if (opening !== undefined)
5
+ return opening;
6
+ const attempt = openControllerSocket(options).finally(() => {
7
+ if (opening === attempt)
8
+ opening = undefined;
9
+ });
10
+ opening = attempt;
11
+ return attempt;
12
+ };
13
+ }
14
+ export function retryableControllerStart(error) {
15
+ const message = error.message;
16
+ return (message.startsWith("busy ") ||
17
+ message.includes("already running") ||
18
+ message.includes("not accepting connections"));
19
+ }
20
+ async function openControllerSocket(options) {
21
+ const socketPath = options.locate();
22
+ let lastError;
23
+ for (let attempt = 0; attempt < 20; attempt += 1) {
24
+ if (await options.ready(socketPath))
25
+ return socketPath;
26
+ try {
27
+ const started = await options.start();
28
+ if (await options.ready(started))
29
+ return started;
30
+ throw new Error("Controller announced a socket that is not accepting connections");
31
+ }
32
+ catch (error) {
33
+ const wrapped = error instanceof Error ? error : new Error(String(error));
34
+ if (await options.ready(socketPath))
35
+ return socketPath;
36
+ if (!retryableControllerStart(wrapped))
37
+ throw wrapped;
38
+ lastError = wrapped;
39
+ }
40
+ await new Promise((resolve) => setTimeout(resolve, 50));
41
+ }
42
+ throw lastError ?? new Error("Controller socket did not become ready");
43
+ }
@@ -0,0 +1,25 @@
1
+ export type StandaloneControllerFailure = "busy" | "model_unavailable";
2
+ export declare class StandaloneControllerError extends Error {
3
+ readonly code: StandaloneControllerFailure;
4
+ readonly parentSessionId: string | undefined;
5
+ constructor(code: StandaloneControllerFailure, message: string, parentSessionId?: string);
6
+ }
7
+ export interface StandaloneController {
8
+ readonly workspace: string;
9
+ readonly parentSessionId: string;
10
+ readonly sessionFile: string;
11
+ readonly socketPath: string;
12
+ dispose(): Promise<void>;
13
+ }
14
+ export declare function controllerSocketPath(agentDir: string, workspace: string): string;
15
+ export declare function startStandaloneController(options: {
16
+ readonly workspace: string;
17
+ readonly agentDir: string;
18
+ }): Promise<StandaloneController>;
19
+ /**
20
+ * A controller left behind by an earlier build keeps this workspace's socket and lock. Its calls are
21
+ * not this build's to make, so it is stopped here and the caller starts its own.
22
+ */
23
+ export declare function stopPreviousController(agentDir: string, workspace: string, expectedPid?: number): Promise<void>;
24
+ /** Pid recorded for this workspace, when the binding names one. */
25
+ export declare function recordedControllerPid(agentDir: string, workspace: string): number | undefined;