funolio-agent 1.0.75 → 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 (236) hide show
  1. package/dist/auth/credential-reader.d.ts.map +1 -1
  2. package/dist/auth/credential-reader.js +4 -3
  3. package/dist/auth/credential-reader.js.map +1 -1
  4. package/dist/auth/token-refresh.d.ts +8 -0
  5. package/dist/auth/token-refresh.d.ts.map +1 -1
  6. package/dist/auth/token-refresh.js +82 -52
  7. package/dist/auth/token-refresh.js.map +1 -1
  8. package/dist/auto-organizer.d.ts.map +1 -1
  9. package/dist/auto-organizer.js +6 -7
  10. package/dist/auto-organizer.js.map +1 -1
  11. package/dist/bench-prefix.d.ts +16 -0
  12. package/dist/bench-prefix.d.ts.map +1 -0
  13. package/dist/bench-prefix.js +25 -0
  14. package/dist/bench-prefix.js.map +1 -0
  15. package/dist/bot-manager.d.ts.map +1 -1
  16. package/dist/bot-manager.js +23 -14
  17. package/dist/bot-manager.js.map +1 -1
  18. package/dist/chat-sync.d.ts +42 -0
  19. package/dist/chat-sync.d.ts.map +1 -0
  20. package/dist/chat-sync.js +95 -0
  21. package/dist/chat-sync.js.map +1 -0
  22. package/dist/clerk-model.d.ts +7 -0
  23. package/dist/clerk-model.d.ts.map +1 -1
  24. package/dist/clerk-model.js +42 -8
  25. package/dist/clerk-model.js.map +1 -1
  26. package/dist/cli-bootstrap-history.d.ts +10 -0
  27. package/dist/cli-bootstrap-history.d.ts.map +1 -0
  28. package/dist/cli-bootstrap-history.js +112 -0
  29. package/dist/cli-bootstrap-history.js.map +1 -0
  30. package/dist/cli-models.d.ts +8 -0
  31. package/dist/cli-models.d.ts.map +1 -0
  32. package/dist/cli-models.js +91 -0
  33. package/dist/cli-models.js.map +1 -0
  34. package/dist/cli-session-epoch.d.ts +13 -3
  35. package/dist/cli-session-epoch.d.ts.map +1 -1
  36. package/dist/cli-session-epoch.js +53 -4
  37. package/dist/cli-session-epoch.js.map +1 -1
  38. package/dist/codex-app-server-manager.d.ts +64 -4
  39. package/dist/codex-app-server-manager.d.ts.map +1 -1
  40. package/dist/codex-app-server-manager.js +755 -55
  41. package/dist/codex-app-server-manager.js.map +1 -1
  42. package/dist/commands/pool.d.ts +32 -0
  43. package/dist/commands/pool.d.ts.map +1 -1
  44. package/dist/commands/pool.js +145 -66
  45. package/dist/commands/pool.js.map +1 -1
  46. package/dist/commands/start.d.ts +21 -0
  47. package/dist/commands/start.d.ts.map +1 -1
  48. package/dist/commands/start.js +484 -63
  49. package/dist/commands/start.js.map +1 -1
  50. package/dist/commands/status.d.ts.map +1 -1
  51. package/dist/commands/status.js +5 -2
  52. package/dist/commands/status.js.map +1 -1
  53. package/dist/config.d.ts +1 -0
  54. package/dist/config.d.ts.map +1 -1
  55. package/dist/config.js +170 -58
  56. package/dist/config.js.map +1 -1
  57. package/dist/context-window.d.ts +37 -1
  58. package/dist/context-window.d.ts.map +1 -1
  59. package/dist/context-window.js +202 -16
  60. package/dist/context-window.js.map +1 -1
  61. package/dist/live-activity.d.ts +3 -1
  62. package/dist/live-activity.d.ts.map +1 -1
  63. package/dist/live-activity.js.map +1 -1
  64. package/dist/local-chat-execution.d.ts +114 -0
  65. package/dist/local-chat-execution.d.ts.map +1 -0
  66. package/dist/local-chat-execution.js +349 -0
  67. package/dist/local-chat-execution.js.map +1 -0
  68. package/dist/local-cli-pty-manager.d.ts +138 -3
  69. package/dist/local-cli-pty-manager.d.ts.map +1 -1
  70. package/dist/local-cli-pty-manager.js +1415 -111
  71. package/dist/local-cli-pty-manager.js.map +1 -1
  72. package/dist/local-conversation-gateway.d.ts +110 -0
  73. package/dist/local-conversation-gateway.d.ts.map +1 -0
  74. package/dist/local-conversation-gateway.js +175 -0
  75. package/dist/local-conversation-gateway.js.map +1 -0
  76. package/dist/local-data.d.ts +235 -5
  77. package/dist/local-data.d.ts.map +1 -1
  78. package/dist/local-data.js +1066 -87
  79. package/dist/local-data.js.map +1 -1
  80. package/dist/local-db.d.ts +6 -0
  81. package/dist/local-db.d.ts.map +1 -1
  82. package/dist/local-db.js +376 -4
  83. package/dist/local-db.js.map +1 -1
  84. package/dist/local-funnel.d.ts.map +1 -1
  85. package/dist/local-funnel.js +6 -5
  86. package/dist/local-funnel.js.map +1 -1
  87. package/dist/local-server.d.ts +30 -0
  88. package/dist/local-server.d.ts.map +1 -1
  89. package/dist/local-server.js +2898 -319
  90. package/dist/local-server.js.map +1 -1
  91. package/dist/managed-process-registry.d.ts +59 -0
  92. package/dist/managed-process-registry.d.ts.map +1 -0
  93. package/dist/managed-process-registry.js +390 -0
  94. package/dist/managed-process-registry.js.map +1 -0
  95. package/dist/mcp/claude-config-writer.d.ts +5 -5
  96. package/dist/mcp/claude-config-writer.d.ts.map +1 -1
  97. package/dist/mcp/claude-config-writer.js +19 -11
  98. package/dist/mcp/claude-config-writer.js.map +1 -1
  99. package/dist/mcp/index.d.ts +4 -2
  100. package/dist/mcp/index.d.ts.map +1 -1
  101. package/dist/mcp/index.js.map +1 -1
  102. package/dist/mcp/sync-cli-config.d.ts +42 -4
  103. package/dist/mcp/sync-cli-config.d.ts.map +1 -1
  104. package/dist/mcp/sync-cli-config.js +497 -17
  105. package/dist/mcp/sync-cli-config.js.map +1 -1
  106. package/dist/message-loop.d.ts.map +1 -1
  107. package/dist/message-loop.js +43 -1
  108. package/dist/message-loop.js.map +1 -1
  109. package/dist/mqtt-client.d.ts +34 -0
  110. package/dist/mqtt-client.d.ts.map +1 -1
  111. package/dist/mqtt-client.js +270 -45
  112. package/dist/mqtt-client.js.map +1 -1
  113. package/dist/mqtt-data-relay.d.ts +44 -0
  114. package/dist/mqtt-data-relay.d.ts.map +1 -0
  115. package/dist/mqtt-data-relay.js +106 -0
  116. package/dist/mqtt-data-relay.js.map +1 -0
  117. package/dist/orchestration/capabilities.d.ts +13 -0
  118. package/dist/orchestration/capabilities.d.ts.map +1 -0
  119. package/dist/orchestration/capabilities.js +152 -0
  120. package/dist/orchestration/capabilities.js.map +1 -0
  121. package/dist/orchestration/dispatch-executor.d.ts +83 -0
  122. package/dist/orchestration/dispatch-executor.d.ts.map +1 -0
  123. package/dist/orchestration/dispatch-executor.js +266 -0
  124. package/dist/orchestration/dispatch-executor.js.map +1 -0
  125. package/dist/orchestration/dispatch-hint.d.ts +134 -0
  126. package/dist/orchestration/dispatch-hint.d.ts.map +1 -0
  127. package/dist/orchestration/dispatch-hint.js +247 -0
  128. package/dist/orchestration/dispatch-hint.js.map +1 -0
  129. package/dist/orchestration/dispatch-runner.d.ts +106 -0
  130. package/dist/orchestration/dispatch-runner.d.ts.map +1 -0
  131. package/dist/orchestration/dispatch-runner.js +604 -0
  132. package/dist/orchestration/dispatch-runner.js.map +1 -0
  133. package/dist/orchestration/dispatch-tools.d.ts +167 -0
  134. package/dist/orchestration/dispatch-tools.d.ts.map +1 -0
  135. package/dist/orchestration/dispatch-tools.js +328 -0
  136. package/dist/orchestration/dispatch-tools.js.map +1 -0
  137. package/dist/orchestration/front-door-policy.d.ts +35 -10
  138. package/dist/orchestration/front-door-policy.d.ts.map +1 -1
  139. package/dist/orchestration/front-door-policy.js +30 -267
  140. package/dist/orchestration/front-door-policy.js.map +1 -1
  141. package/dist/orchestration/orchestrator-dispatch-prompt.d.ts +43 -0
  142. package/dist/orchestration/orchestrator-dispatch-prompt.d.ts.map +1 -0
  143. package/dist/orchestration/orchestrator-dispatch-prompt.js +267 -0
  144. package/dist/orchestration/orchestrator-dispatch-prompt.js.map +1 -0
  145. package/dist/orchestration/orchestrator-operating-prompt.d.ts +14 -0
  146. package/dist/orchestration/orchestrator-operating-prompt.d.ts.map +1 -1
  147. package/dist/orchestration/orchestrator-operating-prompt.js +157 -31
  148. package/dist/orchestration/orchestrator-operating-prompt.js.map +1 -1
  149. package/dist/orchestration/plan-import.d.ts +39 -0
  150. package/dist/orchestration/plan-import.d.ts.map +1 -0
  151. package/dist/orchestration/plan-import.js +547 -0
  152. package/dist/orchestration/plan-import.js.map +1 -0
  153. package/dist/orchestration/worker-operating-prompt.d.ts +2 -0
  154. package/dist/orchestration/worker-operating-prompt.d.ts.map +1 -1
  155. package/dist/orchestration/worker-operating-prompt.js +36 -46
  156. package/dist/orchestration/worker-operating-prompt.js.map +1 -1
  157. package/dist/orchestrator.d.ts +195 -3
  158. package/dist/orchestrator.d.ts.map +1 -1
  159. package/dist/orchestrator.js +1970 -432
  160. package/dist/orchestrator.js.map +1 -1
  161. package/dist/providers/anthropic.d.ts.map +1 -1
  162. package/dist/providers/anthropic.js +8 -4
  163. package/dist/providers/anthropic.js.map +1 -1
  164. package/dist/providers/claude-cli.d.ts.map +1 -1
  165. package/dist/providers/claude-cli.js +28 -3
  166. package/dist/providers/claude-cli.js.map +1 -1
  167. package/dist/providers/codex-cli.d.ts +10 -6
  168. package/dist/providers/codex-cli.d.ts.map +1 -1
  169. package/dist/providers/codex-cli.js +190 -17
  170. package/dist/providers/codex-cli.js.map +1 -1
  171. package/dist/providers/google.d.ts.map +1 -1
  172. package/dist/providers/google.js +15 -5
  173. package/dist/providers/google.js.map +1 -1
  174. package/dist/providers/index.d.ts +15 -1
  175. package/dist/providers/index.d.ts.map +1 -1
  176. package/dist/providers/index.js.map +1 -1
  177. package/dist/providers/openai.d.ts +1 -1
  178. package/dist/providers/openai.d.ts.map +1 -1
  179. package/dist/providers/openai.js +13 -5
  180. package/dist/providers/openai.js.map +1 -1
  181. package/dist/server-adapter.d.ts +8 -0
  182. package/dist/server-adapter.d.ts.map +1 -1
  183. package/dist/server-adapter.js +7 -0
  184. package/dist/server-adapter.js.map +1 -1
  185. package/dist/service-mode.d.ts +1 -1
  186. package/dist/service-mode.d.ts.map +1 -1
  187. package/dist/service-mode.js +64 -1
  188. package/dist/service-mode.js.map +1 -1
  189. package/dist/service-setup-only.d.ts +8 -0
  190. package/dist/service-setup-only.d.ts.map +1 -0
  191. package/dist/service-setup-only.js +37 -0
  192. package/dist/service-setup-only.js.map +1 -0
  193. package/dist/slash-commands.d.ts +21 -0
  194. package/dist/slash-commands.d.ts.map +1 -0
  195. package/dist/slash-commands.js +99 -0
  196. package/dist/slash-commands.js.map +1 -0
  197. package/dist/subagent/index.d.ts +4 -2
  198. package/dist/subagent/index.d.ts.map +1 -1
  199. package/dist/subagent/index.js.map +1 -1
  200. package/dist/summarization-pipeline.d.ts.map +1 -1
  201. package/dist/summarization-pipeline.js +1 -9
  202. package/dist/summarization-pipeline.js.map +1 -1
  203. package/dist/token-counter.d.ts.map +1 -1
  204. package/dist/token-counter.js +11 -4
  205. package/dist/token-counter.js.map +1 -1
  206. package/dist/tool-filter.d.ts.map +1 -1
  207. package/dist/tool-filter.js +10 -6
  208. package/dist/tool-filter.js.map +1 -1
  209. package/dist/tools/admin-tools.d.ts.map +1 -1
  210. package/dist/tools/admin-tools.js +13 -4
  211. package/dist/tools/admin-tools.js.map +1 -1
  212. package/dist/tools/run-command.d.ts.map +1 -1
  213. package/dist/tools/run-command.js +5 -1
  214. package/dist/tools/run-command.js.map +1 -1
  215. package/dist/tools/search-conversation-history.d.ts.map +1 -1
  216. package/dist/tools/search-conversation-history.js +12 -2
  217. package/dist/tools/search-conversation-history.js.map +1 -1
  218. package/dist/tools/todo-tasks.d.ts.map +1 -1
  219. package/dist/tools/todo-tasks.js +77 -5
  220. package/dist/tools/todo-tasks.js.map +1 -1
  221. package/dist/usage-log.d.ts +62 -0
  222. package/dist/usage-log.d.ts.map +1 -0
  223. package/dist/usage-log.js +98 -0
  224. package/dist/usage-log.js.map +1 -0
  225. package/dist/wizard-state.d.ts +13 -0
  226. package/dist/wizard-state.d.ts.map +1 -1
  227. package/dist/wizard-state.js +61 -3
  228. package/dist/wizard-state.js.map +1 -1
  229. package/dist/wizard-support.d.ts.map +1 -1
  230. package/dist/wizard-support.js +27 -1
  231. package/dist/wizard-support.js.map +1 -1
  232. package/dist/workflow-engine.d.ts +40 -1
  233. package/dist/workflow-engine.d.ts.map +1 -1
  234. package/dist/workflow-engine.js +753 -93
  235. package/dist/workflow-engine.js.map +1 -1
  236. package/package.json +2 -2
