funolio-agent 1.0.7 → 1.0.48

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 (239) hide show
  1. package/dist/agent-config.d.ts +9 -1
  2. package/dist/agent-config.d.ts.map +1 -1
  3. package/dist/agent-config.js +4 -1
  4. package/dist/agent-config.js.map +1 -1
  5. package/dist/approval.d.ts +1 -0
  6. package/dist/approval.d.ts.map +1 -1
  7. package/dist/approval.js +12 -0
  8. package/dist/approval.js.map +1 -1
  9. package/dist/auth/auto-detect.d.ts +11 -3
  10. package/dist/auth/auto-detect.d.ts.map +1 -1
  11. package/dist/auth/auto-detect.js +136 -168
  12. package/dist/auth/auto-detect.js.map +1 -1
  13. package/dist/auth/subscription-runtime.js +1 -1
  14. package/dist/auth/subscription-runtime.js.map +1 -1
  15. package/dist/auto-organizer.d.ts.map +1 -1
  16. package/dist/auto-organizer.js +4 -3
  17. package/dist/auto-organizer.js.map +1 -1
  18. package/dist/backfill.d.ts.map +1 -1
  19. package/dist/backfill.js +34 -30
  20. package/dist/backfill.js.map +1 -1
  21. package/dist/bot-manager.d.ts +4 -8
  22. package/dist/bot-manager.d.ts.map +1 -1
  23. package/dist/bot-manager.js +31 -160
  24. package/dist/bot-manager.js.map +1 -1
  25. package/dist/clerk-model.d.ts +15 -7
  26. package/dist/clerk-model.d.ts.map +1 -1
  27. package/dist/clerk-model.js +78 -43
  28. package/dist/clerk-model.js.map +1 -1
  29. package/dist/cli-session-epoch.d.ts +10 -0
  30. package/dist/cli-session-epoch.d.ts.map +1 -0
  31. package/dist/cli-session-epoch.js +61 -0
  32. package/dist/cli-session-epoch.js.map +1 -0
  33. package/dist/cli.js +7 -2
  34. package/dist/cli.js.map +1 -1
  35. package/dist/commands/import-history.js +5 -1
  36. package/dist/commands/import-history.js.map +1 -1
  37. package/dist/commands/init.d.ts.map +1 -1
  38. package/dist/commands/init.js +30 -1
  39. package/dist/commands/init.js.map +1 -1
  40. package/dist/commands/pool.js +1 -1
  41. package/dist/commands/pool.js.map +1 -1
  42. package/dist/commands/setup.d.ts +37 -0
  43. package/dist/commands/setup.d.ts.map +1 -1
  44. package/dist/commands/setup.js +146 -43
  45. package/dist/commands/setup.js.map +1 -1
  46. package/dist/commands/start.d.ts.map +1 -1
  47. package/dist/commands/start.js +117 -255
  48. package/dist/commands/start.js.map +1 -1
  49. package/dist/config-cleanup.d.ts.map +1 -1
  50. package/dist/config-cleanup.js +2 -1
  51. package/dist/config-cleanup.js.map +1 -1
  52. package/dist/config.d.ts +6 -9
  53. package/dist/config.d.ts.map +1 -1
  54. package/dist/config.js +7 -18
  55. package/dist/config.js.map +1 -1
  56. package/dist/context-window.d.ts +33 -5
  57. package/dist/context-window.d.ts.map +1 -1
  58. package/dist/context-window.js +122 -21
  59. package/dist/context-window.js.map +1 -1
  60. package/dist/eval/orchestrator-front-door-replay.js +1 -1
  61. package/dist/eval/orchestrator-front-door-replay.js.map +1 -1
  62. package/dist/eval/policy-detection-replay.js +1 -1
  63. package/dist/eval/policy-detection-replay.js.map +1 -1
  64. package/dist/import-parser-core.d.ts.map +1 -1
  65. package/dist/import-parser-core.js +74 -8
  66. package/dist/import-parser-core.js.map +1 -1
  67. package/dist/integration-tokens.d.ts +1 -6
  68. package/dist/integration-tokens.d.ts.map +1 -1
  69. package/dist/integration-tokens.js +38 -40
  70. package/dist/integration-tokens.js.map +1 -1
  71. package/dist/local-cli-pty-manager.d.ts +50 -0
  72. package/dist/local-cli-pty-manager.d.ts.map +1 -0
  73. package/dist/local-cli-pty-manager.js +645 -0
  74. package/dist/local-cli-pty-manager.js.map +1 -0
  75. package/dist/local-data.d.ts +89 -6
  76. package/dist/local-data.d.ts.map +1 -1
  77. package/dist/local-data.js +600 -63
  78. package/dist/local-data.js.map +1 -1
  79. package/dist/local-db.d.ts.map +1 -1
  80. package/dist/local-db.js +74 -1
  81. package/dist/local-db.js.map +1 -1
  82. package/dist/local-funnel.d.ts +0 -7
  83. package/dist/local-funnel.d.ts.map +1 -1
  84. package/dist/local-funnel.js +22 -30
  85. package/dist/local-funnel.js.map +1 -1
  86. package/dist/local-import-worker.d.ts.map +1 -1
  87. package/dist/local-import-worker.js +49 -4
  88. package/dist/local-import-worker.js.map +1 -1
  89. package/dist/local-memory-search.d.ts +1 -0
  90. package/dist/local-memory-search.d.ts.map +1 -1
  91. package/dist/local-memory-search.js +107 -21
  92. package/dist/local-memory-search.js.map +1 -1
  93. package/dist/local-server.d.ts +21 -0
  94. package/dist/local-server.d.ts.map +1 -1
  95. package/dist/local-server.js +1057 -501
  96. package/dist/local-server.js.map +1 -1
  97. package/dist/mcp/bridge-server.d.ts.map +1 -1
  98. package/dist/mcp/bridge-server.js +2 -1
  99. package/dist/mcp/bridge-server.js.map +1 -1
  100. package/dist/mcp/local-memory-server.d.ts +6 -1
  101. package/dist/mcp/local-memory-server.d.ts.map +1 -1
  102. package/dist/mcp/local-memory-server.js +38 -13
  103. package/dist/mcp/local-memory-server.js.map +1 -1
  104. package/dist/mcp/manager.d.ts +3 -22
  105. package/dist/mcp/manager.d.ts.map +1 -1
  106. package/dist/mcp/manager.js +66 -320
  107. package/dist/mcp/manager.js.map +1 -1
  108. package/dist/memory-extraction.d.ts +2 -0
  109. package/dist/memory-extraction.d.ts.map +1 -1
  110. package/dist/memory-extraction.js +3 -1
  111. package/dist/memory-extraction.js.map +1 -1
  112. package/dist/message-loop.d.ts +1 -3
  113. package/dist/message-loop.d.ts.map +1 -1
  114. package/dist/message-loop.js +220 -437
  115. package/dist/message-loop.js.map +1 -1
  116. package/dist/mqtt-client.d.ts +2 -28
  117. package/dist/mqtt-client.d.ts.map +1 -1
  118. package/dist/mqtt-client.js +2 -2
  119. package/dist/mqtt-client.js.map +1 -1
  120. package/dist/oauth.d.ts +6 -0
  121. package/dist/oauth.d.ts.map +1 -1
  122. package/dist/oauth.js +91 -0
  123. package/dist/oauth.js.map +1 -1
  124. package/dist/orchestration/front-door-policy.d.ts +5 -2
  125. package/dist/orchestration/front-door-policy.d.ts.map +1 -1
  126. package/dist/orchestration/front-door-policy.js +25 -28
  127. package/dist/orchestration/front-door-policy.js.map +1 -1
  128. package/dist/orchestration/orchestrator-blocked-prompt.d.ts +2 -1
  129. package/dist/orchestration/orchestrator-blocked-prompt.d.ts.map +1 -1
  130. package/dist/orchestration/orchestrator-blocked-prompt.js +12 -1
  131. package/dist/orchestration/orchestrator-blocked-prompt.js.map +1 -1
  132. package/dist/orchestration/orchestrator-final-response-prompt.d.ts +4 -1
  133. package/dist/orchestration/orchestrator-final-response-prompt.d.ts.map +1 -1
  134. package/dist/orchestration/orchestrator-final-response-prompt.js +9 -7
  135. package/dist/orchestration/orchestrator-final-response-prompt.js.map +1 -1
  136. package/dist/orchestration/orchestrator-operating-prompt.d.ts +11 -0
  137. package/dist/orchestration/orchestrator-operating-prompt.d.ts.map +1 -1
  138. package/dist/orchestration/orchestrator-operating-prompt.js +67 -44
  139. package/dist/orchestration/orchestrator-operating-prompt.js.map +1 -1
  140. package/dist/orchestration/worker-operating-prompt.d.ts +2 -0
  141. package/dist/orchestration/worker-operating-prompt.d.ts.map +1 -1
  142. package/dist/orchestration/worker-operating-prompt.js +41 -2
  143. package/dist/orchestration/worker-operating-prompt.js.map +1 -1
  144. package/dist/orchestrator.d.ts +17 -0
  145. package/dist/orchestrator.d.ts.map +1 -1
  146. package/dist/orchestrator.js +328 -166
  147. package/dist/orchestrator.js.map +1 -1
  148. package/dist/prompt-template.js +3 -3
  149. package/dist/prompt-template.js.map +1 -1
  150. package/dist/providers/anthropic.d.ts +0 -5
  151. package/dist/providers/anthropic.d.ts.map +1 -1
  152. package/dist/providers/anthropic.js +29 -75
  153. package/dist/providers/anthropic.js.map +1 -1
  154. package/dist/providers/claude-cli-prompt.d.ts.map +1 -1
  155. package/dist/providers/claude-cli-prompt.js +22 -6
  156. package/dist/providers/claude-cli-prompt.js.map +1 -1
  157. package/dist/providers/claude-cli.d.ts.map +1 -1
  158. package/dist/providers/claude-cli.js +36 -142
  159. package/dist/providers/claude-cli.js.map +1 -1
  160. package/dist/providers/codex-cli.d.ts.map +1 -1
  161. package/dist/providers/codex-cli.js +148 -74
  162. package/dist/providers/codex-cli.js.map +1 -1
  163. package/dist/providers/google.d.ts.map +1 -1
  164. package/dist/providers/google.js +4 -2
  165. package/dist/providers/google.js.map +1 -1
  166. package/dist/providers/index.d.ts +13 -0
  167. package/dist/providers/index.d.ts.map +1 -1
  168. package/dist/providers/index.js.map +1 -1
  169. package/dist/providers/openai.d.ts.map +1 -1
  170. package/dist/providers/openai.js +27 -2
  171. package/dist/providers/openai.js.map +1 -1
  172. package/dist/runtime-context.d.ts +10 -0
  173. package/dist/runtime-context.d.ts.map +1 -0
  174. package/dist/runtime-context.js +30 -0
  175. package/dist/runtime-context.js.map +1 -0
  176. package/dist/storage-mode.d.ts +5 -0
  177. package/dist/storage-mode.d.ts.map +1 -0
  178. package/dist/storage-mode.js +21 -0
  179. package/dist/storage-mode.js.map +1 -0
  180. package/dist/subagent/queue.d.ts.map +1 -1
  181. package/dist/subagent/queue.js +1 -0
  182. package/dist/subagent/queue.js.map +1 -1
  183. package/dist/summarization-pipeline.d.ts +10 -0
  184. package/dist/summarization-pipeline.d.ts.map +1 -1
  185. package/dist/summarization-pipeline.js +147 -34
  186. package/dist/summarization-pipeline.js.map +1 -1
  187. package/dist/tool-permissions.d.ts +2 -0
  188. package/dist/tool-permissions.d.ts.map +1 -0
  189. package/dist/tool-permissions.js +25 -0
  190. package/dist/tool-permissions.js.map +1 -0
  191. package/dist/tools/analyze-image.js +2 -2
  192. package/dist/tools/analyze-image.js.map +1 -1
  193. package/dist/tools/edit-file.js +3 -3
  194. package/dist/tools/edit-file.js.map +1 -1
  195. package/dist/tools/index.d.ts +7 -8
  196. package/dist/tools/index.d.ts.map +1 -1
  197. package/dist/tools/index.js +106 -60
  198. package/dist/tools/index.js.map +1 -1
  199. package/dist/tools/list-directory.js +7 -4
  200. package/dist/tools/list-directory.js.map +1 -1
  201. package/dist/tools/read-file.js +3 -3
  202. package/dist/tools/read-file.js.map +1 -1
  203. package/dist/tools/run-command.js +3 -3
  204. package/dist/tools/run-command.js.map +1 -1
  205. package/dist/tools/sandbox.d.ts +10 -5
  206. package/dist/tools/sandbox.d.ts.map +1 -1
  207. package/dist/tools/sandbox.js +41 -13
  208. package/dist/tools/sandbox.js.map +1 -1
  209. package/dist/tools/search-codebase.js +2 -2
  210. package/dist/tools/search-codebase.js.map +1 -1
  211. package/dist/tools/search-local-memory.d.ts.map +1 -1
  212. package/dist/tools/search-local-memory.js +19 -8
  213. package/dist/tools/search-local-memory.js.map +1 -1
  214. package/dist/tools/search-memory.d.ts.map +1 -1
  215. package/dist/tools/search-memory.js +9 -3
  216. package/dist/tools/search-memory.js.map +1 -1
  217. package/dist/tools/spawn-subagent.d.ts.map +1 -1
  218. package/dist/tools/spawn-subagent.js +1 -0
  219. package/dist/tools/spawn-subagent.js.map +1 -1
  220. package/dist/tools/write-file.js +3 -3
  221. package/dist/tools/write-file.js.map +1 -1
  222. package/dist/types.d.ts +5 -0
  223. package/dist/types.d.ts.map +1 -1
  224. package/dist/types.js +0 -3
  225. package/dist/types.js.map +1 -1
  226. package/dist/verification/index.js +2 -2
  227. package/dist/verification/index.js.map +1 -1
  228. package/dist/wizard-state.d.ts.map +1 -1
  229. package/dist/wizard-state.js +16 -2
  230. package/dist/wizard-state.js.map +1 -1
  231. package/dist/wizard-support.d.ts +2 -2
  232. package/dist/wizard-support.d.ts.map +1 -1
  233. package/dist/wizard-support.js +88 -99
  234. package/dist/wizard-support.js.map +1 -1
  235. package/dist/workflow-engine.d.ts +9 -3
  236. package/dist/workflow-engine.d.ts.map +1 -1
  237. package/dist/workflow-engine.js +378 -82
  238. package/dist/workflow-engine.js.map +1 -1
  239. package/package.json +2 -1
@@ -38,6 +38,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
39
  exports.startLocalServer = startLocalServer;
40
40
  exports.stopLocalServer = stopLocalServer;
