@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 +24 -0
- package/docs/EXTRACTION.md +83 -0
- package/index.js +57 -0
- package/lib/approvals/store.js +251 -0
- package/lib/async-lock.js +50 -0
- package/lib/canonical-json.js +63 -0
- package/lib/claude-bin.js +247 -0
- package/lib/compaction-warn.js +60 -0
- package/lib/context-usage.js +94 -0
- package/lib/error/classify.js +469 -0
- package/lib/process/attachment-base.js +41 -0
- package/lib/process/channels-bridge-protocol.js +200 -0
- package/lib/process/channels-bridge-server.js +281 -0
- package/lib/process/channels-bridge.mjs +482 -0
- package/lib/process/cli-process.js +4135 -0
- package/lib/process/factory.js +236 -0
- package/lib/process/hook-append.js +72 -0
- package/lib/process/hook-event-tail.js +163 -0
- package/lib/process/hook-settings.js +182 -0
- package/lib/process/process-manager.js +643 -0
- package/lib/process/process.js +216 -0
- package/lib/process/sdk-process.js +891 -0
- package/lib/process-guard.js +297 -0
- package/lib/questions/store.js +107 -0
- package/lib/tmux/log-tail.js +339 -0
- package/lib/tmux/orphan-sweep.js +80 -0
- package/lib/tmux/poll-scheduler.js +111 -0
- package/lib/tmux/startup-gate.js +251 -0
- package/lib/tmux/tmux-runner.js +415 -0
- package/package.json +46 -0
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 };
|