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
@@ -1716,18 +1716,61 @@ class CanvasStateManager {
1716
1716
  }
1717
1717
 
1718
1718
  addWorkItem(
1719
- input: { title: string; status?: PmxAxWorkItemStatus; detail?: string | null; nodeIds?: string[] },
1719
+ input: {
1720
+ title: string;
1721
+ status?: PmxAxWorkItemStatus;
1722
+ detail?: string | null;
1723
+ nodeIds?: string[];
1724
+ agentId?: string | null;
1725
+ },
1720
1726
  options: { source?: PmxAxSource } = {},
1721
1727
  ): PmxAxWorkItem {
1722
- return this.ax.addWorkItem(input, options);
1728
+ const item = this.ax.addWorkItem(input, options);
1729
+ this.mirrorAxWorkStatusToNodes(item.nodeIds, [], item.status);
1730
+ return item;
1723
1731
  }
1724
1732
 
1725
1733
  updateWorkItem(
1726
1734
  id: string,
1727
- patch: { title?: string; status?: PmxAxWorkItemStatus; detail?: string | null; nodeIds?: string[] },
1735
+ patch: {
1736
+ title?: string;
1737
+ status?: PmxAxWorkItemStatus;
1738
+ detail?: string | null;
1739
+ nodeIds?: string[];
1740
+ agentId?: string | null;
1741
+ },
1728
1742
  options: { source?: PmxAxSource } = {},
1729
1743
  ): PmxAxWorkItem | null {
1730
- return this.ax.updateWorkItem(id, patch, options);
1744
+ const previousNodeIds = this.ax.getWorkItems().find((w) => w.id === id)?.nodeIds ?? [];
1745
+ const item = this.ax.updateWorkItem(id, patch, options);
1746
+ if (!item) return null;
1747
+ this.mirrorAxWorkStatusToNodes(item.nodeIds, previousNodeIds, item.status);
1748
+ return item;
1749
+ }
1750
+
1751
+ /**
1752
+ * Mirror a work item's status onto every linked canvas node's `data.axWorkStatus`,
1753
+ * through the standard node-update path (persistence + undo/redo, same as any other
1754
+ * node data change). Nodes unlinked by an updateWorkItem nodeIds change have the
1755
+ * mirrored status cleared.
1756
+ */
1757
+ private mirrorAxWorkStatusToNodes(nodeIds: string[], previousNodeIds: string[], status: PmxAxWorkItemStatus): void {
1758
+ for (const nodeId of nodeIds) {
1759
+ const node = this.nodes.get(nodeId);
1760
+ if (!node) continue;
1761
+ // No-op guard: a title-/agentId-only work-item patch must not push
1762
+ // history entries and SSE churn for chips that aren't changing.
1763
+ if (node.data.axWorkStatus === status) continue;
1764
+ this.updateNode(nodeId, { data: { ...node.data, axWorkStatus: status } });
1765
+ }
1766
+ for (const nodeId of previousNodeIds) {
1767
+ if (nodeIds.includes(nodeId)) continue;
1768
+ const node = this.nodes.get(nodeId);
1769
+ if (!node || node.data.axWorkStatus === undefined) continue;
1770
+ const data = { ...node.data };
1771
+ delete data.axWorkStatus;
1772
+ this.updateNode(nodeId, { data });
1773
+ }
1731
1774
  }
1732
1775
 
1733
1776
  // ── Approval gates (canvas-bound) ─────────────────────────────────
@@ -1873,7 +1916,7 @@ class CanvasStateManager {
1873
1916
  nodeIds?: string[];
1874
1917
  data?: Record<string, unknown> | null;
1875
1918
  },
1876
- options: { source?: PmxAxSource } = {},
1919
+ options: { source?: PmxAxSource; agentId?: string | null } = {},
1877
1920
  ): PmxAxEvent {
1878
1921
  return this.ax.recordAxEvent(input, options);
1879
1922
  }
@@ -1892,7 +1935,10 @@ class CanvasStateManager {
1892
1935
  return this.ax.addEvidence(input, options);
1893
1936
  }
1894
1937
 