41
+ exports.buildChatRuntimeForTest = buildChatRuntimeForTest;
41
42
  /**
42
43
  * Local HTTP server for desktop-first operation.
43
44
  *
@@ -50,7 +51,6 @@ const index_2 = require("./index");
50
51
  const approval_1 = require("./approval");
51
52
  const config_1 = require("./config");
52
53
  const data = __importStar(require("./local-data"));
53
- const local_funnel_1 = require("./local-funnel");
54
54
  const local_import_worker_1 = require("./local-import-worker");
55
55
  const clerk_model_1 = require("./clerk-model");
56
56
  const workflow_engine_1 = require("./workflow-engine");
@@ -72,9 +72,13 @@ const marketplace_1 = require("./mcp/marketplace");
72
72
  const claude_config_writer_1 = require("./mcp/claude-config-writer");
73
73
  const subscription_runtime_1 = require("./auth/subscription-runtime");
74
74
  const local_memory_search_1 = require("./local-memory-search");
75
+ const local_funnel_1 = require("./local-funnel");
75
76
  const orchestrator_profile_1 = require("./orchestrator-profile");
76
77
  const policy_detection_1 = require("./policy-detection");
77
78
  const server_runtime_1 = require("./server-runtime");
79
+ const storage_mode_1 = require("./storage-mode");
80
+ const local_cli_pty_manager_1 = require("./local-cli-pty-manager");
81
+ const cli_session_epoch_1 = require("./cli-session-epoch");
78
82
  const server_adapter_1 = require("./server-adapter");
79
83
  const wizard_support_1 = require("./wizard-support");
80
84
  const chalk_1 = __importDefault(require("chalk"));
@@ -96,6 +100,9 @@ function startLocalServer(opts) {
96
100
  const express = requireExpress();
97
101
  const app = express();
98
102
  const port = opts.port ?? LOCAL_PORT;
103
+ if ((0, storage_mode_1.isLocalStorageMode)()) {
104
+ data.purgeLegacyExtractionDataOnce();
105
+ }
99
106
  const cliNormalization = data.normalizeCliProviderConnections();
100
107
  if (cliNormalization.updatedIds.length > 0) {
101
108
  console.info(`[local-server] normalized ${cliNormalization.updatedIds.length} CLI provider connection(s): ${cliNormalization.updatedIds.join(', ')}`);
@@ -183,6 +190,15 @@ function startLocalServer(opts) {
183
190
  catch (err) {
184
191
  console.error(chalk_1.default.yellow(` Failed to reconcile conversation/topic project consistency: ${err}`));
185
192
  }
193
+ try {
194
+ const interrupted = data.markRunningChatJobsInterrupted();
195
+ if (interrupted > 0) {
196
+ console.log(chalk_1.default.gray(` Marked ${interrupted} interrupted chat job(s) as failed`));
197
+ }
198
+ }
199
+ catch (err) {
200
+ console.error(chalk_1.default.yellow(` Failed to recover interrupted chat jobs: ${err}`));
201
+ }
186
202
  // ─── Health ──────────────────────────────────────────────────
187
203
  app.get('/api/health', (_req, res) => {
188
204
  try {
@@ -533,6 +549,181 @@ function startLocalServer(opts) {
533
549
  function isConnectedMode() {
534
550
  return (0, server_runtime_1.getRuntimeConnectionConfig)().connectionMode === 'server';
535
551
  }
552
+ function localTimestamp() {
553
+ return new Date().toISOString().replace('T', ' ').replace('Z', '');
554
+ }
555
+ const MAX_LOCAL_CHAT_JOBS = 3;
556
+ const runningChatJobControllers = new Map();
557
+ const finalizeCancelledChatJobMessage = (job) => {
558
+ const existing = data.getMessage(job.assistant_message_id);
559
+ const existingContent = (existing?.content || '').trim();
560
+ data.updateMessage(job.assistant_message_id, {
561
+ content: existingContent ? `${existingContent}\n\n[Stopped by user]` : '[Stopped by user]',
562
+ botId: job.bot_id,
563
+ });
564
+ data.touchConversationActivity(job.conversation_id);
565
+ };
566
+ const parseSsePayloads = async (response, handlers) => {
567
+ const reader = response.body?.getReader();
568
+ if (!reader)
569
+ return;
570
+ const decoder = new TextDecoder();
571
+ let buffer = '';
572
+ let currentEvent = '';
573
+ let receivedDone = false;
574
+ while (true) {
575
+ const { done, value } = await reader.read();
576
+ if (done)
577
+ break;
578
+ buffer += decoder.decode(value, { stream: true });
579
+ const lines = buffer.split('\n');
580
+ buffer = lines.pop() || '';
581
+ for (const line of lines) {
582
+ if (line.startsWith('event: ')) {
583
+ currentEvent = line.slice(7);
584
+ }
585
+ else if (line.startsWith('data: ') && currentEvent) {
586
+ let dataPayload = null;
587
+ try {
588
+ dataPayload = JSON.parse(line.slice(6));
589
+ }
590
+ catch {
591
+ currentEvent = '';
592
+ continue;
593
+ }
594
+ if (currentEvent === 'done') {
595
+ receivedDone = true;
596
+ await handlers.onDone?.(dataPayload);
597
+ }
598
+ else if (currentEvent === 'error') {
599
+ receivedDone = true;
600
+ await handlers.onError?.(dataPayload);
601
+ }
602
+ currentEvent = '';
603
+ }
604
+ }
605
+ }
606
+ if (!receivedDone) {
607
+ await handlers.onDone?.({});
608
+ }
609
+ };
610
+ const runQueuedChatJobs = async () => {
611
+ if (isConnectedMode())
612
+ return;
613
+ while (runningChatJobControllers.size < MAX_LOCAL_CHAT_JOBS) {
614
+ const next = data.listQueuedChatJobs(1)[0];
615
+ if (!next)
616
+ return;
617
+ if (runningChatJobControllers.has(next.id))
618
+ return;
619
+ const controller = new AbortController();
620
+ runningChatJobControllers.set(next.id, controller);
621
+ data.updateChatJob(next.id, {
622
+ status: 'running',
623
+ startedAt: localTimestamp(),
624
+ error: null,
625
+ });
626
+ void (async () => {
627
+ try {
628
+ const job = data.getChatJob(next.id);
629
+ if (!job)
630
+ return;
631
+ const userMessage = data.getMessage(job.user_message_id);
632
+ const requestPayload = (() => {
633
+ try {
634
+ return job.request_json ? JSON.parse(job.request_json) : {};
635
+ }
636
+ catch {
637
+ return {};
638
+ }
639
+ })();
640
+ const response = await fetch(`http://127.0.0.1:${port}/api/chat`, {
641
+ method: 'POST',
642
+ headers: { 'Content-Type': 'application/json' },
643
+ signal: controller.signal,
644
+ body: JSON.stringify({
645
+ conversationId: job.conversation_id,
646
+ message: userMessage?.content || '',
647
+ botId: job.bot_id,
648
+ skipUserMessage: true,
649
+ pinnedMessageIds: Array.isArray(requestPayload?.pinnedMessageIds) ? requestPayload.pinnedMessageIds : undefined,
650
+ topicId: requestPayload?.topicId || undefined,
651
+ projectId: requestPayload?.projectId || undefined,
652
+ orchestrationEnabled: requestPayload?.orchestrationEnabled !== false,
653
+ chatJobId: job.id,
654
+ assistantMessageId: job.assistant_message_id,
655
+ }),
656
+ });
657
+ if (!response.ok) {
658
+ const body = await response.json().catch(() => ({ error: `HTTP ${response.status}` }));
659
+ throw new Error(body.error || `HTTP ${response.status}`);
660
+ }
661
+ await parseSsePayloads(response, {
662
+ onDone: async () => {
663
+ const latest = data.getChatJob(next.id);
664
+ if (!latest || latest.status === 'cancelled')
665
+ return;
666
+ data.updateChatJob(next.id, {
667
+ status: 'completed',
668
+ completedAt: localTimestamp(),
669
+ error: null,
670
+ });
671
+ data.touchConversationActivity(next.conversation_id);
672
+ },
673
+ onError: async (payload) => {
674
+ const latest = data.getChatJob(next.id);
675
+ if (!latest || latest.status === 'cancelled')
676
+ return;
677
+ const errorText = String(payload?.error || 'Background chat failed');
678
+ const existing = data.getMessage(next.assistant_message_id);
679
+ const existingContent = (existing?.content || '').trim();
680
+ data.updateMessage(next.assistant_message_id, {
681
+ content: existingContent ? `${existingContent}\n\n**Error:** ${errorText}` : `**Error:** ${errorText}`,
682
+ botId: next.bot_id,
683
+ });
684
+ data.updateChatJob(next.id, {
685
+ status: 'failed',
686
+ error: errorText,
687
+ completedAt: localTimestamp(),
688
+ });
689
+ data.touchConversationActivity(next.conversation_id);
690
+ },
691
+ });
692
+ }
693
+ catch (err) {
694
+ const latest = data.getChatJob(next.id);
695
+ if (!latest)
696
+ return;
697
+ if (latest.status === 'cancelled' || err?.name === 'AbortError') {
698
+ finalizeCancelledChatJobMessage(latest);
699
+ data.updateChatJob(next.id, {
700
+ status: 'cancelled',
701
+ cancelledAt: latest.cancelled_at || localTimestamp(),
702
+ completedAt: latest.completed_at || localTimestamp(),
703
+ });
704
+ return;
705
+ }
706
+ const errorText = err?.message || 'Background chat failed';
707
+ const existing = data.getMessage(next.assistant_message_id);
708
+ const existingContent = (existing?.content || '').trim();
709
+ data.updateMessage(next.assistant_message_id, {
710
+ content: existingContent ? `${existingContent}\n\n**Error:** ${errorText}` : `**Error:** ${errorText}`,
711
+ botId: next.bot_id,
712
+ });
713
+ data.updateChatJob(next.id, {
714
+ status: 'failed',
715
+ error: errorText,
716
+ completedAt: localTimestamp(),
717
+ });
718
+ data.touchConversationActivity(next.conversation_id);
719
+ }
720
+ finally {
721
+ runningChatJobControllers.delete(next.id);
722
+ void runQueuedChatJobs();
723
+ }
724
+ })();
725
+ }
726
+ };
536
727
  async function relayConnectedChat(req, res) {
537
728
  if (!isConnectedMode())
538
729
  return false;
@@ -678,6 +869,18 @@ function startLocalServer(opts) {
678
869
  });
679
870
  return state.global.auth;
680
871
  }
872
+ async function exchangeDesktopAuthCode(code, redirectUri) {
873
+ const response = await fetch(`${config_1.FUNOLIO_API_URL}/api/v1/agent/auth/token`, {
874
+ method: 'POST',
875
+ headers: { 'Content-Type': 'application/json' },
876
+ body: JSON.stringify({ code, redirectUri }),
877
+ });
878
+ const body = await response.json().catch(() => ({}));
879
+ if (!response.ok) {
880
+ throw new Error(body.error || 'Code exchange failed');
881
+ }
882
+ return persistDesktopAuth(body);
883
+ }
681
884
  app.post('/api/auth/clear-session', (_req, res) => {
682
885
  try {
683
886
  const current = (0, wizard_state_1.loadWizardState)();
@@ -718,8 +921,8 @@ function startLocalServer(opts) {
718
921
  app.post('/api/auth/google/start', (_req, res) => {
719
922
  try {
720
923
  const state = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`;
721
- const redirectUri = 'funolio://wizard?step=login';
722
- const authUrl = `${config_1.FUNOLIO_API_URL}/auth/agent?state=${encodeURIComponent(state)}&redirect_uri=${encodeURIComponent(redirectUri)}`;
924
+ const redirectUri = `http://127.0.0.1:${port}/auth/complete`;
925
+ const authUrl = `${config_1.FUNOLIO_API_URL}/auth/agent-google?state=${encodeURIComponent(state)}&redirect_uri=${encodeURIComponent(redirectUri)}`;
723
926
  res.json({ authUrl, state, redirectUri });
724
927
  }
725
928
  catch (err) {
@@ -732,21 +935,55 @@ function startLocalServer(opts) {
732
935
  if (!code) {
733
936
  return res.status(400).json({ error: 'Code is required' });
734
937
  }
735
- const response = await fetch(`${config_1.FUNOLIO_API_URL}/api/v1/agent/auth/token`, {
736
- method: 'POST',
737
- headers: { 'Content-Type': 'application/json' },
738
- body: JSON.stringify({ code, redirectUri: 'funolio://wizard?step=login' }),
739
- });
740
- const body = await response.json().catch(() => ({}));
741
- if (!response.ok) {
742
- return res.status(response.status).json({ error: body.error || 'Code exchange failed' });
743
- }
744
- res.json(await persistDesktopAuth(body));
938
+ res.json(await exchangeDesktopAuthCode(code, 'funolio://wizard?step=login'));
745
939
  }
746
940
  catch (err) {
747
941
  res.status(500).json({ error: err.message || 'Code exchange failed' });
748
942
  }
749
943
  });
944
+ app.get('/auth/complete', async (req, res) => {
945
+ const code = typeof req.query?.code === 'string' ? req.query.code.trim() : '';
946
+ const state = typeof req.query?.state === 'string' ? req.query.state.trim() : '';
947
+ const escapeHtml = (value) => value
948
+ .replace(/&/g, '&amp;')
949
+ .replace(/</g, '&lt;')
950
+ .replace(/>/g, '&gt;')
951
+ .replace(/"/g, '&quot;')
952
+ .replace(/'/g, '&#39;');
953
+ if (!code) {
954
+ return res.status(400).send(`<!doctype html>
955
+ <html><head><meta charset="utf-8" /><title>Funolio Login</title></head>
956
+ <body style="font-family:system-ui,sans-serif;background:#0b1020;color:#f8fafc;display:flex;min-height:100vh;align-items:center;justify-content:center;padding:32px;">
957
+ <div style="max-width:480px;background:#111827;border:1px solid rgba(148,163,184,.2);border-radius:16px;padding:28px;text-align:center;">
958
+ <h1 style="margin:0 0 12px;font-size:24px;">Missing login code</h1>
959
+ <p style="margin:0;color:#cbd5e1;line-height:1.6;">Funolio did not receive an auth code from the website. Close this tab and try Google sign-in again.</p>
960
+ </div>
961
+ </body></html>`);
962
+ }
963
+ try {
964
+ await exchangeDesktopAuthCode(code, `http://127.0.0.1:${port}/auth/complete`);
965
+ return res.send(`<!doctype html>
966
+ <html><head><meta charset="utf-8" /><title>Funolio Login Complete</title></head>
967
+ <body style="font-family:system-ui,sans-serif;background:#0b1020;color:#f8fafc;display:flex;min-height:100vh;align-items:center;justify-content:center;padding:32px;">
968
+ <div style="max-width:480px;background:#111827;border:1px solid rgba(148,163,184,.2);border-radius:16px;padding:28px;text-align:center;">
969
+ <h1 style="margin:0 0 12px;font-size:24px;">Funolio login complete</h1>
970
+ <p style="margin:0 0 16px;color:#cbd5e1;line-height:1.6;">Your desktop session is now authenticated${state ? ` for request <code style="color:#a78bfa;">${escapeHtml(state)}</code>` : ''}. Return to the Funolio app.</p>
971
+ <p style="margin:0;font-size:13px;color:#94a3b8;">You can close this browser tab.</p>
972
+ </div>
973
+ </body></html>`);
974
+ }
975
+ catch (err) {
976
+ return res.status(500).send(`<!doctype html>
977
+ <html><head><meta charset="utf-8" /><title>Funolio Login Failed</title></head>
978
+ <body style="font-family:system-ui,sans-serif;background:#0b1020;color:#f8fafc;display:flex;min-height:100vh;align-items:center;justify-content:center;padding:32px;">
979
+ <div style="max-width:560px;background:#111827;border:1px solid rgba(239,68,68,.28);border-radius:16px;padding:28px;text-align:center;">
980
+ <h1 style="margin:0 0 12px;font-size:24px;color:#f87171;">Funolio login failed</h1>
981
+ <p style="margin:0 0 16px;color:#fecaca;line-height:1.6;">${escapeHtml(err?.message || 'Code exchange failed')}</p>
982
+ <p style="margin:0;font-size:13px;color:#94a3b8;">Close this tab and try again.</p>
983
+ </div>
984
+ </body></html>`);
985
+ }
986
+ });
750
987
  app.post('/api/auth/forgot-password', async (req, res) => {
751
988
  try {
752
989
  const email = typeof req.body?.email === 'string' ? req.body.email.trim().toLowerCase() : '';
@@ -771,6 +1008,22 @@ function startLocalServer(opts) {
771
1008
  res.status(500).json({ error: err.message || 'Failed to send reset email' });
772
1009
  }
773
1010
  });
1011
+ app.use(async (req, res, next) => {
1012
+ const publicPrefixes = ['/api/health', '/api/runtime/', '/api/auth/', '/api/wizard/'];
1013
+ if (publicPrefixes.some((prefix) => req.path === prefix || req.path.startsWith(prefix))) {
1014
+ return next();
1015
+ }
1016
+ try {
1017
+ const auth = await getHydratedDesktopAuth();
1018
+ if (!auth?.hasSession || !auth?.token) {
1019
+ return res.status(401).json({ error: 'Authentication required' });
1020
+ }
1021
+ return next();
1022
+ }
1023
+ catch (err) {
1024
+ return res.status(500).json({ error: err.message || 'Authentication check failed' });
1025
+ }
1026
+ });
774
1027
  app.get('/api/providers/catalog', (_req, res) => {
775
1028
  try {
776
1029
  res.json({ providers: (0, wizard_support_1.getProviderCatalog)() });
@@ -1113,13 +1366,25 @@ function startLocalServer(opts) {
1113
1366
  const runtime = (0, server_runtime_1.getRuntimeConnectionConfig)();
1114
1367
  const auth = await getHydratedDesktopAuth();
1115
1368
  const profile = await (0, server_adapter_1.getServerBot)(auth, runtime, req.params.id);
1116
- return res.json({ profile, conversationCount: 0 });
1369
+ return res.json({ profile, conversationCount: 0, messageCount: 0, recentConversations: [] });
1117
1370
  }
1118
1371
  const profile = data.getAgentProfile(req.params.id);
1119
1372
  if (!profile)
1120
1373
  return res.status(404).json({ error: 'Not found' });
1121
1374
  const convCount = data.countConversations(req.params.id);
1122
- res.json({ profile, conversationCount: convCount });
1375
+ const messageCount = data.countMessagesForBot(req.params.id);
1376
+ const recentConversations = data.listBotConversationActivity(req.params.id, 8).map((conversation) => ({
1377
+ id: conversation.id,
1378
+ agent_id: conversation.agent_id,
1379
+ title: conversation.title,
1380
+ updated_at: conversation.updated_at,
1381
+ created_at: conversation.created_at,
1382
+ message_count: conversation.message_count,
1383
+ bot_message_count: conversation.bot_message_count,
1384
+ bot_last_message_at: conversation.bot_last_message_at,
1385
+ project_name: conversation.project_name,
1386
+ }));
1387
+ res.json({ profile, conversationCount: convCount, messageCount, recentConversations });
1123
1388
  }
1124
1389
  catch (err) {
1125
1390
  res.status(500).json({ error: err.message });
@@ -1565,7 +1830,7 @@ function startLocalServer(opts) {
1565
1830
  if (msgs.length < 3)
1566
1831
  return res.json({ suggestion: null, reason: 'Not enough messages' });
1567
1832
  const conv = data.getConversation(conversationId);
1568
- const clerk = (0, clerk_model_1.getClerk)();
1833
+ const clerk = (0, clerk_model_1.getClerk)({ runtimeMode: 'local_desktop' });
1569
1834
  if (!clerk) {
1570
1835
  // Fallback: use first few words of first user message
1571
1836
  const firstUser = msgs.find(m => m.role === 'user');
@@ -1774,6 +2039,28 @@ function startLocalServer(opts) {
1774
2039
  res.status(500).json({ error: err.message });
1775
2040
  }
1776
2041
  });
2042
+ app.patch('/api/messages/:id', (req, res) => {
2043
+ try {
2044
+ if (isConnectedMode()) {
2045
+ return res.status(501).json({ error: 'Message updates are local-mode only' });
2046
+ }
2047
+ const updated = data.updateMessage(req.params.id, {
2048
+ content: req.body?.content,
2049
+ model: req.body?.model,
2050
+ botId: req.body?.botId,
2051
+ agentName: req.body?.agentName,
2052
+ resultArtifact: req.body?.resultArtifact,
2053
+ resultSummary: req.body?.resultSummary,
2054
+ resultStatus: req.body?.resultStatus,
2055
+ });
2056
+ if (!updated)
2057
+ return res.status(404).json({ error: 'Message not found' });
2058
+ res.json(updated);
2059
+ }
2060
+ catch (err) {
2061
+ res.status(500).json({ error: err.message });
2062
+ }
2063
+ });
1777
2064
  app.get('/api/conversations/:id/orchestration-audit', (req, res) => {
1778
2065
  try {
1779
2066
  if (isConnectedMode()) {
@@ -2064,7 +2351,13 @@ function startLocalServer(opts) {
2064
2351
  const limit = parseInt(req.query.limit, 10) || 25;
2065
2352
  const beforeSeq = req.query.beforeSeq ? parseInt(req.query.beforeSeq, 10) : 0;
2066
2353
  const rounds = req.query.rounds ? parseInt(req.query.rounds, 10) : 0;
2354
+ const startSeq = req.query.startSeq ? parseInt(req.query.startSeq, 10) : 0;
2355
+ const endSeq = req.query.endSeq ? parseInt(req.query.endSeq, 10) : 0;
2356
+ const hasDirectRange = startSeq > 0 && endSeq >= startSeq;
2067
2357
  if (isConnectedMode()) {
2358
+ if (hasDirectRange) {
2359
+ return res.status(400).json({ error: 'Direct message range fetch is only available in local storage mode' });
2360
+ }
2068
2361
  const runtime = (0, server_runtime_1.getRuntimeConnectionConfig)();
2069
2362
  const auth = await getHydratedDesktopAuth();
2070
2363
  const result = await (0, server_adapter_1.listServerConversationMessages)(auth, runtime, req.params.id, {
@@ -2073,6 +2366,9 @@ function startLocalServer(opts) {
2073
2366
  });
2074
2367
  return res.json(result.messages);
2075
2368
  }
2369
+ if (hasDirectRange) {
2370
+ return res.json(data.getMessagesInRange(req.params.id, startSeq, endSeq));
2371
+ }
2076
2372
  if (beforeSeq > 0) {
2077
2373
  // Backward paging: get N rounds or messages before given seq, returned in ASC order
2078
2374
  const msgs = rounds > 0
@@ -2141,11 +2437,161 @@ function startLocalServer(opts) {
2141
2437
  res.status(500).json({ error: err.message });
2142
2438
  }
2143
2439
  });
2440
+ app.post('/api/chat/jobs', async (req, res) => {
2441
+ try {
2442
+ if (isConnectedMode()) {
2443
+ return res.status(501).json({ error: 'Background chat jobs are local-mode only' });
2444
+ }
2445
+ const { conversationId, message, botId, pinnedMessageIds, topicId, projectId, orchestrationEnabled, } = req.body || {};
2446
+ if (!message || !String(message).trim()) {
2447
+ return res.status(400).json({ error: 'message is required' });
2448
+ }
2449
+ let profile = botId ? data.getAgentProfile(String(botId)) : data.getDefaultAgentProfile();
2450
+ if (!profile) {
2451
+ return res.status(400).json({ error: 'No bot configured. Create one first.' });
2452
+ }
2453
+ const shouldUseOrchestratorMode = orchestrationEnabled !== false && (0, orchestrator_profile_1.isOrchestratorProfile)(profile);
2454
+ if (shouldUseOrchestratorMode) {
2455
+ return res.status(400).json({ error: 'Background chat jobs do not support orchestrator mode yet.' });
2456
+ }
2457
+ let convId = conversationId ? String(conversationId) : '';
2458
+ if (convId) {
2459
+ const latestJob = data.getLatestConversationChatJob(convId);
2460
+ if (latestJob && (latestJob.status === 'queued' || latestJob.status === 'running')) {
2461
+ return res.status(409).json({ error: 'This conversation already has a pending response.' });
2462
+ }
2463
+ }
2464
+ if (!convId) {
2465
+ let topicProjectId = null;
2466
+ if (topicId) {
2467
+ const topic = data.getTopic(String(topicId));
2468
+ topicProjectId = topic?.project_id ?? null;
2469
+ }
2470
+ const requestedProjectId = (projectId ? String(projectId) : null) || topicProjectId;
2471
+ const conv = data.createConversation(profile.id, '', 'local', {
2472
+ projectId: requestedProjectId,
2473
+ });
2474
+ convId = conv.id;
2475
+ }
2476
+ if (topicId && convId) {
2477
+ try {
2478
+ data.upsertConversationTopicSegment(convId, String(topicId));
2479
+ }
2480
+ catch { /* best effort */ }
2481
+ }
2482
+ if (!topicId && projectId && convId) {
2483
+ const selectedProject = data.getProject(String(projectId));
2484
+ if (selectedProject) {
2485
+ data.updateConversation(convId, {
2486
+ projectId: selectedProject.id,
2487
+ projectName: selectedProject.name,
2488
+ });
2489
+ }
2490
+ }
2491
+ const savedUserMessage = data.addMessage(convId, 'user', String(message));
2492
+ (0, context_window_1.incrementTurnCount)(convId);
2493
+ const assistantMessage = data.addMessage(convId, 'assistant', '', profile.model || undefined, undefined, profile.id, profile.name);
2494
+ const conv = data.getConversation(convId);
2495
+ if (conv && !conv.title?.trim() && (conv.turn_count || 0) <= 1) {
2496
+ const shortTitle = String(message).slice(0, 60).replace(/\n/g, ' ').trim();
2497
+ data.updateConversation(convId, { title: shortTitle || 'New Chat' });
2498
+ }
2499
+ const job = data.createChatJob({
2500
+ conversationId: convId,
2501
+ userMessageId: savedUserMessage.id,
2502
+ assistantMessageId: assistantMessage.id,
2503
+ botId: profile.id,
2504
+ status: 'queued',
2505
+ requestJson: JSON.stringify({
2506
+ pinnedMessageIds: Array.isArray(pinnedMessageIds) ? pinnedMessageIds : [],
2507
+ topicId: topicId ? String(topicId) : null,
2508
+ projectId: projectId ? String(projectId) : null,
2509
+ orchestrationEnabled: orchestrationEnabled !== false,
2510
+ }),
2511
+ });
2512
+ void runQueuedChatJobs();
2513
+ res.status(201).json({
2514
+ ok: true,
2515
+ conversationId: convId,
2516
+ userMessageId: savedUserMessage.id,
2517
+ assistantMessageId: assistantMessage.id,
2518
+ jobId: job.id,
2519
+ status: job.status,
2520
+ });
2521
+ }
2522
+ catch (err) {
2523
+ res.status(500).json({ error: err.message });
2524
+ }
2525
+ });
2526
+ app.post('/api/chat/jobs/:id/cancel', async (req, res) => {
2527
+ try {
2528
+ const job = data.getChatJob(req.params.id);
2529
+ if (!job)
2530
+ return res.status(404).json({ error: 'Not found' });
2531
+ if (job.status === 'completed' || job.status === 'failed' || job.status === 'cancelled') {
2532
+ return res.json({ ok: true, status: job.status });
2533
+ }
2534
+ const ts = localTimestamp();
2535
+ data.updateChatJob(job.id, {
2536
+ status: 'cancelled',
2537
+ cancelledAt: ts,
2538
+ completedAt: ts,
2539
+ });
2540
+ const controller = runningChatJobControllers.get(job.id);
2541
+ if (controller) {
2542
+ controller.abort();
2543
+ }
2544
+ else {
2545
+ finalizeCancelledChatJobMessage(job);
2546
+ data.touchConversationActivity(job.conversation_id);
2547
+ }
2548
+ res.json({ ok: true, status: 'cancelled' });
2549
+ }
2550
+ catch (err) {
2551
+ res.status(500).json({ error: err.message });
2552
+ }
2553
+ });
2144
2554
  // ─── Chat (SSE streaming) ──────────────────────────────────
