polygram 0.10.0-rc.2 → 0.10.0-rc.21
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/autosteered-refs.js +20 -2
- package/lib/claude-bin.js +78 -0
- package/lib/db/sessions.js +97 -1
- package/lib/handlers/autosteer.js +6 -0
- package/lib/process/tmux-process.js +967 -216
- package/lib/process-manager.js +56 -2
- package/lib/sdk/callbacks.js +219 -0
- package/lib/tmux/session-log-parser.js +302 -61
- package/lib/tmux/tmux-runner.js +59 -8
- package/package.json +1 -1
- package/polygram.js +150 -29
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://anthropic.com/claude-code/plugin.schema.json",
|
|
3
3
|
"name": "polygram",
|
|
4
|
-
"version": "0.10.0-rc.
|
|
4
|
+
"version": "0.10.0-rc.21",
|
|
5
5
|
"description": "Telegram integration for Claude Code that preserves the OpenClaw per-chat session model. Migration target for OpenClaw users. Multi-bot, multi-chat, per-topic isolation; SQLite transcripts; inline-keyboard approvals. Bundles /polygram:status|logs|pair-code|approvals admin commands plus history (transcript queries) and polygram-send (out-of-turn IPC sends with file-upload validation) skills.",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"telegram",
|
package/lib/autosteered-refs.js
CHANGED
|
@@ -46,12 +46,22 @@
|
|
|
46
46
|
* logged to opts.logger?.error — they never block clearing of
|
|
47
47
|
* subsequent refs.
|
|
48
48
|
* @param {{ error?: (msg: string) => void }} [opts.logger]
|
|
49
|
+
* @param {number} [opts.minIntervalMs=250]
|
|
50
|
+
* minimum gap (ms) between successive applyClear calls inside a
|
|
51
|
+
* single clear() loop. Telegram's setMessageReaction rate limit
|
|
52
|
+
* is ~5/sec/chat; 250ms (4/sec) stays under that. Pass 0 to
|
|
53
|
+
* disable pacing in tests / contexts where the underlying applyClear
|
|
54
|
+
* doesn't talk to a rate-limited API. Only the GAP between calls
|
|
55
|
+
* is paced — the first call fires immediately, single-ref clears
|
|
56
|
+
* incur no delay. L7 fix 2026-05-16: was unpaced, exceeded the
|
|
57
|
+
* Telegram cap under N≥6 autosteers per turn.
|
|
49
58
|
* @returns {AutosteeredRefs}
|
|
50
59
|
*/
|
|
51
|
-
function createAutosteeredRefs({ applyClear, logger = console } = {}) {
|
|
60
|
+
function createAutosteeredRefs({ applyClear, logger = console, minIntervalMs = 250 } = {}) {
|
|
52
61
|
if (typeof applyClear !== 'function') {
|
|
53
62
|
throw new TypeError('applyClear function required');
|
|
54
63
|
}
|
|
64
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
55
65
|
/** @type {Map<string, MsgRef[]>} */
|
|
56
66
|
const refs = new Map();
|
|
57
67
|
|
|
@@ -79,7 +89,15 @@ function createAutosteeredRefs({ applyClear, logger = console } = {}) {
|
|
|
79
89
|
if (!list || list.length === 0) return 0;
|
|
80
90
|
refs.delete(sessionKey);
|
|
81
91
|
let cleared = 0;
|
|
82
|
-
|
|
92
|
+
// L7: pace inter-call gaps to stay under Telegram's
|
|
93
|
+
// setMessageReaction rate limit (~5/sec/chat). The first call
|
|
94
|
+
// fires immediately — pacing applies only to the gap BEFORE the
|
|
95
|
+
// 2nd+ call. minIntervalMs=0 disables pacing entirely.
|
|
96
|
+
for (let i = 0; i < list.length; i += 1) {
|
|
97
|
+
const ref = list[i];
|
|
98
|
+
if (i > 0 && minIntervalMs > 0) {
|
|
99
|
+
await sleep(minIntervalMs);
|
|
100
|
+
}
|
|
83
101
|
try {
|
|
84
102
|
await applyClear(ref);
|
|
85
103
|
cleared += 1;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Resolve + verify the pinned claude CLI binary for the tmux backend.
|
|
9
|
+
*
|
|
10
|
+
* Why this exists: the tmux backend reads claude CLI INTERNAL
|
|
11
|
+
* artefacts (JSONL events, queue-operation semantics, TUI banner
|
|
12
|
+
* ASCII, READY hint strings, stop_reason values) — none a stable
|
|
13
|
+
* public contract. polygram pins ONE version
|
|
14
|
+
* (CLAUDE_CLI_PINNED_VERSION in lib/process/tmux-process.js) and
|
|
15
|
+
* must spawn THAT binary, never whatever `claude` on $PATH happens
|
|
16
|
+
* to resolve to.
|
|
17
|
+
*
|
|
18
|
+
* Before this module the tmux runner spawned the bare string
|
|
19
|
+
* `claude`, resolved through $PATH. The claude CLI installs each
|
|
20
|
+
* version as a standalone binary at
|
|
21
|
+
* ~/.local/share/claude/versions/<version>
|
|
22
|
+
* and points ~/.local/bin/claude (a symlink) at the active one.
|
|
23
|
+
* Its auto-updater re-points that symlink whenever a new version
|
|
24
|
+
* lands — so a $PATH spawn silently drifts (shumorobot 2026-05-16:
|
|
25
|
+
* CLI auto-updated 2.1.142 → 2.1.143 between deploys).
|
|
26
|
+
*
|
|
27
|
+
* Spawning the ABSOLUTE versioned path is immune to that: the
|
|
28
|
+
* updater only ADDS new version files, it never overwrites an
|
|
29
|
+
* existing one. `versions/2.1.142` stays byte-identical forever.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Absolute path to the pinned claude binary.
|
|
34
|
+
*
|
|
35
|
+
* Resolution order:
|
|
36
|
+
* 1. POLYGRAM_CLAUDE_BIN env — explicit override (non-standard
|
|
37
|
+
* installs, CI, hosts where the layout differs).
|
|
38
|
+
* 2. ~/.local/share/claude/versions/<version> — the standard
|
|
39
|
+
* claude-CLI install location.
|
|
40
|
+
*
|
|
41
|
+
* The returned path is NOT guaranteed to exist — callers verify
|
|
42
|
+
* via verifyPinnedClaudeBin().
|
|
43
|
+
*
|
|
44
|
+
* @param {string} version — pinned version, e.g. '2.1.142'
|
|
45
|
+
* @returns {string} absolute path
|
|
46
|
+
*/
|
|
47
|
+
function resolvePinnedClaudeBin(version) {
|
|
48
|
+
const override = process.env.POLYGRAM_CLAUDE_BIN;
|
|
49
|
+
if (override) return override;
|
|
50
|
+
return path.join(os.homedir(), '.local', 'share', 'claude', 'versions', version);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Verify the pinned binary exists and is executable.
|
|
55
|
+
*
|
|
56
|
+
* @param {string} version — pinned version, e.g. '2.1.142'
|
|
57
|
+
* @returns {{ ok: boolean, path: string, reason?: string }}
|
|
58
|
+
* ok=true → path is a spawnable binary.
|
|
59
|
+
* ok=false → reason carries an operator-actionable message.
|
|
60
|
+
*/
|
|
61
|
+
function verifyPinnedClaudeBin(version) {
|
|
62
|
+
const binPath = resolvePinnedClaudeBin(version);
|
|
63
|
+
try {
|
|
64
|
+
fs.accessSync(binPath, fs.constants.X_OK);
|
|
65
|
+
return { ok: true, path: binPath };
|
|
66
|
+
} catch (err) {
|
|
67
|
+
const code = err && err.code ? err.code : (err && err.message) || 'unknown';
|
|
68
|
+
return {
|
|
69
|
+
ok: false,
|
|
70
|
+
path: binPath,
|
|
71
|
+
reason: `pinned claude CLI v${version} not found or not executable at `
|
|
72
|
+
+ `${binPath} (${code}). Install it with \`claude install ${version}\` `
|
|
73
|
+
+ 'or set POLYGRAM_CLAUDE_BIN to the correct binary path.',
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
module.exports = { resolvePinnedClaudeBin, verifyPinnedClaudeBin };
|
package/lib/db/sessions.js
CHANGED
|
@@ -95,4 +95,100 @@ function getClaudeSessionId(db, sessionKey) {
|
|
|
95
95
|
return row?.claude_session_id || null;
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
-
|
|
98
|
+
// ─── S2: session-config drift ────────────────────────────────────────
|
|
99
|
+
//
|
|
100
|
+
// A stored `sessions` row records the config the claude session was
|
|
101
|
+
// SPAWNED under (agent / cwd / pm_backend). Those three are
|
|
102
|
+
// spawn-identity: they are baked into the process at spawn time —
|
|
103
|
+
// `--agent`, the tmux/SDK working dir, the backend class — and cannot
|
|
104
|
+
// be changed on a live session. If the chat/topic config has drifted
|
|
105
|
+
// from the stored row, `--resume`-ing the old session forces claude
|
|
106
|
+
// to run under a config it was never built for. shumorobot
|
|
107
|
+
// 2026-05-17 22:03, topic :3: the row was agent=shumabit / cwd=$HOME
|
|
108
|
+
// / sdk (created before the Music topic got its per-topic override);
|
|
109
|
+
// resuming it under agent=music-curation:music-curator /
|
|
110
|
+
// cwd=.../Music/rekordbox / tmux left the TUI never signalling ready.
|
|
111
|
+
//
|
|
112
|
+
// model + effort are deliberately EXCLUDED from the invalidating set.
|
|
113
|
+
// They are NOT spawn-identity: a live `/model` or `/effort` change is
|
|
114
|
+
// pushed into the running session by `pm.setModel` /
|
|
115
|
+
// `pm.applyFlagSettings` with no respawn (lib/handlers/slash-commands.js,
|
|
116
|
+
// lib/handlers/config-callback.js). Including them here would
|
|
117
|
+
// destructively drop the whole session — discarding all context — on
|
|
118
|
+
// every model switch, double-handling what the live-apply path
|
|
119
|
+
// already covers cleanly. The stored model/effort columns are
|
|
120
|
+
// informational, not identity.
|
|
121
|
+
const SPAWN_IDENTITY_FIELDS = ['agent', 'cwd', 'pm_backend'];
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Decide whether a stored session can be resumed for the next spawn,
|
|
125
|
+
* or whether config drift means it must be dropped and re-spawned
|
|
126
|
+
* fresh.
|
|
127
|
+
*
|
|
128
|
+
* On drift the stale row is DELETED here — so the very next spawn
|
|
129
|
+
* mints a fresh claude_session_id under the correct config and the
|
|
130
|
+
* `onInit` callback re-upserts the row. This self-heals every
|
|
131
|
+
* pre-migration stale row across all chats with no manual SQL.
|
|
132
|
+
*
|
|
133
|
+
* @param {object|null} db — DB handle (null → fresh spawn)
|
|
134
|
+
* @param {string} sessionKey
|
|
135
|
+
* @param {object} resolved — freshly-resolved spawn config
|
|
136
|
+
* @param {string} [resolved.agent]
|
|
137
|
+
* @param {string} [resolved.cwd]
|
|
138
|
+
* @param {string} [resolved.backend] — 'sdk' | 'tmux' (resolved by
|
|
139
|
+
* process/factory.js pickBackend); compared to the row's pm_backend
|
|
140
|
+
* @returns {{ existingSessionId: string|null, drift: object|null }}
|
|
141
|
+
* existingSessionId — pass to start() for --resume, or null for a
|
|
142
|
+
* fresh spawn (no stored row, or drift dropped it)
|
|
143
|
+
* drift — null when no drift; otherwise { fields, before, after }
|
|
144
|
+
* for the `session-config-drift` telemetry event
|
|
145
|
+
*/
|
|
146
|
+
function resolveSessionForSpawn(db, sessionKey, resolved = {}) {
|
|
147
|
+
if (!db) return { existingSessionId: null, drift: null };
|
|
148
|
+
const row = db.getSession(sessionKey);
|
|
149
|
+
if (!row || !row.claude_session_id) {
|
|
150
|
+
return { existingSessionId: null, drift: null };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Normalise: a missing field on either side is treated as equal to
|
|
154
|
+
// a missing field on the other (both null/undefined → no drift).
|
|
155
|
+
const after = {
|
|
156
|
+
agent: resolved.agent || null,
|
|
157
|
+
cwd: resolved.cwd || null,
|
|
158
|
+
pm_backend: resolved.backend || null,
|
|
159
|
+
};
|
|
160
|
+
const before = {
|
|
161
|
+
agent: row.agent || null,
|
|
162
|
+
cwd: row.cwd || null,
|
|
163
|
+
pm_backend: row.pm_backend || null,
|
|
164
|
+
};
|
|
165
|
+
const drifted = SPAWN_IDENTITY_FIELDS.filter((f) => {
|
|
166
|
+
// If the resolved config does not specify a field, do not treat
|
|
167
|
+
// it as drift — we have nothing to compare against.
|
|
168
|
+
if (after[f] == null) return false;
|
|
169
|
+
return before[f] !== after[f];
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
if (drifted.length === 0) {
|
|
173
|
+
return { existingSessionId: row.claude_session_id, drift: null };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Drift: drop the stale row so the next spawn is fresh + correct.
|
|
177
|
+
db.clearSessionId(sessionKey);
|
|
178
|
+
return {
|
|
179
|
+
existingSessionId: null,
|
|
180
|
+
drift: {
|
|
181
|
+
fields: drifted,
|
|
182
|
+
before: { ...before, claude_session_id: row.claude_session_id },
|
|
183
|
+
after,
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
module.exports = {
|
|
189
|
+
migrateJsonToDb,
|
|
190
|
+
getClaudeSessionId,
|
|
191
|
+
resolveSessionForSpawn,
|
|
192
|
+
countSessions,
|
|
193
|
+
SPAWN_IDENTITY_FIELDS,
|
|
194
|
+
};
|
|
@@ -69,9 +69,15 @@ function createAutosteerHandlers({
|
|
|
69
69
|
if (!entry?.inFlight) return { autosteered: false };
|
|
70
70
|
|
|
71
71
|
const priority = priorityFor(chatConfig, config);
|
|
72
|
+
// rc.7: pass the autosteered msg_id through to the backend so the
|
|
73
|
+
// tmux backend can route an extra-turn reply back to Telegram if
|
|
74
|
+
// the TUI dequeues the paste as a fresh user turn (NEW-TURN path).
|
|
75
|
+
// SDK backend ignores msgId — its PostToolBatch fold path
|
|
76
|
+
// guarantees one combined reply via the primary pm.send.
|
|
72
77
|
const ok = pm.injectUserMessage(sessionKey, {
|
|
73
78
|
content: prompt,
|
|
74
79
|
priority,
|
|
80
|
+
msgId: msg.message_id,
|
|
75
81
|
});
|
|
76
82
|
if (!ok) return { autosteered: false };
|
|
77
83
|
|