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.
- package/.claude-plugin/plugin.json +1 -1
- package/lib/config.js +21 -0
- package/lib/error/classify.js +14 -0
- package/lib/handlers/dispatcher.js +54 -1
- package/lib/handlers/drop-redeliver.js +19 -0
- package/lib/handlers/should-handle.js +52 -0
- package/lib/media-group-buffer.js +29 -0
- package/lib/ops/auth-disabled-gate.js +43 -0
- package/lib/ops/heartbeat.js +56 -0
- package/lib/process/channels-tool-dispatcher.js +12 -1
- package/lib/prompt.js +22 -7
- package/lib/sdk/callbacks.js +2 -1
- package/lib/secret-detect.js +13 -1
- package/lib/telegram/chunk.js +20 -6
- package/lib/telegram/process-agent-reply.js +5 -3
- package/lib/telegram/streamer.js +6 -1
- package/package.json +2 -1
- package/polygram.js +202 -45
- package/lib/async-lock.js +0 -49
- package/lib/claude-bin.js +0 -246
- package/lib/compaction-warn.js +0 -59
- package/lib/context-usage.js +0 -93
- package/lib/process/channels-bridge-protocol.js +0 -199
- package/lib/process/channels-bridge-server.js +0 -274
- package/lib/process/channels-bridge.mjs +0 -477
- package/lib/process/cli-process.js +0 -4029
- package/lib/process/factory.js +0 -215
- package/lib/process/hook-event-tail.js +0 -162
- package/lib/process/hook-settings.js +0 -181
- package/lib/process/polygram-hook-append.js +0 -71
- package/lib/process/process.js +0 -215
- package/lib/process/sdk-process.js +0 -880
- package/lib/process-guard.js +0 -296
- package/lib/process-manager.js +0 -628
- package/lib/tmux/log-tail.js +0 -334
- package/lib/tmux/orphan-sweep.js +0 -79
- package/lib/tmux/poll-scheduler.js +0 -110
- package/lib/tmux/startup-gate.js +0 -250
- 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
|
-
};
|
package/lib/compaction-warn.js
DELETED
|
@@ -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
|
-
};
|
package/lib/context-usage.js
DELETED
|
@@ -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
|
-
};
|