1895
- recordSteeringMessage(message: string, options: { source?: PmxAxSource } = {}): PmxAxSteeringMessage {
1938
+ recordSteeringMessage(
1939
+ message: string,
1940
+ options: { source?: PmxAxSource; agentId?: string | null; target?: string | null } = {},
1941
+ ): PmxAxSteeringMessage {
1896
1942
  return this.ax.recordSteeringMessage(message, options);
1897
1943
  }
1898
1944
 
@@ -50,6 +50,7 @@ const intentSignalSchema = z.looseObject({
50
50
  seq: z.number().int().min(0).max(9999).optional(),
51
51
  ttlMs: z.number().positive().max(MAX_INTENT_TTL_MS).optional(),
52
52
  source: z.string().max(60).optional(),
53
+ auto: z.boolean().optional(),
53
54
  });
54
55
 
55
56
  const intentUpdateSchema = z.looseObject({
@@ -146,6 +147,7 @@ export class IntentRegistry {
146
147
  ...(typeof input.confidence === 'number' ? { confidence: input.confidence } : {}),
147
148
  ...(typeof input.seq === 'number' ? { seq: input.seq } : {}),
148
149
  ...(input.source ? { source: input.source } : {}),
150
+ ...(input.auto === true ? { auto: true } : {}),
149
151
  createdAt: existing?.createdAt ?? now,
150
152
  expiresAt: now + ttl,
151
153
  };
@@ -84,7 +84,11 @@ export async function dispatchOperationRoute(req: Request, url: URL): Promise<Re
84
84
  const input = route.readInput
85
85
  ? await route.readInput(req, params, url)
86
86
  : await defaultReadInput(req, params, url);
87
- const result = await executeOperation(op.name, input);
87
+ // The workbench marks its own HTTP calls: a human dragging/editing in
88
+ // the browser is not agent activity, so it never synthesizes an
89
+ // auto-ghost. Everything else (CLI, scripts, adapters) counts as agent.
90
+ const fromWorkbench = req.headers.get('x-pmx-workbench') === '1';
91
+ const result = await executeOperation(op.name, input, { suppressAutoGhost: fromWorkbench });
88
92
  return responseJson(result, route.status ? route.status(result) : 200);
89
93
  } catch (error) {
90
94
  if (error instanceof OperationError) {
@@ -12,11 +12,30 @@ import { z } from 'zod';
12
12
  import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
13
13
  import type { PmxAxSource } from '../../ax-state.js';
14
14
 
15
- export const AX_SOURCES = ['agent', 'api', 'browser', 'cli', 'codex', 'copilot', 'mcp', 'sdk', 'system'] as const;
15
+ export const AX_SOURCES = [
16
+ 'agent',
17
+ 'amp',
18
+ 'api',
19
+ 'browser',
20
+ 'cli',
21
+ 'codex',
22
+ 'copilot',
23
+ 'mcp',
24
+ 'sdk',
25
+ 'system',
26
+ ] as const;
16
27
 
17
28
  /** Zod schema for the optional `source` field shared by AX MCP tool shapes. */
18
29
  export const AX_SOURCE_SHAPE = z.enum(AX_SOURCES).optional().describe('Optional host/source label. Defaults to mcp.');
19
30
 
31
+ /** Zod schema for the optional `agentId` field shared by AX MCP tool shapes. */
32
+ export const AX_AGENT_ID_SHAPE = z
33
+ .string()
34
+ .min(1)
35
+ .max(80)
36
+ .optional()
37
+ .describe('Per-agent identity within the host (orchestration); source stays the host label.');
38
+
20
39
  /** An absent or unrecognized source falls back to the per-surface default. */
21
40
  export function normalizeAxSource(value: unknown, fallback: PmxAxSource): PmxAxSource {
22
41
  return (AX_SOURCES as readonly string[]).includes(value as string) ? (value as PmxAxSource) : fallback;
@@ -27,7 +27,7 @@ import { buildCanvasAxContext } from '../../ax-context.js';
27
27
  import type { PmxAxPolicy } from '../../ax-state.js';
28
28
  import { defineOperation, type Operation } from '../types.js';
29
29
  import { isRecord } from './nodes.js';
30
- import { normalizeAxNodeIds, normalizeAxSource } from './ax-shared.js';
30
+ import { AX_SOURCES, normalizeAxNodeIds, normalizeAxSource } from './ax-shared.js';
31
31
 
32
32
  // ── ax.get (canvas_get_ax) ────────────────────────────────────
33
33
 
@@ -112,7 +112,7 @@ const axFocusSetOperation = defineOperation<z.infer<typeof axFocusSetSchema>, Re
112
112
  extraShape: {
113
113
  nodeIds: z.array(z.string()).describe('Node IDs to place in the AX focus field. Missing nodes are ignored.'),
114
114
  source: z
115
- .enum(['agent', 'api', 'browser', 'cli', 'codex', 'copilot', 'mcp', 'sdk', 'system'])
115
+ .enum(AX_SOURCES)
116
116
  .optional()
117
117
  .describe(
118
118
  'Optional host/source label for adapter-originated focus. Defaults to mcp. Use codex from the Codex app adapter.',
@@ -167,7 +167,7 @@ const axPolicySetOperation = defineOperation<z.infer<typeof axPolicySetSchema>,
167
167
  })
168
168
  .optional(),
169
169
  prompt: z.object({ systemAppend: z.string().optional(), mode: z.string().optional() }).optional(),
170
- source: z.enum(['agent', 'api', 'browser', 'cli', 'codex', 'copilot', 'mcp', 'sdk', 'system']).optional(),
170
+ source: z.enum(AX_SOURCES).optional(),
171
171
  },
172
172
  // Legacy MCP tool forwarded only the present tools/prompt fields (source split out).
173
173
  buildInput: (input) => ({
@@ -236,10 +236,7 @@ const axHostCapabilityReportOperation = defineOperation<
236
236
  files: z.boolean().optional(),
237
237
  uiPrompts: z.boolean().optional(),
238
238
  raw: z.record(z.string(), z.unknown()).optional().describe('Optional raw capability payload for diagnostics.'),
239
- source: z
240
- .enum(['agent', 'api', 'browser', 'cli', 'codex', 'copilot', 'mcp', 'sdk', 'system'])
241
- .optional()
242
- .describe('Optional host/source label. Defaults to mcp.'),
239
+ source: z.enum(AX_SOURCES).optional().describe('Optional host/source label. Defaults to mcp.'),
243
240
  },
244
241
  buildInput: (input) => ({ ...input, source: normalizeAxSource(input.source, 'mcp') }),
245
242
  formatResult: (result) => {
@@ -51,7 +51,13 @@ import { buildPendingAxActivity, isAxEventKind, isAxEvidenceKind } from '../../a
51
51
  import type { PmxAxEventKind, PmxAxEvidenceKind } from '../../ax-state.js';
52
52
  import { defineOperation, OperationError, type Operation } from '../types.js';
53
53
  import { isRecord } from './nodes.js';
54
- import { AX_SOURCE_SHAPE, axJsonResult, normalizeAxNodeIds, normalizeAxSource } from './ax-shared.js';
54
+ import {
55
+ AX_AGENT_ID_SHAPE,
56
+ AX_SOURCE_SHAPE,
57
+ axJsonResult,
58
+ normalizeAxNodeIds,
59
+ normalizeAxSource,
60
+ } from './ax-shared.js';
55
61
 
56
62
  const AX_EVENT_KINDS = [
57
63
  'prompt',
@@ -73,6 +79,7 @@ const axEventRecordShape = {
73
79
  nodeIds: z.unknown().optional().describe('Optional node IDs this event relates to.'),
74
80
  data: z.unknown().optional().describe('Optional structured data payload.'),
75
81
  source: z.unknown().optional().describe('Optional host/source label. Defaults to mcp.'),
82
+ agentId: z.unknown().optional().describe('Optional per-agent identity within the host.'),
76
83
  };
77
84
 
78
85
  const axEventRecordSchema = z.looseObject(axEventRecordShape);
@@ -97,6 +104,7 @@ const axEventRecordOperation = defineOperation<z.infer<typeof axEventRecordSchem
97
104
  nodeIds: z.array(z.string()).optional().describe('Optional node IDs this event relates to.'),
98
105
  data: z.record(z.string(), z.unknown()).optional().describe('Optional structured data payload.'),
99
106
  source: AX_SOURCE_SHAPE,
107
+ agentId: AX_AGENT_ID_SHAPE,
100
108
  },
101
109
  buildInput: (input) => ({ ...input, source: normalizeAxSource(input.source, 'mcp') }),
102
110
  formatResult: axJsonResult,
@@ -113,7 +121,10 @@ const axEventRecordOperation = defineOperation<z.infer<typeof axEventRecordSchem
113
121
  nodeIds: normalizeAxNodeIds(input.nodeIds),
114
122
  data: isRecord(input.data) ? input.data : null,
115
123
  },
116
- { source: normalizeAxSource(input.source, 'api') },
124
+ {
125
+ source: normalizeAxSource(input.source, 'api'),
126
+ ...(typeof input.agentId === 'string' ? { agentId: input.agentId } : {}),
127
+ },
117
128
  );
118
129
  ctx.emit('ax-event-created', { event });
119
130
  return { ok: true, event } as unknown as Record<string, unknown>;
@@ -184,6 +195,8 @@ const axEvidenceAddOperation = defineOperation<z.infer<typeof axEvidenceAddSchem
184
195
  const axSteerShape = {
185
196
  message: z.unknown().optional().describe('The steering instruction to deliver to the active agent session.'),
186
197
  source: z.unknown().optional().describe('Optional host/source label. Defaults to mcp.'),
198
+ agentId: z.unknown().optional().describe('Optional per-agent identity within the host.'),
199
+ target: z.unknown().optional().describe('Optional agentId/consumer this steer is addressed to. Omit to broadcast.'),
187
200
  };
188
201
 
189
202
  const axSteerSchema = z.looseObject(axSteerShape);
@@ -204,6 +217,13 @@ const axSteerOperation = defineOperation<z.infer<typeof axSteerSchema>, Record<s
204
217
  extraShape: {
205
218
  message: z.string().describe('The steering instruction to deliver to the active agent session.'),
206
219
  source: AX_SOURCE_SHAPE,
220
+ agentId: AX_AGENT_ID_SHAPE,
221
+ target: z
222
+ .string()
223
+ .min(1)
224
+ .max(80)
225
+ .optional()
226
+ .describe('The agentId/consumer this steer is addressed to. Omit to broadcast to all consumers.'),
207
227
  },
208
228
  buildInput: (input) => ({ ...input, source: normalizeAxSource(input.source, 'mcp') }),
209
229
  formatResult: axJsonResult,
@@ -214,6 +234,8 @@ const axSteerOperation = defineOperation<z.infer<typeof axSteerSchema>, Record<s
214
234
  }
215
235
  const steering = canvasState.recordSteeringMessage(input.message, {
216
236
  source: normalizeAxSource(input.source, 'api'),
237
+ ...(typeof input.agentId === 'string' ? { agentId: input.agentId } : {}),
238
+ ...(typeof input.target === 'string' ? { target: input.target } : {}),
217
239
  });
218
240
  ctx.emit('ax-event-created', { steering });
219
241
  return { ok: true, steering } as unknown as Record<string, unknown>;
@@ -328,7 +350,8 @@ const axDeliveryMarkOperation = defineOperation<z.infer<typeof axDeliveryMarkSch
328
350
  },
329
351
  mcp: {
330
352
  toolName: 'canvas_mark_ax_delivery',
331
- description: 'Mark a PMX AX steering message as delivered so it is not handed out again.',
353
+ description:
354
+ 'Mark a PMX AX steering message as delivered so it is not handed out again. Compare-and-set: `delivered:true` only on the actual undelivered→delivered transition — a false means the id was unknown OR another consumer already marked it, so do NOT treat the message as your delivery.',
332
355
  extraShape: {
333
356
  id: z.string().describe('The steering message id to mark delivered.'),
334
357
  },
@@ -57,7 +57,14 @@ import type {
57
57
  } from '../../ax-state.js';
58
58
  import { defineOperation, OperationError, type Operation } from '../types.js';
59
59
  import { isRecord } from './nodes.js';
60
- import { AX_SOURCE_SHAPE, AX_SOURCES, axJsonResult, normalizeAxNodeIds, normalizeAxSource } from './ax-shared.js';
60
+ import {
61
+ AX_AGENT_ID_SHAPE,
62
+ AX_SOURCE_SHAPE,
63
+ AX_SOURCES,
64
+ axJsonResult,
65
+ normalizeAxNodeIds,
66
+ normalizeAxSource,
67
+ } from './ax-shared.js';
61
68
 
62
69
  const AX_WORK_ITEM_STATUSES = new Set(['todo', 'in-progress', 'blocked', 'done', 'cancelled']);
63
70
  function normalizeAxWorkItemStatus(value: unknown): PmxAxWorkItemStatus | undefined {
@@ -98,6 +105,7 @@ const axWorkCreateShape = {
98
105
  detail: z.unknown().optional().describe('Optional longer description.'),
99
106
  nodeIds: z.unknown().optional().describe('Optional node IDs this work item is tied to.'),
100
107
  source: z.unknown().optional().describe('Optional host/source label. Defaults to mcp.'),
108
+ agentId: z.unknown().optional().describe('Optional per-agent identity within the host.'),
101
109
  };
102
110
 
103
111
  const axWorkCreateSchema = z.looseObject(axWorkCreateShape);
@@ -124,6 +132,7 @@ const axWorkCreateOperation = defineOperation<z.infer<typeof axWorkCreateSchema>
124
132
  detail: z.string().optional().describe('Optional longer description.'),
125
133
  nodeIds: z.array(z.string()).optional().describe('Optional node IDs this work item is tied to.'),
126
134
  source: AX_SOURCE_SHAPE,
135
+ agentId: AX_AGENT_ID_SHAPE,
127
136
  },
128
137
  buildInput: (input) => ({ ...input, source: normalizeAxSource(input.source, 'mcp') }),
129
138
  formatResult: axJsonResult,
@@ -146,10 +155,18 @@ const axWorkCreateOperation = defineOperation<z.infer<typeof axWorkCreateSchema>
146
155
  ...(status ? { status } : {}),
147
156
  ...(typeof input.detail === 'string' ? { detail: input.detail } : {}),
148
157
  ...(Array.isArray(input.nodeIds) ? { nodeIds: normalizeAxNodeIds(input.nodeIds) } : {}),
158
+ ...(typeof input.agentId === 'string' ? { agentId: input.agentId } : {}),
149
159
  },
150
160
  { source: normalizeAxSource(input.source, 'api') },
151
161
  );
152
162
  ctx.emit('ax-state-changed', { workItem });
163
+ // The status mirror writes data.axWorkStatus onto linked nodes, but this
164
+ // op is mutates:false (AX writes must not spam layout frames) — so emit
165
+ // the ONE layout frame ourselves when links exist, or open boards never
166
+ // see the chip until an unrelated mutation happens to refresh them.
167
+ if (workItem.nodeIds.length > 0) {
168
+ ctx.emit('canvas-layout-update', { layout: canvasState.getLayout() });
169
+ }
153
170
  return { ok: true, workItem } as unknown as Record<string, unknown>;
154
171
  },
