pmx-canvas 0.4.3 → 0.4.5
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/CHANGELOG.md +79 -0
- package/Readme.md +40 -8
- package/dist/canvas/global.css +91 -0
- package/dist/canvas/index.js +53 -53
- package/dist/canvas/surface-theme.css +32 -0
- package/dist/types/cli/agent.d.ts +1 -0
- package/dist/types/cli/commands/skills.d.ts +41 -0
- package/dist/types/client/state/intent-store.d.ts +6 -0
- package/dist/types/client/state/sse-bridge.d.ts +1 -0
- package/dist/types/server/ax-state-manager.d.ts +5 -0
- package/dist/types/server/ax-state.d.ts +9 -3
- package/dist/types/server/canvas-db.d.ts +3 -2
- package/dist/types/server/canvas-state.d.ts +12 -0
- package/dist/types/server/operations/ops/ax-shared.d.ts +4 -1
- package/dist/types/server/operations/registry.d.ts +9 -1
- package/dist/types/shared/ax-intent.d.ts +6 -0
- package/dist/types/shared/themes.d.ts +1 -1
- package/docs/RELEASE.md +14 -6
- package/docs/bun-webview-integration.md +2 -2
- package/docs/cli.md +24 -1
- package/docs/environment.md +3 -2
- package/docs/http-api.md +6 -3
- package/docs/plans/plan-009-tech-debt-backlog.md +4 -3
- package/docs/screenshot.png +0 -0
- package/docs/screenshots/amp-orb-portal.png +0 -0
- package/docs/screenshots/claude-code-desktop.png +0 -0
- package/docs/screenshots/codex-app.png +0 -0
- package/docs/screenshots/github-copilot-app.png +0 -0
- package/package.json +1 -1
- package/skills/pmx-canvas/SKILL.md +25 -5
- package/skills/pmx-canvas/references/full-reference.md +1 -1
- package/skills/pmx-canvas-orchestration/SKILL.md +82 -0
- package/skills/pmx-canvas-testing/SKILL.md +4 -0
- package/src/cli/agent.ts +1 -0
- package/src/cli/commands/skills.ts +183 -0
- package/src/cli/index.ts +17 -3
- package/src/cli/shared.ts +1 -0
- package/src/client/canvas/CanvasNode.tsx +12 -1
- package/src/client/canvas/IntentLayer.tsx +8 -6
- package/src/client/state/intent-bridge.ts +12 -3
- package/src/client/state/intent-store.ts +47 -2
- package/src/client/state/sse-bridge.ts +14 -3
- package/src/client/theme/global.css +91 -0
- package/src/client/theme/surface-theme.css +32 -0
- package/src/mcp/server.ts +3 -5
- package/src/server/ax-state-manager.ts +27 -6
- package/src/server/ax-state.ts +36 -3
- package/src/server/canvas-db.ts +83 -60
- package/src/server/canvas-state.ts +52 -6
- package/src/server/intent-registry.ts +2 -0
- package/src/server/operations/http.ts +5 -1
- package/src/server/operations/ops/ax-shared.ts +20 -1
- package/src/server/operations/ops/ax-state.ts +4 -7
- package/src/server/operations/ops/ax-timeline.ts +26 -3
- package/src/server/operations/ops/ax-work.ts +27 -1
- package/src/server/operations/ops/batch.ts +3 -1
- package/src/server/operations/ops/canvas-wire.ts +1 -1
- package/src/server/operations/ops/nodes.ts +15 -1
- package/src/server/operations/registry.ts +122 -1
- package/src/server/server.ts +23 -4
- package/src/shared/ax-intent.ts +6 -0
- package/src/shared/themes.ts +2 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: pmx-canvas-orchestration
|
|
3
|
+
description: Choreography for running multi-agent orchestration ON a PMX Canvas board — the agent host (Claude Code, Copilot, Codex) executes; the canvas is the shared graph, state, human-steering surface, and audit trail. Load this in the ORCHESTRATOR session and in EVERY subagent that touches the board.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# PMX Canvas Orchestration
|
|
7
|
+
|
|
8
|
+
The division of labor is fixed: **the agent host runs the loop** (spawning, scheduling,
|
|
9
|
+
fan-out/join, retries); **the canvas is the graph** (persistent shared state, the live visual,
|
|
10
|
+
human gates and steering, the timeline). The canvas never executes anything. This skill is the
|
|
11
|
+
choreography that makes that split work — both the orchestrator and every subagent must follow
|
|
12
|
+
it, or the board silently diverges from reality.
|
|
13
|
+
|
|
14
|
+
## Identity
|
|
15
|
+
|
|
16
|
+
- Every participant picks a stable `agentId` (kebab-case role name: `orchestrator`,
|
|
17
|
+
`researcher`, `impl-auth`, …) and passes it on EVERY AX write that supports it (work items,
|
|
18
|
+
timeline events, steering). `source` stays the host label (`mcp`, `cli`, `amp`, …);
|
|
19
|
+
`agentId` is who within the host.
|
|
20
|
+
- One canvas node + one work item per subagent, created BY THE ORCHESTRATOR at spawn time
|
|
21
|
+
(title = the agentId). The subagent updates its own work item; it never creates its own
|
|
22
|
+
identity row.
|
|
23
|
+
- Tag every run: put the same `data.runId` on every NODE (work items carry no free-form data —
|
|
24
|
+
their run scoping comes from the run-prefixed `agentId`, e.g. `run7:impl-auth`), so
|
|
25
|
+
consecutive orchestrations don't interleave in the bounded timeline.
|
|
26
|
+
|
|
27
|
+
## Graph semantics
|
|
28
|
+
|
|
29
|
+
- `flow` edges = planned sequence. `depends-on` edges = blocking dependency (the visual DAG).
|
|
30
|
+
`relation`/`references` = context only — never scheduling.
|
|
31
|
+
- The orchestrator owns the topology: it creates all nodes and edges up front with one
|
|
32
|
+
`canvas_batch`, then only mutates statuses. Subagents mutate their OWN node/work item only.
|
|
33
|
+
- The host's real execution order is authoritative. When the host reorders or skips, the
|
|
34
|
+
orchestrator updates the edges — a board that contradicts execution is worse than no board.
|
|
35
|
+
|
|
36
|
+
## Status discipline
|
|
37
|
+
|
|
38
|
+
- Work-item status is the single source of step state: `todo → in-progress → done`
|
|
39
|
+
(or `blocked` / `cancelled`). Update the work item FIRST; linked nodes mirror the status automatically
|
|
40
|
+
(0.4.5+) — do not hand-edit a status node to fake progress.
|
|
41
|
+
- Signal `canvas_intent` before each phase of visible board mutation (the auto-ghost floor
|
|
42
|
+
covers forgotten signals, but only an explicit signal gives the human a veto window and your
|
|
43
|
+
reasoning).
|
|
44
|
+
|
|
45
|
+
## Gates and human input
|
|
46
|
+
|
|
47
|
+
- ALL approval gates, elicitations, and mode requests are created and awaited by the
|
|
48
|
+
ORCHESTRATOR, never by subagents. `canvas_ax_gate { action: "await" }` blocks at most ~120s
|
|
49
|
+
per call — the orchestrator re-awaits in a loop; a subagent burning turns on a gate wait is
|
|
50
|
+
a bug.
|
|
51
|
+
- A pending gate is NOT approval. Execution-class actions stay blocked until the gate is
|
|
52
|
+
explicitly `approved`.
|
|
53
|
+
|
|
54
|
+
## Steering
|
|
55
|
+
|
|
56
|
+
- Humans steer through the board; agents MUST poll for it — MCP notifications cannot wake a
|
|
57
|
+
subagent, so everything is pull. Cadence: check `canvas://ax-pending-steering` (or
|
|
58
|
+
`canvas_ax_delivery { action: "claim", consumer: "<agentId or host>" }`) between every major
|
|
59
|
+
step.
|
|
60
|
+
- Address a specific agent with the steering `target` field (0.4.5+). Untargeted steers are
|
|
61
|
+
broadcast: only the ORCHESTRATOR claims them and re-routes; subagents claim only steers
|
|
62
|
+
targeted at their own `agentId`.
|
|
63
|
+
- Mark delivery ONLY after acting on the message. The mark is compare-and-set: a
|
|
64
|
+
`delivered:false` response means another consumer owned it — drop it, do not act twice.
|
|
65
|
+
|
|
66
|
+
## Run lifecycle
|
|
67
|
+
|
|
68
|
+
1. Orchestrator: snapshot the board (`canvas_snapshot save`) before the run.
|
|
69
|
+
2. Orchestrator: one `canvas_batch` creating the run's nodes + edges (+ `data.runId`), one
|
|
70
|
+
work item per step/subagent, intent-signaled.
|
|
71
|
+
3. Spawn subagents; each loads this skill, learns its `agentId`, node id, and work-item id
|
|
72
|
+
from the spawn prompt.
|
|
73
|
+
4. Subagents: flip work item to `in-progress`, do the work, attach evidence
|
|
74
|
+
(`canvas_ax_timeline { action: "add-evidence" }`), flip to `done`, poll steering between
|
|
75
|
+
steps.
|
|
76
|
+
5. Orchestrator: join on host-side completion (the host knows the real DAG), resolve gates,
|
|
77
|
+
record a final timeline event, and validate the board (`canvas_query { action: "validate" }`).
|
|
78
|
+
6. On abort/failure: leave work items in their true state (`blocked` + a timeline failure
|
|
79
|
+
event), never green-wash.
|
|
80
|
+
|
|
81
|
+
The acceptance bar: a human watching the board and a human reading the host transcript must
|
|
82
|
+
see the same story.
|
|
@@ -74,6 +74,10 @@ Prefer extending the existing suites before inventing a one-off script.
|
|
|
74
74
|
- MCP harnesses using the official SDK must wind down BOTH halves: call `client.close()` AND
|
|
75
75
|
`transport.close()` on the `StdioClientTransport`. Closing only the client leaves the spawned
|
|
76
76
|
stdio server process alive after the test exits (0.4.0 cycle finding)
|
|
77
|
+
- Before a version-test cycle, run `pmx-canvas skills sync --check` in the consumer workspace —
|
|
78
|
+
exit 1 means the installed skill copies are stale against the package; run
|
|
79
|
+
`pmx-canvas skills sync --yes` to refresh them (whole trees, whatever agent layout owns them) before
|
|
80
|
+
trusting skill-guided results
|
|
77
81
|
|
|
78
82
|
## Layout And Embedded Content Checks
|
|
79
83
|
|
package/src/cli/agent.ts
CHANGED
|
@@ -31,6 +31,7 @@ import './commands/ax.js';
|
|
|
31
31
|
import './commands/webview.js';
|
|
32
32
|
import './commands/apps.js';
|
|
33
33
|
import './commands/copilot.js';
|
|
34
|
+
import './commands/skills.js';
|
|
34
35
|
|
|
35
36
|
// Re-exported for existing importers (tests import it from this module).
|
|
36
37
|
export { extractGlobalTargetFlags };
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// Skill mirror commands: skills sync (+ --check drift detection).
|
|
2
|
+
//
|
|
3
|
+
// Every release cycle found installed skill copies stale against the package,
|
|
4
|
+
// and testers hand-synced complete trees before results were trustworthy.
|
|
5
|
+
// This command automates that WITHOUT assuming any agent layout: it discovers
|
|
6
|
+
// the pmx-canvas skill copies already installed in the workspace (any
|
|
7
|
+
// `<dir>/skills/<skill-name>/SKILL.md`, whatever agent owns `<dir>`) and
|
|
8
|
+
// compares/replaces whole trees — references, evals, fixtures included. It
|
|
9
|
+
// never creates a mirror where none is installed.
|
|
10
|
+
|
|
11
|
+
import { cpSync, existsSync, readFileSync, readdirSync, renameSync, rmSync } from 'node:fs';
|
|
12
|
+
import { join, resolve } from 'node:path';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
import { cmd, output, parseFlags, showCommandHelp } from '../shared.js';
|
|
15
|
+
|
|
16
|
+
export const BUNDLED_SKILLS = ['pmx-canvas', 'pmx-canvas-testing', 'pmx-canvas-orchestration'] as const;
|
|
17
|
+
|
|
18
|
+
function listFilesRecursive(root: string, prefix = ''): string[] {
|
|
19
|
+
const out: string[] = [];
|
|
20
|
+
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
21
|
+
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
22
|
+
if (entry.isDirectory()) {
|
|
23
|
+
out.push(...listFilesRecursive(join(root, entry.name), rel));
|
|
24
|
+
} else if (entry.isFile()) {
|
|
25
|
+
out.push(rel);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return out.sort();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface SkillTreeComparison {
|
|
32
|
+
inSync: boolean;
|
|
33
|
+
/** Files present in the package tree but missing or different in the mirror. */
|
|
34
|
+
missingOrDifferent: string[];
|
|
35
|
+
/** Files present in the mirror but not in the package tree. */
|
|
36
|
+
extra: string[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function compareSkillTrees(sourceRoot: string, targetRoot: string): SkillTreeComparison {
|
|
40
|
+
const sourceFiles = listFilesRecursive(sourceRoot);
|
|
41
|
+
const targetFiles = listFilesRecursive(targetRoot);
|
|
42
|
+
const targetSet = new Set(targetFiles);
|
|
43
|
+
const missingOrDifferent: string[] = [];
|
|
44
|
+
for (const rel of sourceFiles) {
|
|
45
|
+
if (!targetSet.has(rel)) {
|
|
46
|
+
missingOrDifferent.push(rel);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (!readFileSync(join(sourceRoot, rel)).equals(readFileSync(join(targetRoot, rel)))) {
|
|
50
|
+
missingOrDifferent.push(rel);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const sourceSet = new Set(sourceFiles);
|
|
54
|
+
const extra = targetFiles.filter((rel) => !sourceSet.has(rel));
|
|
55
|
+
return { inSync: missingOrDifferent.length === 0 && extra.length === 0, missingOrDifferent, extra };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface InstalledSkillMirror {
|
|
59
|
+
/** The directory that owns the skills/ folder (e.g. ".codex", ".github", or any agent dir). */
|
|
60
|
+
host: string;
|
|
61
|
+
skill: string;
|
|
62
|
+
dir: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Find the bundled skills' installed copies in this workspace: any immediate
|
|
67
|
+
* child directory holding `skills/<skill>/SKILL.md`, regardless of which agent
|
|
68
|
+
* that directory belongs to. The package's own skills source is excluded so
|
|
69
|
+
* running inside the pmx-canvas checkout never compares a tree to itself.
|
|
70
|
+
*/
|
|
71
|
+
export function discoverInstalledSkillMirrors(
|
|
72
|
+
workspaceRoot: string,
|
|
73
|
+
packageSkillsRoot: string,
|
|
74
|
+
): InstalledSkillMirror[] {
|
|
75
|
+
const found: InstalledSkillMirror[] = [];
|
|
76
|
+
for (const entry of readdirSync(workspaceRoot, { withFileTypes: true })) {
|
|
77
|
+
if (!entry.isDirectory() || entry.name === 'node_modules') continue;
|
|
78
|
+
const skillsDir = join(workspaceRoot, entry.name, 'skills');
|
|
79
|
+
if (!existsSync(skillsDir)) continue;
|
|
80
|
+
for (const skill of BUNDLED_SKILLS) {
|
|
81
|
+
const dir = join(skillsDir, skill);
|
|
82
|
+
if (!existsSync(join(dir, 'SKILL.md'))) continue;
|
|
83
|
+
if (resolve(dir) === resolve(join(packageSkillsRoot, skill))) continue;
|
|
84
|
+
found.push({ host: entry.name, skill, dir });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return found;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface SkillMirrorResult {
|
|
91
|
+
host: string;
|
|
92
|
+
skill: string;
|
|
93
|
+
dir: string;
|
|
94
|
+
status: 'in-sync' | 'synced' | 'drifted' | 'skipped-not-a-package-copy';
|
|
95
|
+
missingOrDifferent?: string[];
|
|
96
|
+
extra?: string[];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* A mirror is only replaceable when its SKILL.md frontmatter `name:` matches
|
|
101
|
+
* the bundled skill it sits under. A user's own skill living in a same-named
|
|
102
|
+
* directory (different frontmatter identity) must never be deleted — these
|
|
103
|
+
* trees are typically gitignored, so a wrong replace is unrecoverable.
|
|
104
|
+
*/
|
|
105
|
+
export function mirrorIdentityMatches(mirrorDir: string, skill: string): boolean {
|
|
106
|
+
try {
|
|
107
|
+
const head = readFileSync(join(mirrorDir, 'SKILL.md'), 'utf-8').slice(0, 2000);
|
|
108
|
+
const match = /^name:\s*(\S+)\s*$/m.exec(head);
|
|
109
|
+
return match?.[1] === skill;
|
|
110
|
+
} catch {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Sync (or, with check=true, only compare) every INSTALLED mirror against the package trees. */
|
|
116
|
+
export function syncInstalledSkillMirrors(
|
|
117
|
+
packageSkillsRoot: string,
|
|
118
|
+
workspaceRoot: string,
|
|
119
|
+
opts: { check: boolean },
|
|
120
|
+
): SkillMirrorResult[] {
|
|
121
|
+
const results: SkillMirrorResult[] = [];
|
|
122
|
+
for (const mirror of discoverInstalledSkillMirrors(workspaceRoot, packageSkillsRoot)) {
|
|
123
|
+
const source = join(packageSkillsRoot, mirror.skill);
|
|
124
|
+
if (!mirrorIdentityMatches(mirror.dir, mirror.skill)) {
|
|
125
|
+
results.push({ ...mirror, status: 'skipped-not-a-package-copy' });
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const cmp = compareSkillTrees(source, mirror.dir);
|
|
129
|
+
if (cmp.inSync) {
|
|
130
|
+
results.push({ ...mirror, status: 'in-sync' });
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (opts.check) {
|
|
134
|
+
results.push({ ...mirror, status: 'drifted', ...cmp });
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
// Full-tree replace: a copy-over would leave deleted package files behind
|
|
138
|
+
// in the mirror, which is exactly the drift class release audits kept
|
|
139
|
+
// hitting even when SKILL.md hashes matched. Stage the complete new tree
|
|
140
|
+
// BESIDE the mirror first, so a crash between the rm and the rename leaves
|
|
141
|
+
// the staged copy on disk as recovery instead of losing the install.
|
|
142
|
+
const staging = `${mirror.dir}.pmx-sync-staging`;
|
|
143
|
+
rmSync(staging, { recursive: true, force: true });
|
|
144
|
+
cpSync(source, staging, { recursive: true });
|
|
145
|
+
rmSync(mirror.dir, { recursive: true, force: true });
|
|
146
|
+
renameSync(staging, mirror.dir);
|
|
147
|
+
results.push({ ...mirror, status: 'synced' });
|
|
148
|
+
}
|
|
149
|
+
return results;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ── skills sync ──────────────────────────────────────────────
|
|
153
|
+
cmd(
|
|
154
|
+
'skills sync',
|
|
155
|
+
'Refresh the pmx-canvas skill copies already installed in this workspace (any agent layout)',
|
|
156
|
+
['pmx-canvas skills sync --yes', 'pmx-canvas skills sync --check'],
|
|
157
|
+
async (args) => {
|
|
158
|
+
const { flags } = parseFlags(args);
|
|
159
|
+
if (flags.help || flags.h) return showCommandHelp('skills sync');
|
|
160
|
+
|
|
161
|
+
const packageSkillsRoot = fileURLToPath(new URL('../../../skills', import.meta.url));
|
|
162
|
+
// Replacing a mirror deletes a whole installed tree — destructive, so it
|
|
163
|
+
// follows the CLI's `--yes` contract. Without --yes (or with --check) the
|
|
164
|
+
// command only reports drift; exit 1 signals stale either way.
|
|
165
|
+
const check = flags.check === true || flags.yes !== true;
|
|
166
|
+
const results = syncInstalledSkillMirrors(packageSkillsRoot, process.cwd(), { check });
|
|
167
|
+
|
|
168
|
+
const drifted = results.filter((r) => r.status === 'drifted');
|
|
169
|
+
const ok = drifted.length === 0;
|
|
170
|
+
output({
|
|
171
|
+
ok,
|
|
172
|
+
check,
|
|
173
|
+
packageSkillsRoot,
|
|
174
|
+
mirrorsFound: results.length,
|
|
175
|
+
...(results.length === 0 ? { note: 'No installed pmx-canvas skill copies found in this workspace.' } : {}),
|
|
176
|
+
...(drifted.length > 0 && flags.yes !== true
|
|
177
|
+
? { note: 'Drift detected. Re-run with --yes to replace the drifted trees from the installed package.' }
|
|
178
|
+
: {}),
|
|
179
|
+
results,
|
|
180
|
+
});
|
|
181
|
+
if (!ok) process.exitCode = 1;
|
|
182
|
+
},
|
|
183
|
+
);
|
package/src/cli/index.ts
CHANGED
|
@@ -66,6 +66,7 @@ const AGENT_COMMANDS = new Set([
|
|
|
66
66
|
'validate',
|
|
67
67
|
'serve',
|
|
68
68
|
'copilot',
|
|
69
|
+
'skills',
|
|
69
70
|
]);
|
|
70
71
|
|
|
71
72
|
const firstArg = args[0] ?? '';
|
|
@@ -165,7 +166,13 @@ const serveSubcommand = firstArg === 'serve' ? (args[1] ?? '') : '';
|
|
|
165
166
|
|
|
166
167
|
if (firstArg === 'serve' && (serveSubcommand === 'status' || serveSubcommand === 'stop')) {
|
|
167
168
|
const port = parseInt(
|
|
168
|
-
readOption('port') ??
|
|
169
|
+
readOption('port') ??
|
|
170
|
+
process.env.PMX_WEB_CANVAS_PORT ??
|
|
171
|
+
process.env.PMX_CANVAS_PORT ??
|
|
172
|
+
// Amp orbs assign the portal port via $PORT; honored only under AMP_ORB
|
|
173
|
+
// so a stray PORT in normal shells never shifts the default.
|
|
174
|
+
(process.env.AMP_ORB ? process.env.PORT || undefined : undefined) ??
|
|
175
|
+
'4313',
|
|
169
176
|
10,
|
|
170
177
|
);
|
|
171
178
|
const daemonLogFile = resolve(readOption('log-file') ?? `.pmx-canvas/daemon-${port}.log`);
|
|
@@ -213,7 +220,13 @@ if (AGENT_COMMANDS.has(routedCommand) && routedCommand !== 'serve') {
|
|
|
213
220
|
} else {
|
|
214
221
|
// "serve" is also accessible via flags (backward compat)
|
|
215
222
|
const port = parseInt(
|
|
216
|
-
readOption('port') ??
|
|
223
|
+
readOption('port') ??
|
|
224
|
+
process.env.PMX_WEB_CANVAS_PORT ??
|
|
225
|
+
process.env.PMX_CANVAS_PORT ??
|
|
226
|
+
// Amp orbs assign the portal port via $PORT; honored only under AMP_ORB
|
|
227
|
+
// so a stray PORT in normal shells never shifts the default.
|
|
228
|
+
(process.env.AMP_ORB ? process.env.PORT || undefined : undefined) ??
|
|
229
|
+
'4313',
|
|
217
230
|
10,
|
|
218
231
|
);
|
|
219
232
|
const demo = hasFlag('demo');
|
|
@@ -253,7 +266,7 @@ Server options:
|
|
|
253
266
|
--log-file=PATH Daemon log file (default: ./.pmx-canvas/daemon-${port}.log)
|
|
254
267
|
--pid-file=PATH Optional daemon PID file (default: ./.pmx-canvas/daemon-${port}.pid)
|
|
255
268
|
--wait-ms=MS Health-check wait budget for daemon mode (default: 10000)
|
|
256
|
-
--theme=THEME Theme: dark (default), light, high-contrast, midnight, sepia, arctic, ember, forest
|
|
269
|
+
--theme=THEME Theme: dark (default), light, high-contrast, midnight, sepia, arctic, ember, forest, volt
|
|
257
270
|
--webview-automation Start a headless Bun.WebView automation session for /workbench
|
|
258
271
|
--webview-backend=BACKEND Bun.WebView backend: chrome or webkit
|
|
259
272
|
--webview-width=PX Automation WebView width (default: 1280)
|
|
@@ -343,6 +356,7 @@ Examples:
|
|
|
343
356
|
pmx-canvas watch --events context-pin,move-end Watch semantic deltas
|
|
344
357
|
pmx-canvas clear --dry-run Preview destructive op
|
|
345
358
|
pmx-canvas copilot install-extension --dry-run Preview Copilot adapter install
|
|
359
|
+
pmx-canvas skills sync --yes Refresh installed pmx-canvas skill copies in this workspace (--check to detect drift)
|
|
346
360
|
`);
|
|
347
361
|
process.exit(0);
|
|
348
362
|
}
|
package/src/cli/shared.ts
CHANGED
|
@@ -140,7 +140,15 @@ export function CanvasNode({ node, children, onContextMenu }: CanvasNodeProps) {
|
|
|
140
140
|
requestAnimationFrame(() => renameRef.current?.focus());
|
|
141
141
|
}, []);
|
|
142
142
|
|
|
143
|
-
const title =
|
|
143
|
+
const title =
|
|
144
|
+
(node.data.title as string) ||
|
|
145
|
+
// Untitled file nodes (created before titles were synthesized server-side)
|
|
146
|
+
// still show their filename instead of a bare type label.
|
|
147
|
+
(node.type === 'file'
|
|
148
|
+
? // Separator-agnostic: node paths are OS-native (backslashes on Windows).
|
|
149
|
+
(((node.data.path as string) || (node.data.content as string) || '').split(/[\\/]/).pop() ?? '')
|
|
150
|
+
: '') ||
|
|
151
|
+
TYPE_LABELS[node.type];
|
|
144
152
|
|
|
145
153
|
const commitRename = useCallback(
|
|
146
154
|
(value: string) => {
|
|
@@ -254,6 +262,9 @@ export function CanvasNode({ node, children, onContextMenu }: CanvasNodeProps) {
|
|
|
254
262
|
})()}
|
|
255
263
|
</span>
|
|
256
264
|
<span class="node-type-badge">{TYPE_LABELS[node.type]}</span>
|
|
265
|
+
{typeof node.data.axWorkStatus === 'string' && (
|
|
266
|
+
<span class={`node-ax-status node-ax-status-${node.data.axWorkStatus}`}>{node.data.axWorkStatus}</span>
|
|
267
|
+
)}
|
|
257
268
|
{renaming ? (
|
|
258
269
|
<input
|
|
259
270
|
ref={renameRef}
|
|
@@ -59,8 +59,10 @@ function center(rect: Rect): { x: number; y: number } {
|
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
function ghostOpacity(intent: ClientIntent): number {
|
|
62
|
-
|
|
63
|
-
|
|
62
|
+
const base = typeof intent.confidence !== 'number' ? 0.82 : 0.4 + Math.max(0, Math.min(1, intent.confidence)) * 0.55;
|
|
63
|
+
// Auto-ghosts (server-synthesized, no deliberate signal) read lighter than
|
|
64
|
+
// an explicit canvas_intent so the two are visually distinguishable.
|
|
65
|
+
return intent.auto ? base * 0.62 : base;
|
|
64
66
|
}
|
|
65
67
|
|
|
66
68
|
function bezierPath(a: { x: number; y: number }, b: { x: number; y: number }): string {
|
|
@@ -89,7 +91,7 @@ function GhostInfo({ intent }: { intent: ClientIntent }) {
|
|
|
89
91
|
)}
|
|
90
92
|
<span class="intent-chip-label">{label}</span>
|
|
91
93
|
{confidencePct && <span class="intent-confidence">{confidencePct}</span>}
|
|
92
|
-
{intent.phase === 'forming' && (
|
|
94
|
+
{intent.phase === 'forming' && !intent.auto && (
|
|
93
95
|
<button
|
|
94
96
|
type="button"
|
|
95
97
|
class="intent-veto"
|
|
@@ -114,7 +116,7 @@ function GhostBox({ intent, rect }: { intent: ClientIntent; rect: Rect }) {
|
|
|
114
116
|
const typeLabel = isKnownNodeType(intent.nodeType) ? TYPE_LABELS[intent.nodeType] : 'Node';
|
|
115
117
|
return (
|
|
116
118
|
<div
|
|
117
|
-
class={`intent-ghost intent-ghost-box is-${intent.phase}`}
|
|
119
|
+
class={`intent-ghost intent-ghost-box is-${intent.phase}${intent.auto ? ' is-auto' : ''}`}
|
|
118
120
|
data-intent-id={intent.id}
|
|
119
121
|
style={{
|
|
120
122
|
left: `${rect.left}px`,
|
|
@@ -138,7 +140,7 @@ function GhostBox({ intent, rect }: { intent: ClientIntent; rect: Rect }) {
|
|
|
138
140
|
function GhostOverlay({ intent, rect, variant }: { intent: ClientIntent; rect: Rect; variant: 'remove' | 'edit' }) {
|
|
139
141
|
return (
|
|
140
142
|
<div
|
|
141
|
-
class={`intent-ghost intent-ghost-${variant} is-${intent.phase}`}
|
|
143
|
+
class={`intent-ghost intent-ghost-${variant} is-${intent.phase}${intent.auto ? ' is-auto' : ''}`}
|
|
142
144
|
data-intent-id={intent.id}
|
|
143
145
|
style={{
|
|
144
146
|
left: `${rect.left}px`,
|
|
@@ -199,7 +201,7 @@ function renderGhost(intent: ClientIntent) {
|
|
|
199
201
|
return (
|
|
200
202
|
<div
|
|
201
203
|
key={intent.id}
|
|
202
|
-
class={`intent-ghost intent-ghost-connect is-${intent.phase}`}
|
|
204
|
+
class={`intent-ghost intent-ghost-connect is-${intent.phase}${intent.auto ? ' is-auto' : ''}`}
|
|
203
205
|
data-intent-id={intent.id}
|
|
204
206
|
style={{
|
|
205
207
|
left: `${rect.left}px`,
|
|
@@ -2,9 +2,18 @@ function logRequestError(action: string, error: unknown): void {
|
|
|
2
2
|
console.error(`[intent-bridge] ${action} failed`, error);
|
|
3
3
|
}
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Every workbench-originated request carries this marker so the server never
|
|
7
|
+
* synthesizes an auto-ghost for it — a human dragging or editing in the
|
|
8
|
+
* browser is not agent activity (the ghost cursor shows AGENT moves).
|
|
9
|
+
*/
|
|
10
|
+
function withWorkbenchMarker(init?: RequestInit): RequestInit {
|
|
11
|
+
return { ...init, headers: { ...(init?.headers as Record<string, string> | undefined), 'X-PMX-Workbench': '1' } };
|
|
12
|
+
}
|
|
13
|
+
|
|
5
14
|
async function requestJson<T>(action: string, url: string, fallback: T, init?: RequestInit): Promise<T> {
|
|
6
15
|
try {
|
|
7
|
-
const res = await fetch(url, init);
|
|
16
|
+
const res = await fetch(url, withWorkbenchMarker(init));
|
|
8
17
|
return (await res.json()) as T;
|
|
9
18
|
} catch (error) {
|
|
10
19
|
logRequestError(action, error);
|
|
@@ -14,7 +23,7 @@ async function requestJson<T>(action: string, url: string, fallback: T, init?: R
|
|
|
14
23
|
|
|
15
24
|
async function requestOk(action: string, url: string, init?: RequestInit): Promise<{ ok: boolean }> {
|
|
16
25
|
try {
|
|
17
|
-
const res = await fetch(url, init);
|
|
26
|
+
const res = await fetch(url, withWorkbenchMarker(init));
|
|
18
27
|
return { ok: res.ok };
|
|
19
28
|
} catch (error) {
|
|
20
29
|
logRequestError(action, error);
|
|
@@ -24,7 +33,7 @@ async function requestOk(action: string, url: string, init?: RequestInit): Promi
|
|
|
24
33
|
|
|
25
34
|
async function requestBestEffort(action: string, url: string, init?: RequestInit): Promise<void> {
|
|
26
35
|
try {
|
|
27
|
-
await fetch(url, init);
|
|
36
|
+
await fetch(url, withWorkbenchMarker(init));
|
|
28
37
|
} catch (error) {
|
|
29
38
|
logRequestError(action, error);
|
|
30
39
|
}
|
|
@@ -23,8 +23,16 @@ export const hoveredIntentId = signal<string | null>(null);
|
|
|
23
23
|
|
|
24
24
|
const SETTLE_MS = 480;
|
|
25
25
|
const DISSOLVE_MS = 320;
|
|
26
|
+
/**
|
|
27
|
+
* Minimum time a ghost stays in its forming state before a settle/dissolve
|
|
28
|
+
* plays. Fast agents (and every server auto-ghost) signal and settle within
|
|
29
|
+
* milliseconds — without this floor, correct ghost usage is imperceptible.
|
|
30
|
+
*/
|
|
31
|
+
export const MIN_FORMING_MS = 650;
|
|
26
32
|
|
|
27
33
|
const exitTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
34
|
+
const phaseDelayTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
35
|
+
const formedAt = new Map<string, number>();
|
|
28
36
|
let pruneTimer: ReturnType<typeof setInterval> | null = null;
|
|
29
37
|
|
|
30
38
|
function writeIntents(next: Map<string, ClientIntent>): void {
|
|
@@ -40,9 +48,19 @@ function clearExitTimer(id: string): void {
|
|
|
40
48
|
}
|
|
41
49
|
}
|
|
42
50
|
|
|
51
|
+
function clearPhaseDelay(id: string): void {
|
|
52
|
+
const timer = phaseDelayTimers.get(id);
|
|
53
|
+
if (timer) {
|
|
54
|
+
clearTimeout(timer);
|
|
55
|
+
phaseDelayTimers.delete(id);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
43
59
|
/** A live `ax-intent` frame: (re)place the ghost in its forming state. */
|
|
44
60
|
export function upsertIntent(intent: PmxAxIntent): void {
|
|
45
61
|
clearExitTimer(intent.id);
|
|
62
|
+
clearPhaseDelay(intent.id);
|
|
63
|
+
if (!intents.value.has(intent.id)) formedAt.set(intent.id, Date.now());
|
|
46
64
|
const next = new Map(intents.value);
|
|
47
65
|
next.set(intent.id, { ...intent, phase: 'forming' });
|
|
48
66
|
writeIntents(next);
|
|
@@ -50,6 +68,8 @@ export function upsertIntent(intent: PmxAxIntent): void {
|
|
|
50
68
|
|
|
51
69
|
export function removeIntent(id: string): void {
|
|
52
70
|
clearExitTimer(id);
|
|
71
|
+
clearPhaseDelay(id);
|
|
72
|
+
formedAt.delete(id);
|
|
53
73
|
if (!intents.value.has(id)) return;
|
|
54
74
|
const next = new Map(intents.value);
|
|
55
75
|
next.delete(id);
|
|
@@ -69,19 +89,44 @@ function setPhase(id: string, phase: IntentPhase, ms: number, settledNodeId?: st
|
|
|
69
89
|
);
|
|
70
90
|
}
|
|
71
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Minimum-dwell wrapper: when a settle/dissolve arrives before the ghost has
|
|
94
|
+
* been visible for MIN_FORMING_MS, hold the forming state until the floor
|
|
95
|
+
* elapses, then play the exit phase.
|
|
96
|
+
*/
|
|
97
|
+
function setPhaseAfterDwell(id: string, phase: IntentPhase, ms: number, settledNodeId?: string): void {
|
|
98
|
+
clearPhaseDelay(id);
|
|
99
|
+
const elapsed = Date.now() - (formedAt.get(id) ?? 0);
|
|
100
|
+
const delay = Math.max(0, MIN_FORMING_MS - elapsed);
|
|
101
|
+
if (delay === 0) {
|
|
102
|
+
setPhase(id, phase, ms, settledNodeId);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
phaseDelayTimers.set(
|
|
106
|
+
id,
|
|
107
|
+
setTimeout(() => {
|
|
108
|
+
phaseDelayTimers.delete(id);
|
|
109
|
+
setPhase(id, phase, ms, settledNodeId);
|
|
110
|
+
}, delay),
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
72
114
|
/** Resolve a ghost into a real node — the settle morph, then removal. */
|
|
73
115
|
export function settleIntent(id: string, settledNodeId?: string): void {
|
|
74
|
-
|
|
116
|
+
setPhaseAfterDwell(id, 'settling', SETTLE_MS, settledNodeId);
|
|
75
117
|
}
|
|
76
118
|
|
|
77
119
|
/** Dissolve a ghost (expired / vetoed / evicted / abandoned), then remove it. */
|
|
78
120
|
export function dissolveIntent(id: string): void {
|
|
79
|
-
|
|
121
|
+
setPhaseAfterDwell(id, 'dissolving', DISSOLVE_MS);
|
|
80
122
|
}
|
|
81
123
|
|
|
82
124
|
export function resetIntents(): void {
|
|
83
125
|
for (const timer of exitTimers.values()) clearTimeout(timer);
|
|
84
126
|
exitTimers.clear();
|
|
127
|
+
for (const timer of phaseDelayTimers.values()) clearTimeout(timer);
|
|
128
|
+
phaseDelayTimers.clear();
|
|
129
|
+
formedAt.clear();
|
|
85
130
|
if (pruneTimer) {
|
|
86
131
|
clearInterval(pruneTimer);
|
|
87
132
|
pruneTimer = null;
|
|
@@ -57,10 +57,17 @@ let pollSeq: number | null = null;
|
|
|
57
57
|
let pollGeneration = 0;
|
|
58
58
|
let sseFirstEventWatchdog: ReturnType<typeof setTimeout> | null = null;
|
|
59
59
|
|
|
60
|
-
function forcedTransport(): 'poll' | 'sse' | null {
|
|
61
|
-
if (typeof location === 'undefined') return null;
|
|
60
|
+
export function forcedTransport(): 'poll' | 'sse' | null {
|
|
61
|
+
if (typeof location === 'undefined' || typeof window === 'undefined') return null;
|
|
62
62
|
const value = new URLSearchParams(location.search).get('transport');
|
|
63
|
-
|
|
63
|
+
if (value === 'poll' || value === 'sse') return value;
|
|
64
|
+
// Amp orbs: the portal proxy buffers SSE, so auto mode burns the 3s
|
|
65
|
+
// watchdog before falling back — and on slow proxy days that trips the
|
|
66
|
+
// boot modal. The server stamps the orb flag into the page (canvasSpaHtml);
|
|
67
|
+
// go straight to the transport that works there. `?transport=sse` above
|
|
68
|
+
// still overrides for diagnosis.
|
|
69
|
+
if ((window as Window & { __PMX_AMP_ORB?: unknown }).__PMX_AMP_ORB === true) return 'poll';
|
|
70
|
+
return null;
|
|
64
71
|
}
|
|
65
72
|
|
|
66
73
|
// Maps responseNodeId → thread prompt node ID so response deltas/completions
|
|
@@ -1124,6 +1131,10 @@ export function connectSSE(): () => void {
|
|
|
1124
1131
|
sseFirstEventWatchdog = null;
|
|
1125
1132
|
if (gotFirstEvent || eventSource !== source) return;
|
|
1126
1133
|
console.warn('[sse-bridge] No SSE event within watchdog window — switching to polling transport.');
|
|
1134
|
+
// Give the bootstrap card a fresh deadline for the fallback transport so
|
|
1135
|
+
// the "did not finish booting" modal doesn't fire while polling is
|
|
1136
|
+
// already recovering the boot (the inline bootstrap script listens).
|
|
1137
|
+
window.dispatchEvent(new Event('pmx-canvas-boot-extend'));
|
|
1127
1138
|
source.close();
|
|
1128
1139
|
eventSource = null;
|
|
1129
1140
|
startPollingTransport();
|