@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,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,264 @@
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) upsert by their
10
+ // 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
+
24
+ function newDirty() {
25
+ // runLinks dirt is produced but not yet consumed — no v1 UI renders run links directly; the follower notices carry the visible state.
26
+ return { structure: false, messages: new Set(), blocks: new Map(), answer: new Set(), label: false, meters: false, title: false, runLinks: false };
27
+ }
28
+
29
+ function rowById(id) {
30
+ for (const r of rows) if (r && r.id === id) return r;
31
+ return null;
32
+ }
33
+
34
+ function upsertRow(message) {
35
+ const i = rows.findIndex((r) => r && r.id === message.id);
36
+ if (i >= 0) {
37
+ // The POST-side ask-message broadcast can beat the local echo (same id):
38
+ // an in-place replace must not wipe the store seq it delivered.
39
+ if (typeof message.seq !== 'number' && typeof rows[i].seq === 'number') {
40
+ message = { ...message, seq: rows[i].seq };
41
+ }
42
+ rows[i] = message;
43
+ } else {
44
+ // Sorted insert orders KNOWN seqs only. Seq-less rows are live-created
45
+ // (echo / streaming assistant) and therefore newest — a numeric row must
46
+ // never slot above them; a new row otherwise appends.
47
+ const seq = typeof message.seq === 'number' ? message.seq : Infinity;
48
+ const at = rows.findIndex((r) => typeof r.seq === 'number' && r.seq > seq);
49
+ if (at === -1) rows.push(message);
50
+ else rows.splice(at, 0, message);
51
+ }
52
+ dirty.structure = true;
53
+ }
54
+
55
+ function markBlockDirty(messageId, blockId) {
56
+ if (!dirty.blocks.has(messageId)) dirty.blocks.set(messageId, new Set());
57
+ dirty.blocks.get(messageId).add(blockId);
58
+ }
59
+
60
+ function ensureStreamingRow(messageId, base = {}) {
61
+ let row = rowById(messageId);
62
+ if (!row) {
63
+ row = {
64
+ id: messageId, threadId, seq: undefined, role: 'assistant', text: '', blocks: [],
65
+ status: 'streaming', reason: null, model: base.model ?? null, effort: base.effort ?? null,
66
+ usage: null, costUsd: null, durationMs: null, createdAt: base.startedAt ?? null,
67
+ };
68
+ upsertRow(row);
69
+ }
70
+ if (!Array.isArray(row.blocks)) row.blocks = [];
71
+ return row;
72
+ }
73
+
74
+ function upsertBlock(row, block) {
75
+ const i = row.blocks.findIndex((b) => b && b.id === block.id);
76
+ if (i >= 0) row.blocks[i] = block;
77
+ else row.blocks.push(block);
78
+ }
79
+
80
+ function finalizeDone(row, frame) {
81
+ row.text = frame.text ?? '';
82
+ row.blocks = Array.isArray(frame.blocks) ? frame.blocks : row.blocks;
83
+ row.usage = frame.usage ?? null;
84
+ row.costUsd = frame.costUsd ?? null;
85
+ row.durationMs = frame.durationMs ?? null;
86
+ row.status = frame.status || 'done';
87
+ row.reason = frame.reason ?? null;
88
+ row.model = frame.model ?? row.model;
89
+ if (frame.threadTotals) { thread.totals = frame.threadTotals; }
90
+ live = null;
91
+ inFlight = null;
92
+ dirty.structure = true;
93
+ dirty.messages.add(row.id);
94
+ dirty.answer.add(row.id);
95
+ dirty.meters = true;
96
+ dirty.label = true;
97
+ }
98
+
99
+ function applyJobFrame(frame) {
100
+ const existing = rowById(frame.messageId);
101
+ if (existing && TERMINAL.has(existing.status)) return { dropped: 'terminal-message' };
102
+ if (live && frame.messageId === live.messageId) {
103
+ if (frame.type === 'ask-start' && live.adopted && frame.seq < live.lastSeq) {
104
+ // The subscribe replay (seq 1..N) landing AFTER a stray broadcast frame was
105
+ // adopted at a high seq: rewind to the replayed start so the prefix is
106
+ // applied instead of dropped as stale (review of PR #376 — the answer
107
+ // rendered blank and Send never flipped to Stop). Text restarts from '';
108
+ // the replay re-delivers every delta, and blocks upsert by id.
109
+ live.lastSeq = frame.seq; live.text = ''; live.adopted = false;
110
+ live.userMessageId = frame.userMessageId ?? live.userMessageId;
111
+ live.startedAt = frame.startedAt ?? live.startedAt;
112
+ live.label = 'Thinking';
113
+ dirty.label = true;
114
+ } else {
115
+ if (frame.seq <= live.lastSeq) return { dropped: 'stale-seq' };
116
+ if (frame.seq > live.lastSeq + 1) return { gap: true };
117
+ live.lastSeq = frame.seq;
118
+ }
119
+ } else if (frame.type === 'ask-start') {
120
+ ensureStreamingRow(frame.messageId, frame);
121
+ live = { messageId: frame.messageId, userMessageId: frame.userMessageId ?? null, label: 'Thinking', startedAt: frame.startedAt ?? null, lastSeq: frame.seq, text: '', usage: null, costUsd: null };
122
+ inFlight = { messageId: frame.messageId };
123
+ dirty.label = true;
124
+ } else if (!live && inFlight && frame.messageId === inFlight.messageId) {
125
+ // Adoption: the ring buffer may have evicted the prefix — accept the first
126
+ // frame at whatever seq it carries; ask-done.text heals the missing text.
127
+ ensureStreamingRow(frame.messageId);
128
+ live = { messageId: frame.messageId, userMessageId: null, label: null, startedAt: null, lastSeq: frame.seq, text: '', usage: null, costUsd: null, adopted: true };
129
+ } else {
130
+ return { dropped: 'no-live' };
131
+ }
132
+
133
+ const row = ensureStreamingRow(frame.messageId, frame);
134
+ switch (frame.type) {
135
+ case 'ask-start':
136
+ row.model = frame.model ?? row.model;
137
+ row.effort = frame.effort ?? row.effort;
138
+ break;
139
+ case 'ask-label':
140
+ live.label = frame.label;
141
+ dirty.label = true;
142
+ break;
143
+ case 'ask-delta':
144
+ live.text += String(frame.text ?? '');
145
+ dirty.answer.add(frame.messageId);
146
+ break;
147
+ case 'ask-block':
148
+ case 'ask-card':
149
+ if (frame.block && frame.block.id != null) {
150
+ upsertBlock(row, frame.block);
151
+ markBlockDirty(frame.messageId, frame.block.id);
152
+ }
153
+ break;
154
+ case 'ask-usage':
155
+ live.usage = frame.usage ?? null;
156
+ live.costUsd = frame.costUsd ?? null;
157
+ dirty.meters = true;
158
+ break;
159
+ case 'ask-done':
160
+ finalizeDone(row, frame);
161
+ break;
162
+ case 'ask-error':
163
+ row.text = live.text;
164
+ row.status = 'error';
165
+ row.errorMessage = frame.message || 'unknown error';
166
+ live = null;
167
+ inFlight = null;
168
+ dirty.structure = true;
169
+ dirty.messages.add(row.id);
170
+ dirty.answer.add(row.id);
171
+ dirty.label = true;
172
+ dirty.meters = true;
173
+ break;
174
+ default:
175
+ break; // unknown ask-* job frame: its seq is consumed, its payload ignored
176
+ }
177
+ return { ok: true };
178
+ }
179
+
180
+ function applyOutOfTurn(frame) {
181
+ switch (frame.type) {
182
+ case 'ask-title':
183
+ thread.title = frame.title ?? null;
184
+ dirty.title = true;
185
+ return { ok: true };
186
+ case 'ask-message': {
187
+ const m = frame.message;
188
+ if (!m || typeof m.id !== 'string') return { dropped: 'no-live' };
189
+ upsertRow(m);
190
+ dirty.messages.add(m.id);
191
+ return { ok: true };
192
+ }
193
+ case 'ask-run-status': {
194
+ if (typeof frame.runId !== 'string') return { dropped: 'no-live' };
195
+ const cur = links.get(frame.runId) || { pipelineId: null, cardId: null, status: null, phase: null };
196
+ links.set(frame.runId, {
197
+ pipelineId: frame.pipelineId ?? cur.pipelineId,
198
+ cardId: frame.cardId ?? cur.cardId,
199
+ status: frame.status ?? cur.status,
200
+ phase: frame.phase ?? cur.phase,
201
+ });
202
+ dirty.runLinks = true;
203
+ return { ok: true };
204
+ }
205
+ default:
206
+ return { dropped: 'no-live' };
207
+ }
208
+ }
209
+
210
+ return Object.freeze({
211
+ threadId,
212
+ load(snapshot) {
213
+ thread = { ...snapshot.thread };
214
+ rows = Array.isArray(snapshot.messages) ? snapshot.messages.slice() : [];
215
+ attachments = Array.isArray(snapshot.attachments) ? snapshot.attachments.slice() : [];
216
+ links.clear();
217
+ for (const l of Array.isArray(snapshot.runLinks) ? snapshot.runLinks : []) {
218
+ links.set(l.runId, { pipelineId: l.pipelineId ?? null, cardId: l.cardId ?? null, status: l.status ?? null, phase: l.phase ?? null });
219
+ }
220
+ live = null;
221
+ inFlight = snapshot.inFlight ?? null;
222
+ dirty = newDirty();
223
+ dirty.structure = true;
224
+ dirty.title = true;
225
+ dirty.meters = true;
226
+ dirty.runLinks = true;
227
+ dirty.label = true;
228
+ },
229
+ apply(frame) {
230
+ if (!frame || frame.threadId !== threadId) return { dropped: 'other-thread' };
231
+ if (typeof frame.seq === 'number') return applyJobFrame(frame);
232
+ return applyOutOfTurn(frame);
233
+ },
234
+ takeDirty() {
235
+ const d = dirty;
236
+ dirty = newDirty();
237
+ return d;
238
+ },
239
+ messages() { return rows; },
240
+ thread() { return thread; },
241
+ totals() {
242
+ return { ...thread.totals, live: live ? { usage: live.usage, costUsd: live.costUsd } : null };
243
+ },
244
+ inFlight() { return inFlight; },
245
+ live() { return live; },
246
+ runLinks() { return links; },
247
+ attachmentsBytes() { return attachments.reduce((n, a) => n + (a && Number.isFinite(a.bytes) ? a.bytes : 0), 0); },
248
+ findCard(cardId) {
249
+ for (const r of rows) {
250
+ const b = (r && Array.isArray(r.blocks) ? r.blocks : []).find((x) => x && x.kind === 'card' && x.id === cardId);
251
+ if (b) return { message: r, block: b };
252
+ }
253
+ return null;
254
+ },
255
+ noteLocalUserMessage({ id, text, attachments: atts }) {
256
+ upsertRow({
257
+ id, threadId, seq: undefined, role: 'user', text: String(text ?? ''),
258
+ blocks: (Array.isArray(atts) ? atts : []).map((a) => ({ kind: 'attachment', id: a.id ?? null, name: a.name, bytes: a.bytes })),
259
+ status: null, reason: null, model: null, effort: null, usage: null, costUsd: null, durationMs: null, createdAt: null,
260
+ });
261
+ dirty.messages.add(id);
262
+ },
263
+ });
264
+ }