@coinseeker/opencode-telegram-plugin 1.1.4 → 1.1.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.
package/README.md CHANGED
@@ -15,15 +15,15 @@ Configure the npm package in `~/.config/opencode/opencode.json`:
15
15
 
16
16
  ```json
17
17
  {
18
- "plugin": ["@coinseeker/opencode-telegram-plugin@1.1.4"]
18
+ "plugin": ["@coinseeker/opencode-telegram-plugin@1.1.6"]
19
19
  }
20
20
  ```
21
21
 
22
- Current stable version: `@coinseeker/opencode-telegram-plugin@1.1.4`.
22
+ Current stable version: `@coinseeker/opencode-telegram-plugin@1.1.6`.
23
23
 
24
24
  Restart OpenCode after editing the config. OpenCode resolves npm package plugins on startup.
25
25
 
26
- To update an existing install, replace the previous pinned package entry with `@coinseeker/opencode-telegram-plugin@1.1.4`, keep the rest of the `plugin` array unchanged, and restart OpenCode.
26
+ To update an existing install, replace the previous pinned package entry with `@coinseeker/opencode-telegram-plugin@1.1.6`, keep the rest of the `plugin` array unchanged, and restart OpenCode.
27
27
 
28
28
  ## Configure Telegram
29
29
 
