pmx-canvas 0.4.4 → 0.4.6

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 (98) hide show
  1. package/CHANGELOG.md +108 -0
  2. package/Readme.md +52 -4
  3. package/dist/canvas/global.css +115 -11
  4. package/dist/canvas/index.js +93 -72
  5. package/dist/canvas/mermaid-entry.js +3563 -0
  6. package/dist/canvas/surface-theme.css +2 -2
  7. package/dist/json-render/index.css +1 -1
  8. package/dist/types/cli/agent.d.ts +1 -0
  9. package/dist/types/cli/commands/skills.d.ts +1 -1
  10. package/dist/types/cli/commands/smoke.d.ts +1 -0
  11. package/dist/types/client/mermaid-entry.d.ts +11 -0
  12. package/dist/types/client/nodes/DiffNode.d.ts +20 -0
  13. package/dist/types/client/nodes/ExtAppFrame.d.ts +0 -2
  14. package/dist/types/client/nodes/MermaidNode.d.ts +12 -0
  15. package/dist/types/client/state/intent-store.d.ts +6 -0
  16. package/dist/types/client/types.d.ts +2 -2
  17. package/dist/types/server/ax-state-manager.d.ts +5 -0
  18. package/dist/types/server/ax-state.d.ts +9 -3
  19. package/dist/types/server/canvas-db.d.ts +3 -2
  20. package/dist/types/server/canvas-provenance.d.ts +1 -1
  21. package/dist/types/server/canvas-state.d.ts +22 -1
  22. package/dist/types/server/canvas-validation.d.ts +34 -1
  23. package/dist/types/server/html-surface.d.ts +8 -0
  24. package/dist/types/server/index.d.ts +5 -0
  25. package/dist/types/server/operations/ops/ax-shared.d.ts +4 -1
  26. package/dist/types/server/operations/ops/nodes.d.ts +1 -1
  27. package/dist/types/server/operations/registry.d.ts +9 -1
  28. package/dist/types/server/workboard.d.ts +46 -0
  29. package/dist/types/shared/ax-intent.d.ts +6 -0
  30. package/dist/types/shared/ax-surface-protocol.d.ts +7 -0
  31. package/docs/cli.md +12 -0
  32. package/docs/environment.md +1 -0
  33. package/docs/http-api.md +12 -0
  34. package/docs/mcp.md +1 -1
  35. package/docs/node-types.md +2 -0
  36. package/docs/screenshot.png +0 -0
  37. package/docs/screenshots/amp-orb-portal.png +0 -0
  38. package/docs/screenshots/claude-code-desktop.png +0 -0
  39. package/docs/screenshots/codex-app.png +0 -0
  40. package/docs/screenshots/github-copilot-app.png +0 -0
  41. package/package.json +4 -3
  42. package/skills/pmx-canvas/SKILL.md +71 -24
  43. package/skills/pmx-canvas-orchestration/SKILL.md +82 -0
  44. package/skills/pmx-canvas-testing/SKILL.md +3 -0
  45. package/src/cli/agent.ts +2 -0
  46. package/src/cli/commands/ax.ts +28 -3
  47. package/src/cli/commands/skills.ts +1 -1
  48. package/src/cli/commands/smoke.ts +212 -0
  49. package/src/cli/index.ts +15 -2
  50. package/src/cli/shared.ts +1 -0
  51. package/src/client/canvas/CanvasNode.tsx +12 -1
  52. package/src/client/canvas/CanvasViewport.tsx +6 -0
  53. package/src/client/canvas/ExpandedNodeOverlay.tsx +10 -0
  54. package/src/client/canvas/IntentLayer.tsx +8 -6
  55. package/src/client/canvas/Minimap.tsx +2 -0
  56. package/src/client/canvas/auto-fit.ts +2 -0
  57. package/src/client/icons.tsx +4 -0
  58. package/src/client/mermaid-entry.ts +76 -0
  59. package/src/client/nodes/DiffNode.tsx +114 -0
  60. package/src/client/nodes/ExtAppFrame.tsx +259 -84
  61. package/src/client/nodes/MermaidNode.tsx +81 -0
  62. package/src/client/state/intent-bridge.ts +12 -3
  63. package/src/client/state/intent-store.ts +47 -2
  64. package/src/client/state/node-factory.ts +2 -0
  65. package/src/client/state/sse-bridge.ts +28 -0
  66. package/src/client/theme/global.css +115 -11
  67. package/src/client/theme/surface-theme.css +2 -2
  68. package/src/client/types.ts +6 -0
  69. package/src/mcp/server.ts +22 -6
  70. package/src/server/ax-interaction.ts +6 -0
  71. package/src/server/ax-state-manager.ts +27 -6
  72. package/src/server/ax-state.ts +36 -3
  73. package/src/server/canvas-db.ts +83 -60
  74. package/src/server/canvas-operations.ts +39 -9
  75. package/src/server/canvas-provenance.ts +2 -0
  76. package/src/server/canvas-schema.ts +33 -0
  77. package/src/server/canvas-state.ts +78 -6
  78. package/src/server/canvas-validation.ts +79 -0
  79. package/src/server/html-surface.ts +11 -0
  80. package/src/server/index.ts +25 -6
  81. package/src/server/intent-registry.ts +2 -0
  82. package/src/server/operations/composites.ts +3 -2
  83. package/src/server/operations/http.ts +5 -1
  84. package/src/server/operations/ops/ax-shared.ts +20 -1
  85. package/src/server/operations/ops/ax-state.ts +4 -7
  86. package/src/server/operations/ops/ax-timeline.ts +26 -3
  87. package/src/server/operations/ops/ax-work.ts +27 -1
  88. package/src/server/operations/ops/batch.ts +11 -1
  89. package/src/server/operations/ops/json-render.ts +44 -0
  90. package/src/server/operations/ops/nodes.ts +24 -2
  91. package/src/server/operations/ops/viewport.ts +8 -1
  92. package/src/server/operations/registry.ts +122 -1
  93. package/src/server/server.ts +58 -3
  94. package/src/server/workboard.ts +159 -0
  95. package/src/shared/ax-intent.ts +6 -0
  96. package/src/shared/ax-surface-protocol.ts +8 -0
  97. package/src/shared/surface.ts +2 -0
  98. package/src/shared/themes.ts +1 -1
