funolio-agent 1.0.53 → 1.1.65

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 (276) hide show
  1. package/dist/approval.d.ts +1 -6
  2. package/dist/approval.d.ts.map +1 -1
  3. package/dist/approval.js +2 -7
  4. package/dist/approval.js.map +1 -1
  5. package/dist/auth/credential-reader.d.ts.map +1 -1
  6. package/dist/auth/credential-reader.js +4 -3
  7. package/dist/auth/credential-reader.js.map +1 -1
  8. package/dist/auth/token-refresh.d.ts +8 -0
  9. package/dist/auth/token-refresh.d.ts.map +1 -1
  10. package/dist/auth/token-refresh.js +82 -52
  11. package/dist/auth/token-refresh.js.map +1 -1
  12. package/dist/auto-organizer.d.ts.map +1 -1
  13. package/dist/auto-organizer.js +6 -7
  14. package/dist/auto-organizer.js.map +1 -1
  15. package/dist/bench-prefix.d.ts +16 -0
  16. package/dist/bench-prefix.d.ts.map +1 -0
  17. package/dist/bench-prefix.js +25 -0
  18. package/dist/bench-prefix.js.map +1 -0
  19. package/dist/bot-manager.d.ts +5 -1
  20. package/dist/bot-manager.d.ts.map +1 -1
  21. package/dist/bot-manager.js +46 -27
  22. package/dist/bot-manager.js.map +1 -1
  23. package/dist/chat-sync.d.ts +42 -0
  24. package/dist/chat-sync.d.ts.map +1 -0
  25. package/dist/chat-sync.js +95 -0
  26. package/dist/chat-sync.js.map +1 -0
  27. package/dist/clerk-model.d.ts +7 -0
  28. package/dist/clerk-model.d.ts.map +1 -1
  29. package/dist/clerk-model.js +42 -8
  30. package/dist/clerk-model.js.map +1 -1
  31. package/dist/cli-bootstrap-history.d.ts +10 -0
  32. package/dist/cli-bootstrap-history.d.ts.map +1 -0
  33. package/dist/cli-bootstrap-history.js +112 -0
  34. package/dist/cli-bootstrap-history.js.map +1 -0
  35. package/dist/cli-models.d.ts +8 -0
  36. package/dist/cli-models.d.ts.map +1 -0
  37. package/dist/cli-models.js +91 -0
  38. package/dist/cli-models.js.map +1 -0
  39. package/dist/cli-session-epoch.d.ts +13 -3
  40. package/dist/cli-session-epoch.d.ts.map +1 -1
  41. package/dist/cli-session-epoch.js +53 -4
  42. package/dist/cli-session-epoch.js.map +1 -1
  43. package/dist/cli-session-registry.d.ts +35 -0
  44. package/dist/cli-session-registry.d.ts.map +1 -0
  45. package/dist/cli-session-registry.js +177 -0
  46. package/dist/cli-session-registry.js.map +1 -0
  47. package/dist/cli.js +62 -0
  48. package/dist/cli.js.map +1 -1
  49. package/dist/codex-app-server-manager.d.ts +189 -0
  50. package/dist/codex-app-server-manager.d.ts.map +1 -0
  51. package/dist/codex-app-server-manager.js +1468 -0
  52. package/dist/codex-app-server-manager.js.map +1 -0
  53. package/dist/commands/init.d.ts.map +1 -1
  54. package/dist/commands/init.js +8 -30
  55. package/dist/commands/init.js.map +1 -1
  56. package/dist/commands/pool.d.ts +32 -0
  57. package/dist/commands/pool.d.ts.map +1 -1
  58. package/dist/commands/pool.js +145 -66
  59. package/dist/commands/pool.js.map +1 -1
  60. package/dist/commands/setup.d.ts +4 -1
  61. package/dist/commands/setup.d.ts.map +1 -1
  62. package/dist/commands/setup.js +9 -25
  63. package/dist/commands/setup.js.map +1 -1
  64. package/dist/commands/start.d.ts +21 -0
  65. package/dist/commands/start.d.ts.map +1 -1
  66. package/dist/commands/start.js +559 -63
  67. package/dist/commands/start.js.map +1 -1
  68. package/dist/commands/status.d.ts.map +1 -1
  69. package/dist/commands/status.js +5 -2
  70. package/dist/commands/status.js.map +1 -1
  71. package/dist/completion-marker.d.ts +7 -0
  72. package/dist/completion-marker.d.ts.map +1 -0
  73. package/dist/completion-marker.js +28 -0
  74. package/dist/completion-marker.js.map +1 -0
  75. package/dist/config.d.ts +7 -2
  76. package/dist/config.d.ts.map +1 -1
  77. package/dist/config.js +184 -60
  78. package/dist/config.js.map +1 -1
  79. package/dist/context-window.d.ts +37 -1
  80. package/dist/context-window.d.ts.map +1 -1
  81. package/dist/context-window.js +210 -17
  82. package/dist/context-window.js.map +1 -1
  83. package/dist/live-activity.d.ts +31 -0
  84. package/dist/live-activity.d.ts.map +1 -0
  85. package/dist/live-activity.js +36 -0
  86. package/dist/live-activity.js.map +1 -0
  87. package/dist/local-chat-execution.d.ts +114 -0
  88. package/dist/local-chat-execution.d.ts.map +1 -0
  89. package/dist/local-chat-execution.js +349 -0
  90. package/dist/local-chat-execution.js.map +1 -0
  91. package/dist/local-cli-pty-manager.d.ts +186 -0
  92. package/dist/local-cli-pty-manager.d.ts.map +1 -1
  93. package/dist/local-cli-pty-manager.js +2581 -164
  94. package/dist/local-cli-pty-manager.js.map +1 -1
  95. package/dist/local-conversation-gateway.d.ts +110 -0
  96. package/dist/local-conversation-gateway.d.ts.map +1 -0
  97. package/dist/local-conversation-gateway.js +175 -0
  98. package/dist/local-conversation-gateway.js.map +1 -0
  99. package/dist/local-data.d.ts +276 -5
  100. package/dist/local-data.d.ts.map +1 -1
  101. package/dist/local-data.js +1201 -86
  102. package/dist/local-data.js.map +1 -1
  103. package/dist/local-db.d.ts +6 -0
  104. package/dist/local-db.d.ts.map +1 -1
  105. package/dist/local-db.js +428 -2
  106. package/dist/local-db.js.map +1 -1
  107. package/dist/local-funnel.d.ts.map +1 -1
  108. package/dist/local-funnel.js +6 -5
  109. package/dist/local-funnel.js.map +1 -1
  110. package/dist/local-server.d.ts +55 -0
  111. package/dist/local-server.d.ts.map +1 -1
  112. package/dist/local-server.js +3281 -441
  113. package/dist/local-server.js.map +1 -1
  114. package/dist/managed-process-registry.d.ts +59 -0
  115. package/dist/managed-process-registry.d.ts.map +1 -0
  116. package/dist/managed-process-registry.js +390 -0
  117. package/dist/managed-process-registry.js.map +1 -0
  118. package/dist/mcp/claude-config-writer.d.ts +5 -5
  119. package/dist/mcp/claude-config-writer.d.ts.map +1 -1
  120. package/dist/mcp/claude-config-writer.js +19 -11
  121. package/dist/mcp/claude-config-writer.js.map +1 -1
  122. package/dist/mcp/index.d.ts +4 -2
  123. package/dist/mcp/index.d.ts.map +1 -1
  124. package/dist/mcp/index.js.map +1 -1
  125. package/dist/mcp/sync-cli-config.d.ts +42 -4
  126. package/dist/mcp/sync-cli-config.d.ts.map +1 -1
  127. package/dist/mcp/sync-cli-config.js +497 -17
  128. package/dist/mcp/sync-cli-config.js.map +1 -1
  129. package/dist/message-loop.d.ts +6 -0
  130. package/dist/message-loop.d.ts.map +1 -1
  131. package/dist/message-loop.js +281 -89
  132. package/dist/message-loop.js.map +1 -1
  133. package/dist/mqtt-client.d.ts +44 -1
  134. package/dist/mqtt-client.d.ts.map +1 -1
  135. package/dist/mqtt-client.js +284 -46
  136. package/dist/mqtt-client.js.map +1 -1
  137. package/dist/mqtt-data-relay.d.ts +44 -0
  138. package/dist/mqtt-data-relay.d.ts.map +1 -0
  139. package/dist/mqtt-data-relay.js +106 -0
  140. package/dist/mqtt-data-relay.js.map +1 -0
  141. package/dist/oauth.d.ts.map +1 -1
  142. package/dist/oauth.js +69 -29
  143. package/dist/oauth.js.map +1 -1
  144. package/dist/orchestration/capabilities.d.ts +13 -0
  145. package/dist/orchestration/capabilities.d.ts.map +1 -0
  146. package/dist/orchestration/capabilities.js +152 -0
  147. package/dist/orchestration/capabilities.js.map +1 -0
  148. package/dist/orchestration/dispatch-executor.d.ts +83 -0
  149. package/dist/orchestration/dispatch-executor.d.ts.map +1 -0
  150. package/dist/orchestration/dispatch-executor.js +266 -0
  151. package/dist/orchestration/dispatch-executor.js.map +1 -0
  152. package/dist/orchestration/dispatch-hint.d.ts +134 -0
  153. package/dist/orchestration/dispatch-hint.d.ts.map +1 -0
  154. package/dist/orchestration/dispatch-hint.js +247 -0
  155. package/dist/orchestration/dispatch-hint.js.map +1 -0
  156. package/dist/orchestration/dispatch-runner.d.ts +106 -0
  157. package/dist/orchestration/dispatch-runner.d.ts.map +1 -0
  158. package/dist/orchestration/dispatch-runner.js +604 -0
  159. package/dist/orchestration/dispatch-runner.js.map +1 -0
  160. package/dist/orchestration/dispatch-tools.d.ts +167 -0
  161. package/dist/orchestration/dispatch-tools.d.ts.map +1 -0
  162. package/dist/orchestration/dispatch-tools.js +328 -0
  163. package/dist/orchestration/dispatch-tools.js.map +1 -0
  164. package/dist/orchestration/front-door-policy.d.ts +35 -10
  165. package/dist/orchestration/front-door-policy.d.ts.map +1 -1
  166. package/dist/orchestration/front-door-policy.js +30 -267
  167. package/dist/orchestration/front-door-policy.js.map +1 -1
  168. package/dist/orchestration/orchestrator-dispatch-prompt.d.ts +43 -0
  169. package/dist/orchestration/orchestrator-dispatch-prompt.d.ts.map +1 -0
  170. package/dist/orchestration/orchestrator-dispatch-prompt.js +267 -0
  171. package/dist/orchestration/orchestrator-dispatch-prompt.js.map +1 -0
  172. package/dist/orchestration/orchestrator-operating-prompt.d.ts +15 -0
  173. package/dist/orchestration/orchestrator-operating-prompt.d.ts.map +1 -1
  174. package/dist/orchestration/orchestrator-operating-prompt.js +206 -20
  175. package/dist/orchestration/orchestrator-operating-prompt.js.map +1 -1
  176. package/dist/orchestration/plan-import.d.ts +39 -0
  177. package/dist/orchestration/plan-import.d.ts.map +1 -0
  178. package/dist/orchestration/plan-import.js +547 -0
  179. package/dist/orchestration/plan-import.js.map +1 -0
  180. package/dist/orchestration/validation.d.ts +40 -0
  181. package/dist/orchestration/validation.d.ts.map +1 -0
  182. package/dist/orchestration/validation.js +203 -0
  183. package/dist/orchestration/validation.js.map +1 -0
  184. package/dist/orchestration/worker-operating-prompt.d.ts +2 -0
  185. package/dist/orchestration/worker-operating-prompt.d.ts.map +1 -1
  186. package/dist/orchestration/worker-operating-prompt.js +36 -46
  187. package/dist/orchestration/worker-operating-prompt.js.map +1 -1
  188. package/dist/orchestrator.d.ts +214 -33
  189. package/dist/orchestrator.d.ts.map +1 -1
  190. package/dist/orchestrator.js +2200 -1100
  191. package/dist/orchestrator.js.map +1 -1
  192. package/dist/providers/anthropic.d.ts.map +1 -1
  193. package/dist/providers/anthropic.js +8 -4
  194. package/dist/providers/anthropic.js.map +1 -1
  195. package/dist/providers/claude-cli-prompt.d.ts.map +1 -1
  196. package/dist/providers/claude-cli-prompt.js +49 -5
  197. package/dist/providers/claude-cli-prompt.js.map +1 -1
  198. package/dist/providers/claude-cli.d.ts.map +1 -1
  199. package/dist/providers/claude-cli.js +81 -5
  200. package/dist/providers/claude-cli.js.map +1 -1
  201. package/dist/providers/codex-cli.d.ts +10 -6
  202. package/dist/providers/codex-cli.d.ts.map +1 -1
  203. package/dist/providers/codex-cli.js +204 -26
  204. package/dist/providers/codex-cli.js.map +1 -1
  205. package/dist/providers/google.d.ts.map +1 -1
  206. package/dist/providers/google.js +15 -5
  207. package/dist/providers/google.js.map +1 -1
  208. package/dist/providers/index.d.ts +15 -1
  209. package/dist/providers/index.d.ts.map +1 -1
  210. package/dist/providers/index.js.map +1 -1
  211. package/dist/providers/openai.d.ts +1 -1
  212. package/dist/providers/openai.d.ts.map +1 -1
  213. package/dist/providers/openai.js +13 -5
  214. package/dist/providers/openai.js.map +1 -1
  215. package/dist/response-guard.js +1 -1
  216. package/dist/response-guard.js.map +1 -1
  217. package/dist/server-adapter.d.ts +8 -0
  218. package/dist/server-adapter.d.ts.map +1 -1
  219. package/dist/server-adapter.js +7 -0
  220. package/dist/server-adapter.js.map +1 -1
  221. package/dist/service-mode.d.ts +1 -1
  222. package/dist/service-mode.d.ts.map +1 -1
  223. package/dist/service-mode.js +64 -1
  224. package/dist/service-mode.js.map +1 -1
  225. package/dist/service-setup-only.d.ts +8 -0
  226. package/dist/service-setup-only.d.ts.map +1 -0
  227. package/dist/service-setup-only.js +37 -0
  228. package/dist/service-setup-only.js.map +1 -0
  229. package/dist/slash-commands.d.ts +21 -0
  230. package/dist/slash-commands.d.ts.map +1 -0
  231. package/dist/slash-commands.js +99 -0
  232. package/dist/slash-commands.js.map +1 -0
  233. package/dist/subagent/index.d.ts +4 -2
  234. package/dist/subagent/index.d.ts.map +1 -1
  235. package/dist/subagent/index.js.map +1 -1
  236. package/dist/summarization-pipeline.d.ts.map +1 -1
  237. package/dist/summarization-pipeline.js +1 -9
  238. package/dist/summarization-pipeline.js.map +1 -1
  239. package/dist/token-counter.d.ts.map +1 -1
  240. package/dist/token-counter.js +11 -4
  241. package/dist/token-counter.js.map +1 -1
  242. package/dist/tool-filter.d.ts.map +1 -1
  243. package/dist/tool-filter.js +10 -6
  244. package/dist/tool-filter.js.map +1 -1
  245. package/dist/tools/admin-tools.d.ts.map +1 -1
  246. package/dist/tools/admin-tools.js +20 -5
  247. package/dist/tools/admin-tools.js.map +1 -1
  248. package/dist/tools/index.d.ts.map +1 -1
  249. package/dist/tools/index.js +2 -1
  250. package/dist/tools/index.js.map +1 -1
  251. package/dist/tools/run-command.d.ts.map +1 -1
  252. package/dist/tools/run-command.js +5 -1
  253. package/dist/tools/run-command.js.map +1 -1
  254. package/dist/tools/search-conversation-history.d.ts +16 -0
  255. package/dist/tools/search-conversation-history.d.ts.map +1 -0
  256. package/dist/tools/search-conversation-history.js +334 -0
  257. package/dist/tools/search-conversation-history.js.map +1 -0
  258. package/dist/tools/todo-tasks.d.ts.map +1 -1
  259. package/dist/tools/todo-tasks.js +77 -5
  260. package/dist/tools/todo-tasks.js.map +1 -1
  261. package/dist/usage-log.d.ts +62 -0
  262. package/dist/usage-log.d.ts.map +1 -0
  263. package/dist/usage-log.js +98 -0
  264. package/dist/usage-log.js.map +1 -0
  265. package/dist/wizard-state.d.ts +20 -0
  266. package/dist/wizard-state.d.ts.map +1 -1
  267. package/dist/wizard-state.js +90 -3
  268. package/dist/wizard-state.js.map +1 -1
  269. package/dist/wizard-support.d.ts.map +1 -1
  270. package/dist/wizard-support.js +27 -1
  271. package/dist/wizard-support.js.map +1 -1
  272. package/dist/workflow-engine.d.ts +44 -2
  273. package/dist/workflow-engine.d.ts.map +1 -1
  274. package/dist/workflow-engine.js +932 -111
  275. package/dist/workflow-engine.js.map +1 -1
  276. package/package.json +2 -2
@@ -39,6 +39,12 @@ Object.defineProperty(exports, "__esModule", { value: true });
39
39
  exports.startLocalServer = startLocalServer;
40
40
  exports.stopLocalServer = stopLocalServer;
41
41
  exports.buildChatRuntimeForTest = buildChatRuntimeForTest;
42
+ exports.resolveDirectCliSessionTransportForTest = resolveDirectCliSessionTransportForTest;
43
+ exports.buildLocalDesktopDirectPromptForTest = buildLocalDesktopDirectPromptForTest;
44
+ exports.resolveLocalDesktopPromptContextForTest = resolveLocalDesktopPromptContextForTest;
45
+ exports.shouldSkipFreshCliBootstrapForTest = shouldSkipFreshCliBootstrapForTest;
46
+ exports.buildLocalRuntimeUserFacingErrorForTest = buildLocalRuntimeUserFacingErrorForTest;
47
+ exports.persistCliOauthSessionForTest = persistCliOauthSessionForTest;
42
48
  /**
43
49
  * Local HTTP server for desktop-first operation.
44
50
  *
@@ -47,21 +53,24 @@ exports.buildChatRuntimeForTest = buildChatRuntimeForTest;
47
53
  * Reuses existing LLM providers and tool execution from message-loop/providers.
48
54
  */
49
55
  const index_1 = require("./providers/index");
56
+ const completion_marker_1 = require("./completion-marker");
50
57
  const index_2 = require("./index");
51
58
  const approval_1 = require("./approval");
52
59
  const config_1 = require("./config");
60
+ const bench_prefix_1 = require("./bench-prefix");
61
+ const usage_log_1 = require("./usage-log");
53
62
  const data = __importStar(require("./local-data"));
54
63
  const local_import_worker_1 = require("./local-import-worker");
55
64
  const clerk_model_1 = require("./clerk-model");
56
65
  const workflow_engine_1 = require("./workflow-engine");
57
66
  const context_window_1 = require("./context-window");
67
+ const cli_bootstrap_history_1 = require("./cli-bootstrap-history");
58
68
  const summarization_pipeline_1 = require("./summarization-pipeline");
59
69
  const backfill_1 = require("./backfill");
60
70
  const config_cleanup_1 = require("./config-cleanup");
61
71
  const status_parser_1 = require("./orchestration/status-parser");
62
72
  const guided_actions_1 = require("./orchestration/guided-actions");
63
73
  const topic_normalizer_1 = require("./orchestration/topic-normalizer");
64
- const policy_prompt_1 = require("./orchestration/policy-prompt");
65
74
  const safeguards_1 = require("./orchestration/safeguards");
66
75
  const token_counter_1 = require("./token-counter");
67
76
  const response_guard_1 = require("./response-guard");
@@ -71,14 +80,23 @@ const registry_1 = require("./mcp/registry");
71
80
  const marketplace_1 = require("./mcp/marketplace");
72
81
  const claude_config_writer_1 = require("./mcp/claude-config-writer");
73
82
  const subscription_runtime_1 = require("./auth/subscription-runtime");
83
+ const credential_reader_1 = require("./auth/credential-reader");
84
+ const token_refresh_1 = require("./auth/token-refresh");
74
85
  const local_memory_search_1 = require("./local-memory-search");
75
86
  const local_funnel_1 = require("./local-funnel");
76
87
  const orchestrator_profile_1 = require("./orchestrator-profile");
88
+ const local_chat_execution_1 = require("./local-chat-execution");
89
+ const chat_sync_1 = require("./chat-sync");
77
90
  const policy_detection_1 = require("./policy-detection");
78
91
  const server_runtime_1 = require("./server-runtime");
79
92
  const storage_mode_1 = require("./storage-mode");
80
93
  const local_cli_pty_manager_1 = require("./local-cli-pty-manager");
94
+ const codex_app_server_manager_1 = require("./codex-app-server-manager");
95
+ const managed_process_registry_1 = require("./managed-process-registry");
81
96
  const cli_session_epoch_1 = require("./cli-session-epoch");
97
+ const cli_models_1 = require("./cli-models");
98
+ const slash_commands_1 = require("./slash-commands");
99
+ const sync_cli_config_1 = require("./mcp/sync-cli-config");
82
100
  const server_adapter_1 = require("./server-adapter");
83
101
  const wizard_support_1 = require("./wizard-support");
84
102
  const chalk_1 = __importDefault(require("chalk"));
@@ -96,6 +114,351 @@ function requireExpress() {
96
114
  throw new Error('express is not installed. Run: npm install express');
97
115
  }
98
116
  }