@@ -41,6 +41,10 @@ exports.stopLocalServer = stopLocalServer;
41
41
  exports.buildChatRuntimeForTest = buildChatRuntimeForTest;
42
42
  exports.resolveDirectCliSessionTransportForTest = resolveDirectCliSessionTransportForTest;
43
43
  exports.buildLocalDesktopDirectPromptForTest = buildLocalDesktopDirectPromptForTest;
44
+ exports.resolveLocalDesktopPromptContextForTest = resolveLocalDesktopPromptContextForTest;
45
+ exports.shouldSkipFreshCliBootstrapForTest = shouldSkipFreshCliBootstrapForTest;
46
+ exports.buildLocalRuntimeUserFacingErrorForTest = buildLocalRuntimeUserFacingErrorForTest;
47
+ exports.persistCliOauthSessionForTest = persistCliOauthSessionForTest;
44
48
  /**
45
49
  * Local HTTP server for desktop-first operation.
46
50
  *
@@ -53,11 +57,14 @@ const completion_marker_1 = require("./completion-marker");
53
57
  const index_2 = require("./index");
54
58
  const approval_1 = require("./approval");
55
59
  const config_1 = require("./config");
60
+ const bench_prefix_1 = require("./bench-prefix");
61
+ const usage_log_1 = require("./usage-log");
56
62
  const data = __importStar(require("./local-data"));
57
63
  const local_import_worker_1 = require("./local-import-worker");
58
64
  const clerk_model_1 = require("./clerk-model");
59
65
  const workflow_engine_1 = require("./workflow-engine");
60
66
  const context_window_1 = require("./context-window");
67
+ const cli_bootstrap_history_1 = require("./cli-bootstrap-history");
61
68
  const summarization_pipeline_1 = require("./summarization-pipeline");
62
69
  const backfill_1 = require("./backfill");
63
70
  const config_cleanup_1 = require("./config-cleanup");
@@ -73,15 +80,23 @@ const registry_1 = require("./mcp/registry");
73
80
  const marketplace_1 = require("./mcp/marketplace");
74
81
  const claude_config_writer_1 = require("./mcp/claude-config-writer");
75
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");
76
85
  const local_memory_search_1 = require("./local-memory-search");
77
86
  const local_funnel_1 = require("./local-funnel");
78
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");
79
90
  const policy_detection_1 = require("./policy-detection");
80
91
  const server_runtime_1 = require("./server-runtime");
81
92
  const storage_mode_1 = require("./storage-mode");
82
93
  const local_cli_pty_manager_1 = require("./local-cli-pty-manager");
83
94
  const codex_app_server_manager_1 = require("./codex-app-server-manager");
95
+ const managed_process_registry_1 = require("./managed-process-registry");
84
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");
85
100
  const server_adapter_1 = require("./server-adapter");
86
101
  const wizard_support_1 = require("./wizard-support");
87
102
  const chalk_1 = __importDefault(require("chalk"));
@@ -99,6 +114,351 @@ function requireExpress() {
99
114
  throw new Error('express is not installed. Run: npm install express');
100
115
  }
101
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
+ }
102
462
  function startLocalServer(opts) {
103
463
  const express = requireExpress();
104
464
  const app = express();
@@ -106,6 +466,16 @@ function startLocalServer(opts) {
106
466
  if ((0, storage_mode_1.isLocalStorageMode)()) {
107
467
  data.purgeLegacyExtractionDataOnce();
108
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
+ }
109
479
  const cliNormalization = data.normalizeCliProviderConnections();
110
480
  if (cliNormalization.updatedIds.length > 0) {
111
481
  console.info(`[local-server] normalized ${cliNormalization.updatedIds.length} CLI provider connection(s): ${cliNormalization.updatedIds.join(', ')}`);
@@ -138,6 +508,553 @@ function startLocalServer(opts) {
138
508
  }
139
509
  return res.status(500).json({ error: message });
140
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
+ }
141
1058
  // Auto-seed agent profiles from DB-backed provider connections after legacy migration.
142
1059
  try {
143
1060
  const migration = (0, wizard_state_1.migrateLegacyConfigToDb)();
@@ -194,14 +1111,70 @@ function startLocalServer(opts) {
194
1111
  console.error(chalk_1.default.yellow(` Failed to reconcile conversation/topic project consistency: ${err}`));
195
1112
  }
196
1113
  try {
197
- const interrupted = data.markRunningChatJobsInterrupted();
198
- if (interrupted > 0) {
199
- 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
+ }));
200
1132
  }
201
1133
  }
202
1134
  catch (err) {
203
1135
  console.error(chalk_1.default.yellow(` Failed to recover interrupted chat jobs: ${err}`));
204
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
+ }
205
1178
  // ─── Health ──────────────────────────────────────────────────
206
1179
  app.get('/api/health', (_req, res) => {
207
1180
  try {
@@ -212,12 +1185,37 @@ function startLocalServer(opts) {
212
1185
  db: stats,
213
1186
  version: require('../package.json').version,
214
1187
  runtime,
1188
+ systemInfo: {
1189
+ hostname: os.hostname(),
1190
+ platform: os.platform(),
1191
+ arch: os.arch(),
1192
+ },
215
1193
  });
216
1194
  }
217
1195
  catch (err) {
218
1196
  res.status(500).json({ status: 'error', error: err.message });
219
1197
  }
220
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
+ });
221
1219
  app.get('/api/runtime/config', async (_req, res) => {
222
1220
  try {
223
1221
  const runtime = (0, server_runtime_1.getRuntimeConnectionConfig)();
@@ -234,6 +1232,49 @@ function startLocalServer(opts) {
234
1232
  res.status(500).json({ error: err.message });
235
1233
  }
236
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
+ });
237
1278
  app.get('/api/runtime/agents', async (_req, res) => {
238
1279
  try {
239
1280
  const runtime = (0, server_runtime_1.getRuntimeConnectionConfig)();
@@ -555,6 +1596,36 @@ function startLocalServer(opts) {
555
1596
  function localTimestamp() {
556
1597
  return new Date().toISOString().replace('T', ' ').replace('Z', '');
557
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
+ }
558
1629
  const MAX_LOCAL_CHAT_JOBS = 3;
559
1630
  const runningChatJobControllers = new Map();
560
1631
  const finalizeCancelledChatJobMessage = (job) => {
@@ -610,8 +1681,8 @@ function startLocalServer(opts) {
610
1681
  await handlers.onDone?.({});
611
1682
  }
612
1683
  };
613
- const runQueuedChatJobs = async () => {
614
- if (isConnectedMode())
1684
+ const runQueuedChatJobs = async (opts) => {
1685
+ if (isConnectedMode() && !opts?.force)
615
1686
  return;
616
1687
  while (runningChatJobControllers.size < MAX_LOCAL_CHAT_JOBS) {
617
1688
  const runningKeys = new Set(data.listRunningChatJobs(MAX_LOCAL_CHAT_JOBS + 20).map((job) => `${job.conversation_id}::${job.bot_id}`));
@@ -624,11 +1695,15 @@ function startLocalServer(opts) {
624
1695
  return;
625
1696
  const controller = new AbortController();
626
1697
  runningChatJobControllers.set(next.id, controller);
1698
+ const runningAt = localTimestamp();
627
1699
  data.updateChatJob(next.id, {
628
1700
  status: 'running',
629
- startedAt: localTimestamp(),
1701
+ startedAt: runningAt,
630
1702
  error: null,
631
1703
  });
1704
+ emitConversationSyncEvents(next.conversation_id, [
1705
+ { change: 'job.running', jobId: next.id, jobStatus: 'running' },
1706
+ ], { updatedAt: runningAt });
632
1707
  void (async () => {
633
1708
  try {
634
1709
  const job = data.getChatJob(next.id);
@@ -652,6 +1727,7 @@ function startLocalServer(opts) {
652
1727
  message: userMessage?.content || '',
653
1728
  botId: job.bot_id,
654
1729
  skipUserMessage: true,
1730
+ attachments: Array.isArray(requestPayload?.attachments) ? requestPayload.attachments : undefined,
655
1731
  pinnedMessageIds: Array.isArray(requestPayload?.pinnedMessageIds) ? requestPayload.pinnedMessageIds : undefined,
656
1732
  topicId: requestPayload?.topicId || undefined,
657
1733
  projectId: requestPayload?.projectId || undefined,
@@ -669,12 +1745,17 @@ function startLocalServer(opts) {
669
1745
  const latest = data.getChatJob(next.id);
670
1746
  if (!latest || latest.status === 'cancelled')
671
1747
  return;
1748
+ const completedAt = localTimestamp();
672
1749
  data.updateChatJob(next.id, {
673
1750
  status: 'completed',
674
- completedAt: localTimestamp(),
1751
+ completedAt,
675
1752
  error: null,
676
1753
  });
677
- 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 });
678
1759
  },
679
1760
  onError: async (payload) => {
680
1761
  const latest = data.getChatJob(next.id);
@@ -687,12 +1768,17 @@ function startLocalServer(opts) {
687
1768
  content: existingContent ? `${existingContent}\n\n**Error:** ${errorText}` : `**Error:** ${errorText}`,
688
1769
  botId: next.bot_id,
689
1770
  });
1771
+ const failedAt = localTimestamp();
690
1772
  data.updateChatJob(next.id, {
691
1773
  status: 'failed',
692
1774
  error: errorText,
693
- completedAt: localTimestamp(),
1775
+ completedAt: failedAt,
694
1776
  });
695
- 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 });
696
1782
  },
697
1783
  });
698
1784
  }
@@ -702,11 +1788,16 @@ function startLocalServer(opts) {
702
1788
  return;
703
1789
  if (latest.status === 'cancelled' || err?.name === 'AbortError') {
704
1790
  finalizeCancelledChatJobMessage(latest);
1791
+ const cancelledAt = latest.cancelled_at || localTimestamp();
705
1792
  data.updateChatJob(next.id, {
706
1793
  status: 'cancelled',
707
- cancelledAt: latest.cancelled_at || localTimestamp(),
708
- completedAt: latest.completed_at || localTimestamp(),
1794
+ cancelledAt,
1795
+ completedAt: latest.completed_at || cancelledAt,
709
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 });
710
1801
  return;
711
1802
  }
712
1803
  const errorText = err?.message || 'Background chat failed';
@@ -716,12 +1807,17 @@ function startLocalServer(opts) {
716
1807
  content: existingContent ? `${existingContent}\n\n**Error:** ${errorText}` : `**Error:** ${errorText}`,
717
1808
  botId: next.bot_id,
718
1809
  });
1810
+ const failedAt = localTimestamp();
719
1811
  data.updateChatJob(next.id, {
720
1812
  status: 'failed',
721
1813
  error: errorText,
722
- completedAt: localTimestamp(),
1814
+ completedAt: failedAt,
723
1815
  });
724
- 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 });
725
1821
  }
726
1822
  finally {
727
1823
  runningChatJobControllers.delete(next.id);
@@ -751,6 +1847,7 @@ function startLocalServer(opts) {
751
1847
  conversationId: req.body?.conversationId || undefined,
752
1848
  projectId: req.body?.projectId || undefined,
753
1849
  botId: req.body?.botId || undefined,
1850
+ attachments: Array.isArray(req.body?.attachments) ? req.body.attachments : undefined,
754
1851
  targetAgentId,
755
1852
  stream: true,
756
1853
  skipUserMessage: !!req.body?.skipUserMessage,
@@ -1078,6 +2175,10 @@ function startLocalServer(opts) {
1078
2175
  if (!isCliProvider) {
1079
2176
  return res.status(400).json({ error: 'Desktop auth refresh only supports Claude CLI or Codex CLI sessions.' });
1080
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
+ }
1081
2182
  const metadataJson = JSON.stringify({
1082
2183
  source: cli ? `desktop-cli:${cli}` : 'desktop-cli',
1083
2184
  });
@@ -1107,6 +2208,12 @@ function startLocalServer(opts) {
1107
2208
  metadataJson,
1108
2209
  });
1109
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
+ }
1110
2217
  res.json({ ok: true, updated });
1111
2218
  }
1112
2219
  catch (err) {
@@ -1236,10 +2343,22 @@ function startLocalServer(opts) {
1236
2343
  }
1237
2344
  })();
1238
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
+ });
1239
2358
  app.post('/api/bots', (req, res) => {
1240
2359
  (async () => {
1241
2360
  try {
1242
- const { provider, model, name, soulMd, memoryMd, toolsMd, skillsMd, apiKeyEnc, permissionMode, isDefault, roleLabel, roleClass, isActive, priority, isOrchestrator, is_orchestrator, codexReasoningEffort, codex_reasoning_effort, codexReasoningSummary, codex_reasoning_summary, codexPersonality, codex_personality, codexServiceTier, codex_service_tier, codexSandboxPolicy, codex_sandbox_policy, codexApprovalPolicy, codex_approval_policy, } = 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;
1243
2362
  if (!provider || !model || !name) {
1244
2363
  return res.status(400).json({ error: 'provider, model, and name are required' });
1245
2364
  }
@@ -1266,8 +2385,12 @@ function startLocalServer(opts) {
1266
2385
  isDefault,
1267
2386
  roleLabel,
1268
2387
  roleClass,
2388
+ orchestrationRoleLabel: orchestrationRoleLabel ?? orchestration_role_label,
2389
+ orchestrationRoleClass: orchestrationRoleClass ?? orchestration_role_class,
2390
+ orchestrationIncludeUserPrompt: orchestrationIncludeUserPrompt ?? orchestration_include_user_prompt,
1269
2391
  isActive,
1270
2392
  priority,
2393
+ color,
1271
2394
  isOrchestrator: isOrchestrator ?? is_orchestrator,
1272
2395
  codexReasoningEffort: codexReasoningEffort ?? codex_reasoning_effort,
1273
2396
  codexReasoningSummary: codexReasoningSummary ?? codex_reasoning_summary,
@@ -1275,7 +2398,15 @@ function startLocalServer(opts) {
1275
2398
  codexServiceTier: codexServiceTier ?? codex_service_tier,
1276
2399
  codexSandboxPolicy: codexSandboxPolicy ?? codex_sandbox_policy,
1277
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),
1278
2406
  });
2407
+ syncCliBotConfigFiles(profile);
2408
+ reapCliBotSessions(profile.id);
2409
+ watchCliBotConfig(profile);
1279
2410
  res.status(201).json(profile);
1280
2411
  }
1281
2412
  catch (err) {
@@ -1313,10 +2444,20 @@ function startLocalServer(opts) {
1313
2444
  fields.roleLabel = b.roleLabel ?? b.role_label;
1314
2445
  if (b.roleClass !== undefined || b.role_class !== undefined)
1315
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;
1316
2453
  if (b.isActive !== undefined || b.is_active !== undefined)
1317
2454
  fields.isActive = b.isActive ?? b.is_active;
1318
2455
  if (b.priority !== undefined)
1319
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;
1320
2461
  if (b.showThinking !== undefined || b.show_thinking !== undefined)
1321
2462
  fields.showThinking = b.showThinking ?? b.show_thinking;
1322
2463
  if (b.isOrchestrator !== undefined || b.is_orchestrator !== undefined)
@@ -1333,14 +2474,33 @@ function startLocalServer(opts) {
1333
2474
  fields.codexSandboxPolicy = b.codexSandboxPolicy ?? b.codex_sandbox_policy;
1334
2475
  if (b.codexApprovalPolicy !== undefined || b.codex_approval_policy !== undefined)
1335
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
+ }
1336
2488
  if (isConnectedMode()) {
1337
2489
  const runtime = (0, server_runtime_1.getRuntimeConnectionConfig)();
1338
2490
  const auth = await getHydratedDesktopAuth();
1339
2491
  return res.json(await (0, server_adapter_1.updateServerBot)(auth, runtime, req.params.id, fields));
1340
2492
  }
2493
+ const previous = data.getAgentProfile(req.params.id);
1341
2494
  const updated = data.updateAgentProfile(req.params.id, fields);
1342
2495
  if (!updated)
1343
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
+ }
1344
2504
  res.json(updated);
1345
2505
  }
1346
2506
  catch (err) {
@@ -1376,6 +2536,8 @@ function startLocalServer(opts) {
1376
2536
  const deleted = data.deleteAgentProfile(req.params.id);
1377
2537
  if (!deleted)
1378
2538
  return res.status(404).json({ error: 'Not found' });
2539
+ closeCliBotConfigWatcher(req.params.id);
2540
+ reapCliBotSessions(req.params.id);
1379
2541
  res.json({ ok: true });
1380
2542
  }
1381
2543
  catch (err) {
@@ -1397,18 +2559,52 @@ function startLocalServer(opts) {
1397
2559
  return res.status(404).json({ error: 'Not found' });
1398
2560
  const convCount = data.countConversations(req.params.id);
1399
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;
1400
2579
  const recentConversations = data.listBotConversationActivity(req.params.id, 8).map((conversation) => ({
1401
2580
  id: conversation.id,
1402
- agent_id: conversation.agent_id,
1403
2581
  title: conversation.title,
1404
- updated_at: conversation.updated_at,
1405
- created_at: conversation.created_at,
1406
- message_count: conversation.message_count,
1407
- bot_message_count: conversation.bot_message_count,
1408
- bot_last_message_at: conversation.bot_last_message_at,
1409
- 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,
1410
2588
  }));
1411
- 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
+ });
1412
2608
  }
1413
2609
  catch (err) {
1414
2610
  res.status(500).json({ error: err.message });
@@ -1447,7 +2643,7 @@ function startLocalServer(opts) {
1447
2643
  app.post('/api/conversations', (req, res) => {
1448
2644
  (async () => {
1449
2645
  try {
1450
- const { agentId, title, source, projectId, projectName } = req.body;
2646
+ const { agentId, botIds, initialBotId, title, source, projectId, projectName, topicId } = req.body;
1451
2647
  if (isConnectedMode()) {
1452
2648
  const runtime = (0, server_runtime_1.getRuntimeConnectionConfig)();
1453
2649
  const auth = await getHydratedDesktopAuth();
@@ -1462,12 +2658,27 @@ function startLocalServer(opts) {
1462
2658
  ...(projectName ? { project_name: projectName } : {}),
1463
2659
  });
1464
2660
  }
1465
- if (!agentId)
1466
- return res.status(400).json({ error: 'agentId is required' });
1467
- 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, {
1468
2668
  projectId: projectId ?? null,
1469
2669
  projectName: projectName ?? null,
2670
+ botIds: normalizedBotIds,
2671
+ initialBotId: resolvedInitialBotId,
1470
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
+ ]);
1471
2682
  res.status(201).json(conv);
1472
2683
  }
1473
2684
  catch (err) {
@@ -1512,9 +2723,14 @@ function startLocalServer(opts) {
1512
2723
  const auth = await getHydratedDesktopAuth();
1513
2724
  return res.json(await (0, server_adapter_1.deleteServerConversation)(auth, runtime, req.params.id));
1514
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');
1515
2730
  const deleted = data.deleteConversation(req.params.id);
1516
2731
  if (!deleted)
1517
2732
  return res.status(404).json({ error: 'Not found' });
2733
+ emitDeletedConversationSyncEvent(existing);
1518
2734
  res.json({ ok: true });
1519
2735
  }
1520
2736
  catch (err) {
@@ -1534,6 +2750,9 @@ function startLocalServer(opts) {
1534
2750
  if (!conv)
1535
2751
  return res.status(404).json({ error: 'Not found' });
1536
2752
  data.updateConversation(req.params.id, req.body);
2753
+ emitConversationSyncEvents(req.params.id, [
2754
+ { change: 'conversation.updated' },
2755
+ ]);
1537
2756
  res.json(data.getConversation(req.params.id));
1538
2757
  }
1539
2758
  catch (err) {
@@ -1638,6 +2857,12 @@ function startLocalServer(opts) {
1638
2857
  const auth = await getHydratedDesktopAuth();
1639
2858
  return res.json(await (0, server_adapter_1.deleteServerProject)(auth, runtime, req.params.id));
1640
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
+ }
1641
2866
  const deleted = data.deleteProject(req.params.id);
1642
2867
  if (!deleted)
1643
2868
  return res.status(404).json({ error: 'Not found' });
@@ -1801,6 +3026,10 @@ function startLocalServer(opts) {
1801
3026
  const auth = await getHydratedDesktopAuth();
1802
3027
  return res.json(await (0, server_adapter_1.deleteServerTopic)(auth, runtime, req.params.id));
1803
3028
  }
3029
+ const topic = data.getTopic(req.params.id);
3030
+ for (const segment of topic?.segments || []) {
3031
+ closeCliSessionsForConversation(segment.conversation_id, 'topic_deleted');
3032
+ }
1804
3033
  const deleted = data.deleteTopic(req.params.id);
1805
3034
  if (!deleted)
1806
3035
  return res.status(404).json({ error: 'Not found' });
@@ -2022,6 +3251,9 @@ function startLocalServer(opts) {
2022
3251
  const botId = req.body?.botId ? String(req.body.botId) : undefined;
2023
3252
  const agentName = req.body?.agentName ? String(req.body.agentName) : undefined;
2024
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
+ ]);
2025
3257
  res.status(201).json(msg);
2026
3258
  }
2027
3259
  catch (err) {
@@ -2079,6 +3311,9 @@ function startLocalServer(opts) {
2079
3311
  });
2080
3312
  if (!updated)
2081
3313
  return res.status(404).json({ error: 'Message not found' });
3314
+ emitConversationSyncEvents(updated.conversation_id, [
3315
+ { change: 'message.updated', messageId: updated.id },
3316
+ ]);
2082
3317
  res.json(updated);
2083
3318
  }
2084
3319
  catch (err) {
@@ -2225,11 +3460,18 @@ function startLocalServer(opts) {
2225
3460
  app.post('/api/todo/:id/worker-complete', (req, res) => {
2226
3461
  try {
2227
3462
  const taskId = Number(req.params.id);
2228
- 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;
2229
3468
  const result = data.completeTodoTaskByWorker(taskId, {
2230
3469
  outputSummary: outputSummary === undefined ? undefined : String(outputSummary),
2231
3470
  artifactRefs: Array.isArray(artifactRefs) ? artifactRefs : undefined,
2232
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),
2233
3475
  insertTask: insertTask ? {
2234
3476
  title: String(insertTask.title || ''),
2235
3477
  prompt: String(insertTask.prompt || ''),
@@ -2391,19 +3633,19 @@ function startLocalServer(opts) {
2391
3633
  return res.json(result.messages);
2392
3634
  }
2393
3635
  if (hasDirectRange) {
2394
- return res.json(data.getMessagesInRange(req.params.id, startSeq, endSeq));
3636
+ return res.json(serializeMessageHistoryForRequest(req, data.getMessagesInRange(req.params.id, startSeq, endSeq)));
2395
3637
  }
2396
3638
  if (beforeSeq > 0) {
2397
3639
  // Backward paging: get N rounds or messages before given seq, returned in ASC order
2398
3640
  const msgs = rounds > 0
2399
3641
  ? data.getMessageRoundsBefore(req.params.id, beforeSeq, rounds)
2400
3642
  : data.getMessagesBefore(req.params.id, beforeSeq, limit);
2401
- res.json(msgs);
3643
+ res.json(serializeMessageHistoryForRequest(req, msgs));
2402
3644
  }
2403
3645
  else {
2404
3646
  const offset = parseInt(req.query.offset, 10) || 0;
2405
3647
  const msgs = data.getMessages(req.params.id, { limit, offset });
2406
- res.json(msgs);
3648
+ res.json(serializeMessageHistoryForRequest(req, msgs));
2407
3649
  }
2408
3650
  }
2409
3651
  catch (err) {
@@ -2461,90 +3703,51 @@ function startLocalServer(opts) {
2461
3703
  res.status(500).json({ error: err.message });
2462
3704
  }
2463
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
+ });
2464
3713
  app.post('/api/chat/jobs', async (req, res) => {
2465
3714
  try {
2466
- if (isConnectedMode()) {
2467
- return res.status(501).json({ error: 'Background chat jobs are local-mode only' });
2468
- }
2469
- const { conversationId, message, botId, pinnedMessageIds, topicId, projectId, orchestrationEnabled, } = req.body || {};
2470
- if (!message || !String(message).trim()) {
2471
- return res.status(400).json({ error: 'message is required' });
2472
- }
2473
- let profile = botId ? data.getAgentProfile(String(botId)) : data.getDefaultAgentProfile();
2474
- if (!profile) {
2475
- return res.status(400).json({ error: 'No bot configured. Create one first.' });
2476
- }
2477
- const shouldUseOrchestratorMode = orchestrationEnabled !== false && (data.isClerkOrchestratorEnabled() || (0, orchestrator_profile_1.isOrchestratorProfile)(profile));
2478
- if (shouldUseOrchestratorMode) {
2479
- return res.status(400).json({ error: 'Background chat jobs do not support orchestrator mode yet.' });
2480
- }
2481
- let convId = conversationId ? String(conversationId) : '';
2482
- if (convId) {
2483
- const latestJob = data.getLatestConversationBotChatJob(convId, profile.id);
2484
- if (latestJob && (latestJob.status === 'queued' || latestJob.status === 'running')) {
2485
- return res.status(409).json({ error: 'This bot already has a pending response in this conversation.' });
2486
- }
2487
- }
2488
- if (!convId) {
2489
- let topicProjectId = null;
2490
- if (topicId) {
2491
- const topic = data.getTopic(String(topicId));
2492
- topicProjectId = topic?.project_id ?? null;
2493
- }
2494
- const requestedProjectId = (projectId ? String(projectId) : null) || topicProjectId;
2495
- const conv = data.createConversation(profile.id, '', 'local', {
2496
- projectId: requestedProjectId,
2497
- });
2498
- convId = conv.id;
2499
- }
2500
- if (topicId && convId) {
2501
- try {
2502
- data.upsertConversationTopicSegment(convId, String(topicId));
2503
- }
2504
- catch { /* best effort */ }
2505
- }
2506
- if (!topicId && projectId && convId) {
2507
- const selectedProject = data.getProject(String(projectId));
2508
- if (selectedProject) {
2509
- data.updateConversation(convId, {
2510
- projectId: selectedProject.id,
2511
- projectName: selectedProject.name,
2512
- });
2513
- }
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);
2514
3721
  }
