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