pmx-canvas 0.6.5 → 0.7.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 (181) hide show
  1. package/.github/extensions/pmx-canvas/extension.mjs +139 -14
  2. package/CHANGELOG.md +70 -0
  3. package/Readme.md +32 -6
  4. package/dist/canvas/global.css +510 -31
  5. package/dist/canvas/index.js +92 -77
  6. package/dist/types/cli/agent.d.ts +3 -0
  7. package/dist/types/cli/commands/backup.d.ts +1 -0
  8. package/dist/types/cli/commands/boards.d.ts +1 -0
  9. package/dist/types/cli/commands/export.d.ts +1 -0
  10. package/dist/types/cli/daemon.d.ts +5 -5
  11. package/dist/types/cli/shared.d.ts +3 -1
  12. package/dist/types/client/canvas/BoardCopyDialog.d.ts +5 -0
  13. package/dist/types/client/canvas/BoardFolderDialog.d.ts +7 -0
  14. package/dist/types/client/canvas/BoardLinkDialog.d.ts +3 -0
  15. package/dist/types/client/canvas/BoardSwitcher.d.ts +8 -0
  16. package/dist/types/client/canvas/ExportDialog.d.ts +7 -0
  17. package/dist/types/client/canvas/HomeView.d.ts +9 -0
  18. package/dist/types/client/nodes/AttachmentNode.d.ts +6 -0
  19. package/dist/types/client/nodes/BoardNode.d.ts +5 -0
  20. package/dist/types/client/state/boards-store.d.ts +79 -0
  21. package/dist/types/client/state/canvas-store.d.ts +2 -0
  22. package/dist/types/client/state/intent-bridge.d.ts +1 -0
  23. package/dist/types/client/state/session-store.d.ts +7 -0
  24. package/dist/types/client/types.d.ts +2 -2
  25. package/dist/types/json-render/catalog.d.ts +1 -1
  26. package/dist/types/json-render/charts/definitions.d.ts +1 -1
  27. package/dist/types/json-render/server.d.ts +6 -0
  28. package/dist/types/mcp/canvas-access.d.ts +10 -3
  29. package/dist/types/mcp/context-uri-template.d.ts +6 -0
  30. package/dist/types/server/agent-presence.d.ts +1 -1
  31. package/dist/types/server/attribution.d.ts +11 -0
  32. package/dist/types/server/ax-context.d.ts +3 -1
  33. package/dist/types/server/ax-interaction.d.ts +2 -2
  34. package/dist/types/server/ax-state.d.ts +13 -3
  35. package/dist/types/server/backup.d.ts +36 -0
  36. package/dist/types/server/board-export.d.ts +45 -0
  37. package/dist/types/server/canvas-db.d.ts +63 -9
  38. package/dist/types/server/canvas-operations.d.ts +23 -0
  39. package/dist/types/server/canvas-provenance.d.ts +1 -1
  40. package/dist/types/server/canvas-state.d.ts +140 -3
  41. package/dist/types/server/context-brief.d.ts +111 -0
  42. package/dist/types/server/context-reads.d.ts +55 -0
  43. package/dist/types/server/document-import.d.ts +66 -0
  44. package/dist/types/server/index.d.ts +103 -4
  45. package/dist/types/server/mutation-history.d.ts +1 -0
  46. package/dist/types/server/operations/invoker.d.ts +2 -0
  47. package/dist/types/server/operations/mcp.d.ts +7 -1
  48. package/dist/types/server/operations/ops/ax-shared.d.ts +2 -2
  49. package/dist/types/server/operations/ops/backup.d.ts +2 -0
  50. package/dist/types/server/operations/ops/boards.d.ts +10 -0
  51. package/dist/types/server/operations/ops/export.d.ts +2 -0
  52. package/dist/types/server/operations/ops/imports.d.ts +2 -0
  53. package/dist/types/server/operations/ops/nodes.d.ts +1 -1
  54. package/dist/types/server/operations/ops/query.d.ts +2 -0
  55. package/dist/types/server/operations/registry.d.ts +9 -1
  56. package/dist/types/server/operations/types.d.ts +1 -1
  57. package/dist/types/server/server.d.ts +1 -0
  58. package/dist/types/server/trace-manager.d.ts +7 -0
  59. package/dist/types/server/webpage-node.d.ts +22 -1
  60. package/dist/types/server/workbench-auth.d.ts +3 -0
  61. package/dist/types/shared/boards.d.ts +6 -0
  62. package/docs/RELEASE.md +5 -0
  63. package/docs/cli.md +73 -0
  64. package/docs/http-api.md +198 -2
  65. package/docs/mcp.md +71 -6
  66. package/docs/node-types.md +26 -0
  67. package/docs/plans/plan-010-attributed-ask-answers.md +39 -0
  68. package/docs/plans/plan-011-context-read-instrumentation.md +54 -0
  69. package/docs/plans/plan-012-many-boards-stage-1.md +98 -0
  70. package/docs/plans/plan-013-static-board-export.md +47 -0
  71. package/docs/plans/plan-014-connected-board-memory.md +140 -0
  72. package/docs/product-vision-2026-09.md +118 -18
  73. package/docs/sdk.md +50 -0
  74. package/package.json +1 -1
  75. package/skills/pmx-canvas/SKILL.md +68 -11
  76. package/skills/pmx-canvas/evals/evals.json +130 -0
  77. package/skills/pmx-canvas/references/full-reference.md +43 -7
  78. package/skills/pmx-canvas/references/github-copilot-app-adapter.md +3 -0
  79. package/skills/pmx-canvas/references/installing-pmx-canvas.md +4 -4
  80. package/skills/pmx-canvas/references/tours-and-recording.md +69 -0
  81. package/skills/pmx-canvas-orchestration/SKILL.md +5 -0
  82. package/skills/pmx-canvas-testing/SKILL.md +11 -2
  83. package/src/cli/agent.ts +4 -0
  84. package/src/cli/commands/apps.ts +1 -1
  85. package/src/cli/commands/ax.ts +13 -0
  86. package/src/cli/commands/backup.ts +66 -0
  87. package/src/cli/commands/boards.ts +71 -0
  88. package/src/cli/commands/export.ts +37 -0
  89. package/src/cli/commands/nodes.ts +4 -1
  90. package/src/cli/commands/pins.ts +6 -2
  91. package/src/cli/commands/pump.ts +3 -0
  92. package/src/cli/commands/query.ts +33 -7
  93. package/src/cli/commands/view.ts +7 -5
  94. package/src/cli/daemon.ts +37 -7
  95. package/src/cli/index.ts +19 -9
  96. package/src/cli/shared.ts +11 -2
  97. package/src/client/App.tsx +21 -4
  98. package/src/client/canvas/AttentionToast.tsx +5 -3
  99. package/src/client/canvas/BoardCopyDialog.tsx +153 -0
  100. package/src/client/canvas/BoardFolderDialog.tsx +120 -0
  101. package/src/client/canvas/BoardLinkDialog.tsx +93 -0
  102. package/src/client/canvas/BoardSwitcher.tsx +149 -0
  103. package/src/client/canvas/CanvasNode.tsx +0 -1
  104. package/src/client/canvas/CanvasViewport.tsx +3 -0
  105. package/src/client/canvas/CommandPalette.tsx +107 -4
  106. package/src/client/canvas/ContextMenu.tsx +4 -1
  107. package/src/client/canvas/ExpandedNodeOverlay.tsx +3 -0
  108. package/src/client/canvas/ExportDialog.tsx +199 -0
  109. package/src/client/canvas/HomeView.tsx +277 -0
  110. package/src/client/canvas/Presentation.tsx +39 -9
  111. package/src/client/canvas/SelectionBar.tsx +0 -2
  112. package/src/client/canvas/SessionPanel.tsx +28 -1
  113. package/src/client/canvas/ToolRail.tsx +38 -3
  114. package/src/client/canvas/TopBar.tsx +34 -18
  115. package/src/client/canvas/import-files.ts +19 -1
  116. package/src/client/canvas/kind-colors.ts +1 -0
  117. package/src/client/icons.tsx +2 -0
  118. package/src/client/nodes/AttachmentNode.tsx +145 -0
  119. package/src/client/nodes/BoardNode.tsx +35 -0
  120. package/src/client/nodes/FileNode.tsx +6 -0
  121. package/src/client/nodes/MarkdownNode.tsx +47 -19
  122. package/src/client/nodes/ax-node-actions.ts +2 -5
  123. package/src/client/nodes/use-ax-surface-bridge.ts +1 -1
  124. package/src/client/state/attention-bridge.ts +3 -1
  125. package/src/client/state/boards-store.ts +167 -0
  126. package/src/client/state/canvas-store.ts +22 -0
  127. package/src/client/state/intent-bridge.ts +30 -2
  128. package/src/client/state/node-factory.ts +1 -0
  129. package/src/client/state/session-store.ts +5 -0
  130. package/src/client/state/sse-bridge.ts +52 -6
  131. package/src/client/theme/global.css +510 -31
  132. package/src/client/types.ts +2 -0
  133. package/src/json-render/server.ts +16 -9
  134. package/src/mcp/canvas-access.ts +59 -22
  135. package/src/mcp/context-uri-template.ts +28 -0
  136. package/src/mcp/server.ts +122 -15
  137. package/src/server/agent-context.ts +116 -29
  138. package/src/server/attribution.ts +29 -0
  139. package/src/server/ax-context.ts +4 -2
  140. package/src/server/ax-interaction.ts +1 -0
  141. package/src/server/ax-state-manager.ts +72 -3
  142. package/src/server/ax-state.ts +41 -0
  143. package/src/server/backup.ts +178 -0
  144. package/src/server/board-export.ts +686 -0
  145. package/src/server/canvas-db.ts +599 -107
  146. package/src/server/canvas-operations.ts +68 -9
  147. package/src/server/canvas-provenance.ts +1 -0
  148. package/src/server/canvas-schema.ts +12 -0
  149. package/src/server/canvas-state.ts +774 -45
  150. package/src/server/context-brief.ts +372 -0
  151. package/src/server/context-reads.ts +238 -0
  152. package/src/server/demo-state.json +659 -2
  153. package/src/server/document-import.ts +223 -0
  154. package/src/server/index.ts +231 -5
  155. package/src/server/mutation-history.ts +10 -13
  156. package/src/server/operations/composites.ts +26 -4
  157. package/src/server/operations/http.ts +9 -3
  158. package/src/server/operations/index.ts +8 -0
  159. package/src/server/operations/invoker.ts +14 -3
  160. package/src/server/operations/mcp.ts +50 -11
  161. package/src/server/operations/ops/app.ts +5 -0
  162. package/src/server/operations/ops/ax-read.ts +10 -6
  163. package/src/server/operations/ops/ax-timeline.ts +75 -0
  164. package/src/server/operations/ops/backup.ts +134 -0
  165. package/src/server/operations/ops/batch.ts +8 -1
  166. package/src/server/operations/ops/boards.ts +351 -0
  167. package/src/server/operations/ops/export.ts +74 -0
  168. package/src/server/operations/ops/ext-app.ts +4 -0
  169. package/src/server/operations/ops/imports.ts +236 -0
  170. package/src/server/operations/ops/nodes.ts +57 -5
  171. package/src/server/operations/ops/query.ts +206 -9
  172. package/src/server/operations/registry.ts +123 -31
  173. package/src/server/operations/types.ts +1 -1
  174. package/src/server/server.ts +146 -2
  175. package/src/server/spatial-analysis.ts +9 -17
  176. package/src/server/trace-manager.ts +19 -0
  177. package/src/server/web-artifacts.ts +2 -1
  178. package/src/server/webpage-node.ts +173 -13
  179. package/src/server/workbench-auth.ts +11 -0
  180. package/src/shared/boards.ts +18 -0
  181. package/src/shared/node-sizes.ts +1 -0
