polygram 0.10.0-rc.5 → 0.10.0-rc.50

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://anthropic.com/claude-code/plugin.schema.json",
3
3
  "name": "polygram",
4
- "version": "0.10.0-rc.5",
4
+ "version": "0.10.0-rc.50",
5
5
  "description": "Telegram integration for Claude Code that preserves the OpenClaw per-chat session model. Migration target for OpenClaw users. Multi-bot, multi-chat, per-topic isolation; SQLite transcripts; inline-keyboard approvals. Bundles /polygram:status|logs|pair-code|approvals admin commands plus history (transcript queries) and polygram-send (out-of-turn IPC sends with file-upload validation) skills.",
6
6
  "keywords": [
7
7
  "telegram",
@@ -98,7 +98,8 @@
98
98
  "cwd": "/Users/you/admin-agent",
99
99
  "requireMention": true,
100
100
  "isolateTopics": true,
101
- "_comment_topics": "rc.48: each topic entry is EITHER a string (legacy: just a label) OR an object with optional fields {name, agent, cwd, model, effort, permissionMode}. Object form lets a topic override chat-level config. Per-topic permissionMode overrides chat-level — typical use: scope one topic to permissionMode:'default' (so settings.json gates apply) while the rest of the chat stays on bypassPermissions. Object form requires isolateTopics: true (each topic gets its own SDK Query); polygram emits a startup warning otherwise.",
101
+ "_comment_topics": "rc.48: each topic entry is EITHER a string (legacy: just a label) OR an object with optional fields {name, agent, cwd, model, effort, permissionMode, isolateUserConfig}. Object form lets a topic override chat-level config. Per-topic permissionMode overrides chat-level — typical use: scope one topic to permissionMode:'default' (so settings.json gates apply) while the rest of the chat stays on bypassPermissions. Object form requires isolateTopics: true (each topic gets its own SDK Query); polygram emits a startup warning otherwise.",
102
+ "_comment_isolateUserConfig": "0.10.0, tmux backend only: isolateUserConfig:true spawns the topic's claude TUI cut off from the user-level ~/.claude config — passes --strict-mcp-config (zero MCP servers load) and --setting-sources project,local (drops ~/.claude/settings.json; the spawn cwd's own .claude/settings.json still loads). Use it when a topic's agent would otherwise inherit slow user-global MCP servers whose cold-start (tens of seconds) wedges the TUI before it can accept a prompt. Settable at chat OR topic level (topic wins). Default false.",
102
103
  "topics": {
103
104
  "100": "Customer A",
104
105
  "200": {
@@ -107,7 +108,8 @@
107
108
  "cwd": "/Users/you/customer-b-projects",
108
109
  "model": "opus",
109
110
  "effort": "high",
110
- "permissionMode": "default"
111
+ "permissionMode": "default",
112
+ "isolateUserConfig": true
111
113
  }
112
114
  }
113
115
  },
@@ -46,12 +46,22 @@
46
46
  * logged to opts.logger?.error — they never block clearing of
47
47
  * subsequent refs.
48
48
  * @param {{ error?: (msg: string) => void }} [opts.logger]
49
+ * @param {number} [opts.minIntervalMs=250]
50
+ * minimum gap (ms) between successive applyClear calls inside a
51
+ * single clear() loop. Telegram's setMessageReaction rate limit
52
+ * is ~5/sec/chat; 250ms (4/sec) stays under that. Pass 0 to
53
+ * disable pacing in tests / contexts where the underlying applyClear
54
+ * doesn't talk to a rate-limited API. Only the GAP between calls
55
+ * is paced — the first call fires immediately, single-ref clears
56
+ * incur no delay. L7 fix 2026-05-16: was unpaced, exceeded the
57
+ * Telegram cap under N≥6 autosteers per turn.
49
58
  * @returns {AutosteeredRefs}
50
59
  */
