pmx-canvas 0.4.8 → 0.5.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 (212) hide show
  1. package/.github/extensions/pmx-canvas/extension.mjs +154 -5
  2. package/.github/extensions/pmx-canvas/steering-delivery.mjs +79 -0
  3. package/CHANGELOG.md +156 -0
  4. package/Readme.md +72 -23
  5. package/dist/canvas/global.css +3071 -796
  6. package/dist/canvas/index.js +122 -104
  7. package/dist/json-render/index.css +1 -1
  8. package/dist/types/cli/agent.d.ts +1 -0
  9. package/dist/types/cli/commands/pump.d.ts +9 -0
  10. package/dist/types/client/App.d.ts +1 -1
  11. package/dist/types/client/canvas/AgentPresenceLayer.d.ts +11 -0
  12. package/dist/types/client/canvas/BarHint.d.ts +22 -0
  13. package/dist/types/client/canvas/CanvasNode.d.ts +5 -0
  14. package/dist/types/client/canvas/CanvasViewport.d.ts +4 -4
  15. package/dist/types/client/canvas/CommandBar.d.ts +10 -0
  16. package/dist/types/client/canvas/ConnectionBanner.d.ts +13 -0
  17. package/dist/types/client/canvas/ContextMenu.d.ts +6 -0
  18. package/dist/types/client/canvas/EdgeLayer.d.ts +2 -1
  19. package/dist/types/client/canvas/EmptyState.d.ts +11 -0
  20. package/dist/types/client/canvas/ExternalWriters.d.ts +5 -0
  21. package/dist/types/client/canvas/HumanPresenceLayer.d.ts +8 -0
  22. package/dist/types/client/canvas/Minimap.d.ts +12 -1
  23. package/dist/types/client/canvas/ScopeFenceLayer.d.ts +8 -0
  24. package/dist/types/client/canvas/SelectionBar.d.ts +6 -0
  25. package/dist/types/client/canvas/SessionPanel.d.ts +1 -0
  26. package/dist/types/client/canvas/SessionReceipt.d.ts +17 -0
  27. package/dist/types/client/canvas/TextPrompt.d.ts +25 -0
  28. package/dist/types/client/canvas/ToolRail.d.ts +21 -0
  29. package/dist/types/client/canvas/TopBar.d.ts +7 -0
  30. package/dist/types/client/canvas/canvas-area.d.ts +39 -0
  31. package/dist/types/client/canvas/create-in-view.d.ts +18 -0
  32. package/dist/types/client/canvas/import-files.d.ts +8 -0
  33. package/dist/types/client/canvas/kind-colors.d.ts +3 -0
  34. package/dist/types/client/canvas/use-focus-trap.d.ts +14 -0
  35. package/dist/types/client/canvas/use-now.d.ts +6 -0
  36. package/dist/types/client/icons.d.ts +11 -4
  37. package/dist/types/client/nodes/ExtAppFrame.d.ts +2 -0
  38. package/dist/types/client/nodes/GroupNode.d.ts +6 -1
  39. package/dist/types/client/nodes/MarkdownNode.d.ts +2 -0
  40. package/dist/types/client/nodes/inline-editor-commands.d.ts +3 -4
  41. package/dist/types/client/state/canvas-store.d.ts +49 -5
  42. package/dist/types/client/state/human-store.d.ts +26 -0
  43. package/dist/types/client/state/iframe-mode.d.ts +1 -0
  44. package/dist/types/client/state/intent-bridge.d.ts +37 -5
  45. package/dist/types/client/state/node-factory.d.ts +0 -1
  46. package/dist/types/client/state/presence-store.d.ts +78 -0
  47. package/dist/types/client/state/session-store.d.ts +176 -0
  48. package/dist/types/client/types.d.ts +2 -1
  49. package/dist/types/client/utils/platform.d.ts +2 -0
  50. package/dist/types/server/agent-presence.d.ts +192 -0
  51. package/dist/types/server/ax-gate-ttl.d.ts +6 -0
  52. package/dist/types/server/ax-interaction.d.ts +4 -10
  53. package/dist/types/server/ax-state-manager.d.ts +21 -2
  54. package/dist/types/server/ax-state.d.ts +49 -3
  55. package/dist/types/server/ax-wait.d.ts +13 -0
  56. package/dist/types/server/canvas-db.d.ts +9 -1
  57. package/dist/types/server/canvas-operations.d.ts +11 -0
  58. package/dist/types/server/canvas-state.d.ts +27 -13
  59. package/dist/types/server/canvas-validation.d.ts +0 -14
  60. package/dist/types/server/html-surface.d.ts +1 -1
  61. package/dist/types/server/human-presence.d.ts +40 -0
  62. package/dist/types/server/index.d.ts +29 -3
  63. package/dist/types/server/intent-registry.d.ts +7 -0
  64. package/dist/types/server/mutation-history.d.ts +15 -2
  65. package/dist/types/server/operations/invoker.d.ts +13 -2
  66. package/dist/types/server/operations/ops/ax-presence.d.ts +2 -0
  67. package/dist/types/server/operations/ops/human-presence.d.ts +3 -0
  68. package/dist/types/server/operations/registry.d.ts +11 -0
  69. package/dist/types/server/operations/types.d.ts +1 -1
  70. package/dist/types/server/scope-fence.d.ts +62 -0
  71. package/dist/types/server/server.d.ts +0 -12
  72. package/dist/types/shared/agent-presence.d.ts +130 -0
  73. package/dist/types/shared/approval-gates.d.ts +19 -0
  74. package/dist/types/shared/auto-arrange.d.ts +0 -1
  75. package/dist/types/shared/ax-kinds.d.ts +11 -0
  76. package/dist/types/shared/human-presence.d.ts +31 -0
  77. package/dist/types/shared/iframe-probe.d.ts +8 -0
  78. package/dist/types/shared/scope-fence.d.ts +21 -0
  79. package/docs/RELEASE.md +20 -0
  80. package/docs/ax-host-adapter-contract.md +30 -14
  81. package/docs/cli.md +37 -1
  82. package/docs/environment.md +3 -0
  83. package/docs/http-api.md +222 -5
  84. package/docs/mcp.md +6 -5
  85. package/docs/node-types.md +3 -1
  86. package/docs/plans/plan-009-tech-debt-backlog.md +13 -0
  87. package/docs/screenshot.png +0 -0
  88. package/docs/screenshots/multi-agent-live.png +0 -0
  89. package/docs/screenshots/workbench-live-dark.png +0 -0
  90. package/docs/screenshots/workbench-live-light.png +0 -0
  91. package/docs/sdk.md +1 -1
  92. package/package.json +4 -4
  93. package/skills/pmx-canvas/SKILL.md +112 -17
  94. package/skills/pmx-canvas/references/codex-app-adapter.md +28 -0
  95. package/skills/pmx-canvas/references/full-reference.md +55 -16
  96. package/skills/pmx-canvas/references/github-copilot-app-adapter.md +41 -24
  97. package/skills/pmx-canvas-orchestration/SKILL.md +56 -2
  98. package/skills/pmx-canvas-testing/SKILL.md +17 -0
  99. package/src/cli/agent.ts +1 -0
  100. package/src/cli/commands/copilot.ts +15 -8
  101. package/src/cli/commands/nodes.ts +1 -19
  102. package/src/cli/commands/pump.ts +263 -0
  103. package/src/cli/index.ts +2 -5
  104. package/src/cli/shared.ts +1 -1
  105. package/src/client/App.tsx +209 -585
  106. package/src/client/canvas/AgentPresenceLayer.tsx +74 -0
  107. package/src/client/canvas/AttentionHistory.tsx +0 -10
  108. package/src/client/canvas/BarHint.tsx +49 -0
  109. package/src/client/canvas/CanvasNode.tsx +488 -136
  110. package/src/client/canvas/CanvasViewport.tsx +190 -94
  111. package/src/client/canvas/CommandBar.tsx +140 -0
  112. package/src/client/canvas/CommandPalette.tsx +133 -58
  113. package/src/client/canvas/ConnectionBanner.tsx +65 -0
  114. package/src/client/canvas/ContextMenu.tsx +88 -27
  115. package/src/client/canvas/ContextPinBar.tsx +2 -2
  116. package/src/client/canvas/EdgeLayer.tsx +121 -38
  117. package/src/client/canvas/EmptyState.tsx +93 -0
  118. package/src/client/canvas/ExpandedNodeOverlay.tsx +64 -118
  119. package/src/client/canvas/ExternalWriters.tsx +308 -0
  120. package/src/client/canvas/FocusFieldLayer.tsx +12 -1
  121. package/src/client/canvas/HumanPresenceLayer.tsx +45 -0
  122. package/src/client/canvas/IntentLayer.tsx +1 -1
  123. package/src/client/canvas/Minimap.tsx +117 -190
  124. package/src/client/canvas/ScopeFenceLayer.tsx +38 -0
  125. package/src/client/canvas/SelectionBar.tsx +175 -23
  126. package/src/client/canvas/SessionPanel.tsx +481 -0
  127. package/src/client/canvas/SessionReceipt.tsx +141 -0
  128. package/src/client/canvas/ShortcutOverlay.tsx +21 -6
  129. package/src/client/canvas/SnapshotPanel.tsx +166 -80
  130. package/src/client/canvas/TextPrompt.tsx +128 -0
  131. package/src/client/canvas/ToolRail.tsx +447 -0
  132. package/src/client/canvas/TopBar.tsx +308 -0
  133. package/src/client/canvas/auto-fit.ts +1 -5
  134. package/src/client/canvas/canvas-area.ts +74 -0
  135. package/src/client/canvas/create-in-view.ts +48 -0
  136. package/src/client/canvas/import-files.ts +64 -0
  137. package/src/client/canvas/kind-colors.ts +22 -0
  138. package/src/client/canvas/snap-guides.ts +1 -1
  139. package/src/client/canvas/use-focus-trap.ts +54 -0
  140. package/src/client/canvas/use-now.ts +17 -0
  141. package/src/client/canvas/use-pan-zoom.ts +13 -2
  142. package/src/client/icons.tsx +57 -18
  143. package/src/client/nodes/ExtAppFrame.tsx +86 -20
  144. package/src/client/nodes/GroupNode.tsx +9 -28
  145. package/src/client/nodes/InlineFormatBar.tsx +4 -3
  146. package/src/client/nodes/InlineMarkdownEditor.tsx +27 -0
  147. package/src/client/nodes/MarkdownNode.tsx +59 -24
  148. package/src/client/nodes/MdFormatBar.tsx +2 -4
  149. package/src/client/nodes/inline-editor-commands.ts +17 -6
  150. package/src/client/nodes/use-surface-frame.ts +3 -1
  151. package/src/client/state/attention-bridge.ts +3 -0
  152. package/src/client/state/canvas-store.ts +285 -90
  153. package/src/client/state/human-store.ts +175 -0
  154. package/src/client/state/iframe-mode.ts +33 -9
  155. package/src/client/state/intent-bridge.ts +75 -13
  156. package/src/client/state/node-factory.ts +2 -8
  157. package/src/client/state/presence-store.ts +168 -0
  158. package/src/client/state/session-store.ts +419 -0
  159. package/src/client/state/sse-bridge.ts +93 -30
  160. package/src/client/theme/global.css +3071 -796
  161. package/src/client/types.ts +3 -1
  162. package/src/client/utils/platform.ts +6 -1
  163. package/src/mcp/canvas-access.ts +2 -2
  164. package/src/server/agent-presence.ts +755 -0
  165. package/src/server/ax-context.ts +45 -0
  166. package/src/server/ax-gate-ttl.ts +61 -0
  167. package/src/server/ax-interaction.ts +29 -1
  168. package/src/server/ax-state-manager.ts +78 -6
  169. package/src/server/ax-state.ts +82 -15
  170. package/src/server/ax-wait.ts +33 -0
  171. package/src/server/canvas-db.ts +103 -33
  172. package/src/server/canvas-operations.ts +58 -14
  173. package/src/server/canvas-state.ts +131 -58
  174. package/src/server/canvas-validation.ts +3 -53
  175. package/src/server/demo-state.json +2 -70
  176. package/src/server/html-surface.ts +11 -2
  177. package/src/server/human-presence.ts +160 -0
  178. package/src/server/index.ts +111 -6
  179. package/src/server/intent-registry.ts +13 -0
  180. package/src/server/mutation-history.ts +47 -2
  181. package/src/server/operations/composites.ts +6 -3
  182. package/src/server/operations/http.ts +10 -1
  183. package/src/server/operations/index.ts +4 -0
  184. package/src/server/operations/invoker.ts +33 -6
  185. package/src/server/operations/ops/ax-presence.ts +62 -0
  186. package/src/server/operations/ops/ax-read.ts +8 -0
  187. package/src/server/operations/ops/ax-state.ts +106 -1
  188. package/src/server/operations/ops/ax-timeline.ts +54 -5
  189. package/src/server/operations/ops/ax-work.ts +46 -0
  190. package/src/server/operations/ops/batch.ts +3 -1
  191. package/src/server/operations/ops/edges.ts +88 -2
  192. package/src/server/operations/ops/groups.ts +4 -3
  193. package/src/server/operations/ops/human-presence.ts +35 -0
  194. package/src/server/operations/ops/json-render.ts +11 -1
  195. package/src/server/operations/ops/nodes.ts +77 -16
  196. package/src/server/operations/ops/query.ts +1 -0
  197. package/src/server/operations/ops/snapshots.ts +7 -4
  198. package/src/server/operations/registry.ts +196 -1
  199. package/src/server/operations/types.ts +1 -1
  200. package/src/server/scope-fence.ts +207 -0
  201. package/src/server/server.ts +177 -79
  202. package/src/server/trace-manager.ts +0 -2
  203. package/src/server/web-artifacts.ts +0 -1
  204. package/src/shared/agent-presence.ts +162 -0
  205. package/src/shared/approval-gates.ts +35 -0
  206. package/src/shared/auto-arrange.ts +121 -13
  207. package/src/shared/ax-kinds.ts +23 -0
  208. package/src/shared/human-presence.ts +30 -0
  209. package/src/shared/iframe-probe.ts +8 -0
  210. package/src/shared/scope-fence.ts +31 -0
  211. package/dist/types/client/canvas/DockedNode.d.ts +0 -4
  212. package/src/client/canvas/DockedNode.tsx +0 -165