117
+ const MAX_INLINE_ATTACHMENT_BYTES = 10 * 1024 * 1024;
118
+ const MAX_PLAN_IMPORT_BYTES = 512 * 1024;
119
+ /**
120
+ * Normalize an incoming `orchestration_roles_json` payload into a clean
121
+ * string[] for createAgentProfile / updateAgentProfile. Accepts:
122
+ * - an array of strings (already in the target shape)
123
+ * - a JSON-string of an array (wizard/UI sometimes serializes early)
124
+ * - null / undefined → null (signals "clear the field")
125
+ * Anything else → null (defensive: never pass malformed data to the DB layer).
126
+ */
127
+ function normalizeRolesArray(input) {
128
+ if (input == null)
129
+ return null;
130
+ if (Array.isArray(input)) {
131
+ const cleaned = input.map((v) => String(v ?? '').trim()).filter(Boolean);
132
+ return cleaned.length > 0 ? cleaned : null;
133
+ }
134
+ if (typeof input === 'string') {
135
+ const trimmed = input.trim();
136
+ if (!trimmed)
137
+ return null;
138
+ try {
139
+ const parsed = JSON.parse(trimmed);
140
+ if (Array.isArray(parsed)) {
141
+ const cleaned = parsed.map((v) => String(v ?? '').trim()).filter(Boolean);
142
+ return cleaned.length > 0 ? cleaned : null;
143
+ }
144
+ }
145
+ catch {
146
+ // Not JSON — treat as a single role string for convenience.
147
+ return [trimmed];
148
+ }
149
+ }
150
+ return null;
151
+ }
152
+ function parseChatAttachmentInputs(raw) {
153
+ if (!Array.isArray(raw))
154
+ return [];
155
+ return raw.flatMap((entry) => {
156
+ const filename = String(entry?.filename || '').trim();
157
+ const mimeType = String(entry?.mimeType || entry?.mime_type || '').trim();
158
+ const dataUrl = typeof entry?.dataUrl === 'string'
159
+ ? entry.dataUrl
160
+ : typeof entry?.data_url === 'string'
161
+ ? entry.data_url
162
+ : undefined;
163
+ const sizeBytes = Number(entry?.sizeBytes ?? entry?.size_bytes ?? 0);
164
+ if (!filename || !mimeType || !Number.isFinite(sizeBytes) || sizeBytes < 0)
165
+ return [];
166
+ return [{
167
+ id: typeof entry?.id === 'string' ? entry.id : undefined,
168
+ filename,
169
+ mimeType,
170
+ sizeBytes,
171
+ dataUrl,
172
+ }];
173
+ });
174
+ }
175
+ function parseInlineDataUrl(dataUrl) {
176
+ const match = String(dataUrl || '').match(/^data:([^;]+);base64,(.+)$/);
177
+ if (!match)
178
+ return null;
179
+ return { mimeType: match[1], base64: match[2] };
180
+ }
181
+ function extensionForMimeType(mimeType, filename) {
182
+ const existing = path.extname(filename || '').trim();
183
+ if (existing)
184
+ return existing;
185
+ const normalized = String(mimeType || '').toLowerCase();
186
+ if (normalized === 'image/png')
187
+ return '.png';
188
+ if (normalized === 'image/jpeg')
189
+ return '.jpg';
190
+ if (normalized === 'image/webp')
191
+ return '.webp';
192
+ if (normalized === 'image/gif')
193
+ return '.gif';
194
+ return '';
195
+ }
196
+ function localFilesDir() {
197
+ const filesDir = path.join(os.homedir(), '.funolio', 'files');
198
+ if (!fs.existsSync(filesDir))
199
+ fs.mkdirSync(filesDir, { recursive: true });
200
+ return filesDir;
201
+ }
202
+ function persistInlineAttachments(conversationId, attachments) {
203
+ const filesDir = localFilesDir();
204
+ return attachments.flatMap((attachment) => {
205
+ if (!attachment.mimeType.startsWith('image/'))
206
+ return [];
207
+ if (!attachment.dataUrl)
208
+ return [];
209
+ const parsed = parseInlineDataUrl(attachment.dataUrl);
210
+ if (!parsed)
211
+ return [];
212
+ const buffer = Buffer.from(parsed.base64, 'base64');
213
+ if (buffer.length > MAX_INLINE_ATTACHMENT_BYTES) {
214
+ throw new Error(`Attachment "${attachment.filename}" exceeds max size of 10MB.`);
215
+ }
216
+ const fileId = require('crypto').randomUUID();
217
+ const ext = extensionForMimeType(parsed.mimeType, attachment.filename);
218
+ const storagePath = path.join(filesDir, `${fileId}${ext}`);
219
+ fs.writeFileSync(storagePath, buffer);
220
+ const chatFile = data.createChatFile({
221
+ conversationId,
222
+ filename: attachment.filename,
223
+ fileType: 'image',
224
+ mimeType: parsed.mimeType,
225
+ sizeBytes: buffer.length,
226
+ storagePath,
227
+ preview: attachment.dataUrl,
228
+ });
229
+ return [{
230
+ id: chatFile.id,
231
+ filename: chatFile.filename,
232
+ mimeType: chatFile.mime_type || parsed.mimeType,
233
+ sizeBytes: chatFile.size_bytes || buffer.length,
234
+ fileType: chatFile.file_type || 'image',
235
+ storagePath: chatFile.storage_path,
236
+ }];
237
+ });
238
+ }
239
+ function buildMessageContentWithAttachments(text, attachments) {
240
+ const parts = [];
241
+ for (const attachment of attachments) {
242
+ if (!attachment.mimeType.startsWith('image/') || !attachment.dataUrl)
243
+ continue;
244
+ const parsed = parseInlineDataUrl(attachment.dataUrl);
245
+ if (!parsed)
246
+ continue;
247
+ parts.push({ type: 'image', mimeType: parsed.mimeType, data: parsed.base64 });
248
+ }
249
+ if (parts.length === 0)
250
+ return text;
251
+ parts.push({ type: 'text', text });
252
+ return parts;
253
+ }
254
+ function buildMessageContentWithStoredAttachments(text, attachments) {
255
+ const parts = [];
256
+ for (const attachment of attachments) {
257
+ if (!attachment.mimeType.startsWith('image/') || !attachment.storagePath || !fs.existsSync(attachment.storagePath))
258
+ continue;
259
+ const data = fs.readFileSync(attachment.storagePath).toString('base64');
260
+ parts.push({ type: 'image', mimeType: attachment.mimeType, data });
261
+ }
262
+ if (parts.length === 0)
263
+ return text;
264
+ parts.push({ type: 'text', text });
265
+ return parts;
266
+ }
267
+ function parseStoredMessageAttachments(raw) {
268
+ if (!raw)
269
+ return [];
270
+ try {
271
+ const parsed = JSON.parse(raw);
272
+ return Array.isArray(parsed) ? parsed : [];
273
+ }
274
+ catch {
275
+ return [];
276
+ }
277
+ }
278
+ function hydrateMessageAttachmentForClient(attachment, opts = {}) {
279
+ const chatFile = attachment.id ? data.getChatFile(attachment.id) : undefined;
280
+ const includeAttachmentData = opts.includeAttachmentData !== false;
281
+ let dataUrl;
282
+ if (includeAttachmentData) {
283
+ dataUrl = chatFile?.preview || undefined;
284
+ if (!dataUrl && attachment.mimeType.startsWith('image/') && chatFile?.storage_path && fs.existsSync(chatFile.storage_path)) {
285
+ const encoded = fs.readFileSync(chatFile.storage_path).toString('base64');
286
+ dataUrl = `data:${attachment.mimeType};base64,${encoded}`;
287
+ }
288
+ }
289
+ const hasPreview = !!chatFile?.preview || !!chatFile?.storage_path;
290
+ return {
291
+ id: attachment.id,
292
+ filename: attachment.filename,
293
+ fileType: attachment.fileType,
294
+ file_type: attachment.fileType,
295
+ mimeType: attachment.mimeType,
296
+ mime_type: attachment.mimeType,
297
+ sizeBytes: attachment.sizeBytes,
298
+ size_bytes: attachment.sizeBytes,
299
+ hasPreview,
300
+ has_preview: hasPreview,
301
+ dataUrl,
302
+ data_url: dataUrl,
303
+ };
304
+ }
305
+ function serializeMessageForClient(row, opts = {}) {
306
+ const attachments = parseStoredMessageAttachments(row.attachments_json);
307
+ const includeResultArtifact = opts.includeResultArtifact !== false;
308
+ const includeAttachmentData = opts.includeAttachmentData !== false;
309
+ const resultArtifact = includeResultArtifact ? row.result_artifact : undefined;
310
+ return {
311
+ ...row,
312
+ result_artifact: resultArtifact,
313
+ resultArtifact,
314
+ has_result_artifact: !includeResultArtifact && !!row.result_artifact,
315
+ hasResultArtifact: !includeResultArtifact && !!row.result_artifact,
316
+ result_artifact_bytes: !includeResultArtifact && row.result_artifact ? row.result_artifact.length : undefined,
317
+ resultArtifactBytes: !includeResultArtifact && row.result_artifact ? row.result_artifact.length : undefined,
318
+ attachments: attachments.length > 0
319
+ ? attachments.map((attachment) => hydrateMessageAttachmentForClient(attachment, { includeAttachmentData }))
320
+ : undefined,
321
+ };
322
+ }
323
+ function shouldUseLightweightMessageHistory(req) {
324
+ const relayHeader = String(req.headers?.['x-funolio-relay'] || '').trim();
325
+ const lightQuery = String(req.query?.light || '').trim().toLowerCase();
326
+ const includeDetailsQuery = String(req.query?.includeDetails || req.query?.include_details || '').trim().toLowerCase();
327
+ if (includeDetailsQuery === '1' || includeDetailsQuery === 'true')
328
+ return false;
329
+ return relayHeader === '1' || lightQuery === '1' || lightQuery === 'true';
330
+ }
331
+ function serializeMessageHistoryForRequest(req, rows) {
332
+ const light = shouldUseLightweightMessageHistory(req);
333
+ return rows.map((row) => serializeMessageForClient(row, {
334
+ includeResultArtifact: !light,
335
+ includeAttachmentData: !light,
336
+ }));
337
+ }
338
+ function findLatestUserMessageWithAttachments(conversationId, expectedText) {
339
+ const recent = data.getLastMessages(conversationId, 20);
340
+ const normalizedExpected = String(expectedText || '').trim();
341
+ for (let idx = recent.length - 1; idx >= 0; idx -= 1) {
342
+ const row = recent[idx];
343
+ if (row.role !== 'user' || !row.attachments_json)
344
+ continue;
345
+ if (!normalizedExpected || String(row.content || '').trim() === normalizedExpected) {
346
+ return row;
347
+ }
348
+ }
349
+ return recent.slice().reverse().find((row) => row.role === 'user' && !!row.attachments_json);
350
+ }
351
+ function resolveStoredAttachmentsForTurn(opts) {
352
+ if (opts.persistedAttachments && opts.persistedAttachments.length > 0) {
353
+ return opts.persistedAttachments;
354
+ }
355
+ if (!opts.skipUserMessage)
356
+ return [];
357
+ const job = opts.chatJobId ? data.getChatJob(String(opts.chatJobId)) : undefined;
358
+ const jobUserMessage = job ? data.getMessage(job.user_message_id) : undefined;
359
+ const attachedViaJob = parseStoredMessageAttachments(jobUserMessage?.attachments_json);
360
+ if (attachedViaJob.length > 0)
361
+ return attachedViaJob;
362
+ const latestUser = findLatestUserMessageWithAttachments(opts.conversationId, opts.message);
363
+ return parseStoredMessageAttachments(latestUser?.attachments_json);
364
+ }
365
+ function supportsNativeImageInput(providerName) {
366
+ const normalized = String(providerName || '').trim().toLowerCase();
367
+ return normalized === 'anthropic' || normalized === 'openai' || normalized === 'google' || normalized === 'codex-cli' || normalized === 'claude-cli';
368
+ }
369
+ function buildAttachmentContextBlock(summaries) {
370
+ if (summaries.length === 0)
371
+ return '';
372
+ return [
373
+ '',
374
+ '[Attached Images]',
375
+ ...summaries.map((summary, index) => `Image ${index + 1}:\n${summary}`),
376
+ '',
377
+ ].join('\n');
378
+ }
379
+ function resolveImageAnalysisConfig(preferredProfile) {
380
+ if (preferredProfile) {
381
+ const provider = String(preferredProfile.provider || '').trim().toLowerCase();
382
+ const apiKey = resolveApiKey(preferredProfile);
383
+ if ((provider === 'openai' || provider === 'anthropic') && apiKey) {
384
+ return { provider: provider, apiKey };
385
+ }
386
+ }
387
+ const clerkConfig = data.getResolvedClerkConfigInfo();
388
+ const clerkProvider = String(clerkConfig.provider || '').trim().toLowerCase();
389
+ if (clerkProvider === 'openai' || clerkProvider === 'anthropic') {
390
+ const clerkConn = clerkConfig.providerConnectionId
391
+ ? data.getProviderConnection(clerkConfig.providerConnectionId)
392
+ : data.findProviderConnection(clerkProvider);
393
+ if (clerkConn?.api_key_enc) {
394
+ return {
395
+ provider: clerkProvider,
396
+ apiKey: clerkConn.api_key_enc,
397
+ authMode: clerkConn.access_mode === 'oauth' && clerkProvider === 'anthropic' ? 'oauth-bearer' : 'api-key',
398
+ };
399
+ }
400
+ }
401
+ const openaiConn = data.findProviderConnection('openai');
402
+ if (openaiConn?.api_key_enc)
403
+ return { provider: 'openai', apiKey: openaiConn.api_key_enc };
404
+ if (process.env.OPENAI_API_KEY)
405
+ return { provider: 'openai', apiKey: process.env.OPENAI_API_KEY };
406
+ const anthropicConn = data.findProviderConnection('anthropic');
407
+ if (anthropicConn?.api_key_enc) {
408
+ return {
409
+ provider: 'anthropic',
410
+ apiKey: anthropicConn.api_key_enc,
411
+ authMode: anthropicConn.access_mode === 'oauth' ? 'oauth-bearer' : 'api-key',
412
+ };
413
+ }
414
+ if (process.env.ANTHROPIC_API_KEY)
415
+ return { provider: 'anthropic', apiKey: process.env.ANTHROPIC_API_KEY };
416
+ return null;
417
+ }
418
+ async function summarizeStoredAttachmentsForTextRuntime(opts) {
419
+ if (!opts.attachments.length)
420
+ return '';
421
+ const analysisConfig = resolveImageAnalysisConfig(opts.preferredProfile);
422
+ const summaries = [];
423
+ for (const attachment of opts.attachments) {
424
+ if (!attachment.mimeType.startsWith('image/'))
425
+ continue;
426
+ if (!attachment.storagePath || !fs.existsSync(attachment.storagePath)) {
427
+ summaries.push(`[${attachment.filename}] Attached image is missing from local storage.`);
428
+ continue;
429
+ }
430
+ if (!analysisConfig) {
431
+ summaries.push(`[${attachment.filename}] Attached image is available locally at: ${attachment.storagePath}`);
432
+ continue;
433
+ }
434
+ const toolCtx = (0, index_2.createToolContext)(opts.workspacePath, {
435
+ runtimeMode: 'local_desktop',
436
+ actorType: 'llm',
437
+ actorId: opts.preferredProfile?.name || opts.preferredProfile?.id || 'ImageAnalyzer',
438
+ llmProvider: analysisConfig.provider,
439
+ llmApiKey: analysisConfig.apiKey,
440
+ llmAuthMode: analysisConfig.authMode,
441
+ });
442
+ const result = await (0, index_2.executeToolWithMCP)({
443
+ id: `image-analysis-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
444
+ name: 'analyze_image',
445
+ arguments: {
446
+ image_path: attachment.storagePath,
447
+ prompt: `Describe this image for a text-only assistant. Focus on details relevant to the user's request.\n\nUser request:\n${opts.userPrompt}`,
448
+ },
449
+ }, toolCtx);
450
+ if (result.success && result.output.trim()) {
451
+ summaries.push(`[${attachment.filename}] ${result.output.trim()}`);
452
+ }
453
+ else if (result.error?.trim()) {
454
+ summaries.push(`[${attachment.filename}] Image analysis failed: ${result.error.trim()}`);
455
+ }
456
+ else {
457
+ summaries.push(`[${attachment.filename}] Image attached at local path: ${attachment.storagePath}`);
458
+ }
459
+ }
460
+ return buildAttachmentContextBlock(summaries);
461
+ }
99
462
  function startLocalServer(opts) {
100
463
  const express = requireExpress();
101
464
  const app = express();
@@ -103,6 +466,16 @@ function startLocalServer(opts) {
103
466
  if ((0, storage_mode_1.isLocalStorageMode)()) {
104
467
  data.purgeLegacyExtractionDataOnce();
105
468
  }
469
+ // Sweep orphan managed processes from previous crashes
470
+ try {
471
+ const orphanResult = (0, managed_process_registry_1.sweepOrphans)();
472
+ if (orphanResult.reaped > 0 || orphanResult.skipped > 0) {
473
+ console.info(`[managed-process] startup orphan sweep: reaped=${orphanResult.reaped} skipped=${orphanResult.skipped}`);
474
+ }
475
+ }
476
+ catch (err) {
477
+ console.warn(`[managed-process] startup orphan sweep failed: ${err.message}`);
478
+ }
106
479
  const cliNormalization = data.normalizeCliProviderConnections();
107
480
  if (cliNormalization.updatedIds.length > 0) {
108
481
  console.info(`[local-server] normalized ${cliNormalization.updatedIds.length} CLI provider connection(s): ${cliNormalization.updatedIds.join(', ')}`);
@@ -135,6 +508,553 @@ function startLocalServer(opts) {
135
508
  }
136
509
  return res.status(500).json({ error: message });
137
510
  }
511
+ function syncCliBotConfigFiles(profile) {
512
+ if (!profile)
513
+ return;
514
+ noteCliBotConfigWrite(profile.id);
515
+ if (profile.provider === 'claude-cli') {
516
+ let parsedPermissions = null;
517
+ if (profile.claude_permissions_json) {
518
+ try {
519
+ parsedPermissions = JSON.parse(profile.claude_permissions_json);
520
+ }
521
+ catch {
522
+ parsedPermissions = null;
523
+ }
524
+ }
525
+ (0, sync_cli_config_1.writeClaudeBotSettings)(profile.id, {
526
+ model: profile.model,
527
+ effortLevel: profile.claude_effort_level,
528
+ outputStyle: profile.claude_output_style,
529
+ fastMode: profile.claude_fast_mode === 1,
530
+ permissions: parsedPermissions,
531
+ });
532
+ return;
533
+ }
534
+ if (profile.provider === 'codex-cli') {
535
+ (0, sync_cli_config_1.writeCodexBotConfig)(profile.id, {
536
+ model: profile.model,
537
+ modelReasoningEffort: profile.codex_reasoning_effort,
538
+ personality: profile.codex_personality,
539
+ serviceTier: profile.codex_service_tier,
540
+ sandbox: profile.codex_sandbox_policy,
541
+ approvalPolicy: profile.codex_approval_policy,
542
+ });
543
+ }
544
+ }
545
+ async function refreshCliOAuthTokensIfNeeded(reason) {
546
+ const candidates = [
547
+ { provider: 'anthropic', credential: (0, credential_reader_1.readClaudeCredentials)() },
548
+ { provider: 'openai', credential: (0, credential_reader_1.readCodexCredentials)() },
549
+ ];
550
+ for (const candidate of candidates) {
551
+ const credential = candidate.credential;
552
+ if (!credential?.accessToken || !credential.refreshToken || !credential.expiresAt)
553
+ continue;
554
+ if (!(0, token_refresh_1.needsRefresh)(credential))
555
+ continue;
556
+ try {
557
+ const refreshed = await (0, token_refresh_1.refreshToken)(credential);
558
+ console.info(`[token-refresh] proactive_refresh_success provider=${candidate.provider} reason=${reason} expiresAt=${new Date(refreshed.credential.expiresAt).toISOString()}`);
559
+ (0, credential_reader_1.clearCache)();
560
+ if (candidate.provider === 'anthropic') {
561
+ for (const profile of data.listAgentProfiles().filter((agent) => agent.provider === 'claude-cli')) {
562
+ syncCliBotConfigFiles(profile);
563
+ }
564
+ const closedIdleSessions = (0, local_cli_pty_manager_1.getLocalCliPtySessionManager)().closeIdleClaudeSessions();
565
+ if (closedIdleSessions > 0) {
566
+ console.info(`[claude-auth] evicted ${closedIdleSessions} idle Claude PTY session(s) after proactive refresh`);
567
+ }
568
+ }
569
+ }
570
+ catch (err) {
571
+ console.warn(`[token-refresh] proactive_refresh_failed provider=${candidate.provider} reason=${reason} message=${JSON.stringify(err?.message || String(err))}`);
572
+ }
573
+ }
574
+ }
575
+ void refreshCliOAuthTokensIfNeeded('startup');
576
+ const proactiveTokenRefreshTimer = setInterval(() => {
577
+ void refreshCliOAuthTokensIfNeeded('interval');
578
+ }, 60 * 60_000);
579
+ proactiveTokenRefreshTimer.unref?.();
580
+ function reapCliBotSessions(botId) {
581
+ (0, local_cli_pty_manager_1.getLocalCliPtySessionManager)().closeSessionsByBotId(botId);
582
+ (0, codex_app_server_manager_1.getCodexAppServerManager)().closeSessionsByBotId(botId);
583
+ }
584
+ function closeIdleManagedSession(sessionKey) {
585
+ (0, local_cli_pty_manager_1.getLocalCliPtySessionManager)().closeSessionByKey(sessionKey);
586
+ (0, codex_app_server_manager_1.getCodexAppServerManager)().closeSessionByKey(sessionKey);
587
+ }
588
+ function handleIdleSweepClose(sessionKey, provider) {
589
+ if (provider === 'codex-app-server') {
590
+ (0, codex_app_server_manager_1.getCodexAppServerManager)().closeSessionByKey(sessionKey);
591
+ }
592
+ else {
593
+ (0, local_cli_pty_manager_1.getLocalCliPtySessionManager)().closeSessionByKey(sessionKey);
594
+ }
595
+ }
596
+ function logCliWarmEvent(event, fields) {
597
+ const suffix = Object.entries(fields)
598
+ .filter(([, value]) => value !== undefined && value !== null && value !== '')
599
+ .map(([key, value]) => `${key}=${JSON.stringify(value)}`)
600
+ .join(' ');
601
+ console.info(`[cli-warm] ${event}${suffix ? ` ${suffix}` : ''}`);
602
+ }
603
+ function normalizeCliWarmTrigger(value) {
604
+ return value === 'bot_selection' ? 'bot_selection' : 'first_keystroke';
605
+ }
606
+ function normalizeCliWarmRequestRuntimeMode(value) {
607
+ return value === 'server' ? 'server' : 'local_desktop';
608
+ }
609
+ function buildCliWarmFailureReason(err) {
610
+ const error = err;
611
+ if (error?.code === 'CLI_WARM_TIMEOUT' || error?.code === 'CODEX_APP_SERVER_WARM_TIMEOUT') {
612
+ return 'warm_timeout';
613
+ }
614
+ if (error?.authRequired === true)
615
+ return 'auth_required';
616
+ if (error?.code === 'CLAUDE_FRESH_SESSION_STARTUP_TIMEOUT')
617
+ return 'warm_startup_timeout';
618
+ if (error?.name === 'AbortError')
619
+ return 'warm_aborted';
620
+ const message = String(error?.message || '').toLowerCase();
621
+ if (message.includes('closed before it was ready'))
622
+ return 'session_closed_before_ready';
623
+ return 'warm_failed';
624
+ }
625
+ function recordCliWarmSkip(skipped, context, entry) {
626
+ skipped.push({ botId: entry.botId, reason: entry.reason });
627
+ logCliWarmEvent('warm_skipped', {
628
+ conversationId: context.conversationId,
629
+ topicId: context.topicId,
630
+ trigger: context.trigger,
631
+ runtimeMode: context.runtimeMode,
632
+ botId: entry.botId,
633
+ provider: entry.provider,
634
+ skipReason: entry.reason,
635
+ });
636
+ }
637
+ const CODEX_HIDDEN_WARM_PRIMER = 'This is a hidden background warm-up turn for a new conversation. Internalize the current project, topic, workspace, and bot instructions. There is no user-visible request yet. Reply with exactly READY and nothing else.';
638
+ function closeCliSessionsForConversation(conversationId, reason) {
639
+ const ptyClosed = (0, local_cli_pty_manager_1.getLocalCliPtySessionManager)().closeSessionsByConversation(conversationId);
640
+ const appServerClosed = (0, codex_app_server_manager_1.getCodexAppServerManager)().closeSessionsByConversation(conversationId);
641
+ if (ptyClosed > 0 || appServerClosed > 0) {
642
+ logCliWarmEvent('warm_evicted', { conversationId, reason, ptyClosed, appServerClosed });
643
+ }
644
+ }
645
+ function closeWarmCliSessionsForConversation(conversationId, reason) {
646
+ const ptyClosed = (0, local_cli_pty_manager_1.getLocalCliPtySessionManager)().closeWarmSessionsByConversation(conversationId);
647
+ const appServerClosed = (0, codex_app_server_manager_1.getCodexAppServerManager)().closeWarmSessionsByConversation(conversationId);
648
+ if (ptyClosed > 0 || appServerClosed > 0) {
649
+ logCliWarmEvent(reason === 'expired' ? 'warm_expired' : 'warm_evicted', {
650
+ conversationId,
651
+ reason,
652
+ ptyClosed,
653
+ appServerClosed,
654
+ });
655
+ }
656
+ }
657
+ function resolveConversationCwd(conversation) {
658
+ const project = conversation?.project_id ? data.getProject(conversation.project_id) : undefined;
659
+ const workspacePath = project?.folder?.trim() || undefined;
660
+ return workspacePath && fs.existsSync(workspacePath) ? workspacePath : opts.projectDir;
661
+ }
662
+ async function warmCliSessionsForConversation(input) {
663
+ const conversation = data.getConversation(input.conversationId);
664
+ if (!conversation) {
665
+ return {
666
+ ok: false,
667
+ warmed: [],
668
+ skipped: [],
669
+ failed: [{ botId: '*', error: 'Conversation not found' }],
670
+ };
671
+ }
672
+ const cwd = resolveConversationCwd(conversation);
673
+ const uniqueBotIds = [...new Set((input.botIds || []).map(String).filter(Boolean))];
674
+ const warmed = [];
675
+ const skipped = [];
676
+ const failed = [];
677
+ const trigger = normalizeCliWarmTrigger(input.trigger);
678
+ const runtimeMode = normalizeCliWarmRequestRuntimeMode(input.runtimeMode);
679
+ await Promise.all(uniqueBotIds.map(async (botId) => {
680
+ const profile = data.getAgentProfile(botId);
681
+ if (!profile) {
682
+ recordCliWarmSkip(skipped, {
683
+ conversationId: input.conversationId,
684
+ topicId: input.topicId,
685
+ trigger,
686
+ runtimeMode,
687
+ }, { botId, reason: 'bot_not_found' });
688
+ return;
689
+ }
690
+ if (profile.is_active === 0) {
691
+ recordCliWarmSkip(skipped, {
692
+ conversationId: input.conversationId,
693
+ topicId: input.topicId,
694
+ trigger,
695
+ runtimeMode,
696
+ }, { botId, provider: profile.provider, reason: 'bot_inactive' });
697
+ return;
698
+ }
699
+ let runtime;
700
+ let createdEpochSessionId;
701
+ try {
702
+ runtime = await buildChatRuntime(profile);
703
+ }
704
+ catch {
705
+ recordCliWarmSkip(skipped, {
706
+ conversationId: input.conversationId,
707
+ topicId: input.topicId,
708
+ trigger,
709
+ runtimeMode,
710
+ }, { botId, provider: profile.provider, reason: 'runtime_build_failed' });
711
+ return;
712
+ }
713
+ if (!index_1.CLI_PROVIDERS.has(runtime.providerName)) {
714
+ recordCliWarmSkip(skipped, {
715
+ conversationId: input.conversationId,
716
+ topicId: input.topicId,
717
+ trigger,
718
+ runtimeMode,
719
+ }, { botId, provider: runtime.providerName, reason: 'not_cli_provider' });
720
+ return;
721
+ }
722
+ const transport = resolveDirectCliSessionTransport(runtime.providerName, true, (0, storage_mode_1.isLocalStorageMode)());
723
+ const cliSessionEpochPlan = (0, cli_session_epoch_1.selectCliSessionEpoch)(input.conversationId, profile.id, runtime.providerName);
724
+ logCliWarmEvent('warm_requested', {
725
+ conversationId: input.conversationId,
726
+ topicId: input.topicId,
727
+ botId,
728
+ provider: runtime.providerName,
729
+ trigger,
730
+ runtimeMode,
731
+ });
732
+ try {
733
+ if (transport === 'codex-app-server') {
734
+ // Build the same systemPrompt the send path will use so warm can
735
+ // pre-create the Codex thread under the correct fingerprint. If
736
+ // anything drifts between warm and Send (e.g. user edits soul_md
737
+ // mid-typing), the fingerprint check in codex-app-server-manager
738
+ // invalidates the warm thread and Send falls back to a fresh
739
+ // thread/start — same as today's uncarmed behavior.
740
+ const allToolDefs = (0, index_2.getAllToolDefinitions)('local_desktop', mcpManager);
741
+ const unrestrictedCliProfile = index_1.CLI_PROVIDERS.has(profile.provider);
742
+ const configuredBuiltinTools = parseToolSelectionJson(profile.enabled_builtin_tools_json);
743
+ const configuredMcpTools = parseToolSelectionJson(profile.enabled_mcp_tools_json);
744
+ const allowedToolNames = unrestrictedCliProfile
745
+ ? new Set(allToolDefs.map((tool) => tool.name))
746
+ : expandAllowedToolNames(allToolDefs, configuredBuiltinTools, configuredMcpTools);
747
+ const toolDefs = allToolDefs.filter((tool) => allowedToolNames.has(tool.name));
748
+ const configuredTz = (data.getSetting('timezone') || '').trim();
749
+ const effectiveTimezone = configuredTz && configuredTz.toLowerCase() !== 'system'
750
+ ? configuredTz
751
+ : Intl.DateTimeFormat().resolvedOptions().timeZone;
752
+ const promptContextWindow = (0, context_window_1.getPromptContextWindow)(input.conversationId, safeguards_1.SAFEGUARDS.CONTEXT_WINDOW_TURNS, {
753
+ targetBotId: profile.id,
754
+ targetBotName: profile.name,
755
+ });
756
+ const promptContext = resolveLocalDesktopPromptContext({
757
+ conversationId: input.conversationId,
758
+ conversation,
759
+ explicitTopicId: input.topicId,
760
+ fallbackCwd: cwd,
761
+ });
762
+ const directPrompt = buildLocalDesktopDirectPrompt({
763
+ conversationId: input.conversationId,
764
+ currentBotId: profile.id,
765
+ currentBotName: profile.name,
766
+ currentProvider: runtime.providerName,
767
+ userPrompt: '',
768
+ soulMd: profile.soul_md
769
+ || 'You are an AI assistant running locally. You have access to project files and can execute code.',
770
+ projectName: promptContext.projectName,
771
+ topicTitle: promptContext.topicTitle,
772
+ workspacePath: promptContext.workspacePath,
773
+ timezone: effectiveTimezone,
774
+ availableTools: toolDefs.map((tool) => ({ name: tool.name, description: tool.description })),
775
+ isCliRecurring: !!cliSessionEpochPlan.resumeSessionId,
776
+ cliHistoryFilePath: null,
777
+ crossBotTurn: promptContextWindow.lastCrossBotTurn,
778
+ forceInlinePromptContext: transport === 'codex-app-server' && !cliSessionEpochPlan.resumeSessionId,
779
+ useCompletionSentinel: false,
780
+ });
781
+ const shouldPrimeFreshCodex = runtime.providerName === 'codex-cli'
782
+ && !cliSessionEpochPlan.resumeSessionId
783
+ && (conversation.turn_count || 0) === 0;
784
+ const result = await (0, codex_app_server_manager_1.getCodexAppServerManager)().warmSession({
785
+ runtimeMode: 'local_desktop',
786
+ conversationId: input.conversationId,
787
+ botId: profile.id,
788
+ botName: profile.name,
789
+ cwd,
790
+ projectId: conversation.project_id ?? null,
791
+ timeoutMs: 30_000,
792
+ systemPrompt: directPrompt.systemPrompt,
793
+ model: runtime.model || profile.model || null,
794
+ codexSettings: {
795
+ reasoningEffort: profile.codex_reasoning_effort,
796
+ reasoningSummary: profile.codex_reasoning_summary,
797
+ personality: profile.codex_personality,
798
+ serviceTier: profile.codex_service_tier,
799
+ sandboxPolicy: profile.codex_sandbox_policy,
800
+ approvalPolicy: profile.codex_approval_policy,
801
+ },
802
+ resumeSessionId: cliSessionEpochPlan.resumeSessionId || undefined,
803
+ primerMessages: shouldPrimeFreshCodex
804
+ ? [{ role: 'user', content: CODEX_HIDDEN_WARM_PRIMER }]
805
+ : undefined,
806
+ });
807
+ if (shouldPrimeFreshCodex && result.hiddenPrimerCompleted && result.sessionId) {
808
+ data.upsertCliSessionEpoch({
809
+ conversationId: input.conversationId,
810
+ botId: profile.id,
811
+ provider: runtime.providerName,
812
+ sessionId: result.sessionId,
813
+ epochTurnCount: 1,
814
+ epochStartedAt: localTimestamp(),
815
+ lastUsedAt: localTimestamp(),
816
+ resetReason: null,
817
+ });
818
+ }
819
+ warmed.push({
820
+ botId,
821
+ provider: runtime.providerName,
822
+ reusedExistingSession: result.reusedExistingSession,
823
+ readyAgeMs: result.readyAgeMs,
824
+ });
825
+ logCliWarmEvent('warm_ready', {
826
+ conversationId: input.conversationId,
827
+ topicId: input.topicId,
828
+ botId,
829
+ provider: runtime.providerName,
830
+ trigger,
831
+ runtimeMode,
832
+ reusedExistingSession: result.reusedExistingSession,
833
+ readyAgeMs: result.readyAgeMs,
834
+ });
835
+ return;
836
+ }
837
+ if (transport !== 'pty') {
838
+ recordCliWarmSkip(skipped, {
839
+ conversationId: input.conversationId,
840
+ topicId: input.topicId,
841
+ trigger,
842
+ runtimeMode,
843
+ }, { botId, provider: runtime.providerName, reason: 'transport_not_warmable' });
844
+ return;
845
+ }
846
+ const isFreshClaude = runtime.providerName === 'claude-cli' && !cliSessionEpochPlan.resumeSessionId;
847
+ const newSessionId = isFreshClaude ? data.generateNextSessionId() : undefined;
848
+ const result = await (0, local_cli_pty_manager_1.getLocalCliPtySessionManager)().warmSession({
849
+ conversationId: input.conversationId,
850
+ botId: profile.id,
851
+ provider: runtime.providerName,
852
+ botSettings: {
853
+ claude: runtime.providerName === 'claude-cli'
854
+ ? {
855
+ model: runtime.model || profile.model,
856
+ effortLevel: profile.claude_effort_level,
857
+ outputStyle: profile.claude_output_style,
858
+ fastMode: profile.claude_fast_mode === 1,
859
+ permissionsJson: profile.claude_permissions_json,
860
+ }
861
+ : undefined,
862
+ codex: runtime.providerName === 'codex-cli'
863
+ ? {
864
+ model: runtime.model || profile.model,
865
+ reasoningEffort: profile.codex_reasoning_effort,
866
+ personality: profile.codex_personality,
867
+ serviceTier: profile.codex_service_tier,
868
+ sandboxPolicy: profile.codex_sandbox_policy,
869
+ approvalPolicy: profile.codex_approval_policy,
870
+ }
871
+ : undefined,
872
+ },
873
+ cwd,
874
+ toolActorId: profile.name,
875
+ toolProjectId: conversation.project_id ?? null,
876
+ topicId: input.topicId,
877
+ runtimeMode,
878
+ resumeSessionId: cliSessionEpochPlan.resumeSessionId || undefined,
879
+ newSessionId,
880
+ useConpty: input.orchestrationMode ? process.platform !== 'win32' : undefined,
881
+ timeoutMs: 10_000,
882
+ });
883
+ if (!result.reusedExistingSession && newSessionId) {
884
+ createdEpochSessionId = newSessionId;
885
+ data.upsertCliSessionEpoch({
886
+ conversationId: input.conversationId,
887
+ botId: profile.id,
888
+ provider: runtime.providerName,
889
+ sessionId: newSessionId,
890
+ epochTurnCount: 0,
891
+ lastInputTokens: 0,
892
+ lastOutputTokens: 0,
893
+ resetReason: cliSessionEpochPlan.resetReason || 'prewarm',
894
+ epochStartedAt: localTimestamp(),
895
+ lastUsedAt: localTimestamp(),
896
+ });
897
+ }
898
+ warmed.push({
899
+ botId,
900
+ provider: runtime.providerName,
901
+ reusedExistingSession: result.reusedExistingSession,
902
+ readyAgeMs: result.readyAgeMs,
903
+ });
904
+ logCliWarmEvent('warm_ready', {
905
+ conversationId: input.conversationId,
906
+ topicId: input.topicId,
907
+ botId,
908
+ provider: runtime.providerName,
909
+ trigger,
910
+ runtimeMode,
911
+ reusedExistingSession: result.reusedExistingSession,
912
+ readyAgeMs: result.readyAgeMs,
913
+ });
914
+ }
915
+ catch (err) {
916
+ if (createdEpochSessionId) {
917
+ data.deleteCliSessionEpoch(input.conversationId, profile.id);
918
+ }
919
+ const message = err?.message || String(err);
920
+ const failureReason = buildCliWarmFailureReason(err);
921
+ const timeout = failureReason === 'warm_timeout';
922
+ const timeoutMs = err?.code === 'CODEX_APP_SERVER_WARM_TIMEOUT'
923
+ ? 30_000
924
+ : err?.code === 'CLI_WARM_TIMEOUT'
925
+ ? 10_000
926
+ : undefined;
927
+ failed.push({ botId, provider: runtime.providerName, error: message, timeout });
928
+ logCliWarmEvent(timeout ? 'warm_timeout' : 'warm_failed', {
929
+ conversationId: input.conversationId,
930
+ topicId: input.topicId,
931
+ botId,
932
+ provider: runtime.providerName,
933
+ trigger,
934
+ runtimeMode,
935
+ timeoutMs,
936
+ failureReason,
937
+ });
938
+ }
939
+ }));
940
+ return {
941
+ ok: failed.length === 0,
942
+ warmed,
943
+ skipped,
944
+ failed,
945
+ };
946
+ }
947
+ const cliBotConfigWatchers = new Map();
948
+ function noteCliBotConfigWrite(botId) {
949
+ const existing = cliBotConfigWatchers.get(botId);
950
+ if (!existing)
951
+ return;
952
+ existing.suppressUntilMs = Date.now() + 1_500;
953
+ }
954
+ function closeCliBotConfigWatcher(botId) {
955
+ const existing = cliBotConfigWatchers.get(botId);
956
+ if (!existing)
957
+ return;
958
+ if (existing.debounceTimer)
959
+ clearTimeout(existing.debounceTimer);
960
+ try {
961
+ existing.watcher.close();
962
+ }
963
+ catch {
964
+ // best effort
965
+ }
966
+ cliBotConfigWatchers.delete(botId);
967
+ }
968
+ function buildCliBotConfigUpdateFromDisk(profile) {
969
+ if (profile.provider === 'claude-cli') {
970
+ const disk = (0, sync_cli_config_1.readClaudeBotSettings)(profile.id);
971
+ const nextPermissionsJson = disk.permissions ? JSON.stringify(disk.permissions) : null;
972
+ const fields = {};
973
+ const nextModel = (disk.model || '').trim();
974
+ if (nextModel && nextModel !== profile.model)
975
+ fields.model = nextModel;
976
+ const nextEffort = (disk.effortLevel || 'auto').trim() || 'auto';
977
+ if (nextEffort !== profile.claude_effort_level)
978
+ fields.claudeEffortLevel = nextEffort;
979
+ const nextOutputStyle = (disk.outputStyle || 'default').trim() || 'default';
980
+ if (nextOutputStyle !== profile.claude_output_style)
981
+ fields.claudeOutputStyle = nextOutputStyle;
982
+ const nextFastMode = disk.fastMode === true;
983
+ if ((nextFastMode ? 1 : 0) !== profile.claude_fast_mode)
984
+ fields.claudeFastMode = nextFastMode;
985
+ if ((nextPermissionsJson || null) !== (profile.claude_permissions_json || null)) {
986
+ fields.claudePermissionsJson = nextPermissionsJson;
987
+ }
988
+ return Object.keys(fields).length > 0 ? fields : null;
989
+ }
990
+ if (profile.provider === 'codex-cli') {
991
+ const disk = (0, sync_cli_config_1.readCodexBotConfig)(profile.id);
992
+ const fields = {};
993
+ const nextModel = (disk.model || '').trim();
994
+ if (nextModel && nextModel !== profile.model)
995
+ fields.model = nextModel;
996
+ const nextReasoning = (disk.modelReasoningEffort || 'high').trim() || 'high';
997
+ if (nextReasoning !== profile.codex_reasoning_effort)
998
+ fields.codexReasoningEffort = nextReasoning;
999
+ const nextPersonality = (disk.personality || 'friendly').trim() || 'friendly';
1000
+ if (nextPersonality !== profile.codex_personality)
1001
+ fields.codexPersonality = nextPersonality;
1002
+ const nextServiceTier = (disk.serviceTier || 'fast').trim() || 'fast';
1003
+ if (nextServiceTier !== profile.codex_service_tier)
1004
+ fields.codexServiceTier = nextServiceTier;
1005
+ const nextSandbox = (disk.sandbox || 'danger-full-access').trim() || 'danger-full-access';
1006
+ if (nextSandbox !== profile.codex_sandbox_policy)
1007
+ fields.codexSandboxPolicy = nextSandbox;
1008
+ const nextApproval = (disk.approvalPolicy || 'never').trim() || 'never';
1009
+ if (nextApproval !== profile.codex_approval_policy)
1010
+ fields.codexApprovalPolicy = nextApproval;
1011
+ return Object.keys(fields).length > 0 ? fields : null;
1012
+ }
1013
+ return null;
1014
+ }
1015
+ function applyCliBotConfigUpdateFromDisk(botId) {
1016
+ const profile = data.getAgentProfile(botId);
1017
+ if (!profile || (profile.provider !== 'claude-cli' && profile.provider !== 'codex-cli'))
1018
+ return;
1019
+ const fields = buildCliBotConfigUpdateFromDisk(profile);
1020
+ if (!fields)
1021
+ return;
1022
+ data.updateAgentProfile(botId, fields);
1023
+ reapCliBotSessions(botId);
1024
+ }
1025
+ function watchCliBotConfig(profile) {
1026
+ if (!profile || (profile.provider !== 'claude-cli' && profile.provider !== 'codex-cli'))
1027
+ return;
1028
+ closeCliBotConfigWatcher(profile.id);
1029
+ const configPath = profile.provider === 'claude-cli'
1030
+ ? (0, sync_cli_config_1.claudeBotSettingsPath)(profile.id)
1031
+ : (0, sync_cli_config_1.codexConfigPath)((0, sync_cli_config_1.getCliBotSessionHome)('codex-cli', profile.id));
1032
+ const watchDir = path.dirname(configPath);
1033
+ if (!fs.existsSync(watchDir))
1034
+ return;
1035
+ try {
1036
+ const state = {
1037
+ watcher: fs.watch(watchDir, () => {
1038
+ const current = cliBotConfigWatchers.get(profile.id);
1039
+ if (!current)
1040
+ return;
1041
+ if (current.debounceTimer)
1042
+ clearTimeout(current.debounceTimer);
1043
+ current.debounceTimer = setTimeout(() => {
1044
+ if (Date.now() < current.suppressUntilMs)
1045
+ return;
1046
+ applyCliBotConfigUpdateFromDisk(profile.id);
1047
+ }, 200);
1048
+ }),
1049
+ debounceTimer: null,
1050
+ suppressUntilMs: 0,
1051
+ };
1052
+ cliBotConfigWatchers.set(profile.id, state);
1053
+ }
1054
+ catch {
1055
+ // best effort only
1056
+ }
1057
+ }
138
1058
  // Auto-seed agent profiles from DB-backed provider connections after legacy migration.
139
1059
  try {
140
1060
  const migration = (0, wizard_state_1.migrateLegacyConfigToDb)();
@@ -191,14 +1111,70 @@ function startLocalServer(opts) {
191
1111
  console.error(chalk_1.default.yellow(` Failed to reconcile conversation/topic project consistency: ${err}`));
192
1112
  }
193
1113
  try {
194
- const interrupted = data.markRunningChatJobsInterrupted();
195
- if (interrupted > 0) {
196
- console.log(chalk_1.default.gray(` Marked ${interrupted} interrupted chat job(s) as failed`));
1114
+ const recovery = data.recoverInterruptedChatJobs();
1115
+ if (recovery.totalRecovered > 0) {
1116
+ console.log(chalk_1.default.gray(` Recovered ${recovery.totalRecovered} interrupted chat job(s)`));
1117
+ }
1118
+ console.log(JSON.stringify({
1119
+ ts: new Date().toISOString(),
1120
+ level: 'info',
1121
+ event: 'CRASH_RECOVERY',
1122
+ recoveredCount: recovery.totalRecovered,
1123
+ }));
1124
+ for (const job of recovery.recoveredJobs) {
1125
+ console.log(JSON.stringify({
1126
+ ts: new Date().toISOString(),
1127
+ level: 'info',
1128
+ event: 'CRASH_RECOVERY_JOB',
1129
+ jobId: job.jobId,
1130
+ previousStatus: job.previousStatus,
1131
+ }));
197
1132
  }
198
1133
  }
199
1134
  catch (err) {
200
1135
  console.error(chalk_1.default.yellow(` Failed to recover interrupted chat jobs: ${err}`));
201
1136
  }
1137
+ try {
1138
+ const workflowRecovery = data.recoverInterruptedWorkflowRuns();
1139
+ if (workflowRecovery.totalRecovered > 0) {
1140
+ console.log(chalk_1.default.gray(` Recovered ${workflowRecovery.totalRecovered} interrupted workflow item(s)`));
1141
+ }
1142
+ console.log(JSON.stringify({
1143
+ ts: new Date().toISOString(),
1144
+ level: 'info',
1145
+ event: 'WORKFLOW_CRASH_RECOVERY',
1146
+ recoveredCount: workflowRecovery.totalRecovered,
1147
+ recoveredWorkflowExecutions: workflowRecovery.recoveredWorkflowExecutions.length,
1148
+ recoveredImportedPlanRuns: workflowRecovery.recoveredImportedPlanRuns.length,
1149
+ }));
1150
+ for (const workflow of workflowRecovery.recoveredWorkflowExecutions) {
1151
+ console.log(JSON.stringify({
1152
+ ts: new Date().toISOString(),
1153
+ level: 'info',
1154
+ event: 'WORKFLOW_CRASH_RECOVERY_EXECUTION',
1155
+ workflowId: workflow.workflowId,
1156
+ conversationId: workflow.conversationId,
1157
+ previousStatus: workflow.previousStatus,
1158
+ }));
1159
+ }
1160
+ for (const run of workflowRecovery.recoveredImportedPlanRuns) {
1161
+ console.log(JSON.stringify({
1162
+ ts: new Date().toISOString(),
1163
+ level: 'info',
1164
+ event: 'WORKFLOW_CRASH_RECOVERY_IMPORTED_PLAN',
1165
+ runId: run.runId,
1166
+ conversationId: run.conversationId,
1167
+ previousStatus: run.previousStatus,
1168
+ remainingTaskCount: run.remainingTaskCount,
1169
+ }));
1170
+ }
1171
+ }
1172
+ catch (err) {
1173
+ console.error(chalk_1.default.yellow(` Failed to recover interrupted workflow runs: ${err}`));
1174
+ }
1175
+ for (const profile of data.listAgentProfiles()) {
1176
+ watchCliBotConfig(profile);
1177
+ }
202
1178
  // ─── Health ──────────────────────────────────────────────────
203
1179
  app.get('/api/health', (_req, res) => {
204
1180
  try {
@@ -209,12 +1185,37 @@ function startLocalServer(opts) {
209
1185
  db: stats,
210
1186
  version: require('../package.json').version,
211
1187
  runtime,
1188
+ systemInfo: {
1189
+ hostname: os.hostname(),
1190
+ platform: os.platform(),
1191
+ arch: os.arch(),
1192
+ },
212
1193
  });
213
1194
  }
214
1195
  catch (err) {
215
1196
  res.status(500).json({ status: 'error', error: err.message });
216
1197
  }
217
1198
  });
1199
+ // ─── Diagnostics: Managed Sessions ─────────────────────────
1200
+ app.get('/api/diagnostics/sessions', (_req, res) => {
1201
+ try {
1202
+ const sessions = (0, managed_process_registry_1.getDiagnostics)();
1203
+ res.json({ sessions });
1204
+ }
1205
+ catch (err) {
1206
+ res.status(500).json({ error: err.message });
1207
+ }
1208
+ });
1209
+ app.post('/api/diagnostics/sessions/:sessionKey/close', (req, res) => {
1210
+ try {
1211
+ const sessionKey = decodeURIComponent(req.params.sessionKey);
1212
+ closeIdleManagedSession(sessionKey);
1213
+ res.json({ ok: true, sessionKey });
1214
+ }
1215
+ catch (err) {
1216
+ res.status(500).json({ error: err.message });
1217
+ }
1218
+ });
218
1219
  app.get('/api/runtime/config', async (_req, res) => {
219
1220
  try {
220
1221
  const runtime = (0, server_runtime_1.getRuntimeConnectionConfig)();
@@ -231,6 +1232,49 @@ function startLocalServer(opts) {
231
1232
  res.status(500).json({ error: err.message });
232
1233
  }
233
1234
  });
1235
+ app.post('/api/cli-sessions/warm', async (req, res) => {
1236
+ try {
1237
+ if (isConnectedMode()) {
1238
+ return res.json({ ok: true, warmed: [], skipped: [{ botId: '*', reason: 'connected_mode' }], failed: [] });
1239
+ }
1240
+ const conversationId = String(req.body?.conversationId || '').trim();
1241
+ const botIds = Array.isArray(req.body?.botIds) ? req.body.botIds.map(String) : [];
1242
+ if (!conversationId)
1243
+ return res.status(400).json({ error: 'conversationId is required' });
1244
+ if (botIds.length === 0)
1245
+ return res.json({ ok: true, warmed: [], skipped: [], failed: [] });
1246
+ const result = await warmCliSessionsForConversation({
1247
+ conversationId,
1248
+ botIds,
1249
+ topicId: typeof req.body?.topicId === 'string' ? req.body.topicId : undefined,
1250
+ trigger: typeof req.body?.trigger === 'string' ? req.body.trigger : undefined,
1251
+ runtimeMode: typeof req.body?.runtimeMode === 'string' ? req.body.runtimeMode : undefined,
1252
+ orchestrationMode: req.body?.orchestrationMode === true,
1253
+ });
1254
+ if (!result.ok && result.failed.some((entry) => entry.error === 'Conversation not found')) {
1255
+ return res.status(404).json(result);
1256
+ }
1257
+ res.json(result);
1258
+ }
1259
+ catch (err) {
1260
+ res.status(500).json({ error: err.message });
1261
+ }
1262
+ });
1263
+ app.post('/api/cli-sessions/warm/close', async (req, res) => {
1264
+ try {
1265
+ if (isConnectedMode()) {
1266
+ return res.json({ ok: true });
1267
+ }
1268
+ const conversationId = String(req.body?.conversationId || '').trim();
1269
+ if (!conversationId)
1270
+ return res.status(400).json({ error: 'conversationId is required' });
1271
+ closeWarmCliSessionsForConversation(conversationId, String(req.body?.reason || 'expired'));
1272
+ res.json({ ok: true });
1273
+ }
1274
+ catch (err) {
1275
+ res.status(500).json({ error: err.message });
1276
+ }
1277
+ });
234
1278
  app.get('/api/runtime/agents', async (_req, res) => {
235
1279
  try {
236
1280
  const runtime = (0, server_runtime_1.getRuntimeConnectionConfig)();
@@ -552,6 +1596,36 @@ function startLocalServer(opts) {
552
1596
  function localTimestamp() {
553
1597
  return new Date().toISOString().replace('T', ' ').replace('Z', '');
554
1598
  }
1599
+ function emitConversationSyncEvents(conversationId, events, opts) {
1600
+ const revision = typeof opts?.revision === 'number'
1601
+ ? opts.revision
1602
+ : data.bumpConversationSyncRevision(conversationId, opts?.updatedAt || undefined);
1603
+ const updatedAt = opts?.updatedAt || localTimestamp();
1604
+ for (const event of events) {
1605
+ (0, chat_sync_1.emitChatSyncEvent)({
1606
+ change: event.change,
1607
+ conversationId,
1608
+ messageId: event.messageId ?? null,
1609
+ jobId: event.jobId ?? null,
1610
+ jobStatus: event.jobStatus ?? null,
1611
+ revision,
1612
+ updatedAt,
1613
+ source: event.source ?? 'agent.local',
1614
+ });
1615
+ }
1616
+ return revision;
1617
+ }
1618
+ function emitDeletedConversationSyncEvent(conversation) {
1619
+ (0, chat_sync_1.emitChatSyncEvent)({
1620
+ change: 'conversation.deleted',
1621
+ conversationId: conversation.id,
1622
+ revision: Number(conversation.sync_revision || 0) + 1,
1623
+ projectId: conversation.project_id ?? null,
1624
+ topicId: data.getPrimaryTopicIdForConversation(conversation.id),
1625
+ updatedAt: localTimestamp(),
1626
+ source: 'agent.local',
1627
+ });
1628
+ }
555
1629
  const MAX_LOCAL_CHAT_JOBS = 3;
556
1630
  const runningChatJobControllers = new Map();
557
1631
  const finalizeCancelledChatJobMessage = (job) => {
@@ -607,22 +1681,29 @@ function startLocalServer(opts) {
607
1681
  await handlers.onDone?.({});
608
1682
  }
609
1683
  };
610
- const runQueuedChatJobs = async () => {
611
- if (isConnectedMode())
1684
+ const runQueuedChatJobs = async (opts) => {
1685
+ if (isConnectedMode() && !opts?.force)
612
1686
  return;
613
1687
  while (runningChatJobControllers.size < MAX_LOCAL_CHAT_JOBS) {
614
- const next = data.listQueuedChatJobs(1)[0];
1688
+ const runningKeys = new Set(data.listRunningChatJobs(MAX_LOCAL_CHAT_JOBS + 20).map((job) => `${job.conversation_id}::${job.bot_id}`));
1689
+ const next = data
1690
+ .listQueuedChatJobs(50)
1691
+ .find((job) => !runningKeys.has(`${job.conversation_id}::${job.bot_id}`));
615
1692
  if (!next)
616
1693
  return;
617
1694
  if (runningChatJobControllers.has(next.id))
618
1695
  return;
619
1696
  const controller = new AbortController();
620
1697
  runningChatJobControllers.set(next.id, controller);
1698
+ const runningAt = localTimestamp();
621
1699
  data.updateChatJob(next.id, {
622
1700
  status: 'running',
623
- startedAt: localTimestamp(),
1701
+ startedAt: runningAt,
624
1702
  error: null,
625
1703
  });
1704
+ emitConversationSyncEvents(next.conversation_id, [
1705
+ { change: 'job.running', jobId: next.id, jobStatus: 'running' },
1706
+ ], { updatedAt: runningAt });
626
1707
  void (async () => {
627
1708
  try {
628
1709
  const job = data.getChatJob(next.id);
@@ -646,6 +1727,7 @@ function startLocalServer(opts) {
646
1727
  message: userMessage?.content || '',
647
1728
  botId: job.bot_id,
648
1729
  skipUserMessage: true,
1730
+ attachments: Array.isArray(requestPayload?.attachments) ? requestPayload.attachments : undefined,
649
1731
  pinnedMessageIds: Array.isArray(requestPayload?.pinnedMessageIds) ? requestPayload.pinnedMessageIds : undefined,
650
1732
  topicId: requestPayload?.topicId || undefined,
651
1733
  projectId: requestPayload?.projectId || undefined,
@@ -663,12 +1745,17 @@ function startLocalServer(opts) {
663
1745
  const latest = data.getChatJob(next.id);
664
1746
  if (!latest || latest.status === 'cancelled')
665
1747
  return;
1748
+ const completedAt = localTimestamp();
666
1749
  data.updateChatJob(next.id, {
667
1750
  status: 'completed',
668
- completedAt: localTimestamp(),
1751
+ completedAt,
669
1752
  error: null,
670
1753
  });
671
- data.touchConversationActivity(next.conversation_id);
1754
+ data.touchConversationActivity(next.conversation_id, completedAt);
1755
+ emitConversationSyncEvents(next.conversation_id, [
1756
+ { change: 'message.updated', messageId: next.assistant_message_id },
1757
+ { change: 'job.completed', jobId: next.id, jobStatus: 'completed' },
1758
+ ], { updatedAt: completedAt });
672
1759
  },
673
1760
  onError: async (payload) => {
674
1761
  const latest = data.getChatJob(next.id);
@@ -681,12 +1768,17 @@ function startLocalServer(opts) {
681
1768
  content: existingContent ? `${existingContent}\n\n**Error:** ${errorText}` : `**Error:** ${errorText}`,
682
1769
  botId: next.bot_id,
683
1770
  });
1771
+ const failedAt = localTimestamp();
684
1772
  data.updateChatJob(next.id, {
685
1773
  status: 'failed',
686
1774
  error: errorText,
687
- completedAt: localTimestamp(),
1775
+ completedAt: failedAt,
688
1776
  });
689
- data.touchConversationActivity(next.conversation_id);
1777
+ data.touchConversationActivity(next.conversation_id, failedAt);
1778
+ emitConversationSyncEvents(next.conversation_id, [
1779
+ { change: 'message.updated', messageId: next.assistant_message_id },
1780
+ { change: 'job.failed', jobId: next.id, jobStatus: 'failed' },
1781
+ ], { updatedAt: failedAt });
690
1782
  },
691
1783
  });
692
1784
  }
@@ -696,11 +1788,16 @@ function startLocalServer(opts) {
696
1788
  return;
697
1789
  if (latest.status === 'cancelled' || err?.name === 'AbortError') {
698
1790
  finalizeCancelledChatJobMessage(latest);
1791
+ const cancelledAt = latest.cancelled_at || localTimestamp();
699
1792
  data.updateChatJob(next.id, {
700
1793
  status: 'cancelled',
701
- cancelledAt: latest.cancelled_at || localTimestamp(),
702
- completedAt: latest.completed_at || localTimestamp(),
1794
+ cancelledAt,
1795
+ completedAt: latest.completed_at || cancelledAt,
703
1796
  });
1797
+ emitConversationSyncEvents(next.conversation_id, [
1798
+ { change: 'message.updated', messageId: next.assistant_message_id },
1799
+ { change: 'job.cancelled', jobId: next.id, jobStatus: 'cancelled' },
1800
+ ], { updatedAt: cancelledAt });
704
1801
  return;
705
1802
  }
706
1803
  const errorText = err?.message || 'Background chat failed';
@@ -710,12 +1807,17 @@ function startLocalServer(opts) {
710
1807
  content: existingContent ? `${existingContent}\n\n**Error:** ${errorText}` : `**Error:** ${errorText}`,
711
1808
  botId: next.bot_id,
712
1809
  });
1810
+ const failedAt = localTimestamp();
713
1811
  data.updateChatJob(next.id, {
714
1812
  status: 'failed',
715
1813
  error: errorText,
716
- completedAt: localTimestamp(),
1814
+ completedAt: failedAt,
717
1815
  });
718
- data.touchConversationActivity(next.conversation_id);
1816
+ data.touchConversationActivity(next.conversation_id, failedAt);
1817
+ emitConversationSyncEvents(next.conversation_id, [
1818
+ { change: 'message.updated', messageId: next.assistant_message_id },
1819
+ { change: 'job.failed', jobId: next.id, jobStatus: 'failed' },
1820
+ ], { updatedAt: failedAt });
719
1821
  }
720
1822
  finally {
721
1823
  runningChatJobControllers.delete(next.id);
@@ -745,6 +1847,7 @@ function startLocalServer(opts) {
745
1847
  conversationId: req.body?.conversationId || undefined,
746
1848
  projectId: req.body?.projectId || undefined,
747
1849
  botId: req.body?.botId || undefined,
1850
+ attachments: Array.isArray(req.body?.attachments) ? req.body.attachments : undefined,
748
1851
  targetAgentId,
749
1852
  stream: true,
750
1853
  skipUserMessage: !!req.body?.skipUserMessage,
@@ -1072,6 +2175,10 @@ function startLocalServer(opts) {
1072
2175
  if (!isCliProvider) {
1073
2176
  return res.status(400).json({ error: 'Desktop auth refresh only supports Claude CLI or Codex CLI sessions.' });
1074
2177
  }
2178
+ const persisted = persistCliOauthSession(effectiveProviderId, accessToken, refreshToken, expiresAt);
2179
+ if (!persisted) {
2180
+ return res.status(500).json({ error: `Failed to persist ${effectiveProviderId} OAuth credentials locally.` });
2181
+ }
1075
2182
  const metadataJson = JSON.stringify({
1076
2183
  source: cli ? `desktop-cli:${cli}` : 'desktop-cli',
1077
2184
  });
@@ -1101,6 +2208,12 @@ function startLocalServer(opts) {
1101
2208
  metadataJson,
1102
2209
  });
1103
2210
  });
2211
+ if (effectiveProviderId === 'claude-cli') {
2212
+ const closedIdleSessions = (0, local_cli_pty_manager_1.getLocalCliPtySessionManager)().closeIdleClaudeSessions();
2213
+ if (closedIdleSessions > 0) {
2214
+ console.info(`[claude-auth] evicted ${closedIdleSessions} idle Claude PTY session(s) after reauth`);
2215
+ }
2216
+ }
1104
2217
  res.json({ ok: true, updated });
1105
2218
  }
1106
2219
  catch (err) {
@@ -1230,10 +2343,22 @@ function startLocalServer(opts) {
1230
2343
  }
1231
2344
  })();
1232
2345
  });
2346
+ app.get('/api/cli/model-options', (req, res) => {
2347
+ try {
2348
+ const provider = String(req.query.provider || '').trim();
2349
+ if (provider !== 'claude-cli' && provider !== 'codex-cli') {
2350
+ return res.status(400).json({ error: 'provider must be claude-cli or codex-cli' });
2351
+ }
2352
+ res.json({ provider, models: (0, cli_models_1.getCliModelOptions)(provider) });
2353
+ }
2354
+ catch (err) {
2355
+ res.status(500).json({ error: err.message });
2356
+ }
2357
+ });
1233
2358
  app.post('/api/bots', (req, res) => {
1234
2359
  (async () => {
1235
2360
  try {
1236
- const { provider, model, name, soulMd, memoryMd, toolsMd, skillsMd, apiKeyEnc, permissionMode, isDefault, roleLabel, roleClass, isActive, priority, isOrchestrator, is_orchestrator } = req.body;
2361
+ const { provider, model, name, soulMd, memoryMd, toolsMd, skillsMd, apiKeyEnc, permissionMode, isDefault, roleLabel, roleClass, orchestrationRoleLabel, orchestration_role_label, orchestrationRoleClass, orchestration_role_class, orchestrationIncludeUserPrompt, orchestration_include_user_prompt, isActive, priority, isOrchestrator, is_orchestrator, color, codexReasoningEffort, codex_reasoning_effort, codexReasoningSummary, codex_reasoning_summary, codexPersonality, codex_personality, codexServiceTier, codex_service_tier, codexSandboxPolicy, codex_sandbox_policy, codexApprovalPolicy, codex_approval_policy, claudeEffortLevel, claude_effort_level, claudeOutputStyle, claude_output_style, claudeFastMode, claude_fast_mode, claudePermissionsJson, claude_permissions_json, orchestrationRolesJson, orchestration_roles_json, } = req.body;
1237
2362
  if (!provider || !model || !name) {
1238
2363
  return res.status(400).json({ error: 'provider, model, and name are required' });
1239
2364
  }
@@ -1260,10 +2385,28 @@ function startLocalServer(opts) {
1260
2385
  isDefault,
1261
2386
  roleLabel,
1262
2387
  roleClass,
2388
+ orchestrationRoleLabel: orchestrationRoleLabel ?? orchestration_role_label,
2389
+ orchestrationRoleClass: orchestrationRoleClass ?? orchestration_role_class,
2390
+ orchestrationIncludeUserPrompt: orchestrationIncludeUserPrompt ?? orchestration_include_user_prompt,
1263
2391
  isActive,
1264
2392
  priority,
2393
+ color,
1265
2394
  isOrchestrator: isOrchestrator ?? is_orchestrator,
2395
+ codexReasoningEffort: codexReasoningEffort ?? codex_reasoning_effort,
2396
+ codexReasoningSummary: codexReasoningSummary ?? codex_reasoning_summary,
2397
+ codexPersonality: codexPersonality ?? codex_personality,
2398
+ codexServiceTier: codexServiceTier ?? codex_service_tier,
2399
+ codexSandboxPolicy: codexSandboxPolicy ?? codex_sandbox_policy,
2400
+ codexApprovalPolicy: codexApprovalPolicy ?? codex_approval_policy,
2401
+ claudeEffortLevel: claudeEffortLevel ?? claude_effort_level,
2402
+ claudeOutputStyle: claudeOutputStyle ?? claude_output_style,
2403
+ claudeFastMode: claudeFastMode ?? claude_fast_mode,
2404
+ claudePermissionsJson: claudePermissionsJson ?? claude_permissions_json,
2405
+ orchestrationRolesJson: normalizeRolesArray(orchestrationRolesJson ?? orchestration_roles_json),
1266
2406
  });
2407
+ syncCliBotConfigFiles(profile);
2408
+ reapCliBotSessions(profile.id);
2409
+ watchCliBotConfig(profile);
1267
2410
  res.status(201).json(profile);
1268
2411
  }
1269
2412
  catch (err) {
@@ -1301,22 +2444,63 @@ function startLocalServer(opts) {
1301
2444
  fields.roleLabel = b.roleLabel ?? b.role_label;
1302
2445
  if (b.roleClass !== undefined || b.role_class !== undefined)
1303
2446
  fields.roleClass = b.roleClass ?? b.role_class;
2447
+ if (b.orchestrationRoleLabel !== undefined || b.orchestration_role_label !== undefined)
2448
+ fields.orchestrationRoleLabel = b.orchestrationRoleLabel ?? b.orchestration_role_label;
2449
+ if (b.orchestrationRoleClass !== undefined || b.orchestration_role_class !== undefined)
2450
+ fields.orchestrationRoleClass = b.orchestrationRoleClass ?? b.orchestration_role_class;
2451
+ if (b.orchestrationIncludeUserPrompt !== undefined || b.orchestration_include_user_prompt !== undefined)
2452
+ fields.orchestrationIncludeUserPrompt = b.orchestrationIncludeUserPrompt ?? b.orchestration_include_user_prompt;
1304
2453
  if (b.isActive !== undefined || b.is_active !== undefined)
1305
2454
  fields.isActive = b.isActive ?? b.is_active;
1306
2455
  if (b.priority !== undefined)
1307
2456
  fields.priority = b.priority;
2457
+ if (b.color !== undefined)
2458
+ fields.color = b.color;
2459
+ if (b.purposeMd !== undefined || b.purpose_md !== undefined)
2460
+ fields.purposeMd = b.purposeMd ?? b.purpose_md;
1308
2461
  if (b.showThinking !== undefined || b.show_thinking !== undefined)
1309
2462
  fields.showThinking = b.showThinking ?? b.show_thinking;
1310
2463
  if (b.isOrchestrator !== undefined || b.is_orchestrator !== undefined)
1311
2464
  fields.isOrchestrator = b.isOrchestrator ?? b.is_orchestrator;
2465
+ if (b.codexReasoningEffort !== undefined || b.codex_reasoning_effort !== undefined)
2466
+ fields.codexReasoningEffort = b.codexReasoningEffort ?? b.codex_reasoning_effort;
2467
+ if (b.codexReasoningSummary !== undefined || b.codex_reasoning_summary !== undefined)
2468
+ fields.codexReasoningSummary = b.codexReasoningSummary ?? b.codex_reasoning_summary;
2469
+ if (b.codexPersonality !== undefined || b.codex_personality !== undefined)
2470
+ fields.codexPersonality = b.codexPersonality ?? b.codex_personality;
2471
+ if (b.codexServiceTier !== undefined || b.codex_service_tier !== undefined)
2472
+ fields.codexServiceTier = b.codexServiceTier ?? b.codex_service_tier;
2473
+ if (b.codexSandboxPolicy !== undefined || b.codex_sandbox_policy !== undefined)
2474
+ fields.codexSandboxPolicy = b.codexSandboxPolicy ?? b.codex_sandbox_policy;
2475
+ if (b.codexApprovalPolicy !== undefined || b.codex_approval_policy !== undefined)
2476
+ fields.codexApprovalPolicy = b.codexApprovalPolicy ?? b.codex_approval_policy;
2477
+ if (b.claudeEffortLevel !== undefined || b.claude_effort_level !== undefined)
2478
+ fields.claudeEffortLevel = b.claudeEffortLevel ?? b.claude_effort_level;
2479
+ if (b.claudeOutputStyle !== undefined || b.claude_output_style !== undefined)
2480
+ fields.claudeOutputStyle = b.claudeOutputStyle ?? b.claude_output_style;
2481
+ if (b.claudeFastMode !== undefined || b.claude_fast_mode !== undefined)
2482
+ fields.claudeFastMode = b.claudeFastMode ?? b.claude_fast_mode;
2483
+ if (b.claudePermissionsJson !== undefined || b.claude_permissions_json !== undefined)
2484
+ fields.claudePermissionsJson = b.claudePermissionsJson ?? b.claude_permissions_json;
2485
+ if (b.orchestrationRolesJson !== undefined || b.orchestration_roles_json !== undefined) {
2486
+ fields.orchestrationRolesJson = normalizeRolesArray(b.orchestrationRolesJson ?? b.orchestration_roles_json);
2487
+ }
1312
2488
  if (isConnectedMode()) {
1313
2489
  const runtime = (0, server_runtime_1.getRuntimeConnectionConfig)();
1314
2490
  const auth = await getHydratedDesktopAuth();
1315
2491
  return res.json(await (0, server_adapter_1.updateServerBot)(auth, runtime, req.params.id, fields));
1316
2492
  }
2493
+ const previous = data.getAgentProfile(req.params.id);
1317
2494
  const updated = data.updateAgentProfile(req.params.id, fields);
1318
2495
  if (!updated)
1319
2496
  return res.status(404).json({ error: 'Not found' });
2497
+ syncCliBotConfigFiles(updated);
2498
+ reapCliBotSessions(updated.id);
2499
+ watchCliBotConfig(updated);
2500
+ if (previous && previous.id !== updated.id) {
2501
+ reapCliBotSessions(previous.id);
2502
+ closeCliBotConfigWatcher(previous.id);
2503
+ }
1320
2504
  res.json(updated);
1321
2505
  }
1322
2506
  catch (err) {
@@ -1352,6 +2536,8 @@ function startLocalServer(opts) {
1352
2536
  const deleted = data.deleteAgentProfile(req.params.id);
1353
2537
  if (!deleted)
1354
2538
  return res.status(404).json({ error: 'Not found' });
2539
+ closeCliBotConfigWatcher(req.params.id);
2540
+ reapCliBotSessions(req.params.id);
1355
2541
  res.json({ ok: true });
1356
2542
  }
1357
2543
  catch (err) {
@@ -1373,18 +2559,52 @@ function startLocalServer(opts) {
1373
2559
  return res.status(404).json({ error: 'Not found' });
1374
2560
  const convCount = data.countConversations(req.params.id);
1375
2561
  const messageCount = data.countMessagesForBot(req.params.id);
2562
+ const now = new Date();
2563
+ const todayStart = new Date(now);
2564
+ todayStart.setHours(0, 0, 0, 0);
2565
+ const oneWeekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
2566
+ const twoWeeksAgo = new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000);
2567
+ const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
2568
+ const decisionCount = data.countDecisionsForBot(req.params.id);
2569
+ const messagesToday = data.countMessagesForBotSince(req.params.id, todayStart.toISOString());
2570
+ const convsThisWeek = data.countBotConversationsUpdatedBetween(req.params.id, oneWeekAgo.toISOString());
2571
+ const convsPrevWeek = data.countBotConversationsUpdatedBetween(req.params.id, twoWeeksAgo.toISOString(), oneWeekAgo.toISOString());
2572
+ const tokenUsage = data.sumBotTokenUsageSince(req.params.id, monthStart.toISOString());
2573
+ const activeDays = data.countBotActiveDays(req.params.id);
2574
+ const createdAt = profile.created_at || now.toISOString();
2575
+ const daysSinceCreation = Math.max(1, Math.ceil((now.getTime() - new Date(createdAt).getTime()) / (24 * 60 * 60 * 1000)));
2576
+ const uptimePercent = Math.min(100, Math.round((activeDays / daysSinceCreation) * 100));
2577
+ const convTrend = convsThisWeek > convsPrevWeek ? 'up' : convsThisWeek < convsPrevWeek ? 'down' : 'flat';
2578
+ const estimatedCost = Math.round(tokenUsage * 0.000003 * 100) / 100;
1376
2579
  const recentConversations = data.listBotConversationActivity(req.params.id, 8).map((conversation) => ({
1377
2580
  id: conversation.id,
1378
- agent_id: conversation.agent_id,
1379
2581
  title: conversation.title,
1380
- updated_at: conversation.updated_at,
1381
- created_at: conversation.created_at,
1382
- message_count: conversation.message_count,
1383
- bot_message_count: conversation.bot_message_count,
1384
- bot_last_message_at: conversation.bot_last_message_at,
1385
- project_name: conversation.project_name,
2582
+ updatedAt: conversation.updated_at,
2583
+ createdAt: conversation.created_at,
2584
+ messageCount: conversation.message_count,
2585
+ botMessageCount: conversation.bot_message_count,
2586
+ botLastMessageAt: conversation.bot_last_message_at,
2587
+ projectName: conversation.project_name,
1386
2588
  }));
1387
- res.json({ profile, conversationCount: convCount, messageCount, recentConversations });
2589
+ const lastActivityAt = recentConversations[0]?.botLastMessageAt || recentConversations[0]?.updatedAt || null;
2590
+ res.json({
2591
+ botId: req.params.id,
2592
+ botName: profile.name || profile.id,
2593
+ status: profile.is_active === 0 ? 'inactive' : 'active',
2594
+ createdAt,
2595
+ totalConversations: convCount,
2596
+ totalMessages: messageCount,
2597
+ totalDecisions: decisionCount,
2598
+ messagesToday,
2599
+ lastActivityAt,
2600
+ uptimePercent,
2601
+ avgResponseTime: null,
2602
+ convTrend,
2603
+ tokenUsage,
2604
+ estimatedCost,
2605
+ recentConversations,
2606
+ profile,
2607
+ });
1388
2608
  }
1389
2609
  catch (err) {
1390
2610
  res.status(500).json({ error: err.message });
@@ -1423,7 +2643,7 @@ function startLocalServer(opts) {
1423
2643
  app.post('/api/conversations', (req, res) => {
1424
2644
  (async () => {
1425
2645
  try {
1426
- const { agentId, title, source, projectId, projectName } = req.body;
2646
+ const { agentId, botIds, initialBotId, title, source, projectId, projectName, topicId } = req.body;
1427
2647
  if (isConnectedMode()) {
1428
2648
  const runtime = (0, server_runtime_1.getRuntimeConnectionConfig)();
1429
2649
  const auth = await getHydratedDesktopAuth();
@@ -1438,12 +2658,27 @@ function startLocalServer(opts) {
1438
2658
  ...(projectName ? { project_name: projectName } : {}),
1439
2659
  });
1440
2660
  }
1441
- if (!agentId)
1442
- return res.status(400).json({ error: 'agentId is required' });
1443
- const conv = data.createConversation(agentId, title, source, {
2661
+ const normalizedBotIds = Array.isArray(botIds)
2662
+ ? botIds.map((value) => String(value || '').trim()).filter(Boolean)
2663
+ : [];
2664
+ const resolvedInitialBotId = String(initialBotId || agentId || normalizedBotIds[0] || '').trim();
2665
+ if (!resolvedInitialBotId)
2666
+ return res.status(400).json({ error: 'agentId or botIds is required' });
2667
+ const conv = data.createConversation(resolvedInitialBotId, title, source, {
1444
2668
  projectId: projectId ?? null,
1445
2669
  projectName: projectName ?? null,
2670
+ botIds: normalizedBotIds,
2671
+ initialBotId: resolvedInitialBotId,
1446
2672
  });
2673
+ if (topicId) {
2674
+ try {
2675
+ data.upsertConversationTopicSegment(conv.id, String(topicId));
2676
+ }
2677
+ catch { /* best-effort */ }
2678
+ }
2679
+ emitConversationSyncEvents(conv.id, [
2680
+ { change: 'conversation.created' },
2681
+ ]);
1447
2682
  res.status(201).json(conv);
1448
2683
  }
1449
2684
  catch (err) {
@@ -1488,9 +2723,14 @@ function startLocalServer(opts) {
1488
2723
  const auth = await getHydratedDesktopAuth();
1489
2724
  return res.json(await (0, server_adapter_1.deleteServerConversation)(auth, runtime, req.params.id));
1490
2725
  }
2726
+ const existing = data.getConversation(req.params.id);
2727
+ if (!existing)
2728
+ return res.status(404).json({ error: 'Not found' });
2729
+ closeCliSessionsForConversation(req.params.id, 'conversation_deleted');
1491
2730
  const deleted = data.deleteConversation(req.params.id);
1492
2731
  if (!deleted)
1493
2732
  return res.status(404).json({ error: 'Not found' });
2733
+ emitDeletedConversationSyncEvent(existing);
1494
2734
  res.json({ ok: true });
1495
2735
  }
1496
2736
  catch (err) {
@@ -1510,6 +2750,9 @@ function startLocalServer(opts) {
1510
2750
  if (!conv)
1511
2751
  return res.status(404).json({ error: 'Not found' });
1512
2752
  data.updateConversation(req.params.id, req.body);
2753
+ emitConversationSyncEvents(req.params.id, [
2754
+ { change: 'conversation.updated' },
2755
+ ]);
1513
2756
  res.json(data.getConversation(req.params.id));
1514
2757
  }
1515
2758
  catch (err) {
@@ -1614,6 +2857,12 @@ function startLocalServer(opts) {
1614
2857
  const auth = await getHydratedDesktopAuth();
1615
2858
  return res.json(await (0, server_adapter_1.deleteServerProject)(auth, runtime, req.params.id));
1616
2859
  }
2860
+ const projectConversationIds = data.listConversations({ limit: 100000 })
2861
+ .filter((conv) => conv.project_id === req.params.id)
2862
+ .map((conv) => conv.id);
2863
+ for (const conversationId of projectConversationIds) {
2864
+ closeCliSessionsForConversation(conversationId, 'project_deleted');
2865
+ }
1617
2866
  const deleted = data.deleteProject(req.params.id);
1618
2867
  if (!deleted)
1619
2868
  return res.status(404).json({ error: 'Not found' });
@@ -1777,6 +3026,10 @@ function startLocalServer(opts) {
1777
3026
  const auth = await getHydratedDesktopAuth();
1778
3027
  return res.json(await (0, server_adapter_1.deleteServerTopic)(auth, runtime, req.params.id));
1779
3028
  }
3029
+ const topic = data.getTopic(req.params.id);
3030
+ for (const segment of topic?.segments || []) {
3031
+ closeCliSessionsForConversation(segment.conversation_id, 'topic_deleted');
3032
+ }
1780
3033
  const deleted = data.deleteTopic(req.params.id);
1781
3034
  if (!deleted)
1782
3035
  return res.status(404).json({ error: 'Not found' });
@@ -1998,6 +3251,9 @@ function startLocalServer(opts) {
1998
3251
  const botId = req.body?.botId ? String(req.body.botId) : undefined;
1999
3252
  const agentName = req.body?.agentName ? String(req.body.agentName) : undefined;
2000
3253
  const msg = data.addMessage(req.params.id, role, content, model, undefined, botId, agentName);
3254
+ emitConversationSyncEvents(req.params.id, [
3255
+ { change: 'message.created', messageId: msg.id },
3256
+ ]);
2001
3257
  res.status(201).json(msg);
2002
3258
  }
2003
3259
  catch (err) {
@@ -2055,6 +3311,9 @@ function startLocalServer(opts) {
2055
3311
  });
2056
3312
  if (!updated)
2057
3313
  return res.status(404).json({ error: 'Message not found' });
3314
+ emitConversationSyncEvents(updated.conversation_id, [
3315
+ { change: 'message.updated', messageId: updated.id },
3316
+ ]);
2058
3317
  res.json(updated);
2059
3318
  }
2060
3319
  catch (err) {
@@ -2201,11 +3460,18 @@ function startLocalServer(opts) {
2201
3460
  app.post('/api/todo/:id/worker-complete', (req, res) => {
2202
3461
  try {
2203
3462
  const taskId = Number(req.params.id);
2204
- const { outputSummary, artifactRefs, handoffPrompt, insertTask, expectedVersion, expectedLastUpdated, } = req.body || {};
3463
+ const { outputSummary, artifactRefs, handoffPrompt, qaResult, findings, requiresFixTask, insertTask, expectedVersion, expectedLastUpdated, } = req.body || {};
3464
+ const rawQaResult = qaResult === undefined ? undefined : String(qaResult).trim().toLowerCase();
3465
+ const normalizedQaResult = rawQaResult === 'pass' || rawQaResult === 'fail' || rawQaResult === 'not_applicable'
3466
+ ? rawQaResult
3467
+ : undefined;
2205
3468
  const result = data.completeTodoTaskByWorker(taskId, {
2206
3469
  outputSummary: outputSummary === undefined ? undefined : String(outputSummary),
2207
3470
  artifactRefs: Array.isArray(artifactRefs) ? artifactRefs : undefined,
2208
3471
  handoffPrompt: handoffPrompt === undefined ? undefined : String(handoffPrompt),
3472
+ qaResult: normalizedQaResult,
3473
+ findings: Array.isArray(findings) ? findings.map((v) => String(v || '').trim()).filter(Boolean) : undefined,
3474
+ requiresFixTask: requiresFixTask === undefined ? undefined : Boolean(requiresFixTask),
2209
3475
  insertTask: insertTask ? {
2210
3476
  title: String(insertTask.title || ''),
2211
3477
  prompt: String(insertTask.prompt || ''),
@@ -2367,19 +3633,19 @@ function startLocalServer(opts) {
2367
3633
  return res.json(result.messages);
2368
3634
  }
2369
3635
  if (hasDirectRange) {
2370
- return res.json(data.getMessagesInRange(req.params.id, startSeq, endSeq));
3636
+ return res.json(serializeMessageHistoryForRequest(req, data.getMessagesInRange(req.params.id, startSeq, endSeq)));
2371
3637
  }
2372
3638
  if (beforeSeq > 0) {
2373
3639
  // Backward paging: get N rounds or messages before given seq, returned in ASC order
2374
3640
  const msgs = rounds > 0
2375
3641
  ? data.getMessageRoundsBefore(req.params.id, beforeSeq, rounds)
2376
3642
  : data.getMessagesBefore(req.params.id, beforeSeq, limit);
2377
- res.json(msgs);
3643
+ res.json(serializeMessageHistoryForRequest(req, msgs));
2378
3644
  }
2379
3645
  else {
2380
3646
  const offset = parseInt(req.query.offset, 10) || 0;
2381
3647
  const msgs = data.getMessages(req.params.id, { limit, offset });
2382
- res.json(msgs);
3648
+ res.json(serializeMessageHistoryForRequest(req, msgs));
2383
3649
  }
2384
3650
  }
2385
3651
  catch (err) {
@@ -2437,90 +3703,51 @@ function startLocalServer(opts) {
2437
3703
  res.status(500).json({ error: err.message });
2438
3704
  }
2439
3705
  });
3706
+ // ─── Chat job gateway (shared by POST + GET endpoints) ────
3707
+ const chatGateway = new local_chat_execution_1.LocalChatExecution({
3708
+ runQueuedChatJobs,
3709
+ runningChatJobControllers,
3710
+ finalizeCancelledChatJobMessage,
3711
+ persistInlineAttachments,
3712
+ });
2440
3713
  app.post('/api/chat/jobs', async (req, res) => {
2441
3714
  try {
2442
- if (isConnectedMode()) {
2443
- return res.status(501).json({ error: 'Background chat jobs are local-mode only' });
2444
- }
2445
- const { conversationId, message, botId, pinnedMessageIds, topicId, projectId, orchestrationEnabled, } = req.body || {};
2446
- if (!message || !String(message).trim()) {
2447
- return res.status(400).json({ error: 'message is required' });
2448
- }
2449
- let profile = botId ? data.getAgentProfile(String(botId)) : data.getDefaultAgentProfile();
2450
- if (!profile) {
2451
- return res.status(400).json({ error: 'No bot configured. Create one first.' });
2452
- }
2453
- const shouldUseOrchestratorMode = orchestrationEnabled !== false && (0, orchestrator_profile_1.isOrchestratorProfile)(profile);
2454
- if (shouldUseOrchestratorMode) {
2455
- return res.status(400).json({ error: 'Background chat jobs do not support orchestrator mode yet.' });
2456
- }
2457
- let convId = conversationId ? String(conversationId) : '';
2458
- if (convId) {
2459
- const latestJob = data.getLatestConversationChatJob(convId);
2460
- if (latestJob && (latestJob.status === 'queued' || latestJob.status === 'running')) {
2461
- return res.status(409).json({ error: 'This conversation already has a pending response.' });
2462
- }
2463
- }
2464
- if (!convId) {
2465
- let topicProjectId = null;
2466
- if (topicId) {
2467
- const topic = data.getTopic(String(topicId));
2468
- topicProjectId = topic?.project_id ?? null;
2469
- }
2470
- const requestedProjectId = (projectId ? String(projectId) : null) || topicProjectId;
2471
- const conv = data.createConversation(profile.id, '', 'local', {
2472
- projectId: requestedProjectId,
2473
- });
2474
- convId = conv.id;
2475
- }
2476
- if (topicId && convId) {
2477
- try {
2478
- data.upsertConversationTopicSegment(convId, String(topicId));
2479
- }
2480
- catch { /* best effort */ }
2481
- }
2482
- if (!topicId && projectId && convId) {
2483
- const selectedProject = data.getProject(String(projectId));
2484
- if (selectedProject) {
2485
- data.updateConversation(convId, {
2486
- projectId: selectedProject.id,
2487
- projectName: selectedProject.name,
2488
- });
2489
- }
3715
+ const isRelay = req.headers?.['x-funolio-relay'] === '1';
3716
+ if (isConnectedMode() && !isRelay) {
3717
+ const runtime = (0, server_runtime_1.getRuntimeConnectionConfig)();
3718
+ const auth = await getHydratedDesktopAuth();
3719
+ const result = await (0, server_adapter_1.createServerChatJob)(auth, runtime, req.body || {});
3720
+ return res.status(201).json(result);
2490
3721
  }
2491
- const savedUserMessage = data.addMessage(convId, 'user', String(message));
2492
- (0, context_window_1.incrementTurnCount)(convId);
2493
- const assistantMessage = data.addMessage(convId, 'assistant', '', profile.model || undefined, undefined, profile.id, profile.name);
2494
- const conv = data.getConversation(convId);
2495
- if (conv && !conv.title?.trim() && (conv.turn_count || 0) <= 1) {
2496
- const shortTitle = String(message).slice(0, 60).replace(/\n/g, ' ').trim();
2497
- data.updateConversation(convId, { title: shortTitle || 'New Chat' });
3722
+ const result = await chatGateway.createJob(req.body || {});
3723
+ const canonicalResponse = {
3724
+ conversationId: result.conversationId,
3725
+ jobs: result.jobs.map((j) => ({ id: j.id, botId: j.botId, status: j.status })),
3726
+ };
3727
+ if (isRelay) {
3728
+ // In connected mode, the dep call inside chatGateway.createJob() is
3729
+ // a no-op (isConnectedMode() guard). Force-run the queue so the
3730
+ // relay-created job actually starts local execution.
3731
+ runQueuedChatJobs({ force: true });
3732
+ console.log(JSON.stringify({
3733
+ ts: new Date().toISOString(),
3734
+ level: 'info',
3735
+ event: 'JOB_CREATED_VIA_RELAY',
3736
+ jobId: result.jobs[0]?.id,
3737
+ conversationId: result.conversationId,
3738
+ }));
2498
3739
  }
2499
- const job = data.createChatJob({
2500
- conversationId: convId,
2501
- userMessageId: savedUserMessage.id,
2502
- assistantMessageId: assistantMessage.id,
2503
- botId: profile.id,
2504
- status: 'queued',
2505
- requestJson: JSON.stringify({
2506
- pinnedMessageIds: Array.isArray(pinnedMessageIds) ? pinnedMessageIds : [],
2507
- topicId: topicId ? String(topicId) : null,
2508
- projectId: projectId ? String(projectId) : null,
2509
- orchestrationEnabled: orchestrationEnabled !== false,
2510
- }),
2511
- });
2512
- void runQueuedChatJobs();
2513
- res.status(201).json({
2514
- ok: true,
2515
- conversationId: convId,
2516
- userMessageId: savedUserMessage.id,
2517
- assistantMessageId: assistantMessage.id,
2518
- jobId: job.id,
2519
- status: job.status,
2520
- });
3740
+ res.status(201).json(canonicalResponse);
2521
3741
  }
2522
3742
  catch (err) {
2523
- res.status(500).json({ error: err.message });
3743
+ const msg = err.message || '';
3744
+ if (msg === 'message is required' || msg.startsWith('No bot configured')) {
3745
+ return res.status(400).json({ error: msg });
3746
+ }
3747
+ if (msg.includes('already has a pending response')) {
3748
+ return res.status(409).json({ error: msg });
3749
+ }
3750
+ res.status(500).json({ error: msg });
2524
3751
  }
2525
3752
  });
2526
3753
  app.post('/api/chat/jobs/:id/cancel', async (req, res) => {
@@ -2542,19 +3769,114 @@ function startLocalServer(opts) {
2542
3769
  controller.abort();
2543
3770
  }
2544
3771
  else {
2545
- finalizeCancelledChatJobMessage(job);
2546
- data.touchConversationActivity(job.conversation_id);
3772
+ finalizeCancelledChatJobMessage(job);
3773
+ data.touchConversationActivity(job.conversation_id, ts);
3774
+ emitConversationSyncEvents(job.conversation_id, [
3775
+ { change: 'message.updated', messageId: job.assistant_message_id },
3776
+ { change: 'job.cancelled', jobId: job.id, jobStatus: 'cancelled' },
3777
+ ], { updatedAt: ts });
3778
+ }
3779
+ res.json({ ok: true, status: 'cancelled' });
3780
+ }
3781
+ catch (err) {
3782
+ res.status(500).json({ error: err.message });
3783
+ }
3784
+ });
3785
+ // ─── Chat job GET endpoints ────────────────────────────────
3786
+ app.get('/api/chat/jobs', async (req, res) => {
3787
+ try {
3788
+ const conversationId = req.query?.conversationId;
3789
+ if (!conversationId || typeof conversationId !== 'string') {
3790
+ return res.status(400).json({ error: 'conversationId query parameter is required' });
3791
+ }
3792
+ const result = await chatGateway.listJobs(conversationId);
3793
+ res.json(result);
3794
+ }
3795
+ catch (err) {
3796
+ res.status(500).json({ error: err.message });
3797
+ }
3798
+ });
3799
+ app.get('/api/chat/jobs/:id', async (req, res) => {
3800
+ try {
3801
+ const result = await chatGateway.getJobStatus(req.params.id);
3802
+ res.json(result);
3803
+ }
3804
+ catch (err) {
3805
+ if (err.message === 'Job not found') {
3806
+ return res.status(404).json({ error: 'Job not found' });
3807
+ }
3808
+ res.status(500).json({ error: err.message });
3809
+ }
3810
+ });
3811
+ app.get('/api/chat/jobs/:id/stream', async (req, res) => {
3812
+ try {
3813
+ res.writeHead(200, {
3814
+ 'Content-Type': 'text/event-stream',
3815
+ 'Cache-Control': 'no-cache',
3816
+ 'Connection': 'keep-alive',
3817
+ });
3818
+ let closed = false;
3819
+ req.on('close', () => { closed = true; });
3820
+ for await (const event of chatGateway.streamJob(req.params.id)) {
3821
+ if (closed)
3822
+ break;
3823
+ res.write(`data: ${JSON.stringify(event)}\n\n`);
3824
+ }
3825
+ if (!closed) {
3826
+ res.write('data: [DONE]\n\n');
3827
+ res.end();
3828
+ }
3829
+ }
3830
+ catch (err) {
3831
+ if (!res.headersSent) {
3832
+ if (err.message === 'Job not found') {
3833
+ return res.status(404).json({ error: 'Job not found' });
3834
+ }
3835
+ res.status(500).json({ error: err.message });
3836
+ }
3837
+ else {
3838
+ res.write(`data: ${JSON.stringify({ type: 'error', error: err.message })}\n\n`);
3839
+ res.write('data: [DONE]\n\n');
3840
+ res.end();
2547
3841
  }
2548
- res.json({ ok: true, status: 'cancelled' });
2549
- }
2550
- catch (err) {
2551
- res.status(500).json({ error: err.message });
2552
3842
  }
2553
3843
  });
2554
3844
  // ─── Chat (SSE streaming) ──────────────────────────────────
3845
+ app.get('/api/chat/sync/stream', async (req, res) => {
3846
+ res.writeHead(200, {
3847
+ 'Content-Type': 'text/event-stream',
3848
+ 'Cache-Control': 'no-cache',
3849
+ 'Connection': 'keep-alive',
3850
+ });
3851
+ res.write(`event: ready\ndata: ${JSON.stringify({ ok: true })}\n\n`);
3852
+ const heartbeat = setInterval(() => {
3853
+ try {
3854
+ res.write(`event: ping\ndata: ${JSON.stringify({ ts: Date.now() })}\n\n`);
3855
+ }
3856
+ catch {
3857
+ // Connection cleanup handled below.
3858
+ }
3859
+ }, 15000);
3860
+ const unsubscribe = (0, chat_sync_1.subscribeChatSync)((event) => {
3861
+ try {
3862
+ res.write(`event: sync\ndata: ${JSON.stringify(event)}\n\n`);
3863
+ }
3864
+ catch {
3865
+ // Connection cleanup handled below.
3866
+ }
3867
+ });
3868
+ req.on('close', () => {
3869
+ clearInterval(heartbeat);
3870
+ unsubscribe();
3871
+ res.end();
3872
+ });
3873
+ });
2555
3874
  app.post('/api/conversations/:id/chat-job/cancel', async (req, res) => {
2556
3875
  try {
2557
- const latestJob = data.getLatestConversationChatJob(req.params.id);
3876
+ const requestedBotId = String(req.body?.botId || '').trim();
3877
+ const latestJob = requestedBotId
3878
+ ? data.getLatestConversationBotChatJob(req.params.id, requestedBotId)
3879
+ : data.getLatestConversationChatJob(req.params.id);
2558
3880
  if (!latestJob)
2559
3881
  return res.status(404).json({ error: 'Chat job not found' });
2560
3882
  if (latestJob.status === 'completed' || latestJob.status === 'failed' || latestJob.status === 'cancelled') {
@@ -2572,7 +3894,11 @@ function startLocalServer(opts) {
2572
3894
  }
2573
3895
  else {
2574
3896
  finalizeCancelledChatJobMessage(latestJob);
2575
- data.touchConversationActivity(latestJob.conversation_id);
3897
+ data.touchConversationActivity(latestJob.conversation_id, ts);
3898
+ emitConversationSyncEvents(latestJob.conversation_id, [
3899
+ { change: 'message.updated', messageId: latestJob.assistant_message_id },
3900
+ { change: 'job.cancelled', jobId: latestJob.id, jobStatus: 'cancelled' },
3901
+ ], { updatedAt: ts });
2576
3902
  void runQueuedChatJobs();
2577
3903
  }
2578
3904
  res.json({ ok: true, status: 'cancelled', jobId: latestJob.id });
@@ -2590,15 +3916,44 @@ function startLocalServer(opts) {
2590
3916
  routeAbortController.abort();
2591
3917
  };
2592
3918
  req.on('close', abortOnClientClose);
3919
+ res.on('close', abortOnClientClose);
2593
3920
  try {
2594
- let { conversationId, message, botId, skipUserMessage, pinnedMessageIds, topicId, projectId, workflowTemplateId, orchestrationEnabled, chatJobId, assistantMessageId, persistAssistantPlaceholder, } = req.body;
3921
+ let { conversationId, message, botId, skipUserMessage, pinnedMessageIds, topicId, projectId, workflowTemplateId, orchestrationEnabled, chatJobId, assistantMessageId, persistAssistantPlaceholder, botIds, attachments, } = req.body;
2595
3922
  if (!message)
2596
3923
  return res.status(400).json({ error: 'message is required' });
3924
+ const requestedBotIds = [
3925
+ typeof botId === 'string' ? botId : '',
3926
+ ...(Array.isArray(botIds) ? botIds : []),
3927
+ ]
3928
+ .map((value) => String(value || '').trim())
3929
+ .filter((value, index, list) => !!value && list.indexOf(value) === index);
3930
+ const resolvedBotId = requestedBotIds[0] || null;
3931
+ const normalizedOrchestrationEnabled = typeof orchestrationEnabled === 'boolean'
3932
+ ? orchestrationEnabled
3933
+ : typeof req.body?.orchestrate === 'boolean'
3934
+ ? req.body.orchestrate
3935
+ : true;
3936
+ // ─── token.txt §6: bench-prefix tagging ─────────────────────────
3937
+ // Detect [bench:<id>] at the start of the user message OR
3938
+ // X-Test-Run-Id header. If present, strip the prefix from the
3939
+ // message (so the LLM never sees it / token counts aren't
3940
+ // inflated) and remember testRunId + turnIndex for the
3941
+ // LlmUsageLog row written after the LLM call returns.
3942
+ const __benchHeader = String(req.headers?.['x-test-run-id'] || '').trim();
3943
+ const __turnIndexHeader = String(req.headers?.['x-test-turn-index'] || '').trim();
3944
+ const __turnIndexParsed = __turnIndexHeader ? parseInt(__turnIndexHeader, 10) : null;
3945
+ const __benchResult = (0, bench_prefix_1.extractBenchPrefix)(typeof message === 'string' ? message : '');
3946
+ const __testRunId = __benchHeader || __benchResult.testRunId || null;
3947
+ if (__benchResult.testRunId && typeof message === 'string') {
3948
+ message = __benchResult.cleanMessage;
3949
+ }
3950
+ const __benchUserMessage = typeof message === 'string' ? message : null;
3951
+ // ─── end bench-prefix block ─────────────────────────────────────
2597
3952
  if (await relayConnectedChat(req, res)) {
2598
3953
  return;
2599
3954
  }
2600
3955
  // Resolve bot
2601
- let profile = botId ? data.getAgentProfile(botId) : data.getDefaultAgentProfile();
3956
+ let profile = resolvedBotId ? data.getAgentProfile(resolvedBotId) : data.getDefaultAgentProfile();
2602
3957
  if (!profile) {
2603
3958
  // Auto-create a default profile from the DB-backed provider connection if available.
2604
3959
  const providerConnection = data.listProviderConnections().find((conn) => conn.access_mode === 'cli' || !!conn.api_key_enc);
@@ -2614,7 +3969,11 @@ function startLocalServer(opts) {
2614
3969
  if (!profile)
2615
3970
  return res.status(400).json({ error: 'No bot configured. Create one first.' });
2616
3971
  }
3972
+ const incomingAttachments = parseChatAttachmentInputs(attachments);
3973
+ const shouldUseOrchestratorMode = normalizedOrchestrationEnabled !== false && (data.isClerkOrchestratorEnabled() || (0, orchestrator_profile_1.isOrchestratorProfile)(profile));
2617
3974
  // Resolve or create conversation
3975
+ let conversationCreated = false;
3976
+ let turnStartSyncRevision = null;
2618
3977
  let convId = conversationId;
2619
3978
  if (!convId) {
2620
3979
  // Create with empty title so auto-title can fill it in after first response
@@ -2626,8 +3985,14 @@ function startLocalServer(opts) {
2626
3985
  const requestedProjectId = (projectId ? String(projectId) : null) || topicProjectId;
2627
3986
  const conv = data.createConversation(profile.id, '', 'local', {
2628
3987
  projectId: requestedProjectId,
3988
+ botIds: requestedBotIds.length > 0 ? requestedBotIds : [profile.id],
3989
+ initialBotId: profile.id,
2629
3990
  });
2630
3991
  convId = conv.id;
3992
+ conversationCreated = true;
3993
+ }
3994
+ if (convId) {
3995
+ data.syncConversationParticipants(convId, requestedBotIds.length > 0 ? requestedBotIds : [profile.id], { initialBotId: profile.id, replace: true });
2631
3996
  }
2632
3997
  // Link conversation to topic if topicId provided
2633
3998
  if (topicId && convId) {
@@ -2703,8 +4068,12 @@ function startLocalServer(opts) {
2703
4068
  };
2704
4069
  // Save user message (skip if multi-bot call where first bot already saved it)
2705
4070
  let savedUserMessage = null;
4071
+ let persistedAttachments = [];
2706
4072
  if (!skipUserMessage) {
2707
- savedUserMessage = data.addMessage(convId, 'user', message);
4073
+ persistedAttachments = incomingAttachments.length > 0
4074
+ ? persistInlineAttachments(convId, incomingAttachments)
4075
+ : [];
4076
+ savedUserMessage = data.addMessage(convId, 'user', message, undefined, undefined, undefined, undefined, undefined, persistedAttachments.length > 0 ? JSON.stringify(persistedAttachments) : null);
2708
4077
  (0, context_window_1.incrementTurnCount)(convId);
2709
4078
  const convForPolicy = data.getConversation(convId);
2710
4079
  const effectiveProjectId = projectId ? String(projectId) : (convForPolicy?.project_id || undefined);
@@ -2729,24 +4098,232 @@ function startLocalServer(opts) {
2729
4098
  });
2730
4099
  }
2731
4100
  }
2732
- if (!assistantMessageId && persistAssistantPlaceholder === true) {
4101
+ if (savedUserMessage) {
4102
+ turnStartSyncRevision = emitConversationSyncEvents(convId, [
4103
+ ...(conversationCreated ? [{ change: 'conversation.created' }] : []),
4104
+ { change: 'message.created', messageId: savedUserMessage.id },
4105
+ ]);
4106
+ }
4107
+ const interceptedSlashCommand = (0, slash_commands_1.classifySlashCommand)(message, profile?.provider);
4108
+ if (interceptedSlashCommand) {
4109
+ if (!assistantMessageId && persistAssistantPlaceholder === true) {
4110
+ const placeholder = data.addMessage(convId, 'assistant', '', buildConfiguredMessageModel(profile), undefined, profile?.id || undefined, profile?.name || undefined);
4111
+ assistantMessageId = placeholder.id;
4112
+ turnStartSyncRevision = emitConversationSyncEvents(convId, [
4113
+ { change: 'message.created', messageId: placeholder.id },
4114
+ ], turnStartSyncRevision ? { revision: turnStartSyncRevision } : undefined);
4115
+ }
4116
+ res.writeHead(200, {
4117
+ 'Content-Type': 'text/event-stream',
4118
+ 'Cache-Control': 'no-cache',
4119
+ 'Connection': 'keep-alive',
4120
+ 'X-Conversation-Id': convId,
4121
+ });
4122
+ const sendSlashEvent = (event, payload) => {
4123
+ if (responseEnded)
4124
+ return;
4125
+ res.write(`event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`);
4126
+ };
4127
+ sendSlashEvent('meta', {
4128
+ conversationId: convId,
4129
+ botId: profile.id,
4130
+ assistantMessageId: assistantMessageId || null,
4131
+ });
4132
+ if (interceptedSlashCommand.kind === 'rotate') {
4133
+ data.upsertCliSessionEpoch({
4134
+ conversationId: convId,
4135
+ botId: profile.id,
4136
+ provider: profile.provider,
4137
+ sessionId: '',
4138
+ epochTurnCount: 0,
4139
+ lastInputTokens: 0,
4140
+ lastOutputTokens: 0,
4141
+ resetReason: interceptedSlashCommand.resetReason,
4142
+ epochStartedAt: localTimestamp(),
4143
+ lastUsedAt: localTimestamp(),
4144
+ });
4145
+ reapCliBotSessions(profile.id);
4146
+ if (assistantMessageId) {
4147
+ data.updateMessage(assistantMessageId, {
4148
+ content: interceptedSlashCommand.summary,
4149
+ model: buildConfiguredMessageModel(profile),
4150
+ botId: profile.id,
4151
+ agentName: profile.name,
4152
+ });
4153
+ emitConversationSyncEvents(convId, [
4154
+ { change: 'message.updated', messageId: assistantMessageId },
4155
+ ]);
4156
+ }
4157
+ sendSlashEvent('done', {
4158
+ content: interceptedSlashCommand.summary,
4159
+ });
4160
+ responseEnded = true;
4161
+ res.end();
4162
+ return;
4163
+ }
4164
+ if (interceptedSlashCommand.kind === 'settings-ui') {
4165
+ sendSlashEvent('slash_command', {
4166
+ kind: interceptedSlashCommand.kind,
4167
+ command: interceptedSlashCommand.command,
4168
+ provider: interceptedSlashCommand.provider,
4169
+ botId: profile.id,
4170
+ conversationId: convId,
4171
+ assistantMessageId: assistantMessageId || null,
4172
+ summary: interceptedSlashCommand.summary,
4173
+ });
4174
+ if (assistantMessageId) {
4175
+ data.updateMessage(assistantMessageId, {
4176
+ content: interceptedSlashCommand.summary,
4177
+ model: buildConfiguredMessageModel(profile),
4178
+ botId: profile.id,
4179
+ agentName: profile.name,
4180
+ });
4181
+ emitConversationSyncEvents(convId, [
4182
+ { change: 'message.updated', messageId: assistantMessageId },
4183
+ ]);
4184
+ }
4185
+ sendSlashEvent('done', {
4186
+ content: interceptedSlashCommand.summary,
4187
+ });
4188
+ responseEnded = true;
4189
+ res.end();
4190
+ return;
4191
+ }
4192
+ const workspaceForSlashCommand = (() => {
4193
+ const conversation = data.getConversation(convId);
4194
+ const project = conversation?.project_id ? data.getProject(conversation.project_id) : undefined;
4195
+ const workspacePath = project?.folder?.trim() || undefined;
4196
+ return workspacePath && fs.existsSync(workspacePath) ? workspacePath : opts.projectDir;
4197
+ })();
4198
+ try {
4199
+ const passthroughResult = await (0, local_cli_pty_manager_1.getLocalCliPtySessionManager)().runPassthroughCommand({
4200
+ conversationId: convId,
4201
+ botId: profile.id,
4202
+ provider: interceptedSlashCommand.provider,
4203
+ botSettings: {
4204
+ claude: interceptedSlashCommand.provider === 'claude-cli'
4205
+ ? {
4206
+ model: profile.model,
4207
+ effortLevel: profile.claude_effort_level,
4208
+ outputStyle: profile.claude_output_style,
4209
+ fastMode: profile.claude_fast_mode === 1,
4210
+ permissionsJson: profile.claude_permissions_json,
4211
+ }
4212
+ : null,
4213
+ codex: interceptedSlashCommand.provider === 'codex-cli'
4214
+ ? {
4215
+ model: profile.model,
4216
+ reasoningEffort: profile.codex_reasoning_effort,
4217
+ personality: profile.codex_personality,
4218
+ serviceTier: profile.codex_service_tier,
4219
+ sandboxPolicy: profile.codex_sandbox_policy,
4220
+ approvalPolicy: profile.codex_approval_policy,
4221
+ }
4222
+ : null,
4223
+ },
4224
+ cwd: workspaceForSlashCommand,
4225
+ command: interceptedSlashCommand.command,
4226
+ abortSignal: routeAbortController.signal,
4227
+ onRawChunk: async (chunk) => {
4228
+ sendSlashEvent('terminal_chunk', {
4229
+ text: chunk,
4230
+ provider: interceptedSlashCommand.provider,
4231
+ botId: profile.id,
4232
+ agentName: profile.name,
4233
+ });
4234
+ },
4235
+ });
4236
+ const finalContent = passthroughResult.content.trim() || interceptedSlashCommand.summary;
4237
+ if (assistantMessageId) {
4238
+ data.updateMessage(assistantMessageId, {
4239
+ content: finalContent,
4240
+ model: buildConfiguredMessageModel(profile),
4241
+ botId: profile.id,
4242
+ agentName: profile.name,
4243
+ });
4244
+ emitConversationSyncEvents(convId, [
4245
+ { change: 'message.updated', messageId: assistantMessageId },
4246
+ ]);
4247
+ }
4248
+ sendSlashEvent('done', {
4249
+ content: finalContent,
4250
+ });
4251
+ responseEnded = true;
4252
+ res.end();
4253
+ return;
4254
+ }
4255
+ catch (slashErr) {
4256
+ const errorMessage = slashErr?.message || `Failed to run ${interceptedSlashCommand.command}`;
4257
+ if (assistantMessageId) {
4258
+ data.updateMessage(assistantMessageId, {
4259
+ content: `Error: ${errorMessage}`,
4260
+ model: buildConfiguredMessageModel(profile),
4261
+ botId: profile.id,
4262
+ agentName: profile.name,
4263
+ });
4264
+ emitConversationSyncEvents(convId, [
4265
+ { change: 'message.updated', messageId: assistantMessageId },
4266
+ ]);
4267
+ }
4268
+ sendSlashEvent('error', { error: errorMessage });
4269
+ responseEnded = true;
4270
+ res.end();
4271
+ return;
4272
+ }
4273
+ }
4274
+ const storedAttachmentsForTurn = resolveStoredAttachmentsForTurn({
4275
+ conversationId: convId,
4276
+ message,
4277
+ skipUserMessage: !!skipUserMessage,
4278
+ chatJobId: chatJobId ? String(chatJobId) : null,
4279
+ persistedAttachments,
4280
+ });
4281
+ const conversationForAttachments = data.getConversation(convId);
4282
+ const projectForAttachments = conversationForAttachments?.project_id ? data.getProject(conversationForAttachments.project_id) : undefined;
4283
+ const attachmentWorkspacePath = projectForAttachments?.folder?.trim() || opts.projectDir;
4284
+ const attachmentSummaryBlock = storedAttachmentsForTurn.length > 0
4285
+ ? await summarizeStoredAttachmentsForTextRuntime({
4286
+ attachments: storedAttachmentsForTurn,
4287
+ workspacePath: attachmentWorkspacePath,
4288
+ userPrompt: message,
4289
+ preferredProfile: profile,
4290
+ })
4291
+ : '';
4292
+ const runtimeUserPrompt = attachmentSummaryBlock ? `${message}${attachmentSummaryBlock}` : message;
4293
+ if (!assistantMessageId && persistAssistantPlaceholder === true && !shouldUseOrchestratorMode) {
2733
4294
  const placeholder = data.addMessage(convId, 'assistant', '', buildConfiguredMessageModel(profile), undefined, profile?.id || undefined, profile?.name || undefined);
2734
4295
  assistantMessageId = placeholder.id;
4296
+ turnStartSyncRevision = emitConversationSyncEvents(convId, [
4297
+ { change: 'message.created', messageId: placeholder.id },
4298
+ ], turnStartSyncRevision ? { revision: turnStartSyncRevision } : undefined);
2735
4299
  }
2736
4300
  // ─── Orchestrator Mode Branch ─────────────────────────
2737
- const shouldUseOrchestratorMode = orchestrationEnabled !== false && (0, orchestrator_profile_1.isOrchestratorProfile)(profile);
2738
4301
  if (shouldUseOrchestratorMode) {
2739
- const clerk = (0, clerk_model_1.getClerk)({ runtimeMode: 'local_desktop' });
2740
- if (!clerk) {
2741
- // Fix #2: Do not silently fall through to direct chat — return a clear error
2742
- return res.status(400).json({
2743
- error: 'Orchestrator mode requires a clerk model to be configured. Please add a provider connection in Settings.',
2744
- });
2745
- }
2746
4302
  const { OrchestratorAgent } = require('./orchestrator');
4303
+ const { buildLocalDesktopOrchestratorRuntime } = require('./orchestrator');
2747
4304
  const { getWorkflowEngine } = require('./workflow-engine');
2748
4305
  const workflowEngine = getWorkflowEngine(opts.projectDir, 'local_desktop');
2749
- const orchestrator = new OrchestratorAgent(clerk, workflowEngine);
4306
+ // Resolve orchestrator runtime independently of request/conversation bot identity.
4307
+ // The request botId is a participant hint, never runtime authority.
4308
+ // Orchestrator must be explicitly designated: clerk → is_orchestrator=1 bot.
4309
+ // No fallback to is_default=1 (that's the hardcoded-Ben trap — made the
4310
+ // default worker bot double as the orchestrator and caused dispatch bugs).
4311
+ const orchestratorBotIdHint = data.isClerkOrchestratorEnabled()
4312
+ ? null
4313
+ : (data.getOrchestratorBot()?.id || null);
4314
+ const explicitOrchestratorBot = orchestratorBotIdHint
4315
+ ? data.getAgentProfile(orchestratorBotIdHint)
4316
+ : null;
4317
+ let orchestratorRuntime;
4318
+ try {
4319
+ orchestratorRuntime = buildLocalDesktopOrchestratorRuntime(explicitOrchestratorBot);
4320
+ }
4321
+ catch (runtimeErr) {
4322
+ return res.status(400).json({
4323
+ error: runtimeErr?.message || 'Orchestrator mode is not configured. Mark a bot with is_orchestrator=1 or enable Clerk orchestration.',
4324
+ });
4325
+ }
4326
+ const orchestrator = new OrchestratorAgent(orchestratorRuntime, workflowEngine);
2750
4327
  // Resolve effective project ID from request or existing conversation
2751
4328
  const conv = data.getConversation(convId);
2752
4329
  const effectiveProjectId = projectId ? String(projectId) : (conv?.project_id || undefined);
@@ -2755,6 +4332,44 @@ function startLocalServer(opts) {
2755
4332
  const shortTitle = message.slice(0, 60).replace(/\n/g, ' ').trim();
2756
4333
  data.updateConversation(convId, { title: shortTitle || 'New Chat' });
2757
4334
  }
4335
+ // Create an empty assistant placeholder message BEFORE the
4336
+ // orchestrator runs. All worker progress activities recorded via
4337
+ // recordWorkerActivity (below) attach to this message_id, so the
4338
+ // UI can render the in-flight orchestrator card immediately — no
4339
+ // more blank chat pane when a user navigates away mid-run and
4340
+ // comes back. (april19fixes.txt item 6a.) At turn end we UPDATE
4341
+ // the placeholder with the final orchestrator response instead
4342
+ // of addMessage'ing a new row.
4343
+ //
4344
+ // Identity note (Codex QA 2026-04-19 on 308cbcfd): when Clerk
4345
+ // is the orchestrator, the placeholder must be attributed to
4346
+ // the Orchestrator, not to the selected request bot (profile).
4347
+ // Otherwise the in-flight card renders under the wrong bot
4348
+ // identity mid-run, and an errored turn permanently persists
4349
+ // under the wrong bot. At turn end we update agentName/botId/
4350
+ // model with the authoritative final values from
4351
+ // orchestrator.getLastResponseMeta().
4352
+ const placeholderClerkMode = data.isClerkOrchestratorEnabled();
4353
+ const placeholderAgentName = placeholderClerkMode
4354
+ ? 'Orchestrator'
4355
+ : (profile?.name || undefined);
4356
+ const placeholderBotId = placeholderClerkMode
4357
+ ? undefined
4358
+ : (profile?.id || undefined);
4359
+ const placeholderClerkConfig = placeholderClerkMode
4360
+ ? data.getResolvedClerkConfigInfo()
4361
+ : null;
4362
+ const placeholderModel = placeholderClerkMode
4363
+ ? ((placeholderClerkConfig?.model || '').trim() || 'Orchestrator')
4364
+ : buildConfiguredMessageModel(profile);
4365
+ if (!assistantMessageId) {
4366
+ const placeholder = data.addMessage(convId, 'assistant', '', placeholderModel, undefined, placeholderBotId, placeholderAgentName);
4367
+ assistantMessageId = placeholder.id;
4368
+ activityErrorContext.messageId = String(assistantMessageId);
4369
+ turnStartSyncRevision = emitConversationSyncEvents(convId, [
4370
+ { change: 'message.created', messageId: placeholder.id },
4371
+ ], turnStartSyncRevision ? { revision: turnStartSyncRevision } : undefined);
4372
+ }
2758
4373
  // SSE setup — same contract as normal chat path
2759
4374
  res.writeHead(200, {
2760
4375
  'Content-Type': 'text/event-stream',
@@ -2769,35 +4384,61 @@ function startLocalServer(opts) {
2769
4384
  };
2770
4385
  let orchestratorRuntimeLabel = '';
2771
4386
  let orchestratorRuntimePayload;
4387
+ const clerkSelectedAsOrchestrator = data.isClerkOrchestratorEnabled();
2772
4388
  try {
2773
- const orchestratorRuntime = await buildChatRuntime(profile);
2774
- orchestratorRuntimeLabel = [
2775
- orchestratorRuntime.model || profile.model || '',
2776
- runtimeModeLabel(orchestratorRuntime.runtimeMode, orchestratorRuntime.runtimeSource),
2777
- ].filter(Boolean).join(' | ');
2778
- orchestratorRuntimePayload = runtimePayloadForDisplay(profile.provider, orchestratorRuntime.model || profile.model || null, orchestratorRuntime.runtimeMode, orchestratorRuntime.runtimeSource || null);
4389
+ if (clerkSelectedAsOrchestrator) {
4390
+ const clerkConfig = data.getResolvedClerkConfigInfo();
4391
+ const clerkProvider = clerkConfig.provider || profile.provider;
4392
+ const clerkModel = clerkConfig.model || profile.model || null;
4393
+ const clerkConnection = clerkConfig.providerConnectionId
4394
+ ? data.getProviderConnection(clerkConfig.providerConnectionId)
4395
+ : data.findProviderConnection(clerkProvider);
4396
+ const clerkRuntimeMode = clerkProvider === 'claude-cli' || clerkProvider === 'codex-cli'
4397
+ ? 'subscription-cli'
4398
+ : 'api-key';
4399
+ const clerkRuntimeSource = clerkConnection?.access_mode === 'oauth'
4400
+ ? 'oauth-token'
4401
+ : clerkConnection?.access_mode === 'cli'
4402
+ ? 'cli-direct'
4403
+ : 'api-key';
4404
+ orchestratorRuntimeLabel = [
4405
+ clerkModel || '',
4406
+ runtimeModeLabel(clerkRuntimeMode, clerkRuntimeSource),
4407
+ ].filter(Boolean).join(' | ');
4408
+ orchestratorRuntimePayload = runtimePayloadForDisplay(clerkProvider, clerkModel, clerkRuntimeMode, clerkRuntimeSource);
4409
+ }
4410
+ else {
4411
+ const orchestratorRuntime = await buildChatRuntime(profile);
4412
+ orchestratorRuntimeLabel = [
4413
+ orchestratorRuntime.model || profile.model || '',
4414
+ runtimeModeLabel(orchestratorRuntime.runtimeMode, orchestratorRuntime.runtimeSource),
4415
+ ].filter(Boolean).join(' | ');
4416
+ orchestratorRuntimePayload = runtimePayloadForDisplay(profile.provider, orchestratorRuntime.model || profile.model || null, orchestratorRuntime.runtimeMode, orchestratorRuntime.runtimeSource || null);
4417
+ }
2779
4418
  }
2780
4419
  catch {
2781
4420
  orchestratorRuntimeLabel = buildConfiguredMessageModel(profile);
2782
4421
  }
2783
4422
  sendEvent('meta', {
2784
4423
  conversationId: convId,
4424
+ assistantMessageId: assistantMessageId || null,
2785
4425
  ...(orchestratorRuntimePayload ? { runtime: orchestratorRuntimePayload } : {}),
2786
4426
  });
2787
4427
  let lastProgressChat = '';
2788
4428
  let lastProgressActivity = '';
2789
4429
  let selfExecuteStreamed = false;
2790
- let hasWorkerActivity = false;
2791
4430
  try {
2792
4431
  let lastInterimMessage = '';
2793
- const response = await orchestrator.handleUserMessage(message, convId, {
4432
+ const response = await orchestrator.handleUserMessage(runtimeUserPrompt, convId, {
2794
4433
  projectDir: opts.projectDir,
2795
4434
  projectId: effectiveProjectId,
4435
+ orchestratorBotIdHint,
4436
+ storedAttachments: storedAttachmentsForTurn.length > 0 ? storedAttachmentsForTurn : undefined,
4437
+ abortSignal: routeAbortController.signal,
2796
4438
  commandId: `local-${Date.now()}`,
2797
4439
  workflowTemplateId: workflowTemplateId || undefined,
2798
4440
  onWorkerChunk: (event) => {
2799
4441
  if (event.type === 'step_start') {
2800
- hasWorkerActivity = true;
2801
4442
  recordWorkerActivity('worker_step_start', event, {
2802
4443
  stepId: event.stepId,
2803
4444
  agentName: event.agentName,
@@ -2816,7 +4457,6 @@ function startLocalServer(opts) {
2816
4457
  });
2817
4458
  }
2818
4459
  else if (event.type === 'worker_chunk') {
2819
- hasWorkerActivity = true;
2820
4460
  recordWorkerActivity('worker_chunk', event, {
2821
4461
  stepId: event.stepId,
2822
4462
  agentName: event.agentName,
@@ -2836,8 +4476,18 @@ function startLocalServer(opts) {
2836
4476
  text: event.text,
2837
4477
  });
2838
4478
  }
4479
+ else if (event.type === 'worker_terminal_chunk') {
4480
+ sendEvent('worker_terminal_chunk', {
4481
+ stepId: event.stepId,
4482
+ botId: resolveWorkerBotId(event.agentName),
4483
+ agentName: event.agentName,
4484
+ description: event.description,
4485
+ stepIndex: event.stepIndex,
4486
+ totalSteps: event.totalSteps,
4487
+ text: event.rawText,
4488
+ });
4489
+ }
2839
4490
  else if (event.type === 'worker_tool_call') {
2840
- hasWorkerActivity = true;
2841
4491
  recordWorkerActivity('worker_tool_call', event, {
2842
4492
  stepId: event.stepId,
2843
4493
  agentName: event.agentName,
@@ -2862,7 +4512,6 @@ function startLocalServer(opts) {
2862
4512
  });
2863
4513
  }
2864
4514
  else if (event.type === 'worker_tool_result') {
2865
- hasWorkerActivity = true;
2866
4515
  recordWorkerActivity('worker_tool_result', event, {
2867
4516
  stepId: event.stepId,
2868
4517
  agentName: event.agentName,
@@ -2901,7 +4550,6 @@ function startLocalServer(opts) {
2901
4550
  sendEvent('chunk', { text: `> [${icon}] ${event.toolName} completed\n` });
2902
4551
  }
2903
4552
  else if (event.type === 'step_done') {
2904
- hasWorkerActivity = true;
2905
4553
  recordWorkerActivity('worker_step_done', event, {
2906
4554
  stepId: event.stepId,
2907
4555
  agentName: event.agentName,
@@ -2938,7 +4586,7 @@ function startLocalServer(opts) {
2938
4586
  // Progress chatText suppressed — worker card handles all streaming display.
2939
4587
  // Main bubble only gets final content via 'done' event.
2940
4588
  // Emit interim messages for key orchestrator transitions
2941
- const interimMessage = deriveOrchestratorInterimMessage(status);
4589
+ const interimMessage = deriveVisibleOrchestratorMessage(status);
2942
4590
  if (interimMessage && interimMessage !== lastInterimMessage) {
2943
4591
  lastInterimMessage = interimMessage;
2944
4592
  recordActivity('orchestrator_interim', { text: interimMessage }, interimMessage);
@@ -2958,41 +4606,44 @@ function startLocalServer(opts) {
2958
4606
  // Save O's response (no incrementTurnCount — Fix #1: user message already incremented it)
2959
4607
  const responseMeta = orchestrator.getLastResponseMeta();
2960
4608
  const finalAgentName = responseMeta?.agentName || 'Orchestrator';
2961
- const finalBotId = responseMeta?.botId || (finalAgentName === 'Orchestrator' ? profile.id : undefined);
4609
+ const finalBotId = responseMeta?.botId
4610
+ || (finalAgentName === 'Orchestrator' && !clerkSelectedAsOrchestrator ? profile.id : undefined);
2962
4611
  const finalModelLabel = responseMeta?.modelLabel || orchestratorRuntimeLabel || undefined;
2963
- const splitFinalMessage = hasWorkerActivity && finalAgentName === 'Clerk';
2964
- if (splitFinalMessage) {
2965
- const orchestratorMessage = data.addMessage(convId, 'assistant', '', orchestratorRuntimeLabel || buildConfiguredMessageModel(profile), undefined, profile.id, 'Orchestrator');
2966
- data.attachMessageActivitiesToMessage(activityStreamId, orchestratorMessage.id);
2967
- const clerkMessage = data.addMessage(convId, 'assistant', response, finalModelLabel, undefined, finalBotId, finalAgentName);
2968
- data.createMessageActivity({
2969
- conversationId: convId,
2970
- messageId: clerkMessage.id,
4612
+ // UPDATE the placeholder we created at turn start (april19fixes.txt
4613
+ // item 6a). Activities are already attached via assistantMessageId,
4614
+ // so the final message row completes the in-flight card rather than
4615
+ // creating a second message row.
4616
+ let savedMessage = assistantMessageId
4617
+ ? data.updateMessage(assistantMessageId, {
4618
+ content: response,
4619
+ model: finalModelLabel,
2971
4620
  botId: finalBotId,
2972
4621
  agentName: finalAgentName,
2973
- activityType: 'message',
2974
- summary: 'Final assistant response',
2975
- payload: { content: response },
2976
- expiresAt: activityExpiresAt,
2977
- });
4622
+ })
4623
+ : null;
4624
+ if (!savedMessage) {
4625
+ savedMessage = data.addMessage(convId, 'assistant', response, finalModelLabel, undefined, finalBotId, finalAgentName);
2978
4626
  }
2979
- else {
2980
- const savedMessage = data.addMessage(convId, 'assistant', response, finalModelLabel, undefined, finalBotId, finalAgentName);
2981
- data.attachMessageActivitiesToMessage(activityStreamId, savedMessage.id);
2982
- data.createMessageActivity({
2983
- conversationId: convId,
4627
+ emitConversationSyncEvents(convId, [
4628
+ {
4629
+ change: savedMessage.id === assistantMessageId ? 'message.updated' : 'message.created',
2984
4630
  messageId: savedMessage.id,
2985
- botId: finalBotId,
2986
- agentName: finalAgentName,
2987
- activityType: 'message',
2988
- summary: 'Final orchestrator response',
2989
- payload: { content: response },
2990
- expiresAt: activityExpiresAt,
2991
- });
2992
- }
4631
+ },
4632
+ ]);
4633
+ data.attachMessageActivitiesToMessage(activityStreamId, savedMessage.id);
4634
+ data.createMessageActivity({
4635
+ conversationId: convId,
4636
+ messageId: savedMessage.id,
4637
+ botId: finalBotId,
4638
+ agentName: finalAgentName,
4639
+ activityType: 'message',
4640
+ summary: 'Final orchestrator response',
4641
+ payload: { content: response },
4642
+ expiresAt: activityExpiresAt,
4643
+ });
2993
4644
  // Emit chunk + done events using the same SSE contract as normal chat
2994
4645
  // Skip the final bulk chunk if we already streamed via worker_chunk (execute_self)
2995
- if (!selfExecuteStreamed && !splitFinalMessage) {
4646
+ if (!selfExecuteStreamed) {
2996
4647
  sendEvent('chunk', { text: response });
2997
4648
  }
2998
4649
  sendEvent('done', {
@@ -3000,7 +4651,6 @@ function startLocalServer(opts) {
3000
4651
  content: response,
3001
4652
  agentName: finalAgentName,
3002
4653
  botId: finalBotId,
3003
- separateFinalMessage: splitFinalMessage,
3004
4654
  ...((responseMeta?.modelLabel || orchestratorRuntimePayload)
3005
4655
  ? {
3006
4656
  runtime: {
@@ -3020,16 +4670,47 @@ function startLocalServer(opts) {
3020
4670
  (0, local_funnel_1.scheduleFunnelProcessing)(convId);
3021
4671
  }
3022
4672
  catch (orchErr) {
3023
- recordActivity('error', { error: orchErr.message }, orchErr.message);
4673
+ if (routeAbortController.signal.aborted || orchErr?.name === 'AbortError') {
4674
+ responseEnded = true;
4675
+ res.end();
4676
+ return;
4677
+ }
4678
+ // Log the full stack so recursion crashes like "Maximum
4679
+ // call stack size exceeded" land in agent.log with the
4680
+ // failing frame. Previously only the message was captured.
4681
+ console.error(chalk_1.default.red(`Orchestrator error: ${orchErr.message}`));
4682
+ if (orchErr?.stack) {
4683
+ console.error(String(orchErr.stack));
4684
+ }
4685
+ recordActivity('error', { error: orchErr.message, stack: orchErr?.stack || null }, orchErr.message);
4686
+ // Populate the placeholder with the error text AND correct
4687
+ // the identity fields so an errored turn doesn't remain
4688
+ // persisted under the wrong bot. Under Clerk orchestration
4689
+ // the placeholder was created with agentName='Orchestrator',
4690
+ // botId=null — those are re-applied here explicitly because
4691
+ // updateMessage with no overrides preserves existing values
4692
+ // but we want to guarantee nothing else mutated them.
4693
+ // (Codex QA 2026-04-19 on 308cbcfd: error-path identity bug.)
4694
+ if (assistantMessageId) {
4695
+ try {
4696
+ data.updateMessage(assistantMessageId, {
4697
+ content: `Error: ${orchErr.message}`,
4698
+ agentName: placeholderAgentName,
4699
+ botId: placeholderBotId,
4700
+ model: placeholderModel,
4701
+ });
4702
+ emitConversationSyncEvents(convId, [
4703
+ { change: 'message.updated', messageId: assistantMessageId },
4704
+ ]);
4705
+ }
4706
+ catch { /* best effort */ }
4707
+ }
3024
4708
  sendEvent('error', { type: 'error', error: orchErr.message });
3025
4709
  responseEnded = true;
3026
4710
  res.end();
3027
4711
  }
3028
4712
  return;
3029
4713
  }
3030
- // Prompt Contract v1: system carries summary + last 5 turns.
3031
- // Send only the current user request as the primary user message.
3032
- const llmMessages = [{ role: 'user', content: message }];
3033
4714
  const configuredTz = (data.getSetting('timezone') || '').trim();
3034
4715
  const effectiveTimezone = configuredTz && configuredTz.toLowerCase() !== 'system'
3035
4716
  ? configuredTz
@@ -3042,101 +4723,9 @@ function startLocalServer(opts) {
3042
4723
  ? new Set(allToolDefs.map((tool) => tool.name))
3043
4724
  : expandAllowedToolNames(allToolDefs, configuredBuiltinTools, configuredMcpTools);
3044
4725
  const toolDefs = allToolDefs.filter((tool) => allowedToolNames.has(tool.name));
3045
- // Build system prompt via clerk (token-budgeted context injection)
3046
- let systemPrompt;
3047
- let llmSpawnCwd = opts.projectDir;
3048
- const clerk = (0, clerk_model_1.getClerk)({ runtimeMode: 'local_desktop' });
3049
- if (clerk) {
3050
- const conv = data.getConversation(convId);
3051
- const topicTitle = topicId ? data.getTopic(topicId)?.title : undefined;
3052
- const project = conv?.project_id ? data.getProject(conv.project_id) : undefined;
3053
- const workspacePath = project?.folder?.trim() || undefined;
3054
- if (workspacePath && fs.existsSync(workspacePath)) {
3055
- llmSpawnCwd = workspacePath;
3056
- }
3057
- const built = clerk.buildPrompt(message, profile.id, profile, {
3058
- targetModel: profile.model,
3059
- conversationId: convId,
3060
- projectName: conv?.project_name || undefined,
3061
- projectId: conv?.project_id || undefined,
3062
- topicTitle: topicTitle || undefined,
3063
- workspacePath,
3064
- timezone: effectiveTimezone,
3065
- includeKeyDecisions: false,
3066
- availableTools: toolDefs.map((tool) => ({ name: tool.name, description: tool.description })),
3067
- });
3068
- systemPrompt = built.systemPrompt;
3069
- console.log(chalk_1.default.gray(` [clerk] Context: ${built.injectedSummaries} summaries (${built.contextTokensUsed} tokens)`));
3070
- }
3071
- else {
3072
- // Fallback: manual prompt building
3073
- systemPrompt = '[Bot Identity]\n' + (profile.soul_md
3074
- || 'You are an AI assistant running locally. You have access to project files and can execute code.');
3075
- systemPrompt += '\n\nDo not end with a deferred promise (for example: "Let me check..."). Return a final answer in this turn, or state exactly what is unavailable.';
3076
- systemPrompt += '\n\nWhen [Project Overview] is present, treat Project/Topic/Workspace values there as authoritative for the current turn and override stale prior-chat claims.';
3077
- systemPrompt += '\n\n[Response Style]\nWrite in short readable paragraphs. Put a blank line between distinct ideas. Use bullets when listing findings, steps, or issues. Do not return one dense wall of text. For progress updates, keep them compact and clearly separate what you checked, what you found, and what you are doing next.';
3078
- const convForFallback = data.getConversation(convId);
3079
- const projectForFallback = convForFallback?.project_id ? data.getProject(convForFallback.project_id) : undefined;
3080
- const workspaceForFallback = projectForFallback?.folder?.trim();
3081
- if (workspaceForFallback && fs.existsSync(workspaceForFallback)) {
3082
- llmSpawnCwd = workspaceForFallback;
3083
- }
3084
- const fallbackMeta = [];
3085
- if (convForFallback?.project_name)
3086
- fallbackMeta.push(`Project: ${convForFallback.project_name}`);
3087
- if (topicId) {
3088
- const fallbackTopic = data.getTopic(topicId);
3089
- if (fallbackTopic?.title)
3090
- fallbackMeta.push(`Topic: ${fallbackTopic.title}`);
3091
- }
3092
- if (convForFallback?.project_id) {
3093
- fallbackMeta.push(`Workspace: ${workspaceForFallback || '(project folder not configured)'}`);
3094
- }
3095
- if (fallbackMeta.length > 0) {
3096
- systemPrompt += '\n\n[Project Overview]\n' + fallbackMeta.join('\n');
3097
- }
3098
- const effectivePolicy = data.getEffectiveOrchestrationPolicy(convForFallback?.project_id || undefined);
3099
- systemPrompt += '\n\n' + (0, policy_prompt_1.buildEffectivePolicyPromptSection)(effectivePolicy, {
3100
- heading: '[Effective Policy]',
3101
- defaultLine: 'No confirmed special policy is set.',
3102
- });
3103
- try {
3104
- const todoStatus = data.getTodoStatusMarker(convForFallback?.project_id ?? undefined);
3105
- systemPrompt += `\n\n[TODO Coordination]\nTODO STATUS: ${todoStatus}`;
3106
- }
3107
- catch { /* best-effort */ }
3108
- systemPrompt += '\n\n' + (0, clerk_model_1.buildTodoInstructions)(profile?.name || profile?.id || 'LLM');
3109
- let hasSummary = false;
3110
- try {
3111
- const summaryContext = (0, context_window_1.getPromptContextWindow)(convId, safeguards_1.SAFEGUARDS.CONTEXT_WINDOW_TURNS);
3112
- if (summaryContext.summary?.summary_text) {
3113
- const summaryHeader = summaryContext.carriedForward
3114
- ? '[Context Summary (Carried Forward from Previous Conversation in This Topic)]'
3115
- : '[Context Summary]';
3116
- systemPrompt += `\n\n${summaryHeader}\n` + summaryContext.summary.summary_text;
3117
- hasSummary = true;
3118
- }
3119
- }
3120
- catch { /* best-effort */ }
3121
- try {
3122
- const turnWindow = hasSummary
3123
- ? safeguards_1.SAFEGUARDS.CONTEXT_WINDOW_TURNS
3124
- : safeguards_1.SAFEGUARDS.NO_SUMMARY_CONTEXT_WINDOW_TURNS;
3125
- const promptContext = (0, context_window_1.getPromptContextWindow)(convId, turnWindow);
3126
- if (promptContext.turns.length > 0) {
3127
- const turnsHeader = promptContext.carriedForward
3128
- ? `[Recent Messages (Last ${promptContext.turns.length} Turns from Previous Conversation in This Topic)]`
3129
- : `[Recent Messages (Last ${promptContext.turns.length} Turns)]`;
3130
- systemPrompt += `\n\n${turnsHeader}\n` + (0, context_window_1.formatTurnsForPrompt)(promptContext.turns);
3131
- }
3132
- }
3133
- catch { /* best-effort */ }
3134
- }
3135
- // Resolve LLM runtime.
3136
- // Desktop local mode intentionally supports only:
3137
- // - Subscription CLI
3138
- // - API Key
3139
- // We do not use subscription-token API routing for local CLI bots.
4726
+ const conversation = data.getConversation(convId);
4727
+ // Resolve LLM runtime early so the local desktop prompt contract can differ
4728
+ // between API/fresh CLI and recurring CLI sessions without affecting server paths.
3140
4729
  const runtime = await buildChatRuntime(profile);
3141
4730
  let activeProviderName = runtime.providerName;
3142
4731
  let activeModelName = runtime.model;
@@ -3164,6 +4753,133 @@ function startLocalServer(opts) {
3164
4753
  const cliEpochStartedAt = cliSessionEpochPlan.resumeSessionId
3165
4754
  ? (cliSessionEpochPlan.existing?.epoch_started_at || localTimestamp())
3166
4755
  : localTimestamp();
4756
+ const promptContextWindow = (0, context_window_1.getPromptContextWindow)(convId, safeguards_1.SAFEGUARDS.CONTEXT_WINDOW_TURNS, {
4757
+ targetBotId: profile.id,
4758
+ targetBotName: profile.name,
4759
+ });
4760
+ const skipFreshCliBootstrap = shouldSkipFreshCliBootstrap({
4761
+ providerName: activeProviderName,
4762
+ resumeSessionId: cliSessionEpochPlan.resumeSessionId,
4763
+ promptContextMode: promptContextWindow.mode,
4764
+ });
4765
+ // Session lifecycle logging — tracks invariant between prompt-building
4766
+ // decisions and session runtime reality plus structured telemetry events.
4767
+ if (enableCliSessionEpoch) {
4768
+ const sessionLaunchReason = cliSessionEpochPlan.resumeSessionId
4769
+ ? 'resumed'
4770
+ : promptContextWindow.mode === 'new_topic'
4771
+ ? 'new_topic/no_context'
4772
+ : cliSessionEpochPlan.resetReason
4773
+ ? cliSessionEpochPlan.resetReason
4774
+ : 'fresh_with_bootstrap';
4775
+ console.info(`[chat] session_launch_reason=${sessionLaunchReason} botId=${profile.id} provider=${activeProviderName} mode=${promptContextWindow.mode || 'unknown'} bootstrap=${!!promptContextWindow.allowBootstrap && !skipFreshCliBootstrap}`);
4776
+ console.info(`[chat] cli_session_selected conversationId=${convId} botId=${profile.id} provider=${activeProviderName} selectedSessionId=${cliSessionEpochPlan.resumeSessionId || '(none)'} resetReason=${cliSessionEpochPlan.resetReason || '(none)'}`);
4777
+ if (cliSessionEpochPlan.resumeSessionId) {
4778
+ console.info(`[chat] cli_session_resuming conversationId=${convId} botId=${profile.id} provider=${activeProviderName} sessionId=${cliSessionEpochPlan.resumeSessionId}`);
4779
+ }
4780
+ else if (promptContextWindow.mode === 'new_topic') {
4781
+ console.info(`[chat] cli_session_fresh_without_bootstrap_new_topic conversationId=${convId} botId=${profile.id} provider=${activeProviderName}`);
4782
+ }
4783
+ else if (promptContextWindow.allowBootstrap && !skipFreshCliBootstrap) {
4784
+ console.info(`[chat] cli_session_fresh_bootstrap_applied conversationId=${convId} botId=${profile.id} provider=${activeProviderName}`);
4785
+ }
4786
+ else {
4787
+ console.warn(`[chat] cli_session_fresh_without_bootstrap_unexpected conversationId=${convId} botId=${profile.id} provider=${activeProviderName} mode=${promptContextWindow.mode || 'unknown'}`);
4788
+ }
4789
+ }
4790
+ const promptContext = resolveLocalDesktopPromptContext({
4791
+ conversationId: convId,
4792
+ conversation,
4793
+ explicitTopicId: topicId,
4794
+ fallbackCwd: opts.projectDir,
4795
+ writeCliHistoryBootstrap: activeIsCliProvider
4796
+ && !cliSessionEpochPlan.resumeSessionId
4797
+ && !!promptContextWindow.allowBootstrap
4798
+ && !skipFreshCliBootstrap,
4799
+ });
4800
+ const llmSpawnCwd = promptContext.llmSpawnCwd;
4801
+ let cliEpochResetReason = cliSessionEpochPlan.resetReason;
4802
+ let directPrompt = buildLocalDesktopDirectPrompt({
4803
+ conversationId: convId,
4804
+ currentBotId: profile.id,
4805
+ currentBotName: profile.name,
4806
+ currentProvider: activeProviderName,
4807
+ userPrompt: supportsNativeImageInput(activeProviderName) ? message : runtimeUserPrompt,
4808
+ soulMd: profile.soul_md || 'You are an AI assistant running locally. You have access to project files and can execute code.',
4809
+ projectName: promptContext.projectName,
4810
+ topicTitle: promptContext.topicTitle,
4811
+ workspacePath: promptContext.workspacePath,
4812
+ timezone: effectiveTimezone,
4813
+ availableTools: toolDefs.map((tool) => ({ name: tool.name, description: tool.description })),
4814
+ isCliRecurring: !!cliSessionEpochPlan.resumeSessionId || skipFreshCliBootstrap,
4815
+ cliHistoryFilePath: promptContext.cliHistoryFilePath,
4816
+ crossBotTurn: promptContextWindow.lastCrossBotTurn,
4817
+ forceInlinePromptContext: activeProviderName === 'codex-cli'
4818
+ && !!cliSessionEpochPlan.resumeSessionId
4819
+ && ((conversation?.turn_count || 0) === 0),
4820
+ useCompletionSentinel: resolveDirectCliSessionTransport(activeProviderName, enableCliSessionEpoch, (0, storage_mode_1.isLocalStorageMode)()) === 'pty',
4821
+ });
4822
+ // Recurring CLI sessions rely on session memory for same-bot context, so
4823
+ // inject the last completed cross-bot handoff turn explicitly when the
4824
+ // shared prompt-context policy says it is required.
4825
+ if (activeIsCliProvider && !!cliSessionEpochPlan.resumeSessionId && promptContextWindow.lastCrossBotTurn) {
4826
+ const crossBotBlock = (0, context_window_1.formatCrossBotPreviousTurn)(promptContextWindow.lastCrossBotTurn);
4827
+ directPrompt.userPrompt = `${crossBotBlock}\n\n${directPrompt.userPrompt}`;
4828
+ }
4829
+ let llmMessages = [{
4830
+ role: 'user',
4831
+ content: supportsNativeImageInput(activeProviderName)
4832
+ ? buildMessageContentWithStoredAttachments(directPrompt.userPrompt, storedAttachmentsForTurn)
4833
+ : directPrompt.userPrompt,
4834
+ }];
4835
+ let systemPrompt = directPrompt.systemPrompt;
4836
+ let freshCliBootstrapFallbackApplied = false;
4837
+ let freshCliBootstrapFileWritten = false;
4838
+ const applyFreshCliBootstrapFallback = (reason) => {
4839
+ if (freshCliBootstrapFallbackApplied)
4840
+ return;
4841
+ freshCliBootstrapFallbackApplied = true;
4842
+ cliEpochResetReason = reason;
4843
+ const bootstrapHistoryFilePath = promptContextWindow.allowBootstrap
4844
+ ? (0, cli_bootstrap_history_1.writeConversationBootstrapHistoryFile)({
4845
+ conversationId: convId,
4846
+ topicId: promptContext.topicId,
4847
+ })
4848
+ : null;
4849
+ freshCliBootstrapFileWritten = !!bootstrapHistoryFilePath;
4850
+ directPrompt = buildLocalDesktopDirectPrompt({
4851
+ conversationId: convId,
4852
+ currentBotId: profile.id,
4853
+ currentBotName: profile.name,
4854
+ currentProvider: activeProviderName,
4855
+ userPrompt: supportsNativeImageInput(activeProviderName) ? message : runtimeUserPrompt,
4856
+ soulMd: profile.soul_md || 'You are an AI assistant running locally. You have access to project files and can execute code.',
4857
+ projectName: promptContext.projectName,
4858
+ topicTitle: promptContext.topicTitle,
4859
+ workspacePath: promptContext.workspacePath,
4860
+ timezone: effectiveTimezone,
4861
+ availableTools: toolDefs.map((tool) => ({ name: tool.name, description: tool.description })),
4862
+ isCliRecurring: false,
4863
+ cliHistoryFilePath: bootstrapHistoryFilePath,
4864
+ crossBotTurn: promptContextWindow.lastCrossBotTurn,
4865
+ useCompletionSentinel: resolveDirectCliSessionTransport(activeProviderName, enableCliSessionEpoch, (0, storage_mode_1.isLocalStorageMode)()) === 'pty',
4866
+ });
4867
+ llmMessages = [{
4868
+ role: 'user',
4869
+ content: supportsNativeImageInput(activeProviderName)
4870
+ ? buildMessageContentWithStoredAttachments(directPrompt.userPrompt, storedAttachmentsForTurn)
4871
+ : directPrompt.userPrompt,
4872
+ }];
4873
+ systemPrompt = directPrompt.systemPrompt;
4874
+ console.info(`[chat] session_launch_reason=resume_failed botId=${profile.id} provider=${activeProviderName} bootstrap=${!!bootstrapHistoryFilePath}`);
4875
+ console.warn(`[chat] cli_session_resume_failed conversationId=${convId} botId=${profile.id} provider=${activeProviderName} bootstrap=${!!bootstrapHistoryFilePath}`);
4876
+ if (bootstrapHistoryFilePath) {
4877
+ console.info(`[chat] cli_session_fresh_bootstrap_applied conversationId=${convId} botId=${profile.id} provider=${activeProviderName} bootstrapReason=resume_failed`);
4878
+ }
4879
+ else {
4880
+ console.warn(`[chat] cli_session_fresh_without_bootstrap_unexpected conversationId=${convId} botId=${profile.id} provider=${activeProviderName} bootstrapReason=resume_failed`);
4881
+ }
4882
+ };
3167
4883
  if (!activeApiKey) {
3168
4884
  return res.status(400).json({ error: `No API key for provider ${profile.provider}. Configure one in Settings.` });
3169
4885
  }
@@ -3175,14 +4891,6 @@ function startLocalServer(opts) {
3175
4891
  restrictFileAccessToProject: unrestrictedCliProfile ? false : undefined,
3176
4892
  abortSignal: routeAbortController.signal,
3177
4893
  });
3178
- const toolManifest = toolDefs
3179
- .map(t => `- ${t.name}: ${t.description}`)
3180
- .join('\n');
3181
- if (toolManifest.trim()) {
3182
- systemPrompt += unrestrictedCliProfile
3183
- ? '\n\n[Available Tools]\nThe following tools are available in the current runtime:\n' + toolManifest
3184
- : '\n\n[Available Tools]\nOnly the following tools are enabled for this bot in the current runtime:\n' + toolManifest;
3185
- }
3186
4894
  // Inject pinned messages as context (user-selected cross-bot references)
3187
4895
  if (pinnedMessageIds && Array.isArray(pinnedMessageIds) && pinnedMessageIds.length > 0) {
3188
4896
  const pinnedLines = [];
@@ -3199,10 +4907,17 @@ function startLocalServer(opts) {
3199
4907
  // CLI providers only see the last user message — prepend pinned context there
3200
4908
  const lastUserIdx = llmMessages.map(m => m.role).lastIndexOf('user');
3201
4909
  if (lastUserIdx >= 0) {
3202
- llmMessages[lastUserIdx] = {
3203
- ...llmMessages[lastUserIdx],
3204
- content: `${pinnedBlock}\n---\n${llmMessages[lastUserIdx].content}`,
3205
- };
4910
+ const existing = llmMessages[lastUserIdx].content;
4911
+ if (typeof existing === 'string') {
4912
+ llmMessages[lastUserIdx] = { ...llmMessages[lastUserIdx], content: `${pinnedBlock}\n---\n${existing}` };
4913
+ }
4914
+ else {
4915
+ // Multimodal content — prepend pinned text as a new text block, keep images intact
4916
+ llmMessages[lastUserIdx] = {
4917
+ ...llmMessages[lastUserIdx],
4918
+ content: [{ type: 'text', text: `${pinnedBlock}\n---\n` }, ...existing],
4919
+ };
4920
+ }
3206
4921
  }
3207
4922
  }
3208
4923
  else {
@@ -3240,33 +4955,6 @@ function startLocalServer(opts) {
3240
4955
  isApproximate: true,
3241
4956
  },
3242
4957
  });
3243
- sendEvent('status', {
3244
- phase: 'thinking',
3245
- detail: `Sending request to ${activeProviderName}...`,
3246
- runtime: runtimePayload(),
3247
- });
3248
- recordActivity('status', {
3249
- phase: 'thinking',
3250
- detail: `Sending request to ${activeProviderName}...`,
3251
- runtime: runtimePayload(),
3252
- }, `Sending request to ${activeProviderName}...`);
3253
- if (cliSessionEpochPlan.resetReason && enableCliSessionEpoch) {
3254
- const resetDetail = cliSessionEpochPlan.resetReason === 'turn_limit'
3255
- ? 'Resetting CLI session after reaching the turn limit.'
3256
- : cliSessionEpochPlan.resetReason === 'token_limit'
3257
- ? 'Resetting CLI session after reaching the context budget.'
3258
- : 'Resetting CLI session because the runtime changed.';
3259
- sendEvent('status', {
3260
- phase: 'thinking',
3261
- detail: resetDetail,
3262
- runtime: runtimePayload(),
3263
- });
3264
- recordActivity('status', {
3265
- phase: 'thinking',
3266
- detail: resetDetail,
3267
- runtime: runtimePayload(),
3268
- }, resetDetail);
3269
- }
3270
4958
  let partialPersistedContent = '';
3271
4959
  let partialPersistedAt = 0;
3272
4960
  const throwIfChatJobCancelled = () => {
@@ -3306,6 +4994,7 @@ function startLocalServer(opts) {
3306
4994
  let fullContent = '';
3307
4995
  let streamedContent = '';
3308
4996
  let streamedAnyChunk = false;
4997
+ let rawCliTranscript = '';
3309
4998
  let iteration = 0;
3310
4999
  const MAX_ITERATIONS = 10; // Phase 1d: reduced from 20
3311
5000
  let totalInputTokens = 0;
@@ -3316,31 +5005,218 @@ function startLocalServer(opts) {
3316
5005
  let accumulatedThinking = '';
3317
5006
  const thinkingEnabled = !!profile?.show_thinking;
3318
5007
  let useInteractiveCliSession = enableCliSessionEpoch;
3319
- if (useInteractiveCliSession) {
5008
+ const directCliSessionTransport = resolveDirectCliSessionTransport(activeProviderName, useInteractiveCliSession, (0, storage_mode_1.isLocalStorageMode)());
5009
+ const useCodexAppServerInteractive = directCliSessionTransport === 'codex-app-server';
5010
+ const usePtyInteractiveCliSession = directCliSessionTransport === 'pty';
5011
+ const startDetail = `Started response via ${runtimeModeLabel(activeRuntimeMode, activeRuntimeSource) || activeProviderName}`;
5012
+ sendEvent('status', { phase: 'thinking', detail: startDetail });
5013
+ recordActivity('status', { phase: 'thinking', detail: startDetail }, startDetail);
5014
+ if (useCodexAppServerInteractive) {
5015
+ const codexAppServerManager = (0, codex_app_server_manager_1.getCodexAppServerManager)();
5016
+ let appServerAttempt = 0;
5017
+ let forceFreshInteractiveCliSession = false;
5018
+ while (true) {
5019
+ appServerAttempt++;
5020
+ try {
5021
+ const isFreshSession = forceFreshInteractiveCliSession || (!codexAppServerManager.hasActiveSession(convId, profile.id) && !cliSessionEpochPlan.resumeSessionId);
5022
+ const result = await codexAppServerManager.runTurn({
5023
+ runtimeMode: 'local_desktop',
5024
+ conversationId: convId,
5025
+ botId: profile.id,
5026
+ botName: profile.name,
5027
+ cwd: llmSpawnCwd,
5028
+ systemPrompt,
5029
+ messages: llmMessages,
5030
+ forceFreshSession: isFreshSession,
5031
+ resumeSessionId: isFreshSession ? undefined : (cliSessionEpochPlan.resumeSessionId || undefined),
5032
+ model: activeModelName || profile.model || null,
5033
+ projectId: conversation?.project_id ?? null,
5034
+ codexSettings: {
5035
+ reasoningEffort: profile.codex_reasoning_effort,
5036
+ reasoningSummary: profile.codex_reasoning_summary,
5037
+ personality: profile.codex_personality,
5038
+ serviceTier: profile.codex_service_tier,
5039
+ sandboxPolicy: profile.codex_sandbox_policy,
5040
+ approvalPolicy: profile.codex_approval_policy,
5041
+ },
5042
+ abortSignal: routeAbortController.signal,
5043
+ onChunk: async (chunk) => {
5044
+ streamedAnyChunk = true;
5045
+ streamedContent += chunk;
5046
+ persistAssistantPartial(false);
5047
+ sendEvent('chunk', { text: chunk });
5048
+ },
5049
+ onCommentary: async (commentary) => {
5050
+ const text = String(commentary || '').trim();
5051
+ if (!text)
5052
+ return;
5053
+ sendEvent('status', { phase: 'commentary', detail: text });
5054
+ },
5055
+ onDetail: async (detail) => {
5056
+ const text = String(detail || '').trim();
5057
+ if (!text)
5058
+ return;
5059
+ sendEvent('status', { phase: 'thinking', detail: text });
5060
+ recordActivity('status', { phase: 'thinking', detail: text }, text);
5061
+ },
5062
+ onToolEvent: async (toolEvent) => {
5063
+ if (toolEvent.kind === 'call') {
5064
+ sendEvent('tool_call', {
5065
+ name: toolEvent.toolName,
5066
+ callId: toolEvent.toolCallId,
5067
+ arguments: toolEvent.arguments || null,
5068
+ });
5069
+ recordActivity('tool_call', {
5070
+ name: toolEvent.toolName,
5071
+ callId: toolEvent.toolCallId,
5072
+ arguments: toolEvent.arguments || null,
5073
+ }, `Running ${toolEvent.toolName}`);
5074
+ return;
5075
+ }
5076
+ sendEvent('tool_result', {
5077
+ name: toolEvent.toolName,
5078
+ callId: toolEvent.toolCallId,
5079
+ output: toolEvent.output || '',
5080
+ isError: !!toolEvent.isError,
5081
+ });
5082
+ recordActivity('tool_result', {
5083
+ callId: toolEvent.toolCallId,
5084
+ toolName: toolEvent.toolName,
5085
+ output: toolEvent.output || '',
5086
+ isError: !!toolEvent.isError,
5087
+ }, toolEvent.isError
5088
+ ? `${toolEvent.toolName} returned an error`
5089
+ : `${toolEvent.toolName} completed`);
5090
+ },
5091
+ });
5092
+ if (result.sessionId) {
5093
+ activeCliSessionId = result.sessionId;
5094
+ }
5095
+ if (result.usage) {
5096
+ totalInputTokens += result.usage.inputTokens || 0;
5097
+ totalOutputTokens += result.usage.outputTokens || 0;
5098
+ hasExactUsage = true;
5099
+ }
5100
+ // ─── token.txt: agent-side usage log (Codex App Server path) ─
5101
+ // Fire-and-forget. Never blocks the chat reply.
5102
+ try {
5103
+ const __auth = await getHydratedDesktopAuth().catch(() => null);
5104
+ if (__auth?.token && result.usage) {
5105
+ void (0, usage_log_1.writeAgentUsageLog)({
5106
+ apiToken: __auth.token,
5107
+ testRunId: __testRunId,
5108
+ mode: 'local-db-codex-app',
5109
+ conversationId: convId,
5110
+ botId: profile?.id || null,
5111
+ botName: profile?.name || null,
5112
+ turnIndex: __turnIndexParsed,
5113
+ provider: 'openai',
5114
+ model: profile?.model || 'unknown',
5115
+ inputTokensFresh: result.usage.inputTokensFresh ?? 0,
5116
+ inputTokensCacheCreation: result.usage.inputTokensCacheCreation ?? 0,
5117
+ inputTokensCacheRead: result.usage.inputTokensCacheRead ?? 0,
5118
+ outputTokens: result.usage.outputTokens ?? 0,
5119
+ userPromptText: __benchUserMessage,
5120
+ systemPromptText: systemPrompt,
5121
+ responseText: result.content || null,
5122
+ });
5123
+ }
5124
+ }
5125
+ catch { /* best effort; never break chat */ }
5126
+ // ─── end usage log ──────────────────────────────────────────
5127
+ rawCliTranscript = result.rawOutput || '';
5128
+ fullContent = (0, completion_marker_1.stripCompletionSentinel)((result.content || '').trim()).text.trim();
5129
+ if (!fullContent && appServerAttempt < LOCAL_RUNTIME_RETRY_LIMIT) {
5130
+ forceFreshInteractiveCliSession = true;
5131
+ if (cliSessionEpochPlan.resumeSessionId) {
5132
+ applyFreshCliBootstrapFallback('resume_failed');
5133
+ }
5134
+ codexAppServerManager.closeSessionByConversation(convId, profile.id);
5135
+ const retryDetail = `Selected runtime returned an empty response; retrying with a fresh ${activeProviderName} session (${appServerAttempt + 1}/${LOCAL_RUNTIME_RETRY_LIMIT})...`;
5136
+ console.warn(chalk_1.default.yellow(` [chat] ${retryDetail}`));
5137
+ await pauseLocalRuntimeRetry(appServerAttempt);
5138
+ continue;
5139
+ }
5140
+ break;
5141
+ }
5142
+ catch (codexErr) {
5143
+ if (routeAbortController.signal.aborted || codexErr?.name === 'AbortError') {
5144
+ throw codexErr;
5145
+ }
5146
+ clearFailedLocalCliSessionEpoch(convId, profile.id, activeCliSessionId || cliSessionEpochPlan.resumeSessionId);
5147
+ if (appServerAttempt >= LOCAL_RUNTIME_RETRY_LIMIT || !shouldRetrySelectedLocalRuntime(codexErr)) {
5148
+ throw codexErr;
5149
+ }
5150
+ forceFreshInteractiveCliSession = true;
5151
+ if (cliSessionEpochPlan.resumeSessionId) {
5152
+ applyFreshCliBootstrapFallback('resume_failed');
5153
+ }
5154
+ codexAppServerManager.closeSessionByConversation(convId, profile.id);
5155
+ const retryDetail = `Selected runtime failed (${codexErr?.message || codexErr}); retrying with a fresh ${activeProviderName} session (${appServerAttempt + 1}/${LOCAL_RUNTIME_RETRY_LIMIT})...`;
5156
+ console.warn(chalk_1.default.yellow(` [chat] ${retryDetail}`));
5157
+ await pauseLocalRuntimeRetry(appServerAttempt);
5158
+ }
5159
+ }
5160
+ }
5161
+ if (usePtyInteractiveCliSession) {
5162
+ if (activeProviderName !== 'claude-cli') {
5163
+ throw new Error(`Legacy PTY interactive sessions are Claude-only in local desktop mode; expected ${activeProviderName} to use Codex app-server.`);
5164
+ }
3320
5165
  const ptyManager = (0, local_cli_pty_manager_1.getLocalCliPtySessionManager)();
3321
5166
  let ptyAttempt = 0;
5167
+ let forceFreshInteractiveCliSession = false;
5168
+ let currentAttemptSessionId = null;
5169
+ let currentAttemptWasFreshSession = false;
3322
5170
  while (true) {
3323
5171
  ptyAttempt++;
3324
5172
  try {
5173
+ const hasLivePtySession = ptyManager.hasActiveSession(convId, profile.id);
5174
+ const isFreshSession = forceFreshInteractiveCliSession || (!hasLivePtySession && !cliSessionEpochPlan.resumeSessionId);
5175
+ const newSessionId = activeProviderName === 'claude-cli' && isFreshSession
5176
+ ? data.generateNextSessionId()
5177
+ : undefined;
5178
+ currentAttemptWasFreshSession = isFreshSession;
5179
+ currentAttemptSessionId = newSessionId || cliSessionEpochPlan.resumeSessionId || activeCliSessionId || null;
3325
5180
  const result = await ptyManager.runTurn({
3326
5181
  conversationId: convId,
3327
5182
  botId: profile.id,
3328
- provider: activeProviderName,
5183
+ provider: 'claude-cli',
5184
+ botSettings: {
5185
+ claude: {
5186
+ model: profile.model,
5187
+ effortLevel: profile.claude_effort_level,
5188
+ outputStyle: profile.claude_output_style,
5189
+ fastMode: profile.claude_fast_mode === 1,
5190
+ permissionsJson: profile.claude_permissions_json,
5191
+ },
5192
+ },
3329
5193
  cwd: llmSpawnCwd,
3330
5194
  systemPrompt,
3331
5195
  messages: llmMessages,
3332
- forceFreshSession: !cliSessionEpochPlan.resumeSessionId,
3333
- onDetail: async (detail) => {
3334
- sendEvent('status', {
3335
- phase: 'thinking',
3336
- detail,
3337
- runtime: runtimePayload(),
5196
+ forceFreshSession: isFreshSession,
5197
+ resumeSessionId: isFreshSession ? undefined : (cliSessionEpochPlan.resumeSessionId || undefined),
5198
+ newSessionId,
5199
+ abortSignal: routeAbortController.signal,
5200
+ onRawChunk: async (chunk) => {
5201
+ sendEvent('terminal_chunk', {
5202
+ text: chunk,
5203
+ provider: activeProviderName,
5204
+ botId: profile?.id || null,
5205
+ agentName: profile?.name || null,
3338
5206
  });
3339
- recordActivity('status', {
3340
- phase: 'thinking',
3341
- detail,
3342
- runtime: runtimePayload(),
3343
- }, detail);
5207
+ },
5208
+ onChunk: async (chunk) => {
5209
+ streamedAnyChunk = true;
5210
+ streamedContent += chunk;
5211
+ persistAssistantPartial(false);
5212
+ sendEvent('chunk', { text: chunk });
5213
+ },
5214
+ onDetail: async (detail) => {
5215
+ const text = String(detail || '').trim();
5216
+ if (!text)
5217
+ return;
5218
+ sendEvent('status', { phase: 'thinking', detail: text });
5219
+ recordActivity('status', { phase: 'thinking', detail: text }, text);
3344
5220
  },
3345
5221
  });
3346
5222
  if (result.sessionId) {
@@ -3351,25 +5227,76 @@ function startLocalServer(opts) {
3351
5227
  totalOutputTokens += result.usage.outputTokens || 0;
3352
5228
  hasExactUsage = true;
3353
5229
  }
3354
- fullContent = (result.content || '').trim();
5230
+ // ─── token.txt: agent-side usage log (Claude PTY path) ──────
5231
+ // Fire-and-forget. Never blocks the chat reply.
5232
+ try {
5233
+ const __auth = await getHydratedDesktopAuth().catch(() => null);
5234
+ if (__auth?.token && result.usage) {
5235
+ void (0, usage_log_1.writeAgentUsageLog)({
5236
+ apiToken: __auth.token,
5237
+ testRunId: __testRunId,
5238
+ mode: 'local-db-claude-pty',
5239
+ conversationId: convId,
5240
+ botId: profile?.id || null,
5241
+ botName: profile?.name || null,
5242
+ turnIndex: __turnIndexParsed,
5243
+ provider: 'anthropic',
5244
+ model: profile?.model || 'unknown',
5245
+ inputTokensFresh: result.usage.inputTokensFresh ?? 0,
5246
+ inputTokensCacheCreation: result.usage.inputTokensCacheCreation ?? 0,
5247
+ inputTokensCacheRead: result.usage.inputTokensCacheRead ?? 0,
5248
+ outputTokens: result.usage.outputTokens ?? 0,
5249
+ userPromptText: __benchUserMessage,
5250
+ systemPromptText: systemPrompt,
5251
+ responseText: result.content || null,
5252
+ });
5253
+ }
5254
+ }
5255
+ catch { /* best effort; never break chat */ }
5256
+ // ─── end usage log ──────────────────────────────────────────
5257
+ rawCliTranscript = result.rawOutput || '';
5258
+ fullContent = (0, completion_marker_1.stripCompletionSentinel)((result.content || '').trim()).text.trim();
5259
+ if (!fullContent && ptyAttempt < LOCAL_RUNTIME_RETRY_LIMIT) {
5260
+ forceFreshInteractiveCliSession = true;
5261
+ if (cliSessionEpochPlan.resumeSessionId) {
5262
+ applyFreshCliBootstrapFallback('resume_failed');
5263
+ }
5264
+ ptyManager.closeSessionByConversation(convId, profile.id);
5265
+ const retryDetail = `Selected runtime returned an empty response; retrying with a fresh ${activeProviderName} session (${ptyAttempt + 1}/${LOCAL_RUNTIME_RETRY_LIMIT})...`;
5266
+ console.warn(chalk_1.default.yellow(` [chat] ${retryDetail}`));
5267
+ await pauseLocalRuntimeRetry(ptyAttempt);
5268
+ continue;
5269
+ }
3355
5270
  break;
3356
5271
  }
3357
5272
  catch (ptyErr) {
5273
+ if (routeAbortController.signal.aborted || ptyErr?.name === 'AbortError') {
5274
+ throw ptyErr;
5275
+ }
5276
+ ptyManager.logSessionFailureByConversation(convId, profile.id, 'chat_runtime_failure_before_kill', ptyErr);
5277
+ clearFailedLocalCliSessionEpoch(convId, profile.id, currentAttemptSessionId || activeCliSessionId || cliSessionEpochPlan.resumeSessionId);
3358
5278
  if (ptyAttempt >= LOCAL_RUNTIME_RETRY_LIMIT || !shouldRetrySelectedLocalRuntime(ptyErr)) {
3359
5279
  throw ptyErr;
3360
5280
  }
3361
- const retryDetail = `Selected runtime failed (${ptyErr?.message || ptyErr}); retrying the same connection (${ptyAttempt + 1}/${LOCAL_RUNTIME_RETRY_LIMIT})...`;
3362
- console.warn(chalk_1.default.yellow(` [chat] ${retryDetail}`));
3363
- sendEvent('status', {
3364
- phase: 'thinking',
3365
- detail: retryDetail,
3366
- runtime: runtimePayload(),
3367
- });
3368
- recordActivity('status', {
3369
- phase: 'thinking',
3370
- detail: retryDetail,
3371
- runtime: runtimePayload(),
3372
- }, retryDetail);
5281
+ const startupRetry = isClaudeFreshSessionStartupFailure(ptyErr);
5282
+ if (startupRetry || currentAttemptWasFreshSession) {
5283
+ forceFreshInteractiveCliSession = true;
5284
+ ptyManager.closeSessionByConversation(convId, profile.id);
5285
+ }
5286
+ const resumeFailureFallback = !!cliSessionEpochPlan.resumeSessionId && !currentAttemptWasFreshSession;
5287
+ if (resumeFailureFallback) {
5288
+ forceFreshInteractiveCliSession = true;
5289
+ applyFreshCliBootstrapFallback('resume_failed');
5290
+ ptyManager.closeSessionByConversation(convId, profile.id);
5291
+ }
5292
+ const retryDetail = startupRetry
5293
+ ? `Fresh ${activeProviderName} session ${currentAttemptSessionId || '(unknown)'} did not finish startup before the transcript became available; killing it and retrying with a new session (${ptyAttempt + 1}/${LOCAL_RUNTIME_RETRY_LIMIT})...`
5294
+ : resumeFailureFallback
5295
+ ? `Stored ${activeProviderName} session ${currentAttemptSessionId || '(unknown)'} failed to resume (${ptyErr?.message || ptyErr}); retrying with a fresh bootstrapped session (${ptyAttempt + 1}/${LOCAL_RUNTIME_RETRY_LIMIT})...`
5296
+ : currentAttemptWasFreshSession && activeProviderName === 'claude-cli'
5297
+ ? `Fresh ${activeProviderName} session ${currentAttemptSessionId || '(unknown)'} failed (${ptyErr?.message || ptyErr}); killing it and retrying with a new session (${ptyAttempt + 1}/${LOCAL_RUNTIME_RETRY_LIMIT})...`
5298
+ : `Selected runtime failed (${ptyErr?.message || ptyErr}); retrying the same connection (${ptyAttempt + 1}/${LOCAL_RUNTIME_RETRY_LIMIT})...`;
5299
+ console.warn(chalk_1.default.yellow(` [chat] ${retryDetail}`));
3373
5300
  await pauseLocalRuntimeRetry(ptyAttempt);
3374
5301
  }
3375
5302
  }
@@ -3379,10 +5306,6 @@ function startLocalServer(opts) {
3379
5306
  iteration++;
3380
5307
  let iterationFirstChunk = true;
3381
5308
  throwIfChatJobCancelled();
3382
- if (iteration > 1) {
3383
- sendEvent('status', { phase: 'thinking', detail: 'Processing tool results...' });
3384
- recordActivity('status', { phase: 'thinking', detail: 'Processing tool results...' }, 'Processing tool results...');
3385
- }
3386
5309
  let response;
3387
5310
  const chatOptions = {
3388
5311
  messages: llmMessages,
@@ -3398,8 +5321,6 @@ function startLocalServer(opts) {
3398
5321
  throwIfChatJobCancelled();
3399
5322
  if (iterationFirstChunk) {
3400
5323
  iterationFirstChunk = false;
3401
- sendEvent('status', { phase: 'generating' });
3402
- recordActivity('status', { phase: 'generating' }, 'Generating response...');
3403
5324
  }
3404
5325
  streamedAnyChunk = true;
3405
5326
  streamedContent += chunk;
@@ -3531,7 +5452,7 @@ function startLocalServer(opts) {
3531
5452
  continue;
3532
5453
  }
3533
5454
  // Final response (guard against defer-only filler)
3534
- const candidate = (response.content || '').trim();
5455
+ const candidate = (0, completion_marker_1.stripCompletionSentinel)((response.content || '').trim()).text.trim();
3535
5456
  if (!forcedFinalizationPass && (0, response_guard_1.isLikelyDeferredReply)(candidate)) {
3536
5457
  forcedFinalizationPass = true;
3537
5458
  llmMessages.push({ role: 'assistant', content: candidate });
@@ -3539,19 +5460,28 @@ function startLocalServer(opts) {
3539
5460
  role: 'user',
3540
5461
  content: 'Provide the final answer now. Do not say you will check later. Either provide concrete results or explicitly say what is unavailable.',
3541
5462
  });
3542
- sendEvent('status', { phase: 'thinking', detail: 'Finalizing response...' });
3543
- recordActivity('status', { phase: 'thinking', detail: 'Finalizing response...' }, 'Finalizing response...');
3544
5463
  continue;
3545
5464
  }
3546
5465
  fullContent = candidate;
3547
5466
  break;
3548
5467
  }
3549
- const persistedContent = fullContent || streamedContent.trim();
5468
+ const persistedContent = (0, completion_marker_1.stripCompletionSentinel)(fullContent || streamedContent.trim()).text.trim();
3550
5469
  if (!persistedContent) {
3551
5470
  throw new Error('Assistant returned no final response');
3552
5471
  }
3553
5472
  persistAssistantPartial(true);
3554
5473
  if (enableCliSessionEpoch && activeCliSessionId) {
5474
+ if (cliSessionEpochPlan.resumeSessionId && !freshCliBootstrapFallbackApplied) {
5475
+ console.info(`[chat] cli_session_resume_succeeded conversationId=${convId} botId=${profile.id} provider=${activeProviderName} sessionId=${activeCliSessionId}`);
5476
+ }
5477
+ const usedBootstrap = freshCliBootstrapFileWritten
5478
+ || (!cliSessionEpochPlan.resumeSessionId && !!promptContextWindow.allowBootstrap && !skipFreshCliBootstrap);
5479
+ const bootstrapReason = freshCliBootstrapFileWritten
5480
+ ? 'resume_failed'
5481
+ : (!cliSessionEpochPlan.resumeSessionId && !!promptContextWindow.allowBootstrap && !skipFreshCliBootstrap)
5482
+ ? 'fresh_with_context'
5483
+ : null;
5484
+ console.info(`[chat] cli_turn_telemetry conversationId=${convId} botId=${profile.id} provider=${activeProviderName} selectedSessionId=${cliSessionEpochPlan.resumeSessionId || '(none)'} actualSessionId=${activeCliSessionId} promptContextMode=${promptContextWindow.mode || 'unknown'} usedBootstrap=${usedBootstrap} bootstrapReason=${bootstrapReason || '(none)'} resetReason=${cliEpochResetReason || '(none)'}`);
3555
5485
  const nextEpochTurnCount = cliSessionEpochPlan.resumeSessionId
3556
5486
  ? ((cliSessionEpochPlan.existing?.epoch_turn_count || 0) + 1)
3557
5487
  : 1;
@@ -3563,7 +5493,7 @@ function startLocalServer(opts) {
3563
5493
  epochTurnCount: nextEpochTurnCount,
3564
5494
  lastInputTokens: hasExactUsage ? totalInputTokens : approxInputTokens,
3565
5495
  lastOutputTokens: hasExactUsage ? totalOutputTokens : 0,
3566
- resetReason: cliSessionEpochPlan.resetReason,
5496
+ resetReason: cliEpochResetReason,
3567
5497
  epochStartedAt: cliEpochStartedAt,
3568
5498
  lastUsedAt: localTimestamp(),
3569
5499
  });
@@ -3594,8 +5524,15 @@ function startLocalServer(opts) {
3594
5524
  model: modelWithRuntime || null,
3595
5525
  botId: profile.id,
3596
5526
  agentName: profile.name,
5527
+ resultArtifact: useInteractiveCliSession ? (rawCliTranscript || null) : undefined,
3597
5528
  }) || data.addMessage(convId, 'assistant', persistedContent, modelWithRuntime || undefined, undefined, profile.id, profile.name))
3598
5529
  : data.addMessage(convId, 'assistant', persistedContent, modelWithRuntime || undefined, undefined, profile.id, profile.name);
5530
+ emitConversationSyncEvents(convId, [
5531
+ {
5532
+ change: savedMessage.id === assistantMessageId ? 'message.updated' : 'message.created',
5533
+ messageId: savedMessage.id,
5534
+ },
5535
+ ]);
3599
5536
  data.attachMessageActivitiesToMessage(activityStreamId, savedMessage.id);
3600
5537
  data.createMessageActivity({
3601
5538
  conversationId: convId,
@@ -3606,6 +5543,7 @@ function startLocalServer(opts) {
3606
5543
  summary: 'Final assistant response',
3607
5544
  payload: {
3608
5545
  content: persistedContent,
5546
+ ...(useInteractiveCliSession && rawCliTranscript ? { rawOutput: rawCliTranscript } : {}),
3609
5547
  runtime: runtimePayload(),
3610
5548
  },
3611
5549
  expiresAt: activityExpiresAt,
@@ -3632,6 +5570,9 @@ function startLocalServer(opts) {
3632
5570
  // For CLI providers, set a simple title without burning a CLI call
3633
5571
  const shortTitle = message.slice(0, 60).replace(/\n/g, ' ').trim();
3634
5572
  data.updateConversation(convId, { title: shortTitle || 'New Chat' });
5573
+ emitConversationSyncEvents(convId, [
5574
+ { change: 'conversation.updated' },
5575
+ ]);
3635
5576
  }
3636
5577
  else {
3637
5578
  autoTitleConversation(convId, message, persistedContent, activeProviderName, activeModelName, activeApiKey);
@@ -3646,8 +5587,17 @@ function startLocalServer(opts) {
3646
5587
  (0, local_funnel_1.scheduleFunnelProcessing)(convId);
3647
5588
  }
3648
5589
  catch (err) {
5590
+ // Log the FULL stack to agent.log. Previously only err.message
5591
+ // made it into DB / console; for crashes like "Maximum call
5592
+ // stack size exceeded" we lost the one useful diagnostic (the
5593
+ // recursion site). Chalk-colored message + serialized stack.
5594
+ // (2026-04-19.)
3649
5595
  console.error(chalk_1.default.red(`Chat error: ${err.message}`));
5596
+ if (err?.stack) {
5597
+ console.error(String(err.stack));
5598
+ }
3650
5599
  try {
5600
+ const userMessage = buildLocalRuntimeUserFacingError(err);
3651
5601
  if (activityErrorContext.conversationId) {
3652
5602
  data.createMessageActivity({
3653
5603
  conversationId: activityErrorContext.conversationId,
@@ -3659,6 +5609,8 @@ function startLocalServer(opts) {
3659
5609
  summary: err.message,
3660
5610
  payload: {
3661
5611
  error: err.message,
5612
+ userMessage,
5613
+ stack: err?.stack || null,
3662
5614
  authRequired: err?.authRequired === true,
3663
5615
  providerId: err?.providerId || null,
3664
5616
  cli: err?.cli || null,
@@ -3671,12 +5623,13 @@ function startLocalServer(opts) {
3671
5623
  // best effort only
3672
5624
  }
3673
5625
  if (!res.headersSent) {
3674
- res.status(500).json({ error: err.message });
5626
+ res.status(500).json({ error: err.message, userMessage: buildLocalRuntimeUserFacingError(err) });
3675
5627
  }
3676
5628
  else {
3677
5629
  try {
3678
5630
  res.write(`event: error\ndata: ${JSON.stringify({
3679
5631
  error: err.message,
5632
+ userMessage: buildLocalRuntimeUserFacingError(err),
3680
5633
  authRequired: err?.authRequired === true,
3681
5634
  providerId: err?.providerId || null,
3682
5635
  cli: err?.cli || null,
@@ -3689,6 +5642,7 @@ function startLocalServer(opts) {
3689
5642
  }
3690
5643
  finally {
3691
5644
  req.off?.('close', abortOnClientClose);
5645
+ res.off?.('close', abortOnClientClose);
3692
5646
  }
3693
5647
  });
3694
5648
  // ─── Memory Facts ───────────────────────────────────────────
@@ -3763,6 +5717,650 @@ function startLocalServer(opts) {
3763
5717
  res.status(500).json({ error: err.message });
3764
5718
  }
3765
5719
  });
5720
+ // Phase B: tool-dispatch is always on for local_desktop; keep GET for
5721
+ // introspection so any external tooling knows the state. POST is a
5722
+ // no-op that returns the current (permanent) state.
5723
+ app.get('/api/orchestration/tool-dispatch', (_req, res) => {
5724
+ res.json({ enabled: true, permanent: true, phase: 'B' });
5725
+ });
5726
+ app.post('/api/orchestration/tool-dispatch', (_req, res) => {
5727
+ res.json({ enabled: true, permanent: true, phase: 'B' });
5728
+ });
5729
+ function buildPlanImportChatMessage(params) {
5730
+ const lines = [];
5731
+ lines.push(`**Plan Imported: ${params.sourceFileName}**`);
5732
+ lines.push('');
5733
+ lines.push(`**Plan File:** ${params.sourceFileName}`);
5734
+ const plannerLabel = params.planner.agentName || 'Planner';
5735
+ lines.push(`**Planner:** ${plannerLabel}${params.planner.model ? ` (${params.planner.model})` : ''}`);
5736
+ const stageLines = params.stages.map((stage, i) => {
5737
+ const bot = data.getAgentProfile(stage.botId);
5738
+ return `#${i + 1} ${stage.role} → ${bot?.name || stage.botId}`;
5739
+ });
5740
+ lines.push(`**Task Flow:** ${stageLines.join(', ')}`);
5741
+ if (params.plannerInstructions?.trim()) {
5742
+ lines.push(`**Planner Instructions:** ${params.plannerInstructions.trim()}`);
5743
+ }
5744
+ const orderLabel = params.executionOrder === 'batch_by_stage'
5745
+ ? 'batch_by_stage (all coding first, then QA)'
5746
+ : 'per_item_pipeline (Code → QA per item)';
5747
+ lines.push(`**Execution Order:** ${orderLabel}`);
5748
+ lines.push(`**Status:** ${params.status}`);
5749
+ lines.push('');
5750
+ lines.push(`**Created ${params.taskCount} TODOs:**`);
5751
+ lines.push('');
5752
+ for (let i = 0; i < params.tasks.length; i++) {
5753
+ const task = params.tasks[i];
5754
+ const role = task.task_type || 'Task';
5755
+ const owner = task.owner_name || 'Unassigned';
5756
+ lines.push(`${i + 1}. [${role}] ${owner}: ${task.title}`);
5757
+ }
5758
+ lines.push('');
5759
+ lines.push('Review the stored tasks, delete any you do not want, then start the plan.');
5760
+ return lines.join('\n');
5761
+ }
5762
+ function buildPlanStartChatMessage(params) {
5763
+ const lines = [];
5764
+ const action = params.isResume ? 'Resumed' : 'Started';
5765
+ lines.push(`**Plan ${action}: ${params.sourceFileName}**`);
5766
+ lines.push('');
5767
+ if (params.firstTask) {
5768
+ const role = params.firstTask.task_type || 'Task';
5769
+ const owner = params.firstTask.owner_name || 'Unassigned';
5770
+ lines.push(`**Next TODO:** #${params.firstTask.id} "${params.firstTask.title}" (${owner}, ${role})`);
5771
+ }
5772
+ lines.push(`**Active TODOs remaining:** ${params.remainingCount} of ${params.totalCount}`);
5773
+ if (params.executionOrder) {
5774
+ const orderLabel = params.executionOrder === 'batch_by_stage'
5775
+ ? 'batch_by_stage (all coding first, then QA)'
5776
+ : 'per_item_pipeline (Code → QA per item)';
5777
+ lines.push(`**Execution Order:** ${orderLabel}`);
5778
+ }
5779
+ lines.push(`**Status:** ${params.status}`);
5780
+ return lines.join('\n');
5781
+ }
5782
+ function buildPlanFailureChatMessage(params) {
5783
+ const lines = [];
5784
+ lines.push(`**Plan Resume Failed: ${params.sourceFileName}**`);
5785
+ lines.push('');
5786
+ lines.push(`**Error:** ${params.error}`);
5787
+ lines.push(`**Status:** ${params.status}`);
5788
+ lines.push('');
5789
+ lines.push('The plan has been paused. Check the error and retry.');
5790
+ return lines.join('\n');
5791
+ }
5792
+ const activeImportedPlanRunners = new Map();
5793
+ const buildImportedPlanSessionScopeKey = (runId, conversationId) => `${conversationId}::plan::${runId}`;
5794
+ const resolveImportedPlanWorkerBotIds = (run) => {
5795
+ const tasks = data.getImportedPlanRunTasks(run.id);
5796
+ const ids = new Set();
5797
+ for (const stage of run.requested_stages || []) {
5798
+ if (stage.botId)
5799
+ ids.add(stage.botId);
5800
+ }
5801
+ for (const task of tasks) {
5802
+ if (task.owner_bot_id) {
5803
+ ids.add(task.owner_bot_id);
5804
+ continue;
5805
+ }
5806
+ const ownerName = String(task.owner_name || '').trim().toLowerCase();
5807
+ if (!ownerName)
5808
+ continue;
5809
+ const match = data.listAgentProfiles().find((agent) => agent.name.trim().toLowerCase() === ownerName);
5810
+ if (match?.id)
5811
+ ids.add(match.id);
5812
+ }
5813
+ return Array.from(ids);
5814
+ };
5815
+ const teardownImportedPlanRunnerSessions = (run, sessionScopeKey) => {
5816
+ if (!run)
5817
+ return;
5818
+ const scopeKey = String(sessionScopeKey || '').trim() || buildImportedPlanSessionScopeKey(run.id, run.conversation_id);
5819
+ const workerBotIds = resolveImportedPlanWorkerBotIds(run);
5820
+ const ptyManager = (0, local_cli_pty_manager_1.getLocalCliPtySessionManager)();
5821
+ const codexAppServerManager = (0, codex_app_server_manager_1.getCodexAppServerManager)();
5822
+ for (const botId of workerBotIds) {
5823
+ ptyManager.closeSessionByConversation(scopeKey, botId);
5824
+ codexAppServerManager.closeSessionByConversation(scopeKey, botId);
5825
+ data.deleteCliSessionEpoch(run.conversation_id, botId, scopeKey);
5826
+ }
5827
+ };
5828
+ const importedPlanPauseSummary = (action) => {
5829
+ if (action === 'cancel')
5830
+ return 'Plan cancelled. Resume will continue from the current active TODO.';
5831
+ if (action === 'stop')
5832
+ return 'Plan stopped. Resume will continue from the current active TODO.';
5833
+ return 'Plan paused. Resume will continue from the current active TODO.';
5834
+ };
5835
+ const normalizeImportedPlanRunForUi = (run) => {
5836
+ if (!run)
5837
+ return undefined;
5838
+ if (run.status === 'running' && !activeImportedPlanRunners.has(run.id) && data.getImportedPlanRunRemainingTaskIds(run.id).length > 0) {
5839
+ return data.updateImportedPlanRun(run.id, {
5840
+ status: 'paused',
5841
+ lastError: run.last_error || 'Agent restarted or lost the in-memory Plan-TODO runner. Resume will continue from the next active TODO.',
5842
+ }) || run;
5843
+ }
5844
+ return run;
5845
+ };
5846
+ const serializeImportedPlanRun = (run) => {
5847
+ run = normalizeImportedPlanRunForUi(run);
5848
+ if (!run)
5849
+ return null;
5850
+ const tasks = data.getImportedPlanRunTasks(run.id);
5851
+ const remainingTaskIds = data.getImportedPlanRunRemainingTaskIds(run.id);
5852
+ const currentTask = data.getImportedPlanRunCurrentTask(run.id) || null;
5853
+ return {
5854
+ id: run.id,
5855
+ conversationId: run.conversation_id,
5856
+ projectId: run.project_id,
5857
+ sourceFilePath: run.source_file_path,
5858
+ sourceFileName: run.source_file_name,
5859
+ planner: {
5860
+ botId: run.planner_bot_id,
5861
+ agentName: run.planner_agent_name,
5862
+ provider: run.planner_provider,
5863
+ model: run.planner_model,
5864
+ },
5865
+ stages: run.requested_stages,
5866
+ options: run.requested_options,
5867
+ taskIds: run.task_ids,
5868
+ remainingTaskIds,
5869
+ taskCount: tasks.length,
5870
+ completedTaskCount: tasks.filter((task) => task.state === 'completed').length,
5871
+ remainingTaskCount: remainingTaskIds.length,
5872
+ currentTask,
5873
+ status: run.status,
5874
+ createdAt: run.created_at,
5875
+ updatedAt: run.updated_at,
5876
+ startedAt: run.started_at,
5877
+ completedAt: run.completed_at,
5878
+ discardedAt: run.discarded_at,
5879
+ lastError: run.last_error,
5880
+ tasks,
5881
+ };
5882
+ };
5883
+ app.get('/api/orchestration/import-plan/current', (req, res) => {
5884
+ try {
5885
+ const conversationId = String(req.query.conversationId || '').trim();
5886
+ if (!conversationId)
5887
+ return res.status(400).json({ error: 'conversationId is required' });
5888
+ const run = normalizeImportedPlanRunForUi(data.getLatestImportedPlanRunForConversation(conversationId));
5889
+ res.json({ ok: true, run: serializeImportedPlanRun(run) });
5890
+ }
5891
+ catch (err) {
5892
+ res.status(500).json({ error: err.message });
5893
+ }
5894
+ });
5895
+ app.post('/api/orchestration/import-plan/control', (req, res) => {
5896
+ try {
5897
+ if (isConnectedMode()) {
5898
+ return res.status(501).json({ error: 'Imported plan control is only implemented for local desktop storage right now.' });
5899
+ }
5900
+ const runId = String(req.body?.runId || '').trim();
5901
+ const action = String(req.body?.action || '').trim().toLowerCase();
5902
+ if (!runId)
5903
+ return res.status(400).json({ error: 'runId is required' });
5904
+ if (action !== 'pause' && action !== 'stop' && action !== 'cancel') {
5905
+ return res.status(400).json({ error: 'action must be pause, stop, or cancel' });
5906
+ }
5907
+ const run = normalizeImportedPlanRunForUi(data.getImportedPlanRun(runId));
5908
+ if (!run)
5909
+ return res.status(404).json({ error: 'Imported plan run not found.' });
5910
+ if (run.status === 'completed' || run.status === 'discarded') {
5911
+ return res.status(400).json({ error: `Imported plan is already ${run.status}.` });
5912
+ }
5913
+ const runner = activeImportedPlanRunners.get(run.id);
5914
+ const summary = importedPlanPauseSummary(action);
5915
+ const updatedRun = data.updateImportedPlanRun(run.id, {
5916
+ status: 'paused',
5917
+ completedAt: null,
5918
+ lastError: summary,
5919
+ }) || run;
5920
+ if (runner) {
5921
+ runner.stopRequested = true;
5922
+ runner.stopAction = action;
5923
+ runner.abortController.abort();
5924
+ }
5925
+ else {
5926
+ teardownImportedPlanRunnerSessions(updatedRun);
5927
+ }
5928
+ res.json({
5929
+ ok: true,
5930
+ action,
5931
+ summary,
5932
+ run: serializeImportedPlanRun(updatedRun),
5933
+ });
5934
+ }
5935
+ catch (err) {
5936
+ res.status(500).json({ error: err.message });
5937
+ }
5938
+ });
5939
+ app.post('/api/orchestration/import-plan', async (req, res) => {
5940
+ try {
5941
+ if (isConnectedMode()) {
5942
+ return res.status(501).json({ error: 'Plan import is only implemented for local desktop storage right now.' });
5943
+ }
5944
+ const { conversationId, topicId, projectId, filePath, importMode, stages, plannerBotId, plannerInstructions, executionOrder, } = req.body || {};
5945
+ const normalizedFilePath = String(filePath || '').trim();
5946
+ if (!normalizedFilePath)
5947
+ return res.status(400).json({ error: 'filePath is required' });
5948
+ if (String(importMode || '') !== 'custom_pipeline') {
5949
+ return res.status(400).json({ error: 'importMode must be custom_pipeline' });
5950
+ }
5951
+ const normalizedPlannerBotId = String(plannerBotId || '').trim();
5952
+ if (!normalizedPlannerBotId)
5953
+ return res.status(400).json({ error: 'plannerBotId is required' });
5954
+ const normalizedStages = Array.isArray(stages)
5955
+ ? stages.map((stage) => ({
5956
+ role: String(stage?.role || '').trim(),
5957
+ botId: String(stage?.botId || '').trim(),
5958
+ })).filter((stage) => stage.role && stage.botId)
5959
+ : [];
5960
+ if (normalizedStages.length === 0) {
5961
+ return res.status(400).json({ error: 'Choose at least one stage and bot before importing a plan.' });
5962
+ }
5963
+ const normalizedExecutionOrder = String(executionOrder || '').trim();
5964
+ if (normalizedExecutionOrder
5965
+ && normalizedExecutionOrder !== 'per_item_pipeline'
5966
+ && normalizedExecutionOrder !== 'batch_by_stage') {
5967
+ return res.status(400).json({ error: 'executionOrder must be per_item_pipeline or batch_by_stage.' });
5968
+ }
5969
+ const resolvedFilePath = path.resolve(normalizedFilePath);
5970
+ if (!fs.existsSync(resolvedFilePath)) {
5971
+ return res.status(404).json({ error: `File not found: ${resolvedFilePath}` });
5972
+ }
5973
+ const stat = fs.statSync(resolvedFilePath);
5974
+ if (!stat.isFile()) {
5975
+ return res.status(400).json({ error: 'Selected plan path is not a file.' });
5976
+ }
5977
+ if (stat.size > MAX_PLAN_IMPORT_BYTES) {
5978
+ return res.status(400).json({ error: `Selected plan file is too large (${stat.size} bytes). Keep it under ${MAX_PLAN_IMPORT_BYTES} bytes.` });
5979
+ }
5980
+ const rawFileContent = fs.readFileSync(resolvedFilePath, 'utf8');
5981
+ if (!rawFileContent.trim()) {
5982
+ return res.status(400).json({ error: 'Selected plan file is empty.' });
5983
+ }
5984
+ const topic = topicId ? data.getTopic(String(topicId)) : undefined;
5985
+ let effectiveProjectId = projectId ? String(projectId) : (topic?.project_id || null);
5986
+ let convId = conversationId ? String(conversationId) : '';
5987
+ const existingConversation = convId ? data.getConversation(convId) : undefined;
5988
+ if (!effectiveProjectId) {
5989
+ effectiveProjectId = existingConversation?.project_id || null;
5990
+ }
5991
+ if (!effectiveProjectId) {
5992
+ return res.status(400).json({ error: 'Select a project before importing a plan.' });
5993
+ }
5994
+ if (!convId) {
5995
+ const project = data.getProject(effectiveProjectId);
5996
+ const projectBots = (project?.bot_ids || [])
5997
+ .map((botId) => data.getAgentProfile(botId))
5998
+ .filter((bot) => !!bot && bot.is_active === 1);
5999
+ const seedBot = projectBots[0] || data.getDefaultAgentProfile() || data.listAgentProfiles().find((bot) => bot.is_active === 1);
6000
+ if (!seedBot)
6001
+ return res.status(400).json({ error: 'No active bot is available to seed the plan-import conversation.' });
6002
+ const conv = data.createConversation(seedBot.id, `Plan Import: ${path.basename(resolvedFilePath)}`, 'local', {
6003
+ projectId: effectiveProjectId,
6004
+ projectName: project?.name || null,
6005
+ botIds: projectBots.length > 0 ? projectBots.map((bot) => bot.id) : [seedBot.id],
6006
+ initialBotId: seedBot.id,
6007
+ });
6008
+ convId = conv.id;
6009
+ }
6010
+ if (topicId) {
6011
+ try {
6012
+ data.upsertConversationTopicSegment(convId, String(topicId));
6013
+ }
6014
+ catch { /* best effort */ }
6015
+ }
6016
+ const existingRun = normalizeImportedPlanRunForUi(data.getLatestImportedPlanRunForConversation(convId));
6017
+ if (existingRun && (existingRun.status === 'paused' || existingRun.status === 'running')) {
6018
+ return res.status(409).json({
6019
+ error: 'This conversation already has an imported plan waiting for review or execution.',
6020
+ run: serializeImportedPlanRun(existingRun),
6021
+ });
6022
+ }
6023
+ const { OrchestratorAgent, buildLocalDesktopOrchestratorRuntime } = require('./orchestrator');
6024
+ const workflowEngine = (0, workflow_engine_1.getWorkflowEngine)(opts.projectDir, 'local_desktop');
6025
+ const explicitOrchestratorBot = data.isClerkOrchestratorEnabled()
6026
+ ? null
6027
+ : (data.getOrchestratorBot() || null);
6028
+ let orchestratorRuntime;
6029
+ try {
6030
+ orchestratorRuntime = buildLocalDesktopOrchestratorRuntime(explicitOrchestratorBot);
6031
+ }
6032
+ catch (runtimeErr) {
6033
+ return res.status(400).json({
6034
+ error: runtimeErr?.message || 'Plan import needs an orchestrator or clerk runtime configured.',
6035
+ });
6036
+ }
6037
+ const orchestrator = new OrchestratorAgent(orchestratorRuntime, workflowEngine);
6038
+ const normalizedPlannerInstructions = typeof plannerInstructions === 'string' ? plannerInstructions.trim() : null;
6039
+ const result = await orchestrator.importPlanAsTodos({
6040
+ conversationId: convId,
6041
+ projectId: effectiveProjectId,
6042
+ filePath: resolvedFilePath,
6043
+ fileContent: rawFileContent,
6044
+ mode: importMode,
6045
+ stages: normalizedStages,
6046
+ plannerInstructions: normalizedPlannerInstructions || null,
6047
+ executionOrder: normalizedExecutionOrder === 'batch_by_stage' || normalizedExecutionOrder === 'per_item_pipeline'
6048
+ ? normalizedExecutionOrder
6049
+ : null,
6050
+ plannerBotId: normalizedPlannerBotId,
6051
+ orchestratorBot: explicitOrchestratorBot,
6052
+ autoDispatchTodos: false,
6053
+ opts: {
6054
+ ...opts,
6055
+ projectId: effectiveProjectId,
6056
+ onProgress: () => { },
6057
+ mqttPublish: async () => { },
6058
+ commandId: `plan-import-${Date.now()}`,
6059
+ autoDispatchTodos: false,
6060
+ },
6061
+ });
6062
+ const importedPlanRunForMsg = data.getImportedPlanRun(result.planRunId);
6063
+ const importedTasks = importedPlanRunForMsg ? data.getImportedPlanRunTasks(result.planRunId) : [];
6064
+ const richImportMessage = buildPlanImportChatMessage({
6065
+ sourceFileName: path.basename(resolvedFilePath),
6066
+ planner: result.planner,
6067
+ stages: normalizedStages,
6068
+ plannerInstructions: normalizedPlannerInstructions,
6069
+ executionOrder: importedPlanRunForMsg?.requested_options?.executionOrder || null,
6070
+ taskCount: result.taskIds.length,
6071
+ tasks: importedTasks.map((t) => ({
6072
+ id: t.id,
6073
+ owner_name: t.owner_name,
6074
+ task_type: t.task_type,
6075
+ title: t.title,
6076
+ state: t.state,
6077
+ })),
6078
+ status: importedPlanRunForMsg?.status || 'paused',
6079
+ });
6080
+ const assistant = data.addMessage(convId, 'assistant', richImportMessage, result.planner.model || 'Orchestrator', undefined, undefined, 'Orchestrator');
6081
+ res.json({
6082
+ ok: true,
6083
+ conversationId: convId,
6084
+ projectId: effectiveProjectId,
6085
+ messageId: assistant.id,
6086
+ planRun: serializeImportedPlanRun(importedPlanRunForMsg),
6087
+ planRunId: result.planRunId,
6088
+ importMode,
6089
+ planner: result.planner,
6090
+ taskCount: result.taskIds.length,
6091
+ taskIds: result.taskIds,
6092
+ ownerNames: result.ownerNames,
6093
+ summary: richImportMessage,
6094
+ });
6095
+ }
6096
+ catch (err) {
6097
+ res.status(500).json({ error: err.message });
6098
+ }
6099
+ });
6100
+ app.post('/api/orchestration/import-plan/start', async (req, res) => {
6101
+ try {
6102
+ if (isConnectedMode()) {
6103
+ return res.status(501).json({ error: 'Imported plan execution is only implemented for local desktop storage right now.' });
6104
+ }
6105
+ const runId = String(req.body?.runId || '').trim();
6106
+ if (!runId)
6107
+ return res.status(400).json({ error: 'runId is required' });
6108
+ const run = normalizeImportedPlanRunForUi(data.getImportedPlanRun(runId));
6109
+ if (!run)
6110
+ return res.status(404).json({ error: 'Imported plan run not found.' });
6111
+ if (run.status === 'discarded')
6112
+ return res.status(400).json({ error: 'This imported plan was discarded.' });
6113
+ if (activeImportedPlanRunners.has(run.id)) {
6114
+ return res.json({ ok: true, started: false, run: serializeImportedPlanRun(run), summary: 'Imported plan is already running.' });
6115
+ }
6116
+ const project = run.project_id ? data.getProject(run.project_id) : undefined;
6117
+ const { OrchestratorAgent, buildLocalDesktopOrchestratorRuntime } = require('./orchestrator');
6118
+ const workflowEngine = (0, workflow_engine_1.getWorkflowEngine)(project?.folder || opts.projectDir, 'local_desktop');
6119
+ const explicitOrchestratorBot = data.isClerkOrchestratorEnabled()
6120
+ ? null
6121
+ : (data.getOrchestratorBot() || null);
6122
+ let orchestratorRuntime;
6123
+ try {
6124
+ orchestratorRuntime = buildLocalDesktopOrchestratorRuntime(explicitOrchestratorBot);
6125
+ }
6126
+ catch (runtimeErr) {
6127
+ return res.status(400).json({
6128
+ error: runtimeErr?.message || 'Imported plan execution needs an orchestrator or clerk runtime configured.',
6129
+ });
6130
+ }
6131
+ const orchestrator = new OrchestratorAgent(orchestratorRuntime, workflowEngine);
6132
+ const startAllTasks = data.getImportedPlanRunTasks(run.id);
6133
+ const startRemainingIds = data.getImportedPlanRunRemainingTaskIds(run.id);
6134
+ const startFirstTask = startRemainingIds.length > 0
6135
+ ? data.getTodoTask(startRemainingIds[0], 'active')
6136
+ : null;
6137
+ const isResumingPlan = !!run.started_at;
6138
+ const startedSummary = buildPlanStartChatMessage({
6139
+ sourceFileName: run.source_file_name,
6140
+ isResume: isResumingPlan,
6141
+ remainingCount: startRemainingIds.length,
6142
+ totalCount: startAllTasks.length,
6143
+ firstTask: startFirstTask ? {
6144
+ id: startFirstTask.id,
6145
+ title: startFirstTask.title,
6146
+ owner_name: startFirstTask.owner_name,
6147
+ task_type: startFirstTask.task_type,
6148
+ } : null,
6149
+ executionOrder: run.requested_options?.executionOrder || null,
6150
+ status: 'running',
6151
+ });
6152
+ const assistant = data.addMessage(run.conversation_id, 'assistant', startedSummary, orchestratorRuntime.model || 'Orchestrator', undefined, undefined, 'Orchestrator');
6153
+ const activityStreamId = `activity-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
6154
+ const activityExpiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000)
6155
+ .toISOString()
6156
+ .replace('T', ' ')
6157
+ .replace('Z', '');
6158
+ const workerBotByName = new Map();
6159
+ const resolveWorkerBotId = (agentName) => {
6160
+ const key = String(agentName || '').trim().toLowerCase();
6161
+ if (!key)
6162
+ return null;
6163
+ if (workerBotByName.has(key))
6164
+ return workerBotByName.get(key) || null;
6165
+ const match = data.listAgentProfiles().find((agent) => agent.name.trim().toLowerCase() === key);
6166
+ const resolved = match?.id || null;
6167
+ workerBotByName.set(key, resolved || '');
6168
+ return resolved;
6169
+ };
6170
+ const recordActivity = (activityType, payload, summary) => {
6171
+ try {
6172
+ data.createMessageActivity({
6173
+ conversationId: run.conversation_id,
6174
+ messageId: String(assistant.id),
6175
+ streamId: activityStreamId,
6176
+ botId: null,
6177
+ agentName: 'Orchestrator',
6178
+ activityType,
6179
+ summary: summary || null,
6180
+ payload,
6181
+ expiresAt: activityExpiresAt,
6182
+ });
6183
+ }
6184
+ catch {
6185
+ // Best effort only
6186
+ }
6187
+ };
6188
+ const recordWorkerActivity = (activityType, event, payload, summary) => {
6189
+ try {
6190
+ data.createMessageActivity({
6191
+ conversationId: run.conversation_id,
6192
+ messageId: String(assistant.id),
6193
+ streamId: activityStreamId,
6194
+ botId: resolveWorkerBotId(event.agentName) || null,
6195
+ agentName: event.agentName || null,
6196
+ activityType,
6197
+ summary: summary || null,
6198
+ payload,
6199
+ expiresAt: activityExpiresAt,
6200
+ });
6201
+ }
6202
+ catch {
6203
+ // Best effort only
6204
+ }
6205
+ };
6206
+ recordActivity('status', { phase: 'starting', detail: startedSummary }, startedSummary);
6207
+ const activeRunner = {
6208
+ runId: run.id,
6209
+ conversationId: run.conversation_id,
6210
+ sessionScopeKey: buildImportedPlanSessionScopeKey(run.id, run.conversation_id),
6211
+ abortController: new AbortController(),
6212
+ stopRequested: false,
6213
+ stopAction: null,
6214
+ };
6215
+ activeImportedPlanRunners.set(run.id, activeRunner);
6216
+ void (async () => {
6217
+ try {
6218
+ let lastProgressActivity = '';
6219
+ let lastInterimMessage = '';
6220
+ const started = await orchestrator.startImportedPlanRun({
6221
+ runId: run.id,
6222
+ orchestratorBot: explicitOrchestratorBot,
6223
+ opts: {
6224
+ ...opts,
6225
+ projectId: run.project_id || undefined,
6226
+ projectDir: project?.folder || opts.projectDir,
6227
+ abortSignal: activeRunner.abortController.signal,
6228
+ importedPlanRunId: run.id,
6229
+ cliSessionScopeKey: activeRunner.sessionScopeKey,
6230
+ onProgress: (status) => {
6231
+ const progressUpdate = classifyOrchestratorProgress(status);
6232
+ if (progressUpdate.activityText && progressUpdate.activityText !== lastProgressActivity) {
6233
+ lastProgressActivity = progressUpdate.activityText;
6234
+ recordActivity('status', { phase: 'running', detail: progressUpdate.activityText }, progressUpdate.activityText);
6235
+ }
6236
+ const interimMessage = deriveVisibleOrchestratorMessage(status);
6237
+ if (interimMessage && interimMessage !== lastInterimMessage) {
6238
+ lastInterimMessage = interimMessage;
6239
+ recordActivity('orchestrator_interim', { text: interimMessage }, interimMessage);
6240
+ }
6241
+ },
6242
+ onWorkerChunk: (event) => {
6243
+ if (event.type === 'step_start') {
6244
+ recordWorkerActivity('worker_step_start', event, {
6245
+ stepId: event.stepId,
6246
+ botId: resolveWorkerBotId(event.agentName),
6247
+ agentName: event.agentName,
6248
+ description: event.description,
6249
+ stepIndex: event.stepIndex,
6250
+ totalSteps: event.totalSteps,
6251
+ });
6252
+ }
6253
+ else if (event.type === 'worker_chunk') {
6254
+ recordWorkerActivity('worker_chunk', event, {
6255
+ stepId: event.stepId,
6256
+ botId: resolveWorkerBotId(event.agentName),
6257
+ agentName: event.agentName,
6258
+ description: event.description,
6259
+ stepIndex: event.stepIndex,
6260
+ totalSteps: event.totalSteps,
6261
+ text: event.text,
6262
+ });
6263
+ }
6264
+ else if (event.type === 'worker_tool_call') {
6265
+ recordWorkerActivity('worker_tool_call', event, {
6266
+ stepId: event.stepId,
6267
+ botId: resolveWorkerBotId(event.agentName),
6268
+ agentName: event.agentName,
6269
+ description: event.description,
6270
+ stepIndex: event.stepIndex,
6271
+ totalSteps: event.totalSteps,
6272
+ toolCallId: event.toolCallId,
6273
+ toolName: event.toolName,
6274
+ toolArguments: event.toolArguments,
6275
+ });
6276
+ }
6277
+ else if (event.type === 'worker_tool_result') {
6278
+ recordWorkerActivity('worker_tool_result', event, {
6279
+ stepId: event.stepId,
6280
+ botId: resolveWorkerBotId(event.agentName),
6281
+ agentName: event.agentName,
6282
+ description: event.description,
6283
+ stepIndex: event.stepIndex,
6284
+ totalSteps: event.totalSteps,
6285
+ toolCallId: event.toolCallId,
6286
+ toolName: event.toolName,
6287
+ toolOutput: event.toolOutput,
6288
+ toolIsError: event.toolIsError,
6289
+ });
6290
+ }
6291
+ else if (event.type === 'step_done') {
6292
+ recordWorkerActivity('worker_step_done', event, {
6293
+ stepId: event.stepId,
6294
+ botId: resolveWorkerBotId(event.agentName),
6295
+ agentName: event.agentName,
6296
+ description: event.description,
6297
+ stepIndex: event.stepIndex,
6298
+ totalSteps: event.totalSteps,
6299
+ status: event.status,
6300
+ summary: event.summary,
6301
+ });
6302
+ }
6303
+ },
6304
+ mqttPublish: async () => { },
6305
+ commandId: `plan-run-${run.id}-${Date.now()}`,
6306
+ },
6307
+ });
6308
+ const summary = (started.replyText || '').trim();
6309
+ if (summary && !activeRunner.stopRequested) {
6310
+ data.addMessage(run.conversation_id, 'assistant', summary, orchestratorRuntime.model || 'Orchestrator', undefined, undefined, 'Orchestrator');
6311
+ }
6312
+ }
6313
+ catch (startErr) {
6314
+ if (activeRunner.stopRequested || startErr?.name === 'AbortError') {
6315
+ const pausedRun = data.getImportedPlanRun(run.id) || run;
6316
+ data.updateImportedPlanRun(run.id, {
6317
+ status: 'paused',
6318
+ completedAt: null,
6319
+ lastError: pausedRun.last_error || importedPlanPauseSummary(activeRunner.stopAction || 'pause'),
6320
+ });
6321
+ return;
6322
+ }
6323
+ const errorDetail = startErr?.message || 'Imported plan execution failed.';
6324
+ data.updateImportedPlanRun(run.id, { status: 'paused', lastError: errorDetail });
6325
+ const failureMessage = buildPlanFailureChatMessage({
6326
+ sourceFileName: run.source_file_name,
6327
+ error: errorDetail,
6328
+ status: 'paused (was running)',
6329
+ });
6330
+ data.addMessage(run.conversation_id, 'assistant', failureMessage, orchestratorRuntime.model || 'Orchestrator', undefined, undefined, 'Orchestrator');
6331
+ }
6332
+ finally {
6333
+ activeImportedPlanRunners.delete(run.id);
6334
+ teardownImportedPlanRunnerSessions(data.getImportedPlanRun(run.id) || run, activeRunner.sessionScopeKey);
6335
+ }
6336
+ })();
6337
+ res.json({
6338
+ ok: true,
6339
+ started: true,
6340
+ messageId: assistant.id,
6341
+ run: serializeImportedPlanRun(data.getImportedPlanRun(run.id)),
6342
+ summary: startedSummary,
6343
+ });
6344
+ }
6345
+ catch (err) {
6346
+ res.status(500).json({ error: err.message });
6347
+ }
6348
+ });
6349
+ app.post('/api/orchestration/import-plan/delete-selected', (req, res) => {
6350
+ try {
6351
+ const runId = String(req.body?.runId || '').trim();
6352
+ const taskIds = Array.isArray(req.body?.taskIds) ? req.body.taskIds : [];
6353
+ if (!runId)
6354
+ return res.status(400).json({ error: 'runId is required' });
6355
+ const updatedRun = data.deleteImportedPlanRunSelectedTasks(runId, taskIds, { actorType: 'user', actorId: 'plan-import-review' });
6356
+ if (!updatedRun)
6357
+ return res.status(404).json({ error: 'Imported plan run not found.' });
6358
+ res.json({ ok: true, run: serializeImportedPlanRun(updatedRun) });
6359
+ }
6360
+ catch (err) {
6361
+ res.status(500).json({ error: err.message });
6362
+ }
6363
+ });
3766
6364
  app.get('/api/admin/audit', (req, res) => {
3767
6365
  try {
3768
6366
  const rows = data.listAdminAudit({
@@ -3804,14 +6402,19 @@ function startLocalServer(opts) {
3804
6402
  // ─── Clerk Config ──────────────────────────────────────────
3805
6403
  app.get('/api/clerk/config', (_req, res) => {
3806
6404
  try {
3807
- const clerkProvider = data.getSetting('clerk_provider');
3808
- const clerkModel = data.getSetting('clerk_model');
3809
- const hasKey = !!data.getSetting('clerk_api_key');
6405
+ const clerkConfig = data.getResolvedClerkConfigInfo();
6406
+ const currentOrchestrator = data.getCurrentOrchestratorSelection();
6407
+ const currentDefaultBot = data.getDefaultAgentProfile();
3810
6408
  res.json({
3811
- provider: clerkProvider || null,
3812
- model: clerkModel || null,
3813
- hasApiKey: hasKey,
3814
- configured: !!(clerkProvider && clerkModel && hasKey),
6409
+ provider: clerkConfig.provider,
6410
+ model: clerkConfig.model,
6411
+ hasApiKey: clerkConfig.hasSecret,
6412
+ configured: clerkConfig.configured,
6413
+ isOrchestrator: data.isClerkOrchestratorEnabled(),
6414
+ currentOrchestrator,
6415
+ currentDefaultBot: currentDefaultBot
6416
+ ? { botId: currentDefaultBot.id, botName: currentDefaultBot.name }
6417
+ : null,
3815
6418
  });
3816
6419
  }
3817
6420
  catch (err) {
@@ -3820,13 +6423,16 @@ function startLocalServer(opts) {
3820
6423
  });
3821
6424
  app.put('/api/clerk/config', (req, res) => {
3822
6425
  try {
3823
- const { provider, model, apiKey } = req.body;
6426
+ const { provider, model, apiKey, isOrchestrator } = req.body;
3824
6427
  if (!provider || !model)
3825
6428
  return res.status(400).json({ error: 'provider and model are required' });
3826
6429
  data.setSetting('clerk_provider', provider);
3827
6430
  data.setSetting('clerk_model', model);
3828
6431
  if (apiKey)
3829
6432
  data.setSetting('clerk_api_key', apiKey);
6433
+ if (typeof isOrchestrator === 'boolean') {
6434
+ data.setClerkAsOrchestrator(isOrchestrator);
6435
+ }
3830
6436
  // Reset clerk instance so it picks up new config
3831
6437
  const { resetClerk } = require('./clerk-model');
3832
6438
  resetClerk();
@@ -3842,11 +6448,31 @@ function startLocalServer(opts) {
3842
6448
  const { prompt } = req.body;
3843
6449
  if (!prompt)
3844
6450
  return res.status(400).json({ error: 'prompt is required' });
3845
- const clerk = (0, clerk_model_1.getClerk)({ runtimeMode: 'local_desktop' });
3846
- if (!clerk)
3847
- return res.json({ routing: 'default', reason: 'No clerk configured' });
3848
6451
  const agents = data.listAgentProfiles();
3849
- const route = await clerk.routeTask(prompt, agents);
6452
+ const normalized = String(prompt || '').toLowerCase();
6453
+ const defaultAgent = data.getDefaultAgentProfile() || agents[0];
6454
+ const namedAgent = agents.find((agent) => normalized.includes(agent.name.toLowerCase()));
6455
+ const roleMatchedAgent = /\b(qa|review|verify|test)\b/i.test(normalized)
6456
+ ? agents.find((agent) => /\bqa|review\b/i.test(String(agent.role_class || agent.role_label || '')))
6457
+ : /\b(research|brainstorm|analy[sz]e|evaluate|investigate)\b/i.test(normalized)
6458
+ ? agents.find((agent) => /\bresearch|analyst\b/i.test(String(agent.role_class || agent.role_label || '')))
6459
+ : /\b(build|code|implement|create|fix|write|update)\b/i.test(normalized)
6460
+ ? agents.find((agent) => /\bcode|coding|developer|builder\b/i.test(String(agent.role_class || agent.role_label || '')))
6461
+ : undefined;
6462
+ const routeAgent = namedAgent || roleMatchedAgent || defaultAgent;
6463
+ const route = routeAgent
6464
+ ? {
6465
+ agentId: routeAgent.id,
6466
+ agentName: routeAgent.name,
6467
+ provider: routeAgent.provider,
6468
+ model: routeAgent.model,
6469
+ reasoning: namedAgent
6470
+ ? 'Matched the explicitly named bot.'
6471
+ : roleMatchedAgent
6472
+ ? 'Matched the prompt to the configured bot role.'
6473
+ : 'Fell back to the configured default bot.',
6474
+ }
6475
+ : { routing: 'default', reason: 'No bot configured' };
3850
6476
  res.json(route);
3851
6477
  }
3852
6478
  catch (err) {
@@ -4679,6 +7305,8 @@ function startLocalServer(opts) {
4679
7305
  });
4680
7306
  // Initialize workflow engine
4681
7307
  (0, workflow_engine_1.getWorkflowEngine)(opts.projectDir, 'local_desktop');
7308
+ // Start idle session sweep (checks every 60s, reaps after 30min idle)
7309
+ (0, managed_process_registry_1.startIdleSweep)(handleIdleSweepClose);
4682
7310
  // Start server
4683
7311
  return new Promise((resolve, reject) => {
4684
7312
  _server = app.listen(port, '127.0.0.1', () => {
@@ -4686,6 +7314,12 @@ function startLocalServer(opts) {
4686
7314
  console.log(chalk_1.default.green(`\n Local server: http://127.0.0.1:${port}`));
4687
7315
  resolve(_server);
4688
7316
  });
7317
+ _server.on('close', () => {
7318
+ (0, managed_process_registry_1.stopIdleSweep)();
7319
+ for (const botId of [...cliBotConfigWatchers.keys()]) {
7320
+ closeCliBotConfigWatcher(botId);
7321
+ }
7322
+ });
4689
7323
  _server.on('error', (err) => {
4690
7324
  if (err.code === 'EADDRINUSE') {
4691
7325
  console.error(chalk_1.default.red(`Port ${port} already in use`));
@@ -4695,6 +7329,7 @@ function startLocalServer(opts) {
4695
7329
  });
4696
7330
  }
4697
7331
  function stopLocalServer() {
7332
+ (0, managed_process_registry_1.stopIdleSweep)();
4698
7333
  return new Promise((resolve) => {
4699
7334
  if (_server) {
4700
7335
  _server.close(() => {
@@ -4752,6 +7387,13 @@ function runtimeModeLabel(mode, runtimeSource) {
4752
7387
  return (0, subscription_runtime_1.claudeSubscriptionRuntimeLabel)(runtimeSource);
4753
7388
  return 'API Key';
4754
7389
  }
7390
+ function resolveDirectCliSessionTransport(providerName, enableCliSessionEpoch, localStorageMode) {
7391
+ if (!enableCliSessionEpoch)
7392
+ return 'none';
7393
+ if (providerName === 'codex-cli' && localStorageMode)
7394
+ return 'codex-app-server';
7395
+ return 'pty';
7396
+ }
4755
7397
  function runtimePayloadForDisplay(providerName, model, runtimeMode, runtimeSource) {
4756
7398
  return {
4757
7399
  mode: runtimeMode,
@@ -4783,6 +7425,9 @@ function configuredRuntimeLabelForProfile(profile) {
4783
7425
  async function buildChatRuntimeForTest(profile) {
4784
7426
  return buildChatRuntime(profile);
4785
7427
  }
7428
+ function resolveDirectCliSessionTransportForTest(providerName, enableCliSessionEpoch, localStorageMode) {
7429
+ return resolveDirectCliSessionTransport(providerName, enableCliSessionEpoch, localStorageMode);
7430
+ }
4786
7431
  function buildConfiguredMessageModel(profile) {
4787
7432
  if (!profile)
4788
7433
  return '';
@@ -4808,100 +7453,206 @@ function hydrateMessageDisplayMetadata(message) {
4808
7453
  model: [modelBase, runtimeLabel].filter(Boolean).join(' | '),
4809
7454
  };
4810
7455
  }
7456
+ function isSameTurn(a, b) {
7457
+ return String(a.userMessage.id || '') === String(b.userMessage.id || '');
7458
+ }
7459
+ function shouldSkipFreshCliBootstrap(input) {
7460
+ return input.providerName === 'codex-cli'
7461
+ && !input.resumeSessionId
7462
+ && input.promptContextMode === 'new_topic';
7463
+ }
7464
+ function resolveLocalDesktopPromptContext(input) {
7465
+ const project = input.conversation?.project_id ? data.getProject(input.conversation.project_id) : undefined;
7466
+ const configuredWorkspacePath = String(project?.folder || '').trim();
7467
+ const llmSpawnCwd = configuredWorkspacePath && fs.existsSync(configuredWorkspacePath)
7468
+ ? configuredWorkspacePath
7469
+ : input.fallbackCwd;
7470
+ const normalizedExplicitTopicId = String(input.explicitTopicId || '').trim();
7471
+ const topicId = normalizedExplicitTopicId || data.getPrimaryTopicIdForConversation(input.conversationId) || undefined;
7472
+ const topicTitle = topicId ? data.getTopic(topicId)?.title : undefined;
7473
+ return {
7474
+ llmSpawnCwd,
7475
+ workspacePath: llmSpawnCwd,
7476
+ projectName: project?.name || input.conversation?.project_name || undefined,
7477
+ topicId,
7478
+ topicTitle: topicTitle || undefined,
7479
+ cliHistoryFilePath: input.writeCliHistoryBootstrap
7480
+ ? (0, cli_bootstrap_history_1.writeConversationBootstrapHistoryFile)({
7481
+ conversationId: input.conversationId,
7482
+ topicId,
7483
+ })
7484
+ : null,
7485
+ };
7486
+ }
7487
+ function buildLocalDesktopDirectPrompt(input) {
7488
+ const isCliProvider = index_1.CLI_PROVIDERS.has(input.currentProvider);
7489
+ const useCompletionSentinel = !!input.useCompletionSentinel;
7490
+ const forceInlinePromptContext = !!input.forceInlinePromptContext;
7491
+ const useCliHistoryBootstrap = isCliProvider && !input.isCliRecurring && !forceInlinePromptContext;
7492
+ const crossBotBlock = input.crossBotTurn ? (0, context_window_1.formatCrossBotPreviousTurn)(input.crossBotTurn) : null;
7493
+ const contract = !isCliProvider || !input.isCliRecurring
7494
+ ? 'api_or_fresh_cli'
7495
+ : 'cli_recurring_single';
7496
+ const lines = [
7497
+ '[Bot Identity]',
7498
+ input.soulMd.trim(),
7499
+ '',
7500
+ 'If the provided context is not enough, use available tools to get what you need.',
7501
+ '',
7502
+ '[Response Style]',
7503
+ 'Write in short readable paragraphs.',
7504
+ 'Put a blank line between distinct ideas.',
7505
+ 'Use bullets when listing findings, steps, or issues.',
7506
+ 'Do not return one dense wall of text.',
7507
+ 'Keep progress updates compact and factual.',
7508
+ 'Do not mention bootstrap files, history files, file paths, or that you loaded context unless the user explicitly asks about them.',
7509
+ ];
7510
+ if (useCompletionSentinel) {
7511
+ lines.push(completion_marker_1.CLI_COMPLETION_INSTRUCTION);
7512
+ }
7513
+ const projectLines = [];
7514
+ if (input.projectName)
7515
+ projectLines.push(`Project: ${input.projectName}`);
7516
+ if (input.topicTitle)
7517
+ projectLines.push(`Topic: ${input.topicTitle}`);
7518
+ projectLines.push(`Workspace: ${input.workspacePath || '(project folder not configured)'}`);
7519
+ if (input.timezone)
7520
+ projectLines.push(`Timezone: ${input.timezone}`);
7521
+ lines.push('', '[Project Overview]', ...projectLines);
7522
+ const toolManifest = isCliProvider ? '' : buildLocalDesktopToolManifest(input.availableTools, contract);
7523
+ if (toolManifest) {
7524
+ lines.push('', '[Available Tools]', toolManifest);
7525
+ if (/\bsearch_local_memory\b|\bsearch_memory\b/i.test(toolManifest)) {
7526
+ lines.push('If the user refers to prior project or conversation context that is not included here, search with these tools first.');
7527
+ }
7528
+ }
7529
+ // Worker TODO mechanics paragraph (Decision 7 of orchestration-plan.txt).
7530
+ // Advertises ONLY the TODO tools actually available to this bot. Respects
7531
+ // Codex Guardrail #6: don't advertise tools the bot can't call.
7532
+ const toolNamesAvailable = new Set((input.availableTools || [])
7533
+ .map((tool) => String(tool?.name || '').trim().toLowerCase())
7534
+ .filter(Boolean));
7535
+ const todoToolMentions = [];
7536
+ if (toolNamesAvailable.has('list_tasks')) {
7537
+ todoToolMentions.push('Use `list_tasks` to query the TODO list (filter by `owner` for specific bots).');
7538
+ }
7539
+ if (toolNamesAvailable.has('check_task')) {
7540
+ todoToolMentions.push('Use `check_task` to mark an item complete.');
7541
+ }
7542
+ if (toolNamesAvailable.has('add_task')) {
7543
+ todoToolMentions.push('Use `add_task` to add new items.');
7544
+ }
7545
+ if (toolNamesAvailable.has('edit_task')) {
7546
+ todoToolMentions.push('Use `edit_task` to modify.');
7547
+ }
7548
+ if (toolNamesAvailable.has('delete_task')) {
7549
+ todoToolMentions.push('Use `delete_task` to remove.');
7550
+ }
7551
+ if (todoToolMentions.length > 0) {
7552
+ lines.push('', '[TODO List Tools]', ...todoToolMentions);
7553
+ }
7554
+ if (crossBotBlock && !input.isCliRecurring) {
7555
+ lines.push('', '[Immediate Cross-Bot Handoff]', crossBotBlock);
7556
+ }
7557
+ if ((contract === 'api_or_fresh_cli' && !useCliHistoryBootstrap) || forceInlinePromptContext) {
7558
+ const summaryWindow = (0, context_window_1.getPromptContextWindow)(input.conversationId, safeguards_1.SAFEGUARDS.CONTEXT_WINDOW_TURNS);
7559
+ if (summaryWindow.summary?.summary_text?.trim()) {
7560
+ lines.push('', summaryWindow.carriedForward
7561
+ ? '[Context Summary (Carried Forward from Previous Conversation in This Topic)]'
7562
+ : '[Context Summary]', summaryWindow.summary.summary_text.trim());
7563
+ }
7564
+ const recentTurnsWindow = (0, context_window_1.getPromptContextWindow)(input.conversationId, summaryWindow.summary?.summary_text?.trim()
7565
+ ? safeguards_1.SAFEGUARDS.CONTEXT_WINDOW_TURNS
7566
+ : safeguards_1.SAFEGUARDS.NO_SUMMARY_CONTEXT_WINDOW_TURNS);
7567
+ const recentTurns = crossBotBlock
7568
+ ? recentTurnsWindow.turns.filter((turn) => !input.crossBotTurn || !isSameTurn(turn, input.crossBotTurn))
7569
+ : recentTurnsWindow.turns;
7570
+ if (recentTurns.length > 0) {
7571
+ lines.push('', recentTurnsWindow.carriedForward
7572
+ ? '[Recent Messages (Carried Forward from Previous Conversation in This Topic)]'
7573
+ : '[Recent Messages]', (0, context_window_1.formatTurnsForPrompt)(recentTurns));
7574
+ }
7575
+ }
7576
+ let effectiveUserPrompt = input.userPrompt;
7577
+ if (useCliHistoryBootstrap && input.cliHistoryFilePath) {
7578
+ effectiveUserPrompt = [
7579
+ `Please read the history file at: ${input.cliHistoryFilePath}`,
7580
+ 'Use it for context only.',
7581
+ 'There is no need to mention the file, its path, or that you loaded context unless the user explicitly asks about it.',
7582
+ 'Then respond to the user request below.',
7583
+ '',
7584
+ 'Current user request:',
7585
+ input.userPrompt,
7586
+ ].join('\n');
7587
+ }
7588
+ if (useCompletionSentinel) {
7589
+ effectiveUserPrompt = [
7590
+ effectiveUserPrompt,
7591
+ '',
7592
+ `Required final line when the response is fully complete: ${completion_marker_1.CLI_COMPLETION_SENTINEL}`,
7593
+ 'Do not mention or explain the tag.',
7594
+ ].join('\n');
7595
+ }
7596
+ return {
7597
+ systemPrompt: lines.join('\n').trim(),
7598
+ userPrompt: effectiveUserPrompt,
7599
+ };
7600
+ }
7601
+ function buildLocalDesktopDirectPromptForTest(input) {
7602
+ return buildLocalDesktopDirectPrompt(input);
7603
+ }
7604
+ function resolveLocalDesktopPromptContextForTest(input) {
7605
+ return resolveLocalDesktopPromptContext(input);
7606
+ }
7607
+ function shouldSkipFreshCliBootstrapForTest(input) {
7608
+ return shouldSkipFreshCliBootstrap(input);
7609
+ }
7610
+ function buildLocalRuntimeUserFacingErrorForTest(err) {
7611
+ return buildLocalRuntimeUserFacingError(err);
7612
+ }
7613
+ function persistCliOauthSessionForTest(input) {
7614
+ return persistCliOauthSession(input.providerId, input.accessToken, input.refreshToken, input.expiresAt);
7615
+ }
7616
+ function buildLocalDesktopToolManifest(availableTools, contract) {
7617
+ const selectedTools = contract === 'api_or_fresh_cli'
7618
+ ? availableTools
7619
+ : availableTools.filter((tool) => ['search_local_memory', 'search_memory'].includes(tool.name));
7620
+ return selectedTools
7621
+ .map((tool) => `- ${tool.name}: ${tool.description}`)
7622
+ .join('\n')
7623
+ .trim();
7624
+ }
4811
7625
  function classifyOrchestratorProgress(status) {
4812
7626
  const trimmed = String(status || '').trim();
4813
7627
  if (!trimmed)
4814
7628
  return {};
4815
7629
  const match = trimmed.match(/^([a-z][a-z0-9_ -]{1,48})::\s*(.+)$/i);
4816
- if (!match) {
4817
- return {
4818
- activityText: trimmed.replace(/\*\*/g, '').replace(/\s+/g, ' ').trim(),
4819
- chatText: trimmed,
4820
- };
4821
- }
7630
+ if (!match)
7631
+ return {};
4822
7632
  const roleName = match[1].trim().toLowerCase();
4823
7633
  const detail = match[2].trim();
4824
- if (roleName === 'intent_classifier') {
4825
- if (/decomposing request|classifying request/i.test(detail)) {
4826
- return { activityText: 'Classifying request', chatText: 'Classifying request..' };
4827
- }
4828
- if (/routed single intent as (.+)$/i.test(detail)) {
4829
- const classifiedAs = detail.replace(/^routed single intent as\s+/i, '').trim();
4830
- return { activityText: `Classified as ${classifiedAs}`, chatText: `Classified as ${classifiedAs}..` };
4831
- }
4832
- const orderedCount = detail.match(/decomposed request into (\d+) ordered intents/i);
4833
- if (orderedCount) {
4834
- return { activityText: `${orderedCount[1]} ordered steps established` };
4835
- }
7634
+ if (/(intent_classifier|orchestration_planner|dispatch_controller|policy_interpreter|verifier)/i.test(roleName)) {
4836
7635
  return {};
4837
7636
  }
4838
- if (roleName === 'orchestration_planner') {
4839
- if (/planning multi-step workflow/i.test(detail)) {
4840
- return { activityText: 'Planning multi-step workflow', chatText: 'Planning multi-step workflow' };
7637
+ if (roleName === 'orchestrator') {
7638
+ if (/^understanding request$/i.test(detail)
7639
+ || /^still understanding the request$/i.test(detail)
7640
+ || /is working on the request/i.test(detail)
7641
+ || /completed the request/i.test(detail)) {
7642
+ return {};
4841
7643
  }
4842
- const prepared = detail.match(/prepared (\d+) workflow steps/i);
4843
- if (prepared) {
4844
- return {
4845
- activityText: `${prepared[1]} workflow steps established`,
4846
- chatText: `${prepared[1]} workflow steps established`,
4847
- };
7644
+ if (/hit an issue while working/i.test(detail)) {
7645
+ return { activityText: detail };
4848
7646
  }
4849
- return {};
4850
- }
4851
- if (roleName === 'dispatch_controller') {
4852
- if (/still working:/i.test(detail))
4853
- return {};
4854
- if (/locked .* execution/i.test(detail))
4855
- return {};
4856
- return { activityText: detail };
4857
- }
4858
- if (roleName === 'orchestrator') {
4859
- return {
4860
- activityText: detail,
4861
- };
4862
- }
4863
- if (roleName === 'policy_interpreter' || roleName === 'verifier') {
4864
- return { activityText: detail };
4865
7647
  }
4866
7648
  return {};
4867
7649
  }
4868
7650
  /** Derive user-visible interim messages from orchestrator progress for key transitions */
4869
7651
  function deriveOrchestratorInterimMessage(status) {
4870
- const trimmed = String(status || '').trim();
4871
- const match = trimmed.match(/^([a-z][a-z0-9_ -]{1,48})::\s*(.+)$/i);
4872
- if (!match)
4873
- return null;
4874
- const roleName = match[1].trim().toLowerCase();
4875
- const detail = match[2].trim();
7652
+ void status;
4876
7653
  // Intent classification → "I'll analyze this request..."
4877
- if (roleName === 'intent_classifier') {
4878
- if (/classif/i.test(detail))
4879
- return null; // skip classification (too early)
4880
- if (/decomposed request into (\d+)/i.test(detail)) {
4881
- const m = detail.match(/(\d+)/);
4882
- return `I've broken this down into ${m?.[1] || 'multiple'} steps. Let me work through them...`;
4883
- }
4884
- if (/routed single intent/i.test(detail))
4885
- return null; // handled by dispatch
4886
- }
4887
7654
  // Dispatch → "I'm sending this to [Bot Name]..."
4888
- if (roleName === 'dispatch_controller') {
4889
- const routingMatch = detail.match(/routing (?:direct )?request to (.+)/i);
4890
- if (routingMatch) {
4891
- return `Sending this to ${routingMatch[1]}...`;
4892
- }
4893
- const connectMatch = detail.match(/connecting request to (.+)/i);
4894
- if (connectMatch) {
4895
- return `Connecting to ${connectMatch[1]}...`;
4896
- }
4897
- }
4898
7655
  // Orchestration planner → workflow planning
4899
- if (roleName === 'orchestration_planner') {
4900
- const stepsMatch = detail.match(/prepared (\d+) workflow steps/i);
4901
- if (stepsMatch) {
4902
- return `I've prepared a ${stepsMatch[1]}-step workflow. Starting execution...`;
4903
- }
4904
- }
4905
7656
  return null;
4906
7657
  }
4907
7658
  function resolveCliNameForProvider(providerName) {
@@ -4912,8 +7663,45 @@ function resolveCliNameForProvider(providerName) {
4912
7663
  return 'codex';
4913
7664
  return null;
4914
7665
  }
7666
+ function deriveVisibleOrchestratorMessage(status) {
7667
+ const trimmed = String(status || '').trim();
7668
+ const match = trimmed.match(/^Orchestrator::\s*(.+)$/i);
7669
+ const detail = match?.[1]?.trim() || '';
7670
+ if (/^Detected requested (workflow|bot sequence):/i.test(detail)) {
7671
+ return detail;
7672
+ }
7673
+ // Robotic system-generated orchestrator placeholders stay hidden. The
7674
+ // detected workflow candidate is the one exception: it is user-facing
7675
+ // confirmation that no workers start until the orchestrator approves.
7676
+ return null;
7677
+ }
4915
7678
  function isInteractiveAuthFailure(text) {
4916
- return /\b(not logged in|please run \/login|unauthorized|invalid api key|authentication required)\b/i.test(text);
7679
+ return /\b(not logged in|please run \/login|login required|unauthorized|invalid authorization|invalid api key|missing api key|authentication required|invalid authentication credentials|invalid bearer token|token expired|session expired|credentials expired|expired credentials|reauthenticate)\b/i.test(text);
7680
+ }
7681
+ function normalizeCliOauthExpiry(expiresAt) {
7682
+ return typeof expiresAt === 'number' && Number.isFinite(expiresAt) && expiresAt > 0
7683
+ ? expiresAt
7684
+ : Date.now() + 60 * 60 * 1000;
7685
+ }
7686
+ function persistCliOauthSession(providerId, accessToken, refreshToken, expiresAt) {
7687
+ const normalizedExpiry = normalizeCliOauthExpiry(expiresAt);
7688
+ const written = providerId === 'claude-cli'
7689
+ ? (0, token_refresh_1.writeClaudeCredentials)({
7690
+ provider: 'anthropic',
7691
+ accessToken,
7692
+ refreshToken,
7693
+ expiresAt: normalizedExpiry,
7694
+ })
7695
+ : (0, token_refresh_1.writeCodexCredentials)({
7696
+ provider: 'openai',
7697
+ accessToken,
7698
+ refreshToken,
7699
+ expiresAt: normalizedExpiry,
7700
+ });
7701
+ if (written) {
7702
+ (0, credential_reader_1.clearCache)();
7703
+ }
7704
+ return written;
4917
7705
  }
4918
7706
  function detectInteractiveAuthFailure(text, activeProviderName, configuredProviderName) {
4919
7707
  if (!isInteractiveAuthFailure(text))
@@ -4931,14 +7719,60 @@ function detectInteractiveAuthFailure(text, activeProviderName, configuredProvid
4931
7719
  };
4932
7720
  }
4933
7721
  const LOCAL_RUNTIME_RETRY_LIMIT = 2;
7722
+ function resolveProviderDisplayLabel(providerName) {
7723
+ const cli = resolveCliNameForProvider(String(providerName || ''));
7724
+ if (cli === 'claude')
7725
+ return 'Claude';
7726
+ if (cli === 'codex')
7727
+ return 'Codex';
7728
+ const normalized = String(providerName || '').trim().toLowerCase();
7729
+ if (normalized === 'anthropic')
7730
+ return 'Anthropic';
7731
+ if (normalized === 'openai')
7732
+ return 'OpenAI';
7733
+ if (normalized === 'google' || normalized === 'gemini')
7734
+ return 'Gemini';
7735
+ return 'the selected LLM';
7736
+ }
7737
+ function isClaudeFreshSessionStartupFailure(err) {
7738
+ return err?.code === 'CLAUDE_FRESH_SESSION_STARTUP_TIMEOUT'
7739
+ || err?.name === 'ClaudeFreshSessionStartupTimeoutError'
7740
+ || /fresh session startup timed out/i.test(String(err?.message || err || ''));
7741
+ }
7742
+ function clearFailedLocalCliSessionEpoch(conversationId, botId, sessionId, sessionScopeKey) {
7743
+ const normalizedSessionId = String(sessionId || '').trim();
7744
+ if (!normalizedSessionId)
7745
+ return;
7746
+ try {
7747
+ const cleared = data.clearCliSessionEpochIfMatches(conversationId, botId, normalizedSessionId, sessionScopeKey);
7748
+ if (cleared) {
7749
+ console.warn(`[chat] cleared poisoned cli_session_epoch conversationId=${conversationId} botId=${botId} sessionId=${normalizedSessionId}`);
7750
+ }
7751
+ }
7752
+ catch (err) {
7753
+ console.warn(`[chat] failed to clear cli_session_epoch conversationId=${conversationId} botId=${botId} sessionId=${normalizedSessionId}: ${err?.message || err}`);
7754
+ }
7755
+ }
7756
+ function buildLocalRuntimeUserFacingError(err) {
7757
+ const exact = String(err?.message || err || '').trim();
7758
+ if (!exact)
7759
+ return null;
7760
+ const providerLabel = resolveProviderDisplayLabel(err?.providerId || err?.provider || 'claude-cli');
7761
+ if (isClaudeFreshSessionStartupFailure(err)
7762
+ || err?.cli
7763
+ || /\b(pty|app-server|resume|session|transcript)\b/i.test(exact)) {
7764
+ return `${providerLabel} local runtime failed: ${exact}`;
7765
+ }
7766
+ return null;
7767
+ }
4934
7768
  function shouldRetrySelectedLocalRuntime(err) {
4935
7769
  const text = String(err?.message || err || '').toLowerCase();
4936
7770
  if (!text)
4937
7771
  return false;
4938
- if (/\b(no api key|configure one in settings|not available on this machine|not installed|please run \/login|not logged in|invalid api key)\b/i.test(text)) {
7772
+ if (/\b(no api key|missing api key|configure one in settings|not available on this machine|not installed|please run \/login|login required|not logged in|unauthorized|invalid authorization|invalid api key|authentication required|invalid authentication credentials|invalid bearer token|token expired|session expired|credentials expired|expired credentials|reauthenticate)\b/i.test(text)) {
4939
7773
  return false;
4940
7774
  }
4941
- return /\b(429|rate limit|timeout|timed out|temporar|temporarily|econnreset|etimedout|enotfound|econnrefused|socket hang up|network|try again|overloaded|busy)\b/i.test(text);
7775
+ return /\b(429|rate limit|timeout|timed out|temporar|temporarily|econnreset|etimedout|enotfound|econnrefused|socket hang up|network|try again|overloaded|busy|no meaningful pty activity)\b/i.test(text);
4942
7776
  }
4943
7777
  async function pauseLocalRuntimeRetry(attempt) {
4944
7778
  const delayMs = attempt <= 1 ? 750 : 1500;
@@ -5024,6 +7858,12 @@ async function autoTitleConversation(convId, userMsg, assistantMsg, providerName
5024
7858
  });
5025
7859
  if (resp.content) {
5026
7860
  data.updateConversation(convId, { title: resp.content.trim().slice(0, 100) });
7861
+ const revision = data.bumpConversationSyncRevision(convId);
7862
+ (0, chat_sync_1.emitChatSyncEvent)({
7863
+ change: 'conversation.updated',
7864
+ conversationId: convId,
7865
+ revision,
7866
+ });
5027
7867
  }
5028
7868
  }
5029
7869
  catch { /* best-effort */ }