@narumitw/pi-subagents 3.0.1 → 3.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.
@@ -1,153 +1,145 @@
1
1
  import net from "node:net";
2
2
  import type { ExtensionFactory } from "@earendil-works/pi-coding-agent";
3
- import { captureBrokerCredentials } from "./broker-credentials.js";
4
- import {
5
- type ChildCommunicationClient,
6
- createChildCommunicationExtension,
7
- } from "./child-communication-tools.js";
3
+ import { captureChildBootstrap } from "./broker-credentials.js";
4
+ import { type ChildCommunicationClient, createChildCommunicationExtension } from "./child-communication-tools.js";
8
5
  import { MAX_FRAME_BYTES, MAX_IDENTIFIER_LENGTH } from "./message-broker.js";
9
6
  import type { BrokerCredentials } from "./types.js";
10
7
 
11
8
  const CONNECT_TIMEOUT_MS = 2_000;
12
9
  const SEND_RESPONSE_TIMEOUT_MS = 5_000;
13
10
 
14
- const captured = captureBrokerCredentials();
11
+ const captured = captureChildBootstrap();
15
12
 
16
13
  const childCommunicationBridge: ExtensionFactory = captured
17
- ? createChildCommunicationExtension(createBrokerClient(captured))
18
- : () => undefined;
14
+ ? createChildCommunicationExtension(createBrokerClient(captured.communication))
15
+ : () => undefined;
19
16
 
20
17
  export default childCommunicationBridge;
21
18
 
22
19
  export function createBrokerClient(credentials: BrokerCredentials): ChildCommunicationClient {
23
- return {
24
- async send(params, signal) {
25
- const response = await requestBroker(
26
- credentials,
27
- { type: "send", token: credentials.token, ...params },
28
- signal,
29
- SEND_RESPONSE_TIMEOUT_MS,
30
- );
31
- if (response.ok !== true) throw brokerError(response);
32
- if (
33
- typeof response.requestId !== "string" ||
34
- !response.requestId ||
35
- response.requestId.length > MAX_IDENTIFIER_LENGTH ||
36
- typeof response.accepted !== "boolean" ||
37
- typeof response.duplicate !== "boolean"
38
- ) {
39
- throw new Error("Subagent broker returned an invalid send acknowledgement.");
40
- }
41
- return {
42
- requestId: response.requestId,
43
- accepted: response.accepted,
44
- duplicate: response.duplicate,
45
- };
46
- },
47
- async wait(requestId, timeoutMs, signal) {
48
- const response = await requestBroker(
49
- credentials,
50
- {
51
- type: "wait",
52
- token: credentials.token,
53
- requestId,
54
- ...(timeoutMs !== undefined ? { timeoutMs } : {}),
55
- },
56
- signal,
57
- );
58
- if (response.ok !== true) throw brokerError(response);
59
- if (typeof response.response !== "string") {
60
- throw new Error("Subagent broker returned an invalid plain-text response.");
61
- }
62
- return response.response;
63
- },
64
- };
20
+ return {
21
+ async send(params, signal) {
22
+ const response = await requestBroker(
23
+ credentials,
24
+ { type: "send", token: credentials.token, ...params },
25
+ signal,
26
+ SEND_RESPONSE_TIMEOUT_MS,
27
+ );
28
+ if (response.ok !== true) throw brokerError(response);
29
+ if (
30
+ typeof response.requestId !== "string" ||
31
+ !response.requestId ||
32
+ response.requestId.length > MAX_IDENTIFIER_LENGTH ||
33
+ typeof response.accepted !== "boolean" ||
34
+ typeof response.duplicate !== "boolean"
35
+ ) {
36
+ throw new Error("Subagent broker returned an invalid send acknowledgement.");
37
+ }
38
+ return {
39
+ requestId: response.requestId,
40
+ accepted: response.accepted,
41
+ duplicate: response.duplicate,
42
+ };
43
+ },
44
+ async wait(requestId, timeoutMs, signal) {
45
+ const response = await requestBroker(
46
+ credentials,
47
+ {
48
+ type: "wait",
49
+ token: credentials.token,
50
+ requestId,
51
+ ...(timeoutMs !== undefined ? { timeoutMs } : {}),
52
+ },
53
+ signal,
54
+ );
55
+ if (response.ok !== true) throw brokerError(response);
56
+ if (typeof response.response !== "string") {
57
+ throw new Error("Subagent broker returned an invalid plain-text response.");
58
+ }
59
+ return response.response;
60
+ },
61
+ };
65
62
  }
