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.
- package/.github/extensions/pmx-canvas/extension.mjs +154 -5
- package/.github/extensions/pmx-canvas/steering-delivery.mjs +79 -0
- package/CHANGELOG.md +156 -0
- package/Readme.md +72 -23
- package/dist/canvas/global.css +3069 -796
- package/dist/canvas/index.js +104 -104
- package/dist/json-render/index.css +1 -1
- package/dist/types/cli/agent.d.ts +1 -0
- package/dist/types/cli/commands/pump.d.ts +9 -0
- package/dist/types/client/App.d.ts +1 -1
- package/dist/types/client/canvas/AgentPresenceLayer.d.ts +11 -0
- package/dist/types/client/canvas/BarHint.d.ts +22 -0
- package/dist/types/client/canvas/CanvasNode.d.ts +5 -0
- package/dist/types/client/canvas/CanvasViewport.d.ts +4 -4
- package/dist/types/client/canvas/CommandBar.d.ts +10 -0
- package/dist/types/client/canvas/ConnectionBanner.d.ts +13 -0
- package/dist/types/client/canvas/ContextMenu.d.ts +6 -0
- package/dist/types/client/canvas/EdgeLayer.d.ts +2 -1
- package/dist/types/client/canvas/EmptyState.d.ts +11 -0
- package/dist/types/client/canvas/ExternalWriters.d.ts +5 -0
- package/dist/types/client/canvas/HumanPresenceLayer.d.ts +8 -0
- package/dist/types/client/canvas/Minimap.d.ts +12 -1
- package/dist/types/client/canvas/ScopeFenceLayer.d.ts +8 -0
- package/dist/types/client/canvas/SelectionBar.d.ts +6 -0
- package/dist/types/client/canvas/SessionPanel.d.ts +1 -0
- package/dist/types/client/canvas/SessionReceipt.d.ts +17 -0
- package/dist/types/client/canvas/TextPrompt.d.ts +25 -0
- package/dist/types/client/canvas/ToolRail.d.ts +21 -0
- package/dist/types/client/canvas/TopBar.d.ts +7 -0
- package/dist/types/client/canvas/canvas-area.d.ts +25 -0
- package/dist/types/client/canvas/create-in-view.d.ts +18 -0
- package/dist/types/client/canvas/import-files.d.ts +8 -0
- package/dist/types/client/canvas/kind-colors.d.ts +3 -0
- package/dist/types/client/canvas/use-focus-trap.d.ts +14 -0
- package/dist/types/client/canvas/use-now.d.ts +6 -0
- package/dist/types/client/icons.d.ts +11 -4
- package/dist/types/client/nodes/ExtAppFrame.d.ts +32 -0
- package/dist/types/client/nodes/GroupNode.d.ts +6 -1
- package/dist/types/client/nodes/MarkdownNode.d.ts +2 -0
- package/dist/types/client/nodes/inline-editor-commands.d.ts +3 -4
- package/dist/types/client/state/canvas-store.d.ts +49 -5
- package/dist/types/client/state/human-store.d.ts +26 -0
- package/dist/types/client/state/iframe-mode.d.ts +1 -0
- package/dist/types/client/state/intent-bridge.d.ts +37 -5
- package/dist/types/client/state/node-factory.d.ts +0 -1
- package/dist/types/client/state/presence-store.d.ts +78 -0
- package/dist/types/client/state/session-store.d.ts +176 -0
- package/dist/types/client/types.d.ts +2 -1
- package/dist/types/client/utils/platform.d.ts +2 -0
- package/dist/types/server/agent-presence.d.ts +192 -0
- package/dist/types/server/ax-gate-ttl.d.ts +6 -0
- package/dist/types/server/ax-interaction.d.ts +4 -10
- package/dist/types/server/ax-state-manager.d.ts +21 -2
- package/dist/types/server/ax-state.d.ts +49 -3
- package/dist/types/server/ax-wait.d.ts +13 -0
- package/dist/types/server/canvas-db.d.ts +23 -1
- package/dist/types/server/canvas-operations.d.ts +1 -0
- package/dist/types/server/canvas-state.d.ts +27 -13
- package/dist/types/server/canvas-validation.d.ts +0 -14
- package/dist/types/server/html-surface.d.ts +1 -1
- package/dist/types/server/human-presence.d.ts +40 -0
- package/dist/types/server/index.d.ts +29 -3
- package/dist/types/server/intent-registry.d.ts +7 -0
- package/dist/types/server/mutation-history.d.ts +15 -2
- package/dist/types/server/operations/invoker.d.ts +13 -2
- package/dist/types/server/operations/ops/ax-presence.d.ts +2 -0
- package/dist/types/server/operations/ops/human-presence.d.ts +3 -0
- package/dist/types/server/operations/registry.d.ts +11 -0
- package/dist/types/server/operations/types.d.ts +1 -1
- package/dist/types/server/scope-fence.d.ts +62 -0
- package/dist/types/server/server.d.ts +0 -12
- package/dist/types/shared/agent-presence.d.ts +130 -0
- package/dist/types/shared/approval-gates.d.ts +19 -0
- package/dist/types/shared/auto-arrange.d.ts +0 -1
- package/dist/types/shared/ax-kinds.d.ts +11 -0
- package/dist/types/shared/human-presence.d.ts +31 -0
- package/dist/types/shared/iframe-probe.d.ts +8 -0
- package/dist/types/shared/scope-fence.d.ts +21 -0
- package/docs/RELEASE.md +54 -0
- package/docs/ax-host-adapter-contract.md +30 -14
- package/docs/cli.md +37 -1
- package/docs/environment.md +3 -0
- package/docs/http-api.md +222 -5
- package/docs/mcp.md +6 -5
- package/docs/node-types.md +3 -1
- package/docs/plans/plan-009-tech-debt-backlog.md +13 -0
- package/docs/screenshot.png +0 -0
- package/docs/screenshots/multi-agent-live.png +0 -0
- package/docs/screenshots/workbench-live-dark.png +0 -0
- package/docs/screenshots/workbench-live-light.png +0 -0
- package/docs/sdk.md +1 -1
- package/package.json +1 -1
- package/skills/pmx-canvas/SKILL.md +109 -18
- package/skills/pmx-canvas/references/codex-app-adapter.md +28 -0
- package/skills/pmx-canvas/references/full-reference.md +55 -16
- package/skills/pmx-canvas/references/github-copilot-app-adapter.md +41 -24
- package/skills/pmx-canvas-orchestration/SKILL.md +56 -2
- package/skills/pmx-canvas-testing/SKILL.md +57 -0
- package/src/cli/agent.ts +1 -0
- package/src/cli/commands/copilot.ts +15 -8
- package/src/cli/commands/nodes.ts +1 -19
- package/src/cli/commands/pump.ts +252 -0
- package/src/cli/index.ts +1 -5
- package/src/cli/shared.ts +1 -1
- package/src/client/App.tsx +209 -585
- package/src/client/canvas/AgentPresenceLayer.tsx +74 -0
- package/src/client/canvas/AttentionHistory.tsx +0 -10
- package/src/client/canvas/BarHint.tsx +49 -0
- package/src/client/canvas/CanvasNode.tsx +488 -136
- package/src/client/canvas/CanvasViewport.tsx +190 -94
- package/src/client/canvas/CommandBar.tsx +140 -0
- package/src/client/canvas/CommandPalette.tsx +133 -58
- package/src/client/canvas/ConnectionBanner.tsx +65 -0
- package/src/client/canvas/ContextMenu.tsx +88 -27
- package/src/client/canvas/ContextPinBar.tsx +2 -2
- package/src/client/canvas/EdgeLayer.tsx +121 -38
- package/src/client/canvas/EmptyState.tsx +93 -0
- package/src/client/canvas/ExpandedNodeOverlay.tsx +64 -118
- package/src/client/canvas/ExternalWriters.tsx +308 -0
- package/src/client/canvas/FocusFieldLayer.tsx +12 -1
- package/src/client/canvas/HumanPresenceLayer.tsx +45 -0
- package/src/client/canvas/IntentLayer.tsx +1 -1
- package/src/client/canvas/Minimap.tsx +117 -190
- package/src/client/canvas/ScopeFenceLayer.tsx +38 -0
- package/src/client/canvas/SelectionBar.tsx +175 -23
- package/src/client/canvas/SessionPanel.tsx +481 -0
- package/src/client/canvas/SessionReceipt.tsx +141 -0
- package/src/client/canvas/ShortcutOverlay.tsx +21 -6
- package/src/client/canvas/SnapshotPanel.tsx +166 -80
- package/src/client/canvas/TextPrompt.tsx +128 -0
- package/src/client/canvas/ToolRail.tsx +447 -0
- package/src/client/canvas/TopBar.tsx +308 -0
- package/src/client/canvas/auto-fit.ts +1 -5
- package/src/client/canvas/canvas-area.ts +41 -0
- package/src/client/canvas/create-in-view.ts +48 -0
- package/src/client/canvas/import-files.ts +64 -0
- package/src/client/canvas/kind-colors.ts +22 -0
- package/src/client/canvas/snap-guides.ts +1 -1
- package/src/client/canvas/use-focus-trap.ts +54 -0
- package/src/client/canvas/use-now.ts +17 -0
- package/src/client/canvas/use-pan-zoom.ts +13 -2
- package/src/client/icons.tsx +57 -18
- package/src/client/nodes/ExtAppFrame.tsx +79 -2
- package/src/client/nodes/GroupNode.tsx +9 -28
- package/src/client/nodes/InlineFormatBar.tsx +4 -3
- package/src/client/nodes/InlineMarkdownEditor.tsx +27 -0
- package/src/client/nodes/MarkdownNode.tsx +59 -24
- package/src/client/nodes/MdFormatBar.tsx +2 -4
- package/src/client/nodes/inline-editor-commands.ts +17 -6
- package/src/client/nodes/use-surface-frame.ts +3 -1
- package/src/client/state/attention-bridge.ts +3 -0
- package/src/client/state/canvas-store.ts +273 -84
- package/src/client/state/human-store.ts +175 -0
- package/src/client/state/iframe-mode.ts +33 -9
- package/src/client/state/intent-bridge.ts +75 -13
- package/src/client/state/node-factory.ts +2 -8
- package/src/client/state/presence-store.ts +168 -0
- package/src/client/state/session-store.ts +419 -0
- package/src/client/state/sse-bridge.ts +93 -30
- package/src/client/theme/global.css +3069 -796
- package/src/client/types.ts +3 -1
- package/src/client/utils/platform.ts +6 -1
- package/src/mcp/canvas-access.ts +2 -2
- package/src/server/agent-presence.ts +755 -0
- package/src/server/ax-context.ts +45 -0
- package/src/server/ax-gate-ttl.ts +61 -0
- package/src/server/ax-interaction.ts +29 -1
- package/src/server/ax-state-manager.ts +78 -6
- package/src/server/ax-state.ts +82 -15
- package/src/server/ax-wait.ts +33 -0
- package/src/server/canvas-db.ts +237 -61
- package/src/server/canvas-operations.ts +17 -8
- package/src/server/canvas-state.ts +131 -58
- package/src/server/canvas-validation.ts +3 -53
- package/src/server/demo-state.json +2 -70
- package/src/server/html-surface.ts +11 -2
- package/src/server/human-presence.ts +160 -0
- package/src/server/index.ts +107 -4
- package/src/server/intent-registry.ts +13 -0
- package/src/server/mutation-history.ts +47 -2
- package/src/server/operations/composites.ts +6 -3
- package/src/server/operations/http.ts +10 -1
- package/src/server/operations/index.ts +4 -0
- package/src/server/operations/invoker.ts +33 -6
- package/src/server/operations/ops/ax-presence.ts +62 -0
- package/src/server/operations/ops/ax-read.ts +8 -0
- package/src/server/operations/ops/ax-state.ts +106 -1
- package/src/server/operations/ops/ax-timeline.ts +54 -5
- package/src/server/operations/ops/ax-work.ts +46 -0
- package/src/server/operations/ops/batch.ts +3 -1
- package/src/server/operations/ops/edges.ts +88 -2
- package/src/server/operations/ops/groups.ts +4 -3
- package/src/server/operations/ops/human-presence.ts +35 -0
- package/src/server/operations/ops/json-render.ts +11 -1
- package/src/server/operations/ops/nodes.ts +65 -16
- package/src/server/operations/ops/query.ts +1 -0
- package/src/server/operations/ops/snapshots.ts +16 -0
- package/src/server/operations/registry.ts +196 -1
- package/src/server/operations/types.ts +1 -1
- package/src/server/scope-fence.ts +207 -0
- package/src/server/server.ts +177 -79
- package/src/server/trace-manager.ts +0 -2
- package/src/server/web-artifacts.ts +0 -1
- package/src/shared/agent-presence.ts +162 -0
- package/src/shared/approval-gates.ts +35 -0
- package/src/shared/auto-arrange.ts +121 -13
- package/src/shared/ax-kinds.ts +23 -0
- package/src/shared/human-presence.ts +30 -0
- package/src/shared/iframe-probe.ts +8 -0
- package/src/shared/scope-fence.ts +31 -0
- package/dist/types/client/canvas/DockedNode.d.ts +0 -4
- 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
|
@@ -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
|
|
21
|
-
|
|
22
|
-
|
|
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(
|
|
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
|
-
|
|
41
|
-
|
|
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,
|
|
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);
|