@hheei/pi-subagents 0.1.2 → 0.2.3

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 (65) hide show
  1. package/README.md +45 -137
  2. package/dist/agent-resolver.d.ts +20 -0
  3. package/dist/agent-resolver.js +155 -21
  4. package/dist/bridge-client.d.ts +55 -0
  5. package/dist/bridge-client.js +453 -0
  6. package/dist/bridge-server.d.ts +72 -0
  7. package/dist/bridge-server.js +490 -0
  8. package/dist/builtin-agents.d.ts +24 -0
  9. package/dist/builtin-agents.js +152 -0
  10. package/dist/child-bridge.d.ts +31 -15
  11. package/dist/child-bridge.js +95 -117
  12. package/dist/child-control.d.ts +58 -0
  13. package/dist/child-control.js +176 -0
  14. package/dist/child-process.d.ts +21 -0
  15. package/dist/child-process.js +93 -0
  16. package/dist/commands.d.ts +2 -2
  17. package/dist/commands.js +52 -60
  18. package/dist/diagnostics.d.ts +0 -2
  19. package/dist/diagnostics.js +0 -8
  20. package/dist/domain.d.ts +57 -27
  21. package/dist/domain.js +60 -11
  22. package/dist/extension.d.ts +26 -1
  23. package/dist/extension.js +243 -46
  24. package/dist/host-adapter.d.ts +37 -12
  25. package/dist/host-adapter.js +214 -63
  26. package/dist/json-lines.js +2 -1
  27. package/dist/launch-spec.d.ts +8 -6
  28. package/dist/launch-spec.js +61 -18
  29. package/dist/manager.d.ts +93 -49
  30. package/dist/manager.js +1175 -528
  31. package/dist/markdown.d.ts +8 -0
  32. package/dist/markdown.js +21 -0
  33. package/dist/protocol.d.ts +33 -52
  34. package/dist/protocol.js +41 -52
  35. package/dist/registry.d.ts +3 -8
  36. package/dist/registry.js +41 -163
  37. package/dist/rpc-events.d.ts +3 -5
  38. package/dist/rpc-events.js +5 -9
  39. package/dist/runtime.d.ts +66 -11
  40. package/dist/runtime.js +182 -208
  41. package/dist/session-bootstrap.d.ts +23 -7
  42. package/dist/session-bootstrap.js +79 -15
  43. package/dist/session-fork.d.ts +16 -0
  44. package/dist/session-fork.js +67 -0
  45. package/dist/state.d.ts +7 -0
  46. package/dist/state.js +60 -25
  47. package/dist/tool-activation.d.ts +18 -0
  48. package/dist/tool-activation.js +227 -0
  49. package/dist/tools.d.ts +6 -3
  50. package/dist/tools.js +213 -72
  51. package/dist/widget.d.ts +4 -8
  52. package/dist/widget.js +55 -64
  53. package/package.json +51 -52
  54. package/dist/child-nudge.d.ts +0 -24
  55. package/dist/child-nudge.js +0 -102
  56. package/dist/connector.d.ts +0 -53
  57. package/dist/connector.js +0 -283
  58. package/dist/pause-gate.d.ts +0 -14
  59. package/dist/pause-gate.js +0 -64
  60. package/dist/rpc-adapter.d.ts +0 -40
  61. package/dist/rpc-adapter.js +0 -194
  62. package/dist/runner-entry.d.ts +0 -24
  63. package/dist/runner-entry.js +0 -166
  64. package/dist/runner.d.ts +0 -52
  65. package/dist/runner.js +0 -930
