pmx-canvas 0.4.6 → 0.4.7

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 (70) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/Readme.md +56 -33
  3. package/dist/canvas/global.css +101 -3
  4. package/dist/canvas/index.js +87 -83
  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/FileNode.d.ts +14 -0
  10. package/dist/types/client/state/canvas-store.d.ts +10 -0
  11. package/dist/types/client/state/intent-bridge.d.ts +9 -0
  12. package/dist/types/server/ax-flow-loop.d.ts +5 -0
  13. package/dist/types/server/ax-interaction.d.ts +21 -2
  14. package/dist/types/server/canvas-operations.d.ts +5 -0
  15. package/dist/types/server/canvas-state.d.ts +8 -0
  16. package/dist/types/server/canvas-validation.d.ts +15 -21
  17. package/dist/types/server/file-content.d.ts +46 -0
  18. package/dist/types/server/html-primitives.d.ts +13 -1
  19. package/dist/types/server/operations/ops/ax-work.d.ts +1 -0
  20. package/dist/types/server/server.d.ts +1 -0
  21. package/dist/types/server/web-artifacts.d.ts +14 -0
  22. package/dist/types/shared/ax-flow.d.ts +51 -0
  23. package/dist/types/shared/node-sizes.d.ts +33 -0
  24. package/docs/cli.md +19 -2
  25. package/docs/environment.md +1 -0
  26. package/docs/http-api.md +17 -0
  27. package/docs/mcp.md +12 -0
  28. package/docs/node-types.md +45 -1
  29. package/docs/screenshot.png +0 -0
  30. package/package.json +2 -2
  31. package/skills/pmx-canvas/SKILL.md +68 -7
  32. package/skills/pmx-canvas/evals/evals.json +54 -3
  33. package/skills/pmx-canvas/references/full-reference.md +11 -6
  34. package/src/cli/commands/history.ts +1 -1
  35. package/src/cli/commands/nodes.ts +2 -2
  36. package/src/cli/commands/query.ts +1 -1
  37. package/src/cli/commands/view.ts +1 -1
  38. package/src/cli/index.ts +6 -0
  39. package/src/cli/shared.ts +34 -5
  40. package/src/client/App.tsx +28 -15
  41. package/src/client/canvas/CanvasNode.tsx +54 -5
  42. package/src/client/canvas/EdgeLayer.tsx +44 -24
  43. package/src/client/canvas/ExpandedNodeOverlay.tsx +11 -0
  44. package/src/client/canvas/auto-fit.ts +10 -1
  45. package/src/client/canvas/use-pan-zoom.ts +16 -1
  46. package/src/client/nodes/AxStepControls.tsx +226 -0
  47. package/src/client/nodes/FileNode.tsx +249 -51
  48. package/src/client/state/canvas-store.ts +19 -0
  49. package/src/client/state/intent-bridge.ts +32 -0
  50. package/src/client/state/sse-bridge.ts +4 -1
  51. package/src/client/theme/global.css +101 -3
  52. package/src/server/ax-flow-loop.ts +114 -0
  53. package/src/server/ax-interaction.ts +299 -4
  54. package/src/server/canvas-operations.ts +44 -17
  55. package/src/server/canvas-state.ts +20 -10
  56. package/src/server/canvas-validation.ts +50 -41
  57. package/src/server/demo-state.json +4605 -523
  58. package/src/server/demo.ts +24 -0
  59. package/src/server/file-content.ts +152 -0
  60. package/src/server/file-watcher.ts +7 -10
  61. package/src/server/html-primitives.ts +636 -0
  62. package/src/server/operations/ops/ax-work.ts +5 -0
  63. package/src/server/operations/ops/nodes.ts +15 -1
  64. package/src/server/operations/ops/viewport.ts +7 -1
  65. package/src/server/server.ts +106 -5
  66. package/src/server/web-artifacts.ts +109 -0
  67. package/src/shared/ax-flow.ts +102 -0
  68. package/src/shared/node-sizes.ts +52 -0
  69. package/dist/types/server/chart-template.d.ts +0 -29
  70. package/src/server/chart-template.ts +0 -448
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Server-side bounded loop for materialized AX flows.
3
+ *
4
+ * A flow's loop lives on the CANVAS, not in a panel: the anchor step node carries
5
+ * `data.axFlow.loop` (see shared/ax-flow.ts), so it survives a browser refresh, an
6
+ * iframe reload, and a server restart — where a panel-driven loop dies with its
7
+ * document. The driver is `canvasState`'s work-item change hook: whenever a step's
8
+ * work item reaches `done`, the next step is opened; when the LAST step finishes,
9
+ * the next run starts — until the run cap is reached or a human presses Stop.
10
+ *
11
+ * Bounds (non-negotiable, enforced here rather than trusted from node data):
12
+ * - advances only while `loop.running` is true
13
+ * - stops at `maxRuns`, itself clamped to AX_FLOW_LOOP_HARD_CAP (20)
14
+ * - a `blocked`/`cancelled` step halts advancement (the loop waits, it does not
15
+ * skip); a step whose work item no longer exists stops the loop outright
16
+ * - re-entrancy: every write this module makes fires the same work-item change
17
+ * hook, so `advancing` drops the nested call. State is recomputed from scratch
18
+ * on each pass, so dropping nested calls loses nothing.
19
+ */
20
+ import { AX_FLOW_LOOP_HARD_CAP, readAxFlow, type AxFlowLoopState, type AxFlowStamp } from '../shared/ax-flow.js';
21
+ import type { PmxAxWorkItem } from './ax-state.js';
22
+ import { emitCanvasLayoutUpdate } from './canvas-operations.js';
23
+ import { canvasState, type CanvasNodeState } from './canvas-state.js';
24
+ import { refreshWorkboardNodes } from './workboard.js';
25
+
26
+ /** Re-entrancy guard: true while an advance pass is writing. */
27
+ let advancing = false;
28
+
29
+ /** Persist a new loop state on the anchor node (durable: this is what Stop writes). */
30
+ function writeLoopState(node: CanvasNodeState, flow: AxFlowStamp, loop: AxFlowLoopState): void {
31
+ const current = canvasState.getNode(node.id);
32
+ if (!current) return;
33
+ canvasState.updateNode(node.id, { data: { ...current.data, axFlow: { ...flow, loop } } });
34
+ }
35
+
36
+ /**
37
+ * Advance one flow by at most one step (or one run boundary). Returns true when
38
+ * anything changed, so the caller knows whether to broadcast a layout update.
39
+ */
40
+ function advanceFlow(node: CanvasNodeState, flow: AxFlowStamp, byId: Map<string, PmxAxWorkItem>): boolean {
41
+ const items = flow.steps.map((step) => byId.get(step.workItemId));
42
+ if (items.some((item) => !item)) {
43
+ // The flow was partially dismantled (work items cleared/deleted). Stop rather
44
+ // than keep a loop armed against state it can no longer drive.
45
+ writeLoopState(node, flow, { ...flow.loop, running: false });
46
+ return true;
47
+ }
48
+ const statuses = items as PmxAxWorkItem[];
49
+ // A blocked/cancelled step is a deliberate halt — hold position, stay armed.
50
+ if (statuses.some((item) => item.status === 'blocked' || item.status === 'cancelled')) return false;
51
+
52
+ const openIndex = statuses.findIndex((item) => item.status !== 'done');
53
+ if (openIndex >= 0) {
54
+ // Every earlier step is done by construction. Open this one exactly once:
55
+ // an already in-progress step is the loop's own previous advance.
56
+ if (statuses[openIndex].status !== 'todo') return false;
57
+ canvasState.updateWorkItem(statuses[openIndex].id, { status: 'in-progress' }, { source: 'system' });
58
+ return true;
59
+ }
60
+
61
+ // Every step is done — one full run just completed.
62
+ const maxRuns = Math.min(flow.loop.maxRuns, AX_FLOW_LOOP_HARD_CAP);
63
+ const run = flow.loop.run + 1;
64
+ if (run >= maxRuns) {
65
+ // The cap is reached WITH this run recorded: no run `maxRuns + 1` is opened.
66
+ writeLoopState(node, flow, { running: false, run, maxRuns });
67
+ return true;
68
+ }
69
+ // Write the counter before re-opening the steps, so a crash mid-pass can never
70
+ // replay a run for free.
71
+ writeLoopState(node, flow, { running: true, run, maxRuns });
72
+ for (const step of flow.steps) {
73
+ canvasState.updateWorkItem(step.workItemId, { status: 'todo' }, { source: 'system' });
74
+ }
75
+ canvasState.updateWorkItem(flow.steps[0].workItemId, { status: 'in-progress' }, { source: 'system' });
76
+ return true;
77
+ }
78
+
79
+ /**
80
+ * Advance every running flow loop on the canvas. Safe to call on any work-item
81
+ * change: flows that are not running, not ready, or already advanced are no-ops.
82
+ */
83
+ export function advanceAxFlowLoops(): void {
84
+ if (advancing) return;
85
+ advancing = true;
86
+ try {
87
+ const anchors = canvasState
88
+ .getLayout()
89
+ .nodes.map((node) => ({ node, flow: readAxFlow(node.data) }))
90
+ .filter((entry): entry is { node: CanvasNodeState; flow: AxFlowStamp } => entry.flow?.loop.running === true);
91
+ if (anchors.length === 0) return;
92
+ const byId = new Map(canvasState.getWorkItems().map((item) => [item.id, item]));
93
+ let changed = false;
94
+ // Suppressed: loop bookkeeping (status chips, the run counter) is automatic
95
+ // and must not fill undo history — same contract as the live workboard.
96
+ canvasState.withSuppressedRecording(() => {
97
+ for (const { node, flow } of anchors) {
98
+ if (advanceFlow(node, flow, byId)) changed = true;
99
+ }
100
+ });
101
+ if (changed) emitCanvasLayoutUpdate();
102
+ } finally {
103
+ advancing = false;
104
+ }
105
+ }
106
+
107
+ // canvasState exposes ONE work-item listener slot, so this module owns the
108
+ // composition: the live workboard refresh first (unchanged behaviour), then the
109
+ // flow advance. Importing workboard also guarantees ordering — its own
110
+ // registration runs while this module is still evaluating, so this one wins.
111
+ canvasState.setWorkItemsChangedListener(() => {
112
+ refreshWorkboardNodes();
113
+ advanceAxFlowLoops();
114
+ });
@@ -16,7 +16,9 @@
16
16
  */
