pmx-canvas 0.4.6 → 0.4.8

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 (76) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/Readme.md +56 -33
  3. package/dist/canvas/global.css +101 -3
  4. package/dist/canvas/index.js +93 -89
  5. package/dist/types/cli/shared.d.ts +13 -1
  6. package/dist/types/client/canvas/CanvasNode.d.ts +11 -0
  7. package/dist/types/client/canvas/EdgeLayer.d.ts +8 -0
  8. package/dist/types/client/nodes/AxStepControls.d.ts +4 -0
  9. package/dist/types/client/nodes/ExtAppFrame.d.ts +32 -0
  10. package/dist/types/client/nodes/FileNode.d.ts +14 -0
  11. package/dist/types/client/state/canvas-store.d.ts +10 -0
  12. package/dist/types/client/state/intent-bridge.d.ts +9 -0
  13. package/dist/types/server/ax-flow-loop.d.ts +5 -0
  14. package/dist/types/server/ax-interaction.d.ts +21 -2
  15. package/dist/types/server/canvas-db.d.ts +14 -0
  16. package/dist/types/server/canvas-operations.d.ts +5 -0
  17. package/dist/types/server/canvas-state.d.ts +8 -0
  18. package/dist/types/server/canvas-validation.d.ts +15 -21
  19. package/dist/types/server/file-content.d.ts +46 -0
  20. package/dist/types/server/html-primitives.d.ts +13 -1
  21. package/dist/types/server/operations/ops/ax-work.d.ts +1 -0
  22. package/dist/types/server/server.d.ts +1 -0
  23. package/dist/types/server/web-artifacts.d.ts +14 -0
  24. package/dist/types/shared/ax-flow.d.ts +51 -0
  25. package/dist/types/shared/node-sizes.d.ts +33 -0
  26. package/docs/RELEASE.md +48 -0
  27. package/docs/cli.md +19 -2
  28. package/docs/environment.md +1 -0
  29. package/docs/http-api.md +17 -0
  30. package/docs/mcp.md +12 -0
  31. package/docs/node-types.md +45 -1
  32. package/docs/screenshot.png +0 -0
  33. package/package.json +2 -2
  34. package/skills/pmx-canvas/SKILL.md +74 -9
  35. package/skills/pmx-canvas/evals/evals.json +54 -3
  36. package/skills/pmx-canvas/references/full-reference.md +11 -6
  37. package/skills/pmx-canvas-testing/SKILL.md +40 -0
  38. package/src/cli/commands/history.ts +1 -1
  39. package/src/cli/commands/nodes.ts +2 -2
  40. package/src/cli/commands/query.ts +1 -1
  41. package/src/cli/commands/view.ts +1 -1
  42. package/src/cli/index.ts +6 -0
  43. package/src/cli/shared.ts +34 -5
  44. package/src/client/App.tsx +28 -15
  45. package/src/client/canvas/CanvasNode.tsx +54 -5
  46. package/src/client/canvas/EdgeLayer.tsx +44 -24
  47. package/src/client/canvas/ExpandedNodeOverlay.tsx +11 -0
  48. package/src/client/canvas/auto-fit.ts +10 -1
  49. package/src/client/canvas/use-pan-zoom.ts +16 -1
  50. package/src/client/nodes/AxStepControls.tsx +226 -0
  51. package/src/client/nodes/ExtAppFrame.tsx +79 -2
  52. package/src/client/nodes/FileNode.tsx +249 -51
  53. package/src/client/state/canvas-store.ts +19 -0
  54. package/src/client/state/intent-bridge.ts +32 -0
  55. package/src/client/state/sse-bridge.ts +4 -1
  56. package/src/client/theme/global.css +101 -3
  57. package/src/server/ax-flow-loop.ts +114 -0
  58. package/src/server/ax-interaction.ts +299 -4
  59. package/src/server/canvas-db.ts +138 -32
  60. package/src/server/canvas-operations.ts +44 -17
  61. package/src/server/canvas-state.ts +20 -10
  62. package/src/server/canvas-validation.ts +50 -41
  63. package/src/server/demo-state.json +4605 -523
  64. package/src/server/demo.ts +24 -0
  65. package/src/server/file-content.ts +152 -0
  66. package/src/server/file-watcher.ts +7 -10
  67. package/src/server/html-primitives.ts +636 -0
  68. package/src/server/operations/ops/ax-work.ts +5 -0
  69. package/src/server/operations/ops/nodes.ts +15 -1
  70. package/src/server/operations/ops/viewport.ts +7 -1
  71. package/src/server/server.ts +106 -5
  72. package/src/server/web-artifacts.ts +109 -0
  73. package/src/shared/ax-flow.ts +102 -0
  74. package/src/shared/node-sizes.ts +52 -0
  75. package/dist/types/server/chart-template.d.ts +0 -29
  76. package/src/server/chart-template.ts +0 -448
@@ -1,3 +1,5 @@
1
+ import type { AxInteractionType } from './ax-interaction.js';
2
+
1
3
  export const HTML_PRIMITIVE_KINDS = [
2
4
  'choice-grid',
3
5
  'plan-timeline',
@@ -18,6 +20,8 @@ export const HTML_PRIMITIVE_KINDS = [
18
20
  'triage-board',
19
21
  'config-editor',
20
22
  'prompt-tuner',
23
+ 'ax-board',
24
+ 'ax-flow',
21
25
  ] as const;
22
26
 
23
27
  export type HtmlPrimitiveKind = (typeof HTML_PRIMITIVE_KINDS)[number];
@@ -30,6 +34,14 @@ export interface HtmlPrimitiveDescriptor {
30
34
  defaultSize: { width: number; height: number };
31
35
  dataShape: string;
32
36
  example: Record<string, unknown>;
37
+ /**
38
+ * AX capabilities this kind REQUIRES to function. `html` is AX-opt-in, so a
39
+ * primitive that emits interactions is inert without them — node creation
40
+ * applies this to `data.axCapabilities` (an explicit caller value still wins,
41
+ * and the server clamps both to the `html` type ceiling). Only set it on kinds
42
+ * that actually emit; every other kind stays AX-free.
43
+ */
44
+ axCapabilities?: { enabled: true; allowed: AxInteractionType[] };
33
45
  }
34
46
 
35
47
  export interface HtmlPrimitiveInput {
@@ -429,6 +441,53 @@ const DESCRIPTORS: HtmlPrimitiveDescriptor[] = [
429
441
  },
430
442
  },
431
443
  },
