@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,889 @@
1
+ // ui/public/graph/composer.mjs
2
+ // The v2 composer editor. It owns the POINTER PIPELINE and nothing else about
3
+ // rendering: view.mjs paints, this file classifies and mutates.
4
+ //
5
+ // The four PR #359 defects are structurally excluded here:
6
+ // · listeners live on the STAGE with pointer capture, never on `document`
7
+ // · onMove stores a point and schedules ONE rAF — no DOM, no hit-test, no validate
8
+ // · a node drag re-routes the dirty region only and writes `d` for the wires
9
+ // whose route actually moved, through the view's d-cache
10
+ // · pointercancel / lostpointercapture / window blur / Escape all cancel, and
11
+ // finish() releases capture
12
+ // Depth 3 ⇒ the shared core is three `..` up.
13
+ import { createGraphView } from './view.mjs';
14
+ import { renderPalette, applyFilter, FLOW_GROUP } from './palette.mjs';
15
+ import { renderNodeInspector, renderWireInspector, renderEmptyInspector } from './inspector.mjs';
16
+ import { renderSaveDialog, openDialog, closeDialog } from './save-dialog.mjs';
17
+ import { PORT_HIT_R, SNAP, ZOOM_MIN, ZOOM_MAX, ZOOM_K, NODE_W, snap }
18
+ from '../../../src/shared/graph/geometry.mjs';
19
+ import { hitRoute } from '../../../src/shared/graph/route.mjs';
20
+ import { canWire, newNode, newWire, normalizeTemplate, serializeTemplate }
21
+ from '../../../src/shared/graph/template.mjs';
22
+ import { validateGraph } from '../../../src/shared/graph/validate.mjs';
23
+ import { autoLayout } from '../../../src/shared/graph/layout.mjs';
24
+
25
+ export const UNDO_LIMIT = 50;
26
+ /** The ONE reserved workflow id: the server prepends this built-in to
27
+ * GET /api/workflows and refuses DELETE on it (ui/server.mjs). Exported HERE so
28
+ * the composer and app.js share one literal (the saved list hides the ×, the
29
+ * Save dialog prefills a copy name). */
30
+ export const RESERVED_WORKFLOW_ID = 'wf_default';
31
+ export const INSPECTOR_KEY = 'worca.composer.inspector';
32
+ export const TAB_KEY = 'worca.composer.tab';
33
+ export const TABS = Object.freeze(['agents', 'info']);
34
+ /** px of canvas hidden under the floating inspector rail (§7.6 constants). */
35
+ export const INSET_OPEN = 340;
36
+ export const INSET_COLLAPSED = 28;
37
+
38
+ const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
39
+ /** 'plugin:demo-plug' -> 'demo-plug'; anything else -> ''. `origin` is a row
40
+ * column the server's rowToTpl projects and normalizeTemplate drops, so the
41
+ * composer keeps it beside the template. */
42
+ export const pluginOriginName = (origin) => (typeof origin === 'string' && origin.startsWith('plugin:')
43
+ ? origin.slice('plugin:'.length) : '');
44
+ const isTyping = (t) => !!t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT' || t.isContentEditable);
45
+
46
+ export function createComposer(hostEls, { doc = globalThis.document, api, raf = null, viewport = null, storage = null, portsFn } = {}) {
47
+ const win = doc.defaultView || globalThis;
48
+ const schedule = raf || ((fn) => win.requestAnimationFrame(fn));
49
+ const stats = { rectReads: 0, frames: 0, pointerMoves: 0, validations: 0 };
50
+
51
+ let tpl = emptyCanvas();
52
+ let tplOrigin = ''; // 'plugin:<name>' of the LOADED row ('' = user-created)
53
+ let sel = null; // {kind:'node'|'wire', id}
54
+ let dirty = false;
55
+ let savedHash = '';
56
+ let gesture = null;
57
+ let pend = null;
58
+ let rafId = 0;
59
+ let space = false;
60
+ let validateTimer = null;
61
+ let lastReport = { ok: true, errors: [], warnings: [] };
62
+ const undoStack = [];
63
+ const redoStack = [];
64
+ let agents = {}; // key -> meta (palette + header tint)
65
+
66
+ const view = createGraphView(hostEls.canvas, {
67
+ doc, mode: 'edit', portsFn, agents, viewport,
68
+ zoomMin: ZOOM_MIN, zoomMax: ZOOM_MAX, wheelPan: 'always',
69
+ });
70
+ const stage = view.stage;
71
+
72
+ function emptyCanvas() {
73
+ // A fresh canvas preloads the two bookends V20/V21 require.
74
+ return normalizeTemplate({
75
+ id: '', name: '', version: 2, domain: '',
76
+ nodes: [newNode('task', null, 60, 200), newNode('end', null, 960, 200)],
77
+ wires: [],
78
+ });
79
+ }
80
+
81
+ const nodeById = (id) => tpl.nodes.find((n) => n.id === id) || null;
82
+ const wireById = (id) => tpl.wires.find((w) => w.id === id) || null;
83
+
84
+ // -------------------------------------------------------------- rect cache
85
+ let R = { left: 0, top: 0, width: 0, height: 0 };
86
+ function readRect() {
87
+ R = viewport ? { ...viewport() } : (() => {
88
+ const b = stage.getBoundingClientRect();
89
+ return { left: b.left, top: b.top, width: b.width, height: b.height };
90
+ })();
91
+ stats.rectReads += 1;
92
+ return R;
93
+ }
94
+ const T = () => view.getTransform();
95
+ const toWorld = (cx, cy) => { const t = T(); return { x: (cx - R.left - t.x) / t.z, y: (cy - R.top - t.y) / t.z }; };
96
+ const toScreen = (wx, wy) => { const t = T(); return { x: wx * t.z + t.x, y: wy * t.z + t.y }; };
97
+
98
+ // ------------------------------------------------------------- hit testing
99
+ // Model-driven, in world coordinates. Never elementFromPoint, never a rect
100
+ // read, never a per-path listener.
101
+ function allPortsOf(node) {
102
+ const p = view.ports(node);
103
+ const out = [];
104
+ for (const q of p.inputs) out.push({ node, port: q.id, dir: 'in', type: q.type, synthetic: Boolean(q.synthetic) });
105
+ for (const q of p.outputs) out.push({ node, port: q.id, dir: 'out', type: q.type });
106
+ return out;
107
+ }
108
+ function hitPortAt(pt, exclude) {
109
+ let best = null;
110
+ let bd = PORT_HIT_R;
111
+ for (const node of tpl.nodes) {
112
+ for (const q of allPortsOf(node)) {
113
+ if (exclude && exclude.node.id === node.id && exclude.port === q.port && exclude.dir === q.dir) continue;
114
+ const a = view.anchor(node, q.port, q.dir);
115
+ const d = Math.hypot(pt.x - a.x, pt.y - a.y);
116
+ if (d <= bd) { bd = d; best = { ...q, anchor: a }; }
117
+ }
118
+ }
119
+ return best;
120
+ }
121
+ function hitNodeAt(pt) {
122
+ for (let i = tpl.nodes.length - 1; i >= 0; i -= 1) {
123
+ const n = tpl.nodes[i];
124
+ const s = view.size(n);
125
+ if (pt.x >= n.x && pt.x <= n.x + s.w && pt.y >= n.y && pt.y <= n.y + s.h) return n;
126
+ }
127
+ return null;
128
+ }
129
+ /** The hit test reads the EXACT painted polyline (view.wireRoute) and measures
130
+ * point-to-SEGMENT per leg, so there is nothing to re-derive and no second
131
+ * geometry to keep in sync: hit and paint cannot diverge. The private copy
132
+ * this replaced compared the click to 49 SAMPLED POINTS, which left up to
133
+ * 37.7% of a shipped wire's drawn line dead (MAJ-18). The painted corners are
134
+ * rounded by ≤2.35px relative to the sharp polyline — well inside the 6px
135
+ * tolerance. */
136
+ function hitWireAt(pt) {
137
+ for (const w of tpl.wires) {
138
+ const pts = view.wireRoute(w.id);
139
+ if (pts && hitRoute(pts, pt)) return w; // dangling endpoint is never a target
140
+ }
141
+ return null;
142
+ }
143
+
144
+ // ------------------------------------------------------------ mutate/render
145
+ const snapshot = () => JSON.stringify({ nodes: tpl.nodes, wires: tpl.wires });
146
+ function pushUndo() {
147
+ undoStack.push(snapshot());
148
+ if (undoStack.length > UNDO_LIMIT) undoStack.shift();
149
+ redoStack.length = 0;
150
+ }
151
+ /** The ONE mutation gate: undo → mutate → dirty → targeted render → one
152
+ * deferred validate. Nothing on the pointer path may call render/validate. */
153
+ let metaDirty = false; // name/domain edits: not part of the structural hash
154
+ function commit(label, mutate) {
155
+ pushUndo();
156
+ mutate();
157
+ dirty = metaDirty || snapshot() !== savedHash;
158
+ render();
159
+ scheduleValidate();
160
+ }
161
+ function scheduleValidate() {
162
+ if (validateTimer) return; // collapsed to one run
163
+ validateTimer = setTimeout(() => {
164
+ validateTimer = null;
165
+ lastReport = validateGraph(tpl, portsFn);
166
+ stats.validations += 1;
167
+ view.render(tpl, { selection: sel, report: lastReport });
168
+ paintChrome();
169
+ }, 0);
170
+ }
171
+ function render() {
172
+ view.render(tpl, { selection: sel, report: lastReport });
173
+ paintChrome();
174
+ paintPalette();
175
+ paintInspector();
176
+ if (hooks.onRender) hooks.onRender();
177
+ }
178
+ function paintChrome() {
179
+ const n = lastReport.errors.length;
180
+ if (hostEls.saveBtn) hostEls.saveBtn.disabled = n > 0 || !ready;
181
+ if (hostEls.errors) {
182
+ hostEls.errors.hidden = n === 0;
183
+ hostEls.errors.textContent = n ? `${n} error${n === 1 ? '' : 's'}` : '';
184
+ }
185
+ }
186
+
187
+ // ------------------------------------------------------------------- ghost
188
+ function showChip(text, pt) {
189
+ if (!hostEls.chip) return;
190
+ const s = toScreen(pt.x, pt.y); // world→screen math, never a rect read
191
+ hostEls.chip.textContent = text;
192
+ hostEls.chip.style.left = `${s.x + 14}px`;
193
+ hostEls.chip.style.top = `${s.y + 14}px`;
194
+ hostEls.chip.hidden = false;
195
+ }
196
+ const hideChip = () => { if (hostEls.chip) hostEls.chip.hidden = true; };
197
+ function markDropRow(target, ok) {
198
+ clearDropRows();
199
+ if (!target) return;
200
+ const card = view.nodeEl(target.node.id);
201
+ const row = card && card.querySelector(`.prow[data-port="${target.port}"][data-dir="${target.dir}"]`);
202
+ if (row) row.classList.add(ok ? 'drop-ok' : 'drop-bad');
203
+ }
204
+ function clearDropRows() {
205
+ for (const row of view.world.querySelectorAll('.prow.drop-ok, .prow.drop-bad')) row.classList.remove('drop-ok', 'drop-bad');
206
+ }
207
+ /** Wire legality for the LIVE drag: a Map lookup + a type check. Never
208
+ * classifyLoops, never validateGraph. Reasons come from canWire verbatim. */
209
+ function legality(origin, target) {
210
+ if (!target || origin.dir === target.dir) return null;
211
+ const out = origin.dir === 'out' ? origin : target;
212
+ const inp = origin.dir === 'in' ? origin : target;
213
+ const v = canWire({
214
+ tpl, portsFn,
215
+ from: { node: out.node.id, port: out.port },
216
+ to: { node: inp.node.id, port: inp.port },
217
+ });
218
+ return { ...v, from: { node: out.node.id, port: out.port }, to: { node: inp.node.id, port: inp.port } };
219
+ }
220
+
221
+ // ---------------------------------------------------------------- pipeline
222
+ function onDown(ev) {
223
+ if (gesture || (ev.button !== 0 && ev.button !== 1)) return;
224
+ readRect(); // the ONE rect read of this gesture
225
+ const pt = toWorld(ev.clientX, ev.clientY);
226
+ let g = null;
227
+ if (ev.button === 1 || space) g = { type: 'pan' };
228
+ else {
229
+ const port = hitPortAt(pt);
230
+ const node = port ? null : hitNodeAt(pt);
231
+ if (port) {
232
+ g = { type: 'wire', origin: port, anchor: port.anchor, mirror: port.dir === 'in' };
233
+ view.setGhost(view.routeGhost(g.anchor, pt, { mirror: g.mirror }), '');
234
+ } else if (node) {
235
+ g = { type: 'node', id: node.id, grab: { dx: pt.x - node.x, dy: pt.y - node.y }, start: { x: node.x, y: node.y }, moved: false };
236
+ select({ kind: 'node', id: node.id });
237
+ view.nodeEl(node.id).classList.add('dragging');
238
+ } else {
239
+ const wire = hitWireAt(pt);
240
+ if (wire) { select({ kind: 'wire', id: wire.id }); g = { type: 'idle' }; }
241
+ else { select(null); g = { type: 'pan' }; }
242
+ }
243
+ }
244
+ if (g.type === 'pan') { const t = T(); Object.assign(g, { sx: ev.clientX, sy: ev.clientY, ox: t.x, oy: t.y }); stage.classList.add('panning'); }
245
+ g.pointerId = ev.pointerId;
246
+ gesture = g;
247
+ // Chrome throws NotFoundError for a SYNTHETIC pointerId it does not own and
248
+ // jsdom has no such method: capture is a bonus, never a precondition.
249
+ try { stage.setPointerCapture?.(ev.pointerId); } catch { /* synthetic pointer */ }
250
+ ev.preventDefault();
251
+ }
252
+
253
+ function onMove(ev) {
254
+ if (!gesture) return;
255
+ stats.pointerMoves += 1;
256
+ pend = { x: ev.clientX, y: ev.clientY };
257
+ if (!rafId) rafId = schedule(frame);
258
+ }
259
+
260
+ function frame() {
261
+ rafId = 0;
262
+ const g = gesture;
263
+ const p = pend;
264
+ if (!g || !p) return;
265
+ stats.frames += 1;
266
+ if (g.type === 'pan') {
267
+ view.setTransform({ x: g.ox + (p.x - g.sx), y: g.oy + (p.y - g.sy), z: T().z });
268
+ return;
269
+ }
270
+ if (g.type === 'idle') return;
271
+ const pt = toWorld(p.x, p.y);
272
+ if (g.type === 'node') {
273
+ const n = nodeById(g.id);
274
+ const nx = snap(pt.x - g.grab.dx);
275
+ const ny = snap(pt.y - g.grab.dy);
276
+ if (!n || (nx === n.x && ny === n.y)) return;
277
+ n.x = nx; n.y = ny; g.moved = true;
278
+ view.moveNode(n.id); // dirty-filtered re-route, d-cache gated
279
+ return;
280
+ }
281
+ const target = hitPortAt(pt, g.origin);
282
+ const v = legality(g.origin, target);
283
+ const end = v && v.ok ? target.anchor : pt; // snap to the legal anchor
284
+ view.setGhost(view.routeGhost(g.anchor, end, { mirror: g.mirror }), v ? (v.ok ? 'legal' : 'illegal') : '');
285
+ markDropRow(target, Boolean(v && v.ok));
286
+ if (v && !v.ok) showChip(v.reason || v.code, pt); else hideChip();
287
+ }
288
+
289
+ function finish(g) {
290
+ gesture = null; pend = null;
291
+ if (rafId) { rafId = 0; }
292
+ view.setGhost(null); hideChip(); clearDropRows();
293
+ stage.classList.remove('panning');
294
+ if (g && g.type === 'node') view.nodeEl(g.id)?.classList.remove('dragging');
295
+ try { if (stage.hasPointerCapture?.(g && g.pointerId)) stage.releasePointerCapture(g.pointerId); } catch { /* already gone */ }
296
+ }
297
+
298
+ function onUp(ev) {
299
+ const g = gesture;
300
+ if (!g || ev.pointerId !== g.pointerId) return;
301
+ pend = { x: ev.clientX, y: ev.clientY };
302
+ rafId = 0;
303
+ frame(); // settle the last position
304
+ if (g.type === 'wire') {
305
+ const pt = toWorld(ev.clientX, ev.clientY);
306
+ const v = legality(g.origin, hitPortAt(pt, g.origin));
307
+ finish(g);
308
+ if (v && v.ok) commit('wire', () => { tpl.wires.push(newWire(v.from, v.to)); });
309
+ return;
310
+ }
311
+ if (g.type === 'node' && g.moved) {
312
+ const n = nodeById(g.id);
313
+ const to = { x: n.x, y: n.y };
314
+ n.x = g.start.x; n.y = g.start.y; // rewind so ONE undo entry covers the drag
315
+ finish(g);
316
+ commit('move', () => { n.x = to.x; n.y = to.y; });
317
+ return;
318
+ }
319
+ finish(g);
320
+ }
321
+
322
+ function cancel() {
323
+ const g = gesture;
324
+ if (!g) return;
325
+ if (g.type === 'node' && g.moved) {
326
+ const n = nodeById(g.id);
327
+ // rerouteAll is the D15 canonical pass: cancel never renders, so without it
328
+ // the drag's dirty-filtered routes could outlive the gesture. Paint only —
329
+ // no commit, no undo entry, no capture op.
330
+ if (n) { n.x = g.start.x; n.y = g.start.y; view.moveNode(n.id); view.rerouteAll(); }
331
+ }
332
+ finish(g);
333
+ }
334
+
335
+ let ready = true; // false while /api/agents is in flight (app.js sets it)
336
+ const hooks = {}; // onRender, set by app.js
337
+
338
+ function select(next) {
339
+ sel = next;
340
+ view.setSelection(sel);
341
+ paintInspector();
342
+ if (hooks.onSelect) hooks.onSelect(sel);
343
+ }
344
+
345
+ const onCancelEv = () => cancel();
346
+ const onLost = () => { if (gesture) cancel(); };
347
+ const onBlur = () => { space = false; stage.classList.remove('space'); cancel(); };
348
+ const onRefresh = () => readRect();
349
+ const onNewCanvas = () => composer.newCanvas();
350
+ const onSaveClick = () => openSaveDialog();
351
+
352
+ function zoomAbout(zNext, sx, sy) {
353
+ const t = T();
354
+ const z2 = clamp(zNext, ZOOM_MIN, ZOOM_MAX);
355
+ view.setTransform({ x: sx - ((sx - t.x) / t.z) * z2, y: sy - ((sy - t.y) / t.z) * z2, z: z2 });
356
+ }
357
+
358
+ function onWheel(ev) {
359
+ ev.preventDefault(); // the page never scrolls under the canvas
360
+ const m = ev.deltaMode === 1 ? 16 : ev.deltaMode === 2 ? (R.height || 560) : 1;
361
+ const dX = ev.deltaX * m;
362
+ const dY = ev.deltaY * m;
363
+ if (ev.ctrlKey || ev.metaKey) { // trackpad pinch sets ctrlKey
364
+ zoomAbout(T().z * Math.exp(-dY * ZOOM_K), ev.clientX - R.left, ev.clientY - R.top);
365
+ return;
366
+ }
367
+ const t = T();
368
+ view.setTransform({ x: t.x - dX, y: t.y - dY, z: t.z });
369
+ }
370
+
371
+ /** px of canvas hidden under the floating rail — the fit band's right inset. */
372
+ function insetRight() {
373
+ return hostEls.insRail && hostEls.insRail.dataset.open === 'collapsed' ? INSET_COLLAPSED : INSET_OPEN;
374
+ }
375
+ function fit(opts = {}) {
376
+ view.fit({ insetRight: opts.insetRight == null ? insetRight() : opts.insetRight, pad: 60 });
377
+ }
378
+ // `autoLayout(tpl, portsFn)` returns a POSITION MAP `{ [nodeId]: {x, y} }` — not
379
+ // a template, not `{nodes}` (verified 2026-08-27 against src/shared/graph/layout.mjs,
380
+ // whose own JSDoc says "the caller applies them").
381
+ function runAutoLayout() {
382
+ commit('auto-layout', () => {
383
+ const pos = autoLayout(tpl, portsFn);
384
+ for (const n of tpl.nodes) {
385
+ const p = pos[n.id];
386
+ if (p) { n.x = p.x; n.y = p.y; }
387
+ }
388
+ });
389
+ }
390
+ function deleteSelection() {
391
+ if (!sel) return;
392
+ if (sel.kind === 'wire') { const id = sel.id; commit('delete wire', () => { tpl.wires = tpl.wires.filter((w) => w.id !== id); }); }
393
+ else { const id = sel.id; commit('delete node', () => {
394
+ tpl.nodes = tpl.nodes.filter((n) => n.id !== id);
395
+ tpl.wires = tpl.wires.filter((w) => w.from.node !== id && w.to.node !== id);
396
+ }); }
397
+ select(null);
398
+ }
399
+ function nudge(dx, dy) {
400
+ if (!sel || sel.kind !== 'node') return;
401
+ const n = nodeById(sel.id);
402
+ if (!n) return;
403
+ commit('nudge', () => { n.x = snap(n.x + dx); n.y = snap(n.y + dy); });
404
+ }
405
+ function restore(json) {
406
+ const st = JSON.parse(json);
407
+ tpl.nodes = st.nodes; tpl.wires = st.wires;
408
+ if (sel && ((sel.kind === 'node' && !nodeById(sel.id)) || (sel.kind === 'wire' && !wireById(sel.id)))) sel = null;
409
+ dirty = metaDirty || snapshot() !== savedHash;
410
+ render();
411
+ scheduleValidate();
412
+ }
413
+ function undo() { if (!undoStack.length) return; redoStack.push(snapshot()); restore(undoStack.pop()); }
414
+ function redo() { if (!redoStack.length) return; undoStack.push(snapshot()); restore(redoStack.pop()); }
415
+
416
+ /** True while ANY modal owns the keyboard (MAJ-19). Two selectors, because no
417
+ * single one covers the two modal shapes this document carries:
418
+ * · the composer's own <dialog> — `open` is set by showModal() (spec: the
419
+ * attribute is reflected) AND by save-dialog.mjs's jsdom fallback. Its
420
+ * Cancel/Save buttons are focusable NON-inputs, which `isTyping`
421
+ * deliberately does not cover, and showModal's inertness does not stop the
422
+ * dialog's own keydown from bubbling to this document listener;
423
+ * · the app's overlays — plain <div role="dialog" aria-modal="true"> that only
424
+ * toggle the `hidden` CLASS (index.html:1247 #confirm-modal and four
425
+ * siblings), which `dialog[open]` misses entirely; confirmModal auto-focuses
426
+ * their <button> (app.js:6973), so a Backspace aimed at a Delete-pipeline
427
+ * confirm would otherwise delete the selected NODE behind it. The Ask Worca
428
+ * sheet carries role="dialog" WITHOUT aria-modal and is deliberately NOT
429
+ * matched: it is a docked, non-modal panel the canvas is used alongside.
430
+ * The `:not(.hidden):not([hidden])` pair is load-bearing: without it the arm
431
+ * is permanently true (every overlay is in the DOM from page load) and the
432
+ * canvas keyboard dies for good. */
433
+ function modalUp() {
434
+ if (doc.querySelector('dialog[open]')) return true;
435
+ // `aria-modal`, not `role="dialog"`: the Ask Worca sheet (ask-panel.mjs:206) is a
436
+ // role="dialog" DOCKED panel that stays open while the canvas is used — it must
437
+ // never own the canvas keyboard. Every app overlay declares aria-modal="true".
438
+ return Boolean(doc.querySelector('[aria-modal="true"]:not(.hidden):not([hidden])'));
439
+ }
440
+
441
+ function onKeyDown(ev) {
442
+ if (modalUp()) return; // a modal owns the keyboard — never the canvas
443
+ if (isTyping(ev.target)) return; // guard FIRST — before space, before anything
444
+ if (ev.key === ' ') { if (!space) { space = true; stage.classList.add('space'); } ev.preventDefault(); return; }
445
+ if (ev.key === 'Escape') {
446
+ if (gesture) cancel();
447
+ else if (hostEls.filter && hostEls.filter.value) { hostEls.filter.value = ''; onFilterInput(); }
448
+ else select(null);
449
+ return;
450
+ }
451
+ if (ev.key === 'Delete' || ev.key === 'Backspace') { ev.preventDefault(); deleteSelection(); return; }
452
+ if (ev.key === 'ArrowLeft') { ev.preventDefault(); nudge(-SNAP, 0); return; }
453
+ if (ev.key === 'ArrowRight') { ev.preventDefault(); nudge(SNAP, 0); return; }
454
+ if (ev.key === 'ArrowUp') { ev.preventDefault(); nudge(0, -SNAP); return; }
455
+ if (ev.key === 'ArrowDown') { ev.preventDefault(); nudge(0, SNAP); return; }
456
+ if ((ev.metaKey || ev.ctrlKey) && (ev.key === 'z' || ev.key === 'Z')) { ev.preventDefault(); if (ev.shiftKey) redo(); else undo(); }
457
+ }
458
+ function onKeyUp(ev) { if (ev.key === ' ') { space = false; stage.classList.remove('space'); } }
459
+
460
+ const collapsed = new Set();
461
+ let query = '';
462
+
463
+ function paintPalette() {
464
+ renderPalette(hostEls.palette, {
465
+ agents: Object.values(agents), placedKinds: tpl.nodes.map((n) => n.kind), collapsed, query, doc,
466
+ });
467
+ }
468
+ /** Diagonal de-stacker: 24 tries, SNAP*2 per try so each attempt lands one
469
+ * dot-grid cell down-right. Not a general overlap avoider — it exists so a
470
+ * run of spawns does not stack. */
471
+ function freeSlot(p) {
472
+ let { x, y } = p;
473
+ for (let i = 0; i < 24; i += 1) {
474
+ if (!tpl.nodes.some((n) => n.x === snap(x) && n.y === snap(y))) break;
475
+ x += SNAP * 2; y += SNAP * 2;
476
+ }
477
+ return { x, y };
478
+ }
479
+ function centerWorld() {
480
+ readRect();
481
+ const c = toWorld(R.left + (R.width - insetRight()) / 2, R.top + R.height / 2);
482
+ return { x: c.x - NODE_W / 2, y: c.y - 60 };
483
+ }
484
+ function spawn(entry, at) {
485
+ const p = at || freeSlot(centerWorld());
486
+ let node = null;
487
+ commit('add', () => {
488
+ node = entry.kind
489
+ ? newNode(entry.kind, null, snap(p.x), snap(p.y))
490
+ : newNode('agent', entry.key, snap(p.x), snap(p.y));
491
+ if (entry.kind === 'and' || entry.kind === 'or' || entry.kind === 'combine') node.config.arity = 2;
492
+ tpl.nodes.push(node);
493
+ });
494
+ select({ kind: 'node', id: node.id });
495
+ return node;
496
+ }
497
+
498
+ // ---- drag-to-spawn: 4px threshold, fixed-position ghost, drop must land
499
+ // inside the cached stage rect MINUS the inspector inset.
500
+ let drag = null;
501
+ function onPalDown(ev) {
502
+ const btn = ev.target.closest && ev.target.closest('.ap');
503
+ if (!btn || btn.disabled || ev.button !== 0) return;
504
+ drag = { entry: btn.dataset.kind ? { kind: btn.dataset.kind } : { key: btn.dataset.key },
505
+ sx: ev.clientX, sy: ev.clientY, id: ev.pointerId, label: btn.querySelector('.n').textContent, ghost: null, btn };
506
+ try { btn.setPointerCapture?.(ev.pointerId); } catch { /* synthetic */ }
507
+ doc.addEventListener('pointermove', onPalMove);
508
+ doc.addEventListener('pointerup', onPalUp);
509
+ doc.addEventListener('pointercancel', onPalCancel);
510
+ }
511
+ function onPalMove(ev) {
512
+ if (!drag) return;
513
+ if (!drag.ghost && Math.hypot(ev.clientX - drag.sx, ev.clientY - drag.sy) < 4) return;
514
+ if (!drag.ghost) {
515
+ drag.ghost = doc.createElement('div');
516
+ drag.ghost.className = 'gv-drag-ghost';
517
+ drag.ghost.textContent = drag.label;
518
+ doc.body.appendChild(drag.ghost);
519
+ readRect();
520
+ }
521
+ drag.ghost.style.left = `${ev.clientX + 8}px`;
522
+ drag.ghost.style.top = `${ev.clientY + 8}px`;
523
+ }
524
+ function onPalUp(ev) {
525
+ const d = drag;
526
+ if (!d) return;
527
+ const dragged = Boolean(d.ghost);
528
+ endPalDrag();
529
+ if (!dragged) { spawn(d.entry); return; } // < 4px is a click
530
+ const inBand = ev.clientX >= R.left && ev.clientX <= R.left + R.width - insetRight()
531
+ && ev.clientY >= R.top && ev.clientY <= R.top + R.height;
532
+ if (!inBand) return;
533
+ const p = toWorld(ev.clientX, ev.clientY);
534
+ spawn(d.entry, { x: snap(p.x - NODE_W / 2), y: snap(p.y - 20) });
535
+ }
536
+ const onPalCancel = () => endPalDrag();
537
+ function endPalDrag() {
538
+ if (drag && drag.ghost) drag.ghost.remove();
539
+ // The stage releases its capture in finish(); the pill releases here, on every
540
+ // exit (up, cancel, destroy) — browsers release implicitly after pointerup,
541
+ // so this is a no-op there and matters only for destroy() mid-drag.
542
+ try { if (drag && drag.btn.hasPointerCapture?.(drag.id)) drag.btn.releasePointerCapture(drag.id); } catch { /* already gone */ }
543
+ drag = null;
544
+ doc.removeEventListener('pointermove', onPalMove);
545
+ doc.removeEventListener('pointerup', onPalUp);
546
+ doc.removeEventListener('pointercancel', onPalCancel);
547
+ }
548
+ function onPalClick(ev) {
549
+ const head = ev.target.closest && ev.target.closest('.pal-grp');
550
+ if (!head) return;
551
+ const d = head.dataset.domain;
552
+ if (collapsed.has(d)) collapsed.delete(d); else collapsed.add(d);
553
+ paintPalette();
554
+ }
555
+ const onFilterInput = () => { query = hostEls.filter ? hostEls.filter.value : ''; applyFilter(hostEls.palette, query, collapsed); };
556
+
557
+ let models = [];
558
+ let efforts = [];
559
+ let subagentModels = [];
560
+ let modelsSet = false; // an explicit setModels() wins over the mount fetch
561
+ const readKey = (k) => { try { return storage ? storage.getItem(k) : null; } catch { return null; } };
562
+ const writeKey = (k, v) => { try { if (storage) storage.setItem(k, v); } catch { /* private mode */ } };
563
+
564
+ function applyModels({ models: m = [], efforts: e = [], subagentModels: sm = [] } = {}) {
565
+ models = Array.isArray(m) ? m : [];
566
+ efforts = Array.isArray(e) ? e : [];
567
+ subagentModels = Array.isArray(sm) ? sm : [];
568
+ paintInspector();
569
+ }
570
+
571
+ function paintInspector() {
572
+ const hostBody = hostEls.insBody;
573
+ if (!hostBody) return;
574
+ if (!sel) return void hostBody.replaceChildren(renderEmptyInspector({ doc }));
575
+ if (sel.kind === 'node') {
576
+ const node = nodeById(sel.id);
577
+ if (!node) return void hostBody.replaceChildren(renderEmptyInspector({ doc }));
578
+ const meta = node.kind === 'agent' ? (agents[node.key] || null) : null;
579
+ return void hostBody.replaceChildren(renderNodeInspector(node, { template: tpl, portsFn, meta, models, efforts, subagentModels, doc }));
580
+ }
581
+ const wire = wireById(sel.id);
582
+ if (!wire) return void hostBody.replaceChildren(renderEmptyInspector({ doc }));
583
+ hostBody.replaceChildren(renderWireInspector(wire, { loop: view.isLoopWire(wire.id), doc }));
584
+ }
585
+
586
+ function setRail(open, { persist = false } = {}) {
587
+ if (!hostEls.insRail) return;
588
+ hostEls.insRail.dataset.open = open ? 'open' : 'collapsed';
589
+ if (hostEls.insToggle) {
590
+ hostEls.insToggle.setAttribute('aria-expanded', open ? 'true' : 'false');
591
+ hostEls.insToggle.setAttribute('aria-label', open ? 'Collapse inspector' : 'Expand inspector');
592
+ }
593
+ if (persist) writeKey(INSPECTOR_KEY, open ? 'open' : 'collapsed');
594
+ }
595
+ const onRailToggle = () => setRail(hostEls.insRail.dataset.open === 'collapsed', { persist: true });
596
+
597
+ /** The rail's two panes. Selection NEVER moves the tab (D2): a canvas click
598
+ * repaints Info silently, so placing a run of agents is never interrupted. */
599
+ function setTab(name, { persist = false } = {}) {
600
+ if (!hostEls.insRail) return;
601
+ const tab = TABS.includes(name) ? name : TABS[0];
602
+ hostEls.insRail.dataset.tab = tab;
603
+ if (hostEls.insTabs) {
604
+ for (const btn of hostEls.insTabs.querySelectorAll('[data-tab]')) {
605
+ btn.setAttribute('aria-selected', btn.dataset.tab === tab ? 'true' : 'false');
606
+ btn.tabIndex = btn.dataset.tab === tab ? 0 : -1;
607
+ }
608
+ }
609
+ if (persist) writeKey(TAB_KEY, tab);
610
+ }
611
+ function onTabClick(ev) {
612
+ const btn = ev.target.closest && ev.target.closest('[data-tab]');
613
+ if (!btn) return;
614
+ setTab(btn.dataset.tab, { persist: true });
615
+ }
616
+
617
+ function onInspectorChange(ev) {
618
+ const name = ev.target.dataset && ev.target.dataset.field;
619
+ if (!name || !sel) return;
620
+ if (sel.kind === 'wire') {
621
+ const wire = wireById(sel.id);
622
+ if (!wire || name !== 'maxCycles') return;
623
+ const n = Number.parseInt(ev.target.value, 10);
624
+ commit('maxCycles', () => {
625
+ wire.config = { ...(wire.config || {}) };
626
+ if (Number.isInteger(n) && n >= 1) wire.config.maxCycles = n; else delete wire.config.maxCycles;
627
+ if (!Object.keys(wire.config).length) delete wire.config;
628
+ });
629
+ return;
630
+ }
631
+ const node = nodeById(sel.id);
632
+ if (!node) return;
633
+ commit(name, () => {
634
+ if (name === 'arity') {
635
+ const n = Number.parseInt(ev.target.value, 10);
636
+ node.config.arity = Number.isInteger(n) && n >= 2 ? n : 2; // V12 floor
637
+ } else if (ev.target.type === 'checkbox') {
638
+ if (ev.target.checked) node.config[name] = true; else delete node.config[name];
639
+ } else if (ev.target.value === '') {
640
+ delete node.config[name];
641
+ } else {
642
+ node.config[name] = ev.target.value;
643
+ }
644
+ });
645
+ paintInspector(); // re-read the committed value
646
+ }
647
+
648
+ let dialog = null;
649
+ let savedDomains = [];
650
+
651
+ /** Two loaded rows may never be saved IN PLACE, so Save on them IS Save-a-copy:
652
+ * · the built-in Default — its id is reserved and its name re-mints it, so an
653
+ * in-place save writes a row nothing can list, read or delete (C-3);
654
+ * · a plugin-owned row — `worca plugin update` overwrites name/domain/graph
655
+ * unconditionally, so the edits are erased with no prompt (MAJ-4).
656
+ * @returns {{copy: boolean, plugin: string}} */
657
+ function saveMode(saveAs) {
658
+ const plugin = pluginOriginName(tplOrigin);
659
+ return { copy: Boolean(saveAs) || tpl.id === RESERVED_WORKFLOW_ID || Boolean(plugin), plugin };
660
+ }
661
+
662
+ function openSaveDialog({ saveAs = false } = {}) {
663
+ if (!hostEls.dialogHost) return null;
664
+ if (dialog) dialog.remove();
665
+ const mode = saveMode(saveAs);
666
+ dialog = renderSaveDialog({
667
+ name: mode.copy ? `${tpl.name || 'Untitled'} copy` : (tpl.name || ''),
668
+ domain: tpl.domain || '', domains: savedDomains,
669
+ title: mode.copy ? 'Save a copy' : 'Save pipeline',
670
+ note: mode.plugin
671
+ ? `This pipeline belongs to plugin "${mode.plugin}" and is replaced on plugin update — saving creates your own copy.`
672
+ : '',
673
+ doc,
674
+ });
675
+ dialog.dataset.saveAs = mode.copy ? '1' : '';
676
+ hostEls.dialogHost.replaceChildren(dialog);
677
+ dialog.querySelector('.sd-cancel').addEventListener('click', () => closeDialog(dialog));
678
+ dialog.querySelector('.sd-confirm').addEventListener('click', () => { confirmSave(); });
679
+ openDialog(dialog);
680
+ return dialog;
681
+ }
682
+
683
+ async function confirmSave() {
684
+ if (!dialog) return;
685
+ const msg = dialog.querySelector('.sd-msg');
686
+ const name = dialog.querySelector('.sd-name').value.trim();
687
+ msg.className = 'sd-msg';
688
+ if (!name) { msg.textContent = 'name is required'; msg.className = 'sd-msg err'; return; }
689
+ const domain = dialog.querySelector('.sd-domain').value.trim();
690
+ const saveAs = dialog.dataset.saveAs === '1';
691
+ const body = { ...serializeTemplate(tpl), version: 2, name, domain };
692
+ // Save on a LOADED row sends its id; a copy omits it so the server mints
693
+ // wf_${slugify(name)}. `dataset.saveAs` already carries saveMode()'s verdict,
694
+ // so the reserved built-in and every plugin-owned row land here as copies.
695
+ if (!saveAs && tpl.id && tpl.id !== RESERVED_WORKFLOW_ID) body.id = tpl.id;
696
+ else delete body.id;
697
+ try {
698
+ const res = await api.saveWorkflow(body);
699
+ if (res && res.ok === false) {
700
+ // 422 = the shared validator's issues; render them VERBATIM.
701
+ const issues = Array.isArray(res.issues) ? res.issues : [];
702
+ msg.textContent = issues.length
703
+ ? issues.map((i) => (i.code ? `${i.code}: ${i.message}` : i.message)).join('\n')
704
+ : (res.error || `save failed (${res.status || 'error'})`);
705
+ msg.className = 'sd-msg err';
706
+ return;
707
+ }
708
+ composer.markSaved(res && res.workflow && res.workflow.id, name, domain);
709
+ closeDialog(dialog);
710
+ if (hooks.onSaved) hooks.onSaved(res && res.workflow);
711
+ render();
712
+ } catch (err) {
713
+ msg.textContent = err && err.message ? err.message : String(err);
714
+ msg.className = 'sd-msg err';
715
+ }
716
+ }
717
+
718
+ function firstErrorNode() {
719
+ const e = (lastReport.errors || []).find((x) => x.nodeId);
720
+ return e ? e.nodeId : null;
721
+ }
722
+ const onErrChip = () => { const id = firstErrorNode(); if (id) { select({ kind: 'node', id }); view.centerOn(id); } };
723
+ const onWorldClick = (ev) => {
724
+ const pip = ev.target.closest && ev.target.closest('.npip');
725
+ if (!pip) return;
726
+ ev.stopPropagation();
727
+ const id = pip.dataset.nodeId;
728
+ if (id) { select({ kind: 'node', id }); view.centerOn(id); }
729
+ };
730
+
731
+ let live = false;
732
+ function resume() {
733
+ if (live) return;
734
+ live = true;
735
+ doc.addEventListener('keydown', onKeyDown);
736
+ doc.addEventListener('keyup', onKeyUp);
737
+ doc.addEventListener('scroll', onRefresh, { capture: true, passive: true });
738
+ win.addEventListener('blur', onBlur);
739
+ win.addEventListener('resize', onRefresh);
740
+ }
741
+ function suspend() {
742
+ if (!live) return;
743
+ live = false;
744
+ cancel();
745
+ space = false; stage.classList.remove('space');
746
+ doc.removeEventListener('keydown', onKeyDown);
747
+ doc.removeEventListener('keyup', onKeyUp);
748
+ doc.removeEventListener('scroll', onRefresh, { capture: true });
749
+ win.removeEventListener('blur', onBlur);
750
+ win.removeEventListener('resize', onRefresh);
751
+ }
752
+
753
+ function mount() {
754
+ stage.addEventListener('pointerdown', onDown);
755
+ stage.addEventListener('wheel', onWheel, { passive: false });
756
+ resume(); // doc/window listeners live here
757
+ hostEls.autoBtn?.addEventListener('click', runAutoLayout);
758
+ hostEls.newBtn?.addEventListener('click', onNewCanvas);
759
+ hostEls.errors?.addEventListener('click', onErrChip);
760
+ view.world.addEventListener('click', onWorldClick);
761
+ hostEls.palette?.addEventListener('pointerdown', onPalDown);
762
+ hostEls.palette?.addEventListener('click', onPalClick);
763
+ hostEls.filter?.addEventListener('input', onFilterInput);
764
+ hostEls.insBody?.addEventListener('change', onInspectorChange);
765
+ hostEls.insToggle?.addEventListener('click', onRailToggle);
766
+ hostEls.insTabs?.addEventListener('click', onTabClick);
767
+ hostEls.saveBtn?.addEventListener('click', onSaveClick);
768
+ setRail(readKey(INSPECTOR_KEY) !== 'collapsed');
769
+ setTab(readKey(TAB_KEY) || TABS[0]);
770
+ // The model/effort lists are chrome, not graph state: pull them once through
771
+ // the injected api so the inspector's selects are usable the moment the rail
772
+ // opens. app.js may still call setModels() explicitly; that wins.
773
+ if (api && typeof api.config === 'function') {
774
+ Promise.resolve(api.config()).then((cfg) => { if (!modelsSet && cfg) applyModels(cfg); }).catch(() => {});
775
+ }
776
+ stage.addEventListener('pointermove', onMove);
777
+ stage.addEventListener('pointerup', onUp);
778
+ stage.addEventListener('pointercancel', onCancelEv);
779
+ stage.addEventListener('lostpointercapture', onLost);
780
+ if (typeof win.ResizeObserver === 'function') {
781
+ ro = new win.ResizeObserver(onRefresh);
782
+ ro.observe(stage);
783
+ }
784
+ readRect();
785
+ return composer;
786
+ }
787
+ let ro = null;
788
+
789
+ function destroy() {
790
+ suspend();
791
+ stage.removeEventListener('pointerdown', onDown);
792
+ stage.removeEventListener('wheel', onWheel);
793
+ hostEls.autoBtn?.removeEventListener('click', runAutoLayout);
794
+ hostEls.newBtn?.removeEventListener('click', onNewCanvas);
795
+ hostEls.errors?.removeEventListener('click', onErrChip);
796
+ view.world.removeEventListener('click', onWorldClick);
797
+ hostEls.palette?.removeEventListener('pointerdown', onPalDown);
798
+ hostEls.palette?.removeEventListener('click', onPalClick);
799
+ hostEls.filter?.removeEventListener('input', onFilterInput);
800
+ hostEls.insBody?.removeEventListener('change', onInspectorChange);
801
+ hostEls.insToggle?.removeEventListener('click', onRailToggle);
802
+ hostEls.insTabs?.removeEventListener('click', onTabClick);
803
+ hostEls.saveBtn?.removeEventListener('click', onSaveClick);
804
+ endPalDrag();
805
+ stage.removeEventListener('pointermove', onMove);
806
+ stage.removeEventListener('pointerup', onUp);
807
+ stage.removeEventListener('pointercancel', onCancelEv);
808
+ stage.removeEventListener('lostpointercapture', onLost);
809
+ if (ro) { ro.disconnect(); ro = null; }
810
+ if (validateTimer) { clearTimeout(validateTimer); validateTimer = null; }
811
+ }
812
+
813
+ /** The unsaved-work gate for the two entry points that REPLACE the canvas and
814
+ * wipe the undo ring, so the work is unrecoverable (MAJ-6). `confirmDiscard`
815
+ * is an injected hook — app.js installs its confirmModal — and an ABSENT hook
816
+ * PROCEEDS, which is what keeps every headless caller (unit tests, the CDP
817
+ * probe) behaving exactly as it did. A hook that throws counts as "no":
818
+ * losing the answer must never lose the graph.
819
+ * @returns {Promise<boolean>} true to go ahead. */
820
+ async function guardDiscard() {
821
+ if (!dirty) return true;
822
+ const ask = hooks.confirmDiscard;
823
+ if (typeof ask !== 'function') return true;
824
+ try { return Boolean(await ask()); } catch { return false; }
825
+ }
826
+
827
+ /** Load a saved template (or `null` for a fresh canvas). Resets undo + dirty.
828
+ * SYNCHRONOUS and UNGUARDED on purpose: this is the programmatic model API
829
+ * (tests, the CDP probe, initComposer's first paint). Every path a USER can
830
+ * click goes through newCanvas()/openTemplate(), which ask first. */
831
+ function loadTemplate(next) {
832
+ tpl = next ? normalizeTemplate(next) : emptyCanvas();
833
+ tplOrigin = (next && typeof next.origin === 'string') ? next.origin : '';
834
+ sel = null;
835
+ undoStack.length = 0; redoStack.length = 0;
836
+ savedHash = snapshot();
837
+ metaDirty = false;
838
+ dirty = false;
839
+ lastReport = validateGraph(tpl, portsFn);
840
+ if (hostEls.name) hostEls.name.value = tpl.name || '';
841
+ render();
842
+ return tpl;
843
+ }
844
+
845
+ const composer = {
846
+ view, stats, hooks,
847
+ mount, destroy, resume, suspend, commit, loadTemplate,
848
+ fit, autoLayout: runAutoLayout, zoomAbout, undo, redo, undoDepth: () => undoStack.length, deleteSelection,
849
+ spawn, paintPalette, paintInspector,
850
+ openSaveDialog, setSavedDomains(list) { savedDomains = list || []; },
851
+ setModels(cfg) { modelsSet = true; applyModels(cfg || {}); },
852
+ /** The header's New-canvas button. Asks before discarding (MAJ-6).
853
+ * @returns {Promise<object|null>} the new template, or null when refused. */
854
+ newCanvas: async () => ((await guardDiscard()) ? loadTemplate(null) : null),
855
+ /** The saved list's Open. Asks before discarding (MAJ-6).
856
+ * @returns {Promise<object|null>} the loaded template, or null when refused. */
857
+ openTemplate: async (next) => ((await guardDiscard()) ? loadTemplate(next) : null),
858
+ template: () => tpl,
859
+ serialize: () => serializeTemplate(tpl),
860
+ report: () => lastReport,
861
+ selection: () => (sel ? { ...sel } : null),
862
+ select,
863
+ gesture: () => gesture,
864
+ isDirty: () => dirty,
865
+ setReady(v) { ready = v; paintChrome(); },
866
+ // A registry reload (MAJ-16) must re-validate the OPEN canvas: a wire into a
867
+ // port the Agents view just deleted is an error now, not at the next edit.
868
+ setAgents(map) { agents = map || {}; view.setAgents(agents); if (tpl.nodes.length) scheduleValidate(); },
869
+ /** Rename: an unsaved edit, so it sets `dirty` — it never clears it. */
870
+ setName(name) { tpl.name = String(name || ''); metaDirty = true; dirty = true; paintChrome(); },
871
+ /** 'plugin:<name>' of the loaded row, '' when user-created. */
872
+ origin: () => tplOrigin,
873
+ markSaved(id, name, domain) {
874
+ // What was just written is a row of the USER's, whatever the source row
875
+ // was: a copy of a plugin template is not itself plugin-owned.
876
+ if (id && id !== tpl.id) tplOrigin = '';
877
+ if (id) tpl.id = id;
878
+ if (name != null) tpl.name = name;
879
+ if (name != null && hostEls.name) hostEls.name.value = name; // the header follows the saved name (a forced copy renames)
880
+ if (domain != null) tpl.domain = domain;
881
+ savedHash = snapshot();
882
+ metaDirty = false;
883
+ dirty = false;
884
+ paintChrome();
885
+ },
886
+ _internal: { readRect, toWorld, toScreen, hitPortAt, hitNodeAt, hitWireAt, nodeById, wireById, pushUndo, undoStack, redoStack, scheduleValidate, setSpace(v) { space = v; }, isSpace: () => space, getR: () => ({ ...R }) },
887
+ };
888
+ return composer;
889
+ }