@@ -542,15 +542,22 @@ export class PmxCanvas extends EventEmitter {
542
542
  nodeIds?: string[];
543
543
  data?: Record<string, unknown> | null;
544
544
  },
545
- options?: { source?: PmxAxSource },
545
+ options?: { source?: PmxAxSource; agentId?: string | null },
546
546
  ): PmxAxEvent {
547
- const event = canvasState.recordAxEvent(input, { source: options?.source ?? 'sdk' });
547
+ const event = canvasState.recordAxEvent(input, { source: options?.source ?? 'sdk', agentId: options?.agentId });
548
548
  emitPrimaryWorkbenchEvent('ax-event-created', { event });
549
549
  return event;
550
550
  }
551
551
 
552
- sendSteering(message: string, options?: { source?: PmxAxSource }): PmxAxSteeringMessage {
553
- const steering = canvasState.recordSteeringMessage(message, { source: options?.source ?? 'sdk' });
552
+ sendSteering(
553
+ message: string,
554
+ options?: { source?: PmxAxSource; agentId?: string | null; target?: string | null },
555
+ ): PmxAxSteeringMessage {
556
+ const steering = canvasState.recordSteeringMessage(message, {
557
+ source: options?.source ?? 'sdk',
558
+ agentId: options?.agentId,
559
+ target: options?.target,
560
+ });
554
561
  emitPrimaryWorkbenchEvent('ax-event-created', { steering });
555
562
  return steering;
556
563
  }
@@ -604,7 +611,13 @@ export class PmxCanvas extends EventEmitter {
604
611
  }
605
612
 
606
613
  addWorkItem(
607
- input: { title: string; status?: PmxAxWorkItemStatus; detail?: string | null; nodeIds?: string[] },
614
+ input: {
615
+ title: string;
616
+ status?: PmxAxWorkItemStatus;
617
+ detail?: string | null;
618
+ nodeIds?: string[];
619
+ agentId?: string | null;
620
+ },
608
621
  options?: { source?: PmxAxSource },
609
622
  ): PmxAxWorkItem {
610
623
  const workItem = canvasState.addWorkItem(input, { source: options?.source ?? 'sdk' });
@@ -614,7 +627,13 @@ export class PmxCanvas extends EventEmitter {
614
627
 
615
628
  updateWorkItem(
616
629
  id: string,
617
- patch: { title?: string; status?: PmxAxWorkItemStatus; detail?: string | null; nodeIds?: string[] },
630
+ patch: {
631
+ title?: string;
632
+ status?: PmxAxWorkItemStatus;
633
+ detail?: string | null;
634
+ nodeIds?: string[];
635
+ agentId?: string | null;
636
+ },
618
637
  options?: { source?: PmxAxSource },
619
638
  ): PmxAxWorkItem | null {
620
639
  const workItem = canvasState.updateWorkItem(id, patch, { source: options?.source ?? 'sdk' });
@@ -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
  };
@@ -156,14 +156,15 @@ export const compositeToolDefinitions: CompositeToolDefinition[] = [
156
156
  {
157
157
  toolName: 'canvas_render',
158
158
  description:
159
- 'Spec-driven content: discover the schema, validate a payload, or create a json-render / graph node. Action "describe-schema" returns the json-render component catalog + create schemas; "validate" checks a spec or graph payload without creating a node; "add-json-render" creates a json-render node from a complete spec; "stream-json-render" progressively builds one from SpecStream patches (omit nodeId to create, pass it back to append, done=true to finish); "add-graph" creates a chart node (line, bar, pie, area, scatter, radar, stacked-bar, composed, sparkline, dot-plot, bullet, slopegraph).',
160
- actionSummary: 'describe-schema | validate | add-json-render | stream-json-render | add-graph',
159
+ 'Spec-driven content: discover the schema, validate a payload, or create a json-render / graph node. Action "describe-schema" returns the json-render component catalog + create schemas; "validate" checks a spec or graph payload without creating a node; "add-json-render" creates a json-render node from a complete spec; "stream-json-render" progressively builds one from SpecStream patches (omit nodeId to create, pass it back to append, done=true to finish); "add-graph" creates a chart node (line, bar, pie, area, scatter, radar, stacked-bar, composed, sparkline, dot-plot, bullet, slopegraph); "workboard" materializes a live AX work-item board (a json-render node with a column per status) that auto-refreshes whenever work items change — idempotent, rebuilding the existing board in place.',
160
+ actionSummary: 'describe-schema | validate | add-json-render | stream-json-render | add-graph | workboard',
161
161
  actions: {
162
162
  'describe-schema': 'schema.describe',
163
163
  validate: 'spec.validate',
164
164
  'add-json-render': 'jsonrender.add',
165
165
  'stream-json-render': 'jsonrender.stream',
166
166
  'add-graph': 'graph.add',
167
+ workboard: 'render.workboard',
167
168
  },
168
169
  },
169
170
  {
@@ -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) {
@@ -373,6 +375,14 @@ const batchOperation = defineOperation<z.infer<typeof batchSchema>, BatchEnvelop
373
375
  // ctx.emit). After the loop completes, emit ONE final canvas-layout-update.
374
376
  const envelope = await runWithSuppressedEmits(() => runBatch(operations));
375
377
  ctx.emit('canvas-layout-update', { layout: canvasState.getLayout() });
378
+ // The suppression window also swallows the pins op's context-pins-changed
379
+ // frame, leaving the client's pin state (menus, indicators) stale until
380
+ // reload (0.4.5 report Finding X). If the batch touched pins, follow the
381
+ // layout frame with ONE final pins frame carrying the end state.
382
+ if (operations.some((entry) => entry.op.startsWith('pin.'))) {
383
+ const nodeIds = Array.from(canvasState.contextPinnedNodeIds);
384
+ ctx.emit('context-pins-changed', { count: nodeIds.length, nodeIds });
385
+ }
376
386
  return envelope;
377
387
  },
378
388
  });
@@ -39,6 +39,7 @@ import {
39
39
  } from '../../canvas-operations.js';
40
40
  import { describeCanvasSchema, validateStructuredCanvasPayload } from '../../canvas-schema.js';
41
41
  import { isHtmlPrimitiveKind } from '../../html-primitives.js';
42
+ import { renderWorkboard } from '../../workboard.js';
42
43
  import { defineOperation, OperationError, type Operation } from '../types.js';
43
44
  import {
44
45
  buildNodeResponse,
@@ -311,6 +312,48 @@ const jsonRenderStreamOperation = defineOperation<z.infer<typeof jsonRenderStrea
311
312
  // Wire shape is `{ id, url, ok, applied, ... }` — handler output verbatim.
312
313
  });
313
314
 
315
+ // ── render.workboard ──────────────────────────────────────────
316
+
317
+ const workboardRenderShape = {
318
+ intentId: z
319
+ .string()
320
+ .optional()
321
+ .catch(undefined)
322
+ .describe('Ghost intent id returned by canvas_intent signal. A vetoed or expired intent blocks this mutation.'),
323
+ x: z.number().optional().catch(undefined).describe('Optional X position (only used when creating the board)'),
324
+ y: z.number().optional().catch(undefined).describe('Optional Y position (only used when creating the board)'),
325
+ };
326
+
327
+ const workboardRenderSchema = z.looseObject(workboardRenderShape);
328
+
329
+ const workboardRenderOperation = defineOperation<
330
+ z.infer<typeof workboardRenderSchema>,
331
+ ReturnType<typeof renderWorkboard>
332
+ >({
333
+ name: 'render.workboard',
334
+ mutates: true,
335
+ input: workboardRenderSchema,
336
+ inputShape: workboardRenderShape,
337
+ http: {
338
+ method: 'POST',
339
+ path: '/api/canvas/workboard',
340
+ },
341
+ mcp: {
342
+ toolName: 'canvas_render_workboard',
343
+ description:
344
+ 'Materialize a live work-item board: a json-render node with one column per status (todo → in-progress → blocked → done → cancelled) showing every AX work item as a card (title, agentId chip, detail). Idempotent: rebuilds the existing board node in place when one exists, otherwise creates it. The board auto-refreshes whenever work items change.',
345
+ },
346
+ handler: (input) => {
347
+ const body: Record<string, unknown> = input;
348
+ const x = pickFiniteNumber(body, 'x');
349
+ const y = pickFiniteNumber(body, 'y');
350
+ return renderWorkboard({
351
+ ...(x !== undefined ? { x } : {}),
352
+ ...(y !== undefined ? { y } : {}),
353
+ });
354
+ },
355
+ });
356
+
314
357
  // ── graph.add ─────────────────────────────────────────────────
315
358
 
316
359
  const graphAddShape = {
@@ -829,6 +872,7 @@ const specValidateOperation = defineOperation<z.infer<typeof specValidateSchema>
829
872
  export const jsonRenderOperations: Operation[] = [
830
873
  jsonRenderAddOperation,
831
874
  jsonRenderStreamOperation,
875
+ workboardRenderOperation,
832
876
  graphAddOperation,
833
877
  schemaDescribeOperation,
834
878
  specValidateOperation,
@@ -55,6 +55,8 @@ export const NODE_TYPES = [
55
55
  'ledger',
56
56
  'trace',
57
57
  'file',
58
+ 'diff',
59
+ 'mermaid',
58
60
  'image',
59
61
  'mcp-app',
60
62
  'webpage',
@@ -69,6 +71,15 @@ export function defaultNodeSize(type: string): { width: number; height: number }
69
71
  switch (type) {
70
72
  case 'html':
71
73
  return { width: 720, height: 640 };
74
+ case 'file':
75
+ // Code preview needs real width and enough height for the header bar +
76
+ // a useful snippet — the generic default rendered as a content-less bar
77
+ // (0.4.4 portal feedback).
78
+ return { width: 520, height: 360 };
79
+ case 'diff':
80
+ return { width: 640, height: 420 };
81
+ case 'mermaid':
82
+ return { width: 640, height: 460 };
72
83
  case 'markdown':
73
84
  return MARKDOWN_NODE_DEFAULT_SIZE;
74
85
  case 'mcp-app':
@@ -415,7 +426,16 @@ export function createBasicCanvasNode(
415
426
  try {
416
427
  const { node, needsCodeGraphRecompute } = addCanvasNode({
417
428
  type: type as CanvasNodeState['type'],
418
- ...(typeof body.title === 'string' ? { title: body.title } : {}),
429
+ ...(typeof body.title === 'string'
430
+ ? { title: body.title }
431
+ : // Untitled file nodes get the filename — otherwise the title bar
432
+ // reads as a bare "FILE" chip with nothing identifying the file.
433
+ type === 'file' && typeof content === 'string' && content.trim()
434
+ ? // Separator-agnostic: Windows paths use backslashes (0.3.2's
435
+ // basename fix class — a '/'-only split makes the whole path
436
+ // the title there).
437
+ { title: content.trim().split(/[\\/]/).pop() }
438
+ : {}),
419
439
  ...(typeof content === 'string' ? { content } : {}),
420
440
  ...(htmlMergedData && Object.keys(htmlMergedData).length > 0 ? { data: htmlMergedData } : {}),
421
441
  ...(type === 'trace' && typeof body.toolName === 'string' ? { toolName: body.toolName } : {}),
@@ -769,7 +789,7 @@ const nodeAddOperation = defineOperation<z.infer<typeof nodeAddSchema>, NodeAddR
769
789
  mcp: {
770
790
  toolName: 'canvas_add_node',
771
791
  description:
772
- 'Add a basic node to the canvas. Returns the created node with normalized title/content and rendered geometry. Supported here: markdown, status, context, ledger, trace, file, image, webpage, mcp-app, html, group. Dedicated routes: json-render -> canvas_render {action:"add-json-render"}, graph -> canvas_render {action:"add-graph"}, web-artifact -> canvas_app {action:"build-artifact"}, external apps -> canvas_app {action:"open-mcp-app"}, groups -> canvas_group {action:"create"}. Call canvas_render {action:"describe-schema"} for the full nodeTypeRouting table.',
792
+ 'Add a basic node to the canvas. Returns the created node with normalized title/content and rendered geometry. Supported here: markdown, status, context, ledger, trace, file, diff, mermaid, image, webpage, mcp-app, html, group. Dedicated routes: json-render -> canvas_render {action:"add-json-render"}, graph -> canvas_render {action:"add-graph"}, web-artifact -> canvas_app {action:"build-artifact"}, external apps -> canvas_app {action:"open-mcp-app"}, groups -> canvas_group {action:"create"}. Call canvas_render {action:"describe-schema"} for the full nodeTypeRouting table.',
773
793
  extraShape: {
774
794
  type: z
775
795
  .enum([
@@ -779,6 +799,8 @@ const nodeAddOperation = defineOperation<z.infer<typeof nodeAddSchema>, NodeAddR
779
799
  'ledger',
780
800
  'trace',
781
801
  'file',
802
+ 'diff',
803
+ 'mermaid',
782
804
  'image',
783
805
  'webpage',
784
806
  'mcp-app',
@@ -121,7 +121,14 @@ const focusOperation = defineOperation<z.infer<typeof focusSchema>, Record<strin
121
121
  if (!node) throw new OperationError(`Node "${nodeId}" not found.`, 404);
122
122
  const noPan = body.noPan === true;
123
123
  if (!noPan) {
124
- canvasState.setViewport({ x: node.position.x - 100, y: node.position.y - 100 });
124
+ // The viewport is a SCREEN-space translate: screen = world * scale + v
125
+ // (CanvasViewport's matrix(scale,0,0,scale,x,y)), the same convention
126
+ // fitCanvasView and the client's focusNode use. So park the node's
127
+ // top-left at a fixed screen-space margin — 64/96 screen px, which
128
+ // clears the 44px floating toolbar at ANY zoom (0.4.5 report Finding Z:
129
+ // the old world-space margin shrank below the toolbar at low scales).
130
+ const scale = canvasState.viewport.scale || 1;
131
+ canvasState.setViewport({ x: 64 - node.position.x * scale, y: 96 - node.position.y * scale });
125
132
  } else {
126
133
  const maxZ = canvasState.getLayout().nodes.reduce((max, layoutNode) => Math.max(max, layoutNode.zIndex), 0);
127
134
  canvasState.updateNode(nodeId, { zIndex: maxZ + 1 });