@bd7pil/ocrc 0.9.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 (292) hide show
  1. package/LICENSE +34 -0
  2. package/README.md +330 -0
  3. package/dist/cli/host-backends.js +216 -0
  4. package/dist/cli/host-backends.js.map +1 -0
  5. package/dist/cli/host.js +185 -0
  6. package/dist/cli/host.js.map +1 -0
  7. package/dist/cli/index.js +67 -0
  8. package/dist/cli/index.js.map +1 -0
  9. package/dist/cli/init.js +68 -0
  10. package/dist/cli/init.js.map +1 -0
  11. package/dist/cli/install.js +220 -0
  12. package/dist/cli/install.js.map +1 -0
  13. package/dist/cli/pair.js +11 -0
  14. package/dist/cli/pair.js.map +1 -0
  15. package/dist/cli/uninstall.js +75 -0
  16. package/dist/cli/uninstall.js.map +1 -0
  17. package/dist/connectivity/auth/cf-access.js +20 -0
  18. package/dist/connectivity/auth/cf-access.js.map +1 -0
  19. package/dist/connectivity/auth/index.js +2 -0
  20. package/dist/connectivity/auth/index.js.map +1 -0
  21. package/dist/connectivity/auth/select.js +19 -0
  22. package/dist/connectivity/auth/select.js.map +1 -0
  23. package/dist/connectivity/auth/token.js +157 -0
  24. package/dist/connectivity/auth/token.js.map +1 -0
  25. package/dist/connectivity/exposure/cloudflared.js +77 -0
  26. package/dist/connectivity/exposure/cloudflared.js.map +1 -0
  27. package/dist/connectivity/exposure/index.js +2 -0
  28. package/dist/connectivity/exposure/index.js.map +1 -0
  29. package/dist/connectivity/exposure/providers.js +49 -0
  30. package/dist/connectivity/exposure/providers.js.map +1 -0
  31. package/dist/connectivity/pairing.js +29 -0
  32. package/dist/connectivity/pairing.js.map +1 -0
  33. package/dist/core/agent/acp-backend.js +539 -0
  34. package/dist/core/agent/acp-backend.js.map +1 -0
  35. package/dist/core/agent/acp-connect.js +92 -0
  36. package/dist/core/agent/acp-connect.js.map +1 -0
  37. package/dist/core/agent/acp-normalizer.js +81 -0
  38. package/dist/core/agent/acp-normalizer.js.map +1 -0
  39. package/dist/core/agent/acp-replay.js +89 -0
  40. package/dist/core/agent/acp-replay.js.map +1 -0
  41. package/dist/core/agent/acp-store.js +129 -0
  42. package/dist/core/agent/acp-store.js.map +1 -0
  43. package/dist/core/agent/backend.js +2 -0
  44. package/dist/core/agent/backend.js.map +1 -0
  45. package/dist/core/agent/diff-util.js +70 -0
  46. package/dist/core/agent/diff-util.js.map +1 -0
  47. package/dist/core/agent/event.js +12 -0
  48. package/dist/core/agent/event.js.map +1 -0
  49. package/dist/core/agent/opencode-backend.js +598 -0
  50. package/dist/core/agent/opencode-backend.js.map +1 -0
  51. package/dist/core/agent/opencode-normalizer.js +69 -0
  52. package/dist/core/agent/opencode-normalizer.js.map +1 -0
  53. package/dist/core/agent/registry.js +55 -0
  54. package/dist/core/agent/registry.js.map +1 -0
  55. package/dist/core/agent/v2-backend.js +176 -0
  56. package/dist/core/agent/v2-backend.js.map +1 -0
  57. package/dist/core/card-bus.js +74 -0
  58. package/dist/core/card-bus.js.map +1 -0
  59. package/dist/core/channels.js +95 -0
  60. package/dist/core/channels.js.map +1 -0
  61. package/dist/core/history.js +91 -0
  62. package/dist/core/history.js.map +1 -0
  63. package/dist/core/list-files.js +50 -0
  64. package/dist/core/list-files.js.map +1 -0
  65. package/dist/core/opencode-events.js +29 -0
  66. package/dist/core/opencode-events.js.map +1 -0
  67. package/dist/core/primary-election.js +91 -0
  68. package/dist/core/primary-election.js.map +1 -0
  69. package/dist/core/push.js +152 -0
  70. package/dist/core/push.js.map +1 -0
  71. package/dist/core/relay.js +400 -0
  72. package/dist/core/relay.js.map +1 -0
  73. package/dist/core/scheduler.js +150 -0
  74. package/dist/core/scheduler.js.map +1 -0
  75. package/dist/core/state.js +222 -0
  76. package/dist/core/state.js.map +1 -0
  77. package/dist/core/stream-accumulator.js +76 -0
  78. package/dist/core/stream-accumulator.js.map +1 -0
  79. package/dist/core/structured-card.js +2 -0
  80. package/dist/core/structured-card.js.map +1 -0
  81. package/dist/core/types.js +2 -0
  82. package/dist/core/types.js.map +1 -0
  83. package/dist/index.js +3 -0
  84. package/dist/index.js.map +1 -0
  85. package/dist/opencode/global-events.js +57 -0
  86. package/dist/opencode/global-events.js.map +1 -0
  87. package/dist/opencode/list-sessions.js +122 -0
  88. package/dist/opencode/list-sessions.js.map +1 -0
  89. package/dist/opencode/submit.js +39 -0
  90. package/dist/opencode/submit.js.map +1 -0
  91. package/dist/opencode/workspaces.js +51 -0
  92. package/dist/opencode/workspaces.js.map +1 -0
  93. package/dist/plugin/config.js +128 -0
  94. package/dist/plugin/config.js.map +1 -0
  95. package/dist/plugin/control-plane.js +133 -0
  96. package/dist/plugin/control-plane.js.map +1 -0
  97. package/dist/plugin/entry.js +409 -0
  98. package/dist/plugin/entry.js.map +1 -0
  99. package/dist/plugin/v2/event-map.js +193 -0
  100. package/dist/plugin/v2/event-map.js.map +1 -0
  101. package/dist/plugin/v2/types.js +15 -0
  102. package/dist/plugin/v2/types.js.map +1 -0
  103. package/dist/transport/interface.js +2 -0
  104. package/dist/transport/interface.js.map +1 -0
  105. package/dist/transport/telegram/esc.js +9 -0
  106. package/dist/transport/telegram/esc.js.map +1 -0
  107. package/dist/transport/telegram/handlers/info-commands.js +101 -0
  108. package/dist/transport/telegram/handlers/info-commands.js.map +1 -0
  109. package/dist/transport/telegram/handlers.js +1348 -0
  110. package/dist/transport/telegram/handlers.js.map +1 -0
  111. package/dist/transport/telegram/i18n/en.js +538 -0
  112. package/dist/transport/telegram/i18n/en.js.map +1 -0
  113. package/dist/transport/telegram/i18n/index.js +83 -0
  114. package/dist/transport/telegram/i18n/index.js.map +1 -0
  115. package/dist/transport/telegram/i18n/zh.js +538 -0
  116. package/dist/transport/telegram/i18n/zh.js.map +1 -0
  117. package/dist/transport/telegram/index.js +792 -0
  118. package/dist/transport/telegram/index.js.map +1 -0
  119. package/dist/transport/telegram/main-keyboard.js +36 -0
  120. package/dist/transport/telegram/main-keyboard.js.map +1 -0
  121. package/dist/transport/telegram/managers/abort-suppression-manager.js +33 -0
  122. package/dist/transport/telegram/managers/abort-suppression-manager.js.map +1 -0
  123. package/dist/transport/telegram/managers/external-input-suppression-manager.js +51 -0
  124. package/dist/transport/telegram/managers/external-input-suppression-manager.js.map +1 -0
  125. package/dist/transport/telegram/managers/interaction-manager.js +254 -0
  126. package/dist/transport/telegram/managers/interaction-manager.js.map +1 -0
  127. package/dist/transport/telegram/managers/permission-manager.js +242 -0
  128. package/dist/transport/telegram/managers/permission-manager.js.map +1 -0
  129. package/dist/transport/telegram/managers/question-manager.js +191 -0
  130. package/dist/transport/telegram/managers/question-manager.js.map +1 -0
  131. package/dist/transport/telegram/managers/rename-manager.js +56 -0
  132. package/dist/transport/telegram/managers/rename-manager.js.map +1 -0
  133. package/dist/transport/telegram/menus.js +77 -0
  134. package/dist/transport/telegram/menus.js.map +1 -0
  135. package/dist/transport/telegram/permission-flow.js +156 -0
  136. package/dist/transport/telegram/permission-flow.js.map +1 -0
  137. package/dist/transport/telegram/question-flow.js +224 -0
  138. package/dist/transport/telegram/question-flow.js.map +1 -0
  139. package/dist/transport/telegram/render/block-parser.js +365 -0
  140. package/dist/transport/telegram/render/block-parser.js.map +1 -0
  141. package/dist/transport/telegram/render/block-plain-text.js +87 -0
  142. package/dist/transport/telegram/render/block-plain-text.js.map +1 -0
  143. package/dist/transport/telegram/render/block-splitter.js +152 -0
  144. package/dist/transport/telegram/render/block-splitter.js.map +1 -0
  145. package/dist/transport/telegram/render/chunker.js +71 -0
  146. package/dist/transport/telegram/render/chunker.js.map +1 -0
  147. package/dist/transport/telegram/render/limits.js +13 -0
  148. package/dist/transport/telegram/render/limits.js.map +1 -0
  149. package/dist/transport/telegram/render/markdown-normalizer.js +102 -0
  150. package/dist/transport/telegram/render/markdown-normalizer.js.map +1 -0
  151. package/dist/transport/telegram/render/markdown-to-telegram-v2.js +116 -0
  152. package/dist/transport/telegram/render/markdown-to-telegram-v2.js.map +1 -0
  153. package/dist/transport/telegram/render/part-signature.js +14 -0
  154. package/dist/transport/telegram/render/part-signature.js.map +1 -0
  155. package/dist/transport/telegram/render/pipeline.js +23 -0
  156. package/dist/transport/telegram/render/pipeline.js.map +1 -0
  157. package/dist/transport/telegram/render/rich-blocks.js +311 -0
  158. package/dist/transport/telegram/render/rich-blocks.js.map +1 -0
  159. package/dist/transport/telegram/render/text-splitter.js +64 -0
  160. package/dist/transport/telegram/render/text-splitter.js.map +1 -0
  161. package/dist/transport/telegram/render/types.js +2 -0
  162. package/dist/transport/telegram/render/types.js.map +1 -0
  163. package/dist/transport/telegram/renderer.js +305 -0
  164. package/dist/transport/telegram/renderer.js.map +1 -0
  165. package/dist/transport/telegram/streaming/duration-formatter.js +68 -0
  166. package/dist/transport/telegram/streaming/duration-formatter.js.map +1 -0
  167. package/dist/transport/telegram/streaming/finalize-assistant-response.js +37 -0
  168. package/dist/transport/telegram/streaming/finalize-assistant-response.js.map +1 -0
  169. package/dist/transport/telegram/streaming/response-streamer.js +400 -0
  170. package/dist/transport/telegram/streaming/response-streamer.js.map +1 -0
  171. package/dist/transport/telegram/streaming/running-tool-tracker.js +150 -0
  172. package/dist/transport/telegram/streaming/running-tool-tracker.js.map +1 -0
  173. package/dist/transport/telegram/streaming/stream-throttle.js +57 -0
  174. package/dist/transport/telegram/streaming/stream-throttle.js.map +1 -0
  175. package/dist/transport/telegram/streaming/tool-call-streamer.js +390 -0
  176. package/dist/transport/telegram/streaming/tool-call-streamer.js.map +1 -0
  177. package/dist/transport/telegram/streaming-render.js +182 -0
  178. package/dist/transport/telegram/streaming-render.js.map +1 -0
  179. package/dist/transport/telegram/types/interaction.js +2 -0
  180. package/dist/transport/telegram/types/interaction.js.map +1 -0
  181. package/dist/transport/telegram/types/model.js +25 -0
  182. package/dist/transport/telegram/types/model.js.map +1 -0
  183. package/dist/transport/telegram/types/permission.js +2 -0
  184. package/dist/transport/telegram/types/permission.js.map +1 -0
  185. package/dist/transport/telegram/types/question.js +2 -0
  186. package/dist/transport/telegram/types/question.js.map +1 -0
  187. package/dist/transport/telegram/types/rename.js +2 -0
  188. package/dist/transport/telegram/types/rename.js.map +1 -0
  189. package/dist/transport/telegram/types/scheduled-task.js +23 -0
  190. package/dist/transport/telegram/types/scheduled-task.js.map +1 -0
  191. package/dist/transport/telegram/types/session.js +2 -0
  192. package/dist/transport/telegram/types/session.js.map +1 -0
  193. package/dist/transport/telegram/ui.js +28 -0
  194. package/dist/transport/telegram/ui.js.map +1 -0
  195. package/dist/transport/web/index.js +148 -0
  196. package/dist/transport/web/index.js.map +1 -0
  197. package/dist/transport/web/middleware/cf-access.js +104 -0
  198. package/dist/transport/web/middleware/cf-access.js.map +1 -0
  199. package/dist/transport/web/opencode-config.js +15 -0
  200. package/dist/transport/web/opencode-config.js.map +1 -0
  201. package/dist/transport/web/routes/abort.js +12 -0
  202. package/dist/transport/web/routes/abort.js.map +1 -0
  203. package/dist/transport/web/routes/approval.js +16 -0
  204. package/dist/transport/web/routes/approval.js.map +1 -0
  205. package/dist/transport/web/routes/catalog.js +11 -0
  206. package/dist/transport/web/routes/catalog.js.map +1 -0
  207. package/dist/transport/web/routes/channels.js +53 -0
  208. package/dist/transport/web/routes/channels.js.map +1 -0
  209. package/dist/transport/web/routes/commands.js +15 -0
  210. package/dist/transport/web/routes/commands.js.map +1 -0
  211. package/dist/transport/web/routes/context.js +17 -0
  212. package/dist/transport/web/routes/context.js.map +1 -0
  213. package/dist/transport/web/routes/controls.js +34 -0
  214. package/dist/transport/web/routes/controls.js.map +1 -0
  215. package/dist/transport/web/routes/create-session.js +21 -0
  216. package/dist/transport/web/routes/create-session.js.map +1 -0
  217. package/dist/transport/web/routes/diff.js +7 -0
  218. package/dist/transport/web/routes/diff.js.map +1 -0
  219. package/dist/transport/web/routes/files.js +22 -0
  220. package/dist/transport/web/routes/files.js.map +1 -0
  221. package/dist/transport/web/routes/logs.js +9 -0
  222. package/dist/transport/web/routes/logs.js.map +1 -0
  223. package/dist/transport/web/routes/m8.js +69 -0
  224. package/dist/transport/web/routes/m8.js.map +1 -0
  225. package/dist/transport/web/routes/mcp.js +7 -0
  226. package/dist/transport/web/routes/mcp.js.map +1 -0
  227. package/dist/transport/web/routes/message.js +32 -0
  228. package/dist/transport/web/routes/message.js.map +1 -0
  229. package/dist/transport/web/routes/overrides.js +20 -0
  230. package/dist/transport/web/routes/overrides.js.map +1 -0
  231. package/dist/transport/web/routes/question.js +32 -0
  232. package/dist/transport/web/routes/question.js.map +1 -0
  233. package/dist/transport/web/routes/rename.js +12 -0
  234. package/dist/transport/web/routes/rename.js.map +1 -0
  235. package/dist/transport/web/routes/schedules.js +28 -0
  236. package/dist/transport/web/routes/schedules.js.map +1 -0
  237. package/dist/transport/web/routes/session.js +20 -0
  238. package/dist/transport/web/routes/session.js.map +1 -0
  239. package/dist/transport/web/routes/sessions.js +25 -0
  240. package/dist/transport/web/routes/sessions.js.map +1 -0
  241. package/dist/transport/web/routes/subagents.js +11 -0
  242. package/dist/transport/web/routes/subagents.js.map +1 -0
  243. package/dist/transport/web/routes/suggestions.js +7 -0
  244. package/dist/transport/web/routes/suggestions.js.map +1 -0
  245. package/dist/transport/web/routes/todo.js +7 -0
  246. package/dist/transport/web/routes/todo.js.map +1 -0
  247. package/dist/transport/web/routes/version.js +5 -0
  248. package/dist/transport/web/routes/version.js.map +1 -0
  249. package/dist/transport/web/routes/workspaces.js +7 -0
  250. package/dist/transport/web/routes/workspaces.js.map +1 -0
  251. package/dist/transport/web/server.js +111 -0
  252. package/dist/transport/web/server.js.map +1 -0
  253. package/dist/transport/web/session-summary.js +46 -0
  254. package/dist/transport/web/session-summary.js.map +1 -0
  255. package/dist/transport/web/ws-hub.js +83 -0
  256. package/dist/transport/web/ws-hub.js.map +1 -0
  257. package/dist/utils/logger.js +105 -0
  258. package/dist/utils/logger.js.map +1 -0
  259. package/dist/utils/markdown.js +129 -0
  260. package/dist/utils/markdown.js.map +1 -0
  261. package/dist/utils/paths.js +18 -0
  262. package/dist/utils/paths.js.map +1 -0
  263. package/dist/utils/version.js +44 -0
  264. package/dist/utils/version.js.map +1 -0
  265. package/package.json +91 -0
  266. package/web/dist/_app/env.js +1 -0
  267. package/web/dist/_app/immutable/assets/0.DxN3RsuG.css +1 -0
  268. package/web/dist/_app/immutable/assets/2.BNuGfmZP.css +1 -0
  269. package/web/dist/_app/immutable/assets/3.CTDprntu.css +1 -0
  270. package/web/dist/_app/immutable/chunks/BUpwHpMJ.js +1 -0
  271. package/web/dist/_app/immutable/chunks/BjY4dxcB.js +2 -0
  272. package/web/dist/_app/immutable/chunks/C2FuqD3s.js +1 -0
  273. package/web/dist/_app/immutable/chunks/CUHGq08X.js +2 -0
  274. package/web/dist/_app/immutable/chunks/CWj6FrbW.js +1 -0
  275. package/web/dist/_app/immutable/chunks/ClvoqtGj.js +1 -0
  276. package/web/dist/_app/immutable/chunks/DIEDQ_hk.js +1 -0
  277. package/web/dist/_app/immutable/entry/app.BoRpcVMj.js +2 -0
  278. package/web/dist/_app/immutable/entry/start.Cj-PQNTq.js +1 -0
  279. package/web/dist/_app/immutable/nodes/0.y9YEms5p.js +3 -0
  280. package/web/dist/_app/immutable/nodes/1.CPhhiRM2.js +1 -0
  281. package/web/dist/_app/immutable/nodes/2.B_c84Mc9.js +1 -0
  282. package/web/dist/_app/immutable/nodes/3.ClTd0J3D.js +64 -0
  283. package/web/dist/_app/version.json +1 -0
  284. package/web/dist/apple-touch-icon.png +0 -0
  285. package/web/dist/favicon.png +0 -0
  286. package/web/dist/favicon.svg +17 -0
  287. package/web/dist/icon-192.png +0 -0
  288. package/web/dist/icon-512-maskable.png +0 -0
  289. package/web/dist/icon-512.png +0 -0
  290. package/web/dist/index.html +73 -0
  291. package/web/dist/manifest.webmanifest +28 -0
  292. package/web/dist/service-worker.js +1 -0