17
17
 
18
18
  import { z } from 'zod';
19
- import type { CanvasNodeState } from './canvas-state.js';
19
+ import { clampAxFlowMaxRuns, type AxFlowStamp, type AxFlowStepStamp } from '../shared/ax-flow.js';
20
+ import { findOpenCanvasPosition, overlapsAny, type CanvasPlacementRect } from '../shared/placement.js';
21
+ import type { CanvasEdge, CanvasLayout, CanvasNodeState } from './canvas-state.js';
20
22
  import type { CanvasNodeType } from './canvas-provenance.js';
21
23
  import type {
22
24
  PmxAxApprovalGate,
@@ -54,6 +56,7 @@ export const AX_INTERACTION_TYPES = [
54
56
  'ax.command.invoke',
55
57
  'ax.elicitation.request',
56
58
  'ax.mode.request',
59
+ 'ax.flow.materialize',
57
60
  ] as const;
58
61
 
59
62
  export type AxInteractionType = (typeof AX_INTERACTION_TYPES)[number];
@@ -86,9 +89,21 @@ function caps(
86
89
  * a node can anchor AX state but only eligible types may EMIT interactions.
87
90
  */
88
91
  export const DEFAULT_NODE_AX_CAPABILITIES: Record<CanvasNodeType, NodeAxCapabilities> = {
92
+ // `ax.work.update` is what the native step controls (Start/Done/Blocked) on a
93
+ // materialized flow step emit, and every step node is a `markdown` node. Two
94
+ // reasons this is not an escalation: it is no broader in kind than the
95
+ // `ax.work.create` / `ax.steer` this type already grants (creating work and
96
+ // steering the agent are strictly more powerful than flipping a status), and a
97
+ // markdown node has no sandboxed emit bridge — `window.PMX_AX` is injected only
98
+ // into `html` / `mcp-app` surfaces, so the only emitters here are the trusted
99
+ // native controls in the canvas app and direct local API calls, which can
100
+ // already PATCH a work item with no node capability at all.
101
+ // NB: markdown IS rendered as HTML into the parent document (see the trust
102
+ // boundary note in MarkdownNode.tsx — localhost, user-owned content), so the
103
+ // justification is "no sandboxed emitter", NOT "no scriptable surface".
89
104
  markdown: caps(
90
105
  true,
91
- ['ax.steer', 'ax.work.create', 'ax.evidence.add', 'ax.command.invoke', 'ax.event.record'],
106
+ ['ax.steer', 'ax.work.create', 'ax.work.update', 'ax.evidence.add', 'ax.command.invoke', 'ax.event.record'],
92
107
  'notify-agent',
93
108
  ),
94
109
  context: caps(
@@ -137,6 +152,11 @@ export const DEFAULT_NODE_AX_CAPABILITIES: Record<CanvasNodeType, NodeAxCapabili
137
152
  'ax.mode.request',
138
153
  'ax.command.invoke',
139
154
  'ax.event.record',
155
+ // Only `html` gets the flow materializer: a panel is the one surface that
156
+ // authors a flow the human then wants ON the board. The output shape is
157
+ // server-owned (fixed markdown/flow/references geometry), so this grants no
158
+ // arbitrary node creation — see materializeFlow.
159
+ 'ax.flow.materialize',
140
160
  ],
141
161
  'notify-agent',
142
162
  ),
@@ -246,6 +266,23 @@ export interface AxInteractionInput {
246
266
  metadata?: Record<string, unknown>;
247
267
  }
248
268
 
269
+ // ── ax.flow.materialize bounds + fixed layout ──────────────────
270
+ /** Hard cap on materialized steps: one screen-band of nodes, not a node bomb. */
271
+ const FLOW_MAX_STEPS = 12;
272
+ const FLOW_TITLE_MAX = 120;
273
+ const FLOW_DETAIL_MAX = 2000;
274
+ const FLOW_MAX_LOOP_RUNS = 20;
275
+ /**
276
+ * Step node geometry. 360 is markdown's minimum create size, but it is too
277
+ * narrow for a step's NAME: the title bar also carries the type icon, the work
278
+ * status pill and the control cluster, so a title like "3. Regenerate + verify"
279
+ * ellipsised away at 100% zoom. Wide enough that the step reads at a glance.
280
+ */
281
+ const FLOW_STEP_WIDTH = 460;
282
+ const FLOW_STEP_HEIGHT = 200;
283
+ const FLOW_STEP_GAP = 48;
284
+ const FLOW_STEP_COLUMNS = 4;
285
+
249
286
  const PAYLOAD_SCHEMAS: Record<string, z.ZodType> = {
250
287
  'ax.event.record': z.object({
251
288
  kind: z.enum(EVENT_KINDS),
@@ -311,6 +348,27 @@ const PAYLOAD_SCHEMAS: Record<string, z.ZodType> = {
311
348
  name: z.string().min(1),
312
349
  args: z.record(z.string(), z.unknown()).optional(),
313
350
  }),
351
+ // TEXT ONLY. The caller never supplies a node type, geometry, or node data —
352
+ // the server owns the materialized shape (see materializeFlow), which is what
353
+ // keeps this from being "a sandboxed panel can create arbitrary canvas nodes".
354
+ 'ax.flow.materialize': z.object({
355
+ title: z.string().trim().max(FLOW_TITLE_MAX).optional(),
356
+ steps: z
357
+ .array(
358
+ z.object({
359
+ title: z.string().trim().min(1).max(FLOW_TITLE_MAX),
360
+ detail: z.string().trim().max(FLOW_DETAIL_MAX).optional(),
361
+ }),
362
+ )
363
+ .min(1)
364
+ .max(FLOW_MAX_STEPS),
365
+ loop: z
366
+ .object({
367
+ enabled: z.boolean().optional(),
368
+ maxRuns: z.number().int().min(1).max(FLOW_MAX_LOOP_RUNS).optional(),
369
+ })
370
+ .optional(),
371
+ }),
314
372
  };
315
373
 
316
374
  // ── Dispatch ───────────────────────────────────────────────────
@@ -321,6 +379,13 @@ const PAYLOAD_SCHEMAS: Record<string, z.ZodType> = {
321
379
  */
322
380
  export interface AxInteractionManager {
323
381
  getNode(id: string): CanvasNodeState | undefined;
382
+ /** Canvas read for `ax.flow.materialize` (placement obstacles + stale flow sweep). */
383
+ getLayout(): CanvasLayout;
384
+ addNode(node: CanvasNodeState): void;
385
+ updateNode(id: string, patch: Partial<CanvasNodeState>): void;
386
+ /** Also drops the node's edges — the flow's replace path relies on that. */
387
+ removeNode(id: string): void;
388
+ addEdge(edge: CanvasEdge): boolean;
324
389
  recordAxEvent(
325
390
  input: {
326
391
  kind: PmxAxEventKind;
@@ -428,6 +493,227 @@ function accept(
428
493
  };
429
494
  }
430
495
 
496
+ /**
497
+ * Add a layout broadcast to a work-item result when the item is linked to nodes.
498
+ *
499
+ * A linked work item mirrors its status onto `data.axWorkStatus` of every node it
500
+ * names, so a work interaction IS a node-data change even though the op itself is
501
+ * layout-neutral. Without this the browser keeps rendering a stale status chip
502
+ * (the AX SSE frames carry AX state, not nodes) — the same reason materializeFlow
503
+ * emits one.
504
+ */
505
+ function withLayoutBroadcast(
506
+ result: AxInteractionResult,
507
+ manager: AxInteractionManager,
508
+ workItems: PmxAxWorkItem[],
509
+ ): AxInteractionResult {
510
+ if (!workItems.some((item) => item.nodeIds.length > 0)) return result;
511
+ result.events.push({ event: 'canvas-layout-update', payload: { layout: manager.getLayout() } });
512
+ return result;
513
+ }
514
+
515
+ // ── ax.flow.materialize ────────────────────────────────────────
516
+
517
+ export interface AxFlowMaterializeInput {
518
+ title?: string;
519
+ steps: Array<{ title: string; detail?: string }>;
520
+ loop?: { enabled?: boolean; maxRuns?: number };
521
+ }
522
+
523
+ /** Shared tag on every node this source materialized — the unit of idempotent replace. */
524
+ function axFlowId(sourceNodeId: string): string {
525
+ return `axflow-${sourceNodeId}`;
526
+ }
527
+
528
+ /**
529
+ * Ids the source node recorded the last time it materialized. This manifest — not
530
+ * the `axFlowId` tag alone — is what the replace sweep deletes from: `axFlowId` is
531
+ * ordinary node data, so any actor could stamp a plausible-looking one onto an
532
+ * unrelated node and get it swept. A node is only removed when the source node
533
+ * says it created it AND the node still carries the matching tag.
534
+ */
535
+ function axFlowManifest(source: CanvasNodeState): string[] {
536
+ const ids = source.data.axFlowNodeIds;
537
+ return Array.isArray(ids) ? ids.filter((id): id is string => typeof id === 'string') : [];
538
+ }
539
+
540
+ /**
541
+ * A clear band for the flow: directly under the source panel when that space is
542
+ * free, otherwise the canvas's normal open-position search. The band is placed
543
+ * as ONE rect, so the steps inside it are collision-free by construction.
544
+ */
545
+ function flowBandOrigin(
546
+ source: CanvasNodeState | undefined,
547
+ obstacles: CanvasPlacementRect[],
548
+ width: number,
549
+ height: number,
550
+ ): { x: number; y: number } {
551
+ if (source) {
552
+ const below = { x: source.position.x, y: source.position.y + source.size.height + FLOW_STEP_GAP };
553
+ if (!overlapsAny(below, width, height, obstacles, FLOW_STEP_GAP)) return below;
554
+ }
555
+ return findOpenCanvasPosition(obstacles, width, height, FLOW_STEP_GAP);
556
+ }
557
+
558
+ /**
559
+ * Lay a validated, text-only flow out as real canvas nodes and edges.
560
+ *
561
+ * The SERVER owns the entire output shape — one `markdown` node per step, `flow`
562
+ * edges in order, a dashed `references` loop-back edge when looping, and one AX
563
+ * work item per step linked to its node so the existing work-item→node status
564
+ * mirror lights the `axWorkStatus` chip. The caller supplies text only, which is
565
+ * why this can be granted to a sandboxed panel at all.
566
+ *
567
+ * Idempotent: the source node records the ids it created in `data.axFlowNodeIds`
568
+ * and a re-materialize removes exactly those first (removeNode takes their edges
569
+ * with them) instead of stacking a second copy. Work items from a previous
570
+ * materialize survive with their dangling node ref stripped — they are the durable
571
+ * AX record, exactly as for any other node deletion.
572
+ */
573
+ function materializeFlow(
574
+ manager: AxInteractionManager,
575
+ sourceNode: CanvasNodeState,
576
+ input: AxFlowMaterializeInput,
577
+ source: PmxAxSource,
578
+ ): AxInteractionResult {
579
+ const sourceNodeId = sourceNode.id;
580
+ const flowId = axFlowId(sourceNodeId);
581
+ const title = input.title?.trim() || 'Flow';
582
+ const loopEnabled = input.loop?.enabled === true;
583
+ const maxRuns = input.loop?.maxRuns ?? 3;
584
+
585
+ // Replace, never duplicate — and only what this panel actually created: the id
586
+ // must be on the source's manifest AND still carry the matching tag. The source
587
+ // itself is excluded so a self-tagged panel cannot delete itself.
588
+ const owned = new Set(axFlowManifest(sourceNode));
589
+ const stale = manager
590
+ .getLayout()
591
+ .nodes.filter((node) => node.id !== sourceNodeId && owned.has(node.id) && node.data.axFlowId === flowId);
592
+ for (const node of stale) manager.removeNode(node.id);
593
+
594
+ const layout = manager.getLayout();
595
+ const obstacles: CanvasPlacementRect[] = layout.nodes.map((node) => ({ position: node.position, size: node.size }));
596
+ const columns = Math.min(input.steps.length, FLOW_STEP_COLUMNS);
597
+ const rows = Math.ceil(input.steps.length / FLOW_STEP_COLUMNS);
598
+ const origin = flowBandOrigin(
599
+ layout.nodes.find((node) => node.id === sourceNodeId),
600
+ obstacles,
601
+ columns * FLOW_STEP_WIDTH + (columns - 1) * FLOW_STEP_GAP,
602
+ rows * FLOW_STEP_HEIGHT + (rows - 1) * FLOW_STEP_GAP,
603
+ );
604
+
605
+ const total = input.steps.length;
606
+ // Last-written data of the anchor step, so its `axFlow` stamp below never has
607
+ // to assume the manager can read a node back.
608
+ let anchorData: Record<string, unknown> = {};
609
+ const steps = input.steps.map((step, index) => {
610
+ const nodeId = `${flowId}-step-${index + 1}`;
611
+ const nodeData: Record<string, unknown> = {
612
+ title: `${index + 1}. ${step.title}`,
613
+ content: step.detail ?? `Step ${index + 1} of ${total} in "${title}".`,
614
+ axFlowId: flowId,
615
+ axFlowStep: index + 1,
616
+ axFlowSourceNodeId: sourceNodeId,
617
+ };
618
+ manager.addNode({
619
+ id: nodeId,
620
+ type: 'markdown',
621
+ position: {
622
+ x: origin.x + (index % FLOW_STEP_COLUMNS) * (FLOW_STEP_WIDTH + FLOW_STEP_GAP),
623
+ y: origin.y + Math.floor(index / FLOW_STEP_COLUMNS) * (FLOW_STEP_HEIGHT + FLOW_STEP_GAP),
624
+ },
625
+ size: { width: FLOW_STEP_WIDTH, height: FLOW_STEP_HEIGHT },
626
+ zIndex: 1,
627
+ collapsed: false,
628
+ pinned: false,
629
+ dockPosition: null,
630
+ data: nodeData,
631
+ });
632
+ const workItem = manager.addWorkItem(
633
+ {
634
+ title: `${title} — ${index + 1}. ${step.title}`,
635
+ status: 'todo',
636
+ detail: step.detail ?? null,
637
+ nodeIds: [nodeId],
638
+ },
639
+ { source },
640
+ );
641
+ // `data.axStep` is the contract the native step controls read (see
642
+ // shared/ax-flow.ts). It can only be stamped once the work item exists, and
643
+ // the node is re-read first because addWorkItem's status mirror has already
644
+ // written `axWorkStatus` onto it.
645
+ const axStep: AxFlowStepStamp = { flowId, index: index + 1, total, workItemId: workItem.id };
646
+ const stamped = { ...(manager.getNode(nodeId)?.data ?? nodeData), axStep };
647
+ manager.updateNode(nodeId, { data: stamped });
648
+ if (index === 0) anchorData = stamped;
649
+ return { index: index + 1, title: step.title, nodeId, workItemId: workItem.id };
650
+ });
651
+
652
+ const edgeIds: string[] = [];
653
+ for (let i = 0; i < steps.length - 1; i++) {
654
+ const edge: CanvasEdge = {
655
+ id: `${flowId}-edge-${i + 1}`,
656
+ from: steps[i].nodeId,
657
+ to: steps[i + 1].nodeId,
658
+ type: 'flow',
659
+ style: 'solid',
660
+ };
661
+ if (manager.addEdge(edge)) edgeIds.push(edge.id);
662
+ }
663
+ if (loopEnabled && steps.length > 1) {
664
+ const loopEdge: CanvasEdge = {
665
+ id: `${flowId}-loop`,
666
+ from: steps[steps.length - 1].nodeId,
667
+ to: steps[0].nodeId,
668
+ type: 'references',
669
+ label: 'loop',
670
+ style: 'dashed',
671
+ animated: true,
672
+ };
673
+ if (manager.addEdge(loopEdge)) edgeIds.push(loopEdge.id);
674
+ }
675
+
676
+ // `data.axFlow` on the anchor (first) step: the whole flow plus its loop state,
677
+ // so the anchor's native controls can run/stop the loop and the server loop
678
+ // (ax-flow-loop.ts) can advance it. Always stamped, at rest (`running: false`,
679
+ // no completed passes) — a loop only ever starts from an explicit human click.
680
+ const anchorId = steps[0].nodeId;
681
+ const axFlow: AxFlowStamp = {
682
+ flowId,
683
+ title,
684
+ steps: steps.map((step) => ({
685
+ index: step.index,
686
+ nodeId: step.nodeId,
687
+ workItemId: step.workItemId,
688
+ title: step.title,
689
+ })),
690
+ loop: { running: false, run: 0, maxRuns: clampAxFlowMaxRuns(maxRuns) },
691
+ };
692
+ manager.updateNode(anchorId, { data: { ...(manager.getNode(anchorId)?.data ?? anchorData), axFlow } });
693
+
694
+ // The manifest the NEXT materialize sweeps from.
695
+ manager.updateNode(sourceNodeId, {
696
+ data: { ...sourceNode.data, axFlowNodeIds: steps.map((step) => step.nodeId) },
697
+ });
698
+
699
+ const primitive = {
700
+ flowId,
701
+ title,
702
+ loop: { enabled: loopEnabled, maxRuns },
703
+ replacedNodeCount: stale.length,
704
+ steps,
705
+ edgeIds,
706
+ };
707
+ return {
708
+ result: { ok: true, type: 'ax.flow.materialize', sourceNodeId, primitive },
709
+ events: [
710
+ outcomeEvent({ ok: true, type: 'ax.flow.materialize', sourceNodeId }),
711
+ { event: 'canvas-layout-update', payload: { layout: manager.getLayout() } },
712
+ { event: 'ax-state-changed', payload: { flow: primitive } },
713
+ ],
714
+ };
715
+ }
716
+
431
717
  /**
432
718
  * Validate + execute a node-originated AX interaction. Returns the public result
433
719
  * plus the SSE events the caller should emit (accepted/rejected outcome + the
@@ -548,7 +834,9 @@ export function applyAxInteraction(
548
834
  },
549
835
  opts,
550
836
  );
551
- return accept(type, sourceNodeId, workItem, 'ax-state-changed', { workItem });
837
+ return withLayoutBroadcast(accept(type, sourceNodeId, workItem, 'ax-state-changed', { workItem }), manager, [
838
+ workItem,
839
+ ]);
552
840
  }
553
841
  case 'ax.work.update': {
554
842
  const p = payloadParsed.data as {
@@ -562,7 +850,9 @@ export function applyAxInteraction(
562
850
  if (scoped && patch.nodeIds !== undefined) patch.nodeIds = [sourceNodeId];
563
851
  const workItem = manager.updateWorkItem(id, patch, opts);
564
852
  if (!workItem) return reject(type, sourceNodeId, 404, 'work-item-not-found', `Work item "${id}" not found.`);
565
- return accept(type, sourceNodeId, workItem, 'ax-state-changed', { workItem });
853
+ return withLayoutBroadcast(accept(type, sourceNodeId, workItem, 'ax-state-changed', { workItem }), manager, [
854
+ workItem,
855
+ ]);
566
856
  }
567
857
  case 'ax.evidence.add': {
568
858
  const p = payloadParsed.data as {
@@ -685,6 +975,11 @@ export function applyAxInteraction(
685
975
  if (!event) return reject(type, sourceNodeId, 400, 'unknown-command', `Unknown command "${p.name}".`);
686
976
  return accept(type, sourceNodeId, event, 'ax-event-created', { event });
687
977
  }
978
+ case 'ax.flow.materialize':
979
+ // No `nodeIds` in this payload, so `scopedNodeIds` never applies: the step
980
+ // nodes are created BY the server and their work-item links are server-owned
981
+ // ids, not caller-supplied ones. Sandboxed callers pass through unchanged.
982
+ return materializeFlow(manager, node, payloadParsed.data as AxFlowMaterializeInput, source);
688
983
  default:
689
984
  return reject(type, sourceNodeId, 501, 'not-executable', `"${type}" is recognized but not yet executable.`);
690
985
  }
@@ -8,6 +8,7 @@ import {
8
8
  type CanvasNodeUpdate,
9
9
  type CanvasSnapshot,
10
10
  } from './canvas-state.js';
11
+ import { applyFileContentToNodeData, readFileNodeContent } from './file-content.js';
11
12
  import { rewatchAllFileNodes, unwatchAll, unwatchFileForNode, watchFileForNode } from './file-watcher.js';
12
13
  import {
13
14
  closeMcpAppSession,
@@ -568,6 +569,11 @@ function prepareExtAppNodesForSessionSync(forceRehydrate: boolean): string[] {
568
569
 
569
570
  const transportConfig = normalizeTransportConfig(node.data.transportConfig);
570
571
  if (!transportConfig) {
572
+ // Only a node that HAD a session has one to fail restoring. A static
573
+ // ext-app node (no session, no transport — e.g. the demo board's
574
+ // showcase tile) would otherwise be stamped with a persisted error
575
+ // banner on every start after the first, for a session it never had.
576
+ if (!sessionId) continue;
571
577
  setExtAppRuntimeState(node.id, {
572
578
  appSessionId: null,
573
579
  sessionStatus: 'error',
@@ -748,19 +754,8 @@ function buildFileNodeData(input: CanvasAddNodeInput): Record<string, unknown> {
748
754
  title: input.title ?? fileName,
749
755
  };
750
756
 
751
- try {
752
- if (existsSync(resolved)) {
753
- const fileContent = readFileSync(resolved, 'utf-8');
754
- const stat = statSync(resolved);
755
- data.fileContent = fileContent;
756
- data.lineCount = fileContent.split('\n').length;
757
- data.updatedAt = new Date(stat.mtimeMs).toISOString();
758
- }
759
- } catch {
760
- // Missing or unreadable files still render as path-backed file nodes.
761
- }
762
-
763
- return data;
757
+ // Missing or unreadable files still render as path-backed file nodes.
758
+ return applyFileContentToNodeData(data, readFileNodeContent(resolved));
764
759
  }
765
760
 
766
761
  function buildImageNodeData(input: CanvasAddNodeInput): Record<string, unknown> {
@@ -833,7 +828,7 @@ function normalizeTraceNodeData(input: CanvasAddNodeInput): Record<string, unkno
833
828
  return data;
834
829
  }
835
830
 
836
- function buildNodeData(input: CanvasAddNodeInput): Record<string, unknown> {
831
+ function buildNodeDataForType(input: CanvasAddNodeInput): Record<string, unknown> {
837
832
  if (input.type === 'file') return buildFileNodeData(input);
838
833
  if (input.type === 'image') return buildImageNodeData(input);
839
834
  if (input.type === 'webpage') return buildWebpageNodeData(input);
@@ -842,10 +837,21 @@ function buildNodeData(input: CanvasAddNodeInput): Record<string, unknown> {
842
837
  ...(input.data ?? {}),
843
838
  ...(input.title ? { title: input.title } : {}),
844
839
  ...(input.content ? { content: input.content } : {}),
845
- ...(input.strictSize ? { strictSize: true } : {}),
846
840
  };
847
841
  }
848
842
 
843
+ function buildNodeData(input: CanvasAddNodeInput): Record<string, unknown> {
844
+ const data = buildNodeDataForType(input);
845
+ // `strictSize` is type-agnostic — it exempts a node from BOTH the creation
846
+ // size clamp and the browser's auto-fit. It used to be applied inside each
847
+ // per-type builder, and the file / image / webpage builders return a fresh
848
+ // object without it, so those three silently dropped the flag: the clamp
849
+ // honored it (it reads `input.strictSize` directly) but nothing persisted
850
+ // `data.strictSize`, so auto-fit resized the node in the browser anyway.
851
+ // Applying it once here makes it uniform for every type.
852
+ return input.strictSize ? { ...data, strictSize: true } : data;
853
+ }
854
+
849
855
  export function mergeTraceNodeDataFields(
850
856
  base: Record<string, unknown>,
851
857
  input: Record<string, unknown>,
@@ -1709,9 +1715,30 @@ export function createCanvasGraphNode(input: GraphNodeInput): {
1709
1715
  return { id, url: String(node.data.url), spec, node };
1710
1716
  }
1711
1717
 
1718
+ /**
1719
+ * The connected workbench's actual window size, reported by the client (0.4.6
1720
+ * orb feedback #2). `fit` used to compute against a hardcoded 1440x900, so an
1721
+ * agent calling fit without dimensions produced a scale sized for a window the
1722
+ * human did not have — the board overflowed and clipped on both sides. The
1723
+ * client reports its size on connect and on resize; explicit width/height in a
1724
+ * fit call still win. In-memory only (a live client fact, never persisted);
1725
+ * with several clients connected, the most recent reporter wins.
1726
+ */
1727
+ let lastClientViewportSize: { width: number; height: number } | null = null;
1728
+
1729
+ export function setClientViewportSize(width: unknown, height: unknown): void {
1730
+ if (typeof width !== 'number' || typeof height !== 'number') return;
1731
+ if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return;
1732
+ lastClientViewportSize = { width, height };
1733
+ }
1734
+
1735
+ export function getClientViewportSize(): { width: number; height: number } | null {
1736
+ return lastClientViewportSize ? { ...lastClientViewportSize } : null;
1737
+ }
1738
+
1712
1739
  export function fitCanvasView(options: CanvasFitViewOptions = {}): CanvasFitViewResult {
1713
- const width = positiveNumber(options.width, 1440);
1714
- const height = positiveNumber(options.height, 900);
1740
+ const width = positiveNumber(options.width, lastClientViewportSize?.width ?? 1440);
1741
+ const height = positiveNumber(options.height, lastClientViewportSize?.height ?? 900);
1715
1742
  const padding = positiveNumber(options.padding, 60);
1716
1743
  const maxScale = positiveNumber(options.maxScale, 1);
1717
1744
  const nodeIdFilter = options.nodeIds && options.nodeIds.length > 0 ? new Set(options.nodeIds) : null;
@@ -1320,16 +1320,15 @@ class CanvasStateManager {
1320
1320
  }
1321
1321
 
1322
1322
  addNode(node: CanvasNodeState): void {
1323
- // Context nodes default to a right-docked, collapsed pill when created without
1324
- // an explicit dock position. CREATE-time default only — once placed, updates
1325
- // (including undock → dockPosition null) are respected (see normalizeNode).
1326
- // Skip during suppressed replay (undo/redo re-add) so a deliberately-undocked
1327
- // context node is restored verbatim instead of being snapped back to the dock.
1328
- const seeded =
1329
- node.type === 'context' && node.dockPosition == null && this._suppressRecordingDepth === 0
1330
- ? { ...node, dockPosition: 'right' as const, collapsed: true }
1331
- : node;
1332
- const cloned = structuredClone(this.normalizeNode(seeded));
1323
+ // Docking is EXPLICIT (0.4.6). Context nodes used to auto-dock to a collapsed
1324
+ // right-hand pill when created without a dock position, which meant an agent
1325
+ // creating a context node — even at explicit coordinates — got a node that
1326
+ // existed in the API and passed validation but never rendered on the canvas,
1327
+ // and any edge to it terminated in empty space (0.4.6 orb feedback #1). The
1328
+ // seeded HUD widgets (status-main / context-main) pass `dockPosition` on
1329
+ // create, so the docked pills are unaffected; anything else reaches the
1330
+ // canvas where the caller put it. Dock deliberately via node.update.
1331
+ const cloned = structuredClone(this.normalizeNode(node));
1333
1332
  this.nodes.set(node.id, cloned);
1334
1333
  this.scheduleSave();
1335
1334
  this.notifyChange('nodes');
@@ -1722,6 +1721,17 @@ class CanvasStateManager {
1722
1721
  return this.ax.getAxState();
1723
1722
  }
1724
1723
 
1724
+ /**
1725
+ * Replace the canvas-bound AX partition from a persisted blob, normalized against
1726
+ * the current node set — the same operation snapshot restore performs internally
1727
+ * (`applyPersistedState`), exposed for the demo seed. Both restore nodes whose
1728
+ * `data.axStep.workItemId` only resolves if the work items come back with their
1729
+ * original ids, so this preserves them verbatim rather than re-creating them.
1730
+ */
1731
+ applyPersistedAxState(ax: unknown): void {
1732
+ this.ax.applyPersistedAx(ax);
1733
+ }
1734
+
1725
1735
  getAxFocus(): PmxAxFocusState {
1726
1736
  return this.ax.getAxFocus();
1727
1737
  }