@fitchmultz/pi-subagents 0.0.0-stage → 0.44.5
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/CHANGELOG.md +1654 -0
- package/LICENSE +21 -0
- package/README.md +1533 -2
- package/agents/context-builder.md +97 -0
- package/agents/debugger.md +51 -0
- package/agents/delegate.md +13 -0
- package/agents/fixer.md +37 -0
- package/agents/oracle.md +71 -0
- package/agents/planner.md +72 -0
- package/agents/researcher.md +51 -0
- package/agents/reviewer-claude.md +69 -0
- package/agents/reviewer-gpt.md +68 -0
- package/agents/reviewer-ponytail.md +82 -0
- package/agents/reviewer-security.md +81 -0
- package/agents/reviewer.md +69 -0
- package/agents/scout.md +66 -0
- package/agents/ui-designer.md +51 -0
- package/agents/watcher.md +26 -0
- package/agents/worker.md +46 -0
- package/agents/writer.md +36 -0
- package/dist/agents/agent-management.js +923 -0
- package/dist/agents/agent-scope.js +7 -0
- package/dist/agents/agent-selection.js +20 -0
- package/dist/agents/agent-serializer.js +100 -0
- package/dist/agents/agents.js +752 -0
- package/dist/agents/chain-serializer.js +367 -0
- package/dist/agents/frontmatter.js +21 -0
- package/dist/agents/identity.js +29 -0
- package/dist/agents/skills.js +570 -0
- package/dist/extension/build-info.js +1 -0
- package/dist/extension/compact-tools.js +112 -0
- package/dist/extension/config.js +22 -0
- package/dist/extension/control-notices.js +29 -0
- package/dist/extension/doctor.js +143 -0
- package/dist/extension/fanout-child.js +326 -0
- package/dist/extension/index.js +577 -0
- package/dist/extension/schemas.js +416 -0
- package/dist/extension/tool-input.js +20 -0
- package/dist/extension/tool-result.js +14 -0
- package/dist/history/canonical-result.js +81 -0
- package/dist/history/database.js +72 -0
- package/dist/history/index.js +199 -0
- package/dist/history/ingest.js +468 -0
- package/dist/history/queries.js +350 -0
- package/dist/history/store.js +216 -0
- package/dist/history/types.js +4 -0
- package/dist/history/worker.js +466 -0
- package/dist/intercom/intercom-bridge.js +112 -0
- package/dist/intercom/live-intercom.js +94 -0
- package/dist/intercom/result-intercom.js +373 -0
- package/dist/pi-intercom/agent-dir.js +10 -0
- package/dist/pi-intercom/bridge.js +750 -0
- package/dist/pi-intercom/broker/broker.js +426 -0
- package/dist/pi-intercom/broker/client.js +492 -0
- package/dist/pi-intercom/broker/framing.js +65 -0
- package/dist/pi-intercom/broker/paths.js +42 -0
- package/dist/pi-intercom/broker/pid.js +66 -0
- package/dist/pi-intercom/broker/spawn.js +221 -0
- package/dist/pi-intercom/config.js +102 -0
- package/dist/pi-intercom/index.js +2942 -0
- package/dist/pi-intercom/reply-tracker.js +182 -0
- package/dist/pi-intercom/session-targets.js +128 -0
- package/dist/pi-intercom/subagent-live-events.js +132 -0
- package/dist/pi-intercom/topics.js +157 -0
- package/dist/pi-intercom/types.js +163 -0
- package/dist/pi-intercom/ui/compose.js +269 -0
- package/dist/pi-intercom/ui/inline-message.js +80 -0
- package/dist/pi-intercom/ui/session-list.js +124 -0
- package/dist/runs/background/async-control.js +95 -0
- package/dist/runs/background/async-execution.js +676 -0
- package/dist/runs/background/async-job-tracker.js +383 -0
- package/dist/runs/background/async-result-file.js +58 -0
- package/dist/runs/background/async-resume.js +305 -0
- package/dist/runs/background/async-status.js +442 -0
- package/dist/runs/background/completion-dedupe.js +69 -0
- package/dist/runs/background/completion-delivery.js +245 -0
- package/dist/runs/background/notify.js +71 -0
- package/dist/runs/background/parallel-groups.js +55 -0
- package/dist/runs/background/result-watcher.js +426 -0
- package/dist/runs/background/run-id-resolver.js +64 -0
- package/dist/runs/background/run-status.js +382 -0
- package/dist/runs/background/stale-run-reconciler.js +335 -0
- package/dist/runs/background/subagent-runner-launcher.js +55 -0
- package/dist/runs/background/subagent-runner.js +1872 -0
- package/dist/runs/background/top-level-async.js +5 -0
- package/dist/runs/foreground/chain-clarify.js +1152 -0
- package/dist/runs/foreground/clarify-invocation.js +72 -0
- package/dist/runs/foreground/execution-input.js +423 -0
- package/dist/runs/foreground/foreground-control.js +817 -0
- package/dist/runs/foreground/question-control.js +88 -0
- package/dist/runs/foreground/run-async-path.js +205 -0
- package/dist/runs/foreground/subagent-executor.js +442 -0
- package/dist/runs/foreground/subagent-params.js +165 -0
- package/dist/runs/foreground/wait-run.js +141 -0
- package/dist/runs/shared/acceptance-contract.js +253 -0
- package/dist/runs/shared/acceptance-evaluation.js +256 -0
- package/dist/runs/shared/acceptance-finalization.js +280 -0
- package/dist/runs/shared/acceptance-reports.js +147 -0
- package/dist/runs/shared/acceptance.js +4 -0
- package/dist/runs/shared/chain-outputs.js +110 -0
- package/dist/runs/shared/child-attempt.js +693 -0
- package/dist/runs/shared/child-execution-cwd.js +96 -0
- package/dist/runs/shared/child-observations.js +114 -0
- package/dist/runs/shared/claude-code.js +199 -0
- package/dist/runs/shared/completion-guard.js +31 -0
- package/dist/runs/shared/dynamic-fanout.js +274 -0
- package/dist/runs/shared/history-index.js +72 -0
- package/dist/runs/shared/mcp-direct-tool-allowlist.js +334 -0
- package/dist/runs/shared/model-fallback.js +185 -0
- package/dist/runs/shared/mutating-tool-guard.js +170 -0
- package/dist/runs/shared/native-finalization.js +101 -0
- package/dist/runs/shared/native-usage.js +153 -0
- package/dist/runs/shared/nested-events.js +700 -0
- package/dist/runs/shared/nested-path.js +48 -0
- package/dist/runs/shared/nested-render.js +108 -0
- package/dist/runs/shared/parallel-utils.js +61 -0
- package/dist/runs/shared/parent-receipts.js +68 -0
- package/dist/runs/shared/parent-usage.js +121 -0
- package/dist/runs/shared/pi-args.js +310 -0
- package/dist/runs/shared/pi-spawn.js +52 -0
- package/dist/runs/shared/run-history-queries.js +45 -0
- package/dist/runs/shared/run-history.js +110 -0
- package/dist/runs/shared/run-id-ambiguity.js +4 -0
- package/dist/runs/shared/run-records.js +679 -0
- package/dist/runs/shared/session-cwd-preload.js +33 -0
- package/dist/runs/shared/single-output.js +222 -0
- package/dist/runs/shared/streaming-text.js +15 -0
- package/dist/runs/shared/structured-output.js +55 -0
- package/dist/runs/shared/subagent-control.js +179 -0
- package/dist/runs/shared/subagent-prompt-runtime.js +197 -0
- package/dist/runs/shared/subagent-tool-loop-guard.js +66 -0
- package/dist/runs/shared/supervisor-questions.js +350 -0
- package/dist/runs/shared/workflow-graph.js +214 -0
- package/dist/runs/shared/workflow-policy.js +84 -0
- package/dist/runs/shared/worktree.js +525 -0
- package/dist/shared/agent-context-policy.js +126 -0
- package/dist/shared/artifacts.js +96 -0
- package/dist/shared/atomic-json.js +13 -0
- package/dist/shared/cooperative.js +20 -0
- package/dist/shared/execution-cwd.js +14 -0
- package/dist/shared/file-coalescer.js +21 -0
- package/dist/shared/fork-context.js +49 -0
- package/dist/shared/formatters.js +85 -0
- package/dist/shared/journal-reader.js +407 -0
- package/dist/shared/lazy-tools.js +32 -0
- package/dist/shared/model-info.js +68 -0
- package/dist/shared/native-session.js +15 -0
- package/dist/shared/native-tui.js +18 -0
- package/dist/shared/native-typebox.js +20 -0
- package/dist/shared/post-exit-stdio-guard.js +197 -0
- package/dist/shared/prompt-sections.js +8 -0
- package/dist/shared/session-entries.js +52 -0
- package/dist/shared/session-identity.js +14 -0
- package/dist/shared/settings.js +254 -0
- package/dist/shared/status-format.js +111 -0
- package/dist/shared/temp-root.js +114 -0
- package/dist/shared/types.js +207 -0
- package/dist/shared/ui-mode.js +3 -0
- package/dist/shared/utils.js +334 -0
- package/dist/slash/prompt-template-bridge.js +298 -0
- package/dist/slash/slash-bridge.js +116 -0
- package/dist/slash/slash-commands.js +607 -0
- package/dist/slash/slash-live-state.js +246 -0
- package/dist/tui/action-hints.js +75 -0
- package/dist/tui/agent-history.js +197 -0
- package/dist/tui/agent-view.js +1656 -0
- package/dist/tui/render.js +1515 -0
- package/docs/intercom-bridge.md +212 -0
- package/docs/intercom.md +581 -0
- package/docs/performance.md +121 -0
- package/package.json +102 -4
- package/prompts/gather-context-and-clarify.md +14 -0
- package/prompts/parallel-cleanup.md +62 -0
- package/prompts/parallel-context-build.md +56 -0
- package/prompts/parallel-handoff-plan.md +62 -0
- package/prompts/parallel-research.md +51 -0
- package/prompts/parallel-review.md +59 -0
- package/prompts/review-loop.md +42 -0
- package/scripts/build.mjs +134 -0
- package/scripts/check-compat.mjs +48 -0
- package/scripts/compat-host.mjs +19 -0
- package/scripts/intercom-bridge-credentials.mjs +143 -0
- package/scripts/intercom-remote.mjs +103 -0
- package/scripts/linux-smoke.sh +51 -0
- package/scripts/local-install-smoke.mjs +66 -0
- package/scripts/native-package-smoke.mjs +82 -0
- package/scripts/package-smoke.mjs +209 -0
- package/scripts/prepare.mjs +67 -0
- package/scripts/real-pi-smoke.mjs +415 -0
- package/scripts/run-tests.mjs +149 -0
- package/skills/pi-intercom/SKILL.md +113 -0
- package/skills/pi-intercom/evals/evals.json +29 -0
- package/skills/pi-intercom/evals/trigger-evals.json +47 -0
- package/skills/pi-intercom/references/peer-sessions.md +69 -0
- package/skills/pi-subagents/SKILL.md +76 -0
- package/skills/pi-subagents/evals/evals.json +29 -0
- package/skills/pi-subagents/evals/trigger-evals.json +42 -0
- package/skills/pi-subagents/references/full-orchestration-guide.md +863 -0
- package/src/agents/agent-management.ts +813 -0
- package/src/agents/agent-scope.ts +7 -0
- package/src/agents/agent-selection.ts +23 -0
- package/src/agents/agent-serializer.ts +99 -0
- package/src/agents/agents.ts +916 -0
- package/src/agents/chain-serializer.ts +329 -0
- package/src/agents/frontmatter.ts +21 -0
- package/src/agents/identity.ts +30 -0
- package/src/agents/skills.ts +637 -0
- package/src/extension/build-info.ts +2 -0
- package/src/extension/compact-tools.ts +114 -0
- package/src/extension/config.ts +23 -0
- package/src/extension/control-notices.ts +47 -0
- package/src/extension/doctor.ts +171 -0
- package/src/extension/fanout-child.ts +312 -0
- package/src/extension/index.ts +649 -0
- package/src/extension/schemas.ts +444 -0
- package/src/extension/tool-input.ts +19 -0
- package/src/extension/tool-result.ts +16 -0
- package/src/history/canonical-result.ts +62 -0
- package/src/history/database.ts +59 -0
- package/src/history/index.ts +156 -0
- package/src/history/ingest.ts +349 -0
- package/src/history/queries.ts +241 -0
- package/src/history/store.ts +203 -0
- package/src/history/types.ts +166 -0
- package/src/history/worker.ts +321 -0
- package/src/intercom/intercom-bridge.ts +123 -0
- package/src/intercom/live-intercom.ts +104 -0
- package/src/intercom/result-intercom.ts +429 -0
- package/src/pi-intercom/agent-dir.ts +9 -0
- package/src/pi-intercom/bridge.ts +668 -0
- package/src/pi-intercom/broker/broker.ts +463 -0
- package/src/pi-intercom/broker/client.ts +558 -0
- package/src/pi-intercom/broker/framing.ts +77 -0
- package/src/pi-intercom/broker/paths.ts +47 -0
- package/src/pi-intercom/broker/pid.ts +59 -0
- package/src/pi-intercom/broker/spawn.ts +248 -0
- package/src/pi-intercom/config.ts +142 -0
- package/src/pi-intercom/index.ts +2929 -0
- package/src/pi-intercom/reply-tracker.ts +211 -0
- package/src/pi-intercom/session-targets.ts +172 -0
- package/src/pi-intercom/subagent-live-events.ts +133 -0
- package/src/pi-intercom/topics.ts +125 -0
- package/src/pi-intercom/types.ts +271 -0
- package/src/pi-intercom/ui/compose.ts +289 -0
- package/src/pi-intercom/ui/inline-message.ts +92 -0
- package/src/pi-intercom/ui/session-list.ts +139 -0
- package/src/runs/background/async-control.ts +89 -0
- package/src/runs/background/async-execution.ts +850 -0
- package/src/runs/background/async-job-tracker.ts +385 -0
- package/src/runs/background/async-result-file.ts +62 -0
- package/src/runs/background/async-resume.ts +333 -0
- package/src/runs/background/async-status.ts +481 -0
- package/src/runs/background/completion-dedupe.ts +79 -0
- package/src/runs/background/completion-delivery.ts +195 -0
- package/src/runs/background/notify.ts +123 -0
- package/src/runs/background/parallel-groups.ts +53 -0
- package/src/runs/background/result-watcher.ts +423 -0
- package/src/runs/background/run-id-resolver.ts +69 -0
- package/src/runs/background/run-status.ts +384 -0
- package/src/runs/background/stale-run-reconciler.ts +369 -0
- package/src/runs/background/subagent-runner-launcher.ts +56 -0
- package/src/runs/background/subagent-runner.ts +2053 -0
- package/src/runs/background/top-level-async.ts +13 -0
- package/src/runs/foreground/chain-clarify.ts +1340 -0
- package/src/runs/foreground/clarify-invocation.ts +77 -0
- package/src/runs/foreground/execution-input.ts +467 -0
- package/src/runs/foreground/foreground-control.ts +821 -0
- package/src/runs/foreground/question-control.ts +82 -0
- package/src/runs/foreground/run-async-path.ts +243 -0
- package/src/runs/foreground/subagent-executor.ts +501 -0
- package/src/runs/foreground/subagent-params.ts +320 -0
- package/src/runs/foreground/wait-run.ts +124 -0
- package/src/runs/shared/acceptance-contract.ts +257 -0
- package/src/runs/shared/acceptance-evaluation.ts +275 -0
- package/src/runs/shared/acceptance-finalization.ts +353 -0
- package/src/runs/shared/acceptance-reports.ts +152 -0
- package/src/runs/shared/acceptance.ts +33 -0
- package/src/runs/shared/chain-outputs.ts +114 -0
- package/src/runs/shared/child-attempt.ts +623 -0
- package/src/runs/shared/child-execution-cwd.ts +91 -0
- package/src/runs/shared/child-observations.ts +99 -0
- package/src/runs/shared/claude-code.ts +243 -0
- package/src/runs/shared/completion-guard.ts +35 -0
- package/src/runs/shared/dynamic-fanout.ts +295 -0
- package/src/runs/shared/history-index.ts +69 -0
- package/src/runs/shared/mcp-direct-tool-allowlist.ts +376 -0
- package/src/runs/shared/model-fallback.ts +219 -0
- package/src/runs/shared/mutating-tool-guard.ts +194 -0
- package/src/runs/shared/native-finalization.ts +128 -0
- package/src/runs/shared/native-usage.ts +113 -0
- package/src/runs/shared/nested-events.ts +752 -0
- package/src/runs/shared/nested-path.ts +52 -0
- package/src/runs/shared/nested-render.ts +119 -0
- package/src/runs/shared/parallel-utils.ts +153 -0
- package/src/runs/shared/parent-receipts.ts +59 -0
- package/src/runs/shared/parent-usage.ts +121 -0
- package/src/runs/shared/pi-args.ts +350 -0
- package/src/runs/shared/pi-spawn.ts +52 -0
- package/src/runs/shared/run-history-queries.ts +44 -0
- package/src/runs/shared/run-history.ts +100 -0
- package/src/runs/shared/run-id-ambiguity.ts +4 -0
- package/src/runs/shared/run-records.ts +607 -0
- package/src/runs/shared/session-cwd-preload.ts +27 -0
- package/src/runs/shared/single-output.ts +267 -0
- package/src/runs/shared/streaming-text.ts +18 -0
- package/src/runs/shared/structured-output.ts +68 -0
- package/src/runs/shared/subagent-control.ts +219 -0
- package/src/runs/shared/subagent-prompt-runtime.ts +201 -0
- package/src/runs/shared/subagent-tool-loop-guard.ts +89 -0
- package/src/runs/shared/supervisor-questions.ts +415 -0
- package/src/runs/shared/workflow-graph.ts +220 -0
- package/src/runs/shared/workflow-policy.ts +125 -0
- package/src/runs/shared/worktree.ts +635 -0
- package/src/shared/agent-context-policy.ts +184 -0
- package/src/shared/artifacts.ts +95 -0
- package/src/shared/atomic-json.ts +16 -0
- package/src/shared/cooperative.ts +21 -0
- package/src/shared/execution-cwd.ts +14 -0
- package/src/shared/file-coalescer.ts +29 -0
- package/src/shared/fork-context.ts +68 -0
- package/src/shared/formatters.ts +84 -0
- package/src/shared/journal-reader.ts +333 -0
- package/src/shared/lazy-tools.ts +36 -0
- package/src/shared/model-info.ts +81 -0
- package/src/shared/native-session.ts +8 -0
- package/src/shared/native-tui.ts +10 -0
- package/src/shared/native-typebox.ts +14 -0
- package/src/shared/post-exit-stdio-guard.ts +204 -0
- package/src/shared/prompt-sections.ts +10 -0
- package/src/shared/session-entries.ts +49 -0
- package/src/shared/session-identity.ts +18 -0
- package/src/shared/settings.ts +384 -0
- package/src/shared/status-format.ts +123 -0
- package/src/shared/temp-root.ts +107 -0
- package/src/shared/types.ts +1260 -0
- package/src/shared/ui-mode.ts +5 -0
- package/src/shared/utils.ts +342 -0
- package/src/slash/prompt-template-bridge.ts +408 -0
- package/src/slash/slash-bridge.ts +178 -0
- package/src/slash/slash-commands.ts +617 -0
- package/src/slash/slash-live-state.ts +292 -0
- package/src/tui/action-hints.ts +69 -0
- package/src/tui/agent-history.ts +210 -0
- package/src/tui/agent-view.ts +1323 -0
- package/src/tui/render.ts +1550 -0
- package/tsconfig.build.json +12 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
import { EventEmitter } from "events";
|
|
2
|
+
import net from "net";
|
|
3
|
+
import { randomUUID } from "crypto";
|
|
4
|
+
import { writeMessage, createMessageReader } from "./framing.js";
|
|
5
|
+
import { getBrokerSocketPath, getLegacyBrokerSocketPath, isOwnedBrokerSocket } from "./paths.js";
|
|
6
|
+
import { compactTopicMessage, normalizeMessage, normalizeSessionInfo } from "../types.js";
|
|
7
|
+
/** Default delivery-ack timeout for `send` (broker acknowledges quickly). */
|
|
8
|
+
const DEFAULT_SEND_TIMEOUT_MS = 8000;
|
|
9
|
+
/** Default timeout for `listSessions` responses. */
|
|
10
|
+
const DEFAULT_LIST_TIMEOUT_MS = 5000;
|
|
11
|
+
function toError(error) {
|
|
12
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
13
|
+
}
|
|
14
|
+
function connectSocket(socketPath, timeoutMs = 500) {
|
|
15
|
+
return new Promise((resolve, reject) => {
|
|
16
|
+
const socket = net.connect(socketPath);
|
|
17
|
+
const finish = (error) => {
|
|
18
|
+
clearTimeout(timeout);
|
|
19
|
+
socket.off("connect", onConnect);
|
|
20
|
+
socket.off("error", onError);
|
|
21
|
+
if (error) {
|
|
22
|
+
socket.destroy();
|
|
23
|
+
reject(error);
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
resolve(socket);
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
const onConnect = () => finish();
|
|
30
|
+
const onError = (error) => finish(error);
|
|
31
|
+
socket.once("connect", onConnect);
|
|
32
|
+
socket.once("error", onError);
|
|
33
|
+
const timeout = setTimeout(() => finish(new Error(`Connection timeout: ${socketPath}`)), timeoutMs);
|
|
34
|
+
timeout.unref?.();
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
async function connectBrokerSocket() {
|
|
38
|
+
const preferred = getBrokerSocketPath();
|
|
39
|
+
const legacy = getLegacyBrokerSocketPath();
|
|
40
|
+
const candidates = [preferred, ...(legacy !== preferred ? [legacy] : [])];
|
|
41
|
+
let lastError;
|
|
42
|
+
for (const candidate of candidates) {
|
|
43
|
+
if (!isOwnedBrokerSocket(candidate))
|
|
44
|
+
continue;
|
|
45
|
+
try {
|
|
46
|
+
return await connectSocket(candidate);
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
lastError = toError(error);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
throw lastError ?? new Error(`Intercom broker socket is unavailable: ${preferred}`);
|
|
53
|
+
}
|
|
54
|
+
export class IntercomClient extends EventEmitter {
|
|
55
|
+
socket = null;
|
|
56
|
+
_sessionId = null;
|
|
57
|
+
_topicsSupported = false;
|
|
58
|
+
pendingSends = new Map();
|
|
59
|
+
pendingLists = new Map();
|
|
60
|
+
connecting = false;
|
|
61
|
+
disconnecting = false;
|
|
62
|
+
disconnectError = null;
|
|
63
|
+
sendTimeoutMs;
|
|
64
|
+
listTimeoutMs;
|
|
65
|
+
constructor(options = {}) {
|
|
66
|
+
super();
|
|
67
|
+
this.sendTimeoutMs = options.sendTimeoutMs ?? DEFAULT_SEND_TIMEOUT_MS;
|
|
68
|
+
this.listTimeoutMs = options.listTimeoutMs ?? DEFAULT_LIST_TIMEOUT_MS;
|
|
69
|
+
}
|
|
70
|
+
failPending(error) {
|
|
71
|
+
for (const pending of this.pendingSends.values()) {
|
|
72
|
+
pending.reject(error);
|
|
73
|
+
}
|
|
74
|
+
this.pendingSends.clear();
|
|
75
|
+
for (const pending of this.pendingLists.values()) {
|
|
76
|
+
pending.reject(error);
|
|
77
|
+
}
|
|
78
|
+
this.pendingLists.clear();
|
|
79
|
+
}
|
|
80
|
+
get sessionId() {
|
|
81
|
+
return this._sessionId;
|
|
82
|
+
}
|
|
83
|
+
get supportsTopics() { return this.isConnected() && this._topicsSupported; }
|
|
84
|
+
isConnected() {
|
|
85
|
+
const socket = this.socket;
|
|
86
|
+
return Boolean(socket && this._sessionId && !this.disconnecting && !socket.destroyed && !socket.writableEnded && socket.writable);
|
|
87
|
+
}
|
|
88
|
+
requireActiveSocket() {
|
|
89
|
+
if (this.disconnecting) {
|
|
90
|
+
throw new Error("Client disconnecting");
|
|
91
|
+
}
|
|
92
|
+
const socket = this.socket;
|
|
93
|
+
if (!socket || !this._sessionId) {
|
|
94
|
+
throw new Error("Not connected");
|
|
95
|
+
}
|
|
96
|
+
if (socket.destroyed || socket.writableEnded || !socket.writable) {
|
|
97
|
+
throw new Error("Client disconnected");
|
|
98
|
+
}
|
|
99
|
+
return socket;
|
|
100
|
+
}
|
|
101
|
+
async connect(session, requestedId) {
|
|
102
|
+
if (this.socket || this.connecting) {
|
|
103
|
+
throw new Error("Already connected");
|
|
104
|
+
}
|
|
105
|
+
this.connecting = true;
|
|
106
|
+
let socket;
|
|
107
|
+
try {
|
|
108
|
+
socket = await connectBrokerSocket();
|
|
109
|
+
}
|
|
110
|
+
finally {
|
|
111
|
+
this.connecting = false;
|
|
112
|
+
}
|
|
113
|
+
const { topics, subscriptions, ...identity } = session;
|
|
114
|
+
await new Promise((resolve, reject) => {
|
|
115
|
+
this.socket = socket;
|
|
116
|
+
this.disconnectError = null;
|
|
117
|
+
let settled = false;
|
|
118
|
+
const timeout = setTimeout(() => {
|
|
119
|
+
if (!this._sessionId) {
|
|
120
|
+
cleanupConnectionAttempt();
|
|
121
|
+
cleanupSocketListeners();
|
|
122
|
+
if (this.socket === socket) {
|
|
123
|
+
this.socket = null;
|
|
124
|
+
}
|
|
125
|
+
socket.destroy();
|
|
126
|
+
reject(new Error("Connection timeout"));
|
|
127
|
+
}
|
|
128
|
+
}, 10000);
|
|
129
|
+
timeout.unref?.();
|
|
130
|
+
let connectionEstablished = false;
|
|
131
|
+
const onRegistered = () => {
|
|
132
|
+
settled = true;
|
|
133
|
+
connectionEstablished = true;
|
|
134
|
+
cleanupConnectionAttempt();
|
|
135
|
+
resolve();
|
|
136
|
+
};
|
|
137
|
+
const onError = (err) => {
|
|
138
|
+
settled = true;
|
|
139
|
+
cleanupConnectionAttempt();
|
|
140
|
+
cleanupSocketListeners();
|
|
141
|
+
if (this.socket === socket) {
|
|
142
|
+
this.socket = null;
|
|
143
|
+
}
|
|
144
|
+
socket.destroy();
|
|
145
|
+
reject(err);
|
|
146
|
+
};
|
|
147
|
+
const onClose = () => {
|
|
148
|
+
const wasConnecting = !settled && !this._sessionId;
|
|
149
|
+
const wasDisconnecting = this.disconnecting;
|
|
150
|
+
const disconnectError = this.disconnectError ?? new Error("Client disconnected");
|
|
151
|
+
this.disconnecting = false;
|
|
152
|
+
cleanupConnectionAttempt();
|
|
153
|
+
cleanupSocketListeners();
|
|
154
|
+
this.failPending(disconnectError);
|
|
155
|
+
if (this.socket === socket) {
|
|
156
|
+
this.socket = null;
|
|
157
|
+
}
|
|
158
|
+
this._sessionId = null;
|
|
159
|
+
this.disconnectError = null;
|
|
160
|
+
if (connectionEstablished && !wasDisconnecting) {
|
|
161
|
+
this.emit("disconnected", disconnectError);
|
|
162
|
+
}
|
|
163
|
+
if (wasConnecting) {
|
|
164
|
+
reject(new Error("Connection closed before registration"));
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
const onSocketError = (err) => {
|
|
168
|
+
if (connectionEstablished) {
|
|
169
|
+
this.disconnectError = err;
|
|
170
|
+
this.emit("error", err);
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
const onReaderError = (error) => {
|
|
174
|
+
const protocolError = new Error(`Intercom protocol error: ${error.message}`, { cause: error });
|
|
175
|
+
if (!connectionEstablished) {
|
|
176
|
+
onError(protocolError);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
this.disconnectError = protocolError;
|
|
180
|
+
this.emit("error", protocolError);
|
|
181
|
+
socket.destroy();
|
|
182
|
+
};
|
|
183
|
+
const reader = createMessageReader((msg) => {
|
|
184
|
+
this.handleBrokerMessage(msg);
|
|
185
|
+
}, onReaderError);
|
|
186
|
+
const cleanupConnectionAttempt = () => {
|
|
187
|
+
this.off("_registered", onRegistered);
|
|
188
|
+
socket.off("error", onError);
|
|
189
|
+
clearTimeout(timeout);
|
|
190
|
+
};
|
|
191
|
+
const cleanupSocketListeners = () => {
|
|
192
|
+
socket.off("data", reader);
|
|
193
|
+
socket.off("error", onSocketError);
|
|
194
|
+
socket.off("close", onClose);
|
|
195
|
+
};
|
|
196
|
+
socket.on("data", reader);
|
|
197
|
+
socket.on("error", onError);
|
|
198
|
+
socket.on("close", onClose);
|
|
199
|
+
socket.on("error", onSocketError);
|
|
200
|
+
this.once("_registered", onRegistered);
|
|
201
|
+
try {
|
|
202
|
+
writeMessage(socket, { type: "register", session: identity, requestedId, topicFrames: true });
|
|
203
|
+
}
|
|
204
|
+
catch (error) {
|
|
205
|
+
cleanupConnectionAttempt();
|
|
206
|
+
cleanupSocketListeners();
|
|
207
|
+
if (this.socket === socket) {
|
|
208
|
+
this.socket = null;
|
|
209
|
+
}
|
|
210
|
+
socket.destroy();
|
|
211
|
+
reject(toError(error));
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
if (this.supportsTopics) {
|
|
215
|
+
for (const topic of topics ?? [])
|
|
216
|
+
await this.updateTopics({ action: "restore", topic });
|
|
217
|
+
for (const subscription of subscriptions ?? [])
|
|
218
|
+
await this.updateTopics({ action: "restore", subscription });
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
handleBrokerMessage(msg) {
|
|
222
|
+
if (typeof msg !== "object" || msg === null || !("type" in msg) || typeof msg.type !== "string") {
|
|
223
|
+
throw new Error("Invalid broker message");
|
|
224
|
+
}
|
|
225
|
+
const brokerMessage = msg;
|
|
226
|
+
if (this._sessionId === null && brokerMessage.type !== "registered") {
|
|
227
|
+
throw new Error(`Received ${brokerMessage.type} before registered`);
|
|
228
|
+
}
|
|
229
|
+
switch (brokerMessage.type) {
|
|
230
|
+
case "registered": {
|
|
231
|
+
if (typeof brokerMessage.sessionId !== "string") {
|
|
232
|
+
throw new Error("Invalid registered message");
|
|
233
|
+
}
|
|
234
|
+
if (this._sessionId !== null) {
|
|
235
|
+
throw new Error("Received duplicate registered message");
|
|
236
|
+
}
|
|
237
|
+
this._sessionId = brokerMessage.sessionId;
|
|
238
|
+
this._topicsSupported = brokerMessage.topicsSupported === true && brokerMessage.topicFrames === true;
|
|
239
|
+
this.emit("_registered", { type: "registered", sessionId: brokerMessage.sessionId });
|
|
240
|
+
break;
|
|
241
|
+
}
|
|
242
|
+
case "sessions": {
|
|
243
|
+
const { requestId, sessions, receipts, more, error } = brokerMessage;
|
|
244
|
+
if (typeof requestId !== "string" || !Array.isArray(sessions) || (more !== undefined && typeof more !== "boolean"))
|
|
245
|
+
throw new Error("Invalid sessions message");
|
|
246
|
+
const pending = this.pendingLists.get(requestId);
|
|
247
|
+
if (!pending)
|
|
248
|
+
return;
|
|
249
|
+
if (typeof error === "string") {
|
|
250
|
+
this.pendingLists.delete(requestId);
|
|
251
|
+
pending.reject(new Error(error));
|
|
252
|
+
break;
|
|
253
|
+
}
|
|
254
|
+
for (const row of sessions) {
|
|
255
|
+
if (!row || typeof row.id !== "string" || (row.topics !== undefined && !Array.isArray(row.topics)) || (row.subscriptions !== undefined && !Array.isArray(row.subscriptions)))
|
|
256
|
+
throw new Error("Invalid session snapshot row");
|
|
257
|
+
const previous = pending.sessions.get(row.id);
|
|
258
|
+
const info = normalizeSessionInfo({ ...previous, ...row,
|
|
259
|
+
...(row.topics ? { topics: [...(previous?.topics ?? []), ...row.topics] } : {}),
|
|
260
|
+
...(row.subscriptions ? { subscriptions: [...(previous?.subscriptions ?? []), ...row.subscriptions] } : {}),
|
|
261
|
+
});
|
|
262
|
+
if (!info)
|
|
263
|
+
throw new Error("Invalid session snapshot");
|
|
264
|
+
pending.sessions.set(info.id, info);
|
|
265
|
+
}
|
|
266
|
+
if (receipts !== undefined) {
|
|
267
|
+
if (!Array.isArray(receipts) || receipts.some((receipt) => !receipt || typeof receipt.to !== "string" || typeof receipt.id !== "string" || typeof receipt.accepted !== "boolean" || typeof receipt.delivered !== "boolean"))
|
|
268
|
+
throw new Error("Invalid topic delivery receipts");
|
|
269
|
+
pending.receipts.push(...receipts);
|
|
270
|
+
}
|
|
271
|
+
if (more !== true) {
|
|
272
|
+
this.pendingLists.delete(requestId);
|
|
273
|
+
pending.resolve({ sessions: [...pending.sessions.values()], receipts: pending.receipts });
|
|
274
|
+
}
|
|
275
|
+
break;
|
|
276
|
+
}
|
|
277
|
+
case "message": {
|
|
278
|
+
const { from } = brokerMessage;
|
|
279
|
+
const message = normalizeMessage(brokerMessage.message);
|
|
280
|
+
const normalizedFrom = normalizeSessionInfo(from);
|
|
281
|
+
if (!normalizedFrom || !message) {
|
|
282
|
+
throw new Error("Invalid message event");
|
|
283
|
+
}
|
|
284
|
+
this.emit("message", normalizedFrom, message);
|
|
285
|
+
break;
|
|
286
|
+
}
|
|
287
|
+
case "delivered": {
|
|
288
|
+
const { messageId } = brokerMessage;
|
|
289
|
+
if (typeof messageId !== "string") {
|
|
290
|
+
throw new Error("Invalid delivered message");
|
|
291
|
+
}
|
|
292
|
+
const pending = this.pendingSends.get(messageId);
|
|
293
|
+
if (!pending) {
|
|
294
|
+
// Late send responses are harmless once the caller has already timed out.
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
this.pendingSends.delete(messageId);
|
|
298
|
+
pending.resolve({ id: messageId, accepted: true, delivered: true });
|
|
299
|
+
break;
|
|
300
|
+
}
|
|
301
|
+
case "delivery_queued": {
|
|
302
|
+
const { messageId, reason } = brokerMessage;
|
|
303
|
+
if (typeof messageId !== "string" || typeof reason !== "string") {
|
|
304
|
+
throw new Error("Invalid delivery_queued message");
|
|
305
|
+
}
|
|
306
|
+
const pending = this.pendingSends.get(messageId);
|
|
307
|
+
if (!pending) {
|
|
308
|
+
// Late send responses are harmless once the caller has already timed out.
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
this.pendingSends.delete(messageId);
|
|
312
|
+
pending.resolve({ id: messageId, accepted: true, delivered: false, queued: true, reason });
|
|
313
|
+
break;
|
|
314
|
+
}
|
|
315
|
+
case "delivery_failed": {
|
|
316
|
+
const { messageId, reason } = brokerMessage;
|
|
317
|
+
if (typeof messageId !== "string" || typeof reason !== "string") {
|
|
318
|
+
throw new Error("Invalid delivery_failed message");
|
|
319
|
+
}
|
|
320
|
+
const pending = this.pendingSends.get(messageId);
|
|
321
|
+
if (!pending) {
|
|
322
|
+
// Late send responses are harmless once the caller has already timed out.
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
this.pendingSends.delete(messageId);
|
|
326
|
+
pending.resolve({ id: messageId, accepted: false, delivered: false, reason });
|
|
327
|
+
break;
|
|
328
|
+
}
|
|
329
|
+
case "session_left": {
|
|
330
|
+
if (typeof brokerMessage.sessionId !== "string") {
|
|
331
|
+
throw new Error("Invalid session_left message");
|
|
332
|
+
}
|
|
333
|
+
this.emit("session_left", brokerMessage.sessionId);
|
|
334
|
+
break;
|
|
335
|
+
}
|
|
336
|
+
default:
|
|
337
|
+
throw new Error(`Unknown broker message type: ${brokerMessage.type}`);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
async disconnect() {
|
|
341
|
+
const socket = this.socket;
|
|
342
|
+
if (!socket) {
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
this.disconnecting = true;
|
|
346
|
+
this.disconnectError = null;
|
|
347
|
+
this.failPending(new Error("Client disconnected"));
|
|
348
|
+
await new Promise((resolve) => {
|
|
349
|
+
let settled = false;
|
|
350
|
+
const finish = () => {
|
|
351
|
+
if (settled) {
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
settled = true;
|
|
355
|
+
clearTimeout(timeout);
|
|
356
|
+
socket.off("close", onClose);
|
|
357
|
+
socket.off("error", onError);
|
|
358
|
+
resolve();
|
|
359
|
+
};
|
|
360
|
+
const onClose = () => finish();
|
|
361
|
+
const onError = () => {
|
|
362
|
+
socket.destroy();
|
|
363
|
+
};
|
|
364
|
+
const timeout = setTimeout(() => {
|
|
365
|
+
socket.destroy();
|
|
366
|
+
}, 2000);
|
|
367
|
+
socket.once("close", onClose);
|
|
368
|
+
socket.once("error", onError);
|
|
369
|
+
try {
|
|
370
|
+
writeMessage(socket, { type: "unregister" });
|
|
371
|
+
socket.end();
|
|
372
|
+
}
|
|
373
|
+
catch {
|
|
374
|
+
// Disconnect should still finish even if the unregister write fails.
|
|
375
|
+
socket.destroy();
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
listSessions() {
|
|
380
|
+
return this.requestSessions().then((snapshot) => snapshot.sessions);
|
|
381
|
+
}
|
|
382
|
+
updateTopics(change, onAccepted) {
|
|
383
|
+
if (!this.supportsTopics)
|
|
384
|
+
return Promise.reject(new Error("This broker does not support topic snapshots."));
|
|
385
|
+
return this.requestSessions(change, onAccepted);
|
|
386
|
+
}
|
|
387
|
+
requestSessions(change, onAccepted) {
|
|
388
|
+
let socket;
|
|
389
|
+
try {
|
|
390
|
+
socket = this.requireActiveSocket();
|
|
391
|
+
}
|
|
392
|
+
catch (error) {
|
|
393
|
+
return Promise.reject(toError(error));
|
|
394
|
+
}
|
|
395
|
+
return new Promise((resolve, reject) => {
|
|
396
|
+
const requestId = randomUUID();
|
|
397
|
+
const wrappedResolve = (snapshot) => {
|
|
398
|
+
clearTimeout(timeout);
|
|
399
|
+
try {
|
|
400
|
+
// Commit confirmed subscriptions before handling a following message in the same read.
|
|
401
|
+
onAccepted?.(snapshot);
|
|
402
|
+
resolve(snapshot);
|
|
403
|
+
}
|
|
404
|
+
catch (error) {
|
|
405
|
+
reject(toError(error));
|
|
406
|
+
}
|
|
407
|
+
};
|
|
408
|
+
const wrappedReject = (error) => {
|
|
409
|
+
clearTimeout(timeout);
|
|
410
|
+
reject(error);
|
|
411
|
+
};
|
|
412
|
+
const timeout = setTimeout(() => {
|
|
413
|
+
if (this.pendingLists.has(requestId)) {
|
|
414
|
+
this.pendingLists.delete(requestId);
|
|
415
|
+
wrappedReject(new Error("List sessions timeout"));
|
|
416
|
+
}
|
|
417
|
+
}, this.listTimeoutMs);
|
|
418
|
+
timeout.unref?.();
|
|
419
|
+
this.pendingLists.set(requestId, { resolve: wrappedResolve, reject: wrappedReject, sessions: new Map(), receipts: [] });
|
|
420
|
+
try {
|
|
421
|
+
writeMessage(socket, { type: "list", requestId, stream: true, ...(change ? { change } : {}) });
|
|
422
|
+
}
|
|
423
|
+
catch (error) {
|
|
424
|
+
clearTimeout(timeout);
|
|
425
|
+
this.pendingLists.delete(requestId);
|
|
426
|
+
reject(toError(error));
|
|
427
|
+
}
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
send(to, options) {
|
|
431
|
+
let socket;
|
|
432
|
+
try {
|
|
433
|
+
socket = this.requireActiveSocket();
|
|
434
|
+
}
|
|
435
|
+
catch (error) {
|
|
436
|
+
return Promise.reject(toError(error));
|
|
437
|
+
}
|
|
438
|
+
const messageId = options.messageId ?? randomUUID();
|
|
439
|
+
const message = {
|
|
440
|
+
id: messageId,
|
|
441
|
+
timestamp: Date.now(),
|
|
442
|
+
replyTo: options.replyTo,
|
|
443
|
+
expectsReply: options.expectsReply,
|
|
444
|
+
...(options.human ? { human: options.human } : {}),
|
|
445
|
+
...(options.topic ? { topic: options.topic } : {}),
|
|
446
|
+
delivery: options.delivery ?? (options.passive === true ? "passive" : options.expectsReply === true ? undefined : "steer"),
|
|
447
|
+
queueMode: options.queueMode,
|
|
448
|
+
threadId: options.threadId,
|
|
449
|
+
passive: options.passive,
|
|
450
|
+
content: {
|
|
451
|
+
text: options.text,
|
|
452
|
+
attachments: options.attachments,
|
|
453
|
+
},
|
|
454
|
+
};
|
|
455
|
+
return new Promise((resolve, reject) => {
|
|
456
|
+
const wrappedResolve = (result) => {
|
|
457
|
+
clearTimeout(timeout);
|
|
458
|
+
resolve(result);
|
|
459
|
+
};
|
|
460
|
+
const wrappedReject = (error) => {
|
|
461
|
+
clearTimeout(timeout);
|
|
462
|
+
reject(error);
|
|
463
|
+
};
|
|
464
|
+
const timeout = setTimeout(() => {
|
|
465
|
+
if (this.pendingSends.has(messageId)) {
|
|
466
|
+
this.pendingSends.delete(messageId);
|
|
467
|
+
wrappedReject(new Error("Send timeout"));
|
|
468
|
+
}
|
|
469
|
+
}, this.sendTimeoutMs);
|
|
470
|
+
timeout.unref?.();
|
|
471
|
+
this.pendingSends.set(messageId, { resolve: wrappedResolve, reject: wrappedReject });
|
|
472
|
+
try {
|
|
473
|
+
writeMessage(socket, { type: "send", to, message: this._topicsSupported ? compactTopicMessage(message) : message });
|
|
474
|
+
}
|
|
475
|
+
catch (error) {
|
|
476
|
+
clearTimeout(timeout);
|
|
477
|
+
this.pendingSends.delete(messageId);
|
|
478
|
+
reject(toError(error));
|
|
479
|
+
}
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
updatePresence(updates) {
|
|
483
|
+
if (this.disconnecting) {
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
const socket = this.socket;
|
|
487
|
+
if (!socket || !this._sessionId || socket.destroyed || socket.writableEnded || !socket.writable) {
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
writeMessage(socket, { type: "presence", ...updates });
|
|
491
|
+
}
|
|
492
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
export const MAX_FRAME_SIZE_BYTES = 1024 * 1024;
|
|
2
|
+
export function intercomMessageSizeBytes(msg) {
|
|
3
|
+
return Buffer.byteLength(JSON.stringify(msg), "utf-8");
|
|
4
|
+
}
|
|
5
|
+
export function validateIntercomMessageSize(msg) {
|
|
6
|
+
const size = intercomMessageSizeBytes(msg);
|
|
7
|
+
return size > MAX_FRAME_SIZE_BYTES
|
|
8
|
+
? new Error(`Intercom message too large (${size} bytes; max ${MAX_FRAME_SIZE_BYTES})`)
|
|
9
|
+
: null;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Write a length-prefixed message to a socket.
|
|
13
|
+
* Format: 4-byte big-endian length + JSON payload
|
|
14
|
+
*/
|
|
15
|
+
export function writeMessage(socket, msg) {
|
|
16
|
+
const tooLarge = validateIntercomMessageSize(msg);
|
|
17
|
+
if (tooLarge)
|
|
18
|
+
throw tooLarge;
|
|
19
|
+
const json = JSON.stringify(msg);
|
|
20
|
+
const payload = Buffer.from(json, "utf-8");
|
|
21
|
+
const header = Buffer.alloc(4);
|
|
22
|
+
header.writeUInt32BE(payload.length, 0);
|
|
23
|
+
socket.write(Buffer.concat([header, payload]));
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Create a message reader that handles partial reads.
|
|
27
|
+
* Calls onMessage for each complete message received.
|
|
28
|
+
* Protocol or handler errors are reported to onError so the caller can close the socket.
|
|
29
|
+
*/
|
|
30
|
+
export function createMessageReader(onMessage, onError) {
|
|
31
|
+
let buffer = Buffer.alloc(0);
|
|
32
|
+
return (data) => {
|
|
33
|
+
buffer = Buffer.concat([buffer, data]);
|
|
34
|
+
while (buffer.length >= 4) {
|
|
35
|
+
const length = buffer.readUInt32BE(0);
|
|
36
|
+
if (length > MAX_FRAME_SIZE_BYTES) {
|
|
37
|
+
onError(new Error(`Intercom frame too large (${length} bytes; max ${MAX_FRAME_SIZE_BYTES})`));
|
|
38
|
+
buffer = Buffer.alloc(0);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (buffer.length < 4 + length) {
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
const payload = buffer.subarray(4, 4 + length);
|
|
45
|
+
buffer = buffer.subarray(4 + length);
|
|
46
|
+
let msg;
|
|
47
|
+
try {
|
|
48
|
+
msg = JSON.parse(payload.toString("utf-8"));
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
52
|
+
onError(new Error(`Failed to parse intercom message: ${message}`, { cause: error }));
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
onMessage(msg);
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
60
|
+
onError(new Error(`Failed to handle intercom message: ${message}`, { cause: error }));
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { getPiAgentDir } from "../agent-dir.js";
|
|
6
|
+
const MAX_UNIX_SOCKET_PATH_BYTES = 100;
|
|
7
|
+
function agentDigest(agentDir, uid) {
|
|
8
|
+
return createHash("sha256").update(uid === undefined ? agentDir : `${uid}:${agentDir}`).digest("hex").slice(0, 16);
|
|
9
|
+
}
|
|
10
|
+
export function getLegacyBrokerSocketPath(agentDir = getPiAgentDir()) {
|
|
11
|
+
return join(tmpdir(), `pi-intercom-${agentDigest(agentDir)}.sock`);
|
|
12
|
+
}
|
|
13
|
+
export function isOwnedBrokerSocket(socketPath) {
|
|
14
|
+
try {
|
|
15
|
+
const stat = fs.lstatSync(socketPath);
|
|
16
|
+
const uid = process.getuid?.();
|
|
17
|
+
return stat.isSocket() && (uid === undefined || stat.uid === uid);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export function getBrokerSocketPath(agentDir = getPiAgentDir()) {
|
|
24
|
+
const uid = process.getuid?.();
|
|
25
|
+
const suffix = `pi-intercom-${agentDigest(agentDir, uid)}`;
|
|
26
|
+
const preferred = join(tmpdir(), suffix, "broker.sock");
|
|
27
|
+
return Buffer.byteLength(preferred) <= MAX_UNIX_SOCKET_PATH_BYTES
|
|
28
|
+
? preferred
|
|
29
|
+
: join("/tmp", suffix, "broker.sock");
|
|
30
|
+
}
|
|
31
|
+
export function prepareBrokerSocketPath() {
|
|
32
|
+
const socketPath = getBrokerSocketPath();
|
|
33
|
+
const brokerDir = dirname(socketPath);
|
|
34
|
+
fs.mkdirSync(brokerDir, { recursive: true, mode: 0o700 });
|
|
35
|
+
const stat = fs.lstatSync(brokerDir);
|
|
36
|
+
const uid = process.getuid?.();
|
|
37
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || (uid !== undefined && stat.uid !== uid)) {
|
|
38
|
+
throw new Error(`Unsafe intercom socket directory: ${brokerDir}`);
|
|
39
|
+
}
|
|
40
|
+
fs.chmodSync(brokerDir, 0o700);
|
|
41
|
+
return socketPath;
|
|
42
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { closeSync, openSync, readFileSync, readlinkSync } from "node:fs";
|
|
2
|
+
// Keep the first numeric line readable by older brokers/spawn guards.
|
|
3
|
+
// starttime is /proc/<pid>/stat field 22, in clock ticks; never round it to Number.
|
|
4
|
+
const IDENTITY = /^linux-v1 ([0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}) (pid:\[\d+\]) (time:\[\d+\]) (\d+)$/;
|
|
5
|
+
export function readLinuxProcess(pid) {
|
|
6
|
+
if (process.platform !== "linux")
|
|
7
|
+
return undefined;
|
|
8
|
+
let fd;
|
|
9
|
+
try {
|
|
10
|
+
// A procfs mounted from an ancestor PID namespace has different PID numbers
|
|
11
|
+
// from kill(2)/process.pid. Do not use that view to disprove broker identity.
|
|
12
|
+
const self = readFileSync("/proc/self/status", "utf8");
|
|
13
|
+
if (!new RegExp(`^NStgid:[ \\t]+${process.pid}$`, "m").test(self))
|
|
14
|
+
return undefined;
|
|
15
|
+
// Pin this proc directory so separate metadata reads cannot cross PID reuse.
|
|
16
|
+
fd = openSync(`/proc/${pid}`, "r");
|
|
17
|
+
const base = `/proc/self/fd/${fd}`;
|
|
18
|
+
const status = readFileSync(`${base}/status`, "utf8");
|
|
19
|
+
const tgid = Number(/^Tgid:[ \t]+(\d+)$/m.exec(status)?.[1]);
|
|
20
|
+
if (!Number.isSafeInteger(tgid) || tgid <= 0
|
|
21
|
+
|| Number(/^Pid:[ \t]+(\d+)$/m.exec(status)?.[1]) !== pid)
|
|
22
|
+
return undefined;
|
|
23
|
+
// Node writes process.pid (the thread-group leader), never a worker TID.
|
|
24
|
+
if (tgid !== pid)
|
|
25
|
+
return { tgid };
|
|
26
|
+
const stat = readFileSync(`${base}/stat`, "utf8");
|
|
27
|
+
// comm can contain spaces, newlines and ')'; fields after its final ')' are fixed.
|
|
28
|
+
if (!stat.startsWith(`${pid} (`))
|
|
29
|
+
return undefined;
|
|
30
|
+
const startTicks = stat.slice(stat.lastIndexOf(")") + 2).split(/\s+/)[19];
|
|
31
|
+
const bootId = readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim();
|
|
32
|
+
const pidNamespace = readlinkSync(`${base}/ns/pid`);
|
|
33
|
+
// Linux applies the *reader's* time-namespace offset to stat.starttime.
|
|
34
|
+
const timeNamespace = readlinkSync("/proc/self/ns/time");
|
|
35
|
+
const identity = `linux-v1 ${bootId} ${pidNamespace} ${timeNamespace} ${startTicks}`;
|
|
36
|
+
return IDENTITY.test(identity) ? { tgid, identity } : undefined;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
// Unavailable procfs, permissions or incomplete observations prove nothing.
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
finally {
|
|
43
|
+
if (fd !== undefined)
|
|
44
|
+
closeSync(fd);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
export function brokerPidRecord() {
|
|
48
|
+
const identity = readLinuxProcess(process.pid)?.identity;
|
|
49
|
+
return `${process.pid}\n${identity ? `${identity}\n` : ""}`;
|
|
50
|
+
}
|
|
51
|
+
export function isBrokerPidReused(pid, record) {
|
|
52
|
+
const current = readLinuxProcess(pid);
|
|
53
|
+
if (!current)
|
|
54
|
+
return false;
|
|
55
|
+
if (current.tgid !== pid)
|
|
56
|
+
return true;
|
|
57
|
+
const saved = IDENTITY.exec(record.slice(record.indexOf("\n") + 1).trim());
|
|
58
|
+
const actual = IDENTITY.exec(current.identity ?? "");
|
|
59
|
+
if (!saved || !actual)
|
|
60
|
+
return false;
|
|
61
|
+
const [, boot, namespace, clock, start] = saved;
|
|
62
|
+
const [, actualBoot, actualNamespace, actualClock, actualStart] = actual;
|
|
63
|
+
// A clock-namespace change alone is not evidence that the process changed.
|
|
64
|
+
return boot !== actualBoot || namespace !== actualNamespace
|
|
65
|
+
|| (clock === actualClock && start !== actualStart);
|
|
66
|
+
}
|