pmx-canvas 0.4.8 → 0.5.1

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 (212) hide show
  1. package/.github/extensions/pmx-canvas/extension.mjs +154 -5
  2. package/.github/extensions/pmx-canvas/steering-delivery.mjs +79 -0
  3. package/CHANGELOG.md +156 -0
  4. package/Readme.md +72 -23
  5. package/dist/canvas/global.css +3071 -796
  6. package/dist/canvas/index.js +122 -104
  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/pump.d.ts +9 -0
  10. package/dist/types/client/App.d.ts +1 -1
  11. package/dist/types/client/canvas/AgentPresenceLayer.d.ts +11 -0
  12. package/dist/types/client/canvas/BarHint.d.ts +22 -0
  13. package/dist/types/client/canvas/CanvasNode.d.ts +5 -0
  14. package/dist/types/client/canvas/CanvasViewport.d.ts +4 -4
  15. package/dist/types/client/canvas/CommandBar.d.ts +10 -0
  16. package/dist/types/client/canvas/ConnectionBanner.d.ts +13 -0
  17. package/dist/types/client/canvas/ContextMenu.d.ts +6 -0
  18. package/dist/types/client/canvas/EdgeLayer.d.ts +2 -1
  19. package/dist/types/client/canvas/EmptyState.d.ts +11 -0
  20. package/dist/types/client/canvas/ExternalWriters.d.ts +5 -0
  21. package/dist/types/client/canvas/HumanPresenceLayer.d.ts +8 -0
  22. package/dist/types/client/canvas/Minimap.d.ts +12 -1
  23. package/dist/types/client/canvas/ScopeFenceLayer.d.ts +8 -0
  24. package/dist/types/client/canvas/SelectionBar.d.ts +6 -0
  25. package/dist/types/client/canvas/SessionPanel.d.ts +1 -0
  26. package/dist/types/client/canvas/SessionReceipt.d.ts +17 -0
  27. package/dist/types/client/canvas/TextPrompt.d.ts +25 -0
  28. package/dist/types/client/canvas/ToolRail.d.ts +21 -0
  29. package/dist/types/client/canvas/TopBar.d.ts +7 -0
  30. package/dist/types/client/canvas/canvas-area.d.ts +39 -0
  31. package/dist/types/client/canvas/create-in-view.d.ts +18 -0
  32. package/dist/types/client/canvas/import-files.d.ts +8 -0
  33. package/dist/types/client/canvas/kind-colors.d.ts +3 -0
  34. package/dist/types/client/canvas/use-focus-trap.d.ts +14 -0
  35. package/dist/types/client/canvas/use-now.d.ts +6 -0
  36. package/dist/types/client/icons.d.ts +11 -4
  37. package/dist/types/client/nodes/ExtAppFrame.d.ts +2 -0
  38. package/dist/types/client/nodes/GroupNode.d.ts +6 -1
  39. package/dist/types/client/nodes/MarkdownNode.d.ts +2 -0
  40. package/dist/types/client/nodes/inline-editor-commands.d.ts +3 -4
  41. package/dist/types/client/state/canvas-store.d.ts +49 -5
  42. package/dist/types/client/state/human-store.d.ts +26 -0
  43. package/dist/types/client/state/iframe-mode.d.ts +1 -0
  44. package/dist/types/client/state/intent-bridge.d.ts +37 -5
  45. package/dist/types/client/state/node-factory.d.ts +0 -1
  46. package/dist/types/client/state/presence-store.d.ts +78 -0
  47. package/dist/types/client/state/session-store.d.ts +176 -0
  48. package/dist/types/client/types.d.ts +2 -1
  49. package/dist/types/client/utils/platform.d.ts +2 -0
  50. package/dist/types/server/agent-presence.d.ts +192 -0
  51. package/dist/types/server/ax-gate-ttl.d.ts +6 -0
  52. package/dist/types/server/ax-interaction.d.ts +4 -10
  53. package/dist/types/server/ax-state-manager.d.ts +21 -2
  54. package/dist/types/server/ax-state.d.ts +49 -3
  55. package/dist/types/server/ax-wait.d.ts +13 -0
  56. package/dist/types/server/canvas-db.d.ts +9 -1
  57. package/dist/types/server/canvas-operations.d.ts +11 -0
  58. package/dist/types/server/canvas-state.d.ts +27 -13
  59. package/dist/types/server/canvas-validation.d.ts +0 -14
  60. package/dist/types/server/html-surface.d.ts +1 -1
  61. package/dist/types/server/human-presence.d.ts +40 -0
  62. package/dist/types/server/index.d.ts +29 -3
  63. package/dist/types/server/intent-registry.d.ts +7 -0
  64. package/dist/types/server/mutation-history.d.ts +15 -2
  65. package/dist/types/server/operations/invoker.d.ts +13 -2
  66. package/dist/types/server/operations/ops/ax-presence.d.ts +2 -0
  67. package/dist/types/server/operations/ops/human-presence.d.ts +3 -0
  68. package/dist/types/server/operations/registry.d.ts +11 -0
  69. package/dist/types/server/operations/types.d.ts +1 -1
  70. package/dist/types/server/scope-fence.d.ts +62 -0
  71. package/dist/types/server/server.d.ts +0 -12
  72. package/dist/types/shared/agent-presence.d.ts +130 -0
  73. package/dist/types/shared/approval-gates.d.ts +19 -0
  74. package/dist/types/shared/auto-arrange.d.ts +0 -1
  75. package/dist/types/shared/ax-kinds.d.ts +11 -0
  76. package/dist/types/shared/human-presence.d.ts +31 -0
  77. package/dist/types/shared/iframe-probe.d.ts +8 -0
  78. package/dist/types/shared/scope-fence.d.ts +21 -0
  79. package/docs/RELEASE.md +20 -0
  80. package/docs/ax-host-adapter-contract.md +30 -14
  81. package/docs/cli.md +37 -1
  82. package/docs/environment.md +3 -0
  83. package/docs/http-api.md +222 -5
  84. package/docs/mcp.md +6 -5
  85. package/docs/node-types.md +3 -1
  86. package/docs/plans/plan-009-tech-debt-backlog.md +13 -0
  87. package/docs/screenshot.png +0 -0
  88. package/docs/screenshots/multi-agent-live.png +0 -0
  89. package/docs/screenshots/workbench-live-dark.png +0 -0
  90. package/docs/screenshots/workbench-live-light.png +0 -0
  91. package/docs/sdk.md +1 -1
  92. package/package.json +4 -4
  93. package/skills/pmx-canvas/SKILL.md +112 -17
  94. package/skills/pmx-canvas/references/codex-app-adapter.md +28 -0
  95. package/skills/pmx-canvas/references/full-reference.md +55 -16
  96. package/skills/pmx-canvas/references/github-copilot-app-adapter.md +41 -24
  97. package/skills/pmx-canvas-orchestration/SKILL.md +56 -2
  98. package/skills/pmx-canvas-testing/SKILL.md +17 -0
  99. package/src/cli/agent.ts +1 -0
  100. package/src/cli/commands/copilot.ts +15 -8
  101. package/src/cli/commands/nodes.ts +1 -19
  102. package/src/cli/commands/pump.ts +263 -0
  103. package/src/cli/index.ts +2 -5
  104. package/src/cli/shared.ts +1 -1
  105. package/src/client/App.tsx +209 -585
  106. package/src/client/canvas/AgentPresenceLayer.tsx +74 -0
  107. package/src/client/canvas/AttentionHistory.tsx +0 -10
  108. package/src/client/canvas/BarHint.tsx +49 -0
  109. package/src/client/canvas/CanvasNode.tsx +488 -136
  110. package/src/client/canvas/CanvasViewport.tsx +190 -94
  111. package/src/client/canvas/CommandBar.tsx +140 -0
  112. package/src/client/canvas/CommandPalette.tsx +133 -58
  113. package/src/client/canvas/ConnectionBanner.tsx +65 -0
  114. package/src/client/canvas/ContextMenu.tsx +88 -27
  115. package/src/client/canvas/ContextPinBar.tsx +2 -2
  116. package/src/client/canvas/EdgeLayer.tsx +121 -38
  117. package/src/client/canvas/EmptyState.tsx +93 -0
  118. package/src/client/canvas/ExpandedNodeOverlay.tsx +64 -118
  119. package/src/client/canvas/ExternalWriters.tsx +308 -0
  120. package/src/client/canvas/FocusFieldLayer.tsx +12 -1
  121. package/src/client/canvas/HumanPresenceLayer.tsx +45 -0
  122. package/src/client/canvas/IntentLayer.tsx +1 -1
  123. package/src/client/canvas/Minimap.tsx +117 -190
  124. package/src/client/canvas/ScopeFenceLayer.tsx +38 -0
  125. package/src/client/canvas/SelectionBar.tsx +175 -23
  126. package/src/client/canvas/SessionPanel.tsx +481 -0
  127. package/src/client/canvas/SessionReceipt.tsx +141 -0
  128. package/src/client/canvas/ShortcutOverlay.tsx +21 -6
  129. package/src/client/canvas/SnapshotPanel.tsx +166 -80
  130. package/src/client/canvas/TextPrompt.tsx +128 -0
  131. package/src/client/canvas/ToolRail.tsx +447 -0
  132. package/src/client/canvas/TopBar.tsx +308 -0
  133. package/src/client/canvas/auto-fit.ts +1 -5
  134. package/src/client/canvas/canvas-area.ts +74 -0
  135. package/src/client/canvas/create-in-view.ts +48 -0
  136. package/src/client/canvas/import-files.ts +64 -0
  137. package/src/client/canvas/kind-colors.ts +22 -0
  138. package/src/client/canvas/snap-guides.ts +1 -1
  139. package/src/client/canvas/use-focus-trap.ts +54 -0
  140. package/src/client/canvas/use-now.ts +17 -0
  141. package/src/client/canvas/use-pan-zoom.ts +13 -2
  142. package/src/client/icons.tsx +57 -18
  143. package/src/client/nodes/ExtAppFrame.tsx +86 -20
  144. package/src/client/nodes/GroupNode.tsx +9 -28
  145. package/src/client/nodes/InlineFormatBar.tsx +4 -3
  146. package/src/client/nodes/InlineMarkdownEditor.tsx +27 -0
  147. package/src/client/nodes/MarkdownNode.tsx +59 -24
  148. package/src/client/nodes/MdFormatBar.tsx +2 -4
  149. package/src/client/nodes/inline-editor-commands.ts +17 -6
  150. package/src/client/nodes/use-surface-frame.ts +3 -1
  151. package/src/client/state/attention-bridge.ts +3 -0
  152. package/src/client/state/canvas-store.ts +285 -90
  153. package/src/client/state/human-store.ts +175 -0
  154. package/src/client/state/iframe-mode.ts +33 -9
  155. package/src/client/state/intent-bridge.ts +75 -13
  156. package/src/client/state/node-factory.ts +2 -8
  157. package/src/client/state/presence-store.ts +168 -0
  158. package/src/client/state/session-store.ts +419 -0
  159. package/src/client/state/sse-bridge.ts +93 -30
  160. package/src/client/theme/global.css +3071 -796
  161. package/src/client/types.ts +3 -1
  162. package/src/client/utils/platform.ts +6 -1
  163. package/src/mcp/canvas-access.ts +2 -2
  164. package/src/server/agent-presence.ts +755 -0
  165. package/src/server/ax-context.ts +45 -0
  166. package/src/server/ax-gate-ttl.ts +61 -0
  167. package/src/server/ax-interaction.ts +29 -1
  168. package/src/server/ax-state-manager.ts +78 -6
  169. package/src/server/ax-state.ts +82 -15
  170. package/src/server/ax-wait.ts +33 -0
  171. package/src/server/canvas-db.ts +103 -33
  172. package/src/server/canvas-operations.ts +58 -14
  173. package/src/server/canvas-state.ts +131 -58
  174. package/src/server/canvas-validation.ts +3 -53
  175. package/src/server/demo-state.json +2 -70
  176. package/src/server/html-surface.ts +11 -2
  177. package/src/server/human-presence.ts +160 -0
  178. package/src/server/index.ts +111 -6
  179. package/src/server/intent-registry.ts +13 -0
  180. package/src/server/mutation-history.ts +47 -2
  181. package/src/server/operations/composites.ts +6 -3
  182. package/src/server/operations/http.ts +10 -1
  183. package/src/server/operations/index.ts +4 -0
  184. package/src/server/operations/invoker.ts +33 -6
  185. package/src/server/operations/ops/ax-presence.ts +62 -0
  186. package/src/server/operations/ops/ax-read.ts +8 -0
  187. package/src/server/operations/ops/ax-state.ts +106 -1
  188. package/src/server/operations/ops/ax-timeline.ts +54 -5
  189. package/src/server/operations/ops/ax-work.ts +46 -0
  190. package/src/server/operations/ops/batch.ts +3 -1
  191. package/src/server/operations/ops/edges.ts +88 -2
  192. package/src/server/operations/ops/groups.ts +4 -3
  193. package/src/server/operations/ops/human-presence.ts +35 -0
  194. package/src/server/operations/ops/json-render.ts +11 -1
  195. package/src/server/operations/ops/nodes.ts +77 -16
  196. package/src/server/operations/ops/query.ts +1 -0
  197. package/src/server/operations/ops/snapshots.ts +7 -4
  198. package/src/server/operations/registry.ts +196 -1
  199. package/src/server/operations/types.ts +1 -1
  200. package/src/server/scope-fence.ts +207 -0
  201. package/src/server/server.ts +177 -79
  202. package/src/server/trace-manager.ts +0 -2
  203. package/src/server/web-artifacts.ts +0 -1
  204. package/src/shared/agent-presence.ts +162 -0
  205. package/src/shared/approval-gates.ts +35 -0
  206. package/src/shared/auto-arrange.ts +121 -13
  207. package/src/shared/ax-kinds.ts +23 -0
  208. package/src/shared/human-presence.ts +30 -0
  209. package/src/shared/iframe-probe.ts +8 -0
  210. package/src/shared/scope-fence.ts +31 -0
  211. package/dist/types/client/canvas/DockedNode.d.ts +0 -4
  212. package/src/client/canvas/DockedNode.tsx +0 -165