@@ -0,0 +1,490 @@
1
+ import { chmod, lstat, unlink } from "node:fs/promises";
2
+ import { connect, createServer } from "node:net";
3
+ import { errorMessage, isRecord } from "@hheei/pi-ext-core";
4
+ import { PROTOCOL_VERSION } from "./domain.js";
5
+ import { attachJsonLineReader, serializeJsonLine } from "./json-lines.js";
6
+ import { BRIDGE_OPERATIONS, BridgeError, DEFAULT_MAX_FRAME_BYTES, DEFAULT_MAX_PENDING_REQUESTS, failureResponse, isEventFrame, isHelloFrame, isRequestFrame, isResponseFrame, successResponse, } from "./protocol.js";
7
+ /**
8
+ * A handshake must arrive promptly: a socket that connects and then stays silent is either a
9
+ * probe or a broken client, and neither may occupy the server.
10
+ */
11
+ const HANDSHAKE_TIMEOUT_MS = 10_000;
12
+ /** How long the parent waits for a child's answer before failing the call visibly. */
13
+ export const DEFAULT_CHILD_REQUEST_TIMEOUT_MS = 30_000;
14
+ /**
15
+ * Reports are at-least-once: a child that loses the connection before the answer arrives sends the
16
+ * same request again, and the parent answers it from the first result instead of acting twice. The
17
+ * cache is per child and in memory — it covers a reconnect, not a parent restart.
18
+ */
19
+ const MAX_SERVED_REQUESTS = 256;
20
+ /** How long adoption waits for a surviving child to reconnect. */
21
+ const DEFAULT_ADOPT_WAIT_MS = 5_000;
22
+ /**
23
+ * The parent side of the child bridge: one Unix socket per parent session, one long-lived
24
+ * connection per child, carrying both directions of control.
25
+ *
26
+ * Control lives in the parent process instead of a helper process, so a child presented in a host
27
+ * panel and a child running headless are driven by exactly the same requests. A child that
28
+ * reconnects (parent restart, extension reload, dropped socket) replaces its previous connection
29
+ * and is otherwise treated as the same runtime.
30
+ */
31
+ export class ChildBridgeServer {
32
+ #options;
33
+ #connections = new Map();
34
+ /**
35
+ * Requests already answered, keyed by runtime and request id, so a retry is not executed twice.
36
+ * The runtime identity belongs in the key: a relaunched child starts counting its request ids
37
+ * from the beginning again, and answering its first report with the previous runtime's result
38
+ * would silently drop a report the child believes was delivered.
39
+ */
40
+ #served = new Map();
41
+ /** Requests being handled right now, so two copies in flight share one answer. */
42
+ #serving = new Map();
43
+ /** Sockets that have connected but not finished their handshake, so close() can end them. */
44
+ #handshaking = new Set();
45
+ #adoptWaiters = new Set();
46
+ #eventListeners = new Set();
47
+ #connectionListeners = new Set();
48
+ #server;
49
+ #boundEndpoint = false;
50
+ #requestSeq = 0;
51
+ #closed = false;
52
+ constructor(options) {
53
+ this.#options = options;
54
+ }
55
+ get endpoint() {
56
+ return this.#options.endpoint;
57
+ }
58
+ /** Binds the endpoint, refusing one that another live bridge server still owns. */
59
+ async listen() {
60
+ if (this.#server !== undefined)
61
+ throw new Error("bridge server is already listening");
62
+ await this.#clearStaleEndpoint();
63
+ const server = createServer({ allowHalfOpen: false });
64
+ this.#server = server;
65
+ server.on("connection", (socket) => this.#accept(socket));
66
+ server.on("error", (error) => this.#diagnose(`socket server error: ${errorMessage(error)}`));
67
+ const listening = new Promise((resolve, reject) => {
68
+ server.once("error", reject);
69
+ server.listen(this.#options.endpoint, () => {
70
+ server.off("error", reject);
71
+ resolve();
72
+ });
73
+ });
74
+ try {
75
+ await listening;
76
+ }
77
+ catch (error) {
78
+ this.#server = undefined;
79
+ server.close();
80
+ throw error instanceof Error ? error : new Error(errorMessage(error));
81
+ }
82
+ this.#boundEndpoint = true;
83
+ await this.#restrictEndpoint();
84
+ }
85
+ isConnected(childId) {
86
+ const connection = this.#connections.get(childId);
87
+ return connection !== undefined && !connection.closed;
88
+ }
89
+ connectedChildren() {
90
+ return [...this.#connections.values()]
91
+ .filter((entry) => !entry.closed)
92
+ .map((entry) => entry.childId);
93
+ }
94
+ /** Sends a request to a connected child and waits for its answer. */
95
+ async request(childId, operation, payload, options = {}) {
96
+ const connection = this.#connections.get(childId);
97
+ if (connection === undefined || connection.closed) {
98
+ throw new BridgeError("child_not_connected", `Child ${childId} has no live bridge connection`);
99
+ }
100
+ if (!BRIDGE_OPERATIONS.includes(operation)) {
101
+ // The child closes a connection that carries a frame it cannot parse, so an operation
102
+ // outside the protocol must fail here instead of hanging until the request timeout.
103
+ throw new BridgeError("unsupported_operation", `${operation} is not a bridge operation`);
104
+ }
105
+ const maxPending = this.#options.maxPendingRequests ?? DEFAULT_MAX_PENDING_REQUESTS;
106
+ if (connection.pending.size >= maxPending) {
107
+ throw new BridgeError("too_many_pending_requests", `Child ${childId} has too many requests in flight`);
108
+ }
109
+ const id = `bridge-${++this.#requestSeq}`;
110
+ const timeoutMs = options.timeoutMs ?? this.#options.requestTimeoutMs ?? DEFAULT_CHILD_REQUEST_TIMEOUT_MS;
111
+ const frame = {
112
+ version: PROTOCOL_VERSION,
113
+ type: "request",
114
+ id,
115
+ operation,
116
+ ...(payload === undefined ? {} : { payload }),
117
+ };
118
+ return await new Promise((resolve, reject) => {
119
+ const timer = setTimeout(() => {
120
+ connection.pending.delete(id);
121
+ reject(new BridgeError("timeout", `Child ${childId} did not answer ${operation} in time`));
122
+ }, timeoutMs);
123
+ connection.pending.set(id, { resolve, reject, timer });
124
+ if (this.#write(connection, frame))
125
+ return;
126
+ // A request that never left the parent must fail now, not after the timeout.
127
+ connection.pending.delete(id);
128
+ clearTimeout(timer);
129
+ reject(new BridgeError("write_failed", `Could not send ${operation} to child ${childId}`));
130
+ });
131
+ }
132
+ /**
133
+ * Waits for a child's bridge to come up. A child that survived a parent restart reconnects on
134
+ * its own schedule, so adoption is a bounded wait rather than a spawn.
135
+ */
136
+ async waitForConnection(childId, options = {}) {
137
+ if (this.isConnected(childId))
138
+ return true;
139
+ return await new Promise((resolve) => {
140
+ let settled = false;
141
+ const finish = (connected) => {
142
+ if (settled)
143
+ return;
144
+ settled = true;
145
+ clearTimeout(timer);
146
+ this.#adoptWaiters.delete(waiter);
147
+ options.signal?.removeEventListener("abort", onAbort);
148
+ resolve(connected);
149
+ };
150
+ const waiter = { childId, notify: finish };
151
+ const timer = setTimeout(() => finish(false), options.timeoutMs ?? DEFAULT_ADOPT_WAIT_MS);
152
+ const onAbort = () => finish(false);
153
+ options.signal?.addEventListener("abort", onAbort, { once: true });
154
+ this.#adoptWaiters.add(waiter);
155
+ });
156
+ }
157
+ /**
158
+ * Ends a child's connection from this side. The parent uses it when the process stopped being the
159
+ * child: leaving the socket open would let a relaunch mistake the old connection for the new
160
+ * runtime and send into a session the parent no longer owns.
161
+ */
162
+ disconnect(childId) {
163
+ const connection = this.#connections.get(childId);
164
+ if (connection === undefined)
165
+ return;
166
+ this.#connections.delete(childId);
167
+ this.#drop(connection, "the child left the delegated session");
168
+ this.#emitConnection(childId, false);
169
+ }
170
+ /** Subscribes to the fire-and-forget frames a child pushes: Pi events, lifecycle, input. */
171
+ onEvent(listener) {
172
+ this.#eventListeners.add(listener);
173
+ return () => {
174
+ this.#eventListeners.delete(listener);
175
+ };
176
+ }
177
+ /** Subscribes to connection transitions; a reconnect is what tells the parent a child is alive. */
178
+ onConnectionChange(listener) {
179
+ this.#connectionListeners.add(listener);
180
+ return () => {
181
+ this.#connectionListeners.delete(listener);
182
+ };
183
+ }
184
+ #emitEvent(childId, event) {
185
+ for (const listener of [...this.#eventListeners]) {
186
+ try {
187
+ listener(childId, event);
188
+ }
189
+ catch (error) {
190
+ this.#diagnose(`event listener failed: ${errorMessage(error)}`);
191
+ }
192
+ }
193
+ }
194
+ #emitConnection(childId, connected) {
195
+ for (const listener of [...this.#connectionListeners]) {
196
+ try {
197
+ listener(childId, connected);
198
+ }
199
+ catch (error) {
200
+ this.#diagnose(`connection listener failed: ${errorMessage(error)}`);
201
+ }
202
+ }
203
+ }
204
+ #notifyAdopters(childId) {
205
+ for (const waiter of [...this.#adoptWaiters]) {
206
+ if (waiter.childId === childId)
207
+ waiter.notify(true);
208
+ }
209
+ }
210
+ /** Closes every connection and removes the socket this server created. */
211
+ async close() {
212
+ if (this.#closed)
213
+ return;
214
+ this.#closed = true;
215
+ for (const connection of [...this.#connections.values()]) {
216
+ this.#drop(connection, "bridge server is closing");
217
+ }
218
+ this.#connections.clear();
219
+ // A handshake that is still waiting for its answer holds a socket that would otherwise keep
220
+ // the server open, so teardown must end it here.
221
+ for (const socket of [...this.#handshaking])
222
+ socket.destroy();
223
+ this.#handshaking.clear();
224
+ for (const waiter of [...this.#adoptWaiters])
225
+ waiter.notify(false);
226
+ const server = this.#server;
227
+ this.#server = undefined;
228
+ if (server !== undefined) {
229
+ await new Promise((resolve) => {
230
+ server.close(() => resolve());
231
+ });
232
+ }
233
+ if (!this.#boundEndpoint)
234
+ return;
235
+ this.#boundEndpoint = false;
236
+ try {
237
+ await unlink(this.#options.endpoint);
238
+ }
239
+ catch (error) {
240
+ if (!isErrno(error, "ENOENT")) {
241
+ this.#diagnose(`could not remove endpoint: ${errorMessage(error)}`);
242
+ }
243
+ }
244
+ }
245
+ #accept(socket) {
246
+ let connection;
247
+ this.#handshaking.add(socket);
248
+ const handshake = setTimeout(() => {
249
+ if (connection !== undefined)
250
+ return;
251
+ this.#diagnose("closing a connection that never completed the bridge handshake");
252
+ socket.destroy();
253
+ }, HANDSHAKE_TIMEOUT_MS);
254
+ const stop = attachJsonLineReader(socket, {
255
+ maxFrameBytes: this.#frameLimit(),
256
+ onValue: (value) => {
257
+ if (connection === undefined) {
258
+ // A child waits for `hello_ack` before sending anything else, so nothing is pipelined
259
+ // behind the hello: the handshake can complete asynchronously without losing frames.
260
+ void this.#handshake(socket, value).then((accepted) => {
261
+ if (accepted === undefined)
262
+ return;
263
+ clearTimeout(handshake);
264
+ connection = accepted;
265
+ });
266
+ return;
267
+ }
268
+ this.#receive(connection, value);
269
+ },
270
+ onError: (error) => {
271
+ this.#diagnose(`bridge connection frame error: ${error.message}`);
272
+ socket.destroy();
273
+ },
274
+ });
275
+ const teardown = () => {
276
+ clearTimeout(handshake);
277
+ stop();
278
+ this.#handshaking.delete(socket);
279
+ const current = connection;
280
+ if (current === undefined)
281
+ return;
282
+ connection = undefined;
283
+ // A replaced connection already stopped being the child's connection.
284
+ if (current.closed || this.#connections.get(current.childId) !== current)
285
+ return;
286
+ this.#connections.delete(current.childId);
287
+ this.#drop(current, "bridge connection closed");
288
+ this.#emitConnection(current.childId, false);
289
+ };
290
+ socket.on("close", teardown);
291
+ socket.on("error", (error) => this.#diagnose(`bridge connection error: ${error.message}`));
292
+ }
293
+ async #handshake(socket, value) {
294
+ if (!isHelloFrame(value)) {
295
+ this.#diagnose("refused a bridge connection with an invalid hello frame");
296
+ socket.destroy();
297
+ return undefined;
298
+ }
299
+ if (value.parentSessionId !== this.#options.parentSessionId) {
300
+ this.#diagnose(`refused a bridge hello for another parent session: ${value.parentSessionId}`);
301
+ socket.destroy();
302
+ return undefined;
303
+ }
304
+ // The parent may need to read persisted state to answer this, so the handshake waits for it:
305
+ // a connection is never accepted on a partially verified hello. The socket can be gone by the
306
+ // time the answer arrives (a handshake timeout or a disconnect), and then there is nothing to
307
+ // accept.
308
+ const refusal = await this.#options.authorize(value);
309
+ // The answer can arrive after the server started closing, or after the socket went away; a late
310
+ // handshake must not create a connection that teardown is no longer waiting for.
311
+ if (this.#closed || socket.destroyed || !this.#handshaking.has(socket))
312
+ return undefined;
313
+ if (refusal !== undefined) {
314
+ this.#diagnose(`refused a bridge hello for ${value.subagentId}: ${refusal}`);
315
+ socket.destroy();
316
+ return undefined;
317
+ }
318
+ const previous = this.#connections.get(value.subagentId);
319
+ const connection = {
320
+ childId: value.subagentId,
321
+ runtimeIdentity: value.runtimeIdentity,
322
+ socket,
323
+ pending: new Map(),
324
+ closed: false,
325
+ };
326
+ this.#connections.set(value.subagentId, connection);
327
+ this.#write(connection, { version: PROTOCOL_VERSION, type: "hello_ack" });
328
+ if (previous !== undefined && previous !== connection) {
329
+ // The child reconnected: the newer socket is the live one, the old one is stale.
330
+ this.#drop(previous, "replaced by a newer bridge connection");
331
+ return connection;
332
+ }
333
+ this.#emitConnection(connection.childId, true);
334
+ this.#notifyAdopters(connection.childId);
335
+ return connection;
336
+ }
337
+ #receive(connection, value) {
338
+ if (isEventFrame(value)) {
339
+ this.#emitEvent(connection.childId, value.event);
340
+ return;
341
+ }
342
+ if (isResponseFrame(value)) {
343
+ const pending = connection.pending.get(value.id);
344
+ if (pending === undefined)
345
+ return;
346
+ connection.pending.delete(value.id);
347
+ clearTimeout(pending.timer);
348
+ if (value.ok)
349
+ pending.resolve(value.data);
350
+ else
351
+ pending.reject(new BridgeError(value.error.code, value.error.message));
352
+ return;
353
+ }
354
+ if (isRequestFrame(value)) {
355
+ const key = this.#requestKey(connection, value.id);
356
+ const answered = this.#served.get(key);
357
+ if (answered !== undefined) {
358
+ // The child did not receive the first answer, so it sent the same request again. It is
359
+ // answered from that first result: the parent already acted on it.
360
+ this.#write(connection, answered);
361
+ return;
362
+ }
363
+ const inFlight = this.#serving.get(key);
364
+ if (inFlight !== undefined) {
365
+ // The same request arrived twice, either because the answer was lost or because the child
366
+ // reconnected while the first copy was still being handled. Both askers want that one
367
+ // result, and the one asking now gets it on the connection it is asking on.
368
+ void inFlight.then((response) => this.#write(connection, response));
369
+ return;
370
+ }
371
+ const pending = this.#serve(connection, key, value.id, value.operation, value.payload);
372
+ this.#serving.set(key, pending);
373
+ return;
374
+ }
375
+ this.#diagnose("closing a bridge connection that sent an unrecognised frame");
376
+ connection.socket.destroy();
377
+ }
378
+ /**
379
+ * A request is identified by the runtime that sent it. A relaunched child — the same session, a
380
+ * new process — counts its request ids from one again, so the child alone is not an identity.
381
+ */
382
+ #requestKey(connection, id) {
383
+ return `${connection.childId}:${connection.runtimeIdentity}:${id}`;
384
+ }
385
+ /** Handles one child request and returns the frame that answers it. */
386
+ async #serve(connection, key, id, operation, payload) {
387
+ let response;
388
+ try {
389
+ const data = await this.#options.handleRequest({
390
+ childId: connection.childId,
391
+ runtimeIdentity: connection.runtimeIdentity,
392
+ operation,
393
+ payload,
394
+ });
395
+ response = successResponse(id, data);
396
+ }
397
+ catch (error) {
398
+ const code = error instanceof BridgeError ? error.code : "bridge_error";
399
+ response = failureResponse(id, code, errorMessage(error));
400
+ }
401
+ this.#serving.delete(key);
402
+ this.#served.set(key, response);
403
+ while (this.#served.size > MAX_SERVED_REQUESTS) {
404
+ const oldest = this.#served.keys().next();
405
+ if (oldest.done === true)
406
+ break;
407
+ this.#served.delete(oldest.value);
408
+ }
409
+ this.#write(connection, response);
410
+ return response;
411
+ }
412
+ /** Reports whether the frame left the parent; a frame that did not is diagnosed. */
413
+ #write(connection, frame) {
414
+ if (connection.closed || connection.socket.destroyed) {
415
+ this.#diagnose(`dropped a frame for child ${connection.childId}: the connection is gone`);
416
+ return false;
417
+ }
418
+ let encoded;
419
+ try {
420
+ encoded = serializeJsonLine(frame, this.#frameLimit());
421
+ }
422
+ catch (error) {
423
+ this.#diagnose(`could not serialize a frame for child ${connection.childId}: ${errorMessage(error)}`);
424
+ return false;
425
+ }
426
+ connection.socket.write(encoded);
427
+ return true;
428
+ }
429
+ #frameLimit() {
430
+ return this.#options.maxFrameBytes ?? DEFAULT_MAX_FRAME_BYTES;
431
+ }
432
+ /** Fails every request in flight for a connection that is going away. */
433
+ #drop(connection, reason) {
434
+ if (connection.closed)
435
+ return;
436
+ connection.closed = true;
437
+ for (const [id, pending] of connection.pending) {
438
+ connection.pending.delete(id);
439
+ clearTimeout(pending.timer);
440
+ pending.reject(new BridgeError("connection_lost", `Child ${connection.childId} connection closed: ${reason}`));
441
+ }
442
+ connection.socket.destroy();
443
+ }
444
+ async #clearStaleEndpoint() {
445
+ const endpoint = this.#options.endpoint;
446
+ try {
447
+ const stats = await lstat(endpoint);
448
+ if (!stats.isSocket()) {
449
+ throw new Error(`bridge endpoint path exists and is not a Unix socket: ${endpoint}`);
450
+ }
451
+ }
452
+ catch (error) {
453
+ if (isErrno(error, "ENOENT"))
454
+ return;
455
+ throw error instanceof Error ? error : new Error(errorMessage(error));
456
+ }
457
+ if (await isEndpointLive(endpoint)) {
458
+ throw new Error(`a live bridge server already owns endpoint ${endpoint}`);
459
+ }
460
+ await unlink(endpoint);
461
+ this.#diagnose(`removed stale endpoint ${endpoint}`);
462
+ }
463
+ async #restrictEndpoint() {
464
+ try {
465
+ await chmod(this.#options.endpoint, 0o600);
466
+ }
467
+ catch (error) {
468
+ this.#diagnose(`could not restrict endpoint permissions: ${errorMessage(error)}`);
469
+ }
470
+ }
471
+ #diagnose(message) {
472
+ this.#options.diagnose?.(message);
473
+ }
474
+ }
475
+ /** Probes whether something is still listening on a socket path. */
476
+ function isEndpointLive(endpoint) {
477
+ return new Promise((resolve) => {
478
+ const socket = connect(endpoint);
479
+ const done = (live) => {
480
+ socket.destroy();
481
+ resolve(live);
482
+ };
483
+ socket.once("connect", () => done(true));
484
+ socket.once("error", () => done(false));
485
+ setTimeout(() => done(false), 1_000).unref();
486
+ });
487
+ }
488
+ function isErrno(error, code) {
489
+ return isRecord(error) && error.code === code;
490
+ }
@@ -0,0 +1,24 @@
1
+ import type { DiscoveredAgent } from "./agent-resolver.js";
2
+ /**
3
+ * A synthetic path, not a file: it names the definition in errors and UI without pretending
4
+ * to be an editable location.
5
+ */
6
+ export declare const BUILTIN_AGENT_PREFIX = "<builtin>/";
7
+ /**
8
+ * The tools the scout must keep: reading and searching, plus the channel it reports on. Anything
9
+ * missing is a configuration failure, not a shrug.
10
+ */
11
+ export declare const SCOUT_REQUIRED_TOOLS: readonly ["read", "grep", "find", "ls", "contact_parent"];
12
+ /** The tools a worker must keep: it exists to change code and to say what it changed. */
13
+ export declare const WORKER_REQUIRED_TOOLS: readonly ["read", "edit", "write", "bash", "contact_parent"];
14
+ /** The tools a reviewer must keep: it reads the code, runs the checks, and reports. */
15
+ export declare const REVIEWER_REQUIRED_TOOLS: readonly ["read", "grep", "bash", "contact_parent"];
16
+ /** The built-in definitions, parsed once. A malformed constant is a build-time bug, not user input. */
17
+ export declare function builtinAgents(): readonly DiscoveredAgent[];
18
+ /**
19
+ * Why a resolved tool allowlist would break a built-in agent's promise, or `undefined` when it
20
+ * does not. A built-in ships exactly the tools it names, so an allowlist that lost them, gained
21
+ * others, or became empty (which Pi reads as "every tool") would give the agent different powers
22
+ * than its definition advertises; resolution fails instead.
23
+ */
24
+ export declare function builtinAgentToolProblem(sourcePath: string, tools: readonly string[]): string | undefined;
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Built-in agent definitions.
3
+ *
4
+ * These ship inside the package so a fresh install has usable roles without writing anything
5
+ * into the user's home: reconnaissance, implementation, and review. A user definition at the
6
+ * same name always wins, because discovery only falls back here after every filesystem scope
7
+ * has been searched.
8
+ */
9
+ import { parseFrontmatter } from "@earendil-works/pi-coding-agent";
10
+ /**
11
+ * A synthetic path, not a file: it names the definition in errors and UI without pretending
12
+ * to be an editable location.
13
+ */
14
+ export const BUILTIN_AGENT_PREFIX = "<builtin>/";
15
+ /**
16
+ * The tools the scout must keep: reading and searching, plus the channel it reports on. Anything
17
+ * missing is a configuration failure, not a shrug.
18
+ */
19
+ export const SCOUT_REQUIRED_TOOLS = ["read", "grep", "find", "ls", "contact_parent"];
20
+ /** The tools a worker must keep: it exists to change code and to say what it changed. */
21
+ export const WORKER_REQUIRED_TOOLS = ["read", "edit", "write", "bash", "contact_parent"];
22
+ /** The tools a reviewer must keep: it reads the code, runs the checks, and reports. */
23
+ export const REVIEWER_REQUIRED_TOOLS = ["read", "grep", "bash", "contact_parent"];
24
+ const SCOUT_DEFINITION = `---
25
+ name: scout
26
+ description: Read-only reconnaissance of a codebase or a question, reported back to the parent.
27
+ hidden: false
28
+ tools: read,grep,find,ls,contact_parent
29
+ ---
30
+
31
+ You are a read-only scout. Your job is to look things up and report exactly what you found.
32
+
33
+ Rules:
34
+ - Read and search only. You cannot write files, run commands, or change state; do not pretend otherwise.
35
+ - Ground every claim in a file path and the line you read. If you could not verify something, say so.
36
+ - Finish by writing your final findings and summary as normal text in your response; the harness automatically delivers it to the parent once completed.
37
+ - Use contact_parent ONLY if you are blocked or urgently require a parent decision midway; NEVER call contact_parent to report success or task completion.
38
+ `;
39
+ const WORKER_DEFINITION = `---
40
+ name: worker
41
+ description: Carries out an implementation task in the repository and reports what it changed.
42
+ hidden: false
43
+ tools: read,grep,find,ls,bash,edit,write,contact_parent
44
+ model: inherit
45
+ ---
46
+
47
+ You are a worker. Carry out the task in the repository you were given, and report what you did.
48
+
49
+ Rules:
50
+ - Read the surrounding code and the repository's own conventions before you edit. Keep the change
51
+ focused on the task; do not refactor unrelated code.
52
+ - Run the focused checks the change needs (tests, typecheck, lint) and report their real output,
53
+ including failures. Do not describe a check you did not run.
54
+ - Do not commit, push, or open a pull request unless the task asks for it explicitly.
55
+ - Finish by writing what changed, what you verified, and what you could not verify as normal text
56
+ in your response; the harness automatically delivers it to the parent once completed.
57
+ - Use contact_parent ONLY if you are blocked or urgently require a parent decision midway; NEVER
58
+ call contact_parent to report success or task completion.
59
+ `;
60
+ const REVIEWER_DEFINITION = `---
61
+ name: reviewer
62
+ description: Reviews code or a change and reports findings, using the review skills it is given.
63
+ hidden: false
64
+ skills: true
65
+ tools: read,grep,find,ls,bash,contact_parent
66
+ model: inherit
67
+ ---
68
+
69
+ You are a reviewer. Inspect what you were pointed at and report findings. Do not change files unless
70
+ the task asks you to fix what you find.
71
+
72
+ Rules:
73
+ - Ground every finding in a file path and the line you read. If you could not verify something, say
74
+ so instead of guessing.
75
+ - Order findings by severity and report the smallest set of real defects, risks and simplifications.
76
+ A padded list costs the parent more than it tells it.
77
+ - When the code-review skill is listed in your prompt and the task is a review of a change, follow
78
+ it: review along its two axes (the repository's own standards, and the spec the change came from)
79
+ and report them separately, as the skill describes.
80
+ - When the ponytail-review skill is listed and the question is whether something is over-engineered,
81
+ follow it: one line per finding — location, what to cut, what replaces it.
82
+ - Use bash to run the checks a finding depends on (tests, typecheck, a reproduction) and report the
83
+ real output, not your expectation.
84
+ - A child cannot delegate further, so when a skill or a plan asks for parallel sub-agents, run those
85
+ passes yourself instead of trying to spawn them, and say that is what you did.
86
+ - Finish by writing your findings as normal text in your response; the harness automatically
87
+ delivers it to the parent once completed.
88
+ - Use contact_parent ONLY if you are blocked or urgently require a parent decision midway; NEVER
89
+ call contact_parent to report success or task completion.
90
+ `;
91
+ const DEFINITIONS = [
92
+ { text: SCOUT_DEFINITION, requiredTools: SCOUT_REQUIRED_TOOLS },
93
+ { text: WORKER_DEFINITION, requiredTools: WORKER_REQUIRED_TOOLS },
94
+ { text: REVIEWER_DEFINITION, requiredTools: REVIEWER_REQUIRED_TOOLS },
95
+ ];
96
+ /** Reads the comma form used by built-in definitions. */
97
+ function toolNames(value) {
98
+ if (typeof value !== "string")
99
+ return [];
100
+ return value
101
+ .split(",")
102
+ .map((entry) => entry.trim())
103
+ .filter(Boolean);
104
+ }
105
+ let parsed;
106
+ /** The built-in definitions, parsed once. A malformed constant is a build-time bug, not user input. */
107
+ export function builtinAgents() {
108
+ parsed ??= DEFINITIONS.map(parseDefinition);
109
+ return parsed;
110
+ }
111
+ function parseDefinition(definition) {
112
+ const { frontmatter, body } = parseFrontmatter(definition.text);
113
+ const name = frontmatter.name;
114
+ if (typeof name !== "string" || name === "") {
115
+ throw new Error("Built-in agent definition has no name");
116
+ }
117
+ const declared = toolNames(frontmatter.tools);
118
+ // An empty `tools` list means "every tool" downstream, which is the opposite of what a
119
+ // built-in promises, so the definition is rejected rather than widened.
120
+ if (declared.length === 0) {
121
+ throw new Error(`Built-in agent ${name} must name the tools it may use`);
122
+ }
123
+ const missing = definition.requiredTools.filter((tool) => !declared.includes(tool));
124
+ if (missing.length > 0) {
125
+ throw new Error(`Built-in agent ${name} must keep its required tools; missing: ${missing.join(", ")}`);
126
+ }
127
+ return { name, path: `${BUILTIN_AGENT_PREFIX}${name}.md`, frontmatter, body };
128
+ }
129
+ /**
130
+ * Why a resolved tool allowlist would break a built-in agent's promise, or `undefined` when it
131
+ * does not. A built-in ships exactly the tools it names, so an allowlist that lost them, gained
132
+ * others, or became empty (which Pi reads as "every tool") would give the agent different powers
133
+ * than its definition advertises; resolution fails instead.
134
+ */
135
+ export function builtinAgentToolProblem(sourcePath, tools) {
136
+ const definition = builtinAgents().find((agent) => agent.path === sourcePath);
137
+ if (definition === undefined)
138
+ return undefined;
139
+ if (tools.length === 0) {
140
+ return "its tool list was lost, which would give it every tool";
141
+ }
142
+ const declared = toolNames(definition.frontmatter.tools);
143
+ const missing = declared.filter((tool) => !tools.includes(tool));
144
+ if (missing.length > 0) {
145
+ return `tools it declares are missing: ${missing.join(", ")}`;
146
+ }
147
+ const gained = tools.filter((tool) => !declared.includes(tool) && tool !== "codemode");
148
+ if (gained.length > 0) {
149
+ return `it would gain tools it never declared: ${gained.join(", ")}`;
150
+ }
151
+ return undefined;
152
+ }