155
172
  });
@@ -163,6 +180,7 @@ const axWorkUpdateShape = {
163
180
  detail: z.unknown().optional().describe('New detail text.'),
164
181
  nodeIds: z.unknown().optional().describe('Replacement node IDs.'),
165
182
  source: z.unknown().optional().describe('Optional host/source label. Defaults to mcp.'),
183
+ agentId: z.unknown().optional().describe('Optional per-agent identity within the host.'),
166
184
  };
167
185
 
168
186
  const axWorkUpdateSchema = z.looseObject(axWorkUpdateShape);
@@ -187,6 +205,7 @@ const axWorkUpdateOperation = defineOperation<z.infer<typeof axWorkUpdateSchema>
187
205
  detail: z.string().optional().describe('New detail text.'),
188
206
  nodeIds: z.array(z.string()).optional().describe('Replacement node IDs.'),
189
207
  source: AX_SOURCE_SHAPE,
208
+ agentId: AX_AGENT_ID_SHAPE,
190
209
  },
191
210
  buildInput: (input) => ({ ...input, source: normalizeAxSource(input.source, 'mcp') }),
192
211
  formatResult: axJsonResult,
@@ -207,11 +226,18 @@ const axWorkUpdateOperation = defineOperation<z.infer<typeof axWorkUpdateSchema>
207
226
  ...(status ? { status } : {}),
