cowork-os 0.3.21

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 (526) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1638 -0
  3. package/bin/cowork.js +42 -0
  4. package/build/entitlements.mac.plist +16 -0
  5. package/build/icon.icns +0 -0
  6. package/build/icon.png +0 -0
  7. package/dist/electron/electron/activity/ActivityRepository.js +190 -0
  8. package/dist/electron/electron/agent/browser/browser-service.js +639 -0
  9. package/dist/electron/electron/agent/context-manager.js +225 -0
  10. package/dist/electron/electron/agent/custom-skill-loader.js +566 -0
  11. package/dist/electron/electron/agent/daemon.js +975 -0
  12. package/dist/electron/electron/agent/executor.js +3561 -0
  13. package/dist/electron/electron/agent/llm/anthropic-provider.js +155 -0
  14. package/dist/electron/electron/agent/llm/bedrock-provider.js +202 -0
  15. package/dist/electron/electron/agent/llm/gemini-provider.js +375 -0
  16. package/dist/electron/electron/agent/llm/index.js +34 -0
  17. package/dist/electron/electron/agent/llm/ollama-provider.js +263 -0
  18. package/dist/electron/electron/agent/llm/openai-oauth.js +101 -0
  19. package/dist/electron/electron/agent/llm/openai-provider.js +657 -0
  20. package/dist/electron/electron/agent/llm/openrouter-provider.js +232 -0
  21. package/dist/electron/electron/agent/llm/pricing.js +160 -0
  22. package/dist/electron/electron/agent/llm/provider-factory.js +880 -0
  23. package/dist/electron/electron/agent/llm/types.js +178 -0
  24. package/dist/electron/electron/agent/queue-manager.js +378 -0
  25. package/dist/electron/electron/agent/sandbox/docker-sandbox.js +402 -0
  26. package/dist/electron/electron/agent/sandbox/macos-sandbox.js +407 -0
  27. package/dist/electron/electron/agent/sandbox/runner.js +410 -0
  28. package/dist/electron/electron/agent/sandbox/sandbox-factory.js +228 -0
  29. package/dist/electron/electron/agent/sandbox/security-utils.js +258 -0
  30. package/dist/electron/electron/agent/search/brave-provider.js +119 -0
  31. package/dist/electron/electron/agent/search/google-provider.js +100 -0
  32. package/dist/electron/electron/agent/search/index.js +28 -0
  33. package/dist/electron/electron/agent/search/provider-factory.js +395 -0
  34. package/dist/electron/electron/agent/search/serpapi-provider.js +112 -0
  35. package/dist/electron/electron/agent/search/tavily-provider.js +90 -0
  36. package/dist/electron/electron/agent/search/types.js +40 -0
  37. package/dist/electron/electron/agent/security/index.js +12 -0
  38. package/dist/electron/electron/agent/security/input-sanitizer.js +303 -0
  39. package/dist/electron/electron/agent/security/output-filter.js +217 -0
  40. package/dist/electron/electron/agent/skill-eligibility.js +281 -0
  41. package/dist/electron/electron/agent/skill-registry.js +396 -0
  42. package/dist/electron/electron/agent/skills/document.js +878 -0
  43. package/dist/electron/electron/agent/skills/image-generator.js +225 -0
  44. package/dist/electron/electron/agent/skills/organizer.js +141 -0
  45. package/dist/electron/electron/agent/skills/presentation.js +367 -0
  46. package/dist/electron/electron/agent/skills/spreadsheet.js +165 -0
  47. package/dist/electron/electron/agent/tools/browser-tools.js +523 -0
  48. package/dist/electron/electron/agent/tools/builtin-settings.js +384 -0
  49. package/dist/electron/electron/agent/tools/canvas-tools.js +530 -0
  50. package/dist/electron/electron/agent/tools/cron-tools.js +577 -0
  51. package/dist/electron/electron/agent/tools/edit-tools.js +194 -0
  52. package/dist/electron/electron/agent/tools/file-tools.js +719 -0
  53. package/dist/electron/electron/agent/tools/glob-tools.js +283 -0
  54. package/dist/electron/electron/agent/tools/grep-tools.js +387 -0
  55. package/dist/electron/electron/agent/tools/image-tools.js +111 -0
  56. package/dist/electron/electron/agent/tools/mention-tools.js +282 -0
  57. package/dist/electron/electron/agent/tools/node-tools.js +476 -0
  58. package/dist/electron/electron/agent/tools/registry.js +2719 -0
  59. package/dist/electron/electron/agent/tools/search-tools.js +91 -0
  60. package/dist/electron/electron/agent/tools/shell-tools.js +574 -0
  61. package/dist/electron/electron/agent/tools/skill-tools.js +274 -0
  62. package/dist/electron/electron/agent/tools/system-tools.js +578 -0
  63. package/dist/electron/electron/agent/tools/web-fetch-tools.js +444 -0
  64. package/dist/electron/electron/agent/tools/x-tools.js +264 -0
  65. package/dist/electron/electron/agents/AgentRoleRepository.js +420 -0
  66. package/dist/electron/electron/agents/HeartbeatService.js +356 -0
  67. package/dist/electron/electron/agents/MentionRepository.js +197 -0
  68. package/dist/electron/electron/agents/TaskSubscriptionRepository.js +168 -0
  69. package/dist/electron/electron/agents/WorkingStateRepository.js +229 -0
  70. package/dist/electron/electron/canvas/canvas-manager.js +714 -0
  71. package/dist/electron/electron/canvas/canvas-preload.js +53 -0
  72. package/dist/electron/electron/canvas/canvas-protocol.js +195 -0
  73. package/dist/electron/electron/canvas/canvas-store.js +174 -0
  74. package/dist/electron/electron/canvas/index.js +13 -0
  75. package/dist/electron/electron/control-plane/client.js +364 -0
  76. package/dist/electron/electron/control-plane/handlers.js +572 -0
  77. package/dist/electron/electron/control-plane/index.js +41 -0
  78. package/dist/electron/electron/control-plane/node-manager.js +264 -0
  79. package/dist/electron/electron/control-plane/protocol.js +194 -0
  80. package/dist/electron/electron/control-plane/remote-client.js +437 -0
  81. package/dist/electron/electron/control-plane/server.js +640 -0
  82. package/dist/electron/electron/control-plane/settings.js +369 -0
  83. package/dist/electron/electron/control-plane/ssh-tunnel.js +549 -0
  84. package/dist/electron/electron/cron/index.js +30 -0
  85. package/dist/electron/electron/cron/schedule.js +190 -0
  86. package/dist/electron/electron/cron/service.js +614 -0
  87. package/dist/electron/electron/cron/store.js +155 -0
  88. package/dist/electron/electron/cron/types.js +82 -0
  89. package/dist/electron/electron/cron/webhook.js +258 -0
  90. package/dist/electron/electron/database/SecureSettingsRepository.js +444 -0
  91. package/dist/electron/electron/database/TaskLabelRepository.js +120 -0
  92. package/dist/electron/electron/database/repositories.js +1781 -0
  93. package/dist/electron/electron/database/schema.js +978 -0
  94. package/dist/electron/electron/extensions/index.js +33 -0
  95. package/dist/electron/electron/extensions/loader.js +313 -0
  96. package/dist/electron/electron/extensions/registry.js +485 -0
  97. package/dist/electron/electron/extensions/types.js +11 -0
  98. package/dist/electron/electron/gateway/channel-registry.js +1102 -0
  99. package/dist/electron/electron/gateway/channels/bluebubbles-client.js +479 -0
  100. package/dist/electron/electron/gateway/channels/bluebubbles.js +432 -0
  101. package/dist/electron/electron/gateway/channels/discord.js +975 -0
  102. package/dist/electron/electron/gateway/channels/email-client.js +593 -0
  103. package/dist/electron/electron/gateway/channels/email.js +443 -0
  104. package/dist/electron/electron/gateway/channels/google-chat.js +631 -0
  105. package/dist/electron/electron/gateway/channels/imessage-client.js +363 -0
  106. package/dist/electron/electron/gateway/channels/imessage.js +465 -0
  107. package/dist/electron/electron/gateway/channels/index.js +36 -0
  108. package/dist/electron/electron/gateway/channels/line-client.js +470 -0
  109. package/dist/electron/electron/gateway/channels/line.js +479 -0
  110. package/dist/electron/electron/gateway/channels/matrix-client.js +432 -0
  111. package/dist/electron/electron/gateway/channels/matrix.js +592 -0
  112. package/dist/electron/electron/gateway/channels/mattermost-client.js +394 -0
  113. package/dist/electron/electron/gateway/channels/mattermost.js +496 -0
  114. package/dist/electron/electron/gateway/channels/signal-client.js +500 -0
  115. package/dist/electron/electron/gateway/channels/signal.js +582 -0
  116. package/dist/electron/electron/gateway/channels/slack.js +415 -0
  117. package/dist/electron/electron/gateway/channels/teams.js +596 -0
  118. package/dist/electron/electron/gateway/channels/telegram.js +1390 -0
  119. package/dist/electron/electron/gateway/channels/twitch-client.js +502 -0
  120. package/dist/electron/electron/gateway/channels/twitch.js +396 -0
  121. package/dist/electron/electron/gateway/channels/types.js +8 -0
  122. package/dist/electron/electron/gateway/channels/whatsapp.js +953 -0
  123. package/dist/electron/electron/gateway/context-policy.js +268 -0
  124. package/dist/electron/electron/gateway/index.js +1063 -0
  125. package/dist/electron/electron/gateway/infrastructure.js +496 -0
  126. package/dist/electron/electron/gateway/router.js +2700 -0
  127. package/dist/electron/electron/gateway/security.js +375 -0
  128. package/dist/electron/electron/gateway/session.js +115 -0
  129. package/dist/electron/electron/gateway/tunnel.js +503 -0
  130. package/dist/electron/electron/guardrails/guardrail-manager.js +348 -0
  131. package/dist/electron/electron/hooks/gmail-watcher.js +300 -0
  132. package/dist/electron/electron/hooks/index.js +46 -0
  133. package/dist/electron/electron/hooks/mappings.js +381 -0
  134. package/dist/electron/electron/hooks/server.js +480 -0
  135. package/dist/electron/electron/hooks/settings.js +447 -0
  136. package/dist/electron/electron/hooks/types.js +41 -0
  137. package/dist/electron/electron/ipc/canvas-handlers.js +158 -0
  138. package/dist/electron/electron/ipc/handlers.js +3138 -0
  139. package/dist/electron/electron/ipc/mission-control-handlers.js +141 -0
  140. package/dist/electron/electron/main.js +448 -0
  141. package/dist/electron/electron/mcp/client/MCPClientManager.js +330 -0
  142. package/dist/electron/electron/mcp/client/MCPServerConnection.js +437 -0
  143. package/dist/electron/electron/mcp/client/transports/SSETransport.js +304 -0
  144. package/dist/electron/electron/mcp/client/transports/StdioTransport.js +307 -0
  145. package/dist/electron/electron/mcp/client/transports/WebSocketTransport.js +329 -0
  146. package/dist/electron/electron/mcp/host/MCPHostServer.js +354 -0
  147. package/dist/electron/electron/mcp/host/ToolAdapter.js +100 -0
  148. package/dist/electron/electron/mcp/registry/MCPRegistryManager.js +497 -0
  149. package/dist/electron/electron/mcp/settings.js +446 -0
  150. package/dist/electron/electron/mcp/types.js +59 -0
  151. package/dist/electron/electron/memory/MemoryService.js +435 -0
  152. package/dist/electron/electron/notifications/index.js +17 -0
  153. package/dist/electron/electron/notifications/service.js +118 -0
  154. package/dist/electron/electron/notifications/store.js +144 -0
  155. package/dist/electron/electron/preload.js +842 -0
  156. package/dist/electron/electron/reports/StandupReportService.js +272 -0
  157. package/dist/electron/electron/security/concurrency.js +293 -0
  158. package/dist/electron/electron/security/index.js +15 -0
  159. package/dist/electron/electron/security/policy-manager.js +435 -0
  160. package/dist/electron/electron/settings/appearance-manager.js +193 -0
  161. package/dist/electron/electron/settings/personality-manager.js +724 -0
  162. package/dist/electron/electron/settings/x-manager.js +58 -0
  163. package/dist/electron/electron/tailscale/exposure.js +188 -0
  164. package/dist/electron/electron/tailscale/index.js +28 -0
  165. package/dist/electron/electron/tailscale/settings.js +205 -0
  166. package/dist/electron/electron/tailscale/tailscale.js +355 -0
  167. package/dist/electron/electron/tray/QuickInputWindow.js +568 -0
  168. package/dist/electron/electron/tray/TrayManager.js +895 -0
  169. package/dist/electron/electron/tray/index.js +9 -0
  170. package/dist/electron/electron/updater/index.js +6 -0
  171. package/dist/electron/electron/updater/update-manager.js +418 -0
  172. package/dist/electron/electron/utils/env-migration.js +209 -0
  173. package/dist/electron/electron/utils/process.js +102 -0
  174. package/dist/electron/electron/utils/rate-limiter.js +104 -0
  175. package/dist/electron/electron/utils/validation.js +419 -0
  176. package/dist/electron/electron/utils/x-cli.js +177 -0
  177. package/dist/electron/electron/voice/VoiceService.js +507 -0
  178. package/dist/electron/electron/voice/index.js +14 -0
  179. package/dist/electron/electron/voice/voice-settings-manager.js +359 -0
  180. package/dist/electron/shared/channelMessages.js +170 -0
  181. package/dist/electron/shared/types.js +1185 -0
  182. package/package.json +159 -0
  183. package/resources/skills/1password.json +10 -0
  184. package/resources/skills/add-documentation.json +31 -0
  185. package/resources/skills/analyze-csv.json +17 -0
  186. package/resources/skills/apple-notes.json +10 -0
  187. package/resources/skills/apple-reminders.json +10 -0
  188. package/resources/skills/auto-commenter.json +10 -0
  189. package/resources/skills/bear-notes.json +10 -0
  190. package/resources/skills/bird.json +35 -0
  191. package/resources/skills/blogwatcher.json +10 -0
  192. package/resources/skills/blucli.json +10 -0
  193. package/resources/skills/bluebubbles.json +10 -0
  194. package/resources/skills/camsnap.json +10 -0
  195. package/resources/skills/clean-imports.json +18 -0
  196. package/resources/skills/code-review.json +18 -0
  197. package/resources/skills/coding-agent.json +10 -0
  198. package/resources/skills/compare-files.json +23 -0
  199. package/resources/skills/convert-code.json +34 -0
  200. package/resources/skills/create-changelog.json +24 -0
  201. package/resources/skills/debug-error.json +17 -0
  202. package/resources/skills/dependency-check.json +10 -0
  203. package/resources/skills/discord.json +10 -0
  204. package/resources/skills/eightctl.json +10 -0
  205. package/resources/skills/explain-code.json +29 -0
  206. package/resources/skills/extract-todos.json +18 -0
  207. package/resources/skills/food-order.json +10 -0
  208. package/resources/skills/gemini.json +10 -0
  209. package/resources/skills/generate-readme.json +10 -0
  210. package/resources/skills/gifgrep.json +10 -0
  211. package/resources/skills/git-commit.json +10 -0
  212. package/resources/skills/github.json +10 -0
  213. package/resources/skills/gog.json +10 -0
  214. package/resources/skills/goplaces.json +10 -0
  215. package/resources/skills/himalaya.json +10 -0
  216. package/resources/skills/imsg.json +10 -0
  217. package/resources/skills/karpathy-guidelines.json +12 -0
  218. package/resources/skills/last30days.json +26 -0
  219. package/resources/skills/local-places.json +10 -0
  220. package/resources/skills/mcporter.json +10 -0
  221. package/resources/skills/model-usage.json +10 -0
  222. package/resources/skills/nano-banana-pro.json +10 -0
  223. package/resources/skills/nano-pdf.json +10 -0
  224. package/resources/skills/notion.json +10 -0
  225. package/resources/skills/obsidian.json +10 -0
  226. package/resources/skills/openai-image-gen.json +10 -0
  227. package/resources/skills/openai-whisper-api.json +10 -0
  228. package/resources/skills/openai-whisper.json +10 -0
  229. package/resources/skills/openhue.json +10 -0
  230. package/resources/skills/oracle.json +10 -0
  231. package/resources/skills/ordercli.json +10 -0
  232. package/resources/skills/peekaboo.json +10 -0
  233. package/resources/skills/project-structure.json +10 -0
  234. package/resources/skills/proofread.json +17 -0
  235. package/resources/skills/refactor-code.json +31 -0
  236. package/resources/skills/rename-symbol.json +23 -0
  237. package/resources/skills/sag.json +10 -0
  238. package/resources/skills/security-audit.json +18 -0
  239. package/resources/skills/session-logs.json +10 -0
  240. package/resources/skills/sherpa-onnx-tts.json +10 -0
  241. package/resources/skills/skill-creator.json +15 -0
  242. package/resources/skills/skill-hub.json +29 -0
  243. package/resources/skills/slack.json +10 -0
  244. package/resources/skills/songsee.json +10 -0
  245. package/resources/skills/sonoscli.json +10 -0
  246. package/resources/skills/spotify-player.json +10 -0
  247. package/resources/skills/startup-cfo.json +55 -0
  248. package/resources/skills/summarize-folder.json +18 -0
  249. package/resources/skills/summarize.json +10 -0
  250. package/resources/skills/things-mac.json +10 -0
  251. package/resources/skills/tmux.json +10 -0
  252. package/resources/skills/translate.json +36 -0
  253. package/resources/skills/trello.json +10 -0
  254. package/resources/skills/video-frames.json +10 -0
  255. package/resources/skills/voice-call.json +10 -0
  256. package/resources/skills/wacli.json +10 -0
  257. package/resources/skills/weather.json +10 -0
  258. package/resources/skills/write-tests.json +31 -0
  259. package/src/electron/activity/ActivityRepository.ts +238 -0
  260. package/src/electron/agent/browser/browser-service.ts +721 -0
  261. package/src/electron/agent/context-manager.ts +257 -0
  262. package/src/electron/agent/custom-skill-loader.ts +634 -0
  263. package/src/electron/agent/daemon.ts +1097 -0
  264. package/src/electron/agent/executor.ts +4017 -0
  265. package/src/electron/agent/llm/anthropic-provider.ts +175 -0
  266. package/src/electron/agent/llm/bedrock-provider.ts +236 -0
  267. package/src/electron/agent/llm/gemini-provider.ts +422 -0
  268. package/src/electron/agent/llm/index.ts +9 -0
  269. package/src/electron/agent/llm/ollama-provider.ts +347 -0
  270. package/src/electron/agent/llm/openai-oauth.ts +127 -0
  271. package/src/electron/agent/llm/openai-provider.ts +686 -0
  272. package/src/electron/agent/llm/openrouter-provider.ts +273 -0
  273. package/src/electron/agent/llm/pricing.ts +180 -0
  274. package/src/electron/agent/llm/provider-factory.ts +971 -0
  275. package/src/electron/agent/llm/types.ts +291 -0
  276. package/src/electron/agent/queue-manager.ts +408 -0
  277. package/src/electron/agent/sandbox/docker-sandbox.ts +453 -0
  278. package/src/electron/agent/sandbox/macos-sandbox.ts +426 -0
  279. package/src/electron/agent/sandbox/runner.ts +453 -0
  280. package/src/electron/agent/sandbox/sandbox-factory.ts +337 -0
  281. package/src/electron/agent/sandbox/security-utils.ts +251 -0
  282. package/src/electron/agent/search/brave-provider.ts +141 -0
  283. package/src/electron/agent/search/google-provider.ts +131 -0
  284. package/src/electron/agent/search/index.ts +6 -0
  285. package/src/electron/agent/search/provider-factory.ts +450 -0
  286. package/src/electron/agent/search/serpapi-provider.ts +138 -0
  287. package/src/electron/agent/search/tavily-provider.ts +108 -0
  288. package/src/electron/agent/search/types.ts +118 -0
  289. package/src/electron/agent/security/index.ts +20 -0
  290. package/src/electron/agent/security/input-sanitizer.ts +380 -0
  291. package/src/electron/agent/security/output-filter.ts +259 -0
  292. package/src/electron/agent/skill-eligibility.ts +334 -0
  293. package/src/electron/agent/skill-registry.ts +457 -0
  294. package/src/electron/agent/skills/document.ts +1070 -0
  295. package/src/electron/agent/skills/image-generator.ts +272 -0
  296. package/src/electron/agent/skills/organizer.ts +131 -0
  297. package/src/electron/agent/skills/presentation.ts +418 -0
  298. package/src/electron/agent/skills/spreadsheet.ts +166 -0
  299. package/src/electron/agent/tools/browser-tools.ts +546 -0
  300. package/src/electron/agent/tools/builtin-settings.ts +422 -0
  301. package/src/electron/agent/tools/canvas-tools.ts +572 -0
  302. package/src/electron/agent/tools/cron-tools.ts +723 -0
  303. package/src/electron/agent/tools/edit-tools.ts +196 -0
  304. package/src/electron/agent/tools/file-tools.ts +811 -0
  305. package/src/electron/agent/tools/glob-tools.ts +303 -0
  306. package/src/electron/agent/tools/grep-tools.ts +432 -0
  307. package/src/electron/agent/tools/image-tools.ts +126 -0
  308. package/src/electron/agent/tools/mention-tools.ts +371 -0
  309. package/src/electron/agent/tools/node-tools.ts +550 -0
  310. package/src/electron/agent/tools/registry.ts +3052 -0
  311. package/src/electron/agent/tools/search-tools.ts +111 -0
  312. package/src/electron/agent/tools/shell-tools.ts +651 -0
  313. package/src/electron/agent/tools/skill-tools.ts +340 -0
  314. package/src/electron/agent/tools/system-tools.ts +665 -0
  315. package/src/electron/agent/tools/web-fetch-tools.ts +528 -0
  316. package/src/electron/agent/tools/x-tools.ts +267 -0
  317. package/src/electron/agents/AgentRoleRepository.ts +557 -0
  318. package/src/electron/agents/HeartbeatService.ts +469 -0
  319. package/src/electron/agents/MentionRepository.ts +242 -0
  320. package/src/electron/agents/TaskSubscriptionRepository.ts +231 -0
  321. package/src/electron/agents/WorkingStateRepository.ts +278 -0
  322. package/src/electron/canvas/canvas-manager.ts +818 -0
  323. package/src/electron/canvas/canvas-preload.ts +102 -0
  324. package/src/electron/canvas/canvas-protocol.ts +174 -0
  325. package/src/electron/canvas/canvas-store.ts +200 -0
  326. package/src/electron/canvas/index.ts +8 -0
  327. package/src/electron/control-plane/client.ts +527 -0
  328. package/src/electron/control-plane/handlers.ts +723 -0
  329. package/src/electron/control-plane/index.ts +51 -0
  330. package/src/electron/control-plane/node-manager.ts +322 -0
  331. package/src/electron/control-plane/protocol.ts +269 -0
  332. package/src/electron/control-plane/remote-client.ts +517 -0
  333. package/src/electron/control-plane/server.ts +853 -0
  334. package/src/electron/control-plane/settings.ts +401 -0
  335. package/src/electron/control-plane/ssh-tunnel.ts +624 -0
  336. package/src/electron/cron/index.ts +9 -0
  337. package/src/electron/cron/schedule.ts +217 -0
  338. package/src/electron/cron/service.ts +743 -0
  339. package/src/electron/cron/store.ts +165 -0
  340. package/src/electron/cron/types.ts +291 -0
  341. package/src/electron/cron/webhook.ts +303 -0
  342. package/src/electron/database/SecureSettingsRepository.ts +514 -0
  343. package/src/electron/database/TaskLabelRepository.ts +148 -0
  344. package/src/electron/database/repositories.ts +2397 -0
  345. package/src/electron/database/schema.ts +1017 -0
  346. package/src/electron/extensions/index.ts +18 -0
  347. package/src/electron/extensions/loader.ts +336 -0
  348. package/src/electron/extensions/registry.ts +546 -0
  349. package/src/electron/extensions/types.ts +372 -0
  350. package/src/electron/gateway/channel-registry.ts +1267 -0
  351. package/src/electron/gateway/channels/bluebubbles-client.ts +641 -0
  352. package/src/electron/gateway/channels/bluebubbles.ts +509 -0
  353. package/src/electron/gateway/channels/discord.ts +1150 -0
  354. package/src/electron/gateway/channels/email-client.ts +708 -0
  355. package/src/electron/gateway/channels/email.ts +516 -0
  356. package/src/electron/gateway/channels/google-chat.ts +760 -0
  357. package/src/electron/gateway/channels/imessage-client.ts +473 -0
  358. package/src/electron/gateway/channels/imessage.ts +520 -0
  359. package/src/electron/gateway/channels/index.ts +21 -0
  360. package/src/electron/gateway/channels/line-client.ts +598 -0
  361. package/src/electron/gateway/channels/line.ts +559 -0
  362. package/src/electron/gateway/channels/matrix-client.ts +632 -0
  363. package/src/electron/gateway/channels/matrix.ts +655 -0
  364. package/src/electron/gateway/channels/mattermost-client.ts +526 -0
  365. package/src/electron/gateway/channels/mattermost.ts +550 -0
  366. package/src/electron/gateway/channels/signal-client.ts +722 -0
  367. package/src/electron/gateway/channels/signal.ts +666 -0
  368. package/src/electron/gateway/channels/slack.ts +458 -0
  369. package/src/electron/gateway/channels/teams.ts +681 -0
  370. package/src/electron/gateway/channels/telegram.ts +1727 -0
  371. package/src/electron/gateway/channels/twitch-client.ts +665 -0
  372. package/src/electron/gateway/channels/twitch.ts +468 -0
  373. package/src/electron/gateway/channels/types.ts +1002 -0
  374. package/src/electron/gateway/channels/whatsapp.ts +1101 -0
  375. package/src/electron/gateway/context-policy.ts +382 -0
  376. package/src/electron/gateway/index.ts +1274 -0
  377. package/src/electron/gateway/infrastructure.ts +645 -0
  378. package/src/electron/gateway/router.ts +3206 -0
  379. package/src/electron/gateway/security.ts +422 -0
  380. package/src/electron/gateway/session.ts +144 -0
  381. package/src/electron/gateway/tunnel.ts +626 -0
  382. package/src/electron/guardrails/guardrail-manager.ts +380 -0
  383. package/src/electron/hooks/gmail-watcher.ts +355 -0
  384. package/src/electron/hooks/index.ts +30 -0
  385. package/src/electron/hooks/mappings.ts +404 -0
  386. package/src/electron/hooks/server.ts +574 -0
  387. package/src/electron/hooks/settings.ts +466 -0
  388. package/src/electron/hooks/types.ts +245 -0
  389. package/src/electron/ipc/canvas-handlers.ts +223 -0
  390. package/src/electron/ipc/handlers.ts +3661 -0
  391. package/src/electron/ipc/mission-control-handlers.ts +182 -0
  392. package/src/electron/main.ts +496 -0
  393. package/src/electron/mcp/client/MCPClientManager.ts +406 -0
  394. package/src/electron/mcp/client/MCPServerConnection.ts +514 -0
  395. package/src/electron/mcp/client/transports/SSETransport.ts +360 -0
  396. package/src/electron/mcp/client/transports/StdioTransport.ts +355 -0
  397. package/src/electron/mcp/client/transports/WebSocketTransport.ts +384 -0
  398. package/src/electron/mcp/host/MCPHostServer.ts +388 -0
  399. package/src/electron/mcp/host/ToolAdapter.ts +140 -0
  400. package/src/electron/mcp/registry/MCPRegistryManager.ts +565 -0
  401. package/src/electron/mcp/settings.ts +468 -0
  402. package/src/electron/mcp/types.ts +371 -0
  403. package/src/electron/memory/MemoryService.ts +523 -0
  404. package/src/electron/notifications/index.ts +16 -0
  405. package/src/electron/notifications/service.ts +161 -0
  406. package/src/electron/notifications/store.ts +163 -0
  407. package/src/electron/preload.ts +2845 -0
  408. package/src/electron/reports/StandupReportService.ts +356 -0
  409. package/src/electron/security/concurrency.ts +333 -0
  410. package/src/electron/security/index.ts +17 -0
  411. package/src/electron/security/policy-manager.ts +539 -0
  412. package/src/electron/settings/appearance-manager.ts +182 -0
  413. package/src/electron/settings/personality-manager.ts +800 -0
  414. package/src/electron/settings/x-manager.ts +62 -0
  415. package/src/electron/tailscale/exposure.ts +262 -0
  416. package/src/electron/tailscale/index.ts +34 -0
  417. package/src/electron/tailscale/settings.ts +218 -0
  418. package/src/electron/tailscale/tailscale.ts +379 -0
  419. package/src/electron/tray/QuickInputWindow.ts +609 -0
  420. package/src/electron/tray/TrayManager.ts +1005 -0
  421. package/src/electron/tray/index.ts +6 -0
  422. package/src/electron/updater/index.ts +1 -0
  423. package/src/electron/updater/update-manager.ts +447 -0
  424. package/src/electron/utils/env-migration.ts +203 -0
  425. package/src/electron/utils/process.ts +124 -0
  426. package/src/electron/utils/rate-limiter.ts +130 -0
  427. package/src/electron/utils/validation.ts +493 -0
  428. package/src/electron/utils/x-cli.ts +198 -0
  429. package/src/electron/voice/VoiceService.ts +583 -0
  430. package/src/electron/voice/index.ts +9 -0
  431. package/src/electron/voice/voice-settings-manager.ts +403 -0
  432. package/src/renderer/App.tsx +775 -0
  433. package/src/renderer/components/ActivityFeed.tsx +407 -0
  434. package/src/renderer/components/ActivityFeedItem.tsx +285 -0
  435. package/src/renderer/components/AgentRoleCard.tsx +343 -0
  436. package/src/renderer/components/AgentRoleEditor.tsx +805 -0
  437. package/src/renderer/components/AgentSquadSettings.tsx +295 -0
  438. package/src/renderer/components/AgentWorkingStatePanel.tsx +411 -0
  439. package/src/renderer/components/AppearanceSettings.tsx +122 -0
  440. package/src/renderer/components/ApprovalDialog.tsx +100 -0
  441. package/src/renderer/components/BlueBubblesSettings.tsx +505 -0
  442. package/src/renderer/components/BuiltinToolsSettings.tsx +307 -0
  443. package/src/renderer/components/CanvasPreview.tsx +1189 -0
  444. package/src/renderer/components/CommandOutput.tsx +202 -0
  445. package/src/renderer/components/ContextPolicySettings.tsx +523 -0
  446. package/src/renderer/components/ControlPlaneSettings.tsx +1134 -0
  447. package/src/renderer/components/DisclaimerModal.tsx +124 -0
  448. package/src/renderer/components/DiscordSettings.tsx +436 -0
  449. package/src/renderer/components/EmailSettings.tsx +606 -0
  450. package/src/renderer/components/ExtensionsSettings.tsx +542 -0
  451. package/src/renderer/components/FileViewer.tsx +224 -0
  452. package/src/renderer/components/GoogleChatSettings.tsx +535 -0
  453. package/src/renderer/components/GuardrailSettings.tsx +487 -0
  454. package/src/renderer/components/HooksSettings.tsx +581 -0
  455. package/src/renderer/components/ImessageSettings.tsx +484 -0
  456. package/src/renderer/components/LineSettings.tsx +483 -0
  457. package/src/renderer/components/MCPRegistryBrowser.tsx +386 -0
  458. package/src/renderer/components/MCPSettings.tsx +943 -0
  459. package/src/renderer/components/MainContent.tsx +2433 -0
  460. package/src/renderer/components/MatrixSettings.tsx +510 -0
  461. package/src/renderer/components/MattermostSettings.tsx +473 -0
  462. package/src/renderer/components/MemorySettings.tsx +247 -0
  463. package/src/renderer/components/MentionBadge.tsx +87 -0
  464. package/src/renderer/components/MentionInput.tsx +409 -0
  465. package/src/renderer/components/MentionList.tsx +476 -0
  466. package/src/renderer/components/MissionControlPanel.tsx +1995 -0
  467. package/src/renderer/components/NodesSettings.tsx +316 -0
  468. package/src/renderer/components/NotificationPanel.tsx +481 -0
  469. package/src/renderer/components/Onboarding/AwakeningOrb.tsx +44 -0
  470. package/src/renderer/components/Onboarding/Onboarding.tsx +443 -0
  471. package/src/renderer/components/Onboarding/TypewriterText.tsx +102 -0
  472. package/src/renderer/components/Onboarding/index.ts +3 -0
  473. package/src/renderer/components/OnboardingModal.tsx +698 -0
  474. package/src/renderer/components/PairingCodeDisplay.tsx +324 -0
  475. package/src/renderer/components/PersonalitySettings.tsx +597 -0
  476. package/src/renderer/components/QueueSettings.tsx +119 -0
  477. package/src/renderer/components/QuickTaskFAB.tsx +71 -0
  478. package/src/renderer/components/RightPanel.tsx +413 -0
  479. package/src/renderer/components/ScheduledTasksSettings.tsx +1328 -0
  480. package/src/renderer/components/SearchSettings.tsx +328 -0
  481. package/src/renderer/components/Settings.tsx +1504 -0
  482. package/src/renderer/components/Sidebar.tsx +344 -0
  483. package/src/renderer/components/SignalSettings.tsx +673 -0
  484. package/src/renderer/components/SkillHubBrowser.tsx +458 -0
  485. package/src/renderer/components/SkillParameterModal.tsx +185 -0
  486. package/src/renderer/components/SkillsSettings.tsx +451 -0
  487. package/src/renderer/components/SlackSettings.tsx +442 -0
  488. package/src/renderer/components/StandupReportViewer.tsx +614 -0
  489. package/src/renderer/components/TaskBoard.tsx +498 -0
  490. package/src/renderer/components/TaskBoardCard.tsx +357 -0
  491. package/src/renderer/components/TaskBoardColumn.tsx +211 -0
  492. package/src/renderer/components/TaskLabelManager.tsx +472 -0
  493. package/src/renderer/components/TaskQueuePanel.tsx +144 -0
  494. package/src/renderer/components/TaskQuickActions.tsx +492 -0
  495. package/src/renderer/components/TaskTimeline.tsx +216 -0
  496. package/src/renderer/components/TaskView.tsx +162 -0
  497. package/src/renderer/components/TeamsSettings.tsx +518 -0
  498. package/src/renderer/components/TelegramSettings.tsx +421 -0
  499. package/src/renderer/components/Toast.tsx +76 -0
  500. package/src/renderer/components/TraySettings.tsx +189 -0
  501. package/src/renderer/components/TwitchSettings.tsx +511 -0
  502. package/src/renderer/components/UpdateSettings.tsx +295 -0
  503. package/src/renderer/components/VoiceIndicator.tsx +270 -0
  504. package/src/renderer/components/VoiceSettings.tsx +867 -0
  505. package/src/renderer/components/WhatsAppSettings.tsx +721 -0
  506. package/src/renderer/components/WorkingStateEditor.tsx +309 -0
  507. package/src/renderer/components/WorkingStateHistory.tsx +481 -0
  508. package/src/renderer/components/WorkspaceSelector.tsx +150 -0
  509. package/src/renderer/components/XSettings.tsx +311 -0
  510. package/src/renderer/global.d.ts +9 -0
  511. package/src/renderer/hooks/useAgentContext.ts +153 -0
  512. package/src/renderer/hooks/useOnboardingFlow.ts +548 -0
  513. package/src/renderer/hooks/useVoiceInput.ts +268 -0
  514. package/src/renderer/index.html +12 -0
  515. package/src/renderer/main.tsx +10 -0
  516. package/src/renderer/public/cowork-os-logo.png +0 -0
  517. package/src/renderer/quick-input.html +164 -0
  518. package/src/renderer/styles/index.css +14504 -0
  519. package/src/renderer/utils/agentMessages.ts +749 -0
  520. package/src/renderer/utils/voice-directives.ts +169 -0
  521. package/src/shared/channelMessages.ts +213 -0
  522. package/src/shared/types.ts +3608 -0
  523. package/tsconfig.electron.json +26 -0
  524. package/tsconfig.json +26 -0
  525. package/tsconfig.node.json +10 -0
  526. package/vite.config.ts +23 -0
