@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.
@@ -0,0 +1,236 @@
1
+ // provenance: polygram@0.17.11 lib/process/factory.js (git 746bca6) — adapt: env prefix WATER_, bridge name water-bridge, vendor path (SHARED-LIB.md).
2
+ /**
3
+ * Process factory — chooses + constructs the right Process subclass
4
+ * per session, based on chat / topic / bot config.
5
+ *
6
+ * Backends (post-0.12):
7
+ * - 'sdk' → SdkProcess (long-lived SDK Query, per-token API billing)
8
+ * - 'cli' → CliProcess (claude TUI in tmux + Channels MCP bridge + hooks ndjson,
9
+ * subscription billing; default production path)
10
+ *
11
+ * Config aliases (back-compat for existing chat configs):
12
+ * - 'channels' → 'cli' (0.11.0-channels driver folded into CliProcess)
13
+ * - 'tmux' → 'cli' (0.10.0 tmux backend deleted in 0.12 Phase 4;
14
+ * existing configs keep working via this alias)
15
+ *
16
+ * Backend selection precedence:
17
+ * topicConfig.pm > chatConfig.pm > config.bot.pm > 'sdk'
18
+ *
19
+ * Per-backend wiring requirements:
20
+ * cli — tmuxRunner + botName + toolDispatcher + claudeBin
21
+ *
22
+ * If a backend is configured but its wiring is missing, we log a loud
23
+ * warning and fall back to SDK so the daemon stays up (R2-F7 — never
24
+ * silent-fail config).
25
+ *
26
+ * @see docs/0.10.0-process-manager-abstraction-plan.md §6.4
27
+ * @see docs/0.11.0-channels-driver-plan.md
28
+ * @see docs/0.12.0-cli-driver-plan.md
29
+ */
30
+
31
+ 'use strict';
32
+
33
+ // orchestra ships BOTH Claude-session backends. The SDK backend lives in
34
+ // ./sdk-process; @anthropic-ai/claude-agent-sdk is an OPTIONAL dependency that
35
+ // sdk-process lazy-requires only when a pm:'sdk' session actually starts — so
36
+ // requiring this factory (or the whole package) never loads the SDK, and cli-only
37
+ // consumers (water) can install with --omit=optional. A consumer may still inject a
38
+ // custom SdkProcess via createProcessFactory({ SdkProcess }); the default is ours.
39
+ const { SdkProcess: DefaultSdkProcess } = require('./sdk-process');
40
+ const { CliProcess } = require('./cli-process');
41
+
42
+ // Aliases — config values that map to a different canonical backend.
43
+ // Each alias emits ONE deprecation warn per-bot-process lifetime
44
+ // (tracked in `_warnedAliases` below). Avoids per-spawn log flooding
45
+ // on multi-chat deploys.
46
+ const ALIASES = new Map([
47
+ ['channels', 'cli'],
48
+ ['tmux', 'cli'], // 0.12 Phase 4: tmux backend deleted; existing configs alias to cli
49
+ ]);
50
+
51
+ const _warnedAliases = new Set();
52
+ function _maybeWarnAlias(alias, canonical, logger) {
53
+ if (_warnedAliases.has(alias)) return;
54
+ _warnedAliases.add(alias);
55
+ logger.warn?.(
56
+ `[factory] pm:'${alias}' is deprecated and now aliases to pm:'${canonical}'. ` +
57
+ `Update chat config to silence this warning. ` +
58
+ `See docs/0.12.0-cli-driver-plan.md §"Open questions resolved here" / Q5.`,
59
+ );
60
+ }
61
+
62
+ // 0.12 Phase 4.5.3 (R12 mitigation): chats migrating from pm:'tmux' (the
63
+ // 0.10 backend with implicit pane-scrape approval gating) to pm:'cli'
64
+ // silently lose approvals unless the operator explicitly sets
65
+ // permissionMode. Warn ONCE per (botName, chatId, threadId) tuple so
66
+ // the migration trade-off is deliberate, not a surprise regression.
67
+ // Fires at pickBackend time (factory.js is the choke point for backend
68
+ // resolution).
69
+ const _warnedR12Chats = new Set();
70
+ function _maybeWarnR12Migration({ rawPm, canonical, chatId, threadId, chatCfg, topicCfg, logger }) {
71
+ if (rawPm !== 'tmux' || canonical !== 'cli') return;
72
+ // Resolved permissionMode honors the same precedence cli-process.js
73
+ // uses: topic > chat > opt-default. Check both topic and chat config
74
+ // here; we don't know opt-default (set inside CliProcess.start), but
75
+ // its default is 'bypassPermissions' so absence = bypass.
76
+ const explicitMode = topicCfg?.permissionMode || chatCfg?.permissionMode;
77
+ if (explicitMode && explicitMode !== 'bypassPermissions') return;
78
+ const key = `${chatId}:${threadId ?? ''}`;
79
+ if (_warnedR12Chats.has(key)) return;
80
+ _warnedR12Chats.add(key);
81
+ logger.warn?.(
82
+ `[factory] R12 migration warning: chat=${chatId}${threadId ? ` thread=${threadId}` : ''} ` +
83
+ `was configured as pm:'tmux' and now aliases to pm:'cli'. The 0.10 tmux backend gated ` +
84
+ `Bash/Edit/etc tool calls via pane-scrape approval cards; the 0.12 CliProcess defaults to ` +
85
+ `permissionMode:'bypassPermissions' (no approvals). To preserve approval gating on this ` +
86
+ `chat, set permissionMode: 'default' (or 'acceptEdits' / 'plan') in chat or topic config. ` +
87
+ `See docs/0.12.0-cli-driver-plan.md §"Security posture" + R12.`,
88
+ );
89
+ }
90
+
91
+ /**
92
+ * @param {object} opts
93
+ * @param {object} opts.config — runtime config object
94
+ * @param {Function} opts.spawnFn — buildSdkOptions (SDK backend only)
95
+ * @param {object} [opts.db] — for SdkProcess._logEvent + clearSessionId
96
+ * @param {object} [opts.logger]
97
+ * @param {number} [opts.queueCap]
98
+ * @param {number} [opts.queryCloseTimeoutMs]
99
+ * @param {object} [opts.tmuxRunner] — required when ANY chat routes to 'cli'
100
+ * @param {string} [opts.botName] — required when ANY chat routes to 'cli'
101
+ * @param {Function} [opts.toolDispatcher] — required when ANY chat routes to 'cli'.
102
+ * async ({sessionKey, chatId, threadId, toolName, text, files}) => {ok, error?}.
103
+ * Called when Claude's reply (or react/edit_message) tool fires inside a
104
+ * CliProcess. Polygram supplies the actual Telegram-send wiring.
105
+ * @param {string} [opts.channelsClaudeBin] — absolute path to pinned claude binary;
106
+ * required when ANY chat routes to 'cli'. (Name kept for back-compat with
107
+ * existing wiring; can be renamed to `claudeBin` in a future refactor.)
108
+ * @returns {Function} processFactory(sessionKey, ctx) → Process
109
+ */
110
+ function createProcessFactory({
111
+ config,
112
+ spawnFn,
113
+ db = null,
114
+ logger = console,
115
+ queueCap,
116
+ queryCloseTimeoutMs,
117
+ tmuxRunner = null,
118
+ botName = null,
119
+ toolDispatcher = null,
120
+ channelsClaudeBin = null,
121
+ displayHint = '', // orchestra: consumer surface-rendering hint
122
+ maxOutboundFileBytes = 100 * 1024 * 1024, // orchestra: consumer outbound file cap
123
+ // orchestra identity — forwarded to every CliProcess so the engine is neutral.
124
+ sessionPrefix = 'orchestra',
125
+ bridgeServerName = 'orchestra-bridge',
126
+ appDataDir,
127
+ attachmentBase,
128
+ productName = 'orchestra',
129
+ surfaceName = 'the chat',
130
+ pmDefault = 'cli', // default backend when a chat has no pm
131
+ SdkProcess = DefaultSdkProcess, // orchestra ships this; consumer may override
132
+ } = {}) {
133
+ // spawnFn (buildSdkOptions) is only used by the deferred SDK backend; water v1 is
134
+ // cli-only, so it is optional here (a missing SDK wiring surfaces as the SdkProcess
135
+ // constructor's clear "not available" error, not a factory-construction failure).
136
+
137
+ return function processFactory(sessionKey, ctx) {
138
+ const chatId = ctx?.chatId ?? null;
139
+ const threadId = ctx?.threadId ?? null;
140
+ const label = ctx?.label || sessionKey;
141
+
142
+ const choice = pickBackend({ config, chatId, threadId, logger, pmDefault });
143
+
144
+ if (choice === 'cli') {
145
+ const missing = [];
146
+ if (!tmuxRunner) missing.push('tmuxRunner');
147
+ if (!botName) missing.push('botName');
148
+ if (typeof toolDispatcher !== 'function') missing.push('toolDispatcher');
149
+ if (!channelsClaudeBin) missing.push('channelsClaudeBin');
150
+ if (missing.length) {
151
+ logger.warn?.(
152
+ `[${label}] config requests pm:'cli' but ${missing.join(', ')} not wired; ` +
153
+ `falling back to SdkProcess. Pass these to createProcessFactory.`,
154
+ );
155
+ } else {
156
+ return new CliProcess({
157
+ sessionKey, chatId, threadId, label,
158
+ tmuxRunner,
159
+ botName,
160
+ claudeBin: channelsClaudeBin,
161
+ toolDispatcher,
162
+ displayHint,
163
+ maxOutboundFileBytes,
164
+ sessionPrefix, bridgeServerName, appDataDir, attachmentBase, productName, surfaceName,
165
+ logger,
166
+ db, // Parity P1: telemetry parity with sdk/tmux
167
+ });
168
+ }
169
+ }
170
+
171
+ return new SdkProcess({
172
+ sessionKey, chatId, threadId, label,
173
+ spawnFn,
174
+ db,
175
+ logger,
176
+ queueCap,
177
+ queryCloseTimeoutMs,
178
+ });
179
+ };
180
+ }
181
+
182
+ /**
183
+ * Per-chat / per-topic backend choice.
184
+ *
185
+ * Honors topicConfig.pm / chatConfig.pm / config.bot.pm. Resolves aliases
186
+ * (e.g., 'channels' → 'cli') and emits a once-per-process deprecation warn.
187
+ *
188
+ * Review AC3: unknown `pm` values (typos like `'channel'` singular) used to
189
+ * silently fall through to 'sdk' with no warning — violates R2-F7 "never
190
+ * silent-fail config". Now logs a warn and falls back to the default.
191
+ */
192
+ const CANONICAL_BACKENDS = new Set(['sdk', 'cli']);
193
+
194
+ function pickBackend({ config, chatId, threadId, logger = console, pmDefault = 'cli' } = {}) {
195
+ if (!chatId) return pmDefault;
196
+ const chatCfg = config?.chats?.[chatId];
197
+ const topicCfg = threadId && chatCfg?.topics?.[threadId];
198
+ const raw = topicCfg?.pm || chatCfg?.pm || config?.bot?.pm || pmDefault;
199
+
200
+ // Resolve alias (e.g., 'channels' → 'cli'). Warns once per process per
201
+ // alias kind, NOT per spawn — multi-chat deploys shouldn't flood logs.
202
+ let picked = raw;
203
+ if (ALIASES.has(raw)) {
204
+ picked = ALIASES.get(raw);
205
+ _maybeWarnAlias(raw, picked, logger);
206
+ // R12 — per-chat migration warning when pm:'tmux' aliases to 'cli'
207
+ // without an explicit non-bypass permissionMode override.
208
+ _maybeWarnR12Migration({
209
+ rawPm: raw,
210
+ canonical: picked,
211
+ chatId, threadId,
212
+ chatCfg, topicCfg,
213
+ logger,
214
+ });
215
+ }
216
+
217
+ if (!CANONICAL_BACKENDS.has(picked)) {
218
+ logger.warn?.(
219
+ `[factory] unknown pm value '${raw}' for chat=${chatId} thread=${threadId ?? ''}; ` +
220
+ `falling back to 'sdk'. Valid: ${[...CANONICAL_BACKENDS].join(', ')} ` +
221
+ `(aliases: ${[...ALIASES.keys()].join(', ')}).`,
222
+ );
223
+ return 'sdk';
224
+ }
225
+ return picked;
226
+ }
227
+
228
+ // _resetAliasWarnings — test-only helper. Resets the once-per-process warn
229
+ // tracking so unit tests can verify alias-warn + R12-migration-warn behavior
230
+ // across multiple pickBackend() invocations within a single test run.
231
+ function _resetAliasWarnings() {
232
+ _warnedAliases.clear();
233
+ _warnedR12Chats.clear();
234
+ }
235
+
236
+ module.exports = { createProcessFactory, pickBackend, _resetAliasWarnings, ALIASES, CANONICAL_BACKENDS };
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env node
2
+ // provenance: polygram@0.17.11 lib/process/polygram-hook-append.js (git 746bca6) — verbatim*: env prefix WATER_, bridge name water-bridge, vendor path (SHARED-LIB.md).
3
+ /**
4
+ * water-hook-append — claude-CLI hook subprocess that appends one
5
+ * compacted JSON line to a per-session ndjson file.
6
+ *
7
+ * Invoked by claude as: `node <abs path>/water-hook-append.js <ndjson abs path>`
8
+ * Stdin: one JSON document (the hook payload). Stdout: nothing.
9
+ *
10
+ * Used by the tmux backend H1 (hook-based turn observability). See
11
+ * docs/0.10.0-tmux-hook-observability.md.
12
+ *
13
+ * Behaviour:
14
+ * - Reads stdin to EOF, parses as JSON.
15
+ * - Stamps `polygram_received_at_ms` (Date.now) so we can measure
16
+ * Pre↔Post latency from polygram's wall clock independent of the
17
+ * hook's own `duration_ms`.
18
+ * - Writes ONE line (JSON.stringify + '\n') with a single fs.writeSync
19
+ * on a fd opened O_APPEND. On macOS, O_APPEND atomicity is NOT
20
+ * guaranteed above PIPE_BUF (~4 KB); H1 is observe-only and records
21
+ * parse failures so we can measure interleave during the soak.
22
+ * - Bad JSON → emits a wrapped record with the raw body and a marker
23
+ * so the tail's parser can surface it (never silent-drop).
24
+ * - Failures (missing argv, open/write error) exit non-zero but never
25
+ * throw out of `claude` (claude already runs us with stdout/stderr
26
+ * captured and a timeout); the worst case is a single missing line.
27
+ *
28
+ * Determinism: no shell, no external deps. Resolved at fixed absolute
29
+ * path so the `command:` string in the settings JSON is free of
30
+ * metachars and `~`-expansion.
31
+ */
32
+
33
+ 'use strict';
34
+
35
+ const fs = require('fs');
36
+
37
+ const outPath = process.argv[2];
38
+ if (!outPath) {
39
+ process.stderr.write('water-hook-append: missing ndjson path (argv[2])\n');
40
+ process.exit(2);
41
+ }
42
+
43
+ let buf = '';
44
+ process.stdin.setEncoding('utf8');
45
+ process.stdin.on('data', (chunk) => { buf += chunk; });
46
+ process.stdin.on('end', () => {
47
+ let line;
48
+ try {
49
+ const obj = JSON.parse(buf);
50
+ obj.polygram_received_at_ms = Date.now();
51
+ line = JSON.stringify(obj);
52
+ } catch (err) {
53
+ // Preserve the raw body so the tail can flag it; never silent-drop.
54
+ line = JSON.stringify({
55
+ polygram_parse_error: err.message,
56
+ polygram_received_at_ms: Date.now(),
57
+ raw: buf.length > 64 * 1024 ? buf.slice(0, 64 * 1024) + '…[truncated]' : buf,
58
+ });
59
+ }
60
+ let fd;
61
+ try {
62
+ fd = fs.openSync(outPath, 'a');
63
+ fs.writeSync(fd, line + '\n');
64
+ } catch (err) {
65
+ process.stderr.write(`water-hook-append: write failed: ${err.message}\n`);
66
+ process.exitCode = 3;
67
+ } finally {
68
+ if (fd != null) {
69
+ try { fs.closeSync(fd); } catch { /* swallow */ }
70
+ }
71
+ }
72
+ });
@@ -0,0 +1,163 @@
1
+ // provenance: polygram@0.17.11 lib/process/hook-event-tail.js (git 746bca6) — verbatim: env prefix WATER_, bridge name water-bridge, vendor path (SHARED-LIB.md).
2
+ /**
3
+ * hook-event-tail — typed-event parser around the per-session hook
4
+ * ndjson that `water-hook-append.js` writes for the H1 hook-based
5
+ * turn observability (docs/0.10.0-tmux-hook-observability.md).
6
+ *
7
+ * Mirrors the JSONL stream's `pipeToParser(tail)` shape so TmuxProcess
8
+ * wires it the same way `_armSessionLogTail` wires the JSONL tail.
9
+ *
10
+ * Per-line behaviour:
11
+ * - Parse JSON. If the line is missing, malformed, or the helper
12
+ * wrapped it with `polygram_parse_error`, emit a `parse-error`
13
+ * event (observability — H1 soak measures how often this fires).
14
+ * - Discriminate on `hook_event_name`. Known events become typed
15
+ * HookEvent records with normalized fields; unknown event names
16
+ * pass through as `unknown` with the raw object attached so we
17
+ * can investigate without re-deploying.
18
+ * - Empty lines are ignored (atomic-append interleave between two
19
+ * helper invocations can produce them in theory — H1 measures
20
+ * whether it happens in practice on macOS).
21
+ *
22
+ * Normalized HookEvent shape (the fields downstream code may rely on
23
+ * once H1's observer-only soak proves the stream — H2+ phases consume
24
+ * these):
25
+ *
26
+ * {
27
+ * type: 'PreToolUse' | 'PostToolUse' | 'UserPromptSubmit'
28
+ * | 'Stop' | 'SubagentStop' | 'Notification' | 'unknown'
29
+ * | 'parse-error',
30
+ * sessionId, transcriptPath, cwd, permissionMode, // common
31
+ * toolName, toolUseId, toolInput, toolResponse, durationMs, // tool events
32
+ * agentId, agentType, // subagent-inner
33
+ * agentTranscriptPath, // SubagentStop
34
+ * prompt, // UserPromptSubmit
35
+ * stopHookActive, lastAssistantMessage, // Stop
36
+ * receivedAtMs, raw, // always
37
+ * }
38
+ *
39
+ * Per the 2.1.142 spike, `parent_tool_use_id` is NOT a field, and
40
+ * `SubagentStart` does not fire (for general-purpose subagents) —
41
+ * neither is in the typed shape.
42
+ */
43
+
44
+ 'use strict';
45
+
46
+ const { LogTail } = require('../tmux/log-tail');
47
+
48
+ const KNOWN_EVENT_NAMES = new Set([
49
+ 'UserPromptSubmit',
50
+ 'PreToolUse',
51
+ 'PostToolUse',
52
+ 'SubagentStop',
53
+ 'Stop',
54
+ 'Notification',
55
+ // 0.12.0-rc.13: compaction lifecycle (carry `trigger` + custom_instructions).
56
+ 'PreCompact',
57
+ 'PostCompact',
58
+ ]);
59
+
60
+ /**
61
+ * Normalize one raw hook payload (already JSON.parsed) into the
62
+ * shape downstream code consumes. Unknown shapes pass through as
63
+ * `unknown` so a 2.1.143-style schema drift doesn't silently lose
64
+ * events.
65
+ */
66
+ function normalizeHookEvent(raw) {
67
+ if (raw && typeof raw === 'object' && raw.polygram_parse_error) {
68
+ return {
69
+ type: 'parse-error',
70
+ error: raw.polygram_parse_error,
71
+ receivedAtMs: raw.polygram_received_at_ms ?? null,
72
+ raw,
73
+ };
74
+ }
75
+ const name = raw && typeof raw === 'object' ? raw.hook_event_name : null;
76
+ const type = KNOWN_EVENT_NAMES.has(name) ? name : 'unknown';
77
+ return {
78
+ type,
79
+ sessionId: raw?.session_id ?? null,
80
+ transcriptPath: raw?.transcript_path ?? null,
81
+ cwd: raw?.cwd ?? null,
82
+ permissionMode: raw?.permission_mode ?? null,
83
+ toolName: raw?.tool_name ?? null,
84
+ toolUseId: raw?.tool_use_id ?? null,
85
+ toolInput: raw?.tool_input ?? null,
86
+ toolResponse: raw?.tool_response ?? null,
87
+ durationMs: raw?.duration_ms ?? null,
88
+ agentId: raw?.agent_id ?? null,
89
+ agentType: raw?.agent_type ?? null,
90
+ agentTranscriptPath: raw?.agent_transcript_path ?? null,
91
+ prompt: raw?.prompt ?? null,
92
+ stopHookActive: raw?.stop_hook_active ?? null,
93
+ lastAssistantMessage: raw?.last_assistant_message ?? null,
94
+ // PreCompact/PostCompact payload: trigger distinguishes auto vs manual
95
+ // compaction; custom_instructions is the `/compact <hint>` text (manual).
96
+ trigger: raw?.trigger ?? null,
97
+ customInstructions: raw?.custom_instructions ?? null,
98
+ receivedAtMs: raw?.polygram_received_at_ms ?? null,
99
+ raw,
100
+ };
101
+ }
102
+
103
+ /**
104
+ * Wrap a LogTail with line-by-line hook parsing. Forwards parsed
105
+ * events via `'event'` (same shape as claude-session-jsonl.pipeToParser).
106
+ *
107
+ * @returns the same emitter (chainable).
108
+ */
109
+ function pipeHookParser(tail) {
110
+ tail.on('line', (line) => {
111
+ const trimmed = line.trim();
112
+ if (!trimmed) return; // blank-line guard (interleave-paranoid)
113
+ let raw;
114
+ try {
115
+ raw = JSON.parse(trimmed);
116
+ } catch (err) {
117
+ tail.emit('event', {
118
+ type: 'parse-error',
119
+ error: err.message,
120
+ receivedAtMs: Date.now(),
121
+ raw: trimmed.length > 1024 ? trimmed.slice(0, 1024) + '…' : trimmed,
122
+ });
123
+ return;
124
+ }
125
+ tail.emit('event', normalizeHookEvent(raw));
126
+ });
127
+ return tail;
128
+ }
129
+
130
+ /**
131
+ * One-shot helper: build a LogTail at the given path with the
132
+ * H1-typical config (watch mode), wire the hook parser, and return
133
+ * it. Caller calls `.start()` and `.on('event', ...)`.
134
+ *
135
+ * `skipExisting`:
136
+ * - false (default) for a FRESH spawn — the ndjson was just
137
+ * touched at start time and is empty, so any future write IS a
138
+ * new event.
139
+ * - true for a `--resume` spawn — `writeHookFiles` uses 'a' mode
140
+ * (append) and never truncates, so the prior session's hook
141
+ * events are still on disk. Without skipExisting they replay
142
+ * into the fresh process, arming a Stop synth against the
143
+ * fresh turn (H4) and heartbeating it (H3) from stale events.
144
+ * rc.42 #5 (review-driven): mirror what `_armSessionLogTail`
145
+ * already does for the JSONL tail.
146
+ */
147
+ function createHookTail({ path: filePath, skipExisting = false, logger = console } = {}) {
148
+ const tail = new LogTail({
149
+ path: filePath,
150
+ intervalMs: 50,
151
+ skipExisting,
152
+ useWatch: 'auto',
153
+ logger,
154
+ });
155
+ return pipeHookParser(tail);
156
+ }
157
+
158
+ module.exports = {
159
+ KNOWN_EVENT_NAMES,
160
+ normalizeHookEvent,
161
+ pipeHookParser,
162
+ createHookTail,
163
+ };
@@ -0,0 +1,182 @@
1
+ // provenance: polygram@0.17.11 lib/process/hook-settings.js (git 746bca6) — verbatim*: env prefix WATER_, bridge name water-bridge, vendor path (SHARED-LIB.md).
2
+ /**
3
+ * hook-settings — build the per-session `--settings <file>` JSON
4
+ * polygram injects at claude-spawn time for the H1 hook-based
5
+ * observability stream.
6
+ *
7
+ * See docs/0.10.0-tmux-hook-observability.md. The settings file
8
+ * registers a single command-type hook on every event we want to
9
+ * observe; the command is `hook-append.js` (a Node helper at
10
+ * a fixed absolute path) which appends each event as a compacted JSON
11
+ * line to the per-session ndjson.
12
+ *
13
+ * Path layout:
14
+ * ~/.water/<bot>/hooks/<sid>.settings.json (this file's output)
15
+ * ~/.water/<bot>/hooks/<sid>.ndjson (hook stream sink)
16
+ *
17
+ * 2.1.142 spike findings (2026-05-21) baked into the schema:
18
+ * - hooks DO fire alongside `--strict-mcp-config
19
+ * --setting-sources project,local --settings <file>` (so the file
20
+ * is the right transport for the Music topic).
21
+ * - hooks are non-blocking by default → no `async`/`timeout` needed,
22
+ * but `timeout: 30` is included as a belt-and-braces backstop in
23
+ * case a future CLI release flips the default to sync. 30 s is a
24
+ * safe ceiling (the helper is single-syscall fast in practice).
25
+ * - registered events: the five confirmed in the spike +
26
+ * `Notification` (not yet observed; harmless to register).
27
+ * `SubagentStart` is intentionally omitted — it did not fire for
28
+ * general-purpose subagents on 2.1.142 and the design does not
29
+ * depend on it.
30
+ */
31
+
32
+ 'use strict';
33
+
34
+ const path = require('path');
35
+ const fs = require('fs');
36
+
37
+ const HOOK_HELPER_ABS_PATH = path.resolve(__dirname, 'hook-append.js');
38
+
39
+ // Events we register hooks for. Order is informational only — claude
40
+ // merges by event name.
41
+ const HOOK_EVENTS = [
42
+ 'UserPromptSubmit',
43
+ 'PreToolUse',
44
+ 'PostToolUse',
45
+ 'SubagentStop',
46
+ 'Stop',
47
+ 'Notification',
48
+ // 0.12.0-rc.13: compaction lifecycle. PreCompact fires when claude is about
49
+ // to (auto-)compact — the moment that detaches the channels MCP bridge.
50
+ // PostCompact fires after, when context has dropped (used to re-arm the
51
+ // per-chat compaction warning). Both confirmed supported by the pinned CLI
52
+ // (2.1.142) and carry a `trigger: auto|manual` field.
53
+ 'PreCompact',
54
+ 'PostCompact',
55
+ ];
56
+
57
+ /**
58
+ * Per-bot hooks dir (parent of both settings + ndjson files).
59
+ * Mirrors `lib/tmux/tmux-runner.js#debugLogPath`'s `~/.water/<bot>/logs`
60
+ * convention. No /tmp fallback when HOME is unset — fail loud (audit
61
+ * M4 style — symlink races on world-writable dirs).
62
+ *
63
+ * @param {string} botName
64
+ * @param {string} [hooksDir] override (for tests)
65
+ */
66
+ function hooksBaseDir(botName, hooksDir) {
67
+ if (!hooksDir && !process.env.HOME) {
68
+ throw Object.assign(
69
+ new Error('HOME env var unset; refusing /tmp fallback for hooks dir'),
70
+ { code: 'HOME_UNSET' },
71
+ );
72
+ }
73
+ const safeBot = String(botName).replace(/[^\w-]/g, '_');
74
+ return hooksDir || path.join(process.env.HOME, '.orchestra', safeBot, 'hooks');
75
+ }
76
+
77
+ function hookNdjsonPath(botName, sessionId, hooksDir) {
78
+ return path.join(hooksBaseDir(botName, hooksDir), `${sessionId}.ndjson`);
79
+ }
80
+
81
+ function hookSettingsPath(botName, sessionId, hooksDir) {
82
+ return path.join(hooksBaseDir(botName, hooksDir), `${sessionId}.settings.json`);
83
+ }
84
+
85
+ /**
86
+ * Build the settings-JSON object that claude reads via `--settings`.
87
+ *
88
+ * @param {object} opts
89
+ * @param {string} opts.ndjsonPath absolute path the helper appends to
90
+ * @param {string} [opts.helperPath] absolute path to hook-append.js
91
+ * (defaults to the one shipped with polygram)
92
+ */
93
+ // 0.12 SEC-03: shell-quote helper + ndjson paths in the command string.
94
+ // claude's hook runner shells out via execvp on a parsed argv. If either
95
+ // path contains characters the shell tokenizer would split on (most
96
+ // commonly: spaces in HOME like `/Users/Ivan Shumkov/...`), the helper
97
+ // receives the wrong argv and exits with code 2 ("missing ndjson path").
98
+ // Silent-drop of all hook events on bots whose HOME has a space.
99
+ //
100
+ // Single-quote escaping rule: replace any literal `'` with `'\''` (close
101
+ // quote, escaped quote, re-open quote), then wrap the whole thing in
102
+ // single quotes. POSIX-shell safe; no special chars inside single quotes
103
+ // except `'` itself.
104
+ function _shqSingle(s) {
105
+ return `'${String(s).replace(/'/g, `'\\''`)}'`;
106
+ }
107
+
108
+ function buildHookSettings({ ndjsonPath, helperPath = HOOK_HELPER_ABS_PATH } = {}) {
109
+ if (!ndjsonPath || !path.isAbsolute(ndjsonPath)) {
110
+ throw new TypeError('buildHookSettings: ndjsonPath must be an absolute path');
111
+ }
112
+ if (!path.isAbsolute(helperPath)) {
113
+ throw new TypeError('buildHookSettings: helperPath must be an absolute path');
114
+ }
115
+ const command = `node ${_shqSingle(helperPath)} ${_shqSingle(ndjsonPath)}`;
116
+ // Per-event entry: PreToolUse/PostToolUse take a matcher (".*" =
117
+ // every tool); the lifecycle events don't.
118
+ const matched = (matcher) => [{ matcher, hooks: [{ type: 'command', command, timeout: 30 }] }];
119
+ const unmatched = () => [{ hooks: [{ type: 'command', command, timeout: 30 }] }];
120
+ const hooks = {};
121
+ for (const evt of HOOK_EVENTS) {
122
+ hooks[evt] = (evt === 'PreToolUse' || evt === 'PostToolUse') ? matched('.*') : unmatched();
123
+ }
124
+ return { hooks };
125
+ }
126
+
127
+ /**
128
+ * Write the settings JSON to disk (creates parent dirs). Returns the
129
+ * absolute path. Caller pushes `--settings <path>` to the spawn args.
130
+ *
131
+ * The empty ndjson sink is also touched here so the LogTail's
132
+ * fs.watch can attach immediately (LogTail handles ENOENT, but touching
133
+ * eliminates a small race window on the first hook event).
134
+ */
135
+ function writeHookFiles({ botName, sessionId, hooksDir, helperPath, fsImpl = fs } = {}) {
136
+ const settingsPath = hookSettingsPath(botName, sessionId, hooksDir);
137
+ const ndjsonPath = hookNdjsonPath(botName, sessionId, hooksDir);
138
+ fsImpl.mkdirSync(path.dirname(settingsPath), { recursive: true });
139
+ const settings = buildHookSettings({ ndjsonPath, helperPath });
140
+
141
+ // 0.12 SEC-04: write both files with mode 0o600. The ndjson stream
142
+ // carries every tool_input (file paths, Bash commands, write content,
143
+ // partial outputs) — load-bearing observability in CliProcess, NOT the
144
+ // tmux-backend observe-only surface this code was originally built for.
145
+ // umask 022 default leaves the files world-readable; mirror the existing
146
+ // 0o600 pattern from cli-process.js mcp-config writes.
147
+ fsImpl.writeFileSync(settingsPath, JSON.stringify(settings), { mode: 0o600 });
148
+ // Touch the ndjson so fs.watch attaches before the first hook fires.
149
+ // Use openSync with mode 0o600 so a brand-new file is created restricted
150
+ // from birth; if the file already exists (re-spawn case) the mode flag
151
+ // is silently ignored, which is fine because writeHookFiles' caller
152
+ // (CliProcess._spawnTmuxClaude) controls the parent dir's lifecycle.
153
+ const fd = fsImpl.openSync(ndjsonPath, 'a', 0o600);
154
+ fsImpl.closeSync(fd);
155
+ // Defensive re-chmod on both — in case umask interfered with the mode
156
+ // arg above. Same belt-and-suspenders pattern as cli-process.js:404-406.
157
+ try { fsImpl.chmodSync(settingsPath, 0o600); } catch {}
158
+ try { fsImpl.chmodSync(ndjsonPath, 0o600); } catch {}
159
+ return { settingsPath, ndjsonPath };
160
+ }
161
+
162
+ /**
163
+ * Best-effort unlink of both files. Called on kill + orphan-sweep.
164
+ * Errors are swallowed (ENOENT is the common case after a clean kill).
165
+ */
166
+ function removeHookFiles({ botName, sessionId, hooksDir, fsImpl = fs } = {}) {
167
+ for (const p of [hookSettingsPath(botName, sessionId, hooksDir),
168
+ hookNdjsonPath(botName, sessionId, hooksDir)]) {
169
+ try { fsImpl.unlinkSync(p); } catch { /* swallow */ }
170
+ }
171
+ }
172
+
173
+ module.exports = {
174
+ HOOK_HELPER_ABS_PATH,
175
+ HOOK_EVENTS,
176
+ hooksBaseDir,
177
+ hookNdjsonPath,
178
+ hookSettingsPath,
179
+ buildHookSettings,
180
+ writeHookFiles,
181
+ removeHookFiles,
182
+ };