@@ -3,6 +3,7 @@
3
3
  * JSON body the HTTP route serves), so MCP/CLI callers format one shape
4
4
  * regardless of transport. Failures throw `OperationError`.
5
5
  */
6
+ import { SOURCE_LABEL_RE } from '../agent-presence.js';
6
7
  import { executeOperation, getOperation } from './registry.js';
7
8
  import { OperationError, type OperationErrorStatus } from './types.js';
8
9
 
@@ -10,26 +11,52 @@ export interface OperationInvoker {
10
11
  invoke(name: string, input: Record<string, unknown>): Promise<unknown>;
11
12
  }
12
13
 
14
+ /**
15
+ * The agent-presence label for a transport: `PMX_CANVAS_AGENT_SOURCE` lets a
16
+ * host name its agent ('codex', 'claude-code', …) so writes, attach and cursor
17
+ * all key on one identity; otherwise the transport's own label.
18
+ */
19
+ export function agentSourceLabel(fallback: string): string {
20
+ const raw = process.env.PMX_CANVAS_AGENT_SOURCE?.trim();
21
+ return raw && SOURCE_LABEL_RE.test(raw) ? raw : fallback;
22
+ }
23
+
24
+ /** `ax.presence.set` without an explicit source attaches under the caller's own label. */
25
+ function defaultPresenceSource(name: string, input: Record<string, unknown>, source: string): Record<string, unknown> {
26
+ return name === 'ax.presence.set' && typeof input.source !== 'string' ? { ...input, source } : input;
27
+ }
28
+
13
29
  /** Runs operations in-process against the shared canvasState singleton. */