208
227
  ...(typeof input.detail === 'string' || input.detail === null ? { detail: input.detail as string | null } : {}),
209
228
  ...(Array.isArray(input.nodeIds) ? { nodeIds: normalizeAxNodeIds(input.nodeIds) } : {}),
229
+ ...(typeof input.agentId === 'string' ? { agentId: input.agentId } : {}),
210
230
  },
211
231
  { source: normalizeAxSource(input.source, 'api') },
212
232
  );
213
233
  if (!workItem) throw new OperationError('work item not found.', 404);
214
234
  ctx.emit('ax-state-changed', { workItem });
235
+ // Same as create: the mirror touched node data only if the patch carried
236
+ // status or nodeIds — emit the layout frame those chips need (this op is
237
+ // mutates:false, so nothing else will).
238
+ if (input.status !== undefined || input.nodeIds !== undefined) {
239
+ ctx.emit('canvas-layout-update', { layout: canvasState.getLayout() });
240
+ }
215
241
  return { ok: true, workItem } as unknown as Record<string, unknown>;
216
242
  },
217
243
  });
@@ -238,7 +238,9 @@ async function runBatch(operations: BatchEntry[]): Promise<BatchEnvelope> {
238
238
  continue;
239
239
  }
240
240
  const dispatch = resolveDispatch(operation.op, args);