51
- function createAutosteeredRefs({ applyClear, logger = console } = {}) {
60
+ function createAutosteeredRefs({ applyClear, logger = console, minIntervalMs = 250 } = {}) {
52
61
  if (typeof applyClear !== 'function') {
53
62
  throw new TypeError('applyClear function required');
54
63
  }
64
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
55
65
  /** @type {Map<string, MsgRef[]>} */
56
66
  const refs = new Map();
57
67
 
@@ -79,7 +89,15 @@ function createAutosteeredRefs({ applyClear, logger = console } = {}) {
79
89
  if (!list || list.length === 0) return 0;
80
90
  refs.delete(sessionKey);
81
91
  let cleared = 0;
82
- for (const ref of list) {
92
+ // L7: pace inter-call gaps to stay under Telegram's
93
+ // setMessageReaction rate limit (~5/sec/chat). The first call
94
+ // fires immediately — pacing applies only to the gap BEFORE the
95
+ // 2nd+ call. minIntervalMs=0 disables pacing entirely.
96
+ for (let i = 0; i < list.length; i += 1) {
97
+ const ref = list[i];
98
+ if (i > 0 && minIntervalMs > 0) {
99
+ await sleep(minIntervalMs);
100
+ }
83
101
  try {
84
102
  await applyClear(ref);
85
103
  cleared += 1;
@@ -0,0 +1,78 @@
1
+ 'use strict';
2
+
3
+ const os = require('os');
4
+ const path = require('path');
5
+ const fs = require('fs');
6
+
7
+ /**
8
+ * Resolve + verify the pinned claude CLI binary for the tmux backend.
9
+ *
10
+ * Why this exists: the tmux backend reads claude CLI INTERNAL
11
+ * artefacts (JSONL events, queue-operation semantics, TUI banner
12
+ * ASCII, READY hint strings, stop_reason values) — none a stable
13
+ * public contract. polygram pins ONE version
14
+ * (CLAUDE_CLI_PINNED_VERSION in lib/process/tmux-process.js) and
15
+ * must spawn THAT binary, never whatever `claude` on $PATH happens
16
+ * to resolve to.
17
+ *
18
+ * Before this module the tmux runner spawned the bare string
19
+ * `claude`, resolved through $PATH. The claude CLI installs each
20
+ * version as a standalone binary at
21
+ * ~/.local/share/claude/versions/<version>
22
+ * and points ~/.local/bin/claude (a symlink) at the active one.
23
+ * Its auto-updater re-points that symlink whenever a new version
24
+ * lands — so a $PATH spawn silently drifts (shumorobot 2026-05-16:
25
+ * CLI auto-updated 2.1.142 → 2.1.143 between deploys).
26
+ *
27
+ * Spawning the ABSOLUTE versioned path is immune to that: the
28
+ * updater only ADDS new version files, it never overwrites an
29
+ * existing one. `versions/2.1.142` stays byte-identical forever.
30
+ */
31
+
32
+ /**
33
+ * Absolute path to the pinned claude binary.
34
+ *
35
+ * Resolution order:
36
+ * 1. POLYGRAM_CLAUDE_BIN env — explicit override (non-standard
37
+ * installs, CI, hosts where the layout differs).
38
+ * 2. ~/.local/share/claude/versions/<version> — the standard
39
+ * claude-CLI install location.
40
+ *
41
+ * The returned path is NOT guaranteed to exist — callers verify
42
+ * via verifyPinnedClaudeBin().
43
+ *
44
+ * @param {string} version — pinned version, e.g. '2.1.142'
45
+ * @returns {string} absolute path
46
+ */
47
+ function resolvePinnedClaudeBin(version) {
48
+ const override = process.env.POLYGRAM_CLAUDE_BIN;
49
+ if (override) return override;
50
+ return path.join(os.homedir(), '.local', 'share', 'claude', 'versions', version);
51
+ }
52
+
53
+ /**
54
+ * Verify the pinned binary exists and is executable.
55
+ *
56
+ * @param {string} version — pinned version, e.g. '2.1.142'
57
+ * @returns {{ ok: boolean, path: string, reason?: string }}
58
+ * ok=true → path is a spawnable binary.
59
+ * ok=false → reason carries an operator-actionable message.
60
+ */
61
+ function verifyPinnedClaudeBin(version) {
62
+ const binPath = resolvePinnedClaudeBin(version);
63
+ try {
64
+ fs.accessSync(binPath, fs.constants.X_OK);
65
+ return { ok: true, path: binPath };
66
+ } catch (err) {
67
+ const code = err && err.code ? err.code : (err && err.message) || 'unknown';
68
+ return {
69
+ ok: false,
70
+ path: binPath,
71
+ reason: `pinned claude CLI v${version} not found or not executable at `
72
+ + `${binPath} (${code}). Install it with \`claude install ${version}\` `
73
+ + 'or set POLYGRAM_CLAUDE_BIN to the correct binary path.',
74
+ };
75
+ }
76
+ }
77
+
78
+ module.exports = { resolvePinnedClaudeBin, verifyPinnedClaudeBin };
@@ -95,4 +95,117 @@ function getClaudeSessionId(db, sessionKey) {
95
95
  return row?.claude_session_id || null;
96
96
  }
97
97
 
98
- module.exports = { migrateJsonToDb, getClaudeSessionId, countSessions };
98
+ // ─── S2: session-config drift ────────────────────────────────────────
99
+ //
100
+ // A stored `sessions` row records the config the claude session was
101
+ // SPAWNED under. Two of the recorded fields are spawn-identity:
102
+ // - agent — `--agent <name>` is baked into the spawned process;
103
+ // resuming a session spawned under agent X under agent Y forces
104
+ // claude to use Y's system prompt + tool whitelist against
105
+ // conversation history built under X's. Incoherent.
106
+ // - cwd — `--cwd <path>` (SDK) / tmux session cwd; claude resolves
107
+ // project-local config (.claude/settings.json, agent files,
108
+ // plugins) relative to it. Mid-conversation cwd drift means
109
+ // half the messages are answered with one project's allowlist
110
+ // and the other half with another's.
111
+ //
112
+ // pm_backend was REMOVED from spawn-identity (rc.32, 2026-05-21).
113
+ // Both backends spawn the same pinned claude binary and write the
114
+ // same on-disk JSONL (~/.claude/projects/<cwd-enc>/<sid>.jsonl) —
115
+ // claude itself doesn't know or care which Node-side wrapper invoked
116
+ // it. Treating a backend flip as drift was destructively dropping
117
+ // context across the SDK→tmux migration window, costing every chat
118
+ // its conversation history on its first turn under the new backend.
119
+ // shumorobot 2026-05-20 18:51 incident: the Music topic flipped
120
+ // tmux→sdk→tmux during runtime and lost its agent's prior context
121
+ // at each flip. The orphan-tmux problem that the flip ALSO triggered
122
+ // is solved by rc.31's spawn-time reconcile (TmuxProcess.start) —
123
+ // independently, so a backend flip is now a no-op for session-state.
124
+ //
125
+ // shumorobot 2026-05-17 22:03, topic :3 (the original drift incident)
126
+ // remains correctly handled: that row had agent+cwd drift in
127
+ // addition to backend, so the agent+cwd drift alone still drops it.
128
+ //
129
+ // model + effort are deliberately EXCLUDED from the invalidating set.
130
+ // They are NOT spawn-identity: a live `/model` or `/effort` change is
131
+ // pushed into the running session by `pm.setModel` /
132
+ // `pm.applyFlagSettings` with no respawn (lib/handlers/slash-commands.js,
133
+ // lib/handlers/config-callback.js). Including them here would
134
+ // destructively drop the whole session — discarding all context — on
135
+ // every model switch, double-handling what the live-apply path
136
+ // already covers cleanly. The stored model/effort columns are
137
+ // informational, not identity.
138
+ const SPAWN_IDENTITY_FIELDS = ['agent', 'cwd'];
139
+
140
+ /**
141
+ * Decide whether a stored session can be resumed for the next spawn,
142
+ * or whether config drift means it must be dropped and re-spawned
143
+ * fresh.
144
+ *
145
+ * On drift the stale row is DELETED here — so the very next spawn
146
+ * mints a fresh claude_session_id under the correct config and the
147
+ * `onInit` callback re-upserts the row. This self-heals every
148
+ * pre-migration stale row across all chats with no manual SQL.
149
+ *
150
+ * @param {object|null} db — DB handle (null → fresh spawn)
151
+ * @param {string} sessionKey
152
+ * @param {object} resolved — freshly-resolved spawn config
153
+ * @param {string} [resolved.agent]
154
+ * @param {string} [resolved.cwd]
155
+ * @param {string} [resolved.backend] — 'sdk' | 'tmux' (resolved by
156
+ * process/factory.js pickBackend); compared to the row's pm_backend
157
+ * @returns {{ existingSessionId: string|null, drift: object|null }}
158
+ * existingSessionId — pass to start() for --resume, or null for a
159
+ * fresh spawn (no stored row, or drift dropped it)
160
+ * drift — null when no drift; otherwise { fields, before, after }
161
+ * for the `session-config-drift` telemetry event
162
+ */
163
+ function resolveSessionForSpawn(db, sessionKey, resolved = {}) {
164
+ if (!db) return { existingSessionId: null, drift: null };
165
+ const row = db.getSession(sessionKey);
166
+ if (!row || !row.claude_session_id) {
167
+ return { existingSessionId: null, drift: null };
168
+ }
169
+
170
+ // Normalise: a missing field on either side is treated as equal to
171
+ // a missing field on the other (both null/undefined → no drift).
172
+ const after = {
173
+ agent: resolved.agent || null,
174
+ cwd: resolved.cwd || null,
175
+ pm_backend: resolved.backend || null,
176
+ };
177
+ const before = {
178
+ agent: row.agent || null,
179
+ cwd: row.cwd || null,
180
+ pm_backend: row.pm_backend || null,
181
+ };
182
+ const drifted = SPAWN_IDENTITY_FIELDS.filter((f) => {
183
+ // If the resolved config does not specify a field, do not treat
184
+ // it as drift — we have nothing to compare against.
185
+ if (after[f] == null) return false;
186
+ return before[f] !== after[f];
187
+ });
188
+
189
+ if (drifted.length === 0) {
190
+ return { existingSessionId: row.claude_session_id, drift: null };
191
+ }
192
+
193
+ // Drift: drop the stale row so the next spawn is fresh + correct.
194
+ db.clearSessionId(sessionKey);
195
+ return {
196
+ existingSessionId: null,
197
+ drift: {
198
+ fields: drifted,
199
+ before: { ...before, claude_session_id: row.claude_session_id },
200
+ after,
201
+ },
202
+ };
203
+ }
204
+
205
+ module.exports = {
206
+ migrateJsonToDb,
207
+ getClaudeSessionId,
208
+ resolveSessionForSpawn,
209
+ countSessions,
210
+ SPAWN_IDENTITY_FIELDS,
211
+ };
@@ -74,6 +74,31 @@ const PATTERNS = {
74
74
  // image-failure verb co-located with "image" or "photo".
75
75
  imageProcess: /(could not process|cannot process|failed to (process|load|decode)|unsupported|invalid|corrupt(?:ed)?)[^\n]{0,80}\b(image|photo)\b|\b(image|photo)\b[^\n]{0,80}(could not process|failed to (process|load|decode)|is (invalid|corrupted|unsupported))/i,
76
76
 
77
+ // rc.50: lib/process-manager.js `_awaitLruSlot` rejects when no
78
+ // backend slot is available within `lruWaitMs` (default 5 min).
79
+ // Symptom of an upstream wedge (an inFlight tmux process can't be
80
+ // LRU-evicted; if its turn is wedged the slot stays held for the
81
+ // whole idle-ceiling). Production 2026-05-24 (shumorobot Music):
82
+ // hit twice during the wedged-"yes" turn that took 30 min to
83
+ // surface. Pre-rc.50 the user got `Hit a snag: lru wait timed
84
+ // out after 300000ms` which is opaque; now they get a hint that
85
+ // it's a busy/queued condition and a retry will probably work.
86
+ // Placed BEFORE the generic `timeout` pattern so the LRU phrasing
87
+ // wins over the broader timeout match.
88
+ lruWaitTimeout: /lru wait timed out/i,
89
+
90
+ // rc.47: tmux backend's TMUX_TURN_TIMEOUT after H3 idle-ceiling fired.
91
+ // Production wedge 2026-05-24 msg 1020: a Bash tool's `PreToolUse`
92
+ // fired but `PostToolUse` never came — claude waited forever for the
93
+ // tool result, polygram waited 30 min then killed the turn, and the
94
+ // user got the generic `Hit a snag: turn did not complete in time`
95
+ // (the fall-through "unknown" path). This kind matches the enriched
96
+ // error message that `_runTurn` now throws (with the wedged-tool
97
+ // name + outstanding count in parens) AND the bare pre-rc.47 form
98
+ // for backwards compatibility. Placed BEFORE the generic `timeout`
99
+ // pattern so the more-specific TmuxProcess message wins.
100
+ tmuxToolWedge: /TmuxProcess: turn did not complete in time/i,
101
+
77
102
  // Idle/wall-clock timeout from polygram's pm timers, OR
78
103
  // model-side timeout. Mapped to a single class; user message is
79
104
  // identical either way.
@@ -102,6 +127,8 @@ const USER_MESSAGES = {
102
127
  roleOrdering: '⚠️ Conversation got into a tangled state. Try /new.',
103
128
  missingToolInput: '⚠️ Session history looks corrupted. Try /new.',
104
129
  imageProcess: '🖼 One of the images in this conversation can\'t be re-processed by Claude — likely an older one in the history. Starting a fresh session for this chat.',
130
+ tmuxToolWedge: '🔧 A tool didn\'t return in time — I cut it off. Most often this is a Bash command waiting on something external (a server, a file lock, an interactive prompt). Try resending; if it happens again, break the task into smaller steps.',
131
+ lruWaitTimeout: '⏳ Other chats are busy — couldn\'t free up a backend slot in time. Try resending in a moment; if it keeps happening, an operator may need to raise the warm-process cap.',
105
132
  timeout: '⏳ I went quiet too long without finishing. Try resending or simplifying.',
106
133
  format: '⚠️ Invalid request format. Try rephrasing or /new.',
107
134
  // Used both for in-flight retry attempts AND for the post-retry-failed
@@ -41,13 +41,37 @@ function createHandleAbort({
41
41
 
42
42
  const threadId = msg.message_thread_id?.toString();
43
43
  const sessionKey = getSessionKey(chatId, threadId, chatConfig);
44
- const hadActive = pm.has(sessionKey) && !!pm.get(sessionKey)?.inFlight;
44
+ const proc = pm.has(sessionKey) ? pm.get(sessionKey) : null;
45
+ const hadActive = !!proc?.inFlight;
45
46
 
46
47
  // Mark BEFORE killing: the 'close' event fires almost immediately
47
48
  // after interrupt, and the surrounding handleMessage's catch
48
49
  // needs to see the flag to skip the generic error-reply.
49
50
  if (hadActive) markSessionAborted(sessionKey);
50
51
 
52
+ // Bug 1 (incident 2026-05-18): "Stop" was turn-scoped — it only
53
+ // looked at an in-flight TURN. But the agent can leave a DETACHED
54
+ // background shell running (a `run_in_background:true` Bash) that
55
+ // outlives the turn; the tmux TUI shows an `N shell` indicator.
56
+ // When there is no live turn, check for such a shell and stop it
57
+ // so "Stop" acts truthfully instead of replying "Nothing to stop"
58
+ // while work is still churning. tmux-only — the SDK Process has no
59
+ // hasBackgroundShell()/killBackgroundShells(); the typeof guards
60
+ // make this a no-op there.
61
+ let killedBackgroundShell = false;
62
+ if (!hadActive && proc
63
+ && typeof proc.hasBackgroundShell === 'function'
64
+ && typeof proc.killBackgroundShells === 'function') {
65
+ try {
66
+ if (await proc.hasBackgroundShell()) {
67
+ markSessionAborted(sessionKey);
68
+ killedBackgroundShell = await proc.killBackgroundShells();
69
+ }
70
+ } catch (err) {
71
+ logger.error?.(`[${botName}] background-shell stop failed: ${err.message}`);
72
+ }
73
+ }
74
+
51
75
  // SDK abort: interrupt() + drainQueue(). interrupt() cancels
52
76
  // the in-flight turn at SDK level WITHOUT tearing down the
53
77
  // Query (cheap to reuse for the user's next message);
@@ -62,6 +86,7 @@ function createHandleAbort({
62
86
  logEvent('abort-requested', {
63
87
  chat_id: chatId, user_id: msg.from?.id || null,
64
88
  had_active: hadActive,
89
+ killed_background_shell: killedBackgroundShell,
65
90
  trigger: cleanText.slice(0, 40),
66
91
  });
67
92
 
@@ -69,10 +94,23 @@ function createHandleAbort({
69
94
  // detection is crude but reliable for ru/en.
70
95
  const lang = /[а-яё]/i.test(cleanText) ? 'ru' : 'en';
71
96
  const strs = {
72
- en: { stopped: 'Stopped.', nothing: 'Nothing to stop.' },
73
- ru: { stopped: 'Остановлено.', nothing: 'Нечего останавливать.' },
97
+ en: {
98
+ stopped: 'Stopped.',
99
+ bgStopped: 'Stopped the background task.',
100
+ nothing: 'Nothing to stop.',
101
+ },
102
+ ru: {
103
+ stopped: 'Остановлено.',
104
+ bgStopped: 'Фоновая задача остановлена.',
105
+ nothing: 'Нечего останавливать.',
106
+ },
74
107
  }[lang];
75
- const reply = hadActive ? strs.stopped : strs.nothing;
108
+ // Truthful ack: a stopped in-flight turn → "Stopped"; a stopped
109
+ // background shell → "Stopped the background task"; neither →
110
+ // "Nothing to stop".
111
+ const reply = hadActive ? strs.stopped
112
+ : killedBackgroundShell ? strs.bgStopped
113
+ : strs.nothing;
76
114
  try {
77
115
  await tg(bot, 'sendMessage', {
78
116
  chat_id: chatId, text: reply,
@@ -69,9 +69,15 @@ function createAutosteerHandlers({
69
69
  if (!entry?.inFlight) return { autosteered: false };
70
70
 
71
71
  const priority = priorityFor(chatConfig, config);
72
+ // rc.7: pass the autosteered msg_id through to the backend so the
73
+ // tmux backend can route an extra-turn reply back to Telegram if
74
+ // the TUI dequeues the paste as a fresh user turn (NEW-TURN path).
75
+ // SDK backend ignores msgId — its PostToolBatch fold path
76
+ // guarantees one combined reply via the primary pm.send.
72
77
  const ok = pm.injectUserMessage(sessionKey, {
73
78
  content: prompt,
74
79
  priority,
80
+ msgId: msg.message_id,
75
81
  });
76
82
  if (!ok) return { autosteered: false };
77
83
 
@@ -0,0 +1,155 @@
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
+ ]);
55
+
56
+ /**
57
+ * Normalize one raw hook payload (already JSON.parsed) into the
58
+ * shape downstream code consumes. Unknown shapes pass through as
59
+ * `unknown` so a 2.1.143-style schema drift doesn't silently lose
60
+ * events.
61
+ */
62
+ function normalizeHookEvent(raw) {
63
+ if (raw && typeof raw === 'object' && raw.polygram_parse_error) {
64
+ return {
65
+ type: 'parse-error',
66
+ error: raw.polygram_parse_error,
67
+ receivedAtMs: raw.polygram_received_at_ms ?? null,
68
+ raw,
69
+ };
70
+ }
71
+ const name = raw && typeof raw === 'object' ? raw.hook_event_name : null;
72
+ const type = KNOWN_EVENT_NAMES.has(name) ? name : 'unknown';
73
+ return {
74
+ type,
75
+ sessionId: raw?.session_id ?? null,
76
+ transcriptPath: raw?.transcript_path ?? null,
77
+ cwd: raw?.cwd ?? null,
78
+ permissionMode: raw?.permission_mode ?? null,
79
+ toolName: raw?.tool_name ?? null,
80
+ toolUseId: raw?.tool_use_id ?? null,
81
+ toolInput: raw?.tool_input ?? null,
82
+ toolResponse: raw?.tool_response ?? null,
83
+ durationMs: raw?.duration_ms ?? null,
84
+ agentId: raw?.agent_id ?? null,
85
+ agentType: raw?.agent_type ?? null,
86
+ agentTranscriptPath: raw?.agent_transcript_path ?? null,
87
+ prompt: raw?.prompt ?? null,
88
+ stopHookActive: raw?.stop_hook_active ?? null,
89
+ lastAssistantMessage: raw?.last_assistant_message ?? null,
90
+ receivedAtMs: raw?.polygram_received_at_ms ?? null,
91
+ raw,
92
+ };
93
+ }
94
+
95
+ /**
96
+ * Wrap a LogTail with line-by-line hook parsing. Forwards parsed
97
+ * events via `'event'` (same shape as session-log-parser.pipeToParser).
98
+ *
99
+ * @returns the same emitter (chainable).
100
+ */
101
+ function pipeHookParser(tail) {
102
+ tail.on('line', (line) => {
103
+ const trimmed = line.trim();
104
+ if (!trimmed) return; // blank-line guard (interleave-paranoid)
105
+ let raw;
106
+ try {
107
+ raw = JSON.parse(trimmed);
108
+ } catch (err) {
109
+ tail.emit('event', {
110
+ type: 'parse-error',
111
+ error: err.message,
112
+ receivedAtMs: Date.now(),
113
+ raw: trimmed.length > 1024 ? trimmed.slice(0, 1024) + '…' : trimmed,
114
+ });
115
+ return;
116
+ }
117
+ tail.emit('event', normalizeHookEvent(raw));
118
+ });
119
+ return tail;
120
+ }
121
+
122
+ /**
123
+ * One-shot helper: build a LogTail at the given path with the
124
+ * H1-typical config (watch mode), wire the hook parser, and return
125
+ * it. Caller calls `.start()` and `.on('event', ...)`.
126
+ *
127
+ * `skipExisting`:
128
+ * - false (default) for a FRESH spawn — the ndjson was just
129
+ * touched at start time and is empty, so any future write IS a
130
+ * new event.
131
+ * - true for a `--resume` spawn — `writeHookFiles` uses 'a' mode
132
+ * (append) and never truncates, so the prior session's hook
133
+ * events are still on disk. Without skipExisting they replay
134
+ * into the fresh process, arming a Stop synth against the
135
+ * fresh turn (H4) and heartbeating it (H3) from stale events.
136
+ * rc.42 #5 (review-driven): mirror what `_armSessionLogTail`
137
+ * already does for the JSONL tail.
138
+ */
139
+ function createHookTail({ path: filePath, skipExisting = false, logger = console } = {}) {
140
+ const tail = new LogTail({
141
+ path: filePath,
142
+ intervalMs: 50,
143
+ skipExisting,
144
+ useWatch: 'auto',
145
+ logger,
146
+ });
147
+ return pipeHookParser(tail);
148
+ }
149
+
150
+ module.exports = {
151
+ KNOWN_EVENT_NAMES,
152
+ normalizeHookEvent,
153
+ pipeHookParser,
154
+ createHookTail,
155
+ };