14
30
  export class LocalOperationInvoker implements OperationInvoker {
31
+ private readonly source: string;
32
+
33
+ /** `source` labels this caller's agent presence ('mcp', 'sdk', …). */
34
+ constructor(source = 'api') {
35
+ this.source = agentSourceLabel(source);
36
+ }
37
+
15
38
  async invoke(name: string, input: Record<string, unknown>): Promise<unknown> {
16
- return await executeOperation(name, input);
39
+ return await executeOperation(name, defaultPresenceSource(name, input, this.source), { source: this.source });
17
40
  }
18
41
  }
19
42
 
20
43
  function toOperationErrorStatus(status: number): OperationErrorStatus {
21
- return status === 404 ? 404 : status === 409 ? 409 : 400;
44
+ return status === 404 ? 404 : status === 409 ? 409 : status === 403 ? 403 : 400;
22
45
  }
23
46
 
24
47
  /** Builds the HTTP request from the op's route template (`:id` from input, GET flags to query). */
25
48
  export class HttpOperationInvoker implements OperationInvoker {
26
49
  private readonly baseUrl: string;
50
+ private readonly source: string;
27
51
 
28
- constructor(baseUrl: string) {
52
+ /** `source` labels this caller's agent presence on the server ('cli', 'mcp', …). */
53
+ constructor(baseUrl: string, source = 'api') {
29
54
  this.baseUrl = baseUrl.replace(/\/$/, '');
55
+ this.source = agentSourceLabel(source);
30
56
  }
31
57
 
32
- async invoke(name: string, input: Record<string, unknown>): Promise<unknown> {
58
+ async invoke(name: string, rawInput: Record<string, unknown>): Promise<unknown> {
59
+ const input = defaultPresenceSource(name, rawInput, this.source);
33
60
  const op = getOperation(name);
34
61
  const route = op.http;
35
62
  if (!route) throw new OperationError(`Operation "${name}" has no HTTP route.`, 400);
@@ -52,7 +79,7 @@ export class HttpOperationInvoker implements OperationInvoker {
52
79
  }
53
80
 
54
81
  let url = `${this.baseUrl}${path}`;
55
- const init: RequestInit = { method: route.method };
82
+ const init: RequestInit = { method: route.method, headers: { 'x-pmx-source': this.source } };
56
83
  if (route.method === 'GET' || route.method === 'DELETE') {
57
84
  const params = new URLSearchParams();
58
85
  for (const [key, value] of Object.entries(rest)) {
@@ -61,7 +88,7 @@ export class HttpOperationInvoker implements OperationInvoker {
61
88
  const query = params.toString();
62
89
  if (query) url += `?${query}`;
63
90
  } else {
64
- init.headers = { 'Content-Type': 'application/json' };
91
+ init.headers = { 'Content-Type': 'application/json', 'x-pmx-source': this.source };
65
92
  init.body = JSON.stringify(rest);
66
93
  }
67
94
 
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Agent presence operations (rail-chrome-v2 phase 2): the connect-time read
3
+ * and the explicit update for adapters with richer hooks than the derived
4
+ * feed provides (`thinking`, cursor, focus, attach/detach).
5
+ *
6
+ * This module must never import server.ts or index.ts.
7
+ */
8
+ import { z } from 'zod';
9
+ import { agentPresence, SOURCE_LABEL_RE, PRESENCE_SET_SHAPE } from '../../agent-presence.js';
10
+ import { defineOperation, type Operation, type OperationContext } from '../types.js';
11
+
12
+ const emptyShape = {};
13
+ const emptySchema = z.looseObject(emptyShape);
14
+
15
+ const presenceGetOperation = defineOperation<z.infer<typeof emptySchema>, Record<string, unknown>>({
16
+ name: 'ax.presence.get',
17
+ mutates: false,
18
+ input: emptySchema,
19
+ inputShape: emptyShape,
20
+ http: {
21
+ method: 'GET',
22
+ path: '/api/canvas/ax/presence',
23
+ },
24
+ handler: () => ({ ok: true, ...agentPresence.snapshot() }),
25
+ });
26
+
27
+ // One schema (agent-presence.ts) — only the descriptions are added here.
28
+ const presenceSetShape = {
29
+ source: PRESENCE_SET_SHAPE.source.describe('Host label (copilot, codex, mcp, …); defaults to the transport'),
30
+ agentId: PRESENCE_SET_SHAPE.agentId.describe('Per-agent identity within the host (sub-agents keep their own cursor)'),
31
+ label: PRESENCE_SET_SHAPE.label.describe('Display name for this writer'),
32
+ phase: PRESENCE_SET_SHAPE.phase.describe('idle | thinking | tooling | waiting-approval'),
33
+ detail: PRESENCE_SET_SHAPE.detail.describe('Tool / step name shown beside the phase'),
34
+ focusNodeId: PRESENCE_SET_SHAPE.focusNodeId.describe('Node the agent is working on'),
35
+ cursor: PRESENCE_SET_SHAPE.cursor.describe('Agent cursor in world coordinates'),
36
+ attached: PRESENCE_SET_SHAPE.attached.describe('true attaches a session (Focus Session chrome); false detaches it'),
37
+ };
38
+ const presenceSetSchema = z.looseObject(presenceSetShape);
39
+
40
+ const presenceSetOperation = defineOperation<z.infer<typeof presenceSetSchema>, Record<string, unknown>>({
41
+ name: 'ax.presence.set',
42
+ // Presence is not canvas state: no layout event, no history entry, no auto-ghost.
43
+ mutates: false,
44
+ input: presenceSetSchema,
45
+ inputShape: presenceSetShape,
46
+ http: {
47
+ method: 'POST',
48
+ path: '/api/canvas/ax/presence',
49
+ },
50
+ handler: (input, _ctx: OperationContext) => {
51
+ const { source, ...rest } = input;
52
+ // The presence label is a WRITER label, not an AX source enum value: an
53
+ // adapter or an MCP server running with PMX_CANVAS_AGENT_SOURCE=claude-code
54
+ // must attach under the same label its writes carry (SOURCE_LABEL_RE), or
55
+ // the session and the writer split into two cursors.
56
+ const label = typeof source === 'string' && SOURCE_LABEL_RE.test(source) ? source : 'api';
57
+ const presence = agentPresence.set(rest, label);
58
+ return { ok: true, presence, ...agentPresence.snapshot() };
59
+ },
60
+ });
61
+
62
+ export const axPresenceOperations: Operation[] = [presenceGetOperation, presenceSetOperation];
@@ -11,6 +11,7 @@
11
11
  *
12
12
  * This module must never import server.ts or index.ts.
13
13
  */
14
+ import { agentPresence } from '../../agent-presence.js';
14
15
  import { z } from 'zod';
15
16
  import { buildAgentContextPreamble, serializeNodeForAgentContext } from '../../agent-context.js';
16
17
  import { buildCanvasAxContext, buildCanvasAxSurfaceSnapshot } from '../../ax-context.js';
@@ -248,6 +249,13 @@ const activityIngestOperation = defineOperation<z.infer<typeof activitySchema>,
248
249
  if (result.workItem) ctx.emit('ax-state-changed', { workItem: result.workItem });
249
250
  if (result.evidence) ctx.emit('ax-event-created', { evidence: result.evidence });
250
251
  if (result.review) ctx.emit('ax-state-changed', { reviewAnnotation: result.review });
252
+ // Presence is derived from this feed: session-start attaches, session-end
253
+ // detaches, tool-start/-result drive the phase (rail-chrome-v2 phase 2).
254
+ agentPresence.observeActivity(body.kind, {
255
+ source: normalizeAxSource(body.source, 'api'),
256
+ agentId: typeof body.agentId === 'string' ? body.agentId : null,
257
+ title: body.title,
258
+ });
251
259
  return { ok: true, ...result };
252
260
  },
253
261
  });
@@ -140,11 +140,42 @@ const axFocusSetOperation = defineOperation<z.infer<typeof axFocusSetSchema>, Re
140
140
  const axPolicySetShape = {
141
141
  tools: z.unknown().optional().describe('Tool policy patch: allowed/excluded/approvalRequired arrays.'),
142
142
  prompt: z.unknown().optional().describe('Prompt policy patch: systemAppend/mode.'),
143
+ scope: z
144
+ .unknown()
145
+ .optional()
146
+ .describe(
147
+ 'Scope fence (human-owned, set from the workbench): { nodeIds, padding? } limits agent WRITES to those nodes plus padding px around them; null clears it. Agent calls that include it are refused (403) — read policy.scope instead.',
148
+ ),
149
+ agentScopes: z
150
+ .unknown()
151
+ .optional()
152
+ .describe(
153
+ 'Per-agent territories (fleet orchestration): { "<agentId or source>": { nodeIds, padding? } | null }. Each entry overrides the global fence for that writer; null clears that entry. An agent may fence OTHER writers (its workers) but never its own key.',
154
+ ),
143
155
  source: z.unknown().optional().describe('Optional host/source label. Defaults to mcp.'),
144
156
  };
145
157
 
146
158
  const axPolicySetSchema = z.looseObject(axPolicySetShape);
147
159
 
160
+ /**
161
+ * A fence is a granted REGION: fencing a group frame grants everything inside
162
+ * it, so group ids expand to their members (nested groups included). Without
163
+ * this, "Fence to selection" on a frame lets the agent touch the frame only.
164
+ */
165
+ function withGroupMembers(nodeIds: string[]): string[] {
166
+ const out = new Set<string>();
167
+ const visit = (id: string) => {
168
+ if (out.has(id)) return;
169
+ out.add(id);
170
+ const node = canvasState.getNode(id);
171
+ if (node?.type === 'group' && Array.isArray(node.data.children)) {
172
+ for (const child of node.data.children) if (typeof child === 'string') visit(child);
173
+ }
174
+ };
175
+ for (const id of nodeIds) visit(id);
176
+ return [...out];
177
+ }
178
+
148
179
  const axPolicySetOperation = defineOperation<z.infer<typeof axPolicySetSchema>, Record<string, unknown>>({
149
180
  name: 'ax.policy.set',
150
181
  mutates: false,
@@ -167,12 +198,25 @@ const axPolicySetOperation = defineOperation<z.infer<typeof axPolicySetSchema>,
167
198
  })
168
199
  .optional(),
169
200
  prompt: z.object({ systemAppend: z.string().optional(), mode: z.string().optional() }).optional(),
201
+ agentScopes: z
202
+ .record(
203
+ z.string(),
204
+ z.union([z.object({ nodeIds: z.array(z.string()), padding: z.number().optional() }), z.null()]),
205
+ )
206
+ .optional()
207
+ .describe(
208
+ 'Per-agent territories: writer key (agentId or source) → { nodeIds, padding? } | null. The orchestration surface — fence each worker to its lane; you cannot set your own key.',
209
+ ),
170
210
  source: z.enum(AX_SOURCES).optional(),
171
211
  },
172
212
  // Legacy MCP tool forwarded only the present tools/prompt fields (source split out).
213
+ // The GLOBAL scope fence is human-owned (see checkScopeOwnership) — MCP
214
+ // callers are agents, so the tool neither advertises nor forwards `scope`.
215
+ // Per-agent territories ARE the agent surface and forward.
173
216
  buildInput: (input) => ({
174
217
  ...(input.tools ? { tools: input.tools } : {}),
175
218
  ...(input.prompt ? { prompt: input.prompt } : {}),
219
+ ...(input.agentScopes ? { agentScopes: input.agentScopes } : {}),
176
220
  source: normalizeAxSource(input.source, 'mcp'),
177
221
  }),
178
222
  formatResult: (result) => {
@@ -183,11 +227,72 @@ const axPolicySetOperation = defineOperation<z.infer<typeof axPolicySetSchema>,
183
227
  },
184
228
  },
185
229
  handler: (input, ctx) => {
186
- const patch: { tools?: Partial<PmxAxPolicy['tools']>; prompt?: Partial<PmxAxPolicy['prompt']> } = {};
230
+ const patch: {
231
+ tools?: Partial<PmxAxPolicy['tools']>;
232
+ prompt?: Partial<PmxAxPolicy['prompt']>;
233
+ scope?: { nodeIds: string[]; padding?: number } | null;
234
+ agentScopes?: Record<string, { nodeIds: string[]; padding?: number } | null>;
235
+ } = {};
187
236
  if (isRecord(input.tools)) patch.tools = input.tools as Partial<PmxAxPolicy['tools']>;
188
237
  if (isRecord(input.prompt)) patch.prompt = input.prompt as Partial<PmxAxPolicy['prompt']>;
238
+ if (isRecord(input.agentScopes)) {
239
+ const territories: Record<string, { nodeIds: string[]; padding?: number } | null> = {};
240
+ for (const [key, value] of Object.entries(input.agentScopes)) {
241
+ if (value === null) {
242
+ territories[key] = null;
243
+ continue;
244
+ }
245
+ if (!isRecord(value)) continue;
246
+ const ids = withGroupMembers(
247
+ Array.isArray(value.nodeIds) ? value.nodeIds.filter((id): id is string => typeof id === 'string') : [],
248
+ );
249
+ const pad = Number(value.padding);
250
+ territories[key] = ids.length > 0 ? { nodeIds: ids, ...(Number.isFinite(pad) ? { padding: pad } : {}) } : null;
251
+ }
252
+ patch.agentScopes = territories;
253
+ }
254
+ if (input.scope === null) patch.scope = null;
255
+ else if (isRecord(input.scope)) {
256
+ const nodeIds = withGroupMembers(
257
+ Array.isArray(input.scope.nodeIds)
258
+ ? input.scope.nodeIds.filter((id): id is string => typeof id === 'string')
259
+ : [],
260
+ );
261
+ const padding = Number(input.scope.padding);
262
+ patch.scope = nodeIds.length > 0 ? { nodeIds, ...(Number.isFinite(padding) ? { padding } : {}) } : null;
263
+ }
189
264
  const policy = canvasState.setPolicy(patch, { source: normalizeAxSource(input.source, 'api') });
190
265
  ctx.emit('ax-state-changed', { policy });
266
+ if (patch.agentScopes && Object.keys(patch.agentScopes).length > 0) {
267
+ const parts = Object.entries(patch.agentScopes).map(([key, fence]) =>
268
+ fence ? `${key}: ${fence.nodeIds.length} node${fence.nodeIds.length === 1 ? '' : 's'}` : `${key}: cleared`,
269
+ );
270
+ canvasState.recordAxEvent(
271
+ {
272
+ kind: 'policy',
273
+ summary: `Territories updated — ${parts.join('; ')}.`,
274
+ nodeIds: [],
275
+ data: { policy: 'agent-scopes', keys: Object.keys(patch.agentScopes) },
276
+ },
277
+ { source: normalizeAxSource(input.source, 'api') },
278
+ );
279
+ }
280
+ if (patch.scope !== undefined) {
281
+ // The agent reads the timeline — tell it the fence moved, and why its
282
+ // next write outside it will be refused.
283
+ const event = canvasState.recordAxEvent(
284
+ {
285
+ kind: 'policy',
286
+ summary: policy.scope
287
+ ? `Scope fence set: agent writes limited to ${policy.scope.nodeIds.length} node${policy.scope.nodeIds.length === 1 ? '' : 's'} (reads unaffected).`
288
+ : 'Scope fence cleared: the agent may write anywhere on the board.',
289
+ nodeIds: policy.scope?.nodeIds ?? [],
290
+ data: { policy: 'scope-fence', nodeIds: policy.scope?.nodeIds ?? [] },
291
+ },
292
+ { source: normalizeAxSource(input.source, 'browser') },
293
+ );
294
+ ctx.emit('ax-event-created', { event });
295
+ }
191
296
  return { ok: true, policy } as unknown as Record<string, unknown>;
192
297
  },
193
298
  });