241
- const raw = await executeOperation(dispatch.name, dispatch.args);
241
+ // Batch churn is exempt from auto-ghosts (matches the skill's explicit
242
+ // ghost-cursor exemption for batch operations).
243
+ const raw = await executeOperation(dispatch.name, dispatch.args, { suppressAutoGhost: true });
242
244
  const result = shapeBatchEntry(operation.op, raw);
243
245
  results.push(result);
244
246
  if (typeof operation.assign === 'string' && operation.assign.trim().length > 0) {
@@ -69,6 +69,11 @@ export function defaultNodeSize(type: string): { width: number; height: number }
69
69
  switch (type) {
70
70
  case 'html':
71
71
  return { width: 720, height: 640 };
72
+ case 'file':
73
+ // Code preview needs real width and enough height for the header bar +
74
+ // a useful snippet — the generic default rendered as a content-less bar
75
+ // (0.4.4 portal feedback).
76
+ return { width: 520, height: 360 };
72
77
  case 'markdown':
73
78
  return MARKDOWN_NODE_DEFAULT_SIZE;
74
79
  case 'mcp-app':
@@ -415,7 +420,16 @@ export function createBasicCanvasNode(
415
420
  try {
416
421
  const { node, needsCodeGraphRecompute } = addCanvasNode({
417
422
  type: type as CanvasNodeState['type'],
418
- ...(typeof body.title === 'string' ? { title: body.title } : {}),
423
+ ...(typeof body.title === 'string'
424
+ ? { title: body.title }
425
+ : // Untitled file nodes get the filename — otherwise the title bar
426
+ // reads as a bare "FILE" chip with nothing identifying the file.
427
+ type === 'file' && typeof content === 'string' && content.trim()
428
+ ? // Separator-agnostic: Windows paths use backslashes (0.3.2's
429
+ // basename fix class — a '/'-only split makes the whole path
430
+ // the title there).
431
+ { title: content.trim().split(/[\\/]/).pop() }
432
+ : {}),
419
433
  ...(typeof content === 'string' ? { content } : {}),
420
434
  ...(htmlMergedData && Object.keys(htmlMergedData).length > 0 ? { data: htmlMergedData } : {}),
421
435
  ...(type === 'trace' && typeof body.toolName === 'string' ? { toolName: body.toolName } : {}),
@@ -105,6 +105,19 @@ function allowedIntentKinds(name: string, rawInput: unknown): readonly PmxAxInte
105
105
  return INTENT_KINDS_BY_OPERATION[name];
106
106
  }
107
107
 
108
+ /**
109
+ * Ops exempt from auto-ghost SYNTHESIS only (explicit intent linking above is
110
+ * unaffected). Stream appends arrive in rapid succession — a fresh ghost per
111
+ * chunk is exactly the high-frequency churn class the batch exemption exists
112
+ * for; the stream's CREATING call (no nodeId yet) still ghosts once.
113
+ */
114
+ function autoGhostExempt(name: string, rawInput: unknown): boolean {
115
+ if (name !== 'jsonrender.stream') return false;
116
+ const input =
117
+ rawInput && typeof rawInput === 'object' && !Array.isArray(rawInput) ? (rawInput as Record<string, unknown>) : {};
118
+ return typeof input.nodeId === 'string' && input.nodeId.length > 0;
119
+ }
120
+
108
121
  function settledNodeId(result: unknown, intent: PmxAxIntent): string | undefined {
109
122
  if (intent.kind === 'connect' || intent.kind === 'remove') return undefined;
110
123
  if (!result || typeof result !== 'object' || Array.isArray(result)) return undefined;
@@ -118,7 +131,75 @@ function settledNodeId(result: unknown, intent: PmxAxIntent): string | undefined
118
131
  return typeof record.id === 'string' ? record.id : undefined;
119
132
  }
120
133
 
121
- export async function executeOperation(name: string, rawInput: unknown): Promise<unknown> {
134
+ /** Verb for the synthesized auto-ghost chip, per intent kind. */
135
+ const AUTO_GHOST_VERBS: Record<string, string> = {
136
+ create: 'Adding',
137
+ move: 'Moving',
138
+ edit: 'Updating',
139
+ connect: 'Connecting',
140
+ remove: 'Removing',
141
+ };
142
+
143
+ const AUTO_GHOST_EDGE_TYPES = new Set(['flow', 'depends-on', 'relation', 'references']);
144
+
145
+ /**
146
+ * Build a valid signal input for a synthesized ghost, or null when the raw
147
+ * input can't satisfy the kind's required fields (e.g. a create with server
148
+ * auto-placement has no position to ghost at) — in that case the mutation
149
+ * simply runs unghosted.
150
+ */
151
+ function autoGhostInput(name: string, rawInput: unknown, kind: string): Record<string, unknown> | null {
152
+ const body =
153
+ rawInput && typeof rawInput === 'object' && !Array.isArray(rawInput) ? (rawInput as Record<string, unknown>) : {};
154
+ const subject =
155
+ typeof body.title === 'string' && body.title.trim()
156
+ ? body.title.trim().slice(0, 80)
157
+ : typeof body.type === 'string'
158
+ ? `${body.type} node`
159
+ : typeof body.id === 'string'
160
+ ? body.id
161
+ : name.split('.')[0];
162
+ const base = {
163
+ kind,
164
+ label: `${AUTO_GHOST_VERBS[kind] ?? 'Changing'} ${subject}`,
165
+ ...(typeof body.type === 'string' ? { nodeType: body.type } : {}),
166
+ source: 'auto',
167
+ auto: true,
168
+ ttlMs: 6000,
169
+ };
170
+ const position = typeof body.x === 'number' && typeof body.y === 'number' ? { x: body.x, y: body.y } : undefined;
171
+ switch (kind) {
172
+ case 'create':
173
+ return position ? { ...base, position } : null;
174
+ case 'move':
175
+ return typeof body.id === 'string' && position ? { ...base, nodeId: body.id, position } : null;
176
+ case 'edit':
177
+ case 'remove':
178
+ return typeof body.id === 'string' ? { ...base, nodeId: body.id } : null;
179
+ case 'connect': {
180
+ if (typeof body.from !== 'string' || typeof body.to !== 'string') return null;
181
+ const edgeType = typeof body.type === 'string' && AUTO_GHOST_EDGE_TYPES.has(body.type) ? body.type : 'flow';
182
+ return { ...base, nodeType: undefined, edge: { from: body.from, to: body.to, type: edgeType } };
183
+ }
184
+ default:
185
+ return null;
186
+ }
187
+ }
188
+
189
+ export interface ExecuteOperationMeta {
190
+ /**
191
+ * Skip the synthesized auto-ghost: set by the workbench's own HTTP calls
192
+ * (a human dragging a node is not agent activity) and by canvas.batch for
193
+ * its inner dispatches (batch churn is exempt, matching the skill contract).
194
+ */
195
+ suppressAutoGhost?: boolean;
196
+ }
197
+
198
+ export async function executeOperation(
199
+ name: string,
200
+ rawInput: unknown,
201
+ meta: ExecuteOperationMeta = {},
202
+ ): Promise<unknown> {
122
203
  const op = getOperation(name);
123
204
  const intentId = linkedIntentId(rawInput);
124
205
  const allowedKinds = intentId ? allowedIntentKinds(name, rawInput) : undefined;
@@ -140,6 +221,46 @@ export async function executeOperation(name: string, rawInput: unknown): Promise
140
221
  );
141
222
  }
142
223
 
224
+ // Auto-ghost: an agent-originated visible mutation with NO explicit intent
225
+ // still shows its move on the canvas — a server-synthesized ghost that
226
+ // settles the moment the mutation lands (the client's minimum-dwell keeps
227
+ // the flash perceptible). Explicit canvas_intent signalling stays the
228
+ // richer path (labels, reasons, a real veto window before the mutation).
229
+ const autoKinds = allowedIntentKinds(name, rawInput);
230
+ if (
231
+ autoKinds &&
232
+ !meta.suppressAutoGhost &&
233
+ !autoGhostExempt(name, rawInput) &&
234
+ process.env.PMX_CANVAS_AUTO_INTENT !== '0'
235
+ ) {
236
+ // Ghost synthesis must NEVER break the mutation: skip silently when the
237
+ // input can't satisfy a kind's required fields or signalling throws.
238
+ let ghost: PmxAxIntent | null = null;
239
+ try {
240
+ for (const kind of autoKinds) {
241
+ const input = autoGhostInput(name, rawInput, kind);
242
+ if (input) {
243
+ ghost = intentRegistry.signal(input);
244
+ break;
245
+ }
246
+ }
247
+ } catch {
248
+ ghost = null;
249
+ }
250
+ if (ghost) {
251
+ const ghostId = ghost.id;
252
+ try {
253
+ const result = await op.execute(rawInput, operationContext);
254
+ if (op.mutates) emitOperationEvent('canvas-layout-update', { layout: canvasState.getLayout() });
255
+ intentRegistry.clear(ghostId, { settledNodeId: settledNodeId(result, ghost) ?? undefined });
256
+ return result;
257
+ } catch (error) {
258
+ intentRegistry.clear(ghostId);
259
+ throw error;
260
+ }
261
+ }
262
+ }
263
+
143
264
  const result = await op.execute(rawInput, operationContext);
144
265
  if (op.mutates) emitOperationEvent('canvas-layout-update', { layout: canvasState.getLayout() });
145
266
  return result;
@@ -455,7 +455,7 @@ export async function startCanvasAutomationWebView(
455
455
  return runCanvasAutomationWebViewTask(async () => {
456
456
  const WebView = getCanvasWebViewConstructor();
457
457
  if (!WebView) {
458
- const message = 'Bun.WebView is not available in this Bun runtime. Bun >=1.3.12 is required.';
458
+ const message = `Bun.WebView is not available in this Bun runtime (Bun ${Bun.version}). Bun >=1.3.12 is required — the canvas itself keeps working; only WebView automation/screenshots are unavailable.`;
459
459
  canvasAutomationWebViewStatus = {
460
460
  ...canvasAutomationWebViewStatus,
461
461
  lastError: message,
@@ -2999,7 +2999,15 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
2999
2999
  // M11: PMX_WEB_CANVAS_PORT (server-specific) wins, then PMX_CANVAS_PORT — so
3000
3000
  // setting only the client var no longer splits the client and server ports.
3001
3001
  const preferredPort =
3002
- options.port ?? Number(process.env.PMX_WEB_CANVAS_PORT ?? process.env.PMX_CANVAS_PORT ?? DEFAULT_PORT);
3002
+ options.port ??
3003
+ Number(
3004
+ process.env.PMX_WEB_CANVAS_PORT ??
3005
+ process.env.PMX_CANVAS_PORT ??
3006
+ // Amp orbs assign the portal port via $PORT (gated on AMP_ORB so a
3007
+ // stray PORT in normal shells never shifts the default).
3008
+ (process.env.AMP_ORB ? process.env.PORT || undefined : undefined) ??
3009
+ DEFAULT_PORT,
3010
+ );
3003
3011
  const portCandidates =
3004
3012
  options.port === 0
3005
3013
  ? [0]
@@ -44,6 +44,12 @@ export interface PmxAxIntent {
44
44
  seq?: number;
45
45
  /** Source label of the surface that signalled the intent (mcp/api/sdk/...). */
46
46
  source?: string;
47
+ /**
48
+ * Server-synthesized ghost for an agent mutation that arrived WITHOUT an
49
+ * explicit canvas_intent signal (auto-ghost). Rendered visually lighter than
50
+ * a deliberate signal, and not vetoable — it settles immediately.
51
+ */
52
+ auto?: boolean;
47
53
  /** Epoch ms when the intent was first signalled. */
48
54
  createdAt: number;
49
55
  /** Epoch ms when the intent auto-dissolves if not settled/cleared first. */
@@ -51,7 +51,7 @@ export const CANVAS_THEME_META: Record<CanvasThemeName, CanvasThemeMeta> = {
51
51
  arctic: { label: 'Arctic', scheme: 'dark', swatchBg: '#20242E', swatchAccent: '#7FC8DE' },
52
52
  ember: { label: 'Ember', scheme: 'dark', swatchBg: '#0B0B0C', swatchAccent: '#F34E1C' },
53
53
  forest: { label: 'Forest', scheme: 'dark', swatchBg: '#0C1712', swatchAccent: '#5CCF8F' },
54
- volt: { label: 'Volt', scheme: 'dark', swatchBg: '#0B0D0B', swatchAccent: '#F34E3F' },
54
+ volt: { label: 'Volt', scheme: 'dark', swatchBg: '#0B0D0B', swatchAccent: '#DFDFC1' },
55
55
  };
56
56
 
57
57
  /** Collapse a theme to the dark/light scheme embedded dark|light-only viewers understand. */