66
63
 
67
64
  function requestBroker(
68
- credentials: BrokerCredentials,
69
- request: Record<string, unknown>,
70
- signal?: AbortSignal,
71
- responseTimeoutMs?: number,
65
+ credentials: BrokerCredentials,
66
+ request: Record<string, unknown>,
67
+ signal?: AbortSignal,
68
+ responseTimeoutMs?: number,
72
69
  ): Promise<Record<string, unknown>> {
73
- if (signal?.aborted) return Promise.reject(abortError("Subagent broker request was cancelled."));
74
- return new Promise((resolve, reject) => {
75
- const socket = net.createConnection({ host: credentials.host, port: credentials.port });
76
- let response = Buffer.alloc(0);
77
- let settled = false;
78
- let responseTimer: NodeJS.Timeout | undefined;
79
- const connectTimer = setTimeout(
80
- () => finish(new Error("Subagent broker connection timed out.")),
81
- CONNECT_TIMEOUT_MS,
82
- );
83
- connectTimer.unref();
84
- const onAbort = () => finish(abortError("Subagent broker request was cancelled."));
85
- const finish = (error?: Error, value?: Record<string, unknown>) => {
86
- if (settled) return;
87
- settled = true;
88
- clearTimeout(connectTimer);
89
- if (responseTimer) clearTimeout(responseTimer);
90
- signal?.removeEventListener("abort", onAbort);
91
- socket.destroy();
92
- if (error) reject(error);
93
- else resolve(value ?? {});
94
- };
95
- signal?.addEventListener("abort", onAbort, { once: true });
96
- socket.once("connect", () => {
97
- clearTimeout(connectTimer);
98
- if (responseTimeoutMs !== undefined) {
99
- responseTimer = setTimeout(
100
- () => finish(new Error("Subagent broker response timed out.")),
101
- responseTimeoutMs,
102
- );
103
- responseTimer.unref();
104
- }
105
- socket.write(`${JSON.stringify(request)}\n`);
106
- });
107
- socket.on("data", (chunk: Buffer) => {
108
- if (settled) return;
109
- response = Buffer.concat([response, chunk]);
110
- if (response.byteLength > MAX_FRAME_BYTES) {
111
- finish(new Error("Subagent broker response exceeded its size limit."));
112
- return;
113
- }
114
- const newline = response.indexOf(0x0a);
115
- if (newline < 0) return;
116
- const trailing = response
117
- .subarray(newline + 1)
118
- .toString("utf8")
119
- .trim();
120
- if (trailing) {
121
- finish(new Error("Subagent broker returned more than one response frame."));
122
- return;
123
- }
124
- try {
125
- const parsed = JSON.parse(response.subarray(0, newline).toString("utf8")) as unknown;
126
- if (!isRecord(parsed)) throw new Error();
127
- finish(undefined, parsed);
128
- } catch {
129
- finish(new Error("Subagent broker returned malformed JSON."));
130
- }
131
- });
132
- socket.once("error", (error) => finish(error));
133
- socket.once("close", () => {
134
- if (!settled) finish(new Error("Subagent broker closed without a response."));
135
- });
136
- });
70
+ if (signal?.aborted) return Promise.reject(abortError("Subagent broker request was cancelled."));
71
+ return new Promise((resolve, reject) => {
72
+ const socket = net.createConnection({ host: credentials.host, port: credentials.port });
73
+ let response = Buffer.alloc(0);
74
+ let settled = false;
75
+ let responseTimer: NodeJS.Timeout | undefined;
76
+ const connectTimer = setTimeout(
77
+ () => finish(new Error("Subagent broker connection timed out.")),
78
+ CONNECT_TIMEOUT_MS,
79
+ );
80
+ connectTimer.unref();
81
+ const onAbort = () => finish(abortError("Subagent broker request was cancelled."));
82
+ const finish = (error?: Error, value?: Record<string, unknown>) => {
83
+ if (settled) return;
84
+ settled = true;
85
+ clearTimeout(connectTimer);
86
+ if (responseTimer) clearTimeout(responseTimer);
87
+ signal?.removeEventListener("abort", onAbort);
88
+ socket.destroy();
89
+ if (error) reject(error);
90
+ else resolve(value ?? {});
91
+ };
92
+ signal?.addEventListener("abort", onAbort, { once: true });
93
+ socket.once("connect", () => {
94
+ clearTimeout(connectTimer);
95
+ if (responseTimeoutMs !== undefined) {
96
+ responseTimer = setTimeout(() => finish(new Error("Subagent broker response timed out.")), responseTimeoutMs);
97
+ responseTimer.unref();
98
+ }
99
+ socket.write(`${JSON.stringify(request)}\n`);
100
+ });
101
+ socket.on("data", (chunk: Buffer) => {
102
+ if (settled) return;
103
+ response = Buffer.concat([response, chunk]);
104
+ if (response.byteLength > MAX_FRAME_BYTES) {
105
+ finish(new Error("Subagent broker response exceeded its size limit."));
106
+ return;
107
+ }
108
+ const newline = response.indexOf(0x0a);
109
+ if (newline < 0) return;
110
+ const trailing = response
111
+ .subarray(newline + 1)
112
+ .toString("utf8")
113
+ .trim();
114
+ if (trailing) {
115
+ finish(new Error("Subagent broker returned more than one response frame."));
116
+ return;
117
+ }
118
+ try {
119
+ const parsed = JSON.parse(response.subarray(0, newline).toString("utf8")) as unknown;
120
+ if (!isRecord(parsed)) throw new Error();
121
+ finish(undefined, parsed);
122
+ } catch {
123
+ finish(new Error("Subagent broker returned malformed JSON."));
124
+ }
125
+ });
126
+ socket.once("error", (error) => finish(error));
127
+ socket.once("close", () => {
128
+ if (!settled) finish(new Error("Subagent broker closed without a response."));
129
+ });
130
+ });
137
131
  }
