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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +32 -0
- package/commands/status.md +50 -44
- package/package.json +4 -2
- package/polygram.js +30 -7
- package/scripts/smoke.js +122 -0
|
@@ -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.
|
|
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
|
package/commands/status.md
CHANGED
|
@@ -2,66 +2,72 @@
|
|
|
2
2
|
description: Show polygram daemon health — running bots, IPC sockets, recent events.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
Report
|
|
5
|
+
Report polygram health. Be mechanical: run exactly these commands in
|
|
6
|
+
order, parse their output, produce a single Markdown table.
|
|
6
7
|
|
|
7
|
-
|
|
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
|
-
|
|
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
|
-
|
|
21
|
-
|
|
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
|
-
|
|
24
|
-
|
|
20
|
+
# 4. Running Node processes (authoritative)
|
|
21
|
+
pgrep -fa 'polygram --bot' || true
|
|
25
22
|
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
35
|
+
## Interpretation rules
|
|
36
36
|
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
55
|
-
|
|
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
|
-
|
|
58
|
+
## Output format
|
|
58
59
|
|
|
59
|
-
|
|
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
|
-
|
|
62
|
-
|
|
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
|
|
67
|
-
|
|
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.
|
|
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
|
|
109
|
-
//
|
|
110
|
-
//
|
|
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
|
-
|
|
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,
|
package/scripts/smoke.js
ADDED
|
@@ -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
|
+
});
|