charness 3.3.2 → 3.4.0

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.
Files changed (183) hide show
  1. package/CHANGELOG.md +612 -0
  2. package/DEVELOPMENT.md +76 -0
  3. package/README.md +5 -2
  4. package/SETUP.md +65 -19
  5. package/dist/adapters/openCodeAdapter.d.ts +11 -0
  6. package/dist/adapters/openCodeAdapter.d.ts.map +1 -1
  7. package/dist/adapters/openCodeAdapter.js +24 -9
  8. package/dist/adapters/openCodeAdapter.js.map +1 -1
  9. package/dist/agentLogin/agentLogin.d.ts +1 -0
  10. package/dist/agentLogin/agentLogin.d.ts.map +1 -1
  11. package/dist/agentLogin/agentLogin.js +5 -0
  12. package/dist/agentLogin/agentLogin.js.map +1 -1
  13. package/dist/bot.d.ts +0 -3
  14. package/dist/bot.d.ts.map +1 -1
  15. package/dist/bot.js +311 -159
  16. package/dist/bot.js.map +1 -1
  17. package/dist/changelogEntries.d.ts +25 -0
  18. package/dist/changelogEntries.d.ts.map +1 -0
  19. package/dist/changelogEntries.js +66 -0
  20. package/dist/changelogEntries.js.map +1 -0
  21. package/dist/cli/bot.d.ts +6 -2
  22. package/dist/cli/bot.d.ts.map +1 -1
  23. package/dist/cli/bot.js +34 -2
  24. package/dist/cli/bot.js.map +1 -1
  25. package/dist/cli/hot.d.ts +7 -0
  26. package/dist/cli/hot.d.ts.map +1 -1
  27. package/dist/cli/hot.js +53 -3
  28. package/dist/cli/hot.js.map +1 -1
  29. package/dist/cli.d.ts +1 -1
  30. package/dist/cli.d.ts.map +1 -1
  31. package/dist/cli.js +8 -0
  32. package/dist/cli.js.map +1 -1
  33. package/dist/connectors/telegram/bindPicker.d.ts +64 -0
  34. package/dist/connectors/telegram/bindPicker.d.ts.map +1 -0
  35. package/dist/connectors/telegram/bindPicker.js +97 -0
  36. package/dist/connectors/telegram/bindPicker.js.map +1 -0
  37. package/dist/connectors/telegram/commands/selfUpdate.d.ts +16 -0
  38. package/dist/connectors/telegram/commands/selfUpdate.d.ts.map +1 -0
  39. package/dist/connectors/telegram/commands/selfUpdate.js +210 -0
  40. package/dist/connectors/telegram/commands/selfUpdate.js.map +1 -0
  41. package/dist/connectors/telegram/commands/stt.d.ts +1 -0
  42. package/dist/connectors/telegram/commands/stt.d.ts.map +1 -1
  43. package/dist/connectors/telegram/commands/stt.js +5 -0
  44. package/dist/connectors/telegram/commands/stt.js.map +1 -1
  45. package/dist/connectors/telegram/commands/topicCommand.d.ts +75 -0
  46. package/dist/connectors/telegram/commands/topicCommand.d.ts.map +1 -0
  47. package/dist/connectors/telegram/commands/topicCommand.js +391 -0
  48. package/dist/connectors/telegram/commands/topicCommand.js.map +1 -0
  49. package/dist/i18n/de.d.ts.map +1 -1
  50. package/dist/i18n/de.js +108 -2
  51. package/dist/i18n/de.js.map +1 -1
  52. package/dist/i18n/en.d.ts.map +1 -1
  53. package/dist/i18n/en.js +108 -2
  54. package/dist/i18n/en.js.map +1 -1
  55. package/dist/i18n/es.d.ts.map +1 -1
  56. package/dist/i18n/es.js +108 -2
  57. package/dist/i18n/es.js.map +1 -1
  58. package/dist/i18n/fr.d.ts.map +1 -1
  59. package/dist/i18n/fr.js +108 -2
  60. package/dist/i18n/fr.js.map +1 -1
  61. package/dist/i18n/hi.d.ts.map +1 -1
  62. package/dist/i18n/hi.js +108 -2
  63. package/dist/i18n/hi.js.map +1 -1
  64. package/dist/i18n/ja.d.ts.map +1 -1
  65. package/dist/i18n/ja.js +108 -2
  66. package/dist/i18n/ja.js.map +1 -1
  67. package/dist/i18n/ka.d.ts.map +1 -1
  68. package/dist/i18n/ka.js +108 -2
  69. package/dist/i18n/ka.js.map +1 -1
  70. package/dist/i18n/pt.d.ts.map +1 -1
  71. package/dist/i18n/pt.js +108 -2
  72. package/dist/i18n/pt.js.map +1 -1
  73. package/dist/i18n/ru.d.ts.map +1 -1
  74. package/dist/i18n/ru.js +108 -2
  75. package/dist/i18n/ru.js.map +1 -1
  76. package/dist/i18n/uk.d.ts.map +1 -1
  77. package/dist/i18n/uk.js +108 -2
  78. package/dist/i18n/uk.js.map +1 -1
  79. package/dist/i18n/uz.d.ts.map +1 -1
  80. package/dist/i18n/uz.js +108 -2
  81. package/dist/i18n/uz.js.map +1 -1
  82. package/dist/i18n/zh.d.ts.map +1 -1
  83. package/dist/i18n/zh.js +108 -2
  84. package/dist/i18n/zh.js.map +1 -1
  85. package/dist/instanceSettings.d.ts +12 -1
  86. package/dist/instanceSettings.d.ts.map +1 -1
  87. package/dist/instanceSettings.js +40 -4
  88. package/dist/instanceSettings.js.map +1 -1
  89. package/dist/selfUpdate/bootRollback.d.ts +22 -0
  90. package/dist/selfUpdate/bootRollback.d.ts.map +1 -0
  91. package/dist/selfUpdate/bootRollback.js +79 -0
  92. package/dist/selfUpdate/bootRollback.js.map +1 -0
  93. package/dist/selfUpdate/commandRunner.d.ts +21 -0
  94. package/dist/selfUpdate/commandRunner.d.ts.map +1 -0
  95. package/dist/selfUpdate/commandRunner.js +32 -0
  96. package/dist/selfUpdate/commandRunner.js.map +1 -0
  97. package/dist/selfUpdate/constants.d.ts +26 -0
  98. package/dist/selfUpdate/constants.d.ts.map +1 -0
  99. package/dist/selfUpdate/constants.js +31 -0
  100. package/dist/selfUpdate/constants.js.map +1 -0
  101. package/dist/selfUpdate/gitSource.d.ts +58 -0
  102. package/dist/selfUpdate/gitSource.d.ts.map +1 -0
  103. package/dist/selfUpdate/gitSource.js +183 -0
  104. package/dist/selfUpdate/gitSource.js.map +1 -0
  105. package/dist/selfUpdate/hostLock.d.ts +16 -0
  106. package/dist/selfUpdate/hostLock.d.ts.map +1 -0
  107. package/dist/selfUpdate/hostLock.js +101 -0
  108. package/dist/selfUpdate/hostLock.js.map +1 -0
  109. package/dist/selfUpdate/hotSupervisor.d.ts +47 -0
  110. package/dist/selfUpdate/hotSupervisor.d.ts.map +1 -0
  111. package/dist/selfUpdate/hotSupervisor.js +125 -0
  112. package/dist/selfUpdate/hotSupervisor.js.map +1 -0
  113. package/dist/selfUpdate/installKind.d.ts +45 -0
  114. package/dist/selfUpdate/installKind.d.ts.map +1 -0
  115. package/dist/selfUpdate/installKind.js +99 -0
  116. package/dist/selfUpdate/installKind.js.map +1 -0
  117. package/dist/selfUpdate/launchEnvironment.d.ts +3 -0
  118. package/dist/selfUpdate/launchEnvironment.d.ts.map +1 -0
  119. package/dist/selfUpdate/launchEnvironment.js +16 -0
  120. package/dist/selfUpdate/launchEnvironment.js.map +1 -0
  121. package/dist/selfUpdate/npmSource.d.ts +34 -0
  122. package/dist/selfUpdate/npmSource.d.ts.map +1 -0
  123. package/dist/selfUpdate/npmSource.js +107 -0
  124. package/dist/selfUpdate/npmSource.js.map +1 -0
  125. package/dist/selfUpdate/releaseNotes.d.ts +33 -0
  126. package/dist/selfUpdate/releaseNotes.d.ts.map +1 -0
  127. package/dist/selfUpdate/releaseNotes.js +115 -0
  128. package/dist/selfUpdate/releaseNotes.js.map +1 -0
  129. package/dist/selfUpdate/restart.d.ts +33 -0
  130. package/dist/selfUpdate/restart.d.ts.map +1 -0
  131. package/dist/selfUpdate/restart.js +94 -0
  132. package/dist/selfUpdate/restart.js.map +1 -0
  133. package/dist/selfUpdate/selfCheck.d.ts +20 -0
  134. package/dist/selfUpdate/selfCheck.d.ts.map +1 -0
  135. package/dist/selfUpdate/selfCheck.js +126 -0
  136. package/dist/selfUpdate/selfCheck.js.map +1 -0
  137. package/dist/selfUpdate/semver.d.ts +24 -0
  138. package/dist/selfUpdate/semver.d.ts.map +1 -0
  139. package/dist/selfUpdate/semver.js +165 -0
  140. package/dist/selfUpdate/semver.js.map +1 -0
  141. package/dist/selfUpdate/types.d.ts +51 -0
  142. package/dist/selfUpdate/types.d.ts.map +1 -0
  143. package/dist/selfUpdate/types.js +3 -0
  144. package/dist/selfUpdate/types.js.map +1 -0
  145. package/dist/selfUpdate/updateEngine.d.ts +110 -0
  146. package/dist/selfUpdate/updateEngine.d.ts.map +1 -0
  147. package/dist/selfUpdate/updateEngine.js +727 -0
  148. package/dist/selfUpdate/updateEngine.js.map +1 -0
  149. package/dist/selfUpdate/updateMessages.d.ts +84 -0
  150. package/dist/selfUpdate/updateMessages.d.ts.map +1 -0
  151. package/dist/selfUpdate/updateMessages.js +115 -0
  152. package/dist/selfUpdate/updateMessages.js.map +1 -0
  153. package/dist/selfUpdate/updateModes.d.ts +17 -0
  154. package/dist/selfUpdate/updateModes.d.ts.map +1 -0
  155. package/dist/selfUpdate/updateModes.js +14 -0
  156. package/dist/selfUpdate/updateModes.js.map +1 -0
  157. package/dist/selfUpdate/updatePending.d.ts +76 -0
  158. package/dist/selfUpdate/updatePending.d.ts.map +1 -0
  159. package/dist/selfUpdate/updatePending.js +131 -0
  160. package/dist/selfUpdate/updatePending.js.map +1 -0
  161. package/dist/selfUpdate/updatePolicy.d.ts +115 -0
  162. package/dist/selfUpdate/updatePolicy.d.ts.map +1 -0
  163. package/dist/selfUpdate/updatePolicy.js +83 -0
  164. package/dist/selfUpdate/updatePolicy.js.map +1 -0
  165. package/dist/selfUpdate/updateRuntime.d.ts +22 -0
  166. package/dist/selfUpdate/updateRuntime.d.ts.map +1 -0
  167. package/dist/selfUpdate/updateRuntime.js +148 -0
  168. package/dist/selfUpdate/updateRuntime.js.map +1 -0
  169. package/dist/startupPromptBuffer.d.ts +2 -0
  170. package/dist/startupPromptBuffer.d.ts.map +1 -1
  171. package/dist/startupPromptBuffer.js +4 -0
  172. package/dist/startupPromptBuffer.js.map +1 -1
  173. package/dist/state.d.ts +12 -0
  174. package/dist/state.d.ts.map +1 -1
  175. package/dist/state.js +18 -0
  176. package/dist/state.js.map +1 -1
  177. package/dist/types.d.ts +4 -0
  178. package/dist/types.d.ts.map +1 -1
  179. package/dist/utils/updateScreenPlan.d.ts +38 -0
  180. package/dist/utils/updateScreenPlan.d.ts.map +1 -0
  181. package/dist/utils/updateScreenPlan.js +143 -0
  182. package/dist/utils/updateScreenPlan.js.map +1 -0
  183. package/package.json +3 -1
