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
@@ -50,6 +50,9 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
50
50
  Object.defineProperty(exports, "__esModule", { value: true });
51
51
  exports.WorkflowEngine = void 0;
52
52
  exports.resolveWorkflowCliSessionTransportForTest = resolveWorkflowCliSessionTransportForTest;
53
+ exports.resolveWorkflowClaudeUseConptyForTest = resolveWorkflowClaudeUseConptyForTest;
54
+ exports.buildDeferredImageReferenceBlockForTest = buildDeferredImageReferenceBlockForTest;
55
+ exports.parseImageEscalationRequestForTest = parseImageEscalationRequestForTest;
53
56
  exports.getWorkflowEngine = getWorkflowEngine;
54
57
  const events_1 = require("events");
55
58
  const fs_1 = __importDefault(require("fs"));
@@ -68,33 +71,93 @@ const runtime_context_1 = require("./runtime-context");
68
71
  const local_cli_pty_manager_1 = require("./local-cli-pty-manager");
69
72
  const codex_app_server_manager_1 = require("./codex-app-server-manager");
70
73
  const cli_session_epoch_1 = require("./cli-session-epoch");
74
+ const cli_bootstrap_history_1 = require("./cli-bootstrap-history");
75
+ const context_window_1 = require("./context-window");
76
+ const capabilities_1 = require("./orchestration/capabilities");
71
77
  // ─── Workflow Engine ─────────────────────────────────────────────
72
78
  const MAX_STEP_ATTEMPTS = safeguards_1.SAFEGUARDS.MAX_AGENT_ATTEMPTS;
73
79
  const MAX_STEPS = safeguards_1.SAFEGUARDS.MAX_WORKFLOW_STEPS;
74
80
  function isInteractiveAuthFailure(text) {
75
- return /\b(not logged in|please run \/login|unauthorized|invalid api key|authentication required)\b/i.test(text);
81
+ return /\b(not logged in|please run \/login|login required|unauthorized|invalid authorization|invalid api key|missing api key|authentication required|invalid authentication credentials|invalid bearer token|token expired|session expired|credentials expired|expired credentials|reauthenticate)\b/i.test(text);
76
82
  }
77
83
  const LOCAL_WORKFLOW_RUNTIME_RETRY_LIMIT = 2;
