polygram 0.17.11 → 0.17.12

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