@worca/app 1.0.0 → 1.2.0-rc.1

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 (143) hide show
  1. package/README.md +30 -9
  2. package/agents/clarify.meta.json +4 -4
  3. package/agents/decomposer.meta.json +5 -5
  4. package/agents/implementer.meta.json +15 -5
  5. package/agents/manualTestsChecklist.meta.json +5 -4
  6. package/agents/manualWebUiTesting.meta.json +9 -4
  7. package/agents/planReviewer.meta.json +12 -4
  8. package/agents/planner.meta.json +12 -5
  9. package/agents/refiner.meta.json +15 -4
  10. package/agents/reviewer.meta.json +14 -4
  11. package/agents/worca-cc-clarify.md +7 -0
  12. package/agents/worca-cc-code-reviewer.md +11 -6
  13. package/agents/worca-cc-decomposer.md +7 -0
  14. package/agents/worca-cc-implementer.md +9 -0
  15. package/agents/worca-cc-manual-tests-checklist.md +8 -5
  16. package/agents/worca-cc-manual-web-ui-testing.md +10 -6
  17. package/agents/worca-cc-plan-refiner.md +11 -6
  18. package/agents/worca-cc-plan-reviewer.md +10 -7
  19. package/agents/worca-cc-planner.md +9 -0
  20. package/agents/worca-cc-workspace-reviewer.md +11 -4
  21. package/agents/worca-cc-workspace-scanner.md +8 -4
  22. package/agents/workspaceReviewer.meta.json +15 -4
  23. package/agents/workspaceScanner.meta.json +5 -4
  24. package/package.json +8 -2
  25. package/skills/worca/SKILL.md +5 -5
  26. package/src/cli/render.mjs +148 -0
  27. package/src/cli/worca-cc.mjs +386 -56
  28. package/src/core/agent-gen.mjs +69 -31
  29. package/src/core/agent-registry.mjs +124 -144
  30. package/src/core/agent-store.mjs +164 -4
  31. package/src/core/artifacts.mjs +199 -23
  32. package/src/core/ask/attachment-kind.mjs +95 -0
  33. package/src/core/ask/catalog.mjs +111 -0
  34. package/src/core/ask/comment-deps.mjs +55 -0
  35. package/src/core/ask/events.mjs +545 -0
  36. package/src/core/ask/follow.mjs +113 -0
  37. package/src/core/ask/git-allowlist.mjs +226 -0
  38. package/src/core/ask/limits.mjs +57 -0
  39. package/src/core/ask/mcp-stdio.mjs +135 -0
  40. package/src/core/ask/models.mjs +125 -0
  41. package/src/core/ask/prompt.mjs +286 -0
  42. package/src/core/ask/proposal.mjs +170 -0
  43. package/src/core/ask/redact.mjs +30 -0
  44. package/src/core/ask/spawn.mjs +156 -0
  45. package/src/core/ask/store.mjs +438 -0
  46. package/src/core/ask/tool-deps.mjs +87 -0
  47. package/src/core/ask/tools.mjs +879 -0
  48. package/src/core/ask/turn.mjs +462 -0
  49. package/src/core/ask/worktree-deps.mjs +27 -0
  50. package/src/core/ask/worktrees.mjs +285 -0
  51. package/src/core/chat/command-router.mjs +28 -7
  52. package/src/core/chat/notifier.mjs +6 -1
  53. package/src/core/chat/renderers.mjs +15 -8
  54. package/src/core/claude-runner.mjs +541 -62
  55. package/src/core/config.mjs +310 -44
  56. package/src/core/cost-budget.mjs +29 -2
  57. package/src/core/db.mjs +773 -53
  58. package/src/core/diff-anchor.mjs +213 -0
  59. package/src/core/diff-comments.mjs +273 -0
  60. package/src/core/engine-select.mjs +32 -0
  61. package/src/core/failure-policy.mjs +201 -0
  62. package/src/core/git-info.mjs +49 -10
  63. package/src/core/graph/builtin-workflows.mjs +51 -0
  64. package/src/core/graph/executor.mjs +894 -0
  65. package/src/core/graph/registry-ports.mjs +12 -0
  66. package/src/core/graph/scheduler.mjs +1072 -0
  67. package/src/core/graph/seed-templates.mjs +318 -0
  68. package/src/core/host-guard.mjs +271 -0
  69. package/src/core/model-env.mjs +180 -8
  70. package/src/core/model-test.mjs +79 -0
  71. package/src/core/orchestrator.mjs +994 -4097
  72. package/src/core/overview-agent.mjs +15 -3
  73. package/src/core/phases.mjs +208 -537
  74. package/src/core/pipeline-delete.mjs +13 -2
  75. package/src/core/plugin-api.mjs +8 -3
  76. package/src/core/plugin-config.mjs +178 -28
  77. package/src/core/plugin-inventory.mjs +6 -2
  78. package/src/core/plugin-manifest.mjs +199 -11
  79. package/src/core/plugin-models.mjs +1 -0
  80. package/src/core/plugin-repo.mjs +16 -4
  81. package/src/core/plugin-shim-child.mjs +9 -3
  82. package/src/core/plugin-shim.mjs +80 -17
  83. package/src/core/plugin-store.mjs +236 -29
  84. package/src/core/plugin-workflows.mjs +90 -41
  85. package/src/core/preflight.mjs +135 -3
  86. package/src/core/projects.mjs +7 -5
  87. package/src/core/protocol.mjs +8 -35
  88. package/src/core/recoverable-error.mjs +1 -1
  89. package/src/core/run-harness.mjs +3934 -0
  90. package/src/core/run-manifest.mjs +5 -1
  91. package/src/core/settings.mjs +184 -13
  92. package/src/core/skills.mjs +10 -3
  93. package/src/core/source-bindings.mjs +175 -0
  94. package/src/core/sources.mjs +87 -25
  95. package/src/core/stats.mjs +25 -6
  96. package/src/core/title.mjs +51 -4
  97. package/src/core/workflows.mjs +358 -259
  98. package/src/core/workspace-scan.mjs +4 -0
  99. package/src/core/worktree.mjs +98 -7
  100. package/src/shared/graph/agent-meta.mjs +278 -0
  101. package/src/shared/graph/constants.mjs +105 -0
  102. package/src/shared/graph/geometry.mjs +157 -0
  103. package/src/shared/graph/layout.mjs +134 -0
  104. package/src/shared/graph/loops.mjs +130 -0
  105. package/src/shared/graph/manifest.mjs +257 -0
  106. package/src/shared/graph/ports.mjs +153 -0
  107. package/src/shared/graph/route.mjs +397 -0
  108. package/src/shared/graph/template.mjs +165 -0
  109. package/src/shared/graph/thumbnail.mjs +67 -0
  110. package/src/shared/graph/validate.mjs +491 -0
  111. package/src/shared/graph/verdict.mjs +41 -0
  112. package/ui/public/app.js +4240 -1682
  113. package/ui/public/ask-markdown.mjs +145 -0
  114. package/ui/public/ask-model.mjs +317 -0
  115. package/ui/public/ask-panel.mjs +2129 -0
  116. package/ui/public/chat-settings-view.mjs +6 -2
  117. package/ui/public/diff-view.mjs +66 -11
  118. package/ui/public/file-tree.mjs +305 -0
  119. package/ui/public/graph/composer.mjs +889 -0
  120. package/ui/public/graph/inspector.mjs +183 -0
  121. package/ui/public/graph/model.mjs +37 -0
  122. package/ui/public/graph/palette.mjs +144 -0
  123. package/ui/public/graph/run-decor.mjs +410 -0
  124. package/ui/public/graph/run-hosts.mjs +201 -0
  125. package/ui/public/graph/save-dialog.mjs +56 -0
  126. package/ui/public/graph/view.mjs +858 -0
  127. package/ui/public/guardrails-view.mjs +4 -2
  128. package/ui/public/hljs-loader.mjs +180 -0
  129. package/ui/public/index.html +311 -265
  130. package/ui/public/log-filter.mjs +22 -4
  131. package/ui/public/log-line.mjs +45 -19
  132. package/ui/public/models-view.mjs +171 -9
  133. package/ui/public/plugins-view.mjs +106 -4
  134. package/ui/public/source-pane.mjs +190 -8
  135. package/ui/public/stats-view.mjs +81 -1
  136. package/ui/public/style.css +1487 -229
  137. package/ui/public/syntax-highlight.mjs +270 -0
  138. package/ui/public/thinking-orb.mjs +110 -0
  139. package/ui/server.mjs +1894 -104
  140. package/src/core/channels.mjs +0 -302
  141. package/src/core/runners.mjs +0 -167
  142. package/src/core/workflow-validator.mjs +0 -185
  143. package/ui/public/composer-core.mjs +0 -211
