@shumkov/orchestra 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,247 @@
1
+ // provenance: polygram@0.17.11 lib/claude-bin.js (git 746bca6) — verbatim*: env prefix WATER_, bridge name water-bridge, vendor path (SHARED-LIB.md).
2
+ 'use strict';
3
+
4
+ const os = require('os');
5
+ const path = require('path');
6
+ const fs = require('fs');
7
+ const { execFileSync } = require('child_process');
8
+
9
+ // 0.12 Phase 4: moved from lib/process/tmux-process.js into the helper module
10
+ // that consumes it, so the constant survives TmuxProcess deletion. CliProcess
11
+ // + spike scripts + polygram boot all import from here now.
12
+ // 0.12.0-rc.18: bumped 2.1.142 → 2.1.158 (latest installed) chasing the
13
+ // dev-channels reliability issues (see docs/0.12.0-known-issues.md).
14
+ // 0.12.0-rc.38: bumped 2.1.158 → 2.1.173. Two reasons: (1) the ~32s startup
15
+ // deaths root-caused 2026-06-11 to a stale MCP connect-timeout racing the
16
+ // --resume session-id swap — a newer claude may fix the timer (2.1.173 also
17
+ // adds "Channel notifications re-registered after reconnect"); (2) keep the
18
+ // research-preview channels current. Per-bump re-validation done 2026-06-11:
19
+ // resume-dialog env vars survive (CLAUDE_CODE_RESUME_THRESHOLD_MINUTES /
20
+ // _TOKEN_THRESHOLD), trust + dev-channels dialogs unchanged, "esc to
21
+ // interrupt" hint unchanged (template-rendered), but the channels READY
22
+ // banner text CHANGED → readySignal in cli-process.js matches both forms.
23
+ // Re-validate the channel flow on each bump via
24
+ // tests/e2e-channels-real-claude.test.js (run with E2E_REAL_CLAUDE=1).
25
+ const CLAUDE_CLI_PINNED_VERSION = '2.1.173';
26
+
27
+ /**
28
+ * Resolve + verify the pinned claude CLI binary.
29
+ *
30
+ * Why this exists: the tmux + CLI backends read claude CLI internal
31
+ * artefacts (TUI banner ASCII, READY hint strings, channel notification
32
+ * registration timing, MCP-init order) — none a stable public contract.
33
+ * polygram pins ONE version (`CLAUDE_CLI_PINNED_VERSION`) and must
34
+ * spawn THAT binary, never whatever `claude` on $PATH happens to
35
+ * resolve to.
36
+ *
37
+ * Before this module the tmux runner spawned the bare string
38
+ * `claude`, resolved through $PATH. The claude CLI installs each
39
+ * version as a standalone binary at
40
+ * ~/.local/share/claude/versions/<version>
41
+ * and points ~/.local/bin/claude (a symlink) at the active one.
42
+ * Its auto-updater re-points that symlink whenever a new version
43
+ * lands — so a $PATH spawn silently drifts (shumorobot 2026-05-16:
44
+ * CLI auto-updated 2.1.142 → 2.1.143 between deploys).
45
+ *
46
+ * Spawning the ABSOLUTE versioned path avoids the symlink-drift, but is
47
+ * NOT immune to the updater: claude keeps only the ~3 newest versions
48
+ * and PRUNES (deletes) the rest. Once the pin falls out of the top 3 the
49
+ * pinned path is a dead file → every cli spawn exits in ~14ms (prod
50
+ * outages 2026-06-21/22). So `verifyPinnedClaudeBin` (point-in-time check)
51
+ * is not enough; `ensureVendoredClaudeBin` (below, 0.17) keeps a
52
+ * polygram-owned copy the pruner can't touch.
53
+ */
54
+
55
+ /**
56
+ * Absolute path to the pinned claude binary.
57
+ *
58
+ * Resolution order:
59
+ * 1. ORCHESTRA_CLAUDE_BIN env — explicit override (non-standard
60
+ * installs, CI, hosts where the layout differs).
61
+ * 2. ~/.local/share/claude/versions/<version> — the standard
62
+ * claude-CLI install location.
63
+ *
64
+ * The returned path is NOT guaranteed to exist — callers verify
65
+ * via verifyPinnedClaudeBin().
66
+ *
67
+ * @param {string} version — pinned version, e.g. '2.1.142'
68
+ * @returns {string} absolute path
69
+ */
70
+ function resolvePinnedClaudeBin(version) {
71
+ const override = process.env.ORCHESTRA_CLAUDE_BIN;
72
+ if (override) return override;
73
+ return path.join(os.homedir(), '.local', 'share', 'claude', 'versions', version);
74
+ }
75
+
76
+ /**
77
+ * Verify the pinned binary exists and is executable.
78
+ *
79
+ * @param {string} version — pinned version, e.g. '2.1.142'
80
+ * @returns {{ ok: boolean, path: string, reason?: string }}
81
+ * ok=true → path is a spawnable binary.
82
+ * ok=false → reason carries an operator-actionable message.
83
+ */
84
+ function verifyPinnedClaudeBin(version) {
85
+ const binPath = resolvePinnedClaudeBin(version);
86
+ try {
87
+ fs.accessSync(binPath, fs.constants.X_OK);
88
+ return { ok: true, path: binPath };
89
+ } catch (err) {
90
+ const code = err && err.code ? err.code : (err && err.message) || 'unknown';
91
+ return {
92
+ ok: false,
93
+ path: binPath,
94
+ reason: `pinned claude CLI v${version} not found or not executable at `
95
+ + `${binPath} (${code}). Install it with \`claude install ${version}\` `
96
+ + 'or set ORCHESTRA_CLAUDE_BIN to the correct binary path.',
97
+ };
98
+ }
99
+ }
100
+
101
+ // ─── 0.17: vendored pinned binary (immune to claude's auto-pruner) ──────────
102
+ //
103
+ // claude's updater deletes all but the ~3 newest versions, so the pinned
104
+ // version eventually vanishes from ~/.local/share/claude/versions and every
105
+ // cli spawn dies. We can't fall forward (the cli backend reads version-specific
106
+ // TUI internals). Fix: polygram keeps its OWN copy of the exact pinned binary
107
+ // in a dir the pruner never touches, and spawns from there. Once vendored it
108
+ // never depends on the system copy or the network again.
109
+
110
+ /**
111
+ * polygram-owned vendor dir for claude binaries. Under ~/.local/share/water
112
+ * (XDG data) — claude's pruner only touches ~/.local/share/claude/versions, and
113
+ * `npm i -g polygram` only replaces the package dir, so this survives both.
114
+ * Override with ORCHESTRA_CLAUDE_VENDOR_DIR.
115
+ */
116
+ function vendorDir() {
117
+ return process.env.ORCHESTRA_CLAUDE_VENDOR_DIR
118
+ || path.join(os.homedir(), '.local', 'share', 'orchestra', 'claude-bin');
119
+ }
120
+
121
+ function isExecutable(p) {
122
+ try { fs.accessSync(p, fs.constants.X_OK); return true; } catch { return false; }
123
+ }
124
+
125
+ // Atomic: copy to a unique tmp in the same dir, chmod, then rename over.
126
+ function _atomicCopyExec(src, dst) {
127
+ const tmp = `${dst}.tmp.${process.pid}.${Date.now()}`;
128
+ fs.copyFileSync(src, tmp);
129
+ fs.chmodSync(tmp, 0o755);
130
+ fs.renameSync(tmp, dst);
131
+ }
132
+
133
+ // Remove vendored binaries (and stale .tmp.*) that aren't the live version.
134
+ function _gcVendored(dir, keepVersion, logger) {
135
+ let entries = [];
136
+ try { entries = fs.readdirSync(dir); } catch { return; }
137
+ for (const name of entries) {
138
+ if (name === keepVersion) continue;
139
+ // Never delete an in-flight copy: a CONCURRENT boot (multi-bot host shares
140
+ // this dir) may be mid-copy into `<keepVersion>.tmp.<pid>.<ts>`; removing it
141
+ // ENOENTs that boot's rename → it falls back to SDK. Skip all .tmp.* — a
142
+ // genuinely orphaned tmp is cheap to leave (cleaned when its version is GC'd
143
+ // by name, or harmless). Defense-in-depth: only GC version-shaped names so a
144
+ // misconfigured vendor dir can't nuke unrelated files.
145
+ if (name.includes('.tmp.')) continue;
146
+ if (!/^\d+\.\d+\.\d+$/.test(name)) continue;
147
+ try { fs.rmSync(path.join(dir, name), { force: true }); } catch (e) {
148
+ logger?.warn?.(`[claude-bin] vendor GC: could not remove ${name}: ${e.message}`);
149
+ }
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Ensure a polygram-owned copy of the pinned claude binary exists and return
155
+ * its path. Steady state is a single stat (fast). On a cold/pruned host it
156
+ * obtains the binary once (copy from the system install, else `claude install`
157
+ * then copy) and caches it forever.
158
+ *
159
+ * @param {string} version
160
+ * @param {{ logger?: object }} [opts]
161
+ * @returns {{ ok: boolean, path: string, vendored?: boolean, reason?: string }}
162
+ */
163
+ function ensureVendoredClaudeBin(version, { logger = console } = {}) {
164
+ // Explicit override wins, unchanged — non-standard installs / CI / tests.
165
+ const override = process.env.ORCHESTRA_CLAUDE_BIN;
166
+ if (override) {
167
+ return isExecutable(override)
168
+ ? { ok: true, path: override, vendored: false }
169
+ : { ok: false, path: override, reason: `ORCHESTRA_CLAUDE_BIN=${override} not executable` };
170
+ }
171
+
172
+ const dir = vendorDir();
173
+ const vendored = path.join(dir, version);
174
+
175
+ // Fast path: already vendored.
176
+ if (isExecutable(vendored)) {
177
+ _gcVendored(dir, version, logger);
178
+ return { ok: true, path: vendored, vendored: true };
179
+ }
180
+
181
+ // Need to obtain it. Ensure the dir exists.
182
+ try { fs.mkdirSync(dir, { recursive: true }); } catch (e) {
183
+ return { ok: false, path: vendored, reason: `cannot create vendor dir ${dir}: ${e.message}` };
184
+ }
185
+
186
+ const versionsDir = process.env.ORCHESTRA_CLAUDE_VERSIONS_DIR
187
+ || path.join(os.homedir(), '.local', 'share', 'claude', 'versions');
188
+ const systemPath = path.join(versionsDir, version);
189
+
190
+ // (a) copy from the system install if present.
191
+ if (isExecutable(systemPath)) {
192
+ try {
193
+ _atomicCopyExec(systemPath, vendored);
194
+ logger?.log?.(`[claude-bin] vendored claude v${version} ← ${systemPath} → ${vendored}`);
195
+ } catch (e) {
196
+ return { ok: false, path: vendored, reason: `copy ${systemPath} → ${vendored} failed: ${e.message}` };
197
+ }
198
+ } else {
199
+ // (b) try to install the exact version, then copy. If
200
+ // ORCHESTRA_CLAUDE_INSTALL_BIN is set, use it VERBATIM (no fallback — an
201
+ // explicit override that's wrong must fail loudly, not silently shell out to
202
+ // a different claude). Otherwise prefer ~/.local/bin/claude, else PATH.
203
+ let installerBin = process.env.ORCHESTRA_CLAUDE_INSTALL_BIN;
204
+ if (!installerBin) {
205
+ const localBin = path.join(os.homedir(), '.local', 'bin', 'claude');
206
+ installerBin = isExecutable(localBin) ? localBin : 'claude';
207
+ }
208
+ logger?.warn?.(`[claude-bin] pinned claude v${version} absent from ${systemPath}; installing via ${installerBin}…`);
209
+ try {
210
+ // Synchronous: blocks boot until the install completes. Rare (deploys
211
+ // pre-install the pin → the fast copy path above is the norm). On the VPS
212
+ // polygram boots DETACHED in tmux (Type=oneshot start-sessions.sh), so
213
+ // this block is NOT gated by systemd's TimeoutStartSec; on the Mac launchd
214
+ // has no hard start-timeout. Timeout kept under the VPS unit's 120s anyway.
215
+ execFileSync(installerBin, ['install', version], { timeout: 110_000, stdio: 'ignore' });
216
+ } catch (e) {
217
+ return {
218
+ ok: false, path: vendored,
219
+ reason: `claude v${version} not present and \`claude install ${version}\` failed (${e.message}). `
220
+ + 'Install it manually or set ORCHESTRA_CLAUDE_BIN.',
221
+ };
222
+ }
223
+ if (!isExecutable(systemPath)) {
224
+ return { ok: false, path: vendored, reason: `claude install ${version} ran but ${systemPath} still missing` };
225
+ }
226
+ try {
227
+ _atomicCopyExec(systemPath, vendored);
228
+ logger?.log?.(`[claude-bin] installed + vendored claude v${version} → ${vendored}`);
229
+ } catch (e) {
230
+ return { ok: false, path: vendored, reason: `copy after install failed: ${e.message}` };
231
+ }
232
+ }
233
+
234
+ _gcVendored(dir, version, logger);
235
+ if (!isExecutable(vendored)) {
236
+ return { ok: false, path: vendored, reason: `vendored copy ${vendored} is not executable after copy` };
237
+ }
238
+ return { ok: true, path: vendored, vendored: true };
239
+ }
240
+
241
+ module.exports = {
242
+ resolvePinnedClaudeBin,
243
+ verifyPinnedClaudeBin,
244
+ ensureVendoredClaudeBin,
245
+ vendorDir,
246
+ CLAUDE_CLI_PINNED_VERSION,
247
+ };
@@ -0,0 +1,60 @@
1
+ // provenance: polygram@0.17.11 lib/compaction-warn.js (git 746bca6) — verbatim: env prefix WATER_, bridge name water-bridge, vendor path (SHARED-LIB.md).
2
+ /**
3
+ * compaction-warn — per-chat config resolution + warn-once state for the
4
+ * compaction warning (0.12.0-rc.13).
5
+ *
6
+ * The warning is OFF by default. A chat (or topic) opts in via
7
+ * `compactionWarnings`:
8
+ * true → enabled, default threshold
9
+ * { enabled: true, thresholdPct: 80 } → enabled, custom threshold
10
+ * false / absent / object w/o enabled → off
11
+ *
12
+ * `thresholdPct` is the context-fill % at which the PROACTIVE warning fires
13
+ * (propose /compact before claude auto-compacts mid-turn). Default 75 — below
14
+ * claude's own auto-compact threshold so the user gets a window to act.
15
+ */
16
+
17
+ 'use strict';
18
+
19
+ const DEFAULT_THRESHOLD_PCT = 75;
20
+
21
+ /**
22
+ * @param {object|undefined} cfg resolved topic/chat config (getTopicConfig result)
23
+ * @returns {{enabled: boolean, thresholdPct: number}}
24
+ */
25
+ function resolveCompactionWarnConfig(cfg) {
26
+ const raw = cfg?.compactionWarnings;
27
+ const off = { enabled: false, thresholdPct: DEFAULT_THRESHOLD_PCT };
28
+
29
+ if (raw === true) return { enabled: true, thresholdPct: DEFAULT_THRESHOLD_PCT };
30
+ if (raw && typeof raw === 'object' && raw.enabled === true) {
31
+ const t = Number(raw.thresholdPct);
32
+ const thresholdPct = (Number.isFinite(t) && t > 0 && t < 100) ? t : DEFAULT_THRESHOLD_PCT;
33
+ return { enabled: true, thresholdPct };
34
+ }
35
+ return off;
36
+ }
37
+
38
+ /**
39
+ * Per-session "have we already warned on this climb?" state. Warn ONCE per
40
+ * session until reset — without this the proactive warning would re-fire on
41
+ * every turn-end while the context stays high. Reset on a successful
42
+ * compaction (PostCompact → context dropped) or a fresh session so the next
43
+ * climb can warn again. Mirrors the autoResumeTracker shape.
44
+ */
45
+ function createCompactionWarnTracker() {
46
+ const warned = new Set();
47
+ return {
48
+ shouldWarn(sessionKey) { return !warned.has(sessionKey); },
49
+ markWarned(sessionKey) { warned.add(sessionKey); },
50
+ reset(sessionKey) { warned.delete(sessionKey); },
51
+ resetAll() { warned.clear(); },
52
+ _size() { return warned.size; },
53
+ };
54
+ }
55
+
56
+ module.exports = {
57
+ resolveCompactionWarnConfig,
58
+ createCompactionWarnTracker,
59
+ DEFAULT_THRESHOLD_PCT,
60
+ };
@@ -0,0 +1,94 @@
1
+ // provenance: polygram@0.17.11 lib/context-usage.js (git 746bca6) — verbatim: env prefix WATER_, bridge name water-bridge, vendor path (SHARED-LIB.md).
2
+ /**
3
+ * context-usage — read live context occupancy from a Claude Code session
4
+ * transcript (JSONL).
5
+ *
6
+ * Used by the per-chat compaction warning (0.12.0-rc.13). polygram has no
7
+ * usage payload on the channels/CLI backend (hook events carry none — see
8
+ * the rc.13 spike), so the only source of "how full is the context" is the
9
+ * transcript itself. We read it ONCE per turn-end (Stop hook), not on a
10
+ * poll loop, so a single streamed pass is fine.
11
+ *
12
+ * What "occupancy" means: Claude's own context-% / auto-compact threshold is
13
+ * measured against what's fed INTO the model each turn —
14
+ * input_tokens + cache_read_input_tokens + cache_creation_input_tokens
15
+ * (cache_read dominates once the conversation is warm). output_tokens is the
16
+ * reply, not context, so it's excluded.
17
+ *
18
+ * We take the LAST main-thread (non-sidechain) assistant frame with a usage
19
+ * block. Subagents write to their own agent_transcript_path so sidechain
20
+ * frames don't normally appear here, but we skip them defensively: a format
21
+ * change that inlined a subagent's large usage would otherwise spike the
22
+ * parent's apparent context and trigger a false "you're full" warning.
23
+ */
24
+
25
+ 'use strict';
26
+
27
+ const fs = require('node:fs');
28
+ const readline = require('node:readline');
29
+
30
+ // Standard Claude context window (sonnet/opus, non-beta). The warning is a
31
+ // heuristic ("you're getting full"), so an approximate denominator is fine;
32
+ // callers can pass a different window for 1M-beta sessions.
33
+ const DEFAULT_WINDOW_TOKENS = 200_000;
34
+
35
+ /**
36
+ * @param {string} transcriptPath
37
+ * @returns {Promise<{inputTokens:number, cacheReadTokens:number, cacheCreationTokens:number, total:number} | null>}
38
+ * null when the path is falsy/unreadable or no usable usage frame exists.
39
+ */
40
+ async function readContextTokens(transcriptPath) {
41
+ if (!transcriptPath) return null;
42
+
43
+ let stream;
44
+ try {
45
+ stream = fs.createReadStream(transcriptPath, { encoding: 'utf8' });
46
+ } catch {
47
+ return null;
48
+ }
49
+
50
+ return new Promise((resolve) => {
51
+ let last = null;
52
+ // Resolve only once — error and close can both fire.
53
+ let done = false;
54
+ const finish = (v) => { if (!done) { done = true; resolve(v); } };
55
+
56
+ stream.on('error', () => finish(null));
57
+
58
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
59
+ // readline forwards the input stream's 'error' (e.g. ENOENT on open) to
60
+ // the interface; without this handler that re-emit is unhandled and
61
+ // crashes the process even though we resolved null on the stream error.
62
+ rl.on('error', () => finish(null));
63
+ rl.on('line', (line) => {
64
+ if (!line) return;
65
+ let o;
66
+ try { o = JSON.parse(line); } catch { return; } // skip partial/non-JSON lines
67
+ if (!o || o.type !== 'assistant' || o.isSidechain === true) return;
68
+ const u = o.message?.usage;
69
+ if (!u) return;
70
+ const inputTokens = Number(u.input_tokens) || 0;
71
+ const cacheReadTokens = Number(u.cache_read_input_tokens) || 0;
72
+ const cacheCreationTokens = Number(u.cache_creation_input_tokens) || 0;
73
+ const total = inputTokens + cacheReadTokens + cacheCreationTokens;
74
+ if (total > 0) last = { inputTokens, cacheReadTokens, cacheCreationTokens, total };
75
+ });
76
+ rl.on('close', () => finish(last));
77
+ });
78
+ }
79
+
80
+ /**
81
+ * Fraction (0..1) of the context window currently occupied. Clamps to 0 on
82
+ * non-positive / non-finite inputs so callers never see NaN/Infinity.
83
+ *
84
+ * @param {number} totalTokens
85
+ * @param {number} [windowTokens=DEFAULT_WINDOW_TOKENS]
86
+ * @returns {number}
87
+ */
88
+ function contextPct(totalTokens, windowTokens = DEFAULT_WINDOW_TOKENS) {
89
+ if (!Number.isFinite(totalTokens) || totalTokens <= 0) return 0;
90
+ if (!Number.isFinite(windowTokens) || windowTokens <= 0) return 0;
91
+ return totalTokens / windowTokens;
92
+ }
93
+
94
+ module.exports = { readContextTokens, contextPct, DEFAULT_WINDOW_TOKENS };