2515
- const savedUserMessage = data.addMessage(convId, 'user', String(message));
2516
- (0, context_window_1.incrementTurnCount)(convId);
2517
- const assistantMessage = data.addMessage(convId, 'assistant', '', profile.model || undefined, undefined, profile.id, profile.name);
2518
- const conv = data.getConversation(convId);
2519
- if (conv && !conv.title?.trim() && (conv.turn_count || 0) <= 1) {
2520
- const shortTitle = String(message).slice(0, 60).replace(/\n/g, ' ').trim();
2521
- 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
+ }));
2522
3739
  }
2523
- const job = data.createChatJob({
2524
- conversationId: convId,
2525
- userMessageId: savedUserMessage.id,
2526
- assistantMessageId: assistantMessage.id,
2527
- botId: profile.id,
2528
- status: 'queued',
2529
- requestJson: JSON.stringify({
2530
- pinnedMessageIds: Array.isArray(pinnedMessageIds) ? pinnedMessageIds : [],
2531
- topicId: topicId ? String(topicId) : null,
2532
- projectId: projectId ? String(projectId) : null,
2533
- orchestrationEnabled: orchestrationEnabled !== false,
2534
- }),
2535
- });
2536
- void runQueuedChatJobs();
2537
- res.status(201).json({
2538
- ok: true,
2539
- conversationId: convId,
2540
- userMessageId: savedUserMessage.id,
2541
- assistantMessageId: assistantMessage.id,
2542
- jobId: job.id,
2543
- status: job.status,
2544
- });
3740
+ res.status(201).json(canonicalResponse);
2545
3741
  }
