polygram 0.3.4 → 0.3.6

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.4",
4
+ "version": "0.3.6",
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",
@@ -11,6 +11,14 @@
11
11
  "streamReplies": true,
12
12
  "streamMinChars": 30,
13
13
  "streamThrottleMs": 500,
14
+ "_comment_pairedChatDefaults": "When a user sends /pair <CODE> from a private chat that isn't in config.chats yet, polygram auto-creates a chat entry using these defaults (merged over top-level `defaults`). Leave out `cwd` at your peril — without it, auto-onboarded DMs have no working directory and pairing will fail.",
15
+ "pairedChatDefaults": {
16
+ "agent": "admin",
17
+ "cwd": "/Users/you/admin-agent",
18
+ "model": "sonnet",
19
+ "effort": "medium",
20
+ "timeout": 600
21
+ },
14
22
  "voice": {
15
23
  "enabled": true,
16
24
  "provider": "openai",
@@ -173,14 +173,20 @@ class ProcessManager {
173
173
  entry.sessionId = event.session_id;
174
174
  if (this.onInit) this.onInit(sessionKey, event, entry);
175
175
  }
176
- if (event.type === 'assistant' && this.onStreamChunk && entry.pending) {
177
- const added = extractAssistantText(event);
178
- if (added) {
179
- entry.streamText = entry.streamText
180
- ? `${entry.streamText}\n\n${added}`
181
- : added;
182
- try { this.onStreamChunk(sessionKey, entry.streamText, entry); }
183
- catch (err) { this.logger.error(`[${entry.label}] onStreamChunk: ${err.message}`); }
176
+ if (event.type === 'assistant' && entry.pending) {
177
+ // Any assistant step (text block, tool_use, tool_result) counts as
178
+ // Claude activity — reset the idle timeout so long turns don't
179
+ // wall-clock out.
180
+ entry.pending.resetIdleTimer?.();
181
+ if (this.onStreamChunk) {
182
+ const added = extractAssistantText(event);
183
+ if (added) {
184
+ entry.streamText = entry.streamText
185
+ ? `${entry.streamText}\n\n${added}`
186
+ : added;
187
+ try { this.onStreamChunk(sessionKey, entry.streamText, entry); }
188
+ catch (err) { this.logger.error(`[${entry.label}] onStreamChunk: ${err.message}`); }
189
+ }
184
190
  }
185
191
  }
186
192
  if (event.type === 'result' && entry.pending) {
@@ -250,18 +256,27 @@ class ProcessManager {
250
256
  entry.pending = { resolve, reject };
251
257
  entry.streamText = '';
252
258
 
253
- const timer = setTimeout(() => {
259
+ // Idle timeout: counts N seconds of SILENCE from Claude, not total
260
+ // wall-clock. Long multi-tool turns that produce visible progress
261
+ // (streaming chunks, tool_use events) should not time out as long as
262
+ // they're actively working. onStreamChunk resets this below.
263
+ const arm = () => setTimeout(() => {
254
264
  if (entry.pending) {
255
265
  entry.pending = null;
256
266
  entry.inFlight = false;
257
- reject(new Error(`Timeout after ${timeoutMs / 1000}s`));
267
+ reject(new Error(`Timeout: ${timeoutMs / 1000}s idle with no Claude activity`));
258
268
  }
259
269
  }, timeoutMs);
270
+ entry.pending.timer = arm();
271
+ entry.pending.resetIdleTimer = () => {
272
+ if (entry.pending?.timer) clearTimeout(entry.pending.timer);
273
+ if (entry.pending) entry.pending.timer = arm();
274
+ };
260
275
 
261
276
  const wrappedResolve = entry.pending.resolve;
262
277
  const wrappedReject = entry.pending.reject;
263
- entry.pending.resolve = (r) => { clearTimeout(timer); wrappedResolve(r); };
264
- entry.pending.reject = (e) => { clearTimeout(timer); wrappedReject(e); };
278
+ entry.pending.resolve = (r) => { if (entry.pending?.timer) clearTimeout(entry.pending.timer); wrappedResolve(r); };
279
+ entry.pending.reject = (e) => { if (entry.pending?.timer) clearTimeout(entry.pending.timer); wrappedReject(e); };
265
280
 
266
281
  try {
267
282
  entry.proc.stdin.write(JSON.stringify({
@@ -269,9 +284,9 @@ class ProcessManager {
269
284
  message: { role: 'user', content: prompt },
270
285
  }) + '\n');
271
286
  } catch (err) {
287
+ if (entry.pending?.timer) clearTimeout(entry.pending.timer);
272
288
  entry.pending = null;
273
289
  entry.inFlight = false;
274
- clearTimeout(timer);
275
290
  reject(err);
276
291
  }
277
292
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
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
@@ -1271,6 +1271,87 @@ function createBot(token) {
1271
1271
  // not another bot's problem.
1272
1272
  const knownChat = (chatId) => !!config.chats[chatId];
1273
1273
 
1274
+ // Claim a pair code from an unconfigured private chat and persist a new
1275
+ // chat entry so subsequent messages go through the normal flow. Replies
1276
+ // to the user on both success and failure. Returns the new chatConfig on
1277
+ // success, null on any failure.
1278
+ //
1279
+ // The new chat inherits cwd/agent from bot-level pairedChatDefaults if
1280
+ // present, otherwise from the first existing chat the bot owns — on the
1281
+ // reasonable assumption that paired DMs should behave like other DMs for
1282
+ // this bot. Operator can override by setting config.bots.<bot>.pairedChatDefaults.
1283
+ async function onboardPairedChat(ctx, code) {
1284
+ const chatId = ctx.chat.id.toString();
1285
+ const userId = ctx.message.from?.id;
1286
+ const send = (text) => bot.api.sendMessage(chatId, text).catch(() => {});
1287
+
1288
+ if (!userId) {
1289
+ await send('No user id on request.');
1290
+ return null;
1291
+ }
1292
+
1293
+ const res = pairings.claimCode({
1294
+ code, claimer_user_id: userId,
1295
+ chat_id: chatId, bot_name: BOT_NAME,
1296
+ });
1297
+ dbWrite(() => db.logEvent('pair-claim-attempt', {
1298
+ bot: BOT_NAME, user_id: userId, chat_id: chatId,
1299
+ ok: res.ok, reason: res.reason, via: 'auto-onboard',
1300
+ }), 'log pair-claim-attempt');
1301
+
1302
+ if (!res.ok) {
1303
+ const reply = res.reason === 'rate-limited'
1304
+ ? 'Too many attempts. Try again later.'
1305
+ : 'Invalid or expired code.';
1306
+ await send(reply);
1307
+ return null;
1308
+ }
1309
+
1310
+ const paired = config.bot?.pairedChatDefaults || {};
1311
+ const globals = config.defaults || {};
1312
+ const firstChat = Object.values(config.chats)[0] || {};
1313
+ const chatName = paired.name
1314
+ || (ctx.chat.username && `@${ctx.chat.username}`)
1315
+ || ctx.chat.first_name
1316
+ || `User ${userId}`;
1317
+
1318
+ const cwd = paired.cwd || firstChat.cwd;
1319
+ if (!cwd) {
1320
+ dbWrite(() => db.logEvent('auto-onboard-failed', {
1321
+ bot: BOT_NAME, chat_id: chatId, user_id: userId,
1322
+ reason: 'no-cwd',
1323
+ }), 'log auto-onboard-failed');
1324
+ await send('Paired, but no working directory is configured. Ask the operator to set pairedChatDefaults.cwd.');
1325
+ return null;
1326
+ }
1327
+
1328
+ const newChat = {
1329
+ name: chatName,
1330
+ bot: BOT_NAME,
1331
+ agent: paired.agent || firstChat.agent,
1332
+ model: paired.model || globals.model || 'sonnet',
1333
+ effort: paired.effort || globals.effort || 'medium',
1334
+ cwd,
1335
+ timeout: paired.timeout || globals.timeout || 600,
1336
+ };
1337
+ if (paired.requireMention != null) newChat.requireMention = paired.requireMention;
1338
+
1339
+ config.chats[chatId] = newChat;
1340
+ try { saveConfig(); }
1341
+ catch (err) {
1342
+ console.error(`[${BOT_NAME}] saveConfig on auto-onboard failed: ${err.message}`);
1343
+ }
1344
+ dbWrite(() => db.logEvent('chat-auto-created', {
1345
+ bot: BOT_NAME, chat_id: chatId, user_id: userId,
1346
+ source: 'pair-claim', model: newChat.model, effort: newChat.effort,
1347
+ }), 'log chat-auto-created');
1348
+
1349
+ const chatLabel = res.chat_id ? `chat ${res.chat_id}` : `every chat ${BOT_NAME} is in`;
1350
+ const suffix = res.note ? `\n(${res.note})` : '';
1351
+ await send(`Paired. You can use me in ${chatLabel}.${suffix}`);
1352
+ return newChat;
1353
+ }
1354
+
1274
1355
  bot.on('message', async (ctx) => {
1275
1356
  if (!isWellFormedMessage(ctx.message)) {
1276
1357
  dbWrite(() => db.logEvent('malformed-update', {
@@ -1281,7 +1362,25 @@ function createBot(token) {
1281
1362
  return;
1282
1363
  }
1283
1364
  const chatId = ctx.chat.id.toString();
1284
- const chatConfig = config.chats[chatId];
1365
+ let chatConfig = config.chats[chatId];
1366
+
1367
+ // Auto-onboarding: /pair <CODE> from an unconfigured private chat.
1368
+ // Without this, the !chatConfig drop below would silently eat pair
1369
+ // claims from DMs the operator hasn't pre-listed — defeating the
1370
+ // whole point of pair codes (which exist to grant access without
1371
+ // pre-configuration). Group chats are not auto-onboarded: they must
1372
+ // still be added to config.json by the operator, because adding a
1373
+ // group can affect multiple users.
1374
+ if (!chatConfig && ctx.chat.type === 'private') {
1375
+ const probe = (ctx.message.text || '').trim();
1376
+ const pairMatch = /^\/pair(?:@\S+)?\s+(\S+)\s*$/.exec(probe);
1377
+ if (pairMatch) {
1378
+ chatConfig = await onboardPairedChat(ctx, pairMatch[1]);
1379
+ if (!chatConfig) return;
1380
+ recordInbound(ctx.message);
1381
+ return;
1382
+ }
1383
+ }
1285
1384
  if (!chatConfig) return;
1286
1385
 
1287
1386
  // Record every inbound msg, even unaddressed ones — needed for reply-to