mixdog 0.9.0 → 0.9.2

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 (240) hide show
  1. package/package.json +10 -3
  2. package/scripts/_bench-cwc.json +20 -0
  3. package/scripts/agent-loop-policy-test.mjs +37 -0
  4. package/scripts/agent-parallel-smoke.mjs +54 -10
  5. package/scripts/background-task-meta-smoke.mjs +1 -1
  6. package/scripts/bench-run.mjs +262 -0
  7. package/scripts/compact-smoke.mjs +12 -0
  8. package/scripts/compact-trigger-migration-smoke.mjs +67 -1
  9. package/scripts/ingest-pure-conversation-smoke.mjs +148 -0
  10. package/scripts/internal-comms-bench.mjs +727 -0
  11. package/scripts/internal-comms-smoke.mjs +75 -0
  12. package/scripts/lead-workflow-smoke.mjs +4 -4
  13. package/scripts/live-worker-smoke.mjs +9 -9
  14. package/scripts/output-style-bench.mjs +285 -0
  15. package/scripts/output-style-smoke.mjs +13 -10
  16. package/scripts/patch-replay.mjs +90 -0
  17. package/scripts/provider-stream-stall-test.mjs +276 -0
  18. package/scripts/provider-toolcall-test.mjs +599 -1
  19. package/scripts/routing-corpus.mjs +281 -0
  20. package/scripts/session-bench.mjs +1526 -0
  21. package/scripts/session-diag.mjs +595 -0
  22. package/scripts/session-ingest-smoke.mjs +2 -2
  23. package/scripts/task-bench.mjs +207 -0
  24. package/scripts/tool-failures.mjs +6 -6
  25. package/scripts/tool-smoke.mjs +306 -66
  26. package/scripts/toolcall-args-test.mjs +81 -0
  27. package/src/agents/debugger/AGENT.md +4 -4
  28. package/src/agents/heavy-worker/AGENT.md +4 -2
  29. package/src/agents/reviewer/AGENT.md +4 -4
  30. package/src/agents/worker/AGENT.md +4 -2
  31. package/src/app.mjs +10 -6
  32. package/src/defaults/{hidden-roles.json → agents.json} +7 -7
  33. package/src/examples/schedules/SCHEDULE.example.md +32 -0
  34. package/src/examples/webhooks/WEBHOOK.example.md +40 -0
  35. package/src/headless-role.mjs +14 -14
  36. package/src/help.mjs +1 -0
  37. package/src/lib/mixdog-debug.cjs +0 -22
  38. package/src/lib/plugin-paths.cjs +1 -7
  39. package/src/lib/rules-builder.cjs +34 -56
  40. package/src/mixdog-session-runtime.mjs +710 -319
  41. package/src/output-styles/default.md +12 -7
  42. package/src/output-styles/minimal.md +25 -0
  43. package/src/output-styles/oneline.md +21 -0
  44. package/src/output-styles/simple.md +10 -9
  45. package/src/repl.mjs +12 -4
  46. package/src/rules/agent/00-common.md +7 -5
  47. package/src/rules/agent/30-explorer.md +7 -8
  48. package/src/rules/lead/01-general.md +3 -1
  49. package/src/rules/lead/lead-tool.md +7 -0
  50. package/src/rules/shared/01-tool.md +17 -12
  51. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +90 -32
  52. package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +32 -0
  53. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +18 -6
  54. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +23 -20
  55. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +48 -14
  56. package/src/runtime/agent/orchestrator/agent-trace.mjs +87 -12
  57. package/src/runtime/agent/orchestrator/config.mjs +3 -0
  58. package/src/runtime/agent/orchestrator/context/collect.mjs +131 -67
  59. package/src/runtime/agent/orchestrator/{internal-roles.mjs → internal-agents.mjs} +72 -72
  60. package/src/runtime/agent/orchestrator/internal-tools.mjs +13 -26
  61. package/src/runtime/agent/orchestrator/mcp/client.mjs +94 -16
  62. package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +7 -0
  63. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +188 -0
  64. package/src/runtime/agent/orchestrator/providers/anthropic-leaked-toolcall.mjs +444 -0
  65. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +359 -106
  66. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +63 -51
  67. package/src/runtime/agent/orchestrator/providers/api-usage.mjs +27 -20
  68. package/src/runtime/agent/orchestrator/providers/gemini.mjs +184 -17
  69. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +8 -1
  70. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +18 -8
  71. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +210 -21
  72. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +86 -30
  73. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +254 -280
  74. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +191 -50
  75. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +18 -0
  76. package/src/runtime/agent/orchestrator/providers/opencode-go-usage.mjs +11 -5
  77. package/src/runtime/agent/orchestrator/providers/registry.mjs +2 -1
  78. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +265 -1
  79. package/src/runtime/agent/orchestrator/session/compact.mjs +560 -51
  80. package/src/runtime/agent/orchestrator/session/context-utils.mjs +250 -3
  81. package/src/runtime/agent/orchestrator/session/loop.mjs +394 -132
  82. package/src/runtime/agent/orchestrator/session/manager.mjs +217 -170
  83. package/src/runtime/agent/orchestrator/session/store.mjs +4 -4
  84. package/src/runtime/agent/orchestrator/session/tool-envelope.mjs +61 -0
  85. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +5 -0
  86. package/src/runtime/agent/orchestrator/stall-policy.mjs +63 -15
  87. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +1 -1
  88. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +194 -32
  89. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.test.mjs +143 -0
  90. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +1 -44
  91. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +34 -18
  92. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +0 -0
  93. package/src/runtime/agent/orchestrator/tools/builtin/list-formatting.mjs +10 -0
  94. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +5 -4
  95. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +15 -0
  96. package/src/runtime/agent/orchestrator/tools/builtin/read-args.mjs +9 -44
  97. package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +2 -1
  98. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +13 -4
  99. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +10 -17
  100. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +18 -2
  101. package/src/runtime/agent/orchestrator/tools/builtin/shell-output.mjs +3 -2
  102. package/src/runtime/agent/orchestrator/tools/builtin/tool-output-limit.mjs +10 -0
  103. package/src/runtime/agent/orchestrator/tools/builtin.mjs +59 -1
  104. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +5 -5
  105. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +4076 -3985
  106. package/src/runtime/agent/orchestrator/tools/patch.mjs +116 -2
  107. package/src/runtime/channels/backends/discord.mjs +99 -9
  108. package/src/runtime/channels/backends/telegram.mjs +501 -0
  109. package/src/runtime/channels/index.mjs +441 -1254
  110. package/src/runtime/channels/lib/cli-worker-host.mjs +1 -8
  111. package/src/runtime/channels/lib/config.mjs +54 -3
  112. package/src/runtime/channels/lib/drop-trace.mjs +1 -1
  113. package/src/runtime/channels/lib/executor.mjs +0 -3
  114. package/src/runtime/channels/lib/format.mjs +4 -2
  115. package/src/runtime/channels/lib/memory-client.mjs +0 -38
  116. package/src/runtime/channels/lib/output-forwarder.mjs +77 -71
  117. package/src/runtime/channels/lib/runtime-paths.mjs +29 -6
  118. package/src/runtime/channels/lib/scheduler.mjs +1 -1
  119. package/src/runtime/channels/lib/session-discovery.mjs +0 -4
  120. package/src/runtime/channels/lib/telegram-format.mjs +283 -0
  121. package/src/runtime/channels/lib/tool-format.mjs +1 -2
  122. package/src/runtime/channels/lib/transcript-discovery.mjs +20 -11
  123. package/src/runtime/channels/lib/webhook.mjs +59 -31
  124. package/src/runtime/channels/tool-defs.mjs +1 -1
  125. package/src/runtime/lib/keychain-cjs.cjs +0 -1
  126. package/src/runtime/memory/data/runtime-manifest.json +6 -7
  127. package/src/runtime/memory/index.mjs +187 -43
  128. package/src/runtime/memory/lib/agent-ipc.mjs +2 -2
  129. package/src/runtime/memory/lib/core-memory-store.mjs +1 -1
  130. package/src/runtime/memory/lib/llm-worker-host.mjs +0 -4
  131. package/src/runtime/memory/lib/memory-cycle1.mjs +1 -1
  132. package/src/runtime/memory/lib/memory-cycle2.mjs +9 -6
  133. package/src/runtime/memory/lib/memory-cycle3.mjs +1 -1
  134. package/src/runtime/memory/lib/memory-ops-policy.mjs +0 -1
  135. package/src/runtime/memory/lib/memory.mjs +101 -4
  136. package/src/runtime/memory/lib/pg/adapter.mjs +139 -15
  137. package/src/runtime/memory/lib/runtime-fetcher.mjs +43 -18
  138. package/src/runtime/memory/lib/session-ingest.mjs +116 -7
  139. package/src/runtime/memory/lib/trace-store.mjs +69 -22
  140. package/src/runtime/memory/tool-defs.mjs +6 -3
  141. package/src/runtime/search/index.mjs +2 -7
  142. package/src/runtime/search/lib/config.mjs +0 -4
  143. package/src/runtime/search/lib/state.mjs +1 -15
  144. package/src/runtime/search/lib/web-tools.mjs +0 -1
  145. package/src/runtime/shared/channel-notification-routing.mjs +12 -0
  146. package/src/runtime/shared/channel-notification-routing.test.mjs +45 -0
  147. package/src/runtime/shared/child-spawn-gate.mjs +0 -6
  148. package/src/runtime/shared/config.mjs +9 -0
  149. package/src/runtime/shared/llm/http-agent.mjs +12 -5
  150. package/src/runtime/shared/schedules-store.mjs +21 -19
  151. package/src/runtime/shared/tool-surface.mjs +98 -13
  152. package/src/runtime/shared/transcript-writer.mjs +129 -0
  153. package/src/runtime/shared/update-checker.mjs +214 -0
  154. package/src/standalone/agent-tool.mjs +255 -109
  155. package/src/standalone/channel-admin.mjs +133 -40
  156. package/src/standalone/channel-worker.mjs +8 -291
  157. package/src/standalone/explore-tool.mjs +2 -2
  158. package/src/standalone/memory-runtime-proxy.mjs +3 -1
  159. package/src/standalone/provider-admin.mjs +11 -0
  160. package/src/standalone/seeds.mjs +1 -11
  161. package/src/standalone/usage-dashboard.mjs +1 -1
  162. package/src/tui/App.jsx +2137 -750
  163. package/src/tui/components/ConfirmBar.jsx +47 -0
  164. package/src/tui/components/ContextPanel.jsx +5 -3
  165. package/src/tui/components/ItemRightHintOverprint.jsx +54 -0
  166. package/src/tui/components/Markdown.jsx +22 -98
  167. package/src/tui/components/Message.jsx +14 -35
  168. package/src/tui/components/Picker.jsx +87 -12
  169. package/src/tui/components/PromptInput.jsx +146 -9
  170. package/src/tui/components/QueuedCommands.jsx +1 -1
  171. package/src/tui/components/SlashCommandPalette.jsx +8 -5
  172. package/src/tui/components/Spinner.jsx +7 -7
  173. package/src/tui/components/StatusLine.jsx +40 -21
  174. package/src/tui/components/TextEntryPanel.jsx +51 -7
  175. package/src/tui/components/ToolExecution.jsx +177 -100
  176. package/src/tui/components/TurnDone.jsx +4 -4
  177. package/src/tui/components/UsagePanel.jsx +1 -1
  178. package/src/tui/components/tool-output-format.mjs +312 -40
  179. package/src/tui/components/tool-output-format.test.mjs +180 -1
  180. package/src/tui/display-width.mjs +69 -0
  181. package/src/tui/display-width.test.mjs +35 -0
  182. package/src/tui/dist/index.mjs +7324 -2393
  183. package/src/tui/engine.mjs +287 -126
  184. package/src/tui/index.jsx +117 -7
  185. package/src/tui/keyboard-protocol.mjs +42 -0
  186. package/src/tui/lib/voice-recorder.mjs +453 -0
  187. package/src/tui/markdown/format-token.mjs +354 -142
  188. package/src/tui/markdown/format-token.test.mjs +155 -17
  189. package/src/tui/markdown/measure-rendered-rows.mjs +85 -0
  190. package/src/tui/markdown/render-ansi.test.mjs +1 -1
  191. package/src/tui/markdown/streaming-markdown.mjs +167 -0
  192. package/src/tui/markdown/streaming-markdown.test.mjs +70 -0
  193. package/src/tui/markdown/table-layout.mjs +9 -9
  194. package/src/tui/paste-attachments.mjs +0 -11
  195. package/src/tui/prompt-history-store.mjs +129 -0
  196. package/src/tui/prompt-history-store.test.mjs +52 -0
  197. package/src/tui/statusline-ansi-bridge.test.mjs +3 -3
  198. package/src/tui/theme.mjs +41 -647
  199. package/src/tui/themes/base.mjs +86 -0
  200. package/src/tui/themes/basic.mjs +85 -0
  201. package/src/tui/themes/catppuccin.mjs +72 -0
  202. package/src/tui/themes/dracula.mjs +70 -0
  203. package/src/tui/themes/everforest.mjs +71 -0
  204. package/src/tui/themes/gruvbox.mjs +71 -0
  205. package/src/tui/themes/index.mjs +71 -0
  206. package/src/tui/themes/indigo.mjs +78 -0
  207. package/src/tui/themes/kanagawa.mjs +80 -0
  208. package/src/tui/themes/light.mjs +81 -0
  209. package/src/tui/themes/nord.mjs +72 -0
  210. package/src/tui/themes/onedark.mjs +16 -0
  211. package/src/tui/themes/rosepine.mjs +70 -0
  212. package/src/tui/themes/teal.mjs +81 -0
  213. package/src/tui/themes/tokyonight.mjs +79 -0
  214. package/src/tui/themes/utils.mjs +106 -0
  215. package/src/tui/themes/warm.mjs +79 -0
  216. package/src/tui/transcript-tool-failures.mjs +13 -2
  217. package/src/ui/markdown.mjs +1 -1
  218. package/src/ui/model-display.mjs +2 -2
  219. package/src/ui/statusline.mjs +26 -27
  220. package/src/vendor/statusline/bin/statusline-lib.mjs +0 -623
  221. package/src/vendor/statusline/bin/statusline-route.mjs +5 -12
  222. package/src/vendor/statusline/src/gateway/claude-current.mjs +3 -3
  223. package/src/vendor/statusline/src/gateway/route-meta.mjs +30 -16
  224. package/src/workflows/default/WORKFLOW.md +39 -12
  225. package/src/workflows/sequential/WORKFLOW.md +46 -0
  226. package/src/workflows/solo/WORKFLOW.md +7 -0
  227. package/vendor/ink/build/display-width.js +62 -0
  228. package/vendor/ink/build/ink.js +154 -20
  229. package/vendor/ink/build/measure-text.js +4 -1
  230. package/vendor/ink/build/output.js +115 -9
  231. package/vendor/ink/build/render-node-to-output.js +4 -1
  232. package/vendor/ink/build/render.js +4 -0
  233. package/src/hooks/lib/permission-rules.cjs +0 -170
  234. package/src/hooks/lib/settings-loader.cjs +0 -112
  235. package/src/lib/hook-pipe-path.cjs +0 -10
  236. package/src/output-styles/extreme-simple.md +0 -20
  237. package/src/rules/lead/04-workflow.md +0 -51
  238. package/src/runtime/channels/lib/hook-pipe-server.mjs +0 -671
  239. package/src/workflows/default/workflow.json +0 -13
  240. package/src/workflows/solo/workflow.json +0 -7
