@coinseeker/opencode-telegram-plugin 1.0.11 → 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
@@ -15,11 +15,11 @@ Configure the npm package in `~/.config/opencode/opencode.json`:
15
15
 
16
16
  ```json
17
17
  {
18
- "plugin": ["@coinseeker/opencode-telegram-plugin@1.0.11"]
18
+ "plugin": ["@coinseeker/opencode-telegram-plugin@1.0.12"]
19
19
  }
20
20
  ```
21
21
 
22
- Current stable version: `@coinseeker/opencode-telegram-plugin@1.0.11`.
22
+ Current stable version: `@coinseeker/opencode-telegram-plugin@1.0.12`.
23
23
 
24
24
  Restart OpenCode after editing the config. OpenCode resolves npm package plugins on startup.
25
25
 
@@ -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
@@ -508,6 +566,46 @@ async function handlePermissionUpdated(event, ctx) {
508
566
  async function handlePermissionAsked(event, ctx) {
509
567
  await handleNormalizedPermission(normalizeAsked(event.properties), ctx);
510
568
  }
569
+ function isEventPermissionReplied(event) {
570
+ if (event.type !== "permission.replied") return false;
571
+ const props = event.properties;
572
+ if (!props) return false;
573
+ if (typeof props.sessionID !== "string") return false;
574
+ const hasId = typeof props.permissionID === "string" || typeof props.requestID === "string";
575
+ return hasId;
576
+ }
577
+ function externalReplyLabel(value) {
578
+ if (value === "once") return "Allowed once in opencode";
579
+ if (value === "always") return "Always allowed in opencode";
580
+ if (value === "reject") return "Rejected in opencode";
581
+ return "Already answered in opencode";
582
+ }
583
+ async function handlePermissionReplied(event, ctx) {
584
+ const requestID = event.properties.requestID ?? event.properties.permissionID;
585
+ if (!requestID) return;
586
+ const found = await ctx.pendingPermissions.findByRequestID(requestID);
587
+ if (!found) return;
588
+ const label = externalReplyLabel(event.properties.reply ?? event.properties.response);
589
+ try {
590
+ await ctx.bot.editMessageRemoveKeyboard(
591
+ found.data.telegramMessageId,
592
+ `\u2705 ${label}
593
+
594
+ ${found.data.permission}: ${found.data.title}`
595
+ );
596
+ ctx.logger.info("permission externally replied - cleared pending", {
597
+ requestID,
598
+ sessionID: event.properties.sessionID
599
+ });
600
+ } catch (err) {
601
+ ctx.logger.error("failed to edit externally replied permission", {
602
+ error: String(err),
603
+ requestID
604
+ });
605
+ } finally {
606
+ await ctx.pendingPermissions.deletePending(found.shortHash);
607
+ }
608
+ }
511
609
  function createPermissionDispatcher(ctx) {
512
610
  return {
513
611
  async handleCallbackQuery(data, messageId) {
@@ -1253,17 +1351,439 @@ async function handleSessionUpdated(event, ctx) {
1253
1351
  ctx.sessionTitleService.setSessionInfo(info);
1254
1352
  }
1255
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
+
1256
1776
  // src/lib/env-loader.ts
1257
1777
  import { existsSync } from "fs";
1258
1778
  import { homedir } from "os";
1259
- import { join as join5 } from "path";
1779
+ import { join as join6 } from "path";
1260
1780
  import dotenv from "dotenv";
1261
1781
  function loadPluginEnv(opts) {
1262
1782
  const paths = [
1263
- join5(opts.pluginDir, "../../.env"),
1264
- join5(opts.pluginDir, "..", ".env"),
1265
- join5(opts.pluginDir, ".env"),
1266
- 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")
1267
1787
  ];
1268
1788
  const loadedFrom = [];
1269
1789
  const values = {};
@@ -1281,7 +1801,7 @@ function loadPluginEnv(opts) {
1281
1801
  }
1282
1802
 
1283
1803
  // src/lib/lock.ts
1284
- 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";
1285
1805
  import { hostname } from "os";
1286
1806
  var DEFAULT_TTL_MS2 = 5 * 60 * 1e3;
1287
1807
  function hasCode5(err, code) {
@@ -1334,7 +1854,7 @@ async function inspectExisting(lockPath, ttlMs) {
1334
1854
  let ownerPid;
1335
1855
  let dead = false;
1336
1856
  try {
1337
- const text = await readFile4(lockPath, "utf8");
1857
+ const text = await readFile5(lockPath, "utf8");
1338
1858
  const data = parseLockData(text);
1339
1859
  if (data) {
1340
1860
  ownerPid = data.pid;
@@ -1344,7 +1864,7 @@ async function inspectExisting(lockPath, ttlMs) {
1344
1864
  return { stale: true, reason: "unreadable lock" };
1345
1865
  }
1346
1866
  try {
1347
- const fileStat = await stat2(lockPath);
1867
+ const fileStat = await stat3(lockPath);
1348
1868
  const expired = Date.now() - fileStat.mtimeMs > ttlMs;
1349
1869
  if (dead) return { stale: true, ownerPid, reason: "dead owner" };
1350
1870
  if (expired) return { stale: true, ownerPid, reason: "expired lock" };
@@ -1447,11 +1967,146 @@ function createLogger(opts = {}) {
1447
1967
  };
1448
1968
  }
1449
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
+
1450
2105
  // src/lib/state-store.ts
1451
- 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";
1452
2107
  import { homedir as homedir2 } from "os";
1453
- import { dirname as dirname4, join as join6 } from "path";
1454
- function hasCode6(err, code) {
2108
+ import { dirname as dirname5, join as join8 } from "path";
2109
+ function hasCode7(err, code) {
1455
2110
  return "code" in err && err.code === code;
1456
2111
  }
1457
2112
  function parseState(text) {
@@ -1463,28 +2118,28 @@ function parseState(text) {
1463
2118
  return state;
1464
2119
  }
1465
2120
  function createStateStore(opts = {}) {
1466
- 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");
1467
2122
  return {
1468
2123
  async read() {
1469
2124
  try {
1470
- return parseState(await readFile5(filePath, "utf8"));
2125
+ return parseState(await readFile7(filePath, "utf8"));
1471
2126
  } catch (err) {
1472
- if (err instanceof Error && hasCode6(err, "ENOENT")) return {};
2127
+ if (err instanceof Error && hasCode7(err, "ENOENT")) return {};
1473
2128
  throw err;
1474
2129
  }
1475
2130
  },
1476
2131
  async write(patch) {
1477
2132
  const existing = await this.read();
1478
2133
  const next = { ...existing, ...patch, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
1479
- await mkdir5(dirname4(filePath), { recursive: true });
2134
+ await mkdir6(dirname5(filePath), { recursive: true });
1480
2135
  const tmpPath = `${filePath}.tmp.${process.pid}`;
1481
- await writeFile4(tmpPath, JSON.stringify(next, null, 2), "utf8");
2136
+ await writeFile5(tmpPath, JSON.stringify(next, null, 2), "utf8");
1482
2137
  try {
1483
- await rename4(tmpPath, filePath);
2138
+ await rename5(tmpPath, filePath);
1484
2139
  } catch (err) {
1485
- if (!(err instanceof Error) || !hasCode6(err, "ENOENT")) throw err;
1486
- await writeFile4(tmpPath, JSON.stringify(next, null, 2), "utf8");
1487
- 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);
1488
2143
  }
1489
2144
  return next;
1490
2145
  }
@@ -1492,7 +2147,7 @@ function createStateStore(opts = {}) {
1492
2147
  }
1493
2148
 
1494
2149
  // src/services/session-title-service.ts
1495
- function agentFromSession(info) {
2150
+ function agentFromSession2(info) {
1496
2151
  const candidate = info;
1497
2152
  return typeof candidate.agent === "string" ? candidate.agent : void 0;
1498
2153
  }
@@ -1503,9 +2158,11 @@ var SessionTitleService = class {
1503
2158
  this.sessions.set(info.id, {
1504
2159
  title: info.title || null,
1505
2160
  parentID: info.parentID ?? null,
1506
- agent: agentFromSession(info) ?? existing?.agent,
2161
+ agent: agentFromSession2(info) ?? existing?.agent,
1507
2162
  status: existing?.status,
1508
- idleNotificationPending: existing?.idleNotificationPending ?? false
2163
+ idleNotificationPending: existing?.idleNotificationPending ?? false,
2164
+ lastSeenAt: Date.now(),
2165
+ serverUrl: existing?.serverUrl
1509
2166
  });
1510
2167
  }
1511
2168
  setSessionTitle(sessionId, title) {
@@ -1515,7 +2172,9 @@ var SessionTitleService = class {
1515
2172
  parentID: existing?.parentID,
1516
2173
  agent: existing?.agent,
1517
2174
  status: existing?.status,
1518
- idleNotificationPending: existing?.idleNotificationPending ?? false
2175
+ idleNotificationPending: existing?.idleNotificationPending ?? false,
2176
+ lastSeenAt: Date.now(),
2177
+ serverUrl: existing?.serverUrl
1519
2178
  });
1520
2179
  }
1521
2180
  setSessionAgent(sessionId, agent) {
@@ -1525,7 +2184,9 @@ var SessionTitleService = class {
1525
2184
  parentID: existing?.parentID,
1526
2185
  agent,
1527
2186
  status: existing?.status,
1528
- idleNotificationPending: existing?.idleNotificationPending ?? false
2187
+ idleNotificationPending: existing?.idleNotificationPending ?? false,
2188
+ lastSeenAt: Date.now(),
2189
+ serverUrl: existing?.serverUrl
1529
2190
  });
1530
2191
  }
1531
2192
  setSessionStatus(sessionId, status) {
@@ -1535,9 +2196,48 @@ var SessionTitleService = class {
1535
2196
  parentID: existing?.parentID,
1536
2197
  agent: existing?.agent,
1537
2198
  status,
1538
- idleNotificationPending: status === "idle" ? existing?.idleNotificationPending ?? false : false
2199
+ idleNotificationPending: status === "idle" ? existing?.idleNotificationPending ?? false : false,
2200
+ lastSeenAt: Date.now(),
2201
+ serverUrl: existing?.serverUrl
1539
2202
  });
1540
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
+ }
1541
2241
  getSessionTitle(sessionId) {
1542
2242
  return this.sessions.get(sessionId)?.title ?? null;
1543
2243
  }
@@ -1565,7 +2265,9 @@ var SessionTitleService = class {
1565
2265
  parentID: existing?.parentID,
1566
2266
  agent: existing?.agent,
1567
2267
  status: existing?.status ?? "idle",
1568
- idleNotificationPending: true
2268
+ idleNotificationPending: true,
2269
+ lastSeenAt: existing?.lastSeenAt ?? Date.now(),
2270
+ serverUrl: existing?.serverUrl
1569
2271
  });
1570
2272
  }
1571
2273
  hasDeferredIdleNotification(sessionId) {
@@ -1582,7 +2284,7 @@ var SessionTitleService = class {
1582
2284
  };
1583
2285
 
1584
2286
  // src/telegram-remote.ts
1585
- var pluginDir = dirname5(fileURLToPath(import.meta.url));
2287
+ var pluginDir = dirname6(fileURLToPath(import.meta.url));
1586
2288
  async function postToServer(serverUrl, path, body) {
1587
2289
  const url = new URL(path, serverUrl);
1588
2290
  const response = await fetch(url, {
@@ -1618,8 +2320,10 @@ var TelegramRemote = async (input) => {
1618
2320
  const stateStore = createStateStore();
1619
2321
  const initialState = await stateStore.read();
1620
2322
  const tokenHash = createHash5("sha256").update(config.botToken).digest("hex").slice(0, 16);
1621
- const lockPath = join7(tmpdir5(), `opencoder-telegram-${tokenHash}.lock`);
1622
- 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}`);
1623
2327
  const pendingQuestions = createPendingQuestionStore({ tokenHash });
1624
2328
  const pendingPermissions = createPendingPermissionStore({ tokenHash });
1625
2329
  const pendingStartWorks = createPendingStartWorkStore({ tokenHash });
@@ -1742,6 +2446,26 @@ var TelegramRemote = async (input) => {
1742
2446
  bot.setQuestionDispatcher(createQuestionDispatcher(ctx));
1743
2447
  bot.setPermissionDispatcher(createPermissionDispatcher(ctx));
1744
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
+ }
1745
2469
  }
1746
2470
  return {
1747
2471
  event: async ({ event }) => {
@@ -1753,13 +2477,17 @@ var TelegramRemote = async (input) => {
1753
2477
  logger.info("session.status received", { statusType: event.properties.status.type });
1754
2478
  return handleSessionStatus(event, ctx);
1755
2479
  case "session.created":
2480
+ ctx.sessionTitleService.setServerUrl(event.properties.info.id, input.serverUrl.href);
1756
2481
  return handleSessionCreated(event, ctx);
1757
2482
  case "session.updated":
2483
+ ctx.sessionTitleService.setServerUrl(event.properties.info.id, input.serverUrl.href);
1758
2484
  return handleSessionUpdated(event, ctx);
1759
2485
  case "message.updated": {
1760
2486
  const messageAgent = getSessionAgentFromMessage(event);
1761
- if (messageAgent)
2487
+ if (messageAgent) {
1762
2488
  ctx.sessionTitleService.setSessionAgent(messageAgent.sessionID, messageAgent.agent);
2489
+ ctx.sessionTitleService.setServerUrl(messageAgent.sessionID, input.serverUrl.href);
2490
+ }
1763
2491
  return;
1764
2492
  }
1765
2493
  case "permission.updated":
@@ -1784,6 +2512,9 @@ var TelegramRemote = async (input) => {
1784
2512
  if (isEventQuestionReplied(extEvent)) {
1785
2513
  return handleQuestionReplied(extEvent, ctx);
1786
2514
  }
2515
+ if (isEventPermissionReplied(extEvent)) {
2516
+ return handlePermissionReplied(extEvent, ctx);
2517
+ }
1787
2518
  return;
1788
2519
  }
1789
2520
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coinseeker/opencode-telegram-plugin",
3
- "version": "1.0.11",
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",