polygram 0.11.0-rc.2 → 0.11.0-rc.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/lib/tmux/startup-gate.js +49 -2
- package/package.json +1 -1
- package/polygram.js +46 -38
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://anthropic.com/claude-code/plugin.schema.json",
|
|
3
3
|
"name": "polygram",
|
|
4
|
-
"version": "0.11.0-rc.
|
|
4
|
+
"version": "0.11.0-rc.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 plus history (transcript queries) and polygram-send (out-of-turn IPC sends with file-upload validation) skills.",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"telegram",
|
package/lib/tmux/startup-gate.js
CHANGED
|
@@ -72,16 +72,42 @@ async function runStartupGate({
|
|
|
72
72
|
const deadline = startedAt + deadlineMs;
|
|
73
73
|
const seen = new Set();
|
|
74
74
|
const matchedTriggers = [];
|
|
75
|
+
// rc.4: remember the most recent successful pane snapshot. If the gate
|
|
76
|
+
// fails (deadline OR session-gone), include the snapshot in the error
|
|
77
|
+
// so we can see what the TUI last printed before claude exited. Without
|
|
78
|
+
// this, "claude exits code 0 after dev-channels Enter" surfaces as a
|
|
79
|
+
// 30-second `can't find pane` spam with no diagnostic about WHY.
|
|
80
|
+
let lastPane = null;
|
|
75
81
|
|
|
76
82
|
while (Date.now() < deadline) {
|
|
77
83
|
let pane;
|
|
78
84
|
try {
|
|
79
85
|
pane = await runner.captureWide(tmuxName);
|
|
80
86
|
} catch (err) {
|
|
81
|
-
|
|
87
|
+
// rc.4: detect "can't find pane" / "no server" — tmux session died
|
|
88
|
+
// (claude exited, killed the bash that hosted it, tmux tore down the
|
|
89
|
+
// pane). Fast-fail with a distinct code instead of spinning for the
|
|
90
|
+
// full deadline. Pattern matches the actual tmux capture-pane errors:
|
|
91
|
+
// - "can't find pane: <name>" (session/pane gone after spawn)
|
|
92
|
+
// - "no server running" (entire tmux server gone)
|
|
93
|
+
const msg = err?.message || '';
|
|
94
|
+
if (/can't find (pane|session)|no server running|session not found/i.test(msg)) {
|
|
95
|
+
const goneErr = new Error(
|
|
96
|
+
`[${label}] tmux session disappeared for ${tmuxName} after ${Date.now() - startedAt}ms ` +
|
|
97
|
+
`(matched: ${matchedTriggers.length ? matchedTriggers.join(', ') : 'none'}). ` +
|
|
98
|
+
`claude likely exited; last pane content:\n` +
|
|
99
|
+
_formatPaneTail(lastPane),
|
|
100
|
+
);
|
|
101
|
+
goneErr.code = 'TMUX_SESSION_GONE';
|
|
102
|
+
goneErr.lastPane = lastPane;
|
|
103
|
+
goneErr.matchedTriggers = matchedTriggers;
|
|
104
|
+
throw goneErr;
|
|
105
|
+
}
|
|
106
|
+
logger.warn?.(`[${label}] captureWide failed: ${msg}`);
|
|
82
107
|
await new Promise(r => setTimeout(r, settleMs));
|
|
83
108
|
continue;
|
|
84
109
|
}
|
|
110
|
+
lastPane = pane;
|
|
85
111
|
|
|
86
112
|
// Walk triggers in declaration order — first match (and not yet seen) wins
|
|
87
113
|
let matched = false;
|
|
@@ -111,12 +137,33 @@ async function runStartupGate({
|
|
|
111
137
|
|
|
112
138
|
const err = new Error(
|
|
113
139
|
`[${label}] startup gate did not resolve within ${deadlineMs}ms for ${tmuxName} ` +
|
|
114
|
-
`(matched: ${matchedTriggers.length ? matchedTriggers.join(', ') : 'none'})
|
|
140
|
+
`(matched: ${matchedTriggers.length ? matchedTriggers.join(', ') : 'none'}). ` +
|
|
141
|
+
`Last pane content:\n` +
|
|
142
|
+
_formatPaneTail(lastPane),
|
|
115
143
|
);
|
|
116
144
|
err.code = timeoutCode;
|
|
145
|
+
err.lastPane = lastPane;
|
|
146
|
+
err.matchedTriggers = matchedTriggers;
|
|
117
147
|
throw err;
|
|
118
148
|
}
|
|
119
149
|
|
|
150
|
+
/**
|
|
151
|
+
* Render the last ~800 chars of pane content for inclusion in error messages.
|
|
152
|
+
* Truncate at line boundaries when possible so the diagnostic isn't visually
|
|
153
|
+
* mangled. Returns "(no pane content ever captured)" for null/undefined.
|
|
154
|
+
*/
|
|
155
|
+
function _formatPaneTail(pane) {
|
|
156
|
+
if (!pane) return ' (no pane content ever captured — claude exited before first captureWide)';
|
|
157
|
+
const MAX = 800;
|
|
158
|
+
const text = String(pane);
|
|
159
|
+
if (text.length <= MAX) return text.split('\n').map(l => ' ' + l).join('\n');
|
|
160
|
+
// Take last MAX chars, then trim to a line boundary if one exists nearby
|
|
161
|
+
let tail = text.slice(-MAX);
|
|
162
|
+
const nl = tail.indexOf('\n');
|
|
163
|
+
if (nl > 0 && nl < 80) tail = tail.slice(nl + 1);
|
|
164
|
+
return ' …(truncated)…\n' + tail.split('\n').map(l => ' ' + l).join('\n');
|
|
165
|
+
}
|
|
166
|
+
|
|
120
167
|
module.exports = {
|
|
121
168
|
runStartupGate,
|
|
122
169
|
DEFAULT_DEADLINE_MS,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "polygram",
|
|
3
|
-
"version": "0.11.0-rc.
|
|
3
|
+
"version": "0.11.0-rc.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": {
|
package/polygram.js
CHANGED
|
@@ -476,6 +476,31 @@ async function sendToProcess(sessionKey, prompt, context = {}, { onDispatched }
|
|
|
476
476
|
const chatConfig = config.chats[chatId];
|
|
477
477
|
const timeoutMs = (chatConfig.timeout || config.defaults.timeout) * 1000;
|
|
478
478
|
const maxTurnMs = (chatConfig.maxTurn || config.defaults?.maxTurn || 1800) * 1000;
|
|
479
|
+
|
|
480
|
+
// ChannelsProcess-only liveness heartbeat. Lazy-attached HERE (after
|
|
481
|
+
// getOrSpawnForChat) so handleMessage stays fast for slash commands and
|
|
482
|
+
// any other non-pm.send paths — earlier wiring at handleMessage:~1003
|
|
483
|
+
// forced a cold-spawn (~30s on channels) before THINKING reactor /
|
|
484
|
+
// typing indicator / autosteer decision, hiding user feedback. Now the
|
|
485
|
+
// reactor only exists when we genuinely need a turn.
|
|
486
|
+
//
|
|
487
|
+
// Gated on entry.backend === 'channels' AND context.sourceMsgId (the
|
|
488
|
+
// TG user-msg to react on). Non-msg callers (boot-replay,
|
|
489
|
+
// autonomous-wakeup re-dispatch) pass no sourceMsgId and skip the
|
|
490
|
+
// reactor entirely.
|
|
491
|
+
let heartbeatReactor = null;
|
|
492
|
+
if (entry.backend === 'channels'
|
|
493
|
+
&& typeof context.heartbeatSetReaction === 'function'
|
|
494
|
+
&& context.sourceMsgId != null) {
|
|
495
|
+
heartbeatReactor = new HeartbeatReactor({
|
|
496
|
+
process: entry,
|
|
497
|
+
chatId,
|
|
498
|
+
messageId: context.sourceMsgId,
|
|
499
|
+
setReaction: context.heartbeatSetReaction,
|
|
500
|
+
logger: { debug: () => {}, warn: (m) => console.warn(`[${sessionKey}] ${m}`) },
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
|
|
479
504
|
// Hold the per-session lock across the FULL turn (write + result wait),
|
|
480
505
|
// not just the stdin write. Claude's stream-json input mode batches any
|
|
481
506
|
// user messages that arrive while a turn is in flight into the next
|
|
@@ -504,6 +529,10 @@ async function sendToProcess(sessionKey, prompt, context = {}, { onDispatched }
|
|
|
504
529
|
return await turnP;
|
|
505
530
|
} finally {
|
|
506
531
|
release();
|
|
532
|
+
// Belt-and-braces stop. The reactor auto-stops on idle/close/
|
|
533
|
+
// bridge-disconnected events from the Process, so this is idempotent
|
|
534
|
+
// — but it also covers the "send threw before any event fired" path.
|
|
535
|
+
heartbeatReactor?.stop();
|
|
507
536
|
}
|
|
508
537
|
}
|
|
509
538
|
|
|
@@ -1001,35 +1030,18 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1001
1030
|
});
|
|
1002
1031
|
},
|
|
1003
1032
|
});
|
|
1004
|
-
//
|
|
1005
|
-
//
|
|
1006
|
-
//
|
|
1007
|
-
//
|
|
1008
|
-
//
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
const entry = await getOrSpawnForChat(sessionKey);
|
|
1017
|
-
if (entry && typeof entry.on === 'function') {
|
|
1018
|
-
heartbeatReactor = new HeartbeatReactor({
|
|
1019
|
-
process: entry,
|
|
1020
|
-
chatId,
|
|
1021
|
-
messageId: msg.message_id,
|
|
1022
|
-
setReaction: async (cid, mid, emoji) => {
|
|
1023
|
-
await tg(bot, 'setMessageReaction', {
|
|
1024
|
-
chat_id: cid,
|
|
1025
|
-
message_id: mid,
|
|
1026
|
-
reaction: emoji.length ? [{ type: 'emoji', emoji: emoji[0] }] : [],
|
|
1027
|
-
}, { source: 'channels-heartbeat', botName: BOT_NAME }).catch(() => {});
|
|
1028
|
-
},
|
|
1029
|
-
logger: { debug: () => {}, warn: (m) => console.warn(`[${label}] ${m}`) },
|
|
1030
|
-
});
|
|
1031
|
-
}
|
|
1032
|
-
}
|
|
1033
|
+
// Channels-only heartbeat setReaction adapter. Plumbed into sendToProcess
|
|
1034
|
+
// via context; sendToProcess instantiates the actual HeartbeatReactor
|
|
1035
|
+
// lazily after getOrSpawnForChat returns (rc.3: see sendToProcess body
|
|
1036
|
+
// for why we no longer construct here). Closure over `bot` keeps the
|
|
1037
|
+
// tg() dependency local.
|
|
1038
|
+
const heartbeatSetReaction = async (cid, mid, emoji) => {
|
|
1039
|
+
await tg(bot, 'setMessageReaction', {
|
|
1040
|
+
chat_id: cid,
|
|
1041
|
+
message_id: mid,
|
|
1042
|
+
reaction: emoji.length ? [{ type: 'emoji', emoji: emoji[0] }] : [],
|
|
1043
|
+
}, { source: 'channels-heartbeat', botName: BOT_NAME }).catch(() => {});
|
|
1044
|
+
};
|
|
1033
1045
|
|
|
1034
1046
|
// rc.32: skip QUEUED (👀) entirely for first-message-in-chain. Go
|
|
1035
1047
|
// straight to THINKING (🤔). The 👀 → 🤔 two-hop didn't add
|
|
@@ -1096,6 +1108,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1096
1108
|
await new Promise((dispatched) => {
|
|
1097
1109
|
sendPromise = sendToProcess(sessionKey, prompt, {
|
|
1098
1110
|
streamer, reactor, sourceMsgId: msg.message_id,
|
|
1111
|
+
heartbeatSetReaction,
|
|
1099
1112
|
// 0.7.4 (item B): fire THINKING when Claude actually starts
|
|
1100
1113
|
// emitting — not the moment we wrote stdin.
|
|
1101
1114
|
onFirstStream: () => reactor.setState('THINKING'),
|
|
@@ -1115,11 +1128,8 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1115
1128
|
// AUTOSTEERED is terminal; stop the reactor's STALL / TIMEOUT
|
|
1116
1129
|
// timers so they don't pin the closure for up to 30s.
|
|
1117
1130
|
reactor.stop();
|
|
1118
|
-
//
|
|
1119
|
-
//
|
|
1120
|
-
// will continue ticking on its primary msg-id; this msg's heartbeat
|
|
1121
|
-
// stops here because its turn was folded into the primary's.)
|
|
1122
|
-
heartbeatReactor?.stop();
|
|
1131
|
+
// No channels-heartbeat stop here — autosteer skips sendToProcess
|
|
1132
|
+
// entirely, so no HeartbeatReactor was constructed.
|
|
1123
1133
|
markReplied();
|
|
1124
1134
|
return;
|
|
1125
1135
|
}
|
|
@@ -1620,10 +1630,8 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1620
1630
|
} finally {
|
|
1621
1631
|
stopTyping();
|
|
1622
1632
|
reactor.stop();
|
|
1623
|
-
//
|
|
1624
|
-
//
|
|
1625
|
-
// every exit path (success, throw, abort, timeout).
|
|
1626
|
-
heartbeatReactor?.stop();
|
|
1633
|
+
// HeartbeatReactor (channels-only) is stopped inside sendToProcess's
|
|
1634
|
+
// own finally block — no handleMessage-level stop needed.
|
|
1627
1635
|
// rc.38: defensive clear-on-exit for ✍ reactions. Pre-rc.38 only
|
|
1628
1636
|
// the success path (line ~2622), the abort path (line ~2858), and
|
|
1629
1637
|
// the tool-only-completion path (line ~2681) cleared
|