444
+ {
445
+ kind: 'ax-board',
446
+ title: 'AX Board',
447
+ description: 'Live agent work board: hand the agent tasks, watch status, steer, and run bounded task loops.',
448
+ useWhen: 'Use when the human needs a control surface over the agent queue rather than a static write-up.',
449
+ defaultSize: { width: 1000, height: 800 },
450
+ dataShape: '{ note?: string, defaultRuns?: number }',
451
+ example: {
452
+ kind: 'ax-board',
453
+ title: 'Agent Board',
454
+ data: { note: 'Tasks added here land in the canvas AX work queue.', defaultRuns: 3 },
455
+ },
456
+ // The only AX-emitting primitive: it creates and advances work items, steers,
457
+ // and records loop lifecycle events.
458
+ axCapabilities: {
459
+ enabled: true,
460
+ allowed: ['ax.work.create', 'ax.work.update', 'ax.steer', 'ax.event.record'],
461
+ },
462
+ },
463
+ {
464
+ kind: 'ax-flow',
465
+ title: 'AX Flow',
466
+ description:
467
+ 'Live task-flow control panel: a step diagram with an explicit loop-back, per-step status, and a one-click materialize onto the board.',
468
+ useWhen:
469
+ 'Use when the work is an ordered sequence (or a repeating loop) the human wants to watch and drive step by step.',
470
+ defaultSize: { width: 1040, height: 880 },
471
+ dataShape: '{ note?: string, steps: [{ title, detail? }], loop?: { enabled?: boolean, maxRuns?: number } }',
472
+ example: {
473
+ kind: 'ax-flow',
474
+ title: 'Review Loop',
475
+ data: {
476
+ steps: [
477
+ { title: 'Read the diff', detail: 'Load every changed file before judging any of them.' },
478
+ { title: 'Run the gates', detail: 'typecheck, unit tests, lint.' },
479
+ { title: 'Write the review', detail: 'Findings with severity and a fix for each.' },
480
+ ],
481
+ loop: { enabled: true, maxRuns: 3 },
482
+ },
483
+ },
484
+ // Same control surface as ax-board, plus the narrowly-scoped materializer that
485
+ // turns the drawn flow into real canvas nodes and edges.
486
+ axCapabilities: {
487
+ enabled: true,
488
+ allowed: ['ax.work.create', 'ax.work.update', 'ax.steer', 'ax.event.record', 'ax.flow.materialize'],
489
+ },
490
+ },
432
491
  ];
433
492
 
