funolio-agent 1.0.75 → 1.1.65

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (236) hide show
  1. package/dist/auth/credential-reader.d.ts.map +1 -1
  2. package/dist/auth/credential-reader.js +4 -3
  3. package/dist/auth/credential-reader.js.map +1 -1
  4. package/dist/auth/token-refresh.d.ts +8 -0
  5. package/dist/auth/token-refresh.d.ts.map +1 -1
  6. package/dist/auth/token-refresh.js +82 -52
  7. package/dist/auth/token-refresh.js.map +1 -1
  8. package/dist/auto-organizer.d.ts.map +1 -1
  9. package/dist/auto-organizer.js +6 -7
  10. package/dist/auto-organizer.js.map +1 -1
  11. package/dist/bench-prefix.d.ts +16 -0
  12. package/dist/bench-prefix.d.ts.map +1 -0
  13. package/dist/bench-prefix.js +25 -0
  14. package/dist/bench-prefix.js.map +1 -0
  15. package/dist/bot-manager.d.ts.map +1 -1
  16. package/dist/bot-manager.js +23 -14
  17. package/dist/bot-manager.js.map +1 -1
  18. package/dist/chat-sync.d.ts +42 -0
  19. package/dist/chat-sync.d.ts.map +1 -0
  20. package/dist/chat-sync.js +95 -0
  21. package/dist/chat-sync.js.map +1 -0
  22. package/dist/clerk-model.d.ts +7 -0
  23. package/dist/clerk-model.d.ts.map +1 -1
  24. package/dist/clerk-model.js +42 -8
  25. package/dist/clerk-model.js.map +1 -1
  26. package/dist/cli-bootstrap-history.d.ts +10 -0
  27. package/dist/cli-bootstrap-history.d.ts.map +1 -0
  28. package/dist/cli-bootstrap-history.js +112 -0
  29. package/dist/cli-bootstrap-history.js.map +1 -0
  30. package/dist/cli-models.d.ts +8 -0
  31. package/dist/cli-models.d.ts.map +1 -0
  32. package/dist/cli-models.js +91 -0
  33. package/dist/cli-models.js.map +1 -0
  34. package/dist/cli-session-epoch.d.ts +13 -3
  35. package/dist/cli-session-epoch.d.ts.map +1 -1
  36. package/dist/cli-session-epoch.js +53 -4
  37. package/dist/cli-session-epoch.js.map +1 -1
  38. package/dist/codex-app-server-manager.d.ts +64 -4
  39. package/dist/codex-app-server-manager.d.ts.map +1 -1
  40. package/dist/codex-app-server-manager.js +755 -55
  41. package/dist/codex-app-server-manager.js.map +1 -1
  42. package/dist/commands/pool.d.ts +32 -0
  43. package/dist/commands/pool.d.ts.map +1 -1
  44. package/dist/commands/pool.js +145 -66
  45. package/dist/commands/pool.js.map +1 -1
  46. package/dist/commands/start.d.ts +21 -0
  47. package/dist/commands/start.d.ts.map +1 -1
  48. package/dist/commands/start.js +484 -63
  49. package/dist/commands/start.js.map +1 -1
  50. package/dist/commands/status.d.ts.map +1 -1
  51. package/dist/commands/status.js +5 -2
  52. package/dist/commands/status.js.map +1 -1
  53. package/dist/config.d.ts +1 -0
  54. package/dist/config.d.ts.map +1 -1
  55. package/dist/config.js +170 -58
  56. package/dist/config.js.map +1 -1
  57. package/dist/context-window.d.ts +37 -1
  58. package/dist/context-window.d.ts.map +1 -1
  59. package/dist/context-window.js +202 -16
  60. package/dist/context-window.js.map +1 -1
  61. package/dist/live-activity.d.ts +3 -1
  62. package/dist/live-activity.d.ts.map +1 -1
  63. package/dist/live-activity.js.map +1 -1
  64. package/dist/local-chat-execution.d.ts +114 -0
  65. package/dist/local-chat-execution.d.ts.map +1 -0
  66. package/dist/local-chat-execution.js +349 -0
  67. package/dist/local-chat-execution.js.map +1 -0
  68. package/dist/local-cli-pty-manager.d.ts +138 -3
  69. package/dist/local-cli-pty-manager.d.ts.map +1 -1
  70. package/dist/local-cli-pty-manager.js +1415 -111
  71. package/dist/local-cli-pty-manager.js.map +1 -1
  72. package/dist/local-conversation-gateway.d.ts +110 -0
  73. package/dist/local-conversation-gateway.d.ts.map +1 -0
  74. package/dist/local-conversation-gateway.js +175 -0
  75. package/dist/local-conversation-gateway.js.map +1 -0
  76. package/dist/local-data.d.ts +235 -5
  77. package/dist/local-data.d.ts.map +1 -1
  78. package/dist/local-data.js +1066 -87
  79. package/dist/local-data.js.map +1 -1
  80. package/dist/local-db.d.ts +6 -0
  81. package/dist/local-db.d.ts.map +1 -1
  82. package/dist/local-db.js +376 -4
  83. package/dist/local-db.js.map +1 -1
  84. package/dist/local-funnel.d.ts.map +1 -1
  85. package/dist/local-funnel.js +6 -5
  86. package/dist/local-funnel.js.map +1 -1
  87. package/dist/local-server.d.ts +30 -0
  88. package/dist/local-server.d.ts.map +1 -1
  89. package/dist/local-server.js +2898 -319
  90. package/dist/local-server.js.map +1 -1
  91. package/dist/managed-process-registry.d.ts +59 -0
  92. package/dist/managed-process-registry.d.ts.map +1 -0
  93. package/dist/managed-process-registry.js +390 -0
  94. package/dist/managed-process-registry.js.map +1 -0
  95. package/dist/mcp/claude-config-writer.d.ts +5 -5
  96. package/dist/mcp/claude-config-writer.d.ts.map +1 -1
  97. package/dist/mcp/claude-config-writer.js +19 -11
  98. package/dist/mcp/claude-config-writer.js.map +1 -1
  99. package/dist/mcp/index.d.ts +4 -2
  100. package/dist/mcp/index.d.ts.map +1 -1
  101. package/dist/mcp/index.js.map +1 -1
  102. package/dist/mcp/sync-cli-config.d.ts +42 -4
  103. package/dist/mcp/sync-cli-config.d.ts.map +1 -1
  104. package/dist/mcp/sync-cli-config.js +497 -17
  105. package/dist/mcp/sync-cli-config.js.map +1 -1
  106. package/dist/message-loop.d.ts.map +1 -1
  107. package/dist/message-loop.js +43 -1
  108. package/dist/message-loop.js.map +1 -1
  109. package/dist/mqtt-client.d.ts +34 -0
  110. package/dist/mqtt-client.d.ts.map +1 -1
  111. package/dist/mqtt-client.js +270 -45
  112. package/dist/mqtt-client.js.map +1 -1
  113. package/dist/mqtt-data-relay.d.ts +44 -0
  114. package/dist/mqtt-data-relay.d.ts.map +1 -0
  115. package/dist/mqtt-data-relay.js +106 -0
  116. package/dist/mqtt-data-relay.js.map +1 -0
  117. package/dist/orchestration/capabilities.d.ts +13 -0
  118. package/dist/orchestration/capabilities.d.ts.map +1 -0
  119. package/dist/orchestration/capabilities.js +152 -0
  120. package/dist/orchestration/capabilities.js.map +1 -0
  121. package/dist/orchestration/dispatch-executor.d.ts +83 -0
  122. package/dist/orchestration/dispatch-executor.d.ts.map +1 -0
  123. package/dist/orchestration/dispatch-executor.js +266 -0
  124. package/dist/orchestration/dispatch-executor.js.map +1 -0
  125. package/dist/orchestration/dispatch-hint.d.ts +134 -0
  126. package/dist/orchestration/dispatch-hint.d.ts.map +1 -0
  127. package/dist/orchestration/dispatch-hint.js +247 -0
  128. package/dist/orchestration/dispatch-hint.js.map +1 -0
  129. package/dist/orchestration/dispatch-runner.d.ts +106 -0
  130. package/dist/orchestration/dispatch-runner.d.ts.map +1 -0
  131. package/dist/orchestration/dispatch-runner.js +604 -0
  132. package/dist/orchestration/dispatch-runner.js.map +1 -0
  133. package/dist/orchestration/dispatch-tools.d.ts +167 -0
  134. package/dist/orchestration/dispatch-tools.d.ts.map +1 -0
  135. package/dist/orchestration/dispatch-tools.js +328 -0
  136. package/dist/orchestration/dispatch-tools.js.map +1 -0
  137. package/dist/orchestration/front-door-policy.d.ts +35 -10
  138. package/dist/orchestration/front-door-policy.d.ts.map +1 -1
  139. package/dist/orchestration/front-door-policy.js +30 -267
  140. package/dist/orchestration/front-door-policy.js.map +1 -1
  141. package/dist/orchestration/orchestrator-dispatch-prompt.d.ts +43 -0
  142. package/dist/orchestration/orchestrator-dispatch-prompt.d.ts.map +1 -0
  143. package/dist/orchestration/orchestrator-dispatch-prompt.js +267 -0
  144. package/dist/orchestration/orchestrator-dispatch-prompt.js.map +1 -0
  145. package/dist/orchestration/orchestrator-operating-prompt.d.ts +14 -0
  146. package/dist/orchestration/orchestrator-operating-prompt.d.ts.map +1 -1
  147. package/dist/orchestration/orchestrator-operating-prompt.js +157 -31
  148. package/dist/orchestration/orchestrator-operating-prompt.js.map +1 -1
  149. package/dist/orchestration/plan-import.d.ts +39 -0
  150. package/dist/orchestration/plan-import.d.ts.map +1 -0
  151. package/dist/orchestration/plan-import.js +547 -0
  152. package/dist/orchestration/plan-import.js.map +1 -0
  153. package/dist/orchestration/worker-operating-prompt.d.ts +2 -0
  154. package/dist/orchestration/worker-operating-prompt.d.ts.map +1 -1
  155. package/dist/orchestration/worker-operating-prompt.js +36 -46
  156. package/dist/orchestration/worker-operating-prompt.js.map +1 -1
  157. package/dist/orchestrator.d.ts +195 -3
  158. package/dist/orchestrator.d.ts.map +1 -1
  159. package/dist/orchestrator.js +1970 -432
  160. package/dist/orchestrator.js.map +1 -1
  161. package/dist/providers/anthropic.d.ts.map +1 -1
  162. package/dist/providers/anthropic.js +8 -4
  163. package/dist/providers/anthropic.js.map +1 -1
  164. package/dist/providers/claude-cli.d.ts.map +1 -1
  165. package/dist/providers/claude-cli.js +28 -3
  166. package/dist/providers/claude-cli.js.map +1 -1
  167. package/dist/providers/codex-cli.d.ts +10 -6
  168. package/dist/providers/codex-cli.d.ts.map +1 -1
  169. package/dist/providers/codex-cli.js +190 -17
  170. package/dist/providers/codex-cli.js.map +1 -1
  171. package/dist/providers/google.d.ts.map +1 -1
  172. package/dist/providers/google.js +15 -5
  173. package/dist/providers/google.js.map +1 -1
  174. package/dist/providers/index.d.ts +15 -1
  175. package/dist/providers/index.d.ts.map +1 -1
  176. package/dist/providers/index.js.map +1 -1
  177. package/dist/providers/openai.d.ts +1 -1
  178. package/dist/providers/openai.d.ts.map +1 -1
  179. package/dist/providers/openai.js +13 -5
  180. package/dist/providers/openai.js.map +1 -1
  181. package/dist/server-adapter.d.ts +8 -0
  182. package/dist/server-adapter.d.ts.map +1 -1
  183. package/dist/server-adapter.js +7 -0
  184. package/dist/server-adapter.js.map +1 -1
  185. package/dist/service-mode.d.ts +1 -1
  186. package/dist/service-mode.d.ts.map +1 -1
  187. package/dist/service-mode.js +64 -1
  188. package/dist/service-mode.js.map +1 -1
  189. package/dist/service-setup-only.d.ts +8 -0
  190. package/dist/service-setup-only.d.ts.map +1 -0
  191. package/dist/service-setup-only.js +37 -0
  192. package/dist/service-setup-only.js.map +1 -0
  193. package/dist/slash-commands.d.ts +21 -0
  194. package/dist/slash-commands.d.ts.map +1 -0
  195. package/dist/slash-commands.js +99 -0
  196. package/dist/slash-commands.js.map +1 -0
  197. package/dist/subagent/index.d.ts +4 -2
  198. package/dist/subagent/index.d.ts.map +1 -1
  199. package/dist/subagent/index.js.map +1 -1
  200. package/dist/summarization-pipeline.d.ts.map +1 -1
  201. package/dist/summarization-pipeline.js +1 -9
  202. package/dist/summarization-pipeline.js.map +1 -1
  203. package/dist/token-counter.d.ts.map +1 -1
  204. package/dist/token-counter.js +11 -4
  205. package/dist/token-counter.js.map +1 -1
  206. package/dist/tool-filter.d.ts.map +1 -1
  207. package/dist/tool-filter.js +10 -6
  208. package/dist/tool-filter.js.map +1 -1
  209. package/dist/tools/admin-tools.d.ts.map +1 -1
  210. package/dist/tools/admin-tools.js +13 -4
  211. package/dist/tools/admin-tools.js.map +1 -1
  212. package/dist/tools/run-command.d.ts.map +1 -1
  213. package/dist/tools/run-command.js +5 -1
  214. package/dist/tools/run-command.js.map +1 -1
  215. package/dist/tools/search-conversation-history.d.ts.map +1 -1
  216. package/dist/tools/search-conversation-history.js +12 -2
  217. package/dist/tools/search-conversation-history.js.map +1 -1
  218. package/dist/tools/todo-tasks.d.ts.map +1 -1
  219. package/dist/tools/todo-tasks.js +77 -5
  220. package/dist/tools/todo-tasks.js.map +1 -1
  221. package/dist/usage-log.d.ts +62 -0
  222. package/dist/usage-log.d.ts.map +1 -0
  223. package/dist/usage-log.js +98 -0
  224. package/dist/usage-log.js.map +1 -0
  225. package/dist/wizard-state.d.ts +13 -0
  226. package/dist/wizard-state.d.ts.map +1 -1
  227. package/dist/wizard-state.js +61 -3
  228. package/dist/wizard-state.js.map +1 -1
  229. package/dist/wizard-support.d.ts.map +1 -1
  230. package/dist/wizard-support.js +27 -1
  231. package/dist/wizard-support.js.map +1 -1
  232. package/dist/workflow-engine.d.ts +40 -1
  233. package/dist/workflow-engine.d.ts.map +1 -1
  234. package/dist/workflow-engine.js +753 -93
  235. package/dist/workflow-engine.js.map +1 -1
  236. package/package.json +2 -2