@@ -46,9 +46,11 @@
46
46
  * This module must never import server.ts or index.ts.
47
47
  */
48
48
  import { z } from 'zod';
49
+ import { agentPresence } from '../../agent-presence.js';
49
50
  import { canvasState } from '../../canvas-state.js';
50
51
  import { buildPendingAxActivity, isAxEventKind, isAxEvidenceKind } from '../../ax-state.js';
51
52
  import type { PmxAxEventKind, PmxAxEvidenceKind } from '../../ax-state.js';
53
+ import { waitForAxCondition } from '../../ax-wait.js';
52
54
  import { defineOperation, OperationError, type Operation } from '../types.js';
53
55
  import { isRecord } from './nodes.js';
54
56
  import {
@@ -238,6 +240,10 @@ const axSteerOperation = defineOperation<z.infer<typeof axSteerSchema>, Record<s
238
240
  ...(typeof input.target === 'string' ? { target: input.target } : {}),
239
241
  });
240
242
  ctx.emit('ax-event-created', { steering });
243
+ // Roster rows show each steerable writer's unclaimed-queue depth — a new
244
+ // steer changes that count even when the sender is the human (whose write
245
+ // never touches agent presence), so re-emit the snapshot explicitly.
246
+ agentPresence.refresh();
241
247
  return { ok: true, steering } as unknown as Record<string, unknown>;
