@agentprojectcontext/apx 1.81.0 → 1.89.1

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 (838) hide show
  1. package/package.json +9 -8
  2. package/skills/apc-context/SKILL.md +14 -7
  3. package/skills/apx/SKILL.md +62 -2
  4. package/skills/apx-mcp/SKILL.md +42 -15
  5. package/src/core/agent/a2a/peers.js +161 -0
  6. package/src/core/agent/a2a/reply.js +379 -27
  7. package/src/core/agent/abort.js +25 -0
  8. package/src/core/agent/agent-model.js +58 -0
  9. package/src/core/agent/agent-tools.js +101 -0
  10. package/src/core/agent/author-line.js +110 -0
  11. package/src/core/agent/build-agent-system.js +52 -18
  12. package/src/core/agent/channels/voice-context.js +17 -2
  13. package/src/core/agent/closing-floor.js +117 -0
  14. package/src/core/agent/constants.js +119 -11
  15. package/src/core/agent/engine-call.js +56 -0
  16. package/src/core/agent/group/run-group-turn.js +218 -0
  17. package/src/core/agent/group/turn-resolver.js +165 -0
  18. package/src/core/agent/judge.js +138 -11
  19. package/src/core/agent/loop/side-effects.js +106 -3
  20. package/src/core/agent/memory.js +31 -18
  21. package/src/core/agent/model-capabilities.js +47 -0
  22. package/src/core/agent/model-router.js +11 -40
  23. package/src/core/agent/prompt-builder.js +47 -0
  24. package/src/core/agent/prompts/channels/a2a.md +12 -0
  25. package/src/core/agent/prompts/channels/code.md +3 -1
  26. package/src/core/agent/prompts/channels/telegram.md +2 -0
  27. package/src/core/agent/prompts/channels/web_code.md +1 -1
  28. package/src/core/agent/prompts/channels/whatsapp.md +37 -0
  29. package/src/core/agent/prompts/core/agent-base.md +2 -0
  30. package/src/core/agent/prompts/discipline/action.md +1 -0
  31. package/src/core/agent/prompts/discipline/two-segment.md +2 -1
  32. package/src/core/agent/prompts/modes/mobility.md +36 -0
  33. package/src/core/agent/retry.js +42 -0
  34. package/src/core/agent/run-agent.js +322 -25
  35. package/src/core/agent/run-turn.js +253 -0
  36. package/src/core/agent/self-memory.js +18 -52
  37. package/src/core/agent/skills/agent-skills.js +118 -0
  38. package/src/core/agent/skills/catalog.js +1 -1
  39. package/src/core/agent/skills/declared.js +13 -0
  40. package/src/core/agent/skills/index-store.js +8 -1
  41. package/src/core/agent/skills/inspector.js +20 -3
  42. package/src/core/agent/skills/loader.js +18 -3
  43. package/src/core/agent/skills/media.js +121 -0
  44. package/src/core/agent/skills/origin.js +61 -0
  45. package/src/core/agent/skills/sections.js +136 -0
  46. package/src/core/agent/super-agent.js +54 -14
  47. package/src/core/agent/tools/handlers/_calendar.js +50 -0
  48. package/src/core/agent/tools/handlers/add-mcp.js +103 -0
  49. package/src/core/agent/tools/handlers/asana-create-task.js +3 -1
  50. package/src/core/agent/tools/handlers/attach-media.js +56 -0
  51. package/src/core/agent/tools/handlers/calendar-create-event.js +38 -0
  52. package/src/core/agent/tools/handlers/calendar-find-slot.js +39 -0
  53. package/src/core/agent/tools/handlers/calendar-list-events.js +39 -0
  54. package/src/core/agent/tools/handlers/calendar-update-event.js +35 -0
  55. package/src/core/agent/tools/handlers/call-agent.js +14 -6
  56. package/src/core/agent/tools/handlers/call-runtime.js +27 -49
  57. package/src/core/agent/tools/handlers/comment-task.js +78 -0
  58. package/src/core/agent/tools/handlers/complete-task.js +60 -0
  59. package/src/core/agent/tools/handlers/configure-agent.js +70 -0
  60. package/src/core/agent/tools/handlers/create-agent.js +68 -0
  61. package/src/core/agent/tools/handlers/create-task.js +41 -3
  62. package/src/core/agent/tools/handlers/import-agent.js +5 -5
  63. package/src/core/agent/tools/handlers/list-mcp-tools.js +83 -0
  64. package/src/core/agent/tools/handlers/list-routines.js +46 -0
  65. package/src/core/agent/tools/handlers/list-tasks.js +8 -1
  66. package/src/core/agent/tools/handlers/mark-commitment.js +57 -0
  67. package/src/core/agent/tools/handlers/read-skill.js +64 -0
  68. package/src/core/agent/tools/handlers/remember-routine.js +2 -8
  69. package/src/core/agent/tools/handlers/remember.js +82 -6
  70. package/src/core/agent/tools/handlers/remove-agent.js +42 -0
  71. package/src/core/agent/tools/handlers/run-routine.js +94 -0
  72. package/src/core/agent/tools/handlers/run-shell.js +44 -2
  73. package/src/core/agent/tools/handlers/send-telegram.js +41 -19
  74. package/src/core/agent/tools/handlers/set-agent-prompt.js +44 -0
  75. package/src/core/agent/tools/handlers/view-media.js +69 -0
  76. package/src/core/agent/tools/handlers/write-agent-memory.js +52 -0
  77. package/src/core/agent/tools/helpers.js +36 -4
  78. package/src/core/agent/tools/names.js +64 -0
  79. package/src/core/agent/tools/registry-bridge.js +24 -1
  80. package/src/core/agent/tools/registry.js +79 -0
  81. package/src/core/agent/tools/tool-call-parser.js +136 -13
  82. package/src/core/agent/vision-bridge.js +69 -0
  83. package/src/core/apc/agent-identity.js +74 -0
  84. package/src/core/apc/agent-rename-refs.js +187 -0
  85. package/src/core/apc/agent-write.js +312 -0
  86. package/src/core/apc/agents-vault.js +1 -1
  87. package/src/core/apc/blob-keys.js +42 -0
  88. package/src/core/apc/frontmatter.js +166 -10
  89. package/src/core/apc/parser.js +33 -83
  90. package/src/core/apc/paths.js +4 -6
  91. package/src/core/apc/project-memory.js +44 -0
  92. package/src/core/apc/projects-helpers.js +33 -0
  93. package/src/core/apc/scaffold.js +36 -16
  94. package/src/core/channels/telegram/ask-callbacks.js +103 -0
  95. package/src/core/channels/telegram/dispatch.js +154 -30
  96. package/src/core/channels/telegram/helpers.js +59 -7
  97. package/src/core/channels/telegram/inbound/audio.js +26 -30
  98. package/src/core/channels/telegram/inbound/file.js +22 -32
  99. package/src/core/channels/telegram/inbound/photo.js +24 -28
  100. package/src/core/channels/telegram/progress-gate.js +76 -0
  101. package/src/core/channels/telegram/reply.js +151 -43
  102. package/src/core/channels/telegram/voice-note.js +150 -0
  103. package/src/core/config/index.js +53 -54
  104. package/src/core/config/paths.js +117 -31
  105. package/src/core/config/redact.js +89 -4
  106. package/src/core/config/secret-values.js +2 -4
  107. package/src/core/constants/actors.js +5 -0
  108. package/src/core/constants/channels.js +3 -1
  109. package/src/core/constants/task-categories.js +73 -0
  110. package/src/core/constants/task-fields.js +28 -0
  111. package/src/core/engines/_globs.js +33 -0
  112. package/src/core/engines/anthropic.js +19 -1
  113. package/src/core/engines/catalog.js +20 -0
  114. package/src/core/engines/gemini.js +84 -16
  115. package/src/core/engines/index.js +19 -3
  116. package/src/core/engines/mock.js +145 -1
  117. package/src/core/engines/openai-compatible.js +150 -15
  118. package/src/core/engines/presets.js +36 -9
  119. package/src/core/engines/zen.js +131 -0
  120. package/src/core/events/bus.js +140 -0
  121. package/src/core/events/mascot-notify.js +156 -0
  122. package/src/core/http-tools/browser.js +366 -36
  123. package/src/core/http-tools/catalog.js +206 -22
  124. package/src/core/http-tools/grep.js +62 -7
  125. package/src/core/http-tools/registry.js +3 -1
  126. package/src/core/i18n/en.js +34 -3
  127. package/src/core/i18n/es.js +36 -4
  128. package/src/core/i18n/index.js +5 -5
  129. package/src/core/i18n/pt.js +32 -3
  130. package/src/core/identity/self.js +14 -1
  131. package/src/core/images/engines/a1111.js +219 -0
  132. package/src/core/images/engines/index.js +187 -0
  133. package/src/core/images/engines/mock.js +74 -0
  134. package/src/core/images/engines/openai.js +130 -0
  135. package/src/core/images/engines/sdcpp.js +204 -0
  136. package/src/core/images/engines/shared.js +218 -0
  137. package/src/core/images/generate.js +260 -0
  138. package/src/core/integrations/catalog.js +8 -3
  139. package/src/core/integrations/plugins/_google-oauth.js +179 -0
  140. package/src/core/integrations/plugins/asana.js +7 -3
  141. package/src/core/integrations/plugins/calendar.js +404 -0
  142. package/src/core/integrations/store.js +2 -0
  143. package/src/core/memory/broker.js +16 -1
  144. package/src/core/memory/compactor.js +19 -3
  145. package/src/core/memory/consolidate.js +2 -2
  146. package/src/core/memory/dated-log.js +66 -0
  147. package/src/core/memory/embed-engines/custom.js +59 -0
  148. package/src/core/memory/embed-engines/gemini.js +9 -3
  149. package/src/core/memory/embed-engines/index.js +87 -15
  150. package/src/core/memory/embeddings.js +52 -24
  151. package/src/core/memory/index.js +13 -3
  152. package/src/core/memory/indexer.js +83 -27
  153. package/src/core/memory/prune.js +182 -0
  154. package/src/core/memory/store.js +22 -0
  155. package/src/core/memory/summarizer.js +72 -8
  156. package/src/core/mobility/answer.js +80 -0
  157. package/src/core/mobility/geofence.js +667 -0
  158. package/src/core/mobility/osm-route.js +282 -0
  159. package/src/core/mobility/preferences.js +91 -0
  160. package/src/core/mobility/state.js +245 -0
  161. package/src/core/mobility/trip-event.js +384 -0
  162. package/src/core/net/tailscale.js +168 -0
  163. package/src/core/profiles/bundled/secretary/PROFILE.md +32 -42
  164. package/src/core/profiles/bundled/secretary/config.schema.es.json +68 -0
  165. package/src/core/profiles/bundled/secretary/config.schema.json +30 -2
  166. package/src/core/profiles/bundled/secretary/profile.json +3 -1
  167. package/src/core/profiles/bundled/secretary/routines/a2a-sweep.json +20 -0
  168. package/src/core/profiles/bundled/secretary/routines/day-close.json +4 -1
  169. package/src/core/profiles/bundled/secretary/routines/day-open.json +7 -1
  170. package/src/core/profiles/bundled/secretary/routines/watch.json +13 -2
  171. package/src/core/profiles/lifecycle.js +243 -19
  172. package/src/core/profiles/paths.js +12 -0
  173. package/src/core/profiles/store.js +47 -0
  174. package/src/core/routines/active-runs.js +213 -0
  175. package/src/core/routines/delivery.js +612 -0
  176. package/src/core/routines/header.js +88 -0
  177. package/src/core/routines/index.js +26 -0
  178. package/src/core/routines/run-log.js +78 -0
  179. package/src/core/routines/runner.js +629 -66
  180. package/src/core/routines/signals.js +75 -0
  181. package/src/core/runtime-skills/apc-context/SKILL.md +14 -7
  182. package/src/core/runtime-skills/apx/SKILL.md +93 -1
  183. package/src/core/runtime-skills/apx-agency-agents/SKILL.md +2 -2
  184. package/src/core/runtime-skills/apx-agent/SKILL.md +134 -19
  185. package/src/core/runtime-skills/apx-commitment/SKILL.md +21 -11
  186. package/src/core/runtime-skills/apx-image/SKILL.md +60 -0
  187. package/src/core/runtime-skills/apx-integrations/SKILL.md +82 -0
  188. package/src/core/runtime-skills/apx-mcp/SKILL.md +45 -14
  189. package/src/core/runtime-skills/apx-mcp-builder/SKILL.md +4 -4
  190. package/src/core/runtime-skills/apx-profile/SKILL.md +27 -2
  191. package/src/core/runtime-skills/apx-project/SKILL.md +23 -2
  192. package/src/core/runtime-skills/apx-routine/SKILL.md +40 -17
  193. package/src/core/runtime-skills/apx-runtime/SKILL.md +124 -14
  194. package/src/core/runtime-skills/apx-sessions/SKILL.md +7 -6
  195. package/src/core/runtime-skills/apx-skill-builder/SKILL.md +15 -13
  196. package/src/core/runtime-skills/apx-task/SKILL.md +79 -26
  197. package/src/core/runtime-skills/apx-telegram/SKILL.md +12 -12
  198. package/src/core/runtime-skills/apx-transcribe/SKILL.md +56 -0
  199. package/src/core/runtime-skills/apx-voice/SKILL.md +27 -8
  200. package/src/core/runtime-skills/claude-code/SKILL.md +8 -0
  201. package/src/core/runtime-skills/openrouter/SKILL.md +8 -2
  202. package/src/core/runtimes/_spawn.js +9 -1
  203. package/src/core/runtimes/claude-code.js +11 -2
  204. package/src/core/runtimes/codex.js +44 -10
  205. package/src/core/runtimes/index.js +17 -2
  206. package/src/core/runtimes/opencode.js +88 -9
  207. package/src/core/runtimes/outcome.js +119 -0
  208. package/src/core/sessions/index.js +193 -1
  209. package/src/core/stores/agent-inbox.js +109 -23
  210. package/src/core/stores/code-sessions.js +21 -0
  211. package/src/core/stores/commitments.js +27 -3
  212. package/src/core/stores/conversations.js +220 -20
  213. package/src/core/stores/deliveries.js +177 -0
  214. package/src/core/stores/media-archive.js +228 -0
  215. package/src/core/stores/messages.js +1096 -112
  216. package/src/core/stores/organization.js +34 -0
  217. package/src/core/stores/project-memory.js +71 -0
  218. package/src/core/stores/routine-memory.js +16 -8
  219. package/src/core/stores/routines.js +27 -49
  220. package/src/core/stores/sessions.js +4 -8
  221. package/src/core/stores/tasks.js +212 -18
  222. package/src/core/stores/turn-record.js +209 -0
  223. package/src/core/tasks/columns.js +136 -0
  224. package/src/core/tasks/comment-turn.js +258 -0
  225. package/src/core/util/time.js +13 -0
  226. package/src/core/voice/engines/index.js +54 -1
  227. package/src/core/voice/engines/openai.js +50 -4
  228. package/src/core/voice/opus.js +67 -0
  229. package/src/core/voice/pronounceable.js +43 -0
  230. package/src/core/voice/transcription.js +4 -0
  231. package/src/core/voice/tts.js +114 -24
  232. package/src/host/daemon/active-turns.js +188 -0
  233. package/src/host/daemon/api/admin-config.js +9 -0
  234. package/src/host/daemon/api/agents.js +99 -90
  235. package/src/host/daemon/api/artifact-preview.js +7 -6
  236. package/src/host/daemon/api/artifacts.js +3 -2
  237. package/src/host/daemon/api/code.js +55 -9
  238. package/src/host/daemon/api/commitments.js +11 -0
  239. package/src/host/daemon/api/confirm.js +3 -2
  240. package/src/host/daemon/api/conversations.js +543 -35
  241. package/src/host/daemon/api/deck.js +9 -8
  242. package/src/host/daemon/api/deliveries.js +33 -0
  243. package/src/host/daemon/api/desktop.js +5 -4
  244. package/src/host/daemon/api/embeddings.js +15 -6
  245. package/src/host/daemon/api/engines.js +59 -8
  246. package/src/host/daemon/api/exec.js +437 -121
  247. package/src/host/daemon/api/groups.js +215 -0
  248. package/src/host/daemon/api/images.js +107 -0
  249. package/src/host/daemon/api/inbox.js +123 -2
  250. package/src/host/daemon/api/integrations.js +64 -2
  251. package/src/host/daemon/api/mcps.js +7 -6
  252. package/src/host/daemon/api/media.js +283 -0
  253. package/src/host/daemon/api/mobility.js +177 -0
  254. package/src/host/daemon/api/net.js +182 -0
  255. package/src/host/daemon/api/pairing.js +38 -5
  256. package/src/host/daemon/api/profiles.js +30 -2
  257. package/src/host/daemon/api/routines.js +36 -4
  258. package/src/host/daemon/api/runtimes.js +74 -39
  259. package/src/host/daemon/api/sessions-search.js +7 -2
  260. package/src/host/daemon/api/sessions.js +44 -2
  261. package/src/host/daemon/api/shared.js +127 -20
  262. package/src/host/daemon/api/skills.js +11 -4
  263. package/src/host/daemon/api/super-agent.js +384 -53
  264. package/src/host/daemon/api/tasks.js +180 -10
  265. package/src/host/daemon/api/telegram.js +11 -10
  266. package/src/host/daemon/api/thread-faces.js +159 -0
  267. package/src/host/daemon/api/top-level.js +3 -3
  268. package/src/host/daemon/api/transcribe.js +11 -10
  269. package/src/host/daemon/api/tts.js +97 -4
  270. package/src/host/daemon/api/turn-abort.js +23 -0
  271. package/src/host/daemon/api/turns.js +49 -0
  272. package/src/host/daemon/api/voice.js +5 -4
  273. package/src/host/daemon/api/web.js +98 -0
  274. package/src/host/daemon/api.js +39 -0
  275. package/src/host/daemon/delivery-sweep.js +58 -0
  276. package/src/host/daemon/desktop-ws.js +5 -23
  277. package/src/host/daemon/events-ws.js +273 -0
  278. package/src/host/daemon/index.js +59 -15
  279. package/src/host/daemon/plugins/desktop/index.js +17 -4
  280. package/src/host/daemon/plugins/telegram/index.js +78 -40
  281. package/src/host/daemon/pty-bridge.py +99 -0
  282. package/src/host/daemon/routines-scheduler.js +1 -0
  283. package/src/host/daemon/smoke.js +1 -2
  284. package/src/host/daemon/terminal-ws.js +132 -0
  285. package/src/host/daemon/tts-keepwarm.js +87 -0
  286. package/src/host/daemon/wakeup.js +11 -6
  287. package/src/host/daemon/whisper-server.js +44 -0
  288. package/src/host/daemon/whisper-server.py +70 -13
  289. package/src/host/daemon/ws-auth.js +28 -0
  290. package/src/interfaces/android/app/build.gradle +42 -0
  291. package/src/interfaces/android/app/proguard-rules.pro +4 -0
  292. package/src/interfaces/android/app/src/main/AndroidManifest.xml +139 -0
  293. package/src/interfaces/android/app/src/main/java/dev/agentprojectcontext/apx/AlertScreen.java +139 -0
  294. package/src/interfaces/android/app/src/main/java/dev/agentprojectcontext/apx/AndroidAutoDetector.java +106 -0
  295. package/src/interfaces/android/app/src/main/java/dev/agentprojectcontext/apx/ApxCarAppService.java +57 -0
  296. package/src/interfaces/android/app/src/main/java/dev/agentprojectcontext/apx/ApxPreferences.java +165 -0
  297. package/src/interfaces/android/app/src/main/java/dev/agentprojectcontext/apx/CarAlertStore.java +84 -0
  298. package/src/interfaces/android/app/src/main/java/dev/agentprojectcontext/apx/CarMessageActionReceiver.java +47 -0
  299. package/src/interfaces/android/app/src/main/java/dev/agentprojectcontext/apx/CarMessageNotification.java +152 -0
  300. package/src/interfaces/android/app/src/main/java/dev/agentprojectcontext/apx/DaemonAddress.java +35 -0
  301. package/src/interfaces/android/app/src/main/java/dev/agentprojectcontext/apx/DaemonClient.java +339 -0
  302. package/src/interfaces/android/app/src/main/java/dev/agentprojectcontext/apx/DeviceLocation.java +38 -0
  303. package/src/interfaces/android/app/src/main/java/dev/agentprojectcontext/apx/ErrandScreen.java +127 -0
  304. package/src/interfaces/android/app/src/main/java/dev/agentprojectcontext/apx/MainActivity.java +872 -0
  305. package/src/interfaces/android/app/src/main/java/dev/agentprojectcontext/apx/MapsNavigationDetector.java +63 -0
  306. package/src/interfaces/android/app/src/main/java/dev/agentprojectcontext/apx/MapsNavigationListenerService.java +453 -0
  307. package/src/interfaces/android/app/src/main/java/dev/agentprojectcontext/apx/MapsShareIntentParser.java +93 -0
  308. package/src/interfaces/android/app/src/main/java/dev/agentprojectcontext/apx/MascotBlobCatalog.java +66 -0
  309. package/src/interfaces/android/app/src/main/java/dev/agentprojectcontext/apx/MascotOverlayService.java +448 -0
  310. package/src/interfaces/android/app/src/main/java/dev/agentprojectcontext/apx/MascotView.java +249 -0
  311. package/src/interfaces/android/app/src/main/java/dev/agentprojectcontext/apx/MessageFrameParser.java +240 -0
  312. package/src/interfaces/android/app/src/main/java/dev/agentprojectcontext/apx/NotifyChannels.java +69 -0
  313. package/src/interfaces/android/app/src/main/java/dev/agentprojectcontext/apx/ProximityActionReceiver.java +78 -0
  314. package/src/interfaces/android/app/src/main/java/dev/agentprojectcontext/apx/ProximityNotification.java +161 -0
  315. package/src/interfaces/android/app/src/main/java/dev/agentprojectcontext/apx/StartupReceiver.java +57 -0
  316. package/src/interfaces/android/app/src/main/java/dev/agentprojectcontext/apx/TravelEventGate.java +62 -0
  317. package/src/interfaces/android/app/src/main/java/dev/agentprojectcontext/apx/TravelStatusBanner.java +116 -0
  318. package/src/interfaces/android/app/src/main/java/dev/agentprojectcontext/apx/TripLocationService.java +272 -0
  319. package/src/interfaces/android/app/src/main/java/dev/agentprojectcontext/apx/TripPlacesSheet.java +203 -0
  320. package/src/interfaces/android/app/src/main/java/dev/agentprojectcontext/apx/TripScreen.java +182 -0
  321. package/src/interfaces/android/app/src/main/res/drawable/app_background.xml +12 -0
  322. package/src/interfaces/android/app/src/main/res/drawable/ic_apx_app.xml +10 -0
  323. package/src/interfaces/android/app/src/main/res/drawable/ic_apx_launcher_foreground.xml +12 -0
  324. package/src/interfaces/android/app/src/main/res/drawable/ic_apx_notification.xml +22 -0
  325. package/src/interfaces/android/app/src/main/res/drawable/ic_trip_car.xml +37 -0
  326. package/src/interfaces/android/app/src/main/res/drawable-nodpi/apx_logo.png +0 -0
  327. package/src/interfaces/android/app/src/main/res/drawable-nodpi/mascot_amatista.png +0 -0
  328. package/src/interfaces/android/app/src/main/res/drawable-nodpi/mascot_aqua.png +0 -0
  329. package/src/interfaces/android/app/src/main/res/drawable-nodpi/mascot_brasa.png +0 -0
  330. package/src/interfaces/android/app/src/main/res/drawable-nodpi/mascot_campana.png +0 -0
  331. package/src/interfaces/android/app/src/main/res/drawable-nodpi/mascot_caracol.png +0 -0
  332. package/src/interfaces/android/app/src/main/res/drawable-nodpi/mascot_carbon.png +0 -0
  333. package/src/interfaces/android/app/src/main/res/drawable-nodpi/mascot_cobalto.png +0 -0
  334. package/src/interfaces/android/app/src/main/res/drawable-nodpi/mascot_cometa.png +0 -0
  335. package/src/interfaces/android/app/src/main/res/drawable-nodpi/mascot_coral.png +0 -0
  336. package/src/interfaces/android/app/src/main/res/drawable-nodpi/mascot_cubi.png +0 -0
  337. package/src/interfaces/android/app/src/main/res/drawable-nodpi/mascot_eclipse.png +0 -0
  338. package/src/interfaces/android/app/src/main/res/drawable-nodpi/mascot_faro.png +0 -0
  339. package/src/interfaces/android/app/src/main/res/drawable-nodpi/mascot_gajo.png +0 -0
  340. package/src/interfaces/android/app/src/main/res/drawable-nodpi/mascot_iris.png +0 -0
  341. package/src/interfaces/android/app/src/main/res/drawable-nodpi/mascot_kiwi.png +0 -0
  342. package/src/interfaces/android/app/src/main/res/drawable-nodpi/mascot_laguna.png +0 -0
  343. package/src/interfaces/android/app/src/main/res/drawable-nodpi/mascot_lila.png +0 -0
  344. package/src/interfaces/android/app/src/main/res/drawable-nodpi/mascot_menta.png +0 -0
  345. package/src/interfaces/android/app/src/main/res/drawable-nodpi/mascot_nimbo.png +0 -0
  346. package/src/interfaces/android/app/src/main/res/drawable-nodpi/mascot_noche.png +0 -0
  347. package/src/interfaces/android/app/src/main/res/drawable-nodpi/mascot_onyx.png +0 -0
  348. package/src/interfaces/android/app/src/main/res/drawable-nodpi/mascot_orbita.png +0 -0
  349. package/src/interfaces/android/app/src/main/res/drawable-nodpi/mascot_ovni.png +0 -0
  350. package/src/interfaces/android/app/src/main/res/drawable-nodpi/mascot_papa.png +0 -0
  351. package/src/interfaces/android/app/src/main/res/drawable-nodpi/mascot_parche.png +0 -0
  352. package/src/interfaces/android/app/src/main/res/drawable-nodpi/mascot_peon.png +0 -0
  353. package/src/interfaces/android/app/src/main/res/drawable-nodpi/mascot_perla.png +0 -0
  354. package/src/interfaces/android/app/src/main/res/drawable-nodpi/mascot_pino.png +0 -0
  355. package/src/interfaces/android/app/src/main/res/drawable-nodpi/mascot_quad.png +0 -0
  356. package/src/interfaces/android/app/src/main/res/drawable-nodpi/mascot_rubi.png +0 -0
  357. package/src/interfaces/android/app/src/main/res/drawable-nodpi/mascot_saturno.png +0 -0
  358. package/src/interfaces/android/app/src/main/res/drawable-nodpi/mascot_trebol.png +0 -0
  359. package/src/interfaces/android/app/src/main/res/drawable-nodpi/mascot_trino.png +0 -0
  360. package/src/interfaces/android/app/src/main/res/drawable-nodpi/mascot_volcan.png +0 -0
  361. package/src/interfaces/android/app/src/main/res/drawable-nodpi/mascot_zafiro.png +0 -0
  362. package/src/interfaces/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +5 -0
  363. package/src/interfaces/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +5 -0
  364. package/src/interfaces/android/app/src/main/res/raw/apx_notification.mp3 +0 -0
  365. package/src/interfaces/android/app/src/main/res/values/colors.xml +4 -0
  366. package/src/interfaces/android/app/src/main/res/values/strings.xml +25 -0
  367. package/src/interfaces/android/app/src/main/res/values/styles.xml +10 -0
  368. package/src/interfaces/android/app/src/main/res/values-v31/styles.xml +14 -0
  369. package/src/interfaces/android/app/src/main/res/xml/automotive_app_desc.xml +9 -0
  370. package/src/interfaces/android/app/src/main/res/xml/network_security_config.xml +10 -0
  371. package/src/interfaces/android/app/src/test/java/dev/agentprojectcontext/apx/AndroidAutoDetectorTest.java +85 -0
  372. package/src/interfaces/android/app/src/test/java/dev/agentprojectcontext/apx/CarAlertStoreTest.java +104 -0
  373. package/src/interfaces/android/app/src/test/java/dev/agentprojectcontext/apx/DaemonAddressTest.java +23 -0
  374. package/src/interfaces/android/app/src/test/java/dev/agentprojectcontext/apx/MapsNavigationDetectorTest.java +69 -0
  375. package/src/interfaces/android/app/src/test/java/dev/agentprojectcontext/apx/MapsShareIntentParserTest.java +31 -0
  376. package/src/interfaces/android/app/src/test/java/dev/agentprojectcontext/apx/MessageFrameParserTest.java +180 -0
  377. package/src/interfaces/android/app/src/test/java/dev/agentprojectcontext/apx/TravelEventGateTest.java +64 -0
  378. package/src/interfaces/android/app/src/test/java/dev/agentprojectcontext/apx/TripPlacesSheetTest.java +66 -0
  379. package/src/interfaces/android/build.gradle +3 -0
  380. package/src/interfaces/android/gradle/wrapper/gradle-wrapper.jar +0 -0
  381. package/src/interfaces/android/gradle/wrapper/gradle-wrapper.properties +9 -0
  382. package/src/interfaces/android/gradle.properties +3 -0
  383. package/src/interfaces/android/gradlew +248 -0
  384. package/src/interfaces/android/gradlew.bat +82 -0
  385. package/src/interfaces/android/settings.gradle +18 -0
  386. package/src/interfaces/cli/commands/a2a.js +145 -4
  387. package/src/interfaces/cli/commands/agent.js +247 -22
  388. package/src/interfaces/cli/commands/code.js +1 -1
  389. package/src/interfaces/cli/commands/commitment.js +5 -0
  390. package/src/interfaces/cli/commands/config.js +85 -6
  391. package/src/interfaces/cli/commands/exec.js +90 -1
  392. package/src/interfaces/cli/commands/image.js +314 -0
  393. package/src/interfaces/cli/commands/mcp.js +87 -23
  394. package/src/interfaces/cli/commands/memory.js +35 -22
  395. package/src/interfaces/cli/commands/panel.js +94 -0
  396. package/src/interfaces/cli/commands/profile.js +14 -0
  397. package/src/interfaces/cli/commands/project.js +29 -0
  398. package/src/interfaces/cli/commands/routine.js +45 -10
  399. package/src/interfaces/cli/commands/runtime.js +30 -7
  400. package/src/interfaces/cli/commands/session.js +90 -38
  401. package/src/interfaces/cli/commands/sessions.js +35 -2
  402. package/src/interfaces/cli/commands/skills.js +15 -9
  403. package/src/interfaces/cli/commands/status.js +2 -3
  404. package/src/interfaces/cli/commands/task.js +54 -5
  405. package/src/interfaces/cli/commands/telegram.js +12 -4
  406. package/src/interfaces/cli/commands/transcribe.js +177 -0
  407. package/src/interfaces/cli/help/index.js +311 -72
  408. package/src/interfaces/cli/http.js +61 -13
  409. package/src/interfaces/cli/index.js +1 -1
  410. package/src/interfaces/cli/routes/agent.js +5 -4
  411. package/src/interfaces/cli/routes/commitment.js +3 -2
  412. package/src/interfaces/cli/routes/config.js +7 -2
  413. package/src/interfaces/cli/routes/image.js +11 -0
  414. package/src/interfaces/cli/routes/index.js +2 -0
  415. package/src/interfaces/cli/routes/log.js +1 -1
  416. package/src/interfaces/cli/routes/memory.js +3 -2
  417. package/src/interfaces/cli/routes/panel.js +15 -2
  418. package/src/interfaces/cli/routes/profile.js +3 -2
  419. package/src/interfaces/cli/routes/transcribe.js +10 -0
  420. package/src/interfaces/cli/stdin.js +29 -0
  421. package/src/interfaces/desktop/assets/blobs/amatista.png +0 -0
  422. package/src/interfaces/desktop/assets/blobs/aqua.png +0 -0
  423. package/src/interfaces/desktop/assets/blobs/brasa.png +0 -0
  424. package/src/interfaces/desktop/assets/blobs/campana.png +0 -0
  425. package/src/interfaces/desktop/assets/blobs/caracol.png +0 -0
  426. package/src/interfaces/desktop/assets/blobs/carbon.png +0 -0
  427. package/src/interfaces/desktop/assets/blobs/cobalto.png +0 -0
  428. package/src/interfaces/desktop/assets/blobs/cometa.png +0 -0
  429. package/src/interfaces/desktop/assets/blobs/coral.png +0 -0
  430. package/src/interfaces/desktop/assets/blobs/cubi.png +0 -0
  431. package/src/interfaces/desktop/assets/blobs/eclipse.png +0 -0
  432. package/src/interfaces/desktop/assets/blobs/faro.png +0 -0
  433. package/src/interfaces/desktop/assets/blobs/gajo.png +0 -0
  434. package/src/interfaces/desktop/assets/blobs/iris.png +0 -0
  435. package/src/interfaces/desktop/assets/blobs/kiwi.png +0 -0
  436. package/src/interfaces/desktop/assets/blobs/laguna.png +0 -0
  437. package/src/interfaces/desktop/assets/blobs/lila.png +0 -0
  438. package/src/interfaces/desktop/assets/blobs/menta.png +0 -0
  439. package/src/interfaces/desktop/assets/blobs/nimbo.png +0 -0
  440. package/src/interfaces/desktop/assets/blobs/noche.png +0 -0
  441. package/src/interfaces/desktop/assets/blobs/onyx.png +0 -0
  442. package/src/interfaces/desktop/assets/blobs/orbita.png +0 -0
  443. package/src/interfaces/desktop/assets/blobs/ovni.png +0 -0
  444. package/src/interfaces/desktop/assets/blobs/papa.png +0 -0
  445. package/src/interfaces/desktop/assets/blobs/parche.png +0 -0
  446. package/src/interfaces/desktop/assets/blobs/peon.png +0 -0
  447. package/src/interfaces/desktop/assets/blobs/perla.png +0 -0
  448. package/src/interfaces/desktop/assets/blobs/pino.png +0 -0
  449. package/src/interfaces/desktop/assets/blobs/quad.png +0 -0
  450. package/src/interfaces/desktop/assets/blobs/rubi.png +0 -0
  451. package/src/interfaces/desktop/assets/blobs/saturno.png +0 -0
  452. package/src/interfaces/desktop/assets/blobs/trebol.png +0 -0
  453. package/src/interfaces/desktop/assets/blobs/trino.png +0 -0
  454. package/src/interfaces/desktop/assets/blobs/volcan.png +0 -0
  455. package/src/interfaces/desktop/assets/blobs/zafiro.png +0 -0
  456. package/src/interfaces/desktop/assets/notification.mp3 +0 -0
  457. package/src/interfaces/desktop/main.js +473 -29
  458. package/src/interfaces/desktop/mascot-blobs.js +41 -0
  459. package/src/interfaces/desktop/mascot-preload.js +32 -0
  460. package/src/interfaces/desktop/mascot.css +186 -0
  461. package/src/interfaces/desktop/mascot.html +40 -0
  462. package/src/interfaces/desktop/mascot.js +163 -0
  463. package/src/interfaces/desktop/preload.js +10 -0
  464. package/src/interfaces/desktop/renderer.js +308 -48
  465. package/src/interfaces/desktop/style.css +34 -0
  466. package/src/interfaces/mcp-server/index.js +87 -0
  467. package/src/interfaces/tui/_shims/util-locale.ts +0 -2
  468. package/src/interfaces/tui/app.tsx +0 -6
  469. package/src/interfaces/tui/attach.ts +0 -1
  470. package/src/interfaces/tui/context/sdk-apx.tsx +0 -4
  471. package/src/interfaces/tui/plugin/api.tsx +0 -3
  472. package/src/interfaces/tui/plugin/runtime.ts +0 -3
  473. package/src/interfaces/tui/thread.ts +0 -16
  474. package/src/interfaces/web/README.md +22 -14
  475. package/src/interfaces/web/dist/assets/claude-XtpLmGtF.webp +0 -0
  476. package/src/interfaces/web/dist/assets/codex-DExPUgvh.webp +0 -0
  477. package/src/interfaces/web/dist/assets/index-BEoRgIqd.css +32 -0
  478. package/src/interfaces/web/dist/assets/index-BEoRgIqd.css.br +0 -0
  479. package/src/interfaces/web/dist/assets/index-BEoRgIqd.css.gz +0 -0
  480. package/src/interfaces/web/dist/assets/index-C6fXHc1p.js +1074 -0
  481. package/src/interfaces/web/dist/assets/index-C6fXHc1p.js.br +0 -0
  482. package/src/interfaces/web/dist/assets/index-C6fXHc1p.js.gz +0 -0
  483. package/src/interfaces/web/dist/assets/index-C6fXHc1p.js.map +1 -0
  484. package/src/interfaces/web/dist/assets/index-C6fXHc1p.js.map.br +0 -0
  485. package/src/interfaces/web/dist/assets/index-C6fXHc1p.js.map.gz +0 -0
  486. package/src/interfaces/web/dist/favicon/dark/site.webmanifest +12 -6
  487. package/src/interfaces/web/dist/favicon/white/site.webmanifest +12 -6
  488. package/src/interfaces/web/dist/index.html +22 -6
  489. package/src/interfaces/web/dist/index.html.br +0 -0
  490. package/src/interfaces/web/dist/index.html.gz +0 -0
  491. package/src/interfaces/web/dist/manifest.webmanifest +25 -0
  492. package/src/interfaces/web/dist/modules/blobs/amatista.png +0 -0
  493. package/src/interfaces/web/dist/modules/blobs/aqua.png +0 -0
  494. package/src/interfaces/web/dist/modules/blobs/brasa.png +0 -0
  495. package/src/interfaces/web/dist/modules/blobs/campana.png +0 -0
  496. package/src/interfaces/web/dist/modules/blobs/caracol.png +0 -0
  497. package/src/interfaces/web/dist/modules/blobs/carbon.png +0 -0
  498. package/src/interfaces/web/dist/modules/blobs/cobalto.png +0 -0
  499. package/src/interfaces/web/dist/modules/blobs/cometa.png +0 -0
  500. package/src/interfaces/web/dist/modules/blobs/coral.png +0 -0
  501. package/src/interfaces/web/dist/modules/blobs/cubi.png +0 -0
  502. package/src/interfaces/web/dist/modules/blobs/eclipse.png +0 -0
  503. package/src/interfaces/web/dist/modules/blobs/faro.png +0 -0
  504. package/src/interfaces/web/dist/modules/blobs/gajo.png +0 -0
  505. package/src/interfaces/web/dist/modules/blobs/iris.png +0 -0
  506. package/src/interfaces/web/dist/modules/blobs/kiwi.png +0 -0
  507. package/src/interfaces/web/dist/modules/blobs/laguna.png +0 -0
  508. package/src/interfaces/web/dist/modules/blobs/lila.png +0 -0
  509. package/src/interfaces/web/dist/modules/blobs/menta.png +0 -0
  510. package/src/interfaces/web/dist/modules/blobs/nimbo.png +0 -0
  511. package/src/interfaces/web/dist/modules/blobs/noche.png +0 -0
  512. package/src/interfaces/web/dist/modules/blobs/onyx.png +0 -0
  513. package/src/interfaces/web/dist/modules/blobs/orbita.png +0 -0
  514. package/src/interfaces/web/dist/modules/blobs/ovni.png +0 -0
  515. package/src/interfaces/web/dist/modules/blobs/papa.png +0 -0
  516. package/src/interfaces/web/dist/modules/blobs/parche.png +0 -0
  517. package/src/interfaces/web/dist/modules/blobs/peon.png +0 -0
  518. package/src/interfaces/web/dist/modules/blobs/perla.png +0 -0
  519. package/src/interfaces/web/dist/modules/blobs/pino.png +0 -0
  520. package/src/interfaces/web/dist/modules/blobs/quad.png +0 -0
  521. package/src/interfaces/web/dist/modules/blobs/rubi.png +0 -0
  522. package/src/interfaces/web/dist/modules/blobs/saturno.png +0 -0
  523. package/src/interfaces/web/dist/modules/blobs/trebol.png +0 -0
  524. package/src/interfaces/web/dist/modules/blobs/trino.png +0 -0
  525. package/src/interfaces/web/dist/modules/blobs/volcan.png +0 -0
  526. package/src/interfaces/web/dist/modules/blobs/zafiro.png +0 -0
  527. package/src/interfaces/web/dist/sw.js +106 -0
  528. package/src/interfaces/web/dist/sw.js.br +0 -0
  529. package/src/interfaces/web/dist/sw.js.gz +0 -0
  530. package/src/interfaces/web/eslint.config.js +83 -0
  531. package/src/interfaces/web/index.html +20 -4
  532. package/src/interfaces/web/package-lock.json +1651 -1280
  533. package/src/interfaces/web/package.json +9 -10
  534. package/src/interfaces/web/pnpm-lock.yaml +867 -869
  535. package/src/interfaces/web/public/favicon/dark/site.webmanifest +12 -6
  536. package/src/interfaces/web/public/favicon/white/site.webmanifest +12 -6
  537. package/src/interfaces/web/public/manifest.webmanifest +25 -0
  538. package/src/interfaces/web/public/modules/blobs/amatista.png +0 -0
  539. package/src/interfaces/web/public/modules/blobs/aqua.png +0 -0
  540. package/src/interfaces/web/public/modules/blobs/brasa.png +0 -0
  541. package/src/interfaces/web/public/modules/blobs/campana.png +0 -0
  542. package/src/interfaces/web/public/modules/blobs/caracol.png +0 -0
  543. package/src/interfaces/web/public/modules/blobs/carbon.png +0 -0
  544. package/src/interfaces/web/public/modules/blobs/cobalto.png +0 -0
  545. package/src/interfaces/web/public/modules/blobs/cometa.png +0 -0
  546. package/src/interfaces/web/public/modules/blobs/coral.png +0 -0
  547. package/src/interfaces/web/public/modules/blobs/cubi.png +0 -0
  548. package/src/interfaces/web/public/modules/blobs/eclipse.png +0 -0
  549. package/src/interfaces/web/public/modules/blobs/faro.png +0 -0
  550. package/src/interfaces/web/public/modules/blobs/gajo.png +0 -0
  551. package/src/interfaces/web/public/modules/blobs/iris.png +0 -0
  552. package/src/interfaces/web/public/modules/blobs/kiwi.png +0 -0
  553. package/src/interfaces/web/public/modules/blobs/laguna.png +0 -0
  554. package/src/interfaces/web/public/modules/blobs/lila.png +0 -0
  555. package/src/interfaces/web/public/modules/blobs/menta.png +0 -0
  556. package/src/interfaces/web/public/modules/blobs/nimbo.png +0 -0
  557. package/src/interfaces/web/public/modules/blobs/noche.png +0 -0
  558. package/src/interfaces/web/public/modules/blobs/onyx.png +0 -0
  559. package/src/interfaces/web/public/modules/blobs/orbita.png +0 -0
  560. package/src/interfaces/web/public/modules/blobs/ovni.png +0 -0
  561. package/src/interfaces/web/public/modules/blobs/papa.png +0 -0
  562. package/src/interfaces/web/public/modules/blobs/parche.png +0 -0
  563. package/src/interfaces/web/public/modules/blobs/peon.png +0 -0
  564. package/src/interfaces/web/public/modules/blobs/perla.png +0 -0
  565. package/src/interfaces/web/public/modules/blobs/pino.png +0 -0
  566. package/src/interfaces/web/public/modules/blobs/quad.png +0 -0
  567. package/src/interfaces/web/public/modules/blobs/rubi.png +0 -0
  568. package/src/interfaces/web/public/modules/blobs/saturno.png +0 -0
  569. package/src/interfaces/web/public/modules/blobs/trebol.png +0 -0
  570. package/src/interfaces/web/public/modules/blobs/trino.png +0 -0
  571. package/src/interfaces/web/public/modules/blobs/volcan.png +0 -0
  572. package/src/interfaces/web/public/modules/blobs/zafiro.png +0 -0
  573. package/src/interfaces/web/public/sw.js +106 -0
  574. package/src/interfaces/web/src/App.tsx +106 -10
  575. package/src/interfaces/web/src/assets/cli/claude.webp +0 -0
  576. package/src/interfaces/web/src/assets/cli/codex.webp +0 -0
  577. package/src/interfaces/web/src/assets/cli/cursor.svg +1 -0
  578. package/src/interfaces/web/src/assets/cli/opencode.png +0 -0
  579. package/src/interfaces/web/src/components/Combobox.tsx +201 -0
  580. package/src/interfaces/web/src/components/MobileHint.tsx +95 -0
  581. package/src/interfaces/web/src/components/MobileLinkDialog.tsx +156 -0
  582. package/src/interfaces/web/src/components/ModelCombobox.tsx +21 -113
  583. package/src/interfaces/web/src/components/{routines/ReadOnlyBlock.tsx → ReadOnlyBlock.tsx} +13 -4
  584. package/src/interfaces/web/src/components/Roby.tsx +5 -3
  585. package/src/interfaces/web/src/components/RobyBubble.tsx +23 -5
  586. package/src/interfaces/web/src/components/RowMenu.tsx +46 -0
  587. package/src/interfaces/web/src/components/TimezoneSelect.tsx +4 -1
  588. package/src/interfaces/web/src/components/Toast.tsx +50 -14
  589. package/src/interfaces/web/src/components/UiSelect.tsx +12 -5
  590. package/src/interfaces/web/src/components/agents/AgentAvatar.tsx +171 -0
  591. package/src/interfaces/web/src/components/agents/AgentFormFields.tsx +42 -11
  592. package/src/interfaces/web/src/components/agents/AgentModelBadge.tsx +157 -0
  593. package/src/interfaces/web/src/components/agents/AgentModelSelect.tsx +57 -0
  594. package/src/interfaces/web/src/components/agents/AgentSkillsPicker.tsx +115 -0
  595. package/src/interfaces/web/src/components/agents/BlobAvatar.tsx +50 -0
  596. package/src/interfaces/web/src/components/agents/blobPresets.ts +50 -0
  597. package/src/interfaces/web/src/components/agents/modelCatalog.ts +66 -0
  598. package/src/interfaces/web/src/components/chat/ActionGroup.tsx +162 -0
  599. package/src/interfaces/web/src/components/chat/AskAnswersCard.tsx +4 -3
  600. package/src/interfaces/web/src/components/chat/AskQuestionsCard.tsx +1 -1
  601. package/src/interfaces/web/src/components/chat/Attachment.tsx +220 -0
  602. package/src/interfaces/web/src/components/chat/ChatList.tsx +226 -51
  603. package/src/interfaces/web/src/components/chat/ChatRowActivity.tsx +45 -0
  604. package/src/interfaces/web/src/components/chat/Composer.tsx +672 -23
  605. package/src/interfaces/web/src/components/chat/ComposerCommands.tsx +175 -0
  606. package/src/interfaces/web/src/components/chat/ComposerSuggest.tsx +215 -0
  607. package/src/interfaces/web/src/components/chat/ContextBar.tsx +133 -65
  608. package/src/interfaces/web/src/components/chat/InlineAskPanel.tsx +107 -47
  609. package/src/interfaces/web/src/components/chat/MessageBubble.tsx +460 -119
  610. package/src/interfaces/web/src/components/chat/MessageList.tsx +165 -18
  611. package/src/interfaces/web/src/components/chat/ReasoningBlock.tsx +58 -0
  612. package/src/interfaces/web/src/components/chat/SessionPicker.tsx +182 -0
  613. package/src/interfaces/web/src/components/chat/SkillTrace.tsx +183 -0
  614. package/src/interfaces/web/src/components/chat/ToolCall.tsx +11 -11
  615. package/src/interfaces/web/src/components/code/CodeContextTab.tsx +87 -53
  616. package/src/interfaces/web/src/components/code/CodeFileTree.tsx +11 -52
  617. package/src/interfaces/web/src/components/code/CodeFileViewer.tsx +5 -4
  618. package/src/interfaces/web/src/components/code/CodeProjectPicker.tsx +31 -7
  619. package/src/interfaces/web/src/components/code/CodeSessionList.tsx +32 -11
  620. package/src/interfaces/web/src/components/code/CodeSidePanel.tsx +33 -3
  621. package/src/interfaces/web/src/components/code/CodeTerminal.tsx +5 -4
  622. package/src/interfaces/web/src/components/code/NewCodeSessionDialog.tsx +164 -0
  623. package/src/interfaces/web/src/components/commitments/CommitmentDetail.tsx +162 -0
  624. package/src/interfaces/web/src/components/commitments/CommitmentFormDialog.tsx +158 -0
  625. package/src/interfaces/web/src/components/commitments/CommitmentList.tsx +194 -0
  626. package/src/interfaces/web/src/components/commitments/commitmentState.tsx +46 -0
  627. package/src/interfaces/web/src/components/common/BulkActionBar.tsx +60 -0
  628. package/src/interfaces/web/src/components/common/ConfirmDialog.tsx +4 -1
  629. package/src/interfaces/web/src/components/common/SelectCheckbox.tsx +40 -0
  630. package/src/interfaces/web/src/components/common/TabNav.tsx +1 -1
  631. package/src/interfaces/web/src/components/cron/CronPicker.tsx +1 -1
  632. package/src/interfaces/web/src/components/files/FileBrowser.tsx +1 -1
  633. package/src/interfaces/web/src/components/files/FileTree.tsx +6 -4
  634. package/src/interfaces/web/src/components/files/FileViewer.tsx +9 -5
  635. package/src/interfaces/web/src/components/files/MarkdownPreview.tsx +45 -9
  636. package/src/interfaces/web/src/components/images/ImageDefaultsCard.tsx +93 -0
  637. package/src/interfaces/web/src/components/images/ImageProviderList.tsx +142 -0
  638. package/src/interfaces/web/src/components/images/ImageProviderModal.tsx +263 -0
  639. package/src/interfaces/web/src/components/images/ImageTestCard.tsx +142 -0
  640. package/src/interfaces/web/src/components/inbox/ChannelFilter.tsx +178 -0
  641. package/src/interfaces/web/src/components/inbox/InboxList.tsx +77 -81
  642. package/src/interfaces/web/src/components/inbox/InboxRowItem.tsx +140 -0
  643. package/src/interfaces/web/src/components/integrations/ComingSoonPlugin.tsx +2 -2
  644. package/src/interfaces/web/src/components/integrations/PluginConnect.tsx +208 -30
  645. package/src/interfaces/web/src/components/integrations/PluginToolsSection.tsx +1 -1
  646. package/src/interfaces/web/src/components/layout/ProjectAvatar.tsx +39 -24
  647. package/src/interfaces/web/src/components/layout/ProjectSidebar.tsx +253 -58
  648. package/src/interfaces/web/src/components/memory/MemoryBrowser.tsx +94 -47
  649. package/src/interfaces/web/src/components/routines/ExecutionsList.tsx +202 -90
  650. package/src/interfaces/web/src/components/routines/RoutineDetail.tsx +28 -6
  651. package/src/interfaces/web/src/components/routines/RoutineEditor.tsx +16 -5
  652. package/src/interfaces/web/src/components/routines/RoutineList.tsx +49 -9
  653. package/src/interfaces/web/src/components/routines/VarTextarea.tsx +1 -1
  654. package/src/interfaces/web/src/components/routines/runChat.ts +121 -0
  655. package/src/interfaces/web/src/components/sessions/SessionDetail.tsx +148 -0
  656. package/src/interfaces/web/src/components/sessions/SessionTerminal.tsx +98 -0
  657. package/src/interfaces/web/src/components/settings/AccessPanel.tsx +205 -0
  658. package/src/interfaces/web/src/components/settings/DefaultRouterCard.tsx +246 -101
  659. package/src/interfaces/web/src/components/settings/DesktopSettingsPanel.tsx +36 -2
  660. package/src/interfaces/web/src/components/settings/DevicesPanel.tsx +19 -6
  661. package/src/interfaces/web/src/components/settings/EmbedProviderList.tsx +274 -0
  662. package/src/interfaces/web/src/components/settings/EnginesPanel.tsx +26 -6
  663. package/src/interfaces/web/src/components/settings/MemoryPanel.tsx +118 -119
  664. package/src/interfaces/web/src/components/settings/NudgePanel.tsx +4 -3
  665. package/src/interfaces/web/src/components/settings/PairDeviceDialog.tsx +15 -4
  666. package/src/interfaces/web/src/components/settings/PanelPrefs.tsx +374 -0
  667. package/src/interfaces/web/src/components/settings/ProfilePanel.tsx +70 -2
  668. package/src/interfaces/web/src/components/settings/RoutingPanel.tsx +2 -1
  669. package/src/interfaces/web/src/components/settings/SkillsInspectorPanel.tsx +102 -43
  670. package/src/interfaces/web/src/components/settings/SkillsManager.tsx +76 -21
  671. package/src/interfaces/web/src/components/settings/SkillsSettings.tsx +10 -3
  672. package/src/interfaces/web/src/components/settings/SuperAgentPanel.tsx +8 -0
  673. package/src/interfaces/web/src/components/settings/TelegramChannelsPanel.tsx +11 -2
  674. package/src/interfaces/web/src/components/settings/TelegramContactsPanel.tsx +19 -8
  675. package/src/interfaces/web/src/components/settings/TelegramRolesPanel.tsx +12 -2
  676. package/src/interfaces/web/src/components/settings/WebPanel.tsx +22 -31
  677. package/src/interfaces/web/src/components/settings/providers/ProviderCard.tsx +15 -3
  678. package/src/interfaces/web/src/components/settings/providers/ProviderModal.tsx +97 -66
  679. package/src/interfaces/web/src/components/settings/providers/ProviderTestDialog.tsx +150 -0
  680. package/src/interfaces/web/src/components/settings/providers/typeStyles.ts +67 -25
  681. package/src/interfaces/web/src/components/settings/providers/types.ts +3 -0
  682. package/src/interfaces/web/src/components/tasks/TaskBoard.tsx +201 -0
  683. package/src/interfaces/web/src/components/tasks/TaskColumnsDialog.tsx +243 -0
  684. package/src/interfaces/web/src/components/tasks/TaskComments.tsx +167 -0
  685. package/src/interfaces/web/src/components/tasks/TaskDetail.tsx +335 -0
  686. package/src/interfaces/web/src/components/tasks/TaskFormDialog.tsx +406 -0
  687. package/src/interfaces/web/src/components/tasks/TaskList.tsx +258 -0
  688. package/src/interfaces/web/src/components/tasks/TaskSubtasks.tsx +126 -0
  689. package/src/interfaces/web/src/components/tasks/columns.ts +51 -0
  690. package/src/interfaces/web/src/components/tasks/taskStatus.tsx +159 -34
  691. package/src/interfaces/web/src/components/tasks/taskViewPrefs.ts +73 -0
  692. package/src/interfaces/web/src/components/tasks/useTaskColumns.ts +31 -0
  693. package/src/interfaces/web/src/components/ui/chat-input.tsx +136 -12
  694. package/src/interfaces/web/src/components/ui/context-menu.tsx +15 -198
  695. package/src/interfaces/web/src/components/ui/dialog.tsx +3 -2
  696. package/src/interfaces/web/src/components/ui/filter-chips.tsx +4 -0
  697. package/src/interfaces/web/src/components/ui/popover.tsx +14 -49
  698. package/src/interfaces/web/src/components/ui/sheet.tsx +2 -1
  699. package/src/interfaces/web/src/components/ui/spinner.tsx +2 -1
  700. package/src/interfaces/web/src/components/ui.tsx +62 -23
  701. package/src/interfaces/web/src/components/voice/VoiceProviderList.tsx +2 -1
  702. package/src/interfaces/web/src/components/voice/VoiceProviderModal.tsx +66 -8
  703. package/src/interfaces/web/src/components/voice/VoiceQvoxInstallCard.tsx +63 -0
  704. package/src/interfaces/web/src/components/voice/VoiceQvoxStatusCard.tsx +51 -0
  705. package/src/interfaces/web/src/components/voice/VoiceSttCard.tsx +4 -3
  706. package/src/interfaces/web/src/components/voice/VoiceTestCard.tsx +38 -4
  707. package/src/interfaces/web/src/constants/index.ts +1 -0
  708. package/src/interfaces/web/src/hooks/useChannelPrefs.ts +39 -0
  709. package/src/interfaces/web/src/hooks/useChat.ts +1092 -85
  710. package/src/interfaces/web/src/hooks/useChatActivity.ts +24 -0
  711. package/src/interfaces/web/src/hooks/useInbox.ts +26 -4
  712. package/src/interfaces/web/src/hooks/useLiveMessages.ts +16 -0
  713. package/src/interfaces/web/src/hooks/useOllamaModels.ts +79 -0
  714. package/src/interfaces/web/src/hooks/useRecorder.ts +198 -0
  715. package/src/interfaces/web/src/hooks/useTokenBootstrap.ts +23 -6
  716. package/src/interfaces/web/src/i18n/en.ts +832 -175
  717. package/src/interfaces/web/src/i18n/es.ts +841 -188
  718. package/src/interfaces/web/src/lib/api/agents.ts +23 -4
  719. package/src/interfaces/web/src/lib/api/code.ts +17 -0
  720. package/src/interfaces/web/src/lib/api/commitments.ts +7 -1
  721. package/src/interfaces/web/src/lib/api/conversations.ts +38 -11
  722. package/src/interfaces/web/src/lib/api/embeddings.ts +3 -0
  723. package/src/interfaces/web/src/lib/api/engines.ts +14 -0
  724. package/src/interfaces/web/src/lib/api/groups.ts +93 -0
  725. package/src/interfaces/web/src/lib/api/images.ts +193 -0
  726. package/src/interfaces/web/src/lib/api/inbox.ts +29 -5
  727. package/src/interfaces/web/src/lib/api/integrations.ts +9 -0
  728. package/src/interfaces/web/src/lib/api/media.ts +126 -0
  729. package/src/interfaces/web/src/lib/api/net.ts +44 -0
  730. package/src/interfaces/web/src/lib/api/profiles.ts +12 -0
  731. package/src/interfaces/web/src/lib/api/projects.ts +9 -1
  732. package/src/interfaces/web/src/lib/api/routines.ts +9 -1
  733. package/src/interfaces/web/src/lib/api/sessions.ts +18 -0
  734. package/src/interfaces/web/src/lib/api/skills.ts +8 -0
  735. package/src/interfaces/web/src/lib/api/super_agent.ts +3 -0
  736. package/src/interfaces/web/src/lib/api/tasks.ts +46 -6
  737. package/src/interfaces/web/src/lib/api/tools.ts +2 -0
  738. package/src/interfaces/web/src/lib/api/transcribe.ts +31 -0
  739. package/src/interfaces/web/src/lib/api/turns.ts +20 -0
  740. package/src/interfaces/web/src/lib/api/voice.ts +72 -2
  741. package/src/interfaces/web/src/lib/api.ts +3 -0
  742. package/src/interfaces/web/src/lib/channels.ts +214 -0
  743. package/src/interfaces/web/src/lib/chat-activity.ts +175 -0
  744. package/src/interfaces/web/src/lib/chat-prefs.ts +48 -0
  745. package/src/interfaces/web/src/lib/code-context.ts +0 -2
  746. package/src/interfaces/web/src/lib/http.ts +51 -17
  747. package/src/interfaces/web/src/lib/inbox-selection.ts +29 -0
  748. package/src/interfaces/web/src/lib/live.ts +252 -0
  749. package/src/interfaces/web/src/lib/net.ts +211 -0
  750. package/src/interfaces/web/src/lib/notify.ts +331 -0
  751. package/src/interfaces/web/src/lib/projectNav.ts +40 -0
  752. package/src/interfaces/web/src/lib/pwa.ts +115 -0
  753. package/src/interfaces/web/src/lib/qvox.ts +24 -0
  754. package/src/interfaces/web/src/lib/slug.ts +3 -0
  755. package/src/interfaces/web/src/lib/tone.ts +150 -0
  756. package/src/interfaces/web/src/main.tsx +15 -4
  757. package/src/interfaces/web/src/screens/ApxAdminScreen.tsx +33 -11
  758. package/src/interfaces/web/src/screens/InboxScreen.tsx +126 -71
  759. package/src/interfaces/web/src/screens/ProjectScreen.tsx +8 -4
  760. package/src/interfaces/web/src/screens/SettingsScreen.tsx +7 -4
  761. package/src/interfaces/web/src/screens/base/AgentDefaultsTab.tsx +30 -9
  762. package/src/interfaces/web/src/screens/base/CommitmentsTab.tsx +150 -168
  763. package/src/interfaces/web/src/screens/base/LogsTab.tsx +8 -7
  764. package/src/interfaces/web/src/screens/base/SessionsTab.tsx +99 -37
  765. package/src/interfaces/web/src/screens/base/WorkspacesTab.tsx +1 -1
  766. package/src/interfaces/web/src/screens/mobile/MobileChat.tsx +85 -0
  767. package/src/interfaces/web/src/screens/mobile/MobileChatList.tsx +244 -0
  768. package/src/interfaces/web/src/screens/mobile/MobileCommitments.tsx +350 -0
  769. package/src/interfaces/web/src/screens/mobile/MobileScreen.tsx +138 -0
  770. package/src/interfaces/web/src/screens/mobile/MobileTabBar.tsx +105 -0
  771. package/src/interfaces/web/src/screens/mobile/MobileTasks.tsx +407 -0
  772. package/src/interfaces/web/src/screens/mobile/NewChatSheet.tsx +313 -0
  773. package/src/interfaces/web/src/screens/mobile/mobileList.tsx +194 -0
  774. package/src/interfaces/web/src/screens/mobile/routes.ts +239 -0
  775. package/src/interfaces/web/src/screens/modules/CodeScreen.tsx +135 -64
  776. package/src/interfaces/web/src/screens/modules/DeckScreen.tsx +0 -1
  777. package/src/interfaces/web/src/screens/modules/ImagesScreen.tsx +172 -0
  778. package/src/interfaces/web/src/screens/modules/VoiceScreen.tsx +36 -4
  779. package/src/interfaces/web/src/screens/project/AgentBrainGraph.tsx +117 -30
  780. package/src/interfaces/web/src/screens/project/AgentDetailScreen.tsx +537 -92
  781. package/src/interfaces/web/src/screens/project/AgentsTab.tsx +344 -93
  782. package/src/interfaces/web/src/screens/project/ArtifactsTab.tsx +1 -1
  783. package/src/interfaces/web/src/screens/project/ChatTab.tsx +1160 -94
  784. package/src/interfaces/web/src/screens/project/IntegrationsTab.tsx +3 -2
  785. package/src/interfaces/web/src/screens/project/McpsTab.tsx +62 -14
  786. package/src/interfaces/web/src/screens/project/Overview.tsx +36 -15
  787. package/src/interfaces/web/src/screens/project/RoutinesTab.tsx +93 -8
  788. package/src/interfaces/web/src/screens/project/SkillsTab.tsx +4 -1
  789. package/src/interfaces/web/src/screens/project/StructureTab.tsx +5 -4
  790. package/src/interfaces/web/src/screens/project/TasksTab.tsx +351 -129
  791. package/src/interfaces/web/src/screens/project/VarsTab.tsx +17 -5
  792. package/src/interfaces/web/src/styles.css +106 -31
  793. package/src/interfaces/web/src/types/daemon.ts +246 -2
  794. package/src/interfaces/web/src/vite-env.d.ts +20 -0
  795. package/src/interfaces/tui/plugin/command-shim.ts +0 -109
  796. package/src/interfaces/web/components.json +0 -25
  797. package/src/interfaces/web/dist/assets/index-CvEoGtTf.js +0 -849
  798. package/src/interfaces/web/dist/assets/index-CvEoGtTf.js.map +0 -1
  799. package/src/interfaces/web/dist/assets/index-DzBBXFaO.css +0 -1
  800. package/src/interfaces/web/src/components/tasks/TaskDetailPanel.tsx +0 -142
  801. package/src/interfaces/web/src/components/ui/accordion.tsx +0 -72
  802. package/src/interfaces/web/src/components/ui/alert-dialog.tsx +0 -187
  803. package/src/interfaces/web/src/components/ui/alert.tsx +0 -76
  804. package/src/interfaces/web/src/components/ui/aspect-ratio.tsx +0 -22
  805. package/src/interfaces/web/src/components/ui/avatar.tsx +0 -107
  806. package/src/interfaces/web/src/components/ui/breadcrumb.tsx +0 -125
  807. package/src/interfaces/web/src/components/ui/button-group.tsx +0 -87
  808. package/src/interfaces/web/src/components/ui/calendar.tsx +0 -221
  809. package/src/interfaces/web/src/components/ui/card.tsx +0 -103
  810. package/src/interfaces/web/src/components/ui/carousel.tsx +0 -242
  811. package/src/interfaces/web/src/components/ui/chart.tsx +0 -371
  812. package/src/interfaces/web/src/components/ui/checkbox.tsx +0 -29
  813. package/src/interfaces/web/src/components/ui/collapsible.tsx +0 -19
  814. package/src/interfaces/web/src/components/ui/combobox.tsx +0 -295
  815. package/src/interfaces/web/src/components/ui/command.tsx +0 -196
  816. package/src/interfaces/web/src/components/ui/direction.tsx +0 -4
  817. package/src/interfaces/web/src/components/ui/drawer.tsx +0 -134
  818. package/src/interfaces/web/src/components/ui/empty.tsx +0 -104
  819. package/src/interfaces/web/src/components/ui/field.tsx +0 -236
  820. package/src/interfaces/web/src/components/ui/hover-card.tsx +0 -51
  821. package/src/interfaces/web/src/components/ui/input-otp.tsx +0 -85
  822. package/src/interfaces/web/src/components/ui/item.tsx +0 -201
  823. package/src/interfaces/web/src/components/ui/menubar.tsx +0 -280
  824. package/src/interfaces/web/src/components/ui/native-select.tsx +0 -61
  825. package/src/interfaces/web/src/components/ui/navigation-menu.tsx +0 -168
  826. package/src/interfaces/web/src/components/ui/pagination.tsx +0 -130
  827. package/src/interfaces/web/src/components/ui/progress.tsx +0 -83
  828. package/src/interfaces/web/src/components/ui/radio-group.tsx +0 -36
  829. package/src/interfaces/web/src/components/ui/resizable.tsx +0 -50
  830. package/src/interfaces/web/src/components/ui/scroll-area.tsx +0 -53
  831. package/src/interfaces/web/src/components/ui/sidebar.tsx +0 -726
  832. package/src/interfaces/web/src/components/ui/slider.tsx +0 -52
  833. package/src/interfaces/web/src/components/ui/sonner.tsx +0 -49
  834. package/src/interfaces/web/src/components/ui/table.tsx +0 -116
  835. package/src/interfaces/web/src/components/ui/toggle-group.tsx +0 -87
  836. package/src/interfaces/web/src/components/ui/toggle.tsx +0 -45
  837. package/src/interfaces/web/src/hooks/use-mobile.ts +0 -19
  838. package/src/interfaces/web/src/screens/base/GlobalTasksTab.tsx +0 -161
