@narumitw/pi-subagents 2.1.4 → 3.0.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.
- package/README.md +187 -1228
- package/dist/child-communication-bridge.ts +136 -0
- package/dist/child-communication-bridge.ts.map +7 -0
- package/dist/chunks/chunk-UKTWUQRM.js +667 -0
- package/dist/chunks/chunk-UKTWUQRM.js.map +7 -0
- package/dist/index.ts +1448 -1614
- package/dist/index.ts.map +4 -4
- package/docs/design-principles.md +31 -0
- package/docs/ipc-transport-idea.md +103 -0
- package/docs/tools.md +109 -0
- package/package.json +11 -19
- package/src/broker-credentials.ts +71 -0
- package/src/child-communication-bridge.ts +153 -0
- package/src/child-communication-tools.ts +147 -0
- package/src/completion-renderer.ts +60 -0
- package/src/index.ts +1 -1
- package/src/message-broker.ts +628 -0
- package/src/model-output.ts +42 -0
- package/src/process.ts +688 -0
- package/src/runtime.ts +538 -0
- package/src/subagents.ts +33 -34
- package/src/tools.ts +416 -0
- package/src/types.ts +85 -0
- package/src/widget.ts +130 -0
- package/dist/chunks/auto-transport-FUUKFDIG.ts +0 -109
- package/dist/chunks/auto-transport-FUUKFDIG.ts.map +0 -7
- package/dist/chunks/capability-grant-PR72SWWS.ts +0 -19
- package/dist/chunks/capability-grant-PR72SWWS.ts.map +0 -7
- package/dist/chunks/child-peer-bridge.ts +0 -108
- package/dist/chunks/child-peer-bridge.ts.map +0 -7
- package/dist/chunks/chunk-2LMJU25E.ts +0 -1641
- package/dist/chunks/chunk-2LMJU25E.ts.map +0 -7
- package/dist/chunks/chunk-47SK2QYC.ts +0 -56
- package/dist/chunks/chunk-47SK2QYC.ts.map +0 -7
- package/dist/chunks/chunk-4AQSF7AS.ts +0 -29
- package/dist/chunks/chunk-4AQSF7AS.ts.map +0 -7
- package/dist/chunks/chunk-5DGRMKNV.ts +0 -126
- package/dist/chunks/chunk-5DGRMKNV.ts.map +0 -7
- package/dist/chunks/chunk-6H6TBBED.ts +0 -108
- package/dist/chunks/chunk-6H6TBBED.ts.map +0 -7
- package/dist/chunks/chunk-6KJ34M6S.ts +0 -159
- package/dist/chunks/chunk-6KJ34M6S.ts.map +0 -7
- package/dist/chunks/chunk-6NSJVPXX.ts +0 -480
- package/dist/chunks/chunk-6NSJVPXX.ts.map +0 -7
- package/dist/chunks/chunk-7AAJEUSL.ts +0 -238
- package/dist/chunks/chunk-7AAJEUSL.ts.map +0 -7
- package/dist/chunks/chunk-BFK2Z4ZF.ts +0 -189
- package/dist/chunks/chunk-BFK2Z4ZF.ts.map +0 -7
- package/dist/chunks/chunk-BOAXY55Y.ts +0 -153
- package/dist/chunks/chunk-BOAXY55Y.ts.map +0 -7
- package/dist/chunks/chunk-D4CR7T73.ts +0 -353
- package/dist/chunks/chunk-D4CR7T73.ts.map +0 -7
- package/dist/chunks/chunk-FEVPWRMU.ts +0 -300
- package/dist/chunks/chunk-FEVPWRMU.ts.map +0 -7
- package/dist/chunks/chunk-G6VZTCFH.ts +0 -70
- package/dist/chunks/chunk-G6VZTCFH.ts.map +0 -7
- package/dist/chunks/chunk-H3FP6DLR.ts +0 -160
- package/dist/chunks/chunk-H3FP6DLR.ts.map +0 -7
- package/dist/chunks/chunk-HA36LPCO.ts +0 -130
- package/dist/chunks/chunk-HA36LPCO.ts.map +0 -7
- package/dist/chunks/chunk-HOP5FXDT.ts +0 -27
- package/dist/chunks/chunk-HOP5FXDT.ts.map +0 -7
- package/dist/chunks/chunk-IGAMVWFN.ts +0 -139
- package/dist/chunks/chunk-IGAMVWFN.ts.map +0 -7
- package/dist/chunks/chunk-ILEQ27AL.ts +0 -45
- package/dist/chunks/chunk-ILEQ27AL.ts.map +0 -7
- package/dist/chunks/chunk-ITVWPNU4.ts +0 -819
- package/dist/chunks/chunk-ITVWPNU4.ts.map +0 -7
- package/dist/chunks/chunk-IWC32VPY.ts +0 -172
- package/dist/chunks/chunk-IWC32VPY.ts.map +0 -7
- package/dist/chunks/chunk-JSZIP73U.ts +0 -362
- package/dist/chunks/chunk-JSZIP73U.ts.map +0 -7
- package/dist/chunks/chunk-JU6LUNLP.ts +0 -380
- package/dist/chunks/chunk-JU6LUNLP.ts.map +0 -7
- package/dist/chunks/chunk-KMGKCEO4.ts +0 -38
- package/dist/chunks/chunk-KMGKCEO4.ts.map +0 -7
- package/dist/chunks/chunk-LASD73CM.ts +0 -339
- package/dist/chunks/chunk-LASD73CM.ts.map +0 -7
- package/dist/chunks/chunk-LEOYDZI3.ts +0 -22
- package/dist/chunks/chunk-LEOYDZI3.ts.map +0 -7
- package/dist/chunks/chunk-LL4LP2T7.ts +0 -737
- package/dist/chunks/chunk-LL4LP2T7.ts.map +0 -7
- package/dist/chunks/chunk-N2T5IN4X.ts +0 -18
- package/dist/chunks/chunk-N2T5IN4X.ts.map +0 -7
- package/dist/chunks/chunk-N7BLVXKK.ts +0 -73
- package/dist/chunks/chunk-N7BLVXKK.ts.map +0 -7
- package/dist/chunks/chunk-NLT67IZS.ts +0 -322
- package/dist/chunks/chunk-NLT67IZS.ts.map +0 -7
- package/dist/chunks/chunk-NTRPLF46.ts +0 -63
- package/dist/chunks/chunk-NTRPLF46.ts.map +0 -7
- package/dist/chunks/chunk-ONDTY4EL.ts +0 -91
- package/dist/chunks/chunk-ONDTY4EL.ts.map +0 -7
- package/dist/chunks/chunk-OVPHFGKO.ts +0 -1769
- package/dist/chunks/chunk-OVPHFGKO.ts.map +0 -7
- package/dist/chunks/chunk-P7OH4XMF.ts +0 -60
- package/dist/chunks/chunk-P7OH4XMF.ts.map +0 -7
- package/dist/chunks/chunk-PBZMBTNJ.ts +0 -422
- package/dist/chunks/chunk-PBZMBTNJ.ts.map +0 -7
- package/dist/chunks/chunk-PGLSFLYW.ts +0 -52
- package/dist/chunks/chunk-PGLSFLYW.ts.map +0 -7
- package/dist/chunks/chunk-RRR66UWR.ts +0 -28
- package/dist/chunks/chunk-RRR66UWR.ts.map +0 -7
- package/dist/chunks/chunk-RSUXZD6S.ts +0 -275
- package/dist/chunks/chunk-RSUXZD6S.ts.map +0 -7
- package/dist/chunks/chunk-RTCVYIZA.ts +0 -221
- package/dist/chunks/chunk-RTCVYIZA.ts.map +0 -7
- package/dist/chunks/chunk-RY2AEGMZ.ts +0 -34
- package/dist/chunks/chunk-RY2AEGMZ.ts.map +0 -7
- package/dist/chunks/chunk-SWGQLFSD.ts +0 -60
- package/dist/chunks/chunk-SWGQLFSD.ts.map +0 -7
- package/dist/chunks/chunk-TM2R67J3.ts +0 -52
- package/dist/chunks/chunk-TM2R67J3.ts.map +0 -7
- package/dist/chunks/chunk-TMZRHIIK.ts +0 -497
- package/dist/chunks/chunk-TMZRHIIK.ts.map +0 -7
- package/dist/chunks/chunk-TZ34IQ3M.ts +0 -59
- package/dist/chunks/chunk-TZ34IQ3M.ts.map +0 -7
- package/dist/chunks/chunk-UMBPMVJW.ts +0 -22
- package/dist/chunks/chunk-UMBPMVJW.ts.map +0 -7
- package/dist/chunks/chunk-VDG7LTYE.ts +0 -80
- package/dist/chunks/chunk-VDG7LTYE.ts.map +0 -7
- package/dist/chunks/chunk-X4NMONPE.ts +0 -73
- package/dist/chunks/chunk-X4NMONPE.ts.map +0 -7
- package/dist/chunks/chunk-YPJEN6NU.ts +0 -65
- package/dist/chunks/chunk-YPJEN6NU.ts.map +0 -7
- package/dist/chunks/chunk-YU53SHA7.ts +0 -328
- package/dist/chunks/chunk-YU53SHA7.ts.map +0 -7
- package/dist/chunks/completion-delivery-RSJU6BXL.ts +0 -17
- package/dist/chunks/completion-delivery-RSJU6BXL.ts.map +0 -7
- package/dist/chunks/config-status-FKGDECZ3.ts +0 -35
- package/dist/chunks/config-status-FKGDECZ3.ts.map +0 -7
- package/dist/chunks/config-ui-ABHYNGQ7.ts +0 -1212
- package/dist/chunks/config-ui-ABHYNGQ7.ts.map +0 -7
- package/dist/chunks/consult-LJU3IQY5.ts +0 -519
- package/dist/chunks/consult-LJU3IQY5.ts.map +0 -7
- package/dist/chunks/context-QDKLVQXE.ts +0 -11
- package/dist/chunks/context-QDKLVQXE.ts.map +0 -7
- package/dist/chunks/create-stateful-transport-JWC2EFYL.ts +0 -130
- package/dist/chunks/create-stateful-transport-JWC2EFYL.ts.map +0 -7
- package/dist/chunks/cwd-policy-NB6XZ5GF.ts +0 -17
- package/dist/chunks/cwd-policy-NB6XZ5GF.ts.map +0 -7
- package/dist/chunks/delegation-contract-LA56I5DT.ts +0 -24
- package/dist/chunks/delegation-contract-LA56I5DT.ts.map +0 -7
- package/dist/chunks/discovery-MIFB2U4Y.ts +0 -12
- package/dist/chunks/discovery-MIFB2U4Y.ts.map +0 -7
- package/dist/chunks/execution-Q2JZLKJA.ts +0 -4037
- package/dist/chunks/execution-Q2JZLKJA.ts.map +0 -7
- package/dist/chunks/in-process-transport-HJ6TZXC3.ts +0 -39
- package/dist/chunks/in-process-transport-HJ6TZXC3.ts.map +0 -7
- package/dist/chunks/inspect-UH2TKH6E.ts +0 -634
- package/dist/chunks/inspect-UH2TKH6E.ts.map +0 -7
- package/dist/chunks/peer-communication-KL36Z6CO.ts +0 -294
- package/dist/chunks/peer-communication-KL36Z6CO.ts.map +0 -7
- package/dist/chunks/persistence-UY3PY6E5.ts +0 -348
- package/dist/chunks/persistence-UY3PY6E5.ts.map +0 -7
- package/dist/chunks/registry-BT54L6CY.ts +0 -1445
- package/dist/chunks/registry-BT54L6CY.ts.map +0 -7
- package/dist/chunks/retained-semantic-state-GM75FZGE.ts +0 -81
- package/dist/chunks/retained-semantic-state-GM75FZGE.ts.map +0 -7
- package/dist/chunks/rpc-transport-7R7DVCEB.ts +0 -1222
- package/dist/chunks/rpc-transport-7R7DVCEB.ts.map +0 -7
- package/dist/chunks/runtime-policy-5YELCOVC.ts +0 -15
- package/dist/chunks/runtime-policy-5YELCOVC.ts.map +0 -7
- package/dist/chunks/semantic-snapshot-WPJ3UAFW.ts +0 -19
- package/dist/chunks/semantic-snapshot-WPJ3UAFW.ts.map +0 -7
- package/dist/chunks/spawn-idempotency-BNHOSMZW.ts +0 -13
- package/dist/chunks/spawn-idempotency-BNHOSMZW.ts.map +0 -7
- package/dist/chunks/stateful-lifecycle-JAR6K55H.ts +0 -15
- package/dist/chunks/stateful-lifecycle-JAR6K55H.ts.map +0 -7
- package/dist/chunks/subprocess-transport-VHZJRBTW.ts +0 -160
- package/dist/chunks/subprocess-transport-VHZJRBTW.ts.map +0 -7
- package/dist/chunks/usage-recording-store-CW3EH2SZ.ts +0 -164
- package/dist/chunks/usage-recording-store-CW3EH2SZ.ts.map +0 -7
- package/dist/chunks/workspace-WDKLN72J.ts +0 -11
- package/dist/chunks/workspace-WDKLN72J.ts.map +0 -7
- package/docs/async-runtime-protocol.md +0 -84
- package/docs/implementation-notes/pi-subagents-capability-matrix.md +0 -62
- package/docs/implementation-notes/pi-subagents-current-direction.md +0 -99
- package/docs/implementation-notes/pi-subagents-rpc-v1.md +0 -139
- package/docs/pi-subagents-diagrams.md +0 -183
- package/src/adaptive-scheduler.ts +0 -224
- package/src/admission-policy.ts +0 -78
- package/src/agent-projection.ts +0 -53
- package/src/agents/built-ins.ts +0 -71
- package/src/agents/catalog.ts +0 -241
- package/src/agents/discovery.ts +0 -265
- package/src/agents/types.ts +0 -100
- package/src/agents.ts +0 -51
- package/src/auto-transport.ts +0 -121
- package/src/blocking-status.ts +0 -63
- package/src/cached-module-loader.ts +0 -18
- package/src/capabilities.ts +0 -145
- package/src/capability-grant.ts +0 -115
- package/src/capability-router.ts +0 -107
- package/src/child-peer-bridge.ts +0 -124
- package/src/child-peer-tools.ts +0 -132
- package/src/completion-delivery.ts +0 -409
- package/src/completion-render.ts +0 -190
- package/src/completion-requirement.ts +0 -479
- package/src/completion-routing.ts +0 -24
- package/src/config-registration.ts +0 -114
- package/src/config-status.ts +0 -217
- package/src/config-ui.ts +0 -983
- package/src/consult-policy.ts +0 -15
- package/src/consult-registration.ts +0 -125
- package/src/consult-render.ts +0 -194
- package/src/consult-resources.ts +0 -51
- package/src/consult-tool.ts +0 -95
- package/src/consult.ts +0 -566
- package/src/context.ts +0 -126
- package/src/create-stateful-transport.ts +0 -157
- package/src/cwd-policy.ts +0 -183
- package/src/delegation-contract.ts +0 -463
- package/src/execution/budget.ts +0 -56
- package/src/execution/runtime-policy.ts +0 -19
- package/src/execution-plan.ts +0 -322
- package/src/execution-ui.ts +0 -259
- package/src/execution.ts +0 -1679
- package/src/in-process-transport.ts +0 -941
- package/src/inspect-registration.ts +0 -64
- package/src/inspect-render.ts +0 -334
- package/src/inspect-tool.ts +0 -45
- package/src/inspect.ts +0 -769
- package/src/integration-controller.ts +0 -98
- package/src/limits.ts +0 -75
- package/src/orchestration-metrics.ts +0 -116
- package/src/outcome.ts +0 -61
- package/src/panel-child-group.ts +0 -35
- package/src/panel-contract.ts +0 -343
- package/src/panel-evidence.ts +0 -59
- package/src/panel-execution.ts +0 -769
- package/src/panel-failure.ts +0 -56
- package/src/panel-planning.ts +0 -175
- package/src/panel-presets.ts +0 -3
- package/src/panel-prompts.ts +0 -132
- package/src/panel-reconciliation.ts +0 -57
- package/src/panel-render.ts +0 -103
- package/src/parallel-limit-ui.ts +0 -113
- package/src/params.ts +0 -278
- package/src/peer-communication.ts +0 -352
- package/src/peer-transport.ts +0 -49
- package/src/persistence.ts +0 -522
- package/src/pi-args.ts +0 -43
- package/src/pi-invocation.ts +0 -168
- package/src/process-control.ts +0 -43
- package/src/prompt-resources.ts +0 -38
- package/src/prompt-source-safety.ts +0 -48
- package/src/protocol.ts +0 -76
- package/src/registry-types.ts +0 -204
- package/src/registry.ts +0 -1698
- package/src/render-common.ts +0 -252
- package/src/render.ts +0 -704
- package/src/result-contract.ts +0 -431
- package/src/retained-semantic-state.ts +0 -100
- package/src/rpc-timeout-finalization.ts +0 -207
- package/src/rpc-transport-metadata.ts +0 -65
- package/src/rpc-transport.ts +0 -1035
- package/src/rpc-turn-capture.ts +0 -217
- package/src/runner-outcome.ts +0 -31
- package/src/runner-result.ts +0 -55
- package/src/runner-types.ts +0 -102
- package/src/runner-usage.ts +0 -48
- package/src/runner.ts +0 -866
- package/src/safe-text.ts +0 -67
- package/src/semantic-snapshot.ts +0 -214
- package/src/session-guidance-contract.ts +0 -399
- package/src/settings/inspection.ts +0 -302
- package/src/settings/schema.ts +0 -195
- package/src/settings-reader.ts +0 -215
- package/src/settings.ts +0 -452
- package/src/spawn-idempotency.ts +0 -68
- package/src/stateful-agent-view.ts +0 -85
- package/src/stateful-config.ts +0 -13
- package/src/stateful-guidance.ts +0 -29
- package/src/stateful-lifecycle.ts +0 -74
- package/src/stateful-limit-ui.ts +0 -249
- package/src/stateful-limits.ts +0 -96
- package/src/stateful-prompt.ts +0 -49
- package/src/stateful-registration.ts +0 -1320
- package/src/stateful-render.ts +0 -318
- package/src/stateful-safety.ts +0 -47
- package/src/stateful-tool-params.ts +0 -215
- package/src/stateful.ts +0 -12
- package/src/subagent-details.ts +0 -43
- package/src/subagents-extension.ts +0 -372
- package/src/subprocess-transport.ts +0 -145
- package/src/supervision.ts +0 -104
- package/src/task-path.ts +0 -65
- package/src/timeout-checkpoint.ts +0 -305
- package/src/timeout-finalization.ts +0 -75
- package/src/tool-schema-compatibility.ts +0 -73
- package/src/transport-types.ts +0 -74
- package/src/transport-ui.ts +0 -135
- package/src/transport.ts +0 -43
- package/src/turn-budget.ts +0 -109
- package/src/usage-format.ts +0 -42
- package/src/usage-recording-config.ts +0 -13
- package/src/usage-recording-store.ts +0 -183
- package/src/usage-recording.ts +0 -478
- package/src/verification-harness.ts +0 -516
- package/src/verification-policy.ts +0 -67
- package/src/verification-receipt.ts +0 -275
- package/src/verified-execution-benchmark.ts +0 -86
- package/src/verified-execution-contract.ts +0 -191
- package/src/verified-execution-schema.ts +0 -32
- package/src/work-item-ledger.ts +0 -1404
- package/src/work-item-persistence.ts +0 -254
- package/src/workflow-completion-controller.ts +0 -397
- package/src/workflow-planning.ts +0 -172
- package/src/workflow-tree-identity.ts +0 -289
- package/src/workflow-ui.ts +0 -69
- package/src/workflow-verification.ts +0 -296
- package/src/workspace.ts +0 -174
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { type ExtensionAPI, getMarkdownTheme, keyText } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Box, type Component, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
|
|
3
|
+
import { sanitizeTerminalText } from "./message-broker.js";
|
|
4
|
+
|
|
5
|
+
export const COMPLETION_MESSAGE_TYPE = "pi-subagents-completion";
|
|
6
|
+
|
|
7
|
+
function createCompletionBox(
|
|
8
|
+
children: Component[],
|
|
9
|
+
outputPad: number,
|
|
10
|
+
background: (text: string) => string,
|
|
11
|
+
): Component {
|
|
12
|
+
return {
|
|
13
|
+
render(width) {
|
|
14
|
+
if (width <= 0) return [];
|
|
15
|
+
const maxPadding = Math.floor((width - 1) / 2);
|
|
16
|
+
const box = new Box(Math.min(Math.max(0, outputPad), maxPadding), 1, background);
|
|
17
|
+
for (const child of children) box.addChild(child);
|
|
18
|
+
return box.render(width);
|
|
19
|
+
},
|
|
20
|
+
invalidate() {
|
|
21
|
+
for (const child of children) child.invalidate();
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function registerCompletionRenderer(pi: ExtensionAPI): void {
|
|
27
|
+
pi.registerMessageRenderer(COMPLETION_MESSAGE_TYPE, (message, options, theme) => {
|
|
28
|
+
const children: Component[] = [
|
|
29
|
+
new Text(theme.fg("customMessageLabel", theme.bold(`[${COMPLETION_MESSAGE_TYPE}]`)), 0, 0),
|
|
30
|
+
new Spacer(1),
|
|
31
|
+
];
|
|
32
|
+
if (!options.expanded) {
|
|
33
|
+
children.push(
|
|
34
|
+
new Text(
|
|
35
|
+
theme.fg("customMessageText", "Subagent job completion (") +
|
|
36
|
+
theme.fg("dim", keyText("app.tools.expand")) +
|
|
37
|
+
theme.fg("customMessageText", " to expand)"),
|
|
38
|
+
0,
|
|
39
|
+
0,
|
|
40
|
+
),
|
|
41
|
+
);
|
|
42
|
+
} else {
|
|
43
|
+
const content =
|
|
44
|
+
typeof message.content === "string"
|
|
45
|
+
? message.content
|
|
46
|
+
: message.content
|
|
47
|
+
.filter((part) => part.type === "text")
|
|
48
|
+
.map((part) => part.text)
|
|
49
|
+
.join("\n");
|
|
50
|
+
children.push(
|
|
51
|
+
new Markdown(sanitizeTerminalText(content), 0, 0, getMarkdownTheme(), {
|
|
52
|
+
color: (text) => theme.fg("customMessageText", text),
|
|
53
|
+
}),
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
return createCompletionBox(children, options.outputPad, (text) =>
|
|
57
|
+
theme.bg("customMessageBg", text),
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { default } from "./subagents
|
|
1
|
+
export { default } from "./subagents.js";
|
|
@@ -0,0 +1,628 @@
|
|
|
1
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
2
|
+
import net, { type AddressInfo, type Server, type Socket } from "node:net";
|
|
3
|
+
import type { BrokerCredentials } from "./types.js";
|
|
4
|
+
|
|
5
|
+
export const BROKER_HOST = "127.0.0.1" as const;
|
|
6
|
+
export const MAX_MESSAGE_BYTES = 48 * 1024;
|
|
7
|
+
export const MAX_MESSAGE_LINES = 1_992;
|
|
8
|
+
export const MAX_FRAME_BYTES = 384 * 1024;
|
|
9
|
+
export const MAX_ERROR_BYTES = 8 * 1024;
|
|
10
|
+
export const MAX_OUTSTANDING_REQUESTS = 4;
|
|
11
|
+
export const MAX_IDENTIFIER_LENGTH = 128;
|
|
12
|
+
|
|
13
|
+
const MAX_CONNECTIONS = 32;
|
|
14
|
+
const REQUEST_FRAME_TIMEOUT_MS = 2_000;
|
|
15
|
+
const MAX_TIMEOUT_MS = 2_147_483_647;
|
|
16
|
+
const MAX_RETAINED_CONSUMED_REQUESTS = 4;
|
|
17
|
+
const CHILD_WAIT_INTERRUPTED_ERROR =
|
|
18
|
+
"Subagent wait was interrupted by an incoming main-agent request. The original request remains active.";
|
|
19
|
+
|
|
20
|
+
export type BrokerRequestOrigin = "main" | "child";
|
|
21
|
+
|
|
22
|
+
export interface BrokerInboundMessage {
|
|
23
|
+
kind: "request" | "response";
|
|
24
|
+
requestId: string;
|
|
25
|
+
jobId: string;
|
|
26
|
+
message: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface BrokerSendAcknowledgement {
|
|
30
|
+
requestId: string;
|
|
31
|
+
accepted: boolean;
|
|
32
|
+
duplicate: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface MessageBrokerOptions {
|
|
36
|
+
onMessage(message: BrokerInboundMessage): void;
|
|
37
|
+
createServer?: (listener: (socket: Socket) => void) => Server;
|
|
38
|
+
now?: () => number;
|
|
39
|
+
requestFrameTimeoutMs?: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface JobRecord {
|
|
43
|
+
jobId: string;
|
|
44
|
+
generation: number;
|
|
45
|
+
token: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface RequestWaiter {
|
|
49
|
+
socket: Socket;
|
|
50
|
+
timer?: NodeJS.Timeout;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface RequestRecord {
|
|
54
|
+
requestId: string;
|
|
55
|
+
jobId: string;
|
|
56
|
+
generation: number;
|
|
57
|
+
origin: BrokerRequestOrigin;
|
|
58
|
+
expectedResponder: BrokerRequestOrigin;
|
|
59
|
+
message: string;
|
|
60
|
+
createdAt: number;
|
|
61
|
+
response?: string;
|
|
62
|
+
consumedAt?: number;
|
|
63
|
+
deliveryQueued?: boolean;
|
|
64
|
+
waiter?: RequestWaiter;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
type BrokerRequest =
|
|
68
|
+
| {
|
|
69
|
+
type: "send";
|
|
70
|
+
token: string;
|
|
71
|
+
recipient?: string;
|
|
72
|
+
requestId?: string;
|
|
73
|
+
message: string;
|
|
74
|
+
}
|
|
75
|
+
| { type: "wait"; token: string; requestId: string; timeoutMs?: number };
|
|
76
|
+
|
|
77
|
+
/** Session-scoped authenticated loopback transport for bidirectional requests and responses. */
|
|
78
|
+
export class MessageBroker {
|
|
79
|
+
private server?: Server;
|
|
80
|
+
private starting?: Promise<void>;
|
|
81
|
+
private address?: { host: typeof BROKER_HOST; port: number };
|
|
82
|
+
private failure?: string;
|
|
83
|
+
private generation = 0;
|
|
84
|
+
private readonly sockets = new Set<Socket>();
|
|
85
|
+
private readonly jobsByToken = new Map<string, JobRecord>();
|
|
86
|
+
private readonly tokenByJob = new Map<string, string>();
|
|
87
|
+
private readonly requests = new Map<string, RequestRecord>();
|
|
88
|
+
private readonly inboundMessageListeners = new Set<() => void>();
|
|
89
|
+
private pendingInboundResponse = false;
|
|
90
|
+
private readonly createServer: (listener: (socket: Socket) => void) => Server;
|
|
91
|
+
private readonly now: () => number;
|
|
92
|
+
private readonly requestFrameTimeoutMs: number;
|
|
93
|
+
|
|
94
|
+
constructor(private readonly options: MessageBrokerOptions) {
|
|
95
|
+
this.createServer = options.createServer ?? ((listener) => net.createServer(listener));
|
|
96
|
+
this.now = options.now ?? Date.now;
|
|
97
|
+
this.requestFrameTimeoutMs = options.requestFrameTimeoutMs ?? REQUEST_FRAME_TIMEOUT_MS;
|
|
98
|
+
if (!Number.isFinite(this.requestFrameTimeoutMs) || this.requestFrameTimeoutMs <= 0) {
|
|
99
|
+
throw new Error("Subagent broker frame timeout must be positive.");
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async start(): Promise<void> {
|
|
104
|
+
if (this.server && this.address && !this.failure) return;
|
|
105
|
+
if (this.starting) return this.starting;
|
|
106
|
+
const ownerGeneration = ++this.generation;
|
|
107
|
+
this.failure = undefined;
|
|
108
|
+
this.starting = new Promise<void>((resolve, reject) => {
|
|
109
|
+
const server = this.createServer((socket) => this.accept(socket));
|
|
110
|
+
server.maxConnections = MAX_CONNECTIONS;
|
|
111
|
+
const onStartError = (error: Error) => {
|
|
112
|
+
server.close(() => undefined);
|
|
113
|
+
reject(error);
|
|
114
|
+
};
|
|
115
|
+
server.once("error", onStartError);
|
|
116
|
+
server.listen({ host: BROKER_HOST, port: 0 }, () => {
|
|
117
|
+
server.off("error", onStartError);
|
|
118
|
+
if (ownerGeneration !== this.generation) {
|
|
119
|
+
server.close(() => undefined);
|
|
120
|
+
reject(new Error("Subagent message broker start was superseded."));
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const address = server.address();
|
|
124
|
+
if (!isLoopbackAddress(address)) {
|
|
125
|
+
server.close(() => undefined);
|
|
126
|
+
reject(new Error("Subagent message broker did not bind to loopback."));
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
this.server = server;
|
|
130
|
+
this.address = { host: BROKER_HOST, port: address.port };
|
|
131
|
+
server.on("error", (error) => this.fail(error));
|
|
132
|
+
server.unref();
|
|
133
|
+
resolve();
|
|
134
|
+
});
|
|
135
|
+
})
|
|
136
|
+
.catch((error) => {
|
|
137
|
+
const message = truncateError(error instanceof Error ? error.message : String(error));
|
|
138
|
+
this.failure = message;
|
|
139
|
+
throw new Error(`Subagent message broker failed to start: ${message}`);
|
|
140
|
+
})
|
|
141
|
+
.finally(() => {
|
|
142
|
+
this.starting = undefined;
|
|
143
|
+
});
|
|
144
|
+
return this.starting;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
assertReady(): void {
|
|
148
|
+
if (this.server && this.address && !this.failure) return;
|
|
149
|
+
throw new Error(
|
|
150
|
+
this.failure
|
|
151
|
+
? `Subagent messaging is unavailable: ${this.failure}`
|
|
152
|
+
: "Subagent messaging is unavailable because the session broker is not ready.",
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
issueCredentials(input: { jobId: string; generation: number }): BrokerCredentials {
|
|
157
|
+
this.assertReady();
|
|
158
|
+
if (this.tokenByJob.has(input.jobId)) {
|
|
159
|
+
throw new Error(`Subagent messaging credentials already exist for ${input.jobId}.`);
|
|
160
|
+
}
|
|
161
|
+
const token = randomBytes(32).toString("hex");
|
|
162
|
+
const record: JobRecord = { ...input, token };
|
|
163
|
+
this.jobsByToken.set(token, record);
|
|
164
|
+
this.tokenByJob.set(input.jobId, token);
|
|
165
|
+
return { host: BROKER_HOST, port: this.address?.port ?? 0, token };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
createMainRequest(jobId: string, message: string): BrokerSendAcknowledgement {
|
|
169
|
+
this.assertReady();
|
|
170
|
+
const job = this.requireJob(jobId);
|
|
171
|
+
return this.createRequest(job, "main", message);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
replyFromMain(requestId: string, message: string): BrokerSendAcknowledgement {
|
|
175
|
+
this.assertReady();
|
|
176
|
+
return this.acceptResponse("main", undefined, requestId, message);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
rollbackMainRequest(requestId: string): void {
|
|
180
|
+
const request = this.requests.get(requestId);
|
|
181
|
+
if (request?.origin === "main" && request.response === undefined) {
|
|
182
|
+
this.clearWaiter(request);
|
|
183
|
+
this.requests.delete(requestId);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
revokeJob(jobId: string, reason = "Subagent job is no longer active."): void {
|
|
188
|
+
const token = this.tokenByJob.get(jobId);
|
|
189
|
+
if (token) {
|
|
190
|
+
this.tokenByJob.delete(jobId);
|
|
191
|
+
this.jobsByToken.delete(token);
|
|
192
|
+
}
|
|
193
|
+
for (const request of [...this.requests.values()]) {
|
|
194
|
+
if (request.jobId !== jobId) continue;
|
|
195
|
+
if (request.waiter) this.respondError(request.waiter.socket, reason);
|
|
196
|
+
this.clearWaiter(request);
|
|
197
|
+
this.requests.delete(request.requestId);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
hasPendingMainRequest(): boolean {
|
|
202
|
+
return [...this.requests.values()].some(
|
|
203
|
+
(request) => request.origin === "child" && request.response === undefined,
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
takePendingInboundResponse(): boolean {
|
|
208
|
+
const pending = this.pendingInboundResponse;
|
|
209
|
+
this.pendingInboundResponse = false;
|
|
210
|
+
return pending;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
markMainRequestQueued(requestId: string): boolean {
|
|
214
|
+
const request = this.requests.get(requestId);
|
|
215
|
+
if (request?.origin !== "main") {
|
|
216
|
+
throw new Error("Unknown or expired main-agent subagent request.");
|
|
217
|
+
}
|
|
218
|
+
request.deliveryQueued = true;
|
|
219
|
+
return request.response === undefined;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
interruptChildWaits(jobId: string): number {
|
|
223
|
+
let interrupted = 0;
|
|
224
|
+
for (const request of this.requests.values()) {
|
|
225
|
+
if (request.jobId !== jobId || request.origin !== "child" || !request.waiter) continue;
|
|
226
|
+
const socket = request.waiter.socket;
|
|
227
|
+
this.clearWaiter(request);
|
|
228
|
+
this.respondError(socket, CHILD_WAIT_INTERRUPTED_ERROR);
|
|
229
|
+
interrupted++;
|
|
230
|
+
}
|
|
231
|
+
return interrupted;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
subscribeInboundMessage(listener: () => void): () => void {
|
|
235
|
+
this.inboundMessageListeners.add(listener);
|
|
236
|
+
if (this.hasPendingMainRequest()) listener();
|
|
237
|
+
return () => this.inboundMessageListeners.delete(listener);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async shutdown(): Promise<void> {
|
|
241
|
+
++this.generation;
|
|
242
|
+
const starting = this.starting;
|
|
243
|
+
if (starting) await starting.catch(() => undefined);
|
|
244
|
+
for (const jobId of [...this.tokenByJob.keys()]) {
|
|
245
|
+
this.revokeJob(jobId, "Subagent session shut down.");
|
|
246
|
+
}
|
|
247
|
+
this.requests.clear();
|
|
248
|
+
this.inboundMessageListeners.clear();
|
|
249
|
+
this.pendingInboundResponse = false;
|
|
250
|
+
for (const socket of this.sockets) socket.destroy();
|
|
251
|
+
this.sockets.clear();
|
|
252
|
+
const server = this.server;
|
|
253
|
+
this.server = undefined;
|
|
254
|
+
this.address = undefined;
|
|
255
|
+
this.failure = undefined;
|
|
256
|
+
if (!server?.listening) return;
|
|
257
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
private requireJob(jobId: string): JobRecord {
|
|
261
|
+
const token = this.tokenByJob.get(jobId);
|
|
262
|
+
const job = token ? this.jobsByToken.get(token) : undefined;
|
|
263
|
+
if (!job) throw new Error("Unknown or inactive subagent job.");
|
|
264
|
+
return job;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
private hasQueuedRequestForChild(jobId: string): boolean {
|
|
268
|
+
return [...this.requests.values()].some(
|
|
269
|
+
(request) =>
|
|
270
|
+
request.jobId === jobId &&
|
|
271
|
+
request.origin === "main" &&
|
|
272
|
+
request.deliveryQueued === true &&
|
|
273
|
+
request.response === undefined,
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
private createRequest(
|
|
278
|
+
job: JobRecord,
|
|
279
|
+
origin: BrokerRequestOrigin,
|
|
280
|
+
message: string,
|
|
281
|
+
): BrokerSendAcknowledgement {
|
|
282
|
+
validateMessage(message, "Subagent request");
|
|
283
|
+
const outstanding = [...this.requests.values()].filter(
|
|
284
|
+
(request) => request.jobId === job.jobId && request.consumedAt === undefined,
|
|
285
|
+
).length;
|
|
286
|
+
if (outstanding >= MAX_OUTSTANDING_REQUESTS) {
|
|
287
|
+
throw new Error(
|
|
288
|
+
`Subagent job may have at most ${MAX_OUTSTANDING_REQUESTS} outstanding requests.`,
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
const request: RequestRecord = {
|
|
292
|
+
requestId: `req_${randomUUID()}`,
|
|
293
|
+
jobId: job.jobId,
|
|
294
|
+
generation: job.generation,
|
|
295
|
+
origin,
|
|
296
|
+
expectedResponder: origin === "main" ? "child" : "main",
|
|
297
|
+
message,
|
|
298
|
+
createdAt: this.now(),
|
|
299
|
+
};
|
|
300
|
+
this.requests.set(request.requestId, request);
|
|
301
|
+
if (origin === "child") {
|
|
302
|
+
try {
|
|
303
|
+
this.deliverInbound({
|
|
304
|
+
kind: "request",
|
|
305
|
+
requestId: request.requestId,
|
|
306
|
+
jobId: request.jobId,
|
|
307
|
+
message: request.message,
|
|
308
|
+
});
|
|
309
|
+
} catch (error) {
|
|
310
|
+
this.requests.delete(request.requestId);
|
|
311
|
+
throw error;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return { requestId: request.requestId, accepted: true, duplicate: false };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
private acceptResponse(
|
|
318
|
+
responder: BrokerRequestOrigin,
|
|
319
|
+
job: JobRecord | undefined,
|
|
320
|
+
requestId: string,
|
|
321
|
+
message: string,
|
|
322
|
+
): BrokerSendAcknowledgement {
|
|
323
|
+
validateRequestId(requestId);
|
|
324
|
+
validateMessage(message, "Subagent response");
|
|
325
|
+
const request = this.requests.get(requestId);
|
|
326
|
+
if (
|
|
327
|
+
!request ||
|
|
328
|
+
request.expectedResponder !== responder ||
|
|
329
|
+
(job && (request.jobId !== job.jobId || request.generation !== job.generation))
|
|
330
|
+
) {
|
|
331
|
+
throw new Error("Unknown, expired, or unauthorized subagent request.");
|
|
332
|
+
}
|
|
333
|
+
if (request.response !== undefined) {
|
|
334
|
+
return { requestId, accepted: false, duplicate: true };
|
|
335
|
+
}
|
|
336
|
+
request.response = message;
|
|
337
|
+
if (responder === "child") {
|
|
338
|
+
try {
|
|
339
|
+
this.deliverInbound({
|
|
340
|
+
kind: "response",
|
|
341
|
+
requestId,
|
|
342
|
+
jobId: request.jobId,
|
|
343
|
+
message,
|
|
344
|
+
});
|
|
345
|
+
this.markConsumed(request);
|
|
346
|
+
} catch (error) {
|
|
347
|
+
request.response = undefined;
|
|
348
|
+
throw error;
|
|
349
|
+
}
|
|
350
|
+
} else if (request.waiter) {
|
|
351
|
+
const socket = request.waiter.socket;
|
|
352
|
+
this.clearWaiter(request);
|
|
353
|
+
this.respond(socket, { ok: true, response: message }, () => this.markConsumed(request));
|
|
354
|
+
}
|
|
355
|
+
return { requestId, accepted: true, duplicate: false };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
private deliverInbound(message: BrokerInboundMessage): void {
|
|
359
|
+
this.options.onMessage(message);
|
|
360
|
+
if (message.kind === "response" && this.inboundMessageListeners.size === 0) {
|
|
361
|
+
this.pendingInboundResponse = true;
|
|
362
|
+
}
|
|
363
|
+
for (const listener of [...this.inboundMessageListeners]) {
|
|
364
|
+
try {
|
|
365
|
+
listener();
|
|
366
|
+
} catch {
|
|
367
|
+
// Runtime wait observers cannot interrupt broker delivery.
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
private accept(socket: Socket): void {
|
|
373
|
+
if (!this.server || this.failure || this.sockets.size >= MAX_CONNECTIONS) {
|
|
374
|
+
socket.destroy();
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
this.sockets.add(socket);
|
|
378
|
+
socket.setNoDelay(true);
|
|
379
|
+
let frame = Buffer.alloc(0);
|
|
380
|
+
let handled = false;
|
|
381
|
+
const frameTimer = setTimeout(() => {
|
|
382
|
+
if (handled) return;
|
|
383
|
+
handled = true;
|
|
384
|
+
this.respondError(socket, "Subagent broker request frame timed out.");
|
|
385
|
+
}, this.requestFrameTimeoutMs);
|
|
386
|
+
frameTimer.unref();
|
|
387
|
+
const cleanup = () => {
|
|
388
|
+
clearTimeout(frameTimer);
|
|
389
|
+
this.sockets.delete(socket);
|
|
390
|
+
for (const request of this.requests.values()) {
|
|
391
|
+
if (request.waiter?.socket === socket) this.clearWaiter(request);
|
|
392
|
+
}
|
|
393
|
+
};
|
|
394
|
+
socket.on("data", (chunk: Buffer) => {
|
|
395
|
+
if (handled) return;
|
|
396
|
+
frame = Buffer.concat([frame, chunk]);
|
|
397
|
+
if (frame.byteLength > MAX_FRAME_BYTES) {
|
|
398
|
+
handled = true;
|
|
399
|
+
clearTimeout(frameTimer);
|
|
400
|
+
this.respondError(socket, "Subagent broker request exceeded its size limit.");
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
const newline = frame.indexOf(0x0a);
|
|
404
|
+
if (newline < 0) return;
|
|
405
|
+
handled = true;
|
|
406
|
+
clearTimeout(frameTimer);
|
|
407
|
+
socket.removeAllListeners("data");
|
|
408
|
+
const trailing = frame
|
|
409
|
+
.subarray(newline + 1)
|
|
410
|
+
.toString("utf8")
|
|
411
|
+
.trim();
|
|
412
|
+
if (trailing) {
|
|
413
|
+
this.respondError(socket, "Subagent broker accepts one request per connection.");
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
this.handleFrame(socket, frame.subarray(0, newline).toString("utf8"));
|
|
417
|
+
});
|
|
418
|
+
socket.once("close", cleanup);
|
|
419
|
+
socket.once("error", cleanup);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
private handleFrame(socket: Socket, frame: string): void {
|
|
423
|
+
let request: BrokerRequest;
|
|
424
|
+
try {
|
|
425
|
+
const parsed = JSON.parse(frame) as unknown;
|
|
426
|
+
if (!isRecord(parsed)) throw new Error();
|
|
427
|
+
request = parsed as BrokerRequest;
|
|
428
|
+
} catch {
|
|
429
|
+
this.respondError(socket, "Malformed subagent broker request.");
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
const job = typeof request.token === "string" ? this.jobsByToken.get(request.token) : undefined;
|
|
433
|
+
if (!job) {
|
|
434
|
+
this.respondError(socket, "Unauthenticated subagent broker request.");
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
try {
|
|
438
|
+
if (request.type === "send") {
|
|
439
|
+
this.handleSend(socket, job, request);
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
if (request.type === "wait") {
|
|
443
|
+
this.handleWait(socket, job, request);
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
throw new Error("Unsupported subagent broker request.");
|
|
447
|
+
} catch (error) {
|
|
448
|
+
this.respondError(socket, error instanceof Error ? error.message : String(error));
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
private handleSend(
|
|
453
|
+
socket: Socket,
|
|
454
|
+
job: JobRecord,
|
|
455
|
+
request: Extract<BrokerRequest, { type: "send" }>,
|
|
456
|
+
): void {
|
|
457
|
+
if (typeof request.message !== "string") {
|
|
458
|
+
throw new Error("subagent_send requires a message string.");
|
|
459
|
+
}
|
|
460
|
+
const hasRecipient = typeof request.recipient === "string";
|
|
461
|
+
const hasRequestId = typeof request.requestId === "string";
|
|
462
|
+
if (hasRecipient === hasRequestId) {
|
|
463
|
+
throw new Error("subagent_send requires exactly one of recipient or requestId.");
|
|
464
|
+
}
|
|
465
|
+
const acknowledgement = hasRecipient
|
|
466
|
+
? request.recipient === "main"
|
|
467
|
+
? this.createRequest(job, "child", request.message)
|
|
468
|
+
: (() => {
|
|
469
|
+
throw new Error('A subagent may send new requests only to recipient "main".');
|
|
470
|
+
})()
|
|
471
|
+
: this.acceptResponse("child", job, request.requestId ?? "", request.message);
|
|
472
|
+
this.respond(socket, { ok: true, ...acknowledgement });
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
private handleWait(
|
|
476
|
+
socket: Socket,
|
|
477
|
+
job: JobRecord,
|
|
478
|
+
request: Extract<BrokerRequest, { type: "wait" }>,
|
|
479
|
+
): void {
|
|
480
|
+
if (typeof request.requestId !== "string") {
|
|
481
|
+
throw new Error("Subagent wait requires a request ID.");
|
|
482
|
+
}
|
|
483
|
+
validateRequestId(request.requestId);
|
|
484
|
+
if (request.timeoutMs !== undefined) validateTimeout(request.timeoutMs);
|
|
485
|
+
const record = this.requests.get(request.requestId);
|
|
486
|
+
if (
|
|
487
|
+
record?.origin !== "child" ||
|
|
488
|
+
record.jobId !== job.jobId ||
|
|
489
|
+
record.generation !== job.generation
|
|
490
|
+
) {
|
|
491
|
+
throw new Error("Unknown or expired subagent request.");
|
|
492
|
+
}
|
|
493
|
+
if (record.consumedAt !== undefined && record.response !== undefined) {
|
|
494
|
+
this.respond(socket, { ok: true, response: record.response });
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
if (record.response !== undefined) {
|
|
498
|
+
this.respond(socket, { ok: true, response: record.response }, () =>
|
|
499
|
+
this.markConsumed(record),
|
|
500
|
+
);
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
if (this.hasQueuedRequestForChild(job.jobId)) {
|
|
504
|
+
this.respondError(socket, CHILD_WAIT_INTERRUPTED_ERROR);
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
if (record.waiter) {
|
|
508
|
+
throw new Error(`A wait is already active for subagent request ${request.requestId}.`);
|
|
509
|
+
}
|
|
510
|
+
const waiter: RequestWaiter = { socket };
|
|
511
|
+
record.waiter = waiter;
|
|
512
|
+
if (request.timeoutMs !== undefined) {
|
|
513
|
+
waiter.timer = setTimeout(() => {
|
|
514
|
+
if (record.waiter !== waiter) return;
|
|
515
|
+
this.clearWaiter(record);
|
|
516
|
+
this.respondError(socket, "Subagent response wait timed out.");
|
|
517
|
+
}, request.timeoutMs);
|
|
518
|
+
waiter.timer.unref();
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
private markConsumed(request: RequestRecord): void {
|
|
523
|
+
if (this.requests.get(request.requestId) !== request || request.response === undefined) return;
|
|
524
|
+
request.consumedAt ??= this.now();
|
|
525
|
+
const consumed = [...this.requests.values()]
|
|
526
|
+
.filter(
|
|
527
|
+
(candidate) => candidate.jobId === request.jobId && candidate.consumedAt !== undefined,
|
|
528
|
+
)
|
|
529
|
+
.sort((left, right) => (left.consumedAt ?? 0) - (right.consumedAt ?? 0));
|
|
530
|
+
for (const expired of consumed.slice(
|
|
531
|
+
0,
|
|
532
|
+
Math.max(0, consumed.length - MAX_RETAINED_CONSUMED_REQUESTS),
|
|
533
|
+
)) {
|
|
534
|
+
this.requests.delete(expired.requestId);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
private clearWaiter(request: RequestRecord): void {
|
|
539
|
+
if (request.waiter?.timer) clearTimeout(request.waiter.timer);
|
|
540
|
+
request.waiter = undefined;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
private respondError(socket: Socket, error: string): void {
|
|
544
|
+
this.respond(socket, { ok: false, error: truncateError(error) });
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
private respond(socket: Socket, value: Record<string, unknown>, onFlushed?: () => void): void {
|
|
548
|
+
if (socket.destroyed) return;
|
|
549
|
+
let content = `${JSON.stringify(value)}\n`;
|
|
550
|
+
if (Buffer.byteLength(content, "utf8") > MAX_FRAME_BYTES) {
|
|
551
|
+
content = `${JSON.stringify({ ok: false, error: "Subagent broker response exceeded its size limit." })}\n`;
|
|
552
|
+
}
|
|
553
|
+
socket.end(content, onFlushed);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
private fail(error: Error): void {
|
|
557
|
+
this.failure = truncateError(error.message);
|
|
558
|
+
for (const jobId of [...this.tokenByJob.keys()]) {
|
|
559
|
+
this.revokeJob(jobId, "Subagent message broker failed.");
|
|
560
|
+
}
|
|
561
|
+
for (const socket of this.sockets) socket.destroy();
|
|
562
|
+
this.sockets.clear();
|
|
563
|
+
this.address = undefined;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
export function validateMessage(message: string, label: string): void {
|
|
568
|
+
if (!message.trim()) throw new Error(`${label} is required.`);
|
|
569
|
+
if (message.includes("\0")) throw new Error(`${label} must not contain NUL bytes.`);
|
|
570
|
+
if (Buffer.byteLength(message, "utf8") > MAX_MESSAGE_BYTES) {
|
|
571
|
+
throw new Error(`${label} must be at most ${MAX_MESSAGE_BYTES} UTF-8 bytes.`);
|
|
572
|
+
}
|
|
573
|
+
if (lineCount(message) > MAX_MESSAGE_LINES) {
|
|
574
|
+
throw new Error(`${label} must contain at most ${MAX_MESSAGE_LINES} lines.`);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
export function sanitizeTerminalText(value: string): string {
|
|
579
|
+
return [...value]
|
|
580
|
+
.filter((character) => {
|
|
581
|
+
const codePoint = character.codePointAt(0) ?? 0;
|
|
582
|
+
if (character === "\n" || character === "\t") return true;
|
|
583
|
+
if (codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f)) return false;
|
|
584
|
+
return !(
|
|
585
|
+
(codePoint >= 0x202a && codePoint <= 0x202e) ||
|
|
586
|
+
(codePoint >= 0x2066 && codePoint <= 0x2069)
|
|
587
|
+
);
|
|
588
|
+
})
|
|
589
|
+
.join("");
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function validateRequestId(requestId: string): void {
|
|
593
|
+
if (
|
|
594
|
+
requestId.length > MAX_IDENTIFIER_LENGTH ||
|
|
595
|
+
!/^req_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u.test(requestId)
|
|
596
|
+
) {
|
|
597
|
+
throw new Error("Invalid subagent request ID.");
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function validateTimeout(timeoutMs: number): void {
|
|
602
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > MAX_TIMEOUT_MS) {
|
|
603
|
+
throw new Error("Subagent wait timeout is outside the supported range.");
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function lineCount(value: string): number {
|
|
608
|
+
let count = 1;
|
|
609
|
+
for (const character of value) if (character === "\n") count++;
|
|
610
|
+
return count;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function truncateError(error: string): string {
|
|
614
|
+
const bytes = Buffer.from(error, "utf8");
|
|
615
|
+
if (bytes.length <= MAX_ERROR_BYTES) return error;
|
|
616
|
+
return `${bytes
|
|
617
|
+
.subarray(0, Math.max(0, MAX_ERROR_BYTES - 18))
|
|
618
|
+
.toString("utf8")
|
|
619
|
+
.replace(/�+$/gu, "")}\n… [truncated]`;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
623
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function isLoopbackAddress(address: AddressInfo | string | null): address is AddressInfo {
|
|
627
|
+
return Boolean(address && typeof address !== "string" && address.address === BROKER_HOST);
|
|
628
|
+
}
|