pmx-canvas 0.4.7 → 0.5.0

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 +3069 -796
  6. package/dist/canvas/index.js +104 -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 +25 -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 +32 -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 +23 -1
  57. package/dist/types/server/canvas-operations.d.ts +1 -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 +54 -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 +1 -1
  93. package/skills/pmx-canvas/SKILL.md +109 -18
  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 +57 -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 +252 -0
  103. package/src/cli/index.ts +1 -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 +41 -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 +79 -2
  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 +273 -84
  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 +3069 -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 +237 -61
  172. package/src/server/canvas-operations.ts +17 -8
  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 +107 -4
  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 +65 -16
  196. package/src/server/operations/ops/query.ts +1 -0
  197. package/src/server/operations/ops/snapshots.ts +16 -0
  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,
@@ -273,7 +278,13 @@ export function openCanvasDb(dbPath: string): Database {
273
278
 
274
279
  const db = new Database(dbPath);
275
280
  db.exec('PRAGMA journal_mode=WAL');
276
- db.exec('PRAGMA synchronous=NORMAL');
281
+ // FULL, not NORMAL: under WAL, NORMAL lets a host/OS-level crash lose the most
282
+ // recent commits (a clean process exit loses nothing either way). The reporter of
283
+ // issue #22 declined FULL specifically because a save rewrote the whole board, so
284
+ // an fsync per commit scaled with board size. Saves are incremental now — a
285
+ // typical save is a row or two — so that objection is gone and the durability is
286
+ // worth the fsync.
287
+ db.exec('PRAGMA synchronous=FULL');
277
288
  db.exec('PRAGMA busy_timeout=5000');
278
289
  db.exec(SCHEMA_SQL);
279
290
 
@@ -295,38 +306,116 @@ export function checkpointCanvasDb(db: Database): void {
295
306
  db.exec('PRAGMA wal_checkpoint(TRUNCATE)');
296
307
  }
297
308
 
309
+ /**
310
+ * Delete blob rows nothing references any more. Blobs are content-addressed and
311
+ * written with INSERT OR IGNORE, so editing a node's html supersedes its old blob
312
+ * and leaves it orphaned — without this, `canvas.db` (which is git-committable)
313
+ * would grow without bound once externalization applies to ordinary html nodes.
314
+ *
315
+ * References are collected by scanning the raw persisted JSON for sha256 fields
316
+ * rather than parsing every node. That deliberately OVER-approximates: a stray
317
+ * 64-hex string keeps a blob alive that could have been dropped, which wastes a
318
+ * little space. The opposite error would delete live content, so the scan is
319
+ * biased to the safe side on purpose. Snapshot rows are scanned too — a snapshot
320
+ * restore must still find its blobs.
321
+ */
322
+ export function gcBlobsInDB(db: Database): number {
323
+ const referenced = new Set<string>();
324
+ const collect = (sql: string): void => {
325
+ for (const row of db.query<{ data: string }, []>(sql).all()) {
326
+ if (!row.data) continue;
327
+ for (const match of row.data.matchAll(/"sha256"\s*:\s*"([0-9a-f]{64})"/g)) referenced.add(match[1]);
328
+ }
329
+ };
330
+ collect('SELECT data FROM nodes');
331
+ collect('SELECT data FROM snapshot_nodes');
332
+
333
+ const all = db.query<{ sha256: string }, []>('SELECT sha256 FROM blobs').all();
334
+ const orphans = all.filter((row) => !referenced.has(row.sha256));
335
+ if (orphans.length === 0) return 0;
336
+ const remove = db.prepare('DELETE FROM blobs WHERE sha256 = ?');
337
+ const transaction = db.transaction(() => {
338
+ for (const row of orphans) remove.run(row.sha256);
339
+ });
340
+ transaction();
341
+ return orphans.length;
342
+ }
343
+
298
344
  export function finalizeCanvasDbForClose(db: Database): void {
299
345
  checkpointCanvasDb(db);
300
- db.exec('PRAGMA journal_mode=DELETE');
346
+ // The WAL→DELETE switch takes an exclusive lock, so it succeeds only when no
347
+ // other connection has the DB open — and that makes it the GC gate. GC deletes
348
+ // blob rows a second live process may still depend on: its in-memory nodes
349
+ // hold content-addressed refs, not content, so a GC'd blob is unrecoverable
350
+ // once that process re-saves (review of #22, reproduced). Locked ⇒ someone
351
+ // else is on this canvas: leave WAL in place and keep every blob.
352
+ try {
353
+ db.exec('PRAGMA journal_mode=DELETE');
354
+ } catch {
355
+ return;
356
+ }
357
+ gcBlobsInDB(db);
301
358
  }
302
359
 
303
360
  // ── State Persistence ───────────────────────────────────────────
304
361
 
362
+ /**
363
+ * Drop rows whose id is no longer in the live state. Reads only the id column
364
+ * (index-only scan) so it stays cheap on large boards.
365
+ */
366
+ function deleteMissingRows(
367
+ db: Database,
368
+ table: 'nodes' | 'edges' | 'annotations' | 'context_pins',
369
+ idColumn: 'id' | 'node_id',
370
+ keep: Set<string>,
371
+ ): void {
372
+ const rows = db.query<{ id: string }, []>(`SELECT ${idColumn} AS id FROM ${table}`).all();
373
+ if (rows.length === 0) return;
374
+ const remove = db.prepare(`DELETE FROM ${table} WHERE ${idColumn} = ?`);
375
+ for (const row of rows) {
376
+ if (!keep.has(row.id)) remove.run(row.id);
377
+ }
378
+ }
379
+
305
380
  export function saveStateToDB(db: Database, state: PersistedCanvasState): void {
306
381
  const transaction = db.transaction(() => {
307
- // Clear current state tables
308
- db.run('DELETE FROM nodes');
309
- db.run('DELETE FROM edges');
310
- db.run('DELETE FROM annotations');
311
- db.run('DELETE FROM context_pins');
312
- db.run('DELETE FROM ax_state');
313
-
314
- // Save viewport and UI preferences
315
- db.run('INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)', ['theme', normalizeCanvasTheme(state.theme)]);
316
- db.run('INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)', ['viewport_x', String(state.viewport.x)]);
317
- db.run('INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)', ['viewport_y', String(state.viewport.y)]);
318
- db.run('INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)', ['viewport_scale', String(state.viewport.scale)]);
319
-
382
+ // Rows are upserted only when a column actually differs, and rows that are
383
+ // gone are deleted by id. This used to be DELETE-all + INSERT-all, which
384
+ // rewrote every row on every debounced save: dragging one node rewrote the
385
+ // whole board, multi-MB html payloads included, so the write cost scaled
386
+ // with board size instead of change size (issue #22). The
387
+ // `WHERE ... IS NOT excluded....` guard makes an unchanged row a true
388
+ // no-op — SQLite performs no update, so no page is written. The comparison
389
+ // is against the DB itself rather than an in-memory cache, so nothing can
390
+ // go stale and silently skip a write that was actually needed.
391
+ const upsertMeta = db.prepare(
392
+ `INSERT INTO meta (key, value) VALUES (?, ?)
393
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value
394
+ WHERE value IS NOT excluded.value`,
395
+ );
396
+ upsertMeta.run('theme', normalizeCanvasTheme(state.theme));
397
+ upsertMeta.run('viewport_x', String(state.viewport.x));
398
+ upsertMeta.run('viewport_y', String(state.viewport.y));
399
+ upsertMeta.run('viewport_scale', String(state.viewport.scale));
320
400
  // Mark DB as populated (for migration detection)
321
- db.run('INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)', ['state_populated', '1']);
401
+ upsertMeta.run('state_populated', '1');
322
402
 
323
403
  // Save nodes
324
- const insertNode = db.prepare(
325
- `INSERT INTO nodes (id, type, pos_x, pos_y, width, height, z_index, collapsed, pinned, dock_position, data)
326
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
404
+ const upsertNode = db.prepare(
405
+ `INSERT INTO nodes (id, type, pos_x, pos_y, width, height, z_index, collapsed, pinned, data)
406
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
407
+ ON CONFLICT(id) DO UPDATE SET
408
+ type = excluded.type, pos_x = excluded.pos_x, pos_y = excluded.pos_y,
409
+ width = excluded.width, height = excluded.height, z_index = excluded.z_index,
410
+ collapsed = excluded.collapsed, pinned = excluded.pinned, data = excluded.data
411
+ WHERE type IS NOT excluded.type OR pos_x IS NOT excluded.pos_x
412
+ OR pos_y IS NOT excluded.pos_y OR width IS NOT excluded.width
413
+ OR height IS NOT excluded.height OR z_index IS NOT excluded.z_index
414
+ OR collapsed IS NOT excluded.collapsed OR pinned IS NOT excluded.pinned
415
+ OR data IS NOT excluded.data`,
327
416
  );
328
417
  for (const node of state.nodes) {
329
- insertNode.run(
418
+ upsertNode.run(
330
419
  node.id,
331
420
  node.type,
332
421
  node.position.x,
@@ -336,18 +425,24 @@ export function saveStateToDB(db: Database, state: PersistedCanvasState): void {
336
425
  node.zIndex,
337
426
  node.collapsed ? 1 : 0,
338
427
  node.pinned ? 1 : 0,
339
- node.dockPosition,
340
428
  JSON.stringify(node.data),
341
429
  );
342
430
  }
431
+ deleteMissingRows(db, 'nodes', 'id', new Set(state.nodes.map((node) => node.id)));
343
432
 
344
433
  // Save edges
345
- const insertEdge = db.prepare(
434
+ const upsertEdge = db.prepare(
346
435
  `INSERT INTO edges (id, from_node, to_node, type, label, style, animated)
347
- VALUES (?, ?, ?, ?, ?, ?, ?)`,
436
+ VALUES (?, ?, ?, ?, ?, ?, ?)
437
+ ON CONFLICT(id) DO UPDATE SET
438
+ from_node = excluded.from_node, to_node = excluded.to_node, type = excluded.type,
439
+ label = excluded.label, style = excluded.style, animated = excluded.animated
440
+ WHERE from_node IS NOT excluded.from_node OR to_node IS NOT excluded.to_node
441
+ OR type IS NOT excluded.type OR label IS NOT excluded.label
442
+ OR style IS NOT excluded.style OR animated IS NOT excluded.animated`,
348
443
  );
349
444
  for (const edge of state.edges) {
350
- insertEdge.run(
445
+ upsertEdge.run(
351
446
  edge.id,
352
447
  edge.from,
353
448
  edge.to,
@@ -357,14 +452,24 @@ export function saveStateToDB(db: Database, state: PersistedCanvasState): void {
357
452
  edge.animated ? 1 : 0,
358
453
  );
359
454
  }
455
+ deleteMissingRows(db, 'edges', 'id', new Set(state.edges.map((edge) => edge.id)));
360
456
 
361
457
  // Save annotations
362
- const insertAnnotation = db.prepare(
458
+ const annotations = state.annotations ?? [];
459
+ const upsertAnnotation = db.prepare(
363
460
  `INSERT INTO annotations (id, type, points, bounds, color, width, text, label, created_at)
364
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
461
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
462
+ ON CONFLICT(id) DO UPDATE SET
463
+ type = excluded.type, points = excluded.points, bounds = excluded.bounds,
464
+ color = excluded.color, width = excluded.width, text = excluded.text,
465
+ label = excluded.label, created_at = excluded.created_at
466
+ WHERE type IS NOT excluded.type OR points IS NOT excluded.points
467
+ OR bounds IS NOT excluded.bounds OR color IS NOT excluded.color
468
+ OR width IS NOT excluded.width OR text IS NOT excluded.text
469
+ OR label IS NOT excluded.label OR created_at IS NOT excluded.created_at`,
365
470
  );
366
- for (const annotation of state.annotations ?? []) {
367
- insertAnnotation.run(
471
+ for (const annotation of annotations) {
472
+ upsertAnnotation.run(
368
473
  annotation.id,
369
474
  annotation.type,
370
475
  JSON.stringify(annotation.points),
@@ -376,17 +481,21 @@ export function saveStateToDB(db: Database, state: PersistedCanvasState): void {
376
481
  annotation.createdAt,
377
482
  );
378
483
  }
484
+ deleteMissingRows(db, 'annotations', 'id', new Set(annotations.map((annotation) => annotation.id)));
379
485
 
380
- // Save context pins
381
- const insertPin = db.prepare('INSERT INTO context_pins (node_id) VALUES (?)');
486
+ // Save context pins (node_id is the whole row — nothing to update)
487
+ const insertPin = db.prepare('INSERT OR IGNORE INTO context_pins (node_id) VALUES (?)');
382
488
  for (const pinId of state.contextPins) {
383
489
  insertPin.run(pinId);
384
490
  }
491
+ deleteMissingRows(db, 'context_pins', 'node_id', new Set(state.contextPins));
385
492
 
386
- db.run('INSERT INTO ax_state (key, value) VALUES (?, ?)', [
387
- 'state',
388
- JSON.stringify(state.ax ?? createEmptyAxState()),
389
- ]);
493
+ db.run(
494
+ `INSERT INTO ax_state (key, value) VALUES (?, ?)
495
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value
496
+ WHERE value IS NOT excluded.value`,
497
+ ['state', JSON.stringify(state.ax ?? createEmptyAxState())],
498
+ );
390
499
  });
391
500
 
392
501
  transaction();
@@ -429,7 +538,6 @@ export function loadStateFromDB(db: Database): PersistedCanvasState | null {
429
538
  z_index: number;
430
539
  collapsed: number;
431
540
  pinned: number;
432
- dock_position: string | null;
433
541
  data: string;
434
542
  }
435
543
  const nodeRows = db.query<NodeRow, []>('SELECT * FROM nodes').all();
@@ -441,7 +549,6 @@ export function loadStateFromDB(db: Database): PersistedCanvasState | null {
441
549
  zIndex: row.z_index,
442
550
  collapsed: row.collapsed === 1,
443
551
  pinned: row.pinned === 1,
444
- dockPosition: row.dock_position as CanvasNodeState['dockPosition'],
445
552
  data: JSON.parse(row.data) as Record<string, unknown>,
446
553
  }));
447
554
 
@@ -549,8 +656,8 @@ export function saveSnapshotToDB(db: Database, snapshot: CanvasSnapshot, state:
549
656
 
550
657
  // Insert snapshot nodes
551
658
  const insertNode = db.prepare(
552
- `INSERT INTO snapshot_nodes (snapshot_id, id, type, pos_x, pos_y, width, height, z_index, collapsed, pinned, dock_position, data)
553
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
659
+ `INSERT INTO snapshot_nodes (snapshot_id, id, type, pos_x, pos_y, width, height, z_index, collapsed, pinned, data)
660
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
554
661
  );
555
662
  for (const node of state.nodes) {
556
663
  insertNode.run(
@@ -564,7 +671,6 @@ export function saveSnapshotToDB(db: Database, snapshot: CanvasSnapshot, state:
564
671
  node.zIndex,
565
672
  node.collapsed ? 1 : 0,
566
673
  node.pinned ? 1 : 0,
567
- node.dockPosition,
568
674
  JSON.stringify(node.data),
569
675
  );
570
676
  }
@@ -674,7 +780,6 @@ export function loadSnapshotFromDB(
674
780
  z_index: number;
675
781
  collapsed: number;
676
782
  pinned: number;
677
- dock_position: string | null;
678
783
  data: string;
679
784
  }
680
785
  const nodeRows = db
@@ -688,7 +793,6 @@ export function loadSnapshotFromDB(
688
793
  zIndex: row.z_index,
689
794
  collapsed: row.collapsed === 1,
690
795
  pinned: row.pinned === 1,
691
- dockPosition: row.dock_position as CanvasNodeState['dockPosition'],
692
796
  data: JSON.parse(row.data) as Record<string, unknown>,
693
797
  }));
694
798
 
@@ -812,6 +916,10 @@ export function listSnapshotsFromDB(db: Database, options: CanvasSnapshotListOpt
812
916
  }));
813
917
  }
814
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
+
815
923
  export function deleteSnapshotFromDB(db: Database, id: string): boolean {
816
924
  const transaction = db.transaction(() => {
817
925
  db.run('DELETE FROM snapshot_nodes WHERE snapshot_id = ?', [id]);
@@ -873,6 +981,9 @@ function trimAxTable(db: Database, table: 'ax_events' | 'ax_evidence' | 'ax_stee
873
981
  db.run(`DELETE FROM ${table} WHERE seq <= (SELECT seq FROM ${table} ORDER BY seq DESC LIMIT 1 OFFSET ?)`, [
874
982
  AX_TIMELINE_RETENTION,
875
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
+ }
876
987
  }
877
988
 
878
989
  function readLastSeq(db: Database, table: 'ax_events' | 'ax_evidence' | 'ax_steering'): number {
@@ -930,10 +1041,28 @@ export function appendAxSteeringToDB(db: Database, s: Omit<PmxAxSteeringMessage,
930
1041
  return { ...s, seq };
931
1042
  }
932
1043
 
933
- export function markAxSteeringDeliveredInDB(db: Database, id: string): boolean {
934
- // Compare-and-set: true ONLY on the undelivered→delivered transition. Two
935
- // adapters racing the same steer see exactly one true — the loser (and any
936
- // 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
+ }
937
1066
  const r = db.run('UPDATE ax_steering SET delivered = 1 WHERE id = ? AND delivered = 0', [id]);
938
1067
  return r.changes > 0;
939
1068
  }
@@ -1006,6 +1135,18 @@ interface AxSteeringRow {
1006
1135
  target: string | null;
1007
1136
  }
1008
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
+
1009
1150
  function mapAxSteeringRow(r: AxSteeringRow): PmxAxSteeringMessage | null {
1010
1151
  return normalizeAxSteeringMessage({
1011
1152
  ...r,
@@ -1023,7 +1164,27 @@ export function loadAxSteeringFromDB(
1023
1164
  ? 'SELECT * FROM ax_steering WHERE delivered = 0 ORDER BY seq DESC LIMIT ?'
1024
1165
  : 'SELECT * FROM ax_steering ORDER BY seq DESC LIMIT ?';
1025
1166
  const rows = db.query<AxSteeringRow, [number]>(sql).all(clampTimelineLimit(q.limit));
1026
- 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);
1027
1188
  }
1028
1189
 
1029
1190
  export function loadPendingAxSteeringFromDB(
@@ -1036,13 +1197,20 @@ export function loadPendingAxSteeringFromDB(
1036
1197
  const limit = clampTimelineLimit(options.limit);
1037
1198
  const rows = options.consumer
1038
1199
  ? db
1039
- .query<AxSteeringRow, [string, string, string, number]>(
1040
- '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 ?',
1041
1205
  )
1042
- .all(options.consumer, options.consumer, options.consumer, limit)
1206
+ .all(options.consumer, options.consumer, options.consumer, broadcastCutoffIso(), options.consumer, limit)
1043
1207
  : db
1044
- .query<AxSteeringRow, [number]>('SELECT * FROM ax_steering WHERE delivered = 0 ORDER BY seq ASC LIMIT ?')
1045
- .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);
1046
1214
  return rows.map(mapAxSteeringRow).filter((s): s is PmxAxSteeringMessage => s !== null);
1047
1215
  }
1048
1216
 
@@ -1061,25 +1229,33 @@ export function loadNewestPendingAxSteeringFromDB(
1061
1229
  const limit = clampTimelineLimit(options.limit);
1062
1230
  const rows = options.consumer
1063
1231
  ? db
1064
- .query<AxSteeringRow, [string, string, string, number]>(
1065
- '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 ?',
1066
1234
  )
1067
- .all(options.consumer, options.consumer, options.consumer, limit)
1235
+ .all(options.consumer, options.consumer, options.consumer, broadcastCutoffIso(), options.consumer, limit)
1068
1236
  : db
1069
- .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
+ )
1070
1240
  .all(limit);
1071
1241
  return rows.map(mapAxSteeringRow).filter((s): s is PmxAxSteeringMessage => s !== null);
1072
1242
  }
1073
1243
 
1074
1244
  /** Total undelivered steering for a consumer (loop-safe — excludes the consumer's own; target-scoped). */
1075
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".
1076
1248
  const n = consumer
1077
1249
  ? db
1078
- .query<{ n: number }, [string, string, string]>(
1079
- '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 > ?',
1080
1257
  )
1081
- .get(consumer, consumer, consumer)?.n
1082
- : db.query<{ n: number }, []>('SELECT COUNT(*) AS n FROM ax_steering WHERE delivered = 0').get()?.n;
1258
+ .get(broadcastCutoffIso())?.n;
1083
1259
  return Number(n ?? 0);
1084
1260
  }
1085
1261
 
@@ -946,7 +946,6 @@ export function addCanvasNode(input: CanvasAddNodeInput): {
946
946
  zIndex: 1,
947
947
  collapsed: false,
948
948
  pinned: false,
949
- dockPosition: null,
950
949
  data,
951
950
  };
952
951
 
@@ -1133,7 +1132,7 @@ export function removeCanvasNode(id: string): {
1133
1132
  }
1134
1133
 
1135
1134
  function isArrangeLocked(node: CanvasNodeState): boolean {
1136
- return node.pinned || node.dockPosition !== null || node.data.arrangeLocked === true;
1135
+ return node.pinned || node.data.arrangeLocked === true;
1137
1136
  }
1138
1137
 
1139
1138
  function collectArrangeExcludedNodeIds(nodes: CanvasNodeState[]): Set<string> {
@@ -1219,7 +1218,7 @@ function rectsOverlap(a: ArrangeObstacleRect, b: ArrangeObstacleRect): boolean {
1219
1218
 
1220
1219
  function collectGridArrangeObstacles(nodes: CanvasNodeState[], excludedIds: Set<string>): ArrangeObstacleRect[] {
1221
1220
  return nodes
1222
- .filter((node) => excludedIds.has(node.id) && node.dockPosition === null)
1221
+ .filter((node) => excludedIds.has(node.id))
1223
1222
  .map((node) => ({
1224
1223
  id: node.id,
1225
1224
  position: { ...node.position },
@@ -1247,7 +1246,7 @@ function shiftGridUpdatesBelowObstacles(
1247
1246
  ): CanvasNodeUpdate[] {
1248
1247
  if (updates.length === 0 || obstacles.length === 0) return updates;
1249
1248
 
1250
- // Grid arrange only sees movable nodes, so preserved locked/docked-group frames
1249
+ // Grid arrange only sees movable nodes, so preserved locked-group frames
1251
1250
  // need a separate obstacle pass before applying the planned positions.
1252
1251
  const nodesById = new Map(nodes.map((node) => [node.id, node]));
1253
1252
  let shifted = updates;
@@ -1465,6 +1464,20 @@ export function addCanvasEdge(input: {
1465
1464
  return edge;
1466
1465
  }
1467
1466
 
1467
+ export function updateCanvasEdge(
1468
+ id: string,
1469
+ patch: Partial<Pick<CanvasEdge, 'type' | 'label' | 'style' | 'animated'>>,
1470
+ ): CanvasEdge {
1471
+ if (!canvasState.getEdges().some((edge) => edge.id === id)) {
1472
+ throw new Error(`Edge "${id}" not found.`);
1473
+ }
1474
+ const updated = canvasState.updateEdge(id, patch);
1475
+ if (!updated) {
1476
+ throw new Error('Retyping would duplicate an existing edge.');
1477
+ }
1478
+ return updated;
1479
+ }
1480
+
1468
1481
  export function removeCanvasEdge(id: string): { removed: boolean } {
1469
1482
  return { removed: canvasState.removeEdge(id) };
1470
1483
  }
@@ -1512,7 +1525,6 @@ export function createCanvasGroup(input: CanvasCreateGroupInput): { id: string;
1512
1525
  zIndex: 0,
1513
1526
  collapsed: false,
1514
1527
  pinned: false,
1515
- dockPosition: null,
1516
1528
  data,
1517
1529
  });
1518
1530
 
@@ -1583,7 +1595,6 @@ export function createCanvasJsonRenderNode(input: JsonRenderNodeInput): {
1583
1595
  zIndex: 1,
1584
1596
  collapsed: false,
1585
1597
  pinned: false,
1586
- dockPosition: null,
1587
1598
  data: createJsonRenderNodeData(id, input.title?.trim() || inferJsonRenderNodeTitle(spec), spec, {
1588
1599
  viewerType: 'json-render',
1589
1600
  ...(input.strictSize ? { strictSize: true } : {}),
@@ -1627,7 +1638,6 @@ export function createCanvasStreamingJsonRenderNode(input: {
1627
1638
  zIndex: 1,
1628
1639
  collapsed: false,
1629
1640
  pinned: false,
1630
- dockPosition: null,
1631
1641
  data: createJsonRenderNodeData(id, input.title?.trim() || 'Streaming', spec, {
1632
1642
  viewerType: 'json-render',
1633
1643
  streamStatus: 'open',
@@ -1703,7 +1713,6 @@ export function createCanvasGraphNode(input: GraphNodeInput): {
1703
1713
  zIndex: 1,
1704
1714
  collapsed: false,
1705
1715
  pinned: false,
1706
- dockPosition: null,
1707
1716
  data: createJsonRenderNodeData(id, title, spec, {
1708
1717
  viewerType: 'graph',
1709
1718
  graphConfig: buildGraphConfig(input),