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.
Files changed (62) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/Readme.md +40 -8
  3. package/dist/canvas/global.css +91 -0
  4. package/dist/canvas/index.js +53 -53
  5. package/dist/canvas/surface-theme.css +32 -0
  6. package/dist/types/cli/agent.d.ts +1 -0
  7. package/dist/types/cli/commands/skills.d.ts +41 -0
  8. package/dist/types/client/state/intent-store.d.ts +6 -0
  9. package/dist/types/client/state/sse-bridge.d.ts +1 -0
  10. package/dist/types/server/ax-state-manager.d.ts +5 -0
  11. package/dist/types/server/ax-state.d.ts +9 -3
  12. package/dist/types/server/canvas-db.d.ts +3 -2
  13. package/dist/types/server/canvas-state.d.ts +12 -0
  14. package/dist/types/server/operations/ops/ax-shared.d.ts +4 -1
  15. package/dist/types/server/operations/registry.d.ts +9 -1
  16. package/dist/types/shared/ax-intent.d.ts +6 -0
  17. package/dist/types/shared/themes.d.ts +1 -1
  18. package/docs/RELEASE.md +14 -6
  19. package/docs/bun-webview-integration.md +2 -2
  20. package/docs/cli.md +24 -1
  21. package/docs/environment.md +3 -2
  22. package/docs/http-api.md +6 -3
  23. package/docs/plans/plan-009-tech-debt-backlog.md +4 -3
  24. package/docs/screenshot.png +0 -0
  25. package/docs/screenshots/amp-orb-portal.png +0 -0
  26. package/docs/screenshots/claude-code-desktop.png +0 -0
  27. package/docs/screenshots/codex-app.png +0 -0
  28. package/docs/screenshots/github-copilot-app.png +0 -0
  29. package/package.json +1 -1
  30. package/skills/pmx-canvas/SKILL.md +25 -5
  31. package/skills/pmx-canvas/references/full-reference.md +1 -1
  32. package/skills/pmx-canvas-orchestration/SKILL.md +82 -0
  33. package/skills/pmx-canvas-testing/SKILL.md +4 -0
  34. package/src/cli/agent.ts +1 -0
  35. package/src/cli/commands/skills.ts +183 -0
  36. package/src/cli/index.ts +17 -3
  37. package/src/cli/shared.ts +1 -0
  38. package/src/client/canvas/CanvasNode.tsx +12 -1
  39. package/src/client/canvas/IntentLayer.tsx +8 -6
  40. package/src/client/state/intent-bridge.ts +12 -3
  41. package/src/client/state/intent-store.ts +47 -2
  42. package/src/client/state/sse-bridge.ts +14 -3
  43. package/src/client/theme/global.css +91 -0
  44. package/src/client/theme/surface-theme.css +32 -0
  45. package/src/mcp/server.ts +3 -5
  46. package/src/server/ax-state-manager.ts +27 -6
  47. package/src/server/ax-state.ts +36 -3
  48. package/src/server/canvas-db.ts +83 -60
  49. package/src/server/canvas-state.ts +52 -6
  50. package/src/server/intent-registry.ts +2 -0
  51. package/src/server/operations/http.ts +5 -1
  52. package/src/server/operations/ops/ax-shared.ts +20 -1
  53. package/src/server/operations/ops/ax-state.ts +4 -7
  54. package/src/server/operations/ops/ax-timeline.ts +26 -3
  55. package/src/server/operations/ops/ax-work.ts +27 -1
  56. package/src/server/operations/ops/batch.ts +3 -1
  57. package/src/server/operations/ops/canvas-wire.ts +1 -1
  58. package/src/server/operations/ops/nodes.ts +15 -1
  59. package/src/server/operations/registry.ts +122 -1
  60. package/src/server/server.ts +23 -4
  61. package/src/shared/ax-intent.ts +6 -0
  62. 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') ?? process.env.PMX_WEB_CANVAS_PORT ?? process.env.PMX_CANVAS_PORT ?? '4313',
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') ?? process.env.PMX_WEB_CANVAS_PORT ?? process.env.PMX_CANVAS_PORT ?? '4313',
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
@@ -163,6 +163,7 @@ function parseFlags(args: string[]): { positional: string[]; flags: Record<strin
163
163
  'permissions',
164
164
  'files',
165
165
  'ui-prompts',
166
+ 'check',
166
167
  ]);
167
168
  for (let i = 0; i < args.length; i++) {
168
169
  const arg = args[i];
@@ -140,7 +140,15 @@ export function CanvasNode({ node, children, onContextMenu }: CanvasNodeProps) {
140
140
  requestAnimationFrame(() => renameRef.current?.focus());
141
141
  }, []);
142
142
 
143
- const title = (node.data.title as string) || TYPE_LABELS[node.type];
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
- if (typeof intent.confidence !== 'number') return 0.82;
63
- return 0.4 + Math.max(0, Math.min(1, intent.confidence)) * 0.55;
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
- setPhase(id, 'settling', SETTLE_MS, settledNodeId);
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
- setPhase(id, 'dissolving', DISSOLVE_MS);
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
- return value === 'poll' || value === 'sse' ? value : null;
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();