138
132
 
139
133
  function brokerError(response: Record<string, unknown>): Error {
140
- return new Error(
141
- typeof response.error === "string" ? response.error : "Subagent broker rejected the request.",
142
- );
134
+ return new Error(typeof response.error === "string" ? response.error : "Subagent broker rejected the request.");
143
135
  }
144
136
 
145
137
  function abortError(message: string): Error {
146
- const error = new Error(message);
147
- error.name = "AbortError";
148
- return error;
138
+ const error = new Error(message);
139
+ error.name = "AbortError";
140
+ return error;
149
141
  }
150
142
 
151
143
  function isRecord(value: unknown): value is Record<string, unknown> {
152
- return typeof value === "object" && value !== null && !Array.isArray(value);
144
+ return typeof value === "object" && value !== null && !Array.isArray(value);
153
145
  }
@@ -1,147 +1,119 @@
1
1
  import type { ExtensionFactory } from "@earendil-works/pi-coding-agent";
2
2
  import { type Static, Type } from "typebox";
3
3
  import {
4
- type BrokerSendAcknowledgement,
5
- MAX_IDENTIFIER_LENGTH,
6
- MAX_MESSAGE_BYTES,
7
- sanitizeTerminalText,
8
- validateMessage,
4
+ type BrokerSendAcknowledgement,
5
+ MAX_IDENTIFIER_LENGTH,
6
+ MAX_MESSAGE_BYTES,
7
+ sanitizeTerminalText,
8
+ validateMessage,
9
9
  } from "./message-broker.js";
10
+ import { prepareTimeoutArguments, resolveTimeoutMs } from "./timeout.js";
10
11
 
11
12
  export const CHILD_COMMUNICATION_TOOL_NAMES = ["subagent_send", "subagent_wait"] as const;
12
13
 