2555
+ app.post('/api/conversations/:id/chat-job/cancel', async (req, res) => {
2556
+ try {
2557
+ const latestJob = data.getLatestConversationChatJob(req.params.id);
2558
+ if (!latestJob)
2559
+ return res.status(404).json({ error: 'Chat job not found' });
2560
+ if (latestJob.status === 'completed' || latestJob.status === 'failed' || latestJob.status === 'cancelled') {
2561
+ return res.json({ ok: true, status: latestJob.status, jobId: latestJob.id });
2562
+ }
2563
+ const ts = localTimestamp();
2564
+ data.updateChatJob(latestJob.id, {
2565
+ status: 'cancelled',
2566
+ cancelledAt: ts,
2567
+ completedAt: ts,
2568
+ });
2569
+ const controller = runningChatJobControllers.get(latestJob.id);
2570
+ if (controller) {
2571
+ controller.abort();
2572
+ }
2573
+ else {
2574
+ finalizeCancelledChatJobMessage(latestJob);
2575
+ data.touchConversationActivity(latestJob.conversation_id);
2576
+ void runQueuedChatJobs();
2577
+ }
2578
+ res.json({ ok: true, status: 'cancelled', jobId: latestJob.id });
2579
+ }
2580
+ catch (err) {
2581
+ res.status(500).json({ error: err.message });
2582
+ }
2583
+ });
2145
2584
  app.post('/api/chat', async (req, res) => {
2146
2585
  const activityErrorContext = {};
2586
+ const routeAbortController = new AbortController();
2587
+ let responseEnded = false;
2588
+ const abortOnClientClose = () => {
2589
+ responseEnded = true;
2590
+ routeAbortController.abort();
2591
+ };
2592
+ req.on('close', abortOnClientClose);
2147
2593
  try {
2148
- const { conversationId, message, botId, skipUserMessage, pinnedMessageIds, topicId, projectId, workflowTemplateId } = req.body;
2594
+ let { conversationId, message, botId, skipUserMessage, pinnedMessageIds, topicId, projectId, workflowTemplateId, orchestrationEnabled, chatJobId, assistantMessageId, persistAssistantPlaceholder, } = req.body;
2149
2595
  if (!message)
2150
2596
  return res.status(400).json({ error: 'message is required' });
2151
2597
  if (await relayConnectedChat(req, res)) {
@@ -2203,6 +2649,7 @@ function startLocalServer(opts) {
2203
2649
  const activityExpiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().replace('T', ' ').replace('Z', '');
2204
2650
  activityErrorContext.conversationId = convId;
2205
2651
  activityErrorContext.streamId = activityStreamId;
2652
+ activityErrorContext.messageId = assistantMessageId ? String(assistantMessageId) : null;
2206
2653
  activityErrorContext.botId = profile?.id ?? null;
2207
2654
  activityErrorContext.agentName = profile?.name ?? null;
2208
2655
  activityErrorContext.expiresAt = activityExpiresAt;
@@ -2210,6 +2657,7 @@ function startLocalServer(opts) {
2210
2657
  try {
2211
2658
  data.createMessageActivity({
2212
2659
  conversationId: convId,
2660
+ messageId: assistantMessageId ? String(assistantMessageId) : null,
2213
2661
  streamId: activityStreamId,
2214
2662
  botId: profile?.id ?? null,
2215
2663
  agentName: profile?.name ?? null,
@@ -2223,6 +2671,36 @@ function startLocalServer(opts) {
2223
2671
  // Best effort only
2224
2672
  }
2225
2673
  };
2674
+ const workerBotByName = new Map();
2675
+ const resolveWorkerBotId = (agentName) => {
2676
+ const key = String(agentName || '').trim().toLowerCase();
2677
+ if (!key)
2678
+ return null;
2679
+ if (workerBotByName.has(key))
2680
+ return workerBotByName.get(key) || null;
2681
+ const match = data.listAgentProfiles().find((agent) => agent.name.trim().toLowerCase() === key);
2682
+ const resolved = match?.id || null;
2683
+ workerBotByName.set(key, resolved || '');
2684
+ return resolved;
2685
+ };
2686
+ const recordWorkerActivity = (activityType, event, payload, summary) => {
2687
+ try {
2688
+ data.createMessageActivity({
2689
+ conversationId: convId,
2690
+ messageId: assistantMessageId ? String(assistantMessageId) : null,
2691
+ streamId: activityStreamId,
2692
+ botId: resolveWorkerBotId(event.agentName) || null,
2693
+ agentName: event.agentName || null,
2694
+ activityType,
2695
+ summary: summary || null,
2696
+ payload,
2697
+ expiresAt: activityExpiresAt,
2698
+ });
2699
+ }
2700
+ catch {
2701
+ // Best effort only
2702
+ }
2703
+ };
2226
2704
  // Save user message (skip if multi-bot call where first bot already saved it)
2227
2705
  let savedUserMessage = null;
2228
2706
  if (!skipUserMessage) {
@@ -2232,7 +2710,7 @@ function startLocalServer(opts) {
2232
2710
  const effectiveProjectId = projectId ? String(projectId) : (convForPolicy?.project_id || undefined);
2233
2711
  const projectForPolicy = effectiveProjectId ? data.getProject(effectiveProjectId) : undefined;
2234
2712
  const currentPolicy = data.getEffectiveOrchestrationPolicy(effectiveProjectId);
2235
- const clerkForPolicy = (0, clerk_model_1.getClerk)();
2713
+ const clerkForPolicy = (0, clerk_model_1.getClerk)({ runtimeMode: 'local_desktop' });
2236
2714
  const agentNames = data.listAgentProfiles().map((agent) => agent.name);
2237
2715
  if (clerkForPolicy && savedUserMessage) {
2238
2716
  void (0, policy_detection_1.stagePolicyDetectionForMessage)({
@@ -2251,9 +2729,14 @@ function startLocalServer(opts) {
2251
2729
  });
2252
2730
  }
2253
2731
  }
2732
+ if (!assistantMessageId && persistAssistantPlaceholder === true) {
2733
+ const placeholder = data.addMessage(convId, 'assistant', '', buildConfiguredMessageModel(profile), undefined, profile?.id || undefined, profile?.name || undefined);
2734
+ assistantMessageId = placeholder.id;
2735
+ }
2254
2736
  // ─── Orchestrator Mode Branch ─────────────────────────
2255
- if ((0, orchestrator_profile_1.isOrchestratorProfile)(profile)) {
2256
- const clerk = (0, clerk_model_1.getClerk)();
2737
+ const shouldUseOrchestratorMode = orchestrationEnabled !== false && (0, orchestrator_profile_1.isOrchestratorProfile)(profile);
2738
+ if (shouldUseOrchestratorMode) {
2739
+ const clerk = (0, clerk_model_1.getClerk)({ runtimeMode: 'local_desktop' });
2257
2740
  if (!clerk) {
2258
2741
  // Fix #2: Do not silently fall through to direct chat — return a clear error
2259
2742
  return res.status(400).json({
@@ -2262,7 +2745,7 @@ function startLocalServer(opts) {
2262
2745
  }
2263
2746
  const { OrchestratorAgent } = require('./orchestrator');
2264
2747
  const { getWorkflowEngine } = require('./workflow-engine');
2265
- const workflowEngine = getWorkflowEngine(opts.projectDir);
2748
+ const workflowEngine = getWorkflowEngine(opts.projectDir, 'local_desktop');
2266
2749
  const orchestrator = new OrchestratorAgent(clerk, workflowEngine);
2267
2750
  // Resolve effective project ID from request or existing conversation
2268
2751
  const conv = data.getConversation(convId);
@@ -2280,6 +2763,8 @@ function startLocalServer(opts) {
2280
2763
  'X-Conversation-Id': convId,
2281
2764
  });
2282
2765
  const sendEvent = (event, payload) => {
2766
+ if (responseEnded)
2767
+ return;
2283
2768
  res.write(`event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`);
2284
2769
  };
2285
2770
  let orchestratorRuntimeLabel = '';
@@ -2290,7 +2775,7 @@ function startLocalServer(opts) {
2290
2775
  orchestratorRuntime.model || profile.model || '',
2291
2776
  runtimeModeLabel(orchestratorRuntime.runtimeMode, orchestratorRuntime.runtimeSource),
2292
2777
  ].filter(Boolean).join(' | ');
2293
- orchestratorRuntimePayload = runtimePayloadForDisplay(profile.provider, orchestratorRuntime.model || profile.model || null, orchestratorRuntime.runtimeMode, orchestratorRuntime.runtimeSource || null, false);
2778
+ orchestratorRuntimePayload = runtimePayloadForDisplay(profile.provider, orchestratorRuntime.model || profile.model || null, orchestratorRuntime.runtimeMode, orchestratorRuntime.runtimeSource || null);
2294
2779
  }
2295
2780
  catch {
2296
2781
  orchestratorRuntimeLabel = buildConfiguredMessageModel(profile);
@@ -2302,6 +2787,7 @@ function startLocalServer(opts) {
2302
2787
  let lastProgressChat = '';
2303
2788
  let lastProgressActivity = '';
2304
2789
  let selfExecuteStreamed = false;
2790
+ let hasWorkerActivity = false;
2305
2791
  try {
2306
2792
  let lastInterimMessage = '';
2307
2793
  const response = await orchestrator.handleUserMessage(message, convId, {
@@ -2311,21 +2797,102 @@ function startLocalServer(opts) {
2311
2797
  workflowTemplateId: workflowTemplateId || undefined,
2312
2798
  onWorkerChunk: (event) => {
2313
2799
  if (event.type === 'step_start') {
2800
+ hasWorkerActivity = true;
2801
+ recordWorkerActivity('worker_step_start', event, {
2802
+ stepId: event.stepId,
2803
+ agentName: event.agentName,
2804
+ description: event.description,
2805
+ stepIndex: event.stepIndex,
2806
+ totalSteps: event.totalSteps,
2807
+ botId: resolveWorkerBotId(event.agentName),
2808
+ });
2314
2809
  sendEvent('worker_step_start', {
2315
2810
  stepId: event.stepId,
2811
+ botId: resolveWorkerBotId(event.agentName),
2316
2812
  agentName: event.agentName,
2317
2813
  description: event.description,
2318
2814
  stepIndex: event.stepIndex,
2319
2815
  totalSteps: event.totalSteps,
2320
2816
  });
2321
2817
  }
2818
+ else if (event.type === 'worker_chunk') {
2819
+ hasWorkerActivity = true;
2820
+ recordWorkerActivity('worker_chunk', event, {
2821
+ stepId: event.stepId,
2822
+ agentName: event.agentName,
2823
+ description: event.description,
2824
+ stepIndex: event.stepIndex,
2825
+ totalSteps: event.totalSteps,
2826
+ text: event.text,
2827
+ botId: resolveWorkerBotId(event.agentName),
2828
+ });
2829
+ sendEvent('worker_chunk', {
2830
+ stepId: event.stepId,
2831
+ botId: resolveWorkerBotId(event.agentName),
2832
+ agentName: event.agentName,
2833
+ description: event.description,
2834
+ stepIndex: event.stepIndex,
2835
+ totalSteps: event.totalSteps,
2836
+ text: event.text,
2837
+ });
2838
+ }
2839
+ else if (event.type === 'worker_tool_call') {
2840
+ hasWorkerActivity = true;
2841
+ recordWorkerActivity('worker_tool_call', event, {
2842
+ stepId: event.stepId,
2843
+ agentName: event.agentName,
2844
+ description: event.description,
2845
+ stepIndex: event.stepIndex,
2846
+ totalSteps: event.totalSteps,
2847
+ toolCallId: event.toolCallId,
2848
+ toolName: event.toolName,
2849
+ toolArguments: event.toolArguments,
2850
+ botId: resolveWorkerBotId(event.agentName),
2851
+ });
2852
+ sendEvent('worker_tool_call', {
2853
+ stepId: event.stepId,
2854
+ botId: resolveWorkerBotId(event.agentName),
2855
+ agentName: event.agentName,
2856
+ description: event.description,
2857
+ stepIndex: event.stepIndex,
2858
+ totalSteps: event.totalSteps,
2859
+ toolCallId: event.toolCallId,
2860
+ toolName: event.toolName,
2861
+ toolArguments: event.toolArguments,
2862
+ });
2863
+ }
2864
+ else if (event.type === 'worker_tool_result') {
2865
+ hasWorkerActivity = true;
2866
+ recordWorkerActivity('worker_tool_result', event, {
2867
+ stepId: event.stepId,
2868
+ agentName: event.agentName,
2869
+ description: event.description,
2870
+ stepIndex: event.stepIndex,
2871
+ totalSteps: event.totalSteps,
2872
+ toolCallId: event.toolCallId,
2873
+ toolName: event.toolName,
2874
+ toolOutput: event.toolOutput,
2875
+ toolIsError: event.toolIsError,
2876
+ botId: resolveWorkerBotId(event.agentName),
2877
+ });
2878
+ sendEvent('worker_tool_result', {
2879
+ stepId: event.stepId,
2880
+ botId: resolveWorkerBotId(event.agentName),
2881
+ agentName: event.agentName,
2882
+ description: event.description,
2883
+ stepIndex: event.stepIndex,
2884
+ totalSteps: event.totalSteps,
2885
+ toolCallId: event.toolCallId,
2886
+ toolName: event.toolName,
2887
+ toolOutput: event.toolOutput,
2888
+ toolIsError: event.toolIsError,
2889
+ });
2890
+ }
2322
2891
  else if (event.type === 'chunk') {
2323
- // Stream all text directly to main bubble — no worker cards
2324
2892
  selfExecuteStreamed = true;
2325
2893
  sendEvent('chunk', { text: event.text });
2326
2894
  }
2327
2895
  else if (event.type === 'tool_call') {
2328
- // Show tool activity inline in main bubble
2329
2896
  selfExecuteStreamed = true;
2330
2897
  sendEvent('chunk', { text: `\n> Running ${event.toolName}...\n` });
2331
2898
  }
@@ -2334,7 +2901,27 @@ function startLocalServer(opts) {
2334
2901
  sendEvent('chunk', { text: `> [${icon}] ${event.toolName} completed\n` });
2335
2902
  }
2336
2903
  else if (event.type === 'step_done') {
2337
- // Step lifecycle — no special handling needed
2904
+ hasWorkerActivity = true;
2905
+ recordWorkerActivity('worker_step_done', event, {
2906
+ stepId: event.stepId,
2907
+ agentName: event.agentName,
2908
+ description: event.description,
2909
+ stepIndex: event.stepIndex,
2910
+ totalSteps: event.totalSteps,
2911
+ status: event.status,
2912
+ summary: event.summary,
2913
+ botId: resolveWorkerBotId(event.agentName),
2914
+ });
2915
+ sendEvent('worker_step_done', {
2916
+ stepId: event.stepId,
2917
+ botId: resolveWorkerBotId(event.agentName),
2918
+ agentName: event.agentName,
2919
+ description: event.description,
2920
+ stepIndex: event.stepIndex,
2921
+ totalSteps: event.totalSteps,
2922
+ status: event.status,
2923
+ summary: event.summary,
2924
+ });
2338
2925
  }
2339
2926
  },
2340
2927
  onProgress: (status) => {
@@ -2358,7 +2945,7 @@ function startLocalServer(opts) {
2358
2945
  sendEvent('orchestrator_message', {
2359
2946
  text: interimMessage,
2360
2947
  botId: profile.id,
2361
- agentName: profile.name || 'Project Manager',
2948
+ agentName: 'Orchestrator',
2362
2949
  });
2363
2950
  }
2364
2951
  },
@@ -2369,40 +2956,73 @@ function startLocalServer(opts) {
2369
2956
  },
2370
2957
  });
2371
2958
  // Save O's response (no incrementTurnCount — Fix #1: user message already incremented it)
2372
- const savedMessage = data.addMessage(convId, 'assistant', response, orchestratorRuntimeLabel || undefined, undefined, profile.id, profile.name || 'Project Manager');
2373
- data.attachMessageActivitiesToMessage(activityStreamId, savedMessage.id);
2374
- data.createMessageActivity({
2375
- conversationId: convId,
2376
- messageId: savedMessage.id,
2377
- botId: profile.id,
2378
- agentName: profile.name || 'Project Manager',
2379
- activityType: 'message',
2380
- summary: 'Final orchestrator response',
2381
- payload: { content: response },
2382
- expiresAt: activityExpiresAt,
2383
- });
2959
+ const responseMeta = orchestrator.getLastResponseMeta();
2960
+ const finalAgentName = responseMeta?.agentName || 'Orchestrator';
2961
+ const finalBotId = responseMeta?.botId || (finalAgentName === 'Orchestrator' ? profile.id : undefined);
2962
+ const finalModelLabel = responseMeta?.modelLabel || orchestratorRuntimeLabel || undefined;
2963
+ const splitFinalMessage = hasWorkerActivity && finalAgentName === 'Clerk';
2964
+ if (splitFinalMessage) {
2965
+ const orchestratorMessage = data.addMessage(convId, 'assistant', '', orchestratorRuntimeLabel || buildConfiguredMessageModel(profile), undefined, profile.id, 'Orchestrator');
2966
+ data.attachMessageActivitiesToMessage(activityStreamId, orchestratorMessage.id);
2967
+ const clerkMessage = data.addMessage(convId, 'assistant', response, finalModelLabel, undefined, finalBotId, finalAgentName);
2968
+ data.createMessageActivity({
2969
+ conversationId: convId,
2970
+ messageId: clerkMessage.id,
2971
+ botId: finalBotId,
2972
+ agentName: finalAgentName,
2973
+ activityType: 'message',
2974
+ summary: 'Final assistant response',
2975
+ payload: { content: response },
2976
+ expiresAt: activityExpiresAt,
2977
+ });
2978
+ }
2979
+ else {
2980
+ const savedMessage = data.addMessage(convId, 'assistant', response, finalModelLabel, undefined, finalBotId, finalAgentName);
2981
+ data.attachMessageActivitiesToMessage(activityStreamId, savedMessage.id);
2982
+ data.createMessageActivity({
2983
+ conversationId: convId,
2984
+ messageId: savedMessage.id,
2985
+ botId: finalBotId,
2986
+ agentName: finalAgentName,
2987
+ activityType: 'message',
2988
+ summary: 'Final orchestrator response',
2989
+ payload: { content: response },
2990
+ expiresAt: activityExpiresAt,
2991
+ });
2992
+ }
2384
2993
  // Emit chunk + done events using the same SSE contract as normal chat
2385
2994
  // Skip the final bulk chunk if we already streamed via worker_chunk (execute_self)
2386
- if (!selfExecuteStreamed) {
2995
+ if (!selfExecuteStreamed && !splitFinalMessage) {
2387
2996
  sendEvent('chunk', { text: response });
2388
2997
  }
2389
2998
  sendEvent('done', {
2390
2999
  conversationId: convId,
2391
3000
  content: response,
2392
- ...(orchestratorRuntimePayload ? { runtime: orchestratorRuntimePayload } : {}),
3001
+ agentName: finalAgentName,
3002
+ botId: finalBotId,
3003
+ separateFinalMessage: splitFinalMessage,
3004
+ ...((responseMeta?.modelLabel || orchestratorRuntimePayload)
3005
+ ? {
3006
+ runtime: {
3007
+ ...(orchestratorRuntimePayload || {}),
3008
+ ...(responseMeta?.modelLabel ? { model: responseMeta.modelLabel } : {}),
3009
+ },
3010
+ }
3011
+ : {}),
2393
3012
  tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0, isApproximate: true, source: 'heuristic' },
2394
3013
  });
3014
+ responseEnded = true;
2395
3015
  res.end();
2396
- // Background post-response processing
2397
- (0, local_funnel_1.scheduleFunnelProcessing)(convId);
2398
3016
  if ((0, context_window_1.shouldSummarize)(convId)) {
2399
3017
  const catalog = (0, backfill_1.buildProjectTopicCatalog)();
2400
3018
  (0, summarization_pipeline_1.runSummarization)(convId, catalog).catch(err => console.error(chalk_1.default.yellow(` [summarization] ${err.message}`)));
2401
3019
  }
3020
+ (0, local_funnel_1.scheduleFunnelProcessing)(convId);
2402
3021
  }
2403
3022
  catch (orchErr) {
2404
3023
  recordActivity('error', { error: orchErr.message }, orchErr.message);
2405
3024
  sendEvent('error', { type: 'error', error: orchErr.message });
3025
+ responseEnded = true;
2406
3026
  res.end();
2407
3027
  }
2408
3028
  return;
@@ -2414,15 +3034,18 @@ function startLocalServer(opts) {
2414
3034
  const effectiveTimezone = configuredTz && configuredTz.toLowerCase() !== 'system'
2415
3035
  ? configuredTz
2416
3036
  : Intl.DateTimeFormat().resolvedOptions().timeZone;
2417
- const allToolDefs = (0, index_2.getAllToolDefinitions)(mcpManager);
3037
+ const unrestrictedCliProfile = index_1.CLI_PROVIDERS.has(profile.provider);
3038
+ const allToolDefs = (0, index_2.getAllToolDefinitions)('local_desktop', mcpManager);
2418
3039
  const configuredBuiltinTools = parseToolSelectionJson(profile.enabled_builtin_tools_json);
2419
3040
  const configuredMcpTools = parseToolSelectionJson(profile.enabled_mcp_tools_json);
2420
- const allowedToolNames = expandAllowedToolNames(allToolDefs, configuredBuiltinTools, configuredMcpTools);
3041
+ const allowedToolNames = unrestrictedCliProfile
3042
+ ? new Set(allToolDefs.map((tool) => tool.name))
3043
+ : expandAllowedToolNames(allToolDefs, configuredBuiltinTools, configuredMcpTools);
2421
3044
  const toolDefs = allToolDefs.filter((tool) => allowedToolNames.has(tool.name));
2422
3045
  // Build system prompt via clerk (token-budgeted context injection)
2423
3046
  let systemPrompt;
2424
3047
  let llmSpawnCwd = opts.projectDir;
2425
- const clerk = (0, clerk_model_1.getClerk)();
3048
+ const clerk = (0, clerk_model_1.getClerk)({ runtimeMode: 'local_desktop' });
2426
3049
  if (clerk) {
2427
3050
  const conv = data.getConversation(convId);
2428
3051
  const topicTitle = topicId ? data.getTopic(topicId)?.title : undefined;
@@ -2443,14 +3066,15 @@ function startLocalServer(opts) {
2443
3066
  availableTools: toolDefs.map((tool) => ({ name: tool.name, description: tool.description })),
2444
3067
  });
2445
3068
  systemPrompt = built.systemPrompt;
2446
- console.log(chalk_1.default.gray(` [clerk] Context: ${built.injectedFacts} facts, ${built.injectedDecisions} decisions, ${built.injectedSummaries} summaries (${built.contextTokensUsed} tokens)`));
3069
+ console.log(chalk_1.default.gray(` [clerk] Context: ${built.injectedSummaries} summaries (${built.contextTokensUsed} tokens)`));
2447
3070
  }
2448
3071
  else {
2449
3072
  // Fallback: manual prompt building
2450
3073
  systemPrompt = '[Bot Identity]\n' + (profile.soul_md
2451
- || 'You are a Funolio AI agent running locally. You have access to project files and can execute code.');
3074
+ || 'You are an AI assistant running locally. You have access to project files and can execute code.');
2452
3075
  systemPrompt += '\n\nDo not end with a deferred promise (for example: "Let me check..."). Return a final answer in this turn, or state exactly what is unavailable.';
2453
3076
  systemPrompt += '\n\nWhen [Project Overview] is present, treat Project/Topic/Workspace values there as authoritative for the current turn and override stale prior-chat claims.';
3077
+ systemPrompt += '\n\n[Response Style]\nWrite in short readable paragraphs. Put a blank line between distinct ideas. Use bullets when listing findings, steps, or issues. Do not return one dense wall of text. For progress updates, keep them compact and clearly separate what you checked, what you found, and what you are doing next.';
2454
3078
  const convForFallback = data.getConversation(convId);
2455
3079
  const projectForFallback = convForFallback?.project_id ? data.getProject(convForFallback.project_id) : undefined;
2456
3080
  const workspaceForFallback = projectForFallback?.folder?.trim();
@@ -2508,8 +3132,11 @@ function startLocalServer(opts) {
2508
3132
  }
2509
3133
  catch { /* best-effort */ }
2510
3134
  }
2511
- // Resolve LLM runtime: for claude-cli/codex-cli, try Subscription-API first
2512
- // and fall back to Subscription-CLI automatically if API fails.
3135
+ // Resolve LLM runtime.
3136
+ // Desktop local mode intentionally supports only:
3137
+ // - Subscription CLI
3138
+ // - API Key
3139
+ // We do not use subscription-token API routing for local CLI bots.
2513
3140
  const runtime = await buildChatRuntime(profile);
2514
3141
  let activeProviderName = runtime.providerName;
2515
3142
  let activeModelName = runtime.model;
@@ -2518,18 +3145,25 @@ function startLocalServer(opts) {
2518
3145
  let activeRuntimeMode = runtime.runtimeMode;
2519
3146
  let activeRuntimeSource = runtime.runtimeSource;
2520
3147
  let activeIsCliProvider = index_1.CLI_PROVIDERS.has(activeProviderName);
2521
- const cliFallback = runtime.cliFallback;
2522
- const apiKeyFallback = runtime.apiKeyFallback;
2523
- let switchedToCliFallback = false;
2524
- let switchedToApiKeyFallback = false;
2525
3148
  const runtimePayload = () => ({
2526
3149
  mode: activeRuntimeMode,
2527
3150
  modeLabel: runtimeModeLabel(activeRuntimeMode, activeRuntimeSource),
2528
3151
  provider: activeProviderName,
2529
3152
  model: activeModelName || null,
2530
3153
  source: activeRuntimeSource || null,
2531
- fallbackUsed: switchedToCliFallback || switchedToApiKeyFallback,
2532
3154
  });
3155
+ const enableCliSessionEpoch = activeIsCliProvider
3156
+ && !shouldUseOrchestratorMode
3157
+ && !workflowTemplateId
3158
+ && !!convId
3159
+ && !!profile?.id;
3160
+ const cliSessionEpochPlan = enableCliSessionEpoch
3161
+ ? (0, cli_session_epoch_1.selectCliSessionEpoch)(convId, profile.id, activeProviderName)
3162
+ : { existing: undefined, resumeSessionId: null, resetReason: null };
3163
+ let activeCliSessionId = cliSessionEpochPlan.resumeSessionId;
3164
+ const cliEpochStartedAt = cliSessionEpochPlan.resumeSessionId
3165
+ ? (cliSessionEpochPlan.existing?.epoch_started_at || localTimestamp())
3166
+ : localTimestamp();
2533
3167
  if (!activeApiKey) {
2534
3168
  return res.status(400).json({ error: `No API key for provider ${profile.provider}. Configure one in Settings.` });
2535
3169
  }
@@ -2537,12 +3171,17 @@ function startLocalServer(opts) {
2537
3171
  projectId: convId ? (data.getConversation(convId)?.project_id ?? null) : null,
2538
3172
  actorType: 'llm',
2539
3173
  actorId: profile?.name || profile?.id || 'LLM',
3174
+ runtimeMode: 'local_desktop',
3175
+ restrictFileAccessToProject: unrestrictedCliProfile ? false : undefined,
3176
+ abortSignal: routeAbortController.signal,
2540
3177
  });
2541
3178
  const toolManifest = toolDefs
2542
3179
  .map(t => `- ${t.name}: ${t.description}`)
2543
3180
  .join('\n');
2544
3181
  if (toolManifest.trim()) {
2545
- systemPrompt += '\n\n[Available Tools]\nOnly the following tools are enabled for this bot in the current runtime:\n' + toolManifest;
3182
+ systemPrompt += unrestrictedCliProfile
3183
+ ? '\n\n[Available Tools]\nThe following tools are available in the current runtime:\n' + toolManifest
3184
+ : '\n\n[Available Tools]\nOnly the following tools are enabled for this bot in the current runtime:\n' + toolManifest;
2546
3185
  }
2547
3186
  // Inject pinned messages as context (user-selected cross-bot references)
2548
3187
  if (pinnedMessageIds && Array.isArray(pinnedMessageIds) && pinnedMessageIds.length > 0) {
@@ -2580,6 +3219,8 @@ function startLocalServer(opts) {
2580
3219
  'X-Conversation-Id': convId,
2581
3220
  });
2582
3221
  const sendEvent = (event, payload) => {
3222
+ if (responseEnded)
3223
+ return;
2583
3224
  res.write(`event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`);
2584
3225
  };
2585
3226
  const approxInputTokens = (0, token_counter_1.estimatePromptInputTokens)({
@@ -2591,6 +3232,7 @@ function startLocalServer(opts) {
2591
3232
  sendEvent('meta', {
2592
3233
  conversationId: convId,
2593
3234
  botId: profile.id,
3235
+ assistantMessageId: assistantMessageId || null,
2594
3236
  runtime: runtimePayload(),
2595
3237
  tokenUsage: {
2596
3238
  approxInputTokens,
@@ -2608,12 +3250,64 @@ function startLocalServer(opts) {
2608
3250
  detail: `Sending request to ${activeProviderName}...`,
2609
3251
  runtime: runtimePayload(),
2610
3252
  }, `Sending request to ${activeProviderName}...`);
3253
+ if (cliSessionEpochPlan.resetReason && enableCliSessionEpoch) {
3254
+ const resetDetail = cliSessionEpochPlan.resetReason === 'turn_limit'
3255
+ ? 'Resetting CLI session after reaching the turn limit.'
3256
+ : cliSessionEpochPlan.resetReason === 'token_limit'
3257
+ ? 'Resetting CLI session after reaching the context budget.'
3258
+ : 'Resetting CLI session because the runtime changed.';
3259
+ sendEvent('status', {
3260
+ phase: 'thinking',
3261
+ detail: resetDetail,
3262
+ runtime: runtimePayload(),
3263
+ });
3264
+ recordActivity('status', {
3265
+ phase: 'thinking',
3266
+ detail: resetDetail,
3267
+ runtime: runtimePayload(),
3268
+ }, resetDetail);
3269
+ }
3270
+ let partialPersistedContent = '';
3271
+ let partialPersistedAt = 0;
3272
+ const throwIfChatJobCancelled = () => {
3273
+ if (!chatJobId)
3274
+ return;
3275
+ const currentJob = data.getChatJob(String(chatJobId));
3276
+ if (currentJob?.status === 'cancelled') {
3277
+ routeAbortController.abort();
3278
+ const abortErr = new Error('aborted');
3279
+ abortErr.name = 'AbortError';
3280
+ throw abortErr;
3281
+ }
3282
+ };
3283
+ const persistAssistantPartial = (force = false) => {
3284
+ if (!assistantMessageId)
3285
+ return;
3286
+ const nowMs = Date.now();
3287
+ const nextContent = streamedContent || '';
3288
+ if (!force && nextContent === partialPersistedContent)
3289
+ return;
3290
+ if (!force && nowMs - partialPersistedAt < 1500)
3291
+ return;
3292
+ partialPersistedContent = nextContent;
3293
+ partialPersistedAt = nowMs;
3294
+ const modelWithRuntime = [
3295
+ activeModelName || profile.model || '',
3296
+ runtimeModeLabel(activeRuntimeMode, activeRuntimeSource),
3297
+ ].filter(Boolean).join(' | ');
3298
+ data.updateMessage(assistantMessageId, {
3299
+ content: nextContent,
3300
+ model: modelWithRuntime || null,
3301
+ botId: profile.id,
3302
+ agentName: profile.name,
3303
+ });
3304
+ };
2611
3305
  // Agentic loop
2612
3306
  let fullContent = '';
2613
3307
  let streamedContent = '';
2614
3308
  let streamedAnyChunk = false;
2615
3309
  let iteration = 0;
2616
- const MAX_ITERATIONS = 20;
3310
+ const MAX_ITERATIONS = 10; // Phase 1d: reduced from 20
2617
3311
  let totalInputTokens = 0;
2618
3312
  let totalOutputTokens = 0;
2619
3313
  let hasExactUsage = false;
@@ -2621,250 +3315,259 @@ function startLocalServer(opts) {
2621
3315
  // Thinking/reasoning accumulator across multi-turn tool loops
2622
3316
  let accumulatedThinking = '';
2623
3317
  const thinkingEnabled = !!profile?.show_thinking;
2624
- while (iteration < MAX_ITERATIONS) {
2625
- iteration++;
2626
- let iterationFirstChunk = true;
2627
- if (iteration > 1) {
2628
- sendEvent('status', { phase: 'thinking', detail: 'Processing tool results...' });
2629
- recordActivity('status', { phase: 'thinking', detail: 'Processing tool results...' }, 'Processing tool results...');
2630
- }
2631
- let response;
2632
- const chatOptions = {
2633
- messages: llmMessages,
2634
- system: systemPrompt,
2635
- stream: true,
2636
- tools: toolDefs,
2637
- cwd: llmSpawnCwd,
2638
- thinkingEnabled,
2639
- onChunk: async (chunk) => {
2640
- if (iterationFirstChunk) {
2641
- iterationFirstChunk = false;
2642
- sendEvent('status', { phase: 'generating' });
2643
- recordActivity('status', { phase: 'generating' }, 'Generating response...');
3318
+ let useInteractiveCliSession = enableCliSessionEpoch;
3319
+ if (useInteractiveCliSession) {
3320
+ const ptyManager = (0, local_cli_pty_manager_1.getLocalCliPtySessionManager)();
3321
+ let ptyAttempt = 0;
3322
+ while (true) {
3323
+ ptyAttempt++;
3324
+ try {
3325
+ const result = await ptyManager.runTurn({
3326
+ conversationId: convId,
3327
+ botId: profile.id,
3328
+ provider: activeProviderName,
3329
+ cwd: llmSpawnCwd,
3330
+ systemPrompt,
3331
+ messages: llmMessages,
3332
+ forceFreshSession: !cliSessionEpochPlan.resumeSessionId,
3333
+ onDetail: async (detail) => {
3334
+ sendEvent('status', {
3335
+ phase: 'thinking',
3336
+ detail,
3337
+ runtime: runtimePayload(),
3338
+ });
3339
+ recordActivity('status', {
3340
+ phase: 'thinking',
3341
+ detail,
3342
+ runtime: runtimePayload(),
3343
+ }, detail);
3344
+ },
3345
+ });
3346
+ if (result.sessionId) {
3347
+ activeCliSessionId = result.sessionId;
2644
3348
  }
2645
- streamedAnyChunk = true;
2646
- streamedContent += chunk;
2647
- sendEvent('chunk', { text: chunk });
2648
- },
2649
- ...(thinkingEnabled ? {
2650
- onThinkingChunk: async (chunk) => {
2651
- sendEvent('thinking_chunk', {
2652
- text: chunk,
2653
- botId: profile?.id || null,
2654
- agentName: profile?.name || null,
2655
- });
2656
- },
2657
- } : {}),
2658
- };
2659
- try {
2660
- response = await activeLlm.chat(chatOptions);
2661
- }
2662
- catch (primaryErr) {
2663
- if (cliFallback && !switchedToCliFallback) {
2664
- switchedToCliFallback = true;
2665
- activeProviderName = cliFallback.providerName;
2666
- activeModelName = cliFallback.model;
2667
- activeApiKey = cliFallback.apiKey;
2668
- activeLlm = cliFallback.llm;
2669
- activeRuntimeMode = cliFallback.runtimeMode;
2670
- activeRuntimeSource = cliFallback.runtimeSource;
2671
- activeIsCliProvider = true;
2672
- const fallbackMsg = activeIsCliProvider
2673
- ? `CLI auth failed (${primaryErr?.message || primaryErr}); switching to fallback...`
2674
- : `Primary provider failed (${primaryErr?.message || primaryErr}); switching to fallback...`;
2675
- console.warn(chalk_1.default.yellow(` [chat] ${fallbackMsg}`));
2676
- if (activeIsCliProvider) {
2677
- console.warn(chalk_1.default.yellow(` [chat] If CLI auth keeps failing, run 'claude' or 'codex' in your terminal to re-authenticate.`));
3349
+ if (result.usage) {
3350
+ totalInputTokens += result.usage.inputTokens || 0;
3351
+ totalOutputTokens += result.usage.outputTokens || 0;
3352
+ hasExactUsage = true;
2678
3353
  }
3354
+ fullContent = (result.content || '').trim();
3355
+ break;
3356
+ }
3357
+ catch (ptyErr) {
3358
+ if (ptyAttempt >= LOCAL_RUNTIME_RETRY_LIMIT || !shouldRetrySelectedLocalRuntime(ptyErr)) {
3359
+ throw ptyErr;
3360
+ }
3361
+ const retryDetail = `Selected runtime failed (${ptyErr?.message || ptyErr}); retrying the same connection (${ptyAttempt + 1}/${LOCAL_RUNTIME_RETRY_LIMIT})...`;
3362
+ console.warn(chalk_1.default.yellow(` [chat] ${retryDetail}`));
2679
3363
  sendEvent('status', {
2680
3364
  phase: 'thinking',
2681
- detail: fallbackMsg,
2682
- runtime: runtimePayload(),
2683
- });
2684
- recordActivity('status', {
2685
- phase: 'thinking',
2686
- detail: fallbackMsg,
2687
- runtime: runtimePayload(),
2688
- }, fallbackMsg);
2689
- sendEvent('status', {
2690
- phase: 'thinking',
2691
- detail: `Sending request to ${activeProviderName}...`,
3365
+ detail: retryDetail,
2692
3366
  runtime: runtimePayload(),
2693
3367
  });
2694
3368
  recordActivity('status', {
2695
3369
  phase: 'thinking',
2696
- detail: `Sending request to ${activeProviderName}...`,
3370
+ detail: retryDetail,
2697
3371
  runtime: runtimePayload(),
2698
- }, `Sending request to ${activeProviderName}...`);
3372
+ }, retryDetail);
3373
+ await pauseLocalRuntimeRetry(ptyAttempt);
3374
+ }
3375
+ }
3376
+ }
3377
+ if (!useInteractiveCliSession)
3378
+ while (iteration < MAX_ITERATIONS) {
3379
+ iteration++;
3380
+ let iterationFirstChunk = true;
3381
+ throwIfChatJobCancelled();
3382
+ if (iteration > 1) {
3383
+ sendEvent('status', { phase: 'thinking', detail: 'Processing tool results...' });
3384
+ recordActivity('status', { phase: 'thinking', detail: 'Processing tool results...' }, 'Processing tool results...');
3385
+ }
3386
+ let response;
3387
+ const chatOptions = {
3388
+ messages: llmMessages,
3389
+ system: systemPrompt,
3390
+ stream: true,
3391
+ tools: toolDefs,
3392
+ cwd: llmSpawnCwd,
3393
+ abortSignal: routeAbortController.signal,
3394
+ resumeSessionId: enableCliSessionEpoch ? activeCliSessionId : null,
3395
+ persistSession: enableCliSessionEpoch,
3396
+ thinkingEnabled,
3397
+ onChunk: async (chunk) => {
3398
+ throwIfChatJobCancelled();
3399
+ if (iterationFirstChunk) {
3400
+ iterationFirstChunk = false;
3401
+ sendEvent('status', { phase: 'generating' });
3402
+ recordActivity('status', { phase: 'generating' }, 'Generating response...');
3403
+ }
3404
+ streamedAnyChunk = true;
3405
+ streamedContent += chunk;
3406
+ persistAssistantPartial(false);
3407
+ sendEvent('chunk', { text: chunk });
3408
+ },
3409
+ ...(thinkingEnabled ? {
3410
+ onThinkingChunk: async (chunk) => {
3411
+ sendEvent('thinking_chunk', {
3412
+ text: chunk,
3413
+ botId: profile?.id || null,
3414
+ agentName: profile?.name || null,
3415
+ });
3416
+ },
3417
+ } : {}),
3418
+ };
3419
+ let chatAttempt = 0;
3420
+ while (true) {
3421
+ chatAttempt++;
2699
3422
  try {
2700
3423
  response = await activeLlm.chat(chatOptions);
3424
+ break;
2701
3425
  }
2702
- catch (cliErr) {
2703
- if (!apiKeyFallback || switchedToApiKeyFallback)
2704
- throw cliErr;
2705
- switchedToApiKeyFallback = true;
2706
- activeProviderName = apiKeyFallback.providerName;
2707
- activeModelName = apiKeyFallback.model;
2708
- activeApiKey = apiKeyFallback.apiKey;
2709
- activeLlm = apiKeyFallback.llm;
2710
- activeRuntimeMode = apiKeyFallback.runtimeMode;
2711
- activeRuntimeSource = apiKeyFallback.runtimeSource;
2712
- activeIsCliProvider = false;
2713
- console.warn(chalk_1.default.yellow(` [chat] CLI fallback failed (${cliErr?.message || cliErr}); switching to API key fallback (${activeProviderName})`));
2714
- sendEvent('status', {
2715
- phase: 'thinking',
2716
- detail: 'CLI fallback unavailable; switching to API key fallback...',
2717
- runtime: runtimePayload(),
2718
- });
2719
- recordActivity('status', {
2720
- phase: 'thinking',
2721
- detail: 'CLI fallback unavailable; switching to API key fallback...',
2722
- runtime: runtimePayload(),
2723
- }, 'CLI fallback unavailable; switching to API key fallback...');
3426
+ catch (primaryErr) {
3427
+ if (routeAbortController.signal.aborted || primaryErr?.name === 'AbortError') {
3428
+ throw primaryErr;
3429
+ }
3430
+ if (chatAttempt >= LOCAL_RUNTIME_RETRY_LIMIT || !shouldRetrySelectedLocalRuntime(primaryErr)) {
3431
+ throw primaryErr;
3432
+ }
3433
+ const retryDetail = `Selected runtime failed (${primaryErr?.message || primaryErr}); retrying the same connection (${chatAttempt + 1}/${LOCAL_RUNTIME_RETRY_LIMIT})...`;
3434
+ console.warn(chalk_1.default.yellow(` [chat] ${retryDetail}`));
2724
3435
  sendEvent('status', {
2725
3436
  phase: 'thinking',
2726
- detail: `Sending request to ${activeProviderName}...`,
3437
+ detail: retryDetail,
2727
3438
  runtime: runtimePayload(),
2728
3439
  });
2729
3440
  recordActivity('status', {
2730
3441
  phase: 'thinking',
2731
- detail: `Sending request to ${activeProviderName}...`,
3442
+ detail: retryDetail,
2732
3443
  runtime: runtimePayload(),
2733
- }, `Sending request to ${activeProviderName}...`);
2734
- response = await activeLlm.chat(chatOptions);
3444
+ }, retryDetail);
3445
+ await pauseLocalRuntimeRetry(chatAttempt);
2735
3446
  }
2736
3447
  }
2737
- else if (apiKeyFallback && !switchedToApiKeyFallback) {
2738
- switchedToApiKeyFallback = true;
2739
- activeProviderName = apiKeyFallback.providerName;
2740
- activeModelName = apiKeyFallback.model;
2741
- activeApiKey = apiKeyFallback.apiKey;
2742
- activeLlm = apiKeyFallback.llm;
2743
- activeRuntimeMode = apiKeyFallback.runtimeMode;
2744
- activeRuntimeSource = apiKeyFallback.runtimeSource;
2745
- activeIsCliProvider = false;
2746
- console.warn(chalk_1.default.yellow(` [chat] Runtime failed (${primaryErr?.message || primaryErr}); switching to API key fallback (${activeProviderName})`));
2747
- sendEvent('status', {
2748
- phase: 'thinking',
2749
- detail: 'Switching to API key fallback...',
2750
- runtime: runtimePayload(),
2751
- });
2752
- recordActivity('status', {
2753
- phase: 'thinking',
2754
- detail: 'Switching to API key fallback...',
2755
- runtime: runtimePayload(),
2756
- }, 'Switching to API key fallback...');
3448
+ if (enableCliSessionEpoch && response?.session?.id) {
3449
+ activeCliSessionId = response.session.id;
3450
+ }
3451
+ throwIfChatJobCancelled();
3452
+ const authFailure = detectInteractiveAuthFailure(response?.content || '', activeProviderName, profile.provider);
3453
+ if (authFailure && (!response?.toolCalls || response.toolCalls.length === 0)) {
2757
3454
  sendEvent('status', {
2758
- phase: 'thinking',
2759
- detail: `Sending request to ${activeProviderName}...`,
3455
+ phase: 'auth_required',
3456
+ detail: authFailure.detail,
2760
3457
  runtime: runtimePayload(),
3458
+ auth: authFailure,
2761
3459
  });
2762
3460
  recordActivity('status', {
2763
- phase: 'thinking',
2764
- detail: `Sending request to ${activeProviderName}...`,
3461
+ phase: 'auth_required',
3462
+ detail: authFailure.detail,
2765
3463
  runtime: runtimePayload(),
2766
- }, `Sending request to ${activeProviderName}...`);
2767
- response = await activeLlm.chat(chatOptions);
3464
+ auth: authFailure,
3465
+ }, authFailure.detail);
3466
+ const authErr = new Error(authFailure.message);
3467
+ authErr.authRequired = true;
3468
+ authErr.providerId = authFailure.providerId;
3469
+ authErr.cli = authFailure.cli;
3470
+ throw authErr;
2768
3471
  }
2769
- else {
2770
- throw primaryErr;
3472
+ if (response.usage) {
3473
+ totalInputTokens += response.usage.inputTokens || 0;
3474
+ totalOutputTokens += response.usage.outputTokens || 0;
3475
+ hasExactUsage = true;
2771
3476
  }
2772
- }
2773
- const authFailure = detectInteractiveAuthFailure(response?.content || '', activeProviderName, profile.provider);
2774
- if (authFailure && (!response?.toolCalls || response.toolCalls.length === 0)) {
2775
- sendEvent('status', {
2776
- phase: 'auth_required',
2777
- detail: authFailure.detail,
2778
- runtime: runtimePayload(),
2779
- auth: authFailure,
2780
- });
2781
- recordActivity('status', {
2782
- phase: 'auth_required',
2783
- detail: authFailure.detail,
2784
- runtime: runtimePayload(),
2785
- auth: authFailure,
2786
- }, authFailure.detail);
2787
- const authErr = new Error(authFailure.message);
2788
- authErr.authRequired = true;
2789
- authErr.providerId = authFailure.providerId;
2790
- authErr.cli = authFailure.cli;
2791
- throw authErr;
2792
- }
2793
- if (response.usage) {
2794
- totalInputTokens += response.usage.inputTokens || 0;
2795
- totalOutputTokens += response.usage.outputTokens || 0;
2796
- hasExactUsage = true;
2797
- }
2798
- // Accumulate thinking/reasoning across multi-turn tool loops
2799
- if (response.thinking) {
2800
- accumulatedThinking += (accumulatedThinking ? '\n---\n' : '') + response.thinking;
2801
- }
2802
- if (response.toolCalls && response.toolCalls.length > 0) {
2803
- llmMessages.push({
2804
- role: 'assistant',
2805
- content: response.content || '',
2806
- toolCalls: response.toolCalls,
2807
- });
2808
- for (const tc of response.toolCalls) {
2809
- sendEvent('status', { phase: 'calling_tool', detail: `Running ${tc.name}...`, toolName: tc.name });
2810
- sendEvent('tool_call', { id: tc.id, name: tc.name, arguments: tc.arguments });
2811
- recordActivity('tool_call', { id: tc.id, name: tc.name, arguments: tc.arguments }, `Tool call: ${tc.name}`);
2812
- if (!allowedToolNames.has(tc.name)) {
2813
- const errMsg = `TOOL_DISABLED: ${tc.name} is not enabled for this bot.`;
2814
- sendEvent('tool_result', { callId: tc.id, output: errMsg, isError: true });
2815
- recordActivity('tool_result', { callId: tc.id, output: errMsg, isError: true }, `Tool failed: ${tc.name}`);
2816
- llmMessages.push({ role: 'tool', content: errMsg, toolCallId: tc.id, toolName: tc.name });
2817
- continue;
2818
- }
2819
- const approval = (0, approval_1.checkPermission)(tc.name, (profile.permission_mode || 'autopilot'));
2820
- if (!approval.approved) {
2821
- const errMsg = `PERMISSION_DENIED: ${approval.reason}`;
2822
- sendEvent('tool_result', { callId: tc.id, output: errMsg, isError: true });
2823
- recordActivity('tool_result', { callId: tc.id, output: errMsg, isError: true }, `Tool denied: ${tc.name}`);
2824
- llmMessages.push({ role: 'tool', content: errMsg, toolCallId: tc.id, toolName: tc.name });
2825
- continue;
2826
- }
2827
- let result;
2828
- try {
2829
- const raw = await (0, index_2.executeToolWithMCP)({ id: tc.id, name: tc.name, arguments: tc.arguments }, toolCtx, mcpManager);
2830
- const verified = await (0, index_2.verifyToolResult)(raw, tc.arguments, toolCtx);
2831
- result = {
2832
- success: verified.success,
2833
- output: verified.output,
2834
- error: verified.error,
2835
- };
2836
- }
2837
- catch (toolErr) {
2838
- result = { success: false, output: '', error: toolErr.message || 'Tool execution failed' };
3477
+ // Accumulate thinking/reasoning across multi-turn tool loops
3478
+ if (response.thinking) {
3479
+ accumulatedThinking += (accumulatedThinking ? '\n---\n' : '') + response.thinking;
3480
+ }
3481
+ if (response.toolCalls && response.toolCalls.length > 0) {
3482
+ llmMessages.push({
3483
+ role: 'assistant',
3484
+ content: response.content || '',
3485
+ toolCalls: response.toolCalls,
3486
+ });
3487
+ for (const tc of response.toolCalls) {
3488
+ throwIfChatJobCancelled();
3489
+ sendEvent('status', { phase: 'calling_tool', detail: `Running ${tc.name}...`, toolName: tc.name });
3490
+ sendEvent('tool_call', { id: tc.id, name: tc.name, arguments: tc.arguments });
3491
+ recordActivity('tool_call', { id: tc.id, name: tc.name, arguments: tc.arguments }, `Tool call: ${tc.name}`);
3492
+ if (!allowedToolNames.has(tc.name)) {
3493
+ const errMsg = `TOOL_DISABLED: ${tc.name} is not enabled for this bot.`;
3494
+ sendEvent('tool_result', { callId: tc.id, output: errMsg, isError: true });
3495
+ recordActivity('tool_result', { callId: tc.id, output: errMsg, isError: true }, `Tool failed: ${tc.name}`);
3496
+ llmMessages.push({ role: 'tool', content: errMsg, toolCallId: tc.id, toolName: tc.name });
3497
+ continue;
3498
+ }
3499
+ const approval = unrestrictedCliProfile
3500
+ ? { approved: true }
3501
+ : (0, approval_1.checkPermission)(tc.name, (profile.permission_mode || 'autopilot'));
3502
+ if (!approval.approved) {
3503
+ const errMsg = `PERMISSION_DENIED: ${approval.reason}`;
3504
+ sendEvent('tool_result', { callId: tc.id, output: errMsg, isError: true });
3505
+ recordActivity('tool_result', { callId: tc.id, output: errMsg, isError: true }, `Tool denied: ${tc.name}`);
3506
+ llmMessages.push({ role: 'tool', content: errMsg, toolCallId: tc.id, toolName: tc.name });
3507
+ continue;
3508
+ }
3509
+ let result;
3510
+ try {
3511
+ const raw = await (0, index_2.executeToolWithMCP)({ id: tc.id, name: tc.name, arguments: tc.arguments }, toolCtx, mcpManager);
3512
+ const verified = await (0, index_2.verifyToolResult)(raw, tc.arguments, toolCtx);
3513
+ result = {
3514
+ success: verified.success,
3515
+ output: verified.output,
3516
+ error: verified.error,
3517
+ };
3518
+ }
3519
+ catch (toolErr) {
3520
+ result = { success: false, output: '', error: toolErr.message || 'Tool execution failed' };
3521
+ }
3522
+ const output = result.success ? result.output : `ERROR: ${result.error || 'Unknown error'}`;
3523
+ sendEvent('tool_result', { callId: tc.id, output, isError: !result.success });
3524
+ recordActivity('tool_result', {
3525
+ callId: tc.id,
3526
+ output,
3527
+ isError: !result.success,
3528
+ }, `${result.success ? 'Tool completed' : 'Tool failed'}: ${tc.name}`);
3529
+ llmMessages.push({ role: 'tool', content: output, toolCallId: tc.id, toolName: tc.name });
2839
3530
  }
2840
- const output = result.success ? result.output : `ERROR: ${result.error || 'Unknown error'}`;
2841
- sendEvent('tool_result', { callId: tc.id, output, isError: !result.success });
2842
- recordActivity('tool_result', {
2843
- callId: tc.id,
2844
- output,
2845
- isError: !result.success,
2846
- }, `${result.success ? 'Tool completed' : 'Tool failed'}: ${tc.name}`);
2847
- llmMessages.push({ role: 'tool', content: output, toolCallId: tc.id, toolName: tc.name });
3531
+ continue;
2848
3532
  }
2849
- continue;
2850
- }
2851
- // Final response (guard against defer-only filler)
2852
- const candidate = (response.content || '').trim();
2853
- if (!forcedFinalizationPass && (0, response_guard_1.isLikelyDeferredReply)(candidate)) {
2854
- forcedFinalizationPass = true;
2855
- llmMessages.push({ role: 'assistant', content: candidate });
2856
- llmMessages.push({
2857
- role: 'user',
2858
- content: 'Provide the final answer now. Do not say you will check later. Either provide concrete results or explicitly say what is unavailable.',
2859
- });
2860
- sendEvent('status', { phase: 'thinking', detail: 'Finalizing response...' });
2861
- recordActivity('status', { phase: 'thinking', detail: 'Finalizing response...' }, 'Finalizing response...');
2862
- continue;
3533
+ // Final response (guard against defer-only filler)
3534
+ const candidate = (response.content || '').trim();
3535
+ if (!forcedFinalizationPass && (0, response_guard_1.isLikelyDeferredReply)(candidate)) {
3536
+ forcedFinalizationPass = true;
3537
+ llmMessages.push({ role: 'assistant', content: candidate });
3538
+ llmMessages.push({
3539
+ role: 'user',
3540
+ content: 'Provide the final answer now. Do not say you will check later. Either provide concrete results or explicitly say what is unavailable.',
3541
+ });
3542
+ sendEvent('status', { phase: 'thinking', detail: 'Finalizing response...' });
3543
+ recordActivity('status', { phase: 'thinking', detail: 'Finalizing response...' }, 'Finalizing response...');
3544
+ continue;
3545
+ }
3546
+ fullContent = candidate;
3547
+ break;
2863
3548
  }
2864
- fullContent = candidate;
2865
- break;
2866
- }
2867
3549
  const persistedContent = fullContent || streamedContent.trim();
3550
+ if (!persistedContent) {
3551
+ throw new Error('Assistant returned no final response');
3552
+ }
3553
+ persistAssistantPartial(true);
3554
+ if (enableCliSessionEpoch && activeCliSessionId) {
3555
+ const nextEpochTurnCount = cliSessionEpochPlan.resumeSessionId
3556
+ ? ((cliSessionEpochPlan.existing?.epoch_turn_count || 0) + 1)
3557
+ : 1;
3558
+ data.upsertCliSessionEpoch({
3559
+ conversationId: convId,
3560
+ botId: profile.id,
3561
+ provider: activeProviderName,
3562
+ sessionId: activeCliSessionId,
3563
+ epochTurnCount: nextEpochTurnCount,
3564
+ lastInputTokens: hasExactUsage ? totalInputTokens : approxInputTokens,
3565
+ lastOutputTokens: hasExactUsage ? totalOutputTokens : 0,
3566
+ resetReason: cliSessionEpochPlan.resetReason,
3567
+ epochStartedAt: cliEpochStartedAt,
3568
+ lastUsedAt: localTimestamp(),
3569
+ });
3570
+ }
2868
3571
  // Emit thinking_done event if we accumulated any thinking
2869
3572
  if (accumulatedThinking) {
2870
3573
  sendEvent('thinking_done', {
@@ -2885,7 +3588,14 @@ function startLocalServer(opts) {
2885
3588
  activeModelName || profile.model || '',
2886
3589
  runtimeModeLabel(activeRuntimeMode, activeRuntimeSource),
2887
3590
  ].filter(Boolean).join(' | ');
2888
- const savedMessage = data.addMessage(convId, 'assistant', persistedContent, modelWithRuntime || undefined, undefined, profile.id, profile.name);
3591
+ const savedMessage = assistantMessageId
3592
+ ? (data.updateMessage(assistantMessageId, {
3593
+ content: persistedContent,
3594
+ model: modelWithRuntime || null,
3595
+ botId: profile.id,
3596
+ agentName: profile.name,
3597
+ }) || data.addMessage(convId, 'assistant', persistedContent, modelWithRuntime || undefined, undefined, profile.id, profile.name))
3598
+ : data.addMessage(convId, 'assistant', persistedContent, modelWithRuntime || undefined, undefined, profile.id, profile.name);
2889
3599
  data.attachMessageActivitiesToMessage(activityStreamId, savedMessage.id);
2890
3600
  data.createMessageActivity({
2891
3601
  conversationId: convId,
@@ -2913,6 +3623,7 @@ function startLocalServer(opts) {
2913
3623
  approxInputTokens,
2914
3624
  },
2915
3625
  });
3626
+ responseEnded = true;
2916
3627
  res.end();
2917
3628
  // Background processing: auto-title + funnel extraction
2918
3629
  const conv = data.getConversation(convId);
@@ -2926,13 +3637,13 @@ function startLocalServer(opts) {
2926
3637
  autoTitleConversation(convId, message, persistedContent, activeProviderName, activeModelName, activeApiKey);
2927
3638
  }
2928
3639
  }
2929
- (0, local_funnel_1.scheduleFunnelProcessing)(convId);
2930
3640
  // Summarization: trigger after every 5-turn boundary (fire-and-forget)
2931
3641
  // Includes topic creation/linking + project verification
2932
3642
  if ((0, context_window_1.shouldSummarize)(convId)) {
2933
3643
  const catalog = (0, backfill_1.buildProjectTopicCatalog)();
2934
3644
  (0, summarization_pipeline_1.runSummarization)(convId, catalog).catch(err => console.error(chalk_1.default.yellow(` [summarization] ${err.message}`)));
2935
3645
  }
3646
+ (0, local_funnel_1.scheduleFunnelProcessing)(convId);
2936
3647
  }
2937
3648
  catch (err) {
2938
3649
  console.error(chalk_1.default.red(`Chat error: ${err.message}`));
@@ -2940,6 +3651,7 @@ function startLocalServer(opts) {
2940
3651
  if (activityErrorContext.conversationId) {
2941
3652
  data.createMessageActivity({
2942
3653
  conversationId: activityErrorContext.conversationId,
3654
+ messageId: activityErrorContext.messageId ?? null,
2943
3655
  streamId: activityErrorContext.streamId ?? null,
2944
3656
  botId: activityErrorContext.botId ?? null,
2945
3657
  agentName: activityErrorContext.agentName ?? null,
@@ -2969,64 +3681,19 @@ function startLocalServer(opts) {
2969
3681
  providerId: err?.providerId || null,
2970
3682
  cli: err?.cli || null,
2971
3683
  })}\n\n`);
3684
+ responseEnded = true;
2972
3685
  res.end();
2973
3686
  }
2974
3687
  catch { /* connection already closed */ }
2975
3688
  }
2976
3689
  }
3690
+ finally {
3691
+ req.off?.('close', abortOnClientClose);
3692
+ }
2977
3693
  });
2978
3694
  // ─── Memory Facts ───────────────────────────────────────────
2979
- app.get('/api/memory/facts', (req, res) => {
2980
- try {
2981
- const { agentId, factType, limit, offset, search, groupByProject } = req.query;
2982
- const limitN = limit ? parseInt(limit) : 100;
2983
- const offsetN = offset ? parseInt(offset) : 0;
2984
- const grouped = groupByProject === '1' || groupByProject === 'true';
2985
- if (grouped) {
2986
- const db = data.getDb();
2987
- const wheres = [];
2988
- const params = [];
2989
- if (agentId) {
2990
- wheres.push('f.agent_id = ?');
2991
- params.push(agentId);
2992
- }
2993
- if (factType) {
2994
- wheres.push('f.fact_type = ?');
2995
- params.push(factType);
2996
- }
2997
- if (search) {
2998
- wheres.push('(f.content LIKE ? OR COALESCE(c.title, \'\') LIKE ?)');
2999
- params.push(`%${search}%`, `%${search}%`);
3000
- }
3001
- let sql = `
3002
- SELECT f.*, c.title as conversation_title, c.project_id,
3003
- COALESCE(p.name, '${data.UNASSIGNED_PROJECT_NAME}') as project_name
3004
- FROM memory_fact f
3005
- LEFT JOIN conversation c ON f.conversation_id = c.id
3006
- LEFT JOIN project p ON c.project_id = p.id
3007
- `;
3008
- if (wheres.length > 0)
3009
- sql += ` WHERE ${wheres.join(' AND ')}`;
3010
- sql += ' ORDER BY f.created_at DESC LIMIT ? OFFSET ?';
3011
- params.push(limitN, offsetN);
3012
- const rows = db.prepare(sql).all(...params);
3013
- return res.json(rows);
3014
- }
3015
- if (search && agentId) {
3016
- const facts = data.searchMemoryFacts(search, agentId, limitN);
3017
- return res.json(facts);
3018
- }
3019
- const facts = data.listMemoryFacts({
3020
- agentId: agentId || undefined,
3021
- factType: factType || undefined,
3022
- limit: limitN,
3023
- offset: offsetN,
3024
- });
3025
- res.json(facts);
3026
- }
3027
- catch (err) {
3028
- res.status(500).json({ error: err.message });
3029
- }
3695
+ app.get('/api/memory/facts', (_req, res) => {
3696
+ res.json([]);
3030
3697
  });
3031
3698
  app.get('/api/memory/search', (req, res) => {
3032
3699
  try {
@@ -3054,120 +3721,23 @@ function startLocalServer(opts) {
3054
3721
  res.status(500).json({ error: err.message });
3055
3722
  }
3056
3723
  });
3057
- app.delete('/api/memory/facts/:id', (req, res) => {
3058
- try {
3059
- const deleted = data.deleteMemoryFact(req.params.id);
3060
- if (!deleted)
3061
- return res.status(404).json({ error: 'Not found' });
3062
- res.json({ ok: true });
3063
- }
3064
- catch (err) {
3065
- res.status(500).json({ error: err.message });
3066
- }
3724
+ app.delete('/api/memory/facts/:id', (_req, res) => {
3725
+ res.json({ ok: true });
3067
3726
  });
3068
- app.get('/api/memory/entities', (req, res) => {
3069
- try {
3070
- const { agentId } = req.query;
3071
- if (!agentId)
3072
- return res.status(400).json({ error: 'agentId required' });
3073
- const graph = data.getEntityGraph(agentId);
3074
- res.json(graph);
3075
- }
3076
- catch (err) {
3077
- res.status(500).json({ error: err.message });
3078
- }
3727
+ app.get('/api/memory/entities', (_req, res) => {
3728
+ res.json({ nodes: [], edges: [] });
3079
3729
  });
3080
- app.get('/api/memory/decisions', (req, res) => {
3081
- try {
3082
- const { conversationId, limit, groupByProject } = req.query;
3083
- const grouped = groupByProject === '1' || groupByProject === 'true';
3084
- if (grouped) {
3085
- const db = data.getDb();
3086
- const params = [];
3087
- let sql = `
3088
- SELECT d.*, c.title as conversation_title, c.project_id,
3089
- COALESCE(p.name, '${data.UNASSIGNED_PROJECT_NAME}') as project_name
3090
- FROM decision d
3091
- LEFT JOIN conversation c ON d.conversation_id = c.id
3092
- LEFT JOIN project p ON c.project_id = p.id
3093
- `;
3094
- if (conversationId) {
3095
- sql += ' WHERE d.conversation_id = ?';
3096
- params.push(conversationId);
3097
- }
3098
- sql += ' ORDER BY d.created_at DESC LIMIT ?';
3099
- params.push(limit ? parseInt(limit) : 200);
3100
- const rows = db.prepare(sql).all(...params);
3101
- return res.json(rows);
3102
- }
3103
- const decisions = data.listDecisions({
3104
- conversationId: conversationId || undefined,
3105
- limit: limit ? parseInt(limit) : undefined,
3106
- });
3107
- res.json(decisions);
3108
- }
3109
- catch (err) {
3110
- res.status(500).json({ error: err.message });
3111
- }
3730
+ app.get('/api/memory/decisions', (_req, res) => {
3731
+ res.json([]);
3112
3732
  });
3113
- app.delete('/api/memory/decisions/:id', (req, res) => {
3114
- try {
3115
- const deleted = data.deleteDecision(req.params.id);
3116
- if (!deleted)
3117
- return res.status(404).json({ error: 'Not found' });
3118
- res.json({ ok: true });
3119
- }
3120
- catch (err) {
3121
- res.status(500).json({ error: err.message });
3122
- }
3733
+ app.delete('/api/memory/decisions/:id', (_req, res) => {
3734
+ res.json({ ok: true });
3123
3735
  });
3124
- app.get('/api/memory/action-items', (req, res) => {
3125
- try {
3126
- const { status, limit, groupByProject } = req.query;
3127
- const grouped = groupByProject === '1' || groupByProject === 'true';
3128
- if (grouped) {
3129
- const db = data.getDb();
3130
- const params = [];
3131
- const wheres = [];
3132
- if (status) {
3133
- wheres.push('a.status = ?');
3134
- params.push(status);
3135
- }
3136
- let sql = `
3137
- SELECT a.*, c.title as conversation_title, c.project_id,
3138
- COALESCE(p.name, '${data.UNASSIGNED_PROJECT_NAME}') as project_name
3139
- FROM action_item a
3140
- LEFT JOIN conversation c ON a.conversation_id = c.id
3141
- LEFT JOIN project p ON c.project_id = p.id
3142
- `;
3143
- if (wheres.length > 0) {
3144
- sql += ` WHERE ${wheres.join(' AND ')}`;
3145
- }
3146
- sql += ' ORDER BY a.created_at DESC LIMIT ?';
3147
- params.push(limit ? parseInt(limit) : 200);
3148
- const rows = db.prepare(sql).all(...params);
3149
- return res.json(rows);
3150
- }
3151
- const items = data.listActionItems({
3152
- status: status || undefined,
3153
- limit: limit ? parseInt(limit) : undefined,
3154
- });
3155
- res.json(items);
3156
- }
3157
- catch (err) {
3158
- res.status(500).json({ error: err.message });
3159
- }
3736
+ app.get('/api/memory/action-items', (_req, res) => {
3737
+ res.json([]);
3160
3738
  });
3161
- app.delete('/api/memory/action-items/:id', (req, res) => {
3162
- try {
3163
- const deleted = data.deleteActionItem(req.params.id);
3164
- if (!deleted)
3165
- return res.status(404).json({ error: 'Not found' });
3166
- res.json({ ok: true });
3167
- }
3168
- catch (err) {
3169
- res.status(500).json({ error: err.message });
3170
- }
3739
+ app.delete('/api/memory/action-items/:id', (_req, res) => {
3740
+ res.json({ ok: true });
3171
3741
  });
3172
3742
  // ─── Settings ───────────────────────────────────────────────
3173
3743
  app.get('/api/settings', (_req, res) => {
@@ -3272,7 +3842,7 @@ function startLocalServer(opts) {
3272
3842
  const { prompt } = req.body;
3273
3843
  if (!prompt)
3274
3844
  return res.status(400).json({ error: 'prompt is required' });
3275
- const clerk = (0, clerk_model_1.getClerk)();
3845
+ const clerk = (0, clerk_model_1.getClerk)({ runtimeMode: 'local_desktop' });
3276
3846
  if (!clerk)
3277
3847
  return res.json({ routing: 'default', reason: 'No clerk configured' });
3278
3848
  const agents = data.listAgentProfiles();
@@ -3289,7 +3859,7 @@ function startLocalServer(opts) {
3289
3859
  const { prompt, conversationId, agentId, pinnedMessageIds } = req.body;
3290
3860
  if (!prompt)
3291
3861
  return res.status(400).json({ error: 'prompt is required' });
3292
- const engine = (0, workflow_engine_1.getWorkflowEngine)(opts.projectDir);
3862
+ const engine = (0, workflow_engine_1.getWorkflowEngine)(opts.projectDir, 'local_desktop');
3293
3863
  const profile = agentId
3294
3864
  ? data.getAgentProfile(agentId)
3295
3865
  : data.getDefaultAgentProfile();
@@ -3307,6 +3877,7 @@ function startLocalServer(opts) {
3307
3877
  const result = await engine.execute(prompt, conversationId || null, profile.id, {
3308
3878
  onProgress: (p) => sendEvent('progress', p),
3309
3879
  pinnedMessageIds: pinnedMessageIds || undefined,
3880
+ runtimeMode: 'local_desktop',
3310
3881
  });
3311
3882
  sendEvent('done', result);
3312
3883
  res.end();
@@ -3334,7 +3905,7 @@ function startLocalServer(opts) {
3334
3905
  return res.status(400).json({ error: 'template id is required' });
3335
3906
  if (!Number.isFinite(taskId) || taskId <= 0)
3336
3907
  return res.status(400).json({ error: 'taskId is required' });
3337
- const engine = (0, workflow_engine_1.getWorkflowEngine)(opts.projectDir);
3908
+ const engine = (0, workflow_engine_1.getWorkflowEngine)(opts.projectDir, 'local_desktop');
3338
3909
  res.writeHead(200, {
3339
3910
  'Content-Type': 'text/event-stream',
3340
3911
  'Cache-Control': 'no-cache',
@@ -3366,7 +3937,7 @@ function startLocalServer(opts) {
3366
3937
  });
3367
3938
  app.get('/api/workflow/active', (_req, res) => {
3368
3939
  try {
3369
- const engine = (0, workflow_engine_1.getWorkflowEngine)(opts.projectDir);
3940
+ const engine = (0, workflow_engine_1.getWorkflowEngine)(opts.projectDir, 'local_desktop');
3370
3941
  const workflows = engine.getActiveWorkflows();
3371
3942
  res.json(workflows);
3372
3943
  }
@@ -3376,7 +3947,7 @@ function startLocalServer(opts) {
3376
3947
  });
3377
3948
  app.post('/api/workflow/:id/cancel', (req, res) => {
3378
3949
  try {
3379
- const engine = (0, workflow_engine_1.getWorkflowEngine)(opts.projectDir);
3950
+ const engine = (0, workflow_engine_1.getWorkflowEngine)(opts.projectDir, 'local_desktop');
3380
3951
  const cancelled = engine.cancel(req.params.id);
3381
3952
  res.json({ ok: cancelled });
3382
3953
  }
@@ -3610,24 +4181,9 @@ function startLocalServer(opts) {
3610
4181
  const ids = convIds.map(c => c.id);
3611
4182
  const placeholders = ids.map(() => '?').join(',');
3612
4183
  const result = {};
3613
- if (!type || type === 'decisions') {
3614
- result.decisions = db.prepare(`SELECT d.*, c.title as conversation_title FROM decision d
3615
- LEFT JOIN conversation c ON d.conversation_id = c.id
3616
- WHERE d.conversation_id IN (${placeholders})
3617
- ORDER BY d.created_at DESC`).all(...ids);
3618
- }
3619
- if (!type || type === 'actionItems') {
3620
- result.actionItems = db.prepare(`SELECT a.*, c.title as conversation_title FROM action_item a
3621
- LEFT JOIN conversation c ON a.conversation_id = c.id
3622
- WHERE a.conversation_id IN (${placeholders})
3623
- ORDER BY a.created_at DESC`).all(...ids);
3624
- }
3625
- if (!type || type === 'facts') {
3626
- result.facts = db.prepare(`SELECT f.*, c.title as conversation_title FROM memory_fact f
3627
- LEFT JOIN conversation c ON f.conversation_id = c.id
3628
- WHERE f.conversation_id IN (${placeholders})
3629
- ORDER BY f.created_at DESC`).all(...ids);
3630
- }
4184
+ result.decisions = [];
4185
+ result.actionItems = [];
4186
+ result.facts = [];
3631
4187
  if (!type || type === 'summaries') {
3632
4188
  result.summaries = db.prepare(`SELECT s.*, c.title as conversation_title FROM conversation_summary s
3633
4189
  LEFT JOIN conversation c ON s.conversation_id = c.id
@@ -4032,6 +4588,7 @@ function startLocalServer(opts) {
4032
4588
  mcpManager.autoLaunch().catch((err) => {
4033
4589
  console.error(chalk_1.default.yellow(`[MCP] Auto-launch error: ${err.message}`));
4034
4590
  });
4591
+ void runQueuedChatJobs();
4035
4592
  /**
4036
4593
  * GET /api/mcp/catalog — full marketplace catalog with install status
4037
4594
  */
@@ -4121,7 +4678,7 @@ function startLocalServer(opts) {
4121
4678
  }
4122
4679
  });
4123
4680
  // Initialize workflow engine
4124
- (0, workflow_engine_1.getWorkflowEngine)(opts.projectDir);
4681
+ (0, workflow_engine_1.getWorkflowEngine)(opts.projectDir, 'local_desktop');
4125
4682
  // Start server
4126
4683
  return new Promise((resolve, reject) => {
4127
4684
  _server = app.listen(port, '127.0.0.1', () => {
@@ -4152,43 +4709,38 @@ function stopLocalServer() {
4152
4709
  }
4153
4710
  });
4154
4711
  }
4155
- function buildCliFallback(profile) {
4156
- const providerName = profile.provider;
4157
- const model = (profile.model || '').trim() || 'default';
4158
- return {
4159
- providerName,
4160
- apiKey: 'cli-auth',
4161
- model,
4162
- llm: (0, index_1.createProvider)(providerName, { apiKey: 'cli-auth', model }),
4163
- runtimeMode: 'subscription-cli',
4164
- runtimeSource: 'cli-direct',
4165
- };
4166
- }
4167
4712
  async function buildChatRuntime(profile) {
4168
4713
  const providerName = profile.provider;
4169
- const cliModel = (profile.model || '').trim() || 'default';
4170
- const apiKeyFallback = buildApiKeyFallback(profile);
4171
- if (providerName === 'claude-cli' || providerName === 'codex-cli') {
4714
+ const model = (profile.model || '').trim() || 'default';
4715
+ if (providerName === 'claude-cli') {
4172
4716
  return {
4173
4717
  providerName,
4174
4718
  apiKey: 'cli-auth',
4175
- model: cliModel,
4176
- llm: (0, index_1.createProvider)(providerName, { apiKey: 'cli-auth', model: cliModel }),
4719
+ model,
4720
+ llm: (0, index_1.createProvider)(providerName, { apiKey: 'cli-auth', model, runtimeMode: 'local_desktop' }),
4721
+ runtimeMode: 'subscription-cli',
4722
+ runtimeSource: 'cli-direct',
4723
+ };
4724
+ }
4725
+ if (providerName === 'codex-cli') {
4726
+ return {
4727
+ providerName,
4728
+ apiKey: 'cli-auth',
4729
+ model,
4730
+ llm: (0, index_1.createProvider)(providerName, { apiKey: 'cli-auth', model, runtimeMode: 'local_desktop' }),
4177
4731
  runtimeMode: 'subscription-cli',
4178
4732
  runtimeSource: 'cli-direct',
4179
- apiKeyFallback,
4180
4733
  };
4181
4734
  }
4182
4735
  const apiKey = resolveApiKey(profile);
4183
4736
  if (!apiKey) {
4184
4737
  throw new Error(`No API key for provider ${profile.provider}. Configure one in Settings.`);
4185
4738
  }
4186
- const model = (profile.model || '').trim() || 'default';
4187
4739
  return {
4188
4740
  providerName,
4189
4741
  apiKey,
4190
4742
  model,
4191
- llm: (0, index_1.createProvider)(providerName, { apiKey, model }),
4743
+ llm: (0, index_1.createProvider)(providerName, { apiKey, model, runtimeMode: 'local_desktop' }),
4192
4744
  runtimeMode: 'api-key',
4193
4745
  runtimeSource: 'api-key',
4194
4746
  };
@@ -4196,16 +4748,17 @@ async function buildChatRuntime(profile) {
4196
4748
  function runtimeModeLabel(mode, runtimeSource) {
4197
4749
  if (mode === 'subscription-cli')
4198
4750
  return 'Subscription CLI';
4751
+ if (mode === 'subscription-api')
4752
+ return (0, subscription_runtime_1.claudeSubscriptionRuntimeLabel)(runtimeSource);
4199
4753
  return 'API Key';
4200
4754
  }
4201
- function runtimePayloadForDisplay(providerName, model, runtimeMode, runtimeSource, fallbackUsed) {
4755
+ function runtimePayloadForDisplay(providerName, model, runtimeMode, runtimeSource) {
4202
4756
  return {
4203
4757
  mode: runtimeMode,
4204
4758
  modeLabel: runtimeModeLabel(runtimeMode, runtimeSource),
4205
4759
  provider: providerName,
4206
4760
  model: model || null,
4207
4761
  source: runtimeSource || null,
4208
- fallbackUsed,
4209
4762
  };
4210
4763
  }
4211
4764
  function configuredRuntimeLabelForProfile(profile) {
@@ -4214,17 +4767,22 @@ function configuredRuntimeLabelForProfile(profile) {
4214
4767
  const directConnection = profile.provider_connection_id
4215
4768
  ? data.listProviderConnections().find((row) => row.id === profile.provider_connection_id)
4216
4769
  : undefined;
4770
+ if (profile.provider === 'claude-cli' || profile.provider === 'codex-cli') {
4771
+ return 'Subscription CLI';
4772
+ }
4217
4773
  const connection = directConnection;
4218
4774
  if (connection) {
4219
4775
  if (connection.access_mode === 'cli')
4220
4776
  return 'Subscription CLI';
4777
+ if (connection.access_mode === 'oauth')
4778
+ return 'Subscription API (Token)';
4221
4779
  return 'API Key';
4222
4780
  }
4223
- if (profile.provider === 'claude-cli' || profile.provider === 'codex-cli') {
4224
- return 'Subscription CLI';
4225
- }
4226
4781
  return 'API Key';
4227
4782
  }
4783
+ async function buildChatRuntimeForTest(profile) {
4784
+ return buildChatRuntime(profile);
4785
+ }
4228
4786
  function buildConfiguredMessageModel(profile) {
4229
4787
  if (!profile)
4230
4788
  return '';
@@ -4372,32 +4930,30 @@ function detectInteractiveAuthFailure(text, activeProviderName, configuredProvid
4372
4930
  cli,
4373
4931
  };
4374
4932
  }
4375
- function buildApiKeyFallback(profile) {
4376
- const providerName = profile.provider === 'codex-cli'
4377
- ? 'openai'
4378
- : profile.provider === 'claude-cli'
4379
- ? 'anthropic'
4380
- : profile.provider;
4381
- const configured = data.findProviderConnection(providerName);
4382
- const apiKey = configured?.api_key_enc || resolveApiKey({ ...profile, provider: providerName });
4383
- if (!apiKey)
4384
- return undefined;
4385
- const model = (0, subscription_runtime_1.resolveSubscriptionApiModel)(profile.model, configured?.default_model || undefined)
4386
- || (configured?.default_model || '').trim()
4387
- || 'default';
4388
- return {
4389
- providerName,
4390
- apiKey,
4391
- model,
4392
- llm: (0, index_1.createProvider)(providerName, { apiKey, model }),
4393
- runtimeMode: 'api-key',
4394
- runtimeSource: 'api-key-fallback',
4395
- };
4933
+ const LOCAL_RUNTIME_RETRY_LIMIT = 2;
4934
+ function shouldRetrySelectedLocalRuntime(err) {
4935
+ const text = String(err?.message || err || '').toLowerCase();
4936
+ if (!text)
4937
+ return false;
4938
+ if (/\b(no api key|configure one in settings|not available on this machine|not installed|please run \/login|not logged in|invalid api key)\b/i.test(text)) {
4939
+ return false;
4940
+ }
4941
+ return /\b(429|rate limit|timeout|timed out|temporar|temporarily|econnreset|etimedout|enotfound|econnrefused|socket hang up|network|try again|overloaded|busy)\b/i.test(text);
4942
+ }
4943
+ async function pauseLocalRuntimeRetry(attempt) {
4944
+ const delayMs = attempt <= 1 ? 750 : 1500;
4945
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
4396
4946
  }
4397
4947
  function resolveApiKey(profile) {
4398
4948
  // Check profile-stored key first
4399
4949
  if (profile.api_key_enc)
4400
4950
  return profile.api_key_enc;
4951
+ // Prefer the bot's explicitly assigned provider connection when present.
4952
+ if (profile.provider_connection_id) {
4953
+ const directConnection = data.getProviderConnection(profile.provider_connection_id);
4954
+ if (directConnection?.api_key_enc)
4955
+ return directConnection.api_key_enc;
4956
+ }
4401
4957
  // Fall back to DB-backed provider connections
4402
4958
  const providerConnection = data.findProviderConnection(profile.provider);
4403
4959
  if (providerConnection?.api_key_enc)
@@ -4461,7 +5017,7 @@ function expandAllowedToolNames(allToolDefs, configuredBuiltinTools, configuredM
4461
5017
  }
4462
5018
  async function autoTitleConversation(convId, userMsg, assistantMsg, providerName, modelName, apiKey) {
4463
5019
  try {
4464
- const llm = (0, index_1.createProvider)(providerName, { apiKey, model: modelName || 'default' });
5020
+ const llm = (0, index_1.createProvider)(providerName, { apiKey, model: modelName || 'default', runtimeMode: 'local_desktop' });
4465
5021
  const resp = await llm.chat({
4466
5022
  messages: [{ role: 'user', content: `Generate a short title (max 6 words, no quotes) for this conversation:\n\nUser: ${userMsg.slice(0, 200)}\nAssistant: ${assistantMsg.slice(0, 200)}` }],
4467
5023
  system: 'You generate short conversation titles. Return ONLY the title, nothing else.',