polygram 0.17.10 → 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 (39) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/lib/config.js +21 -0
  3. package/lib/error/classify.js +14 -0
  4. package/lib/handlers/dispatcher.js +54 -1
  5. package/lib/handlers/drop-redeliver.js +19 -0
  6. package/lib/handlers/should-handle.js +52 -0
  7. package/lib/media-group-buffer.js +29 -0
  8. package/lib/ops/auth-disabled-gate.js +43 -0
  9. package/lib/ops/heartbeat.js +56 -0
  10. package/lib/process/channels-tool-dispatcher.js +12 -1
  11. package/lib/prompt.js +22 -7
  12. package/lib/sdk/callbacks.js +2 -1
  13. package/lib/secret-detect.js +13 -1
  14. package/lib/telegram/chunk.js +20 -6
  15. package/lib/telegram/process-agent-reply.js +5 -3
  16. package/lib/telegram/streamer.js +6 -1
  17. package/package.json +2 -1
  18. package/polygram.js +202 -45
  19. package/lib/async-lock.js +0 -49
  20. package/lib/claude-bin.js +0 -246
  21. package/lib/compaction-warn.js +0 -59
  22. package/lib/context-usage.js +0 -93
  23. package/lib/process/channels-bridge-protocol.js +0 -199
  24. package/lib/process/channels-bridge-server.js +0 -274
  25. package/lib/process/channels-bridge.mjs +0 -477
  26. package/lib/process/cli-process.js +0 -4029
  27. package/lib/process/factory.js +0 -215
  28. package/lib/process/hook-event-tail.js +0 -162
  29. package/lib/process/hook-settings.js +0 -181
  30. package/lib/process/polygram-hook-append.js +0 -71
  31. package/lib/process/process.js +0 -215
  32. package/lib/process/sdk-process.js +0 -880
  33. package/lib/process-guard.js +0 -296
  34. package/lib/process-manager.js +0 -628
  35. package/lib/tmux/log-tail.js +0 -334
  36. package/lib/tmux/orphan-sweep.js +0 -79
  37. package/lib/tmux/poll-scheduler.js +0 -110
  38. package/lib/tmux/startup-gate.js +0 -250
  39. package/lib/tmux/tmux-runner.js +0 -412
