@coinseeker/opencode-telegram-plugin 1.0.12 → 1.0.13

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/README.md CHANGED
@@ -59,6 +59,8 @@ Keep this file private. Never commit or share your Telegram bot token.
59
59
  - Permission approve/reject buttons from Telegram.
60
60
  - Multi-session-safe Telegram polling through a file-lock leader model.
61
61
  - Log file output instead of stdout terminal spam.
62
+ - Remote session listing via `/sessions`, `/status N`, `/start_work N`, `/help` slash commands.
63
+ - Safety-gated remote `/start-work` execution: verifies agent=plan, idle status, incomplete plan, and no active boulder before dispatching.
62
64
 
63
65
  ## Logs
64
66
 
@@ -5,8 +5,8 @@
5
5
 
6
6
  // src/telegram-remote.ts
7
7
  import { createHash as createHash5 } from "crypto";
8
- import { tmpdir as tmpdir5 } from "os";
9
- import { dirname as dirname5, join as join7 } from "path";
8
+ import { homedir as homedir3, tmpdir as tmpdir5 } from "os";
9
+ import { dirname as dirname6, join as join9 } from "path";
10
10
  import { fileURLToPath } from "url";
11
11
 
12
12
  // src/bot.ts
@@ -46,6 +46,11 @@ function createTelegramBot(opts) {
46
46
  let questionDispatcher;
47
47
  let permissionDispatcher;
48
48
  let startWorkDispatcher;
49
+ let sessionsDispatcher;
50
+ let statusDispatcher;
51
+ let startWorkCommandDispatcher;
52
+ let helpDispatcher;
53
+ let managerObj;
49
54
  if (polling) {
50
55
  bot.use(async (ctx, next) => {
51
56
  const userId = ctx.from?.id;
@@ -105,6 +110,36 @@ This chat is now active for OpenCode notifications.`
105
110
  if (!startWorkDispatcher || messageId === void 0) return;
106
111
  await startWorkDispatcher.handleCallbackQuery(data, messageId);
107
112
  });
113
+ bot.command("sessions", async (ctx) => {
114
+ if (!sessionsDispatcher) return;
115
+ const chatId = ctx.chat?.id;
116
+ const userId = ctx.from?.id;
117
+ if (chatId === void 0 || userId === void 0) return;
118
+ await sessionsDispatcher({ chatId, userId, bot: managerObj });
119
+ });
120
+ bot.command("status", async (ctx) => {
121
+ if (!statusDispatcher) return;
122
+ const chatId = ctx.chat?.id;
123
+ const userId = ctx.from?.id;
124
+ if (chatId === void 0 || userId === void 0) return;
125
+ const args = ctx.match.trim().split(/\s+/).filter(Boolean);
126
+ await statusDispatcher({ chatId, userId, bot: managerObj, args });
127
+ });
128
+ bot.command("start_work", async (ctx) => {
129
+ if (!startWorkCommandDispatcher) return;
130
+ const chatId = ctx.chat?.id;
131
+ const userId = ctx.from?.id;
132
+ if (chatId === void 0 || userId === void 0) return;
133
+ const args = ctx.match.trim().split(/\s+/).filter(Boolean);
134
+ await startWorkCommandDispatcher({ chatId, userId, bot: managerObj, args });
135
+ });
136
+ bot.command("help", async (ctx) => {
137
+ if (!helpDispatcher) return;
138
+ const chatId = ctx.chat?.id;
139
+ const userId = ctx.from?.id;
140
+ if (chatId === void 0 || userId === void 0) return;
141
+ await helpDispatcher({ chatId, userId, bot: managerObj });
142
+ });
108
143
  bot.on("message:text", async (ctx) => {
109
144
  const replyToMessageId = ctx.message.reply_to_message?.message_id;
110
145
  const chatId = ctx.chat.id;
@@ -122,12 +157,22 @@ This chat is now active for OpenCode notifications.`
122
157
  }
123
158
  throw new Error(`No active chat for ${action}. Send any message to the bot first.`);
124
159
  };
125
- return {
160
+ managerObj = {
126
161
  async start() {
127
162
  if (!polling) {
128
163
  logger.info("pass-through mode - skipping bot.start()");
129
164
  return;
130
165
  }
166
+ try {
167
+ await bot.api.setMyCommands([
168
+ { command: "sessions", description: "\uD65C\uC131 \uC138\uC158 \uBAA9\uB85D (top 20)" },
169
+ { command: "status", description: "\uC138\uC158 \uC0C1\uD0DC \uC870\uD68C (/status N)" },
170
+ { command: "start_work", description: "plan-ready \uC138\uC158 \uC2E4\uD589 (/start_work N)" },
171
+ { command: "help", description: "\uBA85\uB839 \uB3C4\uC6C0\uB9D0" }
172
+ ]);
173
+ } catch (err) {
174
+ logger.warn("setMyCommands failed", { error: String(err) });
175
+ }
131
176
  await bot.start({
132
177
  drop_pending_updates: true,
133
178
  onStart: () => {
@@ -201,8 +246,21 @@ This chat is now active for OpenCode notifications.`
201
246
  },
202
247
  setStartWorkDispatcher(dispatcher) {
203
248
  startWorkDispatcher = dispatcher;
249
+ },
250
+ setSessionsDispatcher(dispatcher) {
251
+ sessionsDispatcher = dispatcher;
252
+ },
253
+ setStatusDispatcher(dispatcher) {
254
+ statusDispatcher = dispatcher;
255
+ },
256
+ setStartWorkCommandDispatcher(dispatcher) {
257
+ startWorkCommandDispatcher = dispatcher;
258
+ },
259
+ setHelpDispatcher(dispatcher) {
260
+ helpDispatcher = dispatcher;
204
261
  }
205
262
  };
263
+ return managerObj;
206
264
  }
207
265
 
208
266
  // src/config.ts
@@ -1293,17 +1351,439 @@ async function handleSessionUpdated(event, ctx) {
1293
1351
  ctx.sessionTitleService.setSessionInfo(info);
1294
1352
  }
1295
1353
 
1354
+ // src/lib/html-escape.ts
1355
+ function escapeHtml(input) {
1356
+ return input.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1357
+ }
1358
+ function truncateForTelegram(input, maxChars, ellipsis = "\u2026") {
1359
+ const single = input.replace(/\s+/g, " ").trim();
1360
+ if (single.length <= maxChars) return single;
1361
+ if (maxChars <= 0) return "";
1362
+ if (ellipsis.length >= maxChars) return ellipsis.slice(0, maxChars);
1363
+ return single.slice(0, maxChars - ellipsis.length) + ellipsis;
1364
+ }
1365
+ function stripCodeFences(input) {
1366
+ return input.replace(/```[^\r\n`]*\r?\n([\s\S]*?)```/g, "$1").replace(/```([\s\S]*?)```/g, "$1").replace(/`([^`]*)`/g, "$1").replace(/\s+/g, " ").trim();
1367
+ }
1368
+
1369
+ // src/events/sessions-command.ts
1370
+ var MAX_BODY_CHARS = 3900;
1371
+ var MAX_TITLE_CHARS = 55;
1372
+ var MAX_SESSIONS = 20;
1373
+ function createSessionsDispatcher(deps) {
1374
+ return async ({ chatId, bot }) => {
1375
+ const sessions = deps.sessionTitleService.getRootSessionsByRecency(MAX_SESSIONS);
1376
+ if (sessions.length === 0) {
1377
+ await bot.sendMessage("\uD65C\uC131 \uC138\uC158\uC774 \uC5C6\uC2B5\uB2C8\uB2E4.", { parse_mode: "HTML" });
1378
+ return;
1379
+ }
1380
+ const capturedAt = Date.now();
1381
+ const entries = sessions.map((session, i) => {
1382
+ const entry = {
1383
+ index: i + 1,
1384
+ sessionId: session.sessionId,
1385
+ title: session.title ?? "",
1386
+ capturedAt
1387
+ };
1388
+ if (session.agent !== void 0) entry.agent = session.agent;
1389
+ if (session.status !== void 0) entry.status = session.status;
1390
+ if (session.serverUrl !== void 0) entry.serverUrl = session.serverUrl;
1391
+ return entry;
1392
+ });
1393
+ await deps.snapshotStore.saveSnapshot(chatId, entries);
1394
+ const lines = entries.map((entry) => {
1395
+ const agent = entry.agent ? escapeHtml(entry.agent) : "?";
1396
+ const title = truncateForTelegram(escapeHtml(entry.title), MAX_TITLE_CHARS);
1397
+ const status = entry.status ?? "unknown";
1398
+ return `${entry.index}. [${agent}] ${title} \u2014 ${status}`;
1399
+ });
1400
+ let body = lines.join("\n");
1401
+ if (body.length > MAX_BODY_CHARS) {
1402
+ body = body.slice(0, MAX_BODY_CHARS) + "\u2026";
1403
+ }
1404
+ const text = `<b>\uD65C\uC131 \uC138\uC158 (top ${entries.length})</b>
1405
+ ${body}
1406
+
1407
+ <i>/status N \uB610\uB294 /start_work N \uC73C\uB85C \uC870\uC791</i>`;
1408
+ await bot.sendMessage(text, { parse_mode: "HTML" });
1409
+ deps.logger.info("sessions listed", { chatId, count: entries.length });
1410
+ };
1411
+ }
1412
+
1413
+ // src/lib/plan-readiness.ts
1414
+ import { access, readFile as readFile4, readdir as readdir5, stat as stat2 } from "fs/promises";
1415
+ import { join as join5 } from "path";
1416
+ async function checkPlanReadiness(args) {
1417
+ const { projectRoot } = args;
1418
+ const omoDir = join5(projectRoot, ".omo");
1419
+ const plansDir = join5(omoDir, "plans");
1420
+ const boulderPath = join5(omoDir, "boulder.json");
1421
+ try {
1422
+ await access(omoDir);
1423
+ } catch {
1424
+ return {
1425
+ ready: false,
1426
+ reason: "no-omo-dir",
1427
+ detail: `${omoDir} does not exist`
1428
+ };
1429
+ }
1430
+ try {
1431
+ await access(boulderPath);
1432
+ return {
1433
+ ready: false,
1434
+ reason: "boulder-active",
1435
+ detail: `${boulderPath} exists`
1436
+ };
1437
+ } catch {
1438
+ }
1439
+ let planFiles = [];
1440
+ try {
1441
+ const entries = await readdir5(plansDir);
1442
+ planFiles = entries.filter((e) => e.endsWith(".md"));
1443
+ } catch {
1444
+ return {
1445
+ ready: false,
1446
+ reason: "no-plans",
1447
+ detail: `${plansDir} not found or empty`
1448
+ };
1449
+ }
1450
+ if (planFiles.length === 0) {
1451
+ return {
1452
+ ready: false,
1453
+ reason: "no-plans",
1454
+ detail: `No .md files in ${plansDir}`
1455
+ };
1456
+ }
1457
+ const stats = await Promise.all(
1458
+ planFiles.map(async (f) => {
1459
+ const full = join5(plansDir, f);
1460
+ const s = await stat2(full);
1461
+ return { path: full, name: f, mtime: s.mtime.getTime() };
1462
+ })
1463
+ );
1464
+ stats.sort((a, b) => b.mtime - a.mtime);
1465
+ const latest = stats[0];
1466
+ const content = await readFile4(latest.path, "utf8");
1467
+ const totalMatches = content.match(/^- \[[ xX]\]/gm) ?? [];
1468
+ const completedMatches = content.match(/^- \[[xX]\]/gm) ?? [];
1469
+ const total = totalMatches.length;
1470
+ const completed = completedMatches.length;
1471
+ if (total === 0) {
1472
+ return {
1473
+ ready: false,
1474
+ reason: "plan-empty",
1475
+ detail: `${latest.name}: no checkboxes found`
1476
+ };
1477
+ }
1478
+ if (completed >= total) {
1479
+ return {
1480
+ ready: false,
1481
+ reason: "all-plans-complete",
1482
+ detail: `${latest.name}: ${completed}/${total} complete`
1483
+ };
1484
+ }
1485
+ return {
1486
+ ready: true,
1487
+ planPath: latest.path,
1488
+ planName: latest.name.replace(/\.md$/, ""),
1489
+ total,
1490
+ completed
1491
+ };
1492
+ }
1493
+ async function recheckSessionIdle(client, sessionId) {
1494
+ const result = await client.session.status();
1495
+ const statuses = result.data ?? {};
1496
+ const sessionStatus = statuses[sessionId];
1497
+ return sessionStatus?.type === "idle";
1498
+ }
1499
+
1500
+ // src/events/status-command.ts
1501
+ var SNIPPET_MAX_CHARS = 80;
1502
+ var MESSAGES_LIMIT = 10;
1503
+ var EMPTY_MESSAGE = "\uBA54\uC2DC\uC9C0 \uC5C6\uC74C";
1504
+ function resolveProjectRoot(session) {
1505
+ if (!session.directory) throw new Error("session directory missing");
1506
+ return session.directory;
1507
+ }
1508
+ function extractTextFromParts(parts) {
1509
+ const pieces = [];
1510
+ for (const part of parts) {
1511
+ if (part.type === "text" && typeof part.text === "string") {
1512
+ pieces.push(part.text);
1513
+ }
1514
+ }
1515
+ return pieces.join(" ");
1516
+ }
1517
+ function buildSnippet(envelope) {
1518
+ if (!envelope) return EMPTY_MESSAGE;
1519
+ try {
1520
+ const raw = extractTextFromParts(envelope.parts);
1521
+ const cleaned = stripCodeFences(raw);
1522
+ const truncated = truncateForTelegram(cleaned, SNIPPET_MAX_CHARS);
1523
+ if (!truncated) return EMPTY_MESSAGE;
1524
+ return escapeHtml(truncated);
1525
+ } catch {
1526
+ return EMPTY_MESSAGE;
1527
+ }
1528
+ }
1529
+ function findLastByRole(messages, role) {
1530
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
1531
+ const msg = messages[i];
1532
+ if (msg && msg.info.role === role) return msg;
1533
+ }
1534
+ return void 0;
1535
+ }
1536
+ function planReadinessKorean(result) {
1537
+ if (result.ready) {
1538
+ return `${result.completed}/${result.total} (${result.planName})`;
1539
+ }
1540
+ switch (result.reason) {
1541
+ case "no-omo-dir":
1542
+ return "`.omo/` \uC5C6\uC74C";
1543
+ case "no-plans":
1544
+ return "plan \uD30C\uC77C \uC5C6\uC74C";
1545
+ case "plan-empty":
1546
+ return "\uCCB4\uD06C\uBC15\uC2A4 \uC5C6\uC74C";
1547
+ case "all-plans-complete": {
1548
+ const match = result.detail.match(/(\d+)\/(\d+)/);
1549
+ if (match) return `${match[1]}/${match[2]} \uC644\uB8CC`;
1550
+ return "\uC644\uB8CC";
1551
+ }
1552
+ case "boulder-active":
1553
+ return "boulder \uD65C\uC131";
1554
+ }
1555
+ }
1556
+ function planLine(result) {
1557
+ if (result.ready) {
1558
+ return `<b>\uD50C\uB79C \uC9C4\uD589\uB3C4</b>: ${result.completed}/${result.total} (${escapeHtml(result.planName)})`;
1559
+ }
1560
+ return `<b>\uD50C\uB79C \uC0C1\uD0DC</b>: ${planReadinessKorean(result)}`;
1561
+ }
1562
+ function boulderLine(result) {
1563
+ const active = !result.ready && result.reason === "boulder-active";
1564
+ return active ? "<b>Boulder</b>: \uD65C\uC131" : "<b>Boulder</b>: \uC5C6\uC74C";
1565
+ }
1566
+ function createStatusDispatcher(deps) {
1567
+ return async ({ chatId, bot, args }) => {
1568
+ const rawN = args[0];
1569
+ if (rawN === void 0 || rawN === "") {
1570
+ await bot.sendMessage("\uC0AC\uC6A9\uBC95: /status <\uBC88\uD638>. \uBA3C\uC800 /sessions \uB85C \uBAA9\uB85D \uD655\uC778", {
1571
+ parse_mode: "HTML"
1572
+ });
1573
+ return;
1574
+ }
1575
+ const n = Number(rawN);
1576
+ if (Number.isNaN(n)) {
1577
+ await bot.sendMessage(`\uC798\uBABB\uB41C \uC785\uB825: ${escapeHtml(rawN)}\uC740 \uC22B\uC790\uC5EC\uC57C \uD569\uB2C8\uB2E4`, {
1578
+ parse_mode: "HTML"
1579
+ });
1580
+ return;
1581
+ }
1582
+ const snapshot = await deps.snapshotStore.loadSnapshot(chatId);
1583
+ if (!snapshot) {
1584
+ await bot.sendMessage("\uC138\uC158 \uBAA9\uB85D\uC774 \uC5C6\uC2B5\uB2C8\uB2E4. \uBA3C\uC800 /sessions \uB97C \uC2E4\uD589\uD558\uC138\uC694.", {
1585
+ parse_mode: "HTML"
1586
+ });
1587
+ return;
1588
+ }
1589
+ const entry = snapshot.find((e) => e.index === n);
1590
+ if (!entry) {
1591
+ await bot.sendMessage(`${n}\uBC88 \uC138\uC158 \uC5C6\uC74C. \uD604\uC7AC \uBAA9\uB85D \uD06C\uAE30: ${snapshot.length}`, {
1592
+ parse_mode: "HTML"
1593
+ });
1594
+ return;
1595
+ }
1596
+ const [getResult, statusResult, messagesResult] = await Promise.all([
1597
+ deps.client.session.get({ path: { id: entry.sessionId } }),
1598
+ deps.client.session.status(),
1599
+ deps.client.session.messages({
1600
+ path: { id: entry.sessionId },
1601
+ query: { limit: MESSAGES_LIMIT }
1602
+ })
1603
+ ]);
1604
+ const session = getResult.data;
1605
+ const responseStatus = getResult.response?.status;
1606
+ if (!session || responseStatus === 404) {
1607
+ await bot.sendMessage("\uC138\uC158\uC774 \uB354 \uC774\uC0C1 \uC874\uC7AC\uD558\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4. /sessions \uC7AC\uC2E4\uD589 \uD544\uC694", {
1608
+ parse_mode: "HTML"
1609
+ });
1610
+ return;
1611
+ }
1612
+ const statusMap = statusResult.data ?? {};
1613
+ const sessionStatus = statusMap[entry.sessionId]?.type ?? "unknown";
1614
+ const messages = messagesResult.data ?? [];
1615
+ const projectRoot = resolveProjectRoot(session);
1616
+ const planReady = await checkPlanReadiness({ projectRoot });
1617
+ const userSnippet = buildSnippet(findLastByRole(messages, "user"));
1618
+ const assistantSnippet = buildSnippet(findLastByRole(messages, "assistant"));
1619
+ const title = escapeHtml(session.title ?? "");
1620
+ const agent = entry.agent ? escapeHtml(entry.agent) : "?";
1621
+ const text = [
1622
+ `<b>\uC138\uC158 #${n}</b>: ${title}`,
1623
+ `\uC5D0\uC774\uC804\uD2B8: ${agent}`,
1624
+ `\uC0C1\uD0DC: ${escapeHtml(sessionStatus)}`,
1625
+ ``,
1626
+ `<b>\uB9C8\uC9C0\uB9C9 \uBA54\uC2DC\uC9C0</b>`,
1627
+ `\uC720\uC800: ${userSnippet}`,
1628
+ `\uC5D0\uC774\uC804\uD2B8: ${assistantSnippet}`,
1629
+ ``,
1630
+ planLine(planReady),
1631
+ ``,
1632
+ boulderLine(planReady)
1633
+ ].join("\n");
1634
+ await bot.sendMessage(text, { parse_mode: "HTML" });
1635
+ deps.logger.info("status shown", {
1636
+ chatId,
1637
+ sessionId: entry.sessionId,
1638
+ snapshotIndex: n
1639
+ });
1640
+ };
1641
+ }
1642
+
1643
+ // src/events/start-work-command.ts
1644
+ function agentFromSession(session) {
1645
+ const candidate = session;
1646
+ return typeof candidate.agent === "string" ? candidate.agent : void 0;
1647
+ }
1648
+ function resolveProjectRoot2(session) {
1649
+ return session.directory;
1650
+ }
1651
+ function readinessMessage(reason) {
1652
+ switch (reason) {
1653
+ case "no-omo-dir":
1654
+ return ".omo/ \uB514\uB809\uD1A0\uB9AC\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4. plan \uC791\uC131\uC774 \uC120\uD589\uB418\uC5B4\uC57C \uD569\uB2C8\uB2E4";
1655
+ case "no-plans":
1656
+ return ".omo/plans/ \uC5D0 plan \uD30C\uC77C\uC774 \uC5C6\uC2B5\uB2C8\uB2E4";
1657
+ case "plan-empty":
1658
+ return "plan \uD30C\uC77C\uC5D0 \uCCB4\uD06C\uBC15\uC2A4\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4 (\uD5E4\uB354\uB9CC \uC874\uC7AC)";
1659
+ case "all-plans-complete":
1660
+ return "plan \uC758 \uBAA8\uB4E0 task \uAC00 \uC644\uB8CC\uB418\uC5C8\uC2B5\uB2C8\uB2E4. \uC0C8 plan \uC791\uC131 \uD544\uC694";
1661
+ case "boulder-active":
1662
+ return ".omo/boulder.json \uC774 \uC774\uBBF8 \uC874\uC7AC\uD569\uB2C8\uB2E4. \uAE30\uC874 \uC791\uC5C5\uC774 \uC9C4\uD589 \uC911\uC774\uAC70\uB098 archive \uAC00 \uD544\uC694\uD569\uB2C8\uB2E4";
1663
+ }
1664
+ }
1665
+ function isSessionNotFoundError(err) {
1666
+ const httpError = err;
1667
+ return httpError.status === 404 || httpError.statusCode === 404 || httpError.response?.status === 404 || err.message.includes("404");
1668
+ }
1669
+ async function sendHtml(bot, text) {
1670
+ await bot.sendMessage(text, { parse_mode: "HTML" });
1671
+ }
1672
+ async function sendPlain(bot, text) {
1673
+ await bot.sendMessage(text);
1674
+ }
1675
+ function createStartWorkCommandDispatcher(deps) {
1676
+ return async ({ chatId, bot, args }) => {
1677
+ const rawIndex = args[0]?.trim();
1678
+ if (!rawIndex) {
1679
+ await sendPlain(bot, "\uC0AC\uC6A9\uBC95: /start_work <\uBC88\uD638>. \uBA3C\uC800 /sessions \uB85C \uBAA9\uB85D \uD655\uC778");
1680
+ return;
1681
+ }
1682
+ const index = Number(rawIndex);
1683
+ if (Number.isNaN(index)) {
1684
+ await sendPlain(bot, `\uC798\uBABB\uB41C \uC785\uB825: ${rawIndex}\uC740 \uC22B\uC790\uC5EC\uC57C \uD569\uB2C8\uB2E4`);
1685
+ return;
1686
+ }
1687
+ const snapshot = await deps.snapshotStore.loadSnapshot(chatId);
1688
+ if (snapshot === null) {
1689
+ await sendPlain(bot, "\uC138\uC158 \uBAA9\uB85D\uC774 \uC5C6\uC2B5\uB2C8\uB2E4. \uBA3C\uC800 /sessions \uC2E4\uD589");
1690
+ return;
1691
+ }
1692
+ const entry = snapshot.find((candidate) => candidate.index === index);
1693
+ if (!entry) {
1694
+ await sendPlain(bot, `${index}\uBC88 \uC138\uC158 \uC5C6\uC74C (\uBAA9\uB85D \uD06C\uAE30: ${snapshot.length})`);
1695
+ return;
1696
+ }
1697
+ const sessionId = entry.sessionId;
1698
+ let session;
1699
+ try {
1700
+ const result = await deps.client.session.get({ path: { id: sessionId } });
1701
+ if (!result.data) {
1702
+ await sendPlain(bot, "\uC138\uC158\uC774 \uB354 \uC774\uC0C1 \uC874\uC7AC\uD558\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4");
1703
+ return;
1704
+ }
1705
+ session = result.data;
1706
+ } catch (err) {
1707
+ if (err instanceof Error && isSessionNotFoundError(err)) {
1708
+ await sendPlain(bot, "\uC138\uC158\uC774 \uB354 \uC774\uC0C1 \uC874\uC7AC\uD558\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4");
1709
+ return;
1710
+ }
1711
+ await sendPlain(bot, `\uC138\uC158 \uD655\uC778 \uC2E4\uD328: ${String(err)}`);
1712
+ deps.logger.error("start-work session lookup failed", { sessionId, error: String(err) });
1713
+ return;
1714
+ }
1715
+ const agent = deps.sessionTitleService.getSessionAgent(sessionId) ?? agentFromSession(session);
1716
+ if (agent !== "plan") {
1717
+ await sendPlain(
1718
+ bot,
1719
+ `${index}\uBC88 \uC138\uC158\uC758 \uC5D0\uC774\uC804\uD2B8\uB294 'plan' \uC774 \uC544\uB2D9\uB2C8\uB2E4 (\uD604\uC7AC: ${agent ?? "unknown"}). /start_work \uB294 plan \uC138\uC158\uC5D0\uC11C\uB9CC \uAC00\uB2A5\uD569\uB2C8\uB2E4`
1720
+ );
1721
+ return;
1722
+ }
1723
+ const idle = await recheckSessionIdle(deps.client, sessionId);
1724
+ if (!idle) {
1725
+ await sendPlain(bot, `${index}\uBC88 \uC138\uC158\uC774 idle \uC0C1\uD0DC\uAC00 \uC544\uB2D9\uB2C8\uB2E4. \uC791\uC5C5 \uC644\uB8CC\uB97C \uAE30\uB2E4\uB9AC\uC138\uC694`);
1726
+ return;
1727
+ }
1728
+ const readiness = await checkPlanReadiness({ projectRoot: resolveProjectRoot2(session) });
1729
+ if (!readiness.ready) {
1730
+ await sendPlain(bot, readinessMessage(readiness.reason));
1731
+ return;
1732
+ }
1733
+ try {
1734
+ const serverUrl = deps.sessionTitleService.getServerUrl(sessionId);
1735
+ await deps.runSessionCommand(sessionId, "start-work", serverUrl);
1736
+ await sendHtml(
1737
+ bot,
1738
+ `${index}\uBC88 \uC138\uC158\uC5D0 opencode /start-work \uC2AC\uB798\uC2DC \uCEE4\uB9E8\uB4DC \uC804\uC1A1 \uC644\uB8CC. (${escapeHtml(entry.title)})`
1739
+ );
1740
+ deps.logger.info("start-work dispatched", { chatId, sessionId, index });
1741
+ } catch (err) {
1742
+ await sendHtml(bot, "opencode /start-work \uC804\uC1A1 \uC2E4\uD328: " + String(err));
1743
+ deps.logger.error("start-work dispatch failed", { sessionId, error: String(err) });
1744
+ }
1745
+ };
1746
+ }
1747
+
1748
+ // src/events/help-command.ts
1749
+ var HELP_TEXT = `<b>OpenCode Telegram Plugin \u2014 \uBA85\uB839 \uB3C4\uC6C0\uB9D0</b>
1750
+
1751
+ <b>/sessions</b>
1752
+ \uD65C\uC131 root \uC138\uC158 \uBAA9\uB85D\uC744 \uBC88\uD638\uC640 \uD568\uAED8 \uD45C\uC2DC (\uCD5C\uADFC\uD65C\uB3D9\uC21C top 20).
1753
+
1754
+ <b>/status &lt;\uBC88\uD638&gt;</b>
1755
+ \uD574\uB2F9 \uC138\uC158\uC758 \uC5D0\uC774\uC804\uD2B8/\uC0C1\uD0DC/\uB9C8\uC9C0\uB9C9 \uBA54\uC2DC\uC9C0 \uC2A4\uB2C8\uD3AB/\uD50C\uB79C \uC9C4\uD589\uB3C4/boulder \uC0C1\uD0DC \uD45C\uC2DC.
1756
+
1757
+ <b>/start_work &lt;\uBC88\uD638&gt;</b>
1758
+ \uD574\uB2F9 \uC138\uC158\uC5D0 opencode <code>/start-work</code> \uC2AC\uB798\uC2DC \uCEE4\uB9E8\uB4DC \uC804\uC1A1.
1759
+ \uC548\uC804 \uAC8C\uC774\uD2B8: agent='plan' AND status=idle AND .omo/plans \uC5D0 \uBBF8\uC644\uB8CC plan \uC874\uC7AC AND .omo/boulder.json \uBD80\uC7AC.
1760
+ \uC870\uAC74 \uBBF8\uCDA9\uC871\uC2DC \uAD6C\uCCB4\uC801 \uC0AC\uC720 \uC548\uB0B4.
1761
+ (Telegram \uBD07 \uBA85\uB839\uC740 <code>/start_work</code>, \uB0B4\uBD80 \uD2B8\uB9AC\uAC70 \uB300\uC0C1\uC740 opencode \uC758 <code>/start-work</code>)
1762
+
1763
+ <b>/help</b>
1764
+ \uC774 \uB3C4\uC6C0\uB9D0 \uD45C\uC2DC.
1765
+
1766
+ <b>\uC81C\uC57D</b>
1767
+ \uBC88\uD638\uB294 <code>/sessions</code> \uB9C8\uC9C0\uB9C9 \uD638\uCD9C\uC758 \uC2A4\uB0C5\uC0F7\uC5D0 \uC885\uC18D (TTL 1\uC2DC\uAC04).
1768
+ leader \uD504\uB85C\uC138\uC2A4\uAC00 \uAD00\uCC30\uD55C \uC138\uC158\uB9CC \uD45C\uC2DC \u2014 \uB2E4\uB978 OpenCode \uD504\uB85C\uC138\uC2A4\uC758 \uC138\uC158\uC740 \uBCF4\uC774\uC9C0 \uC54A\uC744 \uC218 \uC788\uC74C.`;
1769
+ function createHelpDispatcher(deps) {
1770
+ return async ({ chatId, bot }) => {
1771
+ await bot.sendMessage(HELP_TEXT, { parse_mode: "HTML" });
1772
+ deps.logger.info("help shown", { chatId });
1773
+ };
1774
+ }
1775
+
1296
1776
  // src/lib/env-loader.ts
1297
1777
  import { existsSync } from "fs";
1298
1778
  import { homedir } from "os";
1299
- import { join as join5 } from "path";
1779
+ import { join as join6 } from "path";
1300
1780
  import dotenv from "dotenv";
1301
1781
  function loadPluginEnv(opts) {
1302
1782
  const paths = [
1303
- join5(opts.pluginDir, "../../.env"),
1304
- join5(opts.pluginDir, "..", ".env"),
1305
- join5(opts.pluginDir, ".env"),
1306
- join5(opts.homeDir ?? homedir(), ".config/opencode/telegram-remote/.env")
1783
+ join6(opts.pluginDir, "../../.env"),
1784
+ join6(opts.pluginDir, "..", ".env"),
1785
+ join6(opts.pluginDir, ".env"),
1786
+ join6(opts.homeDir ?? homedir(), ".config/opencode/telegram-remote/.env")
1307
1787
  ];
1308
1788
  const loadedFrom = [];
1309
1789
  const values = {};
@@ -1321,7 +1801,7 @@ function loadPluginEnv(opts) {
1321
1801
  }
1322
1802
 
1323
1803
  // src/lib/lock.ts
1324
- import { open as open2, readFile as readFile4, stat as stat2, unlink as unlink5 } from "fs/promises";
1804
+ import { open as open2, readFile as readFile5, stat as stat3, unlink as unlink5 } from "fs/promises";
1325
1805
  import { hostname } from "os";
1326
1806
  var DEFAULT_TTL_MS2 = 5 * 60 * 1e3;
1327
1807
  function hasCode5(err, code) {
@@ -1374,7 +1854,7 @@ async function inspectExisting(lockPath, ttlMs) {
1374
1854
  let ownerPid;
1375
1855
  let dead = false;
1376
1856
  try {
1377
- const text = await readFile4(lockPath, "utf8");
1857
+ const text = await readFile5(lockPath, "utf8");
1378
1858
  const data = parseLockData(text);
1379
1859
  if (data) {
1380
1860
  ownerPid = data.pid;
@@ -1384,7 +1864,7 @@ async function inspectExisting(lockPath, ttlMs) {
1384
1864
  return { stale: true, reason: "unreadable lock" };
1385
1865
  }
1386
1866
  try {
1387
- const fileStat = await stat2(lockPath);
1867
+ const fileStat = await stat3(lockPath);
1388
1868
  const expired = Date.now() - fileStat.mtimeMs > ttlMs;
1389
1869
  if (dead) return { stale: true, ownerPid, reason: "dead owner" };
1390
1870
  if (expired) return { stale: true, ownerPid, reason: "expired lock" };
@@ -1487,11 +1967,146 @@ function createLogger(opts = {}) {
1487
1967
  };
1488
1968
  }
1489
1969
 
1970
+ // src/lib/session-snapshot.ts
1971
+ import { chmod, mkdir as mkdir5, readFile as readFile6, rename as rename4, unlink as unlink6, writeFile as writeFile4 } from "fs/promises";
1972
+ import { dirname as dirname4, join as join7 } from "path";
1973
+ var TTL_MS = 60 * 60 * 1e3;
1974
+ function hasCode6(err, code) {
1975
+ return err instanceof Error && "code" in err && err.code === code;
1976
+ }
1977
+ function isSnapshotFile(value) {
1978
+ if (!value || typeof value !== "object") return false;
1979
+ const v = value;
1980
+ if (v.version !== 1) return false;
1981
+ if (typeof v.chatId !== "number") return false;
1982
+ if (typeof v.createdAt !== "number") return false;
1983
+ if (!Array.isArray(v.entries)) return false;
1984
+ for (const entry of v.entries) {
1985
+ if (!entry || typeof entry !== "object") return false;
1986
+ const e = entry;
1987
+ if (typeof e.index !== "number") return false;
1988
+ if (typeof e.sessionId !== "string") return false;
1989
+ if (typeof e.title !== "string") return false;
1990
+ if (typeof e.capturedAt !== "number") return false;
1991
+ if (e.agent !== void 0 && typeof e.agent !== "string") return false;
1992
+ if (e.status !== void 0 && typeof e.status !== "string") return false;
1993
+ if (e.serverUrl !== void 0 && typeof e.serverUrl !== "string") return false;
1994
+ }
1995
+ return true;
1996
+ }
1997
+ function normalizeEntry(entry) {
1998
+ const out = {
1999
+ index: entry.index,
2000
+ sessionId: entry.sessionId,
2001
+ title: entry.title,
2002
+ capturedAt: entry.capturedAt
2003
+ };
2004
+ if (entry.agent !== void 0) out.agent = entry.agent;
2005
+ if (entry.status !== void 0) out.status = entry.status;
2006
+ if (entry.serverUrl !== void 0) out.serverUrl = entry.serverUrl;
2007
+ return out;
2008
+ }
2009
+ function createSnapshotStore(opts) {
2010
+ const { configDir, tokenHash, logger } = opts;
2011
+ const snapshotsDir = join7(configDir, "snapshots");
2012
+ const writeLocks = /* @__PURE__ */ new Map();
2013
+ function snapshotFilePath(chatId) {
2014
+ return join7(snapshotsDir, `${tokenHash}-${chatId}.json`);
2015
+ }
2016
+ async function performSave(chatId, entries) {
2017
+ const filePath = snapshotFilePath(chatId);
2018
+ const parent = dirname4(filePath);
2019
+ await mkdir5(parent, { recursive: true });
2020
+ try {
2021
+ await chmod(parent, 448);
2022
+ } catch (err) {
2023
+ logger.error("snapshot: failed to chmod parent dir", {
2024
+ path: parent,
2025
+ error: err instanceof Error ? err.message : String(err)
2026
+ });
2027
+ }
2028
+ const payload = {
2029
+ version: 1,
2030
+ chatId,
2031
+ createdAt: Date.now(),
2032
+ entries: entries.map(normalizeEntry)
2033
+ };
2034
+ const tmpPath = `${filePath}.tmp.${process.pid}.${Date.now()}`;
2035
+ await writeFile4(tmpPath, JSON.stringify(payload, null, 2), "utf8");
2036
+ try {
2037
+ await rename4(tmpPath, filePath);
2038
+ } catch (err) {
2039
+ try {
2040
+ await unlink6(tmpPath);
2041
+ } catch {
2042
+ }
2043
+ throw err;
2044
+ }
2045
+ await chmod(filePath, 384);
2046
+ }
2047
+ async function saveSnapshot(chatId, entries) {
2048
+ const prev = writeLocks.get(chatId) ?? Promise.resolve();
2049
+ const next = prev.catch(() => void 0).then(() => performSave(chatId, entries));
2050
+ const tracked = next.catch(() => void 0);
2051
+ writeLocks.set(chatId, tracked);
2052
+ try {
2053
+ await next;
2054
+ } finally {
2055
+ if (writeLocks.get(chatId) === tracked) {
2056
+ writeLocks.delete(chatId);
2057
+ }
2058
+ }
2059
+ }
2060
+ async function loadSnapshot(chatId) {
2061
+ const filePath = snapshotFilePath(chatId);
2062
+ let text;
2063
+ try {
2064
+ text = await readFile6(filePath, "utf8");
2065
+ } catch (err) {
2066
+ if (hasCode6(err, "ENOENT")) return null;
2067
+ logger.error("snapshot: failed to read file", {
2068
+ path: filePath,
2069
+ error: err instanceof Error ? err.message : String(err)
2070
+ });
2071
+ return null;
2072
+ }
2073
+ let parsed;
2074
+ try {
2075
+ parsed = JSON.parse(text);
2076
+ } catch (err) {
2077
+ logger.error("snapshot: corrupted JSON", {
2078
+ path: filePath,
2079
+ error: err instanceof Error ? err.message : String(err)
2080
+ });
2081
+ return null;
2082
+ }
2083
+ if (!isSnapshotFile(parsed)) {
2084
+ logger.error("snapshot: invalid shape", { path: filePath });
2085
+ return null;
2086
+ }
2087
+ if (parsed.createdAt + TTL_MS < Date.now()) {
2088
+ try {
2089
+ await unlink6(filePath);
2090
+ } catch (err) {
2091
+ if (!hasCode6(err, "ENOENT")) {
2092
+ logger.error("snapshot: failed to unlink expired file", {
2093
+ path: filePath,
2094
+ error: err instanceof Error ? err.message : String(err)
2095
+ });
2096
+ }
2097
+ }
2098
+ return null;
2099
+ }
2100
+ return parsed.entries.map(normalizeEntry);
2101
+ }
2102
+ return { saveSnapshot, loadSnapshot, snapshotFilePath };
2103
+ }
2104
+
1490
2105
  // src/lib/state-store.ts
1491
- import { mkdir as mkdir5, readFile as readFile5, rename as rename4, writeFile as writeFile4 } from "fs/promises";
2106
+ import { mkdir as mkdir6, readFile as readFile7, rename as rename5, writeFile as writeFile5 } from "fs/promises";
1492
2107
  import { homedir as homedir2 } from "os";
1493
- import { dirname as dirname4, join as join6 } from "path";
1494
- function hasCode6(err, code) {
2108
+ import { dirname as dirname5, join as join8 } from "path";
2109
+ function hasCode7(err, code) {
1495
2110
  return "code" in err && err.code === code;
1496
2111
  }
1497
2112
  function parseState(text) {
@@ -1503,28 +2118,28 @@ function parseState(text) {
1503
2118
  return state;
1504
2119
  }
1505
2120
  function createStateStore(opts = {}) {
1506
- const filePath = opts.filePath ?? join6(homedir2(), ".config/opencode/telegram-remote/state.json");
2121
+ const filePath = opts.filePath ?? join8(homedir2(), ".config/opencode/telegram-remote/state.json");
1507
2122
  return {
1508
2123
  async read() {
1509
2124
  try {
1510
- return parseState(await readFile5(filePath, "utf8"));
2125
+ return parseState(await readFile7(filePath, "utf8"));
1511
2126
  } catch (err) {
1512
- if (err instanceof Error && hasCode6(err, "ENOENT")) return {};
2127
+ if (err instanceof Error && hasCode7(err, "ENOENT")) return {};
1513
2128
  throw err;
1514
2129
  }
1515
2130
  },
1516
2131
  async write(patch) {
1517
2132
  const existing = await this.read();
1518
2133
  const next = { ...existing, ...patch, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
1519
- await mkdir5(dirname4(filePath), { recursive: true });
2134
+ await mkdir6(dirname5(filePath), { recursive: true });
1520
2135
  const tmpPath = `${filePath}.tmp.${process.pid}`;
1521
- await writeFile4(tmpPath, JSON.stringify(next, null, 2), "utf8");
2136
+ await writeFile5(tmpPath, JSON.stringify(next, null, 2), "utf8");
1522
2137
  try {
1523
- await rename4(tmpPath, filePath);
2138
+ await rename5(tmpPath, filePath);
1524
2139
  } catch (err) {
1525
- if (!(err instanceof Error) || !hasCode6(err, "ENOENT")) throw err;
1526
- await writeFile4(tmpPath, JSON.stringify(next, null, 2), "utf8");
1527
- await rename4(tmpPath, filePath);
2140
+ if (!(err instanceof Error) || !hasCode7(err, "ENOENT")) throw err;
2141
+ await writeFile5(tmpPath, JSON.stringify(next, null, 2), "utf8");
2142
+ await rename5(tmpPath, filePath);
1528
2143
  }
1529
2144
  return next;
1530
2145
  }
@@ -1532,7 +2147,7 @@ function createStateStore(opts = {}) {
1532
2147
  }
1533
2148
 
1534
2149
  // src/services/session-title-service.ts
1535
- function agentFromSession(info) {
2150
+ function agentFromSession2(info) {
1536
2151
  const candidate = info;
1537
2152
  return typeof candidate.agent === "string" ? candidate.agent : void 0;
1538
2153
  }
@@ -1543,9 +2158,11 @@ var SessionTitleService = class {
1543
2158
  this.sessions.set(info.id, {
1544
2159
  title: info.title || null,
1545
2160
  parentID: info.parentID ?? null,
1546
- agent: agentFromSession(info) ?? existing?.agent,
2161
+ agent: agentFromSession2(info) ?? existing?.agent,
1547
2162
  status: existing?.status,
1548
- idleNotificationPending: existing?.idleNotificationPending ?? false
2163
+ idleNotificationPending: existing?.idleNotificationPending ?? false,
2164
+ lastSeenAt: Date.now(),
2165
+ serverUrl: existing?.serverUrl
1549
2166
  });
1550
2167
  }
1551
2168
  setSessionTitle(sessionId, title) {
@@ -1555,7 +2172,9 @@ var SessionTitleService = class {
1555
2172
  parentID: existing?.parentID,
1556
2173
  agent: existing?.agent,
1557
2174
  status: existing?.status,
1558
- idleNotificationPending: existing?.idleNotificationPending ?? false
2175
+ idleNotificationPending: existing?.idleNotificationPending ?? false,
2176
+ lastSeenAt: Date.now(),
2177
+ serverUrl: existing?.serverUrl
1559
2178
  });
1560
2179
  }
1561
2180
  setSessionAgent(sessionId, agent) {
@@ -1565,7 +2184,9 @@ var SessionTitleService = class {
1565
2184
  parentID: existing?.parentID,
1566
2185
  agent,
1567
2186
  status: existing?.status,
1568
- idleNotificationPending: existing?.idleNotificationPending ?? false
2187
+ idleNotificationPending: existing?.idleNotificationPending ?? false,
2188
+ lastSeenAt: Date.now(),
2189
+ serverUrl: existing?.serverUrl
1569
2190
  });
1570
2191
  }
1571
2192
  setSessionStatus(sessionId, status) {
@@ -1575,9 +2196,48 @@ var SessionTitleService = class {
1575
2196
  parentID: existing?.parentID,
1576
2197
  agent: existing?.agent,
1577
2198
  status,
1578
- idleNotificationPending: status === "idle" ? existing?.idleNotificationPending ?? false : false
2199
+ idleNotificationPending: status === "idle" ? existing?.idleNotificationPending ?? false : false,
2200
+ lastSeenAt: Date.now(),
2201
+ serverUrl: existing?.serverUrl
1579
2202
  });
1580
2203
  }
2204
+ setServerUrl(sessionId, serverUrl) {
2205
+ const existing = this.sessions.get(sessionId);
2206
+ if (existing?.serverUrl) return;
2207
+ const lastSeenAt = existing?.lastSeenAt ?? Date.now();
2208
+ this.sessions.set(sessionId, {
2209
+ ...existing ?? {
2210
+ title: null,
2211
+ parentID: void 0,
2212
+ idleNotificationPending: false,
2213
+ lastSeenAt
2214
+ },
2215
+ lastSeenAt,
2216
+ serverUrl
2217
+ });
2218
+ }
2219
+ getServerUrl(sessionId) {
2220
+ return this.sessions.get(sessionId)?.serverUrl;
2221
+ }
2222
+ getRootSessionsByRecency(limit) {
2223
+ const results = [];
2224
+ for (const [sessionId, entry] of this.sessions.entries()) {
2225
+ if (entry.parentID !== null) continue;
2226
+ results.push({
2227
+ sessionId,
2228
+ title: entry.title,
2229
+ agent: entry.agent,
2230
+ status: entry.status,
2231
+ serverUrl: entry.serverUrl
2232
+ });
2233
+ }
2234
+ results.sort((a, b) => {
2235
+ const lastSeenA = this.sessions.get(a.sessionId)?.lastSeenAt ?? 0;
2236
+ const lastSeenB = this.sessions.get(b.sessionId)?.lastSeenAt ?? 0;
2237
+ return lastSeenB - lastSeenA;
2238
+ });
2239
+ return results.slice(0, limit);
2240
+ }
1581
2241
  getSessionTitle(sessionId) {
1582
2242
  return this.sessions.get(sessionId)?.title ?? null;
1583
2243
  }
@@ -1605,7 +2265,9 @@ var SessionTitleService = class {
1605
2265
  parentID: existing?.parentID,
1606
2266
  agent: existing?.agent,
1607
2267
  status: existing?.status ?? "idle",
1608
- idleNotificationPending: true
2268
+ idleNotificationPending: true,
2269
+ lastSeenAt: existing?.lastSeenAt ?? Date.now(),
2270
+ serverUrl: existing?.serverUrl
1609
2271
  });
1610
2272
  }
1611
2273
  hasDeferredIdleNotification(sessionId) {
@@ -1622,7 +2284,7 @@ var SessionTitleService = class {
1622
2284
  };
1623
2285
 
1624
2286
  // src/telegram-remote.ts
1625
- var pluginDir = dirname5(fileURLToPath(import.meta.url));
2287
+ var pluginDir = dirname6(fileURLToPath(import.meta.url));
1626
2288
  async function postToServer(serverUrl, path, body) {
1627
2289
  const url = new URL(path, serverUrl);
1628
2290
  const response = await fetch(url, {
@@ -1658,8 +2320,10 @@ var TelegramRemote = async (input) => {
1658
2320
  const stateStore = createStateStore();
1659
2321
  const initialState = await stateStore.read();
1660
2322
  const tokenHash = createHash5("sha256").update(config.botToken).digest("hex").slice(0, 16);
1661
- const lockPath = join7(tmpdir5(), `opencoder-telegram-${tokenHash}.lock`);
1662
- const claimsDir = join7(tmpdir5(), `opencoder-telegram-claims-${tokenHash}`);
2323
+ const configDir = join9(homedir3(), ".config/opencode/telegram-remote");
2324
+ const snapshotStore = createSnapshotStore({ configDir, tokenHash, logger });
2325
+ const lockPath = join9(tmpdir5(), `opencoder-telegram-${tokenHash}.lock`);
2326
+ const claimsDir = join9(tmpdir5(), `opencoder-telegram-claims-${tokenHash}`);
1663
2327
  const pendingQuestions = createPendingQuestionStore({ tokenHash });
1664
2328
  const pendingPermissions = createPendingPermissionStore({ tokenHash });
1665
2329
  const pendingStartWorks = createPendingStartWorkStore({ tokenHash });
@@ -1782,6 +2446,26 @@ var TelegramRemote = async (input) => {
1782
2446
  bot.setQuestionDispatcher(createQuestionDispatcher(ctx));
1783
2447
  bot.setPermissionDispatcher(createPermissionDispatcher(ctx));
1784
2448
  bot.setStartWorkDispatcher(createStartWorkDispatcher(ctx));
2449
+ bot.setSessionsDispatcher(createSessionsDispatcher({ sessionTitleService, snapshotStore, logger }));
2450
+ bot.setStatusDispatcher(createStatusDispatcher({ snapshotStore, sessionTitleService, client: input.client, logger }));
2451
+ bot.setStartWorkCommandDispatcher(createStartWorkCommandDispatcher({
2452
+ snapshotStore,
2453
+ sessionTitleService,
2454
+ client: input.client,
2455
+ runSessionCommand,
2456
+ logger
2457
+ }));
2458
+ bot.setHelpDispatcher(createHelpDispatcher({ logger }));
2459
+ try {
2460
+ const sessions = await input.client.session.list();
2461
+ for (const s of sessions.data ?? []) {
2462
+ sessionTitleService.setSessionInfo(s);
2463
+ sessionTitleService.setServerUrl(s.id, input.serverUrl.href);
2464
+ }
2465
+ logger.info("cold-start cache primed", { count: (sessions.data ?? []).length });
2466
+ } catch (err) {
2467
+ logger.error("cold-start priming failed", { error: String(err) });
2468
+ }
1785
2469
  }
1786
2470
  return {
1787
2471
  event: async ({ event }) => {
@@ -1793,13 +2477,17 @@ var TelegramRemote = async (input) => {
1793
2477
  logger.info("session.status received", { statusType: event.properties.status.type });
1794
2478
  return handleSessionStatus(event, ctx);
1795
2479
  case "session.created":
2480
+ ctx.sessionTitleService.setServerUrl(event.properties.info.id, input.serverUrl.href);
1796
2481
  return handleSessionCreated(event, ctx);
1797
2482
  case "session.updated":
2483
+ ctx.sessionTitleService.setServerUrl(event.properties.info.id, input.serverUrl.href);
1798
2484
  return handleSessionUpdated(event, ctx);
1799
2485
  case "message.updated": {
1800
2486
  const messageAgent = getSessionAgentFromMessage(event);
1801
- if (messageAgent)
2487
+ if (messageAgent) {
1802
2488
  ctx.sessionTitleService.setSessionAgent(messageAgent.sessionID, messageAgent.agent);
2489
+ ctx.sessionTitleService.setServerUrl(messageAgent.sessionID, input.serverUrl.href);
2490
+ }
1803
2491
  return;
1804
2492
  }
1805
2493
  case "permission.updated":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coinseeker/opencode-telegram-plugin",
3
- "version": "1.0.12",
3
+ "version": "1.0.13",
4
4
  "description": "Control and monitor OpenCode from Telegram with notifications, question replies, and subagent-aware completion.",
5
5
  "type": "module",
6
6
  "main": "dist/telegram-remote.js",