polygram 0.16.0 → 0.17.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.
package/lib/claude-bin.js CHANGED
@@ -3,6 +3,7 @@
3
3
  const os = require('os');
4
4
  const path = require('path');
5
5
  const fs = require('fs');
6
+ const { execFileSync } = require('child_process');
6
7
 
7
8
  // 0.12 Phase 4: moved from lib/process/tmux-process.js into the helper module
8
9
  // that consumes it, so the constant survives TmuxProcess deletion. CliProcess
@@ -41,9 +42,13 @@ const CLAUDE_CLI_PINNED_VERSION = '2.1.173';
41
42
  * lands — so a $PATH spawn silently drifts (shumorobot 2026-05-16:
42
43
  * CLI auto-updated 2.1.142 → 2.1.143 between deploys).
43
44
  *
44
- * Spawning the ABSOLUTE versioned path is immune to that: the
45
- * updater only ADDS new version files, it never overwrites an
46
- * existing one. `versions/2.1.142` stays byte-identical forever.
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.
47
52
  */
48
53
 
49
54
  /**
@@ -92,4 +97,150 @@ function verifyPinnedClaudeBin(version) {
92
97
  }
93
98
  }
94
99
 
95
- module.exports = { resolvePinnedClaudeBin, verifyPinnedClaudeBin, CLAUDE_CLI_PINNED_VERSION };
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "Telegram daemon for Claude Code that preserves the OpenClaw per-chat session model. Migration path for OpenClaw users moving to Claude Code.",
5
5
  "main": "lib/ipc/client.js",
6
6
  "bin": {
package/polygram.js CHANGED
@@ -2484,12 +2484,16 @@ async function main() {
2484
2484
  // 0.11.0: binCheck reused for channels backend wiring below.
2485
2485
  let pinnedClaudeBin = null;
2486
2486
  {
2487
- const { CLAUDE_CLI_PINNED_VERSION } = require('./lib/claude-bin');
2488
- const { verifyPinnedClaudeBin } = require('./lib/claude-bin');
2489
- const binCheck = verifyPinnedClaudeBin(CLAUDE_CLI_PINNED_VERSION);
2487
+ // 0.17: vendor a polygram-owned copy of the pinned binary so claude's
2488
+ // auto-pruner (keeps only ~3 newest, deletes the rest) can't take cli chats
2489
+ // down. Spawns from ~/.local/share/polygram/claude-bin/<version>, immune to
2490
+ // pruning. Self-heals on boot (copy from the system install, else install).
2491
+ const { CLAUDE_CLI_PINNED_VERSION, ensureVendoredClaudeBin } = require('./lib/claude-bin');
2492
+ const binCheck = ensureVendoredClaudeBin(CLAUDE_CLI_PINNED_VERSION, { logger: console });
2490
2493
  if (binCheck.ok) {
2491
2494
  console.log(
2492
- `[polygram] CliProcess pinned to claude CLI v${CLAUDE_CLI_PINNED_VERSION}: ${binCheck.path}`,
2495
+ `[polygram] CliProcess pinned to claude CLI v${CLAUDE_CLI_PINNED_VERSION}: ${binCheck.path}`
2496
+ + `${binCheck.vendored ? ' (vendored)' : ''}`,
2493
2497
  );
2494
2498
  pinnedClaudeBin = binCheck.path;
2495
2499
  } else {