package/dist/bot.js CHANGED
@@ -34,7 +34,6 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.COMMANDS_MENU = exports.telegramInbound = exports.routedCommandTriggerDeps = void 0;
37
- exports.buildBindKeyboard = buildBindKeyboard;
38
37
  exports.getStartReadyMessage = getStartReadyMessage;
39
38
  exports.checkIsRoutedCommand = checkIsRoutedCommand;
40
39
  exports.handleRoutedCommandMessage = handleRoutedCommandMessage;
@@ -84,6 +83,8 @@ const timezone_1 = require("./utils/timezone");
84
83
  const timezonePicker_1 = require("./connectors/telegram/timezonePicker");
85
84
  const timezoneRecompute_1 = require("./scheduler/timezoneRecompute");
86
85
  const validation_1 = require("./validation");
86
+ const bindPicker_1 = require("./connectors/telegram/bindPicker");
87
+ const topicCommand_1 = require("./connectors/telegram/commands/topicCommand");
87
88
  const providerDisconnectPlan_1 = require("./utils/providerDisconnectPlan");
88
89
  const folderName_1 = require("./folderName");
89
90
  const threadRouting_1 = require("./threadRouting");
@@ -157,6 +158,13 @@ const claudeLivenessAction_1 = require("./utils/claudeLivenessAction");
157
158
  const isoTimestamp_1 = require("./utils/isoTimestamp");
158
159
  const effortLevels_1 = require("./effortLevels");
159
160
  const instanceSettings_1 = require("./instanceSettings");
161
+ const constants_1 = require("./selfUpdate/constants");
162
+ const launchEnvironment_1 = require("./selfUpdate/launchEnvironment");
163
+ const restart_1 = require("./selfUpdate/restart");
164
+ const updateEngine_1 = require("./selfUpdate/updateEngine");
165
+ const updateMessages_1 = require("./selfUpdate/updateMessages");
166
+ const updatePolicy_1 = require("./selfUpdate/updatePolicy");
167
+ const updateRuntime_1 = require("./selfUpdate/updateRuntime");
160
168
  const threadContextPreamble_1 = require("./threadContextPreamble");
161
169
  const pinnedBannerSkipDecision_1 = require("./utils/pinnedBannerSkipDecision");
162
170
  const replyQuote_1 = require("./utils/replyQuote");
@@ -216,6 +224,7 @@ const limitsBoard_1 = require("./connectors/telegram/commands/limitsBoard");
216
224
  const backendSwitch_1 = require("./connectors/telegram/commands/backendSwitch");
217
225
  const operatorSwitchPlan_1 = require("./utils/operatorSwitchPlan");
218
226
  const backendSwitchPlan_1 = require("./utils/backendSwitchPlan");
227
+ const selfUpdate_1 = require("./connectors/telegram/commands/selfUpdate");
219
228
  const stt_1 = require("./connectors/telegram/commands/stt");
220
229
  const resolveBinary_1 = require("./utils/resolveBinary");
221
230
  // ═══════════════════════════════════════════════════════════════════════════════
@@ -595,6 +604,18 @@ bot.use(async (ctx, next) => {
595
604
  });
596
605
  return next();
597
606
  });
607
+ /**
608
+ * Epoch ms of the last update a human sent (a message, a button tap), `null` since the start. The updater restarts the bot only
609
+ * after a quiet period (`quietPeriodMs`) — somebody typing right now is the one case it must not interrupt.
610
+ */
611
+ let lastHumanInboundAt = null;
612
+ bot.use(async (ctx, next) => {
613
+ if (ctx.message || ctx.callbackQuery)
614
+ lastHumanInboundAt = Date.now();
615
+ return next();
616
+ });
617
+ /** The update engine of this process; built at boot (`startBot`), read by the `/update` command. */
618
+ let updateEngine = null;
598
619
  // Locale is a Telegram-chat concern, not a process env knob. Every update runs
599
620
  // inside an async i18n context: explicit `/language` override for the DM/group
600
621
  // wins, else this sender's Telegram `language_code`, else the last seen Telegram