@@ -29,14 +29,19 @@ function defaultPresenceSource(name: string, input: Record<string, unknown>, sou
29
29
  /** Runs operations in-process against the shared canvasState singleton. */
30
30
  export class LocalOperationInvoker implements OperationInvoker {
31
31
  private readonly source: string;
32
+ private readonly proxiedRead: boolean;
32
33
 
33
34
  /** `source` labels this caller's agent presence ('mcp', 'sdk', …). */
34
35
  constructor(source = 'api') {
35
36
  this.source = agentSourceLabel(source);
37
+ this.proxiedRead = source === 'mcp';
36
38
  }
37
39
 
38
40
  async invoke(name: string, input: Record<string, unknown>): Promise<unknown> {
39
- return await executeOperation(name, defaultPresenceSource(name, input, this.source), { source: this.source });
41
+ return await executeOperation(name, defaultPresenceSource(name, input, this.source), {
42
+ source: this.source,
43
+ proxiedRead: this.proxiedRead,
44
+ });
40
45
  }
41
46
  }
42
47
 
@@ -48,11 +53,13 @@ function toOperationErrorStatus(status: number): OperationErrorStatus {
48
53
  export class HttpOperationInvoker implements OperationInvoker {
49
54
  private readonly baseUrl: string;
50
55
  private readonly source: string;
56
+ private readonly proxiedRead: boolean;
51
57
 
52
58
  /** `source` labels this caller's agent presence on the server ('cli', 'mcp', …). */
53
59
  constructor(baseUrl: string, source = 'api') {
54
60
  this.baseUrl = baseUrl.replace(/\/$/, '');
55
61
  this.source = agentSourceLabel(source);
62
+ this.proxiedRead = source === 'mcp';
56
63
  }
57
64
 
58
65
  async invoke(name: string, rawInput: Record<string, unknown>): Promise<unknown> {
@@ -79,7 +86,11 @@ export class HttpOperationInvoker implements OperationInvoker {
79
86
  }
80
87
 
81
88
  let url = `${this.baseUrl}${path}`;
82
- const init: RequestInit = { method: route.method, headers: { 'x-pmx-source': this.source } };
89
+ const proxyHeaders: Record<string, string> = this.proxiedRead ? { 'x-pmx-proxied-read': '1' } : {};
90
+ const init: RequestInit = {
91
+ method: route.method,
92
+ headers: { 'x-pmx-source': this.source, ...proxyHeaders },
93
+ };
83
94
  if (route.method === 'GET' || route.method === 'DELETE') {
84
95
  const params = new URLSearchParams();
85
96
  for (const [key, value] of Object.entries(rest)) {
@@ -88,7 +99,7 @@ export class HttpOperationInvoker implements OperationInvoker {
88
99
  const query = params.toString();
89
100
  if (query) url += `?${query}`;
90
101
  } else {
91
- init.headers = { 'Content-Type': 'application/json', 'x-pmx-source': this.source };
102
+ init.headers = { 'Content-Type': 'application/json', 'x-pmx-source': this.source, ...proxyHeaders };
92
103
  init.body = JSON.stringify(rest);
93
104
  }
94
105
 
@@ -10,12 +10,59 @@
10
10
  import { z, type ZodRawShape, type ZodTypeAny } from 'zod';
11
11
  import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
12
12
  import { getOperation, listOperations } from './registry.js';
13
- import type { OperationInvoker } from './invoker.js';
13
+ import { agentSourceLabel, type OperationInvoker } from './invoker.js';
14
14
  import { OperationError, type Operation, type OperationMcpToolHost } from './types.js';
15
+ import { CONTEXT_READ_OPS, contextReadFromPayload, type ContextReadInput } from '../context-reads.js';
15
16
  import { compositeFoldedOpNames, compositeToolDefinitions, type CompositeToolDefinition } from './composites.js';
16
17
 
17
18
  export interface OperationToolHost extends OperationMcpToolHost {
18
19
  invoker(): OperationInvoker;
20
+ prepareContextRead(board?: string): Promise<{ boardId: string | null; pinnedNodeIds: string[] }>;
21
+ recordContextRead(read: ContextReadInput): Promise<void>;
22
+ }
23
+
24
+ async function invokeMcpOperation(
25
+ host: OperationToolHost,
26
+ op: Operation,
27
+ opInput: Record<string, unknown>,
28
+ formatInput: Record<string, unknown>,
29
+ consumer: string | null,
30
+ ) {
31
+ const board = typeof opInput.board === 'string' && opInput.board.trim() ? opInput.board.trim() : undefined;
32
+ const readTarget = CONTEXT_READ_OPS.has(op.name) ? await host.prepareContextRead(board) : null;
33
+ if (readTarget?.boardId && 'board' in op.inputShape) {
34
+ opInput = { ...opInput, board: readTarget.boardId };
35
+ formatInput = { ...formatInput, board: readTarget.boardId };
36
+ }
37
+ const result = await host.invoker().invoke(op.name, opInput);
38
+ const formatted = op.mcp?.formatResult
39
+ ? await op.mcp.formatResult(result, formatInput, host)
40
+ : { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }] };
41
+ if (readTarget) {
42
+ const text = formatted.content
43
+ .filter((item): item is Extract<(typeof formatted.content)[number], { type: 'text' }> => item.type === 'text')
44
+ .map((item) => item.text)
45
+ .join('');
46
+ try {
47
+ await host.recordContextRead(
48
+ contextReadFromPayload(
49
+ {
50
+ channel: 'operation',
51
+ resource: op.name,
52
+ source: agentSourceLabel('mcp'),
53
+ consumer,
54
+ agentId: null,
55
+ pinnedNodeIds: readTarget.pinnedNodeIds,
56
+ boardId: readTarget.boardId,
57
+ },
58
+ text,
59
+ ),
60
+ );
61
+ } catch (error) {
62
+ console.error('[pmx-canvas mcp] recording a context read failed:', error);
63
+ }
64
+ }
65
+ return formatted;
19
66
  }
20
67
 
21
68
  export function registerOperationTools(server: McpServer, getHost: () => Promise<OperationToolHost>): void {
@@ -43,11 +90,7 @@ export function registerOperationTools(server: McpServer, getHost: () => Promise
43
90
  try {
44
91
  const host = await getHost();
45
92
  const opInput = tool.buildInput ? tool.buildInput(input) : input;
46
- const result = await host.invoker().invoke(op.name, opInput);
47
- if (tool.formatResult) {
48
- return await tool.formatResult(result, input, host);
49
- }
50
- return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }] };
93
+ return await invokeMcpOperation(host, op, opInput, input, server.server.getClientVersion()?.name ?? null);
51
94
  } catch (error) {
52
95
  return {
53
96
  content: [{ type: 'text' as const, text: error instanceof Error ? error.message : String(error) }],
@@ -261,11 +304,7 @@ export function registerCompositeTools(
261
304
  );
262
305
  }
263
306
  const opInput = op.mcp?.buildInput ? op.mcp.buildInput(rest) : rest;
264
- const result = await host.invoker().invoke(opName, opInput);
265
- if (op.mcp?.formatResult) {
266
- return await op.mcp.formatResult(result, rest, host);
267
- }
268
- return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }] };
307
+ return await invokeMcpOperation(host, op, opInput, rest, server.server.getClientVersion()?.name ?? null);
269
308
  } catch (error) {
270
309
  return {
271
310
  content: [{ type: 'text' as const, text: error instanceof Error ? error.message : String(error) }],
@@ -107,6 +107,7 @@ export async function openMcpAppCore(input: OpenMcpAppCoreInput, ctx: OperationC
107
107
  throw new OperationError(`Node "${input.nodeId}" is not an external app node.`);
108
108
  }
109
109
 
110
+ const boardGeneration = canvasState.boardGeneration;
110
111
  const opened = await openExternalMcpApp({
111
112
  transport: input.transport,
112
113
  toolName: input.toolName,
@@ -114,6 +115,10 @@ export async function openMcpAppCore(input: OpenMcpAppCoreInput, ctx: OperationC
114
115
  ...(input.serverName ? { serverName: input.serverName } : {}),
115
116
  ...(typeof input.timeoutMs === 'number' ? { timeoutMs: input.timeoutMs } : {}),
116
117
  });
118
+ if (canvasState.boardGeneration !== boardGeneration) {
119
+ closeMcpAppSession(opened.sessionId);
120
+ throw new OperationError('The board changed while the MCP app was opening.', 409);
121
+ }
117
122
  const toolCallId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
118
123
  const previousSessionId = targetNode?.data.appSessionId;
119
124
  if (typeof previousSessionId === 'string' && previousSessionId.trim().length > 0) {
@@ -28,6 +28,7 @@ import {
28
28
  import { canvasState, type CanvasNodeState } from '../../canvas-state.js';
29
29
  import { buildCodeGraphSummary } from '../../code-graph.js';
30
30
  import { defineOperation, OperationError, type Operation, type OperationContext } from '../types.js';
31
+ import { readTargetBoard } from './boards.js';
31
32
  import { normalizeAxNodeIds, normalizeAxSource } from './ax-shared.js';
32
33
  import { isRecord } from './nodes.js';
33
34
 
@@ -153,19 +154,22 @@ const axSurfaceSnapshotOperation = defineOperation<z.infer<typeof emptySchema>,
153
154
 
154
155
  // ── pinned-context.get ────────────────────────────────────────
155
156
 
156
- const pinnedContextOperation = defineOperation<z.infer<typeof emptySchema>, Record<string, unknown>>({
157
+ const pinnedContextShape = { board: z.unknown().optional().describe('Board id to read without opening it.') };
158
+ const pinnedContextSchema = z.looseObject(pinnedContextShape);
159
+ const pinnedContextOperation = defineOperation<z.infer<typeof pinnedContextSchema>, Record<string, unknown>>({
157
160
  name: 'pinned-context.get',
158
161
  mutates: false,
159
- input: emptySchema,
160
- inputShape: emptyShape,
162
+ input: pinnedContextSchema,
163
+ inputShape: pinnedContextShape,
161
164
  http: {
162
165
  method: 'GET',
163
166
  path: '/api/canvas/pinned-context',
164
167
  },
165
- handler: () => {
166
- const pinnedIds = Array.from(canvasState.contextPinnedNodeIds);
168
+ handler: ({ board }) => {
169
+ const target = readTargetBoard(board);
170
+ const pinnedIds = Array.from(target.pinnedNodeIds);
167
171
  const nodes = pinnedIds
168
- .map((id) => canvasState.getNode(id))
172
+ .map((id) => target.layout.nodes.find((node) => node.id === id))
169
173
  .filter((node): node is CanvasNodeState => node !== undefined);
170
174
  const preamble =
171
175
  pinnedIds.length > 0 ? buildAgentContextPreamble(nodes, { defaultTextLength: 700, webpageTextLength: 1600 }) : '';
@@ -51,6 +51,7 @@ import { canvasState } from '../../canvas-state.js';
51
51
  import { buildPendingAxActivity, isAxEventKind, isAxEvidenceKind } from '../../ax-state.js';
52
52
  import type { PmxAxEventKind, PmxAxEvidenceKind } from '../../ax-state.js';
53
53
  import { waitForAxCondition } from '../../ax-wait.js';
54
+ import { CONTEXT_READ_CHANNELS } from '../../context-reads.js';
54
55
  import { defineOperation, OperationError, type Operation } from '../types.js';
55
56
  import { isRecord } from './nodes.js';
56
57
  import {
@@ -462,6 +463,78 @@ const axCommandInvokeOperation = defineOperation<z.infer<typeof axCommandInvokeS
462
463
  },
463
464
  });
464
465
 
466
+ // ── ax.reads.list / ax.reads.record (context read instrumentation) ──
467
+
468
+ const axReadsListShape = {
469
+ limit: z.unknown().optional().describe('Max reads to return, newest first (default 50, max 500).'),
470
+ };
471
+ const axReadsListSchema = z.looseObject(axReadsListShape);
472
+
473
+ const axReadsListOperation = defineOperation<z.infer<typeof axReadsListSchema>, Record<string, unknown>>({
474
+ name: 'ax.reads.list',
475
+ mutates: false,
476
+ input: axReadsListSchema,
477
+ inputShape: axReadsListShape,
478
+ http: {
479
+ method: 'GET',
480
+ path: '/api/canvas/ax/context-reads',
481
+ },
482
+ mcp: {
483
+ toolName: 'canvas_get_context_reads',
484
+ description:
485
+ 'Read the context read log: which canvas context each agent read, and which pinned nodes were in what it received. Summary is per consumer.',
486
+ extraShape: {
487
+ limit: z.number().optional().describe('Max reads to return, newest first (default 50, max 500).'),
488
+ },
489
+ formatResult: axJsonResult,
490
+ },
491
+ handler: (input) => {
492
+ const limit = Number(input.limit ?? '');
493
+ return { ok: true, ...canvasState.getContextReads(Number.isFinite(limit) && limit > 0 ? limit : undefined) };
494
+ },
495
+ });
496
+
497
+ const axReadsRecordShape = {
498
+ channel: z.enum(CONTEXT_READ_CHANNELS).describe('How the read reached the agent.'),
499
+ resource: z.string().min(1).describe('Resource URI or operation name that was read.'),
500
+ consumer: z.string().nullable().optional().describe('Reader identity (MCP client name, adapter host).'),
501
+ agentId: z.string().nullable().optional().describe('Agent id when known.'),
502
+ source: z.string().optional().describe('Transport label (default "api").'),
503
+ pinnedNodeIds: z.array(z.string()).optional().describe('Pins at read time; defaults to the current pins.'),
504
+ deliveredNodeIds: z.array(z.string()).describe('Pinned ids present in what the agent received.'),
505
+ bytes: z.number().int().nonnegative().describe('Size of what the agent received.'),
506
+ boardId: z.string().nullable().optional().describe('Board that was read, captured before delivery.'),
507
+ };
508
+ const axReadsRecordSchema = z.object(axReadsRecordShape);
509
+
510
+ /** For proxies (an MCP server attached to this daemon, host adapters) that record the read the agent made. */
511
+ const axReadsRecordOperation = defineOperation<z.infer<typeof axReadsRecordSchema>, Record<string, unknown>>({
512
+ name: 'ax.reads.record',
513
+ mutates: false,
514
+ input: axReadsRecordSchema,
515
+ inputShape: axReadsRecordShape,
516
+ http: {
517
+ method: 'POST',
518
+ path: '/api/canvas/ax/context-reads',
519
+ },
520
+ handler: (input) => {
521
+ const read = canvasState.recordContextRead(
522
+ {
523
+ channel: input.channel,
524
+ resource: input.resource,
525
+ source: input.source ?? 'api',
526
+ consumer: input.consumer ?? null,
527
+ agentId: input.agentId ?? null,
528
+ pinnedNodeIds: input.pinnedNodeIds ?? [...canvasState.contextPinnedNodeIds],
529
+ deliveredNodeIds: input.deliveredNodeIds,
530
+ bytes: input.bytes,
531
+ },
532
+ input.boardId === undefined ? canvasState.activeBoardId : input.boardId,
533
+ );
534
+ return { ok: true, read };
535
+ },
536
+ });
537
+
465
538
  export const axTimelineOperations: Operation[] = [
466
539
  axEventRecordOperation,
467
540
  axEvidenceAddOperation,
@@ -470,4 +543,6 @@ export const axTimelineOperations: Operation[] = [
470
543
  axDeliveryPendingOperation,
471
544
  axDeliveryMarkOperation,
472
545
  axCommandInvokeOperation,
546
+ axReadsListOperation,
547
+ axReadsRecordOperation,
473
548
  ];
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Library backup (plan 012): backup.status / backup.run / backup.schedule /
3
+ * backup.restore. HTTP, CLI and SDK only — backing up and restoring are the
4
+ * human's maintenance, not agent tools, so there is no MCP surface.
5
+ *
6
+ * This module must never import server.ts or index.ts.
7
+ */
8
+ import { z } from 'zod';
9
+ import {
10
+ assertRestorableBackup,
11
+ getBackupStatus,
12
+ parseBackupInterval,
13
+ runBackup,
14
+ setBackupSchedule,
15
+ } from '../../backup.js';
16
+ import { restoreCanvasLibrary } from '../../canvas-operations.js';
17
+ import { canvasState } from '../../canvas-state.js';
18
+ import { defineOperation, OperationError, type Operation } from '../types.js';
19
+ import { boardsPayload } from './boards.js';
20
+
21
+ function requireDatabase(): void {
22
+ if (!canvasState.databasePath) throw new OperationError('Backups need a workspace database.', 409);
23
+ }
24
+
25
+ function positiveInteger(value: unknown, field: string): number | undefined {
26
+ if (value === undefined || value === null || value === '') return undefined;
27
+ const parsed = Number(value);
28
+ if (!Number.isInteger(parsed) || parsed < 1)
29
+ throw new OperationError(`${field} must be a whole number of 1 or more.`);
30
+ return parsed;
31
+ }
32
+
33
+ const statusShape = {};
34
+ const statusSchema = z.looseObject(statusShape);
35
+
36
+ const backupStatusOperation = defineOperation<z.infer<typeof statusSchema>, Record<string, unknown>>({
37
+ name: 'backup.status',
38
+ mutates: false,
39
+ input: statusSchema,
40
+ inputShape: statusShape,
41
+ http: { method: 'GET', path: '/api/canvas/backup' },
42
+ handler: () => {
43
+ requireDatabase();
44
+ return { ok: true, ...getBackupStatus() };
45
+ },
46
+ });
47
+
48
+ const runShape = {
49
+ to: z.unknown().optional().describe('Folder to write the backup to (default: the configured folder)'),
50
+ keep: z.unknown().optional().describe('How many backups to keep in that folder'),
51
+ };
52
+ const runSchema = z.looseObject(runShape);
53
+
54
+ const backupRunOperation = defineOperation<z.infer<typeof runSchema>, Record<string, unknown>>({
55
+ name: 'backup.run',
56
+ mutates: false,
57
+ input: runSchema,
58
+ inputShape: runShape,
59
+ http: { method: 'POST', path: '/api/canvas/backup' },
60
+ handler: (input) => {
61
+ requireDatabase();
62
+ const result = runBackup({
63
+ ...(typeof input.to === 'string' && input.to.trim() ? { folder: input.to.trim() } : {}),
64
+ ...(positiveInteger(input.keep, 'keep') ? { keep: positiveInteger(input.keep, 'keep') } : {}),
65
+ });
66
+ return { ok: true, ...result, status: getBackupStatus() };
67
+ },
68
+ });
69
+
70
+ const scheduleShape = {
71
+ every: z.unknown().optional().describe('Interval like "24h", "30m", "7d"; "off" or null stops scheduled backups'),
72
+ to: z.unknown().optional().describe('Backup folder'),
73
+ keep: z.unknown().optional().describe('How many backups to keep'),
74
+ };
75
+ const scheduleSchema = z.looseObject(scheduleShape);
76
+
77
+ const backupScheduleOperation = defineOperation<z.infer<typeof scheduleSchema>, Record<string, unknown>>({
78
+ name: 'backup.schedule',
79
+ mutates: false,
80
+ input: scheduleSchema,
81
+ inputShape: scheduleShape,
82
+ http: { method: 'POST', path: '/api/canvas/backup/schedule' },
83
+ handler: (input) => {
84
+ requireDatabase();
85
+ const off = input.every === null || input.every === 'off';
86
+ const everyMs = off ? null : parseBackupInterval(String(input.every ?? ''));
87
+ if (!off && everyMs === null) throw new OperationError('every must look like "30m", "24h" or "7d", or be "off".');
88
+ try {
89
+ const status = setBackupSchedule({
90
+ everyMs,
91
+ ...(typeof input.to === 'string' && input.to.trim() ? { folder: input.to.trim() } : {}),
92
+ ...(positiveInteger(input.keep, 'keep') ? { keep: positiveInteger(input.keep, 'keep') } : {}),
93
+ });
94
+ return { ok: true, ...status };
95
+ } catch (error) {
96
+ throw new OperationError(error instanceof Error ? error.message : String(error));
97
+ }
98
+ },
99
+ });
100
+
101
+ const restoreShape = {
102
+ file: z.unknown().optional().describe('Backup file to restore'),
103
+ };
104
+ const restoreSchema = z.looseObject(restoreShape);
105
+
106
+ const backupRestoreOperation = defineOperation<z.infer<typeof restoreSchema>, Record<string, unknown>>({
107
+ name: 'backup.restore',
108
+ mutates: false,
109
+ input: restoreSchema,
110
+ inputShape: restoreShape,
111
+ http: { method: 'POST', path: '/api/canvas/backup/restore' },
112
+ handler: (input, ctx) => {
113
+ requireDatabase();
114
+ const file = typeof input.file === 'string' ? input.file.trim() : '';
115
+ if (!file) throw new OperationError('file is required.');
116
+ try {
117
+ assertRestorableBackup(file);
118
+ } catch (error) {
119
+ throw new OperationError(error instanceof Error ? error.message : String(error));
120
+ }
121
+ const previous = `${canvasState.databasePath}.before-restore`;
122
+ restoreCanvasLibrary(file);
123
+ // `reloaded` tells open tabs to take the board fresh even when its id is unchanged.
124
+ ctx.emit('boards-changed', { ...boardsPayload(), reloaded: true });
125
+ return { ok: true, restored: file, previous, ...boardsPayload() };
126
+ },
127
+ });
128
+
129
+ export const backupOperations: Operation[] = [
130
+ backupStatusOperation,
131
+ backupRunOperation,
132
+ backupScheduleOperation,
133
+ backupRestoreOperation,
134
+ ];
@@ -250,7 +250,14 @@ async function runBatch(operations: BatchEntry[]): Promise<BatchEnvelope> {
250
250
  // ghost-cursor exemption for batch operations). No `fromWorkbench`: the
251
251
  // browser never issues batches, so inner writes are agent writes and the
252
252
  // scope fence applies to each of them.
253
- const raw = await executeOperation(dispatch.name, dispatch.args, { suppressAutoGhost: true });
253
+ const pending = executeOperation(dispatch.name, dispatch.args, { suppressAutoGhost: true });
254
+ // The first write may synchronously open a board from Home. Capture after
255
+ // dispatch, then refuse to continue a partially completed batch elsewhere.
256
+ const boardGeneration = canvasState.boardGeneration;
257
+ const raw = await pending;
258
+ if (canvasState.boardGeneration !== boardGeneration) {
259
+ throw new Error('The board changed while the batch was running.');
260
+ }
254
261
  const result = shapeBatchEntry(operation.op, raw);
255
262
  results.push(result);
256
263
  if (typeof operation.assign === 'string' && operation.assign.trim().length > 0) {