polygram 0.8.0-rc.25 → 0.8.0-rc.26
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/abort-grace.js +62 -0
- package/lib/agent-loader.js +16 -0
- package/lib/approval-waiters.js +7 -0
- package/lib/canonical-json.js +19 -1
- package/lib/pm-router.js +40 -8
- package/lib/process-manager-sdk.js +15 -6
- package/package.json +1 -1
- package/polygram.js +10 -23
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://anthropic.com/claude-code/plugin.schema.json",
|
|
3
3
|
"name": "polygram",
|
|
4
|
-
"version": "0.8.0-rc.
|
|
4
|
+
"version": "0.8.0-rc.26",
|
|
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 and a history skill.",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"telegram",
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Abort-grace tracker — per-session timestamps marking "user just
|
|
3
|
+
* /stop'd this session, suppress the next batch of generic error
|
|
4
|
+
* replies".
|
|
5
|
+
*
|
|
6
|
+
* Why this exists: when the user types /stop (or natural-language
|
|
7
|
+
* "стоп"), polygram calls pm.kill(sessionKey). The kill SIGTERM's
|
|
8
|
+
* the in-flight process — every pending in the queue rejects with
|
|
9
|
+
* "Process killed" or INTERRUPTED. WITHOUT abort-grace, polygram
|
|
10
|
+
* would post "💥 Hit a snag" for each rejected pending, even though
|
|
11
|
+
* the user already saw the /stop ack and these errors are caused
|
|
12
|
+
* by their own action.
|
|
13
|
+
*
|
|
14
|
+
* Timestamp model (vs the earlier "delete after first read" Set):
|
|
15
|
+
* a single /stop can drain many pendings, so we mark a TS and let
|
|
16
|
+
* every error within ABORT_GRACE_MS see "yes, aborted, stay quiet".
|
|
17
|
+
*
|
|
18
|
+
* Closes v6 plan §7.1 G11 unit gate.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
'use strict';
|
|
22
|
+
|
|
23
|
+
const DEFAULT_ABORT_GRACE_MS = 15_000;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {object} [opts]
|
|
27
|
+
* @param {number} [opts.windowMs] — grace window (default 15s)
|
|
28
|
+
* @param {() => number} [opts.now] — clock injection for tests
|
|
29
|
+
*/
|
|
30
|
+
function createAbortGrace({ windowMs = DEFAULT_ABORT_GRACE_MS, now = () => Date.now() } = {}) {
|
|
31
|
+
const aborted = new Map(); // sessionKey → ts of abort
|
|
32
|
+
|
|
33
|
+
function mark(sessionKey) {
|
|
34
|
+
if (!sessionKey) return;
|
|
35
|
+
const ts = now();
|
|
36
|
+
aborted.set(sessionKey, ts);
|
|
37
|
+
// Sweep old entries opportunistically. Use 2× window so a
|
|
38
|
+
// session that's marked-and-checked at the boundary doesn't
|
|
39
|
+
// disappear before the check completes.
|
|
40
|
+
for (const [k, t] of aborted) {
|
|
41
|
+
if (ts - t > windowMs * 2) aborted.delete(k);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isRecent(sessionKey) {
|
|
46
|
+
const ts = aborted.get(sessionKey);
|
|
47
|
+
return ts != null && (now() - ts) < windowMs;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function clear(sessionKey) {
|
|
51
|
+
aborted.delete(sessionKey);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
mark,
|
|
56
|
+
isRecent,
|
|
57
|
+
clear,
|
|
58
|
+
get size() { return aborted.size; },
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
module.exports = { createAbortGrace, DEFAULT_ABORT_GRACE_MS };
|
package/lib/agent-loader.js
CHANGED
|
@@ -43,7 +43,23 @@ const cache = new Map(); // cacheKey → AgentBundle
|
|
|
43
43
|
|
|
44
44
|
// Resolve agent file by checking each search path in order.
|
|
45
45
|
// Returns { kind: 'file'|'dir', path, dir | null } or null.
|
|
46
|
+
// Restrict agent names to a conservative charset so they can't
|
|
47
|
+
// path-traverse out of the `.claude/agents/` directory. Pre-fix, an
|
|
48
|
+
// agent name like `../../etc/passwd` silently resolved to whatever
|
|
49
|
+
// existed at that path, loading arbitrary file content as the
|
|
50
|
+
// system prompt. Chat configs are operator-controlled (not user
|
|
51
|
+
// input), so the practical threat is operator typos — but pinning
|
|
52
|
+
// the contract removes the foot-gun.
|
|
53
|
+
//
|
|
54
|
+
// Allowed: alphanumerics, hyphen, underscore, single dots inside
|
|
55
|
+
// (e.g. "shumabit-finance.v2"). Forbidden: leading/trailing dot,
|
|
56
|
+
// consecutive dots, slashes, NUL.
|
|
57
|
+
const AGENT_NAME_RE = /^[A-Za-z0-9_]+(?:[.-][A-Za-z0-9_]+)*$/;
|
|
58
|
+
|
|
46
59
|
function resolveAgentLocation(agentName, homeDir, cwd) {
|
|
60
|
+
if (typeof agentName !== 'string' || !AGENT_NAME_RE.test(agentName)) {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
47
63
|
const fileCandidates = [];
|
|
48
64
|
if (cwd) fileCandidates.push(path.join(cwd, '.claude', 'agents', agentName + '.md'));
|
|
49
65
|
fileCandidates.push(path.join(homeDir, '.claude', 'agents', agentName + '.md'));
|
package/lib/approval-waiters.js
CHANGED
|
@@ -110,6 +110,13 @@ function createApprovalWaiters({
|
|
|
110
110
|
parkedAt: Date.now(),
|
|
111
111
|
sessionKey,
|
|
112
112
|
});
|
|
113
|
+
|
|
114
|
+
// If the signal was ALREADY aborted before we attached the
|
|
115
|
+
// listener, addEventListener never fires — the waiter would
|
|
116
|
+
// sit in the map until timeout-sweep / shutdown picked it up.
|
|
117
|
+
// Trigger the cleanup manually so the parked promise rejects
|
|
118
|
+
// immediately (matches "abort fired during park" semantics).
|
|
119
|
+
if (signal && signal.aborted) sigCleanup();
|
|
113
120
|
});
|
|
114
121
|
}
|
|
115
122
|
|
package/lib/canonical-json.js
CHANGED
|
@@ -31,11 +31,29 @@ function canonicalizeToolInput(input) {
|
|
|
31
31
|
if (input == null || typeof input !== 'object') {
|
|
32
32
|
return JSON.stringify(input);
|
|
33
33
|
}
|
|
34
|
+
// Track in-flight (currently-on-stack) nodes to detect circular
|
|
35
|
+
// references. WeakSet membership marks "we are still inside this
|
|
36
|
+
// node"; we drop the entry after finishing recursion so DAG
|
|
37
|
+
// shapes (shared subtrees that aren't cycles) round-trip fine.
|
|
38
|
+
// Pre-fix sortRec recursed forever on `{a: 1, self: <self>}`
|
|
39
|
+
// and crashed the daemon — DoS path if any tool ever produces
|
|
40
|
+
// self-referencing input. Now throws a clean TypeError matching
|
|
41
|
+
// JSON.stringify's own "Converting circular structure to JSON".
|
|
42
|
+
const onStack = new WeakSet();
|
|
34
43
|
const sortRec = (v) => {
|
|
35
|
-
if (Array.isArray(v))
|
|
44
|
+
if (Array.isArray(v)) {
|
|
45
|
+
if (onStack.has(v)) throw new TypeError('Converting circular structure to JSON');
|
|
46
|
+
onStack.add(v);
|
|
47
|
+
const result = v.map(sortRec);
|
|
48
|
+
onStack.delete(v);
|
|
49
|
+
return result;
|
|
50
|
+
}
|
|
36
51
|
if (v == null || typeof v !== 'object') return v;
|
|
52
|
+
if (onStack.has(v)) throw new TypeError('Converting circular structure to JSON');
|
|
53
|
+
onStack.add(v);
|
|
37
54
|
const out = {};
|
|
38
55
|
for (const k of Object.keys(v).sort()) out[k] = sortRec(v[k]);
|
|
56
|
+
onStack.delete(v);
|
|
39
57
|
return out;
|
|
40
58
|
};
|
|
41
59
|
return JSON.stringify(sortRec(input));
|
package/lib/pm-router.js
CHANGED
|
@@ -73,6 +73,33 @@ function makeRouterPolicy({ useSdkAll = false, sdkChats = [], getChatIdFromKey }
|
|
|
73
73
|
* @param {object|null} opts.sdkPm
|
|
74
74
|
* @param {(sessionKey: string) => 'sdk'|'cli'} opts.pickPmKindFor
|
|
75
75
|
*/
|
|
76
|
+
/**
|
|
77
|
+
* Broadcast helper for killChat / shutdown. Awaits every task to
|
|
78
|
+
* settlement (success OR rejection), then throws an aggregate error
|
|
79
|
+
* if any task rejected. Single rejections re-throw the original
|
|
80
|
+
* error untouched (no AggregateError noise); multiple rejections
|
|
81
|
+
* surface as `AggregateError` with all causes preserved.
|
|
82
|
+
*
|
|
83
|
+
* Each task entry is `[label, () => Promise]`; the label appears in
|
|
84
|
+
* AggregateError messages so a debugger can tell which pm failed.
|
|
85
|
+
*/
|
|
86
|
+
async function broadcastSettle(method, tasks) {
|
|
87
|
+
const results = await Promise.allSettled(tasks.map(([, fn]) => fn()));
|
|
88
|
+
const errors = [];
|
|
89
|
+
results.forEach((r, i) => {
|
|
90
|
+
if (r.status === 'rejected') {
|
|
91
|
+
const tag = tasks[i][0];
|
|
92
|
+
const err = r.reason instanceof Error ? r.reason : new Error(String(r.reason));
|
|
93
|
+
err.pmTag = tag;
|
|
94
|
+
errors.push(err);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
if (errors.length === 1) throw errors[0];
|
|
98
|
+
if (errors.length > 1) {
|
|
99
|
+
throw new AggregateError(errors, `${method} failed in ${errors.length} pms`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
76
103
|
function createPmRouter({ cliPm, sdkPm = null, pickPmKindFor } = {}) {
|
|
77
104
|
if (!cliPm) throw new TypeError('cliPm required');
|
|
78
105
|
if (typeof pickPmKindFor !== 'function') {
|
|
@@ -98,15 +125,20 @@ function createPmRouter({ cliPm, sdkPm = null, pickPmKindFor } = {}) {
|
|
|
98
125
|
|
|
99
126
|
// Lifecycle methods broadcast to both pms because a chat may
|
|
100
127
|
// have spawned sessions on either side at different times.
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
128
|
+
// Promise.allSettled (NOT Promise.all) so a rejection from one
|
|
129
|
+
// pm doesn't abandon the other mid-tear-down. Both must always
|
|
130
|
+
// complete; we then surface aggregated errors. Pre-fix, a cliPm
|
|
131
|
+
// rejection let sdkPm's Query.close() get GC'd with handles
|
|
132
|
+
// still open.
|
|
133
|
+
killChat(chatId) {
|
|
134
|
+
const tasks = [['cli', () => cliPm.killChat(chatId)]];
|
|
135
|
+
if (sdkPm) tasks.push(['sdk', () => sdkPm.killChat(chatId)]);
|
|
136
|
+
return broadcastSettle('killChat', tasks);
|
|
105
137
|
},
|
|
106
|
-
|
|
107
|
-
const tasks = [cliPm.shutdown()];
|
|
108
|
-
if (sdkPm) tasks.push(sdkPm.shutdown());
|
|
109
|
-
|
|
138
|
+
shutdown() {
|
|
139
|
+
const tasks = [['cli', () => cliPm.shutdown()]];
|
|
140
|
+
if (sdkPm) tasks.push(['sdk', () => sdkPm.shutdown()]);
|
|
141
|
+
return broadcastSettle('shutdown', tasks);
|
|
110
142
|
},
|
|
111
143
|
|
|
112
144
|
// Optional methods — forward when the routed pm implements
|
|
@@ -224,6 +224,9 @@ class ProcessManagerSdk {
|
|
|
224
224
|
// ─── Spawn / pool ────────────────────────────────────────────────
|
|
225
225
|
|
|
226
226
|
async getOrSpawn(sessionKey, spawnContext) {
|
|
227
|
+
if (this._shuttingDown) {
|
|
228
|
+
throw new Error('shutdown');
|
|
229
|
+
}
|
|
227
230
|
const existing = this.procs.get(sessionKey);
|
|
228
231
|
if (existing && !existing.closed) return existing;
|
|
229
232
|
|
|
@@ -232,6 +235,7 @@ class ProcessManagerSdk {
|
|
|
232
235
|
if (!evicted) {
|
|
233
236
|
// All entries in-flight — park.
|
|
234
237
|
await this._awaitLruSlot();
|
|
238
|
+
if (this._shuttingDown) throw new Error('shutdown');
|
|
235
239
|
return this.getOrSpawn(sessionKey, spawnContext);
|
|
236
240
|
}
|
|
237
241
|
}
|
|
@@ -899,18 +903,23 @@ class ProcessManagerSdk {
|
|
|
899
903
|
}
|
|
900
904
|
|
|
901
905
|
async shutdown() {
|
|
906
|
+
// Set flag FIRST so any LRU-waiter unparked by _closeEntry's
|
|
907
|
+
// iteration-finally doesn't recurse into a fresh spawn (which
|
|
908
|
+
// would leave an orphaned entry after `procs.clear()` below).
|
|
909
|
+
// Reject parked waiters immediately so their getOrSpawn callers
|
|
910
|
+
// unwind cleanly rather than racing the shutdown.
|
|
911
|
+
this._shuttingDown = true;
|
|
912
|
+
while (this._lruWaiters.length) {
|
|
913
|
+
const w = this._lruWaiters.shift();
|
|
914
|
+
clearTimeout(w.timer);
|
|
915
|
+
w.reject(new Error('shutdown'));
|
|
916
|
+
}
|
|
902
917
|
const entries = [...this.procs.values()];
|
|
903
918
|
await Promise.allSettled(entries.map((e) => {
|
|
904
919
|
this.drainQueue(e.sessionKey, 'SHUTDOWN');
|
|
905
920
|
return this._closeEntry(e, 'shutdown');
|
|
906
921
|
}));
|
|
907
922
|
this.procs.clear();
|
|
908
|
-
// Reject any remaining LRU waiters.
|
|
909
|
-
while (this._lruWaiters.length) {
|
|
910
|
-
const w = this._lruWaiters.shift();
|
|
911
|
-
clearTimeout(w.timer);
|
|
912
|
-
w.reject(new Error('shutdown'));
|
|
913
|
-
}
|
|
914
923
|
}
|
|
915
924
|
|
|
916
925
|
// ─── Helpers ────────────────────────────────────────────────────
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "polygram",
|
|
3
|
-
"version": "0.8.0-rc.
|
|
3
|
+
"version": "0.8.0-rc.26",
|
|
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
|
@@ -44,6 +44,7 @@ const {
|
|
|
44
44
|
const { makeSessionStartHook } = require('./lib/history-preload');
|
|
45
45
|
const { formatContextReply, maybeContextFullHint } = require('./lib/context-format');
|
|
46
46
|
const { appendDisplayHint, appendDisplayHintCliArgs } = require('./lib/telegram-prompt');
|
|
47
|
+
const { createAbortGrace } = require('./lib/abort-grace');
|
|
47
48
|
const agentLoader = require('./lib/agent-loader');
|
|
48
49
|
const USE_SDK = process.env.POLYGRAM_USE_SDK === '1';
|
|
49
50
|
const { createSender } = require('./lib/telegram');
|
|
@@ -1101,30 +1102,16 @@ function errorReplyText(err) {
|
|
|
1101
1102
|
return userMessage; // may be null — caller must handle
|
|
1102
1103
|
}
|
|
1103
1104
|
|
|
1104
|
-
// Sessions the operator just /stop'd (or natural-language "стоп").
|
|
1105
|
-
//
|
|
1106
|
-
//
|
|
1107
|
-
//
|
|
1108
|
-
//
|
|
1109
|
-
//
|
|
1110
|
-
|
|
1111
|
-
// them reject with "Process killed", all of them should be silent, not
|
|
1112
|
-
// just the first one.
|
|
1113
|
-
const ABORT_GRACE_MS = 15_000;
|
|
1114
|
-
const abortedSessions = new Map();
|
|
1115
|
-
|
|
1116
|
-
function markSessionAborted(sessionKey) {
|
|
1117
|
-
abortedSessions.set(sessionKey, Date.now());
|
|
1118
|
-
// Sweep old entries opportunistically.
|
|
1119
|
-
for (const [k, ts] of abortedSessions) {
|
|
1120
|
-
if (Date.now() - ts > ABORT_GRACE_MS * 2) abortedSessions.delete(k);
|
|
1121
|
-
}
|
|
1122
|
-
}
|
|
1105
|
+
// Sessions the operator just /stop'd (or natural-language "стоп").
|
|
1106
|
+
// rc.25: extracted to lib/abort-grace.js so the timestamp/window
|
|
1107
|
+
// logic has its own unit tests. Behaviour identical: any pending
|
|
1108
|
+
// rejected within the grace window is considered abort-caused —
|
|
1109
|
+
// its generic error reply is suppressed and the streamer warning
|
|
1110
|
+
// is skipped.
|
|
1111
|
+
const abortGrace = createAbortGrace();
|
|
1123
1112
|
|
|
1124
|
-
function
|
|
1125
|
-
|
|
1126
|
-
return ts != null && (Date.now() - ts) < ABORT_GRACE_MS;
|
|
1127
|
-
}
|
|
1113
|
+
function markSessionAborted(sessionKey) { abortGrace.mark(sessionKey); }
|
|
1114
|
+
function isSessionRecentlyAborted(sessionKey) { return abortGrace.isRecent(sessionKey); }
|
|
1128
1115
|
|
|
1129
1116
|
// Called by bot.on('message') for every regular (non-admin, non-pair)
|
|
1130
1117
|
// message. Runs handleMessage in a fire-and-forget manner with centralised
|