@@ -36,17 +36,24 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.UNASSIGNED_PROJECT_NAME = void 0;
37
37
  exports.getDb = getDb;
38
38
  exports.closeDb = closeDb;
39
+ exports.getAgentOrchestrationRoleClass = getAgentOrchestrationRoleClass;
40
+ exports.getAgentOrchestrationRoleLabel = getAgentOrchestrationRoleLabel;
41
+ exports.getAgentRolePriorities = getAgentRolePriorities;
42
+ exports.rolesMatch = rolesMatch;
39
43
  exports.createAgentProfile = createAgentProfile;
40
44
  exports.getAgentProfile = getAgentProfile;
41
45
  exports.getDefaultAgentProfile = getDefaultAgentProfile;
42
46
  exports.getOrchestratorBot = getOrchestratorBot;
43
47
  exports.getCurrentOrchestratorSelection = getCurrentOrchestratorSelection;
48
+ exports.getResolvedClerkConfigInfo = getResolvedClerkConfigInfo;
44
49
  exports.listAgentProfiles = listAgentProfiles;
45
50
  exports.migrateAgentProfilesToDefaultToolProfile = migrateAgentProfilesToDefaultToolProfile;
46
51
  exports.updateAgentProfile = updateAgentProfile;
47
52
  exports.setOrchestratorBot = setOrchestratorBot;
48
53
  exports.setDefaultAgentProfile = setDefaultAgentProfile;
49
54
  exports.isClerkOrchestratorEnabled = isClerkOrchestratorEnabled;
55
+ exports.isOrchestrationToolDispatchEnabled = isOrchestrationToolDispatchEnabled;
56
+ exports.setOrchestrationToolDispatchEnabled = setOrchestrationToolDispatchEnabled;
50
57
  exports.setClerkAsOrchestrator = setClerkAsOrchestrator;
51
58
  exports.createProviderConnection = createProviderConnection;
52
59
  exports.listProviderConnections = listProviderConnections;
@@ -59,20 +66,35 @@ exports.upsertOauthProviderConnection = upsertOauthProviderConnection;
59
66
  exports.deleteProviderConnection = deleteProviderConnection;
60
67
  exports.deleteAgentProfile = deleteAgentProfile;
61
68
  exports.createConversation = createConversation;
69
+ exports.getConversationParticipantBotIds = getConversationParticipantBotIds;
70
+ exports.getConversationPrimaryBotId = getConversationPrimaryBotId;
62
71
  exports.getConversation = getConversation;
63
72
  exports.listConversations = listConversations;
64
73
  exports.countConversations = countConversations;
65
74
  exports.countMessagesForBot = countMessagesForBot;
75
+ exports.countMessagesForBotSince = countMessagesForBotSince;
76
+ exports.countDecisionsForBot = countDecisionsForBot;
77
+ exports.countBotActiveDays = countBotActiveDays;
78
+ exports.countBotConversationsUpdatedBetween = countBotConversationsUpdatedBetween;
79
+ exports.sumBotTokenUsageSince = sumBotTokenUsageSince;
66
80
  exports.listBotConversationActivity = listBotConversationActivity;
67
81
  exports.updateConversation = updateConversation;
82
+ exports.syncConversationParticipants = syncConversationParticipants;
68
83
  exports.touchConversationActivity = touchConversationActivity;
84
+ exports.bumpConversationSyncRevision = bumpConversationSyncRevision;
69
85
  exports.deleteConversation = deleteConversation;
86
+ exports.toWarmSessionBindingKey = toWarmSessionBindingKey;
87
+ exports.parseWarmSessionBindingKey = parseWarmSessionBindingKey;
88
+ exports.epochRowToWarmInfo = epochRowToWarmInfo;
70
89
  exports.addMessage = addMessage;
71
90
  exports.updateMessage = updateMessage;
72
91
  exports.createChatJob = createChatJob;
73
92
  exports.getCliSessionEpoch = getCliSessionEpoch;
74
93
  exports.upsertCliSessionEpoch = upsertCliSessionEpoch;
75
94
  exports.deleteCliSessionEpoch = deleteCliSessionEpoch;
95
+ exports.clearCliSessionEpochIfMatches = clearCliSessionEpochIfMatches;
96
+ exports.cleanupPoisonedClaudePrewarmSessionRows = cleanupPoisonedClaudePrewarmSessionRows;
97
+ exports.getCliSessionEpochsByConversation = getCliSessionEpochsByConversation;
76
98
  exports.generateNextSessionId = generateNextSessionId;
77
99
  exports.getChatJob = getChatJob;
78
100
  exports.getLatestConversationChatJob = getLatestConversationChatJob;
@@ -83,6 +105,8 @@ exports.listActiveAssistantMessageIds = listActiveAssistantMessageIds;
83
105
  exports.countRunningChatJobs = countRunningChatJobs;
84
106
  exports.updateChatJob = updateChatJob;
85
107
  exports.markRunningChatJobsInterrupted = markRunningChatJobsInterrupted;
108
+ exports.recoverInterruptedChatJobs = recoverInterruptedChatJobs;
109
+ exports.recoverInterruptedWorkflowRuns = recoverInterruptedWorkflowRuns;
86
110
  exports.createMessageActivity = createMessageActivity;
87
111
  exports.attachMessageActivitiesToMessage = attachMessageActivitiesToMessage;
88
112
  exports.listMessageActivities = listMessageActivities;
@@ -117,6 +141,16 @@ exports.updateImportJob = updateImportJob;
117
141
  exports.createImportJobItem = createImportJobItem;
118
142
  exports.getQueuedImportItems = getQueuedImportItems;
119
143
  exports.updateImportJobItem = updateImportJobItem;
144
+ exports.createImportedPlanRun = createImportedPlanRun;
145
+ exports.getImportedPlanRun = getImportedPlanRun;
146
+ exports.listImportedPlanRunsForConversation = listImportedPlanRunsForConversation;
147
+ exports.getLatestImportedPlanRunForConversation = getLatestImportedPlanRunForConversation;
148
+ exports.updateImportedPlanRun = updateImportedPlanRun;
149
+ exports.touchImportedPlanRun = touchImportedPlanRun;
150
+ exports.getImportedPlanRunTasks = getImportedPlanRunTasks;
151
+ exports.getImportedPlanRunRemainingTaskIds = getImportedPlanRunRemainingTaskIds;
152
+ exports.getImportedPlanRunCurrentTask = getImportedPlanRunCurrentTask;
153
+ exports.deleteImportedPlanRunSelectedTasks = deleteImportedPlanRunSelectedTasks;
120
154
  exports.createOutboxEvent = createOutboxEvent;
121
155
  exports.getPendingOutboxEvents = getPendingOutboxEvents;
122
156
  exports.markOutboxEventSent = markOutboxEventSent;
@@ -156,6 +190,7 @@ exports.completeTodoTaskByUser = completeTodoTaskByUser;
156
190
  exports.listTodoArtifactsForTask = listTodoArtifactsForTask;
157
191
  exports.completeTodoTaskByWorker = completeTodoTaskByWorker;
158
192
  exports.blockTodoTaskByWorker = blockTodoTaskByWorker;
193
+ exports.markTodoTaskRuntimeBlocked = markTodoTaskRuntimeBlocked;
159
194
  exports.deleteTodoTask = deleteTodoTask;
160
195
  exports.reorderTodoTask = reorderTodoTask;
161
196
  exports.listTodoAudit = listTodoAudit;
@@ -163,6 +198,7 @@ exports.moveNullTodoTasksToUnassigned = moveNullTodoTasksToUnassigned;
163
198
  exports.getLatestActiveTodoForConversation = getLatestActiveTodoForConversation;
164
199
  exports.getNextActiveTodoForConversation = getNextActiveTodoForConversation;
165
200
  exports.listCompletedTodoTasksForConversation = listCompletedTodoTasksForConversation;
201
+ exports.listActiveTodoTasksForConversation = listActiveTodoTasksForConversation;
166
202
  exports.createProject = createProject;
167
203
  exports.upsertProject = upsertProject;
168
204
  exports.getProject = getProject;