@@ -269,11 +269,24 @@ This chat is now active for OpenCode notifications.`
269
269
  }
270
270
 
271
271
  // src/config.ts
272
+ function parseInteger(value) {
273
+ if (!/^-?\d+$/.test(value)) return void 0;
274
+ const parsed = Number(value);
275
+ return Number.isSafeInteger(parsed) ? parsed : void 0;
276
+ }
272
277
  function parseAllowedUserIds(value) {
273
278
  if (!value || value.trim() === "") {
274
- return [];
279
+ return void 0;
275
280
  }
276
- return value.split(",").map((id2) => id2.trim()).filter((id2) => id2 !== "").map((id2) => Number.parseInt(id2, 10)).filter((id2) => !Number.isNaN(id2));
281
+ const tokens = value.split(",").map((id2) => id2.trim()).filter((id2) => id2 !== "");
282
+ if (tokens.length === 0) return void 0;
283
+ const parsed = [];
284
+ for (const token of tokens) {
285
+ const id2 = parseInteger(token);
286
+ if (id2 === void 0) return void 0;
287
+ parsed.push(id2);
288
+ }
289
+ return parsed;
277
290
  }
278
291
  function loadConfig(opts) {
279
292
  const { logger, env } = opts;
@@ -285,18 +298,23 @@ function loadConfig(opts) {
285
298
  throw new Error("Missing required environment variable: TELEGRAM_BOT_TOKEN");
286
299
  }
287
300
  const allowedUserIds = parseAllowedUserIds(allowedUserIdsStr);
288
- if (allowedUserIds.length === 0) {
301
+ if (allowedUserIds === void 0) {
289
302
  logger.error("missing or invalid TELEGRAM_ALLOWED_USER_IDS");
290
303
  throw new Error("Missing or invalid TELEGRAM_ALLOWED_USER_IDS");
291
304
  }
292
305
  let chatId;
293
306
  if (chatIdStr && chatIdStr.trim() !== "") {
294
- const parsed = Number.parseInt(chatIdStr.trim(), 10);
295
- if (!Number.isNaN(parsed)) {
296
- chatId = parsed;
307
+ const parsed = parseInteger(chatIdStr.trim());
308
+ if (parsed === void 0) {
309
+ logger.error("invalid TELEGRAM_CHAT_ID");
310
+ throw new Error("Invalid TELEGRAM_CHAT_ID");
297
311
  }
312
+ chatId = parsed;
298
313
  }
299
- logger.info("config loaded", { allowedUserCount: allowedUserIds.length, hasChatId: chatId !== void 0 });
314
+ logger.info("config loaded", {
315
+ allowedUserCount: allowedUserIds.length,
316
+ hasChatId: chatId !== void 0
317
+ });
300
318
  return {
301
319
  botToken,
302
320
  allowedUserIds,
@@ -305,9 +323,9 @@ function loadConfig(opts) {
305
323
  }
306
324
 
307
325
  // src/lib/claim.ts
326
+ import { createHash } from "crypto";
308
327
  import { mkdir, open, readdir, stat, unlink } from "fs/promises";
309
328
  import { join } from "path";
310
- import { createHash } from "crypto";
311
329
  var DEFAULT_TTL_MS = 6e4;
312
330
  var sweptDirs = /* @__PURE__ */ new Set();
313
331
  function hasCode(err, code) {
@@ -322,16 +340,18 @@ async function sweep(claimsDir, ttlMs) {
322
340
  sweptDirs.add(claimsDir);
323
341
  try {
324
342
  const entries = await readdir(claimsDir, { withFileTypes: true });
325
- await Promise.all(entries.filter((entry) => entry.isFile() && entry.name.endsWith(".claim")).map(async (entry) => {
326
- const filePath = join(claimsDir, entry.name);
327
- try {
328
- const fileStat = await stat(filePath);
329
- if (Date.now() - fileStat.mtimeMs > ttlMs * 2) {
330
- await unlink(filePath);
343
+ await Promise.all(
344
+ entries.filter((entry) => entry.isFile() && entry.name.endsWith(".claim")).map(async (entry) => {
345
+ const filePath = join(claimsDir, entry.name);
346
+ try {
347
+ const fileStat = await stat(filePath);
348
+ if (Date.now() - fileStat.mtimeMs > ttlMs * 2) {
349
+ await unlink(filePath);
350
+ }
351
+ } catch {
331
352
  }
332
- } catch {
333
- }
334
- }));
353
+ })
354
+ );
335
355
  } catch {
336
356
  }
337
357
  }
@@ -366,6 +386,13 @@ async function claimOnce(opts) {
366
386
  }
367
387
  return false;
368
388
  }
389
+ async function releaseClaim(opts) {
390
+ try {
391
+ await unlink(claimPath(opts.claimsDir, opts.key));
392
+ } catch (err) {
393
+ if (!(err instanceof Error) || !hasCode(err, "ENOENT")) throw err;
394
+ }
395
+ }
369
396
 
370
397
  // src/lib/pending-permissions.ts
371
398
  import { createHash as createHash2 } from "crypto";
@@ -562,7 +589,8 @@ async function upgradeLegacyPendingPermission(permission, ctx) {
562
589
  }
563
590
  async function handleNormalizedPermission(permission, ctx) {
564
591
  const permissionKey = `${ctx.serverUrl.href}:${permission.sessionID}:${permission.requestID}`;
565
- const claimed = await claimOnce({ claimsDir: ctx.claimsDir, key: `permission:${permissionKey}` });
592
+ const claimKey = `permission:${permissionKey}`;
593
+ const claimed = await claimOnce({ claimsDir: ctx.claimsDir, key: claimKey });
566
594
  if (!claimed) {
567
595
  if (permission.endpoint === "request") await upgradeLegacyPendingPermission(permission, ctx);
568
596
  return;
@@ -596,6 +624,7 @@ async function handleNormalizedPermission(permission, ctx) {
596
624
  };
597
625
  await ctx.pendingPermissions.savePending(shortHash, pending);
598
626
  } catch (err) {
627
+ await releaseClaim({ claimsDir: ctx.claimsDir, key: claimKey });
599
628
  ctx.logger.error("failed to send permission notification", { error: String(err) });
600
629
  }
601
630
  }
@@ -662,6 +691,11 @@ function createPermissionDispatcher(ctx) {
662
691
  await ctx.bot.editMessageRemoveKeyboard(messageId, "This permission request has expired.");
663
692
  return;
664
693
  }
694
+ if (pending.expiresAt < Date.now()) {
695
+ await ctx.bot.editMessageRemoveKeyboard(messageId, "This permission request has expired.");
696
+ await ctx.pendingPermissions.deletePending(shortHash);
697
+ return;
698
+ }
665
699
  try {
666
700
  await ctx.replyToPermission(
667
701
  pending.requestID,
@@ -887,7 +921,7 @@ async function editPromptForQuestion(ctx, pending, shortHash, questionIndex) {
887
921
  });
888
922
  }
889
923
  async function completeIfReady(ctx, pending, shortHash) {
890
- const nextIndex = pending.answersInProgress.findIndex((answer) => answer === null);
924
+ const nextIndex = pending.answersInProgress.indexOf(null);
891
925
  if (nextIndex >= 0) {
892
926
  pending.currentQuestionIndex = nextIndex;
893
927
  await ctx.pendingQuestions.savePending(shortHash, pending);
@@ -920,11 +954,8 @@ ${answerSummary(pending.questions, answers)}`
920
954
  async function handleQuestionAsked(event, ctx) {
921
955
  const request = event.properties;
922
956
  if (request.questions.length === 0) return;
923
- const claimed = await claimOnce({
924
- claimsDir: ctx.claimsDir,
925
- key: `question:${ctx.serverUrl.href}:${request.sessionID}:${request.id}`,
926
- ttlMs: 5e3
927
- });
957
+ const claimKey = `question:${ctx.serverUrl.href}:${request.sessionID}:${request.id}`;
958
+ const claimed = await claimOnce({ claimsDir: ctx.claimsDir, key: claimKey, ttlMs: 5e3 });
928
959
  if (!claimed) return;
929
960
  const shortHash = createQuestionShortHash(request.id, request.sessionID, ctx.serverUrl.href);
930
961
  const firstQuestion = request.questions[0];
@@ -957,6 +988,7 @@ async function handleQuestionAsked(event, ctx) {
957
988
  count: request.questions.length
958
989
  });