84
+ function shouldUseConptyForWorkflowClaudePty() {
85
+ // ConPTY can surface transient console-host flashes on Windows during
86
+ // long-running Claude PTY worker turns. Keep direct chat unchanged, but use
87
+ // the legacy hidden winpty backend for orchestration/workflow Claude runs.
88
+ return process.platform !== 'win32';
89
+ }
90
+ function isClaudeFreshWorkflowSessionStartupFailure(err) {
91
+ return err?.code === 'CLAUDE_FRESH_SESSION_STARTUP_TIMEOUT'
92
+ || err?.name === 'ClaudeFreshSessionStartupTimeoutError'
93
+ || /fresh session startup timed out/i.test(String(err?.message || err || ''));
94
+ }
95
+ function createWorkflowAbortError() {
96
+ const err = new Error('aborted');
97
+ err.name = 'AbortError';
98
+ err.code = 'ABORT_ERR';
99
+ return err;
100
+ }
101
+ function isWorkflowAbortError(err) {
102
+ return err?.name === 'AbortError'
103
+ || err?.code === 'ABORT_ERR'
104
+ || /\baborted\b/i.test(String(err?.message || err || ''));
105
+ }
106
+ function throwIfWorkflowAborted(abortSignal) {
107
+ if (!abortSignal?.aborted)
108
+ return;
109
+ throw createWorkflowAbortError();
110
+ }
78
111
  function shouldRetrySelectedWorkflowRuntime(err) {
79
112
  const text = String(err?.message || err || '').toLowerCase();
80
113
  if (!text)
81
114
  return false;
82
- if (/\b(no api key|configure one in settings|not available on this machine|not installed|please run \/login|not logged in|invalid api key)\b/i.test(text)) {
115
+ if (/\b(no api key|missing api key|configure one in settings|not available on this machine|not installed|please run \/login|login required|not logged in|unauthorized|invalid authorization|invalid api key|authentication required|invalid authentication credentials|invalid bearer token|token expired|session expired|credentials expired|expired credentials|reauthenticate)\b/i.test(text)) {
83
116
  return false;
84
117
  }
85
- return /\b(429|rate limit|timeout|timed out|temporar|temporarily|econnreset|etimedout|enotfound|econnrefused|socket hang up|network|try again|overloaded|busy)\b/i.test(text);
118
+ return /\b(429|rate limit|timeout|timed out|temporar|temporarily|econnreset|etimedout|enotfound|econnrefused|socket hang up|network|try again|overloaded|busy|no meaningful pty activity)\b/i.test(text);
86
119
  }
87
- async function pauseWorkflowRuntimeRetry(attempt) {
120
+ async function pauseWorkflowRuntimeRetry(attempt, abortSignal) {
88
121
  const delayMs = attempt <= 1 ? 750 : 1500;
89
- await new Promise((resolve) => setTimeout(resolve, delayMs));
122
+ throwIfWorkflowAborted(abortSignal);
123
+ await Promise.race([
124
+ new Promise((resolve) => setTimeout(resolve, delayMs)),
125
+ new Promise((_, reject) => {
126
+ if (!abortSignal)
127
+ return;
128
+ const onAbort = () => {
129
+ abortSignal.removeEventListener('abort', onAbort);
130
+ reject(createWorkflowAbortError());
131
+ };
132
+ abortSignal.addEventListener('abort', onAbort, { once: true });
133
+ }),
134
+ ]);
135
+ throwIfWorkflowAborted(abortSignal);
90
136
  }
91
- function buildWorkflowPtyConversationKey(conversationId, step, workflowContext) {
137
+ function buildWorkflowPtyConversationKey(conversationId, step, workflowContext, cliSessionScopeKey) {
138
+ const explicitScope = String(cliSessionScopeKey || '').trim();
139
+ if (explicitScope)
140
+ return explicitScope;
92
141
  if (conversationId)
93
142
  return conversationId;
94
143
  if (workflowContext?.workflowId)
95
144
  return `workflow:${workflowContext.workflowId}`;
96
145
  return `adhoc:${step.agentId}:${step.id}`;
97
146
  }
147
+ function clearFailedWorkflowCliSessionEpoch(conversationId, botId, sessionId, cliSessionScopeKey) {
148
+ const normalizedSessionId = String(sessionId || '').trim();
149
+ if (!conversationId || !normalizedSessionId)
150
+ return;
151
+ try {
152
+ const cleared = data.clearCliSessionEpochIfMatches(conversationId, botId, normalizedSessionId, cliSessionScopeKey);
153
+ if (cleared) {
154
+ console.warn(`[workflow-engine] cleared poisoned cli_session_epoch conversationId=${conversationId} botId=${botId} sessionId=${normalizedSessionId}`);
155
+ }
156
+ }
157
+ catch (err) {
158
+ console.warn(`[workflow-engine] failed to clear cli_session_epoch conversationId=${conversationId} botId=${botId} sessionId=${normalizedSessionId}: ${err?.message || err}`);
159
+ }
160
+ }
98
161
  function resolveWorkflowCliSessionTransport(providerName, isLocalCliSession) {
99
162
  if (!isLocalCliSession)
100
163
  return 'none';
@@ -105,6 +168,42 @@ function resolveWorkflowCliSessionTransport(providerName, isLocalCliSession) {
105
168
  function resolveWorkflowCliSessionTransportForTest(providerName, isLocalCliSession) {
106
169
  return resolveWorkflowCliSessionTransport(providerName, isLocalCliSession);
107
170
  }
171
+ function resolveWorkflowClaudeUseConptyForTest() {
172
+ return shouldUseConptyForWorkflowClaudePty();
173
+ }
174
+ function listImageAttachments(attachments) {
175
+ return (attachments || []).filter((attachment) => attachment.mimeType?.startsWith('image/'));
176
+ }
177
+ function buildDeferredImageReferenceBlock(attachments) {
178
+ const imageAttachments = listImageAttachments(attachments);
179
+ if (imageAttachments.length === 0)
180
+ return '';
181
+ const lines = [
182
+ '[Original Image Context Available]',
183
+ ...imageAttachments.map((attachment) => `- id: ${attachment.id} (${attachment.filename})`),
184
+ 'Use the assigned task, prior worker artifacts, and any available memory/history search tools first.',
185
+ 'If you still need the original image, reply exactly with one line:',
186
+ 'ESCALATE_IMAGE <id>',
187
+ 'Use one of the listed ids. Do not include any other text.',
188
+ ];
189
+ return lines.join('\n');
190
+ }
191
+ function buildDeferredImageReferenceBlockForTest(attachments) {
192
+ return buildDeferredImageReferenceBlock(attachments);
193
+ }
194
+ function parseImageEscalationRequest(text, attachments) {
195
+ const imageAttachments = listImageAttachments(attachments);
196
+ if (imageAttachments.length === 0)
197
+ return null;
198
+ const match = String(text || '').match(/^\s*ESCALATE_IMAGE\s+([A-Za-z0-9._:-]+)\s*$/m);
199
+ if (!match)
200
+ return null;
201
+ const requestedId = match[1].trim();
202
+ return imageAttachments.find((attachment) => attachment.id === requestedId) || null;
203
+ }
204
+ function parseImageEscalationRequestForTest(text, attachments) {
205
+ return parseImageEscalationRequest(text, attachments)?.id || null;
206
+ }
108
207
  class WorkflowEngine extends events_1.EventEmitter {
109
208
  projectDir;
110
209
  runtimeMode;
@@ -343,8 +442,12 @@ class WorkflowEngine extends events_1.EventEmitter {
343
442
  throw new Error(`Workflow step ${index + 1} references missing bot ${templateStep.agent_id}`);
344
443
  }
345
444
  const instruction = templateStep.instruction?.trim() || `Complete stage ${index + 1} for this TODO item.`;
346
- const inferredRole = this.inferRoleForStep({ description: instruction });
347
- const isCheckpointLike = templateStep.is_checkpoint === 1 || inferredRole === 'qa';
445
+ // Role comes from the agent's structured role_class; checkpoint
446
+ // status comes from the template's explicit is_checkpoint flag
447
+ // or a QA-role agent. No prose scanning.
448
+ const agentRole = agent.role_class || undefined;
449
+ const agentIsQa = this.inferRoleForStep({ role: agentRole }) === 'qa';
450
+ const isCheckpointLike = templateStep.is_checkpoint === 1 || agentIsQa;
348
451
  return {
349
452
  id: templateStep.id,
350
453
  index,
@@ -354,6 +457,7 @@ class WorkflowEngine extends events_1.EventEmitter {
354
457
  agentName: agent.name,
355
458
  provider: agent.provider,
356
459
  model: agent.model,
460
+ role: agentRole,
357
461
  dependsOn: index === 0 ? [] : [index - 1],
358
462
  status: 'pending',
359
463
  attempts: 0,
@@ -509,6 +613,11 @@ class WorkflowEngine extends events_1.EventEmitter {
509
613
  const mapped = plannedSteps.slice(0, MAX_STEPS).map((step, index) => {
510
614
  // Route to the named agent or default
511
615
  const targetAgent = this.resolveStepAgent(step, agents, defaultProfile, roleAssignments);
616
+ // Prefer an explicit role on the planned step; otherwise carry the
617
+ // resolved agent's role so downstream code doesn't fall back to
618
+ // regex classification on prose.
619
+ const explicitRole = typeof step.role === 'string' && step.role.trim() ? step.role.trim().toLowerCase() : undefined;
620
+ const agentRole = targetAgent.role_class || undefined;
512
621
  return {
513
622
  id: `step-${index}`,
514
623
  index,
@@ -518,6 +627,7 @@ class WorkflowEngine extends events_1.EventEmitter {
518
627
  agentName: targetAgent.name,
519
628
  provider: targetAgent.provider,
520
629
  model: targetAgent.model,
630
+ role: explicitRole || agentRole,
521
631
  dependsOn: step.dependsOn || [],
522
632
  status: 'pending',
523
633
  expectedOutput: step.expectedOutput || undefined,
@@ -543,7 +653,7 @@ class WorkflowEngine extends events_1.EventEmitter {
543
653
  step.loopCount = 0;
544
654
  }
545
655
  }
546
- return this.pruneTrailingPostQaSteps(mapped);
656
+ return mapped;
547
657
  }
548
658
  }
549
659
  catch {
@@ -593,6 +703,14 @@ class WorkflowEngine extends events_1.EventEmitter {
593
703
  }
594
704
  return steps.length >= 2 ? steps : null;
595
705
  }
706
+ /**
707
+ * @deprecated Phase B of orchestration-plan.txt.
708
+ * The LLM-tool-dispatch path in local_desktop creates TODOs directly with
709
+ * the LLM's explicit step list, so this regex-based role inference no
710
+ * longer drives dispatch. Still called from decomposeTask's fallback for
711
+ * non-local_desktop and legacy planner paths. Remove after those are
712
+ * migrated to tool dispatch.
713
+ */
596
714
  determineDesiredRoleSequence(prompt, roleAssignments) {
597
715
  const normalized = prompt.toLowerCase();
598
716
  const explicitResearch = /\b(brain|gpt|research(?:es|ed|ing)?|investigat|analy[sz]e|analysis|sound idea|review the idea|pressure[- ]?test|brainstorm(?:ing)?)\b/.test(normalized)
@@ -646,6 +764,7 @@ class WorkflowEngine extends events_1.EventEmitter {
646
764
  agentName: agent.name,
647
765
  provider: agent.provider,
648
766
  model: agent.model,
767
+ role: 'research',
649
768
  dependsOn,
650
769
  status: 'pending',
651
770
  expectedOutput: 'Clear implementation guidance, risks, and recommendations for the downstream coder.',
@@ -662,6 +781,7 @@ class WorkflowEngine extends events_1.EventEmitter {
662
781
  agentName: agent.name,
663
782
  provider: agent.provider,
664
783
  model: agent.model,
784
+ role: 'coding',
665
785
  dependsOn,
666
786
  status: 'pending',
667
787
  expectedOutput: 'Completed deliverable that satisfies the user request.',
@@ -678,6 +798,7 @@ class WorkflowEngine extends events_1.EventEmitter {
678
798
  agentName: agent.name,
679
799
  provider: agent.provider,
680
800
  model: agent.model,
801
+ role: 'qa',
681
802
  dependsOn,
682
803
  status: 'pending',
683
804
  expectedOutput: 'PASS if the deliverable fully satisfies the request, otherwise FAIL with actionable defects.',
@@ -724,17 +845,45 @@ class WorkflowEngine extends events_1.EventEmitter {
724
845
  : role === 'qa'
725
846
  ? 'Your role is QA. Verify the delivered work, then complete the TODO through the worker tool or create the next fix handoff.'
726
847
  : 'Your role is implementer. Do the assigned work, then complete the TODO through the worker tool.';
848
+ const botSettingsPrompt = this.buildBotSettingsPrompt(profile);
849
+ const capabilityInstructions = profile
850
+ ? (0, capabilities_1.buildCapabilityInstructionBlock)(data.getAgentRolePriorities(profile))
851
+ : '';
727
852
  return [
728
- `You are ${profile?.name || 'a workflow worker'}.`,
853
+ botSettingsPrompt || `You are ${profile?.name || 'a workflow worker'}.`,
854
+ '',
855
+ '[Orchestrated Worker Guardrails]',
729
856
  'You are executing one orchestrated TODO task.',
730
857
  roleLine,
731
858
  'Follow the assignment in the user message exactly.',
732
859
  'Use available tools when needed.',
733
860
  'Do not return a plain-text final answer until you have called complete_worker_task or block_worker_task.',
861
+ 'Do not create side TODOs with add_task while working an assigned TODO. Use complete_worker_task.handoff_prompt or insert_task when follow-up work is needed.',
734
862
  'Do not add workflow planning, orchestration narration, or TODO-management commentary outside those tools.',
735
863
  'If blocked after checking available context, call block_worker_task with blocker_summary, checked_context, and user_question.',
736
864
  'If the task is complete, call complete_worker_task with output_summary and any needed handoff prompt or inserted next task.',
737
- ].join('\n');
865
+ capabilityInstructions ? '' : null,
866
+ capabilityInstructions || null,
867
+ ].filter(Boolean).join('\n');
868
+ }
869
+ buildBotSettingsPrompt(profile) {
870
+ if (!profile)
871
+ return '';
872
+ const directPrompt = String(profile.final_prompt || profile.soul_md || '').trim();
873
+ if (directPrompt)
874
+ return directPrompt;
875
+ const sections = [`You are ${profile.name}.`];
876
+ const addSection = (label, value) => {
877
+ const text = String(value || '').trim();
878
+ if (text)
879
+ sections.push('', label, text);
880
+ };
881
+ addSection('PURPOSE', profile.purpose_md);
882
+ addSection('IDENTITY', profile.identity_summary);
883
+ addSection('SKILLS', profile.skills_md);
884
+ addSection('TOOLS', profile.tools_md);
885
+ addSection('MEMORY', profile.memory_md);
886
+ return sections.join('\n');
738
887
  }
739
888
  buildOrchestratedTodoHandledResult(taskId) {
740
889
  if (!taskId)
@@ -758,12 +907,11 @@ class WorkflowEngine extends events_1.EventEmitter {
758
907
  || data.getTodoTask(taskId, 'active')?.blocker_summary);
759
908
  }
760
909
  buildWorkflowStepPrompt(step, pinnedContext, handoffArtifacts, handoffPrompts = [], localDesktopHandoffMode = false) {
761
- const primaryPrompt = localDesktopHandoffMode && handoffPrompts.length > 0
762
- ? handoffPrompts[handoffPrompts.length - 1].trim()
763
- : step.prompt.trim();
764
- const sections = [primaryPrompt || step.prompt.trim()];
765
- if (localDesktopHandoffMode && handoffPrompts.length > 1) {
766
- sections.push(`Earlier handoff context:\n${handoffPrompts.slice(0, -1).join('\n\n')}`);
910
+ const primaryPrompt = step.prompt.trim();
911
+ const sections = [primaryPrompt];
912
+ if (localDesktopHandoffMode && handoffPrompts.length > 0) {
913
+ const label = handoffPrompts.length === 1 ? 'Previous worker handoff' : 'Previous worker handoffs';
914
+ sections.push(`${label}:\n${handoffPrompts.join('\n\n')}`);
767
915
  }
768
916
  if (pinnedContext.length > 0) {
769
917
  sections.push(`Pinned context:\n${pinnedContext.join('\n\n')}`);
@@ -811,68 +959,39 @@ class WorkflowEngine extends events_1.EventEmitter {
811
959
  }
812
960
  return defaultProfile;
813
961
  }
962
+ /**
963
+ * Resolve a step's role. Prefers the structured `role` field set at plan
964
+ * creation (from an explicit planner decision or the bot's role_class).
965
+ * Falls through to a small normalizer that maps common role synonyms
966
+ * (e.g. "verify" → "qa") onto the three canonical workflow roles.
967
+ * Does NOT scan prose. Phase B of orchestration-plan.txt: pass/fail,
968
+ * routing, and task creation must come from structured data, not from
969
+ * English wording.
970
+ */
814
971
  inferRoleForStep(step) {
815
- const description = `${step.description || ''}`.toLowerCase();
816
- const prompt = `${step.prompt || ''} ${step.expectedOutput || ''}`.toLowerCase();
817
- const leadingVerbMatch = description.match(/^(?:[a-z0-9_.-]+\s+)?(qa|review(?:s|ed|ing)?|verif(?:y|ies|ied|ying)|validation|test(?:s|ed|ing)?|check(?:s|ed|ing)?|research(?:es|ed|ing)?|investigat(?:e|es|ed|ing)|analysis|analy[sz](?:e|es|ed|ing)|compar(?:e|es|ed|ing)|inspect(?:s|ed|ing)?|discover(?:s|ed|ing)?|defin(?:e|es|ed|ing)|build(?:s|ed|ing)?|creat(?:e|es|ed|ing)|write(?:s|n)?|writ(?:ing)?|fix(?:es|ed|ing)?|edit(?:s|ed|ing)?|updat(?:e|es|ed|ing)|refactor(?:s|ed|ing)?|implement(?:s|ed|ing)?|code(?:s|d|ing)?)\b/);
818
- const qaPattern = /\b(qa|quality assurance|review(?:s|ed|ing)?|verif(?:y|ies|ied|ying|ication)?|validation|test(?:s|ed|ing)?|check(?:s|ed|ing)?)\b/;
819
- const researchPattern = /\b(research(?:es|ed|ing)?|investigat(?:e|es|ed|ing)|analysis|analy[sz](?:e|es|ed|ing)|compar(?:e|es|ed|ing)|inspect(?:s|ed|ing)?|look up|discover(?:s|ed|ing)?|defin(?:e|es|ed|ing)|direction|spec(?:ification)?s?|requirements?)\b/;
820
- const codingPattern = /\b(code(?:s|d|ing)?|implement(?:s|ed|ing)?|build(?:s|ed|ing)?|creat(?:e|es|ed|ing)|write(?:s|n)?|writ(?:ing)?|fix(?:es|ed|ing)?|edit(?:s|ed|ing)?|updat(?:e|es|ed|ing)|refactor(?:s|ed|ing)?)\b/;
821
- if (leadingVerbMatch) {
822
- const verb = leadingVerbMatch[1];
823
- if (/(qa|review|verif|validation|test|check)/.test(verb))
824
- return 'qa';
825
- if (/(research|investigat|analysis|analy|compar|inspect|discover|defin)/.test(verb))
826
- return 'research';
827
- if (/(build|creat|write|writ|fix|edit|updat|refactor|implement|code)/.test(verb))
828
- return 'coding';
829
- }
830
- if (qaPattern.test(description) && !codingPattern.test(description))
972
+ const raw = typeof step.role === 'string' ? step.role.trim().toLowerCase() : '';
973
+ if (!raw)
974
+ return null;
975
+ if (raw === 'qa' || raw === 'review' || raw === 'reviewer' || raw === 'quality' || raw === 'quality assurance')
831
976
  return 'qa';
832
- if (codingPattern.test(description))
833
- return 'coding';
834
- if (researchPattern.test(description))
835
- return 'research';
836
- if (codingPattern.test(prompt) && !qaPattern.test(prompt))
837
- return 'coding';
838
- if (qaPattern.test(prompt))
977
+ if (raw === 'verify' || raw === 'verification')
978
+ return 'qa';
979
+ if (/\b(verify|verification)\b/.test(raw))
839
980
  return 'qa';
840
- if (researchPattern.test(prompt))
981
+ if (raw === 'research' || raw === 'researcher' || raw === 'analysis' || raw === 'analyst' || raw === 'planning' || raw === 'planner' || raw === 'brainstorming' || raw === 'design' || raw === 'designer')
841
982
  return 'research';
842
- if (codingPattern.test(prompt))
983
+ if (raw === 'coding' || raw === 'code' || raw === 'coder' || raw === 'build' || raw === 'building' || raw === 'builder' || raw === 'implement' || raw === 'implementation' || raw === 'dev' || raw === 'developer' || raw === 'engineering' || raw === 'commit' || raw === 'deploy')
843
984
  return 'coding';
985
+ // Comma-separated multi-roles (legacy) — take the first canonical match.
986
+ if (!/[,/]/.test(raw))
987
+ return null;
988
+ for (const part of raw.split(/[,/]/).map((s) => s.trim()).filter(Boolean)) {
989
+ const mapped = this.inferRoleForStep({ role: part });
990
+ if (mapped)
991
+ return mapped;
992
+ }
844
993
  return null;
845
994
  }
846
- pruneTrailingPostQaSteps(steps) {
847
- const firstQaIndex = steps.findIndex((step) => this.inferRoleForStep(step) === 'qa');
848
- if (firstQaIndex < 0)
849
- return steps;
850
- const shouldDrop = (step) => {
851
- if (step.index <= firstQaIndex)
852
- return false;
853
- const normalized = `${step.description || ''} ${step.prompt || ''}`.toLowerCase();
854
- return /\b(if applicable|based on qa feedback|based on feedback|revise|revision|finalize|confirm completion|compile instructions|ensure the file is accessible|ensure file is accessible)\b/.test(normalized);
855
- };
856
- const filtered = steps.filter((step) => !shouldDrop(step));
857
- if (filtered.length === steps.length)
858
- return steps;
859
- return filtered.map((step, newIndex) => {
860
- const oldIndex = step.index;
861
- const retainedPriorSteps = filtered
862
- .filter((candidate) => candidate.index < oldIndex)
863
- .map((candidate) => candidate.index);
864
- const reindexedDeps = (step.dependsOn || [])
865
- .filter((dep) => retainedPriorSteps.includes(dep))
866
- .map((dep) => filtered.findIndex((candidate) => candidate.index === dep))
867
- .filter((dep) => dep >= 0);
868
- return {
869
- ...step,
870
- index: newIndex,
871
- id: step.id || `step-${newIndex}`,
872
- dependsOn: reindexedDeps,
873
- };
874
- });
875
- }
876
995
  findPriorCodingStepIndex(steps, currentIndex) {
877
996
  const priorCodingStep = [...steps]
878
997
  .filter((candidate) => candidate.index < currentIndex)
@@ -913,6 +1032,15 @@ class WorkflowEngine extends events_1.EventEmitter {
913
1032
  const failed = new Set();
914
1033
  const allowMissingFooterForDirectStep = !opts?.isOrchestrated && !!opts?.disableDecomposition && steps.length === 1;
915
1034
  while (completed.size + failed.size < steps.length) {
1035
+ if (opts?.abortSignal?.aborted) {
1036
+ for (const step of steps) {
1037
+ if (step.status === 'pending' || step.status === 'running') {
1038
+ step.status = 'skipped';
1039
+ step.error = 'Cancelled by user';
1040
+ }
1041
+ }
1042
+ break;
1043
+ }
916
1044
  // Find steps that are ready to run (all deps met)
917
1045
  const ready = steps.filter(s => s.status === 'pending' &&
918
1046
  s.dependsOn.every(dep => completed.has(dep)));
@@ -928,6 +1056,11 @@ class WorkflowEngine extends events_1.EventEmitter {
928
1056
  }
929
1057
  // Execute ready steps in parallel
930
1058
  await Promise.all(ready.map(async (step) => {
1059
+ if (opts?.abortSignal?.aborted) {
1060
+ step.status = 'skipped';
1061
+ step.error = 'Cancelled by user';
1062
+ return;
1063
+ }
931
1064
  // Build context from completed dependency results — pass only RESULT artifact
932
1065
  const handoffArtifacts = [];
933
1066
  const handoffPrompts = [];
@@ -956,6 +1089,7 @@ class WorkflowEngine extends events_1.EventEmitter {
956
1089
  }
957
1090
  }
958
1091
  }
1092
+ const initialImageDeliveryMode = opts?.workerImageDeliveryMode || 'inline';
959
1093
  const fullPrompt = this.buildWorkflowStepPrompt(step, pinnedContext, handoffArtifacts, handoffPrompts, localDesktopHandoffMode);
960
1094
  // approvalRequired gate: when enabled, emit an approval request
961
1095
  // event and wait for the listener to resolve before proceeding.
@@ -1005,6 +1139,7 @@ class WorkflowEngine extends events_1.EventEmitter {
1005
1139
  }
1006
1140
  catch { /* best effort */ }
1007
1141
  try {
1142
+ throwIfWorkflowAborted(opts?.abortSignal);
1008
1143
  // Build previous steps summary for workflow context
1009
1144
  const prevStepsText = steps
1010
1145
  .filter(s => s.status === 'completed' && s.index < step.index)
@@ -1023,8 +1158,40 @@ class WorkflowEngine extends events_1.EventEmitter {
1023
1158
  retryCount: step.attempts,
1024
1159
  loopCount: step.loopCount,
1025
1160
  };
1026
- const result = await this.executeStep(step, fullPrompt, conversationId, opts, opts?.apiKey, wfContext);
1161
+ let activeImageDeliveryMode = initialImageDeliveryMode;
1162
+ let result = await this.executeStep(step, fullPrompt, conversationId, { ...opts, workerImageDeliveryMode: activeImageDeliveryMode }, opts?.apiKey, wfContext);
1163
+ const requestedImage = activeImageDeliveryMode === 'reference'
1164
+ ? parseImageEscalationRequest(result, opts?.storedAttachments)
1165
+ : null;
1166
+ if (requestedImage) {
1167
+ throwIfWorkflowAborted(opts?.abortSignal);
1168
+ this.emitProgress(workflowId, step, steps, 'step-progress', opts?.onProgress, `${step.agentName} requested the original image for ${step.description}`);
1169
+ activeImageDeliveryMode = 'inline';
1170
+ result = await this.executeStep(step, fullPrompt, conversationId, { ...opts, workerImageDeliveryMode: activeImageDeliveryMode }, opts?.apiKey, wfContext);
1171
+ const repeatedEscalation = parseImageEscalationRequest(result, opts?.storedAttachments);
1172
+ if (repeatedEscalation) {
1173
+ step.status = 'failed';
1174
+ step.result = result;
1175
+ step.error = `Worker requested image escalation again after the original image was already provided (${repeatedEscalation.id}).`;
1176
+ step.completedAt = Date.now();
1177
+ failed.add(step.index);
1178
+ this.emitProgress(workflowId, step, steps, 'step-failed', opts?.onProgress, undefined, opts?.onWorkerChunk);
1179
+ try {
1180
+ const execId = step._execId;
1181
+ if (execId)
1182
+ data.updateStepExecution(execId, {
1183
+ status: 'failed',
1184
+ attempts: step.attempts,
1185
+ error: step.error,
1186
+ completedAt: new Date().toISOString().replace('T', ' ').replace('Z', ''),
1187
+ });
1188
+ }
1189
+ catch { /* best effort */ }
1190
+ return;
1191
+ }
1192
+ }
1027
1193
  if (opts?.isOrchestrated && opts.taskId) {
1194
+ throwIfWorkflowAborted(opts?.abortSignal);
1028
1195
  const completedTodo = data.getTodoTask(opts.taskId, 'completed');
1029
1196
  if (completedTodo) {
1030
1197
  step._handoffPrompt = completedTodo.handoff_prompt || '';
@@ -1304,6 +1471,31 @@ class WorkflowEngine extends events_1.EventEmitter {
1304
1471
  catch { /* best effort */ }
1305
1472
  }
1306
1473
  catch (err) {
1474
+ if (isWorkflowAbortError(err)) {
1475
+ step.status = 'skipped';
1476
+ step.error = 'Cancelled by user';
1477
+ step.completedAt = Date.now();
1478
+ for (const s of steps) {
1479
+ if (s.status === 'pending') {
1480
+ s.status = 'skipped';
1481
+ s.error = 'Cancelled by user';
1482
+ }
1483
+ }
1484
+ this.emitProgress(workflowId, step, steps, 'step-failed', opts?.onProgress, undefined, opts?.onWorkerChunk);
1485
+ try {
1486
+ const execId = step._execId;
1487
+ if (execId) {
1488
+ data.updateStepExecution(execId, {
1489
+ status: 'failed',
1490
+ attempts: step.attempts,
1491
+ error: step.error,
1492
+ completedAt: new Date().toISOString().replace('T', ' ').replace('Z', ''),
1493
+ });
1494
+ }
1495
+ }
1496
+ catch { /* best effort */ }
1497
+ return;
1498
+ }
1307
1499
  const authLikeFailure = typeof err?.message === 'string'
1308
1500
  && (err.message.startsWith('AUTH_REQUIRED:')
1309
1501
  || /invalid_grant/i.test(err.message)
@@ -1364,14 +1556,40 @@ class WorkflowEngine extends events_1.EventEmitter {
1364
1556
  }
1365
1557
  step.attempts++;
1366
1558
  step.error = err.message;
1367
- if (typeof err?.message === 'string' && this.isTerminalProviderFailure(err.message)) {
1559
+ const providerLimitFailure = typeof err?.message === 'string'
1560
+ ? this.classifyProviderLimitFailure(err.message)
1561
+ : null;
1562
+ const terminalProviderFailure = typeof err?.message === 'string' && this.isTerminalProviderFailure(err.message);
1563
+ if (providerLimitFailure || terminalProviderFailure) {
1368
1564
  step.status = 'failed';
1565
+ step.result = providerLimitFailure?.exactMessage || err.message;
1566
+ step.error = providerLimitFailure?.exactMessage || err.message;
1369
1567
  step.completedAt = Date.now();
1370
1568
  failed.add(step.index);
1371
1569
  for (const s of steps) {
1372
1570
  if (s.dependsOn.includes(step.index) && s.status === 'pending') {
1373
1571
  s.status = 'skipped';
1374
- s.error = 'Stopped because a prior step failed with a provider/runtime error';
1572
+ s.error = providerLimitFailure
1573
+ ? 'Stopped because a prior step hit a provider usage/rate limit'
1574
+ : 'Stopped because a prior step failed with a provider/runtime error';
1575
+ }
1576
+ }
1577
+ if (opts?.taskId) {
1578
+ try {
1579
+ data.markTodoTaskRuntimeBlocked(opts.taskId, {
1580
+ actor: { actorType: 'orchestrator', actorId: 'Workflow Engine' },
1581
+ blockerSummary: providerLimitFailure?.exactMessage || String(err?.message || err),
1582
+ checkedContext: providerLimitFailure
1583
+ ? 'Provider returned a quota/rate-limit error during worker execution. The exact provider message is preserved verbatim.'
1584
+ : 'Provider/runtime error during worker execution. The exact provider or PTY/app-server failure reason is preserved verbatim.',
1585
+ userQuestion: providerLimitFailure
1586
+ ? 'Fix the provider account limit or wait until the provider retry window resets, then resume this plan.'
1587
+ : 'Fix the provider/runtime issue, then resume this plan.',
1588
+ reasonCode: providerLimitFailure?.reasonCode || 'provider_runtime_error',
1589
+ });
1590
+ }
1591
+ catch (blockErr) {
1592
+ console.warn(`[workflow-engine] failed to mark TODO ${opts.taskId} blocked after provider/runtime failure: ${blockErr?.message || blockErr}`);
1375
1593
  }
1376
1594
  }
1377
1595
  this.emitProgress(workflowId, step, steps, 'step-failed', opts?.onProgress, undefined, opts?.onWorkerChunk);
@@ -1382,6 +1600,7 @@ class WorkflowEngine extends events_1.EventEmitter {
1382
1600
  status: 'failed',
1383
1601
  attempts: step.attempts,
1384
1602
  error: step.error,
1603
+ resultSummary: step.result?.slice(0, 200),
1385
1604
  completedAt: new Date().toISOString().replace('T', ' ').replace('Z', ''),
1386
1605
  });
1387
1606
  }
@@ -1514,21 +1733,216 @@ class WorkflowEngine extends events_1.EventEmitter {
1514
1733
  const effectivePrompt = opts?.isOrchestrated && opts?.taskId
1515
1734
  ? `CURRENT TODO ID: ${opts.taskId}\nUse this TODO id when calling complete_worker_task or block_worker_task.\n\n${prompt}`
1516
1735
  : prompt;
1517
- const messages = [{ role: 'user', content: effectivePrompt }];
1736
+ const workerImageDeliveryMode = opts?.workerImageDeliveryMode || 'inline';
1737
+ const deferredImageReferenceBlock = workerImageDeliveryMode === 'reference'
1738
+ ? buildDeferredImageReferenceBlock(opts?.storedAttachments)
1739
+ : '';
1740
+ if (deferredImageReferenceBlock) {
1741
+ systemPrompt = [
1742
+ systemPrompt,
1743
+ '',
1744
+ 'If the user message includes an "Original Image Context Available" section and you truly need the original image, reply exactly with ESCALATE_IMAGE <id> and no other text.',
1745
+ ].join('\n');
1746
+ }
1747
+ const promptWithDeferredImageNote = deferredImageReferenceBlock
1748
+ ? `${effectivePrompt}\n\n${deferredImageReferenceBlock}`
1749
+ : effectivePrompt;
1750
+ // Build user message — include native images if the worker's provider supports them
1751
+ const workerAttachments = opts?.storedAttachments;
1752
+ const workerSupportsImages = activeRuntime.providerName === 'anthropic'
1753
+ || activeRuntime.providerName === 'openai'
1754
+ || activeRuntime.providerName === 'google'
1755
+ || activeRuntime.providerName === 'codex-cli'
1756
+ || activeRuntime.providerName === 'claude-cli';
1757
+ let userContent = promptWithDeferredImageNote;
1758
+ if (workerImageDeliveryMode === 'inline' && workerAttachments && workerAttachments.length > 0 && workerSupportsImages) {
1759
+ const parts = [
1760
+ { type: 'text', text: promptWithDeferredImageNote },
1761
+ ];
1762
+ for (const att of workerAttachments) {
1763
+ if (att.storagePath && att.mimeType?.startsWith('image/')) {
1764
+ try {
1765
+ const imageData = fs_1.default.readFileSync(att.storagePath).toString('base64');
1766
+ parts.push({ type: 'image', mimeType: att.mimeType, data: imageData });
1767
+ }
1768
+ catch { /* file may not exist */ }
1769
+ }
1770
+ }
1771
+ if (parts.length > 1)
1772
+ userContent = parts;
1773
+ }
1774
+ const messages = [{ role: 'user', content: userContent }];
1518
1775
  const promptChars = effectivePrompt.length;
1519
1776
  const systemChars = systemPrompt.length;
1520
1777
  const isLocalCliSession = effectiveRuntimeMode === 'local_desktop'
1521
1778
  && (activeRuntime.providerName === 'claude-cli' || activeRuntime.providerName === 'codex-cli');
1522
1779
  const workflowCliSessionTransport = resolveWorkflowCliSessionTransport(activeRuntime.providerName, isLocalCliSession);
1780
+ const cliSessionScopeKey = String(opts?.cliSessionScopeKey || '').trim() || null;
1523
1781
  const hasPersistentConversation = isLocalCliSession && !!conversationId;
1524
1782
  const cliSessionEpochPlan = hasPersistentConversation
1525
- ? (0, cli_session_epoch_1.selectCliSessionEpoch)(conversationId, step.agentId, activeRuntime.providerName)
1526
- : { existing: undefined, resumeSessionId: null, resetReason: null };
1783
+ ? (0, cli_session_epoch_1.evaluateWarmSessionReuse)(conversationId, step.agentId, activeRuntime.providerName, undefined, cliSessionScopeKey)
1784
+ : {
1785
+ outcome: 'cold_start',
1786
+ existing: undefined,
1787
+ resumeSessionId: null,
1788
+ resetReason: null,
1789
+ topicId: null,
1790
+ };
1527
1791
  let activeCliSessionId = cliSessionEpochPlan.resumeSessionId;
1528
1792
  const cliEpochStartedAt = cliSessionEpochPlan.resumeSessionId
1529
1793
  ? (cliSessionEpochPlan.existing?.epoch_started_at || (0, cli_session_epoch_1.localTimestamp)())
1530
1794
  : (0, cli_session_epoch_1.localTimestamp)();
1531
- const ptyConversationKey = buildWorkflowPtyConversationKey(conversationId, step, workflowContext);
1795
+ const ptyConversationKey = buildWorkflowPtyConversationKey(conversationId, step, workflowContext, cliSessionScopeKey);
1796
+ let cliEpochResetReason = cliSessionEpochPlan.resetReason;
1797
+ // Resolve prompt context window early for session launch classification,
1798
+ // cross-bot context injection, and bootstrap decisions.
1799
+ const promptContextWindow = hasPersistentConversation
1800
+ ? (0, context_window_1.getPromptContextWindow)(conversationId, 5, {
1801
+ targetBotId: step.agentId,
1802
+ targetBotName: step.agentName,
1803
+ })
1804
+ : { summary: null, turns: [], sourceConversationId: conversationId || '', carriedForward: false, mode: 'new_topic', allowBootstrap: false, lastCrossBotTurn: null };
1805
+ // Session lifecycle logging — mirrors local-server.ts invariant plus
1806
+ // structured telemetry events for diagnostics.
1807
+ const sessionLaunchReason = cliSessionEpochPlan.resumeSessionId
1808
+ ? 'resumed'
1809
+ : promptContextWindow.mode === 'new_topic'
1810
+ ? 'new_topic/no_context'
1811
+ : cliSessionEpochPlan.resetReason
1812
+ ? cliSessionEpochPlan.resetReason
1813
+ : 'fresh_with_bootstrap';
1814
+ console.info(`[workflow-engine] session_launch_reason=${sessionLaunchReason} agent=${step.agentName} provider=${activeRuntime.providerName} conversationId=${conversationId || '(none)'} resumeSessionId=${cliSessionEpochPlan.resumeSessionId || '(none)'} mode=${promptContextWindow.mode || 'unknown'} bootstrap=${!!promptContextWindow.allowBootstrap}`);
1815
+ console.info(`[workflow-engine] cli_session_selected conversationId=${conversationId || '(none)'} botId=${step.agentId} provider=${activeRuntime.providerName} selectedSessionId=${cliSessionEpochPlan.resumeSessionId || '(none)'} resetReason=${cliSessionEpochPlan.resetReason || '(none)'}`);
1816
+ if (workflowCliSessionTransport === 'codex-app-server'
1817
+ && cliSessionEpochPlan.outcome === 'epoch_reset'
1818
+ && cliSessionEpochPlan.resetReason === 'token_limit') {
1819
+ console.info(`[workflow-engine] codex_thread_reset_token_limit conversationId=${conversationId || '(none)'} botId=${step.agentId} provider=${activeRuntime.providerName} previousSessionId=${cliSessionEpochPlan.existing?.session_id || '(none)'} topicId=${cliSessionEpochPlan.topicId || '(none)'}`);
1820
+ }
1821
+ if (cliSessionEpochPlan.resumeSessionId) {
1822
+ console.info(`[workflow-engine] cli_session_resuming conversationId=${conversationId || '(none)'} botId=${step.agentId} provider=${activeRuntime.providerName} sessionId=${cliSessionEpochPlan.resumeSessionId}`);
1823
+ }
1824
+ else if (promptContextWindow.mode === 'new_topic') {
1825
+ console.info(`[workflow-engine] cli_session_fresh_without_bootstrap_new_topic conversationId=${conversationId || '(none)'} botId=${step.agentId} provider=${activeRuntime.providerName}`);
1826
+ }
1827
+ else if (promptContextWindow.allowBootstrap) {
1828
+ console.info(`[workflow-engine] cli_session_fresh_bootstrap_applied conversationId=${conversationId || '(none)'} botId=${step.agentId} provider=${activeRuntime.providerName}`);
1829
+ }
1830
+ else {
1831
+ console.warn(`[workflow-engine] cli_session_fresh_without_bootstrap_unexpected conversationId=${conversationId || '(none)'} botId=${step.agentId} provider=${activeRuntime.providerName} mode=${promptContextWindow.mode || 'unknown'}`);
1832
+ }
1833
+ // For initial fresh sessions (not resumed) with existing context, write
1834
+ // bootstrap history and inject cross-bot context into the user message.
1835
+ // This ensures the invariant: fresh session for existing context = bootstrap required.
1836
+ if (hasPersistentConversation
1837
+ && !cliSessionEpochPlan.resumeSessionId
1838
+ && promptContextWindow.allowBootstrap
1839
+ && conversationId) {
1840
+ const topicId = data.getPrimaryTopicIdForConversation(conversationId) || undefined;
1841
+ const bootstrapHistoryFilePath = (0, cli_bootstrap_history_1.writeConversationBootstrapHistoryFile)({
1842
+ conversationId,
1843
+ topicId,
1844
+ botId: step.agentId,
1845
+ botName: step.agentName,
1846
+ });
1847
+ if (bootstrapHistoryFilePath) {
1848
+ const bootstrapInstruction = [
1849
+ `Please read the history file at: ${bootstrapHistoryFilePath}`,
1850
+ 'Use it for context only.',
1851
+ 'There is no need to mention the file, its path, or that you loaded context unless the user explicitly asks about it.',
1852
+ 'Then respond to the user request below.',
1853
+ '',
1854
+ ].join('\n');
1855
+ const currentUserContent = messages[0]?.content;
1856
+ const currentText = typeof currentUserContent === 'string'
1857
+ ? currentUserContent
1858
+ : Array.isArray(currentUserContent)
1859
+ ? currentUserContent.filter((p) => p?.type === 'text').map((p) => p.text).join('\n')
1860
+ : String(currentUserContent || '');
1861
+ messages[0] = { role: 'user', content: `${bootstrapInstruction}${currentText}` };
1862
+ }
1863
+ }
1864
+ // Inject cross-bot context for fresh sessions (not resumed) when the
1865
+ // previous turn was from a different bot. Do not rely on CLI session
1866
+ // memory for cross-bot context.
1867
+ if (hasPersistentConversation
1868
+ && !cliSessionEpochPlan.resumeSessionId
1869
+ && promptContextWindow.lastCrossBotTurn) {
1870
+ const crossBotBlock = (0, context_window_1.formatCrossBotPreviousTurn)(promptContextWindow.lastCrossBotTurn);
1871
+ const currentUserContent = messages[0]?.content;
1872
+ if (typeof currentUserContent === 'string') {
1873
+ messages[0] = { role: 'user', content: `${crossBotBlock}\n\n${currentUserContent}` };
1874
+ }
1875
+ }
1876
+ // For resumed sessions, inject cross-bot context into the user message
1877
+ // since the CLI session memory only has same-bot context.
1878
+ if (hasPersistentConversation
1879
+ && !!cliSessionEpochPlan.resumeSessionId
1880
+ && promptContextWindow.lastCrossBotTurn) {
1881
+ const crossBotBlock = (0, context_window_1.formatCrossBotPreviousTurn)(promptContextWindow.lastCrossBotTurn);
1882
+ const currentUserContent = messages[0]?.content;
1883
+ if (typeof currentUserContent === 'string') {
1884
+ messages[0] = { role: 'user', content: `${crossBotBlock}\n\n${currentUserContent}` };
1885
+ }
1886
+ }
1887
+ // Bootstrap fallback: when a resumed CLI session fails, rebuild the prompt
1888
+ // with bootstrap context (running summary + last 5 turns) before retrying
1889
+ // with a fresh session. Ensures prompt-building path and session runtime
1890
+ // reality always agree.
1891
+ let freshCliBootstrapFallbackApplied = false;
1892
+ let freshCliBootstrapFileWritten = false;
1893
+ const applyFreshCliBootstrapFallback = (reason) => {
1894
+ if (freshCliBootstrapFallbackApplied)
1895
+ return;
1896
+ freshCliBootstrapFallbackApplied = true;
1897
+ cliEpochResetReason = reason;
1898
+ if (!conversationId)
1899
+ return;
1900
+ const topicId = data.getPrimaryTopicIdForConversation(conversationId) || undefined;
1901
+ const freshPromptContextWindow = (0, context_window_1.getPromptContextWindow)(conversationId, 5, {
1902
+ targetBotId: step.agentId,
1903
+ targetBotName: step.agentName,
1904
+ });
1905
+ const bootstrapHistoryFilePath = freshPromptContextWindow.allowBootstrap
1906
+ ? (0, cli_bootstrap_history_1.writeConversationBootstrapHistoryFile)({
1907
+ conversationId,
1908
+ topicId,
1909
+ botId: step.agentId,
1910
+ botName: step.agentName,
1911
+ })
1912
+ : null;
1913
+ freshCliBootstrapFileWritten = !!bootstrapHistoryFilePath;
1914
+ if (bootstrapHistoryFilePath) {
1915
+ const bootstrapInstruction = [
1916
+ `Please read the history file at: ${bootstrapHistoryFilePath}`,
1917
+ 'Use it for context only.',
1918
+ 'There is no need to mention the file, its path, or that you loaded context unless the user explicitly asks about it.',
1919
+ 'Then respond to the user request below.',
1920
+ '',
1921
+ ].join('\n');
1922
+ const currentUserContent = messages[0]?.content;
1923
+ const currentText = typeof currentUserContent === 'string'
1924
+ ? currentUserContent
1925
+ : Array.isArray(currentUserContent)
1926
+ ? currentUserContent.filter((p) => p?.type === 'text').map((p) => p.text).join('\n')
1927
+ : String(currentUserContent || '');
1928
+ messages[0] = { role: 'user', content: `${bootstrapInstruction}${currentText}` };
1929
+ }
1930
+ if (freshPromptContextWindow.lastCrossBotTurn) {
1931
+ const crossBotBlock = (0, context_window_1.formatCrossBotPreviousTurn)(freshPromptContextWindow.lastCrossBotTurn);
1932
+ const currentUserContent = messages[0]?.content;
1933
+ if (typeof currentUserContent === 'string') {
1934
+ messages[0] = { role: 'user', content: `${crossBotBlock}\n\n${currentUserContent}` };
1935
+ }
1936
+ }
1937
+ console.info(`[workflow-engine] session_launch_reason=resume_failed agent=${step.agentName} provider=${activeRuntime.providerName} conversationId=${conversationId} bootstrap=${!!bootstrapHistoryFilePath}`);
1938
+ console.warn(`[workflow-engine] cli_session_resume_failed conversationId=${conversationId || '(none)'} botId=${step.agentId} provider=${activeRuntime.providerName} bootstrap=${!!bootstrapHistoryFilePath}`);
1939
+ if (bootstrapHistoryFilePath) {
1940
+ console.info(`[workflow-engine] cli_session_fresh_bootstrap_applied conversationId=${conversationId || '(none)'} botId=${step.agentId} provider=${activeRuntime.providerName} bootstrapReason=resume_failed`);
1941
+ }
1942
+ else {
1943
+ console.warn(`[workflow-engine] cli_session_fresh_without_bootstrap_unexpected conversationId=${conversationId || '(none)'} botId=${step.agentId} provider=${activeRuntime.providerName} bootstrapReason=resume_failed`);
1944
+ }
1945
+ };
1532
1946
  console.info(`[workflow-engine] step runtime: agent=${step.agentName} provider=${activeRuntime.providerName} model=${activeRuntime.model} runtime=${activeRuntime.runtimeLabel || 'unknown'} promptChars=${promptChars} systemChars=${systemChars} tools=${opts?.disableTools ? 0 : toolDefs.length}`);
1533
1947
  // Agentic loop
1534
1948
  let iteration = 0;
@@ -1536,6 +1950,7 @@ class WorkflowEngine extends events_1.EventEmitter {
1536
1950
  let incompleteExecutionRecoveries = 0;
1537
1951
  let selectedRuntimeRetryCount = 0;
1538
1952
  while (iteration < MAX_ITERATIONS) {
1953
+ throwIfWorkflowAborted(opts?.abortSignal);
1539
1954
  if (opts?.isOrchestrated && this.orchestratedTodoHandled(opts.taskId)) {
1540
1955
  return this.buildOrchestratedTodoHandledResult(opts.taskId);
1541
1956
  }
@@ -1559,8 +1974,10 @@ class WorkflowEngine extends events_1.EventEmitter {
1559
1974
  const codexAppServerManager = (0, codex_app_server_manager_1.getCodexAppServerManager)();
1560
1975
  let appServerAttempt = 0;
1561
1976
  while (true) {
1977
+ throwIfWorkflowAborted(opts?.abortSignal);
1562
1978
  appServerAttempt++;
1563
1979
  try {
1980
+ console.info(`[workflow-engine] codex_app_server_turn_start workflow=${workflowContext?.workflowId || '(none)'} step=${workflowContext?.stepIndex ?? 0} agent=${step.agentName} conversationId=${ptyConversationKey} taskId=${opts?.taskId ?? '(none)'} attempt=${appServerAttempt}`);
1564
1981
  const appServerResult = await codexAppServerManager.runTurn({
1565
1982
  runtimeMode: 'local_desktop',
1566
1983
  conversationId: ptyConversationKey,
@@ -1569,7 +1986,8 @@ class WorkflowEngine extends events_1.EventEmitter {
1569
1986
  cwd: effectiveProjectDir,
1570
1987
  systemPrompt,
1571
1988
  messages,
1572
- forceFreshSession: !hasPersistentConversation || (iteration === 1 && !cliSessionEpochPlan.resumeSessionId),
1989
+ abortSignal: opts?.abortSignal,
1990
+ forceFreshSession: !hasPersistentConversation || (iteration === 1 && !codexAppServerManager.hasActiveSession(ptyConversationKey, step.agentId) && !cliSessionEpochPlan.resumeSessionId),
1573
1991
  resumeSessionId: cliSessionEpochPlan.resumeSessionId || undefined,
1574
1992
  model: activeRuntime.model,
1575
1993
  projectId: opts?.projectId ?? null,
@@ -1604,10 +2022,61 @@ class WorkflowEngine extends events_1.EventEmitter {
1604
2022
  : [step];
1605
2023
  this.emitProgress(workflowContext?.workflowId || 'workflow', step, activeSteps, 'step-progress', opts?.onProgress, `${step.agentName} is still working on ${step.description}`);
1606
2024
  },
2025
+ // Forward Codex app-server tool/file events as worker_terminal_chunk-style detail lines.
2026
+ // This populates the worker child card details panel for Codex workers.
2027
+ onDetail: async (detail) => {
2028
+ if (!detail || !opts?.onWorkerChunk)
2029
+ return;
2030
+ opts.onWorkerChunk({
2031
+ type: 'worker_terminal_chunk',
2032
+ stepId: step.id,
2033
+ agentName: step.agentName,
2034
+ description: step.description,
2035
+ stepIndex: workflowContext?.stepIndex ?? 0,
2036
+ totalSteps: workflowContext?.totalSteps ?? 1,
2037
+ rawText: detail,
2038
+ });
2039
+ },
2040
+ // Forward structured tool call/result events from Codex app-server
2041
+ // (command_execution, file_change, mcpToolCall items) as
2042
+ // worker_tool_call / worker_tool_result events. This gives Codex
2043
+ // workers the same structured tool visibility as API-key workers.
2044
+ onToolEvent: async (event) => {
2045
+ if (!opts?.onWorkerChunk)
2046
+ return;
2047
+ if (event.kind === 'call') {
2048
+ opts.onWorkerChunk({
2049
+ type: opts?.isOrchestrated ? 'worker_tool_call' : 'tool_call',
2050
+ stepId: step.id,
2051
+ agentName: step.agentName,
2052
+ description: step.description,
2053
+ stepIndex: workflowContext?.stepIndex ?? 0,
2054
+ totalSteps: workflowContext?.totalSteps ?? 1,
2055
+ toolCallId: event.toolCallId,
2056
+ toolName: event.toolName,
2057
+ toolArguments: event.arguments,
2058
+ });
2059
+ }
2060
+ else {
2061
+ opts.onWorkerChunk({
2062
+ type: opts?.isOrchestrated ? 'worker_tool_result' : 'tool_result',
2063
+ stepId: step.id,
2064
+ agentName: step.agentName,
2065
+ description: step.description,
2066
+ stepIndex: workflowContext?.stepIndex ?? 0,
2067
+ totalSteps: workflowContext?.totalSteps ?? 1,
2068
+ toolCallId: event.toolCallId,
2069
+ toolName: event.toolName,
2070
+ toolOutput: event.output,
2071
+ toolIsError: event.isError,
2072
+ });
2073
+ }
2074
+ },
1607
2075
  });
1608
2076
  if (appServerResult.sessionId) {
1609
2077
  activeCliSessionId = appServerResult.sessionId;
1610
2078
  }
2079
+ console.info(`[workflow-engine] codex_app_server_turn_done workflow=${workflowContext?.workflowId || '(none)'} step=${workflowContext?.stepIndex ?? 0} agent=${step.agentName} conversationId=${ptyConversationKey} taskId=${opts?.taskId ?? '(none)'} contentChars=${String(appServerResult.content || '').length}`);
1611
2080
  response = {
1612
2081
  content: appServerResult.content || '',
1613
2082
  usage: appServerResult.usage,
@@ -1615,13 +2084,20 @@ class WorkflowEngine extends events_1.EventEmitter {
1615
2084
  break;
1616
2085
  }
1617
2086
  catch (appServerErr) {
1618
- codexAppServerManager.closeSessionByConversation(ptyConversationKey, step.agentId);
2087
+ console.error(`[workflow-engine] codex_app_server_turn_error workflow=${workflowContext?.workflowId || '(none)'} step=${workflowContext?.stepIndex ?? 0} agent=${step.agentName} conversationId=${ptyConversationKey} taskId=${opts?.taskId ?? '(none)'} attempt=${appServerAttempt}: ${appServerErr?.stack || appServerErr?.message || String(appServerErr)}`);
2088
+ if (!isWorkflowAbortError(appServerErr)) {
2089
+ clearFailedWorkflowCliSessionEpoch(conversationId, step.agentId, activeCliSessionId || cliSessionEpochPlan.resumeSessionId, cliSessionScopeKey);
2090
+ codexAppServerManager.closeSessionByConversation(ptyConversationKey, step.agentId);
2091
+ }
1619
2092
  if (appServerAttempt >= LOCAL_WORKFLOW_RUNTIME_RETRY_LIMIT
1620
2093
  || !shouldRetrySelectedWorkflowRuntime(appServerErr)) {
1621
2094
  throw appServerErr;
1622
2095
  }
1623
- console.warn(`[workflow-engine] ${step.agentName} selected runtime failed, retrying the same connection (${appServerAttempt + 1}/${LOCAL_WORKFLOW_RUNTIME_RETRY_LIMIT})`);
1624
- await pauseWorkflowRuntimeRetry(appServerAttempt);
2096
+ if (cliSessionEpochPlan.resumeSessionId) {
2097
+ applyFreshCliBootstrapFallback('resume_failed');
2098
+ }
2099
+ console.warn(`[workflow-engine] ${step.agentName} selected runtime failed, retrying with a fresh session (${appServerAttempt + 1}/${LOCAL_WORKFLOW_RUNTIME_RETRY_LIMIT})`);
2100
+ await pauseWorkflowRuntimeRetry(appServerAttempt, opts?.abortSignal);
1625
2101
  }
1626
2102
  }
1627
2103
  }
@@ -1631,17 +2107,46 @@ class WorkflowEngine extends events_1.EventEmitter {
1631
2107
  }
1632
2108
  const ptyManager = (0, local_cli_pty_manager_1.getLocalCliPtySessionManager)();
1633
2109
  let ptyAttempt = 0;
2110
+ let forceFreshInteractiveCliSession = false;
2111
+ let currentAttemptSessionId = null;
2112
+ let currentAttemptWasFreshSession = false;
1634
2113
  while (true) {
2114
+ throwIfWorkflowAborted(opts?.abortSignal);
1635
2115
  ptyAttempt++;
1636
2116
  try {
2117
+ const hasLivePtySession = ptyManager.hasActiveSession(ptyConversationKey, step.agentId);
2118
+ const isFreshSession = forceFreshInteractiveCliSession
2119
+ || !hasPersistentConversation
2120
+ || (iteration === 1 && !hasLivePtySession && !cliSessionEpochPlan.resumeSessionId);
2121
+ const newSessionId = activeRuntime.providerName === 'claude-cli' && isFreshSession
2122
+ ? data.generateNextSessionId()
2123
+ : undefined;
2124
+ currentAttemptWasFreshSession = isFreshSession;
2125
+ currentAttemptSessionId = newSessionId || cliSessionEpochPlan.resumeSessionId || activeCliSessionId || null;
1637
2126
  const ptyResult = await ptyManager.runTurn({
1638
2127
  conversationId: ptyConversationKey,
1639
2128
  botId: step.agentId,
1640
2129
  provider: 'claude-cli',
2130
+ botSettings: {
2131
+ claude: {
2132
+ model: activeRuntime.model,
2133
+ effortLevel: profile?.claude_effort_level,
2134
+ outputStyle: profile?.claude_output_style,
2135
+ fastMode: profile?.claude_fast_mode === 1,
2136
+ permissionsJson: profile?.claude_permissions_json,
2137
+ },
2138
+ },
1641
2139
  cwd: effectiveProjectDir,
2140
+ toolActorId: step.agentName,
2141
+ toolProjectId: opts?.projectId ?? null,
2142
+ currentTodoTaskId: opts?.taskId,
2143
+ useConpty: shouldUseConptyForWorkflowClaudePty(),
1642
2144
  systemPrompt,
1643
2145
  messages,
1644
- forceFreshSession: !hasPersistentConversation || (iteration === 1 && !cliSessionEpochPlan.resumeSessionId),
2146
+ abortSignal: opts?.abortSignal,
2147
+ forceFreshSession: isFreshSession,
2148
+ resumeSessionId: isFreshSession ? undefined : (cliSessionEpochPlan.resumeSessionId || undefined),
2149
+ newSessionId,
1645
2150
  onRawChunk: async (chunk) => {
1646
2151
  if (opts?.onWorkerChunk) {
1647
2152
  opts.onWorkerChunk({
@@ -1655,6 +2160,93 @@ class WorkflowEngine extends events_1.EventEmitter {
1655
2160
  });
1656
2161
  }
1657
2162
  },
2163
+ // Forward Claude PTY tool_use detail markers as worker_terminal_chunk
2164
+ // detail lines so they appear in the worker child card details panel.
2165
+ // Also detect __ACTIVITY__ markers and emit structured worker_tool_call
2166
+ // events so PTY workers get the same tool visibility as API-key workers.
2167
+ onDetail: async (detail) => {
2168
+ if (!detail || !opts?.onWorkerChunk)
2169
+ return;
2170
+ // Always forward as terminal chunk for textual details panel.
2171
+ opts.onWorkerChunk({
2172
+ type: 'worker_terminal_chunk',
2173
+ stepId: step.id,
2174
+ agentName: step.agentName,
2175
+ description: step.description,
2176
+ stepIndex: workflowContext?.stepIndex ?? 0,
2177
+ totalSteps: workflowContext?.totalSteps ?? 1,
2178
+ rawText: detail,
2179
+ });
2180
+ // Detect __ACTIVITY__<JSON> markers from PTY parser and translate
2181
+ // tool activities into structured worker_tool_call / worker_tool_result events.
2182
+ if (detail.startsWith('__ACTIVITY__')) {
2183
+ try {
2184
+ const json = detail.slice('__ACTIVITY__'.length);
2185
+ const activity = JSON.parse(json);
2186
+ if (!activity || typeof activity !== 'object')
2187
+ return;
2188
+ if (activity.type === 'tool_call' && typeof activity.label === 'string') {
2189
+ // Every tool_use block from the Claude PTY parser
2190
+ opts.onWorkerChunk({
2191
+ type: opts?.isOrchestrated ? 'worker_tool_call' : 'tool_call',
2192
+ stepId: step.id,
2193
+ agentName: step.agentName,
2194
+ description: step.description,
2195
+ stepIndex: workflowContext?.stepIndex ?? 0,
2196
+ totalSteps: workflowContext?.totalSteps ?? 1,
2197
+ toolCallId: typeof activity.key === 'string' ? activity.key : `claude-tool-${Date.now()}`,
2198
+ toolName: activity.label,
2199
+ toolArguments: undefined,
2200
+ });
2201
+ }
2202
+ else if (activity.type === 'tool_result' && typeof activity.label === 'string') {
2203
+ // Result block from the Claude PTY parser
2204
+ opts.onWorkerChunk({
2205
+ type: opts?.isOrchestrated ? 'worker_tool_result' : 'tool_result',
2206
+ stepId: step.id,
2207
+ agentName: step.agentName,
2208
+ description: step.description,
2209
+ stepIndex: workflowContext?.stepIndex ?? 0,
2210
+ totalSteps: workflowContext?.totalSteps ?? 1,
2211
+ toolCallId: typeof activity.key === 'string' ? activity.key : `claude-tool-result-${Date.now()}`,
2212
+ toolName: 'tool',
2213
+ toolOutput: activity.label,
2214
+ toolIsError: !!activity.isError,
2215
+ });
2216
+ }
2217
+ else if (activity.type === 'working' && typeof activity.label === 'string') {
2218
+ // Legacy 'working' activities (sub-agent updates from child files)
2219
+ opts.onWorkerChunk({
2220
+ type: opts?.isOrchestrated ? 'worker_tool_call' : 'tool_call',
2221
+ stepId: step.id,
2222
+ agentName: step.agentName,
2223
+ description: step.description,
2224
+ stepIndex: workflowContext?.stepIndex ?? 0,
2225
+ totalSteps: workflowContext?.totalSteps ?? 1,
2226
+ toolCallId: typeof activity.key === 'string' ? activity.key : `claude-tool-${Date.now()}`,
2227
+ toolName: activity.label,
2228
+ toolArguments: undefined,
2229
+ });
2230
+ }
2231
+ else if (activity.type === 'subagent_started' && typeof activity.label === 'string') {
2232
+ opts.onWorkerChunk({
2233
+ type: opts?.isOrchestrated ? 'worker_tool_call' : 'tool_call',
2234
+ stepId: step.id,
2235
+ agentName: step.agentName,
2236
+ description: step.description,
2237
+ stepIndex: workflowContext?.stepIndex ?? 0,
2238
+ totalSteps: workflowContext?.totalSteps ?? 1,
2239
+ toolCallId: typeof activity.key === 'string' ? activity.key : `claude-agent-${Date.now()}`,
2240
+ toolName: 'Agent',
2241
+ toolArguments: { description: activity.label },
2242
+ });
2243
+ }
2244
+ }
2245
+ catch {
2246
+ // malformed __ACTIVITY__ marker — ignore
2247
+ }
2248
+ }
2249
+ },
1658
2250
  onChunk: async (chunk) => {
1659
2251
  streamedContent += chunk;
1660
2252
  if (opts?.onWorkerChunk) {
@@ -1688,12 +2280,41 @@ class WorkflowEngine extends events_1.EventEmitter {
1688
2280
  break;
1689
2281
  }
1690
2282
  catch (ptyErr) {
2283
+ if (isWorkflowAbortError(ptyErr)) {
2284
+ throw ptyErr;
2285
+ }
2286
+ const startupRetry = isClaudeFreshWorkflowSessionStartupFailure(ptyErr);
2287
+ if (startupRetry || currentAttemptWasFreshSession || cliSessionEpochPlan.resumeSessionId) {
2288
+ ptyManager.logSessionFailureByConversation(ptyConversationKey, step.agentId, startupRetry
2289
+ ? 'workflow_fresh_session_startup_failure_before_kill'
2290
+ : currentAttemptWasFreshSession
2291
+ ? 'workflow_fresh_session_failure_before_kill'
2292
+ : 'workflow_resume_session_failure_before_kill', ptyErr);
2293
+ }
2294
+ clearFailedWorkflowCliSessionEpoch(conversationId, step.agentId, currentAttemptSessionId || activeCliSessionId || cliSessionEpochPlan.resumeSessionId, cliSessionScopeKey);
2295
+ if (startupRetry || currentAttemptWasFreshSession) {
2296
+ forceFreshInteractiveCliSession = true;
2297
+ ptyManager.closeSessionByConversation(ptyConversationKey, step.agentId);
2298
+ }
2299
+ const resumeFailureFallback = !!cliSessionEpochPlan.resumeSessionId && !currentAttemptWasFreshSession;
2300
+ if (resumeFailureFallback) {
2301
+ forceFreshInteractiveCliSession = true;
2302
+ applyFreshCliBootstrapFallback('resume_failed');
2303
+ ptyManager.closeSessionByConversation(ptyConversationKey, step.agentId);
2304
+ }
1691
2305
  if (ptyAttempt >= LOCAL_WORKFLOW_RUNTIME_RETRY_LIMIT
1692
2306
  || !shouldRetrySelectedWorkflowRuntime(ptyErr)) {
1693
2307
  throw ptyErr;
1694
2308
  }
1695
- console.warn(`[workflow-engine] ${step.agentName} selected runtime failed, retrying the same connection (${ptyAttempt + 1}/${LOCAL_WORKFLOW_RUNTIME_RETRY_LIMIT})`);
1696
- await pauseWorkflowRuntimeRetry(ptyAttempt);
2309
+ const retryDetail = startupRetry
2310
+ ? `[workflow-engine] Fresh ${activeRuntime.providerName} session ${currentAttemptSessionId || '(unknown)'} did not finish startup before the transcript became available; killing it and retrying with a new session (${ptyAttempt + 1}/${LOCAL_WORKFLOW_RUNTIME_RETRY_LIMIT})`
2311
+ : resumeFailureFallback
2312
+ ? `[workflow-engine] Stored ${activeRuntime.providerName} session ${currentAttemptSessionId || '(unknown)'} failed to resume (${ptyErr?.message || ptyErr}); retrying with a fresh bootstrapped session (${ptyAttempt + 1}/${LOCAL_WORKFLOW_RUNTIME_RETRY_LIMIT})`
2313
+ : currentAttemptWasFreshSession
2314
+ ? `[workflow-engine] Fresh ${activeRuntime.providerName} session ${currentAttemptSessionId || '(unknown)'} failed (${ptyErr?.message || ptyErr}); killing it and retrying with a new session (${ptyAttempt + 1}/${LOCAL_WORKFLOW_RUNTIME_RETRY_LIMIT})`
2315
+ : `[workflow-engine] ${step.agentName} selected runtime failed (${ptyErr?.message || ptyErr}), retrying the same connection (${ptyAttempt + 1}/${LOCAL_WORKFLOW_RUNTIME_RETRY_LIMIT})`;
2316
+ console.warn(retryDetail);
2317
+ await pauseWorkflowRuntimeRetry(ptyAttempt, opts?.abortSignal);
1697
2318
  }
1698
2319
  }
1699
2320
  }
@@ -1740,6 +2361,7 @@ class WorkflowEngine extends events_1.EventEmitter {
1740
2361
  },
1741
2362
  tools: opts?.disableTools ? undefined : toolDefs,
1742
2363
  cwd: effectiveProjectDir,
2364
+ abortSignal: opts?.abortSignal,
1743
2365
  });
1744
2366
  // Flush remaining chunk buffer
1745
2367
  if (opts?.onWorkerChunk && chunkBuffer) {
@@ -1764,7 +2386,7 @@ class WorkflowEngine extends events_1.EventEmitter {
1764
2386
  && shouldRetrySelectedWorkflowRuntime(err)) {
1765
2387
  selectedRuntimeRetryCount++;
1766
2388
  console.warn(`[workflow-engine] ${step.agentName} selected runtime failed, retrying the same connection (${selectedRuntimeRetryCount + 1}/${LOCAL_WORKFLOW_RUNTIME_RETRY_LIMIT})`);
1767
- await pauseWorkflowRuntimeRetry(selectedRuntimeRetryCount);
2389
+ await pauseWorkflowRuntimeRetry(selectedRuntimeRetryCount, opts?.abortSignal);
1768
2390
  iteration--;
1769
2391
  continue;
1770
2392
  }
@@ -1792,29 +2414,43 @@ class WorkflowEngine extends events_1.EventEmitter {
1792
2414
  throw err;
1793
2415
  }
1794
2416
  if (hasPersistentConversation && activeCliSessionId && !response.toolCalls?.length) {
2417
+ if (cliSessionEpochPlan.resumeSessionId && !freshCliBootstrapFallbackApplied) {
2418
+ console.info(`[workflow-engine] cli_session_resume_succeeded conversationId=${conversationId} botId=${step.agentId} provider=${activeRuntime.providerName} sessionId=${activeCliSessionId}`);
2419
+ }
2420
+ const usedBootstrap = freshCliBootstrapFileWritten
2421
+ || (!cliSessionEpochPlan.resumeSessionId && !!promptContextWindow.allowBootstrap);
2422
+ const bootstrapReason = freshCliBootstrapFileWritten
2423
+ ? 'resume_failed'
2424
+ : (!cliSessionEpochPlan.resumeSessionId && !!promptContextWindow.allowBootstrap)
2425
+ ? 'fresh_with_context'
2426
+ : null;
2427
+ console.info(`[workflow-engine] cli_turn_telemetry conversationId=${conversationId || '(none)'} botId=${step.agentId} provider=${activeRuntime.providerName} selectedSessionId=${cliSessionEpochPlan.resumeSessionId || '(none)'} actualSessionId=${activeCliSessionId} promptContextMode=${promptContextWindow.mode || 'unknown'} usedBootstrap=${usedBootstrap} bootstrapReason=${bootstrapReason || '(none)'} resetReason=${cliEpochResetReason || '(none)'}`);
1795
2428
  const nextEpochTurnCount = cliSessionEpochPlan.resumeSessionId
1796
2429
  ? ((cliSessionEpochPlan.existing?.epoch_turn_count || 0) + 1)
1797
2430
  : 1;
1798
2431
  data.upsertCliSessionEpoch({
1799
2432
  conversationId: conversationId,
2433
+ sessionScopeKey: cliSessionScopeKey,
1800
2434
  botId: step.agentId,
1801
2435
  provider: activeRuntime.providerName,
1802
2436
  sessionId: activeCliSessionId,
1803
2437
  epochTurnCount: nextEpochTurnCount,
1804
2438
  lastInputTokens: response.usage?.inputTokens ?? null,
1805
2439
  lastOutputTokens: response.usage?.outputTokens ?? null,
1806
- resetReason: cliSessionEpochPlan.resetReason,
2440
+ resetReason: cliEpochResetReason,
1807
2441
  epochStartedAt: cliEpochStartedAt,
1808
2442
  lastUsedAt: (0, cli_session_epoch_1.localTimestamp)(),
1809
2443
  });
1810
2444
  }
1811
2445
  if (response.toolCalls && response.toolCalls.length > 0) {
2446
+ throwIfWorkflowAborted(opts?.abortSignal);
1812
2447
  messages.push({
1813
2448
  role: 'assistant',
1814
2449
  content: response.content || '',
1815
2450
  toolCalls: response.toolCalls,
1816
2451
  });
1817
2452
  for (const tc of response.toolCalls) {
2453
+ throwIfWorkflowAborted(opts?.abortSignal);
1818
2454
  const permMode = unrestrictedCliProvider
1819
2455
  ? 'autopilot'
1820
2456
  : (profile?.permission_mode || 'autopilot');
@@ -1838,6 +2474,7 @@ class WorkflowEngine extends events_1.EventEmitter {
1838
2474
  }
1839
2475
  const result = await (0, index_2.executeAndVerify)({ id: tc.id, name: tc.name, arguments: tc.arguments }, toolCtx);
1840
2476
  const output = result.success ? result.output : `ERROR: ${result.error || 'Unknown'}`;
2477
+ throwIfWorkflowAborted(opts?.abortSignal);
1841
2478
  messages.push({ role: 'tool', content: output, toolCallId: tc.id, toolName: tc.name });
1842
2479
  // Emit tool_result event
1843
2480
  if (opts?.onWorkerChunk) {
@@ -2291,15 +2928,38 @@ class WorkflowEngine extends events_1.EventEmitter {
2291
2928
  return count >= ERROR_LOOP_THRESHOLD;
2292
2929
  }
2293
2930
  isTerminalProviderFailure(errorMessage) {
2931
+ const normalized = String(errorMessage || '').toLowerCase();
2932
+ return Boolean(this.classifyProviderLimitFailure(errorMessage))
2933
+ || this.isProviderAuthFailure(errorMessage)
2934
+ || /\b(pty session|app-server|fresh session startup timed out|transcript file|no meaningful pty activity|session exited while waiting|failed to resume|session closed during active turn)\b/i.test(normalized);
2935
+ }
2936
+ isProviderAuthFailure(errorMessage) {
2294
2937
  const normalized = errorMessage.toLowerCase();
2295
- return normalized.includes('api error 429')
2938
+ return normalized.includes('refresh_token_reused')
2939
+ || normalized.includes('invalid_grant')
2940
+ || normalized.includes('oauth token refresh failed');
2941
+ }
2942
+ classifyProviderLimitFailure(errorMessage) {
2943
+ const exactMessage = String(errorMessage || '').trim();
2944
+ const normalized = exactMessage.toLowerCase();
2945
+ if (!exactMessage)
2946
+ return null;
2947
+ if (normalized.includes('rate limit')
2296
2948
  || normalized.includes('rate limited')
2949
+ || normalized.includes('api error 429')
2950
+ || normalized.includes('current session limit')) {
2951
+ return { reasonCode: 'provider_rate_limit', exactMessage };
2952
+ }
2953
+ if (normalized.includes("you've hit your org's monthly usage limit")
2954
+ || normalized.includes('monthly usage limit')
2955
+ || normalized.includes('usage limit reached')
2956
+ || normalized.includes('quota exceeded')
2297
2957
  || normalized.includes('exceeded your current quota')
2298
2958
  || normalized.includes('insufficient_quota')
2299
- || normalized.includes('billing details')
2300
- || normalized.includes('refresh_token_reused')
2301
- || normalized.includes('invalid_grant')
2302
- || normalized.includes('oauth token refresh failed');
2959
+ || normalized.includes('billing details')) {
2960
+ return { reasonCode: 'provider_quota_limit', exactMessage };
2961
+ }
2962
+ return null;
2303
2963
  }
2304
2964
  /**
2305
2965
  * Replan remaining work when retries are exhausted.