pmx-canvas 0.4.4 → 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 (48) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/Readme.md +33 -2
  3. package/dist/canvas/global.css +47 -11
  4. package/dist/canvas/index.js +53 -53
  5. package/dist/canvas/surface-theme.css +2 -2
  6. package/dist/types/cli/commands/skills.d.ts +1 -1
  7. package/dist/types/client/state/intent-store.d.ts +6 -0
  8. package/dist/types/server/ax-state-manager.d.ts +5 -0
  9. package/dist/types/server/ax-state.d.ts +9 -3
  10. package/dist/types/server/canvas-db.d.ts +3 -2
  11. package/dist/types/server/canvas-state.d.ts +12 -0
  12. package/dist/types/server/operations/ops/ax-shared.d.ts +4 -1
  13. package/dist/types/server/operations/registry.d.ts +9 -1
  14. package/dist/types/shared/ax-intent.d.ts +6 -0
  15. package/docs/environment.md +1 -0
  16. package/docs/screenshot.png +0 -0
  17. package/docs/screenshots/amp-orb-portal.png +0 -0
  18. package/docs/screenshots/claude-code-desktop.png +0 -0
  19. package/docs/screenshots/codex-app.png +0 -0
  20. package/docs/screenshots/github-copilot-app.png +0 -0
  21. package/package.json +1 -1
  22. package/skills/pmx-canvas/SKILL.md +15 -5
  23. package/skills/pmx-canvas-orchestration/SKILL.md +82 -0
  24. package/src/cli/commands/skills.ts +1 -1
  25. package/src/cli/index.ts +14 -2
  26. package/src/client/canvas/CanvasNode.tsx +12 -1
  27. package/src/client/canvas/IntentLayer.tsx +8 -6
  28. package/src/client/state/intent-bridge.ts +12 -3
  29. package/src/client/state/intent-store.ts +47 -2
  30. package/src/client/theme/global.css +47 -11
  31. package/src/client/theme/surface-theme.css +2 -2
  32. package/src/mcp/server.ts +3 -5
  33. package/src/server/ax-state-manager.ts +27 -6
  34. package/src/server/ax-state.ts +36 -3
  35. package/src/server/canvas-db.ts +83 -60
  36. package/src/server/canvas-state.ts +52 -6
  37. package/src/server/intent-registry.ts +2 -0
  38. package/src/server/operations/http.ts +5 -1
  39. package/src/server/operations/ops/ax-shared.ts +20 -1
  40. package/src/server/operations/ops/ax-state.ts +4 -7
  41. package/src/server/operations/ops/ax-timeline.ts +26 -3
  42. package/src/server/operations/ops/ax-work.ts +27 -1
  43. package/src/server/operations/ops/batch.ts +3 -1
  44. package/src/server/operations/ops/nodes.ts +15 -1
  45. package/src/server/operations/registry.ts +122 -1
  46. package/src/server/server.ts +10 -2
  47. package/src/shared/ax-intent.ts +6 -0
  48. package/src/shared/themes.ts +1 -1
@@ -291,7 +291,7 @@
291
291
  --c-text-soft: #CBD3CA;
292
292
  --c-muted: #9CA49C;
293
293
  --c-dim: #626962;
294
- --c-accent: #F34E3F;
294
+ --c-accent: #DFDFC1;
295
295
  --c-ok: #52C880;
296
296
  --c-warn: #E4C465;
297
297
  --c-warn-alt: #FFB300;
@@ -307,7 +307,7 @@
307
307
  --color-text-secondary: #CBD3CA;
308
308
  --color-text-muted: #9CA49C;
309
309
  --color-text-dim: #626962;
310
- --color-accent: #F34E3F;
310
+ --color-accent: #DFDFC1;
311
311
  --color-success: #52C880;
312
312
  --color-warning: #E4C465;
313
313
  --color-danger: #D67670;
