polygram 0.3.2 → 0.3.4

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.3.2",
4
+ "version": "0.3.4",
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",
package/README.md CHANGED
@@ -115,6 +115,38 @@ opens a Unix socket at `/tmp/polygram-<bot>.sock`.
115
115
 
116
116
  For production, LaunchAgent plists are in `ops/`. See `ops/README.md`.
117
117
 
118
+ ## Health check
119
+
120
+ Every install includes a round-trip smoke test:
121
+
122
+ ```bash
123
+ polygram-smoke --bot my-bot --to <admin-chat-id>
124
+ ```
125
+
126
+ Exits 0 on success, 1 on any step failure. It verifies:
127
+
128
+ 1. **IPC ping** — the per-bot unix socket is up
129
+ 2. **Outbound round-trip** — IPC `send` op → Telegram API → returns a `msg_id`
130
+ 3. **DB read-back** — that `msg_id` is in the `messages` table with
131
+ `direction='out'`, `status='sent'`, matching text
132
+
133
+ A sample passing run:
134
+
135
+ ```
136
+ ✅ ipc-ping — bot=my-bot
137
+ ✅ outbound-send — msg_id=12345
138
+ ✅ db-readback — sent row confirmed (source=polygram-smoke)
139
+
140
+ polygram-smoke: PASS my-bot 2026-04-22T15:30:00.000Z
141
+ ```
142
+
143
+ Flags: `--db <path>` or `POLYGRAM_DB` for non-default DB location;
144
+ `--timeout-ms <ms>` (default 8000).
145
+
146
+ The test sends a tagged message (`polygram-smoke:<timestamp>`) silently
147
+ to the target chat. Use your own DM as `--to` so the marker arrives
148
+ somewhere you control.
149
+
118
150
  ## Install as a Claude Code plugin
119
151
 
120
152
  polygram also ships as a Claude Code plugin — adds admin slash commands
@@ -2,66 +2,72 @@
2
2
  description: Show polygram daemon health — running bots, IPC sockets, recent events.
3
3
  ---
4
4
 
5
- Report the health of the polygram Telegram daemon.
5
+ Report polygram health. Be mechanical: run exactly these commands in
6
+ order, parse their output, produce a single Markdown table.
6
7
 
7
- Do these checks with the Bash tool and summarise per-bot in a short
8
- Markdown table. Cover launchd **and** tmux supervision — users run either.
9
-
10
- ### 1. Discover configured bots
11
-
12
- Read bot names from `~/polygram/config.json` (or `$POLYGRAM_CONFIG`):
8
+ ## Commands to run (in this order, Bash tool)
13
9
 
14
10
  ```
11
+ # 1. Configured bots
15
12
  jq -r '.bots | keys[]' ~/polygram/config.json
16
- ```
17
13
 
18
- ### 2. Per bot, check supervisor + process
14
+ # 2. launchd supervision (may be empty — that's fine, tmux is also valid)
15
+ launchctl list 2>/dev/null | grep com.polygram || true
19
16
 
20
- **a) LaunchAgent**`launchctl list | grep com.polygram.<bot>`. If
21
- present the bot is under launchd. PID `-` means loaded but not running.
17
+ # 3. tmux supervision (may be empty that's fine, launchd is also valid)
18
+ tmux list-windows -a 2>/dev/null | grep -Ei 'polygram|shumabit|umi-assistant' || true
22
19
 
23
- **b) tmux** `tmux list-windows -a 2>/dev/null | grep <bot>`. If
24
- present the bot is running in a tmux pane (most common during testing).
20
+ # 4. Running Node processes (authoritative)
21
+ pgrep -fa 'polygram --bot' || true
25
22
 
26
- **c) Process** — `pgrep -f "polygram --bot <bot>"`. Confirms the Node
27
- process is actually alive regardless of supervisor.
23
+ # 5. IPC socket ping per bot — `polygram-ipc` is a bin installed by the
24
+ # `polygram` npm package (polygram >= 0.3.2). It is NOT the same as
25
+ # ipc-smoke.js. Do NOT look for ipc-smoke.js anywhere; it doesn't
26
+ # exist as a loose file in the install.
27
+ polygram-ipc <bot1>
28
+ polygram-ipc <bot2>
29
+ # (etc, one per bot from step 1)
28
30
 