@@ -1,7 +1,17 @@
1
- import { useCallback, useRef, useState } from "react";
2
- import { SuperAgent, Agents, Conversations } from "../lib/api";
3
- import type { ChatStreamEvent, ChatUsage, ConversationMessage, ToolSummary } from "../types/daemon";
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+ import { SuperAgent, Agents, Conversations, Groups, Turns } from "../lib/api";
3
+ import type { ActiveTurn, AgentFace, ChatStreamEvent, ChatUsage, ConversationMessage, MessageMedia, ToolSummary, TurnFrame } from "../types/daemon";
4
+ import type { UploadedMedia } from "../lib/api/media";
5
+ import { subscribeTurns } from "../lib/live";
4
6
  import { t } from "../i18n";
7
+ import { queueOnSend, onChatPrefsChange } from "../lib/chat-prefs";
8
+ import {
9
+ conversationActivityKey,
10
+ isChatTurnClosed,
11
+ liveActivityKey,
12
+ setChatQueued,
13
+ threadActivityKey,
14
+ } from "../lib/chat-activity";
5
15
 
6
16
  export type ToolStatus = "running" | "done" | "error" | "deduped";
7
17
 
@@ -17,9 +27,20 @@ export interface ToolPart {
17
27
  export interface TextPart {
18
28
  kind: "text";
19
29
  text: string;
30
+ /** Still being painted by `assistant_delta` tokens. Cleared when the segment
31
+ * closes (`assistant_text` / `final`) and the cleaned text replaces it. */
32
+ streaming?: boolean;
20
33
  }
21
34
 
22
- export type ChatPart = TextPart | ToolPart;
35
+ /** The model thinking out loud. Never part of the answer — `textOf` skips it,
36
+ * so it is not copied, not counted as the reply, and not spoken. */
37
+ export interface ReasoningPart {
38
+ kind: "reasoning";
39
+ text: string;
40
+ streaming?: boolean;
41
+ }
42
+
43
+ export type ChatPart = TextPart | ToolPart | ReasoningPart;
23
44
 
24
45
  export interface ChatMsg {
25
46
  role: "user" | "assistant";
@@ -33,19 +54,40 @@ export interface ChatMsg {
33
54
  agent?: string;
34
55
  /** Stable id of that actor (super_agent | agent slug). Turns are split on it. */
35
56
  agentId?: string;
57
+ /** Group only: which agent's @mention pulled this speaker in ("traído por X"). */
58
+ reason?: string;
59
+ /** A group system notice ("joined"/"left") — rendered as a centred line, not a
60
+ * bubble. `who` is the agent slug it is about. */
61
+ event?: "joined" | "left";
62
+ who?: string;
36
63
  /** Token accounting from the `final` event. */
37
64
  usage?: ChatUsage;
65
+ /** You stopped this turn. What is here is what it had done — real work, kept
66
+ * on purpose so the message that interrupted it reads this as its history. */
67
+ stopped?: boolean;
38
68
  /** Operational notes (engine fallbacks, retries, suppressions). */
39
69
  notes?: string[];
40
70
  /** What a HISTORICAL turn did. Live turns render real ToolCall parts from
41
71
  * the stream; replayed ones only have this, recorded at write time. */
42
72
  toolSummary?: ToolSummary;
73
+ /** The attachment this user turn carried (voice note, photo, document).
74
+ * Rendered as the file itself — the stored text is only the marker the
75
+ * agent was handed. */
76
+ media?: MessageMedia[];
77
+ /** Composed HERE, in this tab, rather than read back from storage.
78
+ * A reply typed into a Telegram thread goes out on the `web` channel, so the
79
+ * Telegram thread file will never contain it — and a background refresh that
80
+ * took the file as the whole truth would erase what you just sent. See
81
+ * `mergeLocalTurns`. */
82
+ local?: boolean;
43
83
  /** Skill Inspector decision for this turn (when the feature is on): which
44
84
  * skills the per-turn RAG loaded inline vs merely hinted. */
45
85
  inspector?: {
46
86
  embedder?: string;
47
87
  loaded?: string[];
48
88
  hinted?: string[];
89
+ /** Top matches with their cosine similarity, for the badge popover. */
90
+ scored?: { slug: string; sim: number }[];
49
91
  };
50
92
  }
51
93
 
@@ -54,25 +96,110 @@ export interface SendOptions {
54
96
  model?: string;
55
97
  /** When set, talk to a project agent (non-streaming) instead of Roby. */
56
98
  agentSlug?: string;
99
+ /** Files already stored under ~/.apx/media that this turn carries. The daemon
100
+ * resolves each path, hands the images to the model and writes the marker
101
+ * that names them. Super-agent turns only. */
102
+ attachments?: UploadedMedia[];
103
+ }
104
+
105
+ /** A turn written while the previous one was still running.
106
+ *
107
+ * It is IN the thread from the moment you send it — dimmed, with a way out —
108
+ * and goes out by itself the moment the running turn lands. Sending during a
109
+ * run used to be refused outright, which left "stop" as the only thing the
110
+ * button under a working agent could do: the way to add a sentence to the
111
+ * conversation was to kill the answer being written. */
112
+ export interface QueuedTurn {
113
+ id: string;
114
+ /** The bubble, built exactly like the one a sent turn gets — the marker text
115
+ * included, so it renders its photo and not the marker's words. */
116
+ msg: ChatMsg;
117
+ /** What actually goes out when its turn comes. */
118
+ text: string;
119
+ opts: SendOptions;
120
+ }
121
+
122
+ // Queue ownership follows the CHAT, not the mounted pane. Navigating away
123
+ // destroys ChatTab but must not destroy a message already accepted from the
124
+ // composer. The worker that started the active turn drains this map even after
125
+ // its component unmounts; a newly mounted pane subscribes and paints it again.
126
+ const backgroundQueues = new Map<string, QueuedTurn[]>();
127
+ const backgroundQueueListeners = new Set<(key: string) => void>();
128
+ let backgroundQueueSeq = 0;
129
+
130
+ function readBackgroundQueue(key: string | null): QueuedTurn[] {
131
+ return key ? backgroundQueues.get(key) || [] : [];
132
+ }
133
+
134
+ function writeBackgroundQueue(key: string, queue: QueuedTurn[]) {
135
+ if (queue.length) backgroundQueues.set(key, queue);
136
+ else backgroundQueues.delete(key);
137
+ setChatQueued(key, queue.length);
138
+ for (const listener of backgroundQueueListeners) listener(key);
139
+ }
140
+
141
+ function takeBackgroundQueue(key: string): QueuedTurn | null {
142
+ const [next, ...rest] = readBackgroundQueue(key);
143
+ if (!next) return null;
144
+ writeBackgroundQueue(key, rest);
145
+ return next;
146
+ }
147
+
148
+ function moveBackgroundQueue(from: string | null, to: string) {
149
+ if (!from || from === to) return;
150
+ const pending = readBackgroundQueue(from);
151
+ if (!pending.length) return;
152
+ writeBackgroundQueue(from, []);
153
+ writeBackgroundQueue(to, [...readBackgroundQueue(to), ...pending]);
154
+ }
155
+
156
+ export interface ReloadOptions {
157
+ /** A BACKGROUND re-read: the same conversation moved somewhere else and we
158
+ * are catching up. It must not blank the pane first, must not drop what was
159
+ * typed here, and must leave the view alone if the fetch fails — none of
160
+ * which apply when the user actively picked a different chat. */
161
+ silent?: boolean;
57
162
  }
58
163
 
59
164
  export interface UseChatResult {
60
165
  msgs: ChatMsg[];
61
166
  send: (text: string, opts?: SendOptions) => Promise<void>;
167
+ /** Run one GROUP turn: the owner's line fans out to the room as a cascade of
168
+ * speakers, each streamed into its own pending bubble (name + "traído por X").
169
+ * `nameOf` resolves a slug to a display name; `rerun` re-runs from a speaker
170
+ * against the last owner message (`from` = slug, `reason` = who pulled them). */
171
+ sendGroup: (gid: string, text: string, nameOf: (slug: string) => string, opts?: { rerun?: boolean; from?: string; reason?: string | null; media?: UploadedMedia[] }) => Promise<void>;
62
172
  stop: () => void;
63
- clear: () => void;
173
+ clear: (queueKey?: string) => void;
64
174
  /** Load a persisted conversation as history and bind subsequent sends to it.
65
175
  * Only supported for project agents (super-agent conversations aren't
66
176
  * persisted per-file). Pass `null` to drop the binding without clearing. */
67
- load: (agentSlug: string, conversationId: string) => Promise<void>;
177
+ load: (agentSlug: string, conversationId: string, opts?: ReloadOptions) => Promise<void>;
68
178
  /** Load a super-agent channel thread (telegram/desktop/…) as history. Not
69
179
  * bound to a conversation file — continuing sends go out as fresh web
70
180
  * turns with the thread as previousMessages context. */
71
- loadThread: (channel: string, threadId: string) => Promise<void>;
181
+ loadThread: (channel: string, threadId: string, opts?: ReloadOptions) => Promise<void>;
182
+ /** Re-run the user turn that produced the assistant message at `index`,
183
+ * dropping that answer and everything after it (in the pane AND, for a bound
184
+ * project-agent conversation, on disk). Project-agent / live chats only. */
185
+ regenerate: (index: number, opts?: SendOptions) => Promise<void>;
186
+ /** Replace the user message at `index` with `text` and re-send, dropping that
187
+ * message and everything after it. Project-agent / live chats only. */
188
+ editAndResend: (index: number, text: string, opts?: SendOptions) => Promise<void>;
72
189
  streaming: boolean;
190
+ /** Turns typed while this one was running, in the order they were written.
191
+ * They belong under the thread, not in `msgs`: everything that paints a live
192
+ * answer works on the trailing message, and a queued bubble parked there
193
+ * would silently swallow the rest of the stream. */
194
+ queued: QueuedTurn[];
195
+ /** Take one back before it goes out. */
196
+ unqueue: (id: string) => void;
73
197
  /** Conversation id we're bound to, if any. Lets callers reflect "live vs
74
198
  * loaded" state in the UI. */
75
199
  conversationId: string | undefined;
200
+ /** What the loaded conversation records about itself — engine, channel — from
201
+ * its own frontmatter, which is authoritative where a list row's guess is not. */
202
+ conversationMeta: ConversationMeta | undefined;
76
203
  }
77
204
 
78
205
  /** Concatenate the text parts of a message (for clipboard). */
@@ -129,8 +256,55 @@ export function historyTextOf(msg: ChatMsg): string {
129
256
  return chunks.join("\n\n").trim();
130
257
  }
131
258
 
259
+ /** Re-send shape for files already on a stored turn (edit / regenerate). */
260
+ export function attachmentsOf(msg: ChatMsg): UploadedMedia[] {
261
+ return (msg.media || [])
262
+ .filter((m): m is MessageMedia & { path: string } => typeof m.path === "string" && m.path.length > 0)
263
+ .map((m) => ({
264
+ path: m.path,
265
+ name: m.name || "file",
266
+ mime: m.mime || "application/octet-stream",
267
+ kind: m.kind,
268
+ size: m.size ?? 0,
269
+ }));
270
+ }
271
+
272
+ /** Drop leading `[…]` markers so re-attaching files does not double them. */
273
+ function stripLeadingMarkers(text: string, count: number): string {
274
+ let out = text;
275
+ for (let i = 0; i < Math.max(1, count); i++) {
276
+ const next = out.replace(/^\[[^\]]*\]\s*/, "");
277
+ if (next === out) break;
278
+ out = next;
279
+ }
280
+ return out.trim();
281
+ }
282
+
132
283
  const userPart = (text: string): ChatPart[] => [{ kind: "text", text }];
133
284
 
285
+ /** The stored-turn shape of a file the composer just uploaded. */
286
+ const mediaOf = (file: UploadedMedia): MessageMedia => ({
287
+ kind: file.kind,
288
+ path: file.path,
289
+ name: file.name,
290
+ mime: file.mime,
291
+ size: file.size,
292
+ duration: null,
293
+ });
294
+
295
+ /** The markers the daemon will write for these files (see readTurnAttachments),
296
+ * mirrored locally so the sent turn matches the ledger after a silent reload.
297
+ * A short `[image attached]` used to diverge from `[image attached — saved to
298
+ * …]` and leave the optimistic bubble dangling as a duplicate photo. */
299
+ const markersFor = (files: UploadedMedia[]): string =>
300
+ files
301
+ .map((f) =>
302
+ f.kind === "photo"
303
+ ? `[image attached — saved to ${f.path}]`
304
+ : `[file attached: ${f.name}, ${f.mime} — saved to ${f.path}. You can open it with your file tools.]`,
305
+ )
306
+ .join(" ");
307
+
134
308
  function isErrorResult(result: unknown): boolean {
135
309
  if (!result || typeof result !== "object") return false;
136
310
  const r = result as Record<string, unknown>;
@@ -159,15 +333,29 @@ function threadToChatMsgs(messages: ConversationMessage[]): ChatMsg[] {
159
333
  if (m.role === "user") {
160
334
  turn = null;
161
335
  turnActor = undefined;
162
- out.push({ role: "user", parts: userPart(m.content), ts });
336
+ // A stored turn records ONE file (the ledger row holds a single media
337
+ // block) — the live turn below can carry several, so the field is a list
338
+ // either way and history just has a list of one.
339
+ out.push({ role: "user", parts: userPart(m.content), ts, ...(m.media ? { media: [m.media] } : {}) });
163
340
  } else if (m.role === "assistant" || m.role === "tool") {
164
341
  // Tool rows inherit the current actor (they're logged by whoever is
165
342
  // running); only assistant rows can start a new one.
343
+ //
344
+ // A turn OPENED by tool rows has no actor yet, and the assistant row that
345
+ // follows is the one that names it — not a second turn. Comparing against
346
+ // an undefined actor split every "tools first, then the answer" turn in
347
+ // two: the calls in a bubble credited to nobody, the answer in another.
348
+ // That is what kept the question panel from ever appearing — the
349
+ // ask_questions call ended up one bubble BEHIND the last message, so the
350
+ // questions read as already answered and there was nothing to pick from.
166
351
  const actor = m.role === "assistant" ? m.agent : turnActor;
167
- if (!turn || (m.role === "assistant" && actor !== turnActor)) {
352
+ const named = turnActor !== undefined;
353
+ if (!turn || (m.role === "assistant" && named && actor !== turnActor)) {
168
354
  turn = { role: "assistant", parts: [], ts };
169
355
  turnActor = actor;
170
356
  out.push(turn);
357
+ } else if (m.role === "assistant" && !named) {
358
+ turnActor = actor;
171
359
  }
172
360
  if (m.role === "tool") {
173
361
  turn.parts.push({
@@ -181,22 +369,109 @@ function threadToChatMsgs(messages: ConversationMessage[]): ChatMsg[] {
181
369
  } else {
182
370
  if (m.agent) turn.agentId = m.agent;
183
371
  if (m.agent_name) turn.agent = m.agent_name;
372
+ if (m.reason) turn.reason = m.reason; // group: "traído por X"
184
373
  if (m.model) turn.model = m.model;
185
374
  if (m.tool_summary) turn.toolSummary = m.tool_summary;
375
+ if (m.skill_inspector) turn.inspector = m.skill_inspector;
376
+ // What the AGENT sent with this turn — a skill's image it attached, a
377
+ // photo it pushed to Telegram, a file a routine delivered. The stored
378
+ // row always carried it; this branch simply never read it, so an agent
379
+ // could hand you a file and the thread would show the caption alone.
380
+ // `media_list` is the several-file spelling a delivery writes; the flat
381
+ // one mirrors its first file, so the list wins where both are present.
382
+ const sent = m.media_list?.length ? m.media_list : m.media ? [m.media] : [];
383
+ if (sent.length) turn.media = [...(turn.media || []), ...sent];
186
384
  if (m.usage) {
187
385
  turn.usage = {
188
386
  input_tokens: (turn.usage?.input_tokens || 0) + (m.usage.input_tokens || 0),
189
387
  output_tokens: (turn.usage?.output_tokens || 0) + (m.usage.output_tokens || 0),
190
388
  };
191
389
  }
390
+ // The thinking first, the answer after — the order it happened in and
391
+ // the order the live stream paints it.
392
+ for (const think of m.reasoning || []) {
393
+ if (think) turn.parts.push({ kind: "reasoning", text: think });
394
+ }
192
395
  if (m.content) turn.parts.push({ kind: "text", text: m.content });
193
396
  }
397
+ } else if (m.role === "system" && m.event) {
398
+ // A group join/leave notice — a standalone centred line, not part of any
399
+ // agent's turn.
400
+ turn = null;
401
+ turnActor = undefined;
402
+ out.push({ role: "assistant", parts: [], ts, event: m.event, who: m.who });
194
403
  }
195
- // system/compact rows are context-only; not rendered in the thread viewer.
404
+ // other system/compact rows are context-only; not rendered in the viewer.
196
405
  }
197
406
  return out;
198
407
  }
199
408
 
409
+ /**
410
+ * Reconcile a background re-read with what this tab composed itself.
411
+ *
412
+ * Storage wins for everything it knows about — it is the record, and it now
413
+ * holds whatever the other device just said. But a turn typed HERE may not be
414
+ * in the file we just read: a reply sent while reading a Telegram thread goes
415
+ * out on the `web` channel, so the Telegram day file will never contain it, and
416
+ * taking the file as the whole truth would make what you just sent vanish.
417
+ *
418
+ * Local turns that DID land in storage are matched by role + text OR by the
419
+ * same attachment path (media markers used to diverge between optimistic and
420
+ * ledger text, which left a duplicate photo at the foot of the thread until
421
+ * a hard reload) and dropped, so the same turn is never shown twice.
422
+ */
423
+ function mediaPathsKey(m: ChatMsg): string {
424
+ return (m.media || []).map((x) => x.path).filter(Boolean).join("\0");
425
+ }
426
+
427
+ export function mergeLocalTurns(remote: ChatMsg[], current: ChatMsg[]): ChatMsg[] {
428
+ const extras = current.filter((m) => m.local);
429
+ if (!extras.length) return remote;
430
+ const seenText = new Set(remote.map((m) => `${m.role}|${textOf(m)}`));
431
+ const seenMedia = new Set(
432
+ remote.filter((m) => mediaPathsKey(m)).map((m) => `${m.role}|${mediaPathsKey(m)}`),
433
+ );
434
+ return [
435
+ ...remote,
436
+ ...extras.filter((m) => {
437
+ if (seenText.has(`${m.role}|${textOf(m)}`)) return false;
438
+ const paths = mediaPathsKey(m);
439
+ if (paths && seenMedia.has(`${m.role}|${paths}`)) return false;
440
+ return true;
441
+ }),
442
+ ];
443
+ }
444
+
445
+ /** History plus a trailing streaming bubble for a turn still being written, so
446
+ * opening a chat mid-answer shows the partial (then the live frames fill it).
447
+ * Marked local so a background refetch keeps it until the real turn persists. */
448
+ function withActiveTurn(loaded: ChatMsg[], active?: ActiveTurn | null): ChatMsg[] {
449
+ if (!active) return loaded;
450
+ const parts: ChatPart[] = active.parts?.length
451
+ ? active.parts.map((part) => part.kind === "text"
452
+ ? { kind: "text", text: part.text, ...(part.streaming ? { streaming: true } : {}) }
453
+ : {
454
+ kind: "tool",
455
+ id: part.id,
456
+ tool: part.tool,
457
+ args: part.args || undefined,
458
+ result: part.result,
459
+ status: part.status,
460
+ })
461
+ : active.text ? [{ kind: "text", text: active.text, streaming: true }] : [];
462
+ return [
463
+ ...loaded,
464
+ {
465
+ role: "assistant",
466
+ parts,
467
+ ts: active.started_at || new Date().toISOString(),
468
+ local: true,
469
+ pending: true,
470
+ ...(active.agent_slug ? { agent: active.agent_slug, agentId: active.agent_slug } : {}),
471
+ },
472
+ ];
473
+ }
474
+
200
475
  /**
201
476
  * Pure reducer: apply one NDJSON stream event to an assistant turn and return
202
477
  * the next turn. Every surface that consumes the super-agent stream (ChatTab,
@@ -226,18 +501,67 @@ export function applyStreamEvent(turn: ChatMsg, ev: ChatStreamEvent): ChatMsg {
226
501
  return withNote(`tools suppressed: ${(ev.tools || []).join(", ")}`);
227
502
  case "skill_inspector": {
228
503
  const insp = ev.inspector;
229
- if (!insp || (!insp.loaded?.length && !insp.hinted?.length)) return turn;
504
+ // Keep the row when anything was injected OR merely scored: the "considered"
505
+ // near-misses are shown each round too, so the per-turn RAG is visible even
506
+ // when nothing crossed the load/hint bar (e.g. on the offline tf embedder).
507
+ if (!insp || (!insp.loaded?.length && !insp.hinted?.length && !insp.scored?.length))
508
+ return turn;
230
509
  return {
231
510
  ...turn,
232
511
  inspector: {
233
512
  embedder: insp.embedder,
234
513
  loaded: insp.loaded || [],
235
514
  hinted: insp.hinted || [],
515
+ scored: insp.scored || [],
236
516
  },
237
517
  };
238
518
  }
239
- case "assistant_text":
240
- return ev.text ? { ...turn, parts: [...turn.parts, { kind: "text", text: ev.text }] } : turn;
519
+ case "assistant_reasoning_delta": {
520
+ // The thinking, live. Fills the wait before the first word of the answer.
521
+ const piece = ev.reasoning || "";
522
+ if (!piece) return turn;
523
+ const parts = [...turn.parts];
524
+ const last = parts[parts.length - 1];
525
+ if (last && last.kind === "reasoning" && last.streaming) {
526
+ parts[parts.length - 1] = { ...last, text: last.text + piece };
527
+ } else {
528
+ parts.push({ kind: "reasoning", text: piece, streaming: true });
529
+ }
530
+ return { ...turn, parts };
531
+ }
532
+ case "assistant_reasoning": {
533
+ // The whole block at once — the loop emits it after the call returns, so
534
+ // by now the answer may already be painted below the streamed thinking.
535
+ const full = ev.reasoning || "";
536
+ if (!full) return turn;
537
+ const parts = [...turn.parts];
538
+ for (let i = parts.length - 1; i >= 0; i--) {
539
+ const p = parts[i];
540
+ if (p.kind === "reasoning" && p.streaming) {
541
+ parts[i] = { kind: "reasoning", text: full };
542
+ return { ...turn, parts };
543
+ }
544
+ }
545
+ // An engine that never streamed it: the thinking happened BEFORE the
546
+ // answer, so it goes in front of the segment being written, not after.
547
+ const last = parts[parts.length - 1];
548
+ const at = last && last.kind === "text" && last.streaming ? parts.length - 1 : parts.length;
549
+ parts.splice(at, 0, { kind: "reasoning", text: full });
550
+ return { ...turn, parts };
551
+ }
552
+ case "assistant_text": {
553
+ // The cleaned close of the segment the deltas were painting. It REPLACES
554
+ // the streamed part — appending it would show the same answer twice, once
555
+ // token by token and once whole.
556
+ if (!ev.text) return turn;
557
+ const parts = [...turn.parts];
558
+ const last = parts[parts.length - 1];
559
+ if (last && last.kind === "text" && last.streaming) {
560
+ parts[parts.length - 1] = { kind: "text", text: ev.text };
561
+ return { ...turn, parts };
562
+ }
563
+ return { ...turn, parts: [...parts, { kind: "text", text: ev.text }] };
564
+ }
241
565
  case "tool_start":
242
566
  return ev.trace
243
567
  ? {
@@ -280,7 +604,24 @@ export function applyStreamEvent(turn: ChatMsg, ev: ChatStreamEvent): ChatMsg {
280
604
  ),
281
605
  };
282
606
  }
283
- case "final":
607
+ case "final": {
608
+ // The closing message. It only counts as "already shown" when it IS one
609
+ // of the streamed segments — the guard used to be "the turn has any text
610
+ // at all", which silently dropped every answer that came after a tool
611
+ // call. Since the model writes a line before each tool, that was every
612
+ // multi-step turn: the reader saw the steps and never the conclusion.
613
+ // (api/code.js fixed the same bug on the persistence side.)
614
+ const finalText = ev.result?.text || "";
615
+ const parts = [...turn.parts];
616
+ const last = parts[parts.length - 1];
617
+ // A part still marked streaming holds raw tokens; `final` carries the
618
+ // cleaned version of that same text, so it lands in place.
619
+ if (finalText && last && last.kind === "text" && last.streaming) {
620
+ parts[parts.length - 1] = { kind: "text", text: finalText };
621
+ }
622
+ const alreadyShown =
623
+ !!finalText &&
624
+ parts.some((p) => p.kind === "text" && p.text.trim() === finalText.trim());
284
625
  return {
285
626
  ...turn,
286
627
  pending: false,
@@ -291,23 +632,73 @@ export function applyStreamEvent(turn: ChatMsg, ev: ChatStreamEvent): ChatMsg {
291
632
  model: turn.model ?? ev.result?.model,
292
633
  agent: turn.agent ?? ev.result?.name,
293
634
  parts:
294
- ev.result?.text && !turn.parts.some((p) => p.kind === "text")
295
- ? [...turn.parts, { kind: "text", text: ev.result.text }]
296
- : turn.parts,
635
+ finalText && !alreadyShown
636
+ ? [...parts, { kind: "text", text: finalText }]
637
+ : parts,
638
+ };
639
+ }
640
+ case "start":
641
+ // Identity only (which conversation / channel this turn is on). The pane
642
+ // binds to it outside the reducer — nothing about the bubble changes.
643
+ return turn;
644
+ case "aborted": {
645
+ // You stopped it. Everything streamed so far stands: it is work that
646
+ // really happened, the daemon persisted it, and the message that
647
+ // interrupted this turn reads it as history. So the only thing to do is
648
+ // close the open segment — no text is thrown away, and this is NOT an
649
+ // error, which is why it does not travel as one.
650
+ return {
651
+ ...turn,
652
+ pending: false,
653
+ stopped: true,
654
+ parts: turn.parts.map((p) =>
655
+ p.kind === "text" && p.streaming ? { kind: "text", text: p.text } : p,
656
+ ),
297
657
  };
658
+ }
298
659
  default: {
299
- // Raw-delta engines (no assistant_text) append to the trailing text.
660
+ // `assistant_delta` tokens as the model writes them. They only ever
661
+ // extend a part that is still streaming; a segment already closed by
662
+ // assistant_text is finished and must not absorb the next one's tokens.
300
663
  const piece = ev.delta || ev.content || "";
301
664
  if (!piece) return turn;
302
665
  const parts = [...turn.parts];
303
666
  const last = parts[parts.length - 1];
304
- if (last && last.kind === "text") parts[parts.length - 1] = { ...last, text: last.text + piece };
305
- else parts.push({ kind: "text", text: piece });
667
+ if (last && last.kind === "text" && last.streaming) {
668
+ parts[parts.length - 1] = { ...last, text: last.text + piece };
669
+ } else {
670
+ parts.push({ kind: "text", text: piece, streaming: true });
671
+ }
306
672
  return { ...turn, parts };
307
673
  }
308
674
  }
309
675
  }
310
676
 
677
+ /** What a stored conversation records about itself (its frontmatter). */
678
+ export interface ConversationMeta {
679
+ engine?: string;
680
+ channel?: string;
681
+ title?: string;
682
+ started?: string;
683
+ /** Multi-agent threads (a2a, group) only: who is in the room, and the face
684
+ * each one wears. Both come resolved from the daemon with the messages, so
685
+ * the header can draw the thread without being handed a list row — which is
686
+ * all the project Chats tab ever had, and why it drew none. */
687
+ participants?: string[];
688
+ faces?: AgentFace[];
689
+ }
690
+
691
+ function metaFromDetail(detail: { channel?: string; meta?: Record<string, unknown> }): ConversationMeta {
692
+ const fm = (detail.meta || {}) as Record<string, unknown>;
693
+ const str = (v: unknown) => (typeof v === "string" && v.trim() ? v.trim() : undefined);
694
+ return {
695
+ engine: str(fm.engine),
696
+ channel: str(fm.channel) || detail.channel,
697
+ title: str(fm.title)?.replace(/^"|"$/g, ""),
698
+ started: str(fm.started),
699
+ };
700
+ }
701
+
311
702
  /**
312
703
  * Single source of truth for the project chat. For Roby (super-agent) it
313
704
  * consumes the NDJSON event stream and builds a rich, opencode-style turn:
@@ -317,25 +708,111 @@ export function applyStreamEvent(turn: ChatMsg, ev: ChatStreamEvent): ChatMsg {
317
708
  */
318
709
  export function useChat(pid: string, onError?: (msg: string) => void): UseChatResult {
319
710
  const [msgs, setMsgs] = useState<ChatMsg[]>([]);
711
+ const msgsRef = useRef<ChatMsg[]>([]);
712
+ // React drops state updates after unmount. The request does not stop, so keep
713
+ // the worker's own copy synchronously: a background queue needs the answer
714
+ // that just finished when it builds the next turn's history.
715
+ const updateMsgs = useCallback((next: ChatMsg[] | ((curr: ChatMsg[]) => ChatMsg[])) => {
716
+ const value = typeof next === "function" ? next(msgsRef.current) : next;
717
+ msgsRef.current = value;
718
+ setMsgs(value);
719
+ }, []);
320
720
  const [streaming, setStreaming] = useState(false);
721
+ const streamingRef = useRef(false);
722
+ const updateStreaming = useCallback((value: boolean) => {
723
+ streamingRef.current = value;
724
+ setStreaming(value);
725
+ }, []);
726
+ // A turn opened from another pane is busy too, but it is followed through the
727
+ // shared feed instead of this hook's NDJSON response.
728
+ const [following, setFollowing] = useState(false);
729
+ const followingRef = useRef(false);
730
+ const updateFollowing = useCallback((value: boolean) => {
731
+ followingRef.current = value;
732
+ setFollowing(value);
733
+ }, []);
321
734
  const [conversationId, setConversationId] = useState<string | undefined>(undefined);
735
+ // What the LOADED conversation says about itself — its engine, its channel —
736
+ // read from the file's own frontmatter rather than from whatever the list row
737
+ // guessed. A routine conversation opened from the sidebar was labelled "new
738
+ // chat · web" and showed no model at all, because the header only ever saw
739
+ // the selection metadata.
740
+ const [conversationMeta, setConversationMeta] = useState<ConversationMeta | undefined>(undefined);
741
+ // Written during a run, waiting their turn. Deliberately their own state and
742
+ // not a flag on a `msgs` entry: `patchLast` and the two error paths below all
743
+ // address the LAST message, so a queued bubble sitting there would take the
744
+ // rest of the stream, or be the thing a failed turn deleted.
745
+ const [queued, setQueued] = useState<QueuedTurn[]>([]);
746
+ const queueKeyRef = useRef<string | null>(null);
747
+ const bindQueue = useCallback((key: string) => {
748
+ queueKeyRef.current = key;
749
+ const snapshot = readBackgroundQueue(key);
750
+ setQueued(snapshot);
751
+ setChatQueued(key, snapshot.length);
752
+ }, []);
753
+ useEffect(() => {
754
+ const listener = (key: string) => {
755
+ if (key === queueKeyRef.current) setQueued(readBackgroundQueue(key));
756
+ };
757
+ backgroundQueueListeners.add(listener);
758
+ return () => { backgroundQueueListeners.delete(listener); };
759
+ }, []);
322
760
  const abortRef = useRef<AbortController | null>(null);
761
+ // How the DAEMON addresses the turn this tab started, so it can be stopped for
762
+ // real. abortRef only closes our socket, which the daemon deliberately ignores
763
+ // (that is what lets another tab catch up on a running turn) — the run itself
764
+ // stops only when asked, through POST /turns/abort.
765
+ const turnTargetRef = useRef<{ conversation_id?: string; channel?: string; thread_id?: string } | null>(null);
323
766
  const convoRef = useRef<string | undefined>(undefined);
767
+ const threadRef = useRef<{ channel: string; id: string } | null>(null);
324
768
  // Monotonic token guarding async history loads. Every load()/loadThread()/
325
769
  // clear() bumps it; a load only applies its result if it's still the latest.
326
770
  // Without this, clicking chat A then B could land A's (slower) response last
327
771
  // and paint A's messages under B's header.
328
772
  const loadSeqRef = useRef(0);
773
+ // A running request belongs to the chat it started in, not whichever chat
774
+ // the owner opens next. Loading another chat advances this epoch: the old
775
+ // request continues in the daemon, but its late NDJSON frames cannot append
776
+ // themselves to the newly selected transcript.
777
+ const viewEpochRef = useRef(0);
778
+ // True while THIS tab is the one streaming a turn over its own NDJSON socket.
779
+ // The sender renders from that; it must ignore the live-turn frames the daemon
780
+ // also pushes for everyone else, or it would paint each token twice.
781
+ // Updated synchronously by updateStreaming; no render/effect round trip, so a
782
+ // send in the same tick cannot start a second run.
783
+ // Interrupt or wait, when you write during a running turn. Held in a ref so
784
+ // flipping the switch mid-turn takes effect on the very next send instead of
785
+ // rebuilding `send` and its captured history.
786
+ const queueOnSendRef = useRef(queueOnSend());
787
+ useEffect(() => {
788
+ const read = () => { queueOnSendRef.current = queueOnSend(); };
789
+ read();
790
+ return onChatPrefsChange(read);
791
+ }, []);
792
+ // The turn this tab is FOLLOWING from the live feed (a turn it did not start):
793
+ // its id and the text accumulated so far, so a frame that arrives after a
794
+ // background refetch wiped the bubble can repaint it whole rather than lose it.
795
+ const liveTurnRef = useRef<{ id: string; text: string } | null>(null);
796
+ const drainQueueRef = useRef<() => void>(() => {});
797
+ const beginViewChange = useCallback(() => {
798
+ viewEpochRef.current++;
799
+ // Detach this pane from a turn that is still running elsewhere. The daemon
800
+ // and shared feed keep it alive; this chat must not inherit its typing pill.
801
+ updateStreaming(false);
802
+ updateFollowing(false);
803
+ liveTurnRef.current = null;
804
+ turnTargetRef.current = null;
805
+ }, [updateFollowing, updateStreaming]);
329
806
 
330
807
  // Mutate the trailing assistant turn in place.
331
808
  const patchLast = useCallback((fn: (m: ChatMsg) => ChatMsg) => {
332
- setMsgs((curr) => {
809
+ updateMsgs((curr) => {
333
810
  const copy = [...curr];
334
811
  const last = copy[copy.length - 1];
335
812
  if (last && last.role === "assistant") copy[copy.length - 1] = fn(last);
336
813
  return copy;
337
814
  });
338
- }, []);
815
+ }, [updateMsgs]);
339
816
 
340
817
  const applyEvent = useCallback(
341
818
  (ev: ChatStreamEvent) => {
@@ -343,53 +820,310 @@ export function useChat(pid: string, onError?: (msg: string) => void): UseChatRe
343
820
  onError?.(ev.error || t("shared_ui.err_stream"));
344
821
  return;
345
822
  }
823
+ // The turn names itself before it does any work, so stopping it (or
824
+ // interrupting it by sending) works from the first token — not only once
825
+ // it is over, which is when `final` used to be the first mention of the
826
+ // conversation it had been writing to all along.
827
+ if (ev.type === "start") {
828
+ if (ev.conversation_id) {
829
+ const nextKey = conversationActivityKey(pid, ev.conversation_id);
830
+ moveBackgroundQueue(queueKeyRef.current, nextKey);
831
+ bindQueue(nextKey);
832
+ convoRef.current = ev.conversation_id;
833
+ threadRef.current = null;
834
+ setConversationId(ev.conversation_id);
835
+ turnTargetRef.current = { conversation_id: ev.conversation_id };
836
+ } else if (ev.channel) {
837
+ const threadId = ev.thread_id || new Date().toISOString().slice(0, 10);
838
+ bindQueue(threadActivityKey(pid, ev.channel, threadId));
839
+ threadRef.current = { channel: ev.channel, id: threadId };
840
+ turnTargetRef.current = { channel: ev.channel };
841
+ }
842
+ }
843
+ // Stopped by you. Mark it the same way the local-abort path does, so the
844
+ // two routes to the same outcome read identically in the thread.
845
+ if (ev.type === "aborted") {
846
+ patchLast((m) => {
847
+ const closed = applyStreamEvent(m, ev);
848
+ return { ...closed, parts: [...closed.parts, { kind: "text", text: t("code_module.stopped") }] };
849
+ });
850
+ return;
851
+ }
346
852
  patchLast((m) => applyStreamEvent(m, ev));
347
853
  },
348
- [patchLast, onError],
854
+ [pid, patchLast, onError, bindQueue],
349
855
  );
350
856
 
857
+ // ── Following a turn this tab did NOT start (live push) ──────────────────
858
+ // paintLive upserts a trailing STREAMING assistant bubble (local, so a
859
+ // background refetch keeps it) from the full accumulated text; finalizeLive
860
+ // turns it into a settled reply (no longer local, so the next silent reload
861
+ // replaces it with the identical persisted turn instead of doubling it).
862
+ const paintLive = useCallback((text: string, agentSlug?: string) => {
863
+ updateMsgs((curr) => {
864
+ const copy = [...curr];
865
+ const last = copy[copy.length - 1];
866
+ const base: ChatMsg = {
867
+ role: "assistant",
868
+ parts: text ? [{ kind: "text", text, streaming: true }] : [],
869
+ ts: new Date().toISOString(),
870
+ local: true,
871
+ pending: true,
872
+ ...(agentSlug ? { agent: agentSlug, agentId: agentSlug } : {}),
873
+ };
874
+ if (last && last.role === "assistant" && (last.pending || last.local)) {
875
+ copy[copy.length - 1] = { ...base, ts: last.ts };
876
+ } else {
877
+ copy.push(base);
878
+ }
879
+ return copy;
880
+ });
881
+ }, [updateMsgs]);
882
+
883
+ const finalizeLive = useCallback(
884
+ (text: string, opts: { model?: string; name?: string; usage?: ChatUsage; error?: string }) => {
885
+ updateMsgs((curr) => {
886
+ const copy = [...curr];
887
+ const last = copy[copy.length - 1];
888
+ if (!last || last.role !== "assistant") return curr;
889
+ copy[copy.length - 1] = {
890
+ ...last,
891
+ local: false,
892
+ pending: false,
893
+ parts: text
894
+ ? [{ kind: "text", text }]
895
+ : opts.error
896
+ ? [{ kind: "text", text: t("shared_ui.err_stream") }]
897
+ : last.parts,
898
+ ...(opts.model ? { model: opts.model } : {}),
899
+ ...(opts.name ? { agent: opts.name, agentId: opts.name } : {}),
900
+ ...(opts.usage ? { usage: opts.usage } : {}),
901
+ };
902
+ return copy;
903
+ });
904
+ },
905
+ [updateMsgs],
906
+ );
907
+
908
+ // The daemon pushes every in-progress turn's tokens over the shared feed. A
909
+ // tab that is NOT the sender — a second window, or this one after a refresh /
910
+ // switching chats and back — follows them here, so streaming survives losing
911
+ // the connection that started it.
912
+ const onTurnFrame = useCallback((f: TurnFrame) => {
913
+ if (streamingRef.current) return; // the sender renders via NDJSON
914
+ if (String(f.project_id) !== String(pid)) return;
915
+ const sameConversation = !!convoRef.current && f.conversation_id === convoRef.current;
916
+ const sameThread = !!threadRef.current &&
917
+ f.channel === threadRef.current.channel &&
918
+ f.thread_id === threadRef.current.id;
919
+ if (!sameConversation && !sameThread) return;
920
+ if (f.phase === "start") {
921
+ updateFollowing(true);
922
+ turnTargetRef.current = sameConversation
923
+ ? { conversation_id: f.conversation_id || undefined }
924
+ : { channel: f.channel || undefined };
925
+ liveTurnRef.current = { id: f.turn_id, text: "" };
926
+ paintLive("", f.agent_slug || undefined);
927
+ } else if (f.phase === "delta") {
928
+ const cur =
929
+ liveTurnRef.current && liveTurnRef.current.id === f.turn_id
930
+ ? liveTurnRef.current
931
+ : (liveTurnRef.current = { id: f.turn_id, text: "" });
932
+ cur.text += f.delta || "";
933
+ paintLive(cur.text, f.agent_slug || undefined);
934
+ } else if (f.phase === "final") {
935
+ liveTurnRef.current = null;
936
+ updateFollowing(false);
937
+ finalizeLive(f.result?.text ?? "", {
938
+ model: f.result?.model,
939
+ name: f.result?.name || f.agent_slug || undefined,
940
+ usage: f.result?.usage,
941
+ });
942
+ turnTargetRef.current = null;
943
+ queueMicrotask(() => drainQueueRef.current());
944
+ } else if (f.phase === "aborted") {
945
+ liveTurnRef.current = null;
946
+ updateFollowing(false);
947
+ finalizeLive(f.result?.text ?? "", {
948
+ model: f.result?.model,
949
+ name: f.result?.name || f.agent_slug || undefined,
950
+ usage: f.result?.usage,
951
+ });
952
+ turnTargetRef.current = null;
953
+ queueMicrotask(() => drainQueueRef.current());
954
+ } else if (f.phase === "error") {
955
+ liveTurnRef.current = null;
956
+ updateFollowing(false);
957
+ finalizeLive("", { error: f.error });
958
+ turnTargetRef.current = null;
959
+ queueMicrotask(() => drainQueueRef.current());
960
+ }
961
+ }, [pid, paintLive, finalizeLive, updateFollowing]);
962
+
963
+ useEffect(() => subscribeTurns(onTurnFrame), [onTurnFrame]);
964
+
965
+ /**
966
+ * Stop the turn the DAEMON is running, not just the socket we read it through.
967
+ *
968
+ * Closing the stream has never stopped a run and must not start to: another
969
+ * tab, or this one after a refresh, catches up on a turn in progress from the
970
+ * daemon's own copy (see active-turns.js). So the ask is explicit, and the
971
+ * daemon answers by closing the stream with `aborted` — which carries the
972
+ * partial and is not an error.
973
+ *
974
+ * The local abort is the fallback for the two cases the daemon cannot answer:
975
+ * there is no live turn under that key (it finished a moment before the click
976
+ * landed), or the request itself failed.
977
+ */
978
+ const stopTurn = useCallback(async () => {
979
+ const target = turnTargetRef.current;
980
+ if (target) {
981
+ try {
982
+ const { aborted } = await Turns.abort(pid, target);
983
+ if (aborted) return;
984
+ } catch {
985
+ /* the daemon could not be asked — fall through and at least stop reading */
986
+ }
987
+ }
988
+ abortRef.current?.abort();
989
+ }, [pid]);
990
+
351
991
  const send = useCallback(
352
992
  async (text: string, opts: SendOptions = {}) => {
353
993
  const trimmed = text.trim();
354
- if (!trimmed || streaming) return;
994
+ const files = opts.attachments || [];
995
+ // A photo with no caption is a turn; text alone still is one.
996
+ if (!trimmed && !files.length) return;
355
997
  const nowIso = () => new Date().toISOString();
356
- const history: ConversationMessage[] = msgs.map((m) => ({
998
+ // The bubble a turn gets, whether it goes out now or in a minute. The
999
+ // marker rides on the turn's text the way it does on every other channel:
1000
+ // the bubble strips it (the file is shown instead) and the NEXT turn's
1001
+ // history still records that something was attached.
1002
+ const bubble = (): ChatMsg => ({
1003
+ role: "user",
1004
+ parts: userPart([markersFor(files), trimmed].filter(Boolean).join(" ")),
1005
+ ts: nowIso(),
1006
+ // Composed here: a background refresh keeps it even when the thread
1007
+ // being read is not the channel this turn goes out on.
1008
+ local: true,
1009
+ ...(files.length ? { media: files.map(mediaOf) } : {}),
1010
+ });
1011
+
1012
+ // Written while the previous turn was still going. It joins the thread
1013
+ // now and leaves on its own when the run lands — refusing it is what made
1014
+ // "stop" the only button a working agent would show you.
1015
+ if (streamingRef.current || followingRef.current) {
1016
+ // Queued either way — the drain effect below is what actually sends it,
1017
+ // once the pane is free and its history includes whatever the running
1018
+ // turn wrote. The preference only decides whether we WAIT for that turn
1019
+ // or cut it short.
1020
+ const key = queueKeyRef.current || (
1021
+ opts.agentSlug
1022
+ ? liveActivityKey(pid, opts.agentSlug)
1023
+ : threadActivityKey(pid, "web", new Date().toISOString().slice(0, 10))
1024
+ );
1025
+ bindQueue(key);
1026
+ writeBackgroundQueue(key, [
1027
+ ...readBackgroundQueue(key),
1028
+ { id: `q${++backgroundQueueSeq}`, text: trimmed, opts, msg: bubble() },
1029
+ ]);
1030
+ // Interrupt, by default: writing while an agent works almost always
1031
+ // means "no, stop, do this instead", which is what a new message has
1032
+ // always done on Telegram. Whatever the turn had written stays in the
1033
+ // thread, so this lands as a redirection of work in progress rather
1034
+ // than a fresh start.
1035
+ if (!queueOnSendRef.current) void stopTurn();
1036
+ return;
1037
+ }
1038
+
1039
+ if (!queueKeyRef.current) {
1040
+ bindQueue(
1041
+ opts.agentSlug
1042
+ ? liveActivityKey(pid, opts.agentSlug)
1043
+ : threadActivityKey(pid, "web", new Date().toISOString().slice(0, 10)),
1044
+ );
1045
+ }
1046
+
1047
+ const history: ConversationMessage[] = msgsRef.current.map((m) => ({
357
1048
  role: m.role,
358
1049
  content: historyTextOf(m),
359
1050
  }));
360
1051
 
361
- setMsgs((curr) => [
1052
+ updateMsgs((curr) => [
362
1053
  ...curr,
363
- { role: "user", parts: userPart(trimmed), ts: nowIso() },
364
- { role: "assistant", parts: [], ts: nowIso(), pending: true },
1054
+ bubble(),
1055
+ { role: "assistant", parts: [], ts: nowIso(), pending: true, local: true },
365
1056
  ]);
366
- setStreaming(true);
1057
+ updateStreaming(true);
1058
+ // This request can outlive the selected pane. Keep its paint work tied
1059
+ // to the view it started from; navigation must never paste old tokens or
1060
+ // a typing state into another agent's history.
1061
+ const streamViewEpoch = viewEpochRef.current;
1062
+ const ownsView = () => viewEpochRef.current === streamViewEpoch;
367
1063
 
368
- // ── Project agent: blocking call, single text part, no tools. ──────────
1064
+ // ── Project agent: streamed NDJSON, token-by-token like the super-agent.
1065
+ // It used to be a single blocking call, which meant no "typing", no
1066
+ // word-by-word — the pane sat blank until the whole reply arrived at once,
1067
+ // and a slow model read as "nothing happened", so turns got re-sent. ─────
369
1068
  if (opts.agentSlug) {
1069
+ const slug = opts.agentSlug;
1070
+ const ctrl = new AbortController();
1071
+ abortRef.current = ctrl;
1072
+ // Best guess until the turn's own `start` event names the conversation
1073
+ // — which it does before any work, so this only covers the microseconds
1074
+ // in between, and the first turn of a brand-new chat has nothing to name.
1075
+ turnTargetRef.current = convoRef.current ? { conversation_id: convoRef.current } : null;
370
1076
  try {
371
- const out = await Agents.chat(pid, opts.agentSlug, {
372
- prompt: trimmed,
373
- conversation_id: convoRef.current,
374
- model: opts.model || undefined,
375
- channel: "web",
376
- });
377
- convoRef.current = out.conversation_id;
378
- setConversationId(out.conversation_id);
379
- patchLast((m) => ({
380
- ...m,
381
- pending: false,
382
- model: out.engine,
383
- agent: opts.agentSlug,
384
- agentId: opts.agentSlug,
385
- usage: out.usage,
386
- parts: [{ kind: "text", text: out.text }],
387
- }));
1077
+ await Agents.chatStream(
1078
+ pid,
1079
+ slug,
1080
+ {
1081
+ prompt: trimmed,
1082
+ conversation_id: convoRef.current,
1083
+ model: opts.model || undefined,
1084
+ channel: "web",
1085
+ // Paths only, same as the super-agent turn below: the bytes are on
1086
+ // disk and the daemon re-resolves each one inside ~/.apx/media. An
1087
+ // engine with vision renders images; one without still gets a marker
1088
+ // in the prompt naming the file and its path.
1089
+ ...(opts.attachments?.length
1090
+ ? { attachments: opts.attachments.map((a) => ({ path: a.path, name: a.name })) }
1091
+ : {}),
1092
+ },
1093
+ (ev) => {
1094
+ if (!ownsView()) return;
1095
+ // Bind this pane (and any later regenerate/edit rewind) to the file
1096
+ // the daemon appended to — it only names it on the closing event.
1097
+ if (ev.type === "final" && ev.result?.conversation_id) {
1098
+ convoRef.current = ev.result.conversation_id;
1099
+ setConversationId(ev.result.conversation_id);
1100
+ }
1101
+ // The face: a project agent's turns are all its own, so stamp the
1102
+ // slug the reducer's final event doesn't know to set as the id.
1103
+ patchLast((m) => (m.agentId ? m : { ...m, agentId: slug }));
1104
+ applyEvent(ev);
1105
+ },
1106
+ ctrl.signal,
1107
+ );
1108
+ if (ownsView()) patchLast((m) => ({ ...m, pending: false }));
388
1109
  } catch (e) {
389
- onError?.((e as Error)?.message || t("shared_ui.err_chat_failed"));
390
- setMsgs((curr) => curr.filter((_, i) => i !== curr.length - 1));
1110
+ if (ctrl.signal.aborted && ownsView()) {
1111
+ patchLast((m) => ({
1112
+ ...m,
1113
+ pending: false,
1114
+ parts: [...m.parts, { kind: "text", text: t("code_module.stopped") }],
1115
+ }));
1116
+ } else if (ownsView()) {
1117
+ onError?.((e as Error)?.message || t("shared_ui.err_chat_failed"));
1118
+ updateMsgs((curr) => curr.filter((_, i) => i !== curr.length - 1));
1119
+ }
391
1120
  } finally {
392
- setStreaming(false);
1121
+ if (ownsView()) {
1122
+ updateStreaming(false);
1123
+ abortRef.current = null;
1124
+ turnTargetRef.current = null;
1125
+ queueMicrotask(() => drainQueueRef.current());
1126
+ }
393
1127
  }
394
1128
  return;
395
1129
  }
@@ -397,78 +1131,221 @@ export function useChat(pid: string, onError?: (msg: string) => void): UseChatRe
397
1131
  // ── Roby (super-agent): NDJSON event stream with tools. ────────────────
398
1132
  const ctrl = new AbortController();
399
1133
  abortRef.current = ctrl;
1134
+ // Roby's thread IS the channel — it has no conversation id — so that is
1135
+ // how the daemon addresses its live turn. See superAgentTurnKey.
1136
+ turnTargetRef.current = { channel: "web" };
400
1137
  try {
401
1138
  await SuperAgent.stream(
402
1139
  pid,
403
- { prompt: trimmed, previousMessages: history, model: opts.model || undefined, channel: "web" },
404
- applyEvent,
1140
+ {
1141
+ prompt: trimmed,
1142
+ previousMessages: history,
1143
+ model: opts.model || undefined,
1144
+ channel: "web",
1145
+ // Paths only: the bytes are already on disk, and the daemon re-resolves
1146
+ // each one inside ~/.apx/media before it reads a single byte.
1147
+ ...(opts.attachments?.length
1148
+ ? { attachments: opts.attachments.map((a) => ({ path: a.path, name: a.name })) }
1149
+ : {}),
1150
+ },
1151
+ (ev) => { if (ownsView()) applyEvent(ev); },
405
1152
  ctrl.signal,
406
1153
  );
407
- patchLast((m) => ({ ...m, pending: false }));
1154
+ if (ownsView()) patchLast((m) => ({ ...m, pending: false }));
408
1155
  } catch (e) {
409
- if (ctrl.signal.aborted) {
1156
+ if (ctrl.signal.aborted && ownsView()) {
410
1157
  patchLast((m) => ({
411
1158
  ...m,
412
1159
  pending: false,
413
1160
  parts: [...m.parts, { kind: "text", text: t("code_module.stopped") }],
414
1161
  }));
415
- } else {
1162
+ } else if (ownsView()) {
416
1163
  onError?.((e as Error)?.message || t("shared_ui.err_stream_failed"));
417
- setMsgs((curr) => curr.filter((_, i) => i !== curr.length - 1));
1164
+ updateMsgs((curr) => curr.filter((_, i) => i !== curr.length - 1));
418
1165
  }
419
1166
  } finally {
420
- setStreaming(false);
421
- abortRef.current = null;
1167
+ if (ownsView()) {
1168
+ updateStreaming(false);
1169
+ abortRef.current = null;
1170
+ turnTargetRef.current = null;
1171
+ queueMicrotask(() => drainQueueRef.current());
1172
+ }
422
1173
  }
423
1174
  },
424
- [pid, msgs, streaming, applyEvent, patchLast, onError],
1175
+ [pid, applyEvent, patchLast, onError, stopTurn, bindQueue, updateMsgs, updateStreaming],
425
1176
  );
426
1177
 
427
- const stop = useCallback(() => abortRef.current?.abort(), []);
428
- const clear = useCallback(() => {
429
- if (streaming) return;
1178
+ // Rewind the pane (and the bound file) to `keepVisible` turns, then send. The
1179
+ // file rewind matters for a project agent: the daemon rebuilds its history
1180
+ // from the conversation file, so without it the dropped turns would still be
1181
+ // there and the "regenerated" answer would see them. send() appends its bubble
1182
+ // to whatever msgs is after this slice, and — for a project agent — reads
1183
+ // history from the file, not its (stale) msgs closure, so the order is right.
1184
+ const rewindAndSend = useCallback(
1185
+ async (keepVisible: number, text: string, opts: SendOptions) => {
1186
+ if (streaming) return;
1187
+ if (convoRef.current && opts.agentSlug) {
1188
+ try {
1189
+ await Conversations.truncate(pid, opts.agentSlug, convoRef.current, keepVisible);
1190
+ } catch (e) {
1191
+ onError?.((e as Error)?.message || t("shared_ui.err_chat_failed"));
1192
+ return;
1193
+ }
1194
+ }
1195
+ updateMsgs((curr) => curr.slice(0, keepVisible));
1196
+ await send(text, opts);
1197
+ },
1198
+ [pid, streaming, send, onError, updateMsgs],
1199
+ );
1200
+
1201
+ const regenerate = useCallback(
1202
+ async (index: number, opts: SendOptions = {}) => {
1203
+ const target = msgs[index];
1204
+ if (!target || target.role !== "assistant") return;
1205
+ // The user turn that produced it: the nearest user message before it.
1206
+ let u = index - 1;
1207
+ while (u >= 0 && msgs[u].role !== "user") u--;
1208
+ if (u < 0) return;
1209
+ // Keep the turns before that user turn; re-send its text (+ any files) so
1210
+ // it (and a fresh answer) are appended again. Strip markers when files
1211
+ // ride along — send() / the daemon add them back.
1212
+ const userMsg = msgs[u];
1213
+ const files = attachmentsOf(userMsg);
1214
+ const raw = historyTextOf(userMsg);
1215
+ const text = files.length ? stripLeadingMarkers(raw, files.length) : raw;
1216
+ await rewindAndSend(u, text, {
1217
+ ...opts,
1218
+ ...(files.length ? { attachments: files } : {}),
1219
+ });
1220
+ },
1221
+ [msgs, rewindAndSend],
1222
+ );
1223
+
1224
+ const editAndResend = useCallback(
1225
+ async (index: number, text: string, opts: SendOptions = {}) => {
1226
+ const target = msgs[index];
1227
+ if (!target || target.role !== "user") return;
1228
+ // Keep the photo/file that was on the edited turn — only the caption
1229
+ // changes. Dropping media here is what made "edit text with photo" wipe
1230
+ // the image until a hard reload couldn't bring it back either.
1231
+ const files = attachmentsOf(target);
1232
+ await rewindAndSend(index, text, {
1233
+ ...opts,
1234
+ ...(files.length ? { attachments: files } : {}),
1235
+ });
1236
+ },
1237
+ [msgs, rewindAndSend],
1238
+ );
1239
+
1240
+ // Stop ends the turn being written; it does not cancel what you queued. That
1241
+ // pairing is the point of the two buttons — "this answer is going the wrong
1242
+ // way, here is what I actually meant": send the correction, stop the run, and
1243
+ // the correction leaves immediately instead of after the wrong answer.
1244
+ const stop = useCallback(() => { void stopTurn(); }, [stopTurn]);
1245
+ const unqueue = useCallback((id: string) => {
1246
+ const key = queueKeyRef.current;
1247
+ if (!key) return;
1248
+ writeBackgroundQueue(key, readBackgroundQueue(key).filter((q) => q.id !== id));
1249
+ }, []);
1250
+
1251
+ // Drain: one at a time, as soon as the pane is free.
1252
+ // msgsRef is synchronous, so the worker can drain after its pane unmounts and
1253
+ // still include the answer that just finished in the next turn's history.
1254
+ const sendRef = useRef(send);
1255
+ sendRef.current = send;
1256
+ const drainQueue = useCallback(() => {
1257
+ if (streamingRef.current || followingRef.current) return;
1258
+ const key = queueKeyRef.current;
1259
+ if (!key) return;
1260
+ const next = takeBackgroundQueue(key);
1261
+ if (next) void sendRef.current(next.text, next.opts);
1262
+ }, []);
1263
+ drainQueueRef.current = drainQueue;
1264
+ useEffect(() => {
1265
+ if (!streaming && !following && queued.length) drainQueue();
1266
+ }, [streaming, following, queued.length, drainQueue]);
1267
+
1268
+ const clear = useCallback((queueKey?: string) => {
430
1269
  loadSeqRef.current++; // cancel any in-flight history load
1270
+ beginViewChange();
431
1271
  convoRef.current = undefined;
1272
+ threadRef.current = null;
432
1273
  setConversationId(undefined);
433
- setMsgs([]);
434
- }, [streaming]);
1274
+ setConversationMeta(undefined);
1275
+ updateFollowing(false);
1276
+ turnTargetRef.current = null;
1277
+ if (queueKey) bindQueue(queueKey);
1278
+ else setQueued([]);
1279
+ updateMsgs([]);
1280
+ }, [beginViewChange, bindQueue, updateFollowing, updateMsgs]);
435
1281
 
436
1282
  const load = useCallback(
437
- async (agentSlug: string, conversationId: string) => {
438
- if (streaming) return;
1283
+ async (agentSlug: string, conversationId: string, opts?: ReloadOptions) => {
439
1284
  const seq = ++loadSeqRef.current;
1285
+ const activityKey = conversationActivityKey(pid, conversationId);
1286
+ if (!opts?.silent) {
1287
+ beginViewChange();
1288
+ bindQueue(activityKey);
1289
+ }
440
1290
  // Blank the pane up front so it never shows the previous chat under the
441
- // new header while the fetch is in flight.
442
- setMsgs([]);
1291
+ // new header while the fetch is in flight. A silent re-read is the SAME
1292
+ // chat catching up, so blanking would be a flash of empty for nothing —
1293
+ // and for the same reason it keeps the queue: what you queued belongs to
1294
+ // this thread, and only actually LEAVING it drops it.
1295
+ if (!opts?.silent) updateMsgs([]);
443
1296
  try {
444
1297
  const detail = await Conversations.get(pid, agentSlug, conversationId);
445
1298
  if (seq !== loadSeqRef.current) return; // superseded by a newer pick
446
- const loaded: ChatMsg[] = (detail.messages ?? [])
447
- .filter((m) => m.role === "user" || m.role === "assistant")
448
- .map((m) => ({
449
- role: m.role as "user" | "assistant",
450
- parts: [{ kind: "text", text: m.content }],
451
- ts: m.ts || new Date().toISOString(),
452
- }));
1299
+ const loaded = threadToChatMsgs(detail.messages ?? []);
453
1300
  convoRef.current = conversationId;
1301
+ threadRef.current = null;
454
1302
  setConversationId(conversationId);
455
- setMsgs(loaded);
1303
+ setConversationMeta(metaFromDetail(detail));
1304
+ // Opening a chat whose answer is still being written: show the partial
1305
+ // as a streaming bubble and let the live "turn" frames carry on filling
1306
+ // it — the whole point of the push feed. Only on a real open; a silent
1307
+ // refetch leaves the live bubble the frames are already maintaining.
1308
+ const active = !opts?.silent && !isChatTurnClosed(detail.active_turn?.turn_id)
1309
+ ? detail.active_turn
1310
+ : null;
1311
+ if (active) {
1312
+ liveTurnRef.current = { id: active.turn_id, text: active.text || "" };
1313
+ updateFollowing(true);
1314
+ turnTargetRef.current = { conversation_id: conversationId };
1315
+ } else if (!opts?.silent) {
1316
+ liveTurnRef.current = null;
1317
+ updateFollowing(false);
1318
+ turnTargetRef.current = null;
1319
+ }
1320
+ updateMsgs((curr) =>
1321
+ opts?.silent ? mergeLocalTurns(loaded, curr) : withActiveTurn(loaded, active),
1322
+ );
456
1323
  } catch (e) {
457
1324
  if (seq !== loadSeqRef.current) return;
1325
+ // A background refresh that failed is a refresh that did not happen:
1326
+ // keep showing the conversation and stay quiet. Only a refresh the user
1327
+ // asked for by picking a chat reports, and clears.
1328
+ if (opts?.silent) return;
458
1329
  convoRef.current = undefined;
1330
+ threadRef.current = null;
459
1331
  setConversationId(undefined);
460
- setMsgs([]);
1332
+ setConversationMeta(undefined);
1333
+ updateFollowing(false);
1334
+ updateMsgs([]);
461
1335
  onError?.((e as Error)?.message || t("shared_ui.err_load_conversation"));
462
1336
  }
463
1337
  },
464
- [pid, streaming, onError],
1338
+ [pid, onError, beginViewChange, bindQueue, updateFollowing, updateMsgs],
465
1339
  );
466
1340
 
467
1341
  const loadThread = useCallback(
468
- async (channel: string, threadId: string) => {
469
- if (streaming) return;
1342
+ async (channel: string, threadId: string, opts?: ReloadOptions) => {
470
1343
  const seq = ++loadSeqRef.current;
471
- setMsgs([]);
1344
+ if (!opts?.silent) {
1345
+ beginViewChange();
1346
+ bindQueue(threadActivityKey(pid, channel, threadId));
1347
+ updateMsgs([]);
1348
+ }
472
1349
  try {
473
1350
  const detail = await Conversations.thread(pid, channel, threadId);
474
1351
  if (seq !== loadSeqRef.current) return; // superseded by a newer pick
@@ -476,18 +1353,148 @@ export function useChat(pid: string, onError?: (msg: string) => void): UseChatRe
476
1353
  // Ledger threads have no conversation file — sends continue as fresh
477
1354
  // web turns with this history as previousMessages.
478
1355
  convoRef.current = undefined;
1356
+ threadRef.current = { channel, id: threadId };
479
1357
  setConversationId(undefined);
480
- setMsgs(loaded);
1358
+ // A thread knows its own name and channel, same as a conversation file
1359
+ // does — the header should not have to be handed them by whichever list
1360
+ // happened to open it.
1361
+ setConversationMeta({
1362
+ channel: detail.channel,
1363
+ title: detail.title,
1364
+ participants: detail.participants,
1365
+ faces: detail.participant_faces,
1366
+ });
1367
+ const active = !opts?.silent && !isChatTurnClosed(detail.active_turn?.turn_id)
1368
+ ? detail.active_turn
1369
+ : null;
1370
+ if (active) {
1371
+ liveTurnRef.current = { id: active.turn_id, text: active.text || "" };
1372
+ updateFollowing(true);
1373
+ turnTargetRef.current = { channel };
1374
+ } else if (!opts?.silent) {
1375
+ liveTurnRef.current = null;
1376
+ updateFollowing(false);
1377
+ turnTargetRef.current = null;
1378
+ }
1379
+ updateMsgs((curr) => (
1380
+ opts?.silent
1381
+ ? mergeLocalTurns(loaded, curr)
1382
+ : withActiveTurn(loaded, active)
1383
+ ));
481
1384
  } catch (e) {
482
1385
  if (seq !== loadSeqRef.current) return;
1386
+ if (opts?.silent) return; // see load(): a failed catch-up changes nothing
483
1387
  convoRef.current = undefined;
1388
+ threadRef.current = null;
484
1389
  setConversationId(undefined);
485
- setMsgs([]);
1390
+ updateFollowing(false);
1391
+ updateMsgs([]);
486
1392
  onError?.((e as Error)?.message || t("shared_ui.err_load_conversation"));
487
1393
  }
488
1394
  },
489
- [pid, streaming, onError],
1395
+ [pid, onError, beginViewChange, bindQueue, updateFollowing, updateMsgs],
1396
+ );
1397
+
1398
+ // A group turn streams like a 1:1 one — same pending-bubble machinery — but the
1399
+ // reply comes as a CASCADE of speakers on the group endpoint. Each speaker gets
1400
+ // its own streaming assistant bubble (name + "traído por X"), tokens land as
1401
+ // they are written, and the "… está escribiendo" pill rides on the pending
1402
+ // bubble exactly where a normal turn's does. `rerun` resumes from one speaker
1403
+ // against the last owner message (regenerate) instead of appending a new one.
1404
+ const sendGroup = useCallback(
1405
+ async (gid: string, text: string, nameOf: (slug: string) => string, opts: { rerun?: boolean; from?: string; reason?: string | null; media?: UploadedMedia[] } = {}) => {
1406
+ const trimmed = text.trim();
1407
+ const files = opts.media || [];
1408
+ if (streaming) return;
1409
+ if (!opts.rerun && !trimmed && !files.length) return;
1410
+ const nowIso = () => new Date().toISOString();
1411
+ if (!opts.rerun) {
1412
+ // Optimistic owner bubble — shows the photo/file immediately, same as a
1413
+ // 1:1 turn; the marker rides on the text the way the daemon folds it.
1414
+ updateMsgs((curr) => [...curr, {
1415
+ role: "user",
1416
+ parts: userPart([markersFor(files), trimmed].filter(Boolean).join(" ")),
1417
+ ts: nowIso(), local: true,
1418
+ ...(files.length ? { media: files.map(mediaOf) } : {}),
1419
+ }]);
1420
+ }
1421
+ updateStreaming(true);
1422
+ const ctrl = new AbortController();
1423
+ abortRef.current = ctrl;
1424
+ // How the DAEMON addresses this cascade, set before the first byte: a room
1425
+ // is a thread on channel "group", and a project runs several at once, so
1426
+ // the id has to ride along. Without it Stop only closed this socket while
1427
+ // the room kept working — the one turn shape that can run ten tool loops
1428
+ // off a single line was the one that could not be interrupted.
1429
+ turnTargetRef.current = { channel: "group", thread_id: gid };
1430
+ const onEvent = (ev: import("../lib/api/groups").GroupStreamEvent) => {
1431
+ if (ev.type === "speaker_start") {
1432
+ updateMsgs((curr) => [...curr, {
1433
+ role: "assistant", parts: [], ts: nowIso(), pending: true, local: true,
1434
+ agentId: ev.slug, agent: nameOf(ev.slug),
1435
+ ...(ev.reason ? { reason: ev.reason } : {}),
1436
+ }]);
1437
+ } else if (ev.type === "speaker_delta") {
1438
+ // Reuse the exact token-merge the 1:1 stream uses.
1439
+ applyEvent({ type: "assistant_delta", delta: ev.delta } as ChatStreamEvent);
1440
+ } else if (
1441
+ ev.type === "tool_start" ||
1442
+ ev.type === "tool_result" ||
1443
+ ev.type === "tool_deduped" ||
1444
+ ev.type === "assistant_text" ||
1445
+ ev.type === "model_start" ||
1446
+ ev.type === "model_routed" ||
1447
+ ev.type === "engine_failed" ||
1448
+ ev.type === "model_retry" ||
1449
+ ev.type === "tools_suppressed" ||
1450
+ ev.type === "skill_inspector" ||
1451
+ ev.type === "reasoning"
1452
+ ) {
1453
+ applyEvent(ev as ChatStreamEvent);
1454
+ } else if (ev.type === "speaker_final") {
1455
+ patchLast((m) => ({ ...m, pending: false, ...(ev.model ? { model: ev.model } : {}), ...(ev.usage ? { usage: ev.usage } : {}) }));
1456
+ } else if (ev.type === "speaker_aborted") {
1457
+ // You stopped the room mid-reply. Whatever this speaker had written is
1458
+ // real work you watched happen and the daemon kept it in the thread —
1459
+ // mark it the way a stopped 1:1 turn is marked, not as a failure.
1460
+ patchLast((m) => ({ ...m, pending: false, parts: [...m.parts, { kind: "text", text: t("code_module.stopped") }] }));
1461
+ }
1462
+ };
1463
+ try {
1464
+ if (opts.rerun) await Groups.rerunStream(pid, gid, onEvent, ctrl.signal,
1465
+ opts.from ? { from: opts.from, reason: opts.reason } : undefined);
1466
+ else await Groups.sendStream(pid, gid, trimmed, onEvent, ctrl.signal,
1467
+ files.map((a) => ({ path: a.path, name: a.name })));
1468
+ patchLast((m) => ({ ...m, pending: false }));
1469
+ } catch (e) {
1470
+ if (ctrl.signal.aborted) patchLast((m) => ({ ...m, pending: false }));
1471
+ else onError?.((e as Error)?.message || t("shared_ui.err_chat_failed"));
1472
+ } finally {
1473
+ updateStreaming(false);
1474
+ abortRef.current = null;
1475
+ turnTargetRef.current = null;
1476
+ // Reconcile with the canonical thread (model/usage/reason from the ledger,
1477
+ // join/leave notices, and drops the local bubbles for the persisted ones).
1478
+ void loadThread("group", gid, { silent: true });
1479
+ }
1480
+ },
1481
+ [pid, streaming, applyEvent, patchLast, loadThread, onError, updateMsgs, updateStreaming],
490
1482
  );
491
1483
 
492
- return { msgs, send, stop, clear, load, loadThread, streaming, conversationId };
1484
+ return {
1485
+ msgs,
1486
+ send,
1487
+ sendGroup,
1488
+ regenerate,
1489
+ editAndResend,
1490
+ stop,
1491
+ clear,
1492
+ load,
1493
+ loadThread,
1494
+ streaming: streaming || following,
1495
+ queued,
1496
+ unqueue,
1497
+ conversationId,
1498
+ conversationMeta,
1499
+ };
493
1500
  }