13
- const MAX_TIMEOUT_MS = 2_147_483_647;
14
- const MAX_TIMEOUT_SECONDS = MAX_TIMEOUT_MS / 1000;
15
-
16
14
  const SendParameters = Type.Object(
17
- {
18
- requestId: Type.Optional(
19
- Type.String({
20
- description: "Pending main-agent request to answer. Omit when starting a new request.",
21
- minLength: 1,
22
- maxLength: MAX_IDENTIFIER_LENGTH,
23
- }),
24
- ),
25
- message: Type.String({
26
- description: "Plain-text request or response. Maximum 48 KiB of UTF-8 text and 1,992 lines.",
27
- minLength: 1,
28
- maxLength: MAX_MESSAGE_BYTES,
29
- }),
30
- },
31
- { additionalProperties: false },
15
+ {
16
+ requestId: Type.Optional(
17
+ Type.String({
18
+ description: "Pending main-agent request to answer. Omit when starting a new request.",
19
+ minLength: 1,
20
+ maxLength: MAX_IDENTIFIER_LENGTH,
21
+ }),
22
+ ),
23
+ message: Type.String({
24
+ description: "Plain-text request or response. Maximum 48 KiB of UTF-8 text and 1,992 lines.",
25
+ minLength: 1,
26
+ maxLength: MAX_MESSAGE_BYTES,
27
+ }),
28
+ },
29
+ { additionalProperties: false },
32
30
  );
33
31
 
34
32
  const WaitParameters = Type.Object(
35
- {
36
- requestId: Type.String({
37
- description: "Request ID returned by subagent_send.",
38
- minLength: 1,
39
- maxLength: MAX_IDENTIFIER_LENGTH,
40
- }),
41
- timeout: Type.Optional(
42
- Type.Number({ description: "Timeout in seconds (optional, no default timeout)" }),
43
- ),
44
- },
45
- { additionalProperties: false },
33
+ {
34
+ requestId: Type.String({
35
+ description: "Request ID returned by subagent_send.",
36
+ minLength: 1,
37
+ maxLength: MAX_IDENTIFIER_LENGTH,
38
+ }),
39
+ timeout: Type.Optional(Type.Number({ description: "Timeout in seconds (optional, no default timeout)" })),
40
+ },
41
+ { additionalProperties: false },
46
42
  );
47
43
 
48
- type ChildBrokerSendArguments =
49
- | { recipient: "main"; message: string }
50
- | { requestId: string; message: string };
44
+ type ChildBrokerSendArguments = { recipient: "main"; message: string } | { requestId: string; message: string };
51
45
 
52
46
  type WaitArguments = Static<typeof WaitParameters>;
53
47
 
54
48
  export interface ChildCommunicationClient {
55
- send(params: ChildBrokerSendArguments, signal?: AbortSignal): Promise<BrokerSendAcknowledgement>;
56
- wait(requestId: string, timeoutMs: number | undefined, signal?: AbortSignal): Promise<string>;
49
+ send(params: ChildBrokerSendArguments, signal?: AbortSignal): Promise<BrokerSendAcknowledgement>;
50
+ wait(requestId: string, timeoutMs: number | undefined, signal?: AbortSignal): Promise<string>;
57
51
  }
58
52
 