@@ -0,0 +1,1348 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { InlineKeyboard } from 'grammy';
3
+ import { inlineKeyboard, btn } from './ui.js';
4
+ import { createLogger } from '../../utils/logger.js';
5
+ import { getVersionInfo } from '../../utils/version.js';
6
+ import { registerInfoCommands } from './handlers/info-commands.js';
7
+ import { esc } from './esc.js';
8
+ const log = createLogger('handlers');
9
+ /** M9 ๅทฅไฝœๅŒบ่ฎฟ้—ฎ่Œƒๅ›ด: filter the workspace list per the tg-default bot scope. */
10
+ function scopeWorkspaces(deps, ws) {
11
+ const scope = deps.channels?.()?.workspaces;
12
+ if (!scope || scope.mode === 'all')
13
+ return ws;
14
+ const allowed = new Set(scope.dirs);
15
+ return ws.filter((w) => allowed.has(w.directory));
16
+ }
17
+ /** M9: is the given workspace allowed for the tg-default bot? */
18
+ function workspaceAllowed(deps, dir) {
19
+ const scope = deps.channels?.()?.workspaces;
20
+ if (!scope || scope.mode === 'all')
21
+ return true;
22
+ return scope.dirs.includes(dir);
23
+ }
24
+ /**
25
+ * Parse a "providerID/modelID" string into the shape expected by state/relay.
26
+ * Returns undefined if the string lacks a slash. Preserves slashes inside modelID.
27
+ */
28
+ function parseAgentModel(modelStr) {
29
+ const idx = modelStr.indexOf('/');
30
+ if (idx <= 0 || idx === modelStr.length - 1)
31
+ return undefined;
32
+ return {
33
+ providerID: modelStr.slice(0, idx),
34
+ modelID: modelStr.slice(idx + 1),
35
+ };
36
+ }
37
+ function shortPath(p) {
38
+ const cwd = process.cwd();
39
+ if (p.startsWith(cwd + '/'))
40
+ return p.slice(cwd.length + 1);
41
+ if (p.startsWith('/')) {
42
+ const parts = p.split('/');
43
+ if (parts.length > 3)
44
+ return 'โ€ฆ/' + parts.slice(-3).join('/');
45
+ }
46
+ return p;
47
+ }
48
+ async function buildStatusCard(deps) {
49
+ const healthy = await deps.backend.ping();
50
+ let busyCount = 0;
51
+ let totalSessions = 0;
52
+ let totalCost = 0;
53
+ try {
54
+ const data = (await deps.backend.getSessionsStatus());
55
+ totalSessions = Object.keys(data).length;
56
+ busyCount = Object.values(data).filter((s) => s.type === 'busy').length;
57
+ for (const sid of Object.keys(data)) {
58
+ const c = deps.state.getSessionCost(sid);
59
+ if (c !== undefined)
60
+ totalCost += c;
61
+ }
62
+ }
63
+ catch {
64
+ try {
65
+ totalSessions = (await deps.backend.listSessions()).length;
66
+ }
67
+ catch { }
68
+ }
69
+ const pinnedSession = deps.state.getPinnedSessionId();
70
+ const lastSession = deps.state.getLastSessionId();
71
+ const tuiSession = deps.state.getTuiSelectedSession();
72
+ const effectiveSession = pinnedSession ?? lastSession;
73
+ const currentAgent = deps.state.getCurrentAgent();
74
+ const nextAgent = deps.state.getNextAgent();
75
+ const nextModel = deps.state.getNextModel();
76
+ const row = (label, value) => ` <b>${label}</b> ${value}`;
77
+ const sessionLabel = pinnedSession ? 'Bot ๐Ÿ“Œ' : 'Bot';
78
+ const lines = [
79
+ `${healthy ? '๐ŸŸข' : '๐Ÿ”ด'} <b>opencode</b> ยท ${healthy ? 'healthy' : 'unreachable'}`,
80
+ '',
81
+ row('Sessions', `${totalSessions} ยท ${busyCount} busy`),
82
+ ...(totalCost > 0 ? [row('Cost', `$${totalCost.toFixed(2)} today`)] : []),
83
+ ...(effectiveSession
84
+ ? [row(sessionLabel, `<code>โ€ฆ${effectiveSession.slice(-8)}</code>${currentAgent ? ` ยท ${currentAgent}` : ''}`)]
85
+ : []),
86
+ ...(tuiSession && tuiSession !== effectiveSession
87
+ ? [row('TUI', `<code>โ€ฆ${tuiSession.slice(-8)}</code>`)]
88
+ : []),
89
+ ...((nextAgent || nextModel)
90
+ ? ['',
91
+ ` Next โ€บ ${nextAgent ? `<b>${nextAgent}</b>` : 'โ€”'} ยท ${nextModel ? `<code>${nextModel.modelID}</code>` : 'โ€”'}`]
92
+ : []),
93
+ ];
94
+ const buttons = [
95
+ btn('๐Ÿ”„ Refresh', 'status:refresh'),
96
+ ];
97
+ if (deps.isGenerating()) {
98
+ buttons.push(btn('โน Stop', 'status:abort'));
99
+ }
100
+ return { lines, buttons };
101
+ }
102
+ export function registerHandlers(deps) {
103
+ // Maps a short token -> workspace directory, so callback_data stays under
104
+ // Telegram's 64-byte limit. Stable per directory (sha1-derived).
105
+ const wsTokens = new Map();
106
+ const wsToken = (dir) => {
107
+ const t = createHash('sha1').update(dir).digest('base64url').slice(0, 16);
108
+ wsTokens.set(t, dir);
109
+ return t;
110
+ };
111
+ // Same token-map pattern for model callbacks: a "providerID:modelID" pair
112
+ // (e.g. openrouter's vendor/model slugs) easily exceeds Telegram's 64-byte
113
+ // callback_data limit, which fails the whole keyboard with BUTTON_DATA_INVALID.
114
+ const modelTokens = new Map();
115
+ const modelToken = (providerID, modelID) => {
116
+ const t = createHash('sha1').update(`model:${providerID}/${modelID}`).digest('base64url').slice(0, 16);
117
+ modelTokens.set(t, `${providerID}/${modelID}`);
118
+ return t;
119
+ };
120
+ // โ”€โ”€ Commands โ”€โ”€
121
+ deps.bot.command('start', async (ctx) => {
122
+ const healthy = await deps.backend.ping();
123
+ const username = ctx.from?.first_name ?? 'there';
124
+ const nextAgent = deps.state.getNextAgent();
125
+ const nextModel = deps.state.getNextModel();
126
+ const lines = [
127
+ `๐Ÿ‘‹ <b>Hi ${username}</b>`,
128
+ '',
129
+ `opencode ${healthy ? '๐ŸŸข ready' : '๐Ÿ”ด unreachable'}`,
130
+ 'Send any message to relay it into opencode.',
131
+ '',
132
+ '<b>Commands</b>',
133
+ ' /status Health + current session',
134
+ ' /sessions List all sessions',
135
+ ' /agent Set next-message agent',
136
+ ' /model Set next-message model',
137
+ ' /pair Pair a new device',
138
+ ' /files Files touched this session',
139
+ ' /diff Pending git diff',
140
+ ' /todo Session todo list',
141
+ ' /context Tokens + cost + model',
142
+ ' /abort Stop generation',
143
+ ' /workspaces List/switch workspaces',
144
+ ' /new New session in active workspace',
145
+ ];
146
+ if (nextAgent || nextModel) {
147
+ lines.push('');
148
+ if (nextAgent)
149
+ lines.push(`<i>Next agent: ${nextAgent}</i>`);
150
+ if (nextModel)
151
+ lines.push(`<i>Next model: ${nextModel.modelID}</i>`);
152
+ }
153
+ await ctx.reply(lines.join('\n'), {
154
+ parse_mode: 'HTML',
155
+ ...inlineKeyboard([
156
+ [btn('๐Ÿ”„ Status', 'status:refresh')],
157
+ ]),
158
+ });
159
+ });
160
+ deps.bot.command('status', async (ctx) => {
161
+ const { lines, buttons } = await buildStatusCard(deps);
162
+ await ctx.reply(lines.join('\n'), {
163
+ parse_mode: 'HTML',
164
+ ...inlineKeyboard([buttons]),
165
+ });
166
+ });
167
+ deps.bot.command('sessions', async (ctx) => {
168
+ try {
169
+ const sessions = (await deps.backend.listSessionSummaries()).map(s => ({
170
+ ...s,
171
+ when: s.lastActiveAt
172
+ ? new Date(s.lastActiveAt).toLocaleString('en-US', {
173
+ month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit',
174
+ })
175
+ : 'unknown',
176
+ }));
177
+ if (sessions.length === 0) {
178
+ await ctx.reply('No sessions.', { parse_mode: 'HTML' });
179
+ return;
180
+ }
181
+ const pinned = deps.state.getLastSessionId();
182
+ const lines = ['<b>๐Ÿ“‹ Sessions</b>', ''];
183
+ for (let i = 0; i < sessions.length; i++) {
184
+ const s = sessions[i];
185
+ const pinEmoji = s.id === pinned ? '๐Ÿ“Œ ' : '';
186
+ lines.push(`${i + 1}. ${pinEmoji}<code>โ€ฆ${s.id.slice(-8)}</code>`);
187
+ lines.push(` ${esc(s.title ?? 'Untitled')} ยท ${s.when}`);
188
+ if (s.directory)
189
+ lines.push(` ๐Ÿ“‚ ${esc(s.directory.split('/').pop() || s.directory)}`);
190
+ lines.push('');
191
+ }
192
+ if (pinned) {
193
+ lines.push(`<i>๐Ÿ“Œ Pinned: โ€ฆ${pinned.slice(-8)}</i>`);
194
+ }
195
+ const rows = sessions.map(s => [
196
+ btn(`๐Ÿ“Œ Pin ${s.id.slice(-6)}`, `session:pin:${s.id}`),
197
+ ]);
198
+ await ctx.reply(lines.join('\n'), {
199
+ parse_mode: 'HTML',
200
+ ...inlineKeyboard(rows),
201
+ });
202
+ }
203
+ catch (err) {
204
+ log.error('failed to list sessions', err);
205
+ await ctx.reply(`โŒ ${esc(err.message)}`, { parse_mode: 'HTML' });
206
+ }
207
+ });
208
+ deps.bot.command('session', async (ctx) => {
209
+ const text = ctx.message && 'text' in ctx.message ? ctx.message.text : '';
210
+ const args = text ? text.split(' ').slice(1)[0]?.trim() : undefined;
211
+ if (args && args.length > 0) {
212
+ const sid = deps.state.normalizeSessionId(args);
213
+ deps.state.setPinnedSessionId(sid);
214
+ await ctx.reply(`<b>๐Ÿ“Œ Pinned</b>\n\n<code>${esc(sid)}</code>`, {
215
+ parse_mode: 'HTML',
216
+ ...inlineKeyboard([
217
+ [btn('Unpin', 'session:unpin')],
218
+ ]),
219
+ });
220
+ return;
221
+ }
222
+ const pinned = deps.state.getPinnedSessionId();
223
+ if (!pinned) {
224
+ await ctx.reply('No session pinned. Use <code>/session &lt;id&gt;</code> or pick from /sessions.', { parse_mode: 'HTML' });
225
+ return;
226
+ }
227
+ await ctx.reply(`<b>๐Ÿ“Œ Pinned session</b>\n\n<code>${pinned}</code>`, {
228
+ parse_mode: 'HTML',
229
+ ...inlineKeyboard([
230
+ [btn('Unpin', 'session:unpin')],
231
+ ]),
232
+ });
233
+ });
234
+ deps.bot.command('files', async (ctx) => {
235
+ const last = deps.state.getLastSessionId();
236
+ if (!last) {
237
+ await ctx.reply('<b>๐Ÿ“ Files</b>\n\nNo session yet. Send a message first.', { parse_mode: 'HTML' });
238
+ return;
239
+ }
240
+ try {
241
+ const cards = await deps.backend.getHistory(last, 0);
242
+ const fileEmoji = {
243
+ read: '๐Ÿ“–', write: '๐Ÿ†•', edit: 'โœ๏ธ',
244
+ };
245
+ const fileOps = new Map();
246
+ for (const card of cards) {
247
+ if (card.kind !== 'assistant')
248
+ continue;
249
+ for (const block of card.blocks) {
250
+ if (block.type === 'tool' && fileEmoji[block.tool] && block.args) {
251
+ fileOps.set(block.args, fileEmoji[block.tool]);
252
+ }
253
+ }
254
+ }
255
+ const shortId = last.slice(-8);
256
+ if (fileOps.size === 0) {
257
+ await ctx.reply(`<b>๐Ÿ“ Files โ€” โ€ฆ${shortId}</b>\n\nNo file operations recorded.`, { parse_mode: 'HTML' });
258
+ return;
259
+ }
260
+ const MAX = 15;
261
+ const entries = [...fileOps.entries()];
262
+ const shown = entries.slice(0, MAX);
263
+ const lines = [
264
+ `<b>๐Ÿ“ Files โ€” โ€ฆ${shortId}</b>`,
265
+ '',
266
+ ...shown.map(([p, emoji]) => `${emoji} <code>${shortPath(p)}</code>`),
267
+ ];
268
+ if (entries.length > MAX) {
269
+ lines.push(`\nโ€ฆand ${entries.length - MAX} more`);
270
+ }
271
+ lines.push('', `<i>${entries.length} file operation${entries.length > 1 ? 's' : ''}</i>`);
272
+ await ctx.reply(lines.join('\n'), { parse_mode: 'HTML' });
273
+ }
274
+ catch (err) {
275
+ log.error('failed to fetch files', err);
276
+ await ctx.reply(`โŒ ${esc(err.message)}`, { parse_mode: 'HTML' });
277
+ }
278
+ });
279
+ deps.bot.command('agent', async (ctx) => {
280
+ try {
281
+ const agents = await deps.backend.getAgents(deps.opencodeProject);
282
+ if (agents.length === 0) {
283
+ await ctx.reply('๐Ÿค– <b>Agent</b>\n\nNo agents configured. Add them in <code>opencode.jsonc</code>.', { parse_mode: 'HTML' });
284
+ return;
285
+ }
286
+ const nextAgent = deps.state.getNextAgent();
287
+ const lines = ['๐Ÿค– <b>Agent</b>', ''];
288
+ for (const a of agents) {
289
+ const active = a.name === nextAgent;
290
+ const modelShort = a.model.split('/').pop() ?? a.model;
291
+ const marker = active ? 'โœ“' : ' ';
292
+ const name = active ? `<b>${a.name}</b>` : a.name;
293
+ const desc = a.description ? ` <i>${a.description}</i>` : '';
294
+ lines.push(`${marker} ${name} <code>${modelShort}</code>${desc}`);
295
+ }
296
+ if (nextAgent)
297
+ lines.push('', `<i>Active override: ${nextAgent}</i>`);
298
+ const rows = agents.map(a => [btn(a.name, `agent:set:${a.name}`)]);
299
+ rows.push([btn('โœ• Clear override', 'agent:clear')]);
300
+ await ctx.reply(lines.join('\n'), {
301
+ parse_mode: 'HTML',
302
+ ...inlineKeyboard(rows),
303
+ });
304
+ }
305
+ catch (err) {
306
+ log.error('failed to list agents', err);
307
+ await ctx.reply(`โŒ ${esc(err.message)}`, { parse_mode: 'HTML' });
308
+ }
309
+ });
310
+ deps.bot.command('model', async (ctx) => {
311
+ try {
312
+ const providers = await deps.backend.getModels(deps.opencodeProject);
313
+ if (providers.length === 0) {
314
+ await ctx.reply('<b>โš™๏ธ Model</b>\n\nNo models configured.', { parse_mode: 'HTML' });
315
+ return;
316
+ }
317
+ const nextModel = deps.state.getNextModel();
318
+ const lines = ['<b>โš™๏ธ Model โ€” Select provider</b>', ''];
319
+ const rows = [];
320
+ for (const p of providers) {
321
+ const count = (p.models ?? []).length;
322
+ const hasSelected = nextModel?.providerID === p.id;
323
+ const marker = hasSelected ? 'โ—' : 'โ–ธ';
324
+ lines.push(`${marker} <b>${p.name}</b> ยท ${count} model${count !== 1 ? 's' : ''}`);
325
+ rows.push([btn(p.name, `model:pick:${p.id}`)]);
326
+ }
327
+ if (nextModel) {
328
+ lines.push('', `<i>Current override: ${nextModel.providerID}/${nextModel.modelID}</i>`);
329
+ }
330
+ rows.push([btn('โœ• Clear', 'model:clear')]);
331
+ await ctx.reply(lines.join('\n'), {
332
+ parse_mode: 'HTML',
333
+ ...inlineKeyboard(rows),
334
+ });
335
+ }
336
+ catch (err) {
337
+ log.error('failed to list models', err);
338
+ await ctx.reply(`โŒ ${esc(err.message)}`, { parse_mode: 'HTML' });
339
+ }
340
+ });
341
+ deps.bot.command('current', async (ctx) => {
342
+ const last = deps.state.getLastSessionId();
343
+ if (!last) {
344
+ await ctx.reply('<b>๐Ÿ“ No session</b>\n\nSend a message to start one.', { parse_mode: 'HTML' });
345
+ return;
346
+ }
347
+ await ctx.reply(`<b>๐Ÿ“ Current session</b>\n\n<code>${last}</code>`, {
348
+ parse_mode: 'HTML',
349
+ ...inlineKeyboard([
350
+ [btn('Unpin', 'session:unpin')],
351
+ ]),
352
+ });
353
+ });
354
+ deps.bot.command('abort', async (ctx) => {
355
+ const sid = deps.abortGeneration();
356
+ if (!sid) {
357
+ await ctx.reply('No session to abort.', { parse_mode: 'HTML' });
358
+ return;
359
+ }
360
+ try {
361
+ await deps.backend.abort(sid);
362
+ await ctx.reply(`<b>๐Ÿ›‘ Aborted</b>\n\n<code>โ€ฆ${sid.slice(-8)}</code>`, { parse_mode: 'HTML' });
363
+ }
364
+ catch (err) {
365
+ await ctx.reply(`โŒ Abort failed: ${esc(err.message)}`, { parse_mode: 'HTML' });
366
+ }
367
+ });
368
+ deps.bot.command('version', async (ctx) => {
369
+ const { version, commit, uptime, node } = getVersionInfo();
370
+ await ctx.reply([
371
+ `<b>opencode-remote-control</b> v${version}`,
372
+ `Commit: <code>${commit}</code>`,
373
+ `Uptime: ${uptime}`,
374
+ `Node: ${node}`,
375
+ ].join('\n'), { parse_mode: 'HTML' });
376
+ });
377
+ deps.bot.command('pair', async (ctx) => {
378
+ try {
379
+ const { buildPairContext, buildPairUrl } = await import('../../connectivity/pairing.js');
380
+ const { token, url } = await buildPairContext();
381
+ const pairUrl = buildPairUrl(url, token);
382
+ // The token travels in the URL fragment. Sending it over Telegram is
383
+ // acceptable: the user already trusts this bot channel for control.
384
+ await ctx.reply(`๐Ÿ”— <b>Pair a device</b>\n\nOpen this once on the device:\n<code>${pairUrl}</code>`, { parse_mode: 'HTML' });
385
+ }
386
+ catch (err) {
387
+ await ctx.reply(`โŒ ${esc(err.message)}`, { parse_mode: 'HTML' });
388
+ }
389
+ });
390
+ deps.bot.command('help', async (ctx) => {
391
+ await ctx.reply([
392
+ '<b>๐Ÿค– opencode-remote-control</b>',
393
+ '',
394
+ '<b>Commands:</b>',
395
+ ' /start Handshake + health',
396
+ ' /status Server health + session',
397
+ ' /sessions List all sessions',
398
+ ' /session Pin a session',
399
+ ' /pair Pair a device (URL + token)',
400
+ ' /files Files touched in last session',
401
+ ' /diff Pending git diff',
402
+ ' /todo Session todo list',
403
+ ' /context Tokens + cost + model',
404
+ ' /agent Set next agent',
405
+ ' /model Set next model',
406
+ ' /current Last session used',
407
+ ' /abort Stop generation',
408
+ ' /version Bot version + uptime',
409
+ ' /workspaces List/switch workspaces',
410
+ ' /new New session in active workspace',
411
+ ' /help This message',
412
+ '',
413
+ 'Send any text to relay it into opencode.',
414
+ ].join('\n'), {
415
+ parse_mode: 'HTML',
416
+ ...inlineKeyboard([
417
+ [btn('๐Ÿ”„ Check status', 'status:refresh')],
418
+ ]),
419
+ });
420
+ });
421
+ const workspacesHandler = async (ctx) => {
422
+ try {
423
+ const ws = scopeWorkspaces(deps, await deps.backend.listWorkspaces());
424
+ if (ws.length === 0) {
425
+ await ctx.reply('ๅฝ“ๅ‰ๅทฅไฝœๅŒบ่Œƒๅ›ด่ฎพ็ฝฎไธ‹ๆฒกๆœ‰ๅฏ็”จ็š„ๅทฅไฝœๅŒบ๏ผˆM9 ๅทฅไฝœๅŒบ่ฎฟ้—ฎ่Œƒๅ›ด๏ผ‰ใ€‚', { parse_mode: 'HTML' });
426
+ return;
427
+ }
428
+ const active = deps.state.getActiveWorkspace();
429
+ const lines = ['<b>๐Ÿ—‚ Workspaces</b>', ''];
430
+ for (const w of ws.slice(0, 20)) {
431
+ const mark = w.directory === active ? '๐Ÿ“ ' : '';
432
+ lines.push(`${mark}<b>${w.name}</b> ยท ${w.sessionCount} session${w.sessionCount === 1 ? '' : 's'}`);
433
+ lines.push(` <code>${w.directory}</code>`);
434
+ }
435
+ const rows = ws.slice(0, 20).map((w) => [btn(`๐Ÿ“‚ ${w.name}`, `ws:set:${wsToken(w.directory)}`)]);
436
+ await ctx.reply(lines.join('\n'), { parse_mode: 'HTML', ...inlineKeyboard(rows) });
437
+ }
438
+ catch (err) {
439
+ await ctx.reply(`โŒ ${esc(err.message)}`, { parse_mode: 'HTML' });
440
+ }
441
+ };
442
+ deps.bot.command('workspaces', workspacesHandler);
443
+ // grinev ๅ‘ฝไปคๅๅฏน้ฝ๏ผš/projects = /workspaces
444
+ deps.bot.command('projects', workspacesHandler);
445
+ deps.bot.callbackQuery(/^ws:set:(.+)$/, async (ctx) => {
446
+ const dir = wsTokens.get(ctx.match[1]);
447
+ if (!dir) {
448
+ await ctx.answerCallbackQuery('Stale โ€” re-run /workspaces');
449
+ return;
450
+ }
451
+ deps.state.setActiveWorkspace(dir);
452
+ await ctx.answerCallbackQuery(`Workspace โ†’ ${dir.split('/').pop()}`);
453
+ try {
454
+ await ctx.editMessageText(`๐Ÿ“ <b>Active workspace</b>\n\n<code>${dir}</code>\n\nUse /new to start a session here.`, { parse_mode: 'HTML' });
455
+ }
456
+ catch { /* ignore */ }
457
+ });
458
+ deps.bot.command('workspaces', workspacesHandler);
459
+ // grinev ๅ‘ฝไปคๅๅฏน้ฝ๏ผš/projects = /workspaces
460
+ deps.bot.command('projects', workspacesHandler);
461
+ // grinev ๅฏน้ฝ๏ผš/detach = ๅ–ๆถˆ่ทŸ้šๅฝ“ๅ‰ไผš่ฏ๏ผˆ็ญ‰ไปท unpin๏ผ‰
462
+ deps.bot.command('detach', async (ctx) => {
463
+ const pinned = deps.state.getPinnedSessionId();
464
+ deps.state.setPinnedSessionId(undefined);
465
+ await ctx.reply(pinned ? `๐Ÿ”“ ๅทฒๅ–ๆถˆ่ทŸ้š โ€ฆ${pinned.slice(-8)}` : 'โ„น๏ธ ๅฝ“ๅ‰ๆฒกๆœ‰่ทŸ้š็š„ไผš่ฏ');
466
+ });
467
+ // grinev ๅฏน้ฝ๏ผš/commands = ๅˆ—ๅ‡บ opencode ่‡ชๅฎšไน‰ๅ‘ฝไปค
468
+ deps.bot.command('commands', async (ctx) => {
469
+ try {
470
+ const cmds = await deps.backend.listCommands();
471
+ if (cmds.length === 0) {
472
+ await ctx.reply('ๆฒกๆœ‰ๅทฒ้…็ฝฎ็š„่‡ชๅฎšไน‰ๅ‘ฝไปคใ€‚');
473
+ return;
474
+ }
475
+ const lines = ['<b>โŒ˜ ่‡ชๅฎšไน‰ๅ‘ฝไปค</b>'];
476
+ for (const c of cmds.slice(0, 20))
477
+ lines.push(`โ€ข <b>/${esc(c.name)}</b> โ€” ${esc(c.description ?? '')}`);
478
+ if (cmds.length > 20)
479
+ lines.push(`โ€ฆ ๅ…ฑ ${cmds.length} ๆก`);
480
+ await ctx.reply(lines.join('\n'), { parse_mode: 'HTML' });
481
+ }
482
+ catch (err) {
483
+ await ctx.reply(`โŒ ${esc(err.message)}`, { parse_mode: 'HTML' });
484
+ }
485
+ });
486
+ // grinev ๅฏน้ฝ๏ผš/mcps = MCP ๆœๅŠกๅ™จ็Šถๆ€
487
+ deps.bot.command('mcps', async (ctx) => {
488
+ try {
489
+ const mcps = await deps.backend.getMcp(deps.opencodeProject);
490
+ if (mcps.length === 0) {
491
+ await ctx.reply('ๆœช้…็ฝฎ MCP ๆœๅŠกๅ™จใ€‚');
492
+ return;
493
+ }
494
+ const lines = ['<b>๐Ÿ”Œ MCP</b>'];
495
+ for (const m of mcps)
496
+ lines.push(`โ€ข <b>${esc(m.name)}</b> โ€” ${m.status === 'configured' ? 'โœ… ๅทฒ้…็ฝฎ' : 'โšช ๆœชๅฏ็”จ'}`);
497
+ await ctx.reply(lines.join('\n'), { parse_mode: 'HTML' });
498
+ }
499
+ catch (err) {
500
+ await ctx.reply(`โŒ ${esc(err.message)}`, { parse_mode: 'HTML' });
501
+ }
502
+ });
503
+ // grinev ๅฏน้ฝ๏ผš/messages = ๅฝ“ๅ‰ไผš่ฏๆœ€่ฟ‘ๆถˆๆฏ
504
+ deps.bot.command('messages', async (ctx) => {
505
+ try {
506
+ const sid = deps.state.getPinnedSessionId() ?? deps.state.getLastSessionId();
507
+ if (!sid) {
508
+ await ctx.reply('ๆฒกๆœ‰ๆดปๅŠจไผš่ฏใ€‚');
509
+ return;
510
+ }
511
+ const cards = await deps.backend.getHistory(sid, 6);
512
+ const lines = ['<b>๐Ÿ•˜ ๆœ€่ฟ‘ๆถˆๆฏ</b>'];
513
+ for (const c of cards) {
514
+ if (c.kind === 'user') {
515
+ lines.push(`๐Ÿง‘ ${esc(c.text?.slice(0, 80) ?? '')}`);
516
+ }
517
+ else if (c.kind === 'assistant') {
518
+ for (const b of c.blocks ?? []) {
519
+ if (b.type === 'text' && b.text) {
520
+ lines.push(`๐Ÿค– ${esc(b.text.slice(0, 80))}`);
521
+ break;
522
+ }
523
+ }
524
+ }
525
+ }
526
+ await ctx.reply(lines.join('\n'), { parse_mode: 'HTML' });
527
+ }
528
+ catch (err) {
529
+ await ctx.reply(`โŒ ${esc(err.message)}`, { parse_mode: 'HTML' });
530
+ }
531
+ });
532
+ // โ”€โ”€ P2c parity: subagent visibility (web plan-card C1) โ”€โ”€
533
+ deps.bot.command('subs', async (ctx) => {
534
+ try {
535
+ const sid = deps.state.getPinnedSessionId() ?? deps.state.getLastSessionId();
536
+ if (!sid) {
537
+ await ctx.reply('ๆฒกๆœ‰ๆดปๅŠจไผš่ฏใ€‚');
538
+ return;
539
+ }
540
+ const subs = await deps.backend.getSubagents?.(sid);
541
+ if (!subs) {
542
+ await ctx.reply('ๅฝ“ๅ‰ๅŽ็ซฏไธๆ”ฏๆŒๅญไปฃ็†ๆŸฅ่ฏขใ€‚');
543
+ return;
544
+ }
545
+ if (subs.length === 0) {
546
+ await ctx.reply('่ฏฅไผš่ฏๆฒกๆœ‰ๅญไปฃ็†ใ€‚');
547
+ return;
548
+ }
549
+ const lines = [`<b>๐Ÿงฉ ๅญไปฃ็† ยท ${subs.length}</b>`];
550
+ const kb = new InlineKeyboard();
551
+ for (const s of subs.slice(0, 8)) {
552
+ const prog = s.total > 0 ? ` ยท ${s.done}/${s.total}` : '';
553
+ lines.push(`โ€ข <b>${esc(s.title || 'โ€ฆ' + s.id.slice(-6))}</b>${prog}`);
554
+ try {
555
+ const { buildPairContext, buildPairUrl } = await import('../../connectivity/pairing.js');
556
+ const { token, url } = await buildPairContext();
557
+ kb.url(`๐Ÿ“ค ๆ‰“ๅผ€ ${s.title.slice(0, 16) || 'โ€ฆ' + s.id.slice(-6)}`, buildPairUrl(url, token).replace(/#.*$/, '') + `/${s.id}`).row();
558
+ }
559
+ catch { /* link build is best-effort */ }
560
+ }
561
+ await ctx.reply(lines.join('\n'), { parse_mode: 'HTML', reply_markup: kb });
562
+ }
563
+ catch (err) {
564
+ await ctx.reply(`โŒ ${esc(err.message)}`, { parse_mode: 'HTML' });
565
+ }
566
+ });
567
+ // โ”€โ”€ P2c parity: session-level mode switch (web SessionControls C5) โ”€โ”€
568
+ const modeTokens = new Map();
569
+ let modeSeq = 0;
570
+ deps.bot.command('mode', async (ctx) => {
571
+ try {
572
+ const sid = deps.state.getPinnedSessionId() ?? deps.state.getLastSessionId();
573
+ if (!sid) {
574
+ await ctx.reply('ๆฒกๆœ‰ๆดปๅŠจไผš่ฏใ€‚');
575
+ return;
576
+ }
577
+ const controls = await deps.backend.getControls?.(sid);
578
+ const options = controls?.mode?.options ?? [];
579
+ if (options.length === 0) {
580
+ await ctx.reply('ๅฝ“ๅ‰ๅŽ็ซฏๆฒกๆœ‰ๅฏๅˆ‡ๆข็š„ modeใ€‚');
581
+ return;
582
+ }
583
+ const kb = new InlineKeyboard();
584
+ for (const o of options) {
585
+ const tok = ++modeSeq;
586
+ modeTokens.set(tok, { sid, modeId: o.id });
587
+ kb.text(`${o.id === controls?.mode?.current ? '๐Ÿ“ ' : ''}${o.name || o.id}`, `tmode:${tok}`).row();
588
+ }
589
+ await ctx.reply('<b>๐ŸŽš ไผš่ฏๆจกๅผ</b>', { parse_mode: 'HTML', reply_markup: kb });
590
+ }
591
+ catch (err) {
592
+ await ctx.reply(`โŒ ${esc(err.message)}`, { parse_mode: 'HTML' });
593
+ }
594
+ });
595
+ deps.bot.callbackQuery(/^tmode:(\d+)$/, async (ctx) => {
596
+ const t = modeTokens.get(Number(ctx.match[1]));
597
+ if (!t) {
598
+ await ctx.answerCallbackQuery('ๅทฒ่ฟ‡ๆœŸ โ€” ้‡ๆ–ฐ /mode');
599
+ return;
600
+ }
601
+ try {
602
+ await deps.backend.setMode?.(t.sid, t.modeId);
603
+ await ctx.answerCallbackQuery(`ๅทฒๅˆ‡ๆข โ†’ ${t.modeId}`);
604
+ try {
605
+ await ctx.editMessageText(`๐ŸŽš ๅทฒๅˆ‡ๆข โ†’ <b>${esc(t.modeId)}</b>`, { parse_mode: 'HTML' });
606
+ }
607
+ catch { }
608
+ }
609
+ catch (err) {
610
+ await ctx.answerCallbackQuery(`ๅˆ‡ๆขๅคฑ่ดฅ๏ผš${err.message.slice(0, 60)}`);
611
+ }
612
+ });
613
+ // โ”€โ”€ P2c parity: cleanup finished subagent sessions (web C4) โ”€โ”€
614
+ deps.bot.command('cleanup', async (ctx) => {
615
+ try {
616
+ const all = await deps.backend.listSessions();
617
+ const children = all.filter((s) => s.parentID);
618
+ let deleted = 0;
619
+ for (const c of children) {
620
+ try {
621
+ await deps.backend.deleteSession(c.id);
622
+ deleted += 1;
623
+ }
624
+ catch { /* skip */ }
625
+ }
626
+ await ctx.reply(`๐Ÿงน ๅทฒๆธ…็† ${deleted} ไธชๅญไปฃ็†ไผš่ฏใ€‚`);
627
+ }
628
+ catch (err) {
629
+ await ctx.reply(`โŒ ${esc(err.message)}`, { parse_mode: 'HTML' });
630
+ }
631
+ });
632
+ // โ”€โ”€ M9: /channels โ€” channel status mirror (config UI lives on web) โ”€โ”€
633
+ deps.bot.command('channels', async (ctx) => {
634
+ try {
635
+ const ch = deps.channels?.()?.enabled;
636
+ const tgOn = ch === undefined ? true : ch;
637
+ const lines = [
638
+ '<b>๐Ÿค– ๆœบๅ™จไบบ / ้€š้“</b>',
639
+ tgOn
640
+ ? 'โ€ข Telegram โ€” โœ… ๅทฒๅฏ็”จ๏ผˆweb ็ซฏใ€Œๆœบๅ™จไบบใ€้ขๆฟๅฏ้…็ฝฎ๏ผ‰'
641
+ : 'โ€ข Telegram โ€” โ›” ๅทฒๅœ็”จ๏ผˆchannels.json๏ผ‰',
642
+ 'โ€ข ๅพฎไฟก โ€” โณ ๅพ…ๆŽฅๅ…ฅ๏ผˆๅ‡ญ่ฏๅˆฐไฝๅŽๅฏ็”จ๏ผ‰',
643
+ 'โ€ข Lark โ€” โณ ๅพ…ๆŽฅๅ…ฅ',
644
+ ];
645
+ await ctx.reply(lines.join('\n'), { parse_mode: 'HTML' });
646
+ }
647
+ catch (err) {
648
+ await ctx.reply(`โŒ ${esc(err.message)}`, { parse_mode: 'HTML' });
649
+ }
650
+ });
651
+ // โ”€โ”€ M8: /skills /ls /open /worktree (web parity with the Skills/Files panels) โ”€โ”€
652
+ const skillsCache = [];
653
+ const lsDirs = new Map();
654
+ const lsFiles = new Map();
655
+ let lsSeq = 0;
656
+ const wtTokens = new Map();
657
+ let wtSeq = 0;
658
+ deps.bot.command('skills', async (ctx) => {
659
+ try {
660
+ const list = await deps.backend.getSkills?.(deps.opencodeProject);
661
+ if (!list || list.length === 0) {
662
+ await ctx.reply('ๆฒกๆœ‰ๅฏ็”จ็š„ๆŠ€่ƒฝใ€‚');
663
+ return;
664
+ }
665
+ skillsCache.length = 0;
666
+ skillsCache.push(...list);
667
+ const PAGE = 6;
668
+ const pages = Math.max(1, Math.ceil(skillsCache.length / PAGE));
669
+ const p = 0;
670
+ const lines = [`<b>๐Ÿงฉ Skills</b> ยท ${p + 1}/${pages} ้กต ยท ๅ…ฑ ${skillsCache.length}`];
671
+ const kb = new InlineKeyboard();
672
+ for (const s of skillsCache.slice(p * PAGE, p * PAGE + PAGE)) {
673
+ lines.push(`โ€ข <b>${esc(s.name)}</b> โ€” ${esc((s.description ?? '').slice(0, 90))}`);
674
+ }
675
+ if (pages > 1) {
676
+ if (p > 0)
677
+ kb.text('โ—€๏ธ', `sk:page:${p - 1}`);
678
+ kb.text(`${p + 1}/${pages}`, 'menu:noop');
679
+ if (p < pages - 1)
680
+ kb.text('โ–ถ๏ธ', `sk:page:${p + 1}`);
681
+ }
682
+ await ctx.reply(lines.join('\n'), { parse_mode: 'HTML', reply_markup: kb });
683
+ }
684
+ catch (err) {
685
+ await ctx.reply(`โŒ ${esc(err.message)}`, { parse_mode: 'HTML' });
686
+ }
687
+ });
688
+ deps.bot.callbackQuery(/^sk:page:(\d+)$/, async (ctx) => {
689
+ const p = Math.max(0, Number(ctx.match[1]));
690
+ const PAGE = 6;
691
+ const pages = Math.max(1, Math.ceil(skillsCache.length / PAGE));
692
+ const pp = Math.min(p, pages - 1);
693
+ const lines = [`<b>๐Ÿงฉ Skills</b> ยท ${pp + 1}/${pages} ้กต ยท ๅ…ฑ ${skillsCache.length}`];
694
+ const kb = new InlineKeyboard();
695
+ for (const s of skillsCache.slice(pp * PAGE, pp * PAGE + PAGE)) {
696
+ lines.push(`โ€ข <b>${esc(s.name)}</b> โ€” ${esc((s.description ?? '').slice(0, 90))}`);
697
+ }
698
+ if (pages > 1) {
699
+ if (pp > 0)
700
+ kb.text('โ—€๏ธ', `sk:page:${pp - 1}`);
701
+ kb.text(`${pp + 1}/${pages}`, 'menu:noop');
702
+ if (pp < pages - 1)
703
+ kb.text('โ–ถ๏ธ', `sk:page:${pp + 1}`);
704
+ }
705
+ await ctx.answerCallbackQuery();
706
+ try {
707
+ await ctx.editMessageText(lines.join('\n'), { parse_mode: 'HTML', reply_markup: kb });
708
+ }
709
+ catch { }
710
+ });
711
+ deps.bot.command('ls', async (ctx) => {
712
+ try {
713
+ const arg = (ctx.message?.text ?? '').replace(/^\/ls\s*/, '').trim();
714
+ const path = arg || '.';
715
+ const files = await deps.backend.listFiles?.(deps.opencodeProject, path);
716
+ if (!files) {
717
+ await ctx.reply('ๅฝ“ๅ‰ๅŽ็ซฏไธๆ”ฏๆŒๆ–‡ไปถๆต่งˆใ€‚');
718
+ return;
719
+ }
720
+ if (files.length === 0) {
721
+ await ctx.reply(`๐Ÿ“‚ ${path}๏ผš็ฉบ็›ฎๅฝ•ใ€‚`);
722
+ return;
723
+ }
724
+ const lines = [`<b>๐Ÿ“‚ ${esc(path)}</b> ยท ๅ…ฑ ${files.length}`];
725
+ const kb = new InlineKeyboard();
726
+ if (path !== '.') {
727
+ const upTok = ++lsSeq;
728
+ lsDirs.set(upTok, path);
729
+ kb.text('โ†ฐ ..', `ls:up:${upTok}`);
730
+ }
731
+ for (const f of files.slice(0, 20)) {
732
+ if (f.type === 'directory') {
733
+ const tok = ++lsSeq;
734
+ lsDirs.set(tok, f.path);
735
+ kb.text(`๐Ÿ“ ${f.name}`, `ls:dir:${tok}`).row();
736
+ }
737
+ else {
738
+ const tok = ++lsSeq;
739
+ lsFiles.set(tok, f.path);
740
+ kb.text(`๐Ÿ“„ ${f.name}`, `ls:cat:${tok}`);
741
+ }
742
+ }
743
+ if (files.length > 20)
744
+ lines.push(`โ€ฆ ๅ…ถไฝ™ ${files.length - 20} ้กนๆœชๆ˜พ็คบ`);
745
+ await ctx.reply(lines.join('\n'), { parse_mode: 'HTML', reply_markup: kb });
746
+ }
747
+ catch (err) {
748
+ await ctx.reply(`โŒ ${esc(err.message)}`, { parse_mode: 'HTML' });
749
+ }
750
+ });
751
+ deps.bot.callbackQuery(/^ls:dir:(\d+)$/, async (ctx) => {
752
+ const path = lsDirs.get(Number(ctx.match[1]));
753
+ if (!path) {
754
+ await ctx.answerCallbackQuery('ๅทฒ่ฟ‡ๆœŸ โ€” ้‡ๆ–ฐ /ls');
755
+ return;
756
+ }
757
+ await ctx.answerCallbackQuery();
758
+ try {
759
+ const files = (await deps.backend.listFiles?.(deps.opencodeProject, path)) ?? [];
760
+ const lines = [`<b>๐Ÿ“‚ ${esc(path)}</b> ยท ๅ…ฑ ${files.length}`];
761
+ const kb = new InlineKeyboard();
762
+ if (path !== '.') {
763
+ const upTok = ++lsSeq;
764
+ lsDirs.set(upTok, path);
765
+ kb.text('โ†ฐ ..', `ls:up:${upTok}`);
766
+ }
767
+ for (const f of files.slice(0, 20)) {
768
+ if (f.type === 'directory') {
769
+ const tok = ++lsSeq;
770
+ lsDirs.set(tok, f.path);
771
+ kb.text(`๐Ÿ“ ${f.name}`, `ls:dir:${tok}`).row();
772
+ }
773
+ else {
774
+ const tok = ++lsSeq;
775
+ lsFiles.set(tok, f.path);
776
+ kb.text(`๐Ÿ“„ ${f.name}`, `ls:cat:${tok}`);
777
+ }
778
+ }
779
+ try {
780
+ await ctx.editMessageText(lines.join('\n'), { parse_mode: 'HTML', reply_markup: kb });
781
+ }
782
+ catch { }
783
+ }
784
+ catch (err) {
785
+ await ctx.reply(`โŒ ${esc(err.message)}`, { parse_mode: 'HTML' });
786
+ }
787
+ });
788
+ // Directory navigation re-runs /ls logic via the registered command flow โ€”
789
+ // simplest correct path: synthesize the message through the same handler.
790
+ deps.bot.callbackQuery(/^ls:cat:(\d+)$/, async (ctx) => {
791
+ const path = lsFiles.get(Number(ctx.match[1]));
792
+ if (!path) {
793
+ await ctx.answerCallbackQuery('ๅทฒ่ฟ‡ๆœŸ โ€” ้‡ๆ–ฐ /ls');
794
+ return;
795
+ }
796
+ await ctx.answerCallbackQuery();
797
+ try {
798
+ const f = await deps.backend.readFile?.(deps.opencodeProject, path);
799
+ if (!f) {
800
+ await ctx.reply('่ฏปๅ–ๅคฑ่ดฅใ€‚');
801
+ return;
802
+ }
803
+ if (f.type !== 'text') {
804
+ await ctx.reply(`๐Ÿ”’ ${esc(path)}๏ผšไบŒ่ฟ›ๅˆถๆ–‡ไปถ๏ผŒไธ้ข„่งˆใ€‚`);
805
+ return;
806
+ }
807
+ const body = f.content.length > 800 ? f.content.slice(0, 800) + '\nโ€ฆ' : f.content;
808
+ await ctx.reply(`<b>๐Ÿ“„ ${esc(path)}</b>\n<pre>${esc(body)}</pre>`, { parse_mode: 'HTML' });
809
+ }
810
+ catch (err) {
811
+ await ctx.reply(`โŒ ${esc(err.message)}`, { parse_mode: 'HTML' });
812
+ }
813
+ });
814
+ deps.bot.callbackQuery(/^ls:up:(\d+)$/, async (ctx) => {
815
+ const cur = lsDirs.get(Number(ctx.match[1]));
816
+ if (!cur) {
817
+ await ctx.answerCallbackQuery('ๅทฒ่ฟ‡ๆœŸ');
818
+ return;
819
+ }
820
+ const parent = cur.includes('/') ? cur.replace(/\/[^/]+\/?$/, '') || '.' : '.';
821
+ await ctx.answerCallbackQuery();
822
+ try {
823
+ const files = (await deps.backend.listFiles?.(deps.opencodeProject, parent)) ?? [];
824
+ const lines = [`<b>๐Ÿ“‚ ${esc(parent)}</b> ยท ๅ…ฑ ${files.length}`];
825
+ const kb = new InlineKeyboard();
826
+ if (parent !== '.') {
827
+ const upTok = ++lsSeq;
828
+ lsDirs.set(upTok, parent);
829
+ kb.text('โ†ฐ ..', `ls:up:${upTok}`);
830
+ }
831
+ for (const f of files.slice(0, 20)) {
832
+ if (f.type === 'directory') {
833
+ const tok = ++lsSeq;
834
+ lsDirs.set(tok, f.path);
835
+ kb.text(`๐Ÿ“ ${f.name}`, `ls:dir:${tok}`).row();
836
+ }
837
+ else {
838
+ const tok = ++lsSeq;
839
+ lsFiles.set(tok, f.path);
840
+ kb.text(`๐Ÿ“„ ${f.name}`, `ls:cat:${tok}`);
841
+ }
842
+ }
843
+ await ctx.editMessageText(lines.join('\n'), { parse_mode: 'HTML', reply_markup: kb });
844
+ }
845
+ catch (err) {
846
+ await ctx.reply(`โŒ ${esc(err.message)}`, { parse_mode: 'HTML' });
847
+ }
848
+ });
849
+ deps.bot.command('open', async (ctx) => {
850
+ const arg = (ctx.message?.text ?? '').replace(/^\/open\s*/, '').trim();
851
+ if (!arg) {
852
+ await ctx.reply('็”จๆณ•๏ผš/open <ๆ–‡ไปถ่ทฏๅพ„>๏ผˆ็›ธๅฏนๅฝ“ๅ‰ๅทฅไฝœๅŒบ๏ผŒๅฆ‚ src/index.ts๏ผ‰');
853
+ return;
854
+ }
855
+ try {
856
+ const f = await deps.backend.readFile?.(deps.opencodeProject, arg);
857
+ if (!f) {
858
+ await ctx.reply('่ฏปๅ–ๅคฑ่ดฅใ€‚');
859
+ return;
860
+ }
861
+ if (f.type !== 'text') {
862
+ await ctx.reply(`๐Ÿ”’ ${esc(arg)}๏ผšไบŒ่ฟ›ๅˆถๆ–‡ไปถ๏ผŒไธ้ข„่งˆใ€‚`);
863
+ return;
864
+ }
865
+ const body = f.content.length > 800 ? f.content.slice(0, 800) + '\nโ€ฆ' : f.content;
866
+ await ctx.reply(`<b>๐Ÿ“„ ${esc(arg)}</b>\n<pre>${esc(body)}</pre>`, { parse_mode: 'HTML' });
867
+ }
868
+ catch (err) {
869
+ await ctx.reply(`โŒ ${esc(err.message)}`, { parse_mode: 'HTML' });
870
+ }
871
+ });
872
+ deps.bot.command('worktree', async (ctx) => {
873
+ try {
874
+ const arg = (ctx.message?.text ?? '').replace(/^\/worktree\s*/, '').trim();
875
+ if (arg.startsWith('=')) {
876
+ const name = arg.slice(1).trim();
877
+ if (!name) {
878
+ await ctx.reply('็”จๆณ•๏ผš/worktree = <ๅ็งฐ>');
879
+ return;
880
+ }
881
+ const dir = deps.state.getActiveWorkspace() || deps.opencodeProject || '';
882
+ const created = await deps.backend.createWorktreeSandboxes?.(dir, name);
883
+ if (!created) {
884
+ await ctx.reply('โŒ ๅˆ›ๅปบๅคฑ่ดฅ๏ผˆexperimental ๆŽฅๅฃ๏ผ‰ใ€‚');
885
+ return;
886
+ }
887
+ await ctx.reply(`๐ŸŒฟ ๅทฒๅˆ›ๅปบ worktree๏ผš<b>${esc(created.name)}</b>`);
888
+ return;
889
+ }
890
+ if (arg.startsWith('rm ')) {
891
+ const name = arg.slice(3).trim();
892
+ if (!name) {
893
+ await ctx.reply('็”จๆณ•๏ผš/worktree rm <ๅ็งฐ>');
894
+ return;
895
+ }
896
+ const tok = ++wtSeq;
897
+ wtTokens.set(tok, name);
898
+ const kb = new InlineKeyboard()
899
+ .text('๐Ÿ—‘ ็กฎ่ฎคๅˆ ้™ค', `wt:rm:${tok}`)
900
+ .text('ๅ–ๆถˆ', 'menu:noop');
901
+ await ctx.reply(`โš ๏ธ ๅˆ ้™ค worktree <b>${esc(name)}</b>๏ผŸ`, { parse_mode: 'HTML', reply_markup: kb });
902
+ return;
903
+ }
904
+ const list = await deps.backend.listWorktreeSandboxes?.(deps.opencodeProject);
905
+ if (!list) {
906
+ await ctx.reply('ๅฝ“ๅ‰ๅŽ็ซฏไธๆ”ฏๆŒ worktree ๆŸฅ่ฏขใ€‚');
907
+ return;
908
+ }
909
+ if (list.length === 0) {
910
+ await ctx.reply('๐ŸŒฟ ๆš‚ๆ—  worktreeใ€‚ๆ–ฐๅปบ๏ผš/worktree = <ๅ็งฐ>๏ผˆbeta๏ผ‰');
911
+ return;
912
+ }
913
+ const lines = ['<b>๐ŸŒฟ Worktrees</b> ยท beta'];
914
+ for (const w of list)
915
+ lines.push(`โ€ข ${esc(w.name)}${w.directory ? ` โ€” <code>${esc(w.directory)}</code>` : ''}`);
916
+ lines.push('ๆ–ฐๅปบ๏ผš/worktree = <ๅ็งฐ> ยท ๅˆ ้™ค๏ผš/worktree rm <ๅ็งฐ>');
917
+ await ctx.reply(lines.join('\n'), { parse_mode: 'HTML' });
918
+ }
919
+ catch (err) {
920
+ await ctx.reply(`โŒ ${esc(err.message)}`, { parse_mode: 'HTML' });
921
+ }
922
+ });
923
+ deps.bot.callbackQuery(/^wt:rm:(\d+)$/, async (ctx) => {
924
+ const name = wtTokens.get(Number(ctx.match[1]));
925
+ if (!name) {
926
+ await ctx.answerCallbackQuery('ๅทฒ่ฟ‡ๆœŸ โ€” ้‡ๆ–ฐๆ‰ง่กŒ /worktree rm');
927
+ return;
928
+ }
929
+ const ok = (await deps.backend.removeWorktreeSandboxes?.(deps.state.getActiveWorkspace() || deps.opencodeProject || '', name)) ?? false;
930
+ await ctx.answerCallbackQuery(ok ? '๐Ÿ—‘ ๅทฒๅˆ ้™ค' : 'ๅˆ ้™คๅคฑ่ดฅ');
931
+ try {
932
+ await ctx.editMessageText(ok ? `๐Ÿ—‘ worktree <b>${esc(name)}</b> ๅทฒๅˆ ้™คใ€‚` : 'ๅˆ ้™คๅคฑ่ดฅใ€‚', { parse_mode: 'HTML' });
933
+ }
934
+ catch { }
935
+ });
936
+ deps.bot.command('new', async (ctx) => {
937
+ const dir = deps.state.getActiveWorkspace();
938
+ if (!dir) {
939
+ await ctx.reply('No active workspace. Use /workspaces first.', { parse_mode: 'HTML' });
940
+ return;
941
+ }
942
+ if (!workspaceAllowed(deps, dir)) {
943
+ await ctx.reply('ๅฝ“ๅ‰ๅทฅไฝœๅŒบไธๅœจๆœบๅ™จไบบ็š„่ฎฟ้—ฎ่Œƒๅ›ดๅ†…๏ผˆM9 ๅทฅไฝœๅŒบ่ฎฟ้—ฎ่Œƒๅ›ด๏ผ‰ใ€‚', { parse_mode: 'HTML' });
944
+ return;
945
+ }
946
+ try {
947
+ const text = ctx.message && 'text' in ctx.message ? ctx.message.text.split(' ').slice(1).join(' ').trim() : '';
948
+ const { id } = await deps.backend.createSession({ directory: dir, ...(text ? { title: text.slice(0, 60) } : {}) });
949
+ deps.state.setPinnedSessionId(id);
950
+ await ctx.reply(`๐Ÿ†• <b>New session</b> in <code>${dir.split('/').pop()}</code>\n<code>โ€ฆ${id.slice(-8)}</code> (pinned). Send a message to start.`, { parse_mode: 'HTML' });
951
+ }
952
+ catch (err) {
953
+ await ctx.reply(`โŒ ${esc(err.message)}`, { parse_mode: 'HTML' });
954
+ }
955
+ });
956
+ deps.bot.command('rename', async (ctx) => {
957
+ const text = ctx.message && 'text' in ctx.message ? ctx.message.text.split(' ').slice(1).join(' ').trim() : '';
958
+ const sid = deps.state.getPinnedSessionId() ?? deps.state.getLastSessionId();
959
+ if (!sid) {
960
+ await ctx.reply('No active session. Pin one with /sessions first.', { parse_mode: 'HTML' });
961
+ return;
962
+ }
963
+ if (!text) {
964
+ await ctx.reply('Usage: <code>/rename New Title</code>', { parse_mode: 'HTML' });
965
+ return;
966
+ }
967
+ try {
968
+ await deps.backend.renameSession(sid, text.slice(0, 100));
969
+ await ctx.reply(`โœ๏ธ Renamed <code>โ€ฆ${sid.slice(-8)}</code> โ†’ <b>${esc(text.slice(0, 100))}</b>`, { parse_mode: 'HTML' });
970
+ }
971
+ catch (err) {
972
+ await ctx.reply(`โŒ ${esc(err.message)}`, { parse_mode: 'HTML' });
973
+ }
974
+ });
975
+ // โ”€โ”€ P2b-M7: scheduled tasks (cross-channel; engine lives in core) โ”€โ”€
976
+ deps.bot.command('tasks', async (ctx) => {
977
+ if (!deps.scheduler) {
978
+ await ctx.reply('โฐ ๅฎšๆ—ถไปปๅŠกๆœชๅฏ็”จ');
979
+ return;
980
+ }
981
+ await renderTasks(ctx);
982
+ });
983
+ // grinev ๅ‘ฝไปคๅๅฏน้ฝ๏ผš/tasklist = /tasks
984
+ deps.bot.command('tasklist', async (ctx) => { await renderTasks(ctx); });
985
+ deps.bot.command('task', async (ctx) => {
986
+ if (!deps.scheduler) {
987
+ await ctx.reply('โฐ ๅฎšๆ—ถไปปๅŠกๆœชๅฏ็”จ');
988
+ return;
989
+ }
990
+ const sched = deps.scheduler;
991
+ const msgText = (ctx.message && 'text' in ctx.message ? ctx.message.text : '') ?? '';
992
+ const body = msgText.trim().replace(/^\/task\s*/, '');
993
+ // /task every <N> min <prompt> | /task daily HH:MM <prompt>
994
+ const every = body.match(/^every\s+(\d+)\s*(?:m|min|ๅˆ†้’Ÿ)?\s+([\s\S]+)$/i);
995
+ const daily = body.match(/^daily\s+([01]\d|2[0-3]):[0-5]\d\s+([\s\S]+)$/i);
996
+ if (every) {
997
+ const s = sched.add({ prompt: every[2], spec: { kind: 'every', minutes: Number(every[1]) }, name: every[2].slice(0, 24) });
998
+ await ctx.reply(s ? `โฐ ๅทฒๅˆ›ๅปบ๏ผšๆฏ ${every[1]} ๅˆ†้’Ÿ\n<code>${esc(s.prompt.slice(0, 80))}</code>\nID: ${s.id}` : 'ๅˆ›ๅปบๅคฑ่ดฅ', { parse_mode: 'HTML' });
999
+ return;
1000
+ }
1001
+ if (daily) {
1002
+ const time = body.match(/daily\s+([01]\d|2[0-3]):[0-5]\d/i)[1];
1003
+ const s = sched.add({ prompt: daily[2], spec: { kind: 'daily', time }, name: daily[2].slice(0, 24) });
1004
+ await ctx.reply(s ? `โฐ ๅทฒๅˆ›ๅปบ๏ผšๆฏๅคฉ ${time}\n<code>${esc(s.prompt.slice(0, 80))}</code>\nID: ${s.id}` : 'ๅˆ›ๅปบๅคฑ่ดฅ', { parse_mode: 'HTML' });
1005
+ return;
1006
+ }
1007
+ await ctx.reply('็”จๆณ•๏ผš/task every 30m <ๆ็คบ่ฏ>\n /task daily 09:00 <ๆ็คบ่ฏ>');
1008
+ });
1009
+ deps.bot.command('taskdel', async (ctx) => {
1010
+ const sched = deps.scheduler;
1011
+ if (!sched) {
1012
+ await ctx.reply('โฐ ๅฎšๆ—ถไปปๅŠกๆœชๅฏ็”จ');
1013
+ return;
1014
+ }
1015
+ const id = (ctx.message && 'text' in ctx.message ? ctx.message.text ?? '' : '').replace(/^\/taskdel\s*/, '').trim();
1016
+ if (!id) {
1017
+ await ctx.reply('็”จๆณ•๏ผš/taskdel <ID>');
1018
+ return;
1019
+ }
1020
+ const ok = sched.remove(id);
1021
+ await ctx.reply(ok ? `๐Ÿ—‘ ๅทฒๅˆ ้™ค ${id}` : `ๆœชๆ‰พๅˆฐ ${id}`);
1022
+ });
1023
+ const renderTasks = async (ctx) => {
1024
+ const list = deps.scheduler.list();
1025
+ if (list.length === 0) {
1026
+ await ctx.reply('โฐ ๆฒกๆœ‰ๅฎšๆ—ถไปปๅŠกใ€‚็”จ /task ๅˆ›ๅปบ๏ผš\n/task every 30m <ๆ็คบ่ฏ>\n/task daily 09:00 <ๆ็คบ่ฏ>');
1027
+ return;
1028
+ }
1029
+ const lines = ['<b>โฐ ๅฎšๆ—ถไปปๅŠก</b>'];
1030
+ const rows = [];
1031
+ for (const s of list) {
1032
+ const spec = s.spec.kind === 'every' ? `ๆฏ ${s.spec.minutes} ๅˆ†้’Ÿ` : `ๆฏๅคฉ ${s.spec.time}`;
1033
+ const on = s.enabled ? '๐ŸŸข' : 'โธ';
1034
+ lines.push(`${on} <b>${esc(s.name)}</b>\n ${spec} ยท ${s.id}`);
1035
+ rows.push([
1036
+ btn(s.enabled ? 'โธ' : 'โ–ถ', `task:toggle:${s.id}`),
1037
+ btn('๐Ÿ—‘', `task:del:${s.id}`),
1038
+ ]);
1039
+ }
1040
+ await ctx.reply(lines.join('\n\n'), { parse_mode: 'HTML', ...inlineKeyboard(rows) });
1041
+ };
1042
+ deps.bot.callbackQuery(/^task:toggle:(.+)$/, async (ctx) => {
1043
+ if (!deps.scheduler)
1044
+ return;
1045
+ const id = ctx.match[1];
1046
+ const s = deps.scheduler.list().find((x) => x.id === id);
1047
+ if (s)
1048
+ deps.scheduler.setEnabled(id, !s.enabled);
1049
+ await ctx.answerCallbackQuery(s?.enabled === false ? 'โ–ถ ๅทฒๅฏ็”จ' : 'โธ ๅทฒๅœ็”จ');
1050
+ await renderTasks(ctx);
1051
+ });
1052
+ deps.bot.callbackQuery(/^task:del:(.+)$/, async (ctx) => {
1053
+ if (!deps.scheduler)
1054
+ return;
1055
+ const id = ctx.match[1];
1056
+ deps.scheduler.remove(id);
1057
+ await ctx.answerCallbackQuery('ๅทฒๅˆ ้™ค');
1058
+ await renderTasks(ctx);
1059
+ });
1060
+ const commands = [
1061
+ { command: 'start', description: 'Handshake and health' },
1062
+ { command: 'status', description: 'Server + last session' },
1063
+ { command: 'sessions', description: 'List all sessions' },
1064
+ { command: 'session', description: 'Pin a session' },
1065
+ { command: 'files', description: 'Files touched in last session' },
1066
+ { command: 'diff', description: 'Pending git diff' },
1067
+ { command: 'todo', description: 'Session todo list' },
1068
+ { command: 'context', description: 'Tokens + cost + model' },
1069
+ { command: 'agent', description: 'Set next agent' },
1070
+ { command: 'model', description: 'Set next model' },
1071
+ { command: 'current', description: 'Last session used' },
1072
+ { command: 'abort', description: 'Stop the current generation' },
1073
+ { command: 'tasks', description: 'Scheduled tasks (list)' },
1074
+ { command: 'task', description: 'Create a scheduled task' },
1075
+ { command: 'taskdel', description: 'Delete a scheduled task' },
1076
+ { command: 'projects', description: 'Switch project (workspace)' },
1077
+ { command: 'commands', description: 'List opencode custom commands' },
1078
+ { command: 'mcps', description: 'MCP server status' },
1079
+ { command: 'messages', description: 'Recent messages' },
1080
+ { command: 'detach', description: 'Unpin the followed session' },
1081
+ { command: 'version', description: 'Bot version + uptime' },
1082
+ { command: 'pair', description: 'Pair a device (URL + token)' },
1083
+ { command: 'workspaces', description: 'List/switch workspaces' },
1084
+ { command: 'new', description: 'New session in active workspace' },
1085
+ { command: 'rename', description: 'Rename the pinned/last session' },
1086
+ { command: 'channels', description: 'Bot channel status' },
1087
+ { command: 'subs', description: 'List subagents of the session' },
1088
+ { command: 'mode', description: 'Switch the session mode (build/planโ€ฆ)' },
1089
+ { command: 'cleanup', description: 'Delete finished subagent sessions' },
1090
+ { command: 'help', description: 'Show help' },
1091
+ ];
1092
+ // Register the same command list for every scope so a stale or competing bot
1093
+ // instance cannot shadow the default menu with a narrower (e.g. private-chat)
1094
+ // scope that only exposes a subset of commands.
1095
+ const scopes = [
1096
+ { type: 'default' },
1097
+ { type: 'all_private_chats' },
1098
+ { type: 'all_group_chats' },
1099
+ ];
1100
+ for (const scope of scopes) {
1101
+ const label = scope.type === 'default' ? 'default' : scope.type;
1102
+ const extra = scope.type === 'default' ? undefined : { scope };
1103
+ deps.bot.api
1104
+ .setMyCommands(commands, extra)
1105
+ .then(() => log.info(`setMyCommands OK [${label}]`))
1106
+ .catch((err) => log.warn(`setMyCommands [${label}] failed`, err));
1107
+ }
1108
+ // โ”€โ”€ Callbacks โ”€โ”€
1109
+ deps.bot.callbackQuery(/^session:pin:(.+)$/, async (ctx) => {
1110
+ const id = ctx.match[1];
1111
+ deps.state.setPinnedSessionId(id);
1112
+ await ctx.answerCallbackQuery(`Pinned ${id.slice(-8)}`);
1113
+ try {
1114
+ await ctx.editMessageText(`<b>๐Ÿ“Œ Pinned</b>\n\n<code>${id}</code>`, { parse_mode: 'HTML' });
1115
+ }
1116
+ catch (err) {
1117
+ const msg = err.message;
1118
+ if (!msg.includes('message is not modified'))
1119
+ log.warn('session:pin edit failed', msg);
1120
+ }
1121
+ });
1122
+ deps.bot.callbackQuery('session:unpin', async (ctx) => {
1123
+ deps.state.setPinnedSessionId(undefined);
1124
+ await ctx.answerCallbackQuery('Unpinned');
1125
+ try {
1126
+ await ctx.editMessageText('<b>๐Ÿ“Œ Session unpinned</b>\n\nMessages will use the most recently active session.', { parse_mode: 'HTML' });
1127
+ }
1128
+ catch (err) {
1129
+ const msg = err.message;
1130
+ if (!msg.includes('message is not modified'))
1131
+ log.warn('session:unpin edit failed', msg);
1132
+ }
1133
+ });
1134
+ deps.bot.callbackQuery('status:refresh', async (ctx) => {
1135
+ const { lines, buttons } = await buildStatusCard(deps);
1136
+ try {
1137
+ await ctx.editMessageText(lines.join('\n'), {
1138
+ parse_mode: 'HTML',
1139
+ ...inlineKeyboard([buttons]),
1140
+ });
1141
+ await ctx.answerCallbackQuery('Refreshed');
1142
+ }
1143
+ catch (err) {
1144
+ const msg = err.message;
1145
+ if (msg.includes('message is not modified')) {
1146
+ await ctx.answerCallbackQuery('Status is unchanged');
1147
+ }
1148
+ else {
1149
+ log.warn('status:refresh edit failed', msg);
1150
+ await ctx.answerCallbackQuery('Failed to refresh');
1151
+ }
1152
+ }
1153
+ });
1154
+ deps.bot.callbackQuery('status:abort', async (ctx) => {
1155
+ const sid = deps.abortGeneration();
1156
+ if (sid) {
1157
+ try {
1158
+ await deps.backend.abort(sid);
1159
+ }
1160
+ catch { }
1161
+ }
1162
+ await ctx.answerCallbackQuery('Abortingโ€ฆ');
1163
+ try {
1164
+ await ctx.editMessageText('๐Ÿ›‘ Generation aborted.', { parse_mode: 'HTML' });
1165
+ }
1166
+ catch (err) {
1167
+ const msg = err.message;
1168
+ if (!msg.includes('message is not modified'))
1169
+ log.warn('status:abort edit failed', msg);
1170
+ }
1171
+ });
1172
+ deps.bot.callbackQuery(/^agent:set:(.+)$/, async (ctx) => {
1173
+ const name = ctx.match[1];
1174
+ log.info(`agent:set callback: ${name}`);
1175
+ deps.state.setNextAgent(name);
1176
+ // Sync model to whatever this agent is bound to in opencode.jsonc, so the
1177
+ // user doesn't end up running a new agent against a stale /model override.
1178
+ let modelSuffix = '';
1179
+ try {
1180
+ const agents = await deps.backend.getAgents(deps.opencodeProject);
1181
+ const a = agents.find((x) => x.name === name);
1182
+ const parsed = a ? parseAgentModel(a.model) : undefined;
1183
+ if (parsed) {
1184
+ deps.state.setNextModel(parsed);
1185
+ modelSuffix = ` ยท <code>${parsed.modelID}</code>`;
1186
+ }
1187
+ }
1188
+ catch (err) {
1189
+ log.warn(`agent:set model sync failed: ${err.message}`);
1190
+ }
1191
+ await ctx.answerCallbackQuery(`Agent โ†’ ${name}`);
1192
+ try {
1193
+ await ctx.editMessageText(`<b>๐Ÿค– Agent set</b>\n\nNext message will use <b>${name}</b>${modelSuffix}.`, { parse_mode: 'HTML' });
1194
+ }
1195
+ catch (err) {
1196
+ const msg = err.message;
1197
+ if (!msg.includes('message is not modified'))
1198
+ log.warn('agent:set edit failed', msg);
1199
+ }
1200
+ });
1201
+ deps.bot.callbackQuery('agent:clear', async (ctx) => {
1202
+ deps.state.setNextAgent(undefined);
1203
+ await ctx.answerCallbackQuery('Agent cleared');
1204
+ try {
1205
+ await ctx.editMessageText('<b>๐Ÿค– Agent cleared</b>\n\nNext message will use the default agent.', { parse_mode: 'HTML' });
1206
+ }
1207
+ catch (err) {
1208
+ const msg = err.message;
1209
+ if (!msg.includes('message is not modified'))
1210
+ log.warn('agent:clear edit failed', msg);
1211
+ }
1212
+ });
1213
+ deps.bot.callbackQuery(/^model:set:(.+)$/, async (ctx) => {
1214
+ const key = modelTokens.get(ctx.match[1]);
1215
+ if (!key) {
1216
+ await ctx.answerCallbackQuery('Stale โ€” re-run /model');
1217
+ return;
1218
+ }
1219
+ const idx = key.indexOf('/');
1220
+ const providerID = key.slice(0, idx);
1221
+ const modelID = key.slice(idx + 1);
1222
+ log.info(`model:set callback: provider=${providerID} model=${modelID}`);
1223
+ const parsed = { providerID, modelID };
1224
+ deps.state.setNextModel(parsed);
1225
+ await ctx.answerCallbackQuery(`Model โ†’ ${modelID}`);
1226
+ try {
1227
+ await ctx.editMessageText(`<b>โš™๏ธ Model set</b>\n\nNext message will use <code>${providerID}/${modelID}</code>.`, { parse_mode: 'HTML' });
1228
+ }
1229
+ catch (err) {
1230
+ const msg = err.message;
1231
+ if (!msg.includes('message is not modified'))
1232
+ log.warn('model:set edit failed', msg);
1233
+ }
1234
+ });
1235
+ deps.bot.callbackQuery('model:clear', async (ctx) => {
1236
+ deps.state.setNextModel(undefined);
1237
+ await ctx.answerCallbackQuery('Model cleared');
1238
+ try {
1239
+ await ctx.editMessageText('<b>โš™๏ธ Model cleared</b>\n\nNext message will use the default model.', { parse_mode: 'HTML' });
1240
+ }
1241
+ catch (err) {
1242
+ const msg = err.message;
1243
+ if (!msg.includes('message is not modified'))
1244
+ log.warn('model:clear edit failed', msg);
1245
+ }
1246
+ });
1247
+ // Step 2: pick a specific model after selecting a provider
1248
+ deps.bot.callbackQuery(/^model:pick:(.+)$/, async (ctx) => {
1249
+ const providerID = ctx.match[1];
1250
+ try {
1251
+ const providers = await deps.backend.getModels(deps.opencodeProject);
1252
+ const provider = providers.find(p => p.id === providerID);
1253
+ if (!provider || (provider.models ?? []).length === 0) {
1254
+ await ctx.answerCallbackQuery('No models for this provider');
1255
+ return;
1256
+ }
1257
+ const nextModel = deps.state.getNextModel();
1258
+ const lines = [
1259
+ `<b>โš™๏ธ Model โ€” ${provider.name}</b>`,
1260
+ '',
1261
+ ];
1262
+ const rows = [];
1263
+ for (const m of (provider.models ?? [])) {
1264
+ const sel = nextModel?.providerID === providerID && nextModel?.modelID === m.id ? 'โ—' : 'โ—‹';
1265
+ lines.push(`${sel} ${m.name ?? m.id}`);
1266
+ rows.push([btn(m.name ?? m.id, `model:set:${modelToken(providerID, m.id)}`)]);
1267
+ }
1268
+ rows.push([btn('โ—€ Back', 'model:back')]);
1269
+ await ctx.editMessageText(lines.join('\n'), {
1270
+ parse_mode: 'HTML',
1271
+ ...inlineKeyboard(rows),
1272
+ });
1273
+ await ctx.answerCallbackQuery();
1274
+ }
1275
+ catch (err) {
1276
+ log.error('model:pick failed', err);
1277
+ await ctx.answerCallbackQuery('Failed to load models');
1278
+ }
1279
+ });
1280
+ // Back to provider list
1281
+ deps.bot.callbackQuery('model:back', async (ctx) => {
1282
+ try {
1283
+ const providers = await deps.backend.getModels(deps.opencodeProject);
1284
+ const nextModel = deps.state.getNextModel();
1285
+ const lines = ['<b>โš™๏ธ Model โ€” Select provider</b>', ''];
1286
+ const rows = [];
1287
+ for (const p of providers) {
1288
+ const count = (p.models ?? []).length;
1289
+ const hasSelected = nextModel?.providerID === p.id;
1290
+ const marker = hasSelected ? 'โ—' : 'โ–ธ';
1291
+ lines.push(`${marker} <b>${p.name}</b> ยท ${count} model${count !== 1 ? 's' : ''}`);
1292
+ rows.push([btn(p.name, `model:pick:${p.id}`)]);
1293
+ }
1294
+ if (nextModel) {
1295
+ lines.push('', `<i>Current override: ${nextModel.providerID}/${nextModel.modelID}</i>`);
1296
+ }
1297
+ rows.push([btn('โœ• Clear', 'model:clear')]);
1298
+ await ctx.editMessageText(lines.join('\n'), {
1299
+ parse_mode: 'HTML',
1300
+ ...inlineKeyboard(rows),
1301
+ });
1302
+ await ctx.answerCallbackQuery();
1303
+ }
1304
+ catch (err) {
1305
+ log.error('model:back failed', err);
1306
+ await ctx.answerCallbackQuery('Failed');
1307
+ }
1308
+ });
1309
+ deps.bot.callbackQuery('card:dismiss', async (ctx) => {
1310
+ await ctx.answerCallbackQuery();
1311
+ await ctx.deleteMessage().catch(() => { });
1312
+ });
1313
+ // โ”€โ”€ Info commands (split to separate file) โ”€โ”€
1314
+ registerInfoCommands({ bot: deps.bot, backend: deps.backend, state: deps.state, opencodeProject: deps.opencodeProject });
1315
+ // โ”€โ”€ Approval callbacks โ€” always registered so buttons work in both modes โ”€โ”€
1316
+ deps.bot.callbackQuery(/^approve:(once|always|reject):(.+)$/, async (ctx) => {
1317
+ const match = ctx.match;
1318
+ const response = match[1];
1319
+ // callback_data carries a short token (64-byte limit); resolve it back to
1320
+ // the permission id. Fall back to the raw value for pre-token cards.
1321
+ const permId = deps.approvalTokens.get(match[2]) ?? match[2];
1322
+ const p = deps.pendingApprovals.get(permId);
1323
+ if (!p) {
1324
+ await ctx.answerCallbackQuery('This request has already been handled.');
1325
+ return;
1326
+ }
1327
+ // Delete BEFORE resolving: a fast double-click must see "already handled"
1328
+ // on the second tap instead of racing the resolvePermission call.
1329
+ deps.pendingApprovals.delete(permId);
1330
+ try {
1331
+ await deps.backend.resolvePermission(p.sessionId, p.permissionId, response);
1332
+ }
1333
+ catch (err) {
1334
+ log.error(`failed to reply permission ${permId}`, err);
1335
+ await ctx.answerCallbackQuery('Failed to reply. The request may have expired.');
1336
+ return;
1337
+ }
1338
+ const labels = {
1339
+ once: 'Allowed (once)',
1340
+ always: 'Always Allowed',
1341
+ reject: 'Rejected',
1342
+ };
1343
+ const display = labels[response];
1344
+ await ctx.editMessageText(`${display}\n\n${esc(p.title)}`, { parse_mode: 'HTML' }).catch(() => { });
1345
+ await ctx.answerCallbackQuery(display);
1346
+ });
1347
+ }
1348
+ //# sourceMappingURL=handlers.js.map