434
493
  function isRecord(value: unknown): value is Record<string, unknown> {
@@ -1470,6 +1529,581 @@ renderPreviews();`,
1470
1529
  });
1471
1530
  }
1472
1531
 
1532
+ /**
1533
+ * Client-side helpers shared by the AX control surfaces (`ax-board`, `ax-flow`).
1534
+ * No DOM contract of its own: `flash` takes the element id to write into.
1535
+ */
1536
+ const AX_SURFACE_PRELUDE = `
1537
+ const AX_STATUSES = ['todo', 'in-progress', 'blocked', 'done', 'cancelled'];
1538
+ const AX_MAX_RUNS = 20;
1539
+
1540
+ const el = (id) => document.getElementById(id);
1541
+ const axFlashTimers = {};
1542
+ function flash(id, message) {
1543
+ const target = el(id);
1544
+ if (!target) return;
1545
+ target.textContent = message;
1546
+ if (axFlashTimers[id]) clearTimeout(axFlashTimers[id]);
1547
+ axFlashTimers[id] = setTimeout(() => { target.textContent = ''; }, 4000);
1548
+ }
1549
+ function axWorkItems() {
1550
+ const state = window.PMX_AX && window.PMX_AX.state;
1551
+ return state && Array.isArray(state.workItems) ? state.workItems : [];
1552
+ }
1553
+ async function axEmit(type, payload) {
1554
+ if (!window.PMX_AX || typeof window.PMX_AX.emit !== 'function') {
1555
+ return { ok: false, code: 'ax-bridge-missing', error: 'AX bridge is not available on this surface.' };
1556
+ }
1557
+ return await window.PMX_AX.emit(type, payload);
1558
+ }
1559
+ function axFailure(result) {
1560
+ return (result && (result.error || result.code)) || 'unknown error';
1561
+ }
1562
+ function statusTone(status) {
1563
+ if (status === 'done') return 'ok';
1564
+ if (status === 'blocked' || status === 'cancelled') return 'danger';
1565
+ if (status === 'in-progress') return 'warn';
1566
+ return 'info';
1567
+ }
1568
+ function elem(tag, className, textContent) {
1569
+ const node = document.createElement(tag);
1570
+ if (className) node.className = className;
1571
+ if (textContent !== undefined) node.textContent = textContent;
1572
+ return node;
1573
+ }
1574
+ async function sendSteer() {
1575
+ const message = el('ax-steer-message').value.trim();
1576
+ if (!message) { flash('ax-steer-status', 'Write a steering message first.'); return; }
1577
+ const result = await axEmit('ax.steer', { message });
1578
+ if (result && result.ok) {
1579
+ el('ax-steer-message').value = '';
1580
+ flash('ax-steer-status', 'Queued for the agent\\'s next turn ✓');
1581
+ } else {
1582
+ flash('ax-steer-status', 'Failed: ' + axFailure(result));
1583
+ }
1584
+ }`;
1585
+
1586
+ /**
1587
+ * The shared bounded-loop engine. Each run is its own work item; the surface
1588
+ * advances when the CURRENT run reaches done in live AX state.
1589
+ *
1590
+ * DOM contract the embedding surface must provide: `#ax-loop-panel`,
1591
+ * `#ax-loop-title`, `#ax-loop-run`, `#ax-loop-status`, plus an `axRenderSurface()`
1592
+ * function (its own list/diagram renderer, which may read `axLoop`).
1593
+ */
1594
+ const AX_SURFACE_LOOP = `
1595
+ // Loop control is in-memory only (the surface is sandboxed: storage APIs throw).
1596
+ let axLoop = null;
1597
+ let axAdvancing = false;
1598
+
1599
+ function renderLoop() {
1600
+ const panel = el('ax-loop-panel');
1601
+ panel.hidden = !axLoop;
1602
+ if (!axLoop) return;
1603
+ el('ax-loop-title').textContent = axLoop.baseTitle;
1604
+ el('ax-loop-run').textContent = 'run ' + axLoop.run + ' of ' + axLoop.maxRuns;
1605
+ }
1606
+ async function openRun(session, run) {
1607
+ const result = await axEmit('ax.work.create', {
1608
+ title: session.baseTitle + ' — run ' + run + '/' + session.maxRuns,
1609
+ ...(session.detail ? { detail: session.detail } : {}),
1610
+ });
1611
+ // The human may have stopped the loop while this was in flight.
1612
+ if (axLoop !== session) return false;
1613
+ if (!result || !result.ok || !result.primitive || !result.primitive.id) {
1614
+ flash('ax-loop-status', 'Could not open run ' + run + ': ' + axFailure(result));
1615
+ return false;
1616
+ }
1617
+ session.run = run;
1618
+ session.workItemId = result.primitive.id;
1619
+ renderLoop();
1620
+ axRenderSurface();
1621
+ await axEmit('ax.steer', {
1622
+ message:
1623
+ 'Loop run ' + run + ' of ' + session.maxRuns + ' for "' + session.baseTitle +
1624
+ '". Mark work item ' + session.workItemId + ' done when this run is complete.',
1625
+ });
1626
+ return true;
1627
+ }
1628
+ /**
1629
+ * Start a bounded loop. NEVER auto-started — a human control only. Returns the
1630
+ * clamped run count on success (0 on failure) so the surface can sync its inputs;
1631
+ * \`onStarted\` fires the moment run 1 is open, before the loop's own bookkeeping.
1632
+ */
1633
+ async function axStartLoop(baseTitle, detail, requested, onStarted) {
1634
+ if (axLoop) return 0;
1635
+ const maxRuns = Math.min(AX_MAX_RUNS, Math.max(1, Math.round(Number.isFinite(requested) ? requested : 3)));
1636
+ const session = { baseTitle: baseTitle, detail: detail, maxRuns: maxRuns, run: 0, workItemId: null };
1637
+ axLoop = session;
1638
+ renderLoop();
1639
+ if (!(await openRun(session, 1))) {
1640
+ if (axLoop === session) { axLoop = null; renderLoop(); }
1641
+ return 0;
1642
+ }
1643
+ if (onStarted) onStarted(maxRuns);
1644
+ flash('ax-loop-status', requested > AX_MAX_RUNS ? 'Capped at ' + AX_MAX_RUNS + ' runs.' : 'Loop started.');
1645
+ await axEmit('ax.event.record', {
1646
+ kind: 'steering',
1647
+ summary: 'Loop started: ' + baseTitle + ' (max ' + maxRuns + ' runs)',
1648
+ });
1649
+ return maxRuns;
1650
+ }
1651
+ async function stopLoop() {
1652
+ const session = axLoop;
1653
+ if (!session) return;
1654
+ axLoop = null;
1655
+ renderLoop();
1656
+ axRenderSurface();
1657
+ flash('ax-loop-status', 'Loop stopped at run ' + session.run + ' of ' + session.maxRuns + '.');
1658
+ await axEmit('ax.event.record', {
1659
+ kind: 'steering',
1660
+ summary: 'Loop stopped by human: ' + session.baseTitle + ' at run ' + session.run + ' of ' + session.maxRuns,
1661
+ });
1662
+ }
1663
+ /**
1664
+ * Advance when the CURRENT run's work item reaches done, observed from live AX
1665
+ * state. Double-advance guards: an in-flight flag, and clearing workItemId before
1666
+ * awaiting so a repeated state push cannot match the same finished run twice.
1667
+ */
1668
+ async function maybeAdvanceLoop() {
1669
+ const session = axLoop;
1670
+ if (!session || axAdvancing || !session.workItemId) return;
1671
+ const current = axWorkItems().find((item) => item.id === session.workItemId);
1672
+ if (!current || current.status !== 'done') return;
1673
+ if (session.run >= session.maxRuns) {
1674
+ axLoop = null;
1675
+ renderLoop();
1676
+ axRenderSurface();
1677
+ flash('ax-loop-status', 'Loop finished — ' + session.maxRuns + ' of ' + session.maxRuns + ' runs done.');
1678
+ await axEmit('ax.event.record', {
1679
+ kind: 'steering',
1680
+ summary: 'Loop complete: ' + session.baseTitle + ' (' + session.maxRuns + ' runs)',
1681
+ });
1682
+ return;
1683
+ }
1684
+ axAdvancing = true;
1685
+ session.workItemId = null;
1686
+ try {
1687
+ if (!(await openRun(session, session.run + 1)) && axLoop === session) { axLoop = null; renderLoop(); }
1688
+ } finally {
1689
+ axAdvancing = false;
1690
+ }
1691
+ }
1692
+
1693
+ function applyAxState() {
1694
+ axRenderSurface();
1695
+ renderLoop();
1696
+ void maybeAdvanceLoop();
1697
+ }`;
1698
+
1699
+ /** Control-surface chrome shared by both AX primitives. */
1700
+ const AX_SURFACE_STYLE = `<style>
1701
+ .ax-field { display: grid; gap: 6px; margin-bottom: 10px; }
1702
+ .ax-row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
1703
+ .ax-work-row { margin-bottom: 10px; padding: 12px 14px; }
1704
+ .ax-work-head { display: flex; gap: 10px; align-items: baseline; justify-content: space-between; }
1705
+ .ax-work-head h3 { margin: 0; }
1706
+ .ax-work-foot { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin-top: 8px; }
1707
+ select { border: 1px solid var(--color-border, #263241); border-radius: 999px; padding: 6px 10px; background: var(--color-bg, #0b0f14); color: var(--color-text, #e8edf2); font: inherit; }
1708
+ input[type="number"] { width: 72px; border: 1px solid var(--color-border, #263241); border-radius: 999px; padding: 6px 10px; background: var(--color-bg, #0b0f14); color: var(--color-text, #e8edf2); font: inherit; }
1709
+ #ax-loop-panel[hidden] { display: none; }
1710
+ #ax-offline { border-color: var(--color-danger, #ef4444); margin-bottom: 14px; }
1711
+ </style>`;
1712
+
1713
+ /** The Steer panel markup — identical on both AX control surfaces. */
1714
+ const AX_STEER_PANEL = `<div class="panel">
1715
+ <h2>Steer</h2>
1716
+ <div class="ax-field"><textarea id="ax-steer-message" placeholder="Prioritize the auth refactor" style="min-height:92px"></textarea></div>
1717
+ <div class="ax-row"><button type="button" id="ax-send-steer">Queue steering message</button></div>
1718
+ <p class="small">Queued for the agent's next turn — steering is recorded on the timeline, it does not wake a running agent.</p>
1719
+ <p class="small" id="ax-steer-status"></p>
1720
+ </div>`;
1721
+
1722
+ /** The bounded-loop panel markup — identical on both AX control surfaces. */
1723
+ const AX_LOOP_PANEL = `<section class="panel" id="ax-loop-panel" hidden style="margin-top:14px">
1724
+ <div class="ax-work-head"><h2 id="ax-loop-title">Loop</h2><span class="badge info" id="ax-loop-run">run 0 of 0</span></div>
1725
+ <p class="small">Each run is its own work item. When you mark the current run <strong>done</strong>, the surface opens the next run and steers again — until the cap or Stop.</p>
1726
+ <div class="ax-row"><button type="button" id="ax-stop-loop">Stop loop</button><span class="small" id="ax-loop-status"></span></div>
1727
+ <p class="small">Loop control lives in this panel, so reloading it stops the loop — the work items stay as the durable record.</p>
1728
+ </section>`;
1729
+
1730
+ /**
1731
+ * The first AX-emitting primitive. Everything it shows comes from the LIVE AX
1732
+ * snapshot (`window.PMX_AX.state`, refreshed by the `pmx-ax-update` event), so
1733
+ * an agent changing a status elsewhere shows up here with no local mirror to
1734
+ * drift. All state is plain JS variables — the surface is a sandboxed
1735
+ * opaque-origin iframe where storage APIs throw.
1736
+ */
1737
+ function renderAxBoard({ title, data, descriptor }: Parameters<PrimitiveRenderer>[0]): string {
1738
+ const note = text(data.note, 'Tasks you add here go straight into the canvas AX work queue.');
1739
+ const defaultRuns = Math.min(20, Math.max(1, Math.round(number(data.defaultRuns, 3))));
1740
+ const body = `${AX_SURFACE_STYLE}
1741
+ <section class="panel" id="ax-offline" hidden><h2>AX bridge unavailable</h2><p class="small">This board is not running as an AX-enabled canvas node, so its controls are inert.</p></section>
1742
+ <section class="two">
1743
+ <div class="panel">
1744
+ <h2>Give the agent a task</h2>
1745
+ <p class="small">${escapeHtml(note)}</p>
1746
+ <div class="ax-field"><input type="text" id="ax-task-title" placeholder="What should the agent do?"></div>
1747
+ <div class="ax-field"><textarea id="ax-task-detail" placeholder="Optional detail" style="min-height:64px"></textarea></div>
1748
+ <div class="ax-row"><button type="button" id="ax-add-task">Add task</button><span class="small" id="ax-task-status"></span></div>
1749
+ <div class="ax-row" style="margin-top:10px">
1750
+ <span class="small">or run it as a bounded loop:</span>
1751
+ <label class="small">max runs <input type="number" id="ax-loop-runs" min="1" max="20" step="1" value="${defaultRuns}"></label>
1752
+ <button type="button" id="ax-start-loop">Start loop</button>
1753
+ </div>
1754
+ </div>
1755
+ ${AX_STEER_PANEL}
1756
+ </section>
1757
+ ${AX_LOOP_PANEL}
1758
+ <section class="panel" style="margin-top:14px">
1759
+ <div class="ax-work-head"><h2>Work</h2><span class="small" id="ax-work-counts"></span></div>
1760
+ <div id="ax-work-list"></div>
1761
+ <p class="small" id="ax-work-status"></p>
1762
+ </section>`;
1763
+ return page({
1764
+ title,
1765
+ kind: 'ax-board',
1766
+ summary: descriptor.description,
1767
+ data: { ...data, note, defaultRuns },
1768
+ body,
1769
+ script: `${AX_SURFACE_PRELUDE}
1770
+ ${AX_SURFACE_LOOP}
1771
+
1772
+ // ── render (live AX state only — no local mirror of the work list) ──
1773
+ function renderWork() {
1774
+ const items = axWorkItems();
1775
+ const list = el('ax-work-list');
1776
+ list.textContent = '';
1777
+ el('ax-work-counts').textContent = items.length
1778
+ ? AX_STATUSES.map((s) => s + ' ' + items.filter((item) => item.status === s).length).join(' · ')
1779
+ : '';
1780
+ if (items.length === 0) {
1781
+ list.appendChild(elem('p', 'muted', 'No work items yet — add a task above and it shows up here.'));
1782
+ return;
1783
+ }
1784
+ for (const item of items) {
1785
+ const row = elem('article', 'card ax-work-row');
1786
+ row.setAttribute('data-work-id', item.id);
1787
+ const head = elem('div', 'ax-work-head');
1788
+ head.appendChild(elem('h3', '', item.title));
1789
+ head.appendChild(elem('span', 'badge ' + statusTone(item.status), item.status));
1790
+ row.appendChild(head);
1791
+ if (item.detail) row.appendChild(elem('p', 'small', item.detail));
1792
+ const foot = elem('div', 'ax-work-foot');
1793
+ const label = elem('label', 'small', 'Status ');
1794
+ const select = document.createElement('select');
1795
+ select.setAttribute('data-status-for', item.id);
1796
+ for (const status of AX_STATUSES) {
1797
+ const option = document.createElement('option');
1798
+ option.value = status;
1799
+ option.textContent = status;
1800
+ if (status === item.status) option.selected = true;
1801
+ select.appendChild(option);
1802
+ }
1803
+ select.addEventListener('change', () => setWorkStatus(item.id, select.value));
1804
+ label.appendChild(select);
1805
+ foot.appendChild(label);
1806
+ if (item.agentId) foot.appendChild(elem('span', 'small', 'agent: ' + item.agentId));
1807
+ if (axLoop && axLoop.workItemId === item.id) foot.appendChild(elem('span', 'badge info', 'current run'));
1808
+ row.appendChild(foot);
1809
+ list.appendChild(row);
1810
+ }
1811
+ }
1812
+ function axRenderSurface() { renderWork(); }
1813
+
1814
+ // ── actions ──
1815
+ async function addTask() {
1816
+ const title = el('ax-task-title').value.trim();
1817
+ if (!title) { flash('ax-task-status', 'Give the task a title first.'); return; }
1818
+ const detail = el('ax-task-detail').value.trim();
1819
+ const result = await axEmit('ax.work.create', detail ? { title, detail } : { title });
1820
+ if (result && result.ok) {
1821
+ el('ax-task-title').value = '';
1822
+ el('ax-task-detail').value = '';
1823
+ flash('ax-task-status', 'Task queued ✓');
1824
+ } else {
1825
+ flash('ax-task-status', 'Failed: ' + axFailure(result));
1826
+ }
1827
+ }
1828
+ async function setWorkStatus(id, status) {
1829
+ const result = await axEmit('ax.work.update', { id, status });
1830
+ flash('ax-work-status', result && result.ok ? 'Status updated ✓' : 'Update failed: ' + axFailure(result));
1831
+ }
1832
+ async function startLoop() {
1833
+ if (axLoop) { flash('ax-task-status', 'A loop is already running — stop it first.'); return; }
1834
+ const title = el('ax-task-title').value.trim();
1835
+ if (!title) { flash('ax-task-status', 'Give the task a title first.'); return; }
1836
+ await axStartLoop(title, el('ax-task-detail').value.trim(), Number(el('ax-loop-runs').value), (maxRuns) => {
1837
+ el('ax-task-title').value = '';
1838
+ el('ax-task-detail').value = '';
1839
+ el('ax-loop-runs').value = String(maxRuns);
1840
+ });
1841
+ }
1842
+
1843
+ el('ax-add-task').addEventListener('click', addTask);
1844
+ el('ax-start-loop').addEventListener('click', startLoop);
1845
+ el('ax-stop-loop').addEventListener('click', stopLoop);
1846
+ el('ax-send-steer').addEventListener('click', sendSteer);
1847
+ window.__pmxGetCopyJson = () => ({
1848
+ ...PMX_DATA,
1849
+ workItems: axWorkItems(),
1850
+ loop: axLoop ? { title: axLoop.baseTitle, run: axLoop.run, maxRuns: axLoop.maxRuns } : null,
1851
+ });
1852
+ if (!window.PMX_AX || typeof window.PMX_AX.emit !== 'function') el('ax-offline').hidden = false;
1853
+ // Seeded snapshot first, then every live push. A loop NEVER auto-starts on load.
1854
+ window.addEventListener('pmx-ax-update', applyAxState);
1855
+ applyAxState();`,
1856
+ });
1857
+ }
1858
+
1859
+ /** Panel-side cap; matches the `ax.flow.materialize` server bound. */
1860
+ const AX_FLOW_MAX_STEPS = 12;
1861
+
1862
+ const DEFAULT_AX_FLOW_STEPS: Record<string, unknown>[] = [
1863
+ { title: 'Reproduce', detail: 'Write the failing case before touching the fix.' },
1864
+ { title: 'Fix', detail: 'Smallest change that makes the case pass.' },
1865
+ { title: 'Verify', detail: 'Typecheck, unit tests, lint.' },
1866
+ { title: 'Report', detail: 'What changed, what it proves, what is still open.' },
1867
+ ];
1868
+
1869
+ /**
1870
+ * The second AX-emitting primitive: `ax-board` for a queue, `ax-flow` for an
1871
+ * ordered (optionally looping) sequence. It draws the flow, colours each step
1872
+ * from LIVE AX state — matched by the work-item ID the panel holds for that
1873
+ * step, never by title, since titles are not unique — and can materialize the
1874
+ * same flow as real canvas nodes through `ax.flow.materialize`.
1875
+ */
1876
+ function renderAxFlow({ title, data, descriptor }: Parameters<PrimitiveRenderer>[0]): string {
1877
+ const note = text(data.note, 'Each step is a canvas AX work item — the agent sees the same queue you drive here.');
1878
+ const steps = fieldRecords(data, 'steps', DEFAULT_AX_FLOW_STEPS)
1879
+ .slice(0, AX_FLOW_MAX_STEPS)
1880
+ .map((step, index) => {
1881
+ const stepTitle = itemTitle(step, `Step ${index + 1}`).slice(0, 120);
1882
+ const detail = text(step.detail).trim().slice(0, 2000);
1883
+ return detail ? { title: stepTitle, detail } : { title: stepTitle };
1884
+ });
1885
+ const loopRecord = isRecord(data.loop) ? data.loop : {};
1886
+ const loopEnabled = loopRecord.enabled === true;
1887
+ const maxRuns = Math.min(20, Math.max(1, Math.round(number(loopRecord.maxRuns, 3))));
1888
+ const loop = { enabled: loopEnabled, maxRuns };
1889
+
1890
+ const body = `${AX_SURFACE_STYLE}
1891
+ <style>
1892
+ /* One left-to-right track that scrolls rather than wrapping: a wrapped row
1893
+ leaves a "→" pointing at nothing at the end of every row. */
1894
+ .ax-flow-scroll { overflow-x: auto; padding: 2px 2px 6px; }
1895
+ /* fit-content: the track fills the panel when the steps fit, and grows to the
1896
+ steps' minimum width (scrolling) when they don't — so the loop rail always
1897
+ spans exactly the flow. */
1898
+ .ax-flow-wrap { position: relative; width: fit-content; min-width: 100%; }
1899
+ .ax-flow-wrap.looping { padding-bottom: 54px; }
1900
+ .ax-flow-diagram { display: flex; align-items: stretch; }
1901
+ .ax-flow-step { flex: 1 1 200px; min-width: 200px; max-width: 360px; border: 1px solid var(--color-border, #263241); border-left-width: 4px; border-radius: 16px; padding: 12px 14px; background: var(--color-surface, #17202b); }
1902
+ .ax-flow-step h3 { margin: 6px 0 6px; font-size: 14px; }
1903
+ .ax-flow-step p { margin: 0 0 10px; }
1904
+ .ax-flow-step.tone-ok { border-left-color: var(--color-success, #22c55e); }
1905
+ .ax-flow-step.tone-warn { border-left-color: var(--color-warning, #eab308); }
1906
+ .ax-flow-step.tone-danger { border-left-color: var(--color-danger, #ef4444); }
1907
+ .ax-flow-step.tone-info { border-left-color: var(--color-accent, #4a9eff); }
1908
+ .ax-flow-step.tone-idle { border-left-color: var(--color-border, #263241); }
1909
+ .ax-flow-num { font-size: 11px; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; color: var(--color-text-muted, #7e8a97); }
1910
+ .ax-flow-foot { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
1911
+ .ax-flow-arrow { align-self: center; padding: 0 7px; color: var(--color-accent, #4a9eff); font-weight: 900; }
1912
+ .badge.idle { color: var(--color-text-muted, #7e8a97); background: transparent; border: 1px solid var(--color-border, #263241); }
1913
+ .ax-flow-rail { position: absolute; left: 22px; right: 22px; bottom: 14px; height: 30px; border: 2px dashed var(--color-accent, #4a9eff); border-top: none; border-radius: 0 0 18px 18px; pointer-events: none; }
1914
+ .ax-flow-rail::before { content: '▲'; position: absolute; left: -8px; top: -14px; font-size: 12px; line-height: 1; color: var(--color-accent, #4a9eff); }
1915
+ .ax-flow-rail-label { position: absolute; left: 50%; bottom: -9px; transform: translateX(-50%); background: var(--color-bg, #0b0f14); padding: 0 8px; }
1916
+ .ax-flow-wrap:not(.looping) .ax-flow-rail { display: none; }
1917
+ </style>
1918
+ <section class="panel" id="ax-offline" hidden><h2>AX bridge unavailable</h2><p class="small">This flow is not running as an AX-enabled canvas node, so its controls are inert.</p></section>
1919
+ <section class="panel">
1920
+ <div class="ax-work-head"><h2>${escapeHtml(title)}</h2><span class="small" id="ax-flow-counts"></span></div>
1921
+ <p class="small">${escapeHtml(note)}</p>
1922
+ <div class="ax-flow-scroll">
1923
+ <div class="ax-flow-wrap${loopEnabled ? ' looping' : ''}" id="ax-flow-wrap">
1924
+ <div class="ax-flow-diagram" id="ax-flow-diagram"></div>
1925
+ <div class="ax-flow-rail"><span class="badge info ax-flow-rail-label">loops back to step 1 · max ${maxRuns} runs</span></div>
1926
+ </div>
1927
+ </div>
1928
+ </section>
1929
+ <section class="two" style="margin-top:14px">
1930
+ <div class="panel">
1931
+ <h2>Run the flow</h2>
1932
+ <div class="ax-row"><button type="button" id="ax-flow-queue-all">Queue every step</button><span class="small" id="ax-flow-status"></span></div>
1933
+ <p class="small">A step is queued once — after that its status control advances it (todo → in-progress → done).</p>
1934
+ <div class="ax-row" style="margin-top:10px">
1935
+ <span class="small">or run the whole flow as a bounded loop:</span>
1936
+ <label class="small">max runs <input type="number" id="ax-loop-runs" min="1" max="20" step="1" value="${maxRuns}"></label>
1937
+ <button type="button" id="ax-start-loop">Start loop</button>
1938
+ </div>
1939
+ </div>
1940
+ ${AX_STEER_PANEL}
1941
+ </section>
1942
+ ${AX_LOOP_PANEL}
1943
+ <section class="panel" style="margin-top:14px">
1944
+ <h2>Materialize</h2>
1945
+ <p class="small">Lays this flow out on the canvas: one markdown node per step, joined in order by flow edges${loopEnabled ? ', plus a dashed loop-back edge' : ''}. Each step node gets its own work item, so its status chip tracks the agent.</p>
1946
+ <div class="ax-row"><button type="button" id="ax-flow-materialize">Materialize to board</button><span class="small" id="ax-flow-materialize-status"></span></div>
1947
+ <p class="small">Re-materializing REPLACES the nodes this panel put on the board — it never stacks a second copy. The work items it already created stay as the durable record.</p>
1948
+ </section>`;
1949
+
1950
+ return page({
1951
+ title,
1952
+ kind: 'ax-flow',
1953
+ summary: descriptor.description,
1954
+ data: { ...data, note, steps, loop },
1955
+ body,
1956
+ script: `${AX_SURFACE_PRELUDE}
1957
+ ${AX_SURFACE_LOOP}
1958
+
1959
+ const AX_FLOW_TITLE = ${JSON.stringify(title).replace(/</g, '\\u003c')};
1960
+ const AX_FLOW_STEPS = Array.isArray(PMX_DATA.steps) ? PMX_DATA.steps : [];
1961
+ const AX_FLOW_LOOP = PMX_DATA.loop || {};
1962
+ // Step index → the id of the work item bound to that step (created here, or
1963
+ // adopted from a materialize). Matching is by ID ONLY: work-item titles are not
1964
+ // unique, so a title match would happily bind the wrong item.
1965
+ const axFlowWorkIds = AX_FLOW_STEPS.map(() => null);
1966
+
1967
+ function stepWorkItem(index) {
1968
+ const id = axFlowWorkIds[index];
1969
+ return id ? axWorkItems().find((item) => item.id === id) || null : null;
1970
+ }
1971
+ function stepTone(item) {
1972
+ return item ? statusTone(item.status) : 'idle';
1973
+ }
1974
+
1975
+ // ── render (live AX state only — no local mirror of step status) ──
1976
+ function renderFlow() {
1977
+ const host = el('ax-flow-diagram');
1978
+ host.textContent = '';
1979
+ let bound = 0;
1980
+ let done = 0;
1981
+ AX_FLOW_STEPS.forEach((step, index) => {
1982
+ if (index > 0) host.appendChild(elem('div', 'ax-flow-arrow', '→'));
1983
+ const item = stepWorkItem(index);
1984
+ if (item) bound += 1;
1985
+ if (item && item.status === 'done') done += 1;
1986
+ const card = elem('article', 'ax-flow-step tone-' + stepTone(item));
1987
+ card.setAttribute('data-step', String(index + 1));
1988
+ const head = elem('div', 'ax-work-head');
1989
+ head.appendChild(elem('span', 'ax-flow-num', 'step ' + (index + 1)));
1990
+ head.appendChild(elem('span', 'badge ' + stepTone(item), item ? item.status : 'not queued'));
1991
+ card.appendChild(head);
1992
+ card.appendChild(elem('h3', '', step.title));
1993
+ if (step.detail) card.appendChild(elem('p', 'small', step.detail));
1994
+ const foot = elem('div', 'ax-flow-foot');
1995
+ if (item) {
1996
+ const label = elem('label', 'small', 'status ');
1997
+ const select = document.createElement('select');
1998
+ select.setAttribute('data-status-for', item.id);
1999
+ for (const status of AX_STATUSES) {
2000
+ const option = document.createElement('option');
2001
+ option.value = status;
2002
+ option.textContent = status;
2003
+ if (status === item.status) option.selected = true;
2004
+ select.appendChild(option);
2005
+ }
2006
+ select.addEventListener('change', () => setStepStatus(index, select.value));
2007
+ label.appendChild(select);
2008
+ foot.appendChild(label);
2009
+ } else {
2010
+ const button = elem('button', '', 'Queue step');
2011
+ button.type = 'button';
2012
+ button.setAttribute('data-queue-step', String(index + 1));
2013
+ button.addEventListener('click', () => queueStep(index));
2014
+ foot.appendChild(button);
2015
+ }
2016
+ if (axLoop && item && axLoop.workItemId === item.id) foot.appendChild(elem('span', 'badge info', 'current run'));
2017
+ card.appendChild(foot);
2018
+ host.appendChild(card);
2019
+ });
2020
+ el('ax-flow-counts').textContent =
2021
+ AX_FLOW_STEPS.length + ' steps · ' + bound + ' queued · ' + done + ' done' +
2022
+ (AX_FLOW_LOOP.enabled ? ' · loops' : '');
2023
+ }
2024
+ function axRenderSurface() { renderFlow(); }
2025
+
2026
+ // ── actions ──
2027
+ async function queueStep(index) {
2028
+ const step = AX_FLOW_STEPS[index];
2029
+ if (!step || axFlowWorkIds[index]) return false;
2030
+ const result = await axEmit('ax.work.create', {
2031
+ title: AX_FLOW_TITLE + ' — ' + (index + 1) + '. ' + step.title,
2032
+ ...(step.detail ? { detail: step.detail } : {}),
2033
+ });
2034
+ if (!result || !result.ok || !result.primitive || !result.primitive.id) {
2035
+ flash('ax-flow-status', 'Step ' + (index + 1) + ' failed: ' + axFailure(result));
2036
+ return false;
2037
+ }
2038
+ axFlowWorkIds[index] = result.primitive.id;
2039
+ renderFlow();
2040
+ flash('ax-flow-status', 'Step ' + (index + 1) + ' queued ✓');
2041
+ return true;
2042
+ }
2043
+ async function queueAllSteps() {
2044
+ for (let index = 0; index < AX_FLOW_STEPS.length; index++) {
2045
+ if (!axFlowWorkIds[index] && !(await queueStep(index))) return;
2046
+ }
2047
+ flash('ax-flow-status', 'Every step is queued ✓');
2048
+ }
2049
+ async function setStepStatus(index, status) {
2050
+ const id = axFlowWorkIds[index];
2051
+ if (!id) return;
2052
+ const result = await axEmit('ax.work.update', { id, status });
2053
+ flash(
2054
+ 'ax-flow-status',
2055
+ result && result.ok ? 'Step ' + (index + 1) + ' → ' + status + ' ✓' : 'Update failed: ' + axFailure(result),
2056
+ );
2057
+ }
2058
+ async function startFlowLoop() {
2059
+ if (axLoop) { flash('ax-flow-status', 'A loop is already running — stop it first.'); return; }
2060
+ await axStartLoop(AX_FLOW_TITLE, AX_FLOW_STEPS.map((step, i) => (i + 1) + '. ' + step.title).join('\\n'),
2061
+ Number(el('ax-loop-runs').value), (maxRuns) => { el('ax-loop-runs').value = String(maxRuns); });
2062
+ }
2063
+ /**
2064
+ * Hand the flow to the server, which owns the whole materialized shape. We send
2065
+ * TEXT ONLY — no node type, geometry, or node data — and adopt the work-item ids
2066
+ * it links to each step node so this panel and the board show the same status.
2067
+ */
2068
+ async function materializeFlow() {
2069
+ const result = await axEmit('ax.flow.materialize', {
2070
+ title: AX_FLOW_TITLE,
2071
+ steps: AX_FLOW_STEPS.map((step) => (step.detail ? { title: step.title, detail: step.detail } : { title: step.title })),
2072
+ loop: { enabled: AX_FLOW_LOOP.enabled === true, maxRuns: Number(el('ax-loop-runs').value) || 3 },
2073
+ });
2074
+ if (!result || !result.ok || !result.primitive) {
2075
+ flash('ax-flow-materialize-status', 'Failed: ' + axFailure(result));
2076
+ return;
2077
+ }
2078
+ for (const step of result.primitive.steps) axFlowWorkIds[step.index - 1] = step.workItemId;
2079
+ renderFlow();
2080
+ flash(
2081
+ 'ax-flow-materialize-status',
2082
+ result.primitive.steps.length + ' step nodes on the board ✓' +
2083
+ (result.primitive.replacedNodeCount ? ' (replaced ' + result.primitive.replacedNodeCount + ')' : ''),
2084
+ );
2085
+ }
2086
+
2087
+ el('ax-flow-queue-all').addEventListener('click', queueAllSteps);
2088
+ el('ax-flow-materialize').addEventListener('click', materializeFlow);
2089
+ el('ax-start-loop').addEventListener('click', startFlowLoop);
2090
+ el('ax-stop-loop').addEventListener('click', stopLoop);
2091
+ el('ax-send-steer').addEventListener('click', sendSteer);
2092
+ window.__pmxGetCopyJson = () => ({
2093
+ ...PMX_DATA,
2094
+ steps: AX_FLOW_STEPS.map((step, index) => {
2095
+ const item = stepWorkItem(index);
2096
+ return { ...step, workItemId: axFlowWorkIds[index], status: item ? item.status : null };
2097
+ }),
2098
+ loop: axLoop ? { title: axLoop.baseTitle, run: axLoop.run, maxRuns: axLoop.maxRuns } : null,
2099
+ });
2100
+ if (!window.PMX_AX || typeof window.PMX_AX.emit !== 'function') el('ax-offline').hidden = false;
2101
+ // Seeded snapshot first, then every live push. A loop NEVER auto-starts on load.
2102
+ window.addEventListener('pmx-ax-update', applyAxState);
2103
+ applyAxState();`,
2104
+ });
2105
+ }
2106
+
1473
2107
  const RENDERERS: Record<HtmlPrimitiveKind, PrimitiveRenderer> = {
1474
2108
  'choice-grid': renderChoiceGrid,
1475
2109
  'plan-timeline': renderPlanTimeline,
@@ -1490,6 +2124,8 @@ const RENDERERS: Record<HtmlPrimitiveKind, PrimitiveRenderer> = {
1490
2124
  'triage-board': renderTriageBoard,
1491
2125
  'config-editor': renderConfigEditor,
1492
2126
  'prompt-tuner': renderPromptTuner,
2127
+ 'ax-board': renderAxBoard,
2128
+ 'ax-flow': renderAxFlow,
1493
2129
  };
1494
2130
 
1495
2131
  export function isHtmlPrimitiveKind(value: string): value is HtmlPrimitiveKind {