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 +155 -4
- package/package.json +1 -1
- package/polygram.js +8 -4
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
|
|
45
|
-
*
|
|
46
|
-
*
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
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 {
|