package/lib/claude-bin.js DELETED
@@ -1,246 +0,0 @@
1
- 'use strict';
2
-
3
- const os = require('os');
4
- const path = require('path');
5
- const fs = require('fs');
6
- const { execFileSync } = require('child_process');
7
-
8
- // 0.12 Phase 4: moved from lib/process/tmux-process.js into the helper module
9
- // that consumes it, so the constant survives TmuxProcess deletion. CliProcess
10
- // + spike scripts + polygram boot all import from here now.
11
- // 0.12.0-rc.18: bumped 2.1.142 → 2.1.158 (latest installed) chasing the
12
- // dev-channels reliability issues (see docs/0.12.0-known-issues.md).
13
- // 0.12.0-rc.38: bumped 2.1.158 → 2.1.173. Two reasons: (1) the ~32s startup
14
- // deaths root-caused 2026-06-11 to a stale MCP connect-timeout racing the
15
- // --resume session-id swap — a newer claude may fix the timer (2.1.173 also
16
- // adds "Channel notifications re-registered after reconnect"); (2) keep the
17
- // research-preview channels current. Per-bump re-validation done 2026-06-11:
18
- // resume-dialog env vars survive (CLAUDE_CODE_RESUME_THRESHOLD_MINUTES /
19
- // _TOKEN_THRESHOLD), trust + dev-channels dialogs unchanged, "esc to
20
- // interrupt" hint unchanged (template-rendered), but the channels READY
21
- // banner text CHANGED → readySignal in cli-process.js matches both forms.
22
- // Re-validate the channel flow on each bump via
23
- // tests/e2e-channels-real-claude.test.js (run with E2E_REAL_CLAUDE=1).
24
- const CLAUDE_CLI_PINNED_VERSION = '2.1.173';
25
-
26
- /**
27
- * Resolve + verify the pinned claude CLI binary.
28
- *
29
- * Why this exists: the tmux + CLI backends read claude CLI internal
30
- * artefacts (TUI banner ASCII, READY hint strings, channel notification
31
- * registration timing, MCP-init order) — none a stable public contract.
32
- * polygram pins ONE version (`CLAUDE_CLI_PINNED_VERSION`) and must
33
- * spawn THAT binary, never whatever `claude` on $PATH happens to
34
- * resolve to.
35
- *
36
- * Before this module the tmux runner spawned the bare string
37
- * `claude`, resolved through $PATH. The claude CLI installs each
38
- * version as a standalone binary at
39
- * ~/.local/share/claude/versions/<version>
40
- * and points ~/.local/bin/claude (a symlink) at the active one.
41
- * Its auto-updater re-points that symlink whenever a new version
42
- * lands — so a $PATH spawn silently drifts (shumorobot 2026-05-16:
43
- * CLI auto-updated 2.1.142 → 2.1.143 between deploys).
44
- *
45
- * Spawning the ABSOLUTE versioned path avoids the symlink-drift, but is
46
- * NOT immune to the updater: claude keeps only the ~3 newest versions
47
- * and PRUNES (deletes) the rest. Once the pin falls out of the top 3 the
48
- * pinned path is a dead file → every cli spawn exits in ~14ms (prod
49
- * outages 2026-06-21/22). So `verifyPinnedClaudeBin` (point-in-time check)
50
- * is not enough; `ensureVendoredClaudeBin` (below, 0.17) keeps a
51
- * polygram-owned copy the pruner can't touch.
52
- */
53
-
54
- /**
55
- * Absolute path to the pinned claude binary.
56
- *
57
- * Resolution order:
58
- * 1. POLYGRAM_CLAUDE_BIN env — explicit override (non-standard
59
- * installs, CI, hosts where the layout differs).
60
- * 2. ~/.local/share/claude/versions/<version> — the standard
61
- * claude-CLI install location.
62
- *
63
- * The returned path is NOT guaranteed to exist — callers verify
64
- * via verifyPinnedClaudeBin().
65
- *
66
- * @param {string} version — pinned version, e.g. '2.1.142'
67
- * @returns {string} absolute path
68
- */
69
- function resolvePinnedClaudeBin(version) {
70
- const override = process.env.POLYGRAM_CLAUDE_BIN;
71
- if (override) return override;
72
- return path.join(os.homedir(), '.local', 'share', 'claude', 'versions', version);
73
- }
74
-
75
- /**
76
- * Verify the pinned binary exists and is executable.
77
- *
78
- * @param {string} version — pinned version, e.g. '2.1.142'
79
- * @returns {{ ok: boolean, path: string, reason?: string }}
80
- * ok=true → path is a spawnable binary.
81
- * ok=false → reason carries an operator-actionable message.
82
- */
83
- function verifyPinnedClaudeBin(version) {
84
- const binPath = resolvePinnedClaudeBin(version);
85
- try {
86
- fs.accessSync(binPath, fs.constants.X_OK);
87
- return { ok: true, path: binPath };
88
- } catch (err) {
89
- const code = err && err.code ? err.code : (err && err.message) || 'unknown';
90
- return {
91
- ok: false,
92
- path: binPath,
93
- reason: `pinned claude CLI v${version} not found or not executable at `
94
- + `${binPath} (${code}). Install it with \`claude install ${version}\` `
95
- + 'or set POLYGRAM_CLAUDE_BIN to the correct binary path.',
96
- };
97
- }
98
- }
99
-
100
- // ─── 0.17: vendored pinned binary (immune to claude's auto-pruner) ──────────
101
- //
102
- // claude's updater deletes all but the ~3 newest versions, so the pinned
103
- // version eventually vanishes from ~/.local/share/claude/versions and every
104
- // cli spawn dies. We can't fall forward (the cli backend reads version-specific
105
- // TUI internals). Fix: polygram keeps its OWN copy of the exact pinned binary
106
- // in a dir the pruner never touches, and spawns from there. Once vendored it
107
- // never depends on the system copy or the network again.
108
-
109
- /**
110
- * polygram-owned vendor dir for claude binaries. Under ~/.local/share/polygram
111
- * (XDG data) — claude's pruner only touches ~/.local/share/claude/versions, and
112
- * `npm i -g polygram` only replaces the package dir, so this survives both.
113
- * Override with POLYGRAM_CLAUDE_VENDOR_DIR.
114
- */
115
- function vendorDir() {
116
- return process.env.POLYGRAM_CLAUDE_VENDOR_DIR
117
- || path.join(os.homedir(), '.local', 'share', 'polygram', 'claude-bin');
118
- }
119
-
120
- function isExecutable(p) {
121
- try { fs.accessSync(p, fs.constants.X_OK); return true; } catch { return false; }
122
- }
123
-
124
- // Atomic: copy to a unique tmp in the same dir, chmod, then rename over.
125
- function _atomicCopyExec(src, dst) {
126
- const tmp = `${dst}.tmp.${process.pid}.${Date.now()}`;
127
- fs.copyFileSync(src, tmp);
128
- fs.chmodSync(tmp, 0o755);
129
- fs.renameSync(tmp, dst);
130
- }
131
-
132
- // Remove vendored binaries (and stale .tmp.*) that aren't the live version.
133
- function _gcVendored(dir, keepVersion, logger) {
134
- let entries = [];
135
- try { entries = fs.readdirSync(dir); } catch { return; }
136
- for (const name of entries) {
137
- if (name === keepVersion) continue;
138
- // Never delete an in-flight copy: a CONCURRENT boot (multi-bot host shares
139
- // this dir) may be mid-copy into `<keepVersion>.tmp.<pid>.<ts>`; removing it
140
- // ENOENTs that boot's rename → it falls back to SDK. Skip all .tmp.* — a
141
- // genuinely orphaned tmp is cheap to leave (cleaned when its version is GC'd
142
- // by name, or harmless). Defense-in-depth: only GC version-shaped names so a
143
- // misconfigured vendor dir can't nuke unrelated files.
144
- if (name.includes('.tmp.')) continue;
145
- if (!/^\d+\.\d+\.\d+$/.test(name)) continue;
146
- try { fs.rmSync(path.join(dir, name), { force: true }); } catch (e) {
147
- logger?.warn?.(`[claude-bin] vendor GC: could not remove ${name}: ${e.message}`);
148
- }
149
- }
150
- }
151
-
152
- /**
153
- * Ensure a polygram-owned copy of the pinned claude binary exists and return
154
- * its path. Steady state is a single stat (fast). On a cold/pruned host it
155
- * obtains the binary once (copy from the system install, else `claude install`
156
- * then copy) and caches it forever.
157
- *
158
- * @param {string} version
159
- * @param {{ logger?: object }} [opts]
160
- * @returns {{ ok: boolean, path: string, vendored?: boolean, reason?: string }}
161
- */
162
- function ensureVendoredClaudeBin(version, { logger = console } = {}) {
163
- // Explicit override wins, unchanged — non-standard installs / CI / tests.
164
- const override = process.env.POLYGRAM_CLAUDE_BIN;
165
- if (override) {
166
- return isExecutable(override)
167
- ? { ok: true, path: override, vendored: false }
168
- : { ok: false, path: override, reason: `POLYGRAM_CLAUDE_BIN=${override} not executable` };
169
- }
170
-
171
- const dir = vendorDir();
172
- const vendored = path.join(dir, version);
173
-
174
- // Fast path: already vendored.
175
- if (isExecutable(vendored)) {
176
- _gcVendored(dir, version, logger);
177
- return { ok: true, path: vendored, vendored: true };
178
- }
179
-
180
- // Need to obtain it. Ensure the dir exists.
181
- try { fs.mkdirSync(dir, { recursive: true }); } catch (e) {
182
- return { ok: false, path: vendored, reason: `cannot create vendor dir ${dir}: ${e.message}` };
183
- }
184
-
185
- const versionsDir = process.env.POLYGRAM_CLAUDE_VERSIONS_DIR
186
- || path.join(os.homedir(), '.local', 'share', 'claude', 'versions');
187
- const systemPath = path.join(versionsDir, version);
188
-
189
- // (a) copy from the system install if present.
190
- if (isExecutable(systemPath)) {
191
- try {
192
- _atomicCopyExec(systemPath, vendored);
193
- logger?.log?.(`[claude-bin] vendored claude v${version} ← ${systemPath} → ${vendored}`);
194
- } catch (e) {
195
- return { ok: false, path: vendored, reason: `copy ${systemPath} → ${vendored} failed: ${e.message}` };
196
- }
197
- } else {
198
- // (b) try to install the exact version, then copy. If
199
- // POLYGRAM_CLAUDE_INSTALL_BIN is set, use it VERBATIM (no fallback — an
200
- // explicit override that's wrong must fail loudly, not silently shell out to
201
- // a different claude). Otherwise prefer ~/.local/bin/claude, else PATH.
202
- let installerBin = process.env.POLYGRAM_CLAUDE_INSTALL_BIN;
203
- if (!installerBin) {
204
- const localBin = path.join(os.homedir(), '.local', 'bin', 'claude');
205
- installerBin = isExecutable(localBin) ? localBin : 'claude';
206
- }
207
- logger?.warn?.(`[claude-bin] pinned claude v${version} absent from ${systemPath}; installing via ${installerBin}…`);
208
- try {
209
- // Synchronous: blocks boot until the install completes. Rare (deploys
210
- // pre-install the pin → the fast copy path above is the norm). On the VPS
211
- // polygram boots DETACHED in tmux (Type=oneshot start-sessions.sh), so
212
- // this block is NOT gated by systemd's TimeoutStartSec; on the Mac launchd
213
- // has no hard start-timeout. Timeout kept under the VPS unit's 120s anyway.
214
- execFileSync(installerBin, ['install', version], { timeout: 110_000, stdio: 'ignore' });
215
- } catch (e) {
216
- return {
217
- ok: false, path: vendored,
218
- reason: `claude v${version} not present and \`claude install ${version}\` failed (${e.message}). `
219
- + 'Install it manually or set POLYGRAM_CLAUDE_BIN.',
220
- };
221
- }
222
- if (!isExecutable(systemPath)) {
223
- return { ok: false, path: vendored, reason: `claude install ${version} ran but ${systemPath} still missing` };
224
- }
225
- try {
226
- _atomicCopyExec(systemPath, vendored);
227
- logger?.log?.(`[claude-bin] installed + vendored claude v${version} → ${vendored}`);
228
- } catch (e) {
229
- return { ok: false, path: vendored, reason: `copy after install failed: ${e.message}` };
230
- }
231
- }
232
-
233
- _gcVendored(dir, version, logger);
234
- if (!isExecutable(vendored)) {
235
- return { ok: false, path: vendored, reason: `vendored copy ${vendored} is not executable after copy` };
236
- }
237
- return { ok: true, path: vendored, vendored: true };
238
- }
239
-
240
- module.exports = {
241
- resolvePinnedClaudeBin,
242
- verifyPinnedClaudeBin,
243
- ensureVendoredClaudeBin,
244
- vendorDir,
245
- CLAUDE_CLI_PINNED_VERSION,
246
- };
@@ -1,59 +0,0 @@
1
- /**
2
- * compaction-warn — per-chat config resolution + warn-once state for the
3
- * compaction warning (0.12.0-rc.13).
4
- *
5
- * The warning is OFF by default. A chat (or topic) opts in via
6
- * `compactionWarnings`:
7
- * true → enabled, default threshold
8
- * { enabled: true, thresholdPct: 80 } → enabled, custom threshold
9
- * false / absent / object w/o enabled → off
10
- *
11
- * `thresholdPct` is the context-fill % at which the PROACTIVE warning fires
12
- * (propose /compact before claude auto-compacts mid-turn). Default 75 — below
13
- * claude's own auto-compact threshold so the user gets a window to act.
14
- */
15
-
16
- 'use strict';
17
-
18
- const DEFAULT_THRESHOLD_PCT = 75;
19
-
20
- /**
21
- * @param {object|undefined} cfg resolved topic/chat config (getTopicConfig result)
22
- * @returns {{enabled: boolean, thresholdPct: number}}
23
- */
24
- function resolveCompactionWarnConfig(cfg) {
25
- const raw = cfg?.compactionWarnings;
26
- const off = { enabled: false, thresholdPct: DEFAULT_THRESHOLD_PCT };
27
-
28
- if (raw === true) return { enabled: true, thresholdPct: DEFAULT_THRESHOLD_PCT };
29
- if (raw && typeof raw === 'object' && raw.enabled === true) {
30
- const t = Number(raw.thresholdPct);
31
- const thresholdPct = (Number.isFinite(t) && t > 0 && t < 100) ? t : DEFAULT_THRESHOLD_PCT;
32
- return { enabled: true, thresholdPct };
33
- }
34
- return off;
35
- }
36
-
37
- /**
38
- * Per-session "have we already warned on this climb?" state. Warn ONCE per
39
- * session until reset — without this the proactive warning would re-fire on
40
- * every turn-end while the context stays high. Reset on a successful
41
- * compaction (PostCompact → context dropped) or a fresh session so the next
42
- * climb can warn again. Mirrors the autoResumeTracker shape.
43
- */
44
- function createCompactionWarnTracker() {
45
- const warned = new Set();
46
- return {
47
- shouldWarn(sessionKey) { return !warned.has(sessionKey); },
48
- markWarned(sessionKey) { warned.add(sessionKey); },
49
- reset(sessionKey) { warned.delete(sessionKey); },
50
- resetAll() { warned.clear(); },
51
- _size() { return warned.size; },
52
- };
53
- }
54
-
55
- module.exports = {
56
- resolveCompactionWarnConfig,
57
- createCompactionWarnTracker,
58
- DEFAULT_THRESHOLD_PCT,
59
- };
@@ -1,93 +0,0 @@
1
- /**
2
- * context-usage — read live context occupancy from a Claude Code session
3
- * transcript (JSONL).
4
- *
5
- * Used by the per-chat compaction warning (0.12.0-rc.13). polygram has no
6
- * usage payload on the channels/CLI backend (hook events carry none — see
7
- * the rc.13 spike), so the only source of "how full is the context" is the
8
- * transcript itself. We read it ONCE per turn-end (Stop hook), not on a
9
- * poll loop, so a single streamed pass is fine.
10
- *
11
- * What "occupancy" means: Claude's own context-% / auto-compact threshold is
12
- * measured against what's fed INTO the model each turn —
13
- * input_tokens + cache_read_input_tokens + cache_creation_input_tokens
14
- * (cache_read dominates once the conversation is warm). output_tokens is the
15
- * reply, not context, so it's excluded.
16
- *
17
- * We take the LAST main-thread (non-sidechain) assistant frame with a usage
18
- * block. Subagents write to their own agent_transcript_path so sidechain
19
- * frames don't normally appear here, but we skip them defensively: a format
20
- * change that inlined a subagent's large usage would otherwise spike the
21
- * parent's apparent context and trigger a false "you're full" warning.
22
- */
23
-
24
- 'use strict';
25
-
26
- const fs = require('node:fs');
27
- const readline = require('node:readline');
28
-
29
- // Standard Claude context window (sonnet/opus, non-beta). The warning is a
30
- // heuristic ("you're getting full"), so an approximate denominator is fine;
31
- // callers can pass a different window for 1M-beta sessions.
32
- const DEFAULT_WINDOW_TOKENS = 200_000;
33
-
34
- /**
35
- * @param {string} transcriptPath
36
- * @returns {Promise<{inputTokens:number, cacheReadTokens:number, cacheCreationTokens:number, total:number} | null>}
37
- * null when the path is falsy/unreadable or no usable usage frame exists.
38
- */
39
- async function readContextTokens(transcriptPath) {
40
- if (!transcriptPath) return null;
41
-
42
- let stream;
43
- try {
44
- stream = fs.createReadStream(transcriptPath, { encoding: 'utf8' });
45
- } catch {
46
- return null;
47
- }
48
-
49
- return new Promise((resolve) => {
50
- let last = null;
51
- // Resolve only once — error and close can both fire.
52
- let done = false;
53
- const finish = (v) => { if (!done) { done = true; resolve(v); } };
54
-
55
- stream.on('error', () => finish(null));
56
-
57
- const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
58
- // readline forwards the input stream's 'error' (e.g. ENOENT on open) to
59
- // the interface; without this handler that re-emit is unhandled and
60
- // crashes the process even though we resolved null on the stream error.
61
- rl.on('error', () => finish(null));
62
- rl.on('line', (line) => {
63
- if (!line) return;
64
- let o;
65
- try { o = JSON.parse(line); } catch { return; } // skip partial/non-JSON lines
66
- if (!o || o.type !== 'assistant' || o.isSidechain === true) return;
67
- const u = o.message?.usage;
68
- if (!u) return;
69
- const inputTokens = Number(u.input_tokens) || 0;
70
- const cacheReadTokens = Number(u.cache_read_input_tokens) || 0;
71
- const cacheCreationTokens = Number(u.cache_creation_input_tokens) || 0;
72
- const total = inputTokens + cacheReadTokens + cacheCreationTokens;
73
- if (total > 0) last = { inputTokens, cacheReadTokens, cacheCreationTokens, total };
74
- });
75
- rl.on('close', () => finish(last));
76
- });
77
- }
78
-
79
- /**
80
- * Fraction (0..1) of the context window currently occupied. Clamps to 0 on
81
- * non-positive / non-finite inputs so callers never see NaN/Infinity.
82
- *
83
- * @param {number} totalTokens
84
- * @param {number} [windowTokens=DEFAULT_WINDOW_TOKENS]
85
- * @returns {number}
86
- */
87
- function contextPct(totalTokens, windowTokens = DEFAULT_WINDOW_TOKENS) {
88
- if (!Number.isFinite(totalTokens) || totalTokens <= 0) return 0;
89
- if (!Number.isFinite(windowTokens) || windowTokens <= 0) return 0;
90
- return totalTokens / windowTokens;
91
- }
92
-
93
- module.exports = { readContextTokens, contextPct, DEFAULT_WINDOW_TOKENS };
@@ -1,199 +0,0 @@
1
- /**
2
- * Bridge ↔ daemon socket protocol — typed schemas.
3
- *
4
- * Wire format: newline-delimited JSON over a unix socket per session.
5
- * Both endpoints (CliProcess and channels-bridge.mjs) speak the same
6
- * message kinds. This module centralizes the shape so both sides safeParse
7
- * inbound messages with the same constraints — protecting against malformed
8
- * payloads silently corrupting pending-state Maps.
9
- *
10
- * Adding a new message kind:
11
- * 1. Define its schema below as `<KindName>MessageSchema`
12
- * 2. Add it to `AnyDaemonToBridgeMessage` or `AnyBridgeToDaemonMessage`
13
- * 3. Handle it in the corresponding switch (cli-process.js
14
- * _onBridgeMsg or channels-bridge.mjs handleDaemonMessage)
15
- *
16
- * Validation policy:
17
- * - Daemon side uses `safeParse` and drops malformed messages with a warn
18
- * (downgrades silent corruption into observable log)
19
- * - Bridge side does the same on inbound from daemon
20
- * - All validation happens AFTER hello-handshake auth (the auth gate is
21
- * the first line of defense; schema is the second)
22
- */
23
-
24
- 'use strict';
25
-
26
- const { z } = require('zod');
27
-
28
- // ─── shared primitives ─────────────────────────────────────────────
29
-
30
- const NonEmptyString = z.string().min(1);
31
- const OptionalString = z.string().optional();
32
- const ToolCallId = z.string().min(1);
33
- const RequestId = z.string().min(1);
34
- const TurnId = z.string().min(1);
35
-
36
- // ─── bridge → daemon ───────────────────────────────────────────────
37
-
38
- const HelloSchema = z.object({
39
- kind: z.literal('hello'),
40
- session_key: NonEmptyString,
41
- secret: NonEmptyString,
42
- }).passthrough();
43
-
44
- const SessionInitSchema = z.object({
45
- kind: z.literal('session_init'),
46
- claude_session_id: z.string(), // may be empty if claude generated one before bridge sees it
47
- }).passthrough();
48
-
49
- const ToolCallMessageSchema = z.object({
50
- kind: z.literal('tool'),
51
- session: NonEmptyString,
52
- tool_call_id: ToolCallId,
53
- // 'ask' (0.12 interactive questions): a blocking tool whose answer rides back
54
- // on a `question_answer` daemon→bridge message (NOT the fast `tool_ack`); its
55
- // args are {chat_id, turn_id?, questions:[...]}, not reply-shaped. _dispatchToolCall
56
- // branches on the name so the reply-only paths (chat_id-mismatch, content-dedup,
57
- // reply-turn-binding) don't run for it.
58
- name: z.enum(['reply', 'react', 'edit_message', 'ask']),
59
- args: z.object({}).passthrough(),
60
- }).passthrough();
61
-
62
- const PermRequestMessageSchema = z.object({
63
- kind: z.literal('perm_req'),
64
- session: NonEmptyString,
65
- request_id: RequestId,
66
- tool_name: NonEmptyString,
67
- description: z.string(),
68
- input_preview: z.string(),
69
- }).passthrough();
70
-
71
- const PongMessageSchema = z.object({
72
- kind: z.literal('pong'),
73
- }).passthrough();
74
-
75
- // 0.12 Phase 1.6: bridge tells daemon when claude has finished registering
76
- // the bridge as an MCP server (claude sent its first ListToolsRequest).
77
- // Polygram's _waitForBridgeHandshake gates on this in addition to hello,
78
- // eliminating the cold-spawn race (Finding 0.3.A).
79
- const McpReadyMessageSchema = z.object({
80
- kind: z.literal('mcp-ready'),
81
- session: NonEmptyString,
82
- }).passthrough();
83
-
84
- const AnyBridgeToDaemonMessage = z.discriminatedUnion('kind', [
85
- HelloSchema,
86
- SessionInitSchema,
87
- ToolCallMessageSchema,
88
- PermRequestMessageSchema,
89
- PongMessageSchema,
90
- McpReadyMessageSchema,
91
- ]);
92
-
93
- // ─── daemon → bridge ───────────────────────────────────────────────
94
-
95
- const HelloAckSchema = z.object({
96
- kind: z.literal('hello_ack'),
97
- }).passthrough();
98
-
99
- const HelloRejectSchema = z.object({
100
- kind: z.literal('hello_reject'),
101
- reason: z.string().optional(),
102
- }).passthrough();
103
-
104
- const UserMessageSchema = z.object({
105
- kind: z.literal('user_msg'),
106
- text: z.string(),
107
- chat_id: z.union([z.string(), z.number()]).optional(),
108
- user: OptionalString,
109
- msg_id: z.union([z.string(), z.number()]).optional(),
110
- turn_id: OptionalString,
111
- }).passthrough();
112
-
113
- const PermVerdictMessageSchema = z.object({
114
- kind: z.literal('perm_verdict'),
115
- request_id: RequestId,
116
- behavior: z.enum(['allow', 'deny']),
117
- }).passthrough();
118
-
119
- const ToolAckMessageSchema = z.object({
120
- kind: z.literal('tool_ack'),
121
- tool_call_id: ToolCallId,
122
- ok: z.boolean(),
123
- error: z.string().optional(),
124
- // 0.13: the delivered Telegram message_id, surfaced back to claude so it can
125
- // `edit_message` that bubble for progressive status. Present on a successful
126
- // `reply`/`edit_message` ack; absent on errors / re-acks.
127
- message_id: z.union([z.number(), z.string()]).nullish(),
128
- }).passthrough();
129
-
130
- // 0.12 interactive questions: carries the user's answer back for an `ask` tool
131
- // call. Separate from `tool_ack` (which has no payload field and resolves the
132
- // fast reply round-trip) so a blocking question can return a structured result.
133
- // `result` is one of {answers:[...]} | {cancelled:true} | {timedout:true}.
134
- const QuestionAnswerMessageSchema = z.object({
135
- kind: z.literal('question_answer'),
136
- tool_call_id: ToolCallId,
137
- result: z.object({}).passthrough(),
138
- }).passthrough();
139
-
140
- const PingMessageSchema = z.object({
141
- kind: z.literal('ping'),
142
- }).passthrough();
143
-
144
- const AnyDaemonToBridgeMessage = z.discriminatedUnion('kind', [
145
- HelloAckSchema,
146
- HelloRejectSchema,
147
- UserMessageSchema,
148
- PermVerdictMessageSchema,
149
- ToolAckMessageSchema,
150
- QuestionAnswerMessageSchema,
151
- PingMessageSchema,
152
- ]);
153
-
154
- // ─── helpers ──────────────────────────────────────────────────────
155
-
156
- /**
157
- * Parse + validate a bridge → daemon message. Returns
158
- * {ok:true, msg} on success or {ok:false, error} on failure.
159
- *
160
- * @param {unknown} raw — already JSON.parsed object
161
- * @returns {{ok: true, msg: object}|{ok: false, error: string}}
162
- */
163
- function parseBridgeToDaemonMessage(raw) {
164
- const r = AnyBridgeToDaemonMessage.safeParse(raw);
165
- if (r.success) return { ok: true, msg: r.data };
166
- return { ok: false, error: zodErrorBrief(r.error, raw?.kind) };
167
- }
168
-
169
- function parseDaemonToBridgeMessage(raw) {
170
- const r = AnyDaemonToBridgeMessage.safeParse(raw);
171
- if (r.success) return { ok: true, msg: r.data };
172
- return { ok: false, error: zodErrorBrief(r.error, raw?.kind) };
173
- }
174
-
175
- function zodErrorBrief(err, kindHint) {
176
- const issues = (err?.issues || []).slice(0, 3).map(i => `${i.path.join('.')}: ${i.message}`);
177
- return `kind=${kindHint || '?'} — ${issues.join('; ') || 'unknown'}`;
178
- }
179
-
180
- module.exports = {
181
- // schemas (exported for tests + downstream consumers)
182
- HelloSchema,
183
- SessionInitSchema,
184
- ToolCallMessageSchema,
185
- PermRequestMessageSchema,
186
- PongMessageSchema,
187
- AnyBridgeToDaemonMessage,
188
- HelloAckSchema,
189
- HelloRejectSchema,
190
- UserMessageSchema,
191
- PermVerdictMessageSchema,
192
- ToolAckMessageSchema,
193
- QuestionAnswerMessageSchema,
194
- PingMessageSchema,
195
- AnyDaemonToBridgeMessage,
196
- // helpers
197
- parseBridgeToDaemonMessage,
198
- parseDaemonToBridgeMessage,
199
- };