@@ -1721,7 +1742,7 @@ function clearLimitResumeNotice(key) {
1721
1742
  */
1722
1743
  let claudeProfileAuthStatusSink = null;
1723
1744
  // Out-of-band sign-in drivers (`/login`, `/connect` OAuth): `agentLogin/README.md`.
1724
- const { cancelPendingLogin, checkIsAuthLoginAwaitingCode, cancelClaudeAuthLogin, startClaudeAuthLogin, submitClaudeAuthLoginCode, checkIsOpenCodeOAuthAwaitingReply, cancelOpenCodeOAuthLogin, startOpenCodeOAuthLogin, submitOpenCodeOAuthReply, } = (0, agentLogin_1.createAgentLogin)({
1745
+ const { cancelPendingLogin, checkIsAuthLoginAwaitingCode, cancelClaudeAuthLogin, startClaudeAuthLogin, submitClaudeAuthLoginCode, checkIsOpenCodeOAuthAwaitingReply, cancelOpenCodeOAuthLogin, startOpenCodeOAuthLogin, submitOpenCodeOAuthReply, checkIsAnyLoginInProgress, } = (0, agentLogin_1.createAgentLogin)({
1725
1746
  replyToThread,
1726
1747
  deleteThreadMessage,
1727
1748
  clearAuthNotice,
@@ -3466,8 +3487,6 @@ function getWorkDirStartDecision(key) {
3466
3487
  // `validateSubdir` + `BindError` live in `src/validation.ts` so the
3467
3488
  // security-critical bits can be unit-tested without booting Telegraf
3468
3489
  // (plan §11 Этап 7, R3). The import at the top of this file pulls them in.
3469
- /** Inline-keyboard page size for `/bind`. 20 fits one phone screen comfortably. */
3470
- const BIND_PAGE_SIZE = 20;
3471
3490
  /**
3472
3491
  * @description List immediate subdirectories of `WORK_ROOT` for the `/bind`
3473
3492
  * inline-button picker and the `forum_topic_created` welcome message.
@@ -3519,71 +3538,6 @@ function listAvailableSubdirs(workRoot, limit = 200) {
3519
3538
  subdirCache.set(cacheKey, { value: result, ts: Date.now() });
3520
3539
  return result;
3521
3540
  }
3522
- /**
3523
- * @description Build a `subdir`-suggestion keyboard for `/bind` and friends.
3524
- *
3525
- * Lists are paginated at {@link BIND_PAGE_SIZE} entries: a `WORK_ROOT` with
3526
- * many subfolders (mono-repo workspace, packages dir, etc.) would otherwise
3527
- * push the inline keyboard past Telegram's per-message height limit and
3528
- * lose entries below the fold.
3529
- *
3530
- * The nav row appears only when there's more than one page. The middle
3531
- * `pageLabel` button is a no-op (`bind_page_noop` callback) — it exists
3532
- * solely so the user sees "2/5" without us having to fake a divider with
3533
- * a separate message.
3534
- *
3535
- * Subdirs whose callback_data exceeds 64 bytes are filtered out upstream
3536
- * in `listAvailableSubdirs`. `page` is clamped to a valid index so a stale
3537
- * callback (folders disappeared since the keyboard was sent) just lands
3538
- * on the last available page.
3539
- *
3540
- * When `isBound` the FIRST row is a full-width «leave current dir» button
3541
- * (`bindLeaveCurrentCallback`, the folded-in `/unbind`); the «create new folder»
3542
- * button (`bindCreateFolderCallback`) follows. Both callbacks deliberately do
3543
- * NOT start with `bind_`, so they can't be mistaken for a folder pick by the
3544
- * `bind_<subdir>` action regex. They ride on every page so they're reachable
3545
- * regardless of pagination.
3546
- */
3547
- const bindCreateFolderCallback = 'bindCreateFolder';
3548
- /**
3549
- * «Leave current dir» button — the folded-in `/unbind`. Like
3550
- * `bindCreateFolderCallback`, the callback id deliberately does NOT start with
3551
- * `bind_`, so the `bind_<subdir>` action regex can't mistake it for a folder
3552
- * pick. Only rendered in a bound topic.
3553
- */
3554
- const bindLeaveCurrentCallback = 'bindLeaveCurrent';
3555
- function buildBindKeyboard(subdirs, page = 0, pageSize = BIND_PAGE_SIZE, isBound = false) {
3556
- const { slice, currentPage, totalPages } = (0, validation_1.paginateBindList)(subdirs, page, pageSize);
3557
- const rows = [
3558
- [telegraf_1.Markup.button.callback((0, i18n_1.t)('bind.create_button'), bindCreateFolderCallback)],
3559
- ];
3560
- // A bound topic gets a full-width «leave current dir» row PREPENDED above the
3561
- // «create new folder» row — this is the folded-in `/unbind`. Its callback
3562
- // deliberately avoids the `bind_` prefix so the `bind_<subdir>` action regex
3563
- // can't catch it. The row is omitted when unbound (nothing to leave).
3564
- if (isBound) {
3565
- rows.unshift([telegraf_1.Markup.button.callback((0, i18n_1.t)('bind.leave_button'), bindLeaveCurrentCallback)]);
3566
- }
3567
- for (let i = 0; i < slice.length; i += 2) {
3568
- const row = [telegraf_1.Markup.button.callback(`📁 ${slice[i]}`, `bind_${slice[i]}`)];
3569
- if (slice[i + 1]) {
3570
- row.push(telegraf_1.Markup.button.callback(`📁 ${slice[i + 1]}`, `bind_${slice[i + 1]}`));
3571
- }
3572
- rows.push(row);
3573
- }
3574
- if (totalPages > 1) {
3575
- const nav = [];
3576
- if (currentPage > 0) {
3577
- nav.push(telegraf_1.Markup.button.callback('⬅️ Prev', `bind_page_${currentPage - 1}`));
3578
- }
3579
- nav.push(telegraf_1.Markup.button.callback(`${currentPage + 1}/${totalPages}`, 'bind_page_noop'));
3580
- if (currentPage < totalPages - 1) {
3581
- nav.push(telegraf_1.Markup.button.callback('Next ➡️', `bind_page_${currentPage + 1}`));
3582
- }
3583
- rows.push(nav);
3584
- }
3585
- return telegraf_1.Markup.inlineKeyboard(rows);
3586
- }
3587
3541
  /**
3588
3542
  * @description Bot-level wrapper around the pure sweep helper in
3589
3543
  * `createAdapter.ts`. Binds the production adapter resolver so callers
@@ -4495,6 +4449,13 @@ const { checkIsThreadCompacting, noteThreadActivity, noteThreadUserActivity, mar
4495
4449
  function checkIsRoutedCommand(name) {
4496
4450
  return commandRouter.checkIsRegistered(name);
4497
4451
  }
4452
+ /** `/esc` and its other names: the interrupt, which also cancels a pending sign-in or `/topic` prompt first. */
4453
+ const escapeCommandNames = ['esc', 'escape', 'interrupt'];
4454
+ /** @description Whether `messageText` is one of the {@link escapeCommandNames} (`@botmention` tolerated). */
4455
+ function checkIsEscapeCommandText(messageText) {
4456
+ const [commandToken = ''] = (0, utils_1.stripCommandBotMention)(messageText.trim()).split(/\s+/, 1);
4457
+ return escapeCommandNames.includes(commandToken.replace(/^\//, '').toLowerCase());
4458
+ }
4498
4459
  /**
4499
4460
  * @description Running ANY command exits the single-purpose input modes an
4500
4461
  * earlier command armed:
@@ -4503,6 +4464,7 @@ function checkIsRoutedCommand(name) {
4503
4464
  * it afterwards via its own callback);
4504
4465
  * - `/connect`'s pending provider connect — cancelled out loud, unless the
4505
4466
  * command IS another `/connect` (which continues the flow);
4467
+ * - `/topic`'s pending name prompts (the topic's, the new folder's) — but `/esc`, which cancels them with a notice of its own;
4506
4468
  * - an in-flight `/reminders` wizard, at whatever step. Unconditional rather
4507
4469
  * than only during the step-4 text wait: that is what makes a repeat
4508
4470
  * `/reminders` retire the previous wizard (relabelling its message) instead
@@ -4512,6 +4474,9 @@ function checkIsRoutedCommand(name) {
4512
4474
  async function exitPendingInputModes(key, messageText) {
4513
4475
  const keyString = (0, sessionKey_1.keyToString)(key);
4514
4476
  awaitingFolderName.delete(keyString);
4477
+ // `/topic`'s name prompts end silently, except for `/esc`, which cancels them out loud in its own handler.
4478
+ if (!checkIsEscapeCommandText(messageText))
4479
+ cancelTopicInput(key);
4515
4480
  const hadPendingProviderConnect = pendingProviderConnects.delete(keyString);
4516
4481
  if (hadPendingProviderConnect && !(0, recvPreviewRedaction_1.checkIsConnectCommandText)(messageText)) {
4517
4482
  await replyToThread(key, (0, i18n_1.t)('connect.cancelled'));
@@ -4892,6 +4857,8 @@ function armFolderCreation(key) {
4892
4857
  const kStr = (0, sessionKey_1.keyToString)(key);
4893
4858
  awaitingModelSelection.delete(kStr);
4894
4859
  awaitingSessionSelection.delete(kStr);
4860
+ // The `/topic` prompts are other readers of the next plain message.
4861
+ cancelTopicInput(key);
4895
4862
  // A stale pending question would otherwise consume the message AFTER the
4896
4863
  // folder name as an answer to the old (pre-rebind) agent question.
4897
4864
  clearPendingQuestion(key);
@@ -4909,21 +4876,18 @@ function mapNewFolderError(reason) {
4909
4876
  }
4910
4877
  }
4911
4878
  /**
4912
- * @description Create a new folder under WORK_ROOT from a user-typed name and
4913
- * bind the thread to it. Validates the name first (no traversal/slashes/dots),
4914
- * then `mkdir` (recursive: an already-existing folder is fine — we just bind
4915
- * to it and tell the user), then routes through `applyBinding` for the same
4916
- * containment/symlink defence + welcome stack the picker uses.
4917
- *
4918
- * Returns `{ ok }` so the caller knows whether to disarm the await-name mode:
4919
- * an invalid name keeps the thread armed for a retry; success or a real
4920
- * filesystem failure disarms it.
4879
+ * @description Create a new folder under WORK_ROOT from a user-typed name:
4880
+ * validate the name first (no traversal/slashes/dots), then `mkdir` (recursive:
4881
+ * an already-existing folder is fine — the caller just uses it, and the topic is
4882
+ * told so). A refusal or a filesystem failure is reported to `key`'s topic here;
4883
+ * the outcome says which, so the caller knows whether a retry is still wanted
4884
+ * (`invalid`) or not (`failed`).
4921
4885
  */
4922
- async function createAndBindFolder(key, rawName) {
4886
+ async function createWorkFolder(key, rawName) {
4923
4887
  const validated = (0, folderName_1.validateNewFolderName)(rawName);
4924
4888
  if (!validated.ok) {
4925
4889
  await replyToThread(key, mapNewFolderError(validated.reason));
4926
- return { ok: false };
4890
+ return { outcome: 'invalid' };
4927
4891
  }
4928
4892
  const targetPath = path.join(ENV.workRoot, validated.name);
4929
4893
  const alreadyExisted = fs.existsSync(targetPath);
@@ -4934,7 +4898,7 @@ async function createAndBindFolder(key, rawName) {
4934
4898
  await replyToThread(key, (0, i18n_1.t)('bind.create_failed', {
4935
4899
  error: e instanceof Error ? e.message : String(e),
4936
4900
  }));
4937
- return { ok: true };
4901
+ return { outcome: 'failed' };
4938
4902
  }
4939
4903
  // Folder freshly on disk → the cached subdir list is stale; drop it so the
4940
4904
  // next picker render includes the new folder.
@@ -4942,12 +4906,43 @@ async function createAndBindFolder(key, rawName) {
4942
4906
  if (alreadyExisted) {
4943
4907
  await replyToThread(key, (0, i18n_1.t)('bind.create_exists', { subdir: validated.name }));
4944
4908
  }
4945
- const result = await applyBinding(key, validated.name);
4909
+ return { outcome: 'ready', subdir: validated.name };
4910
+ }
4911
+ /**
4912
+ * @description Create a new folder under WORK_ROOT from a user-typed name and
4913
+ * bind the thread to it ({@link createWorkFolder}), routing through
4914
+ * `applyBinding` for the same containment/symlink defence + welcome stack the
4915
+ * picker uses.
4916
+ *
4917
+ * Returns `{ ok }` so the caller knows whether to disarm the await-name mode:
4918
+ * an invalid name keeps the thread armed for a retry; success or a real
4919
+ * filesystem failure disarms it.
4920
+ */
4921
+ async function createAndBindFolder(key, rawName) {
4922
+ const folder = await createWorkFolder(key, rawName);
4923
+ if (folder.outcome === 'invalid')
4924
+ return { ok: false };
4925
+ if (folder.outcome === 'failed')
4926
+ return { ok: true };
4927
+ const result = await applyBinding(key, folder.subdir);
4946
4928
  await replyToThread(key, result.message);
4947
4929
  if (result.ok)
4948
4930
  await sendBindingWelcome(key, result.subdir);
4949
4931
  return { ok: true };
4950
4932
  }
4933
+ /**
4934
+ * @description Bind a topic the bot has just created (`/topic`) to `subdir` and greet it with the usual binding welcome:
4935
+ * the folder's stats and the start buttons. No agent starts by itself: it is started there as in any bound topic. The binding's own notice
4936
+ * is left out: its warning about other topics on the folder would warn of what the person just asked for. The topic's name
4937
+ * rides on the binding for the thread-context preamble. Resolves why it could not bind, `null` once it is bound.
4938
+ */
4939
+ async function bindNewTopic(key, subdir, topicName) {
4940
+ const result = await applyBinding(key, subdir, { topicName });
4941
+ if (!result.ok)
4942
+ return result.message;
4943
+ await sendBindingWelcome(key, result.subdir);
4944
+ return null;
4945
+ }
4951
4946
  command('bind', async (_ctx, key, parsed) => {
4952
4947
  if (checkIsGeneral(key)) {
4953
4948
  await replyToThread(key, (0, i18n_1.t)('bind.in_general'));
@@ -4971,7 +4966,7 @@ command('bind', async (_ctx, key, parsed) => {
4971
4966
  // «create new folder» button, which is the only way to bootstrap a folder
4972
4967
  // from an empty WORK_ROOT without the slash form. When already bound it
4973
4968
  // also carries the «leave current dir» button (the folded-in /unbind).
4974
- await replyToThread(key, usage, buildBindKeyboard(subdirs, 0, BIND_PAGE_SIZE, !!binding));
4969
+ await replyToThread(key, usage, (0, bindPicker_1.buildBindKeyboard)(subdirs, 0, bindPicker_1.BIND_PAGE_SIZE, !!binding));
4975
4970
  return;
4976
4971
  }
4977
4972
  const result = await applyBinding(key, arg);
@@ -5158,26 +5153,27 @@ async function getCliVersion(cmd, args = ['--version']) {
5158
5153
  }
5159
5154
  }
5160
5155
  command('version', async (_ctx, key) => {
5161
- // Read our own version from package.json so we don't drift if it ever bumps
5162
- // again without us updating a hardcoded string here.
5163
- let botVersion = (0, i18n_1.t)('version.unknown');
5164
- try {
5165
- const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
5166
- botVersion = pkg.version || botVersion;
5167
- }
5168
- catch { /* fall through to unknown */ }
5156
+ // The version this process STARTED as, read once at boot by the updater's runtime: an in-place install rewrites package.json
5157
+ // under a running bot, and the new number must not show before the restart that makes it true.
5158
+ const updateStatus = updateEngine?.getStatus();
5159
+ const botVersion = updateStatus?.running.version ?? (0, i18n_1.t)('version.unknown');
5160
+ const offer = updateStatus?.offer;
5161
+ const availableUpdate = offer && offer.action !== 'skipped' && offer.action !== 'needsNode' ? offer.target : null;
5169
5162
  const [tmuxV, claudeV, opencodeV] = await Promise.all([
5170
5163
  getCliVersion('tmux', ['-V']),
5171
5164
  getCliVersion('claude'),
5172
5165
  getCliVersion('opencode'),
5173
5166
  ]);
5174
- await replyToThread(key, (0, i18n_1.t)('version.report', {
5167
+ const report = (0, i18n_1.t)('version.report', {
5175
5168
  bot: botVersion,
5176
5169
  node: process.version,
5177
5170
  tmux: tmuxV,
5178
5171
  claude: claudeV,
5179
5172
  opencode: opencodeV,
5180
- }), { parse_mode: 'Markdown' });
5173
+ });
5174
+ await replyToThread(key, availableUpdate === null ? report : `${report}\n${(0, i18n_1.t)('version.updateAvailable', { version: availableUpdate })}`, {
5175
+ parse_mode: 'Markdown',
5176
+ });
5181
5177
  });
5182
5178
  command('whoami', async (ctx, key) => {
5183
5179
  const userId = ctx.from?.id ?? 0;
@@ -5246,7 +5242,7 @@ command('help', async (_ctx, key) => {
5246
5242
  const binding = state.getBinding(key);
5247
5243
  if (!binding) {
5248
5244
  const subdirs = listAvailableSubdirs(ENV.workRoot);
5249
- const extra = buildBindKeyboard(subdirs, 0, BIND_PAGE_SIZE, !!state.getBinding(key));
5245
+ const extra = (0, bindPicker_1.buildBindKeyboard)(subdirs, 0, bindPicker_1.BIND_PAGE_SIZE, !!state.getBinding(key));
5250
5246
  await replyToThread(key, (0, i18n_1.t)('help.thread_unbound'), {
5251
5247
  parse_mode: 'Markdown',
5252
5248
  ...(extra ?? {}),
@@ -5591,7 +5587,7 @@ async function handleAgentStart(key, adapterName, args) {
5591
5587
  // natural-language path in the text handler (plan §11 Этап 4).
5592
5588
  if (!state.getBinding(key)) {
5593
5589
  const subdirs = listAvailableSubdirs(ENV.workRoot);
5594
- const extra = buildBindKeyboard(subdirs, 0, BIND_PAGE_SIZE, !!state.getBinding(key));
5590
+ const extra = (0, bindPicker_1.buildBindKeyboard)(subdirs, 0, bindPicker_1.BIND_PAGE_SIZE, !!state.getBinding(key));
5595
5591
  await replyToThread(key, (0, i18n_1.t)('thread.no_binding'), extra);
5596
5592
  return;
5597
5593
  }
@@ -6440,7 +6436,7 @@ const { registerClaudeAccountCommands, registerClaudeAccountCallbacks, checkIsAw
6440
6436
  applyAutoSwitch,
6441
6437
  });
6442
6438
  claudeProfileAuthStatusSink = recordClaudeProfileAuthStatus;
6443
- const { registerSttCommands, registerSttCallbacks, startSttAutoInstall, resumeSttInstallAtBoot, getAwaitedSttStep, submitSttInput, cancelSttInput, } = (0, stt_1.createStt)({
6439
+ const { registerSttCommands, registerSttCallbacks, startSttAutoInstall, resumeSttInstallAtBoot, getAwaitedSttStep, checkIsAnySttInputAwaited, submitSttInput, cancelSttInput, } = (0, stt_1.createStt)({
6444
6440
  ...botCore,
6445
6441
  getConfigPath: () => (0, sttSettings_1.getSttConfigPath)(),
6446
6442
  getDataDir: state_1.resolveDataDir,
@@ -6453,10 +6449,30 @@ const { registerSttCommands, registerSttCallbacks, startSttAutoInstall, resumeSt
6453
6449
  return groupId !== null ? (0, sessionKeyCodec_1.makeTelegramKey)(groupId, threadRouting_1.GENERAL_THREAD_ID) : null;
6454
6450
  },
6455
6451
  });
6452
+ const { registerTopicCommand, registerTopicCallbacks, checkIsAwaitingTopicInput, submitTopicInput, cancelTopicInput, cancelTopicWizard, } = (0, topicCommand_1.createTopicCommand)({
6453
+ ...botCore,
6454
+ checkIsDmKey,
6455
+ disarmFolderCreation: (key) => {
6456
+ awaitingFolderName.delete((0, sessionKey_1.keyToString)(key));
6457
+ },
6458
+ listAvailableSubdirs: () => listAvailableSubdirs(ENV.workRoot),
6459
+ createWorkFolder,
6460
+ bindNewTopic,
6461
+ });
6456
6462
  registerClaudeAccountCommands();
6463
+ registerTopicCommand();
6457
6464
  registerFallbackChainCommands();
6458
6465
  registerLimitsBoardCommands();
6459
6466
  registerSttCommands();
6467
+ const { registerSelfUpdateCommands, registerSelfUpdateCallbacks } = (0, selfUpdate_1.createSelfUpdate)({
6468
+ ...botCore,
6469
+ getUpdateEngine: () => updateEngine,
6470
+ getSettingsPath: () => (0, instanceSettings_1.getInstanceSettingsPath)(),
6471
+ now: () => Date.now(),
6472
+ // The instance's time zone is `process.env.TZ` (`/timezone`), which `toLocaleTimeString` follows.
6473
+ formatClock: (epochMs) => new Date(epochMs).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }),
6474
+ });
6475
+ registerSelfUpdateCommands();
6460
6476
  registerCompactionCommands();
6461
6477
  /**
6462
6478
  * @description Build the agent-picker keyboard.
@@ -6490,7 +6506,7 @@ async function handleSessionsList(key) {
6490
6506
  }
6491
6507
  if (!state.getBinding(key)) {
6492
6508
  const subdirs = listAvailableSubdirs(ENV.workRoot);
6493
- const extra = buildBindKeyboard(subdirs, 0, BIND_PAGE_SIZE, !!state.getBinding(key));
6509
+ const extra = (0, bindPicker_1.buildBindKeyboard)(subdirs, 0, bindPicker_1.BIND_PAGE_SIZE, !!state.getBinding(key));
6494
6510
  await replyToThread(key, (0, i18n_1.t)('thread.no_binding'), extra);
6495
6511
  return;
6496
6512
  }
@@ -6750,8 +6766,8 @@ command('tab', async (_ctx, key) => {
6750
6766
  else
6751
6767
  await replyToThread(key, `Not supported for ${adapter.label}`);
6752
6768
  });
6753
- command(['esc', 'escape'], async (_ctx, key) => {
6754
- // A pending «Add account» name prompt, then a pending sign-in (`/login`, `/connect` OAuth) take
6769
+ command(escapeCommandNames, async (_ctx, key) => {
6770
+ // A pending «Add account» name prompt, a pending sign-in (`/login`, `/connect` OAuth) and a `/topic` wizard take
6755
6771
  // priority: the first /esc only cancels it, so the topic stops waiting for typed input.
6756
6772
  if (cancelProfileNameCapture(key)) {
6757
6773
  await replyToThread(key, (0, i18n_1.t)('account.askNameCancelled'));
@@ -6761,12 +6777,21 @@ command(['esc', 'escape'], async (_ctx, key) => {
6761
6777
  await replyToThread(key, (0, i18n_1.t)('login.cancelled'));
6762
6778
  return;
6763
6779
  }
6780
+ // A `/topic` wizard is ended at whatever step it waits (it says so itself).
6781
+ if (await cancelTopicWizard(key))
6782
+ return;
6764
6783
  const adapter = (0, createAdapter_1.getThreadAdapter)(key);
6765
6784
  if (adapter.sendEscape) {
6766
6785
  // The operator interrupted the turn: its open request closes silently.
6767
6786
  cancelConversationRequest(key);
6768
6787
  markNeedsNewMessage(key);
6788
+ // A turn blocked on a question ends with it: the question is closed on the agent first (an open OpenCode one is
6789
+ // re-found at every reattach; a Claude stream-json one is denied), then the turn is stopped — neither waits for Telegram. Only
6790
+ // then the question's buttons are retired, as when a prompt walks away from it (that edit goes through the send pacer).
6791
+ adapter.rejectQuestion?.(key);
6769
6792
  adapter.sendEscape(key);
6793
+ if (pendingQuestions.has((0, sessionKey_1.keyToString)(key)))
6794
+ await retirePendingQuestion(key);
6770
6795
  }
6771
6796
  else {
6772
6797
  await replyToThread(key, `Not supported for ${adapter.label}`);
@@ -7037,10 +7062,10 @@ command('clear_messages', async (ctx, key) => {
7037
7062
  // agent.
7038
7063
  const botCommands = new Set([
7039
7064
  'start', 'claude', 'opencode', 'oc', 'terminal', 'agent', 'sessions', 'resume', 'cancel', 'model', 'connect',
7040
- 'disconnect', 'stop', 'stopall', 'stop-all', 'status', 'c', 'y', 'n', 'enter', 'up', 'down', 'tab', 'esc', 'escape', 'output', 'clear_messages',
7041
- 'bind', 'unbind', 'where', 'ls', 'list', 'new', 'clear_session', 'whoami', 'version', 'help', 'language', 'lang',
7065
+ 'disconnect', 'stop', 'stopall', 'stop-all', 'status', 'c', 'y', 'n', 'enter', 'up', 'down', 'tab', 'esc', 'escape', 'interrupt', 'output', 'clear_messages',
7066
+ 'bind', 'topic', 'unbind', 'where', 'ls', 'list', 'new', 'clear_session', 'whoami', 'version', 'help', 'language', 'lang',
7042
7067
  'doctor', 'mcp', 'rename_session', 'compact', 'compact_on_idle', 'compact_summary', 'auto_continue_limits', 'limits', 'limits_autoswitch', 'trace', 'timestamps', 'timezone', 'schedule', 'reminders', 'thinking', 'tool_results',
7043
- 'subagent', 'claude_mode', 'effort', 'verbosity', 'quit', 'q', 'quit-all', 'quitall', 'pair', 'account', 'accounts', 'fallback', 'stt', 'voice', 'speech',
7068
+ 'subagent', 'claude_mode', 'effort', 'verbosity', 'quit', 'q', 'quit-all', 'quitall', 'pair', 'account', 'accounts', 'fallback', 'stt', 'voice', 'speech', 'update',
7044
7069
  ]);
7045
7070
  /**
7046
7071
  * @description The bare `/clear` is no longer bot-owned (it was renamed to
@@ -7073,6 +7098,7 @@ bot.on((0, filters_1.message)('text'), async (ctx) => {
7073
7098
  const cmd = text.slice(1).split(' ')[0].split('@')[0].toLowerCase();
7074
7099
  // A slash command ends a pending «Add account» name prompt (the command itself runs normally).
7075
7100
  cancelProfileNameCapture(key);
7101
+ cancelTopicInput(key);
7076
7102
  // ... and an armed `/stt` address or key step.
7077
7103
  await cancelSttInput(key);
7078
7104
  if (botCommands.has(cmd))
@@ -7124,6 +7150,12 @@ bot.on((0, filters_1.message)('text'), async (ctx) => {
7124
7150
  await submitProfileName(key, text);
7125
7151
  return;
7126
7152
  }
7153
+ // `/topic` asked for the new topic's name, or for the name of a folder to create → this message is the answer (a bad
7154
+ // name keeps the prompt armed and says so; a slash command above already ended it).
7155
+ if (checkIsAwaitingTopicInput(key) && !text.startsWith('/')) {
7156
+ await submitTopicInput(key, text);
7157
+ return;
7158
+ }
7127
7159
  // `/stt` asked for a server address or an API key → this message is the answer. A plausible key is deleted at
7128
7160
  // once and never echoed; anything else re-arms the step. A slash command above already disarmed it.
7129
7161
  if (getAwaitedSttStep(key) !== null && !text.startsWith('/')) {
@@ -7289,7 +7321,7 @@ bot.on((0, filters_1.message)('text'), async (ctx) => {
7289
7321
  });
7290
7322
  if (!result.ok && result.reason === 'unbound') {
7291
7323
  const subdirs = listAvailableSubdirs(ENV.workRoot);
7292
- const extra = buildBindKeyboard(subdirs, 0, BIND_PAGE_SIZE, !!state.getBinding(key));
7324
+ const extra = (0, bindPicker_1.buildBindKeyboard)(subdirs, 0, bindPicker_1.BIND_PAGE_SIZE, !!state.getBinding(key));
7293
7325
  await replyToThread(key, (0, i18n_1.t)('thread.no_binding'), extra);
7294
7326
  return;
7295
7327
  }
@@ -7370,7 +7402,7 @@ bot.on((0, filters_1.message)('text'), async (ctx) => {
7370
7402
  const binding = state.getBinding(key);
7371
7403
  if (!binding) {
7372
7404
  const subdirs = listAvailableSubdirs(ENV.workRoot);
7373
- const extra = buildBindKeyboard(subdirs, 0, BIND_PAGE_SIZE, !!state.getBinding(key));
7405
+ const extra = (0, bindPicker_1.buildBindKeyboard)(subdirs, 0, bindPicker_1.BIND_PAGE_SIZE, !!state.getBinding(key));
7374
7406
  await replyToThread(key, (0, i18n_1.t)('thread.no_binding'), extra);
7375
7407
  return;
7376
7408
  }
@@ -7501,7 +7533,7 @@ async function processVoiceJob(key, fileId, durationSeconds, requesterId, sentAt
7501
7533
  });
7502
7534
  if (!result.ok && result.reason === 'unbound') {
7503
7535
  const subdirs = listAvailableSubdirs(ENV.workRoot);
7504
- const extra = buildBindKeyboard(subdirs, 0, BIND_PAGE_SIZE, !!state.getBinding(key));
7536
+ const extra = (0, bindPicker_1.buildBindKeyboard)(subdirs, 0, bindPicker_1.BIND_PAGE_SIZE, !!state.getBinding(key));
7505
7537
  await replyToThread(key, (0, i18n_1.t)('thread.no_binding'), extra);
7506
7538
  return;
7507
7539
  }
@@ -7521,7 +7553,7 @@ async function processVoiceJob(key, fileId, durationSeconds, requesterId, sentAt
7521
7553
  const binding = state.getBinding(key);
7522
7554
  if (!binding) {
7523
7555
  const subdirs = listAvailableSubdirs(ENV.workRoot);
7524
- const extra = buildBindKeyboard(subdirs, 0, BIND_PAGE_SIZE, !!state.getBinding(key));
7556
+ const extra = (0, bindPicker_1.buildBindKeyboard)(subdirs, 0, bindPicker_1.BIND_PAGE_SIZE, !!state.getBinding(key));
7525
7557
  await replyToThread(key, (0, i18n_1.t)('thread.no_binding'), extra);
7526
7558
  return;
7527
7559
  }
@@ -7589,7 +7621,7 @@ async function checkFileIntakeGatePassed(key, isStarting, sendHint) {
7589
7621
  const binding = state.getBinding(key);
7590
7622
  if (!binding) {
7591
7623
  const subdirs = listAvailableSubdirs(ENV.workRoot);
7592
- const extra = buildBindKeyboard(subdirs, 0, BIND_PAGE_SIZE, !!state.getBinding(key));
7624
+ const extra = (0, bindPicker_1.buildBindKeyboard)(subdirs, 0, bindPicker_1.BIND_PAGE_SIZE, !!state.getBinding(key));
7593
7625
  await sendHint((0, i18n_1.t)('thread.no_binding'), extra);
7594
7626
  return false;
7595
7627
  }
@@ -7924,7 +7956,7 @@ bot.on((0, filters_1.message)('forum_topic_created'), async (ctx) => {
7924
7956
  // Fall through to picker.
7925
7957
  }
7926
7958
  }
7927
- const extra = buildBindKeyboard(subdirs, 0, BIND_PAGE_SIZE, !!state.getBinding(key));
7959
+ const extra = (0, bindPicker_1.buildBindKeyboard)(subdirs, 0, bindPicker_1.BIND_PAGE_SIZE, !!state.getBinding(key));
7928
7960
  await replyToThread(key, (0, i18n_1.t)('thread.welcome_pick'), extra);
7929
7961
  });
7930
7962
  /**
@@ -8038,7 +8070,7 @@ bot.action(/^bind_page_(\d+)$/, async (ctx) => {
8038
8070
  }
8039
8071
  const page = parseInt(ctx.match[1], 10);
8040
8072
  const subdirs = listAvailableSubdirs(ENV.workRoot);
8041
- const keyboard = buildBindKeyboard(subdirs, page, BIND_PAGE_SIZE, !!state.getBinding(key));
8073
+ const keyboard = (0, bindPicker_1.buildBindKeyboard)(subdirs, page, bindPicker_1.BIND_PAGE_SIZE, !!state.getBinding(key));
8042
8074
  try {
8043
8075
  await ctx.editMessageReplyMarkup(keyboard.reply_markup);
8044
8076
  }
@@ -8051,14 +8083,14 @@ bot.action(/^bind_page_(\d+)$/, async (ctx) => {
8051
8083
  await ctx.answerCbQuery();
8052
8084
  });
8053
8085
  // Middle "N/M" pill in the nav row — pure UI, no state change.
8054
- bot.action('bind_page_noop', async (ctx) => {
8086
+ bot.action(bindPicker_1.bindPageNoopCallback, async (ctx) => {
8055
8087
  await ctx.answerCbQuery();
8056
8088
  });
8057
8089
  // «Create new folder» — first option in the /bind picker. Arms the thread's
8058
8090
  // await-folder-name mode; the next text message is the name (see the text
8059
8091
  // handler). Callback id deliberately avoids the `bind_` prefix so it can't be
8060
8092
  // matched by the `bind_<subdir>` regex below.
8061
- bot.action(bindCreateFolderCallback, async (ctx) => {
8093
+ bot.action(bindPicker_1.bindCreateFolderCallback, async (ctx) => {
8062
8094
  const key = await authoriseContext(ctx);
8063
8095
  if (!key) {
8064
8096
  await ctx.answerCbQuery((0, i18n_1.t)('cb.access_denied'));
@@ -8075,7 +8107,7 @@ bot.action(bindCreateFolderCallback, async (ctx) => {
8075
8107
  // «Leave current dir» — the folded-in `/unbind`, shown only in a bound topic.
8076
8108
  // Callback id deliberately avoids the `bind_` prefix so it can't be matched by
8077
8109
  // the `bind_<subdir>` regex below.
8078
- bot.action(bindLeaveCurrentCallback, async (ctx) => {
8110
+ bot.action(bindPicker_1.bindLeaveCurrentCallback, async (ctx) => {
8079
8111
  const key = await authoriseContext(ctx);
8080
8112
  if (!key) {
8081
8113
  await ctx.answerCbQuery((0, i18n_1.t)('cb.access_denied'));
@@ -8088,6 +8120,8 @@ bot.action(bindLeaveCurrentCallback, async (ctx) => {
8088
8120
  await ctx.answerCbQuery();
8089
8121
  await unbindThread(key);
8090
8122
  });
8123
+ // `/topic`'s buttons: patterns of their own (`topic_…`), disjoint from the `bind_<subdir>` regex below.
8124
+ registerTopicCallbacks();
8091
8125
  bot.action(/^bind_(.+)$/, async (ctx) => {
8092
8126
  const key = await authoriseContext(ctx);
8093
8127
  if (!key) {
@@ -8246,6 +8280,7 @@ registerClaudeAccountCallbacks();
8246
8280
  registerFallbackChainCallbacks();
8247
8281
  registerLimitsBoardCallbacks();
8248
8282
  registerSttCallbacks();
8283
+ registerSelfUpdateCallbacks();
8249
8284
  // «⏭ Skip once» on the `/limits` board and the `/limits_autoswitch` screen: drop THIS armed resume and
8250
8285
  // leave the on/off setting alone. The episode is identified by the `fireAt` baked
8251
8286
  // into the callback data, so an untouched older picker can never cancel a LATER
@@ -8568,32 +8603,38 @@ async function deliverActivePrompt(key, adapter, text, intake, sentAtMs, replyCo
8568
8603
  return outcome;
8569
8604
  }
8570
8605
  /**
8571
- * @description Cancel a pending OpenCode question because the user sent free-form
8572
- * input instead of answering it, then deliver that input as a fresh prompt. In
8573
- * order: clear the bot's pending-question state, neutralize the stale buttons
8574
- * message (edit it to a "cancelled" label so a late tap can't re-answer an
8575
- * aborted question), abort the wedged OpenCode turn (`SIGINT` → `/session/:id/
8576
- * abort`), post the "previous question cancelled" notice, then forward.
8606
+ * @description Retire the on-screen state of a pending OpenCode question the user walked away from: clear the bot's
8607
+ * pending-question state and neutralize the stale buttons message (edit it to a "cancelled" label so a late tap can't
8608
+ * re-answer an aborted question). Closing the question on the server is the caller's, before it aborts the turn.
8609
+ * Returns whether the buttons message itself was relabelled — it IS the cancellation notice then.
8577
8610
  *
8578
- * The buttons-message edit runs through `runQuestionLifecycleOp` (same serializer
8579
- * as the answer path) so it can't race a concurrent re-post-to-bottom. The
8580
- * `messageId` + header are captured BEFORE clearing, since `clearPendingQuestion`
8581
- * drops the state.
8611
+ * The edit runs through `runQuestionLifecycleOp` (same serializer as the answer path) so it can't race a concurrent
8612
+ * re-post-to-bottom. The `messageId` + header are captured BEFORE clearing, since `clearPendingQuestion` drops the state.
8582
8613
  */
8583
- async function cancelPendingQuestionAndForward(key, adapter, text, sentAtMs, replyContext, requestHeader) {
8614
+ async function retirePendingQuestion(key) {
8584
8615
  const pending = pendingQuestions.get((0, sessionKey_1.keyToString)(key));
8585
8616
  const cancelledMessageId = pending?.messageId ?? null;
8586
8617
  const cancelledQuestion = pending?.data.questions[pending.currentIndex];
8587
- // Whether we relabel the on-screen question bubble itself to "❌ … cancelled"
8588
- // below. When we do, that IS the cancellation notice — a second standalone
8589
- // "previous question cancelled" line is pure noise (the reported triple
8590
- // message). The standalone notice is kept only for the no-bubble case.
8591
- const didLabelQuestionMessage = cancelledMessageId !== null && cancelledQuestion !== undefined;
8592
8618
  clearPendingQuestion(key);
8593
- if (cancelledMessageId !== null && cancelledQuestion) {
8594
- const header = cancelledQuestion.header || cancelledQuestion.question;
8595
- await runQuestionLifecycleOp(key, () => editThreadMessage(key, cancelledMessageId, (0, i18n_1.t)('agent.question_cancelled_msg_label', { header })));
8596
- }
8619
+ if (cancelledMessageId === null || !cancelledQuestion)
8620
+ return false;
8621
+ const header = cancelledQuestion.header || cancelledQuestion.question;
8622
+ await runQuestionLifecycleOp(key, () => editThreadMessage(key, cancelledMessageId, (0, i18n_1.t)('agent.question_cancelled_msg_label', { header })));
8623
+ return true;
8624
+ }
8625
+ /**
8626
+ * @description Cancel a pending OpenCode question because the user sent free-form
8627
+ * input instead of answering it, then deliver that input as a fresh prompt. In
8628
+ * order: retire the question's on-screen state ({@link retirePendingQuestion}),
8629
+ * close it on the server, abort the wedged OpenCode turn (`SIGINT` → `/session/:id/
8630
+ * abort`), post the "previous question cancelled" notice, then forward.
8631
+ */
8632
+ async function cancelPendingQuestionAndForward(key, adapter, text, sentAtMs, replyContext, requestHeader) {
8633
+ // When the question bubble itself was relabelled to "❌ … cancelled", that IS the
8634
+ // cancellation notice — a second standalone "previous question cancelled" line is
8635
+ // pure noise (the reported triple message). The standalone notice is kept only
8636
+ // for the no-bubble case.
8637
+ const didLabelQuestionMessage = await retirePendingQuestion(key);
8597
8638
  // Close the question on the OpenCode server too (not just the bot-local +
8598
8639
  // Telegram state above): an abandoned-but-still-open question keeps getting
8599
8640
  // re-found by `restoreOpenQuestion` (`GET /question` on every reattach) and
@@ -9954,6 +9995,7 @@ exports.COMMANDS_MENU = [
9954
9995
  { command: 'help', description: '❓ Context-aware help' },
9955
9996
  { command: 'doctor', description: '🔍 Self-diagnostics' },
9956
9997
  { command: 'bind', description: '📁 Bind thread to a subfolder' },
9998
+ { command: 'topic', description: '🧵 New topic on a folder (name, then folder)' },
9957
9999
  { command: 'ls', description: '📂 List WORK_ROOT subfolders' },
9958
10000
  { command: 'list', description: '🧵 List all bound threads' },
9959
10001
  { command: 'mcp', description: '🔌 List active MCP servers' },
@@ -9981,6 +10023,7 @@ exports.COMMANDS_MENU = [
9981
10023
  { command: 'limits', description: '🚧 Usage limits — switch this topic' },
9982
10024
  { command: 'limits_autoswitch', description: '🔁 Automatic switching on a usage limit' },
9983
10025
  { command: 'stt', description: '🎙 Voice recognition: engines, language, local engine' },
10026
+ { command: 'update', description: '🔄 Bot updates: automatic, notify only, off' },
9984
10027
  { command: 'schedule', description: '⏰ Schedule a prompt (agent does the work)' },
9985
10028
  { command: 'reminders', description: '🔔 Reminders — add, list, delete (no agent)' },
9986
10029
  { command: 'status', description: '📊 Show status' },
@@ -9996,7 +10039,8 @@ exports.COMMANDS_MENU = [
9996
10039
  { command: 'up', description: '⬆️ Arrow Up' },
9997
10040
  { command: 'down', description: '⬇️ Arrow Down' },
9998
10041
  { command: 'tab', description: '⇥ Tab' },
9999
- { command: 'esc', description: '⎋ Escape (interrupt / dismiss / cancel a pending login)' },
10042
+ { command: 'interrupt', description: '⏹ Interrupt the running turn — the next message steers it (alias /esc)' },
10043
+ { command: 'esc', description: '⎋ Escape (interrupt / dismiss / cancel a pending login; alias /interrupt)' },
10000
10044
  { command: 'y', description: '✅ Send "y"' },
10001
10045
  { command: 'n', description: '❌ Send "n"' },
10002
10046
  { command: 'c', description: '🛑 Ctrl+C' },
@@ -11055,6 +11099,66 @@ async function resolveBotAdminRights(groupId, botUserId) {
11055
11099
  return null;
11056
11100
  }
11057
11101
  }
11102
+ /**
11103
+ * @description The General topic the updater talks in: the paired group's General when a group is paired, else the owner's DM
11104
+ * General (`null` when neither exists — a Jira-only instance has no Telegram to say anything in).
11105
+ */
11106
+ function getUpdateGeneralKey() {
11107
+ const groupId = getAllowedGroupId();
11108
+ if (groupId !== null)
11109
+ return (0, sessionKeyCodec_1.makeTelegramKey)(groupId, threadRouting_1.GENERAL_THREAD_ID);
11110
+ const ownerUserId = getOwnerUserId();
11111
+ return Number.isFinite(ownerUserId) ? (0, sessionKeyCodec_1.makeTelegramKey)(ownerUserId, threadRouting_1.DM_GENERAL_THREAD_ID) : null;
11112
+ }
11113
+ /**
11114
+ * @description Say an update message in General, worded in that chat's language: pinned when it asks the operator to act, with
11115
+ * the install tap (the version printed on the button is the version it installs) when it offers one. A failed send or pin is
11116
+ * logged — the updater never fails because Telegram did.
11117
+ */
11118
+ async function postUpdateMessageToGeneral(message) {
11119
+ if (!ENV.isTelegramServed)
11120
+ return;
11121
+ const generalKey = getUpdateGeneralKey();
11122
+ if (generalKey === null) {
11123
+ console.log(`[update] no General topic to post "${message.kind}" in`);
11124
+ return;
11125
+ }
11126
+ const rendered = withThreadLocale(generalKey, () => (0, updateMessages_1.renderUpdateMessage)(message, (code, vars) => (0, i18n_1.t)(code, vars)));
11127
+ const extra = rendered.buttons.length > 0
11128
+ ? telegraf_1.Markup.inlineKeyboard(rendered.buttons.map((button) => [telegraf_1.Markup.button.callback(button.label, button.callbackData)]))
11129
+ : {};
11130
+ const messageId = await replyToThread(generalKey, rendered.text, extra);
11131
+ if (messageId !== null && rendered.pin)
11132
+ await pinMessageQuiet(generalKey, messageId, { disableNotification: false });
11133
+ }
11134
+ /**
11135
+ * @description Whether the bot may be restarted now, from what it knows: no agent working in any bound topic (`checkIsWorking`, else
11136
+ * `checkIsBusy`), no input an adapter has not taken in, nothing held in the startup buffer, no sign-in, no in-memory wizard or pasted
11137
+ * key, and no human message within the quiet period. A sleeping or idle-but-alive session does not count: it survives the restart.
11138
+ */
11139
+ function getUpdateIdleVerdict(isUserRequested) {
11140
+ const keys = getServedBindings().map(({ key }) => key);
11141
+ const isAnyAgentWorking = keys.some((key) => {
11142
+ const adapter = (0, createAdapter_1.getThreadAdapter)(key);
11143
+ return adapter.checkIsWorking ? adapter.checkIsWorking(key) : adapter.checkIsBusy?.(key) === true;
11144
+ });
11145
+ return (0, updatePolicy_1.getIdleVerdict)({
11146
+ isAnyAgentWorking,
11147
+ hasUnconsumedInput: keys.some((key) => (0, createAdapter_1.getThreadAdapter)(key).checkHasUnconsumedInput?.(key) === true),
11148
+ hasBufferedStartupPrompt: startupPromptBuffer.checkIsAnyPending(),
11149
+ isSignInRunning: checkIsAnyLoginInProgress(),
11150
+ isWizardOpen: reminderWizards.size > 0 ||
11151
+ awaitingFolderName.size > 0 ||
11152
+ awaitingModelSelection.size > 0 ||
11153
+ awaitingSessionSelection.size > 0 ||
11154
+ pendingProviderConnects.size > 0 ||
11155
+ checkIsAnySttInputAwaited(),
11156
+ lastHumanMessageAt: lastHumanInboundAt,
11157
+ now: Date.now(),
11158
+ quietMs: constants_1.quietPeriodMs,
11159
+ isUserRequested,
11160
+ });
11161
+ }
11058
11162
  /**
11059
11163
  * @description Boot-time readiness status (plan 2026-07-12): on startup tell the
11060
11164
  * owner whether the bot can process messages, or list the setup steps still
@@ -11715,36 +11819,82 @@ async function startBot() {
11715
11819
  // `stopOpenCodeServer()` or `adapter.stopSession()` here — tmux and
11716
11820
  // opencode survive the bot process by design and are picked back up
11717
11821
  // by `reattachExistingSessions` on the next boot.
11822
+ // The ordered sequence is the same for a signal and for an update's restart; only what happens AFTER it differs
11823
+ // (`exit` ends the process, the restart replaces it with a fresh image of the same install).
11824
+ const runShutdown = (signal, exit) => (0, shutdown_1.gracefulShutdown)({
11825
+ signal,
11826
+ // Without Telegram there is no polling to stop, and `bot.stop` throws when it never launched.
11827
+ bot: ENV.isTelegramServed ? bot : { stop: () => { } },
11828
+ state,
11829
+ releaseLock: instanceLock_1.releaseLock,
11830
+ exit,
11831
+ drainPendingUpdates: () => updateDispatcher.drainIdle(updateDrainTimeoutMs),
11832
+ finalizePendingOutput: () => finalizePendingOutputOnShutdown(),
11833
+ clearTransientFrames: () => sweepTransientFramesOnShutdown(),
11834
+ // A hot worker's reload keeps the sidecar for the next worker to adopt; the supervisor stops it for good.
11835
+ stopSidecars: (0, installManager_1.checkIsOpenCodeExternalHost)() ? undefined : () => sttOwner.stop(),
11836
+ cleanupTimers: () => {
11837
+ clearInterval(inMemoryGcInterval);
11838
+ clearInterval(heartbeatInterval);
11839
+ clearInterval(fileSweepInterval);
11840
+ clearInterval(rateSummaryInterval);
11841
+ // Scheduler (S8): clear every armed job timer; persisted nextRunAt
11842
+ // means the next boot's rearmAll picks them back up (catch-up replay).
11843
+ schedulerEngine?.shutdown();
11844
+ requestWakeUpEngine?.stop();
11845
+ jiraConnector?.stop();
11846
+ if (isSchedulerMcpStarted)
11847
+ void schedulerMcpHandle.stop().catch(() => { });
11848
+ updateEngine?.stop();
11849
+ },
11850
+ });
11718
11851
  const shutdown = (signal) => {
11719
- void (0, shutdown_1.gracefulShutdown)({
11720
- signal,
11721
- // Without Telegram there is no polling to stop, and `bot.stop` throws when it never launched.
11722
- bot: ENV.isTelegramServed ? bot : { stop: () => { } },
11723
- state,
11724
- releaseLock: instanceLock_1.releaseLock,
11725
- exit: (code) => process.exit(code),
11726
- drainPendingUpdates: () => updateDispatcher.drainIdle(updateDrainTimeoutMs),
11727
- finalizePendingOutput: () => finalizePendingOutputOnShutdown(),
11728
- clearTransientFrames: () => sweepTransientFramesOnShutdown(),
11729
- // A hot worker's reload keeps the sidecar for the next worker to adopt; the supervisor stops it for good.
11730
- stopSidecars: (0, installManager_1.checkIsOpenCodeExternalHost)() ? undefined : () => sttOwner.stop(),
11731
- cleanupTimers: () => {
11732
- clearInterval(inMemoryGcInterval);
11733
- clearInterval(heartbeatInterval);
11734
- clearInterval(fileSweepInterval);
11735
- clearInterval(rateSummaryInterval);
11736
- // Scheduler (S8): clear every armed job timer; persisted nextRunAt
11737
- // means the next boot's rearmAll picks them back up (catch-up replay).
11738
- schedulerEngine?.shutdown();
11739
- requestWakeUpEngine?.stop();
11740
- jiraConnector?.stop();
11741
- if (isSchedulerMcpStarted)
11742
- void schedulerMcpHandle.stop().catch(() => { });
11743
- },
11744
- });
11852
+ void runShutdown(signal, (code) => process.exit(code));
11745
11853
  };
11746
11854
  process.once('SIGINT', () => shutdown('SIGINT'));
11747
11855
  process.once('SIGTERM', () => shutdown('SIGTERM'));
11856
+ // 7b. Automatic updates (`src/selfUpdate/`): the engine finds a new version, installs it when nobody is using the bot and
11857
+ // restarts it in place — the same sequence as a signal, then `process.execve` with the launch environment.
11858
+ const updateRuntime = await (0, updateRuntime_1.createUpdateRuntime)({
11859
+ dataDir: path.dirname(state.stateFilePath),
11860
+ packageRoot: path.resolve(__dirname, '..'),
11861
+ execPath: process.execPath,
11862
+ platform: process.platform,
11863
+ nodeVersion: process.versions.node,
11864
+ argv: process.argv,
11865
+ isHotMode: (0, installManager_1.checkIsOpenCodeExternalHost)(),
11866
+ });
11867
+ const readUpdateSettings = (0, instanceSettings_1.createUpdateSettingsReader)(() => (0, instanceSettings_1.getInstanceSettingsPath)(), (line) => console.error(line));
11868
+ updateEngine = (0, updateEngine_1.createUpdateEngine)({
11869
+ ...updateRuntime,
11870
+ now: () => Date.now(),
11871
+ setTimer: (callback, delayMs) => {
11872
+ const timer = setTimeout(callback, delayMs);
11873
+ timer.unref();
11874
+ return timer;
11875
+ },
11876
+ clearTimer: (handle) => clearTimeout(handle),
11877
+ log: (line) => console.log(line),
11878
+ getSettings: readUpdateSettings,
11879
+ state: {
11880
+ getRecord: () => state.getSelfUpdateRecord(),
11881
+ setRecord: (record) => state.setSelfUpdateRecord(record),
11882
+ getLastRun: () => state.getLastRunVersion(),
11883
+ setLastRun: (lastRun) => state.setLastRunVersion(lastRun),
11884
+ },
11885
+ getIdleVerdict: getUpdateIdleVerdict,
11886
+ postToGeneral: postUpdateMessageToGeneral,
11887
+ restartInPlace: () => runShutdown('update', () => (0, restart_1.reexecProcess)(updateRuntime.reexecTarget, (0, launchEnvironment_1.getLaunchEnvironment)(), (0, restart_1.createDefaultReexecDeps)())),
11888
+ });
11889
+ const startUpdates = async () => {
11890
+ try {
11891
+ await updateEngine?.runBootStep();
11892
+ }
11893
+ catch (e) {
11894
+ console.warn('[update] boot step failed:', e instanceof Error ? e.message : e);
11895
+ }
11896
+ updateEngine?.start();
11897
+ };
11748
11898
  // 8. Launch. `dropPendingUpdates` follows the boot classifier: on a hot
11749
11899
  // reload we KEEP the buffered backlog so a message typed during the
11750
11900
  // ~1s reload window still routes to its live agent; on a cold start
@@ -11761,6 +11911,7 @@ async function startBot() {
11761
11911
  if (!ENV.isTelegramServed) {
11762
11912
  // No polling: the MCP server and the connector timers keep the process alive.
11763
11913
  console.log('[boot] running without Telegram (connectors: ' + [...ENV.servedPlatforms].join(', ') + ')');
11914
+ await startUpdates();
11764
11915
  return;
11765
11916
  }
11766
11917
  try {
@@ -11769,6 +11920,7 @@ async function startBot() {
11769
11920
  catch (e) {
11770
11921
  console.warn('[startup-status] failed:', e instanceof Error ? e.message : e);
11771
11922
  }
11923
+ await startUpdates();
11772
11924
  // The local speech engine: clean up after an earlier run, re-attach to / start its install. Starts no process.
11773
11925
  void bootSttLocalEngine().catch((e) => console.warn('[STT] boot step failed:', e instanceof Error ? e.message : e));
11774
11926
  console.log(`Launching Telegraf bot (long polling, dropPendingUpdates=${bootMode.dropPendingUpdates})...`);