@@ -65,7 +65,6 @@ const SCHEMA_SQL = `
65
65
  z_index INTEGER NOT NULL DEFAULT 0,
66
66
  collapsed INTEGER NOT NULL DEFAULT 0,
67
67
  pinned INTEGER NOT NULL DEFAULT 0,
68
- dock_position TEXT,
69
68
  data TEXT NOT NULL
70
69
  );
71
70
 
@@ -119,7 +118,6 @@ const SCHEMA_SQL = `
119
118
  z_index INTEGER NOT NULL DEFAULT 0,
120
119
  collapsed INTEGER NOT NULL DEFAULT 0,
121
120
  pinned INTEGER NOT NULL DEFAULT 0,
122
- dock_position TEXT,
123
121
  data TEXT NOT NULL,
124
122
  PRIMARY KEY (snapshot_id, id)
125
123
  );
@@ -209,6 +207,13 @@ const SCHEMA_SQL = `
209
207
  );
210
208
  CREATE INDEX IF NOT EXISTS idx_ax_steering_seq ON ax_steering (seq);
211
209
 
210
+ CREATE TABLE IF NOT EXISTS ax_steering_deliveries (
211
+ steering_id TEXT NOT NULL,
212
+ consumer TEXT NOT NULL,
213
+ at TEXT NOT NULL,
214
+ PRIMARY KEY (steering_id, consumer)
215
+ );
216
+
212
217
  CREATE TABLE IF NOT EXISTS ax_host_capabilities (
213
218
  host TEXT PRIMARY KEY,
214
219
  reported_at TEXT NOT NULL,
@@ -397,18 +402,17 @@ export function saveStateToDB(db: Database, state: PersistedCanvasState): void {
397
402
 
398
403
  // Save nodes
399
404
  const upsertNode = db.prepare(
400
- `INSERT INTO nodes (id, type, pos_x, pos_y, width, height, z_index, collapsed, pinned, dock_position, data)
401
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
405
+ `INSERT INTO nodes (id, type, pos_x, pos_y, width, height, z_index, collapsed, pinned, data)
406
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
402
407
  ON CONFLICT(id) DO UPDATE SET
403
408
  type = excluded.type, pos_x = excluded.pos_x, pos_y = excluded.pos_y,
404
409
  width = excluded.width, height = excluded.height, z_index = excluded.z_index,
405
- collapsed = excluded.collapsed, pinned = excluded.pinned,
406
- dock_position = excluded.dock_position, data = excluded.data
410
+ collapsed = excluded.collapsed, pinned = excluded.pinned, data = excluded.data
407
411
  WHERE type IS NOT excluded.type OR pos_x IS NOT excluded.pos_x
408
412
  OR pos_y IS NOT excluded.pos_y OR width IS NOT excluded.width
409
413
  OR height IS NOT excluded.height OR z_index IS NOT excluded.z_index
410
414
  OR collapsed IS NOT excluded.collapsed OR pinned IS NOT excluded.pinned
411
- OR dock_position IS NOT excluded.dock_position OR data IS NOT excluded.data`,
415
+ OR data IS NOT excluded.data`,
412
416
  );
413
417
  for (const node of state.nodes) {
414
418
  upsertNode.run(
@@ -421,7 +425,6 @@ export function saveStateToDB(db: Database, state: PersistedCanvasState): void {
421
425
  node.zIndex,
422
426
  node.collapsed ? 1 : 0,
423
427
  node.pinned ? 1 : 0,
424
- node.dockPosition,
425
428
  JSON.stringify(node.data),
426
429
  );
427
430
  }
@@ -535,7 +538,6 @@ export function loadStateFromDB(db: Database): PersistedCanvasState | null {
535
538
  z_index: number;
536
539
  collapsed: number;
537
540
  pinned: number;
538
- dock_position: string | null;
539
541
  data: string;
540
542
  }
541
543
  const nodeRows = db.query<NodeRow, []>('SELECT * FROM nodes').all();
@@ -547,7 +549,6 @@ export function loadStateFromDB(db: Database): PersistedCanvasState | null {
547
549
  zIndex: row.z_index,
548
550
  collapsed: row.collapsed === 1,
549
551
  pinned: row.pinned === 1,
550
- dockPosition: row.dock_position as CanvasNodeState['dockPosition'],
551
552
  data: JSON.parse(row.data) as Record<string, unknown>,
552
553
  }));
553
554
 
@@ -655,8 +656,8 @@ export function saveSnapshotToDB(db: Database, snapshot: CanvasSnapshot, state:
655
656
 
656
657
  // Insert snapshot nodes
657
658
  const insertNode = db.prepare(
658
- `INSERT INTO snapshot_nodes (snapshot_id, id, type, pos_x, pos_y, width, height, z_index, collapsed, pinned, dock_position, data)
659
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
659
+ `INSERT INTO snapshot_nodes (snapshot_id, id, type, pos_x, pos_y, width, height, z_index, collapsed, pinned, data)
660
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
660
661
  );
661
662
  for (const node of state.nodes) {
662
663
  insertNode.run(
@@ -670,7 +671,6 @@ export function saveSnapshotToDB(db: Database, snapshot: CanvasSnapshot, state:
670
671
  node.zIndex,
671
672
  node.collapsed ? 1 : 0,
672
673
  node.pinned ? 1 : 0,
673
- node.dockPosition,
674
674
  JSON.stringify(node.data),
675
675
  );
676
676
  }
@@ -780,7 +780,6 @@ export function loadSnapshotFromDB(
780
780
  z_index: number;
781
781
  collapsed: number;
782
782
  pinned: number;
783
- dock_position: string | null;
784
783
  data: string;
785
784
  }
786
785
  const nodeRows = db
@@ -794,7 +793,6 @@ export function loadSnapshotFromDB(
794
793
  zIndex: row.z_index,
795
794
  collapsed: row.collapsed === 1,
796
795
  pinned: row.pinned === 1,
797
- dockPosition: row.dock_position as CanvasNodeState['dockPosition'],
798
796
  data: JSON.parse(row.data) as Record<string, unknown>,
799
797
  }));
800
798
 
@@ -918,6 +916,10 @@ export function listSnapshotsFromDB(db: Database, options: CanvasSnapshotListOpt
918
916
  }));
919
917
  }
920
918
 
919
+ export function renameSnapshotInDB(db: Database, id: string, name: string): boolean {
920
+ return db.run('UPDATE snapshots SET name = ? WHERE id = ?', [name, id]).changes > 0;
921
+ }
922
+
921
923
  export function deleteSnapshotFromDB(db: Database, id: string): boolean {
922
924
  const transaction = db.transaction(() => {
923
925
  db.run('DELETE FROM snapshot_nodes WHERE snapshot_id = ?', [id]);
@@ -979,6 +981,9 @@ function trimAxTable(db: Database, table: 'ax_events' | 'ax_evidence' | 'ax_stee
979
981
  db.run(`DELETE FROM ${table} WHERE seq <= (SELECT seq FROM ${table} ORDER BY seq DESC LIMIT 1 OFFSET ?)`, [
980
982
  AX_TIMELINE_RETENTION,
981
983
  ]);
984
+ if (table === 'ax_steering') {
985
+ db.run('DELETE FROM ax_steering_deliveries WHERE steering_id NOT IN (SELECT id FROM ax_steering)');
986
+ }
982
987
  }
983
988
 
984
989
  function readLastSeq(db: Database, table: 'ax_events' | 'ax_evidence' | 'ax_steering'): number {
@@ -1036,10 +1041,28 @@ export function appendAxSteeringToDB(db: Database, s: Omit<PmxAxSteeringMessage,
1036
1041
  return { ...s, seq };
1037
1042
  }
1038
1043
 
1039
- export function markAxSteeringDeliveredInDB(db: Database, id: string): boolean {
1040
- // Compare-and-set: true ONLY on the undelivered→delivered transition. Two
1041
- // adapters racing the same steer see exactly one true — the loser (and any
1042
- // re-mark) gets false and must not treat the message as its own delivery.
1044
+ export function markAxSteeringDeliveredInDB(db: Database, id: string, consumer?: string | null): boolean {
1045
+ // ADDRESSED steer (target set): global compare-and-set as before — one
1046
+ // legitimate recipient, true only on the undelivered→delivered transition.
1047
+ // BROADCAST + consumer: per-consumer delivery ("all workers: stop" must
1048
+ // reach every worker) — the mark records THIS consumer's pickup and the
1049
+ // message stays pending for everyone else; true iff this consumer had not
1050
+ // marked it before. BROADCAST without a consumer: the legacy anonymous ack
1051
+ // (global CAS) that hides it from all consumers.
1052
+ const row = db.query<{ target: string | null }, [string]>('SELECT target FROM ax_steering WHERE id = ?').get(id);
1053
+ if (!row) return false;
1054
+ // An addressed steer belongs to its target. A different named consumer marking
1055
+ // it delivered would silently swallow the human's instruction (the target then
1056
+ // never sees it). A consumer-less mark is still the single legitimate host ack.
1057
+ if (row.target !== null && consumer && consumer !== row.target) return false;
1058
+ if (row.target === null && consumer) {
1059
+ const r = db.run('INSERT OR IGNORE INTO ax_steering_deliveries (steering_id, consumer, at) VALUES (?, ?, ?)', [
1060
+ id,
1061
+ consumer,
1062
+ new Date().toISOString(),
1063
+ ]);
1064
+ return r.changes > 0;
1065
+ }
1043
1066
  const r = db.run('UPDATE ax_steering SET delivered = 1 WHERE id = ? AND delivered = 0', [id]);
1044
1067
  return r.changes > 0;
1045
1068
  }
@@ -1112,6 +1135,18 @@ interface AxSteeringRow {
1112
1135
  target: string | null;
1113
1136
  }
1114
1137
 
1138
+ /**
1139
+ * Broadcasts age out of PENDING (they stay on the timeline): per-consumer
1140
+ * delivery otherwise means every broadcast greets every FUTURE consumer key
1141
+ * forever — a brand-new pump inherited two-day-old "all workers" calls.
1142
+ * Addressed steers never expire here; they wait for their named consumer.
1143
+ */
1144
+ export const BROADCAST_PENDING_TTL_MS = 15 * 60_000;
1145
+
1146
+ function broadcastCutoffIso(): string {
1147
+ return new Date(Date.now() - BROADCAST_PENDING_TTL_MS).toISOString();
1148
+ }
1149
+
1115
1150
  function mapAxSteeringRow(r: AxSteeringRow): PmxAxSteeringMessage | null {
1116
1151
  return normalizeAxSteeringMessage({
1117
1152
  ...r,
@@ -1129,7 +1164,27 @@ export function loadAxSteeringFromDB(
1129
1164
  ? 'SELECT * FROM ax_steering WHERE delivered = 0 ORDER BY seq DESC LIMIT ?'
1130
1165
  : 'SELECT * FROM ax_steering ORDER BY seq DESC LIMIT ?';
1131
1166
  const rows = db.query<AxSteeringRow, [number]>(sql).all(clampTimelineLimit(q.limit));
1132
- return rows.map(mapAxSteeringRow).filter((s): s is PmxAxSteeringMessage => s !== null);
1167
+ const deliveredTo = new Map<string, string[]>();
1168
+ for (const row of db
1169
+ .query<{ steering_id: string; consumer: string }, []>('SELECT steering_id, consumer FROM ax_steering_deliveries')
1170
+ .all()) {
1171
+ if (!deliveredTo.has(row.steering_id)) deliveredTo.set(row.steering_id, []);
1172
+ deliveredTo.get(row.steering_id)!.push(row.consumer);
1173
+ }
1174
+ return rows
1175
+ .map((row) => {
1176
+ const mapped = mapAxSteeringRow(row);
1177
+ if (!mapped) return null;
1178
+ const pickups = deliveredTo.get(mapped.id) ?? [];
1179
+ // A broadcast reads as delivered once ANY consumer picked it up; the
1180
+ // per-consumer list is exposed for fleet UIs ("picked up by 3").
1181
+ return {
1182
+ ...mapped,
1183
+ delivered: mapped.delivered || pickups.length > 0,
1184
+ ...(pickups.length > 0 ? { deliveredTo: pickups } : {}),
1185
+ };
1186
+ })
1187
+ .filter((s): s is PmxAxSteeringMessage => s !== null);
1133
1188
  }
1134
1189
 
1135
1190
  export function loadPendingAxSteeringFromDB(
@@ -1142,13 +1197,20 @@ export function loadPendingAxSteeringFromDB(
1142
1197
  const limit = clampTimelineLimit(options.limit);
1143
1198
  const rows = options.consumer
1144
1199
  ? db
1145
- .query<AxSteeringRow, [string, string, string, number]>(
1146
- 'SELECT * FROM ax_steering WHERE delivered = 0 AND (source IS NULL OR source != ?) AND (agent_id IS NULL OR agent_id != ?) AND (target IS NULL OR target = ?) ORDER BY seq ASC LIMIT ?',
1200
+ .query<AxSteeringRow, [string, string, string, string, string, number]>(
1201
+ // A broadcast stays pending PER CONSUMER: it leaves this consumer's
1202
+ // queue only once THIS consumer marked it (or an anonymous global ack
1203
+ // set delivered).
1204
+ 'SELECT * FROM ax_steering s WHERE s.delivered = 0 AND (s.source IS NULL OR s.source != ?) AND (s.agent_id IS NULL OR s.agent_id != ?) AND (s.target IS NULL OR s.target = ?) AND (s.target IS NOT NULL OR s.created_at > ?) AND NOT EXISTS (SELECT 1 FROM ax_steering_deliveries d WHERE d.steering_id = s.id AND d.consumer = ?) ORDER BY s.seq ASC LIMIT ?',
1147
1205
  )
1148
- .all(options.consumer, options.consumer, options.consumer, limit)
1206
+ .all(options.consumer, options.consumer, options.consumer, broadcastCutoffIso(), options.consumer, limit)
1149
1207
  : db
1150
- .query<AxSteeringRow, [number]>('SELECT * FROM ax_steering WHERE delivered = 0 ORDER BY seq ASC LIMIT ?')
1151
- .all(limit);
1208
+ .query<AxSteeringRow, [string, number]>(
1209
+ // No consumer given: only broadcasts — addressed steering is claimable
1210
+ // solely by the consumer it names.
1211
+ 'SELECT * FROM ax_steering WHERE delivered = 0 AND target IS NULL AND created_at > ? ORDER BY seq ASC LIMIT ?',
1212
+ )
1213
+ .all(broadcastCutoffIso(), limit);
1152
1214
  return rows.map(mapAxSteeringRow).filter((s): s is PmxAxSteeringMessage => s !== null);
1153
1215
  }
1154
1216
 
@@ -1167,25 +1229,33 @@ export function loadNewestPendingAxSteeringFromDB(
1167
1229
  const limit = clampTimelineLimit(options.limit);
1168
1230
  const rows = options.consumer
1169
1231
  ? db
1170
- .query<AxSteeringRow, [string, string, string, number]>(
1171
- 'SELECT * FROM ax_steering WHERE delivered = 0 AND (source IS NULL OR source != ?) AND (agent_id IS NULL OR agent_id != ?) AND (target IS NULL OR target = ?) ORDER BY seq DESC LIMIT ?',
1232
+ .query<AxSteeringRow, [string, string, string, string, string, number]>(
1233
+ 'SELECT * FROM ax_steering s WHERE s.delivered = 0 AND (s.source IS NULL OR s.source != ?) AND (s.agent_id IS NULL OR s.agent_id != ?) AND (s.target IS NULL OR s.target = ?) AND (s.target IS NOT NULL OR s.created_at > ?) AND NOT EXISTS (SELECT 1 FROM ax_steering_deliveries d WHERE d.steering_id = s.id AND d.consumer = ?) ORDER BY s.seq DESC LIMIT ?',
1172
1234
  )
1173
- .all(options.consumer, options.consumer, options.consumer, limit)
1235
+ .all(options.consumer, options.consumer, options.consumer, broadcastCutoffIso(), options.consumer, limit)
1174
1236
  : db
1175
- .query<AxSteeringRow, [number]>('SELECT * FROM ax_steering WHERE delivered = 0 ORDER BY seq DESC LIMIT ?')
1237
+ .query<AxSteeringRow, [number]>(
1238
+ 'SELECT * FROM ax_steering WHERE delivered = 0 AND target IS NULL ORDER BY seq DESC LIMIT ?',
1239
+ )
1176
1240
  .all(limit);
1177
1241
  return rows.map(mapAxSteeringRow).filter((s): s is PmxAxSteeringMessage => s !== null);
1178
1242
  }
1179
1243
 
1180
1244
  /** Total undelivered steering for a consumer (loop-safe — excludes the consumer's own; target-scoped). */
1181
1245
  export function countPendingAxSteeringFromDB(db: Database, consumer?: string): number {
1246
+ // Must mirror loadPendingAxSteeringFromDB exactly (per-consumer deliveries,
1247
+ // broadcast TTL) — the roster shows this as "what would this consumer claim".
1182
1248
  const n = consumer
1183
1249
  ? db
1184
- .query<{ n: number }, [string, string, string]>(
1185
- 'SELECT COUNT(*) AS n FROM ax_steering WHERE delivered = 0 AND (source IS NULL OR source != ?) AND (agent_id IS NULL OR agent_id != ?) AND (target IS NULL OR target = ?)',
1250
+ .query<{ n: number }, [string, string, string, string, string]>(
1251
+ 'SELECT COUNT(*) AS n FROM ax_steering s WHERE s.delivered = 0 AND (s.source IS NULL OR s.source != ?) AND (s.agent_id IS NULL OR s.agent_id != ?) AND (s.target IS NULL OR s.target = ?) AND (s.target IS NOT NULL OR s.created_at > ?) AND NOT EXISTS (SELECT 1 FROM ax_steering_deliveries d WHERE d.steering_id = s.id AND d.consumer = ?)',
1252
+ )
1253
+ .get(consumer, consumer, consumer, broadcastCutoffIso(), consumer)?.n
1254
+ : db
1255
+ .query<{ n: number }, [string]>(
1256
+ 'SELECT COUNT(*) AS n FROM ax_steering WHERE delivered = 0 AND target IS NULL AND created_at > ?',
1186
1257
  )
1187
- .get(consumer, consumer, consumer)?.n
1188
- : db.query<{ n: number }, []>('SELECT COUNT(*) AS n FROM ax_steering WHERE delivered = 0').get()?.n;
1258
+ .get(broadcastCutoffIso())?.n;
1189
1259
  return Number(n ?? 0);
1190
1260
  }
1191
1261
 
@@ -1,6 +1,8 @@
1
1
  import { existsSync, readFileSync, statSync } from 'node:fs';
2
2
  import { basename, resolve } from 'node:path';
3
+ import { agentPresence } from './agent-presence.js';
3
4
  import { recomputeCodeGraph } from './code-graph.js';
5
+ import { diffLayouts } from './mutation-history.js';
4
6
  import {
5
7
  canvasState,
6
8
  type CanvasEdge,
@@ -946,7 +948,6 @@ export function addCanvasNode(input: CanvasAddNodeInput): {
946
948
  zIndex: 1,
947
949
  collapsed: false,
948
950
  pinned: false,
949
- dockPosition: null,
950
951
  data,
951
952
  };
952
953
 
@@ -1133,7 +1134,7 @@ export function removeCanvasNode(id: string): {
1133
1134
  }
1134
1135
 
1135
1136
  function isArrangeLocked(node: CanvasNodeState): boolean {
1136
- return node.pinned || node.dockPosition !== null || node.data.arrangeLocked === true;
1137
+ return node.pinned || node.data.arrangeLocked === true;
1137
1138
  }
1138
1139
 
1139
1140
  function collectArrangeExcludedNodeIds(nodes: CanvasNodeState[]): Set<string> {
@@ -1219,7 +1220,7 @@ function rectsOverlap(a: ArrangeObstacleRect, b: ArrangeObstacleRect): boolean {
1219
1220
 
1220
1221
  function collectGridArrangeObstacles(nodes: CanvasNodeState[], excludedIds: Set<string>): ArrangeObstacleRect[] {
1221
1222
  return nodes
1222
- .filter((node) => excludedIds.has(node.id) && node.dockPosition === null)
1223
+ .filter((node) => excludedIds.has(node.id))
1223
1224
  .map((node) => ({
1224
1225
  id: node.id,
1225
1226
  position: { ...node.position },
@@ -1247,7 +1248,7 @@ function shiftGridUpdatesBelowObstacles(
1247
1248
  ): CanvasNodeUpdate[] {
1248
1249
  if (updates.length === 0 || obstacles.length === 0) return updates;
1249
1250
 
1250
- // Grid arrange only sees movable nodes, so preserved locked/docked-group frames
1251
+ // Grid arrange only sees movable nodes, so preserved locked-group frames
1251
1252
  // need a separate obstacle pass before applying the planned positions.
1252
1253
  const nodesById = new Map(nodes.map((node) => [node.id, node]));
1253
1254
  let shifted = updates;
@@ -1397,6 +1398,30 @@ export function listCanvasSnapshots(options?: Parameters<typeof canvasState.list
1397
1398
  return canvasState.listSnapshots(options);
1398
1399
  }
1399
1400
 
1401
+ /**
1402
+ * Snapshot save with unchanged-board reuse — the ONE path every surface must
1403
+ * take (op registry, SDK). The SDK once called saveCanvasSnapshot directly and
1404
+ * stacked identical snapshots the HTTP/MCP path deduplicated (0.5.0 Codex
1405
+ * finding V).
1406
+ */
1407
+ export function saveCanvasSnapshotWithReuse(name: string): { snapshot: CanvasSnapshot | null; reused: boolean } {
1408
+ const newest = canvasState.listSnapshots({ limit: 1 })[0];
1409
+ if (newest) {
1410
+ const newestData = canvasState.getSnapshotData(newest.id);
1411
+ if (newestData) {
1412
+ const diff = diffLayouts(newestData.name, newestData, canvasState.getLayout());
1413
+ const identical =
1414
+ diff.addedNodes.length === 0 &&
1415
+ diff.removedNodes.length === 0 &&
1416
+ diff.modifiedNodes.length === 0 &&
1417
+ diff.addedEdges.length === 0 &&
1418
+ diff.removedEdges.length === 0;
1419
+ if (identical) return { snapshot: newest, reused: true };
1420
+ }
1421
+ }
1422
+ return { snapshot: saveCanvasSnapshot(name), reused: false };
1423
+ }
1424
+
1400
1425
  export function saveCanvasSnapshot(name: string): CanvasSnapshot | null {
1401
1426
  return canvasState.saveSnapshot(name);
1402
1427
  }
@@ -1465,6 +1490,20 @@ export function addCanvasEdge(input: {
1465
1490
  return edge;
1466
1491
  }
1467
1492
 
1493
+ export function updateCanvasEdge(
1494
+ id: string,
1495
+ patch: Partial<Pick<CanvasEdge, 'type' | 'label' | 'style' | 'animated'>>,
1496
+ ): CanvasEdge {
1497
+ if (!canvasState.getEdges().some((edge) => edge.id === id)) {
1498
+ throw new Error(`Edge "${id}" not found.`);
1499
+ }
1500
+ const updated = canvasState.updateEdge(id, patch);
1501
+ if (!updated) {
1502
+ throw new Error('Retyping would duplicate an existing edge.');
1503
+ }
1504
+ return updated;
1505
+ }
1506
+
1468
1507
  export function removeCanvasEdge(id: string): { removed: boolean } {
1469
1508
  return { removed: canvasState.removeEdge(id) };
1470
1509
  }
@@ -1512,7 +1551,6 @@ export function createCanvasGroup(input: CanvasCreateGroupInput): { id: string;
1512
1551
  zIndex: 0,
1513
1552
  collapsed: false,
1514
1553
  pinned: false,
1515
- dockPosition: null,
1516
1554
  data,
1517
1555
  });
1518
1556
 
@@ -1583,7 +1621,6 @@ export function createCanvasJsonRenderNode(input: JsonRenderNodeInput): {
1583
1621
  zIndex: 1,
1584
1622
  collapsed: false,
1585
1623
  pinned: false,
1586
- dockPosition: null,
1587
1624
  data: createJsonRenderNodeData(id, input.title?.trim() || inferJsonRenderNodeTitle(spec), spec, {
1588
1625
  viewerType: 'json-render',
1589
1626
  ...(input.strictSize ? { strictSize: true } : {}),
@@ -1627,7 +1664,6 @@ export function createCanvasStreamingJsonRenderNode(input: {
1627
1664
  zIndex: 1,
1628
1665
  collapsed: false,
1629
1666
  pinned: false,
1630
- dockPosition: null,
1631
1667
  data: createJsonRenderNodeData(id, input.title?.trim() || 'Streaming', spec, {
1632
1668
  viewerType: 'json-render',
1633
1669
  streamStatus: 'open',
@@ -1703,7 +1739,6 @@ export function createCanvasGraphNode(input: GraphNodeInput): {
1703
1739
  zIndex: 1,
1704
1740
  collapsed: false,
1705
1741
  pinned: false,
1706
- dockPosition: null,
1707
1742
  data: createJsonRenderNodeData(id, title, spec, {
1708
1743
  viewerType: 'graph',
1709
1744
  graphConfig: buildGraphConfig(input),
@@ -1739,7 +1774,7 @@ export function getClientViewportSize(): { width: number; height: number } | nul
1739
1774
  export function fitCanvasView(options: CanvasFitViewOptions = {}): CanvasFitViewResult {
1740
1775
  const width = positiveNumber(options.width, lastClientViewportSize?.width ?? 1440);
1741
1776
  const height = positiveNumber(options.height, lastClientViewportSize?.height ?? 900);
1742
- const padding = positiveNumber(options.padding, 60);
1777
+ const padding = positiveNumber(options.padding, 24); // screen px now, not world units
1743
1778
  const maxScale = positiveNumber(options.maxScale, 1);
1744
1779
  const nodeIdFilter = options.nodeIds && options.nodeIds.length > 0 ? new Set(options.nodeIds) : null;
1745
1780
  const targetNodes = canvasState.getLayout().nodes.filter((node) => !nodeIdFilter || nodeIdFilter.has(node.id));
@@ -1761,14 +1796,23 @@ export function fitCanvasView(options: CanvasFitViewOptions = {}): CanvasFitView
1761
1796
  }
1762
1797
 
1763
1798
  const bounds = { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
1764
- const worldWidth = Math.max(1, bounds.width + padding * 2);
1765
- const worldHeight = Math.max(1, bounds.height + padding * 2);
1766
- const scale = Math.min(maxScale, width / worldWidth, height / worldHeight);
1799
+ // Screen-space fit (architecture rule 9): `padding` and the chrome reserve
1800
+ // are SCREEN pixels — world-space padding shrank with the fit zoom and
1801
+ // parked nodes under the command bar and minimap (0.5.0 Amp report,
1802
+ // finding A). The server cannot measure the client's chrome, so it reserves
1803
+ // nominal bottom space: the minimap is always there; the command bar only
1804
+ // while a session is attached.
1805
+ const bottomReserve = agentPresence.snapshot().sessionActive ? 128 : 96;
1806
+ const usableWidth = Math.max(80, width - padding * 2);
1807
+ const usableHeight = Math.max(80, height - padding - bottomReserve);
1808
+ const worldWidth = Math.max(1, bounds.width);
1809
+ const worldHeight = Math.max(1, bounds.height);
1810
+ const scale = Math.min(maxScale, usableWidth / worldWidth, usableHeight / worldHeight);
1767
1811
  const centerX = minX + bounds.width / 2;
1768
1812
  const centerY = minY + bounds.height / 2;
1769
1813
  const viewport = {
1770
- x: width / 2 - centerX * scale,
1771
- y: height / 2 - centerY * scale,
1814
+ x: padding + usableWidth / 2 - centerX * scale,
1815
+ y: padding + usableHeight / 2 - centerY * scale,
1772
1816
  scale,
1773
1817
  };
1774
1818