242
248
  },
243
249
  });
@@ -283,6 +289,10 @@ const axDeliveryPendingShape = {
283
289
  consumer: z.unknown().optional().describe('Consumer/source label to exclude from results (e.g. copilot, mcp).'),
284
290
  limit: z.unknown().optional().describe('Max steering messages to return.'),
285
291
  order: z.unknown().optional().describe('"oldest" (FIFO, default) or "newest" first.'),
292
+ timeoutMs: z
293
+ .unknown()
294
+ .optional()
295
+ .describe('Max ms to block until steering arrives (0/omitted = immediate read; capped at 120000).'),
286
296
  };
287
297
 
288
298
  const axDeliveryPendingSchema = z.looseObject(axDeliveryPendingShape);
@@ -295,11 +305,22 @@ const axDeliveryPendingOperation = defineOperation<z.infer<typeof axDeliveryPend
295
305
  http: {
296
306
  method: 'GET',
297
307
  path: '/api/canvas/ax/delivery/pending',
308
+ // `?waitMs` long-polls like the gate awaits: park until steering for this
309
+ // consumer arrives or the timeout elapses. Custom readInput replaces the
310
+ // default query merge, so re-read the standard params here too.
311
+ readInput: (_req, params, url) => {
312
+ const raw = Number(url.searchParams.get('waitMs') ?? '');
313
+ return {
314
+ ...Object.fromEntries(url.searchParams),
315
+ ...params,
316
+ timeoutMs: Number.isFinite(raw) && raw > 0 ? raw : 0,
317
+ };
318
+ },
298
319
  },
