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
@@ -30,6 +30,16 @@ bun run test:web-canvas # Browser smoke against a real running app
30
30
  bun run test:all # Bun suite + browser smoke
31
31
  ```
32
32
 
33
+ **Direct `bun test` is side-effect-guarded by `tests/preload.ts`** (wired via `bunfig.toml`
34
+ `[test].preload`): it defaults `PMX_CANVAS_DISABLE_BROWSER_OPEN=1` so the suite's open-as-site
35
+ tests cannot launch the developer's real browser, whichever way the tests are invoked. The
36
+ preload covers `bun test` only — when you boot a server yourself (`bun run src/cli/index.ts`),
37
+ pass `--no-open` and set `PMX_CANVAS_DISABLE_BROWSER_OPEN=1` explicitly.
38
+
39
+ **Never pipe a gating command through `tail`, `head`, or `grep`.** The pipeline reports the exit
40
+ code of the LAST command, so `bun run test | tail -5` exits 0 on a red suite. Redirect to a file
41
+ and check `$?`, then read the file.
42
+
33
43
  ## Which Command To Run
34
44
 
35
45
  - Server/state/API-only changes: run `bun run test`
@@ -79,6 +89,30 @@ Prefer extending the existing suites before inventing a one-off script.
79
89
  `pmx-canvas skills sync --yes` to refresh them (whole trees, whatever agent layout owns them) before
80
90
  trusting skill-guided results
81
91
 
92
+ ## Tests That Cannot Fail
93
+
94
+ A test that passes against the broken code is worse than no test — it certifies the bug. Every
95
+ regression test must be shown to discriminate: run it against the unfixed code (or hard-code the
96
+ broken value) and watch it go red BEFORE you trust the green.
97
+
98
+ Four ways a test silently stops discriminating in this repo:
99
+
100
+ - **Polled assertions pass on the first sample.** `expect.poll(fn).toBeGreaterThanOrEqual(180)`
101
+ succeeds the instant one sample qualifies, so it never observes the failure window. Wait for the
102
+ settle condition, then assert once.
103
+ - **Committed fixtures embed generated output.** `src/server/demo-state.json` carries each
104
+ primitive's generated markup; count and coverage assertions are satisfied by stale markup just
105
+ as well as fresh. Guard generated fixtures by rebuilding from the generator's INPUT and
106
+ byte-comparing against the current renderer.
107
+ - **Absolute assertions about globally-wired side effects are order-dependent.** Anything flowing
108
+ through a single-slot listener (architecture rule 8 in `CLAUDE.md`) is wired or not depending on
109
+ whether an earlier test file booted a server — so the test passes alone and fails in the suite,
110
+ or vice versa. Assert DIFFERENTIALLY: run the action disarmed and armed, require the deltas to
111
+ match. Always run the full suite before trusting a new test; a single-file run hides this.
112
+ - **A new node type passes every test and renders nothing.** Server, API, and client-unit
113
+ assertions all pass while `isCanvasNodeType` drops the type during layout apply. Only a DOM
114
+ census on a live board catches it.
115
+
82
116
  ## Layout And Embedded Content Checks
83
117
 
84
118
  - For seeded or generated boards, add API-level geometry assertions: expected node/edge counts,
@@ -95,6 +129,12 @@ Prefer extending the existing suites before inventing a one-off script.
95
129
  - When checking embedded frame fit manually, start from a clean seeded state, rebuild stale bundles,
96
130
  and inspect the actual iframe document in a browser. Server dimensions can look correct while the
97
131
  embedded content is still clipped.
132
+ - **A node's stored content is not render evidence.** An html/primitive node's stored HTML holds
133
+ every conditional branch — hidden banners, error states, empty states — so a content-level read
134
+ (search hit, pinned context, `node get`, any text summary) quotes strings the user never saw.
135
+ The 0.4.7 report filed an "AX bridge unavailable" banner as a live product gap on exactly this
136
+ basis; the served surface had the bridge injected ahead of the check and the banner was `hidden`
137
+ in the document. Before filing a rendering bug, fetch the served surface or inspect the DOM.
98
138
  - User-facing creation flows must end with the new nodes visible: after the create (plus the
99
139
  skill-mandated focus/fit), a screenshot must show them without any manual pan. A `panned: true`
100
140
  API result alone is not proof — verify the frame is on-screen and unobscured.
@@ -117,3 +157,20 @@ Before marking work done, report:
117
157
 
118
158
  For non-trivial changes, the default expectation is `bun run test:all` unless there is a clear
119
159
  reason to scope verification more narrowly.
160
+
161
+ ## Presence, sessions, and the redesigned chrome (rail-chrome-v2)
162
+
163
+ - Agent/human presence is in-memory and TTL-swept. An e2e reset must detach any attached
164
+ session (`POST /api/canvas/ax/presence { attached: false }` per attached presence), clear the
165
+ scope fence, and mark its own writes with `x-pmx-workbench: 1` — otherwise the harness reads
166
+ as an external writer and a leftover fence refuses the next test's `clear`. Unattached writers
167
+ from earlier tests can still be live (90 s): assert about YOUR writers, not exact totals.
168
+ - Assert SSE frames as received (`readSseEvent` in `tests/unit/agent-presence-api.test.ts`), not
169
+ the object handed to the emitter — the envelope overwrites `sessionId` and `timestamp`.
170
+ - Two-tab behaviour (human cursors, the edit lock) needs two browser contexts; name them with
171
+ `/workbench?name=…` so assertions can target a cursor by its tag.
172
+ - The Browser pane reports `visibility: hidden` and never fires rAF: drags, drop pills, edge
173
+ previews and presence animations only work under Playwright. Use the pane for static checks.
174
+ - Selection is shift-click on the node BODY (the titlebar drags); groups have no ports and
175
+ their drag handle is the edge row; the selection bar and command bar both float bottom-center
176
+ (the selection bar lifts during a session).
package/src/cli/agent.ts CHANGED
@@ -28,6 +28,7 @@ import './commands/query.js';
28
28
  import './commands/pins.js';
29
29
  import './commands/history.js';
30
30
  import './commands/ax.js';
31
+ import './commands/pump.js';
31
32
  import './commands/webview.js';
32
33
  import './commands/apps.js';
33
34
  import './commands/copilot.js';
@@ -1,7 +1,7 @@
1
1
  // Copilot adapter commands: copilot install-extension.
2
2
 
3
3
  import { copyFileSync, existsSync, mkdirSync } from 'node:fs';
4
- import { dirname, join } from 'node:path';
4
+ import { basename, dirname, join } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { cmd, die, getStringFlag, output, parseFlags, showCommandHelp } from '../shared.js';
7
7
 
@@ -17,18 +17,23 @@ cmd(
17
17
  const { flags } = parseFlags(args);
18
18
  if (flags.help || flags.h) return showCommandHelp('copilot install-extension');
19
19
 
20
- const sourcePath = fileURLToPath(new URL('../../../.github/extensions/pmx-canvas/extension.mjs', import.meta.url));
21
- if (!existsSync(sourcePath)) {
22
- die('Bundled Copilot extension adapter not found.', `Expected at ${sourcePath}`);
20
+ const sourcePaths = [
21
+ fileURLToPath(new URL('../../../.github/extensions/pmx-canvas/extension.mjs', import.meta.url)),
22
+ fileURLToPath(new URL('../../../.github/extensions/pmx-canvas/steering-delivery.mjs', import.meta.url)),
23
+ ];
24
+ const missingSource = sourcePaths.find((sourcePath) => !existsSync(sourcePath));
25
+ if (missingSource) {
26
+ die('Bundled Copilot extension adapter not found.', `Expected at ${missingSource}`);
23
27
  }
24
28
 
25
29
  const targetPath =
26
30
  getStringFlag(flags, 'target') ?? join(process.cwd(), '.github', 'extensions', 'pmx-canvas', 'extension.mjs');
31
+ const targetPaths = sourcePaths.map((sourcePath) => join(dirname(targetPath), basename(sourcePath)));
27
32
  const dryRun = flags['dry-run'] === true;
28
- const targetExists = existsSync(targetPath);
33
+ const targetExists = targetPaths.some((candidate) => existsSync(candidate));
29
34
 
30
35
  if (dryRun) {
31
- output({ ok: true, dryRun: true, sourcePath, targetPath, targetExists, wrote: false });
36
+ output({ ok: true, dryRun: true, sourcePath: sourcePaths[0], targetPath, targetExists, wrote: false });
32
37
  return;
33
38
  }
34
39
 
@@ -37,7 +42,9 @@ cmd(
37
42
  }
38
43
 
39
44
  mkdirSync(dirname(targetPath), { recursive: true });
40
- copyFileSync(sourcePath, targetPath);
41
- output({ ok: true, dryRun: false, sourcePath, targetPath, wrote: true });
45
+ for (let index = 0; index < sourcePaths.length; index += 1) {
46
+ copyFileSync(sourcePaths[index], targetPaths[index]);
47
+ }
48
+ output({ ok: true, dryRun: false, sourcePath: sourcePaths[0], targetPath, wrote: true });
42
49
  },
43
50
  );
@@ -124,7 +124,6 @@ function summarizeNodeResult(node: Record<string, unknown>): Record<string, unkn
124
124
  ...(node.size !== undefined ? { size: node.size } : {}),
125
125
  ...(node.collapsed !== undefined ? { collapsed: node.collapsed } : {}),
126
126
  ...(node.pinned !== undefined ? { pinned: node.pinned } : {}),
127
- ...(node.dockPosition !== undefined ? { dockPosition: node.dockPosition } : {}),
128
127
  ...(node.path !== undefined ? { path: node.path } : {}),
129
128
  ...(node.url !== undefined ? { url: node.url } : {}),
130
129
  ...(node.provenance !== undefined ? { provenance: node.provenance } : {}),
@@ -827,8 +826,6 @@ cmd(
827
826
  'pmx-canvas node update <node-id> --spec-file ./dashboard.json',
828
827
  'pmx-canvas node update <graph-id> --data-file ./metrics.json --chart-height 420',
829
828
  'pmx-canvas node update <node-id> --pinned true',
830
- 'pmx-canvas node update <node-id> --dock-position right',
831
- 'pmx-canvas node update <node-id> --dock-position none # undock back to the canvas',
832
829
  'pmx-canvas node update <node-id> --lock-arrange',
833
830
  'pmx-canvas node update <node-id> --width 840 --full # full node payload instead of the compact envelope',
834
831
  ],
@@ -910,25 +907,10 @@ cmd(
910
907
 
911
908
  if (pinned !== undefined) body.pinned = pinned;
912
909
 
913
- // --dock-position left|right|none : dock a node into the top HUD or undock it.
914
- // `none`/`null`/empty map to JS null (undock). Assigned with a !== undefined
915
- // guard so the null survives JSON.stringify to the server (which accepts a
916
- // top-level dockPosition: null). HTTP PATCH already supports this; this is the
917
- // CLI path the report (#40) found missing.
918
- const dockRaw = getStringFlag(flags, 'dock-position', 'dockPosition');
919
- let dockPosition: 'left' | 'right' | null | undefined;
920
- if (dockRaw !== undefined) {
921
- const v = dockRaw.trim().toLowerCase();
922
- if (v === 'left' || v === 'right') dockPosition = v;
923
- else if (v === 'none' || v === 'null' || v === '') dockPosition = null;
924
- else die(`Invalid --dock-position "${dockRaw}".`, 'Use left, right, or none (to undock).');
925
- }
926
- if (dockPosition !== undefined) body.dockPosition = dockPosition;
927
-
928
910
  if (Object.keys(body).length === 0) {
929
911
  die(
930
912
  'No updates specified',
931
- 'Use --title, --content, --x, --y, --width, --height, --strict-size, --pinned, --dock-position, trace fields, --lock-arrange, --unlock-arrange, or --stdin',
913
+ 'Use --title, --content, --x, --y, --width, --height, --strict-size, --pinned, trace fields, --lock-arrange, --unlock-arrange, or --stdin',
932
914
  );
933
915
  }
934
916
 
@@ -0,0 +1,252 @@
1
+ /**
2
+ * `pmx-canvas pump` — the generic reactive steering loop for hosts without a
3
+ * native adapter (Amp, Codex, any CLI agent). Long-polls the per-consumer
4
+ * delivery claim and hands each steer to a shell command; the command's exit
5
+ * code gates the per-consumer mark. This is the same design as the Copilot
6
+ * extension's delivery pump (send-then-mark, awaiting-mark memory, startup
7
+ * backlog watermark, old-server backoff) with the "send into the host" step
8
+ * generalized to "run your command".
9
+ *
10
+ * Safety: the steer text reaches the command as env (PMX_STEER_MESSAGE) and
11
+ * stdin — never spliced into the shell string — so hostile steer content
12
+ * cannot inject into the command line. `{message}` in the template expands to
13
+ * the SHELL VARIABLE reference "$PMX_STEER_MESSAGE" (quoted), not the text.
14
+ * On Windows that guarantee does not hold — cmd.exe expands `%VAR%` and then
15
+ * re-parses the result — so the placeholders are refused there and the command
16
+ * takes the message from stdin instead. See `renderExecTemplate`.
17
+ */
18
+ import { spawn } from 'node:child_process';
19
+ import { cmd, getBaseUrl, getStringFlag, invokeOperation, parseFlags, requireFlag } from '../shared.js';
20
+
21
+ interface PendingSteer {
22
+ id: string;
23
+ message: string;
24
+ source?: string | null;
25
+ target?: string | null;
26
+ createdAt?: string | null;
27
+ }
28
+
29
+ async function claimPending(consumer: string, waitMs: number): Promise<{ pending: PendingSteer[]; tookMs: number }> {
30
+ const started = Date.now();
31
+ const url = `${getBaseUrl()}/api/canvas/ax/delivery/pending?consumer=${encodeURIComponent(consumer)}&limit=1&order=oldest${waitMs > 0 ? `&waitMs=${waitMs}` : ''}`;
32
+ const res = await fetch(url, { signal: AbortSignal.timeout(waitMs + 15_000) });
33
+ if (!res.ok) throw new Error(`pending claim failed: HTTP ${res.status}`);
34
+ const body = (await res.json()) as { pending?: PendingSteer[] };
35
+ return { pending: Array.isArray(body.pending) ? body.pending : [], tookMs: Date.now() - started };
36
+ }
37
+
38
+ async function markDelivered(id: string, consumer: string): Promise<boolean> {
39
+ const result = (await invokeOperation('ax.delivery.mark', { id, consumer })) as { delivered?: boolean };
40
+ return result.delivered === true;
41
+ }
42
+
43
+ /**
44
+ * Windows has no `sh`, so the exec ran nowhere there. cmd.exe can host the
45
+ * stdin pattern, but NOT the `{message}` / `{id}` placeholders: those expand to
46
+ * an env-var reference so hostile steer content is never spliced into the
47
+ * command string, and cmd.exe expands `%VAR%` before re-parsing — a message
48
+ * containing `& del …` would run. Refuse the placeholders there instead of
49
+ * silently reintroducing the injection the substitution exists to prevent.
50
+ */
51
+ export function renderExecTemplate(template: string, windows = process.platform === 'win32'): string {
52
+ if (windows) {
53
+ if (template.includes('{message}') || template.includes('{id}')) {
54
+ throw new Error(
55
+ 'pmx-canvas pump: {message} and {id} are not supported on Windows — cmd.exe re-parses expanded variables, so a steer could inject commands. Read the message from stdin instead (the exec receives it), or use the PMX_STEER_MESSAGE / PMX_STEER_ID environment variables inside your own script.',
56
+ );
57
+ }
58
+ return template;
59
+ }
60
+ return template.replaceAll('{message}', '"$PMX_STEER_MESSAGE"').replaceAll('{id}', '"$PMX_STEER_ID"');
61
+ }
62
+
63
+ function runExec(template: string, steer: PendingSteer, consumer: string): Promise<number> {
64
+ const rendered = renderExecTemplate(template);
65
+ return new Promise((resolve) => {
66
+ // `shell: true` picks sh on POSIX and ComSpec/cmd.exe on Windows, and sets
67
+ // windowsVerbatimArguments itself — hand-rolling the cmd.exe form escaped
68
+ // the quotes inside the command, so `bun "C:\path\x.mjs"` arrived as
69
+ // `""C:\path\x.mjs""`. Let Node own the platform quoting.
70
+ const child = spawn(rendered, {
71
+ shell: true,
72
+ stdio: ['pipe', 'inherit', 'inherit'],
73
+ env: {
74
+ ...process.env,
75
+ PMX_STEER_MESSAGE: steer.message,
76
+ PMX_STEER_ID: steer.id,
77
+ PMX_STEER_SOURCE: steer.source ?? '',
78
+ // Delivery envelope (round-2 review): the command can tell the host
79
+ // WHO steered, whether it was addressed or a broadcast, and how old
80
+ // the steer is — without re-querying the board.
81
+ PMX_STEER_TARGET: steer.target ?? 'ALL',
82
+ PMX_STEER_CREATED_AT: steer.createdAt ?? '',
83
+ PMX_STEER_CONSUMER: consumer,
84
+ },
85
+ });
86
+ child.stdin.write(steer.message);
87
+ child.stdin.end();
88
+ child.on('close', (code) => resolve(code ?? 1));
89
+ child.on('error', () => resolve(1));
90
+ });
91
+ }
92
+
93
+ async function setPresence(consumer: string, label: string, parent: string | null, patch: Record<string, unknown>) {
94
+ try {
95
+ await invokeOperation('ax.presence.set', {
96
+ source: 'sdk',
97
+ agentId: consumer,
98
+ label,
99
+ ...(parent ? { parentAgentId: parent } : {}),
100
+ ...patch,
101
+ });
102
+ } catch {
103
+ // presence is best-effort — the pump must survive a rolling daemon
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Settle back to idle ONLY when the visible phase is still the pump's own
109
+ * `tooling` marker. The host the pump feeds may have set a richer explicit
110
+ * phase (thinking, waiting-approval) while handling the steer — an
111
+ * unconditional idle stomped it and active work read as Idle (0.5.0
112
+ * readiness, Copilot finding).
113
+ */
114
+ async function settlePresence(consumer: string, label: string, parent: string | null): Promise<void> {
115
+ try {
116
+ const res = await fetch(`${getBaseUrl()}/api/canvas/ax/presence`, { signal: AbortSignal.timeout(5_000) });
117
+ if (res.ok) {
118
+ const body = (await res.json()) as {
119
+ presences?: Array<{ sessionId?: string; phase?: string; detail?: string | null }>;
120
+ };
121
+ const own = body.presences?.find((p) => p.sessionId === consumer);
122
+ const ownToolingMarker = own?.phase === 'tooling' && (own.detail ?? '').startsWith('steer: ');
123
+ if (own && !ownToolingMarker && own.phase !== 'idle') return; // richer phase belongs to the host
124
+ }
125
+ } catch {
126
+ // presence read failed — fall through and settle; better idle than stuck tooling
127
+ }
128
+ await setPresence(consumer, label, parent, { phase: 'idle', detail: null });
129
+ }
130
+
131
+ cmd(
132
+ 'pump',
133
+ 'Reactive steering loop for CLI agents (Amp, Codex CLI): long-poll claims for a consumer and run a command per steer',
134
+ [
135
+ `pmx-canvas pump --consumer codex-cli --exec 'codex exec --full-auto {message}'`,
136
+ `pmx-canvas pump --consumer amp --exec 'amp -x {message}' --parent claude-code`,
137
+ `pmx-canvas pump --consumer testbot --exec 'cat' --once`,
138
+ ],
139
+ async (args) => {
140
+ const { flags } = parseFlags(args, { boolFlags: ['once'] });
141
+ const consumer = requireFlag(
142
+ flags,
143
+ 'consumer',
144
+ `pmx-canvas pump --consumer <key> --exec '<command with {message}>'`,
145
+ );
146
+ const execTemplate = requireFlag(
147
+ flags,
148
+ 'exec',
149
+ `pmx-canvas pump --consumer <key> --exec '<command with {message}>'`,
150
+ );
151
+ const once = flags.once === true;
152
+ const waitMsRaw = Number(getStringFlag(flags, 'wait-ms') ?? 120_000);
153
+ const waitMs = Number.isFinite(waitMsRaw) && waitMsRaw >= 0 ? Math.min(waitMsRaw, 120_000) : 120_000;
154
+ const label = getStringFlag(flags, 'label') ?? consumer;
155
+ const parent = getStringFlag(flags, 'parent') ?? null;
156
+ const deliverBacklog = getStringFlag(flags, 'backlog') === 'deliver';
157
+ const retryDelayRaw = Number(getStringFlag(flags, 'retry-delay-ms') ?? 5_000);
158
+ const retryDelayMs = Number.isFinite(retryDelayRaw) && retryDelayRaw >= 0 ? retryDelayRaw : 5_000;
159
+
160
+ // Startup watermark: steers queued BEFORE the pump existed are history,
161
+ // not instructions — mark them silently unless --backlog=deliver.
162
+ if (!deliverBacklog) {
163
+ const { pending } = await claimPending(consumer, 0);
164
+ for (const steer of pending) await markDelivered(steer.id, consumer);
165
+ let swept = pending.length;
166
+ // pending is capped at limit=1 — drain the rest.
167
+ while (swept > 0) {
168
+ const next = await claimPending(consumer, 0);
169
+ if (next.pending.length === 0) break;
170
+ for (const steer of next.pending) await markDelivered(steer.id, consumer);
171
+ swept = next.pending.length;
172
+ }
173
+ }
174
+
175
+ // Park via the guard: a pump RESTART while its host is mid-work must not
176
+ // knock an explicit phase back to idle either.
177
+ await settlePresence(consumer, label, parent);
178
+ console.log(`[pump] ${consumer} parked on ${getBaseUrl()} (waitMs=${waitMs}${once ? ', once' : ''})`);
179
+
180
+ const awaitingMark = new Set<string>();
181
+ const failures = new Map<string, number>();
182
+ let stopped = false;
183
+ let fastEmpties = 0;
184
+ process.on('SIGINT', () => {
185
+ stopped = true;
186
+ console.log('[pump] stopping');
187
+ process.exit(0);
188
+ });
189
+
190
+ while (!stopped) {
191
+ let pending: PendingSteer[];
192
+ let tookMs: number;
193
+ try {
194
+ ({ pending, tookMs } = await claimPending(consumer, waitMs));
195
+ } catch (error) {
196
+ // Daemon roll or network blip: back off and re-park, never exit.
197
+ console.error(`[pump] claim retrying: ${error instanceof Error ? error.message : String(error)}`);
198
+ await new Promise((resolve) => setTimeout(resolve, 10_000));
199
+ continue;
200
+ }
201
+ if (pending.length === 0) {
202
+ // Old-server guard: a 0.4.8 daemon ignores waitMs, returning instantly —
203
+ // without this the loop would spin hot.
204
+ fastEmpties = tookMs < 1_000 ? fastEmpties + 1 : 0;
205
+ if (fastEmpties >= 2) await new Promise((resolve) => setTimeout(resolve, 10_000));
206
+ continue;
207
+ }
208
+ fastEmpties = 0;
209
+ const steer = pending[0]!;
210
+
211
+ // Awaiting-mark memory: the exec already ran for this id — a failed mark
212
+ // must not re-run the command (the duplicate-send race).
213
+ if (awaitingMark.has(steer.id)) {
214
+ if (await markDelivered(steer.id, consumer)) awaitingMark.delete(steer.id);
215
+ continue;
216
+ }
217
+
218
+ await setPresence(consumer, label, parent, { phase: 'tooling', detail: `steer: ${steer.message.slice(0, 60)}` });
219
+ const age = steer.createdAt
220
+ ? `, age ${Math.max(0, Math.round((Date.now() - Date.parse(steer.createdAt)) / 1000))}s`
221
+ : '';
222
+ console.log(
223
+ `[pump] steer ${steer.id} from ${steer.source ?? '?'} → ${steer.target ?? 'ALL'}${age}: ${steer.message.slice(0, 120)}`,
224
+ );
225
+ const code = await runExec(execTemplate, steer, consumer);
226
+ if (code === 0) {
227
+ awaitingMark.add(steer.id);
228
+ if (await markDelivered(steer.id, consumer)) awaitingMark.delete(steer.id);
229
+ failures.delete(steer.id);
230
+ await settlePresence(consumer, label, parent);
231
+ if (once) return;
232
+ } else {
233
+ const count = (failures.get(steer.id) ?? 0) + 1;
234
+ failures.set(steer.id, count);
235
+ console.error(`[pump] exec exited ${code} for ${steer.id} (attempt ${count}/3)`);
236
+ if (count >= 3) {
237
+ // A failed host injection is still pending work; only successful execs may mark delivery.
238
+ failures.delete(steer.id);
239
+ console.error(`[pump] stopped after three failures for ${steer.id} — steer remains pending`);
240
+ await setPresence(consumer, label, parent, {
241
+ phase: 'idle',
242
+ detail: `delivery failed: ${steer.id} remains pending`,
243
+ });
244
+ throw new Error(`Pump delivery failed three times for ${steer.id}; steer remains pending.`);
245
+ } else {
246
+ await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
247
+ }
248
+ await settlePresence(consumer, label, parent);
249
+ }
250
+ }
251
+ },
252
+ );
package/src/cli/index.ts CHANGED
@@ -8,7 +8,6 @@ import { runAgentCli } from './agent.js';
8
8
  import { isCanvasTheme } from '../shared/themes.js';
9
9
  import { createCanvas } from '../server/index.js';
10
10
  import { seedDemoCanvas } from '../server/demo.js';
11
- import { ensureDefaultDockedNodes } from '../server/server.js';
12
11
 
13
12
  const args = process.argv.slice(2);
14
13
 
@@ -45,6 +44,7 @@ const AGENT_COMMANDS = new Set([
45
44
  'screenshot',
46
45
  'pin',
47
46
  'ax',
47
+ 'pump',
48
48
  'undo',
49
49
  'redo',
50
50
  'history',
@@ -404,10 +404,6 @@ Examples:
404
404
 
405
405
  if (demo && canvas.getLayout().nodes.length === 0) {
406
406
  seedDemoCanvas();
407
- } else if (!demo) {
408
- // First-run only: dock a status (left) + context (right) widget by default so
409
- // a fresh canvas isn't empty. No-op once the workspace has saved state.
410
- ensureDefaultDockedNodes();
411
407
  }
412
408
 
413
409
  console.log(`\n PMX Canvas running at http://localhost:${canvas.port}`);
package/src/cli/shared.ts CHANGED
@@ -118,7 +118,7 @@ function output(data: unknown): void {
118
118
  async function invokeOperation(name: string, input: Record<string, unknown>): Promise<unknown> {
119
119
  const base = getBaseUrl();
120
120
  try {
121
- return await new HttpOperationInvoker(base).invoke(name, input);
121
+ return await new HttpOperationInvoker(base, 'cli').invoke(name, input);
122
122
  } catch (error) {
123
123
  if (error instanceof OperationError) {
124
124
  die(error.message);