959
990
  } catch (err) {
991
+ await releaseClaim({ claimsDir: ctx.claimsDir, key: claimKey });
960
992
  ctx.logger.error("failed to send question prompt", {
961
993
  error: String(err),
962
994
  requestID: request.id
@@ -976,6 +1008,11 @@ function createQuestionDispatcher(ctx) {
976
1008
  await ctx.bot.editMessageRemoveKeyboard(messageId, "This question has expired.");
977
1009
  return;
978
1010
  }
1011
+ if (pending.expiresAt < Date.now()) {
1012
+ await ctx.bot.editMessageRemoveKeyboard(messageId, "This question has expired.");
1013
+ await ctx.pendingQuestions.deletePending(shortHash);
1014
+ return;
1015
+ }
979
1016
  pending.expiresAt = Date.now() + QUESTION_EXPIRY_MS;
980
1017
  const question = pending.questions[questionIndex];
981
1018
  if (!question) return;
@@ -1030,6 +1067,11 @@ function createQuestionDispatcher(ctx) {
1030
1067
  if (!match) return;
1031
1068
  const awaiting = match.data.awaitingCustomFor;
1032
1069
  if (!awaiting || awaiting.promptMessageId !== replyToMessageId) return;
1070
+ if (match.data.expiresAt < Date.now()) {
1071
+ await ctx.bot.sendMessage("This question has expired.");
1072
+ await ctx.pendingQuestions.deletePending(match.shortHash);
1073
+ return;
1074
+ }
1033
1075
  match.data.expiresAt = Date.now() + QUESTION_EXPIRY_MS;
1034
1076
  const question = match.data.questions[awaiting.questionIndex];
1035
1077
  if (question?.multiple === true) {
@@ -1084,7 +1126,7 @@ async function handleQuestionReplied(event, ctx) {
1084
1126
  }
1085
1127
 
1086
1128
  // src/lib/session-registry.ts
1087
- import { chmod, mkdir as mkdir4, readFile as readFile3, readdir as readdir4, rename as rename3, unlink as unlink4, writeFile as writeFile3 } from "fs/promises";
1129
+ import { chmod, mkdir as mkdir4, readdir as readdir4, readFile as readFile3, rename as rename3, unlink as unlink4, writeFile as writeFile3 } from "fs/promises";
1088
1130
  import { join as join4 } from "path";
1089
1131
 
1090
1132
  // src/lib/opencode-http.ts
@@ -1221,7 +1263,7 @@ async function getRemoteMessages(serverUrl, sessionId, limit, fetcher = fetch) {
1221
1263
 
1222
1264
  // src/lib/session-registry.ts
1223
1265
  function filenameForSession(sessionId) {
1224
- return Buffer.from(sessionId).toString("base64url") + ".json";
1266
+ return `${Buffer.from(sessionId).toString("base64url")}.json`;
1225
1267
  }
1226
1268
  function hasCode4(err, code) {
1227
1269
  return err instanceof Error && "code" in err && err.code === code;
@@ -1341,7 +1383,7 @@ function createSessionRegistryStore(opts) {
1341
1383
  ...patch,
1342
1384
  sessionId,
1343
1385
  title: patch.title ?? existing.title,
1344
- parentID: patch.parentID ?? existing.parentID,
1386
+ parentID: patch.parentID === void 0 ? existing.parentID : patch.parentID,
1345
1387
  serverUrl: patch.serverUrl ?? existing.serverUrl,
1346
1388
  updatedAt: patch.updatedAt ?? Date.now()
1347
1389
  });
@@ -1432,8 +1474,8 @@ async function handleSessionError(event, ctx) {
1432
1474
  // src/lib/plan-agent.ts
1433
1475
  function isPlanSessionAgent(agent) {
1434
1476
  if (!agent) return false;
1435
- const normalized = agent.trim().replace(/[–—]/g, "-").replace(/\s+/g, " ").toLowerCase();
1436
- return normalized === "plan" || normalized === "prometheus" || normalized === "prometheus - plan builder" || normalized === "prometheus (plan builder)";
1477
+ const normalized = agent.normalize("NFKC").replace(/[\u200B-\u200D\uFEFF]/g, "").replace(/[‐‑‒–—―]/g, "-").replace(/\s+/g, " ").replace(/\s*-\s*/g, " - ").trim().toLowerCase();
1478
+ return normalized === "plan" || normalized === "prometheus" || normalized === "prometheus - plan builder" || normalized === "prometheus (plan builder)" || normalized.includes("prometheus") && normalized.includes("plan builder");
1437
1479
  }
1438
1480
 
1439
1481
  // src/lib/pending-start-work.ts
@@ -1460,6 +1502,14 @@ function parsePending3(text) {
1460
1502
  throw new Error("Invalid pending start-work: expiresAt");
1461
1503
  if (typeof parsed.telegramMessageId !== "number")
1462
1504
  throw new Error("Invalid pending start-work: telegramMessageId");
1505
+ if (parsed.telegramMessageIds !== void 0 && (!Array.isArray(parsed.telegramMessageIds) || !parsed.telegramMessageIds.every((messageId) => typeof messageId === "number")))
1506
+ throw new Error("Invalid pending start-work: telegramMessageIds");
1507
+ if (parsed.status !== void 0 && parsed.status !== "pending" && parsed.status !== "consumed")
1508
+ throw new Error("Invalid pending start-work: status");
1509
+ if (parsed.handledAt !== void 0 && typeof parsed.handledAt !== "number")
1510
+ throw new Error("Invalid pending start-work: handledAt");
1511
+ parsed.telegramMessageIds = parsed.telegramMessageIds ?? [parsed.telegramMessageId];
1512
+ parsed.status = parsed.status ?? "pending";
1463
1513
  return parsed;
1464
1514
  }
1465
1515
  async function listPendingFiles3(dir) {
@@ -1520,14 +1570,7 @@ function createStartWorkShortHash(sessionID) {
1520
1570
 
1521
1571
  // src/events/start-work.ts
1522
1572
  var CALLBACK_RE3 = /^sw:([^:]+)$/;
1523
- var START_WORK_COMMAND = "start-work";
1524
1573
  var START_WORK_EXPIRY_MS = 24 * 60 * 6e4;
1525
- function startWorkKeyboard(shortHash) {
1526
- const callbackData = `sw:${shortHash}`;
1527
- if (Buffer.byteLength(callbackData, "utf8") > 64)
1528
- throw new Error("Telegram callback_data exceeds 64 bytes");
1529
- return [[{ text: "\u25B6\uFE0F Run /start-work", callback_data: callbackData }]];
1530
- }
1531
1574
  function planCompleteMessage(title) {
1532
1575
  return title ? `plan \uC791\uC131\uC774 \uB05D\uB0AC\uC5B4\uC694.
1533
1576
 
@@ -1541,7 +1584,9 @@ function createPendingStartWork(sessionID, title, serverUrl, telegramMessageId)
1541
1584
  title: title ?? void 0,
1542
1585
  sentAt,
1543
1586
  expiresAt: sentAt + START_WORK_EXPIRY_MS,
1544
- telegramMessageId
1587
+ telegramMessageId,
1588
+ telegramMessageIds: [telegramMessageId],
1589
+ status: "pending"
1545
1590
  };
1546
1591
  }
1547
1592
  function startWorkShortHash(sessionID) {
@@ -1552,6 +1597,34 @@ async function expirePending(ctx, shortHash, pending, messageId) {
1552
1597
  await ctx.pendingStartWorks.deletePending(shortHash);
1553
1598
  ctx.logger.info("pending start-work expired", { sessionID: pending.sessionID });
1554
1599
  }
1600
+ var START_WORK_BUTTON_DISABLED_MESSAGE = "This /start-work button is no longer used. Use /sessions and /start_work <number> instead.";
1601
+ function messageIdsFor(pending, currentMessageId) {
1602
+ return [
1603
+ .../* @__PURE__ */ new Set([...pending.telegramMessageIds ?? [pending.telegramMessageId], currentMessageId])
1604
+ ];
1605
+ }
1606
+ async function editDuplicateMessages(ctx, pending, currentMessageId) {
1607
+ for (const messageId of messageIdsFor(pending, currentMessageId)) {
1608
+ if (messageId === currentMessageId) continue;
1609
+ try {
1610
+ await ctx.bot.editMessageRemoveKeyboard(messageId, START_WORK_BUTTON_DISABLED_MESSAGE);
1611
+ } catch (err) {
1612
+ ctx.logger.warn("failed to clear duplicate start-work keyboard", {
1613
+ messageId,
1614
+ error: String(err)
1615
+ });
1616
+ }
1617
+ }
1618
+ }
1619
+ async function consumePending(ctx, shortHash, pending, messageId) {
1620
+ await ctx.pendingStartWorks.savePending(shortHash, {
1621
+ ...pending,
1622
+ telegramMessageId: messageId,
1623
+ telegramMessageIds: messageIdsFor(pending, messageId),
1624
+ status: "consumed",
1625
+ handledAt: Date.now()
1626
+ });
1627
+ }
1555
1628
  function createStartWorkDispatcher(ctx) {
1556
1629
  return {
1557
1630
  async handleCallbackQuery(data, messageId) {
@@ -1563,32 +1636,18 @@ function createStartWorkDispatcher(ctx) {
1563
1636
  await ctx.bot.editMessageRemoveKeyboard(messageId, "This /start-work request has expired.");
1564
1637
  return;
1565
1638
  }
1639
+ if (pending.status === "consumed") {
1640
+ await ctx.bot.editMessageRemoveKeyboard(messageId, START_WORK_BUTTON_DISABLED_MESSAGE);
1641
+ return;
1642
+ }
1566
1643
  if (pending.expiresAt < Date.now()) {
1567
1644
  await expirePending(ctx, shortHash, pending, messageId);
1568
1645
  return;
1569
1646
  }
1570
- try {
1571
- await ctx.runSessionCommand(pending.sessionID, START_WORK_COMMAND, pending.serverUrl);
1572
- const label = pending.title ?? pending.sessionID;
1573
- await ctx.bot.editMessageRemoveKeyboard(
1574
- messageId,
1575
- `\u25B6\uFE0F Sent /start-work to opencode.
1576
-
1577
- Session: ${label}`
1578
- );
1579
- ctx.logger.info("start-work command sent", { sessionID: pending.sessionID });
1580
- } catch (err) {
1581
- await ctx.bot.editMessageRemoveKeyboard(
1582
- messageId,
1583
- "\u26A0\uFE0F Failed to send /start-work to opencode"
1584
- );
1585
- ctx.logger.error("failed to send start-work command", {
1586
- sessionID: pending.sessionID,
1587
- error: String(err)
1588
- });
1589
- } finally {
1590
- await ctx.pendingStartWorks.deletePending(shortHash);
1591
- }
1647
+ await consumePending(ctx, shortHash, pending, messageId);
1648
+ await ctx.bot.editMessageRemoveKeyboard(messageId, START_WORK_BUTTON_DISABLED_MESSAGE);
1649
+ await editDuplicateMessages(ctx, pending, messageId);
1650
+ ctx.logger.info("legacy start-work button disabled", { sessionID: pending.sessionID });
1592
1651
  }
1593
1652
  };
1594
1653
  }
@@ -1604,6 +1663,34 @@ function agentFinishedMessage(title, agent) {
1604
1663
  const base = title ? `Agent has finished: ${title}` : "Agent has finished.";
1605
1664
  return agent ? `${base} (${agent})` : base;
1606
1665
  }
1666
+ function selectPlanSessionAgent(candidates) {
1667
+ return candidates.find(isPlanSessionAgent) ?? candidates.find((agent) => agent !== void 0);
1668
+ }
1669
+ async function resolveSessionAgent(sessionId, ctx) {
1670
+ const candidates = [
1671
+ ctx.sessionTitleService.getSessionAgent(sessionId)
1672
+ ];
1673
+ try {
1674
+ const registryEntry = (await ctx.sessionRegistry.listSessions()).find(
1675
+ (entry) => entry.sessionId === sessionId
1676
+ );
1677
+ candidates.push(registryEntry?.agent);
1678
+ } catch (err) {
1679
+ ctx.logger.warn("session registry agent lookup failed", { sessionId, error: String(err) });
1680
+ }
1681
+ try {
1682
+ const result = await ctx.client.session.get({ path: { id: sessionId } });
1683
+ if (result.data) {
1684
+ ctx.sessionTitleService.setSessionInfo(result.data);
1685
+ candidates.push(ctx.sessionTitleService.getSessionAgent(sessionId));
1686
+ }
1687
+ } catch (err) {
1688
+ ctx.logger.warn("session agent live lookup failed", { sessionId, error: String(err) });
1689
+ }
1690
+ const agent = selectPlanSessionAgent(candidates);
1691
+ if (agent !== void 0) ctx.sessionTitleService.setSessionAgent(sessionId, agent);
1692
+ return agent;
1693
+ }
1607
1694
  function cancelDeferredParentConfirm(sessionId) {
1608
1695
  const timer = deferredConfirmTimers.get(sessionId);
1609
1696
  if (timer === void 0) return;
@@ -1641,7 +1728,11 @@ async function hydrateDescendants(sessionId, ctx, seen = /* @__PURE__ */ new Set
1641
1728
  for (const child of result.data ?? []) {
1642
1729
  ctx.sessionTitleService.setSessionInfo(child);
1643
1730
  await ctx.sessionRegistry.upsertSession(
1644
- registryEntryFromSession(child, ctx.serverUrl.href, ctx.sessionTitleService.getSessionStatus(child.id))
1731
+ registryEntryFromSession(
1732
+ child,
1733
+ ctx.serverUrl.href,
1734
+ ctx.sessionTitleService.getSessionStatus(child.id)
1735
+ )
1645
1736
  );
1646
1737
  await hydrateDescendants(child.id, ctx, seen);
1647
1738
  }
@@ -1661,24 +1752,30 @@ async function sendIdleNotification(sessionId, ctx) {
1661
1752
  });
1662
1753
  if (!claimed) return;
1663
1754
  const title = ctx.sessionTitleService.getSessionTitle(sessionId);
1664
- const agent = ctx.sessionTitleService.getSessionAgent(sessionId);
1755
+ const agent = await resolveSessionAgent(sessionId, ctx);
1665
1756
  const isPlanSession = isPlanSessionAgent(agent);
1666
1757
  const text = isPlanSession ? planCompleteMessage(title) : agentFinishedMessage(title, agent);
1667
1758
  try {
1668
1759
  if (isPlanSession) {
1669
1760
  const shortHash = startWorkShortHash(sessionId);
1670
- const message = await ctx.bot.sendMessage(text, {
1671
- reply_markup: { inline_keyboard: startWorkKeyboard(shortHash) }
1761
+ const pending = await ctx.pendingStartWorks.loadPending(shortHash);
1762
+ if (pending && pending.expiresAt >= Date.now()) {
1763
+ ctx.logger.info("plan completion notice already sent - skipping duplicate", { sessionId });
1764
+ return;
1765
+ }
1766
+ if (pending) await ctx.pendingStartWorks.deletePending(shortHash);
1767
+ const message = await ctx.bot.sendMessage(text);
1768
+ const sentAt = Date.now();
1769
+ await ctx.pendingStartWorks.savePending(shortHash, {
1770
+ ...createPendingStartWork(sessionId, title, ctx.serverUrl.href, message.message_id),
1771
+ status: "consumed",
1772
+ handledAt: sentAt
1672
1773
  });
1673
- await ctx.pendingStartWorks.savePending(
1674
- shortHash,
1675
- createPendingStartWork(sessionId, title, ctx.serverUrl.href, message.message_id)
1676
- );
1677
1774
  } else {
1678
1775
  await ctx.bot.sendMessage(text);
1679
1776
  }
1680
1777
  ctx.sessionTitleService.clearDeferredIdleNotification(sessionId);
1681
- ctx.logger.info("idle notification sent", { sessionId, title });
1778
+ ctx.logger.info("idle notification sent", { sessionId, title, agent, isPlanSession });
1682
1779
  } catch (err) {
1683
1780
  ctx.logger.error("failed to send idle notification", { error: String(err) });
1684
1781
  }
@@ -1778,7 +1875,10 @@ async function handleSessionStatus(event, ctx) {
1778
1875
  return;
1779
1876
  }
1780
1877
  if (previousStatus !== statusType) {
1781
- await ctx.sessionRegistry.updateSession(sessionId, { status: statusType, updatedAt: Date.now() });
1878
+ await ctx.sessionRegistry.updateSession(sessionId, {
1879
+ status: statusType,
1880
+ updatedAt: Date.now()
1881
+ });
1782
1882
  }
1783
1883
  }
1784
1884
 
@@ -2451,6 +2551,9 @@ function agentFromSession3(session) {
2451
2551
  function resolveProjectRoot2(session) {
2452
2552
  return session.directory;
2453
2553
  }
2554
+ function selectPlanSessionAgent2(candidates) {
2555
+ return candidates.find(isPlanSessionAgent) ?? candidates.find((agent) => agent !== void 0);
2556
+ }
2454
2557
  function readinessMessage(reason) {
2455
2558
  switch (reason) {
2456
2559
  case "no-omo-dir":
@@ -2477,6 +2580,38 @@ async function sendHtml(bot, text) {
2477
2580
  async function sendPlain(bot, text) {
2478
2581
  await bot.sendMessage(text);
2479
2582
  }
2583
+ function pendingMessageIds(pending) {
2584
+ return [.../* @__PURE__ */ new Set([...pending.telegramMessageIds ?? [pending.telegramMessageId]])];
2585
+ }
2586
+ async function consumeInlineStartWorkButtons(bot, pendingStartWorks, sessionId, logger) {
2587
+ if (!pendingStartWorks) return;
2588
+ const shortHash = createStartWorkShortHash(sessionId);
2589
+ const pending = await pendingStartWorks.loadPending(shortHash);
2590
+ if (!pending) return;
2591
+ if (pending.expiresAt < Date.now()) {
2592
+ await pendingStartWorks.deletePending(shortHash);
2593
+ return;
2594
+ }
2595
+ await pendingStartWorks.savePending(shortHash, {
2596
+ ...pending,
2597
+ status: "consumed",
2598
+ handledAt: Date.now()
2599
+ });
2600
+ for (const messageId of pendingMessageIds(pending)) {
2601
+ try {
2602
+ await bot.editMessageRemoveKeyboard(
2603
+ messageId,
2604
+ "This /start-work request was already handled. Use /start_work <number> from /sessions."
2605
+ );
2606
+ } catch (err) {
2607
+ logger.error("failed to clear start-work keyboard after command dispatch", {
2608
+ sessionId,
2609
+ messageId,
2610
+ error: String(err)
2611
+ });
2612
+ }
2613
+ }
2614
+ }
2480
2615
  function createStartWorkCommandDispatcher(deps) {
2481
2616
  return async ({ chatId, bot, args }) => {
2482
2617
  const rawIndex = args[0]?.trim();
@@ -2534,7 +2669,11 @@ function createStartWorkCommandDispatcher(deps) {
2534
2669
  deps.logger.error("start-work session lookup failed", { sessionId, error: String(err) });
2535
2670
  return;
2536
2671
  }
2537
- const agent = deps.sessionTitleService.getSessionAgent(sessionId) ?? agentFromSession3(session) ?? entry.agent;
2672
+ const agent = selectPlanSessionAgent2([
2673
+ deps.sessionTitleService.getSessionAgent(sessionId),
2674
+ entry.agent,
2675
+ agentFromSession3(session)
2676
+ ]);
2538
2677
  if (!isPlanSessionAgent(agent)) {
2539
2678
  await sendPlain(
2540
2679
  bot,
@@ -2561,6 +2700,7 @@ function createStartWorkCommandDispatcher(deps) {
2561
2700
  }
2562
2701
  try {
2563
2702
  await deps.runSessionCommand(sessionId, "start-work", sourceServerUrl);
2703
+ await consumeInlineStartWorkButtons(bot, deps.pendingStartWorks, sessionId, deps.logger);
2564
2704
  await sendHtml(
2565
2705
  bot,
2566
2706
  `${index}\uBC88 \uC138\uC158\uC5D0 opencode /start-work \uC2AC\uB798\uC2DC \uCEE4\uB9E8\uB4DC \uC804\uC1A1 \uC644\uB8CC. (${escapeHtml(entry.title)})`
@@ -3374,6 +3514,7 @@ var TelegramRemote = async (input) => {
3374
3514
  sessionTitleService,
3375
3515
  client: input.client,
3376
3516
  serverUrl: input.serverUrl.href,
3517
+ pendingStartWorks,
3377
3518
  runSessionCommand,
3378
3519
  logger
3379
3520
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coinseeker/opencode-telegram-plugin",
3
- "version": "1.1.4",
3
+ "version": "1.1.6",
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",