@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,145 @@
1
+ // ui/public/ask-markdown.mjs — sandboxed markdown for Ask Worca answers
2
+ // (spec §10.7). marked + DOMPurify are lazy-loaded through the injected
3
+ // `load()` (the app wires it to the P2 vendor routes; tests import the same
4
+ // pinned packages directly). Failure latches to plain text after three
5
+ // attempts — the MAX_RESOURCE_FAILURES precedent of hljs-loader.mjs — never an
6
+ // endless retry. Code blocks are highlighted only on ask-done, with the same
7
+ // detached-staging commit rules as hdApplyHighlights (app.js:11235-11260):
8
+ // text must round-trip byte-for-byte and only class-carrying SPANs may appear.
9
+ import { SUPPORTED_LANGUAGE_IDS } from './syntax-highlight.mjs';
10
+
11
+ const ALLOWED_TAGS = ['p', 'br', 'strong', 'em', 'del', 'code', 'pre', 'ul', 'ol', 'li', 'a',
12
+ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'hr', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'input'];
13
+ const ALLOWED_ATTR = ['href', 'class', 'type', 'checked', 'disabled', 'align'];
14
+ const ALLOWED_URI_REGEXP = /^(?:https?:|mailto:|#)/i;
15
+ // DOMPurify tests EVERY non-URI-safe attribute VALUE against ALLOWED_URI_REGEXP;
16
+ // with the strict regexp above, type="checkbox" and align="right" would be
17
+ // dropped (only `class` is URI-safe by default) — the post-pass would then
18
+ // remove every checkbox. These two carry no URL, so marking them URI-safe
19
+ // restores them without loosening the href guard.
20
+ const ADD_URI_SAFE_ATTR = ['type', 'align'];
21
+ const CODE_CLASS_RE = /^language-[A-Za-z0-9_+-]{1,64}$/;
22
+ // hljs primary tokens plus the secondary `word_` scope tokens it appends — a
23
+ // per-token variant of the attribute-level regex at syntax-highlight.mjs:49
24
+ // (that one requires an hljs-* first token per attribute; this one is checked
25
+ // per classList ENTRY, so a `word_` token may stand alone — a deliberate,
26
+ // harmless superset: spans still carry classes only).
27
+ const HLJS_CLASS_RE = /^(?:hljs-[A-Za-z0-9_-]+|[A-Za-z0-9-]+_+)$/;
28
+ const PLAIN_LIMIT = 200_000;
29
+ const MAX_ATTEMPTS = 3;
30
+
31
+ export const LANGUAGE_ALIASES = Object.freeze({
32
+ js: 'javascript', mjs: 'javascript', cjs: 'javascript', jsx: 'javascript', node: 'javascript',
33
+ ts: 'typescript', tsx: 'typescript',
34
+ sh: 'bash', shell: 'bash', zsh: 'bash', console: 'bash',
35
+ yml: 'yaml', html: 'xml', htm: 'xml', svg: 'xml', vue: 'xml',
36
+ py: 'python', rb: 'ruby', md: 'markdown', golang: 'go', 'c++': 'cpp', cs: 'csharp', patch: 'diff', toml: 'ini',
37
+ });
38
+
39
+ const SUPPORTED = new Set(SUPPORTED_LANGUAGE_IDS);
40
+
41
+ export function createMarkdownRenderer({ doc, load, hljsLoader }) {
42
+ let mods = null;
43
+ let loading = null;
44
+ let attempts = 0;
45
+ let failed = false;
46
+
47
+ function ensure() {
48
+ if (mods) return Promise.resolve(true);
49
+ if (failed) return Promise.resolve(false);
50
+ if (!loading) {
51
+ loading = Promise.resolve()
52
+ .then(() => load())
53
+ .then((loaded) => {
54
+ const marked = loaded && loaded.marked;
55
+ const createDOMPurify = loaded && loaded.createDOMPurify;
56
+ const purifier = typeof createDOMPurify === 'function' ? createDOMPurify(doc.defaultView) : null;
57
+ if (!marked || typeof marked.parse !== 'function' || !purifier || typeof purifier.sanitize !== 'function') {
58
+ throw new Error('markdown modules have an unexpected shape');
59
+ }
60
+ mods = { marked, purifier };
61
+ return true;
62
+ })
63
+ .catch(() => {
64
+ attempts += 1;
65
+ loading = null;
66
+ if (attempts >= MAX_ATTEMPTS) failed = true;
67
+ return false;
68
+ });
69
+ }
70
+ return loading;
71
+ }
72
+
73
+ function render(text) {
74
+ const s = String(text ?? '');
75
+ if (!mods || failed || s.length > PLAIN_LIMIT) return { kind: 'plain' };
76
+ let html;
77
+ try {
78
+ html = mods.marked.parse(s, { gfm: true, breaks: true, async: false });
79
+ } catch {
80
+ return { kind: 'plain' };
81
+ }
82
+ let frag;
83
+ try {
84
+ frag = mods.purifier.sanitize(html, { ALLOWED_TAGS, ALLOWED_ATTR, ALLOWED_URI_REGEXP, ADD_URI_SAFE_ATTR, RETURN_DOM_FRAGMENT: true });
85
+ } catch {
86
+ return { kind: 'plain' };
87
+ }
88
+ for (const node of [...frag.querySelectorAll('[class]')]) {
89
+ const tag = node.tagName;
90
+ const keep = [...node.classList].filter((c) => (
91
+ tag === 'CODE' ? CODE_CLASS_RE.test(c) : tag === 'SPAN' ? HLJS_CLASS_RE.test(c) : false
92
+ ));
93
+ if (keep.length) node.setAttribute('class', keep.join(' '));
94
+ else node.removeAttribute('class');
95
+ }
96
+ for (const a of [...frag.querySelectorAll('a[href]')]) {
97
+ const href = a.getAttribute('href') || '';
98
+ if (/^(?:https?:|mailto:)/i.test(href)) {
99
+ a.setAttribute('target', '_blank');
100
+ a.setAttribute('rel', 'noopener noreferrer');
101
+ }
102
+ }
103
+ for (const input of [...frag.querySelectorAll('input')]) {
104
+ if ((input.getAttribute('type') || '').toLowerCase() !== 'checkbox') { input.remove(); continue; }
105
+ input.setAttribute('disabled', '');
106
+ }
107
+ return { kind: 'md', frag };
108
+ }
109
+
110
+ async function highlight(container) {
111
+ if (!container || !hljsLoader || failed) return;
112
+ for (const code of [...container.querySelectorAll('pre > code')]) {
113
+ const cls = [...code.classList].find((c) => c.startsWith('language-'));
114
+ if (!cls) continue;
115
+ const raw = cls.slice('language-'.length).toLowerCase();
116
+ const lang = SUPPORTED.has(raw) ? raw : LANGUAGE_ALIASES[raw];
117
+ if (!lang || !SUPPORTED.has(lang)) continue;
118
+ let binding = null;
119
+ try { binding = await hljsLoader.forLanguage(lang); } catch { binding = null; }
120
+ if (!binding) continue;
121
+ const source = code.textContent;
122
+ let html = '';
123
+ try { html = binding.highlight(source); } catch { continue; }
124
+ // hdApplyHighlights staging rules, verbatim: detached holder, byte-exact
125
+ // text, SPAN-only markup whose only attribute is a valid class.
126
+ const holder = doc.createElement('span');
127
+ holder.innerHTML = html;
128
+ if (holder.textContent !== source) continue;
129
+ const els = [...holder.querySelectorAll('*')];
130
+ const bad = els.some((el) => el.tagName !== 'SPAN'
131
+ || [...el.attributes].some((attr) => attr.name !== 'class')
132
+ || [...el.classList].some((c) => !HLJS_CLASS_RE.test(c)));
133
+ if (bad) continue;
134
+ code.replaceChildren(...holder.childNodes);
135
+ }
136
+ }
137
+
138
+ return Object.freeze({
139
+ ensure,
140
+ isReady: () => !!mods,
141
+ isFailed: () => failed,
142
+ render,
143
+ highlight,
144
+ });
145
+ }
@@ -0,0 +1,317 @@
1
+ // ui/public/ask-model.mjs — DOM-free thread model + ask-* frame reducer for the
2
+ // Ask Worca panel (spec §10.1). One instance per open thread; the panel swaps
3
+ // instances on thread switch. Everything lives in the factory closure — the
4
+ // module is evaluated once per test file even though app.js is re-imported with
5
+ // a cache-buster, so module scope must stay empty of state.
6
+ //
7
+ // Frame classes (spec §6.6 / the P2→P3 contract): job frames carry
8
+ // {threadId, messageId, seq} and are deduped by the per-job monotonic seq;
9
+ // out-of-turn frames (ask-message / ask-title / ask-run-status / ask-worktrees)
10
+ // upsert by their own key. A seq gap is REPORTED ({gap:true}), never healed here — the panel
11
+ // re-fetches the thread over REST and resubscribes (spec §10.8).
12
+
13
+ const TERMINAL = new Set(['done', 'stopped', 'error']);
14
+
15
+ export function createThreadModel({ threadId }) {
16
+ let thread = { id: threadId, title: null, model: null, effort: null, totals: {}, updatedAt: null };
17
+ let rows = [];
18
+ let attachments = [];
19
+ const links = new Map();
20
+ let live = null;
21
+ let inFlight = null;
22
+ let dirty = newDirty();
23
+ let worktrees = []; // P4 §10: the chat's open worktrees (snapshot + ask-worktrees frames); the panel mirrors it
24
+
25
+ function newDirty() {
26
+ // runLinks dirt is produced but not yet consumed — no v1 UI renders run links directly; the follower notices carry the visible state.
27
+ return { structure: false, messages: new Set(), blocks: new Map(), answer: new Set(), label: false, meters: false, title: false, runLinks: false, worktrees: false };
28
+ }
29
+
30
+ function rowById(id) {
31
+ for (const r of rows) if (r && r.id === id) return r;
32
+ return null;
33
+ }
34
+
35
+ // Agent blocks on the LIVE row, unique by id (upsertBlock replaces in place).
36
+ // Finished turns are already inside thread.totals.agents; ask-done replaces
37
+ // those totals and nulls `live` in ONE frame, so the two never overlap.
38
+ function liveAgentCount() {
39
+ const row = live ? rowById(live.messageId) : null;
40
+ if (!row || !Array.isArray(row.blocks)) return 0;
41
+ let n = 0;
42
+ for (const b of row.blocks) if (b && b.kind === 'agent') n += 1;
43
+ return n;
44
+ }
45
+
46
+ function upsertRow(message) {
47
+ const i = rows.findIndex((r) => r && r.id === message.id);
48
+ if (i >= 0) {
49
+ // The POST-side ask-message broadcast can beat the local echo (same id):
50
+ // an in-place replace must not wipe the store seq it delivered.
51
+ if (typeof message.seq !== 'number' && typeof rows[i].seq === 'number') {
52
+ message = { ...message, seq: rows[i].seq };
53
+ }
54
+ rows[i] = message;
55
+ } else {
56
+ // Sorted insert orders KNOWN seqs only. Seq-less rows are live-created
57
+ // (echo / streaming assistant) and therefore newest — a numeric row must
58
+ // never slot above them; a new row otherwise appends.
59
+ const seq = typeof message.seq === 'number' ? message.seq : Infinity;
60
+ const at = rows.findIndex((r) => typeof r.seq === 'number' && r.seq > seq);
61
+ if (at === -1) rows.push(message);
62
+ else rows.splice(at, 0, message);
63
+ }
64
+ dirty.structure = true;
65
+ }
66
+
67
+ // The thread's attachment ledger (attachmentsBytes → the composer's budget
68
+ // pre-check) is seeded by the snapshot; without this it would never learn of
69
+ // an upload made in this session, and the composer would let a whole over-
70
+ // budget base64 POST through to the server's 413. Keyed by the store id, so a
71
+ // row seen through both the broadcast and the local echo counts once.
72
+ function noteAttachmentBlocks(blocks) {
73
+ for (const b of Array.isArray(blocks) ? blocks : []) {
74
+ if (!b || b.kind !== 'attachment' || typeof b.id !== 'string') continue;
75
+ if (attachments.some((a) => a && a.id === b.id)) continue;
76
+ attachments.push({ id: b.id, name: b.name, bytes: Number.isFinite(b.bytes) ? b.bytes : 0, kind: b.attKind ?? 'text', mime: b.mime ?? null });
77
+ }
78
+ }
79
+
80
+ function markBlockDirty(messageId, blockId) {
81
+ if (!dirty.blocks.has(messageId)) dirty.blocks.set(messageId, new Set());
82
+ dirty.blocks.get(messageId).add(blockId);
83
+ }
84
+
85
+ function ensureStreamingRow(messageId, base = {}) {
86
+ let row = rowById(messageId);
87
+ if (!row) {
88
+ row = {
89
+ id: messageId, threadId, seq: undefined, role: 'assistant', text: '', blocks: [],
90
+ status: 'streaming', reason: null, model: base.model ?? null, effort: base.effort ?? null,
91
+ usage: null, costUsd: null, durationMs: null, createdAt: base.startedAt ?? null,
92
+ };
93
+ upsertRow(row);
94
+ }
95
+ if (!Array.isArray(row.blocks)) row.blocks = [];
96
+ return row;
97
+ }
98
+
99
+ function upsertBlock(row, block) {
100
+ const i = row.blocks.findIndex((b) => b && b.id === block.id);
101
+ if (i >= 0) row.blocks[i] = block;
102
+ else row.blocks.push(block);
103
+ }
104
+
105
+ function finalizeDone(row, frame) {
106
+ row.text = frame.text ?? '';
107
+ row.blocks = Array.isArray(frame.blocks) ? frame.blocks : row.blocks;
108
+ row.usage = frame.usage ?? null;
109
+ row.costUsd = frame.costUsd ?? null;
110
+ row.durationMs = frame.durationMs ?? null;
111
+ row.status = frame.status || 'done';
112
+ row.reason = frame.reason ?? null;
113
+ row.model = frame.model ?? row.model;
114
+ if (frame.threadTotals) { thread.totals = frame.threadTotals; }
115
+ live = null;
116
+ inFlight = null;
117
+ dirty.structure = true;
118
+ dirty.messages.add(row.id);
119
+ dirty.answer.add(row.id);
120
+ dirty.meters = true;
121
+ dirty.label = true;
122
+ }
123
+
124
+ function applyJobFrame(frame) {
125
+ const existing = rowById(frame.messageId);
126
+ if (existing && TERMINAL.has(existing.status)) return { dropped: 'terminal-message' };
127
+ if (live && frame.messageId === live.messageId) {
128
+ if (frame.type === 'ask-start' && live.adopted && frame.seq < live.lastSeq) {
129
+ // The subscribe replay (seq 1..N) landing AFTER a stray broadcast frame was
130
+ // adopted at a high seq: rewind to the replayed start so the prefix is
131
+ // applied instead of dropped as stale (review of PR #376 — the answer
132
+ // rendered blank and Send never flipped to Stop). Text restarts from '';
133
+ // the replay re-delivers every delta, and blocks upsert by id.
134
+ live.lastSeq = frame.seq; live.text = ''; live.adopted = false;
135
+ live.userMessageId = frame.userMessageId ?? live.userMessageId;
136
+ live.startedAt = frame.startedAt ?? live.startedAt;
137
+ live.label = 'Thinking';
138
+ dirty.label = true;
139
+ } else {
140
+ if (frame.seq <= live.lastSeq) return { dropped: 'stale-seq' };
141
+ if (frame.seq > live.lastSeq + 1) return { gap: true };
142
+ live.lastSeq = frame.seq;
143
+ }
144
+ } else if (frame.type === 'ask-start') {
145
+ ensureStreamingRow(frame.messageId, frame);
146
+ live = { messageId: frame.messageId, userMessageId: frame.userMessageId ?? null, label: 'Thinking', startedAt: frame.startedAt ?? null, lastSeq: frame.seq, text: '', usage: null, costUsd: null, estimatedCostUsd: null };
147
+ inFlight = { messageId: frame.messageId };
148
+ dirty.label = true;
149
+ } else if (!live && inFlight && frame.messageId === inFlight.messageId) {
150
+ // Adoption: the ring buffer may have evicted the prefix — accept the first
151
+ // frame at whatever seq it carries; ask-done.text heals the missing text.
152
+ ensureStreamingRow(frame.messageId);
153
+ live = { messageId: frame.messageId, userMessageId: null, label: null, startedAt: null, lastSeq: frame.seq, text: '', usage: null, costUsd: null, estimatedCostUsd: null, adopted: true };
154
+ } else {
155
+ return { dropped: 'no-live' };
156
+ }
157
+
158
+ const row = ensureStreamingRow(frame.messageId, frame);
159
+ switch (frame.type) {
160
+ case 'ask-start':
161
+ row.model = frame.model ?? row.model;
162
+ row.effort = frame.effort ?? row.effort;
163
+ break;
164
+ case 'ask-label':
165
+ live.label = frame.label;
166
+ dirty.label = true;
167
+ break;
168
+ case 'ask-delta':
169
+ live.text += String(frame.text ?? '');
170
+ dirty.answer.add(frame.messageId);
171
+ break;
172
+ case 'ask-block':
173
+ case 'ask-card':
174
+ if (frame.block && frame.block.id != null) {
175
+ upsertBlock(row, frame.block);
176
+ markBlockDirty(frame.messageId, frame.block.id);
177
+ if (frame.type === 'ask-block' && frame.block.kind === 'agent') dirty.meters = true; // "N agents" moves live
178
+ }
179
+ break;
180
+ case 'ask-usage':
181
+ live.usage = frame.usage ?? null;
182
+ live.costUsd = frame.costUsd ?? null;
183
+ live.estimatedCostUsd = Number.isFinite(frame.estimatedCostUsd) ? frame.estimatedCostUsd : null; // display-only
184
+ dirty.meters = true;
185
+ break;
186
+ case 'ask-done':
187
+ finalizeDone(row, frame);
188
+ break;
189
+ case 'ask-error':
190
+ row.text = live.text;
191
+ row.status = 'error';
192
+ row.errorMessage = frame.message || 'unknown error';
193
+ live = null;
194
+ inFlight = null;
195
+ dirty.structure = true;
196
+ dirty.messages.add(row.id);
197
+ dirty.answer.add(row.id);
198
+ dirty.label = true;
199
+ dirty.meters = true;
200
+ break;
201
+ default:
202
+ break; // unknown ask-* job frame: its seq is consumed, its payload ignored
203
+ }
204
+ return { ok: true };
205
+ }
206
+
207
+ function applyOutOfTurn(frame) {
208
+ switch (frame.type) {
209
+ case 'ask-title':
210
+ thread.title = frame.title ?? null;
211
+ dirty.title = true;
212
+ return { ok: true };
213
+ case 'ask-message': {
214
+ const m = frame.message;
215
+ if (!m || typeof m.id !== 'string') return { dropped: 'no-live' };
216
+ upsertRow(m);
217
+ noteAttachmentBlocks(m.blocks);
218
+ dirty.messages.add(m.id);
219
+ return { ok: true };
220
+ }
221
+ case 'ask-run-status': {
222
+ if (typeof frame.runId !== 'string') return { dropped: 'no-live' };
223
+ const cur = links.get(frame.runId) || { pipelineId: null, cardId: null, status: null, phase: null };
224
+ links.set(frame.runId, {
225
+ pipelineId: frame.pipelineId ?? cur.pipelineId,
226
+ cardId: frame.cardId ?? cur.cardId,
227
+ status: frame.status ?? cur.status,
228
+ phase: frame.phase ?? cur.phase,
229
+ });
230
+ dirty.runLinks = true;
231
+ return { ok: true };
232
+ }
233
+ case 'ask-worktrees':
234
+ if (!Array.isArray(frame.worktrees)) return { dropped: 'no-live' };
235
+ worktrees = frame.worktrees.slice();
236
+ dirty.worktrees = true;
237
+ return { ok: true };
238
+ default:
239
+ return { dropped: 'no-live' };
240
+ }
241
+ }
242
+
243
+ return Object.freeze({
244
+ threadId,
245
+ load(snapshot) {
246
+ thread = { ...snapshot.thread };
247
+ rows = Array.isArray(snapshot.messages) ? snapshot.messages.slice() : [];
248
+ attachments = Array.isArray(snapshot.attachments) ? snapshot.attachments.slice() : [];
249
+ links.clear();
250
+ for (const l of Array.isArray(snapshot.runLinks) ? snapshot.runLinks : []) {
251
+ links.set(l.runId, { pipelineId: l.pipelineId ?? null, cardId: l.cardId ?? null, status: l.status ?? null, phase: l.phase ?? null });
252
+ }
253
+ worktrees = Array.isArray(snapshot.worktrees) ? snapshot.worktrees.slice() : []; // the fresh-thread load carries no key
254
+ live = null;
255
+ inFlight = snapshot.inFlight ?? null;
256
+ dirty = newDirty();
257
+ dirty.structure = true;
258
+ dirty.title = true;
259
+ dirty.meters = true;
260
+ dirty.runLinks = true;
261
+ dirty.label = true;
262
+ dirty.worktrees = true;
263
+ },
264
+ apply(frame) {
265
+ if (!frame || frame.threadId !== threadId) return { dropped: 'other-thread' };
266
+ if (typeof frame.seq === 'number') return applyJobFrame(frame);
267
+ return applyOutOfTurn(frame);
268
+ },
269
+ takeDirty() {
270
+ const d = dirty;
271
+ dirty = newDirty();
272
+ return d;
273
+ },
274
+ messages() { return rows; },
275
+ thread() { return thread; },
276
+ totals() {
277
+ const base = thread.totals && typeof thread.totals === 'object' ? thread.totals : {};
278
+ const liveAgents = live ? liveAgentCount() : 0;
279
+ return {
280
+ ...base,
281
+ ...(liveAgents ? { agents: (Number.isFinite(base.agents) ? base.agents : 0) + liveAgents } : {}),
282
+ live: live ? { usage: live.usage, costUsd: live.costUsd, estimatedCostUsd: live.estimatedCostUsd } : null,
283
+ };
284
+ },
285
+ inFlight() { return inFlight; },
286
+ live() { return live; },
287
+ runLinks() { return links; },
288
+ worktrees() { return worktrees; },
289
+ setWorktrees(list) { // the panel's heal (refreshWorktrees) feeds the same store the frames do
290
+ worktrees = Array.isArray(list) ? list.slice() : [];
291
+ dirty.worktrees = true;
292
+ },
293
+ attachmentsBytes() { return attachments.reduce((n, a) => n + (a && Number.isFinite(a.bytes) ? a.bytes : 0), 0); },
294
+ findCard(cardId) {
295
+ for (const r of rows) {
296
+ const b = (r && Array.isArray(r.blocks) ? r.blocks : []).find((x) => x && x.kind === 'card' && x.id === cardId);
297
+ if (b) return { message: r, block: b };
298
+ }
299
+ return null;
300
+ },
301
+ noteLocalUserMessage({ id, text, attachments: atts }) {
302
+ // The POST-side ask-message broadcast can land BEFORE the 202 resolves. That
303
+ // row is the persisted one (seq, store-minted attachment ids); an echo that
304
+ // replaced it would turn an image thumbnail back into a name pill until the
305
+ // next reload. Nothing the echo carries is newer than it, so keep it.
306
+ if (rowById(id)) { dirty.messages.add(id); return; }
307
+ const blocks = (Array.isArray(atts) ? atts : []).map((a) => ({ kind: 'attachment', id: a.id ?? null, name: a.name, bytes: a.bytes, attKind: a.attKind ?? 'text', mime: a.mime ?? null }));
308
+ upsertRow({
309
+ id, threadId, seq: undefined, role: 'user', text: String(text ?? ''),
310
+ blocks,
311
+ status: null, reason: null, model: null, effort: null, usage: null, costUsd: null, durationMs: null, createdAt: null,
312
+ });
313
+ noteAttachmentBlocks(blocks);
314
+ dirty.messages.add(id);
315
+ },
316
+ });
317
+ }