@@ -1,4 +1,4 @@
1
- export declare const BUNDLED_SKILLS: readonly ["pmx-canvas", "pmx-canvas-testing"];
1
+ export declare const BUNDLED_SKILLS: readonly ["pmx-canvas", "pmx-canvas-testing", "pmx-canvas-orchestration"];
2
2
  export interface SkillTreeComparison {
3
3
  inSync: boolean;
4
4
  /** Files present in the package tree but missing or different in the mirror. */
@@ -15,6 +15,12 @@ export interface ClientIntent extends PmxAxIntent {
15
15
  export declare const intents: import("@preact/signals-core").Signal<Map<string, ClientIntent>>;
16
16
  /** The ghost currently hovered — drives Esc-to-veto. */
17
17
  export declare const hoveredIntentId: import("@preact/signals-core").Signal<string | null>;
18
+ /**
19
+ * Minimum time a ghost stays in its forming state before a settle/dissolve
20
+ * plays. Fast agents (and every server auto-ghost) signal and settle within
21
+ * milliseconds — without this floor, correct ghost usage is imperceptible.
22
+ */
23
+ export declare const MIN_FORMING_MS = 650;
18
24
  /** A live `ax-intent` frame: (re)place the ghost in its forming state. */
19
25
  export declare function upsertIntent(intent: PmxAxIntent): void;
20
26
  export declare function removeIntent(id: string): void;
@@ -77,6 +77,7 @@ export declare class AxStateManager {
77
77
  status?: PmxAxWorkItemStatus;
78
78
  detail?: string | null;
79
79
  nodeIds?: string[];
80
+ agentId?: string | null;
80
81
  }, options?: {
81
82
  source?: PmxAxSource;
82
83
  }): PmxAxWorkItem;
@@ -85,6 +86,7 @@ export declare class AxStateManager {
85
86
  status?: PmxAxWorkItemStatus;
86
87
  detail?: string | null;
87
88
  nodeIds?: string[];
89
+ agentId?: string | null;
88
90
  }, options?: {
89
91
  source?: PmxAxSource;
90
92
  }): PmxAxWorkItem | null;
@@ -173,6 +175,7 @@ export declare class AxStateManager {
173
175
  data?: Record<string, unknown> | null;
174
176
  }, options?: {
175
177
  source?: PmxAxSource;
178
+ agentId?: string | null;
176
179
  }): PmxAxEvent;
177
180
  addEvidence(input: {
178
181
  kind: PmxAxEvidenceKind;
@@ -186,6 +189,8 @@ export declare class AxStateManager {
186
189
  }): PmxAxEvidence;
187
190
  recordSteeringMessage(message: string, options?: {
188
191
  source?: PmxAxSource;
192
+ agentId?: string | null;
193
+ target?: string | null;
189
194
  }): PmxAxSteeringMessage;
190
195
  markSteeringDelivered(id: string): boolean;
191
196
  /**
@@ -1,6 +1,6 @@
1
1
  import type { CanvasLayout, CanvasNodeState } from './canvas-state.js';
2
2
  import type { AgentContextNode } from './agent-context.js';
3
- export type PmxAxSource = 'agent' | 'api' | 'browser' | 'cli' | 'codex' | 'copilot' | 'mcp' | 'sdk' | 'system';
3
+ export type PmxAxSource = 'agent' | 'amp' | 'api' | 'browser' | 'cli' | 'codex' | 'copilot' | 'mcp' | 'sdk' | 'system';
4
4
  export interface PmxAxFocusState {
5
5
  nodeIds: string[];
6
6
  primaryNodeId: string | null;
@@ -24,6 +24,7 @@ export interface PmxAxWorkItem {
24
24
  createdAt: string;
25
25
  updatedAt: string;
26
26
  source: PmxAxSource | null;
27
+ agentId: string | null;
27
28
  }
28
29
  export interface PmxAxApprovalGate {
29
30
  id: string;
@@ -67,6 +68,7 @@ export interface PmxAxEvent {
67
68
  data: Record<string, unknown> | null;
68
69
  createdAt: string;
69
70
  source: PmxAxSource | null;
71
+ agentId: string | null;
70
72
  }
71
73
  export interface PmxAxEvidence {
72
74
  id: string;
@@ -87,6 +89,9 @@ export interface PmxAxSteeringMessage {
87
89
  delivered: boolean;
88
90
  createdAt: string;
89
91
  source: PmxAxSource | null;
92
+ agentId: string | null;
93
+ /** The agentId/consumer this steer is addressed to; null = broadcast to all consumers. */
94
+ target: string | null;
90
95
  }