@@ -0,0 +1,545 @@
1
+ // src/core/ask/events.mjs
2
+ // The Ask Worca stream reducer (ask-worca-design.md §6.6): claude's stream-json
3
+ // frames (as forwarded by claude-runner.mjs onEvent: {type, raw}) → bare `ask-*`
4
+ // job frames + the turn Summary. Pure: time, timers, redaction and the proposal
5
+ // hook are injected. It NEVER emits ask-start/ask-done/ask-error (turn.mjs does)
6
+ // and never throws from push().
7
+ //
8
+ // Probed shapes (claude 2.1.239, 2026-08-22) this code relies on:
9
+ // - text deltas: stream_event/content_block_delta{delta.type:'text_delta'} on the
10
+ // MAIN stream only (parent_tool_use_id == null); the `assistant` text block of
11
+ // the same message.id is authoritative; messages join with '\n\n'.
12
+ // - usage: `assistant` frames repeat the message-START usage once per content
13
+ // block (never sum); message_delta.usage is the per-call figure; result wins.
14
+ // - tools: tool_use{id,name,input} ↔ user.tool_result{tool_use_id,content,is_error};
15
+ // content is a string (errors) or [{type:'text',text}] (successes).
16
+ // - sub-agents: the block is named 'Agent' (or 'Task'); child frames carry
17
+ // parent_tool_use_id; the finishing parent tool_result carries the agent
18
+ // object in raw.tool_use_result ({agentId, agentType, resolvedModel,
19
+ // totalDurationMs, totalTokens, usage}) — or {isAsync:true} when claude ran it
20
+ // in the background (spawn.mjs sets CLAUDE_CODE_DISABLE_BACKGROUND_TASKS=1 to
21
+ // avoid that; the reducer still tolerates it). Per-agent cost is an ESTIMATE.
22
+ // - result: subtype error_max_turns / error_max_budget_usd ⇒ stopped; the CLI
23
+ // exits 1 on those, so turn.mjs reads snapshot().resultSubtype on rejection.
24
+ // The LAST result wins (two arrive in background mode).
25
+ import { redactAskText } from './redact.mjs';
26
+ import { ASK_LIMITS } from './limits.mjs';
27
+
28
+ const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0);
29
+ const ZERO = () => ({ input: 0, output: 0, cacheRead: 0, cacheCreation: 0 });
30
+ const add = (a, b) => ({ input: a.input + b.input, output: a.output + b.output, cacheRead: a.cacheRead + b.cacheRead, cacheCreation: a.cacheCreation + b.cacheCreation });
31
+ const weight = (u) => u.input + 1.25 * u.cacheCreation + 0.1 * u.cacheRead + 5 * u.output;
32
+ const clone = (v) => JSON.parse(JSON.stringify(v));
33
+ const short = (name) => String(name ?? '').replace(/^mcp__worca__/, '');
34
+ const isAgentTool = (name) => name === 'Task' || name === 'Agent';
35
+ // The three write tools whose success must reach the browser. The reducer runs in
36
+ // the PARENT process, so this is the only place a child-process write becomes a
37
+ // broadcast (the MCP server cannot call broadcast()).
38
+ const COMMENT_WRITE_TOOLS = new Set([
39
+ 'mcp__worca__add_diff_comment', 'mcp__worca__resolve_diff_comment', 'mcp__worca__delete_diff_comment',
40
+ ]);
41
+ // The worktree-mutating tools (P4): the MCP child opens/removes checkouts and
42
+ // moves HEAD (checkout/switch/fetch → tools.mjs noteNav) — invisible to this
43
+ // process, so a successful result becomes the same `ask-worktrees` broadcast
44
+ // the REST DELETE route emits (ui/server.mjs emitAskWorktrees). `git` counts
45
+ // only when its subcommand is one noteNav acts on; a `log`/`status` never pokes.
46
+ const WORKTREE_TOOLS = new Set(['mcp__worca__open_worktree', 'mcp__worca__remove_worktree', 'mcp__worca__git']);
47
+ const GIT_NAV_SUBCOMMANDS = new Set(['checkout', 'switch', 'fetch']);
48
+ /** True when a SUCCESSFUL call of `name` with `input` changed this thread's worktree rows. */
49
+ export function worktreeMutatingCall(name, input) {
50
+ if (!WORKTREE_TOOLS.has(name)) return false;
51
+ if (name !== 'mcp__worca__git') return true;
52
+ const args = input && Array.isArray(input.args) ? input.args : null;
53
+ return !!args && GIT_NAV_SUBCOMMANDS.has(String(args[0] ?? '').trim());
54
+ }
55
+
56
+ /** claude's usage object → the persisted shape. */
57
+ export function normalizeUsage(u) {
58
+ return {
59
+ input: num(u?.input_tokens),
60
+ output: num(u?.output_tokens),
61
+ cacheRead: num(u?.cache_read_input_tokens),
62
+ cacheCreation: num(u?.cache_creation_input_tokens),
63
+ };
64
+ }
65
+
66
+ /** result.modelUsage key for an agent's model: exact → canonicalModel → stripped -YYYYMMDD → the single key. */
67
+ export function matchModelKey(model, modelUsage) {
68
+ const mu = modelUsage && typeof modelUsage === 'object' ? modelUsage : {};
69
+ const keys = Object.keys(mu);
70
+ if (!keys.length) return null;
71
+ const m = String(model ?? '').trim().toLowerCase();
72
+ if (m) {
73
+ const exact = keys.find((k) => k.toLowerCase() === m);
74
+ if (exact) return exact;
75
+ const canon = keys.find((k) => String(mu[k]?.canonicalModel ?? '').toLowerCase() === m);
76
+ if (canon) return canon;
77
+ const strip = (s) => s.replace(/-\d{8}$/, '');
78
+ const stripped = keys.find((k) => strip(k.toLowerCase()) === strip(m) && !/-\d{8}$/.test(k)) // prefer the un-dated twin
79
+ || keys.find((k) => strip(k.toLowerCase()) === strip(m));
80
+ if (stripped) return stripped;
81
+ }
82
+ return keys.length === 1 ? keys[0] : null;
83
+ }
84
+
85
+ /** Spec §6.6: costUSD × w(agent) / w(model total), clamped; null without usage or a matching model. Always estimated:true. */
86
+ export function estimateAgentCosts(agents, result) {
87
+ const mu = result?.modelUsage && typeof result.modelUsage === 'object' ? result.modelUsage : {};
88
+ return agents.map((a) => {
89
+ if (!a.usage) return { ...a, costUsd: null, estimated: true };
90
+ let key = matchModelKey(a.model, mu);
91
+ let entry = key ? mu[key] : null;
92
+ const totalOf = (e) => weight({ input: num(e.inputTokens), output: num(e.outputTokens), cacheRead: num(e.cacheReadInputTokens), cacheCreation: num(e.cacheCreationInputTokens) });
93
+ const dated = (k) => /-\d{8}$/.test(k);
94
+ if (entry && dated(key) && weight(a.usage) > totalOf(entry)) {
95
+ // A DATED key is the CLI's own `ai-title` side call (probe F12, ≈ 900 tokens): an agent that used more than
96
+ // that whole entry cannot have run there — switch to the un-dated canonical twin before clamping. Never the
97
+ // other way round (an un-dated key that is exceeded is simply clamped).
98
+ const twin = Object.keys(mu).find((k) => k !== key && !dated(k) && mu[k] && mu[k].canonicalModel === entry.canonicalModel);
99
+ if (twin) { key = twin; entry = mu[twin]; }
100
+ }
101
+ if (!entry || typeof entry.costUSD !== 'number' || !Number.isFinite(entry.costUSD)) return { ...a, costUsd: null, estimated: true };
102
+ const total = totalOf(entry);
103
+ if (!(total > 0)) return { ...a, costUsd: null, estimated: true };
104
+ const share = Math.min(entry.costUSD, (entry.costUSD * weight(a.usage)) / total);
105
+ return { ...a, costUsd: Math.round(share * 1e6) / 1e6, estimated: true };
106
+ });
107
+ }
108
+
109
+ /** The activity label for a main-stream tool call (null for sub-agent spawns — those are counted). */
110
+ export function labelForTool(name, input = {}, attachmentNames = {}) {
111
+ if (isAgentTool(name)) return null;
112
+ const n = short(name);
113
+ const id = typeof input?.id === 'string' ? input.id : '';
114
+ switch (n) {
115
+ case 'list_runs': return 'Finding runs';
116
+ case 'get_run':
117
+ case 'get_run_diff': return id ? `Reading run ${id.slice(0, 12)}` : 'Reading run';
118
+ case 'list_workflows': return 'Looking at workflows';
119
+ case 'list_projects': return 'Looking at projects';
120
+ case 'propose_run': return 'Preparing a run';
121
+ case 'read_attachment': return `Reading ${(attachmentNames && attachmentNames[id]) || 'attachment'}`;
122
+ case 'list_diff_comments': return id ? `Reading comments on ${id.slice(0, 12)}` : 'Reading diff comments';
123
+ case 'add_diff_comment': return 'Writing a diff comment';
124
+ case 'resolve_diff_comment': return 'Updating a diff comment';
125
+ case 'delete_diff_comment': return 'Deleting a diff comment';
126
+ default: return `Using ${n}`;
127
+ }
128
+ }
129
+
130
+ const resultText = (content) => {
131
+ if (typeof content === 'string') return content;
132
+ if (Array.isArray(content)) return content.filter((c) => c && c.type === 'text' && typeof c.text === 'string').map((c) => c.text).join('');
133
+ return '';
134
+ };
135
+
136
+ /**
137
+ * @param {object} o
138
+ * @param {(frame:object)=>void} o.onFrame
139
+ * @param {(s:string)=>string} [o.redact]
140
+ * @param {()=>number} [o.now]
141
+ * @param {Function} [o.setTimeout] (fn, ms) => id
142
+ * @param {Function} [o.clearTimeout]
143
+ * @param {(p:{toolUseId:string, input:object, childOk:boolean|null})=>void} [o.onProposal]
144
+ * @param {(p:{runId:string})=>void} [o.onCommentMutation] a successful MCP-side comment write
145
+ * @param {(p:{tool:string})=>void} [o.onWorktreeMutation] a successful MCP-side worktree open/remove/navigate
146
+ * @param {(usage:object)=>number|null} [o.estimateLiveCost] DISPLAY-ONLY $ estimate of the running usage (null = no estimate)
147
+ * @param {Record<string,string>} [o.attachmentNames] id → display name (labels only)
148
+ * @param {(cliCostUsd:number, usage:object)=>number} [o.resolveCost] re-price the
149
+ * turn: given what the CLI reported and this turn's usage, return the
150
+ * AUTHORITATIVE cost. Injected (rather than imported) to keep this reducer free
151
+ * of config/DB dependencies. Default: trust the CLI. A non-finite return, or a
152
+ * throw, falls back to the CLI figure.
153
+ * @param {object} [o.limits]
154
+ */
155
+ export function createTurnReducer({
156
+ onFrame,
157
+ redact = redactAskText,
158
+ now = Date.now,
159
+ setTimeout: setT = globalThis.setTimeout,
160
+ clearTimeout: clearT = globalThis.clearTimeout,
161
+ onProposal = null,
162
+ onCommentMutation = null,
163
+ onWorktreeMutation = null,
164
+ estimateLiveCost = null,
165
+ attachmentNames = {},
166
+ resolveCost = null,
167
+ limits = ASK_LIMITS,
168
+ } = {}) {
169
+ const startedAt = now();
170
+ const emit = (type, payload) => { try { onFrame({ type, ...payload }); } catch { /* a UI/WS failure never breaks the stream */ } };
171
+
172
+ // ── state ──
173
+ const messages = new Map(); // main-stream message id → { deltas, blocks } (insertion order)
174
+ const streams = new Map(); // stream key ('main' | parent tool id) → { messageId }
175
+ let currentMainMsg = null;
176
+ const usageByMsg = new Map(); // message id → { usage, final }
177
+ let lastMainUsageMsg = null; // the LAST main message with usage — its per-call total is the context fill
178
+ let pending = '';
179
+ let timer = null;
180
+ const blocks = []; // persisted blocks in insertion order
181
+ const byId = new Map(); // block id → block (tool / agent / card)
182
+ const startAt = new Map(); // tool or agent id → spawn time
183
+ const fullInputs = new Map(); // tool id → unclipped input (the proposal hook needs it)
184
+ const childTools = new Map(); // child tool id → { agentId, t0, name, input }
185
+ const labels = [];
186
+ let lastLabel = null;
187
+ let anyToolRan = false;
188
+ let runningAgents = 0;
189
+ let sawInit = false;
190
+ let sawAssistant = false;
191
+ let sawResult = false;
192
+ let sessionId = null;
193
+ let lastResult = null;
194
+ let reducerErrors = 0;
195
+ let summary = null;
196
+ const pendingHooks = []; // promises returned by onProposal — settle() awaits them
197
+
198
+ // ── helpers ──
199
+ const label = (l) => { if (!l || l === lastLabel) return; lastLabel = l; labels.push(l); emit('ask-label', { label: l }); };
200
+ const agentsLabel = () => (runningAgents > 0 ? `Running ${runningAgents} sub-agent${runningAgents === 1 ? '' : 's'}` : 'Thinking');
201
+ const clipStr = (s, n) => { const t = String(s ?? ''); return t.length > n ? `${t.slice(0, n)}…` : t; };
202
+ const safeJson = (v) => { try { return JSON.stringify(v); } catch { return String(v); } };
203
+ const clipJson = (v, max) => { const s = safeJson(v); return s.length <= max ? v : { _truncated: true, preview: s.slice(0, max) }; };
204
+ const msgEntry = (id) => { let e = messages.get(id); if (!e) { e = { deltas: '', blocks: [] }; messages.set(id, e); } return e; };
205
+ const messageText = (e) => (e.blocks.length ? e.blocks.join('') : e.deltas);
206
+ const mainText = () => [...messages.values()].map(messageText).filter(Boolean).join('\n\n');
207
+ const usageSum = () => [...usageByMsg.values()].reduce((acc, { usage }) => add(acc, usage), ZERO());
208
+ // A message that never receives a message_delta (killed mid-call) is counted at its message-START usage — an under-count, accepted.
209
+ const noteUsage = (messageId, raw, final, main = false) => {
210
+ if (!messageId || !raw || typeof raw !== 'object') return;
211
+ const cur = usageByMsg.get(messageId);
212
+ if (cur && cur.final && !final) return;
213
+ usageByMsg.set(messageId, { usage: normalizeUsage(raw), final: !!final });
214
+ if (main) lastMainUsageMsg = messageId;
215
+ };
216
+ const ctxOf = (u) => u.input + u.output + u.cacheRead + u.cacheCreation;
217
+ // Context fill = the last MAIN call's per-call total. The cumulative result
218
+ // usage never feeds it — a result would report the whole turn, not one call.
219
+ const ctxNow = () => { const e = lastMainUsageMsg ? usageByMsg.get(lastMainUsageMsg) : null; return e ? ctxOf(e.usage) : null; };
220
+ const currentUsage = () => ({ ...(lastResult && lastResult.usage ? normalizeUsage(lastResult.usage) : usageSum()), ctx: ctxNow() });
221
+ /** What the CLI itself reported for this turn — null until the `result` frame lands. */
222
+ const cliCost = () => (lastResult && typeof lastResult.total_cost_usd === 'number' && Number.isFinite(lastResult.total_cost_usd) ? lastResult.total_cost_usd : null);
223
+ // The AUTHORITATIVE turn cost: cliCost() re-priced by the injected override, if
224
+ // any. Memoized on lastResult — resolveCost reads the model catalog off disk, and
225
+ // this is read by every ask-usage frame as well as finish()/snapshot(). null
226
+ // (no `result` frame seen) is NOT a price and is never re-priced: ask spec §6.2.8
227
+ // makes null mean "no cost observed", which the ledger writer no-ops on.
228
+ let costMemo = null;
229
+ const currentCost = () => {
230
+ const raw = cliCost();
231
+ if (raw === null || !resolveCost) return raw;
232
+ if (!costMemo || costMemo.src !== lastResult) {
233
+ let v = raw;
234
+ try { const r = resolveCost(raw, currentUsage()); if (Number.isFinite(r)) v = r; }
235
+ catch { /* a pricing override must never break a turn */ }
236
+ costMemo = { src: lastResult, value: v };
237
+ }
238
+ return costMemo.value;
239
+ };
240
+ /** authoritative ÷ CLI — the factor the per-agent cost split must ride (1 when no override applies). */
241
+ const costScale = () => {
242
+ const raw = cliCost();
243
+ if (raw === null || !(raw > 0)) return 1;
244
+ const resolved = currentCost();
245
+ return resolved === null ? 1 : resolved / raw;
246
+ };
247
+ // DISPLAY ONLY: the injected estimator prices the running usage sum while no
248
+ // `result` has landed; once cliCost() is a number the authoritative figure is
249
+ // in costUsd and the estimate retires (null). Read by the ask-usage frame
250
+ // alone — never by snapshot()/finish(), so no sink can ever book it.
251
+ const liveEstimate = () => {
252
+ if (typeof estimateLiveCost !== 'function' || cliCost() !== null) return null;
253
+ try { const v = estimateLiveCost(currentUsage()); return Number.isFinite(v) ? v : null; }
254
+ catch { return null; }
255
+ };
256
+ const emitUsage = () => emit('ask-usage', { usage: currentUsage(), costUsd: currentCost(), estimatedCostUsd: liveEstimate() });
257
+ const flushDeltas = () => {
258
+ if (timer !== null) { clearT(timer); timer = null; }
259
+ if (!pending) return;
260
+ const text = redact(pending);
261
+ pending = '';
262
+ if (text) emit('ask-delta', { text });
263
+ };
264
+ const queueDelta = (t) => {
265
+ pending += t;
266
+ if (pending.length >= limits.deltaBatchChars) { flushDeltas(); return; }
267
+ if (timer !== null) return;
268
+ const id = setT(flushDeltas, limits.deltaBatchMs);
269
+ if (pending) timer = id; // a synchronous timer stub already flushed: keep no stale id
270
+ else clearT(id);
271
+ };
272
+ const upsertBlock = (block) => {
273
+ if (block.id !== undefined && block.id !== null) byId.set(block.id, block);
274
+ if (!blocks.includes(block)) blocks.push(block);
275
+ emit(block.kind === 'card' ? 'ask-card' : 'ask-block', { block: clone(block) });
276
+ };
277
+ const appendLog = (agent, text) => {
278
+ const max = limits.agentLogMaxLines;
279
+ if (agent.log.length >= max) return;
280
+ const t = Math.max(0, now() - (startAt.get(agent.id) ?? startedAt));
281
+ agent.log.push(agent.log.length === max - 1 ? { t, text: '… more lines omitted' } : { t, text: redact(text) });
282
+ upsertBlock(agent);
283
+ };
284
+ const elapsed = (id) => { const t0 = startAt.get(id); return t0 === undefined ? null : Math.max(0, now() - t0); };
285
+
286
+ // ── handlers ──
287
+ function onStreamEvent(raw, ptu, isMain) {
288
+ const e = raw.event;
289
+ if (!e || typeof e !== 'object') return;
290
+ const key = ptu ?? 'main';
291
+ if (e.type === 'message_start') {
292
+ const id = e.message && typeof e.message.id === 'string' ? e.message.id : null;
293
+ streams.set(key, { messageId: id });
294
+ if (isMain) { sawAssistant = true; currentMainMsg = id; if (id) msgEntry(id); noteUsage(id, e.message?.usage, false, true); }
295
+ return;
296
+ }
297
+ if (e.type === 'message_delta') {
298
+ noteUsage(streams.get(key)?.messageId, e.usage, true, isMain);
299
+ if (isMain) { emitUsage(); return; }
300
+ const agent = byId.get(ptu);
301
+ if (agent && agent.kind === 'agent' && e.usage && typeof e.usage === 'object') {
302
+ agent.ctx = ctxOf(normalizeUsage(e.usage)); // the child's per-call total; last call wins
303
+ upsertBlock(agent);
304
+ }
305
+ return;
306
+ }
307
+ if (!isMain) return; // child deltas never become the answer
308
+ if (e.type === 'content_block_delta' && e.delta && e.delta.type === 'text_delta' && typeof e.delta.text === 'string') {
309
+ const id = currentMainMsg ?? '__main__';
310
+ const entry = msgEntry(id);
311
+ const first = !entry.deltas && !entry.blocks.length;
312
+ if (first && [...messages.values()].some((x) => x !== entry && messageText(x))) queueDelta('\n\n');
313
+ entry.deltas += e.delta.text;
314
+ if (anyToolRan) label('Writing');
315
+ queueDelta(e.delta.text);
316
+ }
317
+ }
318
+
319
+ function onAssistant(raw, ptu, isMain) {
320
+ const msg = raw.message && typeof raw.message === 'object' ? raw.message : {};
321
+ const id = typeof msg.id === 'string' ? msg.id : null;
322
+ const content = Array.isArray(msg.content) ? msg.content : [];
323
+ if (isMain) {
324
+ sawAssistant = true;
325
+ if (id) {
326
+ if (messages.has('__main__') && !messages.has(id)) { // deltas arrived before any message_start: adopt them
327
+ messages.set(id, messages.get('__main__'));
328
+ messages.delete('__main__');
329
+ currentMainMsg = id;
330
+ }
331
+ const entry = msgEntry(id);
332
+ noteUsage(id, msg.usage, false, true);
333
+ for (const c of content) if (c && c.type === 'text' && typeof c.text === 'string') entry.blocks.push(c.text);
334
+ }
335
+ }
336
+ for (const c of content) {
337
+ if (!c || c.type !== 'tool_use' || typeof c.id !== 'string') continue;
338
+ const input = c.input && typeof c.input === 'object' ? c.input : {};
339
+ if (isMain) {
340
+ anyToolRan = true;
341
+ startAt.set(c.id, now());
342
+ if (isAgentTool(c.name)) {
343
+ runningAgents += 1;
344
+ label(agentsLabel()); // label first, then the block (the client shows both)
345
+ upsertBlock({ kind: 'agent', id: c.id, label: clipStr(input.description || input.subagent_type || c.name, 80), type: typeof input.subagent_type === 'string' ? input.subagent_type : null,
346
+ model: typeof input.model === 'string' ? input.model : null, tokens: null, ctx: null, usage: null, costUsd: null, estimated: true, status: 'running', durationMs: null, log: [] });
347
+ } else {
348
+ fullInputs.set(c.id, input);
349
+ label(labelForTool(c.name, input, attachmentNames));
350
+ upsertBlock({ kind: 'tool', id: c.id, name: c.name, input: clipJson(input, limits.blockIoMaxChars), status: 'running', durationMs: null });
351
+ }
352
+ } else {
353
+ const agent = byId.get(ptu);
354
+ if (!agent || agent.kind !== 'agent') continue;
355
+ childTools.set(c.id, { agentId: ptu, t0: now(), name: c.name, input });
356
+ appendLog(agent, isAgentTool(c.name) ? `→ Task ${clipStr(input.description || '', 60)}` : `→ ${short(c.name)} ${clipStr(safeJson(input), 120)}`);
357
+ }
358
+ }
359
+ }
360
+
361
+ // A comment write happened in the MCP CHILD process, so nothing in this
362
+ // process saw the row change. The tool result names the run it touched
363
+ // (shapeComment.runId / delete's comment.runId), so the parent can turn a
364
+ // successful call into the same diff-comments-changed poke the REST routes
365
+ // broadcast. Error results are skipped: nothing changed.
366
+ // SUB-AGENTS write too — they hold the same mcp__worca grant (spawn.mjs
367
+ // ASK_MCP_GRANTS) — so their results poke as well. No double-fire: the main
368
+ // transcript only ever sees the Task's AGGREGATE result, whose name is never a
369
+ // comment tool, and childTools.delete() makes a re-delivered child result a
370
+ // no-op.
371
+ function pokeCommentWrite(name, text, isError) {
372
+ if (isError || !COMMENT_WRITE_TOOLS.has(name) || typeof onCommentMutation !== 'function') return;
373
+ try {
374
+ const parsed = JSON.parse(text);
375
+ const runId = typeof parsed?.comment?.runId === 'string' ? parsed.comment.runId : null;
376
+ if (runId) onCommentMutation({ runId });
377
+ } catch { /* unparseable result — no poke; the next open refetches anyway */ }
378
+ }
379
+
380
+ // Same idea for worktrees: open_worktree / remove_worktree / a navigating git
381
+ // call succeeded in the CHILD, so the parent re-reads the rows and broadcasts
382
+ // them. Error results changed nothing. Both paths — main transcript and
383
+ // sub-agent — carry the call's input (fullInputs / childTools.input), so the
384
+ // git subcommand filter is the same on both.
385
+ function pokeWorktreeMutation(name, input, isError) {
386
+ if (isError || typeof onWorktreeMutation !== 'function' || !worktreeMutatingCall(name, input)) return;
387
+ try { onWorktreeMutation({ tool: short(name) }); } catch { /* a broken sink never breaks the stream */ }
388
+ }
389
+
390
+ function onUser(raw, ptu, isMain) {
391
+ const content = Array.isArray(raw.message?.content) ? raw.message.content : [];
392
+ for (const c of content) {
393
+ if (!c || c.type !== 'tool_result' || typeof c.tool_use_id !== 'string') continue;
394
+ const text = resultText(c.content);
395
+ if (!isMain) {
396
+ const ct = childTools.get(c.tool_use_id);
397
+ if (!ct) continue;
398
+ childTools.delete(c.tool_use_id);
399
+ const agent = byId.get(ct.agentId);
400
+ if (agent) appendLog(agent, c.is_error ? `← error: ${clipStr(text, 120)}` : `← ok ${((now() - ct.t0) / 1000).toFixed(1)}s`);
401
+ pokeCommentWrite(ct.name, text, c.is_error);
402
+ pokeWorktreeMutation(ct.name, ct.input, c.is_error);
403
+ continue;
404
+ }
405
+ const b = byId.get(c.tool_use_id);
406
+ if (!b || (b.kind !== 'tool' && b.kind !== 'agent')) continue;
407
+ if (b.kind === 'agent') {
408
+ const tur = raw.tool_use_result;
409
+ const obj = tur && typeof tur === 'object' && !Array.isArray(tur) ? tur : null;
410
+ if (obj && (obj.isAsync === true || obj.status === 'async_launched')) { upsertBlock(b); continue; } // background mode: finish() closes it
411
+ runningAgents = Math.max(0, runningAgents - 1);
412
+ if (obj) {
413
+ if (typeof obj.resolvedModel === 'string') b.model = obj.resolvedModel;
414
+ if (obj.usage && typeof obj.usage === 'object') b.usage = normalizeUsage(obj.usage);
415
+ b.tokens = Number.isFinite(obj.totalTokens) ? obj.totalTokens : (b.usage ? b.usage.input + b.usage.output + b.usage.cacheRead + b.usage.cacheCreation : null);
416
+ if (!b.type && typeof obj.agentType === 'string') b.type = obj.agentType;
417
+ if (Number.isFinite(obj.totalDurationMs)) b.durationMs = obj.totalDurationMs;
418
+ }
419
+ if (b.durationMs === null) b.durationMs = elapsed(b.id);
420
+ b.status = c.is_error ? 'error' : 'done';
421
+ if (c.is_error) b.error = redact(clipStr(text, limits.blockIoMaxChars));
422
+ label(agentsLabel()); // label first, then the block — same order as the spawn path
423
+ upsertBlock(b);
424
+ continue;
425
+ }
426
+ b.status = c.is_error ? 'error' : 'done';
427
+ b.durationMs = elapsed(b.id);
428
+ if (c.is_error) b.error = redact(clipStr(text, limits.blockIoMaxChars));
429
+ upsertBlock(b);
430
+ if (b.name === 'mcp__worca__propose_run' && typeof onProposal === 'function') {
431
+ let childOk = null;
432
+ try { const parsed = JSON.parse(text); childOk = typeof parsed?.ok === 'boolean' ? parsed.ok : null; } catch { childOk = null; }
433
+ try {
434
+ const ret = onProposal({ toolUseId: b.id, input: fullInputs.get(b.id) ?? {}, childOk });
435
+ if (ret && typeof ret.then === 'function') pendingHooks.push(ret.then(() => {}, () => { reducerErrors += 1; }));
436
+ } catch { reducerErrors += 1; }
437
+ }
438
+ pokeCommentWrite(b.name, text, c.is_error);
439
+ pokeWorktreeMutation(b.name, fullInputs.get(b.id), c.is_error);
440
+ }
441
+ }
442
+
443
+ function onResult(raw) {
444
+ sawResult = true;
445
+ lastResult = raw; // the LAST result wins; never sum
446
+ if (typeof raw.session_id === 'string') sessionId = raw.session_id;
447
+ emitUsage();
448
+ }
449
+
450
+ function handle(evt) {
451
+ if (!evt || typeof evt !== 'object') return;
452
+ if (!labels.length) label('Thinking');
453
+ if (evt.type === 'session' && typeof evt.sessionId === 'string') { sessionId = evt.sessionId; return; }
454
+ const raw = evt.raw;
455
+ if (!raw || typeof raw !== 'object') return; // stderr / log / hook envelopes
456
+ const ptu = raw.parent_tool_use_id ?? null;
457
+ const isMain = ptu === null;
458
+ switch (raw.type) {
459
+ case 'system':
460
+ if (raw.subtype === 'init') { sawInit = true; if (typeof raw.session_id === 'string') sessionId = raw.session_id; }
461
+ return; // status, thinking_tokens, task_*, background_tasks_changed, hook_*
462
+ case 'stream_event': return onStreamEvent(raw, ptu, isMain);
463
+ case 'assistant': return onAssistant(raw, ptu, isMain);
464
+ case 'user': return onUser(raw, ptu, isMain);
465
+ case 'result': return onResult(raw);
466
+ default: return; // rate_limit_event, unknown
467
+ }
468
+ }
469
+
470
+ const terminal = () => {
471
+ const subtype = lastResult && typeof lastResult.subtype === 'string' ? lastResult.subtype : null;
472
+ const reason = /max_turns/.test(subtype ?? '') ? 'max_turns' : /max_budget/.test(subtype ?? '') ? 'max_budget' : null;
473
+ return {
474
+ status: reason ? 'stopped' : 'done',
475
+ reason,
476
+ resultSubtype: subtype,
477
+ isError: !!(lastResult && lastResult.is_error),
478
+ errors: Array.isArray(lastResult?.errors) ? lastResult.errors.map(String) : [],
479
+ numTurns: Number.isFinite(lastResult?.num_turns) ? lastResult.num_turns : null,
480
+ durationMs: Number.isFinite(lastResult?.duration_ms) ? lastResult.duration_ms : Math.max(0, now() - startedAt),
481
+ };
482
+ };
483
+
484
+ return {
485
+ push(event) {
486
+ if (summary) return;
487
+ try { handle(event); } catch { reducerErrors += 1; }
488
+ },
489
+ flush: flushDeltas,
490
+ /** Await P2's async proposal hooks (validateProposal → addBlock). turn.mjs calls this BEFORE finish(). */
491
+ async settle() {
492
+ while (pendingHooks.length) await pendingHooks.splice(0).reduce((p, h) => p.then(() => h), Promise.resolve());
493
+ },
494
+ addBlock(block) {
495
+ if (summary) { reducerErrors += 1; return null; } // after finish(): the message is persisted — too late
496
+ upsertBlock(block);
497
+ return block;
498
+ },
499
+ updateBlock(id, patch) {
500
+ if (summary) { reducerErrors += 1; return null; }
501
+ const b = byId.get(id);
502
+ if (!b) return null;
503
+ Object.assign(b, patch && typeof patch === 'object' ? patch : {});
504
+ upsertBlock(b);
505
+ return clone(b);
506
+ },
507
+ snapshot() {
508
+ return {
509
+ text: mainText(), blocks: blocks.map(clone), usage: currentUsage(), costUsd: currentCost(), sessionId,
510
+ ...terminal(), sawInit, sawAssistant, sawResult, agents: blocks.filter((b) => b.kind === 'agent').length,
511
+ runningAgents, labels: [...labels], reducerErrors,
512
+ };
513
+ },
514
+ finish() {
515
+ if (summary) return summary;
516
+ flushDeltas();
517
+ for (const b of blocks) {
518
+ if ((b.kind === 'tool' || b.kind === 'agent') && b.status === 'running') {
519
+ b.status = 'error';
520
+ b.error = 'interrupted';
521
+ b.durationMs = elapsed(b.id) ?? Math.max(0, now() - startedAt);
522
+ upsertBlock(b);
523
+ }
524
+ }
525
+ const agents = blocks.filter((b) => b.kind === 'agent');
526
+ if (agents.length && lastResult) {
527
+ const est = estimateAgentCosts(agents, lastResult);
528
+ // §6.6 splits the CLI's OWN modelUsage costUSD across agents. When an
529
+ // override re-prices the turn, the shares must ride the same scale or the
530
+ // agent rows out-total the turn they belong to (a free endpoint would show
531
+ // $0.00 overall next to agents billing real dollars).
532
+ const scale = costScale();
533
+ agents.forEach((a, i) => {
534
+ a.costUsd = est[i].costUsd == null ? null : Math.round(est[i].costUsd * scale * 1e6) / 1e6;
535
+ });
536
+ }
537
+ const text = mainText() || (lastResult && typeof lastResult.result === 'string' ? lastResult.result : '');
538
+ summary = {
539
+ text: redact(text), blocks: blocks.map(clone), usage: currentUsage(), costUsd: currentCost(), sessionId,
540
+ ...terminal(), sawInit, sawAssistant, sawResult, agents: agents.length, labels: [...labels], reducerErrors,
541
+ };
542
+ return summary;
543
+ },
544
+ };
545
+ }
@@ -0,0 +1,113 @@
1
+ // src/core/ask/follow.mjs
2
+ // Follow a run started from an Ask Worca card (ask-worca-design.md §9.5, §11).
3
+ // attachRunFollower(orch, deps) subscribes to the orchestrator's state/phase/
4
+ // question/error/done events — every handler exception-guarded so nothing here
5
+ // can break a run (chat/notifier.mjs precedent) — and mirrors them into the
6
+ // thread through two injected closures:
7
+ // post({kind, text, href}) → a system message + notice
8
+ // updateStatus({pipelineId?, status?, phase?, cardFailed?}) → ask_run_links + ask-run-status
9
+ // Message budget per run: ≤3 question notices (deduped by id) + exactly one of
10
+ // failed/finished/paused; an error-pause rides the paused notice with its detail
11
+ // (no `error` event precedes it). done{status:'error'} posts nothing — the richer
12
+ // `error` event already did (the orchestrator emits both for one failure).
13
+ // detach() removes the named listeners and latches; the follower self-detaches
14
+ // on error/done. Core module: no Express, no orchestrator import — driven by a
15
+ // bare EventEmitter in tests.
16
+ import { fmtMs, fmtUsd } from '../chat/renderers.mjs';
17
+
18
+ const MAX_QUESTION_NOTICES = 3;
19
+
20
+ export function attachRunFollower(orch, {
21
+ threadId, runId, cardId = null, post = () => {}, updateStatus = () => {}, onDetached = null,
22
+ } = {}) {
23
+ let detached = false;
24
+ let seenPipelineId = false;
25
+ let title = '';
26
+ const seenQuestions = new Set();
27
+
28
+ const guard = (fn) => (payload) => {
29
+ if (detached) return;
30
+ try { fn(payload && typeof payload === 'object' ? payload : {}); } catch { /* never break the run */ }
31
+ };
32
+
33
+ const snapshot = () => { try { return (typeof orch.getState === 'function' && orch.getState()) || {}; } catch { return {}; } };
34
+ const runName = () => title || snapshot().title || 'run';
35
+ const finishLine = (status) => {
36
+ // Duration/cost live on the orchestrator state, not the done payload
37
+ // (chat/renderers.mjs:53-74 reads them from meta the same way).
38
+ const state = snapshot();
39
+ const name = runName();
40
+ const parts = [`Run finished — "${name}" · ${status}`];
41
+ const dur = fmtMs(state.totalActiveMs);
42
+ if (dur) parts.push(dur);
43
+ const cost = fmtUsd(state.totalCostUsd);
44
+ if (cost) parts.push(cost);
45
+ return parts.join(' · ');
46
+ };
47
+
48
+ const handlers = {
49
+ state: guard((p) => {
50
+ if (typeof p.title === 'string' && p.title) title = p.title;
51
+ const patch = {};
52
+ // First truthy sight only (ui/server.mjs wireRun guard): null pre-createPipeline
53
+ // snapshots and later re-emits must not churn the stored id.
54
+ if (!seenPipelineId && typeof p.id === 'string' && p.id) {
55
+ seenPipelineId = true;
56
+ patch.pipelineId = p.id;
57
+ }
58
+ if (p.status) patch.status = p.status;
59
+ updateStatus(patch);
60
+ }),
61
+ exec: guard((p) => {
62
+ // The graph engine has no linear phase: report the agent that just started.
63
+ if (p.status !== 'start') return;
64
+ updateStatus({ phase: p.agentKey || p.nodeId || null, status: 'running' });
65
+ }),
66
+ question: guard((p) => {
67
+ const qid = String(p.id ?? 'q');
68
+ if (seenQuestions.has(qid) || seenQuestions.size >= MAX_QUESTION_NOTICES) return;
69
+ seenQuestions.add(qid);
70
+ post({
71
+ kind: 'question',
72
+ text: `Run "${title || 'run'}" is waiting for your answer (${p.kind || 'question'})`,
73
+ href: `#running/${runId}`,
74
+ });
75
+ }),
76
+ error: guard((p) => {
77
+ const message = typeof p.message === 'string' && p.message ? p.message : 'unknown error';
78
+ updateStatus({ status: 'error', cardFailed: message });
79
+ post({ kind: 'failed', text: `Run failed: ${message}`, href: `#running/${runId}` });
80
+ detach();
81
+ }),
82
+ done: guard((p) => {
83
+ const status = p.status || 'done';
84
+ updateStatus({ status });
85
+ if (status === 'paused') {
86
+ // Terminal for THIS orchestrator, not for the run: a resume builds a new
87
+ // one (ui/server.mjs resumeRun), which re-attaches a fresh follower. So say
88
+ // "paused" — never "finished" — and let go (review of PR #376). An ERROR-
89
+ // pause (errors-pause policy: no `error` event precedes it) names the cause
90
+ // here, since this is the only line the thread will ever see for it.
91
+ const text = p.reason === 'error'
92
+ ? `Run paused after an error — "${runName()}": ${String(p.detail || 'unknown error')} · resume it from Running`
93
+ : `Run paused — "${runName()}" · resume it from Running`;
94
+ post({ kind: 'paused', text, href: `#running/${runId}` });
95
+ } else if (status !== 'error') {
96
+ post({ kind: 'done', text: finishLine(status), href: `#running/${runId}` });
97
+ }
98
+ detach();
99
+ }),
100
+ };
101
+
102
+ function detach() {
103
+ if (detached) return;
104
+ detached = true;
105
+ for (const [name, handler] of Object.entries(handlers)) {
106
+ try { orch.removeListener?.(name, handler); } catch { /* already gone */ }
107
+ }
108
+ try { onDetached?.(); } catch { /* prune callback is best-effort */ }
109
+ }
110
+
111
+ for (const [name, handler] of Object.entries(handlers)) orch.on(name, handler);
112
+ return { detach, get detached() { return detached; }, threadId, runId, cardId };
113
+ }