299
320
  mcp: {
300
321
  toolName: 'canvas_claim_ax_delivery',
301
322
  description:
302
- 'Claim pending PMX AX deliveries for a consumer (adapterless delivery). Returns `pending` undelivered steering (mark each with canvas_mark_ax_delivery after acting) AND `pendingActivity`: open canvas-bound AX items awaiting the agent (open work items, pending approval gates / elicitations / mode requests) — typically created by the human in the browser. Both exclude items the consumer itself originated (loop prevention). `pending` defaults to oldest-first (FIFO, for ordered processing); pass `order:"newest"` to surface the human\'s LATEST in-canvas steering first when a small `limit` would otherwise bury it behind a stale backlog (report #68). pendingActivity is read-only here: resolve each via its own tool (canvas_resolve_approval / canvas_respond_elicitation / canvas_resolve_mode / canvas_update_work_item), not canvas_mark_ax_delivery.',
323
+ 'Claim pending PMX AX deliveries for a consumer (adapterless delivery). Returns `pending` undelivered steering (mark each with canvas_mark_ax_delivery after acting) AND `pendingActivity`: open canvas-bound AX items awaiting the agent (open work items, pending approval gates / elicitations / mode requests) — typically created by the human in the browser. Both exclude items the consumer itself originated (loop prevention). `pending` defaults to oldest-first (FIFO, for ordered processing); pass `order:"newest"` to surface the human\'s LATEST in-canvas steering first when a small `limit` would otherwise bury it behind a stale backlog (report #68). pendingActivity is read-only here: resolve each via its own tool (canvas_resolve_approval / canvas_respond_elicitation / canvas_resolve_mode / canvas_update_work_item), not canvas_mark_ax_delivery. Pass `timeoutMs` to LONG-POLL: the call blocks until steering for the consumer arrives or the timeout elapses (max 120000) — loop on this to react to canvas steering promptly without burning your turn on tight polls.',
303
324
  extraShape: {
304
325
  consumer: z.string().optional().describe('Consumer/source label to exclude from results (e.g. copilot, mcp).'),
305
326
  limit: z.number().optional().describe('Max steering messages to return.'),
@@ -309,12 +330,21 @@ const axDeliveryPendingOperation = defineOperation<z.infer<typeof axDeliveryPend
309
330
  .describe(
310
331
  'Order of returned steering: "oldest" (FIFO, default) for ordered processing, or "newest" first to see the latest browser action when limited.',
311
332
  ),
333
+ timeoutMs: z
334
+ .number()
335
+ .min(0)
336
+ .max(120000)
337
+ .optional()
338
+ .describe('Max ms to block until steering arrives (0/omitted = immediate read).'),
312
339
  },
313
340
  // `consumer` is a loop-safety scope, not a source label — never defaulted.
314
341
  formatResult: axJsonResult,
315
342
  },