2546
3742
  catch (err) {
2547
- 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 });
2548
3751
  }
2549
3752
  });
2550
3753
  app.post('/api/chat/jobs/:id/cancel', async (req, res) => {
@@ -2567,7 +3770,11 @@ function startLocalServer(opts) {
2567
3770
  }
2568
3771
  else {
2569
3772
  finalizeCancelledChatJobMessage(job);
2570
- data.touchConversationActivity(job.conversation_id);
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 });
2571
3778
  }
2572
3779
  res.json({ ok: true, status: 'cancelled' });
2573
3780
  }
@@ -2575,7 +3782,95 @@ function startLocalServer(opts) {
2575
3782
  res.status(500).json({ error: err.message });
2576
3783
  }
2577
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();
3841
+ }
3842
+ }
3843
+ });
2578
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
+ });
2579
3874
  app.post('/api/conversations/:id/chat-job/cancel', async (req, res) => {
2580
3875
  try {
2581
3876
  const requestedBotId = String(req.body?.botId || '').trim();
@@ -2599,7 +3894,11 @@ function startLocalServer(opts) {
2599
3894
  }
2600
3895
  else {
2601
3896
  finalizeCancelledChatJobMessage(latestJob);
2602
- 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 });
2603
3902
  void runQueuedChatJobs();
2604
3903
  }
2605
3904
  res.json({ ok: true, status: 'cancelled', jobId: latestJob.id });
@@ -2619,14 +3918,42 @@ function startLocalServer(opts) {
2619
3918
  req.on('close', abortOnClientClose);
2620
3919
  res.on('close', abortOnClientClose);
2621
3920
  try {
2622
- 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;
2623
3922
  if (!message)
2624
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 ─────────────────────────────────────
2625
3952
  if (await relayConnectedChat(req, res)) {
2626
3953
  return;
2627
3954
  }
2628
3955
  // Resolve bot
2629
- let profile = botId ? data.getAgentProfile(botId) : data.getDefaultAgentProfile();
3956
+ let profile = resolvedBotId ? data.getAgentProfile(resolvedBotId) : data.getDefaultAgentProfile();
2630
3957
  if (!profile) {
2631
3958
  // Auto-create a default profile from the DB-backed provider connection if available.
2632
3959
  const providerConnection = data.listProviderConnections().find((conn) => conn.access_mode === 'cli' || !!conn.api_key_enc);
@@ -2642,7 +3969,11 @@ function startLocalServer(opts) {
2642
3969
  if (!profile)
2643
3970
  return res.status(400).json({ error: 'No bot configured. Create one first.' });
2644
3971
  }
3972
+ const incomingAttachments = parseChatAttachmentInputs(attachments);
3973
+ const shouldUseOrchestratorMode = normalizedOrchestrationEnabled !== false && (data.isClerkOrchestratorEnabled() || (0, orchestrator_profile_1.isOrchestratorProfile)(profile));
2645
3974
  // Resolve or create conversation
3975
+ let conversationCreated = false;
3976
+ let turnStartSyncRevision = null;
2646
3977
  let convId = conversationId;
2647
3978
  if (!convId) {
2648
3979
  // Create with empty title so auto-title can fill it in after first response
@@ -2654,8 +3985,14 @@ function startLocalServer(opts) {
2654
3985
  const requestedProjectId = (projectId ? String(projectId) : null) || topicProjectId;
2655
3986
  const conv = data.createConversation(profile.id, '', 'local', {
2656
3987
  projectId: requestedProjectId,
3988
+ botIds: requestedBotIds.length > 0 ? requestedBotIds : [profile.id],
3989
+ initialBotId: profile.id,
2657
3990
  });
2658
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 });
2659
3996
  }
2660
3997
  // Link conversation to topic if topicId provided
2661
3998
  if (topicId && convId) {
@@ -2731,8 +4068,12 @@ function startLocalServer(opts) {
2731
4068
  };
2732
4069
  // Save user message (skip if multi-bot call where first bot already saved it)
2733
4070
  let savedUserMessage = null;
4071
+ let persistedAttachments = [];
2734
4072
  if (!skipUserMessage) {
2735
- 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);
2736
4077
  (0, context_window_1.incrementTurnCount)(convId);
2737
4078
  const convForPolicy = data.getConversation(convId);
2738
4079
  const effectiveProjectId = projectId ? String(projectId) : (convForPolicy?.project_id || undefined);
@@ -2757,24 +4098,229 @@ function startLocalServer(opts) {
2757
4098
  });
2758
4099
  }
2759
4100
  }
2760
- 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) {
2761
4294
  const placeholder = data.addMessage(convId, 'assistant', '', buildConfiguredMessageModel(profile), undefined, profile?.id || undefined, profile?.name || undefined);
2762
4295
  assistantMessageId = placeholder.id;
4296
+ turnStartSyncRevision = emitConversationSyncEvents(convId, [
4297
+ { change: 'message.created', messageId: placeholder.id },
4298
+ ], turnStartSyncRevision ? { revision: turnStartSyncRevision } : undefined);
2763
4299
  }