@@ -0,0 +1,3206 @@
1
+ /**
2
+ * Message Router
3
+ *
4
+ * Routes incoming messages from channels to appropriate handlers.
5
+ * Manages message flow: Security → Session → Task/Response
6
+ */
7
+
8
+ import { BrowserWindow } from 'electron';
9
+ import * as fs from 'fs';
10
+ import * as path from 'path';
11
+ import {
12
+ ChannelAdapter,
13
+ IncomingMessage,
14
+ OutgoingMessage,
15
+ ChannelType,
16
+ GatewayEvent,
17
+ GatewayEventHandler,
18
+ CallbackQuery,
19
+ InlineKeyboardButton,
20
+ } from './channels/types';
21
+ import { TelegramAdapter } from './channels/telegram';
22
+ import { SecurityManager } from './security';
23
+ import { SessionManager } from './session';
24
+ import {
25
+ ChannelRepository,
26
+ ChannelUserRepository,
27
+ ChannelSessionRepository,
28
+ ChannelMessageRepository,
29
+ WorkspaceRepository,
30
+ TaskRepository,
31
+ ArtifactRepository,
32
+ } from '../database/repositories';
33
+ import Database from 'better-sqlite3';
34
+ import { AgentDaemon } from '../agent/daemon';
35
+ import { Task, IPC_CHANNELS, TEMP_WORKSPACE_ID, TEMP_WORKSPACE_NAME, Workspace } from '../../shared/types';
36
+ import * as os from 'os';
37
+ import { LLMProviderFactory, LLMSettings } from '../agent/llm/provider-factory';
38
+ import { ModelKey, LLMProviderType } from '../agent/llm/types';
39
+ import { getCustomSkillLoader } from '../agent/custom-skill-loader';
40
+ import { app } from 'electron';
41
+ import { getVoiceService } from '../voice/VoiceService';
42
+ import { PersonalityManager } from '../settings/personality-manager';
43
+ import {
44
+ getChannelMessage,
45
+ getCompletionMessage,
46
+ DEFAULT_CHANNEL_CONTEXT,
47
+ type ChannelMessageContext,
48
+ } from '../../shared/channelMessages';
49
+ import { DEFAULT_QUIRKS } from '../../shared/types';
50
+
51
+ export interface RouterConfig {
52
+ /** Default workspace ID to use for new sessions */
53
+ defaultWorkspaceId?: string;
54
+ /** Welcome message for new users */
55
+ welcomeMessage?: string;
56
+ /** Message shown when user is not authorized */
57
+ unauthorizedMessage?: string;
58
+ /** Message shown when pairing is required */
59
+ pairingRequiredMessage?: string;
60
+ }
61
+
62
+ const DEFAULT_CONFIG: RouterConfig = {
63
+ welcomeMessage: '👋 Welcome to CoWork! I can help you with tasks in your workspace.',
64
+ unauthorizedMessage: '⚠️ You are not authorized to use this bot. Please contact the administrator.',
65
+ pairingRequiredMessage: '🔐 Please enter your pairing code to get started.',
66
+ };
67
+
68
+ export class MessageRouter {
69
+ private adapters: Map<ChannelType, ChannelAdapter> = new Map();
70
+ private securityManager: SecurityManager;
71
+ private sessionManager: SessionManager;
72
+ private config: RouterConfig;
73
+ private eventHandlers: GatewayEventHandler[] = [];
74
+ private mainWindow: BrowserWindow | null = null;
75
+ private agentDaemon?: AgentDaemon;
76
+ private db: Database.Database;
77
+
78
+ // Repositories
79
+ private channelRepo: ChannelRepository;
80
+ private userRepo: ChannelUserRepository;
81
+ private sessionRepo: ChannelSessionRepository;
82
+ private messageRepo: ChannelMessageRepository;
83
+ private workspaceRepo: WorkspaceRepository;
84
+ private taskRepo: TaskRepository;
85
+ private artifactRepo: ArtifactRepository;
86
+
87
+ // Track pending responses for tasks
88
+ private pendingTaskResponses: Map<string, {
89
+ adapter: ChannelAdapter;
90
+ chatId: string;
91
+ sessionId: string;
92
+ originalMessageId?: string; // For reaction updates
93
+ }> = new Map();
94
+
95
+ // Track pending approval requests for Discord/Telegram
96
+ private pendingApprovals: Map<string, { taskId: string; approval: any; sessionId: string }> = new Map();
97
+
98
+ constructor(db: Database.Database, config: RouterConfig = {}, agentDaemon?: AgentDaemon) {
99
+ this.db = db;
100
+ this.config = { ...DEFAULT_CONFIG, ...config };
101
+ this.agentDaemon = agentDaemon;
102
+
103
+ // Initialize repositories
104
+ this.channelRepo = new ChannelRepository(db);
105
+ this.userRepo = new ChannelUserRepository(db);
106
+ this.sessionRepo = new ChannelSessionRepository(db);
107
+ this.messageRepo = new ChannelMessageRepository(db);
108
+ this.workspaceRepo = new WorkspaceRepository(db);
109
+ this.taskRepo = new TaskRepository(db);
110
+ this.artifactRepo = new ArtifactRepository(db);
111
+
112
+ // Initialize managers
113
+ this.securityManager = new SecurityManager(db);
114
+ this.sessionManager = new SessionManager(db);
115
+
116
+ // Listen for task events if agent daemon is available
117
+ if (this.agentDaemon) {
118
+ this.setupTaskEventListener();
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Set up listener for task events to send responses back to channels
124
+ */
125
+ private setupTaskEventListener(): void {
126
+ // We'll listen for task events through BrowserWindow IPC
127
+ // The agent daemon emits events to all windows
128
+ }
129
+
130
+ /**
131
+ * Set the main window for sending IPC events
132
+ */
133
+ setMainWindow(window: BrowserWindow): void {
134
+ this.mainWindow = window;
135
+ }
136
+
137
+ /**
138
+ * Get the main window for sending IPC events
139
+ */
140
+ getMainWindow(): BrowserWindow | null {
141
+ return this.mainWindow;
142
+ }
143
+
144
+ /**
145
+ * Get the channel message context from personality settings
146
+ */
147
+ private getMessageContext(): ChannelMessageContext {
148
+ try {
149
+ if (PersonalityManager.isInitialized()) {
150
+ const settings = PersonalityManager.loadSettings();
151
+ return {
152
+ agentName: settings.agentName || 'CoWork',
153
+ userName: settings.relationship?.userName,
154
+ personality: settings.activePersonality || 'professional',
155
+ emojiUsage: settings.responseStyle?.emojiUsage || 'minimal',
156
+ quirks: settings.quirks || DEFAULT_QUIRKS,
157
+ };
158
+ }
159
+ } catch (error) {
160
+ console.error('[MessageRouter] Failed to load personality settings:', error);
161
+ }
162
+ return DEFAULT_CHANNEL_CONTEXT;
163
+ }
164
+
165
+ /**
166
+ * Get or create the temp workspace for sessions without a workspace
167
+ */
168
+ private getOrCreateTempWorkspace(): Workspace {
169
+ // Check if temp workspace exists
170
+ const existing = this.workspaceRepo.findById(TEMP_WORKSPACE_ID);
171
+ if (existing) {
172
+ const updatedPermissions = {
173
+ ...existing.permissions,
174
+ read: true,
175
+ write: true,
176
+ delete: true,
177
+ network: true,
178
+ shell: existing.permissions.shell ?? false,
179
+ unrestrictedFileAccess: true,
180
+ };
181
+
182
+ if (!existing.permissions.unrestrictedFileAccess) {
183
+ this.workspaceRepo.updatePermissions(existing.id, updatedPermissions);
184
+ }
185
+
186
+ // Verify directory exists
187
+ if (fs.existsSync(existing.path)) {
188
+ return { ...existing, permissions: updatedPermissions, isTemp: true };
189
+ }
190
+ // Directory was deleted, recreate it
191
+ const tempDir = path.join(os.tmpdir(), 'cowork-os-temp');
192
+ fs.mkdirSync(tempDir, { recursive: true });
193
+ return { ...existing, permissions: updatedPermissions, isTemp: true };
194
+ }
195
+
196
+ // Create temp directory
197
+ const tempDir = path.join(os.tmpdir(), 'cowork-os-temp');
198
+ fs.mkdirSync(tempDir, { recursive: true });
199
+
200
+ // Create workspace record
201
+ const tempWorkspace: Workspace = {
202
+ id: TEMP_WORKSPACE_ID,
203
+ name: TEMP_WORKSPACE_NAME,
204
+ path: tempDir,
205
+ createdAt: Date.now(),
206
+ permissions: {
207
+ read: true,
208
+ write: true,
209
+ delete: true,
210
+ network: true,
211
+ shell: false,
212
+ unrestrictedFileAccess: true,
213
+ },
214
+ isTemp: true,
215
+ };
216
+
217
+ const stmt = this.db.prepare(`
218
+ INSERT OR REPLACE INTO workspaces (id, name, path, created_at, permissions)
219
+ VALUES (?, ?, ?, ?, ?)
220
+ `);
221
+ stmt.run(
222
+ tempWorkspace.id,
223
+ tempWorkspace.name,
224
+ tempWorkspace.path,
225
+ tempWorkspace.createdAt,
226
+ JSON.stringify(tempWorkspace.permissions)
227
+ );
228
+
229
+ return tempWorkspace;
230
+ }
231
+
232
+ /**
233
+ * Register a channel adapter
234
+ */
235
+ registerAdapter(adapter: ChannelAdapter): void {
236
+ // Set up message handler
237
+ adapter.onMessage(async (message) => {
238
+ await this.handleMessage(adapter, message);
239
+ });
240
+
241
+ // Set up callback query handler for inline keyboards
242
+ if (adapter.onCallbackQuery) {
243
+ adapter.onCallbackQuery(async (query) => {
244
+ await this.handleCallbackQuery(adapter, query);
245
+ });
246
+ }
247
+
248
+ // Set up error handler
249
+ adapter.onError((error, context) => {
250
+ console.error(`[${adapter.type}] Error in ${context}:`, error);
251
+ this.emitEvent({
252
+ type: 'channel:error',
253
+ channel: adapter.type,
254
+ timestamp: new Date(),
255
+ data: { error: error.message, context },
256
+ });
257
+ });
258
+
259
+ // Set up status handler
260
+ adapter.onStatusChange((status, error) => {
261
+ const eventType = status === 'connected' ? 'channel:connected' : 'channel:disconnected';
262
+ this.emitEvent({
263
+ type: eventType,
264
+ channel: adapter.type,
265
+ timestamp: new Date(),
266
+ data: { status, error: error?.message },
267
+ });
268
+
269
+ // Update channel status in database
270
+ const channel = this.channelRepo.findByType(adapter.type);
271
+ if (channel) {
272
+ this.channelRepo.update(channel.id, {
273
+ status,
274
+ botUsername: adapter.botUsername,
275
+ });
276
+ }
277
+ });
278
+
279
+ this.adapters.set(adapter.type, adapter);
280
+ }
281
+
282
+ /**
283
+ * Get a registered adapter
284
+ */
285
+ getAdapter(type: ChannelType): ChannelAdapter | undefined {
286
+ return this.adapters.get(type);
287
+ }
288
+
289
+ /**
290
+ * Get all registered adapters
291
+ */
292
+ getAllAdapters(): ChannelAdapter[] {
293
+ return Array.from(this.adapters.values());
294
+ }
295
+
296
+ /**
297
+ * Connect all enabled adapters
298
+ */
299
+ async connectAll(): Promise<void> {
300
+ const enabledChannels = this.channelRepo.findEnabled();
301
+
302
+ for (const channel of enabledChannels) {
303
+ const adapter = this.adapters.get(channel.type as ChannelType);
304
+ if (adapter && adapter.status !== 'connected') {
305
+ try {
306
+ await adapter.connect();
307
+ } catch (error) {
308
+ console.error(`Failed to connect ${channel.type}:`, error);
309
+ }
310
+ }
311
+ }
312
+ }
313
+
314
+ /**
315
+ * Disconnect all adapters
316
+ */
317
+ async disconnectAll(): Promise<void> {
318
+ for (const adapter of this.adapters.values()) {
319
+ if (adapter.status === 'connected') {
320
+ try {
321
+ await adapter.disconnect();
322
+ } catch (error) {
323
+ console.error(`Failed to disconnect ${adapter.type}:`, error);
324
+ }
325
+ }
326
+ }
327
+ }
328
+
329
+ /**
330
+ * Send a message through a channel
331
+ */
332
+ async sendMessage(channelType: ChannelType, message: OutgoingMessage): Promise<string> {
333
+ const adapter = this.adapters.get(channelType);
334
+ if (!adapter) {
335
+ throw new Error(`No adapter registered for channel type: ${channelType}`);
336
+ }
337
+
338
+ if (adapter.status !== 'connected') {
339
+ throw new Error(`Adapter ${channelType} is not connected`);
340
+ }
341
+
342
+ const messageId = await adapter.sendMessage(message);
343
+
344
+ // Find channel for logging
345
+ const channel = this.channelRepo.findByType(channelType);
346
+ if (channel) {
347
+ // Log outgoing message
348
+ this.messageRepo.create({
349
+ channelId: channel.id,
350
+ channelMessageId: messageId,
351
+ chatId: message.chatId,
352
+ direction: 'outgoing',
353
+ content: message.text,
354
+ timestamp: Date.now(),
355
+ });
356
+
357
+ this.emitEvent({
358
+ type: 'message:sent',
359
+ channel: channelType,
360
+ timestamp: new Date(),
361
+ data: { chatId: message.chatId, messageId },
362
+ });
363
+ }
364
+
365
+ return messageId;
366
+ }
367
+
368
+ /**
369
+ * Register an event handler
370
+ */
371
+ onEvent(handler: GatewayEventHandler): void {
372
+ this.eventHandlers.push(handler);
373
+ }
374
+
375
+ // Private methods
376
+
377
+ /**
378
+ * Transcribe audio attachments in a message
379
+ * Downloads audio from URL or uses buffer, transcribes via VoiceService
380
+ * Saves audio file to a temp folder for transcription and sets message text to include full transcript with context
381
+ */
382
+ private async transcribeAudioAttachments(message: IncomingMessage, workspacePath?: string): Promise<void> {
383
+ if (!message.attachments || message.attachments.length === 0) {
384
+ return;
385
+ }
386
+
387
+ const audioAttachments = message.attachments.filter(a => a.type === 'audio');
388
+ if (audioAttachments.length === 0) {
389
+ return;
390
+ }
391
+
392
+ const voiceService = getVoiceService();
393
+
394
+ // Check if transcription is available
395
+ if (!voiceService.isTranscriptionAvailable()) {
396
+ console.log('[Router] Audio transcription not available - no STT provider configured');
397
+ // Add placeholder for audio messages
398
+ for (const attachment of audioAttachments) {
399
+ const fileName = attachment.fileName || 'voice message';
400
+ message.text += message.text ? `\n[Audio: ${fileName} - transcription unavailable]` : `[Audio: ${fileName} - transcription unavailable]`;
401
+ }
402
+ return;
403
+ }
404
+
405
+ console.log(`[Router] Transcribing ${audioAttachments.length} audio attachment(s)...`);
406
+
407
+ for (const attachment of audioAttachments) {
408
+ let savedAudioPath: string | undefined;
409
+ try {
410
+ let audioBuffer: Buffer | undefined;
411
+
412
+ // Get audio data from buffer or file
413
+ if (attachment.data) {
414
+ audioBuffer = attachment.data;
415
+ } else if (attachment.url) {
416
+ // Check if it's a local file path
417
+ if (attachment.url.startsWith('/') || attachment.url.startsWith('file://')) {
418
+ const filePath = attachment.url.replace('file://', '');
419
+ if (fs.existsSync(filePath)) {
420
+ audioBuffer = fs.readFileSync(filePath);
421
+ }
422
+ } else if (attachment.url.startsWith('http')) {
423
+ // Download from URL
424
+ try {
425
+ const response = await fetch(attachment.url);
426
+ if (response.ok) {
427
+ const arrayBuffer = await response.arrayBuffer();
428
+ audioBuffer = Buffer.from(arrayBuffer);
429
+ }
430
+ } catch (fetchError) {
431
+ console.error('[Router] Failed to download audio:', fetchError);
432
+ }
433
+ }
434
+ }
435
+
436
+ if (!audioBuffer || audioBuffer.length === 0) {
437
+ console.log('[Router] No audio data available for transcription');
438
+ const fileName = attachment.fileName || 'voice message';
439
+ message.text += message.text ? `\n[Audio: ${fileName} - could not load]` : `[Audio: ${fileName} - could not load]`;
440
+ continue;
441
+ }
442
+
443
+ // Save audio file to temp directory for transcription
444
+ try {
445
+ const tempDir = path.join(os.tmpdir(), 'cowork-audio');
446
+ if (!fs.existsSync(tempDir)) {
447
+ fs.mkdirSync(tempDir, { recursive: true });
448
+ }
449
+ const audioFileName = attachment.fileName || `voice_message_${Date.now()}.ogg`;
450
+ savedAudioPath = path.join(tempDir, audioFileName);
451
+ fs.writeFileSync(savedAudioPath, audioBuffer);
452
+ console.log(`[Router] Saved audio file to: ${savedAudioPath}`);
453
+ } catch (saveError) {
454
+ console.error('[Router] Failed to save audio file:', saveError);
455
+ }
456
+
457
+ // Transcribe the audio
458
+ const transcript = await voiceService.transcribe(audioBuffer, { force: true });
459
+
460
+ if (transcript && transcript.trim()) {
461
+ console.log(`[Router] Transcribed audio: "${transcript.substring(0, 100)}${transcript.length > 100 ? '...' : ''}"`);
462
+
463
+ // Create a structured message with the full transcript
464
+ // This ensures the agent knows it's a voice message and has the complete transcript
465
+ const voiceMessageContext = [
466
+ '📢 **Voice Message Received**',
467
+ '',
468
+ 'The user sent a voice message. Here is the complete transcription:',
469
+ '',
470
+ '---',
471
+ transcript,
472
+ '---',
473
+ '',
474
+ 'Please respond to the user\'s voice message above.',
475
+ ].filter(line => line !== undefined).join('\n');
476
+
477
+ // Append or set the transcribed text with context
478
+ if (message.text && message.text.trim()) {
479
+ message.text += `\n\n${voiceMessageContext}`;
480
+ } else {
481
+ message.text = voiceMessageContext;
482
+ }
483
+ } else {
484
+ const fileName = attachment.fileName || 'voice message';
485
+ message.text += message.text ? `\n[Audio: ${fileName} - no speech detected]` : `[Audio: ${fileName} - no speech detected]`;
486
+ }
487
+ } catch (error) {
488
+ console.error('[Router] Failed to transcribe audio:', error);
489
+ const fileName = attachment.fileName || 'voice message';
490
+ message.text += message.text ? `\n[Audio: ${fileName} - transcription failed]` : `[Audio: ${fileName} - transcription failed]`;
491
+ } finally {
492
+ if (savedAudioPath && fs.existsSync(savedAudioPath)) {
493
+ try {
494
+ fs.unlinkSync(savedAudioPath);
495
+ } catch (cleanupError) {
496
+ console.error('[Router] Failed to delete temp audio file:', cleanupError);
497
+ }
498
+ }
499
+ }
500
+ }
501
+ }
502
+
503
+ /**
504
+ * Handle an incoming message
505
+ */
506
+ private async handleMessage(adapter: ChannelAdapter, message: IncomingMessage): Promise<void> {
507
+ const channelType = adapter.type;
508
+ const channel = this.channelRepo.findByType(channelType);
509
+
510
+ if (!channel) {
511
+ console.error(`No channel configuration found for ${channelType}`);
512
+ return;
513
+ }
514
+
515
+ // Transcribe any audio attachments before processing
516
+ await this.transcribeAudioAttachments(message);
517
+
518
+ // Log incoming message
519
+ this.messageRepo.create({
520
+ channelId: channel.id,
521
+ channelMessageId: message.messageId,
522
+ chatId: message.chatId,
523
+ direction: 'incoming',
524
+ content: message.text,
525
+ timestamp: message.timestamp.getTime(),
526
+ });
527
+
528
+ this.emitEvent({
529
+ type: 'message:received',
530
+ channel: channelType,
531
+ timestamp: new Date(),
532
+ data: {
533
+ messageId: message.messageId,
534
+ chatId: message.chatId,
535
+ userId: message.userId,
536
+ preview: message.text.slice(0, 100),
537
+ },
538
+ });
539
+
540
+ // Security check
541
+ const securityResult = await this.securityManager.checkAccess(channel, message);
542
+
543
+ if (!securityResult.allowed) {
544
+ // Handle unauthorized access
545
+ await this.handleUnauthorizedMessage(adapter, message, securityResult);
546
+ return;
547
+ }
548
+
549
+ // Update user's last seen
550
+ if (securityResult.user) {
551
+ this.userRepo.update(securityResult.user.id, {
552
+ lastSeenAt: Date.now(),
553
+ });
554
+ }
555
+
556
+ // Get or create session
557
+ const session = await this.sessionManager.getOrCreateSession(
558
+ channel,
559
+ message.chatId,
560
+ securityResult.user?.id,
561
+ this.config.defaultWorkspaceId
562
+ );
563
+
564
+ // Handle the message based on content
565
+ await this.routeMessage(adapter, message, session.id);
566
+ }
567
+
568
+ /**
569
+ * Handle unauthorized message
570
+ */
571
+ private async handleUnauthorizedMessage(
572
+ adapter: ChannelAdapter,
573
+ message: IncomingMessage,
574
+ securityResult: { reason?: string; pairingRequired?: boolean }
575
+ ): Promise<void> {
576
+ // If pairing is required, check if the message IS a pairing code or /pair command
577
+ if (securityResult.pairingRequired) {
578
+ const text = message.text.trim();
579
+
580
+ // Check if it's a /pair command
581
+ if (text.toLowerCase().startsWith('/pair ')) {
582
+ const code = text.slice(6).trim(); // Remove '/pair ' prefix
583
+ if (code) {
584
+ await this.handlePairingAttempt(adapter, message, code);
585
+ return;
586
+ }
587
+ }
588
+
589
+ // Check if the raw text looks like a pairing code
590
+ if (this.looksLikePairingCode(text)) {
591
+ // This looks like a pairing code - try to verify it
592
+ await this.handlePairingAttempt(adapter, message, text);
593
+ return;
594
+ }
595
+ }
596
+
597
+ // Not a pairing code or pairing not required - send appropriate message
598
+ let responseText: string;
599
+
600
+ if (securityResult.pairingRequired) {
601
+ responseText = this.config.pairingRequiredMessage!;
602
+ } else {
603
+ responseText = this.config.unauthorizedMessage!;
604
+ }
605
+
606
+ try {
607
+ await adapter.sendMessage({
608
+ chatId: message.chatId,
609
+ text: responseText,
610
+ replyTo: message.messageId,
611
+ });
612
+ } catch (error) {
613
+ console.error('Failed to send unauthorized message response:', error);
614
+ }
615
+ }
616
+
617
+ /**
618
+ * Route message to appropriate handler
619
+ */
620
+ private async routeMessage(
621
+ adapter: ChannelAdapter,
622
+ message: IncomingMessage,
623
+ sessionId: string
624
+ ): Promise<void> {
625
+ const text = message.text.trim();
626
+
627
+ // Handle commands
628
+ if (text.startsWith('/')) {
629
+ await this.handleCommand(adapter, message, sessionId);
630
+ return;
631
+ }
632
+
633
+ // Check if this is a pairing code
634
+ if (this.looksLikePairingCode(text)) {
635
+ await this.handlePairingAttempt(adapter, message, text);
636
+ return;
637
+ }
638
+
639
+ // Check if session has no workspace - might be workspace selection
640
+ const session = this.sessionRepo.findById(sessionId);
641
+ if (!session?.workspaceId) {
642
+ // Check if this looks like workspace selection (number or short name)
643
+ const workspaces = this.workspaceRepo.findAll();
644
+ if (workspaces.length > 0) {
645
+ // Try to match by number
646
+ const num = parseInt(text, 10);
647
+ if (!isNaN(num) && num > 0 && num <= workspaces.length) {
648
+ const workspace = workspaces[num - 1];
649
+ this.sessionManager.setSessionWorkspace(sessionId, workspace.id);
650
+ await adapter.sendMessage({
651
+ chatId: message.chatId,
652
+ text: `✅ *${workspace.name}* selected!\n\nYou can now send me tasks.\n\nExample: "Create a new React component called Button"`,
653
+ parseMode: 'markdown',
654
+ });
655
+ return;
656
+ }
657
+
658
+ // Try to match by name (case-insensitive partial match)
659
+ const matchedWorkspace = workspaces.find(
660
+ ws => ws.name.toLowerCase() === text.toLowerCase() ||
661
+ ws.name.toLowerCase().startsWith(text.toLowerCase())
662
+ );
663
+ if (matchedWorkspace) {
664
+ this.sessionManager.setSessionWorkspace(sessionId, matchedWorkspace.id);
665
+ await adapter.sendMessage({
666
+ chatId: message.chatId,
667
+ text: `✅ *${matchedWorkspace.name}* selected!\n\nYou can now send me tasks.\n\nExample: "Create a new React component called Button"`,
668
+ parseMode: 'markdown',
669
+ });
670
+ return;
671
+ }
672
+ }
673
+
674
+ // No workspace match found - auto-assign temp workspace so tasks can proceed
675
+ const tempWorkspace = this.getOrCreateTempWorkspace();
676
+ this.sessionManager.setSessionWorkspace(sessionId, tempWorkspace.id);
677
+ }
678
+
679
+ // Regular message - send to desktop app for task processing
680
+ await this.forwardToDesktopApp(adapter, message, sessionId);
681
+ }
682
+
683
+ /**
684
+ * Handle bot commands
685
+ */
686
+ private async handleCommand(
687
+ adapter: ChannelAdapter,
688
+ message: IncomingMessage,
689
+ sessionId: string
690
+ ): Promise<void> {
691
+ const [command, ...args] = message.text.trim().split(/\s+/);
692
+
693
+ switch (command.toLowerCase()) {
694
+ case '/start':
695
+ await this.handleStartCommand(adapter, message, sessionId);
696
+ break;
697
+
698
+ case '/help':
699
+ await adapter.sendMessage({
700
+ chatId: message.chatId,
701
+ text: this.getHelpText(adapter.type),
702
+ parseMode: 'markdown',
703
+ });
704
+ break;
705
+
706
+ case '/status':
707
+ await this.handleStatusCommand(adapter, message, sessionId);
708
+ break;
709
+
710
+ case '/workspaces':
711
+ await this.handleWorkspacesCommand(adapter, message);
712
+ break;
713
+
714
+ case '/workspace':
715
+ await this.handleWorkspaceCommand(adapter, message, sessionId, args);
716
+ break;
717
+
718
+ case '/cancel':
719
+ // Cancel current task if any
720
+ await this.handleCancelCommand(adapter, message, sessionId);
721
+ break;
722
+
723
+ case '/newtask':
724
+ // Start a new task (unlink current session)
725
+ await this.handleNewTaskCommand(adapter, message, sessionId);
726
+ break;
727
+
728
+ case '/addworkspace':
729
+ await this.handleAddWorkspaceCommand(adapter, message, sessionId, args);
730
+ break;
731
+
732
+ case '/models':
733
+ await this.handleModelsCommand(adapter, message);
734
+ break;
735
+
736
+ case '/model':
737
+ await this.handleModelCommand(adapter, message, args);
738
+ break;
739
+
740
+ case '/provider':
741
+ await this.handleProviderCommand(adapter, message, args);
742
+ break;
743
+
744
+ case '/pair':
745
+ // Handle pairing code
746
+ if (args.length === 0) {
747
+ await adapter.sendMessage({
748
+ chatId: message.chatId,
749
+ text: '🔐 Please provide a pairing code.\n\nUsage: `/pair <code>`',
750
+ parseMode: 'markdown',
751
+ });
752
+ } else {
753
+ const code = args[0].trim();
754
+ await this.handlePairingAttempt(adapter, message, code);
755
+ }
756
+ break;
757
+
758
+ case '/shell':
759
+ await this.handleShellCommand(adapter, message, sessionId, args);
760
+ break;
761
+
762
+ case '/approve':
763
+ case '/yes':
764
+ case '/y':
765
+ await this.handleApproveCommand(adapter, message, sessionId);
766
+ break;
767
+
768
+ case '/deny':
769
+ case '/no':
770
+ case '/n':
771
+ await this.handleDenyCommand(adapter, message, sessionId);
772
+ break;
773
+
774
+ case '/queue':
775
+ await this.handleQueueCommand(adapter, message, args);
776
+ break;
777
+
778
+ case '/removeworkspace':
779
+ await this.handleRemoveWorkspaceCommand(adapter, message, sessionId, args);
780
+ break;
781
+
782
+ case '/retry':
783
+ await this.handleRetryCommand(adapter, message, sessionId);
784
+ break;
785
+
786
+ case '/history':
787
+ await this.handleHistoryCommand(adapter, message, sessionId);
788
+ break;
789
+
790
+ case '/skills':
791
+ await this.handleSkillsCommand(adapter, message, sessionId);
792
+ break;
793
+
794
+ case '/skill':
795
+ await this.handleSkillCommand(adapter, message, sessionId, args);
796
+ break;
797
+
798
+ case '/providers':
799
+ await this.handleProvidersCommand(adapter, message);
800
+ break;
801
+
802
+ case '/settings':
803
+ await this.handleSettingsCommand(adapter, message, sessionId);
804
+ break;
805
+
806
+ case '/debug':
807
+ await this.handleDebugCommand(adapter, message, sessionId);
808
+ break;
809
+
810
+ case '/version':
811
+ await this.handleVersionCommand(adapter, message);
812
+ break;
813
+
814
+ default:
815
+ await adapter.sendMessage({
816
+ chatId: message.chatId,
817
+ text: `Unknown command: ${command}\n\nUse /help to see available commands.`,
818
+ replyTo: message.messageId,
819
+ });
820
+ }
821
+ }
822
+
823
+ /**
824
+ * Handle /status command
825
+ */
826
+ private async handleStatusCommand(
827
+ adapter: ChannelAdapter,
828
+ message: IncomingMessage,
829
+ sessionId: string
830
+ ): Promise<void> {
831
+ const session = this.sessionRepo.findById(sessionId);
832
+ let statusText = '✅ Bot is online and ready.\n\n';
833
+
834
+ if (session?.workspaceId) {
835
+ const workspace = this.workspaceRepo.findById(session.workspaceId);
836
+ if (workspace) {
837
+ statusText += `📁 Current workspace: ${workspace.name}\n`;
838
+ statusText += ` Path: ${workspace.path}\n`;
839
+ }
840
+ } else {
841
+ statusText += '⚠️ No workspace selected. Use /workspaces to see available workspaces.';
842
+ }
843
+
844
+ if (session?.taskId) {
845
+ const task = this.taskRepo.findById(session.taskId);
846
+ if (task) {
847
+ statusText += `\n🔄 Active task: ${task.title} (${task.status})`;
848
+ }
849
+ }
850
+
851
+ await adapter.sendMessage({
852
+ chatId: message.chatId,
853
+ text: statusText,
854
+ });
855
+ }
856
+
857
+ /**
858
+ * Handle /workspaces command - list available workspaces
859
+ */
860
+ private async handleWorkspacesCommand(
861
+ adapter: ChannelAdapter,
862
+ message: IncomingMessage
863
+ ): Promise<void> {
864
+ const workspaces = this.workspaceRepo.findAll();
865
+
866
+ if (workspaces.length === 0) {
867
+ await adapter.sendMessage({
868
+ chatId: message.chatId,
869
+ text: '📁 No workspaces configured yet.\n\nAdd a workspace in the CoWork desktop app first, or use:\n`/addworkspace /path/to/your/project`',
870
+ parseMode: 'markdown',
871
+ });
872
+ return;
873
+ }
874
+
875
+ // WhatsApp and iMessage don't support inline keyboards - use text-based selection
876
+ if (adapter.type === 'whatsapp' || adapter.type === 'imessage') {
877
+ let text = '📁 *Available Workspaces*\n\n';
878
+ workspaces.forEach((ws, index) => {
879
+ text += `${index + 1}. *${ws.name}*\n \`${ws.path}\`\n\n`;
880
+ });
881
+ text += '━━━━━━━━━━━━━━━\n';
882
+ text += 'Reply with the number or name to select.\n';
883
+ text += 'Example: `1` or `myproject`';
884
+
885
+ await adapter.sendMessage({
886
+ chatId: message.chatId,
887
+ text,
888
+ parseMode: 'markdown',
889
+ });
890
+ return;
891
+ }
892
+
893
+ // Build inline keyboard with workspace buttons for Telegram/Discord
894
+ const keyboard: InlineKeyboardButton[][] = [];
895
+ for (const ws of workspaces) {
896
+ // Create one button per row for better readability
897
+ keyboard.push([{
898
+ text: `📁 ${ws.name}`,
899
+ callbackData: `workspace:${ws.id}`,
900
+ }]);
901
+ }
902
+
903
+ let text = '📁 *Available Workspaces*\n\nTap a workspace to select it:';
904
+
905
+ await adapter.sendMessage({
906
+ chatId: message.chatId,
907
+ text,
908
+ parseMode: 'markdown',
909
+ inlineKeyboard: keyboard,
910
+ threadId: message.threadId,
911
+ });
912
+ }
913
+
914
+ /**
915
+ * Handle /workspace command - set current workspace
916
+ */
917
+ private async handleWorkspaceCommand(
918
+ adapter: ChannelAdapter,
919
+ message: IncomingMessage,
920
+ sessionId: string,
921
+ args: string[]
922
+ ): Promise<void> {
923
+ if (args.length === 0) {
924
+ // Show current workspace
925
+ let session = this.sessionRepo.findById(sessionId);
926
+
927
+ // Auto-assign temp workspace if none selected
928
+ if (!session?.workspaceId) {
929
+ const tempWorkspace = this.getOrCreateTempWorkspace();
930
+ this.sessionRepo.update(sessionId, { workspaceId: tempWorkspace.id });
931
+ session = this.sessionRepo.findById(sessionId);
932
+ }
933
+
934
+ if (session?.workspaceId) {
935
+ const workspace = this.workspaceRepo.findById(session.workspaceId);
936
+ if (workspace) {
937
+ const isTempWorkspace = workspace.id === TEMP_WORKSPACE_ID;
938
+ const displayName = isTempWorkspace ? 'Temporary Workspace (work in a folder for persistence)' : workspace.name;
939
+ await adapter.sendMessage({
940
+ chatId: message.chatId,
941
+ text: `📁 Current workspace: *${displayName}*\n\`${workspace.path}\`\n\nUse \`/workspaces\` to see available workspaces.`,
942
+ parseMode: 'markdown',
943
+ });
944
+ return;
945
+ }
946
+ }
947
+ await adapter.sendMessage({
948
+ chatId: message.chatId,
949
+ text: 'No workspace selected. Use `/workspaces` to see available workspaces.',
950
+ parseMode: 'markdown',
951
+ });
952
+ return;
953
+ }
954
+
955
+ const workspaces = this.workspaceRepo.findAll();
956
+ const selector = args.join(' ');
957
+ let workspace;
958
+
959
+ // Try to find by number
960
+ const num = parseInt(selector, 10);
961
+ if (!isNaN(num) && num > 0 && num <= workspaces.length) {
962
+ workspace = workspaces[num - 1];
963
+ } else {
964
+ // Try to find by name (case-insensitive)
965
+ workspace = workspaces.find(
966
+ ws => ws.name.toLowerCase() === selector.toLowerCase()
967
+ );
968
+ }
969
+
970
+ if (!workspace) {
971
+ await adapter.sendMessage({
972
+ chatId: message.chatId,
973
+ text: `❌ Workspace not found: "${selector}"\n\nUse /workspaces to see available workspaces.`,
974
+ });
975
+ return;
976
+ }
977
+
978
+ // Update session workspace
979
+ this.sessionManager.setSessionWorkspace(sessionId, workspace.id);
980
+
981
+ await adapter.sendMessage({
982
+ chatId: message.chatId,
983
+ text: `✅ Workspace set to: *${workspace.name}*\n\`${workspace.path}\`\n\nYou can now send messages to create tasks in this workspace.`,
984
+ parseMode: 'markdown',
985
+ });
986
+ }
987
+
988
+ /**
989
+ * Handle /addworkspace command - add a new workspace by path
990
+ */
991
+ private async handleAddWorkspaceCommand(
992
+ adapter: ChannelAdapter,
993
+ message: IncomingMessage,
994
+ sessionId: string,
995
+ args: string[]
996
+ ): Promise<void> {
997
+ if (args.length === 0) {
998
+ await adapter.sendMessage({
999
+ chatId: message.chatId,
1000
+ text: '📁 *Add Workspace*\n\nUsage: `/addworkspace <path>`\n\nExample:\n`/addworkspace /Users/john/projects/myapp`\n`/addworkspace ~/Documents`',
1001
+ parseMode: 'markdown',
1002
+ });
1003
+ return;
1004
+ }
1005
+
1006
+ // Join args to handle paths with spaces
1007
+ let workspacePath = args.join(' ');
1008
+
1009
+ // Expand ~ to home directory
1010
+ if (workspacePath.startsWith('~')) {
1011
+ const homeDir = process.env.HOME || process.env.USERPROFILE || '';
1012
+ workspacePath = workspacePath.replace('~', homeDir);
1013
+ }
1014
+
1015
+ // Resolve to absolute path
1016
+ workspacePath = path.resolve(workspacePath);
1017
+
1018
+ // Check if path exists and is a directory
1019
+ try {
1020
+ const stats = fs.statSync(workspacePath);
1021
+ if (!stats.isDirectory()) {
1022
+ await adapter.sendMessage({
1023
+ chatId: message.chatId,
1024
+ text: `❌ Path is not a directory: \`${workspacePath}\``,
1025
+ parseMode: 'markdown',
1026
+ });
1027
+ return;
1028
+ }
1029
+ } catch {
1030
+ await adapter.sendMessage({
1031
+ chatId: message.chatId,
1032
+ text: `❌ Directory not found: \`${workspacePath}\``,
1033
+ parseMode: 'markdown',
1034
+ });
1035
+ return;
1036
+ }
1037
+
1038
+ // Check if workspace already exists
1039
+ const existingWorkspaces = this.workspaceRepo.findAll();
1040
+ const existing = existingWorkspaces.find(ws => ws.path === workspacePath);
1041
+ if (existing) {
1042
+ // Workspace exists, just select it
1043
+ this.sessionManager.setSessionWorkspace(sessionId, existing.id);
1044
+ await adapter.sendMessage({
1045
+ chatId: message.chatId,
1046
+ text: `📁 Workspace already exists!\n\n✅ Selected: *${existing.name}*\n\`${existing.path}\``,
1047
+ parseMode: 'markdown',
1048
+ });
1049
+ return;
1050
+ }
1051
+
1052
+ // Create workspace name from path
1053
+ const workspaceName = path.basename(workspacePath);
1054
+
1055
+ // Create new workspace with default permissions
1056
+ // Note: network is enabled by default for browser tools (web access)
1057
+ const workspace = this.workspaceRepo.create(
1058
+ workspaceName,
1059
+ workspacePath,
1060
+ {
1061
+ read: true,
1062
+ write: true,
1063
+ delete: false, // Requires approval
1064
+ network: true,
1065
+ shell: false, // Requires approval
1066
+ }
1067
+ );
1068
+
1069
+ // Set as current workspace
1070
+ this.sessionManager.setSessionWorkspace(sessionId, workspace.id);
1071
+
1072
+ // Notify desktop app
1073
+ if (this.mainWindow && !this.mainWindow.isDestroyed()) {
1074
+ this.mainWindow.webContents.send('workspace:added', {
1075
+ id: workspace.id,
1076
+ name: workspace.name,
1077
+ path: workspace.path,
1078
+ });
1079
+ }
1080
+
1081
+ await adapter.sendMessage({
1082
+ chatId: message.chatId,
1083
+ text: `✅ Workspace added and selected!\n\n📁 *${workspace.name}*\n\`${workspace.path}\`\n\nYou can now send messages to create tasks in this workspace.`,
1084
+ parseMode: 'markdown',
1085
+ });
1086
+ }
1087
+
1088
+ /**
1089
+ * Handle /models command - list available models and providers
1090
+ */
1091
+ private async handleModelsCommand(
1092
+ adapter: ChannelAdapter,
1093
+ message: IncomingMessage
1094
+ ): Promise<void> {
1095
+ const status = LLMProviderFactory.getConfigStatus();
1096
+ const settings = LLMProviderFactory.loadSettings();
1097
+ const providerType = status.currentProvider;
1098
+
1099
+ let text = '🤖 *AI Models & Providers*\n\n';
1100
+
1101
+ // Get provider-specific models and current model
1102
+ let models: Array<{ key: string; displayName: string }> = [];
1103
+ let currentModel = settings.modelKey;
1104
+
1105
+ // Provider display names
1106
+ const providerModelNames: Record<string, string> = {
1107
+ 'anthropic': 'Claude',
1108
+ 'bedrock': 'Claude',
1109
+ 'openai': 'OpenAI',
1110
+ 'gemini': 'Gemini',
1111
+ 'openrouter': 'OpenRouter',
1112
+ 'ollama': 'Ollama',
1113
+ };
1114
+
1115
+ // Get models based on current provider
1116
+ switch (providerType) {
1117
+ case 'anthropic':
1118
+ case 'bedrock':
1119
+ models = status.models;
1120
+ break;
1121
+
1122
+ case 'openai': {
1123
+ currentModel = settings.openai?.model || 'gpt-4o-mini';
1124
+ const cachedOpenAI = LLMProviderFactory.getCachedModels('openai');
1125
+ if (cachedOpenAI && cachedOpenAI.length > 0) {
1126
+ models = cachedOpenAI;
1127
+ } else {
1128
+ // Default OpenAI models
1129
+ models = [
1130
+ { key: 'gpt-4o', displayName: 'GPT-4o' },
1131
+ { key: 'gpt-4o-mini', displayName: 'GPT-4o Mini' },
1132
+ { key: 'gpt-4-turbo', displayName: 'GPT-4 Turbo' },
1133
+ { key: 'gpt-3.5-turbo', displayName: 'GPT-3.5 Turbo' },
1134
+ { key: 'o1', displayName: 'o1' },
1135
+ { key: 'o1-mini', displayName: 'o1 Mini' },
1136
+ ];
1137
+ }
1138
+ break;
1139
+ }
1140
+
1141
+ case 'gemini': {
1142
+ currentModel = settings.gemini?.model || 'gemini-2.0-flash';
1143
+ const cachedGemini = LLMProviderFactory.getCachedModels('gemini');
1144
+ if (cachedGemini && cachedGemini.length > 0) {
1145
+ models = cachedGemini;
1146
+ } else {
1147
+ models = [
1148
+ { key: 'gemini-2.0-flash', displayName: 'Gemini 2.0 Flash' },
1149
+ { key: 'gemini-1.5-pro', displayName: 'Gemini 1.5 Pro' },
1150
+ { key: 'gemini-1.5-flash', displayName: 'Gemini 1.5 Flash' },
1151
+ ];
1152
+ }
1153
+ break;
1154
+ }
1155
+
1156
+ case 'openrouter': {
1157
+ currentModel = settings.openrouter?.model || 'anthropic/claude-3.5-sonnet';
1158
+ const cachedOpenRouter = LLMProviderFactory.getCachedModels('openrouter');
1159
+ if (cachedOpenRouter && cachedOpenRouter.length > 0) {
1160
+ models = cachedOpenRouter.slice(0, 10); // Limit to 10 for readability
1161
+ } else {
1162
+ models = [
1163
+ { key: 'anthropic/claude-3.5-sonnet', displayName: 'Claude 3.5 Sonnet' },
1164
+ { key: 'openai/gpt-4o', displayName: 'GPT-4o' },
1165
+ { key: 'google/gemini-pro', displayName: 'Gemini Pro' },
1166
+ ];
1167
+ }
1168
+ break;
1169
+ }
1170
+
1171
+ case 'ollama': {
1172
+ // Ollama handled separately below
1173
+ break;
1174
+ }
1175
+
1176
+ default:
1177
+ models = status.models;
1178
+ }
1179
+
1180
+ // Current configuration
1181
+ text += '*Current:*\n';
1182
+ const currentProvider = status.providers.find(p => p.type === providerType);
1183
+ text += `• Provider: ${currentProvider?.name || providerType}\n`;
1184
+
1185
+ if (providerType === 'ollama') {
1186
+ const ollamaModel = settings.ollama?.model || 'llama3.2';
1187
+ text += `• Model: ${ollamaModel}\n\n`;
1188
+ } else {
1189
+ const modelInfo = models.find(m => m.key === currentModel);
1190
+ text += `• Model: ${modelInfo?.displayName || currentModel}\n\n`;
1191
+ }
1192
+
1193
+ // Available providers
1194
+ text += '*Available Providers:*\n';
1195
+ status.providers.forEach(provider => {
1196
+ const isActive = provider.type === providerType ? ' ✓' : '';
1197
+ const configStatus = provider.configured ? '🟢' : '⚪';
1198
+ text += `${configStatus} ${provider.name}${isActive}\n`;
1199
+ });
1200
+ text += '\n';
1201
+
1202
+ // Available models - show different list based on provider
1203
+ if (providerType === 'ollama') {
1204
+ text += '*Available Ollama Models:*\n';
1205
+ try {
1206
+ const ollamaModels = await LLMProviderFactory.getOllamaModels();
1207
+ const currentOllamaModel = settings.ollama?.model || 'llama3.2';
1208
+
1209
+ if (ollamaModels.length === 0) {
1210
+ text += '⚠️ No models found. Run `ollama pull <model>` to download.\n';
1211
+ } else {
1212
+ ollamaModels.slice(0, 10).forEach((model, index) => {
1213
+ const isActive = model.name === currentOllamaModel ? ' ✓' : '';
1214
+ const sizeGB = (model.size / 1e9).toFixed(1);
1215
+ text += `${index + 1}. ${model.name} (${sizeGB}GB)${isActive}\n`;
1216
+ });
1217
+ if (ollamaModels.length > 10) {
1218
+ text += ` ... and ${ollamaModels.length - 10} more\n`;
1219
+ }
1220
+ }
1221
+ } catch {
1222
+ text += '⚠️ Could not fetch Ollama models. Is Ollama running?\n';
1223
+ }
1224
+ text += '\n💡 Use `/model <name>` to switch (e.g., `/model llama3.2`)';
1225
+ } else {
1226
+ const modelBrand = providerModelNames[providerType] || 'Available';
1227
+ text += `*Available ${modelBrand} Models:*\n`;
1228
+ models.forEach((model, index) => {
1229
+ const isActive = model.key === currentModel ? ' ✓' : '';
1230
+ text += `${index + 1}. ${model.displayName}${isActive}\n`;
1231
+ });
1232
+ text += '\n💡 Use `/model <name>` to switch\n';
1233
+ text += 'Example: `/model 2` or `/model <model-name>`';
1234
+ }
1235
+
1236
+ await adapter.sendMessage({
1237
+ chatId: message.chatId,
1238
+ text,
1239
+ parseMode: 'markdown',
1240
+ });
1241
+ }
1242
+
1243
+ /**
1244
+ * Handle /model command - show or change current model within current provider
1245
+ */
1246
+ private async handleModelCommand(
1247
+ adapter: ChannelAdapter,
1248
+ message: IncomingMessage,
1249
+ args: string[]
1250
+ ): Promise<void> {
1251
+ const status = LLMProviderFactory.getConfigStatus();
1252
+ const settings = LLMProviderFactory.loadSettings();
1253
+ const providerType = status.currentProvider;
1254
+ const currentProviderInfo = status.providers.find(p => p.type === providerType);
1255
+
1256
+ // Get provider-specific models and current model
1257
+ let models: Array<{ key: string; displayName: string }> = [];
1258
+ let currentModel = settings.modelKey;
1259
+
1260
+ // Get models based on current provider
1261
+ switch (providerType) {
1262
+ case 'anthropic':
1263
+ case 'bedrock':
1264
+ models = status.models;
1265
+ break;
1266
+
1267
+ case 'openai': {
1268
+ currentModel = settings.openai?.model || 'gpt-4o-mini';
1269
+ const cachedOpenAI = LLMProviderFactory.getCachedModels('openai');
1270
+ if (cachedOpenAI && cachedOpenAI.length > 0) {
1271
+ models = cachedOpenAI;
1272
+ } else {
1273
+ models = [
1274
+ { key: 'gpt-4o', displayName: 'GPT-4o' },
1275
+ { key: 'gpt-4o-mini', displayName: 'GPT-4o Mini' },
1276
+ { key: 'gpt-4-turbo', displayName: 'GPT-4 Turbo' },
1277
+ { key: 'gpt-3.5-turbo', displayName: 'GPT-3.5 Turbo' },
1278
+ { key: 'o1', displayName: 'o1' },
1279
+ { key: 'o1-mini', displayName: 'o1 Mini' },
1280
+ ];
1281
+ }
1282
+ break;
1283
+ }
1284
+
1285
+ case 'gemini': {
1286
+ currentModel = settings.gemini?.model || 'gemini-2.0-flash';
1287
+ const cachedGemini = LLMProviderFactory.getCachedModels('gemini');
1288
+ if (cachedGemini && cachedGemini.length > 0) {
1289
+ models = cachedGemini;
1290
+ } else {
1291
+ models = [
1292
+ { key: 'gemini-2.0-flash', displayName: 'Gemini 2.0 Flash' },
1293
+ { key: 'gemini-1.5-pro', displayName: 'Gemini 1.5 Pro' },
1294
+ { key: 'gemini-1.5-flash', displayName: 'Gemini 1.5 Flash' },
1295
+ ];
1296
+ }
1297
+ break;
1298
+ }
1299
+
1300
+ case 'openrouter': {
1301
+ currentModel = settings.openrouter?.model || 'anthropic/claude-3.5-sonnet';
1302
+ const cachedOpenRouter = LLMProviderFactory.getCachedModels('openrouter');
1303
+ if (cachedOpenRouter && cachedOpenRouter.length > 0) {
1304
+ models = cachedOpenRouter.slice(0, 10);
1305
+ } else {
1306
+ models = [
1307
+ { key: 'anthropic/claude-3.5-sonnet', displayName: 'Claude 3.5 Sonnet' },
1308
+ { key: 'openai/gpt-4o', displayName: 'GPT-4o' },
1309
+ { key: 'google/gemini-pro', displayName: 'Gemini Pro' },
1310
+ ];
1311
+ }
1312
+ break;
1313
+ }
1314
+
1315
+ case 'ollama':
1316
+ // Handled separately
1317
+ break;
1318
+
1319
+ default:
1320
+ models = status.models;
1321
+ }
1322
+
1323
+ // If no args, show current model and available models
1324
+ if (args.length === 0) {
1325
+ let text = '🤖 *Current Model*\n\n';
1326
+ text += `• Provider: ${currentProviderInfo?.name || providerType}\n`;
1327
+
1328
+ if (providerType === 'ollama') {
1329
+ const ollamaModel = settings.ollama?.model || 'llama3.2';
1330
+ text += `• Model: ${ollamaModel}\n\n`;
1331
+
1332
+ text += '*Available Models:*\n';
1333
+ try {
1334
+ const ollamaModels = await LLMProviderFactory.getOllamaModels();
1335
+ if (ollamaModels.length === 0) {
1336
+ text += '⚠️ No models found.\n';
1337
+ } else {
1338
+ ollamaModels.slice(0, 8).forEach((model, index) => {
1339
+ const isActive = model.name === ollamaModel ? ' ✓' : '';
1340
+ const sizeGB = (model.size / 1e9).toFixed(1);
1341
+ text += `${index + 1}. ${model.name} (${sizeGB}GB)${isActive}\n`;
1342
+ });
1343
+ if (ollamaModels.length > 8) {
1344
+ text += ` ... and ${ollamaModels.length - 8} more\n`;
1345
+ }
1346
+ }
1347
+ } catch {
1348
+ text += '⚠️ Could not fetch models.\n';
1349
+ }
1350
+ text += '\n💡 Use `/model <name>` or `/model <number>` to switch';
1351
+ } else {
1352
+ const modelInfo = models.find(m => m.key === currentModel);
1353
+ text += `• Model: ${modelInfo?.displayName || currentModel}\n\n`;
1354
+
1355
+ text += '*Available Models:*\n';
1356
+ models.forEach((model, index) => {
1357
+ const isActive = model.key === currentModel ? ' ✓' : '';
1358
+ text += `${index + 1}. ${model.displayName}${isActive}\n`;
1359
+ });
1360
+ text += '\n💡 Use `/model <name>` or `/model <number>` to switch';
1361
+ }
1362
+
1363
+ await adapter.sendMessage({
1364
+ chatId: message.chatId,
1365
+ text,
1366
+ parseMode: 'markdown',
1367
+ });
1368
+ return;
1369
+ }
1370
+
1371
+ // Change model within current provider
1372
+ const selector = args.join(' ').toLowerCase();
1373
+
1374
+ if (providerType === 'ollama') {
1375
+ const result = await this.selectOllamaModel(selector, args);
1376
+ if (!result.success) {
1377
+ await adapter.sendMessage({
1378
+ chatId: message.chatId,
1379
+ text: result.error!,
1380
+ parseMode: 'markdown',
1381
+ });
1382
+ return;
1383
+ }
1384
+
1385
+ const newSettings: LLMSettings = {
1386
+ ...settings,
1387
+ ollama: {
1388
+ ...settings.ollama,
1389
+ model: result.model!,
1390
+ },
1391
+ };
1392
+
1393
+ LLMProviderFactory.saveSettings(newSettings);
1394
+ LLMProviderFactory.clearCache();
1395
+
1396
+ await adapter.sendMessage({
1397
+ chatId: message.chatId,
1398
+ text: `✅ Model changed to: *${result.model}*`,
1399
+ parseMode: 'markdown',
1400
+ });
1401
+ return;
1402
+ }
1403
+
1404
+ // For all other providers, use the provider-specific model list
1405
+ const result = this.selectClaudeModel(selector, models);
1406
+ if (!result.success) {
1407
+ await adapter.sendMessage({
1408
+ chatId: message.chatId,
1409
+ text: result.error!,
1410
+ });
1411
+ return;
1412
+ }
1413
+
1414
+ // Save to the appropriate provider-specific setting
1415
+ let newSettings: LLMSettings = { ...settings };
1416
+
1417
+ switch (providerType) {
1418
+ case 'openai':
1419
+ newSettings.openai = {
1420
+ ...settings.openai,
1421
+ model: result.model!.key,
1422
+ };
1423
+ break;
1424
+
1425
+ case 'gemini':
1426
+ newSettings.gemini = {
1427
+ ...settings.gemini,
1428
+ model: result.model!.key,
1429
+ };
1430
+ break;
1431
+
1432
+ case 'openrouter':
1433
+ newSettings.openrouter = {
1434
+ ...settings.openrouter,
1435
+ model: result.model!.key,
1436
+ };
1437
+ break;
1438
+
1439
+ case 'anthropic':
1440
+ case 'bedrock':
1441
+ default:
1442
+ newSettings.modelKey = result.model!.key as ModelKey;
1443
+ break;
1444
+ }
1445
+
1446
+ LLMProviderFactory.saveSettings(newSettings);
1447
+ LLMProviderFactory.clearCache();
1448
+
1449
+ await adapter.sendMessage({
1450
+ chatId: message.chatId,
1451
+ text: `✅ Model changed to: *${result.model!.displayName}*`,
1452
+ parseMode: 'markdown',
1453
+ });
1454
+ }
1455
+
1456
+ /**
1457
+ * Handle /provider command - show or change current provider
1458
+ */
1459
+ private async handleProviderCommand(
1460
+ adapter: ChannelAdapter,
1461
+ message: IncomingMessage,
1462
+ args: string[]
1463
+ ): Promise<void> {
1464
+ const status = LLMProviderFactory.getConfigStatus();
1465
+ const settings = LLMProviderFactory.loadSettings();
1466
+
1467
+ // If no args, show current provider and available options
1468
+ if (args.length === 0) {
1469
+ const currentProvider = status.providers.find(p => p.type === status.currentProvider);
1470
+
1471
+ let text = '🔌 *Current Provider*\n\n';
1472
+ text += `• Provider: ${currentProvider?.name || status.currentProvider}\n`;
1473
+
1474
+ // Show current model for context
1475
+ if (status.currentProvider === 'ollama') {
1476
+ text += `• Model: ${settings.ollama?.model || 'gpt-oss:20b'}\n\n`;
1477
+ } else {
1478
+ const currentModel = status.models.find(m => m.key === status.currentModel);
1479
+ text += `• Model: ${currentModel?.displayName || status.currentModel}\n\n`;
1480
+ }
1481
+
1482
+ text += '*Available Providers:*\n';
1483
+ text += '1. anthropic - Anthropic API (direct)\n';
1484
+ text += '2. openai - OpenAI/ChatGPT\n';
1485
+ text += '3. gemini - Google Gemini\n';
1486
+ text += '4. openrouter - OpenRouter\n';
1487
+ text += '5. bedrock - AWS Bedrock\n';
1488
+ text += '6. ollama - Ollama (local)\n\n';
1489
+
1490
+ text += '💡 Use `/provider <name>` to switch\n';
1491
+ text += 'Example: `/provider bedrock` or `/provider 2`';
1492
+
1493
+ await adapter.sendMessage({
1494
+ chatId: message.chatId,
1495
+ text,
1496
+ parseMode: 'markdown',
1497
+ });
1498
+ return;
1499
+ }
1500
+
1501
+ const selector = args[0].toLowerCase();
1502
+
1503
+ // Map of provider shortcuts
1504
+ const providerMap: Record<string, LLMProviderType> = {
1505
+ '1': 'anthropic',
1506
+ 'anthropic': 'anthropic',
1507
+ 'api': 'anthropic',
1508
+ '2': 'openai',
1509
+ 'openai': 'openai',
1510
+ 'chatgpt': 'openai',
1511
+ '3': 'gemini',
1512
+ 'gemini': 'gemini',
1513
+ 'google': 'gemini',
1514
+ '4': 'openrouter',
1515
+ 'openrouter': 'openrouter',
1516
+ 'or': 'openrouter',
1517
+ '5': 'bedrock',
1518
+ 'bedrock': 'bedrock',
1519
+ 'aws': 'bedrock',
1520
+ '6': 'ollama',
1521
+ 'ollama': 'ollama',
1522
+ 'local': 'ollama',
1523
+ };
1524
+
1525
+ const targetProvider = providerMap[selector];
1526
+ if (!targetProvider) {
1527
+ await adapter.sendMessage({
1528
+ chatId: message.chatId,
1529
+ text: `❌ Unknown provider: "${args[0]}"\n\n*Available providers:*\n1. anthropic\n2. openai\n3. gemini\n4. openrouter\n5. bedrock\n6. ollama\n\nUse \`/provider <name>\` or \`/provider <number>\``,
1530
+ parseMode: 'markdown',
1531
+ });
1532
+ return;
1533
+ }
1534
+
1535
+ // Update provider
1536
+ const newSettings: LLMSettings = {
1537
+ ...settings,
1538
+ providerType: targetProvider,
1539
+ };
1540
+
1541
+ LLMProviderFactory.saveSettings(newSettings);
1542
+ LLMProviderFactory.clearCache();
1543
+
1544
+ // Get provider display info
1545
+ const providerInfo = status.providers.find(p => p.type === targetProvider);
1546
+ let modelInfo: string;
1547
+
1548
+ if (targetProvider === 'ollama') {
1549
+ modelInfo = settings.ollama?.model || 'gpt-oss:20b';
1550
+ } else {
1551
+ const model = status.models.find(m => m.key === settings.modelKey);
1552
+ modelInfo = model?.displayName || settings.modelKey;
1553
+ }
1554
+
1555
+ await adapter.sendMessage({
1556
+ chatId: message.chatId,
1557
+ text: `✅ Provider changed to: *${providerInfo?.name || targetProvider}*\n\nCurrent model: ${modelInfo}\n\nUse \`/model\` to see available models for this provider.`,
1558
+ parseMode: 'markdown',
1559
+ });
1560
+ }
1561
+
1562
+ /**
1563
+ * Handle /shell command - enable or disable shell execution permission
1564
+ */
1565
+ private async handleShellCommand(
1566
+ adapter: ChannelAdapter,
1567
+ message: IncomingMessage,
1568
+ sessionId: string,
1569
+ args: string[]
1570
+ ): Promise<void> {
1571
+ let session = this.sessionRepo.findById(sessionId);
1572
+
1573
+ // Auto-assign temp workspace if none selected
1574
+ if (!session?.workspaceId) {
1575
+ const tempWorkspace = this.getOrCreateTempWorkspace();
1576
+ this.sessionRepo.update(sessionId, { workspaceId: tempWorkspace.id });
1577
+ session = this.sessionRepo.findById(sessionId);
1578
+ }
1579
+
1580
+ const workspace = this.workspaceRepo.findById(session!.workspaceId!);
1581
+ if (!workspace) {
1582
+ await adapter.sendMessage({
1583
+ chatId: message.chatId,
1584
+ text: '❌ Workspace not found.',
1585
+ });
1586
+ return;
1587
+ }
1588
+
1589
+ // If no args, show current status
1590
+ if (args.length === 0) {
1591
+ const status = workspace.permissions.shell ? '🟢 Enabled' : '🔴 Disabled';
1592
+ await adapter.sendMessage({
1593
+ chatId: message.chatId,
1594
+ text: `🖥️ *Shell Commands*\n\nStatus: ${status}\n\nWhen enabled, the AI can execute shell commands like \`npm install\`, \`git\`, etc. Each command requires your approval before running.\n\n*Usage:*\n• \`/shell on\` - Enable shell commands\n• \`/shell off\` - Disable shell commands`,
1595
+ parseMode: 'markdown',
1596
+ });
1597
+ return;
1598
+ }
1599
+
1600
+ const action = args[0].toLowerCase();
1601
+ let newShellPermission: boolean;
1602
+
1603
+ if (action === 'on' || action === 'enable' || action === '1' || action === 'true') {
1604
+ newShellPermission = true;
1605
+ } else if (action === 'off' || action === 'disable' || action === '0' || action === 'false') {
1606
+ newShellPermission = false;
1607
+ } else {
1608
+ await adapter.sendMessage({
1609
+ chatId: message.chatId,
1610
+ text: '❌ Invalid option. Use `/shell on` or `/shell off`',
1611
+ parseMode: 'markdown',
1612
+ });
1613
+ return;
1614
+ }
1615
+
1616
+ // Update workspace permissions
1617
+ const updatedPermissions = {
1618
+ ...workspace.permissions,
1619
+ shell: newShellPermission,
1620
+ };
1621
+
1622
+ // Update in database
1623
+ this.workspaceRepo.updatePermissions(workspace.id, updatedPermissions);
1624
+
1625
+ const statusText = newShellPermission ? '🟢 enabled' : '🔴 disabled';
1626
+ const warning = newShellPermission
1627
+ ? '\n\n⚠️ The AI will now ask for approval before running each command.'
1628
+ : '';
1629
+
1630
+ await adapter.sendMessage({
1631
+ chatId: message.chatId,
1632
+ text: `✅ Shell commands ${statusText} for workspace *${workspace.name}*${warning}`,
1633
+ parseMode: 'markdown',
1634
+ });
1635
+ }
1636
+
1637
+ /**
1638
+ * Helper to select an Ollama model from available models
1639
+ */
1640
+ private async selectOllamaModel(
1641
+ selector: string,
1642
+ originalArgs: string[]
1643
+ ): Promise<{ success: boolean; model?: string; error?: string }> {
1644
+ let ollamaModels: Array<{ name: string; size: number; modified: string }> = [];
1645
+ try {
1646
+ ollamaModels = await LLMProviderFactory.getOllamaModels();
1647
+ } catch {
1648
+ return {
1649
+ success: false,
1650
+ error: `❌ Could not fetch Ollama models. Is Ollama running?\n\nMake sure Ollama is running with \`ollama serve\``,
1651
+ };
1652
+ }
1653
+
1654
+ if (ollamaModels.length === 0) {
1655
+ return {
1656
+ success: false,
1657
+ error: `❌ No Ollama models found.\n\nRun \`ollama pull <model>\` to download a model first.`,
1658
+ };
1659
+ }
1660
+
1661
+ let selectedModel: string | undefined;
1662
+
1663
+ // Try to find model by number
1664
+ const num = parseInt(selector, 10);
1665
+ if (!isNaN(num) && num > 0 && num <= ollamaModels.length) {
1666
+ selectedModel = ollamaModels[num - 1].name;
1667
+ } else {
1668
+ // Try to find by name (exact or partial match)
1669
+ const match = ollamaModels.find(
1670
+ m => m.name.toLowerCase() === selector ||
1671
+ m.name.toLowerCase().includes(selector)
1672
+ );
1673
+ if (match) {
1674
+ selectedModel = match.name;
1675
+ }
1676
+ }
1677
+
1678
+ if (!selectedModel) {
1679
+ const modelList = ollamaModels.slice(0, 5).map((m, i) => `${i + 1}. ${m.name}`).join('\n');
1680
+ const moreText = ollamaModels.length > 5 ? `\n ... and ${ollamaModels.length - 5} more` : '';
1681
+ return {
1682
+ success: false,
1683
+ error: `❌ Model not found: "${originalArgs.join(' ')}"\n\n*Available Ollama models:*\n${modelList}${moreText}\n\nUse \`/model <name>\` or \`/model <number>\``,
1684
+ };
1685
+ }
1686
+
1687
+ return { success: true, model: selectedModel };
1688
+ }
1689
+
1690
+ /**
1691
+ * Helper to select a Claude model from available models
1692
+ */
1693
+ private selectClaudeModel(
1694
+ selector: string,
1695
+ models: Array<{ key: string; displayName: string }>
1696
+ ): { success: boolean; model?: { key: string; displayName: string }; error?: string } {
1697
+ let selectedModel: { key: string; displayName: string } | undefined;
1698
+
1699
+ // Try to find model by number
1700
+ const num = parseInt(selector, 10);
1701
+ if (!isNaN(num) && num > 0 && num <= models.length) {
1702
+ selectedModel = models[num - 1];
1703
+ } else {
1704
+ // Try to find by name (partial match)
1705
+ selectedModel = models.find(
1706
+ m => m.key.toLowerCase() === selector ||
1707
+ m.key.toLowerCase().includes(selector) ||
1708
+ m.displayName.toLowerCase().includes(selector)
1709
+ );
1710
+ }
1711
+
1712
+ if (!selectedModel) {
1713
+ return {
1714
+ success: false,
1715
+ error: `❌ Model not found: "${selector}"\n\nUse /models to see available options.`,
1716
+ };
1717
+ }
1718
+
1719
+ return { success: true, model: selectedModel };
1720
+ }
1721
+
1722
+ /**
1723
+ * Check if text looks like a pairing code
1724
+ */
1725
+ private looksLikePairingCode(text: string): boolean {
1726
+ // Pairing codes are typically 6-8 alphanumeric characters
1727
+ return /^[A-Z0-9]{6,8}$/i.test(text);
1728
+ }
1729
+
1730
+ /**
1731
+ * Handle pairing code attempt
1732
+ */
1733
+ private async handlePairingAttempt(
1734
+ adapter: ChannelAdapter,
1735
+ message: IncomingMessage,
1736
+ code: string
1737
+ ): Promise<void> {
1738
+ const channel = this.channelRepo.findByType(adapter.type);
1739
+ if (!channel) return;
1740
+
1741
+ const result = await this.securityManager.verifyPairingCode(channel, message.userId, code);
1742
+
1743
+ if (result.success) {
1744
+ await adapter.sendMessage({
1745
+ chatId: message.chatId,
1746
+ text: '✅ Pairing successful! You can now use the bot.',
1747
+ replyTo: message.messageId,
1748
+ });
1749
+
1750
+ this.emitEvent({
1751
+ type: 'user:paired',
1752
+ channel: adapter.type,
1753
+ timestamp: new Date(),
1754
+ data: { userId: message.userId, userName: message.userName },
1755
+ });
1756
+ } else {
1757
+ await adapter.sendMessage({
1758
+ chatId: message.chatId,
1759
+ text: `❌ ${result.error || 'Invalid pairing code. Please try again.'}`,
1760
+ replyTo: message.messageId,
1761
+ });
1762
+ }
1763
+ }
1764
+
1765
+ /**
1766
+ * Forward message to desktop app / create task
1767
+ */
1768
+ private async forwardToDesktopApp(
1769
+ adapter: ChannelAdapter,
1770
+ message: IncomingMessage,
1771
+ sessionId: string
1772
+ ): Promise<void> {
1773
+ let session = this.sessionRepo.findById(sessionId);
1774
+
1775
+ // Auto-assign temp workspace if none selected
1776
+ if (!session?.workspaceId) {
1777
+ const tempWorkspace = this.getOrCreateTempWorkspace();
1778
+ this.sessionManager.setSessionWorkspace(sessionId, tempWorkspace.id);
1779
+ session = this.sessionRepo.findById(sessionId);
1780
+ }
1781
+
1782
+ // Check if there's an existing task for this session (active or completed)
1783
+ if (session!.taskId) {
1784
+ const existingTask = this.taskRepo.findById(session!.taskId);
1785
+ if (existingTask) {
1786
+ // For active tasks, send follow-up message
1787
+ // For completed tasks, also allow follow-up (continues the conversation)
1788
+ const activeStatuses = ['pending', 'planning', 'executing', 'paused'];
1789
+ const isActive = activeStatuses.includes(existingTask.status);
1790
+ const isCompleted = existingTask.status === 'completed';
1791
+
1792
+ if (isActive || isCompleted) {
1793
+ if (this.agentDaemon) {
1794
+ try {
1795
+ const statusMsg = isActive
1796
+ ? '💬 Sending follow-up message...'
1797
+ : '💬 Continuing conversation...';
1798
+ await adapter.sendMessage({
1799
+ chatId: message.chatId,
1800
+ text: statusMsg,
1801
+ replyTo: message.messageId,
1802
+ });
1803
+
1804
+ // Re-register task for response tracking (may have been removed after initial completion)
1805
+ this.pendingTaskResponses.set(session!.taskId!, {
1806
+ adapter,
1807
+ chatId: message.chatId,
1808
+ sessionId,
1809
+ });
1810
+
1811
+ await this.agentDaemon.sendMessage(session!.taskId!, message.text);
1812
+ } catch (error) {
1813
+ console.error('Error sending follow-up message:', error);
1814
+ await adapter.sendMessage({
1815
+ chatId: message.chatId,
1816
+ text: '❌ Failed to send message. Use /newtask to start a new task.',
1817
+ });
1818
+ }
1819
+ }
1820
+ return;
1821
+ }
1822
+ // Task is in failed/cancelled state - unlink and create new task
1823
+ this.sessionManager.unlinkSessionFromTask(sessionId);
1824
+ }
1825
+ }
1826
+
1827
+ // Create a new task
1828
+ if (!this.agentDaemon) {
1829
+ await adapter.sendMessage({
1830
+ chatId: message.chatId,
1831
+ text: '❌ Agent not available. Please try again later.',
1832
+ replyTo: message.messageId,
1833
+ });
1834
+ return;
1835
+ }
1836
+
1837
+ // Get workspace
1838
+ const workspace = this.workspaceRepo.findById(session!.workspaceId!);
1839
+ if (!workspace) {
1840
+ await adapter.sendMessage({
1841
+ chatId: message.chatId,
1842
+ text: '❌ Workspace not found. Please select a workspace with /workspace.',
1843
+ replyTo: message.messageId,
1844
+ });
1845
+ return;
1846
+ }
1847
+
1848
+ // Create task
1849
+ const taskTitle = message.text.length > 50
1850
+ ? message.text.substring(0, 50) + '...'
1851
+ : message.text;
1852
+
1853
+ const task = this.taskRepo.create({
1854
+ workspaceId: workspace.id,
1855
+ title: taskTitle,
1856
+ prompt: message.text,
1857
+ status: 'pending',
1858
+ });
1859
+
1860
+ // Link session to task
1861
+ this.sessionManager.linkSessionToTask(sessionId, task.id);
1862
+
1863
+ // Track this task for response handling
1864
+ this.pendingTaskResponses.set(task.id, {
1865
+ adapter,
1866
+ chatId: message.chatId,
1867
+ sessionId,
1868
+ originalMessageId: message.messageId, // Track for reaction updates
1869
+ });
1870
+
1871
+ // Start draft streaming for real-time response preview (Telegram)
1872
+ if (adapter instanceof TelegramAdapter) {
1873
+ await adapter.startDraftStream(message.chatId);
1874
+ }
1875
+
1876
+ // Send acknowledgment - concise for WhatsApp and iMessage
1877
+ const ackMessage = (adapter.type === 'whatsapp' || adapter.type === 'imessage')
1878
+ ? `⏳ Working on it...`
1879
+ : `🚀 Task Started: "${taskTitle}"\n\nI'll notify you when it's complete or if I need your input.`;
1880
+ await adapter.sendMessage({
1881
+ chatId: message.chatId,
1882
+ text: ackMessage,
1883
+ replyTo: message.messageId,
1884
+ });
1885
+
1886
+ // Notify desktop app via IPC
1887
+ if (this.mainWindow && !this.mainWindow.isDestroyed()) {
1888
+ this.mainWindow.webContents.send('gateway:message', {
1889
+ channel: adapter.type,
1890
+ sessionId,
1891
+ taskId: task.id,
1892
+ message: {
1893
+ id: message.messageId,
1894
+ userId: message.userId,
1895
+ userName: message.userName,
1896
+ chatId: message.chatId,
1897
+ text: message.text,
1898
+ timestamp: message.timestamp.getTime(),
1899
+ },
1900
+ });
1901
+ }
1902
+
1903
+ // Start task execution
1904
+ try {
1905
+ await this.agentDaemon.startTask(task);
1906
+ } catch (error) {
1907
+ console.error('Error starting task:', error);
1908
+ await adapter.sendMessage({
1909
+ chatId: message.chatId,
1910
+ text: `❌ Failed to start task: ${error instanceof Error ? error.message : 'Unknown error'}`,
1911
+ });
1912
+
1913
+ // Cleanup
1914
+ this.pendingTaskResponses.delete(task.id);
1915
+ this.sessionManager.unlinkSessionFromTask(sessionId);
1916
+ }
1917
+ }
1918
+
1919
+ /**
1920
+ * Send task update to channel
1921
+ * Uses draft streaming for Telegram to show real-time progress
1922
+ */
1923
+ async sendTaskUpdate(taskId: string, text: string, isStreaming = false): Promise<void> {
1924
+ const pending = this.pendingTaskResponses.get(taskId);
1925
+ if (!pending) {
1926
+ // This is expected for tasks started from the UI (not via Telegram)
1927
+ return;
1928
+ }
1929
+
1930
+ try {
1931
+ // Use draft streaming for Telegram when streaming content
1932
+ if (isStreaming && pending.adapter instanceof TelegramAdapter) {
1933
+ await pending.adapter.updateDraftStream(pending.chatId, text);
1934
+ } else {
1935
+ await pending.adapter.sendMessage({
1936
+ chatId: pending.chatId,
1937
+ text,
1938
+ parseMode: 'markdown',
1939
+ });
1940
+ }
1941
+ } catch (error) {
1942
+ console.error('Error sending task update:', error);
1943
+ }
1944
+ }
1945
+
1946
+ /**
1947
+ * Send typing indicator to channel
1948
+ */
1949
+ async sendTypingIndicator(taskId: string): Promise<void> {
1950
+ const pending = this.pendingTaskResponses.get(taskId);
1951
+ if (!pending) return;
1952
+
1953
+ if (pending.adapter instanceof TelegramAdapter) {
1954
+ await pending.adapter.sendTyping(pending.chatId);
1955
+ }
1956
+ }
1957
+
1958
+ /**
1959
+ * Send any artifacts (images, documents) created during task execution
1960
+ * Called when follow-ups complete to deliver screenshots, etc.
1961
+ */
1962
+ async sendArtifacts(taskId: string): Promise<void> {
1963
+ const pending = this.pendingTaskResponses.get(taskId);
1964
+ if (!pending) {
1965
+ return;
1966
+ }
1967
+
1968
+ await this.sendTaskArtifacts(taskId, pending.adapter, pending.chatId);
1969
+ }
1970
+
1971
+ /**
1972
+ * Handle task completion
1973
+ * Note: We keep the session linked to the task for follow-up messages
1974
+ */
1975
+ async handleTaskCompletion(taskId: string, result?: string): Promise<void> {
1976
+ const pending = this.pendingTaskResponses.get(taskId);
1977
+ if (!pending) return;
1978
+
1979
+ try {
1980
+ // WhatsApp/iMessage-optimized completion message (no follow-up hint)
1981
+ const isSimpleMessaging = pending.adapter.type === 'whatsapp' || pending.adapter.type === 'imessage';
1982
+ const msgCtx = this.getMessageContext();
1983
+ const message = getCompletionMessage(msgCtx, result, !isSimpleMessaging);
1984
+
1985
+ // Finalize draft stream if using Telegram
1986
+ if (pending.adapter instanceof TelegramAdapter) {
1987
+ // Finalize the streaming draft with final message
1988
+ await pending.adapter.finalizeDraftStream(pending.chatId, message);
1989
+
1990
+ // Update reaction from 👀 to ✅ on the original message
1991
+ if (pending.originalMessageId) {
1992
+ await pending.adapter.sendCompletionReaction(pending.chatId, pending.originalMessageId);
1993
+ }
1994
+ } else {
1995
+ // Split long messages (Telegram has 4096 char limit, WhatsApp/iMessage ~65k but keep it reasonable)
1996
+ const maxLen = isSimpleMessaging ? 4000 : 4000;
1997
+ const chunks = this.splitMessage(message, maxLen);
1998
+ for (const chunk of chunks) {
1999
+ await pending.adapter.sendMessage({
2000
+ chatId: pending.chatId,
2001
+ text: chunk,
2002
+ parseMode: 'markdown',
2003
+ });
2004
+ }
2005
+ }
2006
+
2007
+ // Send artifacts if any were created
2008
+ await this.sendTaskArtifacts(taskId, pending.adapter, pending.chatId);
2009
+
2010
+ // Don't unlink session - keep it linked for follow-up messages
2011
+ // User can use /newtask to explicitly start a new task
2012
+ } catch (error) {
2013
+ console.error('Error sending task completion:', error);
2014
+ } finally {
2015
+ this.pendingTaskResponses.delete(taskId);
2016
+ }
2017
+ }
2018
+
2019
+ /**
2020
+ * Send task artifacts as documents/images to the channel
2021
+ */
2022
+ private async sendTaskArtifacts(
2023
+ taskId: string,
2024
+ adapter: ChannelAdapter,
2025
+ chatId: string
2026
+ ): Promise<void> {
2027
+ try {
2028
+ const artifacts = this.artifactRepo.findByTaskId(taskId);
2029
+ if (artifacts.length === 0) return;
2030
+
2031
+ // Image extensions
2032
+ const imageExtensions = ['.png', '.jpg', '.jpeg', '.gif', '.webp'];
2033
+
2034
+ // Document extensions
2035
+ const documentExtensions = [
2036
+ '.docx', '.xlsx', '.pptx', '.pdf', '.doc', '.xls', '.ppt',
2037
+ '.txt', '.csv', '.json', '.md', '.html', '.xml'
2038
+ ];
2039
+
2040
+ // Filter for sendable file types
2041
+ const sendableArtifacts = artifacts.filter(artifact => {
2042
+ const ext = path.extname(artifact.path).toLowerCase();
2043
+ return (imageExtensions.includes(ext) || documentExtensions.includes(ext)) && fs.existsSync(artifact.path);
2044
+ });
2045
+
2046
+ if (sendableArtifacts.length === 0) return;
2047
+
2048
+ // Send each artifact
2049
+ for (const artifact of sendableArtifacts) {
2050
+ try {
2051
+ const ext = path.extname(artifact.path).toLowerCase();
2052
+ const fileName = path.basename(artifact.path);
2053
+
2054
+ if (imageExtensions.includes(ext) && adapter.sendPhoto) {
2055
+ // Send as photo for better display
2056
+ await adapter.sendPhoto(chatId, artifact.path, `📷 ${fileName}`);
2057
+ console.log(`Sent image: ${fileName}`);
2058
+ } else if (adapter.sendDocument) {
2059
+ // Send as document
2060
+ await adapter.sendDocument(chatId, artifact.path, `📎 ${fileName}`);
2061
+ console.log(`Sent document: ${fileName}`);
2062
+ } else {
2063
+ console.log(`Adapter does not support sending ${ext} files, skipping: ${fileName}`);
2064
+ }
2065
+ } catch (err) {
2066
+ console.error(`Failed to send artifact ${artifact.path}:`, err);
2067
+ }
2068
+ }
2069
+ } catch (error) {
2070
+ console.error('Error sending task artifacts:', error);
2071
+ }
2072
+ }
2073
+
2074
+ /**
2075
+ * Handle task failure
2076
+ */
2077
+ async handleTaskFailure(taskId: string, error: string): Promise<void> {
2078
+ const pending = this.pendingTaskResponses.get(taskId);
2079
+ if (!pending) return;
2080
+
2081
+ try {
2082
+ // Cancel any draft stream
2083
+ if (pending.adapter instanceof TelegramAdapter) {
2084
+ await pending.adapter.cancelDraftStream(pending.chatId);
2085
+
2086
+ // Remove ACK reaction on failure
2087
+ if (pending.originalMessageId) {
2088
+ await pending.adapter.removeAckReaction(pending.chatId, pending.originalMessageId);
2089
+ }
2090
+ }
2091
+
2092
+ const message = getChannelMessage('taskFailed', this.getMessageContext(), { error });
2093
+ await pending.adapter.sendMessage({
2094
+ chatId: pending.chatId,
2095
+ text: message,
2096
+ });
2097
+
2098
+ // Unlink session from task
2099
+ this.sessionManager.unlinkSessionFromTask(pending.sessionId);
2100
+ } catch (err) {
2101
+ console.error('Error sending task failure:', err);
2102
+ } finally {
2103
+ this.pendingTaskResponses.delete(taskId);
2104
+ }
2105
+ }
2106
+
2107
+ /**
2108
+ * Send approval request to Discord/Telegram
2109
+ */
2110
+ async sendApprovalRequest(taskId: string, approval: any): Promise<void> {
2111
+ const pending = this.pendingTaskResponses.get(taskId);
2112
+ if (!pending) return;
2113
+
2114
+ // Store approval for response handling
2115
+ this.pendingApprovals.set(approval.id, {
2116
+ taskId,
2117
+ approval,
2118
+ sessionId: pending.sessionId,
2119
+ });
2120
+
2121
+ // Format approval message
2122
+ let message = `🔐 *Approval Required*\n\n`;
2123
+ message += `**${approval.description}**\n\n`;
2124
+
2125
+ if (approval.type === 'run_command' && approval.details?.command) {
2126
+ message += `\`\`\`\n${approval.details.command}\n\`\`\`\n\n`;
2127
+ } else if (approval.details) {
2128
+ message += `Details: ${JSON.stringify(approval.details, null, 2)}\n\n`;
2129
+ }
2130
+
2131
+ message += `⏳ _Expires in 5 minutes_`;
2132
+
2133
+ // WhatsApp/iMessage don't support inline keyboards - use text commands
2134
+ if (pending.adapter.type === 'whatsapp' || pending.adapter.type === 'imessage') {
2135
+ message += `\n\n━━━━━━━━━━━━━━━\nReply */approve* or */deny*`;
2136
+
2137
+ try {
2138
+ await pending.adapter.sendMessage({
2139
+ chatId: pending.chatId,
2140
+ text: message,
2141
+ parseMode: 'markdown',
2142
+ });
2143
+ } catch (error) {
2144
+ console.error('Error sending approval request:', error);
2145
+ }
2146
+ } else {
2147
+ // Create inline keyboard with Approve/Deny buttons for Telegram/Discord
2148
+ const keyboard: InlineKeyboardButton[][] = [
2149
+ [
2150
+ { text: '✅ Approve', callbackData: 'approve:' + approval.id },
2151
+ { text: '❌ Deny', callbackData: 'deny:' + approval.id },
2152
+ ],
2153
+ ];
2154
+
2155
+ try {
2156
+ await pending.adapter.sendMessage({
2157
+ chatId: pending.chatId,
2158
+ text: message,
2159
+ parseMode: 'markdown',
2160
+ inlineKeyboard: keyboard,
2161
+ });
2162
+ } catch (error) {
2163
+ console.error('Error sending approval request:', error);
2164
+ }
2165
+ }
2166
+ }
2167
+
2168
+ /**
2169
+ * Handle /approve command
2170
+ */
2171
+ private async handleApproveCommand(
2172
+ adapter: ChannelAdapter,
2173
+ message: IncomingMessage,
2174
+ sessionId: string
2175
+ ): Promise<void> {
2176
+ // Find pending approval for this session
2177
+ const approvalEntry = Array.from(this.pendingApprovals.entries())
2178
+ .find(([, data]) => data.sessionId === sessionId);
2179
+
2180
+ if (!approvalEntry) {
2181
+ await adapter.sendMessage({
2182
+ chatId: message.chatId,
2183
+ text: '❌ No pending approval request.',
2184
+ });
2185
+ return;
2186
+ }
2187
+
2188
+ const [approvalId, data] = approvalEntry;
2189
+ this.pendingApprovals.delete(approvalId);
2190
+
2191
+ try {
2192
+ await this.agentDaemon?.respondToApproval(approvalId, true);
2193
+ await adapter.sendMessage({
2194
+ chatId: message.chatId,
2195
+ text: '✅ Approved! Executing...',
2196
+ });
2197
+ } catch (error) {
2198
+ console.error('Error responding to approval:', error);
2199
+ await adapter.sendMessage({
2200
+ chatId: message.chatId,
2201
+ text: '❌ Failed to process approval.',
2202
+ });
2203
+ }
2204
+ }
2205
+
2206
+ /**
2207
+ * Handle /deny command
2208
+ */
2209
+ private async handleDenyCommand(
2210
+ adapter: ChannelAdapter,
2211
+ message: IncomingMessage,
2212
+ sessionId: string
2213
+ ): Promise<void> {
2214
+ // Find pending approval for this session
2215
+ const approvalEntry = Array.from(this.pendingApprovals.entries())
2216
+ .find(([, data]) => data.sessionId === sessionId);
2217
+
2218
+ if (!approvalEntry) {
2219
+ await adapter.sendMessage({
2220
+ chatId: message.chatId,
2221
+ text: '❌ No pending approval request.',
2222
+ });
2223
+ return;
2224
+ }
2225
+
2226
+ const [approvalId] = approvalEntry;
2227
+ this.pendingApprovals.delete(approvalId);
2228
+
2229
+ try {
2230
+ await this.agentDaemon?.respondToApproval(approvalId, false);
2231
+ await adapter.sendMessage({
2232
+ chatId: message.chatId,
2233
+ text: '🛑 Denied. Action cancelled.',
2234
+ });
2235
+ } catch (error) {
2236
+ console.error('Error responding to denial:', error);
2237
+ await adapter.sendMessage({
2238
+ chatId: message.chatId,
2239
+ text: '❌ Failed to process denial.',
2240
+ });
2241
+ }
2242
+ }
2243
+
2244
+ /**
2245
+ * Handle /queue command - view or clear task queue
2246
+ */
2247
+ private async handleQueueCommand(
2248
+ adapter: ChannelAdapter,
2249
+ message: IncomingMessage,
2250
+ args: string[]
2251
+ ): Promise<void> {
2252
+ if (!this.agentDaemon) {
2253
+ await adapter.sendMessage({
2254
+ chatId: message.chatId,
2255
+ text: '❌ Agent daemon not available.',
2256
+ });
2257
+ return;
2258
+ }
2259
+
2260
+ const subcommand = args[0]?.toLowerCase();
2261
+
2262
+ if (subcommand === 'clear' || subcommand === 'reset') {
2263
+ // Clear stuck tasks (also properly cancels running tasks to clean up browser sessions)
2264
+ const result = await this.agentDaemon.clearStuckTasks();
2265
+ await adapter.sendMessage({
2266
+ chatId: message.chatId,
2267
+ text: `✅ Queue cleared!\n\n• Running tasks cancelled: ${result.clearedRunning}\n• Queued tasks removed: ${result.clearedQueued}\n\nBrowser sessions and other resources have been cleaned up. You can now start new tasks.`,
2268
+ });
2269
+ } else {
2270
+ // Show queue status
2271
+ const status = this.agentDaemon.getQueueStatus();
2272
+ const statusText = `📊 *Queue Status*
2273
+
2274
+ • Running: ${status.runningCount}/${status.maxConcurrent}
2275
+ • Queued: ${status.queuedCount}
2276
+
2277
+ ${status.runningCount > 0 ? `Running task IDs: ${status.runningTaskIds.join(', ')}` : ''}
2278
+ ${status.queuedCount > 0 ? `Queued task IDs: ${status.queuedTaskIds.join(', ')}` : ''}
2279
+
2280
+ *Commands:*
2281
+ • \`/queue\` - Show this status
2282
+ • \`/queue clear\` - Clear stuck tasks`;
2283
+
2284
+ await adapter.sendMessage({
2285
+ chatId: message.chatId,
2286
+ text: statusText,
2287
+ parseMode: 'markdown',
2288
+ });
2289
+ }
2290
+ }
2291
+
2292
+ /**
2293
+ * Split a message into chunks for Telegram's character limit
2294
+ */
2295
+ private splitMessage(text: string, maxLength: number): string[] {
2296
+ if (text.length <= maxLength) {
2297
+ return [text];
2298
+ }
2299
+
2300
+ const chunks: string[] = [];
2301
+ let remaining = text;
2302
+
2303
+ while (remaining.length > 0) {
2304
+ if (remaining.length <= maxLength) {
2305
+ chunks.push(remaining);
2306
+ break;
2307
+ }
2308
+
2309
+ // Try to split at newline
2310
+ let splitIndex = remaining.lastIndexOf('\n', maxLength);
2311
+ if (splitIndex === -1 || splitIndex < maxLength / 2) {
2312
+ // Try to split at space
2313
+ splitIndex = remaining.lastIndexOf(' ', maxLength);
2314
+ }
2315
+ if (splitIndex === -1 || splitIndex < maxLength / 2) {
2316
+ // Force split
2317
+ splitIndex = maxLength;
2318
+ }
2319
+
2320
+ chunks.push(remaining.substring(0, splitIndex));
2321
+ remaining = remaining.substring(splitIndex).trimStart();
2322
+ }
2323
+
2324
+ return chunks;
2325
+ }
2326
+
2327
+ /**
2328
+ * Handle cancel command
2329
+ */
2330
+ private async handleCancelCommand(
2331
+ adapter: ChannelAdapter,
2332
+ message: IncomingMessage,
2333
+ sessionId: string
2334
+ ): Promise<void> {
2335
+ const session = this.sessionRepo.findById(sessionId);
2336
+
2337
+ if (session?.taskId) {
2338
+ // Notify desktop app to cancel the task
2339
+ if (this.mainWindow && !this.mainWindow.isDestroyed()) {
2340
+ this.mainWindow.webContents.send('gateway:cancel-task', {
2341
+ taskId: session.taskId,
2342
+ sessionId,
2343
+ });
2344
+ }
2345
+
2346
+ // Update session state
2347
+ this.sessionRepo.update(sessionId, { state: 'idle', taskId: undefined });
2348
+
2349
+ await adapter.sendMessage({
2350
+ chatId: message.chatId,
2351
+ text: '🛑 Task cancelled.',
2352
+ });
2353
+ } else {
2354
+ await adapter.sendMessage({
2355
+ chatId: message.chatId,
2356
+ text: 'No active task to cancel.',
2357
+ });
2358
+ }
2359
+ }
2360
+
2361
+ /**
2362
+ * Handle newtask command - start a fresh task session
2363
+ */
2364
+ private async handleNewTaskCommand(
2365
+ adapter: ChannelAdapter,
2366
+ message: IncomingMessage,
2367
+ sessionId: string
2368
+ ): Promise<void> {
2369
+ const session = this.sessionRepo.findById(sessionId);
2370
+
2371
+ if (session?.taskId) {
2372
+ // Unlink current task from session
2373
+ this.sessionManager.unlinkSessionFromTask(sessionId);
2374
+ this.pendingTaskResponses.delete(session.taskId);
2375
+ }
2376
+
2377
+ await adapter.sendMessage({
2378
+ chatId: message.chatId,
2379
+ text: '🆕 Ready for a new task!\n\nSend me a message describing what you want to do.',
2380
+ });
2381
+ }
2382
+
2383
+ /**
2384
+ * Handle /removeworkspace command
2385
+ */
2386
+ private async handleRemoveWorkspaceCommand(
2387
+ adapter: ChannelAdapter,
2388
+ message: IncomingMessage,
2389
+ sessionId: string,
2390
+ args: string[]
2391
+ ): Promise<void> {
2392
+ if (args.length === 0) {
2393
+ await adapter.sendMessage({
2394
+ chatId: message.chatId,
2395
+ text: '❌ Please specify a workspace name to remove.\n\nUsage: `/removeworkspace <name>`',
2396
+ parseMode: 'markdown',
2397
+ });
2398
+ return;
2399
+ }
2400
+
2401
+ const workspaceName = args.join(' ');
2402
+ const workspaces = this.workspaceRepo.findAll();
2403
+ const workspace = workspaces.find(
2404
+ (w) => w.name.toLowerCase() === workspaceName.toLowerCase()
2405
+ );
2406
+
2407
+ if (!workspace) {
2408
+ await adapter.sendMessage({
2409
+ chatId: message.chatId,
2410
+ text: `❌ Workspace "${workspaceName}" not found.\n\nUse /workspaces to see available workspaces.`,
2411
+ });
2412
+ return;
2413
+ }
2414
+
2415
+ // Check if this is the current workspace for the session
2416
+ const session = this.sessionRepo.findById(sessionId);
2417
+ if (session?.workspaceId === workspace.id) {
2418
+ // Clear the workspace from session
2419
+ this.sessionRepo.update(sessionId, { workspaceId: undefined });
2420
+ }
2421
+
2422
+ // Remove the workspace
2423
+ this.workspaceRepo.delete(workspace.id);
2424
+
2425
+ await adapter.sendMessage({
2426
+ chatId: message.chatId,
2427
+ text: `✅ Workspace "${workspace.name}" removed successfully.`,
2428
+ });
2429
+ }
2430
+
2431
+ /**
2432
+ * Handle /retry command - retry the last failed task
2433
+ */
2434
+ private async handleRetryCommand(
2435
+ adapter: ChannelAdapter,
2436
+ message: IncomingMessage,
2437
+ sessionId: string
2438
+ ): Promise<void> {
2439
+ let session = this.sessionRepo.findById(sessionId);
2440
+
2441
+ // Auto-assign temp workspace if none selected
2442
+ if (!session?.workspaceId) {
2443
+ const tempWorkspace = this.getOrCreateTempWorkspace();
2444
+ this.sessionRepo.update(sessionId, { workspaceId: tempWorkspace.id });
2445
+ session = this.sessionRepo.findById(sessionId);
2446
+ }
2447
+
2448
+ // Find the last task for this session's workspace that failed or was cancelled
2449
+ const tasks = this.taskRepo.findByWorkspace(session!.workspaceId!);
2450
+ const lastFailedTask = tasks
2451
+ .filter((t: Task) => t.status === 'failed' || t.status === 'cancelled')
2452
+ .sort((a: Task, b: Task) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())[0];
2453
+
2454
+ if (!lastFailedTask) {
2455
+ await adapter.sendMessage({
2456
+ chatId: message.chatId,
2457
+ text: '❌ No failed task found to retry.\n\nStart a new task by sending a message.',
2458
+ });
2459
+ return;
2460
+ }
2461
+
2462
+ // Re-submit the task by sending the original prompt as a new message
2463
+ await adapter.sendMessage({
2464
+ chatId: message.chatId,
2465
+ text: `🔄 Retrying task...\n\nOriginal prompt: "${lastFailedTask.title}"`,
2466
+ });
2467
+
2468
+ // Create a synthetic message with the original prompt
2469
+ const retryMessage: IncomingMessage = {
2470
+ ...message,
2471
+ text: lastFailedTask.title,
2472
+ };
2473
+
2474
+ // Route as a regular task message
2475
+ await this.routeMessage(adapter, retryMessage, sessionId);
2476
+ }
2477
+
2478
+ /**
2479
+ * Handle /history command - show recent task history
2480
+ */
2481
+ private async handleHistoryCommand(
2482
+ adapter: ChannelAdapter,
2483
+ message: IncomingMessage,
2484
+ sessionId: string
2485
+ ): Promise<void> {
2486
+ let session = this.sessionRepo.findById(sessionId);
2487
+
2488
+ // Auto-assign temp workspace if none selected
2489
+ if (!session?.workspaceId) {
2490
+ const tempWorkspace = this.getOrCreateTempWorkspace();
2491
+ this.sessionRepo.update(sessionId, { workspaceId: tempWorkspace.id });
2492
+ session = this.sessionRepo.findById(sessionId);
2493
+ }
2494
+
2495
+ const tasks = this.taskRepo.findByWorkspace(session!.workspaceId!);
2496
+ const recentTasks = tasks
2497
+ .sort((a: Task, b: Task) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
2498
+ .slice(0, 10);
2499
+
2500
+ if (recentTasks.length === 0) {
2501
+ await adapter.sendMessage({
2502
+ chatId: message.chatId,
2503
+ text: '📋 No task history found.\n\nStart a new task by sending a message.',
2504
+ });
2505
+ return;
2506
+ }
2507
+
2508
+ const statusEmoji: Record<string, string> = {
2509
+ completed: '✅',
2510
+ running: '⏳',
2511
+ pending: '⏸️',
2512
+ error: '❌',
2513
+ cancelled: '🚫',
2514
+ };
2515
+
2516
+ const historyText = recentTasks
2517
+ .map((t: Task, i: number) => {
2518
+ const emoji = statusEmoji[t.status] || '❓';
2519
+ const date = new Date(t.createdAt).toLocaleDateString();
2520
+ const title = t.title.length > 40 ? t.title.substring(0, 40) + '...' : t.title;
2521
+ return `${i + 1}. ${emoji} ${title}\n ${date} • ${t.status}`;
2522
+ })
2523
+ .join('\n\n');
2524
+
2525
+ await adapter.sendMessage({
2526
+ chatId: message.chatId,
2527
+ text: `📋 *Recent Tasks*\n\n${historyText}`,
2528
+ parseMode: 'markdown',
2529
+ });
2530
+ }
2531
+
2532
+ /**
2533
+ * Handle /skills command - list available skills
2534
+ */
2535
+ private async handleSkillsCommand(
2536
+ adapter: ChannelAdapter,
2537
+ message: IncomingMessage,
2538
+ _sessionId: string
2539
+ ): Promise<void> {
2540
+ try {
2541
+ const skillLoader = getCustomSkillLoader();
2542
+ await skillLoader.initialize();
2543
+ const skills = skillLoader.listTaskSkills();
2544
+
2545
+ if (skills.length === 0) {
2546
+ await adapter.sendMessage({
2547
+ chatId: message.chatId,
2548
+ text: '📚 No skills available.\n\nSkills are stored in:\n`~/Library/Application Support/cowork-os/skills/`',
2549
+ parseMode: 'markdown',
2550
+ });
2551
+ return;
2552
+ }
2553
+
2554
+ // Group skills by category
2555
+ const byCategory = new Map<string, typeof skills>();
2556
+ for (const skill of skills) {
2557
+ const category = skill.category || 'Uncategorized';
2558
+ if (!byCategory.has(category)) {
2559
+ byCategory.set(category, []);
2560
+ }
2561
+ byCategory.get(category)!.push(skill);
2562
+ }
2563
+
2564
+ let text = '📚 *Available Skills*\n\n';
2565
+ for (const [category, categorySkills] of byCategory) {
2566
+ text += `*${category}*\n`;
2567
+ for (const skill of categorySkills) {
2568
+ const status = skill.enabled !== false ? '✅' : '❌';
2569
+ text += `${skill.icon || '⚡'} ${skill.name} ${status}\n`;
2570
+ text += ` \`/skill ${skill.id}\` to toggle\n`;
2571
+ }
2572
+ text += '\n';
2573
+ }
2574
+
2575
+ text += '_Use `/skill <name>` to toggle a skill on/off_';
2576
+
2577
+ await adapter.sendMessage({
2578
+ chatId: message.chatId,
2579
+ text,
2580
+ parseMode: 'markdown',
2581
+ });
2582
+ } catch (error) {
2583
+ await adapter.sendMessage({
2584
+ chatId: message.chatId,
2585
+ text: '❌ Failed to load skills.',
2586
+ });
2587
+ }
2588
+ }
2589
+
2590
+ /**
2591
+ * Handle /skill command - toggle a skill on/off
2592
+ */
2593
+ private async handleSkillCommand(
2594
+ adapter: ChannelAdapter,
2595
+ message: IncomingMessage,
2596
+ _sessionId: string,
2597
+ args: string[]
2598
+ ): Promise<void> {
2599
+ if (args.length === 0) {
2600
+ await adapter.sendMessage({
2601
+ chatId: message.chatId,
2602
+ text: '❌ Please specify a skill ID.\n\nUsage: `/skill <id>`\n\nUse /skills to see available skills.',
2603
+ parseMode: 'markdown',
2604
+ });
2605
+ return;
2606
+ }
2607
+
2608
+ try {
2609
+ const skillLoader = getCustomSkillLoader();
2610
+ await skillLoader.initialize();
2611
+ const skillId = args[0].toLowerCase();
2612
+ const skill = skillLoader.getSkill(skillId);
2613
+
2614
+ if (!skill) {
2615
+ await adapter.sendMessage({
2616
+ chatId: message.chatId,
2617
+ text: `❌ Skill "${skillId}" not found.\n\nUse /skills to see available skills.`,
2618
+ });
2619
+ return;
2620
+ }
2621
+
2622
+ // Toggle the enabled state
2623
+ const newState = skill.enabled === false;
2624
+ await skillLoader.updateSkill(skillId, { enabled: newState });
2625
+
2626
+ const statusText = newState ? '✅ enabled' : '❌ disabled';
2627
+ await adapter.sendMessage({
2628
+ chatId: message.chatId,
2629
+ text: `${skill.icon || '⚡'} *${skill.name}* is now ${statusText}`,
2630
+ parseMode: 'markdown',
2631
+ });
2632
+ } catch (error) {
2633
+ await adapter.sendMessage({
2634
+ chatId: message.chatId,
2635
+ text: '❌ Failed to toggle skill.',
2636
+ });
2637
+ }
2638
+ }
2639
+
2640
+ /**
2641
+ * Handle /providers command - list available LLM providers
2642
+ */
2643
+ private async handleProvidersCommand(
2644
+ adapter: ChannelAdapter,
2645
+ message: IncomingMessage
2646
+ ): Promise<void> {
2647
+ const status = LLMProviderFactory.getConfigStatus();
2648
+ const current = status.currentProvider;
2649
+
2650
+ const providerEmoji: Record<string, string> = {
2651
+ anthropic: '🟠',
2652
+ openai: '🟢',
2653
+ gemini: '🔵',
2654
+ bedrock: '🟡',
2655
+ ollama: '⚪',
2656
+ openrouter: '🟣',
2657
+ };
2658
+
2659
+ // Build inline keyboard with provider buttons
2660
+ const keyboard: InlineKeyboardButton[][] = [];
2661
+ const row1: InlineKeyboardButton[] = [];
2662
+ const row2: InlineKeyboardButton[] = [];
2663
+
2664
+ // Get configured providers for the keyboard
2665
+ const providerOrder: LLMProviderType[] = ['anthropic', 'openai', 'gemini', 'bedrock', 'openrouter', 'ollama'];
2666
+
2667
+ for (let i = 0; i < providerOrder.length; i++) {
2668
+ const provider = providerOrder[i];
2669
+ const emoji = providerEmoji[provider] || '⚡';
2670
+ const isCurrent = provider === current ? ' ✓' : '';
2671
+ const providerInfo = status.providers.find(p => p.type === provider);
2672
+ const name = providerInfo?.name || provider;
2673
+
2674
+ const button: InlineKeyboardButton = {
2675
+ text: `${emoji} ${name}${isCurrent}`,
2676
+ callbackData: `provider:${provider}`,
2677
+ };
2678
+
2679
+ // Split into two rows
2680
+ if (i < 3) {
2681
+ row1.push(button);
2682
+ } else {
2683
+ row2.push(button);
2684
+ }
2685
+ }
2686
+
2687
+ keyboard.push(row1);
2688
+ keyboard.push(row2);
2689
+
2690
+ const currentProviderInfo = status.providers.find(p => p.type === current);
2691
+
2692
+ // WhatsApp/iMessage don't support inline keyboards - use text-based selection
2693
+ if (adapter.type === 'whatsapp' || adapter.type === 'imessage') {
2694
+ let text = `🤖 *AI Providers*\n\nCurrent: *${currentProviderInfo?.name || current}*\n\n`;
2695
+ providerOrder.forEach((provider, index) => {
2696
+ const emoji = providerEmoji[provider] || '⚡';
2697
+ const providerInfo = status.providers.find(p => p.type === provider);
2698
+ const name = providerInfo?.name || provider;
2699
+ const isCurrent = provider === current ? ' ✓' : '';
2700
+ text += `${index + 1}. ${emoji} *${name}*${isCurrent}\n`;
2701
+ });
2702
+ text += '\n━━━━━━━━━━━━━━━\n';
2703
+ text += 'Reply with number to switch.\nExample: `1` for Anthropic';
2704
+
2705
+ await adapter.sendMessage({
2706
+ chatId: message.chatId,
2707
+ text,
2708
+ parseMode: 'markdown',
2709
+ threadId: message.threadId,
2710
+ });
2711
+ } else {
2712
+ let text = `🤖 *AI Providers*\n\nCurrent: ${currentProviderInfo?.name || current}\n\nTap to switch:`;
2713
+
2714
+ await adapter.sendMessage({
2715
+ chatId: message.chatId,
2716
+ text,
2717
+ parseMode: 'markdown',
2718
+ inlineKeyboard: keyboard,
2719
+ threadId: message.threadId,
2720
+ });
2721
+ }
2722
+ }
2723
+
2724
+ /**
2725
+ * Handle /settings command - view current settings
2726
+ */
2727
+ private async handleSettingsCommand(
2728
+ adapter: ChannelAdapter,
2729
+ message: IncomingMessage,
2730
+ sessionId: string
2731
+ ): Promise<void> {
2732
+ const session = this.sessionRepo.findById(sessionId);
2733
+ const workspace = session?.workspaceId
2734
+ ? this.workspaceRepo.findById(session.workspaceId)
2735
+ : null;
2736
+
2737
+ const provider = LLMProviderFactory.getSelectedProvider();
2738
+ const model = LLMProviderFactory.getSelectedModel();
2739
+ const settings = LLMProviderFactory.getSettings();
2740
+
2741
+ let text = '⚙️ *Current Settings*\n\n';
2742
+
2743
+ text += '*Workspace*\n';
2744
+ text += workspace ? `📁 ${workspace.name}\n` : '❌ None selected\n';
2745
+ text += '\n';
2746
+
2747
+ text += '*AI Configuration*\n';
2748
+ text += `🤖 Provider: \`${provider}\`\n`;
2749
+ text += `🧠 Model: \`${model}\`\n`;
2750
+ text += '\n';
2751
+
2752
+ text += '*Session*\n';
2753
+ text += `🔧 Shell commands: ${session?.shellEnabled ? '✅' : '❌'}\n`;
2754
+ text += `📝 Debug mode: ${session?.debugMode ? '✅' : '❌'}\n`;
2755
+
2756
+ await adapter.sendMessage({
2757
+ chatId: message.chatId,
2758
+ text,
2759
+ parseMode: 'markdown',
2760
+ });
2761
+ }
2762
+
2763
+ /**
2764
+ * Handle /debug command - toggle debug mode
2765
+ */
2766
+ private async handleDebugCommand(
2767
+ adapter: ChannelAdapter,
2768
+ message: IncomingMessage,
2769
+ sessionId: string
2770
+ ): Promise<void> {
2771
+ const session = this.sessionRepo.findById(sessionId);
2772
+ const currentDebug = session?.debugMode || false;
2773
+ const newDebug = !currentDebug;
2774
+
2775
+ this.sessionRepo.update(sessionId, { debugMode: newDebug });
2776
+
2777
+ const statusText = newDebug ? '✅ enabled' : '❌ disabled';
2778
+ await adapter.sendMessage({
2779
+ chatId: message.chatId,
2780
+ text: `🐛 Debug mode is now ${statusText}`,
2781
+ });
2782
+ }
2783
+
2784
+ /**
2785
+ * Handle /version command - show version info
2786
+ */
2787
+ private async handleVersionCommand(
2788
+ adapter: ChannelAdapter,
2789
+ message: IncomingMessage
2790
+ ): Promise<void> {
2791
+ const version = app.getVersion();
2792
+ const electronVersion = process.versions.electron;
2793
+ const nodeVersion = process.versions.node;
2794
+ const platform = process.platform;
2795
+ const arch = process.arch;
2796
+
2797
+ const text = `📦 *CoWork OS*
2798
+
2799
+ Version: \`${version}\`
2800
+ Platform: \`${platform}\` (${arch})
2801
+ Electron: \`${electronVersion}\`
2802
+ Node.js: \`${nodeVersion}\`
2803
+
2804
+ 🔗 [GitHub](https://github.com/CoWork-OS/cowork-os)`;
2805
+
2806
+ await adapter.sendMessage({
2807
+ chatId: message.chatId,
2808
+ text,
2809
+ parseMode: 'markdown',
2810
+ });
2811
+ }
2812
+
2813
+ /**
2814
+ * Handle /start command with smart onboarding
2815
+ */
2816
+ private async handleStartCommand(
2817
+ adapter: ChannelAdapter,
2818
+ message: IncomingMessage,
2819
+ sessionId: string
2820
+ ): Promise<void> {
2821
+ const session = this.sessionRepo.findById(sessionId);
2822
+ const workspaces = this.workspaceRepo.findAll();
2823
+
2824
+ // WhatsApp/iMessage-optimized welcome flow (no inline keyboards)
2825
+ if (adapter.type === 'whatsapp' || adapter.type === 'imessage') {
2826
+ if (session?.workspaceId) {
2827
+ const workspace = this.workspaceRepo.findById(session.workspaceId);
2828
+ await adapter.sendMessage({
2829
+ chatId: message.chatId,
2830
+ text: `👋 *Welcome back!*\n\nWorkspace: *${workspace?.name || 'Unknown'}*\n\nJust send me what you'd like me to do.\n\nType /help for commands.`,
2831
+ parseMode: 'markdown',
2832
+ });
2833
+ } else if (workspaces.length === 0) {
2834
+ await adapter.sendMessage({
2835
+ chatId: message.chatId,
2836
+ text: `👋 *Welcome to CoWork!*\n\nI'm your AI coding assistant.\n\nFirst, add a workspace:\n\`/addworkspace /path/to/project\`\n\nOr add one from the desktop app.`,
2837
+ parseMode: 'markdown',
2838
+ });
2839
+ } else if (workspaces.length === 1) {
2840
+ // Auto-select the only workspace
2841
+ const workspace = workspaces[0];
2842
+ this.sessionManager.setSessionWorkspace(sessionId, workspace.id);
2843
+ await adapter.sendMessage({
2844
+ chatId: message.chatId,
2845
+ text: `👋 *Welcome to CoWork!*\n\n✅ Workspace: *${workspace.name}*\n\nJust tell me what you'd like me to do!\n\nExamples:\n• "Add dark mode support"\n• "Fix the login bug"\n• "Create a new API endpoint"`,
2846
+ parseMode: 'markdown',
2847
+ });
2848
+ } else {
2849
+ // Multiple workspaces - show selection
2850
+ let text = `👋 *Welcome to CoWork!*\n\nSelect a workspace to start:\n\n`;
2851
+ workspaces.forEach((ws, index) => {
2852
+ text += `${index + 1}. *${ws.name}*\n`;
2853
+ });
2854
+ text += `\nReply with a number (e.g., \`1\`)`;
2855
+
2856
+ await adapter.sendMessage({
2857
+ chatId: message.chatId,
2858
+ text,
2859
+ parseMode: 'markdown',
2860
+ });
2861
+ }
2862
+ return;
2863
+ }
2864
+
2865
+ // Standard welcome for Telegram/Discord
2866
+ await adapter.sendMessage({
2867
+ chatId: message.chatId,
2868
+ text: this.config.welcomeMessage!,
2869
+ });
2870
+
2871
+ // Show workspaces if none selected
2872
+ if (!session?.workspaceId && workspaces.length > 0) {
2873
+ await this.handleWorkspacesCommand(adapter, message);
2874
+ }
2875
+ }
2876
+
2877
+ /**
2878
+ * Get help text - channel-specific for better UX
2879
+ */
2880
+ private getHelpText(channelType?: ChannelType): string {
2881
+ // Compact help for WhatsApp (mobile-friendly)
2882
+ if (channelType === 'whatsapp') {
2883
+ return `📚 *Commands*
2884
+
2885
+ *Basics*
2886
+ /workspaces - Select workspace
2887
+ /status - Current status
2888
+ /newtask - Fresh start
2889
+
2890
+ *Tasks*
2891
+ /cancel - Stop task
2892
+ /approve or /yes - Approve action
2893
+ /deny or /no - Reject action
2894
+
2895
+ *Settings*
2896
+ /shell on|off - Shell access
2897
+ /models - Change AI model
2898
+
2899
+ ━━━━━━━━━━━━━━━
2900
+ 💡 Just send your task directly!
2901
+ Example: "Add a login form"`;
2902
+ }
2903
+
2904
+ // Full help for other channels
2905
+ return `📚 *Available Commands*
2906
+
2907
+ *Core*
2908
+ /start - Start the bot
2909
+ /help - Show this help message
2910
+ /status - Check bot status and workspace
2911
+ /version - Show version information
2912
+
2913
+ *Workspaces*
2914
+ /workspaces - List available workspaces
2915
+ /workspace <name> - Select a workspace
2916
+ /addworkspace <path> - Add a new workspace
2917
+ /removeworkspace <name> - Remove a workspace
2918
+
2919
+ *Tasks*
2920
+ /newtask - Start a fresh task/conversation
2921
+ /cancel - Cancel current task
2922
+ /retry - Retry the last failed task
2923
+ /history - Show recent task history
2924
+ /approve - Approve pending action (or /yes, /y)
2925
+ /deny - Reject pending action (or /no, /n)
2926
+ /queue - View/clear task queue
2927
+
2928
+ *Models*
2929
+ /providers - List available AI providers
2930
+ /provider <name> - Show or change provider
2931
+ /models - List available AI models
2932
+ /model <name> - Show or change model
2933
+
2934
+ *Skills*
2935
+ /skills - List available skills
2936
+ /skill <name> - Toggle a skill on/off
2937
+
2938
+ *Settings*
2939
+ /settings - View current settings
2940
+ /shell - Enable/disable shell commands
2941
+ /debug - Toggle debug mode
2942
+
2943
+ 💬 *Quick Start*
2944
+ 1. \`/workspaces\` → \`/workspace <name>\`
2945
+ 2. \`/shell on\` (if needed)
2946
+ 3. Send your task message
2947
+ 4. \`/newtask\` to start fresh`;
2948
+ }
2949
+
2950
+ /**
2951
+ * Handle callback query from inline keyboard button press
2952
+ */
2953
+ private async handleCallbackQuery(adapter: ChannelAdapter, query: CallbackQuery): Promise<void> {
2954
+ const { data, chatId } = query;
2955
+
2956
+ // Parse callback data (format: action:param)
2957
+ const [action, ...params] = data.split(':');
2958
+ const param = params.join(':');
2959
+
2960
+ try {
2961
+ // Get or create session for this chat
2962
+ const channel = this.channelRepo.findByType(adapter.type);
2963
+ if (!channel) {
2964
+ console.error(`No channel configuration found for ${adapter.type}`);
2965
+ return;
2966
+ }
2967
+
2968
+ // Find existing session or create one
2969
+ let session = this.sessionRepo.findByChatId(channel.id, chatId);
2970
+ if (!session) {
2971
+ // Create a minimal session for handling callback
2972
+ session = this.sessionRepo.create({
2973
+ channelId: channel.id,
2974
+ chatId,
2975
+ state: 'idle',
2976
+ });
2977
+ }
2978
+
2979
+ // Answer the callback to remove loading indicator
2980
+ if (adapter.answerCallbackQuery) {
2981
+ await adapter.answerCallbackQuery(query.id);
2982
+ }
2983
+
2984
+ switch (action) {
2985
+ case 'workspace':
2986
+ await this.handleWorkspaceCallback(adapter, query, session.id, param);
2987
+ break;
2988
+
2989
+ case 'provider':
2990
+ await this.handleProviderCallback(adapter, query, param);
2991
+ break;
2992
+
2993
+ case 'model':
2994
+ await this.handleModelCallback(adapter, query, param);
2995
+ break;
2996
+
2997
+ case 'approve':
2998
+ await this.handleApprovalCallback(adapter, query, session.id, true);
2999
+ break;
3000
+
3001
+ case 'deny':
3002
+ await this.handleApprovalCallback(adapter, query, session.id, false);
3003
+ break;
3004
+
3005
+ default:
3006
+ console.log(`Unknown callback action: ${action}`);
3007
+ }
3008
+ } catch (error) {
3009
+ console.error('Error handling callback query:', error);
3010
+ }
3011
+ }
3012
+
3013
+ /**
3014
+ * Handle workspace selection callback
3015
+ */
3016
+ private async handleWorkspaceCallback(
3017
+ adapter: ChannelAdapter,
3018
+ query: CallbackQuery,
3019
+ sessionId: string,
3020
+ workspaceId: string
3021
+ ): Promise<void> {
3022
+ const workspace = this.workspaceRepo.findById(workspaceId);
3023
+ if (!workspace) {
3024
+ await adapter.sendMessage({
3025
+ chatId: query.chatId,
3026
+ text: '❌ Workspace not found.',
3027
+ });
3028
+ return;
3029
+ }
3030
+
3031
+ // Update session workspace
3032
+ this.sessionManager.setSessionWorkspace(sessionId, workspace.id);
3033
+
3034
+ // Update the original message with the selection
3035
+ if (adapter.editMessageWithKeyboard) {
3036
+ await adapter.editMessageWithKeyboard(
3037
+ query.chatId,
3038
+ query.messageId,
3039
+ `✅ Workspace selected: *${workspace.name}*\n\`${workspace.path}\`\n\nYou can now send messages to create tasks.`
3040
+ );
3041
+ } else {
3042
+ await adapter.sendMessage({
3043
+ chatId: query.chatId,
3044
+ text: `✅ Workspace set to: *${workspace.name}*\n\`${workspace.path}\``,
3045
+ parseMode: 'markdown',
3046
+ });
3047
+ }
3048
+ }
3049
+
3050
+ /**
3051
+ * Handle provider selection callback
3052
+ */
3053
+ private async handleProviderCallback(
3054
+ adapter: ChannelAdapter,
3055
+ query: CallbackQuery,
3056
+ providerType: string
3057
+ ): Promise<void> {
3058
+ const settings = LLMProviderFactory.loadSettings();
3059
+ const status = LLMProviderFactory.getConfigStatus();
3060
+
3061
+ // Update provider
3062
+ const newSettings: LLMSettings = {
3063
+ ...settings,
3064
+ providerType: providerType as LLMProviderType,
3065
+ };
3066
+
3067
+ LLMProviderFactory.saveSettings(newSettings);
3068
+ LLMProviderFactory.clearCache();
3069
+
3070
+ const providerInfo = status.providers.find(p => p.type === providerType);
3071
+
3072
+ // Update the original message
3073
+ if (adapter.editMessageWithKeyboard) {
3074
+ await adapter.editMessageWithKeyboard(
3075
+ query.chatId,
3076
+ query.messageId,
3077
+ `✅ Provider changed to: *${providerInfo?.name || providerType}*\n\nUse /models to see available models.`
3078
+ );
3079
+ } else {
3080
+ await adapter.sendMessage({
3081
+ chatId: query.chatId,
3082
+ text: `✅ Provider changed to: *${providerInfo?.name || providerType}*`,
3083
+ parseMode: 'markdown',
3084
+ });
3085
+ }
3086
+ }
3087
+
3088
+ /**
3089
+ * Handle model selection callback
3090
+ */
3091
+ private async handleModelCallback(
3092
+ adapter: ChannelAdapter,
3093
+ query: CallbackQuery,
3094
+ modelKey: string
3095
+ ): Promise<void> {
3096
+ const settings = LLMProviderFactory.loadSettings();
3097
+ const status = LLMProviderFactory.getConfigStatus();
3098
+ const providerType = status.currentProvider;
3099
+
3100
+ // Save to the appropriate provider-specific setting
3101
+ let newSettings: LLMSettings = { ...settings };
3102
+ let displayName = modelKey;
3103
+
3104
+ switch (providerType) {
3105
+ case 'openai':
3106
+ newSettings.openai = { ...settings.openai, model: modelKey };
3107
+ break;
3108
+ case 'gemini':
3109
+ newSettings.gemini = { ...settings.gemini, model: modelKey };
3110
+ break;
3111
+ case 'openrouter':
3112
+ newSettings.openrouter = { ...settings.openrouter, model: modelKey };
3113
+ break;
3114
+ case 'ollama':
3115
+ newSettings.ollama = { ...settings.ollama, model: modelKey };
3116
+ break;
3117
+ default:
3118
+ newSettings.modelKey = modelKey as ModelKey;
3119
+ const modelInfo = status.models.find(m => m.key === modelKey);
3120
+ displayName = modelInfo?.displayName || modelKey;
3121
+ }
3122
+
3123
+ LLMProviderFactory.saveSettings(newSettings);
3124
+ LLMProviderFactory.clearCache();
3125
+
3126
+ // Update the original message
3127
+ if (adapter.editMessageWithKeyboard) {
3128
+ await adapter.editMessageWithKeyboard(
3129
+ query.chatId,
3130
+ query.messageId,
3131
+ `✅ Model changed to: *${displayName}*`
3132
+ );
3133
+ } else {
3134
+ await adapter.sendMessage({
3135
+ chatId: query.chatId,
3136
+ text: `✅ Model changed to: *${displayName}*`,
3137
+ parseMode: 'markdown',
3138
+ });
3139
+ }
3140
+ }
3141
+
3142
+ /**
3143
+ * Handle approval/deny callback from inline buttons
3144
+ */
3145
+ private async handleApprovalCallback(
3146
+ adapter: ChannelAdapter,
3147
+ query: CallbackQuery,
3148
+ sessionId: string,
3149
+ approved: boolean
3150
+ ): Promise<void> {
3151
+ // Find pending approval for this session
3152
+ const approvalEntry = Array.from(this.pendingApprovals.entries())
3153
+ .find(([, data]) => data.sessionId === sessionId);
3154
+
3155
+ if (!approvalEntry) {
3156
+ if (adapter.editMessageWithKeyboard) {
3157
+ await adapter.editMessageWithKeyboard(
3158
+ query.chatId,
3159
+ query.messageId,
3160
+ '❌ No pending approval request (may have expired).'
3161
+ );
3162
+ }
3163
+ return;
3164
+ }
3165
+
3166
+ const [approvalId] = approvalEntry;
3167
+ this.pendingApprovals.delete(approvalId);
3168
+
3169
+ try {
3170
+ await this.agentDaemon?.respondToApproval(approvalId, approved);
3171
+
3172
+ const statusText = approved ? '✅ Approved! Executing...' : '🛑 Denied. Action cancelled.';
3173
+ if (adapter.editMessageWithKeyboard) {
3174
+ await adapter.editMessageWithKeyboard(
3175
+ query.chatId,
3176
+ query.messageId,
3177
+ statusText
3178
+ );
3179
+ } else {
3180
+ await adapter.sendMessage({
3181
+ chatId: query.chatId,
3182
+ text: statusText,
3183
+ });
3184
+ }
3185
+ } catch (error) {
3186
+ console.error('Error responding to approval:', error);
3187
+ await adapter.sendMessage({
3188
+ chatId: query.chatId,
3189
+ text: '❌ Failed to process response.',
3190
+ });
3191
+ }
3192
+ }
3193
+
3194
+ /**
3195
+ * Emit an event to all handlers
3196
+ */
3197
+ private emitEvent(event: GatewayEvent): void {
3198
+ for (const handler of this.eventHandlers) {
3199
+ try {
3200
+ handler(event);
3201
+ } catch (error) {
3202
+ console.error('Error in event handler:', error);
3203
+ }
3204
+ }
3205
+ }
3206
+ }