@@ -33,6 +33,7 @@ import {
33
33
  } from './agent-task-status.mjs';
34
34
  import { AGENT_OWNER } from '../runtime/agent/orchestrator/agent-owner.mjs';
35
35
  import { clearGatewaySessionRoute, writeGatewaySessionRoute } from '../vendor/statusline/src/gateway/session-routes.mjs';
36
+ import { isKnownProvider } from './provider-admin.mjs';
36
37
 
37
38
  const STANDALONE_SOURCE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
38
39
 
@@ -44,11 +45,16 @@ const DEFAULT_AGENT_PRESETS = Object.freeze({
44
45
  explore: 'sonnet-high',
45
46
  maintainer: 'haiku',
46
47
  worker: 'sonnet-high',
47
- 'heavy-worker': 'opus-high',
48
+ 'heavy-worker': 'sonnet-high',
48
49
  reviewer: 'opus-xhigh',
49
50
  debugger: 'opus-xhigh',
50
51
  });
51
52
 
53
+ // Mirrors DEFAULT_PROVIDER in mixdog-session-runtime.mjs. Used only as the
54
+ // last-resort fallback when a stored agent route omits its provider and the
55
+ // config carries no defaultProvider.
56
+ const DEFAULT_PROVIDER = 'anthropic-oauth';
57
+
52
58
  export const AGENT_TOOL = {
53
59
  name: 'agent',
54
60
  title: 'Agent',
@@ -60,17 +66,17 @@ export const AGENT_TOOL = {
60
66
  openWorldHint: true,
61
67
  agentHidden: true,
62
68
  },
63
- description: 'Delegate scoped work. Agent handoffs always start background tasks and return task IDs immediately. Spawn independent agents in parallel with distinct tags; keep Lead work moving. Wait for the completion notification before dependent work. Do not call status/read after spawn; status/read are manual recovery only.',
69
+ description: 'Delegate scoped work. Agent handoffs always start background tasks and return task IDs immediately. Spawn independent scopes in parallel with distinct tags; for the same scope/tag, use send or spawn with the same tag to reuse the live session. Wait for the completion notification before dependent work. Do not call status/read after spawn; status/read are manual recovery only.',
64
70
  inputSchema: {
65
71
  type: 'object',
66
72
  properties: {
67
73
  type: { type: 'string', enum: ['spawn', 'send', 'list', 'close', 'cancel', 'status', 'read', 'cleanup'], description: 'Action. Default spawn.' },
68
74
  task_id: { type: 'string', description: 'Manual recovery task ID.' },
69
75
  agent: { type: 'string', description: 'Workflow agent id.' },
70
- tag: { type: 'string', description: 'Stable distinct handle; choose a role-index tag by role, e.g. worker01, heavy-worker01, reviewer01.' },
76
+ tag: { type: 'string', description: 'Stable scope handle. Reuse the same tag for follow-up on the same scope; use distinct tags only for independent scopes.' },
71
77
  sessionId: { type: 'string', description: 'Raw sess_ id.' },
72
78
  prompt: { type: 'string', description: 'Scoped task brief.' },
73
- message: { type: 'string', description: 'Follow-up or brief.' },
79
+ message: { type: 'string', description: 'Follow-up for send/reuse, or brief.' },
74
80
  file: { type: 'string', description: 'Prompt file.' },
75
81
  cwd: { type: 'string', description: 'Working directory.' },
76
82
  context: { type: 'string', description: 'Extra agent context.' },
@@ -80,6 +86,45 @@ export const AGENT_TOOL = {
80
86
  };
81
87
 
82
88
  const WORKER_INDEX_FILE = 'agent-workers.json';
89
+
90
+ // A worker that hits the loop iteration ceiling, gets truncated mid-synthesis,
91
+ // or produces an empty terminal turn returns content:'' but never throws — so
92
+ // the background task would otherwise reconcile as a benign `completed` empty
93
+ // success. That is wrong: an empty final answer is an error. loop.mjs is the
94
+ // single classifier: it tags result.terminationReason for abnormal finishes
95
+ // (carried through manager.mjs terminalResultPreview). We key purely off that
96
+ // here. iteration_cap / truncated are real problems for EVERY agent (hidden
97
+ // too). The plain `empty` case is tagged by the loop ONLY for public agents;
98
+ // hidden agents (explorer/cycle/…) legitimately emit empty terminal turns and
99
+ // are left untagged, so they stay benign.
100
+ function abnormalEmptyFinishError(result, agent) {
101
+ // The loop (loop.mjs) is the single classifier: it tags terminationReason
102
+ // ONLY for abnormal finishes, and gates the `empty` case behind !hidden.
103
+ // So we key purely off terminationReason here — no separate content/hidden
104
+ // check, which previously (a) exempted hidden agents from cap/truncated and
105
+ // (b) let a capped tool-call turn with preamble text slip through as success.
106
+ const reason = result?.terminationReason;
107
+ if (!reason) return null;
108
+ const iterations = result?.iterations ?? 0;
109
+ const toolCallsTotal = result?.toolCallsTotal ?? 0;
110
+ const maxLoopIterations = result?.maxLoopIterations ?? 0;
111
+ const stopReason = result?.stopReason ?? result?.stop_reason ?? null;
112
+ switch (reason) {
113
+ case 'iteration_cap':
114
+ // Real problem for EVERY agent (hidden too): the loop never terminated on
115
+ // its own contract, so any preamble text is not a trustworthy final answer.
116
+ return `agent '${agent}' hit the loop iteration ceiling (${maxLoopIterations} iterations, ${toolCallsTotal} tool calls) without producing a final answer`;
117
+ case 'truncated':
118
+ return `agent '${agent}' response was truncated (stopReason=${stopReason}) before a final answer (${iterations} iterations, ${toolCallsTotal} tool calls)`;
119
+ case 'empty':
120
+ // Only tagged for PUBLIC agents (hidden agents legitimately emit empty
121
+ // terminal turns and are left untagged by the loop).
122
+ return `agent '${agent}' finished without a final answer (stopReason=${stopReason ?? 'none'}, ${iterations} iterations, ${toolCallsTotal} tool calls)`;
123
+ default:
124
+ return null;
125
+ }
126
+ }
127
+
83
128
  function envTimeoutMs(name, fallback) {
84
129
  const raw = process.env[name];
85
130
  if (raw === undefined || raw === '') return fallback;
@@ -126,21 +171,21 @@ function normalizeAgentName(value) {
126
171
  return id;
127
172
  }
128
173
 
129
- function readAgentFrontmatterPermission(role, dataDir) {
130
- const cleanRole = clean(role);
131
- if (!cleanRole) return null;
132
- const cacheKey = `${dataDir || ''}\u0000${cleanRole}`;
174
+ function readAgentFrontmatterPermission(agent, dataDir) {
175
+ const cleanAgent = clean(agent);
176
+ if (!cleanAgent) return null;
177
+ const cacheKey = `${dataDir || ''}\u0000${cleanAgent}`;
133
178
  const cached = _frontmatterPermCache.get(cacheKey);
134
179
  if (cached && Date.now() - cached.atMs < FRONTMATTER_PERM_CACHE_TTL_MS) {
135
180
  return cached.value;
136
181
  }
137
182
  const candidates = [];
138
183
  if (dataDir) {
139
- candidates.push(join(dataDir, 'agents', cleanRole, 'AGENT.md'));
140
- candidates.push(join(dataDir, 'agents', `${cleanRole}.md`));
184
+ candidates.push(join(dataDir, 'agents', cleanAgent, 'AGENT.md'));
185
+ candidates.push(join(dataDir, 'agents', `${cleanAgent}.md`));
141
186
  }
142
- candidates.push(join(STANDALONE_SOURCE_ROOT, 'agents', cleanRole, 'AGENT.md'));
143
- candidates.push(join(STANDALONE_SOURCE_ROOT, 'agents', `${cleanRole}.md`));
187
+ candidates.push(join(STANDALONE_SOURCE_ROOT, 'agents', cleanAgent, 'AGENT.md'));
188
+ candidates.push(join(STANDALONE_SOURCE_ROOT, 'agents', `${cleanAgent}.md`));
144
189
  let resolved = null;
145
190
  for (const file of candidates) {
146
191
  if (!existsSync(file)) continue;
@@ -248,8 +293,8 @@ function synthesizePreset(config, key) {
248
293
  };
249
294
  }
250
295
 
251
- function normalizeAgentRoute(routeLike) {
252
- const provider = clean(routeLike?.provider);
296
+ function normalizeAgentRoute(routeLike, fallbackProvider = '') {
297
+ const provider = clean(routeLike?.provider) || clean(fallbackProvider);
253
298
  const model = clean(routeLike?.model);
254
299
  if (!provider || !model) return null;
255
300
  return {
@@ -260,8 +305,8 @@ function normalizeAgentRoute(routeLike) {
260
305
  };
261
306
  }
262
307
 
263
- function agentPresetName(role) {
264
- return `AGENT ${String(role || '').toUpperCase()}`;
308
+ function agentPresetName(agent) {
309
+ return `AGENT ${String(agent || '').toUpperCase()}`;
265
310
  }
266
311
 
267
312
  async function resolvePrompt(args, cwd) {
@@ -334,7 +379,7 @@ function renderResult(value) {
334
379
  for (const worker of workers) {
335
380
  const tokens = worker.windowTokens ? ` ctx=${worker.windowTokens}${worker.windowCap ? `/${worker.windowCap}` : ''}` : '';
336
381
  const terminal = worker.clientHostPid ? ` term=${worker.clientHostPid}` : '';
337
- const base = `- ${worker.tag} ${worker.role || 'agent'} ${worker.status || 'idle'}/${worker.worker_stage || worker.stage || 'idle'} ${worker.provider}/${worker.model}${terminal}${tokens}`;
382
+ const base = `- ${worker.tag} ${worker.agent || 'agent'} ${worker.status || 'idle'}/${worker.worker_stage || worker.stage || 'idle'} ${worker.provider}/${worker.model}${terminal}${tokens}`;
338
383
  lines.push(appendAgentProgressKv(base, worker));
339
384
  }
340
385
  const jobs = Array.isArray(value.jobs) ? value.jobs : [];
@@ -355,7 +400,7 @@ function renderResult(value) {
355
400
  if (value.type) lines.push(`type: ${value.type}`);
356
401
  if (value.reused) lines.push('reused: true');
357
402
  if (value.tag || value.sessionId) lines.push(`target: ${value.tag || '-'} ${value.sessionId || ''}`.trim());
358
- if (value.role) lines.push(`agent: ${value.role}`);
403
+ if (value.agent) lines.push(`agent: ${value.agent}`);
359
404
  if (value.provider && value.model) lines.push(`model: ${value.provider}/${value.model}`);
360
405
  if (value.effort) lines.push(`effort: ${value.effort}`);
361
406
  if (value.fast === true || value.fast === false) lines.push(`fast: ${value.fast ? 'on' : 'off'}`);
@@ -393,7 +438,7 @@ function renderResult(value) {
393
438
  'agent message queued',
394
439
  value.reused ? 'reused: true' : null,
395
440
  `target: ${value.tag || '-'} ${value.sessionId || ''}`.trim(),
396
- value.role ? `agent: ${value.role}` : null,
441
+ value.agent ? `agent: ${value.agent}` : null,
397
442
  `queueDepth: ${value.queueDepth ?? 1}`,
398
443
  ].filter(Boolean).join('\n');
399
444
  }
@@ -412,7 +457,7 @@ function renderResult(value) {
412
457
  const header = [
413
458
  value.respawned ? 'agent respawned' : 'agent result',
414
459
  value.tag ? `tag=${value.tag}` : null,
415
- value.role ? `agent=${value.role}` : null,
460
+ value.agent ? `agent=${value.agent}` : null,
416
461
  value.provider && value.model ? `${value.provider}/${value.model}` : null,
417
462
  ].filter(Boolean).join(' ');
418
463
  return `${header}\n${stripFinalAnswerWrapper(value.content)}`;
@@ -423,7 +468,7 @@ function renderResult(value) {
423
468
 
424
469
  export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultCwd }) {
425
470
  const tags = new Map();
426
- const tagRoles = new Map();
471
+ const tagAgents = new Map();
427
472
  const tagCwds = new Map();
428
473
  const reapTimers = new Map();
429
474
  const workerIndexMutators = [];
@@ -463,7 +508,7 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
463
508
  .map((row) => ({
464
509
  tag: clean(row.tag),
465
510
  sessionId: clean(row.sessionId),
466
- role: clean(row.role) || null,
511
+ agent: clean(row.agent) || null,
467
512
  provider: clean(row.provider) || null,
468
513
  model: clean(row.model) || null,
469
514
  preset: clean(row.preset) || null,
@@ -562,7 +607,7 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
562
607
  if (!key) return;
563
608
  const prev = byKey.get(key) || {};
564
609
  const merged = { ...prev, ...normalized };
565
- for (const field of ['role', 'provider', 'model', 'preset', 'effort', 'fast', 'clientHostPid', 'cwd', 'task_id', 'permission', 'toolPermission']) {
610
+ for (const field of ['agent', 'provider', 'model', 'preset', 'effort', 'fast', 'clientHostPid', 'cwd', 'task_id', 'permission', 'toolPermission']) {
566
611
  if ((merged[field] === null || merged[field] === '') && prev[field] != null && prev[field] !== '') {
567
612
  merged[field] = prev[field];
568
613
  }
@@ -610,7 +655,7 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
610
655
  return {
611
656
  tag,
612
657
  sessionId,
613
- role: clean(extra.role) || clean(session?.role) || null,
658
+ agent: clean(extra.agent) || clean(session?.agent) || null,
614
659
  provider: clean(extra.provider) || clean(session?.provider) || null,
615
660
  model: clean(extra.model) || clean(session?.model) || null,
616
661
  preset: clean(extra.preset) || clean(session?.presetName) || null,
@@ -637,7 +682,7 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
637
682
  return {
638
683
  id: row.sessionId,
639
684
  agentTag: row.tag,
640
- role: row.role || null,
685
+ agent: row.agent || null,
641
686
  provider: row.provider || null,
642
687
  model: row.model || null,
643
688
  presetName: row.preset || null,
@@ -661,7 +706,7 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
661
706
  const normalized = normalizeWorkerRows({ workers: [row] })[0];
662
707
  if (!normalized) return false;
663
708
  tags.set(normalized.tag, normalized.sessionId);
664
- if (normalized.role) tagRoles.set(normalized.tag, normalized.role);
709
+ if (normalized.agent) tagAgents.set(normalized.tag, normalized.agent);
665
710
  if (normalized.cwd) tagCwds.set(normalized.tag, normalized.cwd);
666
711
  if (defer) {
667
712
  return queueWorkerIndexMutation((byKey) => applyWorkerRowUpsert(byKey, normalized));
@@ -698,7 +743,7 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
698
743
  for (const row of rows) {
699
744
  if (!row.tag || !row.sessionId) continue;
700
745
  tags.set(row.tag, row.sessionId);
701
- if (row.role) tagRoles.set(row.tag, row.role);
746
+ if (row.agent) tagAgents.set(row.tag, row.agent);
702
747
  if (row.cwd) tagCwds.set(row.tag, row.cwd);
703
748
  }
704
749
  return rows;
@@ -790,14 +835,14 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
790
835
  return rows;
791
836
  }
792
837
 
793
- function nextTag(role, context = {}) {
838
+ function nextTag(agent, context = {}) {
794
839
  refreshTagsFromSessions({ context });
795
- // Auto tags are role + a per-role local index with NO hyphen
796
- // ("worker3", "heavy-worker7", or "agent1" when the role is unset). The
797
- // index is the max existing `^role(\d+)$` + 1, escaping the role so a
798
- // hyphenated role ("heavy-worker") is matched literally. Keep incrementing
840
+ // Auto tags are agent + a per-agent local index with NO hyphen
841
+ // ("worker3", "heavy-worker7", or "agent1" when the agent is unset). The
842
+ // index is the max existing `^agent(\d+)$` + 1, escaping the agent so a
843
+ // hyphenated agent ("heavy-worker") is matched literally. Keep incrementing
799
844
  // on any live collision.
800
- const base = clean(role) || 'agent';
845
+ const base = clean(agent) || 'agent';
801
846
  const escaped = base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
802
847
  const re = new RegExp(`^${escaped}(\\d+)$`);
803
848
  let maxN = 0;
@@ -827,7 +872,7 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
827
872
  if (!tag || tags.has(tag)) continue;
828
873
  if (!sessionMatchesContext(session, context)) continue;
829
874
  tags.set(tag, session.id);
830
- if (session.role) tagRoles.set(tag, session.role);
875
+ if (session.agent) tagAgents.set(tag, session.agent);
831
876
  if (session.cwd) tagCwds.set(tag, session.cwd);
832
877
  upsertWorkerSessionDeferred(session, tag);
833
878
  }
@@ -836,7 +881,7 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
836
881
  function bindTag(tag, session, extra = {}) {
837
882
  if (!tag || !session?.id) return;
838
883
  tags.set(tag, session.id);
839
- if (session.role) tagRoles.set(tag, session.role);
884
+ if (session.agent) tagAgents.set(tag, session.agent);
840
885
  if (session.cwd) tagCwds.set(tag, session.cwd);
841
886
  upsertWorkerSessionDeferred(session, tag, extra);
842
887
  }
@@ -845,7 +890,7 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
845
890
  if (!tag) return;
846
891
  const sessionId = tags.get(tag) || '';
847
892
  tags.delete(tag);
848
- tagRoles.delete(tag);
893
+ tagAgents.delete(tag);
849
894
  tagCwds.delete(tag);
850
895
  removeWorkerRow({ tag, sessionId });
851
896
  }
@@ -1034,10 +1079,14 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
1034
1079
  };
1035
1080
  }
1036
1081
 
1037
- const agentName = normalizeAgentName(args.agent || args.role);
1082
+ const agentName = normalizeAgentName(args.agent);
1083
+ const configuredDefault = clean(config?.defaultProvider);
1084
+ const fallbackProvider = configuredDefault && isKnownProvider(configuredDefault)
1085
+ ? configuredDefault
1086
+ : DEFAULT_PROVIDER;
1038
1087
  const agentRoute = !clean(args.preset)
1039
- ? (normalizeAgentRoute(config?.agents?.[agentName])
1040
- || (agentName === 'maintainer' ? normalizeAgentRoute(config?.agents?.maintenance) : null))
1088
+ ? (normalizeAgentRoute(config?.agents?.[agentName], fallbackProvider)
1089
+ || (agentName === 'maintainer' ? normalizeAgentRoute(config?.agents?.maintenance, fallbackProvider) : null))
1041
1090
  : null;
1042
1091
  if (agentRoute) {
1043
1092
  return {
@@ -1073,11 +1122,11 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
1073
1122
  const stage = session.stage || (status === 'idle' || status === 'error' || status === 'closed'
1074
1123
  ? status
1075
1124
  : (runtime?.stage || status));
1076
- const progress = sessionProgressExtras(sessionId, session.role || null, now);
1125
+ const progress = sessionProgressExtras(sessionId, session.agent || null, now);
1077
1126
  rows.push({
1078
1127
  tag,
1079
1128
  sessionId,
1080
- role: session.role || null,
1129
+ agent: session.agent || null,
1081
1130
  provider: session.provider,
1082
1131
  model: session.model,
1083
1132
  preset: session.presetName || null,
@@ -1133,7 +1182,7 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
1133
1182
  if (!session) return null;
1134
1183
  const runtime = mgr.getSessionRuntime?.(sessionId);
1135
1184
  const status = session.closed === true ? 'closed' : (session.status || 'idle');
1136
- const progress = sessionProgressExtras(sessionId, session.role || null);
1185
+ const progress = sessionProgressExtras(sessionId, session.agent || null);
1137
1186
  return {
1138
1187
  workerStatus: status,
1139
1188
  stage: progress.worker_stage || runtime?.stage || status,
@@ -1151,7 +1200,7 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
1151
1200
  status: task.status,
1152
1201
  tag: task.tag || null,
1153
1202
  sessionId: task.sessionId || null,
1154
- role: task.role || null,
1203
+ agent: task.agent || null,
1155
1204
  preset: task.preset || null,
1156
1205
  provider: task.provider || null,
1157
1206
  model: task.model || null,
@@ -1162,7 +1211,7 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
1162
1211
  finishedAt: task.finishedAt || null,
1163
1212
  error: task.error || null,
1164
1213
  ...jobWorkerSnapshot(task.sessionId),
1165
- ...sessionProgressExtras(task.sessionId, task.role || null, Date.now(), task.status),
1214
+ ...sessionProgressExtras(task.sessionId, task.agent || null, Date.now(), task.status),
1166
1215
  }));
1167
1216
  return wantedPid ? rows.filter((row) => positiveInt(row.clientHostPid) === wantedPid) : rows;
1168
1217
  }
@@ -1177,7 +1226,7 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
1177
1226
 
1178
1227
  function renderJob(job, includeResult = false) {
1179
1228
  const meta = job.meta || {};
1180
- let progress = sessionProgressExtras(meta.sessionId, meta.role || null, Date.now(), job.status);
1229
+ let progress = sessionProgressExtras(meta.sessionId, meta.agent || null, Date.now(), job.status);
1181
1230
  // Spawn is deferred: before the worker session exists, sessionProgressExtras
1182
1231
  // returns {} and the status card would show only "status: running" with no
1183
1232
  // stage/progress. Fill a minimal stage so the caller can tell the job is
@@ -1196,7 +1245,7 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
1196
1245
  status: job.status,
1197
1246
  tag: meta.tag || null,
1198
1247
  sessionId: meta.sessionId || null,
1199
- role: meta.role || null,
1248
+ agent: meta.agent || null,
1200
1249
  preset: meta.preset || null,
1201
1250
  provider: meta.provider || null,
1202
1251
  model: meta.model || null,
@@ -1217,7 +1266,7 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
1217
1266
  ...(extras || {}),
1218
1267
  tag: prepared.tag,
1219
1268
  sessionId: prepared.session.id,
1220
- role: prepared.role,
1269
+ agent: prepared.agent,
1221
1270
  preset: presetKey(prepared.preset) || prepared.presetName,
1222
1271
  provider: prepared.preset.provider,
1223
1272
  model: prepared.preset.model,
@@ -1228,7 +1277,7 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
1228
1277
  }
1229
1278
 
1230
1279
  function pendingSpawnMeta(args = {}, extras = {}) {
1231
- const role = normalizeAgentName(args.agent || args.role);
1280
+ const agent = normalizeAgentName(args.agent);
1232
1281
  // Best-effort resolve the default preset so the pending "Spawn …" card can
1233
1282
  // already show the model (e.g. "Spawn Heavy Worker (Opus 4.8)") even when
1234
1283
  // the caller did not pass an explicit provider/model. Never throw: fall back
@@ -1242,7 +1291,7 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
1242
1291
  ...(extras || {}),
1243
1292
  tag: clean(args.tag) || null,
1244
1293
  sessionId: null,
1245
- role: role || null,
1294
+ agent: agent || null,
1246
1295
  preset: clean(args.preset) || presetKey(resolved) || null,
1247
1296
  provider: clean(args.provider) || clean(resolved?.provider) || null,
1248
1297
  model: clean(args.model) || clean(resolved?.model) || null,
@@ -1260,7 +1309,7 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
1260
1309
  ...job.input,
1261
1310
  tag: next.tag || job.input.tag || null,
1262
1311
  sessionId: next.sessionId || job.input.sessionId || null,
1263
- role: next.role || job.input.role || null,
1312
+ agent: next.agent || job.input.agent || null,
1264
1313
  };
1265
1314
  }
1266
1315
  job.label = next.tag || next.sessionId || job.label;
@@ -1321,14 +1370,19 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
1321
1370
  const ownerSessionId = clean(notifyContext?.callerSessionId || notifyContext?.sessionId);
1322
1371
  const upstream = typeof notifyContext?.notifyFn === 'function' ? notifyContext.notifyFn : null;
1323
1372
  const finishedAt = new Date().toISOString();
1373
+ // An abnormal-empty finish carries an `error` — the early preview must NOT
1374
+ // present it as a benign `completed` card, or the Lead sees success before
1375
+ // the later `failed` reconcile lands. Mirror the terminal status/instruction.
1376
+ const earlyStatus = resultValue && resultValue.error ? 'failed' : 'completed';
1324
1377
  const snapshot = {
1325
1378
  ...job,
1326
- status: 'completed',
1379
+ status: earlyStatus,
1327
1380
  finishedAt,
1328
1381
  finishedAtMs: Date.now(),
1329
1382
  result: resultValue,
1330
1383
  resultType: job.resultType || 'agent_task_result',
1331
1384
  meta: sanitizeTaskMeta(job.meta || {}),
1385
+ ...(resultValue && resultValue.error ? { error: resultValue.error } : {}),
1332
1386
  };
1333
1387
  // An early notification is only a header-only *preview*: it fires before
1334
1388
  // the worker's session is persisted to signal the running→completed
@@ -1341,8 +1395,8 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
1341
1395
  type: snapshot.resultType,
1342
1396
  execution_surface: 'agent',
1343
1397
  execution_id: job.taskId || null,
1344
- status: 'completed',
1345
- instruction: `The async agent task ${job.taskId || ''} has finished (completed) - review this result in your next step.`,
1398
+ status: earlyStatus,
1399
+ instruction: `The async agent task ${job.taskId || ''} has finished (${earlyStatus}) - review this result in your next step.`,
1346
1400
  ...(ownerSessionId ? { caller_session_id: ownerSessionId } : {}),
1347
1401
  };
1348
1402
  let delivered = false;
@@ -1377,7 +1431,7 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
1377
1431
  surface: 'agent',
1378
1432
  operation: type,
1379
1433
  label: jobMeta?.tag || jobMeta?.sessionId || type,
1380
- input: { type, tag: jobMeta?.tag || null, sessionId: jobMeta?.sessionId || null, role: jobMeta?.role || null },
1434
+ input: { type, tag: jobMeta?.tag || null, sessionId: jobMeta?.sessionId || null, agent: jobMeta?.agent || null },
1381
1435
  context: notifyContext,
1382
1436
  meta: jobMeta,
1383
1437
  resultType: 'agent_task_result',
@@ -1406,8 +1460,10 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
1406
1460
  // installs its progress watchdog, so guard prep with an internal env-
1407
1461
  // backed cap rather than exposing per-call timeout knobs on the agent
1408
1462
  // tool surface.
1409
- const prepDeadlineMs = DEFAULT_SPAWN_PREP_TIMEOUT_MS;
1463
+ const prepDeadlineMs = nonNegativeInt(args.spawnPrepTimeoutMs ?? args.prepTimeoutMs)
1464
+ ?? DEFAULT_SPAWN_PREP_TIMEOUT_MS;
1410
1465
  let prepared;
1466
+ const prepState = { timedOut: false };
1411
1467
  if (prepDeadlineMs > 0) {
1412
1468
  let prepTimer = null;
1413
1469
  let timedOut = false;
@@ -1415,13 +1471,14 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
1415
1471
  // prepareSpawn promise may still resolve later with a fully-built
1416
1472
  // session/tag/route — attach a cleanup so the late-arriving prepared is
1417
1473
  // torn down, otherwise the orphaned tag would collide on re-spawn.
1418
- const prepPromise = prepareSpawn(args, callerCwd, context);
1474
+ const prepPromise = prepareSpawn(args, callerCwd, context, prepState);
1419
1475
  prepPromise.then((late) => {
1420
1476
  if (timedOut) closePreparedSpawn(late, 'agent-spawn-prep-timeout');
1421
1477
  }, () => {});
1422
1478
  const timeout = new Promise((_resolve, reject) => {
1423
1479
  prepTimer = setTimeout(() => {
1424
1480
  timedOut = true;
1481
+ prepState.timedOut = true;
1425
1482
  reject(new Error(`agent spawn prep timed out (${prepDeadlineMs}ms) before model request`));
1426
1483
  }, prepDeadlineMs);
1427
1484
  prepTimer.unref?.();
@@ -1432,7 +1489,7 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
1432
1489
  if (prepTimer) clearTimeout(prepTimer);
1433
1490
  }
1434
1491
  } else {
1435
- prepared = await prepareSpawn(args, callerCwd, context);
1492
+ prepared = await prepareSpawn(args, callerCwd, context, prepState);
1436
1493
  }
1437
1494
  mergeJobMeta(job, preparedSpawnMeta(prepared, extras));
1438
1495
  upsertWorkerSessionDeferred(prepared.session, prepared.tag, {
@@ -1483,17 +1540,20 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
1483
1540
  };
1484
1541
  }
1485
1542
 
1486
- async function prepareSpawn(args, callerCwd = null, context = {}) {
1543
+ async function prepareSpawn(args, callerCwd = null, context = {}, prepState = null) {
1487
1544
  refreshTagsFromSessions({ context });
1488
1545
  const config = cfgMod.loadConfig();
1489
- const role = normalizeAgentName(args.agent || args.role);
1490
- if (!role) throw new Error('agent spawn: agent is required');
1491
- const agentPermission = readAgentFrontmatterPermission(role, dataDir);
1492
- const rolePermission = normalizeAgentPermission(agentPermission) || null;
1546
+ const agent = normalizeAgentName(args.agent);
1547
+ if (!agent) throw new Error('agent spawn: agent is required');
1548
+ const agentPermission = readAgentFrontmatterPermission(agent, dataDir);
1549
+ const agentPerm = normalizeAgentPermission(agentPermission) || null;
1493
1550
  const { presetName, preset } = resolvePreset(config, args);
1494
1551
  await ensureProvider(config, preset.provider);
1552
+ if (prepState?.timedOut) {
1553
+ throw new Error('agent spawn prep timed out before session bind');
1554
+ }
1495
1555
 
1496
- const tag = clean(args.tag) || nextTag(role, context);
1556
+ const tag = clean(args.tag) || nextTag(agent, context);
1497
1557
  // Any resolved same-tag binding in this terminal (live or lingering trace)
1498
1558
  // blocks a fresh spawn. execute() routes live reuse before prepareSpawn.
1499
1559
  if (resolveTag(tag, context, { scanSessions: wantsSessionScan(args) })) {
@@ -1502,25 +1562,28 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
1502
1562
  const baseCwd = resolve(callerCwd || defaultCwd || process.cwd());
1503
1563
  const workerCwd = clean(args.cwd) ? resolve(baseCwd, args.cwd) : baseCwd;
1504
1564
  const prompt = withCwdHeader(await resolvePrompt(args, workerCwd), workerCwd);
1565
+ if (prepState?.timedOut) {
1566
+ throw new Error('agent spawn prep timed out before session bind');
1567
+ }
1505
1568
  const runtimeSpec = cfgMod.resolveRuntimeSpec(preset, { lane: 'agent', agentId: tag });
1506
1569
  const maxLoopIterations = positiveInt(args.maxLoopIterations) || null;
1507
- const watchdogPolicy = resolveAgentWatchdogPolicy(role);
1570
+ const watchdogPolicy = resolveAgentWatchdogPolicy(agent);
1508
1571
  const { session, effectiveCwd } = prepareAgentSession({
1509
- role,
1572
+ agent,
1510
1573
  presetName,
1511
1574
  preset,
1512
1575
  runtimeSpec,
1513
1576
  owner: AGENT_OWNER,
1514
1577
  cwd: workerCwd,
1515
1578
  sourceType: 'cli',
1516
- sourceName: role,
1579
+ sourceName: agent,
1517
1580
  parentSessionId: clean(context?.callerSessionId || context?.sessionId) || null,
1518
1581
  ownerSessionId: clean(context?.callerSessionId || context?.sessionId) || null,
1519
1582
  clientHostPid: terminalPidForContext(context) || null,
1520
1583
  agentTag: tag,
1521
1584
  taskType: clean(args.taskType) || clean(args.typeHint) || undefined,
1522
1585
  maxLoopIterations: maxLoopIterations || undefined,
1523
- permission: rolePermission || undefined,
1586
+ permission: agentPerm || undefined,
1524
1587
  cacheKeyOverride: args.cacheKey || undefined,
1525
1588
  });
1526
1589
  // Lead sessions write a gateway-session route when created; agent sessions
@@ -1528,7 +1591,7 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
1528
1591
  // or the vendored L1/L2 statusline cannot resolve the agent route/model.
1529
1592
  writeAgentStatuslineRoute(session.id, preset);
1530
1593
  bindTag(tag, session, {
1531
- role,
1594
+ agent,
1532
1595
  preset: presetKey(preset) || presetName,
1533
1596
  provider: preset.provider,
1534
1597
  model: preset.model,
@@ -1542,7 +1605,7 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
1542
1605
  args,
1543
1606
  tag,
1544
1607
  session,
1545
- role,
1608
+ agent,
1546
1609
  preset,
1547
1610
  presetName,
1548
1611
  workerCwd: effectiveCwd || workerCwd,
@@ -1553,11 +1616,11 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
1553
1616
  }
1554
1617
 
1555
1618
  async function runSpawn(prepared, notifyContext = null, job = null) {
1556
- const { args, tag, session, role, preset, presetName, workerCwd, prompt, watchdogPolicy } = prepared;
1619
+ const { args, tag, session, agent, preset, presetName, workerCwd, prompt, watchdogPolicy } = prepared;
1557
1620
  const watchdog = startProgressIdleWatchdog(session.id, watchdogPolicy);
1558
1621
  let finalStatus = 'idle';
1559
1622
  upsertWorkerSessionDeferred(session, tag, {
1560
- role,
1623
+ agent,
1561
1624
  preset: presetKey(preset) || presetName,
1562
1625
  provider: preset.provider,
1563
1626
  model: preset.model,
@@ -1567,17 +1630,26 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
1567
1630
  stage: 'running',
1568
1631
  });
1569
1632
  try {
1570
- const completionValue = (result) => ({
1571
- tag,
1572
- sessionId: session.id,
1573
- role,
1574
- preset: presetKey(preset) || presetName,
1575
- provider: preset.provider,
1576
- model: preset.model,
1577
- effort: preset.effort || null,
1578
- fast: preset.fast === true,
1579
- content: result?.content || '',
1580
- });
1633
+ const completionValue = (result) => {
1634
+ // Promote an abnormal finish (iteration cap, truncation, or a public
1635
+ // agent's empty terminal turn) to an explicit error, so the Lead
1636
+ // receives it as a failure with an accurate reason instead of a silent
1637
+ // `completed` empty result. Keyed off loop.mjs terminationReason;
1638
+ // hidden agents finishing normally-empty are left untagged (benign).
1639
+ const abnormalError = abnormalEmptyFinishError(result, agent);
1640
+ return {
1641
+ tag,
1642
+ sessionId: session.id,
1643
+ agent,
1644
+ preset: presetKey(preset) || presetName,
1645
+ provider: preset.provider,
1646
+ model: preset.model,
1647
+ effort: preset.effort || null,
1648
+ fast: preset.fast === true,
1649
+ content: result?.content || '',
1650
+ ...(abnormalError ? { error: abnormalError } : {}),
1651
+ };
1652
+ };
1581
1653
  const result = await mgr.askSession(session.id, prompt, args.context || null, null, workerCwd, null, {
1582
1654
  notifyFn: workerNotifyFn(session.id, notifyContext || {}),
1583
1655
  ...(job ? {
@@ -1591,11 +1663,21 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
1591
1663
  // reconcile remains a backup for the error/no-terminal-result path.
1592
1664
  if (job?.taskId) {
1593
1665
  try {
1594
- reconcileBackgroundTask(job.taskId, {
1595
- status: 'completed',
1596
- result: value,
1597
- terminalReason: 'agent-terminal-result',
1598
- });
1666
+ // An empty/abnormal finish is a failure, not a completion:
1667
+ // reconcile as `failed` with the accurate error so the Lead
1668
+ // card renders `error: …` instead of a header-only empty card.
1669
+ reconcileBackgroundTask(job.taskId, value.error
1670
+ ? {
1671
+ status: 'failed',
1672
+ result: value,
1673
+ error: value.error,
1674
+ terminalReason: 'agent-empty-final',
1675
+ }
1676
+ : {
1677
+ status: 'completed',
1678
+ result: value,
1679
+ terminalReason: 'agent-terminal-result',
1680
+ });
1599
1681
  } catch {}
1600
1682
  }
1601
1683
  },
@@ -1604,14 +1686,40 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
1604
1686
  // The early preview no longer promises body suppression, so the canonical
1605
1687
  // notifyTaskCompletion is left to fire exactly once with output via the
1606
1688
  // resolve/reconcile/finally path.
1607
- return completionValue(result);
1689
+ const finalValue = completionValue(result);
1690
+ // Non-job return path (or job path where the terminal-result reconcile
1691
+ // already ran): if the finish was abnormal-empty, surface it as a thrown
1692
+ // error so finalStatus becomes 'error' and the caller's error path (and
1693
+ // the finally reconcile below, as `failed`) render the accurate reason.
1694
+ if (finalValue.error) {
1695
+ finalStatus = 'error';
1696
+ if (job) job._terminalResultValue = finalValue;
1697
+ throw new Error(finalValue.error);
1698
+ }
1699
+ return finalValue;
1608
1700
  } catch (error) {
1609
1701
  finalStatus = 'error';
1702
+ // Part C: a mid-stream stall (StreamStalledError / ESTREAMSTALL) throws
1703
+ // here WITHOUT a terminal result, so the finally reconcile below (gated on
1704
+ // _terminalResultValue) would be skipped and only the outer task-reject
1705
+ // path would notify. Belt-and-suspenders: reconcile this job to `failed`
1706
+ // now so the owner (Lead) always gets a failure notification instead of a
1707
+ // task stranded in `running`. Idempotent — completeBackgroundTask no-ops
1708
+ // once terminal, so the outer reject path can't double-notify.
1709
+ if (job?.taskId && job._terminalResultValue === undefined) {
1710
+ try {
1711
+ reconcileBackgroundTask(job.taskId, {
1712
+ status: 'failed',
1713
+ error: presentErrorText(error, { surface: 'agent' }),
1714
+ terminalReason: 'agent-stream-stalled',
1715
+ });
1716
+ } catch {}
1717
+ }
1610
1718
  throw error;
1611
1719
  } finally {
1612
1720
  watchdog?.stop?.();
1613
1721
  upsertWorkerSessionDeferred(session, tag, {
1614
- role,
1722
+ agent,
1615
1723
  preset: presetKey(preset) || presetName,
1616
1724
  provider: preset.provider,
1617
1725
  model: preset.model,
@@ -1631,6 +1739,9 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
1631
1739
  reconcileBackgroundTask(job.taskId, {
1632
1740
  status: finalStatus === 'error' ? 'failed' : 'completed',
1633
1741
  result: job._terminalResultValue,
1742
+ ...(finalStatus === 'error' && job._terminalResultValue?.error
1743
+ ? { error: job._terminalResultValue.error }
1744
+ : {}),
1634
1745
  terminalReason: 'agent-finally-reconcile',
1635
1746
  });
1636
1747
  } catch {}
@@ -1658,20 +1769,27 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
1658
1769
 
1659
1770
  async function runSend(prepared, notifyContext = null, job = null) {
1660
1771
  const { args, session, sessionId, prompt } = prepared;
1661
- const sendRole = session.role || normalizeAgentName(args.agent || args.role);
1662
- const watchdog = startProgressIdleWatchdog(sessionId, resolveAgentWatchdogPolicy(sendRole));
1772
+ const sendAgent = session.agent || normalizeAgentName(args.agent);
1773
+ const watchdog = startProgressIdleWatchdog(sessionId, resolveAgentWatchdogPolicy(sendAgent));
1663
1774
  const tag = tagForSession(sessionId);
1664
1775
  let finalStatus = 'idle';
1665
1776
  upsertWorkerSessionDeferred(session, tag, { status: 'running', stage: 'running' });
1666
1777
  try {
1667
- const completionValue = (result) => ({
1668
- tag,
1669
- sessionId,
1670
- role: session.role || null,
1671
- provider: session.provider,
1672
- model: session.model,
1673
- content: result?.content || '',
1674
- });
1778
+ const completionValue = (result) => {
1779
+ // Same abnormal-empty → error promotion as runSpawn: a reused/`send`
1780
+ // worker that hits the cap, truncates, or finishes empty must surface
1781
+ // as a failure with an accurate reason, not a silent completed empty.
1782
+ const abnormalError = abnormalEmptyFinishError(result, session.agent || sendAgent);
1783
+ return {
1784
+ tag,
1785
+ sessionId,
1786
+ agent: session.agent || null,
1787
+ provider: session.provider,
1788
+ model: session.model,
1789
+ content: result?.content || '',
1790
+ ...(abnormalError ? { error: abnormalError } : {}),
1791
+ };
1792
+ };
1675
1793
  const result = await mgr.askSession(sessionId, prompt, args.context || null, null, session.cwd || defaultCwd, null, {
1676
1794
  notifyFn: workerNotifyFn(sessionId, notifyContext || {}),
1677
1795
  ...(job ? {
@@ -1683,11 +1801,18 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
1683
1801
  // post-result save can't strand the task in `running`. Idempotent.
1684
1802
  if (job?.taskId) {
1685
1803
  try {
1686
- reconcileBackgroundTask(job.taskId, {
1687
- status: 'completed',
1688
- result: value,
1689
- terminalReason: 'agent-terminal-result',
1690
- });
1804
+ reconcileBackgroundTask(job.taskId, value.error
1805
+ ? {
1806
+ status: 'failed',
1807
+ result: value,
1808
+ error: value.error,
1809
+ terminalReason: 'agent-empty-final',
1810
+ }
1811
+ : {
1812
+ status: 'completed',
1813
+ result: value,
1814
+ terminalReason: 'agent-terminal-result',
1815
+ });
1691
1816
  } catch {}
1692
1817
  }
1693
1818
  },
@@ -1695,9 +1820,27 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
1695
1820
  });
1696
1821
  // Early preview no longer suppresses the canonical body notification;
1697
1822
  // notifyTaskCompletion fires once with output via resolve/reconcile.
1698
- return completionValue(result);
1823
+ const finalValue = completionValue(result);
1824
+ if (finalValue.error) {
1825
+ finalStatus = 'error';
1826
+ if (job) job._terminalResultValue = finalValue;
1827
+ throw new Error(finalValue.error);
1828
+ }
1829
+ return finalValue;
1699
1830
  } catch (error) {
1700
1831
  finalStatus = 'error';
1832
+ // Part C (send path mirror): a mid-stream stall throws with no terminal
1833
+ // result — reconcile to `failed` so the owner is notified rather than the
1834
+ // task hanging in `running`. Idempotent (see runSpawn note).
1835
+ if (job?.taskId && job._terminalResultValue === undefined) {
1836
+ try {
1837
+ reconcileBackgroundTask(job.taskId, {
1838
+ status: 'failed',
1839
+ error: presentErrorText(error, { surface: 'agent' }),
1840
+ terminalReason: 'agent-stream-stalled',
1841
+ });
1842
+ } catch {}
1843
+ }
1701
1844
  throw error;
1702
1845
  } finally {
1703
1846
  watchdog?.stop?.();
@@ -1713,6 +1856,9 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
1713
1856
  reconcileBackgroundTask(job.taskId, {
1714
1857
  status: finalStatus === 'error' ? 'failed' : 'completed',
1715
1858
  result: job._terminalResultValue,
1859
+ ...(finalStatus === 'error' && job._terminalResultValue?.error
1860
+ ? { error: job._terminalResultValue.error }
1861
+ : {}),
1716
1862
  terminalReason: 'agent-finally-reconcile',
1717
1863
  });
1718
1864
  } catch {}
@@ -1737,14 +1883,14 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
1737
1883
  ...extras,
1738
1884
  tag: tagForSession(prepared.sessionId),
1739
1885
  sessionId: prepared.sessionId,
1740
- role: prepared.session.role || null,
1886
+ agent: prepared.session.agent || null,
1741
1887
  queueDepth,
1742
1888
  });
1743
1889
  }
1744
1890
  const job = startJob('send', {
1745
1891
  tag: tagForSession(prepared.sessionId),
1746
1892
  sessionId: prepared.sessionId,
1747
- role: prepared.session.role || null,
1893
+ agent: prepared.session.agent || null,
1748
1894
  provider: prepared.session.provider || null,
1749
1895
  model: prepared.session.model || null,
1750
1896
  preset: prepared.session.presetName || null,
@@ -1770,7 +1916,7 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
1770
1916
  }
1771
1917
  const sessionId = resolveTag(target, scopedContext, { scanSessions: wantsSessionScan(args) });
1772
1918
  if (!sessionId) {
1773
- if (!target.startsWith('sess_') && tagRoles.has(target)) {
1919
+ if (!target.startsWith('sess_') && tagAgents.has(target)) {
1774
1920
  forgetTag(target);
1775
1921
  if (task?.taskId) cancelBackgroundTask(task.taskId, 'cancelled by agent close');
1776
1922
  return { closed: true, forgotten: true, tag: target, sessionId: null, task_id: task?.taskId || null };
@@ -1829,7 +1975,7 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
1829
1975
  const value = clean(tag);
1830
1976
  if (!value || value.startsWith('sess_')) return false;
1831
1977
  if (resolveTag(value, context, { excludeTerminalTraces: true })) return false; // live -> reuse, not trace
1832
- if (tagRoles.has(value)) return true;
1978
+ if (tagAgents.has(value)) return true;
1833
1979
  return readWorkerRows(context).some((row) => clean(row.tag) === value);
1834
1980
  }
1835
1981