59
- export function createChildCommunicationExtension(
60
- client: ChildCommunicationClient,
61
- ): ExtensionFactory {
62
- return (pi) => {
63
- pi.registerTool({
64
- name: "subagent_send",
65
- label: "Subagent · Send to Main",
66
- description:
67
- "Use subagent_send to send one request to the main agent or answer one pending main-agent request. Omit requestId to start a request. Provide requestId to answer that request.",
68
- promptSnippet: "Use subagent_send to send or answer one main-agent message",
69
- parameters: SendParameters,
70
- async execute(_toolCallId, params, signal) {
71
- validateMessage(params.message, "Subagent message");
72
- const requestId = optionalIdentifier(params.requestId, "requestId");
73
- const acknowledgement = await client.send(
74
- requestId === undefined
75
- ? { recipient: "main", message: params.message }
76
- : { requestId, message: params.message },
77
- signal,
78
- );
79
- return {
80
- content: [{ type: "text" as const, text: JSON.stringify(acknowledgement) }],
81
- details: acknowledgement,
82
- };
83
- },
84
- });
85
-
86
- pi.registerTool({
87
- name: "subagent_wait",
88
- label: "Subagent · Wait for Main Agent",
89
- description:
90
- "Use subagent_wait with a request ID from subagent_send to wait for the main agent's plain-text response. A timeout, caller cancellation, or incoming main-agent request stops only this wait and does not cancel the original request. Retry the wait when its response is still needed.",
91
- promptSnippet: "Use subagent_wait to receive a requested main-agent response",
92
- parameters: WaitParameters,
93
- prepareArguments: prepareWaitArguments,
94
- async execute(_toolCallId, params, signal) {
95
- const requestId = requiredIdentifier(params.requestId, "requestId");
96
- const response = await client.wait(requestId, resolveTimeoutMs(params.timeout), signal);
97
- return {
98
- content: [{ type: "text" as const, text: sanitizeTerminalText(response) }],
99
- details: { requestId },
100
- };
101
- },
102
- });
103
- };
104
- }
53
+ export function createChildCommunicationExtension(client: ChildCommunicationClient): ExtensionFactory {
54
+ return (pi) => {
55
+ pi.registerTool({
56
+ name: "subagent_send",
57
+ label: "Subagent · Send to Main",
58
+ description:
59
+ "Use subagent_send to send one request to the main agent or answer one pending main-agent request. Omit requestId to start a request. Provide requestId to answer that request.",
60
+ promptSnippet: "Use subagent_send to send or answer one main-agent message",
61
+ parameters: SendParameters,
62
+ async execute(_toolCallId, params, signal) {
63
+ validateMessage(params.message, "Subagent message");
64
+ const requestId = optionalIdentifier(params.requestId, "requestId");
65
+ const acknowledgement = await client.send(
66
+ requestId === undefined
67
+ ? { recipient: "main", message: params.message }
68
+ : { requestId, message: params.message },
69
+ signal,
70
+ );
71
+ return {
72
+ content: [{ type: "text" as const, text: JSON.stringify(acknowledgement) }],
73
+ details: acknowledgement,
74
+ };
75
+ },
76
+ });
105
77
 
106
- function resolveTimeoutMs(timeout: number | undefined): number | undefined {
107
- if (timeout === undefined) return undefined;
108
- if (!Number.isFinite(timeout) || timeout <= 0) {
109
- throw new Error("Invalid timeout: must be a finite number of seconds");
110
- }
111
- const timeoutMs = timeout * 1000;
112
- if (timeoutMs > MAX_TIMEOUT_MS) {
113
- throw new Error(`Invalid timeout: maximum is ${MAX_TIMEOUT_SECONDS} seconds`);
114
- }
115
- return timeoutMs;
78
+ pi.registerTool({
79
+ name: "subagent_wait",
80
+ label: "Subagent · Wait for Main Agent",
81
+ description:
82
+ "Use subagent_wait with a request ID from subagent_send to wait for the main agent's plain-text response. A timeout, caller cancellation, or incoming main-agent request stops only this wait and does not cancel the original request. Retry the wait when its response is still needed.",
83
+ promptSnippet: "Use subagent_wait to receive a requested main-agent response",
84
+ parameters: WaitParameters,
85
+ prepareArguments: prepareWaitArguments,
86
+ async execute(_toolCallId, params, signal) {
87
+ const requestId = requiredIdentifier(params.requestId, "requestId");
88
+ const response = await client.wait(requestId, resolveTimeoutMs(params.timeout), signal);
89
+ return {
90
+ content: [{ type: "text" as const, text: sanitizeTerminalText(response) }],
91
+ details: { requestId },
92
+ };
93
+ },
94
+ });
95
+ };
116
96
  }
117
97
 
118
98
  function prepareWaitArguments(args: unknown): WaitArguments {
119
- if (!args || typeof args !== "object") return args as WaitArguments;
120
- if (!Object.hasOwn(args, "timeoutMs")) return args as WaitArguments;
121
- const record = args as Record<string, unknown>;
122
- if (typeof record.timeoutMs !== "number") return record as WaitArguments;
123
- const { timeoutMs, ...prepared } = record;
124
- if (prepared.timeout === undefined) {
125
- return { ...prepared, timeout: timeoutMs / 1000 } as WaitArguments;
126
- }
127
- return prepared as WaitArguments;
99
+ return prepareTimeoutArguments(args) as WaitArguments;
128
100
  }
129
101
 
130
102
  function optionalIdentifier(value: unknown, field: string): string | undefined {
131
- return value === undefined ? undefined : requiredIdentifier(value, field);
103
+ return value === undefined ? undefined : requiredIdentifier(value, field);
132
104
  }
133
105
 
134
106
  function requiredIdentifier(value: unknown, field: string): string {
135
- if (typeof value !== "string" || !value.trim()) throw new Error(`Subagent ${field} is required.`);
136
- const identifier = value.trim();
137
- if (
138
- identifier.length > MAX_IDENTIFIER_LENGTH ||
139
- [...identifier].some((character) => {
140
- const codePoint = character.codePointAt(0) ?? 0;
141
- return codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f);
142
- })
143
- ) {
144
- throw new Error(`Subagent ${field} is invalid.`);
145
- }
146
- return identifier;
107
+ if (typeof value !== "string" || !value.trim()) throw new Error(`Subagent ${field} is required.`);
108
+ const identifier = value.trim();
109
+ if (
110
+ identifier.length > MAX_IDENTIFIER_LENGTH ||
111
+ [...identifier].some((character) => {
112
+ const codePoint = character.codePointAt(0) ?? 0;
113
+ return codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f);
114
+ })
115
+ ) {
116
+ throw new Error(`Subagent ${field} is invalid.`);
117
+ }
118
+ return identifier;
147
119
  }
