@shumkov/orchestra 0.2.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 ADDED
@@ -0,0 +1,24 @@
1
+ # @shumkov/orchestra
2
+
3
+ The transport-agnostic **Claude Code CLI session engine**: spawn interactive `claude`
4
+ sessions, inject messages via the MCP channels bridge, observe turns, and recover —
5
+ independent of which chat network the messages came from.
6
+
7
+ Extracted from [polygram](https://github.com/shumkov/polygram) after
8
+ [water](https://github.com/shumkov/water) proved the copy works end-to-end against a
9
+ real Claude. Shared by polygram (Telegram) and water (WhatsApp); each keeps its own
10
+ transport, persistence, gate, and delivery.
11
+
12
+ See [`docs/EXTRACTION.md`](docs/EXTRACTION.md) for the API and the extraction design.
13
+
14
+ ## Use
15
+
16
+ ```js
17
+ const { createProcessFactory, createTmuxRunner, ProcessManager, claudeBin } = require('@shumkov/orchestra');
18
+ // inject your transport delivery (toolDispatcher), surface hint (displayHint),
19
+ // and file cap (maxOutboundFileBytes); the engine stays chat-network-agnostic.
20
+ ```
21
+
22
+ ## Licence
23
+
24
+ MIT.
@@ -0,0 +1,83 @@
1
+ # orchestra — extraction design
2
+
3
+ `@shumkov/orchestra` is the transport-agnostic **Claude-Code-CLI session engine**,
4
+ extracted from polygram after water proved the copied code works
5
+ (water `docs/SHARED-LIB.md`, proof `scripts/spikes/prove-session-engine.mjs`). Both
6
+ polygram (Telegram) and water (WhatsApp) depend on it; the transport / persistence /
7
+ gate layers stay in each consumer.
8
+
9
+ ## What moves in (the engine)
10
+
11
+ From polygram@0.17.11 / water's proven copies:
12
+
13
+ - `process/process.js` — abstract Process base
14
+ - `process/process-manager.js` — weighted LRU pool, spawn/evict/pin, lazy respawn
15
+ - `process/cli-process.js` — the tmux'd claude CLI driver (**2 app-couplings removed**, below)
16
+ - `process/factory.js` — backend selection (cli; sdk is the consumer's escape hatch)
17
+ - `process/channels-bridge.mjs` / `channels-bridge-server.js` / `channels-bridge-protocol.js`
18
+ — the MCP channels injection protocol
19
+ - `process/hook-settings.js` / `hook-event-tail.js` / `hook-append.js` — turn observability
20
+ - `process/attachment-base.js` — NEW: `DEFAULT_ATTACHMENT_BASE` + path validators (was in
21
+ the consumer's tool-dispatcher; the staging-dir concept is engine-level)
22
+ - `tmux/*` — tmux lifecycle
23
+ - `claude-bin.js` — pin + vendor the claude binary
24
+ - `process-guard.js`, `async-lock.js`
25
+ - `context-usage.js`, `compaction-warn.js`, `canonical-json.js`
26
+ - `questions/store.js`, `approvals/store.js` — the ask/approval lifecycle stores
27
+
28
+ ## Consumer-provided (injected) — the app boundary
29
+
30
+ The engine is parameterized by the consumer at construction, so it knows nothing about
31
+ Telegram or WhatsApp:
32
+
33
+ - `toolDispatcher(call) => {ok, error?, message_id?}` — delivers reply/edit/react on the
34
+ consumer's transport (already injected in polygram/water today).
35
+ - `displayHint: string` — surface-rendering rules appended to the system prompt
36
+ (**new option**, replacing cli-process's hard `require('../delivery/display-hint')`).
37
+ - `maxOutboundFileBytes: number` — outbound file cap (**new option**, replacing
38
+ `require('../attachments').resolveFileCaps`).
39
+ - `claudeBin`, `botName`, `tmuxRunner`, `logger`, `db` (telemetry) — as today.
40
+
41
+ ## The 2 cli-process modifications (the only divergence from the verbatim copy)
42
+
43
+ 1. `WATER_DISPLAY_HINT` import → constructor option `displayHint` (default `''`).
44
+ 2. `resolveFileCaps()` import → constructor option `maxOutboundFileBytes`
45
+ (default 100 MB).
46
+
47
+ Both are recorded in cli-process's provenance header as the extraction-time API design.
48
+ Everything else is byte-identical to the proven copy.
49
+
50
+ ## Public API (`index.js`)
51
+
52
+ ```js
53
+ const {
54
+ ProcessManager, CliProcess, createProcessFactory, // the pool + driver
55
+ createTmuxRunner, orphanSweep, // tmux lifecycle
56
+ claudeBin, // { ensureVendoredClaudeBin, ... }
57
+ ChannelsBridgeServer, bridgeProtocol, // the channels bridge
58
+ hookSettings, hookEventTail, // observability
59
+ DEFAULT_ATTACHMENT_BASE, validateAttachmentPath, // staging-dir validation
60
+ } = require('@shumkov/orchestra');
61
+ ```
62
+
63
+ ## Consumer wiring after extraction
64
+
65
+ water/polygram replace their copied `lib/process/*`, `lib/tmux/*`, `lib/claude-bin.js`,
66
+ `lib/process-guard.js`, `lib/async-lock.js`, `lib/context-usage.js`,
67
+ `lib/compaction-warn.js`, `lib/canonical-json.js`, `lib/questions/store.js`,
68
+ `lib/approvals/store.js` with `require('@shumkov/orchestra')`, and pass `displayHint` +
69
+ `maxOutboundFileBytes` into the factory/CliProcess. Their transport, gate, dispatcher,
70
+ delivery, and ops layers are unchanged.
71
+
72
+ ## Rollout
73
+
74
+ 1. **water first** (this repo → depend on orchestra), re-run unit tests + the
75
+ real-claude E2E to prove parity. water is not yet in production, so this is low-risk.
76
+ 2. **polygram** is a **production revenue system** — its migration to orchestra gets its
77
+ OWN spec + review + Ivan's merge (SHARED-LIB.md). Not done implicitly here.
78
+
79
+ ## Provenance
80
+
81
+ Files retain their `// provenance: polygram@0.17.11 <path> (git 746bca6)` headers; the
82
+ extraction only relocates them and applies the 2 documented cli-process options. Keep
83
+ orchestra diff-clean against the pinned polygram so upstream fixes are cheap to pull.
package/index.js ADDED
@@ -0,0 +1,57 @@
1
+ // @shumkov/orchestra — the transport-agnostic Claude-Code-CLI session engine.
2
+ // Spawn, inject messages via the MCP channels bridge, observe turns, recover.
3
+ // Extracted from polygram after water proved the copy (see docs/EXTRACTION.md).
4
+ //
5
+ // The engine is parameterized by the consumer (toolDispatcher, displayHint,
6
+ // maxOutboundFileBytes, claudeBin, tmuxRunner) so it knows nothing about the chat
7
+ // transport. Consumers keep their own transport / persistence / gate / delivery.
8
+
9
+ 'use strict';
10
+
11
+ const { Process, UnsupportedOperationError } = require('./lib/process/process');
12
+ const { ProcessManager, CALLBACK_TO_EVENT } = require('./lib/process/process-manager');
13
+ const { CliProcess } = require('./lib/process/cli-process');
14
+ const sdkProcess = require('./lib/process/sdk-process');
15
+ const { SdkProcess, extractAssistantText, sumUsage, makeInputController } = sdkProcess;
16
+ const { createProcessFactory, pickBackend } = require('./lib/process/factory');
17
+ const { ChannelsBridgeServer } = require('./lib/process/channels-bridge-server');
18
+ const bridgeProtocol = require('./lib/process/channels-bridge-protocol');
19
+ const hookSettings = require('./lib/process/hook-settings');
20
+ const hookEventTail = require('./lib/process/hook-event-tail');
21
+ const startupGate = require('./lib/tmux/startup-gate');
22
+ const attachmentBase = require('./lib/process/attachment-base');
23
+
24
+ const { createTmuxRunner } = require('./lib/tmux/tmux-runner');
25
+ const orphanSweep = require('./lib/tmux/orphan-sweep');
26
+ const logTail = require('./lib/tmux/log-tail');
27
+ const pollScheduler = require('./lib/tmux/poll-scheduler');
28
+
29
+ const claudeBin = require('./lib/claude-bin');
30
+ const processGuard = require('./lib/process-guard');
31
+ const { createAsyncLock } = require('./lib/async-lock');
32
+ const contextUsage = require('./lib/context-usage');
33
+ const compactionWarn = require('./lib/compaction-warn');
34
+ const canonicalJson = require('./lib/canonical-json');
35
+ const questionsStore = require('./lib/questions/store');
36
+ const approvalsStore = require('./lib/approvals/store');
37
+
38
+ module.exports = {
39
+ // pool + driver
40
+ Process, UnsupportedOperationError, ProcessManager, CALLBACK_TO_EVENT, CliProcess, SdkProcess, extractAssistantText, sumUsage, makeInputController, createProcessFactory, pickBackend,
41
+ // channels bridge (MCP injection protocol)
42
+ ChannelsBridgeServer, bridgeProtocol,
43
+ // tmux lifecycle
44
+ createTmuxRunner, orphanSweep, pollScheduler, logTail, startupGate,
45
+ // claude binary pin+vendor
46
+ claudeBin,
47
+ // observability
48
+ hookSettings, hookEventTail,
49
+ // process safety
50
+ processGuard, createAsyncLock,
51
+ // claude context management
52
+ contextUsage, compactionWarn,
53
+ // ask / approval lifecycle stores
54
+ questionsStore, approvalsStore, canonicalJson,
55
+ // attachment staging-dir validation
56
+ attachmentBase,
57
+ };
@@ -0,0 +1,251 @@
1
+ // provenance: polygram@0.17.11 lib/approvals/store.js (git 746bca6) — verbatim: env prefix WATER_, bridge name water-bridge, vendor path (SHARED-LIB.md).
2
+ /**
3
+ * Approvals - inline keyboard gating for destructive tool calls.
4
+ *
5
+ * Claude Code fires a PreToolUse hook. The hook RPCs to the polygram daemon.
6
+ * The daemon inserts a pending row, posts [Approve]/[Deny] to the admin
7
+ * chat, and blocks on the operator's click (or a timeout).
8
+ *
9
+ * Persistence: `pending_approvals` row captures the whole lifecycle so we
10
+ * keep an audit trail even if polygram restarts. Rows never get deleted;
11
+ * 'pending' rows at boot are swept into 'timeout'.
12
+ */
13
+
14
+ const crypto = require('crypto');
15
+ const { canonicalizeToolInput } = require('../canonical-json');
16
+
17
+ const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
18
+ // 16 random bytes → 22 base64url chars ≈ 128 bits of entropy. Prevents
19
+ // brute-force guessing of approval callback tokens by anyone in the admin
20
+ // chat (old 6-char value was ~36 bits, within chat-storm reach).
21
+ const TOKEN_BYTES = 16;
22
+
23
+ function digestInput(input) {
24
+ // Canonicalise object inputs so key-order doesn't change the digest.
25
+ // Pre-fix `JSON.stringify({a:1,b:2})` and `JSON.stringify({b:2,a:1})`
26
+ // produced different hashes — the dedup contract assumed logical
27
+ // equivalence but the impl was order-sensitive, so an SDK that
28
+ // re-serialised the input between turns would dedup-miss.
29
+ const json = typeof input === 'string'
30
+ ? input
31
+ : JSON.stringify(canonicalizeToolInput(input));
32
+ return crypto.createHash('sha256').update(json).digest('hex').slice(0, 16);
33
+ }
34
+
35
+ function newToken() {
36
+ return crypto.randomBytes(TOKEN_BYTES).toString('base64url');
37
+ }
38
+
39
+ /**
40
+ * Constant-time compare. Different lengths → false without timing leak
41
+ * (timingSafeEqual itself throws on length mismatch).
42
+ */
43
+ function tokensEqual(a, b) {
44
+ if (typeof a !== 'string' || typeof b !== 'string') return false;
45
+ const ab = Buffer.from(a);
46
+ const bb = Buffer.from(b);
47
+ if (ab.length !== bb.length) return false;
48
+ return crypto.timingSafeEqual(ab, bb);
49
+ }
50
+
51
+ /**
52
+ * Translate a Claude-Code-style permission pattern into a RegExp.
53
+ * Supported forms:
54
+ * "Bash" - any Bash tool call
55
+ * "Bash(rm *)" - Bash whose first-argument string matches glob
56
+ * "mcp__shopify__order_cancel" - exact MCP tool name
57
+ * "mcp__*__invoice_create" - glob on segment
58
+ * "WebFetch" - any WebFetch
59
+ * `*` matches anything non-greedily within a segment, including spaces.
60
+ */
61
+ function patternToRegex(pattern) {
62
+ const trimmed = String(pattern).trim();
63
+ const parenIdx = trimmed.indexOf('(');
64
+ const toolPart = parenIdx === -1 ? trimmed : trimmed.slice(0, parenIdx);
65
+ const argPart = parenIdx === -1
66
+ ? null
67
+ : trimmed.slice(parenIdx + 1, trimmed.lastIndexOf(')'));
68
+
69
+ const escape = (s) => s.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
70
+ const globToRe = (s) => escape(s).replace(/\*/g, '.*');
71
+
72
+ const toolRe = new RegExp(`^${globToRe(toolPart)}$`);
73
+ const argRe = argPart == null ? null : new RegExp(`^${globToRe(argPart)}$`);
74
+ return { toolRe, argRe, raw: trimmed };
75
+ }
76
+
77
+ /**
78
+ * Check whether a tool call matches any of the configured patterns.
79
+ * `toolInput` is the original params object. We match on:
80
+ * - Bash: first positional command (`command` param, space-split first token
81
+ * or whole string for arg globs that include spaces)
82
+ * - WebFetch: `url`
83
+ * - others: the JSON stringification (coarse but safe default)
84
+ */
85
+ function matchesAnyPattern(toolName, toolInput, patterns = []) {
86
+ const input = toolInput || {};
87
+ for (const raw of patterns) {
88
+ const p = patternToRegex(raw);
89
+ if (!p.toolRe.test(toolName)) continue;
90
+ if (!p.argRe) return { matched: true, pattern: p.raw };
91
+ let candidate = '';
92
+ if (toolName === 'Bash') {
93
+ candidate = input.command || '';
94
+ } else if (toolName === 'WebFetch') {
95
+ candidate = input.url || '';
96
+ } else {
97
+ candidate = typeof input === 'string' ? input : JSON.stringify(input);
98
+ }
99
+ if (p.argRe.test(candidate)) return { matched: true, pattern: p.raw };
100
+ }
101
+ return { matched: false };
102
+ }
103
+
104
+ function createStore(rawDb, now = () => Date.now()) {
105
+ const insertStmt = rawDb.prepare(`
106
+ INSERT INTO pending_approvals (
107
+ bot_name, turn_id, tool_use_id, requester_chat_id, approver_chat_id,
108
+ tool_name, tool_input_json, tool_input_digest,
109
+ callback_token, requested_ts, timeout_ts
110
+ ) VALUES (
111
+ @bot_name, @turn_id, @tool_use_id, @requester_chat_id, @approver_chat_id,
112
+ @tool_name, @tool_input_json, @tool_input_digest,
113
+ @callback_token, @requested_ts, @timeout_ts
114
+ )
115
+ `);
116
+ // 0.9.0-cleanup commit 10: stronger dedup via tool_use_id when the
117
+ // SDK provides one. tool_use_id is the SDK's stable per-call ID;
118
+ // unlike the legacy (turn_id, tool_input_digest) tuple, it survives
119
+ // JSON-key reordering between retries within a turn. Migration 010
120
+ // added the column + partial index `idx_pending_approvals_tool_use_id`
121
+ // which had been unused since rc.6 because no insert path populated
122
+ // the column. issue() now does.
123
+ const findDedupByToolUseIdStmt = rawDb.prepare(`
124
+ SELECT * FROM pending_approvals
125
+ WHERE bot_name = ? AND tool_use_id = ? AND status = 'pending'
126
+ LIMIT 1
127
+ `);
128
+ const findDedupStmt = rawDb.prepare(`
129
+ SELECT * FROM pending_approvals
130
+ WHERE bot_name = ? AND turn_id IS ? AND tool_input_digest = ?
131
+ AND status = 'pending'
132
+ LIMIT 1
133
+ `);
134
+ const setApproverMsgStmt = rawDb.prepare(`
135
+ UPDATE pending_approvals SET approver_msg_id = ? WHERE id = ?
136
+ `);
137
+ const resolveStmt = rawDb.prepare(`
138
+ UPDATE pending_approvals
139
+ SET status = @status,
140
+ decided_ts = @decided_ts,
141
+ decided_by_user_id = @decided_by_user_id,
142
+ decided_by_user = @decided_by_user,
143
+ reason = @reason
144
+ WHERE id = @id AND status = 'pending'
145
+ `);
146
+ const getByIdStmt = rawDb.prepare(`SELECT * FROM pending_approvals WHERE id = ?`);
147
+ const listTimedOutStmt = rawDb.prepare(`
148
+ SELECT * FROM pending_approvals
149
+ WHERE status = 'pending' AND timeout_ts < ?
150
+ `);
151
+ const listPendingStmt = rawDb.prepare(`
152
+ SELECT * FROM pending_approvals
153
+ WHERE bot_name = ? AND status = 'pending'
154
+ ORDER BY requested_ts DESC
155
+ `);
156
+
157
+ return {
158
+ issue({
159
+ bot_name, turn_id = null, tool_use_id = null,
160
+ requester_chat_id, approver_chat_id,
161
+ tool_name, tool_input, timeoutMs = DEFAULT_TIMEOUT_MS,
162
+ }) {
163
+ if (!bot_name) throw new Error('bot_name required');
164
+ if (!requester_chat_id) throw new Error('requester_chat_id required');
165
+ if (!approver_chat_id) throw new Error('approver_chat_id required');
166
+ if (!tool_name) throw new Error('tool_name required');
167
+
168
+ const tool_input_json = typeof tool_input === 'string'
169
+ ? tool_input
170
+ : JSON.stringify(tool_input || {});
171
+ const tool_input_digest = digestInput(tool_input_json);
172
+ const requested_ts = now();
173
+ const timeout_ts = requested_ts + timeoutMs;
174
+ const callback_token = newToken();
175
+
176
+ // Dedup: wrap find + insert in a single BEGIN IMMEDIATE transaction so
177
+ // two concurrent hook calls (same turn, same input) can't both miss
178
+ // the existing row and insert two. SQLite's UPSERT would also work if
179
+ // we added a UNIQUE partial index in a migration; keeping this in
180
+ // application code avoids a schema bump.
181
+ //
182
+ // 0.9.0: prefer dedup by SDK's stable tool_use_id when available.
183
+ // The legacy (turn_id, tool_input_digest) tuple survives only when
184
+ // the SDK doesn't provide a tool_use_id (cron-driven sends, IPC
185
+ // callers from older Claude versions). Both code paths route
186
+ // through the same INSERT below; the only thing that varies is
187
+ // which existing row counts as a "match."
188
+ let row, reused = false;
189
+ const tx = rawDb.transaction(() => {
190
+ const existing = tool_use_id
191
+ ? findDedupByToolUseIdStmt.get(bot_name, tool_use_id)
192
+ : findDedupStmt.get(bot_name, turn_id, tool_input_digest);
193
+ if (existing) { row = existing; reused = true; return; }
194
+ const res = insertStmt.run({
195
+ bot_name,
196
+ turn_id,
197
+ tool_use_id,
198
+ requester_chat_id: String(requester_chat_id),
199
+ approver_chat_id: String(approver_chat_id),
200
+ tool_name,
201
+ tool_input_json,
202
+ tool_input_digest,
203
+ callback_token,
204
+ requested_ts,
205
+ timeout_ts,
206
+ });
207
+ row = getByIdStmt.get(res.lastInsertRowid);
208
+ });
209
+ tx.immediate(); // BEGIN IMMEDIATE → write lock before the SELECT
210
+ if (reused) return { ...row, reused: true };
211
+ return row;
212
+ },
213
+
214
+ setApproverMsgId(id, msg_id) {
215
+ return setApproverMsgStmt.run(msg_id, id).changes;
216
+ },
217
+
218
+ resolve({ id, status, decided_by_user_id = null, decided_by_user = null, reason = null }) {
219
+ if (!['approved', 'denied', 'timeout', 'cancelled'].includes(status)) {
220
+ throw new Error(`bad status: ${status}`);
221
+ }
222
+ const res = resolveStmt.run({
223
+ id,
224
+ status,
225
+ decided_ts: now(),
226
+ decided_by_user_id,
227
+ decided_by_user,
228
+ reason,
229
+ });
230
+ return res.changes;
231
+ },
232
+
233
+ getById(id) { return getByIdStmt.get(id); },
234
+
235
+ listPending(bot_name) { return listPendingStmt.all(bot_name); },
236
+
237
+ sweepTimedOut() {
238
+ return listTimedOutStmt.all(now());
239
+ },
240
+ };
241
+ }
242
+
243
+ module.exports = {
244
+ createStore,
245
+ digestInput,
246
+ newToken,
247
+ tokensEqual,
248
+ patternToRegex,
249
+ matchesAnyPattern,
250
+ DEFAULT_TIMEOUT_MS,
251
+ };
@@ -0,0 +1,50 @@
1
+ // provenance: polygram@0.17.11 lib/async-lock.js (git 746bca6) — verbatim: env prefix WATER_, bridge name water-bridge, vendor path (SHARED-LIB.md).
2
+ /**
3
+ * Per-key chain lock. Each acquire() returns a release function; the next
4
+ * acquire() awaits the previous one's release.
5
+ *
6
+ * Used by polygram to serialise stdin writes per session. Pre-work
7
+ * (attachment download, voice transcription, prompt formatting) runs
8
+ * concurrently; only the stdin write itself is serialised so Claude
9
+ * reads messages in arrival order and replies come out in the same
10
+ * order.
11
+ *
12
+ * Deliberately minimal — no timeouts, no cancellation, no fairness
13
+ * guarantees beyond FIFO. Callers are expected to ALWAYS call release,
14
+ * even on error paths, or the lock leaks (blocks all future acquires
15
+ * for that key forever).
16
+ */
17
+
18
+ function createAsyncLock() {
19
+ const chains = new Map(); // key → Promise of last release
20
+
21
+ return {
22
+ async acquire(key) {
23
+ const prev = chains.get(key) || Promise.resolve();
24
+ let release;
25
+ const next = new Promise((resolve) => { release = resolve; });
26
+ // Save the chain-entry promise so the cleanup branch can compare
27
+ // against the SAME reference. Pre-fix this re-evaluated
28
+ // `prev.then(() => next)` (a fresh promise each call), so the
29
+ // === compare was always false and the Map leaked one entry per
30
+ // unique key.
31
+ const myEntry = prev.then(() => next);
32
+ chains.set(key, myEntry);
33
+ await prev;
34
+ // Return a wrapper that also clears the chain entry when this is
35
+ // the last holder — avoids the Map growing unbounded across the
36
+ // lifetime of the process. Idempotent: a double-release call is
37
+ // harmless (release() is a Promise resolver; calling resolve
38
+ // twice is a no-op).
39
+ return () => {
40
+ if (chains.get(key) === myEntry) {
41
+ chains.delete(key);
42
+ }
43
+ release();
44
+ };
45
+ },
46
+ get size() { return chains.size; },
47
+ };
48
+ }
49
+
50
+ module.exports = { createAsyncLock };
@@ -0,0 +1,63 @@
1
+ // provenance: polygram@0.17.11 lib/canonical-json.js (git 746bca6) — verbatim: env prefix WATER_, bridge name water-bridge, vendor path (SHARED-LIB.md).
2
+ /**
3
+ * Canonical-JSON stringification for chat_tool_decisions dedup.
4
+ *
5
+ * Used by polygram.js's canUseTool flow (rc.6 Phase 2 step 6):
6
+ * - lookup key for `chat_tool_decisions match_type='exact'`
7
+ * - input_pattern stored on "Always allow / Always deny" clicks
8
+ *
9
+ * Why canonical: Claude can reorder JSON keys between retries of
10
+ * the same tool call (different SDK versions, different temperature
11
+ * sampling). Without canonicalisation, the dedup digest would
12
+ * differ for semantically-identical calls and the user would see
13
+ * the same approval card twice (v4 plan §6.6 ship-breaker M8
14
+ * mitigation).
15
+ *
16
+ * Properties:
17
+ * - Keys sorted alphabetically at every nesting level
18
+ * - Arrays preserve order (only object keys are sorted)
19
+ * - No whitespace in output
20
+ * - null / undefined / primitive inputs round-trip via JSON.stringify
21
+ *
22
+ * NOT a full JSON canonicalisation spec (RFC 8785 / I-D
23
+ * cyberphone-json-canonicalization-scheme); we don't normalise
24
+ * number representations (1.0 vs 1, exponents) or string escapes.
25
+ * Sufficient for SDK-shaped tool inputs which are well-formed JSON
26
+ * objects with string keys.
27
+ */
28
+
29
+ 'use strict';
30
+
31
+ function canonicalizeToolInput(input) {
32
+ if (input == null || typeof input !== 'object') {
33
+ return JSON.stringify(input);
34
+ }
35
+ // Track in-flight (currently-on-stack) nodes to detect circular
36
+ // references. WeakSet membership marks "we are still inside this
37
+ // node"; we drop the entry after finishing recursion so DAG
38
+ // shapes (shared subtrees that aren't cycles) round-trip fine.
39
+ // Pre-fix sortRec recursed forever on `{a: 1, self: <self>}`
40
+ // and crashed the daemon — DoS path if any tool ever produces
41
+ // self-referencing input. Now throws a clean TypeError matching
42
+ // JSON.stringify's own "Converting circular structure to JSON".
43
+ const onStack = new WeakSet();
44
+ const sortRec = (v) => {
45
+ if (Array.isArray(v)) {
46
+ if (onStack.has(v)) throw new TypeError('Converting circular structure to JSON');
47
+ onStack.add(v);
48
+ const result = v.map(sortRec);
49
+ onStack.delete(v);
50
+ return result;
51
+ }
52
+ if (v == null || typeof v !== 'object') return v;
53
+ if (onStack.has(v)) throw new TypeError('Converting circular structure to JSON');
54
+ onStack.add(v);
55
+ const out = {};
56
+ for (const k of Object.keys(v).sort()) out[k] = sortRec(v[k]);
57
+ onStack.delete(v);
58
+ return out;
59
+ };
60
+ return JSON.stringify(sortRec(input));
61
+ }
62
+
63
+ module.exports = { canonicalizeToolInput };