@@ -258,21 +294,102 @@ function newId() {
258
294
  function now() {
259
295
  return new Date().toISOString().replace('T', ' ').replace('Z', '');
260
296
  }
297
+ function defaultOrchestrationIncludeUserPrompt(_fields) {
298
+ // Every worker role gets the original user prompt by default.
299
+ // april19fixes.txt item 3 fix (IV): previously only verify/verification
300
+ // workers received it, which meant Ben/John/Brain flew blind on a
301
+ // generic "Complete X's workflow step" task whenever the handoff
302
+ // lacked detail. The orchestrator's task_instructions is still the
303
+ // primary context source; this default just ensures the original
304
+ // user message is always available as a fallback reference.
305
+ return true;
306
+ }
307
+ function getAgentOrchestrationRoleClass(agent) {
308
+ const roleClass = String(agent?.orchestration_role_class
309
+ || agent?.orchestration_role_label
310
+ || agent?.role_class
311
+ || agent?.role_label
312
+ || '').trim();
313
+ return roleClass || null;
314
+ }
315
+ function getAgentOrchestrationRoleLabel(agent) {
316
+ const roleLabel = String(agent?.orchestration_role_label
317
+ || agent?.orchestration_role_class
318
+ || agent?.role_label
319
+ || agent?.role_class
320
+ || '').trim();
321
+ return roleLabel || null;
322
+ }
323
+ /**
324
+ * Read a bot's role priority list, dual-reading new and old columns.
325
+ * Used everywhere role-based dispatch or display is needed.
326
+ *
327
+ * Reads `orchestration_roles_json` first (authoritative). If empty/missing,
328
+ * falls back to parsing `orchestration_role_class` (comma-separated, left-
329
+ * to-right priority). Returns empty array if neither source has content.
330
+ *
331
+ * Normalization (per Decision 2): trim each entry, drop empties. Does NOT
332
+ * lower-case or canonicalize the role text — comparisons are the caller's
333
+ * responsibility.
334
+ */
335
+ function getAgentRolePriorities(row) {
336
+ const raw = (row.orchestration_roles_json || '').trim();
337
+ if (raw) {
338
+ try {
339
+ const parsed = JSON.parse(raw);
340
+ if (Array.isArray(parsed)) {
341
+ return parsed.map((entry) => String(entry || '').trim()).filter(Boolean);
342
+ }
343
+ }
344
+ catch {
345
+ // fall through to legacy parse
346
+ }
347
+ }
348
+ const legacy = (row.orchestration_role_class || '').trim();
349
+ if (!legacy)
350
+ return [];
351
+ return legacy.split(',').map((s) => s.trim()).filter(Boolean);
352
+ }
353
+ /**
354
+ * Compare two role strings for equality per Decision 2 matching rules:
355
+ * case-insensitive + whitespace-normalized. NO alias table.
356
+ * "QA" == "qa" == " QA " == "qa ". "QA" != "Quality Assurance".
357
+ */
358
+ function rolesMatch(a, b) {
359
+ const na = String(a || '').trim().replace(/\s+/g, ' ').toLowerCase();
360
+ const nb = String(b || '').trim().replace(/\s+/g, ' ').toLowerCase();
361
+ if (!na || !nb)
362
+ return false;
363
+ return na === nb;
364
+ }
365
+ const VALID_CODEX_REASONING_SUMMARIES = new Set(['auto', 'concise', 'detailed', 'none']);
366
+ const VALID_CLAUDE_OUTPUT_STYLES = new Set(['default', 'explanatory']);
367
+ function normalizeCodexReasoningSummary(value) {
368
+ const normalized = String(value || '').trim().toLowerCase();
369
+ return VALID_CODEX_REASONING_SUMMARIES.has(normalized) ? normalized : 'detailed';
370
+ }
371
+ function normalizeClaudeOutputStyle(value) {
372
+ const normalized = String(value || '').trim().toLowerCase();
373
+ return VALID_CLAUDE_OUTPUT_STYLES.has(normalized) ? normalized : 'default';
374
+ }
261
375
  function createAgentProfile(p) {
262
376
  const db = getDb();
263
- const id = newId();
377
+ const id = p.id ?? newId();
264
378
  const ts = now();
265
379
  const insert = db.prepare(`
266
380
  INSERT INTO agent_profile (
267
381
  id, provider, model, name, soul_md, memory_md, tools_md, skills_md, api_key_enc,
268
- permission_mode, is_default, role_label, role_class, is_active, priority,
382
+ permission_mode, is_default, role_label, role_class, orchestration_role_label, orchestration_role_class,
383
+ orchestration_include_user_prompt, is_active, priority,
269
384
  provider_connection_id, color, purpose_md, identity_summary, final_prompt,
270
385
  enabled_builtin_tools_json, enabled_mcp_tools_json, show_thinking, is_orchestrator,
271
386
  codex_reasoning_effort, codex_reasoning_summary, codex_personality,
272
387
  codex_service_tier, codex_sandbox_policy, codex_approval_policy,
388
+ claude_effort_level, claude_output_style, claude_fast_mode, claude_permissions_json,
389
+ orchestration_roles_json,
273
390
  created_at, updated_at
274
391
  )
275
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?)
392
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
276
393
  `);
277
394
  const transaction = db.transaction(() => {
278
395
  if (p.isDefault) {
@@ -282,7 +399,16 @@ function createAgentProfile(p) {
282
399
  db.prepare("UPDATE agent_profile SET is_orchestrator = 0, updated_at = datetime('now') WHERE is_orchestrator = 1").run();
283
400
  setSetting(CLERK_IS_ORCHESTRATOR_KEY, 'false');
284
401
  }
285
- insert.run(id, p.provider, p.model, p.name, p.soulMd ?? null, p.memoryMd ?? null, p.toolsMd ?? null, p.skillsMd ?? null, p.apiKeyEnc ?? null, (0, approval_1.normalizePermissionMode)(p.permissionMode ?? default_tool_profile_1.DEFAULT_PERMISSION_MODE), p.isDefault ? 1 : 0, p.roleLabel ?? null, p.roleClass ?? null, p.isActive !== false ? 1 : 0, p.priority ?? 100, p.providerConnectionId ?? null, p.color ?? null, p.purposeMd ?? null, p.identitySummary ?? null, p.finalPrompt ?? null, p.enabledBuiltinToolsJson ?? null, p.enabledMcpToolsJson ?? null, p.isOrchestrator ? 1 : 0, p.codexReasoningEffort ?? 'none', p.codexReasoningSummary ?? 'detailed', p.codexPersonality ?? 'friendly', p.codexServiceTier ?? 'fast', p.codexSandboxPolicy ?? 'danger-full-access', p.codexApprovalPolicy ?? 'never', ts, ts);
402
+ // Dual-write: if caller provided a role priority list, also derive the
403
+ // legacy comma-separated `orchestration_role_class` value for compat.
404
+ // Priority 0 item becomes the primary for downstream code that still
405
+ // reads the old column directly.
406
+ const rolesList = Array.isArray(p.orchestrationRolesJson)
407
+ ? p.orchestrationRolesJson.map((s) => String(s || '').trim()).filter(Boolean)
408
+ : null;
409
+ const rolesJsonStored = rolesList && rolesList.length > 0 ? JSON.stringify(rolesList) : null;
410
+ const derivedRoleClass = rolesList && rolesList.length > 0 ? rolesList.join(', ') : null;
411
+ insert.run(id, p.provider, p.model, p.name, p.soulMd ?? null, p.memoryMd ?? null, p.toolsMd ?? null, p.skillsMd ?? null, p.apiKeyEnc ?? null, (0, approval_1.normalizePermissionMode)(p.permissionMode ?? default_tool_profile_1.DEFAULT_PERMISSION_MODE), p.isDefault ? 1 : 0, p.roleLabel ?? null, p.roleClass ?? null, p.orchestrationRoleLabel ?? p.roleLabel ?? null, p.orchestrationRoleClass ?? derivedRoleClass ?? p.roleClass ?? null, (p.orchestrationIncludeUserPrompt ?? defaultOrchestrationIncludeUserPrompt(p)) ? 1 : 0, p.isActive !== false ? 1 : 0, p.priority ?? 100, p.providerConnectionId ?? null, p.color ?? null, p.purposeMd ?? null, p.identitySummary ?? null, p.finalPrompt ?? null, p.enabledBuiltinToolsJson ?? null, p.enabledMcpToolsJson ?? null, 0, p.isOrchestrator ? 1 : 0, p.codexReasoningEffort ?? 'low', normalizeCodexReasoningSummary(p.codexReasoningSummary), p.codexPersonality ?? 'friendly', p.codexServiceTier ?? 'fast', p.codexSandboxPolicy ?? 'danger-full-access', p.codexApprovalPolicy ?? 'never', p.claudeEffortLevel ?? 'auto', normalizeClaudeOutputStyle(p.claudeOutputStyle), p.claudeFastMode ? 1 : 0, p.claudePermissionsJson ?? null, rolesJsonStored, ts, ts);
286
412
  });
287
413
  transaction();
288
414
  return db.prepare('SELECT * FROM agent_profile WHERE id = ?').get(id);
@@ -298,10 +424,11 @@ function getOrchestratorBot() {
298
424
  }
299
425
  function getCurrentOrchestratorSelection() {
300
426
  if (isClerkOrchestratorEnabled()) {
427
+ const clerk = getResolvedClerkConfigInfo();
301
428
  return {
302
429
  type: 'clerk',
303
- provider: getSetting('clerk_provider') || null,
304
- model: getSetting('clerk_model') || null,
430
+ provider: clerk.provider,
431
+ model: clerk.model,
305
432
  };
306
433
  }
307
434
  const bot = getOrchestratorBot();
@@ -310,6 +437,32 @@ function getCurrentOrchestratorSelection() {
310
437
  }
311
438
  return { type: 'none' };
312
439
  }
440
+ function getResolvedClerkConfigInfo() {
441
+ const clerkConfig = getJsonSetting('clerk.config');
442
+ const providerConnectionId = clerkConfig?.providerConnectionId
443
+ ? String(clerkConfig.providerConnectionId).trim()
444
+ : '';
445
+ const connection = providerConnectionId ? getProviderConnection(providerConnectionId) : undefined;
446
+ const provider = String(connection?.provider_id
447
+ || clerkConfig?.providerId
448
+ || getSetting('clerk_provider')
449
+ || '').trim() || null;
450
+ const model = String(clerkConfig?.model
451
+ || connection?.default_model
452
+ || getSetting('clerk_model')
453
+ || '').trim() || null;
454
+ const hasSecret = Boolean(connection?.access_mode === 'cli'
455
+ || connection?.api_key_enc
456
+ || connection?.oauth_token
457
+ || getSetting('clerk_api_key'));
458
+ return {
459
+ provider,
460
+ model,
461
+ hasSecret,
462
+ configured: Boolean(provider && model && hasSecret),
463
+ providerConnectionId: providerConnectionId || null,
464
+ };
465
+ }
313
466
  function listAgentProfiles() {
314
467
  return getDb().prepare('SELECT * FROM agent_profile ORDER BY is_default DESC, created_at ASC').all();
315
468
  }
@@ -409,6 +562,18 @@ function updateAgentProfile(id, fields) {
409
562
  sets.push('role_class = ?');
410
563
  vals.push(fields.roleClass);
411
564
  }
565
+ if (fields.orchestrationRoleLabel !== undefined) {
566
+ sets.push('orchestration_role_label = ?');
567
+ vals.push(fields.orchestrationRoleLabel);
568
+ }
569
+ if (fields.orchestrationRoleClass !== undefined) {
570
+ sets.push('orchestration_role_class = ?');
571
+ vals.push(fields.orchestrationRoleClass);
572
+ }
573
+ if (fields.orchestrationIncludeUserPrompt !== undefined) {
574
+ sets.push('orchestration_include_user_prompt = ?');
575
+ vals.push(fields.orchestrationIncludeUserPrompt ? 1 : 0);
576
+ }
412
577
  if (fields.isActive !== undefined) {
413
578
  sets.push('is_active = ?');
414
579
  vals.push(fields.isActive ? 1 : 0);
@@ -459,7 +624,7 @@ function updateAgentProfile(id, fields) {
459
624
  }
460
625
  if (fields.codexReasoningSummary !== undefined) {
461
626
  sets.push('codex_reasoning_summary = ?');
462
- vals.push(fields.codexReasoningSummary);
627
+ vals.push(normalizeCodexReasoningSummary(fields.codexReasoningSummary));
463
628
  }
464
629
  if (fields.codexPersonality !== undefined) {
465
630
  sets.push('codex_personality = ?');
@@ -477,6 +642,39 @@ function updateAgentProfile(id, fields) {
477
642
  sets.push('codex_approval_policy = ?');
478
643
  vals.push(fields.codexApprovalPolicy);
479
644
  }
645
+ if (fields.claudeEffortLevel !== undefined) {
646
+ sets.push('claude_effort_level = ?');
647
+ vals.push(fields.claudeEffortLevel);
648
+ }
649
+ if (fields.claudeOutputStyle !== undefined) {
650
+ sets.push('claude_output_style = ?');
651
+ vals.push(normalizeClaudeOutputStyle(fields.claudeOutputStyle));
652
+ }
653
+ if (fields.claudeFastMode !== undefined) {
654
+ sets.push('claude_fast_mode = ?');
655
+ vals.push(fields.claudeFastMode ? 1 : 0);
656
+ }
657
+ if (fields.claudePermissionsJson !== undefined) {
658
+ sets.push('claude_permissions_json = ?');
659
+ vals.push(fields.claudePermissionsJson);
660
+ }
661
+ if (fields.orchestrationRolesJson !== undefined) {
662
+ // Dual-write: update both the new JSON column and the legacy comma-separated
663
+ // orchestration_role_class so any code still reading the old column stays
664
+ // consistent until Phase B drops it.
665
+ const rolesList = Array.isArray(fields.orchestrationRolesJson)
666
+ ? fields.orchestrationRolesJson.map((s) => String(s || '').trim()).filter(Boolean)
667
+ : null;
668
+ const rolesJsonStored = rolesList && rolesList.length > 0 ? JSON.stringify(rolesList) : null;
669
+ const derivedRoleClass = rolesList && rolesList.length > 0 ? rolesList.join(', ') : null;
670
+ sets.push('orchestration_roles_json = ?');
671
+ vals.push(rolesJsonStored);
672
+ // Only override orchestration_role_class if the caller didn't explicitly pass one this update.
673
+ if (fields.orchestrationRoleClass === undefined) {
674
+ sets.push('orchestration_role_class = ?');
675
+ vals.push(derivedRoleClass);
676
+ }
677
+ }
480
678
  if (sets.length === 0)
481
679
  return row;
482
680
  const transaction = db.transaction(() => {
@@ -522,6 +720,25 @@ function setDefaultAgentProfile(botId) {
522
720
  function isClerkOrchestratorEnabled() {
523
721
  return getSetting(CLERK_IS_ORCHESTRATOR_KEY) === 'true';
524
722
  }
723
+ /**
724
+ * Phase B of orchestration-plan.txt: LLM-tool-dispatch is the only
725
+ * orchestration path in local_desktop mode. The feature flag introduced
726
+ * in Phase A has been retired — new path is always active, no user toggle.
727
+ * These helpers are kept for a brief deprecation window so any external
728
+ * callers (settings UI, HTTP endpoints) still compile while they are
729
+ * removed. Do not use in new code.
730
+ *
731
+ * @deprecated Phase B — tool dispatch is the only path.
732
+ */
733
+ function isOrchestrationToolDispatchEnabled() {
734
+ return true;
735
+ }
736
+ /**
737
+ * @deprecated Phase B — no-op. Tool dispatch is the only path.
738
+ */
739
+ function setOrchestrationToolDispatchEnabled(_enabled) {
740
+ // no-op after Phase B
741
+ }
525
742
  function setClerkAsOrchestrator(enabled) {
526
743
  const db = getDb();
527
744
  const transaction = db.transaction(() => {
@@ -533,7 +750,7 @@ function setClerkAsOrchestrator(enabled) {
533
750
  transaction();
534
751
  }
535
752
  const CLI_PROVIDER_IDS = new Set(['claude-cli', 'codex-cli']);
536
- const SUPPORTED_PROVIDER_ACCESS_MODES = new Set(['api', 'cli']);
753
+ const SUPPORTED_PROVIDER_ACCESS_MODES = new Set(['api', 'apiKey', 'cli']);
537
754
  function createProviderConnection(p) {
538
755
  const db = getDb();
539
756
  const id = newId();
@@ -734,12 +951,40 @@ function deleteAgentProfile(id) {
734
951
  tx();
735
952
  return true;
736
953
  }
954
+ function normalizeConversationBotIds(botIds, fallbackBotId) {
955
+ const next = new Set();
956
+ for (const botId of botIds) {
957
+ const trimmed = String(botId || '').trim();
958
+ if (trimmed)
959
+ next.add(trimmed);
960
+ }
961
+ const fallback = String(fallbackBotId || '').trim();
962
+ if (next.size === 0 && fallback)
963
+ next.add(fallback);
964
+ return Array.from(next);
965
+ }
966
+ function parseConversationBotIds(raw) {
967
+ if (!raw)
968
+ return [];
969
+ try {
970
+ const parsed = JSON.parse(raw);
971
+ if (!Array.isArray(parsed))
972
+ return [];
973
+ return normalizeConversationBotIds(parsed.map((value) => (typeof value === 'string' ? value : null)));
974
+ }
975
+ catch {
976
+ return [];
977
+ }
978
+ }
737
979
  function createConversation(agentId, title, source, opts) {
738
980
  const db = getDb();
739
981
  const id = newId();
740
982
  const ts = now();
741
983
  let projectId = opts?.projectId ?? null;
742
984
  let projectName = opts?.projectName ?? null;
985
+ const initialBotId = String(opts?.initialBotId || agentId || '').trim() || agentId;
986
+ const activeBotIds = normalizeConversationBotIds(opts?.botIds || [], initialBotId);
987
+ const activeBotIdsJson = JSON.stringify(activeBotIds);
743
988
  if (projectId) {
744
989
  const project = getProject(projectId);
745
990
  if (project) {
@@ -757,10 +1002,10 @@ function createConversation(agentId, title, source, opts) {
757
1002
  projectName = unassigned.name;
758
1003
  }
759
1004
  db.prepare(`
760
- INSERT INTO conversation (id, agent_id, title, source, project_id, project_name, created_at, updated_at)
761
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
762
- `).run(id, agentId, title ?? null, source ?? 'local', projectId, projectName, ts, ts);
763
- return db.prepare('SELECT * FROM conversation WHERE id = ?').get(id);
1005
+ INSERT INTO conversation (id, agent_id, initial_bot_id, active_bot_ids_json, title, source, project_id, project_name, sync_revision, created_at, updated_at)
1006
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)
1007
+ `).run(id, agentId, initialBotId, activeBotIdsJson, title ?? null, source ?? 'local', projectId, projectName, ts, ts);
1008
+ return getConversation(id);
764
1009
  }
765
1010
  function latestConversationSummarySql(conversationAlias) {
766
1011
  return `(
@@ -854,14 +1099,42 @@ function conversationSelectSql(conversationAlias) {
854
1099
  function hydrateConversationRow(row) {
855
1100
  if (!row)
856
1101
  return undefined;
1102
+ const activeBotIds = parseConversationBotIds(row.active_bot_ids_json);
857
1103
  return {
858
1104
  ...row,
1105
+ initial_bot_id: row.initial_bot_id ?? null,
1106
+ active_bot_ids_json: row.active_bot_ids_json ?? null,
1107
+ active_bot_ids: activeBotIds,
859
1108
  title: row.resolved_title ?? row.title,
860
1109
  summary: row.resolved_summary ?? row.summary,
861
1110
  created_at: row.effective_created_at ?? row.created_at,
862
1111
  updated_at: row.effective_updated_at ?? row.updated_at,
863
1112
  };
864
1113
  }
1114
+ function getConversationParticipantBotIds(conversationId, conversation) {
1115
+ const conv = conversation || getConversation(conversationId);
1116
+ const fromConversation = normalizeConversationBotIds(conv?.active_bot_ids || []);
1117
+ const rows = getDb().prepare(`
1118
+ SELECT DISTINCT bot_id
1119
+ FROM message
1120
+ WHERE conversation_id = ?
1121
+ AND bot_id IS NOT NULL
1122
+ AND trim(bot_id) <> ''
1123
+ ORDER BY bot_id
1124
+ `).all(conversationId);
1125
+ return normalizeConversationBotIds([...fromConversation, ...rows.map((row) => row.bot_id)], conv?.initial_bot_id || conv?.agent_id || null);
1126
+ }
1127
+ function getConversationPrimaryBotId(conversationId, conversation) {
1128
+ const conv = conversation || getConversation(conversationId);
1129
+ const initialBotId = String(conv?.initial_bot_id || '').trim();
1130
+ if (initialBotId)
1131
+ return initialBotId;
1132
+ const participantIds = getConversationParticipantBotIds(conversationId, conv);
1133
+ if (participantIds.length > 0)
1134
+ return participantIds[0];
1135
+ const legacyBotId = String(conv?.agent_id || '').trim();
1136
+ return legacyBotId || null;
1137
+ }
865
1138
  function getConversation(id) {
866
1139
  return hydrateConversationRow(getDb().prepare(`${conversationSelectSql('c')} WHERE c.id = ?`).get(id));
867
1140
  }
@@ -874,15 +1147,18 @@ function listConversations(opts) {
874
1147
  const wheres = [];
875
1148
  if (opts?.agentId) {
876
1149
  wheres.push(`(
877
- c.agent_id = ?
1150
+ coalesce(c.active_bot_ids_json, '[]') LIKE ?
878
1151
  OR EXISTS (
879
1152
  SELECT 1
880
1153
  FROM message m
881
1154
  WHERE m.conversation_id = c.id
882
1155
  AND m.bot_id = ?
883
1156
  )
1157
+ OR c.initial_bot_id = ?
1158
+ OR c.agent_id = ?
884
1159
  )`);
885
- params.push(opts.agentId, opts.agentId);
1160
+ const botNeedle = `%\"${opts.agentId}\"%`;
1161
+ params.push(botNeedle, opts.agentId, opts.agentId, opts.agentId);
886
1162
  }
887
1163
  if (opts?.search) {
888
1164
  wheres.push(`(
@@ -912,14 +1188,16 @@ function countConversations(agentId) {
912
1188
  return db.prepare(`
913
1189
  SELECT count(DISTINCT c.id) as cnt
914
1190
  FROM conversation c
915
- WHERE c.agent_id = ?
1191
+ WHERE coalesce(c.active_bot_ids_json, '[]') LIKE ?
916
1192
  OR EXISTS (
917
1193
  SELECT 1
918
1194
  FROM message m
919
1195
  WHERE m.conversation_id = c.id
920
1196
  AND m.bot_id = ?
921
1197
  )
922
- `).get(agentId, agentId).cnt;
1198
+ OR c.initial_bot_id = ?
1199
+ OR c.agent_id = ?
1200
+ `).get(`%\"${agentId}\"%`, agentId, agentId, agentId).cnt;
923
1201
  }
924
1202
  return db.prepare('SELECT count(*) as cnt FROM conversation').get().cnt;
925
1203
  }
@@ -930,6 +1208,74 @@ function countMessagesForBot(botId) {
930
1208
  WHERE bot_id = ?
931
1209
  `).get(botId).cnt;
932
1210
  }
1211
+ function botConversationMembershipSql(alias) {
1212
+ return `
1213
+ (
1214
+ coalesce(${alias}.active_bot_ids_json, '[]') LIKE ?
1215
+ OR EXISTS (
1216
+ SELECT 1
1217
+ FROM message m
1218
+ WHERE m.conversation_id = ${alias}.id
1219
+ AND m.bot_id = ?
1220
+ )
1221
+ OR ${alias}.initial_bot_id = ?
1222
+ OR ${alias}.agent_id = ?
1223
+ )
1224
+ `;
1225
+ }
1226
+ function botConversationMembershipParams(botId) {
1227
+ return [`%\"${botId}\"%`, botId, botId, botId];
1228
+ }
1229
+ function countMessagesForBotSince(botId, since) {
1230
+ return getDb().prepare(`
1231
+ SELECT count(*) as cnt
1232
+ FROM message
1233
+ WHERE bot_id = ?
1234
+ AND datetime(created_at) >= datetime(?)
1235
+ `).get(botId, since).cnt;
1236
+ }
1237
+ function countDecisionsForBot(botId) {
1238
+ const membershipSql = botConversationMembershipSql('c');
1239
+ const params = botConversationMembershipParams(botId);
1240
+ return getDb().prepare(`
1241
+ SELECT count(*) as cnt
1242
+ FROM decision d
1243
+ JOIN conversation c ON c.id = d.conversation_id
1244
+ WHERE ${membershipSql}
1245
+ `).get(...params).cnt;
1246
+ }
1247
+ function countBotActiveDays(botId) {
1248
+ const membershipSql = botConversationMembershipSql('c');
1249
+ const params = botConversationMembershipParams(botId);
1250
+ return getDb().prepare(`
1251
+ SELECT count(DISTINCT date(c.updated_at)) as cnt
1252
+ FROM conversation c
1253
+ WHERE ${membershipSql}
1254
+ `).get(...params).cnt;
1255
+ }
1256
+ function countBotConversationsUpdatedBetween(botId, from, to) {
1257
+ const membershipSql = botConversationMembershipSql('c');
1258
+ const params = [...botConversationMembershipParams(botId), from];
1259
+ let sql = `
1260
+ SELECT count(*) as cnt
1261
+ FROM conversation c
1262
+ WHERE ${membershipSql}
1263
+ AND datetime(c.updated_at) >= datetime(?)
1264
+ `;
1265
+ if (to) {
1266
+ sql += ` AND datetime(c.updated_at) < datetime(?)`;
1267
+ params.push(to);
1268
+ }
1269
+ return getDb().prepare(sql).get(...params).cnt;
1270
+ }
1271
+ function sumBotTokenUsageSince(botId, since) {
1272
+ return Number(getDb().prepare(`
1273
+ SELECT coalesce(sum(token_count), 0) as total
1274
+ FROM message
1275
+ WHERE bot_id = ?
1276
+ AND datetime(created_at) >= datetime(?)
1277
+ `).get(botId, since).total ?? 0);
1278
+ }
933
1279
  function listBotConversationActivity(botId, limit = 10) {
934
1280
  const db = getDb();
935
1281
  return db.prepare(`
@@ -950,18 +1296,20 @@ function listBotConversationActivity(botId, limit = 10) {
950
1296
  FROM (
951
1297
  ${conversationSelectSql('c')}
952
1298
  WHERE (
953
- c.agent_id = ?
1299
+ coalesce(c.active_bot_ids_json, '[]') LIKE ?
954
1300
  OR EXISTS (
955
1301
  SELECT 1
956
1302
  FROM message m
957
1303
  WHERE m.conversation_id = c.id
958
1304
  AND m.bot_id = ?
959
1305
  )
1306
+ OR c.initial_bot_id = ?
1307
+ OR c.agent_id = ?
960
1308
  )
961
1309
  ) conv
962
1310
  ORDER BY COALESCE(bot_last_message_at, conv.updated_at, conv.created_at) DESC
963
1311
  LIMIT ?
964
- `).all(botId, botId, botId, botId, limit).map((row) => hydrateConversationRow(row));
1312
+ `).all(botId, botId, `%\"${botId}\"%`, botId, botId, botId, limit).map((row) => hydrateConversationRow(row));
965
1313
  }
966
1314
  function updateConversation(id, fields) {
967
1315
  const db = getDb();
@@ -1003,6 +1351,8 @@ function updateConversation(id, fields) {
1003
1351
  processingError: ['processing_error', fields.processingError],
1004
1352
  botId: ['bot_id', fields.botId],
1005
1353
  forkedFromId: ['forked_from_id', fields.forkedFromId],
1354
+ initialBotId: ['initial_bot_id', fields.initialBotId],
1355
+ activeBotIdsJson: ['active_bot_ids_json', fields.activeBotIdsJson],
1006
1356
  createdAt: ['created_at', fields.createdAt],
1007
1357
  updatedAt: ['updated_at', fields.updatedAt],
1008
1358
  };
@@ -1028,6 +1378,21 @@ function updateConversation(id, fields) {
1028
1378
  vals.push(id);
1029
1379
  db.prepare(`UPDATE conversation SET ${sets.join(', ')} WHERE id = ?`).run(...vals);
1030
1380
  }
1381
+ function syncConversationParticipants(conversationId, botIds, opts) {
1382
+ const conversation = getConversation(conversationId);
1383
+ if (!conversation)
1384
+ return;
1385
+ const normalized = normalizeConversationBotIds(botIds, opts?.initialBotId || conversation.initial_bot_id || conversation.agent_id || null);
1386
+ if (normalized.length === 0)
1387
+ return;
1388
+ const current = normalizeConversationBotIds(conversation.active_bot_ids || [], conversation.initial_bot_id || conversation.agent_id || null);
1389
+ const nextIds = opts?.replace ? normalized : Array.from(new Set([...current, ...normalized]));
1390
+ const nextInitialBotId = String(opts?.initialBotId || conversation.initial_bot_id || conversation.agent_id || nextIds[0] || '').trim() || null;
1391
+ updateConversation(conversationId, {
1392
+ activeBotIdsJson: JSON.stringify(nextIds),
1393
+ initialBotId: nextInitialBotId || undefined,
1394
+ });
1395
+ }
1031
1396
  function touchConversationActivity(id, ts) {
1032
1397
  const stamp = ts?.trim() || now();
1033
1398
  getDb().prepare(`
@@ -1039,10 +1404,46 @@ function touchConversationActivity(id, ts) {
1039
1404
  WHERE id = ?
1040
1405
  `).run(stamp, stamp, id);
1041
1406
  }
1407
+ function bumpConversationSyncRevision(id, ts) {
1408
+ const stamp = ts?.trim() || now();
1409
+ getDb().prepare(`
1410
+ UPDATE conversation
1411
+ SET sync_revision = coalesce(sync_revision, 0) + 1,
1412
+ updated_at = CASE
1413
+ WHEN datetime(updated_at) > datetime(?) THEN updated_at
1414
+ ELSE ?
1415
+ END
1416
+ WHERE id = ?
1417
+ `).run(stamp, stamp, id);
1418
+ const row = getDb().prepare('SELECT sync_revision FROM conversation WHERE id = ?').get(id);
1419
+ return Number(row?.sync_revision ?? 0);
1420
+ }
1042
1421
  function deleteConversation(id) {
1043
1422
  return getDb().prepare('DELETE FROM conversation WHERE id = ?').run(id).changes > 0;
1044
1423
  }
1045
- function addMessage(conversationId, role, content, model, toolCallsJson, botId, agentName, createdAt) {
1424
+ function toWarmSessionBindingKey(conversationId, botId) {
1425
+ return `${conversationId}::${botId}`;
1426
+ }
1427
+ function parseWarmSessionBindingKey(key) {
1428
+ const idx = key.indexOf('::');
1429
+ if (idx < 0)
1430
+ return null;
1431
+ return { conversationId: key.slice(0, idx), botId: key.slice(idx + 2) };
1432
+ }
1433
+ function epochRowToWarmInfo(row) {
1434
+ return {
1435
+ conversationId: row.conversation_id,
1436
+ sessionScopeKey: row.session_scope_key,
1437
+ botId: row.bot_id,
1438
+ provider: row.provider,
1439
+ sessionId: row.session_id,
1440
+ topicId: row.topic_id,
1441
+ epochTurnCount: row.epoch_turn_count,
1442
+ lastUsedAt: row.last_used_at,
1443
+ resetReason: row.reset_reason,
1444
+ };
1445
+ }
1446
+ function addMessage(conversationId, role, content, model, toolCallsJson, botId, agentName, createdAt, attachmentsJson) {
1046
1447
  const db = getDb();
1047
1448
  const id = newId();
1048
1449
  const ts = createdAt?.trim() || now();
@@ -1050,9 +1451,9 @@ function addMessage(conversationId, role, content, model, toolCallsJson, botId,
1050
1451
  const maxSeq = db.prepare('SELECT COALESCE(MAX(seq), 0) as m FROM message WHERE conversation_id = ?').get(conversationId);
1051
1452
  const seq = maxSeq.m + 1;
1052
1453
  db.prepare(`
1053
- INSERT INTO message (id, conversation_id, seq, role, content, model, tool_calls_json, bot_id, agent_name, created_at)
1054
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1055
- `).run(id, conversationId, seq, role, content, model ?? null, toolCallsJson ?? null, botId ?? null, agentName ?? null, ts);
1454
+ INSERT INTO message (id, conversation_id, seq, role, content, attachments_json, model, tool_calls_json, bot_id, agent_name, created_at)
1455
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1456
+ `).run(id, conversationId, seq, role, content, attachmentsJson ?? null, model ?? null, toolCallsJson ?? null, botId ?? null, agentName ?? null, ts);
1056
1457
  // Update conversation message_count and updated_at
1057
1458
  db.prepare(`
1058
1459
  UPDATE conversation
@@ -1076,6 +1477,10 @@ function updateMessage(id, fields) {
1076
1477
  sets.push('content = ?');
1077
1478
  vals.push(fields.content);
1078
1479
  }
1480
+ if (fields.attachmentsJson !== undefined) {
1481
+ sets.push('attachments_json = ?');
1482
+ vals.push(fields.attachmentsJson);
1483
+ }
1079
1484
  if (fields.model !== undefined) {
1080
1485
  sets.push('model = ?');
1081
1486
  vals.push(fields.model);
@@ -1123,29 +1528,37 @@ function createChatJob(input) {
1123
1528
  `).run(id, input.conversationId, input.userMessageId, input.assistantMessageId, input.botId, input.status ?? 'queued', input.requestJson ?? null, ts, ts);
1124
1529
  return db.prepare('SELECT * FROM chat_job WHERE id = ?').get(id);
1125
1530
  }
1126
- function getCliSessionEpoch(conversationId, botId) {
1531
+ function normalizeCliSessionScopeKey(conversationId, sessionScopeKey) {
1532
+ const trimmed = String(sessionScopeKey || '').trim();
1533
+ return trimmed || conversationId;
1534
+ }
1535
+ function getCliSessionEpoch(conversationId, botId, sessionScopeKey) {
1536
+ const scopeKey = normalizeCliSessionScopeKey(conversationId, sessionScopeKey);
1127
1537
  return getDb().prepare(`
1128
1538
  SELECT *
1129
1539
  FROM cli_session_epoch
1130
- WHERE conversation_id = ? AND bot_id = ?
1540
+ WHERE session_scope_key = ? AND bot_id = ?
1131
1541
  LIMIT 1
1132
- `).get(conversationId, botId);
1542
+ `).get(scopeKey, botId);
1133
1543
  }
1134
1544
  function upsertCliSessionEpoch(input) {
1135
1545
  const db = getDb();
1136
- const existing = getCliSessionEpoch(input.conversationId, input.botId);
1546
+ const scopeKey = normalizeCliSessionScopeKey(input.conversationId, input.sessionScopeKey);
1547
+ const existing = getCliSessionEpoch(input.conversationId, input.botId, scopeKey);
1137
1548
  const id = existing?.id || newId();
1138
1549
  const epochStartedAt = input.epochStartedAt || existing?.epoch_started_at || now();
1139
1550
  const lastUsedAt = input.lastUsedAt || now();
1551
+ const topicId = input.topicId !== undefined ? (input.topicId || null) : (existing?.topic_id ?? null);
1140
1552
  db.prepare(`
1141
1553
  INSERT INTO cli_session_epoch (
1142
- id, conversation_id, bot_id, provider, session_id, epoch_turn_count,
1554
+ id, conversation_id, session_scope_key, bot_id, provider, session_id, topic_id, epoch_turn_count,
1143
1555
  last_input_tokens, last_output_tokens, epoch_started_at, last_used_at,
1144
1556
  reset_reason, created_at, updated_at
1145
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1146
- ON CONFLICT(conversation_id, bot_id) DO UPDATE SET
1557
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1558
+ ON CONFLICT(session_scope_key, bot_id) DO UPDATE SET
1147
1559
  provider = excluded.provider,
1148
1560
  session_id = excluded.session_id,
1561
+ topic_id = excluded.topic_id,
1149
1562
  epoch_turn_count = excluded.epoch_turn_count,
1150
1563
  last_input_tokens = excluded.last_input_tokens,
1151
1564
  last_output_tokens = excluded.last_output_tokens,
@@ -1153,14 +1566,42 @@ function upsertCliSessionEpoch(input) {
1153
1566
  last_used_at = excluded.last_used_at,
1154
1567
  reset_reason = excluded.reset_reason,
1155
1568
  updated_at = excluded.updated_at
1156
- `).run(id, input.conversationId, input.botId, input.provider, input.sessionId, input.epochTurnCount, input.lastInputTokens ?? null, input.lastOutputTokens ?? null, epochStartedAt, lastUsedAt, input.resetReason ?? null, existing?.created_at || now(), now());
1157
- return getCliSessionEpoch(input.conversationId, input.botId);
1569
+ `).run(id, input.conversationId, scopeKey, input.botId, input.provider, input.sessionId, topicId, input.epochTurnCount, input.lastInputTokens ?? null, input.lastOutputTokens ?? null, epochStartedAt, lastUsedAt, input.resetReason ?? null, existing?.created_at || now(), now());
1570
+ return getCliSessionEpoch(input.conversationId, input.botId, scopeKey);
1571
+ }
1572
+ function deleteCliSessionEpoch(conversationId, botId, sessionScopeKey) {
1573
+ const scopeKey = normalizeCliSessionScopeKey(conversationId, sessionScopeKey);
1574
+ return getDb().prepare(`
1575
+ DELETE FROM cli_session_epoch
1576
+ WHERE session_scope_key = ? AND bot_id = ?
1577
+ `).run(scopeKey, botId).changes > 0;
1578
+ }
1579
+ function clearCliSessionEpochIfMatches(conversationId, botId, sessionId, sessionScopeKey) {
1580
+ const normalizedSessionId = String(sessionId || '').trim();
1581
+ if (!normalizedSessionId)
1582
+ return false;
1583
+ const scopeKey = normalizeCliSessionScopeKey(conversationId, sessionScopeKey);
1584
+ return getDb().prepare(`
1585
+ DELETE FROM cli_session_epoch
1586
+ WHERE session_scope_key = ? AND bot_id = ? AND session_id = ?
1587
+ `).run(scopeKey, botId, normalizedSessionId).changes > 0;
1158
1588
  }
1159
- function deleteCliSessionEpoch(conversationId, botId) {
1589
+ function cleanupPoisonedClaudePrewarmSessionRows() {
1160
1590
  return getDb().prepare(`
1161
1591
  DELETE FROM cli_session_epoch
1162
- WHERE conversation_id = ? AND bot_id = ?
1163
- `).run(conversationId, botId).changes > 0;
1592
+ WHERE provider = 'claude-cli'
1593
+ AND epoch_turn_count = 0
1594
+ AND reset_reason = 'prewarm'
1595
+ AND session_id LIKE 'fff00000-%'
1596
+ `).run().changes;
1597
+ }
1598
+ function getCliSessionEpochsByConversation(conversationId) {
1599
+ return getDb().prepare(`
1600
+ SELECT *
1601
+ FROM cli_session_epoch
1602
+ WHERE conversation_id = ?
1603
+ ORDER BY last_used_at DESC
1604
+ `).all(conversationId);
1164
1605
  }
1165
1606
  /**
1166
1607
  * Generate the next sequential Funolio session ID.
@@ -1286,6 +1727,119 @@ function markRunningChatJobsInterrupted(reason = 'Interrupted when app closed')
1286
1727
  `).run(reason, ts, ts);
1287
1728
  return result.changes;
1288
1729
  }
1730
+ function recoverInterruptedChatJobs() {
1731
+ const db = getDb();
1732
+ const ts = now();
1733
+ const INTERRUPTED_NOTE = '\n\n[Response interrupted - retry to continue]';
1734
+ const ERROR_MSG = 'Agent was restarted during processing';
1735
+ const orphanedJobs = db.prepare(`
1736
+ SELECT id, status, assistant_message_id
1737
+ FROM chat_job
1738
+ WHERE status IN ('running', 'pending')
1739
+ `).all();
1740
+ if (orphanedJobs.length === 0) {
1741
+ return { recoveredJobs: [], totalRecovered: 0 };
1742
+ }
1743
+ const updateJob = db.prepare(`
1744
+ UPDATE chat_job
1745
+ SET status = 'interrupted',
1746
+ error = ?,
1747
+ updated_at = ?
1748
+ WHERE id = ?
1749
+ `);
1750
+ const getMsg = db.prepare('SELECT id, content FROM message WHERE id = ?');
1751
+ const appendNote = db.prepare('UPDATE message SET content = content || ? WHERE id = ?');
1752
+ const recoveredJobs = [];
1753
+ const runAll = db.transaction(() => {
1754
+ for (const job of orphanedJobs) {
1755
+ updateJob.run(ERROR_MSG, ts, job.id);
1756
+ if (job.assistant_message_id) {
1757
+ const msg = getMsg.get(job.assistant_message_id);
1758
+ if (msg && msg.content && msg.content.trim().length > 0) {
1759
+ appendNote.run(INTERRUPTED_NOTE, msg.id);
1760
+ }
1761
+ }
1762
+ recoveredJobs.push({ jobId: job.id, previousStatus: job.status });
1763
+ }
1764
+ });
1765
+ runAll();
1766
+ return { recoveredJobs, totalRecovered: recoveredJobs.length };
1767
+ }
1768
+ function recoverInterruptedWorkflowRuns(reason = 'Agent was restarted during workflow execution') {
1769
+ const db = getDb();
1770
+ const ts = now();
1771
+ const runningWorkflowRows = db.prepare(`
1772
+ SELECT id, conversation_id, status
1773
+ FROM workflow_execution
1774
+ WHERE status = 'running'
1775
+ `).all();
1776
+ const runningImportedRuns = db.prepare(`
1777
+ SELECT *
1778
+ FROM imported_plan_run
1779
+ WHERE status = 'running'
1780
+ `).all();
1781
+ const updateSteps = db.prepare(`
1782
+ UPDATE workflow_step_execution
1783
+ SET status = 'failed',
1784
+ error = CASE
1785
+ WHEN error IS NULL OR trim(error) = '' THEN ?
1786
+ ELSE error
1787
+ END,
1788
+ completed_at = COALESCE(completed_at, ?)
1789
+ WHERE workflow_execution_id = ?
1790
+ AND status = 'running'
1791
+ `);
1792
+ const updateWorkflow = db.prepare(`
1793
+ UPDATE workflow_execution
1794
+ SET status = 'failed',
1795
+ error = CASE
1796
+ WHEN error IS NULL OR trim(error) = '' THEN ?
1797
+ ELSE error
1798
+ END,
1799
+ completed_at = COALESCE(completed_at, ?)
1800
+ WHERE id = ?
1801
+ AND status = 'running'
1802
+ `);
1803
+ const updateImportedRun = db.prepare(`
1804
+ UPDATE imported_plan_run
1805
+ SET status = ?,
1806
+ completed_at = CASE WHEN ? = 'completed' THEN COALESCE(completed_at, ?) ELSE completed_at END,
1807
+ last_error = ?,
1808
+ updated_at = datetime('now')
1809
+ WHERE id = ?
1810
+ `);
1811
+ const recoveredWorkflowExecutions = [];
1812
+ const recoveredImportedPlanRuns = [];
1813
+ const runAll = db.transaction(() => {
1814
+ for (const workflow of runningWorkflowRows) {
1815
+ updateSteps.run(reason, ts, workflow.id);
1816
+ updateWorkflow.run(reason, ts, workflow.id);
1817
+ recoveredWorkflowExecutions.push({
1818
+ workflowId: workflow.id,
1819
+ conversationId: workflow.conversation_id,
1820
+ previousStatus: workflow.status,
1821
+ });
1822
+ }
1823
+ for (const rawRun of runningImportedRuns) {
1824
+ const run = normalizeImportedPlanRunRow(rawRun);
1825
+ const remainingTaskCount = getImportedPlanRunRemainingTaskIds(run.id).length;
1826
+ const nextStatus = remainingTaskCount > 0 ? 'paused' : 'completed';
1827
+ updateImportedRun.run(nextStatus, nextStatus, ts, remainingTaskCount > 0 ? reason : null, run.id);
1828
+ recoveredImportedPlanRuns.push({
1829
+ runId: run.id,
1830
+ conversationId: run.conversation_id,
1831
+ previousStatus: run.status,
1832
+ remainingTaskCount,
1833
+ });
1834
+ }
1835
+ });
1836
+ runAll();
1837
+ return {
1838
+ recoveredWorkflowExecutions,
1839
+ recoveredImportedPlanRuns,
1840
+ totalRecovered: recoveredWorkflowExecutions.length + recoveredImportedPlanRuns.length,
1841
+ };
1842
+ }
1289
1843
  function createMessageActivity(activity) {
1290
1844
  const db = getDb();
1291
1845
  const id = newId();
@@ -1864,6 +2418,274 @@ function updateImportJobItem(id, fields) {
1864
2418
  vals.push(id);
1865
2419
  db.prepare(`UPDATE import_job_item SET ${sets.join(', ')} WHERE id = ?`).run(...vals);
1866
2420
  }
2421
+ function getImportedPlanFirstKnownOrder(run) {
2422
+ const knownTasks = run.task_ids
2423
+ .map((taskId) => getTodoTask(taskId, 'active') || getTodoTask(taskId, 'completed'))
2424
+ .filter((task) => !!task);
2425
+ if (knownTasks.length === 0)
2426
+ return null;
2427
+ return Math.min(...knownTasks.map((task) => task.created_order));
2428
+ }
2429
+ function doesTodoBelongToImportedPlanRun(run, task) {
2430
+ if (!task.conversation_id || task.conversation_id !== run.conversation_id)
2431
+ return false;
2432
+ if (run.task_ids.includes(task.id))
2433
+ return true;
2434
+ const firstImportedOrder = getImportedPlanFirstKnownOrder(run);
2435
+ if (firstImportedOrder == null)
2436
+ return false;
2437
+ return task.created_order >= firstImportedOrder;
2438
+ }
2439
+ function touchImportedPlanRunForTask(task) {
2440
+ if (!task.conversation_id)
2441
+ return undefined;
2442
+ const rows = getDb().prepare(`
2443
+ SELECT *
2444
+ FROM imported_plan_run
2445
+ WHERE conversation_id = ?
2446
+ AND status IN ('running', 'paused')
2447
+ ORDER BY datetime(updated_at) DESC, id DESC
2448
+ `).all(task.conversation_id);
2449
+ for (const row of rows) {
2450
+ const run = normalizeImportedPlanRunRow(row);
2451
+ if (!doesTodoBelongToImportedPlanRun(run, task))
2452
+ continue;
2453
+ getDb().prepare(`
2454
+ UPDATE imported_plan_run
2455
+ SET updated_at = datetime('now')
2456
+ WHERE id = ?
2457
+ `).run(run.id);
2458
+ return getImportedPlanRun(run.id);
2459
+ }
2460
+ return undefined;
2461
+ }
2462
+ function parseImportedPlanStageArray(raw) {
2463
+ try {
2464
+ const parsed = JSON.parse(String(raw || '[]'));
2465
+ if (!Array.isArray(parsed))
2466
+ return [];
2467
+ return parsed
2468
+ .map((entry) => ({
2469
+ role: String(entry?.role || '').trim(),
2470
+ botId: String(entry?.botId || '').trim(),
2471
+ }))
2472
+ .filter((entry) => entry.role && entry.botId);
2473
+ }
2474
+ catch {
2475
+ return [];
2476
+ }
2477
+ }
2478
+ function parseImportedPlanOptions(raw) {
2479
+ try {
2480
+ const parsed = JSON.parse(String(raw || '{}'));
2481
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
2482
+ return {};
2483
+ return {
2484
+ plannerInstructions: typeof parsed.plannerInstructions === 'string' ? parsed.plannerInstructions : null,
2485
+ executionOrder: parsed.executionOrder === 'batch_by_stage' ? 'batch_by_stage'
2486
+ : parsed.executionOrder === 'per_item_pipeline' ? 'per_item_pipeline'
2487
+ : null,
2488
+ };
2489
+ }
2490
+ catch {
2491
+ return {};
2492
+ }
2493
+ }
2494
+ function parseImportedPlanTaskIds(raw) {
2495
+ try {
2496
+ const parsed = JSON.parse(String(raw || '[]'));
2497
+ if (!Array.isArray(parsed))
2498
+ return [];
2499
+ return parsed
2500
+ .map((value) => Number(value))
2501
+ .filter((value) => Number.isFinite(value) && value > 0);
2502
+ }
2503
+ catch {
2504
+ return [];
2505
+ }
2506
+ }
2507
+ function normalizeImportedPlanRunRow(row) {
2508
+ return {
2509
+ id: String(row.id),
2510
+ conversation_id: String(row.conversation_id),
2511
+ project_id: row.project_id ? String(row.project_id) : null,
2512
+ source_file_path: String(row.source_file_path || ''),
2513
+ source_file_name: String(row.source_file_name || ''),
2514
+ planner_bot_id: row.planner_bot_id ? String(row.planner_bot_id) : null,
2515
+ planner_agent_name: row.planner_agent_name ? String(row.planner_agent_name) : null,
2516
+ planner_provider: row.planner_provider ? String(row.planner_provider) : null,
2517
+ planner_model: row.planner_model ? String(row.planner_model) : null,
2518
+ requested_stages: parseImportedPlanStageArray(row.requested_stages_json),
2519
+ requested_options: parseImportedPlanOptions(row.requested_options_json),
2520
+ task_ids: parseImportedPlanTaskIds(row.task_ids_json),
2521
+ status: String(row.status || 'paused'),
2522
+ created_at: String(row.created_at || ''),
2523
+ updated_at: String(row.updated_at || ''),
2524
+ started_at: row.started_at ? String(row.started_at) : null,
2525
+ completed_at: row.completed_at ? String(row.completed_at) : null,
2526
+ discarded_at: row.discarded_at ? String(row.discarded_at) : null,
2527
+ last_error: row.last_error ? String(row.last_error) : null,
2528
+ };
2529
+ }
2530
+ function createImportedPlanRun(params) {
2531
+ const db = getDb();
2532
+ const id = newId();
2533
+ const ts = now();
2534
+ db.prepare(`
2535
+ INSERT INTO imported_plan_run (
2536
+ id, conversation_id, project_id, source_file_path, source_file_name,
2537
+ planner_bot_id, planner_agent_name, planner_provider, planner_model,
2538
+ requested_stages_json, requested_options_json, task_ids_json, status,
2539
+ created_at, updated_at, started_at, completed_at, discarded_at, last_error
2540
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2541
+ `).run(id, params.conversationId, params.projectId ?? null, params.sourceFilePath, params.sourceFileName, params.plannerBotId ?? null, params.plannerAgentName ?? null, params.plannerProvider ?? null, params.plannerModel ?? null, JSON.stringify(params.requestedStages || []), JSON.stringify(params.requestedOptions || {}), JSON.stringify((params.taskIds || []).filter((taskId) => Number.isFinite(taskId) && taskId > 0)), params.status || 'paused', ts, ts, params.status === 'running' ? ts : null, params.status === 'completed' ? ts : null, params.status === 'discarded' ? ts : null, null);
2542
+ return getImportedPlanRun(id);
2543
+ }
2544
+ function getImportedPlanRun(id) {
2545
+ const row = getDb().prepare('SELECT * FROM imported_plan_run WHERE id = ?').get(id);
2546
+ if (!row)
2547
+ return undefined;
2548
+ return normalizeImportedPlanRunRow(row);
2549
+ }
2550
+ function listImportedPlanRunsForConversation(conversationId) {
2551
+ const rows = getDb().prepare('SELECT * FROM imported_plan_run WHERE conversation_id = ? ORDER BY datetime(created_at) DESC, id DESC').all(conversationId);
2552
+ return rows.map(normalizeImportedPlanRunRow);
2553
+ }
2554
+ function getLatestImportedPlanRunForConversation(conversationId, opts) {
2555
+ const clauses = ['conversation_id = ?'];
2556
+ const params = [conversationId];
2557
+ if (!opts?.includeDiscarded)
2558
+ clauses.push("status <> 'discarded'");
2559
+ if (!opts?.includeCompleted)
2560
+ clauses.push("status <> 'completed'");
2561
+ const row = getDb().prepare(`SELECT * FROM imported_plan_run WHERE ${clauses.join(' AND ')} ORDER BY datetime(updated_at) DESC, id DESC LIMIT 1`).get(...params);
2562
+ if (!row)
2563
+ return undefined;
2564
+ return normalizeImportedPlanRunRow(row);
2565
+ }
2566
+ function updateImportedPlanRun(id, fields) {
2567
+ const db = getDb();
2568
+ const sets = [];
2569
+ const vals = [];
2570
+ if (fields.taskIds !== undefined) {
2571
+ sets.push('task_ids_json = ?');
2572
+ vals.push(JSON.stringify(fields.taskIds));
2573
+ }
2574
+ if (fields.status !== undefined) {
2575
+ sets.push('status = ?');
2576
+ vals.push(fields.status);
2577
+ }
2578
+ if (fields.startedAt !== undefined) {
2579
+ sets.push('started_at = ?');
2580
+ vals.push(fields.startedAt);
2581
+ }
2582
+ if (fields.completedAt !== undefined) {
2583
+ sets.push('completed_at = ?');
2584
+ vals.push(fields.completedAt);
2585
+ }
2586
+ if (fields.discardedAt !== undefined) {
2587
+ sets.push('discarded_at = ?');
2588
+ vals.push(fields.discardedAt);
2589
+ }
2590
+ if (fields.lastError !== undefined) {
2591
+ sets.push('last_error = ?');
2592
+ vals.push(fields.lastError);
2593
+ }
2594
+ if (sets.length === 0)
2595
+ return getImportedPlanRun(id);
2596
+ sets.push("updated_at = datetime('now')");
2597
+ vals.push(id);
2598
+ db.prepare(`UPDATE imported_plan_run SET ${sets.join(', ')} WHERE id = ?`).run(...vals);
2599
+ return getImportedPlanRun(id);
2600
+ }
2601
+ function touchImportedPlanRun(id) {
2602
+ getDb().prepare(`
2603
+ UPDATE imported_plan_run
2604
+ SET updated_at = datetime('now')
2605
+ WHERE id = ?
2606
+ `).run(id);
2607
+ return getImportedPlanRun(id);
2608
+ }
2609
+ function getImportedPlanRunTasks(id) {
2610
+ const run = getImportedPlanRun(id);
2611
+ if (!run)
2612
+ return [];
2613
+ const knownTasks = run.task_ids
2614
+ .map((taskId) => getTodoTask(taskId, 'active') || getTodoTask(taskId, 'completed'))
2615
+ .filter((task) => !!task);
2616
+ if (knownTasks.length === 0) {
2617
+ return run.task_ids
2618
+ .map((taskId) => getTodoTask(taskId, 'active') || getTodoTask(taskId, 'completed'))
2619
+ .filter((task) => !!task);
2620
+ }
2621
+ const firstImportedOrder = Math.min(...knownTasks.map((task) => task.created_order));
2622
+ const db = getDb();
2623
+ const activeRows = db.prepare(`SELECT * FROM todo_active WHERE conversation_id = ? AND COALESCE(internal_only, 0) = 0 AND created_order >= ? ORDER BY created_order ASC, id ASC`).all(run.conversation_id, firstImportedOrder);
2624
+ const completedRows = db.prepare(`SELECT * FROM todo_completed WHERE conversation_id = ? AND COALESCE(internal_only, 0) = 0 AND created_order >= ? ORDER BY created_order ASC, id ASC`).all(run.conversation_id, firstImportedOrder);
2625
+ const allTasks = [
2626
+ ...activeRows.map((row) => normalizeTodoRow(row, 'active')),
2627
+ ...completedRows.map((row) => normalizeTodoRow(row, 'completed')),
2628
+ ].sort((a, b) => (a.created_order - b.created_order) || (a.id - b.id));
2629
+ return allTasks;
2630
+ }
2631
+ function getImportedPlanRunRemainingTaskIds(id) {
2632
+ const run = getImportedPlanRun(id);
2633
+ if (!run)
2634
+ return [];
2635
+ const knownTasks = run.task_ids
2636
+ .map((taskId) => getTodoTask(taskId, 'active') || getTodoTask(taskId, 'completed'))
2637
+ .filter((task) => !!task);
2638
+ if (knownTasks.length === 0) {
2639
+ return run.task_ids.filter((taskId) => !!getTodoTask(taskId, 'active'));
2640
+ }
2641
+ // Resume must follow the current TODO queue, not just the original imported
2642
+ // IDs. QA-fail fix tasks and re-QA companions are inserted into the same
2643
+ // conversation/order after import; if the app resumes later, those inserted
2644
+ // active tasks must run before later original tasks.
2645
+ const firstImportedOrder = Math.min(...knownTasks.map((task) => task.created_order));
2646
+ const rows = getDb().prepare(`
2647
+ SELECT id
2648
+ FROM todo_active
2649
+ WHERE conversation_id = ?
2650
+ AND COALESCE(internal_only, 0) = 0
2651
+ AND created_order >= ?
2652
+ ORDER BY created_order ASC, id ASC
2653
+ `).all(run.conversation_id, firstImportedOrder);
2654
+ return rows.map((row) => Number(row.id)).filter((taskId) => Number.isFinite(taskId) && taskId > 0);
2655
+ }
2656
+ function getImportedPlanRunCurrentTask(id) {
2657
+ return getImportedPlanRunTasks(id).find((task) => task.state === 'active');
2658
+ }
2659
+ function deleteImportedPlanRunSelectedTasks(id, selectedTaskIds, actor) {
2660
+ const db = getDb();
2661
+ const tx = db.transaction(() => {
2662
+ const current = getImportedPlanRun(id);
2663
+ if (!current)
2664
+ throw new Error(`Imported plan run ${id} not found`);
2665
+ if (current.status !== 'paused') {
2666
+ throw new Error('Imported plan tasks can only be edited while the plan is paused.');
2667
+ }
2668
+ const selected = new Set((selectedTaskIds || [])
2669
+ .map((taskId) => Number(taskId))
2670
+ .filter((taskId) => Number.isFinite(taskId) && current.task_ids.includes(taskId)));
2671
+ if (selected.size === 0)
2672
+ return current;
2673
+ for (const taskId of selected) {
2674
+ if (getTodoTask(taskId, 'active')) {
2675
+ deleteTodoTask(taskId, { actor, state: 'active' });
2676
+ }
2677
+ }
2678
+ const remainingTaskIds = current.task_ids.filter((taskId) => !selected.has(taskId) && !!getTodoTask(taskId, 'active'));
2679
+ const nextStatus = remainingTaskIds.length > 0 ? 'paused' : 'discarded';
2680
+ return updateImportedPlanRun(id, {
2681
+ taskIds: remainingTaskIds,
2682
+ status: nextStatus,
2683
+ discardedAt: remainingTaskIds.length > 0 ? null : now(),
2684
+ lastError: null,
2685
+ });
2686
+ });
2687
+ return tx();
2688
+ }
1867
2689
  function createOutboxEvent(e) {
1868
2690
  const db = getDb();
1869
2691
  const id = newId();
@@ -1906,7 +2728,7 @@ function setJsonSetting(key, value) {
1906
2728
  setSetting(key, JSON.stringify(value));
1907
2729
  }
1908
2730
  function getAllSettings() {
1909
- const rows = getDb().prepare("SELECT key, value FROM settings WHERE key NOT LIKE '_%'").all();
2731
+ const rows = getDb().prepare("SELECT key, value FROM settings WHERE substr(key, 1, 1) != '_'").all();
1910
2732
  const result = {};
1911
2733
  for (const r of rows)
1912
2734
  result[r.key] = r.value;
@@ -2319,6 +3141,7 @@ function normalizeTodoRow(row, state) {
2319
3141
  return {
2320
3142
  id: Number(row.id),
2321
3143
  project_id: row.project_id ? String(row.project_id) : null,
3144
+ topic_id: row.topic_id ? String(row.topic_id) : null,
2322
3145
  conversation_id: row.conversation_id ? String(row.conversation_id) : null,
2323
3146
  origin: row.origin ? String(row.origin) : null,
2324
3147
  created_by_bot_id: row.created_by_bot_id ? String(row.created_by_bot_id) : null,
@@ -2358,9 +3181,9 @@ function roleClassToTaskType(roleClass) {
2358
3181
  const normalized = String(roleClass || '').trim().toLowerCase();
2359
3182
  if (!normalized)
2360
3183
  return null;
2361
- if (normalized === 'code' || normalized === 'coding' || normalized === 'developer')
3184
+ if (normalized === 'code' || normalized === 'coding' || normalized === 'developer' || normalized === 'builder' || normalized === 'engineer')
2362
3185
  return 'coding';
2363
- if (normalized === 'qa' || normalized === 'review')
3186
+ if (normalized === 'qa' || normalized === 'review' || normalized === 'reviewer')
2364
3187
  return 'qa';
2365
3188
  if (normalized === 'research' || normalized === 'brainstorm' || normalized === 'planning' || normalized === 'plan')
2366
3189
  return 'research';
@@ -2380,7 +3203,11 @@ function inferTaskTypeForAgent(agent, requested) {
2380
3203
  const explicit = String(requested || '').trim();
2381
3204
  if (explicit)
2382
3205
  return explicit;
2383
- return roleClassToTaskType(agent?.role_class) || roleClassToTaskType(agent?.role_label) || null;
3206
+ return roleClassToTaskType(agent?.orchestration_role_class)
3207
+ || roleClassToTaskType(agent?.orchestration_role_label)
3208
+ || roleClassToTaskType(agent?.role_class)
3209
+ || roleClassToTaskType(agent?.role_label)
3210
+ || null;
2384
3211
  }
2385
3212
  function buildQueuedTaskContext(task) {
2386
3213
  const lines = [
@@ -2393,21 +3220,21 @@ function buildQueuedTaskContext(task) {
2393
3220
  return lines.join('\n');
2394
3221
  }
2395
3222
  function injectWorkerHandoffIntoPrompt(existingPrompt, handoffText, nextTask) {
2396
- const taskReplacement = [
2397
- 'Task:',
3223
+ const safeHandoff = String(handoffText || '');
3224
+ const handoffSection = [
2398
3225
  'PREVIOUS WORKER HANDOFF',
2399
- handoffText,
3226
+ safeHandoff,
2400
3227
  '',
2401
3228
  buildQueuedTaskContext(nextTask),
2402
- '',
2403
- 'RUNTIME CONTEXT',
2404
3229
  ].join('\n');
2405
3230
  if (existingPrompt.includes('\nRUNTIME CONTEXT\n')) {
2406
- return existingPrompt.replace(/Task:\s*[\s\S]*?\nRUNTIME CONTEXT\n/, `${taskReplacement}\n`);
3231
+ return existingPrompt.replace(/\nRUNTIME CONTEXT\n/, `\n${handoffSection}\n\nRUNTIME CONTEXT\n`);
2407
3232
  }
2408
3233
  return [
3234
+ existingPrompt,
3235
+ '',
2409
3236
  'PREVIOUS WORKER HANDOFF',
2410
- handoffText,
3237
+ safeHandoff,
2411
3238
  '',
2412
3239
  buildQueuedTaskContext(nextTask),
2413
3240
  ].join('\n');
@@ -2455,6 +3282,7 @@ function moveActiveTaskToCompletedTx(db, taskId, actor, reason) {
2455
3282
  const row = db.prepare('SELECT * FROM todo_active WHERE id = ?').get(taskId);
2456
3283
  if (!row)
2457
3284
  throw new Error(`Task ${taskId} not found`);
3285
+ const activeTask = normalizeTodoRow(row, 'active');
2458
3286
  const completedAt = now();
2459
3287
  db.prepare(`
2460
3288
  INSERT INTO todo_completed (
@@ -2468,7 +3296,9 @@ function moveActiveTaskToCompletedTx(db, taskId, actor, reason) {
2468
3296
  db.prepare('DELETE FROM todo_active WHERE id = ?').run(taskId);
2469
3297
  logTodoAuditTx(db, taskId, 'completed', 'complete', actor, { reason });
2470
3298
  const completed = db.prepare('SELECT * FROM todo_completed WHERE id = ?').get(taskId);
2471
- return normalizeTodoRow(completed, 'completed');
3299
+ const completedTask = normalizeTodoRow(completed, 'completed');
3300
+ touchImportedPlanRunForTask(activeTask);
3301
+ return completedTask;
2472
3302
  }
2473
3303
  function determineInsertPositionTx(db, projectId, insertAfterTaskId) {
2474
3304
  if (insertAfterTaskId) {
@@ -2542,43 +3372,35 @@ function resolveAllowedWorkerNamesForInsertion(currentTask) {
2542
3372
  allowed.add(previous.owner_name.trim().toLowerCase());
2543
3373
  return Array.from(allowed);
2544
3374
  }
2545
- function resolvePreviousWorkerName(currentTask) {
3375
+ function resolvePreviousWorkerTask(currentTask) {
2546
3376
  const db = getDb();
2547
3377
  const previous = currentTask.project_id
2548
3378
  ? db.prepare(`
2549
- SELECT owner_name
3379
+ SELECT owner_name, task_type
2550
3380
  FROM (
2551
- SELECT owner_name, created_order, internal_only FROM todo_active WHERE project_id = ?
3381
+ SELECT owner_name, task_type, created_order, internal_only FROM todo_active WHERE project_id = ?
2552
3382
  UNION ALL
2553
- SELECT owner_name, created_order, internal_only FROM todo_completed WHERE project_id = ?
3383
+ SELECT owner_name, task_type, created_order, internal_only FROM todo_completed WHERE project_id = ?
2554
3384
  )
2555
3385
  WHERE created_order < ? AND COALESCE(internal_only, 0) = 0 AND owner_name IS NOT NULL
2556
3386
  ORDER BY created_order DESC
2557
3387
  LIMIT 1
2558
3388
  `).get(currentTask.project_id, currentTask.project_id, currentTask.position)
2559
3389
  : db.prepare(`
2560
- SELECT owner_name
3390
+ SELECT owner_name, task_type
2561
3391
  FROM (
2562
- SELECT owner_name, created_order, internal_only FROM todo_active WHERE project_id IS NULL
3392
+ SELECT owner_name, task_type, created_order, internal_only FROM todo_active WHERE project_id IS NULL
2563
3393
  UNION ALL
2564
- SELECT owner_name, created_order, internal_only FROM todo_completed WHERE project_id IS NULL
3394
+ SELECT owner_name, task_type, created_order, internal_only FROM todo_completed WHERE project_id IS NULL
2565
3395
  )
2566
3396
  WHERE created_order < ? AND COALESCE(internal_only, 0) = 0 AND owner_name IS NOT NULL
2567
3397
  ORDER BY created_order DESC
2568
3398
  LIMIT 1
2569
3399
  `).get(currentTask.position);
2570
- return previous?.owner_name?.trim() || null;
3400
+ return previous || null;
2571
3401
  }
2572
- function shouldAutoCreateQaFixTask(task, outputSummary, handoffPrompt) {
2573
- if (String(task.task_type || '').trim().toLowerCase() !== 'qa')
2574
- return false;
2575
- const combined = `${outputSummary || ''}\n${handoffPrompt || ''}`.trim().toLowerCase();
2576
- if (!combined)
2577
- return false;
2578
- if (/(^|\b)(qa passed|all checks passed|no issues found|looks good to ship)(\b|$)/i.test(combined)) {
2579
- return false;
2580
- }
2581
- return /\b(fix|defect|issue|problem|error|not ready|fails?|failed|remaining problem|remaining issue|validator|re-qa)\b/i.test(combined);
3402
+ function resolvePreviousWorkerName(currentTask) {
3403
+ return resolvePreviousWorkerTask(currentTask)?.owner_name?.trim() || null;
2582
3404
  }
2583
3405
  function normalizeArtifactInput(ref) {
2584
3406
  if (typeof ref === 'string') {
@@ -2644,13 +3466,22 @@ function listTodoTasks(opts) {
2644
3466
  const limit = opts?.limit ?? 200;
2645
3467
  const offset = opts?.offset ?? 0;
2646
3468
  const table = state === 'active' ? 'todo_active' : 'todo_completed';
3469
+ const topicSelect = `
3470
+ (
3471
+ SELECT ts.topic_id
3472
+ FROM topic_segment ts
3473
+ WHERE ts.conversation_id = t.conversation_id
3474
+ ORDER BY datetime(ts.created_at) DESC, ts.id DESC
3475
+ LIMIT 1
3476
+ ) AS topic_id
3477
+ `;
2647
3478
  const orderBy = state === 'active'
2648
- ? 'ORDER BY created_order ASC, id ASC'
2649
- : 'ORDER BY datetime(completed_at) DESC, id DESC';
3479
+ ? 'ORDER BY t.created_order ASC, t.id ASC'
3480
+ : 'ORDER BY datetime(t.completed_at) DESC, t.id DESC';
2650
3481
  const hasProjectFilter = Object.prototype.hasOwnProperty.call(opts || {}, 'projectId') && opts?.projectId !== undefined;
2651
3482
  const sql = hasProjectFilter
2652
- ? `SELECT * FROM ${table} WHERE project_id ${opts?.projectId ? '= ?' : 'IS NULL'} ${orderBy} LIMIT ? OFFSET ?`
2653
- : `SELECT * FROM ${table} ${orderBy} LIMIT ? OFFSET ?`;
3483
+ ? `SELECT t.*, ${topicSelect} FROM ${table} t WHERE t.project_id ${opts?.projectId ? '= ?' : 'IS NULL'} ${orderBy} LIMIT ? OFFSET ?`
3484
+ : `SELECT t.*, ${topicSelect} FROM ${table} t ${orderBy} LIMIT ? OFFSET ?`;
2654
3485
  const rows = hasProjectFilter
2655
3486
  ? (opts?.projectId
2656
3487
  ? db.prepare(sql).all(opts.projectId, limit, offset)
@@ -2823,7 +3654,9 @@ function editTodoTask(taskId, params) {
2823
3654
  return moveActiveTaskToCompletedTx(db, taskId, params.actor, 'all_llms_complete');
2824
3655
  }
2825
3656
  const updated = db.prepare('SELECT * FROM todo_active WHERE id = ?').get(taskId);
2826
- return normalizeTodoRow(updated, 'active');
3657
+ const updatedTask = normalizeTodoRow(updated, 'active');
3658
+ touchImportedPlanRunForTask(updatedTask);
3659
+ return updatedTask;
2827
3660
  });
2828
3661
  return tx();
2829
3662
  }
@@ -2887,7 +3720,9 @@ function checkTodoTask(taskId, params) {
2887
3720
  return moveActiveTaskToCompletedTx(db, taskId, params.actor, 'all_llms_complete');
2888
3721
  }
2889
3722
  const updated = db.prepare('SELECT * FROM todo_active WHERE id = ?').get(taskId);
2890
- return normalizeTodoRow(updated, 'active');
3723
+ const updatedTask = normalizeTodoRow(updated, 'active');
3724
+ touchImportedPlanRunForTask(updatedTask);
3725
+ return updatedTask;
2891
3726
  });
2892
3727
  return tx();
2893
3728
  }
@@ -2942,6 +3777,23 @@ function completeTodoTaskByWorker(taskId, params) {
2942
3777
  if (!isAssignedWorker && !isOrchestratorActor) {
2943
3778
  throw new Error(`Actor "${actorName || 'unknown'}" is not the owner of task ${taskId}`);
2944
3779
  }
3780
+ // QA tasks MUST declare qa_result. Codex QA 2026-04-19 (HIGH): the
3781
+ // prompt says so, but the runtime was not enforcing it — a QA
3782
+ // worker that omitted qa_result was silently treated as "pass"
3783
+ // because control fell through to the next_worker handoff branch.
3784
+ // Now rejected at the edge. Orchestrator actors (admin override)
3785
+ // bypass this check — that's for cleanup operations only.
3786
+ const isQaTask = String(currentTask.task_type || '').trim().toLowerCase() === 'qa';
3787
+ if (isQaTask && !isOrchestratorActor) {
3788
+ const incomingQaResult = params.qaResult;
3789
+ if (incomingQaResult !== 'pass' && incomingQaResult !== 'fail') {
3790
+ throw new Error(`QA task ${taskId} must be completed with qa_result='pass' or qa_result='fail'. `
3791
+ + `Got ${incomingQaResult === undefined ? 'no qa_result' : `qa_result='${String(incomingQaResult)}'`}. `
3792
+ + `'not_applicable' is not valid on QA tasks. `
3793
+ + `If QA passed with no defects, set qa_result='pass'. `
3794
+ + `If defects were found, set qa_result='fail' and include findings.`);
3795
+ }
3796
+ }
2945
3797
  const existingArtifactRows = loadArtifactRowsByIdsTx(db, currentTask.artifact_ids);
2946
3798
  const newArtifactRows = resolveArtifactRowsTx(db, params.artifactRefs || [], {
2947
3799
  projectId: currentTask.project_id,
@@ -2968,6 +3820,9 @@ function completeTodoTaskByWorker(taskId, params) {
2968
3820
  outputSummary,
2969
3821
  artifactIds,
2970
3822
  inserted: Boolean(params.insertTask),
3823
+ qaResult: params.qaResult ?? null,
3824
+ findings: params.findings ?? null,
3825
+ requiresFixTask: params.requiresFixTask ?? null,
2971
3826
  });
2972
3827
  const completedTask = moveActiveTaskToCompletedTx(db, taskId, params.actor, 'all_llms_complete');
2973
3828
  let insertedTask;
@@ -3022,26 +3877,46 @@ function completeTodoTaskByWorker(taskId, params) {
3022
3877
  }
3023
3878
  }
3024
3879
  else {
3025
- if (shouldAutoCreateQaFixTask(completedTask, outputSummary, handoffPrompt)) {
3026
- const previousWorkerName = resolvePreviousWorkerName(completedTask);
3880
+ const wantsQaFixTask = params.qaResult === 'fail' && params.requiresFixTask !== false;
3881
+ if (wantsQaFixTask) {
3882
+ const previousWorkerTask = resolvePreviousWorkerTask(completedTask);
3883
+ const previousWorkerName = previousWorkerTask?.owner_name?.trim() || null;
3884
+ const previousTaskType = String(previousWorkerTask?.task_type || '').trim().toLowerCase();
3027
3885
  const previousWorker = resolveAgentByName(previousWorkerName);
3028
- if (previousWorkerName && previousWorker) {
3886
+ const findingsText = (params.findings || []).map((s) => String(s || '').trim()).filter(Boolean).join('\n- ');
3887
+ const fixPrompt = findingsText
3888
+ ? `QA findings to address:\n- ${findingsText}\n\n${handoffPrompt ? handoffPrompt + '\n\n' : ''}Address the findings above and keep the approved parts unchanged.`
3889
+ : (handoffPrompt || outputSummary || 'Address the QA findings and keep the approved parts unchanged.');
3890
+ if (previousTaskType === 'qa') {
3891
+ returnedToOrchestrator = true;
3892
+ logTodoAuditTx(db, taskId, 'completed', 'return_to_orchestrator', params.actor, {
3893
+ handoffPrompt,
3894
+ qaResult: 'fail',
3895
+ findings: params.findings || null,
3896
+ skippedQaFixLoop: true,
3897
+ previousWorkerName,
3898
+ previousTaskType,
3899
+ });
3900
+ }
3901
+ else if (previousWorkerName && previousWorker) {
3029
3902
  const participants = [previousWorker.name];
3030
3903
  insertedTask = insertTodoTaskTx(db, {
3031
3904
  projectId: completedTask.project_id,
3032
3905
  conversationId: completedTask.conversation_id,
3033
3906
  title: 'Fix QA findings',
3034
- details: handoffPrompt || outputSummary || 'Address the QA findings and update the implementation.',
3907
+ details: findingsText
3908
+ ? `QA findings:\n- ${findingsText}`
3909
+ : (handoffPrompt || outputSummary || 'Address the QA findings and update the implementation.'),
3035
3910
  participants,
3036
3911
  successCriteria: 'Address the QA findings and return the updated work for re-QA.',
3037
3912
  ownerBotId: previousWorker.id,
3038
3913
  ownerName: previousWorker.name,
3039
- taskType: inferTaskTypeForAgent(previousWorker, null),
3914
+ taskType: previousWorkerTask?.task_type || inferTaskTypeForAgent(previousWorker, null),
3040
3915
  profileName: completedTask.profile_name || 'Programming',
3041
3916
  nextWorkerBotId: currentTask.owner_bot_id,
3042
3917
  nextWorkerName: currentTask.owner_name,
3043
3918
  nextWorkerRole: actorBot?.role_label || actorBot?.role_class || null,
3044
- taskPrompt: handoffPrompt || outputSummary || 'Address the QA findings and keep the approved parts unchanged.',
3919
+ taskPrompt: fixPrompt,
3045
3920
  artifactIds,
3046
3921
  insertAfterTaskId: completedTask.id,
3047
3922
  actor: params.actor,
@@ -3049,13 +3924,54 @@ function completeTodoTaskByWorker(taskId, params) {
3049
3924
  logTodoAuditTx(db, insertedTask.id, 'active', 'worker_insert', params.actor, {
3050
3925
  insertedAfterTaskId: completedTask.id,
3051
3926
  requestedWorkerName: previousWorker.name,
3052
- autoCreatedFromQaFailure: true,
3927
+ qaFailureFromStructuredField: true,
3928
+ findings: params.findings || null,
3053
3929
  });
3930
+ // Companion re-QA TODO: after the previous worker addresses the
3931
+ // findings, the QA worker must re-review. Without this follow-up
3932
+ // task, the fix TODO's next_worker_bot_id points at a QA worker
3933
+ // with no active TODO, and the dispatcher silently skips re-QA
3934
+ // and runs whatever sits next in the queue (item 5 bug).
3935
+ // Chain: fix -> re-QA -> [QA's original next_worker].
3936
+ const currentOwnerId = currentTask.owner_bot_id;
3937
+ const currentOwnerName = currentTask.owner_name;
3938
+ if (currentOwnerId && currentOwnerName) {
3939
+ const reQaTask = insertTodoTaskTx(db, {
3940
+ projectId: completedTask.project_id,
3941
+ conversationId: completedTask.conversation_id,
3942
+ title: `Re-QA after fix`,
3943
+ details: findingsText
3944
+ ? `Re-review the previous worker's fix for these findings:\n- ${findingsText}`
3945
+ : 'Re-review the previous worker\'s fix against the original handoff.',
3946
+ participants: [currentOwnerName],
3947
+ successCriteria: 'Re-QA confirms the findings were addressed and the work meets the original spec.',
3948
+ ownerBotId: currentOwnerId,
3949
+ ownerName: currentOwnerName,
3950
+ taskType: 'qa',
3951
+ profileName: completedTask.profile_name || 'Programming',
3952
+ nextWorkerBotId: completedTask.next_worker_bot_id,
3953
+ nextWorkerName: completedTask.next_worker_name,
3954
+ nextWorkerRole: completedTask.next_worker_role,
3955
+ taskPrompt: findingsText
3956
+ ? `Re-review the previous worker's fix.\n\nOriginal findings:\n- ${findingsText}\n\nConfirm each finding has been addressed and the work meets the original success criteria.`
3957
+ : 'Re-review the previous worker\'s fix against the original handoff and success criteria.',
3958
+ artifactIds,
3959
+ insertAfterTaskId: insertedTask.id,
3960
+ actor: params.actor,
3961
+ });
3962
+ logTodoAuditTx(db, reQaTask.id, 'active', 'worker_insert', params.actor, {
3963
+ insertedAfterTaskId: insertedTask.id,
3964
+ requestedWorkerName: currentOwnerName,
3965
+ qaReQueueFromStructuredField: true,
3966
+ });
3967
+ }
3054
3968
  }
3055
3969
  else {
3056
3970
  returnedToOrchestrator = true;
3057
3971
  logTodoAuditTx(db, taskId, 'completed', 'return_to_orchestrator', params.actor, {
3058
3972
  handoffPrompt,
3973
+ qaResult: 'fail',
3974
+ findings: params.findings || null,
3059
3975
  missingPreviousWorkerForQaFix: true,
3060
3976
  });
3061
3977
  }
@@ -3122,6 +4038,11 @@ function completeTodoTaskByWorker(taskId, params) {
3122
4038
  }
3123
4039
  }
3124
4040
  }
4041
+ touchImportedPlanRunForTask(completedTask);
4042
+ if (insertedTask)
4043
+ touchImportedPlanRunForTask(insertedTask);
4044
+ if (handedOffTask)
4045
+ touchImportedPlanRunForTask(handedOffTask);
3125
4046
  return {
3126
4047
  completedTask,
3127
4048
  insertedTask,
@@ -3217,6 +4138,7 @@ function blockTodoTaskByWorker(taskId, params) {
3217
4138
  checkedContext,
3218
4139
  userQuestion,
3219
4140
  });
4141
+ touchImportedPlanRunForTask(blockedTask);
3220
4142
  return {
3221
4143
  blockedTask,
3222
4144
  artifacts: artifactRows,
@@ -3226,6 +4148,39 @@ function blockTodoTaskByWorker(taskId, params) {
3226
4148
  });
3227
4149
  return tx();
3228
4150
  }
4151
+ function markTodoTaskRuntimeBlocked(taskId, params) {
4152
+ const blockerSummary = String(params.blockerSummary || '').trim();
4153
+ if (!blockerSummary)
4154
+ throw new Error('Runtime blocked task requires blockerSummary');
4155
+ const checkedContext = String(params.checkedContext || 'Runtime/provider error was captured while executing the worker task.').trim();
4156
+ const userQuestion = String(params.userQuestion || 'Fix the provider/runtime issue, then resume the plan.').trim();
4157
+ const db = getDb();
4158
+ const tx = db.transaction(() => {
4159
+ const row = db.prepare('SELECT * FROM todo_active WHERE id = ?').get(taskId);
4160
+ if (!row)
4161
+ throw new Error(`Task ${taskId} not found`);
4162
+ db.prepare(`
4163
+ UPDATE todo_active
4164
+ SET blocker_summary = ?,
4165
+ blocker_checked = ?,
4166
+ blocker_question = ?,
4167
+ last_updated = ?,
4168
+ version = version + 1
4169
+ WHERE id = ?
4170
+ `).run(blockerSummary, checkedContext, userQuestion, now(), taskId);
4171
+ logTodoAuditTx(db, taskId, 'active', 'worker_blocked', params.actor, {
4172
+ blockerSummary,
4173
+ checkedContext,
4174
+ userQuestion,
4175
+ reasonCode: params.reasonCode || null,
4176
+ runtimeBlocked: true,
4177
+ });
4178
+ const blockedTask = normalizeTodoRow(db.prepare('SELECT * FROM todo_active WHERE id = ?').get(taskId), 'active');
4179
+ touchImportedPlanRunForTask(blockedTask);
4180
+ return blockedTask;
4181
+ });
4182
+ return tx();
4183
+ }
3229
4184
  function deleteTodoTask(taskId, params) {
3230
4185
  if (params.actor.actorType !== 'user' && params.actor.actorType !== 'orchestrator')
3231
4186
  throw new Error('Only user or orchestrator can delete tasks');
@@ -3279,7 +4234,11 @@ function reorderTodoTask(taskId, toPosition, actor) {
3279
4234
  }
3280
4235
  logTodoAuditTx(db, taskId, 'active', 'reorder', actor, { toPosition: clamped });
3281
4236
  const updated = db.prepare('SELECT * FROM todo_active ORDER BY created_order ASC, id ASC').all();
3282
- return updated.map((r) => normalizeTodoRow(r, 'active'));
4237
+ const normalized = updated.map((r) => normalizeTodoRow(r, 'active'));
4238
+ const reorderedTask = normalized.find((task) => task.id === taskId);
4239
+ if (reorderedTask)
4240
+ touchImportedPlanRunForTask(reorderedTask);
4241
+ return normalized;
3283
4242
  });
3284
4243
  return tx();
3285
4244
  }
@@ -3316,6 +4275,15 @@ function listCompletedTodoTasksForConversation(conversationId) {
3316
4275
  const rows = getDb().prepare('SELECT * FROM todo_completed WHERE conversation_id = ? AND COALESCE(internal_only, 0) = 0 ORDER BY created_order ASC, id ASC').all(conversationId);
3317
4276
  return rows.map((row) => normalizeTodoRow(row, 'completed'));
3318
4277
  }
4278
+ /**
4279
+ * List all active (non-completed) TODOs for a conversation in creation order.
4280
+ * Used by the orchestration dispatch-runner to feed `existing_todos` into
4281
+ * the orchestrator hint so the LLM can see what's already queued.
4282
+ */
4283
+ function listActiveTodoTasksForConversation(conversationId) {
4284
+ const rows = getDb().prepare('SELECT * FROM todo_active WHERE conversation_id = ? AND COALESCE(internal_only, 0) = 0 ORDER BY created_order ASC, id ASC').all(conversationId);
4285
+ return rows.map((row) => normalizeTodoRow(row, 'active'));
4286
+ }
3319
4287
  exports.UNASSIGNED_PROJECT_NAME = 'unassigned';
3320
4288
  function createProject(p) {
3321
4289
  const db = getDb();
@@ -3390,12 +4358,22 @@ function getProjectOverview(projectId) {
3390
4358
  const workflowTemplates = listWorkflowTemplates(projectId);
3391
4359
  const roleAssignments = getJsonSetting(`project:${projectId}:role_assignments`)
3392
4360
  || (() => {
3393
- try {
3394
- return JSON.parse(projectSettings.orchestrator_role_assignments || '{}');
3395
- }
3396
- catch {
3397
- return {};
4361
+ const derived = {};
4362
+ for (const bot of bots) {
4363
+ const roleClass = (bot.role_class || '').toLowerCase();
4364
+ if (!roleClass)
4365
+ continue;
4366
+ if (!derived.coding && ['code', 'coding', 'coder', 'builder', 'developer', 'engineer'].includes(roleClass)) {
4367
+ derived.coding = bot.name;
4368
+ }
4369
+ else if (!derived.qa && ['qa', 'review', 'reviewer'].includes(roleClass)) {
4370
+ derived.qa = bot.name;
4371
+ }
4372
+ else if (!derived.research && ['research', 'researcher', 'analyst', 'advisor'].includes(roleClass)) {
4373
+ derived.research = bot.name;
4374
+ }
3398
4375
  }
4376
+ return derived;
3399
4377
  })();
3400
4378
  let preferredWorkflow = null;
3401
4379
  const rawWorkflow = projectSettings.orchestrator_preferred_workflow || projectSettings.preferred_workflow;
@@ -3415,6 +4393,7 @@ function getProjectOverview(projectId) {
3415
4393
  defaultModel: connection.default_model || null,
3416
4394
  hasSecret: connection.access_mode === 'cli' || Boolean(connection.api_key_enc),
3417
4395
  }));
4396
+ const clerkConfig = getResolvedClerkConfigInfo();
3418
4397
  const userPolicy = getUserOrchestrationPolicy() || null;
3419
4398
  const projectPolicyOverride = getProjectOrchestrationPolicyOverride(projectId) || null;
3420
4399
  return {
@@ -3430,9 +4409,9 @@ function getProjectOverview(projectId) {
3430
4409
  preferred_workflow: preferredWorkflow,
3431
4410
  provider_summary: providerSummary,
3432
4411
  clerk: {
3433
- provider: getSetting('clerk_provider') || null,
3434
- model: getSetting('clerk_model') || null,
3435
- configured: Boolean(getSetting('clerk_provider') && getSetting('clerk_model') && getSetting('clerk_api_key')),
4412
+ provider: clerkConfig.provider,
4413
+ model: clerkConfig.model,
4414
+ configured: clerkConfig.configured,
3436
4415
  },
3437
4416
  user_policy: userPolicy,
3438
4417
  project_policy_override: projectPolicyOverride,
@@ -3716,7 +4695,7 @@ function getPreviousConversationInTopic(conversationId) {
3716
4695
  ORDER BY ts.created_at DESC, c.updated_at DESC
3717
4696
  LIMIT 1
3718
4697
  `).get(topicId, conversationId);
3719
- return row || null;
4698
+ return hydrateConversationRow(row) || null;
3720
4699
  }
3721
4700
  function getTopicMessages(topicId) {
3722
4701
  const db = getDb();