316
- handler: (input) => {
343
+ handler: async (input) => {
317
344
  const consumer = typeof input.consumer === 'string' ? input.consumer : undefined;
345
+ // A claim is proof this consumer polls steering — its presence (session or
346
+ // external writer) becomes a legitimate composer steer target.
347
+ if (consumer) agentPresence.noteSteeringConsumer(consumer);
318
348
  const limitRaw = Number(input.limit ?? '');
319
349
  const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? limitRaw : undefined;
320
350
  // #68: default FIFO (oldest-first) for ordered processing; `order:"newest"`
@@ -323,7 +353,18 @@ const axDeliveryPendingOperation = defineOperation<z.infer<typeof axDeliveryPend
323
353
  // queries apply the same loop-safe consumer filter before the limit.
324
354
  const newest = input.order === 'newest';
325
355
  const scope = { ...(consumer ? { consumer } : {}), ...(limit ? { limit } : {}) };
326
- const pending = newest ? canvasState.getPendingSteeringForContext(scope) : canvasState.getPendingSteering(scope);
356
+ const readPending = () =>
357
+ newest ? canvasState.getPendingSteeringForContext(scope) : canvasState.getPendingSteering(scope);
358
+ let pending = readPending();
359
+ const timeoutRaw = Number(input.timeoutMs ?? '');
360
+ const timeoutMs = Number.isFinite(timeoutRaw) && timeoutRaw > 0 ? timeoutRaw : 0;
361
+ if (pending.length === 0 && timeoutMs > 0) {
362
+ // Long-poll: hosts that cannot be woken from outside (Copilot, Codex —
363
+ // their model runs only while the host gives it a turn) park here and
364
+ // wake the moment steering lands, instead of burning the turn polling.
365
+ await waitForAxCondition({ ready: () => readPending().length > 0, timeoutMs });
366
+ pending = readPending();
367
+ }
327
368
  // The MCP tool aggregated pendingActivity; one wire body now serves it over
328
369
  // HTTP too (documented broadening). Loop-safe: consumer scopes both queries.
329
370
  const pendingActivity = buildPendingAxActivity(canvasState.getAxState(), consumer);
@@ -335,6 +376,12 @@ const axDeliveryPendingOperation = defineOperation<z.infer<typeof axDeliveryPend
335
376
 
336
377
  const axDeliveryMarkShape = {
337
378
  id: z.string().optional().catch(undefined).describe('The steering message id to mark delivered.'),
379
+ consumer: z
380
+ .unknown()
381
+ .optional()
382
+ .describe(
383
+ 'The marking consumer. Broadcasts are delivered PER consumer — pass your label so the message stays pending for the other agents.',
384
+ ),
338
385
  };
339
386
 
340
387
  const axDeliveryMarkSchema = z.looseObject(axDeliveryMarkShape);
@@ -351,15 +398,17 @@ const axDeliveryMarkOperation = defineOperation<z.infer<typeof axDeliveryMarkSch
351
398
  mcp: {
352
399
  toolName: 'canvas_mark_ax_delivery',
353
400
  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.',
401
+ 'Mark a PMX AX steering message as delivered so it is not handed out again. ADDRESSED steers: compare-and-set — `delivered:true` only on the actual undelivered→delivered transition. BROADCASTS are delivered PER consumer: pass `consumer` and your mark removes it from YOUR queue only (every other agent still receives it) — an "all workers: stop" reaches the whole fleet. A false means the id was unknown or you (or, for addressed steers, someone) already marked it.',
355
402
  extraShape: {
356
403
  id: z.string().describe('The steering message id to mark delivered.'),
404
+ consumer: z.string().optional().describe('Your consumer label — required for per-consumer broadcast delivery.'),
357
405
  },
358
406
  formatResult: axJsonResult,
359
407
  },
360
408
  handler: (input, ctx) => {
361
409
  const id = typeof input.id === 'string' ? input.id : '';
362
- const delivered = canvasState.markSteeringDelivered(id);
410
+ const consumer = typeof input.consumer === 'string' ? input.consumer : undefined;
411
+ const delivered = canvasState.markSteeringDelivered(id, consumer);
363
412
  // Legacy handleAxDeliveryMark only broadcasts when a message was marked.
364
413
  if (delivered) {
365
414
  ctx.emit('ax-event-created', { steeringDelivered: id });
@@ -378,6 +378,10 @@ const axApprovalRequestShape = {
378
378
  detail: z.unknown().optional().describe('Optional explanation of the action and its impact.'),
379
379
  action: z.unknown().optional().describe('Optional machine-readable action identifier the approval gates.'),
380
380
  nodeIds: z.unknown().optional().describe('Optional node IDs this approval relates to.'),
381
+ ttlMs: z
382
+ .unknown()
383
+ .optional()
384
+ .describe('Unattended-approval TTL in ms; the gate auto-holds if unanswered (default 5 min, max 24 h).'),
381
385
  source: z.unknown().optional().describe('Optional host/source label. Defaults to mcp.'),
382
386
  };
383
387
 
@@ -401,6 +405,10 @@ const axApprovalRequestOperation = defineOperation<z.infer<typeof axApprovalRequ
401
405
  detail: z.string().optional().describe('Optional explanation of the action and its impact.'),
402
406
  action: z.string().optional().describe('Optional machine-readable action identifier the approval gates.'),
403
407
  nodeIds: z.array(z.string()).optional().describe('Optional node IDs this approval relates to.'),
408
+ ttlMs: z
409
+ .number()
410
+ .optional()
411
+ .describe('Unattended-approval TTL in ms; the gate auto-holds if unanswered (default 5 min, max 24 h).'),
404
412
  source: AX_SOURCE_SHAPE,
405
413
  },
406
414
  buildInput: (input) => ({ ...input, source: normalizeAxSource(input.source, 'mcp') }),
@@ -410,12 +418,14 @@ const axApprovalRequestOperation = defineOperation<z.infer<typeof axApprovalRequ
410
418
  if (typeof input.title !== 'string' || !input.title.trim()) {
411
419
  throw new OperationError('approval request requires a title.');
412
420
  }
421
+ const ttlMs = Number(input.ttlMs);
413
422
  const approvalGate = canvasState.requestApproval(
414
423
  {
415
424
  title: input.title,
416
425
  ...(typeof input.detail === 'string' ? { detail: input.detail } : {}),
417
426
  ...(typeof input.action === 'string' ? { action: input.action } : {}),
418
427
  ...(Array.isArray(input.nodeIds) ? { nodeIds: normalizeAxNodeIds(input.nodeIds) } : {}),
428
+ ...(Number.isFinite(ttlMs) && ttlMs > 0 ? { ttlMs } : {}),
419
429
  },
420
430
  { source: normalizeAxSource(input.source, 'api') },
421
431
  );
@@ -424,6 +434,41 @@ const axApprovalRequestOperation = defineOperation<z.infer<typeof axApprovalRequ
424
434
  },
425
435
  });
426
436
 
437
+ // ── ax.approval.reopen (session panel; human-facing) ──────────
438
+
439
+ const axApprovalReopenShape = {
440
+ id: z.unknown().optional().describe('Approval gate id.'),
441
+ ttlMs: z.unknown().optional().describe('Fresh TTL in ms (default 5 min).'),
442
+ source: z.unknown().optional().describe('Optional host/source label.'),
443
+ };
444
+ const axApprovalReopenSchema = z.looseObject(axApprovalReopenShape);
445
+
446
+ /**
447
+ * Reopen a resolved gate — the unattended-approval policy's "auto-held" exit.
448
+ * HTTP + SDK only: reopening is the HUMAN answering late, not an agent op.
449
+ */
450
+ const axApprovalReopenOperation = defineOperation<z.infer<typeof axApprovalReopenSchema>, Record<string, unknown>>({
451
+ name: 'ax.approval.reopen',
452
+ mutates: false,
453
+ input: axApprovalReopenSchema,
454
+ inputShape: axApprovalReopenShape,
455
+ http: {
456
+ method: 'POST',
457
+ path: '/api/canvas/ax/approval/:id/reopen',
458
+ },
459
+ handler: (input, ctx) => {
460
+ const id = typeof input.id === 'string' ? input.id : '';
461
+ const ttlMs = Number(input.ttlMs);
462
+ const approvalGate = canvasState.reopenApproval(id, {
463
+ ...(Number.isFinite(ttlMs) && ttlMs > 0 ? { ttlMs } : {}),
464
+ source: normalizeAxSource(input.source, 'browser'),
465
+ });
466
+ if (!approvalGate) throw new OperationError(`Approval gate "${id}" is not resolved (or does not exist).`, 404);
467
+ ctx.emit('ax-state-changed', { approvalGate });
468
+ return { ok: true, approvalGate } as unknown as Record<string, unknown>;
469
+ },
470
+ });
471
+
427
472
  // ── ax.approval.resolve (canvas_resolve_approval) ─────────────
