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.
- package/CHANGELOG.md +52 -0
- package/Readme.md +33 -2
- package/dist/canvas/global.css +47 -11
- package/dist/canvas/index.js +53 -53
- package/dist/canvas/surface-theme.css +2 -2
- package/dist/types/cli/commands/skills.d.ts +1 -1
- package/dist/types/client/state/intent-store.d.ts +6 -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/docs/environment.md +1 -0
- 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 +15 -5
- package/skills/pmx-canvas-orchestration/SKILL.md +82 -0
- package/src/cli/commands/skills.ts +1 -1
- package/src/cli/index.ts +14 -2
- 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/theme/global.css +47 -11
- package/src/client/theme/surface-theme.css +2 -2
- 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/nodes.ts +15 -1
- package/src/server/operations/registry.ts +122 -1
- package/src/server/server.ts +10 -2
- package/src/shared/ax-intent.ts +6 -0
- 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: #
|
|
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: #
|
|
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
|
|
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
|
|
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. */
|
package/docs/environment.md
CHANGED
|
@@ -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. |
|
package/docs/screenshot.png
CHANGED
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmx-canvas",
|
|
3
|
-
"version": "0.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
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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') ??
|
|
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') ??
|
|
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 =
|
|
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;
|