29
- Label supervision as:
30
- - `launchd` plist loaded
31
- - `tmux` — window present
32
- - `foreground` — alive but unsupervised (will die on logout/crash)
33
- - `absent` — no process
31
+ # 6. Recent events per bot
32
+ sqlite3 ~/polygram/<bot>.db "SELECT datetime(ts/1000, 'unixepoch'), kind FROM events ORDER BY ts DESC LIMIT 5"
33
+ ```
34
34
 
35
- ### 3. IPC socket liveness
35
+ ## Interpretation rules
36
36
 
37
- ```
38
- polygram-ipc <bot-name>
39
- ```
37
+ **Supervision is ✅ if ANY of these are true** (NOT all — any one is fine):
38
+ - launchd line matched in step 2
39
+ - tmux window matched in step 3
40
+ - **either counts as "supervised"** — tmux is a first-class supervisor
41
+ for this project
40
42
 
41
- That's the `polygram-ipc` bin installed alongside the daemon. Interpret:
42
- - `ping: {"ok":true,...}` — socket alive ✅
43
- - `ERR: ECONNREFUSED` — socket stale (supervisor claims running, isn't serving)
44
- - `ERR: ENOENT` — socket missing
45
- - `command not found: polygram-ipc` — user has polygram < 0.3.2; tell
46
- them to `npm install -g polygram@latest`
43
+ **Supervision is (foreground) only when BOTH step 2 AND step 3 are empty**, yet step 4 shows the process running.
47
44
 
48
- ### 4. Recent events
45
+ **Socket is ✅** if step 5 prints `ping: {"id":null,"ok":true,"pong":true,...}`.
46
+ **Socket is ❌** on `ECONNREFUSED` or `ENOENT`.
49
47
 
50
- ```
51
- sqlite3 ~/polygram/<bot>.db "SELECT ts, kind FROM events ORDER BY ts DESC LIMIT 5;"
52
- ```
48
+ **Events healthy** unless step 6 shows any of:
49
+ `*-failed`, `*-error`, `crashed-mid-send`, `poll-stalled`, `approval-sweep-failed`.
50
+
51
+ ## Verdict
53
52
 
54
- Flag anything ending in `-failed`, `-error`, `crashed-mid-send`,
55
- `poll-stalled`, `approval-sweep-failed`.
53
+ - **healthy** every bot: supervised (launchd OR tmux) + live socket + no error events
54
+ - ⚠️ **degraded** — every bot alive but at least one is true-foreground
55
+ (neither launchd nor tmux), OR stale socket, OR recent error events
56
+ - ❌ **broken** — any bot has no live process or its socket is absent
56
57
 
57
- ### 5. Summarise
58
+ ## Output format
58
59
 
59
- Compact Markdown table + overall verdict:
60
+ ```
61
+ | Bot | Supervision | Socket | Errors (last 5) |
62
+ |-----|-------------|--------|-----------------|
63
+ | <bot1> | ✅ tmux | ✅ | clean |
64
+ | <bot2> | ✅ launchd | ✅ | clean |
65
+
66
+ Verdict: ✅ healthy
67
+ ```
60
68
 
61
- - **healthy** every bot supervised, live socket, no recent errors
62
- - ⚠️ **degraded** running but not supervised (foreground) OR sweeper
63
- events present OR stale socket
64
- - ❌ **broken** — any bot has no live process or no live socket
69
+ Do NOT speculate about ipc-smoke.js or any path that wasn't listed above.
70
+ If a command errors, print its stderr verbatim and move on.
65
71
 
66
- If the install isn't at `~/polygram/`, ask for the data-dir path rather
67
- than guessing.
72
+ If `~/polygram/config.json` doesn't exist, ask the user for their data
73
+ directory path before running anything else.
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
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": {
7
7
  "polygram": "polygram.js",
8
8
  "polygram-split-db": "scripts/split-db.js",
9
- "polygram-ipc": "scripts/ipc-smoke.js"
9
+ "polygram-ipc": "scripts/ipc-smoke.js",
10
+ "polygram-smoke": "scripts/smoke.js"
10
11
  },
11
12
  "files": [
12
13
  "polygram.js",
@@ -15,6 +16,7 @@
15
16
  "migrations/",
16
17
  "scripts/split-db.js",
17
18
  "scripts/ipc-smoke.js",
19
+ "scripts/smoke.js",
18
20
  "skills/",
19
21
  "commands/",
20
22
  ".claude-plugin/",
package/polygram.js CHANGED
@@ -105,12 +105,35 @@ function loadConfig() {
105
105
  }
106
106
 
107
107
  function saveConfig() {
108
- // Atomic write: crash between write and rename leaves the old config
109
- // intact. Exclude the `bot` convenience alias from serialisation it's
110
- // a runtime pointer into config.bots[BOT_NAME], not persistent state.
108
+ // Atomic read-merge-write. In-memory `config` is FILTERED (only one bot +
109
+ // its chats) because filterConfigToBot narrowed it at boot. Writing it
110
+ // back directly would clobber every OTHER bot's section on disk. Instead:
111
+ // read the current on-disk config fresh, apply our bot-scoped changes,
112
+ // write the merged result. This is safe against parallel writers because
113
+ // each bot only mutates entries inside its own scope (its bot entry + its
114
+ // own chats), and we use rename for atomicity.
115
+ const onDisk = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
116
+
117
+ // Apply our bot's changes.
118
+ if (BOT_NAME && config.bots?.[BOT_NAME]) {
119
+ onDisk.bots = onDisk.bots || {};
120
+ onDisk.bots[BOT_NAME] = config.bots[BOT_NAME];
121
+ }
122
+ // Apply chat changes — only chats the filter left in our memory belong
123
+ // to this bot; overwrite those keys, leave the rest of onDisk.chats alone.
124
+ if (config.chats) {
125
+ onDisk.chats = onDisk.chats || {};
126
+ for (const [chatId, chat] of Object.entries(config.chats)) {
127
+ onDisk.chats[chatId] = chat;
128
+ }
129
+ }
130
+ // Top-level non-bot-scoped fields (defaults, maxWarmProcesses, etc.)
131
+ // reflect ops-wide policy. Only copy if our in-memory value is newer —
132
+ // but detecting that is hard; simplest safe rule is: don't touch them
133
+ // from a bot-scoped process. Leave onDisk's values as-is.
134
+
111
135
  const tmp = `${CONFIG_PATH}.tmp.${process.pid}`;
112
- const { bot: _bot, ...serialisable } = config;
113
- fs.writeFileSync(tmp, JSON.stringify(serialisable, null, 2));
136
+ fs.writeFileSync(tmp, JSON.stringify(onDisk, null, 2));
114
137
  fs.renameSync(tmp, CONFIG_PATH);
115
138
  }
116
139
 
@@ -928,8 +951,8 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
928
951
  const newModel = text.slice(7).trim();
929
952
  if (['opus', 'sonnet', 'haiku'].includes(newModel)) {
930
953
  const oldModel = chatConfig.model;
954
+ // Ephemeral: in-memory only, reverts to config.json on restart.
931
955
  chatConfig.model = newModel;
932
- saveConfig();
933
956
  dbWrite(() => db.logConfigChange({
934
957
  chat_id: chatId, thread_id: threadIdStr, field: 'model',
935
958
  old_value: oldModel, new_value: newModel,
@@ -949,8 +972,8 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
949
972
  const newEffort = text.slice(8).trim();
950
973
  if (['low', 'medium', 'high', 'xhigh', 'max'].includes(newEffort)) {
951
974
  const oldEffort = chatConfig.effort;
975
+ // Ephemeral: in-memory only, reverts to config.json on restart.
952
976
  chatConfig.effort = newEffort;
953
- saveConfig();
954
977
  dbWrite(() => db.logConfigChange({
955
978
  chat_id: chatId, thread_id: threadIdStr, field: 'effort',
956
979
  old_value: oldEffort, new_value: newEffort,
@@ -0,0 +1,122 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * polygram-smoke — round-trip health check for a polygram bot.
4
+ *
5
+ * polygram-smoke --bot <name> --to <chat_id>
6
+ *
7
+ * Exits 0 if all three round-trips pass, 1 otherwise.
8
+ *
9
+ * Intended for cron (heartbeat) or ad-hoc operator verification.
10
+ *
11
+ * Round-trips:
12
+ * 1. IPC ping — socket is alive and handshakes
13
+ * 2. Outbound — IPC 'send' op → Telegram API → returns msg_id
14
+ * 3. DB read-back — that msg_id appears in messages table with
15
+ * direction='out', status='sent', matching text
16
+ */
17
+
18
+ const path = require('path');
19
+ const Database = require('better-sqlite3');
20
+ const { call, tell, socketPathFor, readSecret } = require('../lib/ipc-client');
21
+
22
+ function parseArg(argv, flag, required = false) {
23
+ const i = argv.indexOf(flag);
24
+ if (i === -1) {
25
+ if (required) die(`missing required flag: ${flag}`);
26
+ return null;
27
+ }
28
+ const v = argv[i + 1];
29
+ if (!v || v.startsWith('--')) die(`${flag} requires a value`);
30
+ return v;
31
+ }
32
+
33
+ function die(msg) {
34
+ process.stderr.write(`polygram-smoke: ${msg}\n`);
35
+ process.exit(2);
36
+ }
37
+
38
+ const bot = parseArg(process.argv, '--bot', true);
39
+ const to = parseArg(process.argv, '--to', true);
40
+ const dbPath = parseArg(process.argv, '--db')
41
+ || process.env.POLYGRAM_DB
42
+ || path.join(process.cwd(), `${bot}.db`);
43
+ const timeoutMs = parseInt(parseArg(process.argv, '--timeout-ms') || '8000', 10);
44
+
45
+ const stamp = Date.now();
46
+ const marker = `polygram-smoke:${stamp}`;
47
+ const results = [];
48
+
49
+ function report(step, ok, detail) {
50
+ const icon = ok ? '✅' : '❌';
51
+ console.log(`${icon} ${step}${detail ? ` — ${detail}` : ''}`);
52
+ results.push({ step, ok, detail });
53
+ }
54
+
55
+ async function main() {
56
+ // Step 1 — IPC ping
57
+ try {
58
+ const res = await call({
59
+ path: socketPathFor(bot),
60
+ op: 'ping',
61
+ callTimeoutMs: timeoutMs,
62
+ });
63
+ if (res?.ok && res.pong) report('ipc-ping', true, `bot=${res.bot}`);
64
+ else { report('ipc-ping', false, JSON.stringify(res)); exitNow(); }
65
+ } catch (err) {
66
+ report('ipc-ping', false, err.message);
67
+ exitNow();
68
+ }
69
+
70
+ // Step 2 — outbound round-trip
71
+ let msgId = null;
72
+ try {
73
+ const res = await tell(bot, 'sendMessage', {
74
+ chat_id: to,
75
+ text: marker,
76
+ disable_notification: true,
77
+ }, { source: 'polygram-smoke', callTimeoutMs: timeoutMs });
78
+ msgId = res?.message_id;
79
+ if (msgId) report('outbound-send', true, `msg_id=${msgId}`);
80
+ else { report('outbound-send', false, JSON.stringify(res)); exitNow(); }
81
+ } catch (err) {
82
+ report('outbound-send', false, err.message);
83
+ exitNow();
84
+ }
85
+
86
+ // Step 3 — DB read-back. The sender writes pending→sent in two steps;
87
+ // give it a tick, then query by msg_id.
88
+ await new Promise((r) => setTimeout(r, 250));
89
+ try {
90
+ const db = new Database(dbPath, { readonly: true, fileMustExist: true });
91
+ const row = db.prepare(`
92
+ SELECT direction, status, text, source, msg_id
93
+ FROM messages
94
+ WHERE chat_id = ? AND msg_id = ?
95
+ `).get(String(to), msgId);
96
+ db.close();
97
+
98
+ if (!row) { report('db-readback', false, `no row for msg_id=${msgId}`); exitNow(); }
99
+ if (row.direction !== 'out') { report('db-readback', false, `direction=${row.direction}`); exitNow(); }
100
+ if (row.status !== 'sent') { report('db-readback', false, `status=${row.status}`); exitNow(); }
101
+ if (!String(row.text).includes(marker)) { report('db-readback', false, `text mismatch: ${row.text?.slice(0, 60)}`); exitNow(); }
102
+ report('db-readback', true, `sent row confirmed (source=${row.source})`);
103
+ } catch (err) {
104
+ report('db-readback', false, err.message);
105
+ exitNow();
106
+ }
107
+
108
+ console.log(`\npolygram-smoke: PASS ${bot} ${new Date().toISOString()}`);
109
+ process.exit(0);
110
+ }
111
+
112
+ function exitNow() {
113
+ const passed = results.filter(r => r.ok).length;
114
+ const total = results.length;
115
+ console.log(`\npolygram-smoke: FAIL ${passed}/${total} steps bot=${bot}`);
116
+ process.exit(1);
117
+ }
118
+
119
+ main().catch((err) => {
120
+ console.error('polygram-smoke: unexpected error:', err.message);
121
+ process.exit(1);
122
+ });