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.
@@ -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.25",
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 };
@@ -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'));
@@ -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
 
@@ -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)) return v.map(sortRec);
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
- async killChat(chatId) {
102
- const tasks = [cliPm.killChat(chatId)];
103
- if (sdkPm) tasks.push(sdkPm.killChat(chatId));
104
- await Promise.all(tasks);
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
- async shutdown() {
107
- const tasks = [cliPm.shutdown()];
108
- if (sdkPm) tasks.push(sdkPm.shutdown());
109
- await Promise.all(tasks);
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.25",
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 "стоп"). Keyed
1105
- // by sessionKey timestamp of abort. ANY pending that rejects within
1106
- // ABORT_GRACE_MS of the mark is considered abort-caused its generic
1107
- // error reply is suppressed and the streamer warning is skipped.
1108
- //
1109
- // Timestamp model (vs the earlier "delete after first read" Set) fixes
1110
- // the case where multiple pendings were in-flight at abort time: all of
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 isSessionRecentlyAborted(sessionKey) {
1125
- const ts = abortedSessions.get(sessionKey);
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