2764
4300
  // ─── Orchestrator Mode Branch ─────────────────────────
2765
- const shouldUseOrchestratorMode = orchestrationEnabled !== false && (data.isClerkOrchestratorEnabled() || (0, orchestrator_profile_1.isOrchestratorProfile)(profile));
2766
4301
  if (shouldUseOrchestratorMode) {
2767
4302
  const { OrchestratorAgent } = require('./orchestrator');
2768
4303
  const { buildLocalDesktopOrchestratorRuntime } = require('./orchestrator');
2769
4304
  const { getWorkflowEngine } = require('./workflow-engine');
2770
4305
  const workflowEngine = getWorkflowEngine(opts.projectDir, 'local_desktop');
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;
2771
4317
  let orchestratorRuntime;
2772
4318
  try {
2773
- orchestratorRuntime = buildLocalDesktopOrchestratorRuntime(profile);
4319
+ orchestratorRuntime = buildLocalDesktopOrchestratorRuntime(explicitOrchestratorBot);
2774
4320
  }
2775
4321
  catch (runtimeErr) {
2776
4322
  return res.status(400).json({
2777
- error: runtimeErr?.message || 'Orchestrator mode is not configured correctly.',
4323
+ error: runtimeErr?.message || 'Orchestrator mode is not configured. Mark a bot with is_orchestrator=1 or enable Clerk orchestration.',
2778
4324
  });
2779
4325
  }
2780
4326
  const orchestrator = new OrchestratorAgent(orchestratorRuntime, workflowEngine);
@@ -2786,6 +4332,44 @@ function startLocalServer(opts) {
2786
4332
  const shortTitle = message.slice(0, 60).replace(/\n/g, ' ').trim();
2787
4333
  data.updateConversation(convId, { title: shortTitle || 'New Chat' });
2788
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
+ }
2789
4373
  // SSE setup — same contract as normal chat path
2790
4374
  res.writeHead(200, {
2791
4375
  'Content-Type': 'text/event-stream',
@@ -2803,9 +4387,12 @@ function startLocalServer(opts) {
2803
4387
  const clerkSelectedAsOrchestrator = data.isClerkOrchestratorEnabled();
2804
4388
  try {
2805
4389
  if (clerkSelectedAsOrchestrator) {
2806
- const clerkProvider = data.getSetting('clerk_provider') || profile.provider;
2807
- const clerkModel = data.getSetting('clerk_model') || profile.model || null;
2808
- const clerkConnection = data.findProviderConnection(clerkProvider);
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);
2809
4396
  const clerkRuntimeMode = clerkProvider === 'claude-cli' || clerkProvider === 'codex-cli'
2810
4397
  ? 'subscription-cli'
2811
4398
  : 'api-key';
@@ -2834,22 +4421,24 @@ function startLocalServer(opts) {
2834
4421
  }
2835
4422
  sendEvent('meta', {
2836
4423
  conversationId: convId,
4424
+ assistantMessageId: assistantMessageId || null,
2837
4425
  ...(orchestratorRuntimePayload ? { runtime: orchestratorRuntimePayload } : {}),
2838
4426
  });
2839
4427
  let lastProgressChat = '';
2840
4428
  let lastProgressActivity = '';
2841
4429
  let selfExecuteStreamed = false;
2842
- let hasWorkerActivity = false;
2843
4430
  try {
2844
4431
  let lastInterimMessage = '';
2845
- const response = await orchestrator.handleUserMessage(message, convId, {
4432
+ const response = await orchestrator.handleUserMessage(runtimeUserPrompt, convId, {
2846
4433
  projectDir: opts.projectDir,
2847
4434
  projectId: effectiveProjectId,
4435
+ orchestratorBotIdHint,
4436
+ storedAttachments: storedAttachmentsForTurn.length > 0 ? storedAttachmentsForTurn : undefined,
4437
+ abortSignal: routeAbortController.signal,
2848
4438
  commandId: `local-${Date.now()}`,
2849
4439
  workflowTemplateId: workflowTemplateId || undefined,
2850
4440
  onWorkerChunk: (event) => {
2851
4441
  if (event.type === 'step_start') {
2852
- hasWorkerActivity = true;
2853
4442
  recordWorkerActivity('worker_step_start', event, {
2854
4443
  stepId: event.stepId,
2855
4444
  agentName: event.agentName,
@@ -2868,7 +4457,6 @@ function startLocalServer(opts) {
2868
4457
  });
2869
4458
  }
2870
4459
  else if (event.type === 'worker_chunk') {
2871
- hasWorkerActivity = true;
2872
4460
  recordWorkerActivity('worker_chunk', event, {
2873
4461
  stepId: event.stepId,
2874
4462
  agentName: event.agentName,
@@ -2889,7 +4477,6 @@ function startLocalServer(opts) {
2889
4477
  });
2890
4478
  }
2891
4479
  else if (event.type === 'worker_terminal_chunk') {
2892
- hasWorkerActivity = true;
2893
4480
  sendEvent('worker_terminal_chunk', {
2894
4481
  stepId: event.stepId,
2895
4482
  botId: resolveWorkerBotId(event.agentName),
@@ -2901,7 +4488,6 @@ function startLocalServer(opts) {
2901
4488
  });
2902
4489
  }
2903
4490
  else if (event.type === 'worker_tool_call') {
2904
- hasWorkerActivity = true;
2905
4491
  recordWorkerActivity('worker_tool_call', event, {
2906
4492
  stepId: event.stepId,
2907
4493
  agentName: event.agentName,
@@ -2926,7 +4512,6 @@ function startLocalServer(opts) {
2926
4512
  });
2927
4513
  }
2928
4514
  else if (event.type === 'worker_tool_result') {
2929
- hasWorkerActivity = true;
2930
4515
  recordWorkerActivity('worker_tool_result', event, {
2931
4516
  stepId: event.stepId,
2932
4517
  agentName: event.agentName,
@@ -2965,7 +4550,6 @@ function startLocalServer(opts) {
2965
4550
  sendEvent('chunk', { text: `> [${icon}] ${event.toolName} completed\n` });
2966
4551
  }
2967
4552
  else if (event.type === 'step_done') {
2968
- hasWorkerActivity = true;
2969
4553
  recordWorkerActivity('worker_step_done', event, {
2970
4554
  stepId: event.stepId,
2971
4555
  agentName: event.agentName,
@@ -3002,7 +4586,7 @@ function startLocalServer(opts) {
3002
4586
  // Progress chatText suppressed — worker card handles all streaming display.
3003
4587
  // Main bubble only gets final content via 'done' event.
3004
4588
  // Emit interim messages for key orchestrator transitions
3005
- const interimMessage = deriveOrchestratorInterimMessage(status);
4589
+ const interimMessage = deriveVisibleOrchestratorMessage(status);
3006
4590
  if (interimMessage && interimMessage !== lastInterimMessage) {
3007
4591
  lastInterimMessage = interimMessage;
3008
4592
  recordActivity('orchestrator_interim', { text: interimMessage }, interimMessage);
@@ -3022,41 +4606,44 @@ function startLocalServer(opts) {
3022
4606
  // Save O's response (no incrementTurnCount — Fix #1: user message already incremented it)
3023
4607
  const responseMeta = orchestrator.getLastResponseMeta();
3024
4608
  const finalAgentName = responseMeta?.agentName || 'Orchestrator';
3025
- const finalBotId = responseMeta?.botId || (finalAgentName === 'Orchestrator' ? profile.id : undefined);
4609
+ const finalBotId = responseMeta?.botId
4610
+ || (finalAgentName === 'Orchestrator' && !clerkSelectedAsOrchestrator ? profile.id : undefined);
3026
4611
  const finalModelLabel = responseMeta?.modelLabel || orchestratorRuntimeLabel || undefined;
3027
- const splitFinalMessage = hasWorkerActivity && finalAgentName === 'Clerk';
3028
- if (splitFinalMessage) {
3029
- const orchestratorMessage = data.addMessage(convId, 'assistant', '', orchestratorRuntimeLabel || buildConfiguredMessageModel(profile), undefined, profile.id, 'Orchestrator');
3030
- data.attachMessageActivitiesToMessage(activityStreamId, orchestratorMessage.id);
3031
- const clerkMessage = data.addMessage(convId, 'assistant', response, finalModelLabel, undefined, finalBotId, finalAgentName);
3032
- data.createMessageActivity({
3033
- conversationId: convId,
3034
- 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,
3035
4620
  botId: finalBotId,
3036
4621
  agentName: finalAgentName,
3037
- activityType: 'message',
3038
- summary: 'Final assistant response',
3039
- payload: { content: response },
3040
- expiresAt: activityExpiresAt,
3041
- });
4622
+ })
4623
+ : null;
4624
+ if (!savedMessage) {
4625
+ savedMessage = data.addMessage(convId, 'assistant', response, finalModelLabel, undefined, finalBotId, finalAgentName);
3042
4626
  }
3043
- else {
3044
- const savedMessage = data.addMessage(convId, 'assistant', response, finalModelLabel, undefined, finalBotId, finalAgentName);
3045
- data.attachMessageActivitiesToMessage(activityStreamId, savedMessage.id);
3046
- data.createMessageActivity({
3047
- conversationId: convId,
4627
+ emitConversationSyncEvents(convId, [
4628
+ {
4629
+ change: savedMessage.id === assistantMessageId ? 'message.updated' : 'message.created',
3048
4630
  messageId: savedMessage.id,
3049
- botId: finalBotId,
3050
- agentName: finalAgentName,
3051
- activityType: 'message',
3052
- summary: 'Final orchestrator response',
3053
- payload: { content: response },
3054
- expiresAt: activityExpiresAt,
3055
- });
3056
- }
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
+ });
3057
4644
  // Emit chunk + done events using the same SSE contract as normal chat
3058
4645
  // Skip the final bulk chunk if we already streamed via worker_chunk (execute_self)
3059
- if (!selfExecuteStreamed && !splitFinalMessage) {
4646
+ if (!selfExecuteStreamed) {
3060
4647
  sendEvent('chunk', { text: response });
3061
4648
  }
3062
4649
  sendEvent('done', {
@@ -3064,7 +4651,6 @@ function startLocalServer(opts) {
3064
4651
  content: response,
3065
4652
  agentName: finalAgentName,
3066
4653
  botId: finalBotId,
3067
- separateFinalMessage: splitFinalMessage,
3068
4654
  ...((responseMeta?.modelLabel || orchestratorRuntimePayload)
3069
4655
  ? {
3070
4656
  runtime: {
@@ -3084,7 +4670,41 @@ function startLocalServer(opts) {
3084
4670
  (0, local_funnel_1.scheduleFunnelProcessing)(convId);
3085
4671
  }
3086
4672
  catch (orchErr) {
3087
- 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
+ }
3088
4708
  sendEvent('error', { type: 'error', error: orchErr.message });
3089
4709
  responseEnded = true;
3090
4710
  res.end();
@@ -3104,13 +4724,6 @@ function startLocalServer(opts) {
3104
4724
  : expandAllowedToolNames(allToolDefs, configuredBuiltinTools, configuredMcpTools);
3105
4725
  const toolDefs = allToolDefs.filter((tool) => allowedToolNames.has(tool.name));
3106
4726
  const conversation = data.getConversation(convId);
3107
- const topicTitle = topicId ? data.getTopic(topicId)?.title : undefined;
3108
- const project = conversation?.project_id ? data.getProject(conversation.project_id) : undefined;
3109
- const workspacePath = project?.folder?.trim() || undefined;
3110
- let llmSpawnCwd = opts.projectDir;
3111
- if (workspacePath && fs.existsSync(workspacePath)) {
3112
- llmSpawnCwd = workspacePath;
3113
- }
3114
4727
  // Resolve LLM runtime early so the local desktop prompt contract can differ
3115
4728
  // between API/fresh CLI and recurring CLI sessions without affecting server paths.
3116
4729
  const runtime = await buildChatRuntime(profile);
@@ -3140,33 +4753,133 @@ function startLocalServer(opts) {
3140
4753
  const cliEpochStartedAt = cliSessionEpochPlan.resumeSessionId
3141
4754
  ? (cliSessionEpochPlan.existing?.epoch_started_at || localTimestamp())
3142
4755
  : localTimestamp();
3143
- const primaryTopicId = topicId || data.getPrimaryTopicIdForConversation(convId) || undefined;
3144
- const cliHistoryFilePath = activeIsCliProvider && !cliSessionEpochPlan.resumeSessionId
3145
- ? writeCliBootstrapHistoryFile({
3146
- conversationId: convId,
3147
- botId: profile.id,
3148
- projectPath: llmSpawnCwd,
3149
- topicId: primaryTopicId,
3150
- })
3151
- : null;
3152
- const directPrompt = buildLocalDesktopDirectPrompt({
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({
3153
4803
  conversationId: convId,
3154
4804
  currentBotId: profile.id,
3155
4805
  currentBotName: profile.name,
3156
4806
  currentProvider: activeProviderName,
3157
- userPrompt: message,
4807
+ userPrompt: supportsNativeImageInput(activeProviderName) ? message : runtimeUserPrompt,
3158
4808
  soulMd: profile.soul_md || 'You are an AI assistant running locally. You have access to project files and can execute code.',
3159
- projectName: conversation?.project_name || undefined,
3160
- topicTitle: topicTitle || undefined,
3161
- workspacePath,
4809
+ projectName: promptContext.projectName,
4810
+ topicTitle: promptContext.topicTitle,
4811
+ workspacePath: promptContext.workspacePath,
3162
4812
  timezone: effectiveTimezone,
3163
4813
  availableTools: toolDefs.map((tool) => ({ name: tool.name, description: tool.description })),
3164
- isCliRecurring: !!cliSessionEpochPlan.resumeSessionId,
3165
- cliHistoryFilePath,
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),
3166
4820
  useCompletionSentinel: resolveDirectCliSessionTransport(activeProviderName, enableCliSessionEpoch, (0, storage_mode_1.isLocalStorageMode)()) === 'pty',
3167
4821
  });
3168
- const llmMessages = [{ role: 'user', content: directPrompt.userPrompt }];
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
+ }];
3169
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
+ };
3170
4883
  if (!activeApiKey) {
3171
4884
  return res.status(400).json({ error: `No API key for provider ${profile.provider}. Configure one in Settings.` });
3172
4885
  }
@@ -3194,10 +4907,17 @@ function startLocalServer(opts) {
3194
4907
  // CLI providers only see the last user message — prepend pinned context there
3195
4908
  const lastUserIdx = llmMessages.map(m => m.role).lastIndexOf('user');
3196
4909
  if (lastUserIdx >= 0) {
3197
- llmMessages[lastUserIdx] = {
3198
- ...llmMessages[lastUserIdx],
3199
- content: `${pinnedBlock}\n---\n${llmMessages[lastUserIdx].content}`,
3200
- };
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
+ }
3201
4921
  }
3202
4922
  }
3203
4923
  else {
@@ -3298,7 +5018,7 @@ function startLocalServer(opts) {
3298
5018
  while (true) {
3299
5019
  appServerAttempt++;
3300
5020
  try {
3301
- const isFreshSession = forceFreshInteractiveCliSession || !cliSessionEpochPlan.resumeSessionId;
5021
+ const isFreshSession = forceFreshInteractiveCliSession || (!codexAppServerManager.hasActiveSession(convId, profile.id) && !cliSessionEpochPlan.resumeSessionId);
3302
5022
  const result = await codexAppServerManager.runTurn({
3303
5023
  runtimeMode: 'local_desktop',
3304
5024
  conversationId: convId,
@@ -3308,7 +5028,7 @@ function startLocalServer(opts) {
3308
5028
  systemPrompt,
3309
5029
  messages: llmMessages,
3310
5030
  forceFreshSession: isFreshSession,
3311
- resumeSessionId: cliSessionEpochPlan.resumeSessionId || undefined,
5031
+ resumeSessionId: isFreshSession ? undefined : (cliSessionEpochPlan.resumeSessionId || undefined),
3312
5032
  model: activeModelName || profile.model || null,
3313
5033
  projectId: conversation?.project_id ?? null,
3314
5034
  codexSettings: {
@@ -3326,6 +5046,12 @@ function startLocalServer(opts) {
3326
5046
  persistAssistantPartial(false);
3327
5047
  sendEvent('chunk', { text: chunk });
3328
5048
  },
5049
+ onCommentary: async (commentary) => {
5050
+ const text = String(commentary || '').trim();
5051
+ if (!text)
5052
+ return;
5053
+ sendEvent('status', { phase: 'commentary', detail: text });
5054
+ },
3329
5055
  onDetail: async (detail) => {
3330
5056
  const text = String(detail || '').trim();
3331
5057
  if (!text)
@@ -3333,6 +5059,35 @@ function startLocalServer(opts) {
3333
5059
  sendEvent('status', { phase: 'thinking', detail: text });
3334
5060
  recordActivity('status', { phase: 'thinking', detail: text }, text);
3335
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
+ },
3336
5091
  });
3337
5092
  if (result.sessionId) {
3338
5093
  activeCliSessionId = result.sessionId;
@@ -3342,10 +5097,40 @@ function startLocalServer(opts) {
3342
5097
  totalOutputTokens += result.usage.outputTokens || 0;
3343
5098
  hasExactUsage = true;
3344
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 ──────────────────────────────────────────
3345
5127
  rawCliTranscript = result.rawOutput || '';
3346
5128
  fullContent = (0, completion_marker_1.stripCompletionSentinel)((result.content || '').trim()).text.trim();
3347
5129
  if (!fullContent && appServerAttempt < LOCAL_RUNTIME_RETRY_LIMIT) {
3348
5130
  forceFreshInteractiveCliSession = true;
5131
+ if (cliSessionEpochPlan.resumeSessionId) {
5132
+ applyFreshCliBootstrapFallback('resume_failed');
5133
+ }
3349
5134
  codexAppServerManager.closeSessionByConversation(convId, profile.id);
3350
5135
  const retryDetail = `Selected runtime returned an empty response; retrying with a fresh ${activeProviderName} session (${appServerAttempt + 1}/${LOCAL_RUNTIME_RETRY_LIMIT})...`;
3351
5136
  console.warn(chalk_1.default.yellow(` [chat] ${retryDetail}`));
@@ -3358,10 +5143,14 @@ function startLocalServer(opts) {
3358
5143
  if (routeAbortController.signal.aborted || codexErr?.name === 'AbortError') {
3359
5144
  throw codexErr;
3360
5145
  }
5146
+ clearFailedLocalCliSessionEpoch(convId, profile.id, activeCliSessionId || cliSessionEpochPlan.resumeSessionId);
3361
5147
  if (appServerAttempt >= LOCAL_RUNTIME_RETRY_LIMIT || !shouldRetrySelectedLocalRuntime(codexErr)) {
3362
5148
  throw codexErr;
3363
5149
  }
3364
5150
  forceFreshInteractiveCliSession = true;
5151
+ if (cliSessionEpochPlan.resumeSessionId) {
5152
+ applyFreshCliBootstrapFallback('resume_failed');
5153
+ }
3365
5154
  codexAppServerManager.closeSessionByConversation(convId, profile.id);
3366
5155
  const retryDetail = `Selected runtime failed (${codexErr?.message || codexErr}); retrying with a fresh ${activeProviderName} session (${appServerAttempt + 1}/${LOCAL_RUNTIME_RETRY_LIMIT})...`;
3367
5156
  console.warn(chalk_1.default.yellow(` [chat] ${retryDetail}`));
@@ -3381,10 +5170,9 @@ function startLocalServer(opts) {
3381
5170
  while (true) {
3382
5171
  ptyAttempt++;
3383
5172
  try {
3384
- // Pattern A (Claude CLI): generate a sequential session ID for new sessions
3385
- // Pattern B (Codex CLI): let the CLI generate its own ID
3386
- const isFreshSession = forceFreshInteractiveCliSession || !cliSessionEpochPlan.resumeSessionId;
3387
- const newSessionId = isFreshSession && activeProviderName === 'claude-cli'
5173
+ const hasLivePtySession = ptyManager.hasActiveSession(convId, profile.id);
5174
+ const isFreshSession = forceFreshInteractiveCliSession || (!hasLivePtySession && !cliSessionEpochPlan.resumeSessionId);
5175
+ const newSessionId = activeProviderName === 'claude-cli' && isFreshSession
3388
5176
  ? data.generateNextSessionId()
3389
5177
  : undefined;
3390
5178
  currentAttemptWasFreshSession = isFreshSession;
@@ -3393,11 +5181,20 @@ function startLocalServer(opts) {
3393
5181
  conversationId: convId,
3394
5182
  botId: profile.id,
3395
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
+ },
3396
5193
  cwd: llmSpawnCwd,
3397
5194
  systemPrompt,
3398
5195
  messages: llmMessages,
3399
5196
  forceFreshSession: isFreshSession,
3400
- resumeSessionId: cliSessionEpochPlan.resumeSessionId || undefined,
5197
+ resumeSessionId: isFreshSession ? undefined : (cliSessionEpochPlan.resumeSessionId || undefined),
3401
5198
  newSessionId,
3402
5199
  abortSignal: routeAbortController.signal,
3403
5200
  onRawChunk: async (chunk) => {
@@ -3430,10 +5227,40 @@ function startLocalServer(opts) {
3430
5227
  totalOutputTokens += result.usage.outputTokens || 0;
3431
5228
  hasExactUsage = true;
3432
5229
  }
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 ──────────────────────────────────────────
3433
5257
  rawCliTranscript = result.rawOutput || '';
3434
5258
  fullContent = (0, completion_marker_1.stripCompletionSentinel)((result.content || '').trim()).text.trim();
3435
5259
  if (!fullContent && ptyAttempt < LOCAL_RUNTIME_RETRY_LIMIT) {
3436
5260
  forceFreshInteractiveCliSession = true;
5261
+ if (cliSessionEpochPlan.resumeSessionId) {
5262
+ applyFreshCliBootstrapFallback('resume_failed');
5263
+ }
3437
5264
  ptyManager.closeSessionByConversation(convId, profile.id);
3438
5265
  const retryDetail = `Selected runtime returned an empty response; retrying with a fresh ${activeProviderName} session (${ptyAttempt + 1}/${LOCAL_RUNTIME_RETRY_LIMIT})...`;
3439
5266
  console.warn(chalk_1.default.yellow(` [chat] ${retryDetail}`));
@@ -3446,6 +5273,8 @@ function startLocalServer(opts) {
3446
5273
  if (routeAbortController.signal.aborted || ptyErr?.name === 'AbortError') {
3447
5274
  throw ptyErr;
3448
5275
  }
5276
+ ptyManager.logSessionFailureByConversation(convId, profile.id, 'chat_runtime_failure_before_kill', ptyErr);
5277
+ clearFailedLocalCliSessionEpoch(convId, profile.id, currentAttemptSessionId || activeCliSessionId || cliSessionEpochPlan.resumeSessionId);
3449
5278
  if (ptyAttempt >= LOCAL_RUNTIME_RETRY_LIMIT || !shouldRetrySelectedLocalRuntime(ptyErr)) {
3450
5279
  throw ptyErr;
3451
5280
  }
@@ -3454,11 +5283,19 @@ function startLocalServer(opts) {
3454
5283
  forceFreshInteractiveCliSession = true;
3455
5284
  ptyManager.closeSessionByConversation(convId, profile.id);
3456
5285
  }
5286
+ const resumeFailureFallback = !!cliSessionEpochPlan.resumeSessionId && !currentAttemptWasFreshSession;
5287
+ if (resumeFailureFallback) {
5288
+ forceFreshInteractiveCliSession = true;
5289
+ applyFreshCliBootstrapFallback('resume_failed');
5290
+ ptyManager.closeSessionByConversation(convId, profile.id);
5291
+ }
3457
5292
  const retryDetail = startupRetry
3458
- ? `Fresh ${activeProviderName} session ${currentAttemptSessionId || '(unknown)'} did not create a transcript within 7s; killing it and retrying with a new session (${ptyAttempt + 1}/${LOCAL_RUNTIME_RETRY_LIMIT})...`
3459
- : currentAttemptWasFreshSession && activeProviderName === 'claude-cli'
3460
- ? `Fresh ${activeProviderName} session ${currentAttemptSessionId || '(unknown)'} failed (${ptyErr?.message || ptyErr}); killing it and retrying with a new session (${ptyAttempt + 1}/${LOCAL_RUNTIME_RETRY_LIMIT})...`
3461
- : `Selected runtime failed (${ptyErr?.message || ptyErr}); retrying the same connection (${ptyAttempt + 1}/${LOCAL_RUNTIME_RETRY_LIMIT})...`;
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})...`;
3462
5299
  console.warn(chalk_1.default.yellow(` [chat] ${retryDetail}`));
3463
5300
  await pauseLocalRuntimeRetry(ptyAttempt);
3464
5301
  }
@@ -3634,6 +5471,17 @@ function startLocalServer(opts) {
3634
5471
  }
3635
5472
  persistAssistantPartial(true);
3636
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)'}`);
3637
5485
  const nextEpochTurnCount = cliSessionEpochPlan.resumeSessionId
3638
5486
  ? ((cliSessionEpochPlan.existing?.epoch_turn_count || 0) + 1)
3639
5487
  : 1;
@@ -3645,7 +5493,7 @@ function startLocalServer(opts) {
3645
5493
  epochTurnCount: nextEpochTurnCount,
3646
5494
  lastInputTokens: hasExactUsage ? totalInputTokens : approxInputTokens,
3647
5495
  lastOutputTokens: hasExactUsage ? totalOutputTokens : 0,
3648
- resetReason: cliSessionEpochPlan.resetReason,
5496
+ resetReason: cliEpochResetReason,
3649
5497
  epochStartedAt: cliEpochStartedAt,
3650
5498
  lastUsedAt: localTimestamp(),
3651
5499
  });
@@ -3679,6 +5527,12 @@ function startLocalServer(opts) {
3679
5527
  resultArtifact: useInteractiveCliSession ? (rawCliTranscript || null) : undefined,
3680
5528
  }) || data.addMessage(convId, 'assistant', persistedContent, modelWithRuntime || undefined, undefined, profile.id, profile.name))
3681
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
+ ]);
3682
5536
  data.attachMessageActivitiesToMessage(activityStreamId, savedMessage.id);
3683
5537
  data.createMessageActivity({
3684
5538
  conversationId: convId,
@@ -3716,6 +5570,9 @@ function startLocalServer(opts) {
3716
5570
  // For CLI providers, set a simple title without burning a CLI call
3717
5571
  const shortTitle = message.slice(0, 60).replace(/\n/g, ' ').trim();
3718
5572
  data.updateConversation(convId, { title: shortTitle || 'New Chat' });
5573
+ emitConversationSyncEvents(convId, [
5574
+ { change: 'conversation.updated' },
5575
+ ]);
3719
5576
  }
3720
5577
  else {
3721
5578
  autoTitleConversation(convId, message, persistedContent, activeProviderName, activeModelName, activeApiKey);
@@ -3730,8 +5587,17 @@ function startLocalServer(opts) {
3730
5587
  (0, local_funnel_1.scheduleFunnelProcessing)(convId);
3731
5588
  }
3732
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.)
3733
5595
  console.error(chalk_1.default.red(`Chat error: ${err.message}`));
5596
+ if (err?.stack) {
5597
+ console.error(String(err.stack));
5598
+ }
3734
5599
  try {
5600
+ const userMessage = buildLocalRuntimeUserFacingError(err);
3735
5601
  if (activityErrorContext.conversationId) {
3736
5602
  data.createMessageActivity({
3737
5603
  conversationId: activityErrorContext.conversationId,
@@ -3743,6 +5609,8 @@ function startLocalServer(opts) {
3743
5609
  summary: err.message,
3744
5610
  payload: {
3745
5611
  error: err.message,
5612
+ userMessage,
5613
+ stack: err?.stack || null,
3746
5614
  authRequired: err?.authRequired === true,
3747
5615
  providerId: err?.providerId || null,
3748
5616
  cli: err?.cli || null,
@@ -3755,12 +5623,13 @@ function startLocalServer(opts) {
3755
5623
  // best effort only
3756
5624
  }
3757
5625
  if (!res.headersSent) {
3758
- res.status(500).json({ error: err.message });
5626
+ res.status(500).json({ error: err.message, userMessage: buildLocalRuntimeUserFacingError(err) });
3759
5627
  }
3760
5628
  else {
3761
5629
  try {
3762
5630
  res.write(`event: error\ndata: ${JSON.stringify({
3763
5631
  error: err.message,
5632
+ userMessage: buildLocalRuntimeUserFacingError(err),
3764
5633
  authRequired: err?.authRequired === true,
3765
5634
  providerId: err?.providerId || null,
3766
5635
  cli: err?.cli || null,
@@ -3848,6 +5717,650 @@ function startLocalServer(opts) {
3848
5717
  res.status(500).json({ error: err.message });
3849
5718
  }
3850
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
+ });
3851
6364
  app.get('/api/admin/audit', (req, res) => {
3852
6365
  try {
3853
6366
  const rows = data.listAdminAudit({
@@ -3889,16 +6402,14 @@ function startLocalServer(opts) {
3889
6402
  // ─── Clerk Config ──────────────────────────────────────────
3890
6403
  app.get('/api/clerk/config', (_req, res) => {
3891
6404
  try {
3892
- const clerkProvider = data.getSetting('clerk_provider');
3893
- const clerkModel = data.getSetting('clerk_model');
3894
- const hasKey = !!data.getSetting('clerk_api_key');
6405
+ const clerkConfig = data.getResolvedClerkConfigInfo();
3895
6406
  const currentOrchestrator = data.getCurrentOrchestratorSelection();
3896
6407
  const currentDefaultBot = data.getDefaultAgentProfile();
3897
6408
  res.json({
3898
- provider: clerkProvider || null,
3899
- model: clerkModel || null,
3900
- hasApiKey: hasKey,
3901
- configured: !!(clerkProvider && clerkModel && hasKey),
6409
+ provider: clerkConfig.provider,
6410
+ model: clerkConfig.model,
6411
+ hasApiKey: clerkConfig.hasSecret,
6412
+ configured: clerkConfig.configured,
3902
6413
  isOrchestrator: data.isClerkOrchestratorEnabled(),
3903
6414
  currentOrchestrator,
3904
6415
  currentDefaultBot: currentDefaultBot
@@ -4794,6 +7305,8 @@ function startLocalServer(opts) {
4794
7305
  });
4795
7306
  // Initialize workflow engine
4796
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);
4797
7310
  // Start server
4798
7311
  return new Promise((resolve, reject) => {
4799
7312
  _server = app.listen(port, '127.0.0.1', () => {
@@ -4801,6 +7314,12 @@ function startLocalServer(opts) {
4801
7314
  console.log(chalk_1.default.green(`\n Local server: http://127.0.0.1:${port}`));
4802
7315
  resolve(_server);
4803
7316
  });
7317
+ _server.on('close', () => {
7318
+ (0, managed_process_registry_1.stopIdleSweep)();
7319
+ for (const botId of [...cliBotConfigWatchers.keys()]) {
7320
+ closeCliBotConfigWatcher(botId);
7321
+ }
7322
+ });
4804
7323
  _server.on('error', (err) => {
4805
7324
  if (err.code === 'EADDRINUSE') {
4806
7325
  console.error(chalk_1.default.red(`Port ${port} already in use`));
@@ -4810,6 +7329,7 @@ function startLocalServer(opts) {
4810
7329
  });
4811
7330
  }
4812
7331
  function stopLocalServer() {
7332
+ (0, managed_process_registry_1.stopIdleSweep)();
4813
7333
  return new Promise((resolve) => {
4814
7334
  if (_server) {
4815
7335
  _server.close(() => {
@@ -4933,18 +7453,46 @@ function hydrateMessageDisplayMetadata(message) {
4933
7453
  model: [modelBase, runtimeLabel].filter(Boolean).join(' | '),
4934
7454
  };
4935
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
+ }
4936
7487
  function buildLocalDesktopDirectPrompt(input) {
4937
7488
  const isCliProvider = index_1.CLI_PROVIDERS.has(input.currentProvider);
4938
7489
  const useCompletionSentinel = !!input.useCompletionSentinel;
4939
- const useCliHistoryBootstrap = isCliProvider && !input.isCliRecurring;
4940
- const crossBotReplies = isCliProvider && input.isCliRecurring
4941
- ? getLatestOtherBotReplies(input.conversationId, input.currentBotId, input.currentBotName)
4942
- : [];
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;
4943
7493
  const contract = !isCliProvider || !input.isCliRecurring
4944
7494
  ? 'api_or_fresh_cli'
4945
- : crossBotReplies.length > 0
4946
- ? 'cli_recurring_multibot'
4947
- : 'cli_recurring_single';
7495
+ : 'cli_recurring_single';
4948
7496
  const lines = [
4949
7497
  '[Bot Identity]',
4950
7498
  input.soulMd.trim(),
@@ -4978,16 +7526,51 @@ function buildLocalDesktopDirectPrompt(input) {
4978
7526
  lines.push('If the user refers to prior project or conversation context that is not included here, search with these tools first.');
4979
7527
  }
4980
7528
  }
4981
- if (contract === 'api_or_fresh_cli' && !useCliHistoryBootstrap) {
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) {
4982
7558
  const summaryWindow = (0, context_window_1.getPromptContextWindow)(input.conversationId, safeguards_1.SAFEGUARDS.CONTEXT_WINDOW_TURNS);
4983
7559
  if (summaryWindow.summary?.summary_text?.trim()) {
4984
- lines.push('', '[Context Summary]', 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());
4985
7563
  }
4986
7564
  const recentTurnsWindow = (0, context_window_1.getPromptContextWindow)(input.conversationId, summaryWindow.summary?.summary_text?.trim()
4987
7565
  ? safeguards_1.SAFEGUARDS.CONTEXT_WINDOW_TURNS
4988
7566
  : safeguards_1.SAFEGUARDS.NO_SUMMARY_CONTEXT_WINDOW_TURNS);
4989
- if (recentTurnsWindow.turns.length > 0) {
4990
- lines.push('', '[Recent Messages]', (0, context_window_1.formatTurnsForPrompt)(recentTurnsWindow.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));
4991
7574
  }
4992
7575
  }
4993
7576
  let effectiveUserPrompt = input.userPrompt;
@@ -5002,15 +7585,6 @@ function buildLocalDesktopDirectPrompt(input) {
5002
7585
  input.userPrompt,
5003
7586
  ].join('\n');
5004
7587
  }
5005
- if (contract === 'cli_recurring_multibot' && crossBotReplies.length > 0) {
5006
- effectiveUserPrompt = [
5007
- '[Cross-Bot Context]',
5008
- ...crossBotReplies.map((line) => `- ${line}`),
5009
- '',
5010
- 'Current user request:',
5011
- input.userPrompt,
5012
- ].join('\n');
5013
- }
5014
7588
  if (useCompletionSentinel) {
5015
7589
  effectiveUserPrompt = [
5016
7590
  effectiveUserPrompt,
@@ -5027,70 +7601,17 @@ function buildLocalDesktopDirectPrompt(input) {
5027
7601
  function buildLocalDesktopDirectPromptForTest(input) {
5028
7602
  return buildLocalDesktopDirectPrompt(input);
5029
7603
  }
5030
- function writeCliBootstrapHistoryFile(input) {
5031
- const historyDir = path.join(input.projectPath, 'history');
5032
- const filename = `${input.conversationId}--${input.botId}.txt`;
5033
- const historyFilePath = path.join(historyDir, filename);
5034
- const historyContent = buildCliBootstrapHistoryContent(input.conversationId, input.topicId);
5035
- try {
5036
- fs.mkdirSync(historyDir, { recursive: true });
5037
- cleanupStaleCliHistoryFiles(historyDir);
5038
- fs.writeFileSync(historyFilePath, historyContent, 'utf8');
5039
- return historyFilePath;
5040
- }
5041
- catch (err) {
5042
- console.warn(chalk_1.default.yellow(` [cli-history] Failed to write bootstrap history file: ${err instanceof Error ? err.message : String(err)}`));
5043
- return null;
5044
- }
7604
+ function resolveLocalDesktopPromptContextForTest(input) {
7605
+ return resolveLocalDesktopPromptContext(input);
5045
7606
  }
5046
- function buildCliBootstrapHistoryContent(conversationId, topicId) {
5047
- const lines = [];
5048
- const previousConversationId = topicId ? data.getPreviousConversationInTopic(conversationId)?.id || null : null;
5049
- lines.push('[Bootstrap History]');
5050
- if (!topicId) {
5051
- lines.push('No prior topic history is available for this conversation.');
5052
- return lines.join('\n').trim();
5053
- }
5054
- if (!previousConversationId) {
5055
- lines.push('No prior topic history is available for this conversation.');
5056
- return lines.join('\n').trim();
5057
- }
5058
- const rollingSummary = (0, context_window_1.getLatestRollingSummary)(previousConversationId);
5059
- const recentTurns = (0, context_window_1.getRecentTurns)(previousConversationId, 5);
5060
- if (rollingSummary?.summary_text?.trim()) {
5061
- lines.push('', '[Running Summary]', rollingSummary.summary_text.trim());
5062
- }
5063
- if (recentTurns.length > 0) {
5064
- lines.push('', '[Last 5 Turns]', (0, context_window_1.formatTurnsForPrompt)(recentTurns));
5065
- }
5066
- else {
5067
- lines.push('', '[Last 5 Turns]', '(no recent topic turns)');
5068
- }
5069
- return lines.join('\n').trim();
7607
+ function shouldSkipFreshCliBootstrapForTest(input) {
7608
+ return shouldSkipFreshCliBootstrap(input);
5070
7609
  }
5071
- function cleanupStaleCliHistoryFiles(historyDir) {
5072
- const maxAgeMs = 14 * 24 * 60 * 60 * 1000;
5073
- const now = Date.now();
5074
- try {
5075
- const files = fs.readdirSync(historyDir);
5076
- for (const file of files) {
5077
- if (!file.toLowerCase().endsWith('.txt'))
5078
- continue;
5079
- const fullPath = path.join(historyDir, file);
5080
- try {
5081
- const stat = fs.statSync(fullPath);
5082
- if (now - stat.mtimeMs > maxAgeMs) {
5083
- fs.unlinkSync(fullPath);
5084
- }
5085
- }
5086
- catch {
5087
- // best effort cleanup only
5088
- }
5089
- }
5090
- }
5091
- catch {
5092
- // best effort cleanup only
5093
- }
7610
+ function buildLocalRuntimeUserFacingErrorForTest(err) {
7611
+ return buildLocalRuntimeUserFacingError(err);
7612
+ }
7613
+ function persistCliOauthSessionForTest(input) {
7614
+ return persistCliOauthSession(input.providerId, input.accessToken, input.refreshToken, input.expiresAt);
5094
7615
  }
5095
7616
  function buildLocalDesktopToolManifest(availableTools, contract) {
5096
7617
  const selectedTools = contract === 'api_or_fresh_cli'
@@ -5101,32 +7622,6 @@ function buildLocalDesktopToolManifest(availableTools, contract) {
5101
7622
  .join('\n')
5102
7623
  .trim();
5103
7624
  }
5104
- function getLatestOtherBotReplies(conversationId, currentBotId, currentBotName) {
5105
- const turns = (0, context_window_1.getPromptContextWindow)(conversationId, safeguards_1.SAFEGUARDS.NO_SUMMARY_CONTEXT_WINDOW_TURNS).turns;
5106
- const seen = new Set();
5107
- const replies = [];
5108
- for (let turnIndex = turns.length - 1; turnIndex >= 0; turnIndex -= 1) {
5109
- const turn = turns[turnIndex];
5110
- for (let responseIndex = turn.responses.length - 1; responseIndex >= 0; responseIndex -= 1) {
5111
- const response = turn.responses[responseIndex];
5112
- if (response.role !== 'assistant')
5113
- continue;
5114
- const identity = String(response.bot_id || response.agent_name || '').trim();
5115
- if (!identity)
5116
- continue;
5117
- const sameBot = (response.bot_id && response.bot_id === currentBotId)
5118
- || (response.agent_name && response.agent_name === currentBotName);
5119
- if (sameBot || seen.has(identity))
5120
- continue;
5121
- const content = String(response.content || '').trim();
5122
- if (!content)
5123
- continue;
5124
- seen.add(identity);
5125
- replies.unshift(`${response.agent_name || 'Assistant'} (${response.created_at}): ${content}`);
5126
- }
5127
- }
5128
- return replies;
5129
- }
5130
7625
  function classifyOrchestratorProgress(status) {
5131
7626
  const trimmed = String(status || '').trim();
5132
7627
  if (!trimmed)
@@ -5168,8 +7663,45 @@ function resolveCliNameForProvider(providerName) {
5168
7663
  return 'codex';
5169
7664
  return null;
5170
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
+ }
5171
7678
  function isInteractiveAuthFailure(text) {
5172
- 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;
5173
7705
  }
5174
7706
  function detectInteractiveAuthFailure(text, activeProviderName, configuredProviderName) {
5175
7707
  if (!isInteractiveAuthFailure(text))
@@ -5187,19 +7719,60 @@ function detectInteractiveAuthFailure(text, activeProviderName, configuredProvid
5187
7719
  };
5188
7720
  }
5189
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
+ }
5190
7737
  function isClaudeFreshSessionStartupFailure(err) {
5191
7738
  return err?.code === 'CLAUDE_FRESH_SESSION_STARTUP_TIMEOUT'
5192
7739
  || err?.name === 'ClaudeFreshSessionStartupTimeoutError'
5193
7740
  || /fresh session startup timed out/i.test(String(err?.message || err || ''));
5194
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
+ }
5195
7768
  function shouldRetrySelectedLocalRuntime(err) {
5196
7769
  const text = String(err?.message || err || '').toLowerCase();
5197
7770
  if (!text)
5198
7771
  return false;
5199
- 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)) {
5200
7773
  return false;
5201
7774
  }
5202
- 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);
5203
7776
  }
5204
7777
  async function pauseLocalRuntimeRetry(attempt) {
5205
7778
  const delayMs = attempt <= 1 ? 750 : 1500;
@@ -5285,6 +7858,12 @@ async function autoTitleConversation(convId, userMsg, assistantMsg, providerName
5285
7858
  });
5286
7859
  if (resp.content) {
5287
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
+ });
5288
7867
  }
5289
7868
  }
5290
7869
  catch { /* best-effort */ }