428
473
 
429
474
  const axApprovalResolveShape = {
@@ -676,6 +721,7 @@ export const axWorkOperations: Operation[] = [
676
721
  axReviewUpdateOperation,
677
722
  axApprovalRequestOperation,
678
723
  axApprovalResolveOperation,
724
+ axApprovalReopenOperation,
679
725
  axElicitationRequestOperation,
680
726
  axElicitationRespondOperation,
681
727
  axModeRequestOperation,
@@ -239,7 +239,9 @@ async function runBatch(operations: BatchEntry[]): Promise<BatchEnvelope> {
239
239
  }
240
240
  const dispatch = resolveDispatch(operation.op, args);
241
241
  // Batch churn is exempt from auto-ghosts (matches the skill's explicit
242
- // ghost-cursor exemption for batch operations).
242
+ // ghost-cursor exemption for batch operations). No `fromWorkbench`: the
243
+ // browser never issues batches, so inner writes are agent writes and the
244
+ // scope fence applies to each of them.
243
245
  const raw = await executeOperation(dispatch.name, dispatch.args, { suppressAutoGhost: true });
244
246
  const result = shapeBatchEntry(operation.op, raw);
245
247
  results.push(result);
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import { z } from 'zod';
7
7
  import type { CanvasEdge } from '../../canvas-state.js';
8
- import { addCanvasEdge, removeCanvasEdge } from '../../canvas-operations.js';
8
+ import { addCanvasEdge, removeCanvasEdge, updateCanvasEdge } from '../../canvas-operations.js';
9
9
  import { readJsonValue } from '../http.js';
10
10
  import { defineOperation, OperationError, type Operation } from '../types.js';
11
11
  import { isRecord } from './nodes.js';
@@ -124,6 +124,92 @@ const edgeAddOperation = defineOperation<z.infer<typeof edgeAddSchema>, CanvasEd
124
124
  serialize: (edge) => ({ ok: true, ...edge }),
125
125
  });
126
126
 
127
+ // ── edge.update ───────────────────────────────────────────────
128
+
129
+ const edgeUpdateShape = {
130
+ id: z.string().optional().catch(undefined).describe('Edge ID to update'),
131
+ type: z.unknown().optional().describe('New edge type: flow, depends-on, relation, or references'),
132
+ label: z.unknown().optional().describe('New label text; empty string or null clears the label'),
133
+ style: z.unknown().optional().describe('New stroke style: solid, dashed, or dotted'),
134
+ animated: z.unknown().optional().describe('Animate the edge stroke'),
135
+ };
136
+
137
+ const edgeUpdateSchema = z.looseObject(edgeUpdateShape);
138
+
139
+ const edgeUpdateOperation = defineOperation<z.infer<typeof edgeUpdateSchema>, CanvasEdge>({
140
+ name: 'edge.update',
141
+ mutates: true,
142
+ input: edgeUpdateSchema,
143
+ inputShape: edgeUpdateShape,
144
+ http: {
145
+ method: 'PATCH',
146
+ path: '/api/canvas/edge/:id',
147
+ },
148
+ mcp: {
149
+ toolName: 'canvas_update_edge',
150
+ description: 'Update an existing edge in place: change its label, type, stroke style, or animation.',
151
+ extraShape: {
152
+ id: z.string().describe('Edge ID to update'),
153
+ type: z.enum(['flow', 'depends-on', 'relation', 'references']).optional().describe('New edge type'),
154
+ label: z.string().nullable().optional().describe('New label text; empty string or null clears the label'),
155
+ style: z.enum(['solid', 'dashed', 'dotted']).optional().describe('New stroke style'),
156
+ animated: z.boolean().optional().describe('Animate the edge stroke'),
157
+ },
158
+ formatResult: (result) => {
159
+ const body = isRecord(result) ? result : {};
160
+ return {
161
+ content: [
162
+ {
163
+ type: 'text' as const,
164
+ text: JSON.stringify(
165
+ { id: body.id, type: body.type, label: body.label, style: body.style, animated: body.animated },
166
+ null,
167
+ 2,
168
+ ),
169
+ },
170
+ ],
171
+ };
172
+ },
173
+ },
174
+ handler: (input) => {
175
+ const body: Record<string, unknown> = input;
176
+ const id = typeof body.id === 'string' ? body.id : '';
177
+ if (!id) {
178
+ throw new OperationError('Missing required field: id.');
179
+ }
180
+ const patch: Partial<Pick<CanvasEdge, 'type' | 'label' | 'style' | 'animated'>> = {};
181
+ if ('type' in body && body.type !== undefined) {
182
+ if (typeof body.type !== 'string' || !VALID_EDGE_TYPES.has(body.type)) {
183
+ throw new OperationError(`Invalid edge type: "${String(body.type)}".`);
184
+ }
185
+ patch.type = body.type as CanvasEdge['type'];
186
+ }
187
+ if ('label' in body) {
188
+ // Empty string / null clears the label; the state layer drops undefined.
189
+ patch.label = typeof body.label === 'string' && body.label.length > 0 ? body.label : undefined;
190
+ }
191
+ if ('style' in body && body.style !== undefined) {
192
+ if (typeof body.style !== 'string' || !VALID_EDGE_STYLES.has(body.style)) {
193
+ throw new OperationError(`Invalid edge style: "${String(body.style)}". Use solid, dashed, or dotted.`);
194
+ }
195
+ patch.style = body.style as CanvasEdge['style'];
196
+ }
197
+ if ('animated' in body && body.animated !== undefined) {
198
+ patch.animated = Boolean(body.animated);
199
+ }
200
+ if (Object.keys(patch).length === 0) {
201
+ throw new OperationError('Nothing to update: provide type, label, style, or animated.');
202
+ }
203
+ try {
204
+ return updateCanvasEdge(id, patch);
205
+ } catch (error) {
206
+ const message = error instanceof Error ? error.message : 'Edge update failed.';
207
+ throw new OperationError(message, message.includes('not found') ? 404 : 400);
208
+ }
209
+ },
210
+ serialize: (edge) => ({ ok: true, ...edge }),
211
+ });
212
+
127
213
  // ── edge.remove ───────────────────────────────────────────────
128
214
 
129
215
  const edgeRemoveShape = {
@@ -176,4 +262,4 @@ const edgeRemoveOperation = defineOperation<z.infer<typeof edgeRemoveSchema>, Re
176
262
  },
177
263
  });
178
264
 
179
- export const edgeOperations: Operation[] = [edgeAddOperation, edgeRemoveOperation];
265
+ export const edgeOperations: Operation[] = [edgeAddOperation, edgeUpdateOperation, edgeRemoveOperation];