polygram 0.3.1 → 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.1",
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,39 +2,72 @@
2
2
  description: Show polygram daemon health — running bots, IPC sockets, recent events.
3
3
  ---
4
4
 
5
- You are asked to report the current health of the polygram Telegram daemon.
6
-
7
- Do these checks, in order, using the Bash tool, and summarise the results in
8
- a short Markdown table (one row per bot):
9
-
10
- 1. **Which bots are supervised by launchd.** Run:
11
- ```
12
- launchctl list | grep -i polygram || echo "no LaunchAgents loaded"
13
- ```
14
- Parse the output. Each line `<PID>\t<exit>\t<label>` is one bot
15
- (e.g. `com.polygram.my-bot`). The PID column tells you whether it is
16
- running; `-` means loaded but not currently running.
17
-
18
- 2. **Is each bot's Unix socket alive?** For every bot you identified, run:
19
- ```
20
- node ~/polygram/scripts/ipc-smoke.js <bot-name>
21
- ```
22
- Interpret the result:
23
- - `ping: {"id":null,"ok":true,"pong":true,"bot":"<bot>"}` socket alive
24
- - `ERR: connect ECONNREFUSED` socket stale (polygram not actually
25
- serving despite plist being loaded)
26
- - `ERR: ENOENT` socket missing (polygram never got that far at boot)
27
-
28
- 3. **Recent events in each bot's DB.** For every bot, run:
29
- ```
30
- sqlite3 ~/polygram/<bot>.db "SELECT ts, kind, detail_json FROM events ORDER BY ts DESC LIMIT 5;"
31
- ```
32
- Call out anything that looks like an error (`-fail`, `-error`,
33
- `crashed-mid-send`, `poll-stalled`, `approval-sweep-failed`).
34
-
35
- 4. **Summarise.** A two-line per-bot summary, plus an overall verdict at
36
- the bottom (✅ healthy / ⚠️ degraded / ❌ broken).
37
-
38
- If the user's polygram install is not at `~/polygram`, they may have set
39
- `POLYGRAM_HOME` or a custom path. Ask them to point you at it rather than
40
- guessing.
5
+ Report polygram health. Be mechanical: run exactly these commands in
6
+ order, parse their output, produce a single Markdown table.
7
+
8
+ ## Commands to run (in this order, Bash tool)
9
+
10
+ ```
11
+ # 1. Configured bots
12
+ jq -r '.bots | keys[]' ~/polygram/config.json
13
+
14
+ # 2. launchd supervision (may be empty — that's fine, tmux is also valid)
15
+ launchctl list 2>/dev/null | grep com.polygram || true
16
+
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
19
+
20
+ # 4. Running Node processes (authoritative)
21
+ pgrep -fa 'polygram --bot' || true
22
+
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)
30
+
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
+
35
+ ## Interpretation rules
36
+
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
42
+
43
+ **Supervision is ❌ (foreground) only when BOTH step 2 AND step 3 are empty**, yet step 4 shows the process running.
44
+
45
+ **Socket is ✅** if step 5 prints `ping: {"id":null,"ok":true,"pong":true,...}`.
46
+ **Socket is ❌** on `ECONNREFUSED` or `ENOENT`.
47
+
48
+ **Events healthy** unless step 6 shows any of:
49
+ `*-failed`, `*-error`, `crashed-mid-send`, `poll-stalled`, `approval-sweep-failed`.
50
+
51
+ ## Verdict
52
+
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
57
+
58
+ ## Output format
59
+
60
+ ```
61
+ | Bot | Supervision | Socket | Errors (last 5) |
62
+ |-----|-------------|--------|-----------------|
63
+ | <bot1> | ✅ tmux | ✅ | clean |
64
+ | <bot2> | ✅ launchd | ✅ | clean |
65
+
66
+ Verdict: ✅ healthy
67
+ ```
68
+
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.
71
+
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,11 +1,13 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.3.1",
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
- "polygram-split-db": "scripts/split-db.js"
8
+ "polygram-split-db": "scripts/split-db.js",
9
+ "polygram-ipc": "scripts/ipc-smoke.js",
10
+ "polygram-smoke": "scripts/smoke.js"
9
11
  },
10
12
  "files": [
11
13
  "polygram.js",
@@ -14,6 +16,7 @@
14
16
  "migrations/",
15
17
  "scripts/split-db.js",
16
18
  "scripts/ipc-smoke.js",
19
+ "scripts/smoke.js",
17
20
  "skills/",
18
21
  "commands/",
19
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
+ });