91
96
  export interface PmxAxHostCapability {
92
97
  host: string | null;
@@ -257,6 +262,7 @@ export declare function createAxWorkItem(input: {
257
262
  status?: PmxAxWorkItemStatus;
258
263
  detail?: string | null;
259
264
  nodeIds?: string[];
265
+ agentId?: string | null;
260
266
  }, source: PmxAxSource | null, validNodeIds?: Set<string>): PmxAxWorkItem;
261
267
  export declare function createAxApprovalGate(input: {
262
268
  title: string;
@@ -280,7 +286,7 @@ export declare function createAxEvent(input: {
280
286
  detail?: string | null;
281
287
  nodeIds?: string[];
282
288
  data?: Record<string, unknown> | null;
283
- }, source: PmxAxSource | null): Omit<PmxAxEvent, 'seq'>;
289
+ }, source: PmxAxSource | null, agentId?: string | null): Omit<PmxAxEvent, 'seq'>;
284
290
  export declare function createAxEvidence(input: {
285
291
  kind: PmxAxEvidenceKind;
286
292
  title: string;
@@ -289,7 +295,7 @@ export declare function createAxEvidence(input: {
289
295
  nodeIds?: string[];
290
296
  data?: Record<string, unknown> | null;
291
297
  }, source: PmxAxSource | null): Omit<PmxAxEvidence, 'seq'>;
292
- export declare function createAxSteeringMessage(message: string, source: PmxAxSource | null): Omit<PmxAxSteeringMessage, 'seq'>;
298
+ export declare function createAxSteeringMessage(message: string, source: PmxAxSource | null, agentId?: string | null, target?: string | null): Omit<PmxAxSteeringMessage, 'seq'>;
293
299
  export declare function normalizeAxState(input: unknown, validNodeIds?: Set<string>): PmxAxState;
294
300
  export declare function buildAxContext(input: {
295
301
  layout: CanvasLayout;
@@ -56,7 +56,8 @@ export declare function loadPendingAxSteeringFromDB(db: Database, options?: {
56
56
  /**
57
57
  * NEWEST undelivered steering first (report #57) for the compact AX context lead
58
58
  * block — so a fresh steer is visible even behind a long backlog. Loop-safe: excludes
59
- * the consumer's own steering in SQL so the LIMIT applies after loop-prevention.
59
+ * the consumer's own steering and steering targeted at a different consumer in SQL
60
+ * so the LIMIT applies after both filters.
60
61
  * Distinct from loadPendingAxSteeringFromDB (FIFO oldest-first) which the claim/ack
61
62
  * delivery queue uses for ordered processing.
62
63
  */
@@ -64,7 +65,7 @@ export declare function loadNewestPendingAxSteeringFromDB(db: Database, options?
64
65
  consumer?: string;
65
66
  limit?: number;
66
67
  }): PmxAxSteeringMessage[];
67
- /** Total undelivered steering for a consumer (loop-safe — excludes the consumer's own). */
68
+ /** Total undelivered steering for a consumer (loop-safe — excludes the consumer's own; target-scoped). */
68
69
  export declare function countPendingAxSteeringFromDB(db: Database, consumer?: string): number;
69
70
  export declare function loadAxTimelineSummaryFromDB(db: Database): PmxAxTimelineSummary;
70
71
  export declare function upsertAxHostCapabilityToDB(db: Database, cap: PmxAxHostCapability): void;
@@ -280,6 +280,7 @@ declare class CanvasStateManager {
280
280
  status?: PmxAxWorkItemStatus;
281
281
  detail?: string | null;
282
282
  nodeIds?: string[];
283
+ agentId?: string | null;
283
284
  }, options?: {
284
285
  source?: PmxAxSource;
285
286
  }): PmxAxWorkItem;
@@ -288,9 +289,17 @@ declare class CanvasStateManager {
288
289
  status?: PmxAxWorkItemStatus;
289
290
  detail?: string | null;
290
291
  nodeIds?: string[];
292
+ agentId?: string | null;
291
293
  }, options?: {
292
294
  source?: PmxAxSource;
293
295
  }): PmxAxWorkItem | null;
296
+ /**
297
+ * Mirror a work item's status onto every linked canvas node's `data.axWorkStatus`,
298
+ * through the standard node-update path (persistence + undo/redo, same as any other
299
+ * node data change). Nodes unlinked by an updateWorkItem nodeIds change have the
300
+ * mirrored status cleared.
301
+ */
302
+ private mirrorAxWorkStatusToNodes;
294
303
  getApprovalGates(): PmxAxApprovalGate[];
295
304
  requestApproval(input: {
296
305
  title: string;
@@ -376,6 +385,7 @@ declare class CanvasStateManager {
376
385
  data?: Record<string, unknown> | null;
377
386
  }, options?: {
378
387
  source?: PmxAxSource;
388
+ agentId?: string | null;
379
389
  }): PmxAxEvent;
380
390
  addEvidence(input: {
381
391
  kind: PmxAxEvidenceKind;
@@ -389,6 +399,8 @@ declare class CanvasStateManager {
389
399
  }): PmxAxEvidence;
390
400
  recordSteeringMessage(message: string, options?: {
391
401
  source?: PmxAxSource;
402
+ agentId?: string | null;
403
+ target?: string | null;
392
404
  }): PmxAxSteeringMessage;
393
405
  markSteeringDelivered(id: string): boolean;
394
406
  ingestActivity(input: {
@@ -11,9 +11,10 @@
11
11
  import { z } from 'zod';
12
12
  import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
13
13
  import type { PmxAxSource } from '../../ax-state.js';
14
- export declare const AX_SOURCES: readonly ["agent", "api", "browser", "cli", "codex", "copilot", "mcp", "sdk", "system"];
14
+ export declare const AX_SOURCES: readonly ["agent", "amp", "api", "browser", "cli", "codex", "copilot", "mcp", "sdk", "system"];
15
15
  /** Zod schema for the optional `source` field shared by AX MCP tool shapes. */
16
16
  export declare const AX_SOURCE_SHAPE: z.ZodOptional<z.ZodEnum<{
17
+ amp: "amp";
17
18
  agent: "agent";
18
19
  api: "api";
19
20
  browser: "browser";
@@ -24,6 +25,8 @@ export declare const AX_SOURCE_SHAPE: z.ZodOptional<z.ZodEnum<{
24
25
  sdk: "sdk";
25
26
  system: "system";
26
27
  }>>;
28
+ /** Zod schema for the optional `agentId` field shared by AX MCP tool shapes. */
29
+ export declare const AX_AGENT_ID_SHAPE: z.ZodOptional<z.ZodString>;
27
30
  /** An absent or unrecognized source falls back to the per-surface default. */
28
31
  export declare function normalizeAxSource(value: unknown, fallback: PmxAxSource): PmxAxSource;
29
32
  export declare function normalizeAxNodeIds(value: unknown): string[];
@@ -11,5 +11,13 @@ export declare function setOperationEventEmitter(emitter: OperationEventEmitter
11
11
  export declare function isEmitSuppressed(): boolean;
12
12
  /** Run `fn` with all operation SSE emits suppressed; restores depth on finally. */
13
13
  export declare function runWithSuppressedEmits<T>(fn: () => Promise<T>): Promise<T>;
14
- export declare function executeOperation(name: string, rawInput: unknown): Promise<unknown>;
14
+ export interface ExecuteOperationMeta {
15
+ /**
16
+ * Skip the synthesized auto-ghost: set by the workbench's own HTTP calls
17
+ * (a human dragging a node is not agent activity) and by canvas.batch for
18
+ * its inner dispatches (batch churn is exempt, matching the skill contract).
19
+ */
20
+ suppressAutoGhost?: boolean;
21
+ }
22
+ export declare function executeOperation(name: string, rawInput: unknown, meta?: ExecuteOperationMeta): Promise<unknown>;
15
23
  export {};
@@ -43,6 +43,12 @@ export interface PmxAxIntent {
43
43
  seq?: number;
44
44
  /** Source label of the surface that signalled the intent (mcp/api/sdk/...). */
45
45
  source?: string;
46
+ /**
47
+ * Server-synthesized ghost for an agent mutation that arrived WITHOUT an
48
+ * explicit canvas_intent signal (auto-ghost). Rendered visually lighter than
49
+ * a deliberate signal, and not vetoable — it settles immediately.
50
+ */
51
+ auto?: boolean;
46
52
  /** Epoch ms when the intent was first signalled. */
47
53
  createdAt: number;
48
54
  /** Epoch ms when the intent auto-dissolves if not settled/cleared first. */
@@ -27,6 +27,7 @@ optional; defaults are listed.
27
27
  |---|---|---|
28
28
  | `PMX_CANVAS_THEME` | server | Startup theme: `dark` (default), `light`, `high-contrast`, `midnight`, `sepia`, `arctic`, `ember`, `forest`, `volt`. |
29
29
  | `AMP_ORB` | server | Set by Amp orb services. When present, the served canvas page tells the browser it is running in an orb, which skips the iframe embed probe, forces `srcdoc` surface rendering when embedded (the orb portal blocks `src`-URL child iframes and the probe is unreliable there), and defaults the event transport to polling instead of SSE (the portal proxy buffers streams; `?transport=sse` overrides for diagnosis). |
30
+ | `PORT` | server | Portal-assigned service port, honored ONLY when `AMP_ORB` is also set (Amp orbs) — after `--port`, `PMX_WEB_CANVAS_PORT`, and `PMX_CANVAS_PORT`, before the 4313 default. A stray `PORT` in normal shells never changes the port. |
30
31
  | `PMX_CANVAS_DISABLE_BROWSER_OPEN` | server | `1` suppresses auto-opening the browser (used by tests/CI). |
31
32
  | `PMX_CANVAS_DIST` | server | Explicit client bundle directory to serve instead of the packaged `dist/canvas`. |
32
33
  | `PMX_CANVAS_WEBVIEW_TIMEOUT_MS` | server | Startup timeout for the Bun.WebView automation session. |
Binary file
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmx-canvas",
3
- "version": "0.4.4",
3
+ "version": "0.4.5",
4
4
  "description": "Spatial canvas workbench for coding agents \u2014 infinite 2D canvas with agent-native CLI, MCP integration, nodes, edges, file watching, and snapshots",
5
5
  "type": "module",
6
6
  "main": "./src/server/index.ts",
@@ -44,6 +44,11 @@ Humans curate agent context by pinning nodes; agents read that curation through
44
44
  human watches intent form and can veto mid-thought. Skip it only for trivial in-place tweaks or
45
45
  high-frequency batch churn. The default TTL (~8s) expires between agent turns: signal with
46
46
  `ttlMs: 30000` and settle by passing `intentId` on the mutation in the same or next call.
47
+ Since 0.4.5, an agent mutation WITHOUT a signal still shows a server-synthesized **auto-ghost**
48
+ (rendered lighter — dimmer, dotted, no veto — and settled instantly). That is a visibility
49
+ floor, not a replacement: only an explicit signal gives the human a real pre-mutation veto
50
+ window, your reasoning (`reason`), and staged multi-step previews. Batch and browser-human
51
+ actions never auto-ghost.
47
52
  6. **Mutate through current composites.** Prefer the 16 composite MCP tools below.
48
53
  7. **Arrange and validate.** After batch changes, use `canvas_view { action: "arrange" }` when
49
54
  appropriate and always finish with `canvas_query { action: "validate" }`.
@@ -236,6 +241,10 @@ Prefer `canvas_query { action: "search" }` over parsing the full layout.
236
241
  - Hosted MCP-app/ext-app nodes such as Excalidraw require the in-canvas host bridge and are not
237
242
  standalone **Open as site** targets. URL-backed viewers and bundled web artifacts remain
238
243
  openable.
244
+ - A standalone html surface (`/api/canvas/surface/:id` opened as a site) is a VISUAL view: it
245
+ renders the same content and theme, but `window.PMX_AX` is not injected without the canvas
246
+ iframe's per-mount nonce, so AX buttons only work inside the in-canvas node (0.4.4 Codex note).
247
+ Do not tell a user a standalone tab's controls will steer the agent.
239
248
  - A hosted ext-app (Excalidraw) node that is already on the board when a **WebKit** host panel
240
249
  loads (e.g. the GitHub Copilot app's embedded WKWebView) can render as a black tile — a host
241
250
  compositor paint race on the nested iframe, **not** a broken node (the session is healthy and
@@ -272,11 +281,12 @@ Prefer `canvas_query { action: "search" }` over parsing the full layout.
272
281
  iframe) can block child iframes from loading ANY `src` URL, breaking every iframe-backed node
273
282
  with a gray placeholder. The canvas probes this at boot and auto-falls back to fetching
274
283
  same-origin surfaces and rendering them inline via `srcdoc` (HTML, graph, json-render, frame
275
- documents). In Amp orbs specifically, the server sees `AMP_ORB` in its environment and stamps
276
- the page so the embedded client skips the (there-unreliable) probe, goes straight to `srcdoc`,
277
- AND defaults to the polling transport (the portal proxy buffers SSE — waiting out the watchdog
278
- could trip the boot modal); HTML surfaces also inline their theme stylesheet so they render
279
- styled. External app
284
+ documents). In Amp orbs specifically, the server sees `AMP_ORB` in its environment and adapts
285
+ end to end: it binds the portal-assigned `$PORT` automatically (no port flag in the service
286
+ command), stamps the page so the embedded client skips the (there-unreliable) probe, goes
287
+ straight to `srcdoc`, AND defaults to the polling transport (the portal proxy buffers SSE —
288
+ waiting out the watchdog could trip the boot modal); HTML surfaces also inline their theme
289
+ stylesheet so they render styled. External app
280
290
  URLs cannot be inlined (cross-origin) and may stay blocked in such hosts. Force a mode with
281
291
  `/workbench?iframe-mode=srcdoc` (or `iframe-mode=src`) when diagnosing.
282
292
  - Graph and json-render standalone surfaces use `display=site` and fill the browser viewport, and
@@ -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.
@@ -13,7 +13,7 @@ import { join, resolve } from 'node:path';
13
13
  import { fileURLToPath } from 'node:url';
14
14
  import { cmd, output, parseFlags, showCommandHelp } from '../shared.js';
15
15
 
16
- export const BUNDLED_SKILLS = ['pmx-canvas', 'pmx-canvas-testing'] as const;
16
+ export const BUNDLED_SKILLS = ['pmx-canvas', 'pmx-canvas-testing', 'pmx-canvas-orchestration'] as const;
17
17
 
18
18
  function listFilesRecursive(root: string, prefix = ''): string[] {
19
19
  const out: string[] = [];
package/src/cli/index.ts CHANGED
@@ -166,7 +166,13 @@ const serveSubcommand = firstArg === 'serve' ? (args[1] ?? '') : '';
166
166
 
167
167
  if (firstArg === 'serve' && (serveSubcommand === 'status' || serveSubcommand === 'stop')) {
168
168
  const port = parseInt(
169
- 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',
170
176
  10,
171
177
  );
172
178
  const daemonLogFile = resolve(readOption('log-file') ?? `.pmx-canvas/daemon-${port}.log`);
@@ -214,7 +220,13 @@ if (AGENT_COMMANDS.has(routedCommand) && routedCommand !== 'serve') {
214
220
  } else {
215
221
  // "serve" is also accessible via flags (backward compat)
216
222
  const port = parseInt(
217
- 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',
218
230
  10,
219
231
  );
220
232
  const demo = hasFlag('demo');
@@ -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;