@@ -0,0 +1,72 @@
1
+ import * as fs from "node:fs";
2
+ import type { ExtensionFactory } from "@earendil-works/pi-coding-agent";
3
+ import { toolSourceId } from "./attachment-utils.js";
4
+ import { MAX_READINESS_FRAME_BYTES, takeCapturedReadiness } from "./broker-credentials.js";
5
+
6
+ export interface ChildReadinessState {
7
+ fd: number;
8
+ expectedTools: string[];
9
+ }
10
+
11
+ const captured = takeCapturedReadiness();
12
+
13
+ const childReadinessProbe: ExtensionFactory = captured ? createChildReadinessProbe(captured) : () => undefined;
14
+
15
+ export default childReadinessProbe;
16
+
17
+ export function createChildReadinessProbe(
18
+ readiness: ChildReadinessState,
19
+ writeFrame: (fd: number, frame: string) => void = writeReadinessFrame,
20
+ close: (fd: number) => void = closeDescriptor,
21
+ ): ExtensionFactory {
22
+ return (pi) => {
23
+ let settled = false;
24
+ const publish = (value: { ok: true; sources: string[] } | { ok: false; error: string }) => {
25
+ if (settled) return;
26
+ settled = true;
27
+ const frame = `${JSON.stringify(value)}\n`;
28
+ if (Buffer.byteLength(frame, "utf8") > MAX_READINESS_FRAME_BYTES) {
29
+ close(readiness.fd);
30
+ throw new Error("Pi-subagents readiness frame exceeded its size limit.");
31
+ }
32
+ try {
33
+ writeFrame(readiness.fd, frame);
34
+ } finally {
35
+ close(readiness.fd);
36
+ }
37
+ };
38
+
39
+ pi.on("resources_discover", () => {
40
+ const active = new Set(pi.getActiveTools());
41
+ const missing = readiness.expectedTools.filter((tool) => !active.has(tool));
42
+ if (missing.length > 0) {
43
+ publish({ ok: false, error: `Unavailable subagent tools: ${missing.join(", ")}.` });
44
+ return;
45
+ }
46
+ const sources = new Map(pi.getAllTools().map((tool) => [tool.name, tool.sourceInfo.path]));
47
+ publish({
48
+ ok: true,
49
+ sources: readiness.expectedTools.map((tool) => {
50
+ const source = sources.get(tool);
51
+ return source ? toolSourceId(source) : "";
52
+ }),
53
+ });
54
+ });
55
+
56
+ pi.on("session_shutdown", () => {
57
+ publish({ ok: false, error: "Subagent child shut down before readiness completed." });
58
+ });
59
+ };
60
+ }
61
+
62
+ function writeReadinessFrame(fd: number, frame: string): void {
63
+ fs.writeFileSync(fd, frame, "utf8");
64
+ }
65
+
66
+ function closeDescriptor(fd: number): void {
67
+ try {
68
+ fs.closeSync(fd);
69
+ } catch {
70
+ // The descriptor may already be closed after a failed write.
71
+ }
72
+ }