pmx-canvas 0.6.4 → 0.7.0

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 (204) hide show
  1. package/.github/extensions/pmx-canvas/extension.mjs +67 -3
  2. package/CHANGELOG.md +73 -0
  3. package/Readme.md +35 -5
  4. package/dist/canvas/global.css +498 -28
  5. package/dist/canvas/index.js +110 -95
  6. package/dist/canvas/mermaid-entry.js +258 -258
  7. package/dist/types/cli/agent.d.ts +4 -0
  8. package/dist/types/cli/commands/backup.d.ts +1 -0
  9. package/dist/types/cli/commands/boards.d.ts +1 -0
  10. package/dist/types/cli/commands/export.d.ts +1 -0
  11. package/dist/types/cli/commands/record.d.ts +12 -0
  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/canvas/Presentation.d.ts +13 -0
  19. package/dist/types/client/canvas/use-node-drag.d.ts +2 -1
  20. package/dist/types/client/nodes/AttachmentNode.d.ts +6 -0
  21. package/dist/types/client/nodes/BoardNode.d.ts +5 -0
  22. package/dist/types/client/nodes/McpAppNode.d.ts +24 -1
  23. package/dist/types/client/state/boards-store.d.ts +79 -0
  24. package/dist/types/client/state/canvas-store.d.ts +2 -0
  25. package/dist/types/client/state/intent-bridge.d.ts +1 -0
  26. package/dist/types/client/state/presentation.d.ts +3 -0
  27. package/dist/types/client/state/session-store.d.ts +7 -0
  28. package/dist/types/client/types.d.ts +2 -2
  29. package/dist/types/json-render/catalog.d.ts +3 -3
  30. package/dist/types/json-render/charts/definitions.d.ts +1 -1
  31. package/dist/types/json-render/server.d.ts +8 -0
  32. package/dist/types/mcp/canvas-access.d.ts +10 -3
  33. package/dist/types/mcp/context-uri-template.d.ts +6 -0
  34. package/dist/types/server/agent-presence.d.ts +1 -1
  35. package/dist/types/server/attribution.d.ts +11 -0
  36. package/dist/types/server/ax-context.d.ts +3 -1
  37. package/dist/types/server/ax-interaction.d.ts +2 -2
  38. package/dist/types/server/ax-state.d.ts +13 -3
  39. package/dist/types/server/backup.d.ts +36 -0
  40. package/dist/types/server/board-export.d.ts +45 -0
  41. package/dist/types/server/canvas-db.d.ts +65 -9
  42. package/dist/types/server/canvas-operations.d.ts +45 -0
  43. package/dist/types/server/canvas-provenance.d.ts +1 -1
  44. package/dist/types/server/canvas-schema.d.ts +1 -0
  45. package/dist/types/server/canvas-state.d.ts +146 -3
  46. package/dist/types/server/canvas-validation.d.ts +3 -0
  47. package/dist/types/server/context-brief.d.ts +111 -0
  48. package/dist/types/server/context-reads.d.ts +55 -0
  49. package/dist/types/server/document-import.d.ts +66 -0
  50. package/dist/types/server/html-surface.d.ts +1 -1
  51. package/dist/types/server/index.d.ts +107 -4
  52. package/dist/types/server/mutation-history.d.ts +1 -0
  53. package/dist/types/server/operations/invoker.d.ts +2 -0
  54. package/dist/types/server/operations/mcp.d.ts +7 -1
  55. package/dist/types/server/operations/ops/app.d.ts +2 -0
  56. package/dist/types/server/operations/ops/ax-shared.d.ts +2 -2
  57. package/dist/types/server/operations/ops/backup.d.ts +2 -0
  58. package/dist/types/server/operations/ops/boards.d.ts +10 -0
  59. package/dist/types/server/operations/ops/export.d.ts +2 -0
  60. package/dist/types/server/operations/ops/imports.d.ts +2 -0
  61. package/dist/types/server/operations/ops/json-render.d.ts +2 -0
  62. package/dist/types/server/operations/ops/nodes.d.ts +4 -2
  63. package/dist/types/server/operations/ops/query.d.ts +2 -0
  64. package/dist/types/server/operations/registry.d.ts +9 -1
  65. package/dist/types/server/operations/types.d.ts +1 -1
  66. package/dist/types/server/server.d.ts +1 -0
  67. package/dist/types/server/trace-manager.d.ts +7 -0
  68. package/dist/types/server/web-artifacts.d.ts +3 -0
  69. package/dist/types/server/webpage-node.d.ts +22 -1
  70. package/dist/types/server/workbench-auth.d.ts +3 -0
  71. package/dist/types/shared/boards.d.ts +6 -0
  72. package/dist/types/shared/tour.d.ts +72 -0
  73. package/docs/RELEASE.md +5 -0
  74. package/docs/cli.md +157 -0
  75. package/docs/http-api.md +277 -6
  76. package/docs/mcp.md +96 -7
  77. package/docs/node-types.md +106 -0
  78. package/docs/plans/plan-010-attributed-ask-answers.md +39 -0
  79. package/docs/plans/plan-011-context-read-instrumentation.md +54 -0
  80. package/docs/plans/plan-012-many-boards-stage-1.md +98 -0
  81. package/docs/plans/plan-013-static-board-export.md +47 -0
  82. package/docs/plans/plan-014-connected-board-memory.md +140 -0
  83. package/docs/product-vision-2026-09.md +118 -18
  84. package/docs/sdk.md +50 -0
  85. package/package.json +1 -1
  86. package/skills/pmx-canvas/SKILL.md +62 -5
  87. package/skills/pmx-canvas/references/full-reference.md +44 -8
  88. package/skills/pmx-canvas/references/github-copilot-app-adapter.md +3 -0
  89. package/skills/pmx-canvas/references/installing-pmx-canvas.md +4 -4
  90. package/skills/pmx-canvas-orchestration/SKILL.md +5 -0
  91. package/skills/pmx-canvas-testing/SKILL.md +4 -0
  92. package/src/cli/agent.ts +9 -0
  93. package/src/cli/commands/ax.ts +13 -0
  94. package/src/cli/commands/backup.ts +66 -0
  95. package/src/cli/commands/boards.ts +71 -0
  96. package/src/cli/commands/export.ts +30 -0
  97. package/src/cli/commands/nodes.ts +14 -1
  98. package/src/cli/commands/pins.ts +6 -2
  99. package/src/cli/commands/pump.ts +3 -0
  100. package/src/cli/commands/query.ts +33 -7
  101. package/src/cli/commands/record.ts +218 -0
  102. package/src/cli/commands/view.ts +7 -5
  103. package/src/cli/index.ts +21 -9
  104. package/src/client/App.tsx +28 -4
  105. package/src/client/canvas/AgentPresenceLayer.tsx +1 -1
  106. package/src/client/canvas/BoardCopyDialog.tsx +153 -0
  107. package/src/client/canvas/BoardFolderDialog.tsx +120 -0
  108. package/src/client/canvas/BoardLinkDialog.tsx +93 -0
  109. package/src/client/canvas/BoardSwitcher.tsx +149 -0
  110. package/src/client/canvas/CanvasNode.tsx +8 -0
  111. package/src/client/canvas/CanvasViewport.tsx +3 -0
  112. package/src/client/canvas/CommandPalette.tsx +107 -4
  113. package/src/client/canvas/ContextMenu.tsx +4 -0
  114. package/src/client/canvas/ExpandedNodeOverlay.tsx +3 -0
  115. package/src/client/canvas/ExportDialog.tsx +197 -0
  116. package/src/client/canvas/HomeView.tsx +277 -0
  117. package/src/client/canvas/IntentLayer.tsx +9 -2
  118. package/src/client/canvas/Presentation.tsx +108 -0
  119. package/src/client/canvas/SessionPanel.tsx +28 -1
  120. package/src/client/canvas/ToolRail.tsx +38 -3
  121. package/src/client/canvas/TopBar.tsx +49 -7
  122. package/src/client/canvas/import-files.ts +19 -1
  123. package/src/client/canvas/kind-colors.ts +1 -0
  124. package/src/client/canvas/use-node-drag.ts +8 -3
  125. package/src/client/icons.tsx +2 -0
  126. package/src/client/mermaid-entry.ts +39 -0
  127. package/src/client/nodes/AttachmentNode.tsx +148 -0
  128. package/src/client/nodes/BoardNode.tsx +35 -0
  129. package/src/client/nodes/FileNode.tsx +6 -0
  130. package/src/client/nodes/HtmlNode.tsx +12 -13
  131. package/src/client/nodes/MarkdownNode.tsx +47 -19
  132. package/src/client/nodes/McpAppNode.tsx +105 -13
  133. package/src/client/nodes/MermaidNode.tsx +3 -2
  134. package/src/client/nodes/ax-node-actions.ts +2 -5
  135. package/src/client/nodes/use-ax-surface-bridge.ts +1 -1
  136. package/src/client/state/boards-store.ts +167 -0
  137. package/src/client/state/canvas-store.ts +22 -0
  138. package/src/client/state/intent-bridge.ts +30 -2
  139. package/src/client/state/intent-store.ts +5 -1
  140. package/src/client/state/node-factory.ts +1 -0
  141. package/src/client/state/presentation.ts +7 -0
  142. package/src/client/state/session-store.ts +8 -0
  143. package/src/client/state/sse-bridge.ts +52 -5
  144. package/src/client/theme/global.css +498 -28
  145. package/src/client/types.ts +2 -0
  146. package/src/json-render/server.ts +45 -9
  147. package/src/mcp/canvas-access.ts +59 -22
  148. package/src/mcp/context-uri-template.ts +28 -0
  149. package/src/mcp/server.ts +122 -15
  150. package/src/server/agent-context.ts +116 -29
  151. package/src/server/agent-presence.ts +15 -9
  152. package/src/server/attribution.ts +29 -0
  153. package/src/server/ax-context.ts +4 -2
  154. package/src/server/ax-interaction.ts +1 -0
  155. package/src/server/ax-state-manager.ts +72 -3
  156. package/src/server/ax-state.ts +41 -0
  157. package/src/server/backup.ts +178 -0
  158. package/src/server/board-export.ts +686 -0
  159. package/src/server/canvas-db.ts +612 -105
  160. package/src/server/canvas-operations.ts +183 -31
  161. package/src/server/canvas-provenance.ts +1 -0
  162. package/src/server/canvas-schema.ts +22 -0
  163. package/src/server/canvas-state.ts +812 -48
  164. package/src/server/canvas-validation.ts +9 -1
  165. package/src/server/context-brief.ts +372 -0
  166. package/src/server/context-reads.ts +238 -0
  167. package/src/server/demo-state.json +670 -9
  168. package/src/server/document-import.ts +223 -0
  169. package/src/server/html-primitives.ts +199 -91
  170. package/src/server/html-surface.ts +8 -3
  171. package/src/server/index.ts +243 -5
  172. package/src/server/mutation-history.ts +10 -13
  173. package/src/server/operations/composites.ts +29 -5
  174. package/src/server/operations/http.ts +9 -3
  175. package/src/server/operations/index.ts +8 -0
  176. package/src/server/operations/invoker.ts +14 -3
  177. package/src/server/operations/mcp.ts +50 -11
  178. package/src/server/operations/ops/app.ts +15 -0
  179. package/src/server/operations/ops/ax-read.ts +10 -6
  180. package/src/server/operations/ops/ax-timeline.ts +75 -0
  181. package/src/server/operations/ops/backup.ts +134 -0
  182. package/src/server/operations/ops/batch.ts +20 -4
  183. package/src/server/operations/ops/boards.ts +351 -0
  184. package/src/server/operations/ops/export.ts +74 -0
  185. package/src/server/operations/ops/ext-app.ts +4 -0
  186. package/src/server/operations/ops/groups.ts +14 -7
  187. package/src/server/operations/ops/imports.ts +220 -0
  188. package/src/server/operations/ops/json-render.ts +37 -5
  189. package/src/server/operations/ops/nodes.ts +99 -21
  190. package/src/server/operations/ops/query.ts +206 -9
  191. package/src/server/operations/ops/viewport.ts +32 -0
  192. package/src/server/operations/registry.ts +129 -31
  193. package/src/server/operations/types.ts +1 -1
  194. package/src/server/scope-fence.ts +1 -1
  195. package/src/server/server.ts +155 -8
  196. package/src/server/spatial-analysis.ts +9 -17
  197. package/src/server/trace-manager.ts +19 -0
  198. package/src/server/web-artifacts.ts +11 -3
  199. package/src/server/webpage-node.ts +173 -13
  200. package/src/server/workbench-auth.ts +11 -0
  201. package/src/server/workboard.ts +3 -3
  202. package/src/shared/boards.ts +18 -0
  203. package/src/shared/node-sizes.ts +1 -0
  204. package/src/shared/tour.ts +87 -0
@@ -38,14 +38,62 @@ function truncateContextText(text: string, maxLength: number): string {
38
38
  return `${normalized.slice(0, Math.max(0, maxLength - 1)).trimEnd()}…`;
39
39
  }
40
40
 
41
- function stringifyContextValue(value: unknown, maxLength: number): string {
42
- if (typeof value === 'string') return truncateContextText(value, maxLength);
43
- if (value === null || value === undefined) return '';
44
- try {
45
- return truncateContextText(JSON.stringify(value), maxLength);
46
- } catch {
47
- return '';
48
- }
41
+ function record(value: unknown): Record<string, unknown> {
42
+ return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
43
+ }
44
+
45
+ /** Only for semantic values (table rows, ledger entries), never renderer configuration. */
46
+ function describeValues(value: unknown): string {
47
+ if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return String(value);
48
+ if (Array.isArray(value)) return value.map(describeValues).filter(Boolean).join('\n');
49
+ return Object.entries(record(value))
50
+ .map(([key, entry]) => {
51
+ const text = describeValues(entry);
52
+ return text ? `${key}: ${text}` : '';
53
+ })
54
+ .filter(Boolean)
55
+ .join('; ');
56
+ }
57
+
58
+ function textFields(data: Record<string, unknown>, keys: string[]): string {
59
+ return keys
60
+ .map((key) => {
61
+ const value = data[key];
62
+ return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' ? String(value) : '';
63
+ })
64
+ .filter(Boolean)
65
+ .join('\n');
66
+ }
67
+
68
+ function summarizeGraph(data: Record<string, unknown>): string {
69
+ const kind = typeof data.graphType === 'string' ? data.graphType : 'chart';
70
+ const rows = describeValues(data.data);
71
+ return `Chart: ${kind}.\n${textFields(data, ['title', 'beforeLabel', 'afterLabel'])}\n${rows || 'No chart values available.'}`;
72
+ }
73
+
74
+ function summarizeSpec(value: unknown): string {
75
+ const spec = record(value);
76
+ const elements = record(spec.elements);
77
+ const visited = new Set<string>();
78
+ const parts: string[] = [];
79
+ // Read only reachable elements, in their authored order. Bindings/conditions are
80
+ // not evaluated here, so this is explicitly authored content, not a DOM report.
81
+ const visit = (id: string): void => {
82
+ if (visited.has(id)) return;
83
+ visited.add(id);
84
+ const element = record(elements[id]);
85
+ const props = record(element.props);
86
+ const text = textFields(props, ['title', 'description', 'text', 'content', 'label', 'value', 'caption']);
87
+ if (text) parts.push(text);
88
+ if (Array.isArray(props.data)) parts.push(describeValues(props.data));
89
+ if (Array.isArray(element.children)) {
90
+ for (const child of element.children) if (typeof child === 'string') visit(child);
91
+ }
92
+ };
93
+ if (typeof spec.root === 'string') visit(spec.root);
94
+ return parts.length
95
+ ? `Authored surface content (dynamic visibility not evaluated):\n${parts.join('\n')}`
96
+ : 'Interactive surface. No static text available; inspect the rendered surface.';
49
97
  }
50
98
 
51
99
  function summarizeWebpageData(data: Record<string, unknown>, maxLength: number): string {
@@ -81,10 +129,10 @@ function summarizeExtAppInput(toolInput: unknown): string {
81
129
  if (typeof toolInput !== 'object' || Array.isArray(toolInput)) return '';
82
130
  const elements = (toolInput as Record<string, unknown>).elements;
83
131
  if (Array.isArray(elements)) {
84
- return `Diagram elements: ${elements.length}`;
132
+ const labels = elements.map((element) => textFields(record(element), ['text', 'label'])).filter(Boolean);
133
+ return `Diagram elements: ${elements.length}\n${labels.length ? `Diagram labels: ${labels.join('; ')}` : 'No diagram description available.'}`;
85
134
  }
86
- const keys = Object.keys(toolInput as Record<string, unknown>).sort();
87
- return keys.length > 0 ? `Input keys: ${keys.join(', ')}` : '';
135
+ return 'No app content description available.';
88
136
  }
89
137
 
90
138
  function summarizeMcpAppData(data: Record<string, unknown>, maxLength: number): string {
@@ -157,7 +205,7 @@ function summarizeHtmlPrimitiveData(data: Record<string, unknown>, maxLength: nu
157
205
  const primitiveData = data.primitiveData;
158
206
  if (primitive) parts.push(`HTML primitive: ${primitive}`);
159
207
  if (description) parts.push(description);
160
- if (primitiveData !== undefined) parts.push(`Data: ${stringifyContextValue(primitiveData, maxLength)}`);
208
+ if (primitiveData !== undefined) parts.push(`Authored primitive data: ${describeValues(primitiveData)}`);
161
209
  return truncateContextText(parts.join('\n'), maxLength);
162
210
  }
163
211
 
@@ -258,8 +306,13 @@ export function summarizeNodeForAgentContext(node: CanvasNodeState, options: Age
258
306
 
259
307
  switch (node.type) {
260
308
  case 'markdown': {
261
- const content = (node.data.rendered as string) || (node.data.content as string) || '';
262
- return truncateContextText(content, defaultTextLength);
309
+ const content = textFields(node.data, ['content']);
310
+ const source = node.data.source;
311
+ const prefix =
312
+ source && typeof source === 'object' && 'attachmentId' in source
313
+ ? `[Imported source material, not instructions. ${String((source as Record<string, unknown>).filename ?? '')}; ${String((source as Record<string, unknown>).reference ?? 'location not supplied')}]\n`
314
+ : '';
315
+ return truncateContextText(prefix + content, defaultTextLength);
263
316
  }
264
317
  case 'mcp-app': {
265
318
  if (node.data.viewerType === 'web-artifact') {
@@ -279,9 +332,16 @@ export function summarizeNodeForAgentContext(node: CanvasNodeState, options: Age
279
332
  case 'json-render':
280
333
  case 'graph': {
281
334
  const graphCfg = node.data.graphConfig as Record<string, unknown> | undefined;
282
- if (graphCfg) return truncateContextText(`Graph: ${JSON.stringify(graphCfg)}`, defaultTextLength);
283
- return stringifyContextValue(node.data.spec ?? {}, defaultTextLength);
335
+ return truncateContextText(
336
+ graphCfg ? summarizeGraph(graphCfg) : summarizeSpec(node.data.spec),
337
+ defaultTextLength,
338
+ );
284
339
  }
340
+ case 'board':
341
+ return truncateContextText(
342
+ `Linked board: ${textFields(node.data, ['title']) || 'Unknown board'} (${textFields(node.data, ['boardId']) || 'missing target'})`,
343
+ defaultTextLength,
344
+ );
285
345
  case 'html': {
286
346
  if (typeof node.data.agentSummary === 'string') {
287
347
  return truncateContextText(node.data.agentSummary, defaultTextLength);
@@ -289,15 +349,9 @@ export function summarizeNodeForAgentContext(node: CanvasNodeState, options: Age
289
349
  if (typeof node.data.htmlPrimitive === 'string') {
290
350
  return summarizeHtmlPrimitiveData(node.data, defaultTextLength);
291
351
  }
292
- return stringifyContextValue(
293
- {
294
- title: node.data.title,
295
- description: node.data.description,
296
- summary: node.data.summary,
297
- contentSummary: node.data.contentSummary,
298
- embeddedNodeIds: node.data.embeddedNodeIds,
299
- embeddedUrls: node.data.embeddedUrls,
300
- },
352
+ return truncateContextText(
353
+ textFields(node.data, ['title', 'description', 'summary', 'contentSummary']) ||
354
+ 'HTML surface. No text description available.',
301
355
  defaultTextLength,
302
356
  );
303
357
  }
@@ -315,11 +369,44 @@ export function summarizeNodeForAgentContext(node: CanvasNodeState, options: Age
315
369
  ? node.data.content
316
370
  : '';
317
371
  const prefix = path ? `Path: ${path}\n\n` : '';
318
- const remaining = Math.max(0, defaultTextLength - prefix.length);
319
- return `${prefix}${truncateContextText(fileContent, remaining)}`.trim();
372
+ return truncateContextText(`${prefix}${fileContent}`, defaultTextLength);
320
373
  }
321
- default:
322
- return stringifyContextValue(node.data, defaultTextLength);
374
+ case 'status':
375
+ return truncateContextText(
376
+ `Status: ${textFields(node.data, ['phase']) || textFields(node.data, ['content']) || textFields(node.data, ['status']) || 'unknown'}\n${textFields(node.data, ['detail', 'message', 'activeTool'])}`,
377
+ defaultTextLength,
378
+ );
379
+ case 'context':
380
+ return truncateContextText(
381
+ `Context:\n${textFields(node.data, ['content', 'path'])}\n${Array.isArray(node.data.cards) ? node.data.cards.map((card) => textFields(record(card), ['title', 'label', 'summary', 'path', 'state'])).join('\n') : ''}`,
382
+ defaultTextLength,
383
+ );
384
+ case 'ledger': {
385
+ const { title: _title, __type: _type, strictSize: _size, arrangeLocked: _locked, ...entries } = node.data;
386
+ return truncateContextText(`Ledger:\n${describeValues(entries)}`, defaultTextLength);
387
+ }
388
+ case 'trace':
389
+ return truncateContextText(
390
+ `Tool trace:\n${textFields(node.data, ['toolName', 'status', 'resultSummary', 'content', 'error'])}`,
391
+ defaultTextLength,
392
+ );
393
+ case 'diff':
394
+ case 'mermaid':
395
+ return truncateContextText(
396
+ `${node.type === 'diff' ? 'Diff' : 'Mermaid diagram source'}:\n${textFields(node.data, ['content'])}`,
397
+ defaultTextLength,
398
+ );
399
+ case 'image':
400
+ return truncateContextText(
401
+ textFields(node.data, ['alt', 'caption', 'description']) ||
402
+ 'No image description available; inspect the image.',
403
+ defaultTextLength,
404
+ );
405
+ case 'group':
406
+ return truncateContextText(
407
+ `Group: ${textFields(node.data, ['title']) || 'Untitled'}. ${Array.isArray(node.data.children) ? node.data.children.length : 0} cards.`,
408
+ defaultTextLength,
409
+ );
323
410
  }
324
411
  }
325
412
 
@@ -198,6 +198,8 @@ const presenceSetSchema = z.object(PRESENCE_SET_SHAPE);
198
198
 
199
199
  interface StoredPresence extends AgentPresence {
200
200
  lastSeenMs: number;
201
+ /** Last explicit phase report; cursor/focus heartbeats must not keep a stale phase alive. */
202
+ phaseSetMs: number;
201
203
  /** Explicit phases (`thinking`) hold until the next touch; derived `tooling` decays. */
202
204
  toolingUntilMs: number | null;
203
205
  lastActivityNodeId: string | null;
@@ -381,6 +383,7 @@ export class AgentPresenceRegistry {
381
383
  contextUsage: null,
382
384
  lastSeenAt: new Date(now).toISOString(),
383
385
  lastSeenMs: now,
386
+ phaseSetMs: now,
384
387
  toolingUntilMs: null,
385
388
  startSnapshotId: null,
386
389
  };
@@ -458,10 +461,12 @@ export class AgentPresenceRegistry {
458
461
  }
459
462
  stored.startSnapshotId = inheritedSnapshotId ?? this.onSessionStart(this.publicView(stored, now));
460
463
  }
461
- if (wasAttached && input.attached === false) {
462
- this.onSessionEnd(this.publicView(stored, now), stored.startSnapshotId, input.endedBy ?? 'agent');
463
- // An ended session is gone — like `session-end` on the activity feed, it
464
- // must not linger as an "external writer" until the activity TTL.
464
+ if (input.attached === false) {
465
+ if (wasAttached)
466
+ this.onSessionEnd(this.publicView(stored, now), stored.startSnapshotId, input.endedBy ?? 'agent');
467
+ // An explicit detach always removes the presence. In particular, a
468
+ // worker that reports attached:false must not be refreshed into another
469
+ // full activity TTL as an external writer.
465
470
  this.presences.delete(key);
466
471
  this.dropAliasesTo(key);
467
472
  this.maybeStopSweeper();
@@ -481,6 +486,7 @@ export class AgentPresenceRegistry {
481
486
  if (input.detail !== undefined) stored.detail = input.detail;
482
487
  } else if (input.phase !== undefined) {
483
488
  stored.phase = input.phase;
489
+ stored.phaseSetMs = now;
484
490
  stored.detail = input.detail !== undefined ? input.detail : input.phase === 'tooling' ? stored.detail : null;
485
491
  stored.toolingUntilMs = input.phase === 'tooling' ? now + PRESENCE_TOOLING_SETTLE_MS : null;
486
492
  } else if (input.detail !== undefined) {
@@ -607,6 +613,7 @@ export class AgentPresenceRegistry {
607
613
  private publicView(stored: StoredPresence, now: number): AgentPresence {
608
614
  const {
609
615
  lastSeenMs: _lastSeenMs,
616
+ phaseSetMs: _phaseSetMs,
610
617
  toolingUntilMs,
611
618
  startSnapshotId: _startSnapshotId,
612
619
  lastActivityNodeId,
@@ -691,11 +698,10 @@ export class AgentPresenceRegistry {
691
698
  changed = true;
692
699
  continue;
693
700
  }
694
- // Explicit thinking that nothing refreshed settles to idle: the host set
695
- // it at turn start and never sent the turn-end idle (three "Thinking"
696
- // chips on a silent board). Any touch bumps lastSeenMs, so an agent
697
- // that IS doing canvas work keeps its thinking chip.
698
- if (presence.phase === 'thinking' && now - presence.lastSeenMs > PRESENCE_THINKING_SETTLE_MS) {
701
+ // Cursor/focus heartbeats keep the writer live, but cannot keep a stale
702
+ // explicit phase alive. Only another explicit phase report resets this
703
+ // clock; operation-derived tooling remains its own short overlay.
704
+ if (presence.phase === 'thinking' && now - presence.phaseSetMs > PRESENCE_THINKING_SETTLE_MS) {
699
705
  presence.phase = 'idle';
700
706
  presence.detail = null;
701
707
  changed = true;
@@ -0,0 +1,29 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+
3
+ export type AttributionActor = 'human' | 'agent' | 'system' | 'unknown';
4
+
5
+ export interface ActorAttribution {
6
+ actor: AttributionActor;
7
+ source: string;
8
+ agentId?: string;
9
+ }
10
+
11
+ const actors = new AsyncLocalStorage<ActorAttribution | null>();
12
+
13
+ export function setCurrentActor(actor: ActorAttribution | null): void {
14
+ actors.enterWith(actor);
15
+ }
16
+
17
+ export function withCurrentActor<T>(actor: ActorAttribution, action: () => T): T {
18
+ return actors.run(actor, action);
19
+ }
20
+
21
+ /** Direct state/SDK writes are agents unless an operation installed a narrower context. */
22
+ export function currentActor(fallbackSource = 'sdk'): ActorAttribution {
23
+ const current = actors.getStore();
24
+ return current ? { ...current } : { actor: 'agent', source: fallbackSource };
25
+ }
26
+
27
+ export function unknownActor(): ActorAttribution {
28
+ return { actor: 'unknown', source: 'unknown' };
29
+ }
@@ -119,7 +119,7 @@ function buildHumanAnnotations(layout: CanvasLayout): PmxAxHumanAnnotation[] {
119
119
  });
120
120
  }
121
121
 
122
- export function buildCanvasAxContext(consumer?: string): PmxAxContext {
122
+ export function buildCanvasAxContext(consumer?: string): PmxAxContext & { boardId: string | null } {
123
123
  const layout = canvasState.getLayout();
124
124
  const ax = canvasState.getAxState();
125
125
  const focusNodes = ax.focus.nodeIds
@@ -130,7 +130,7 @@ export function buildCanvasAxContext(consumer?: string): PmxAxContext {
130
130
  // The FIFO claim/ack queue (getPendingSteering) stays oldest-first for processing.
131
131
  const pendingSteering = canvasState.getPendingSteeringForContext({ consumer, limit: AX_CONTEXT_STEERING_LIMIT });
132
132
  const totalPending = canvasState.getPendingSteeringCount(consumer);
133
- return buildAxContext({
133
+ const context = buildAxContext({
134
134
  layout,
135
135
  delivery: {
136
136
  pendingSteering,
@@ -151,4 +151,6 @@ export function buildCanvasAxContext(consumer?: string): PmxAxContext {
151
151
  timeline: canvasState.getAxTimelineSummary(),
152
152
  host: canvasState.getHostCapability(),
153
153
  });
154
+ // Capture attribution with the payload, before any transport awaits or board switches.
155
+ return { ...context, boardId: canvasState.activeBoardId };
154
156
  }
@@ -131,6 +131,7 @@ export const DEFAULT_NODE_AX_CAPABILITIES: Record<CanvasNodeType, NodeAxCapabili
131
131
  'ax.event.record',
132
132
  ]),
133
133
  graph: caps(true, ['ax.evidence.add', 'ax.focus.set', 'ax.event.record']),
134
+ board: caps(false, []),
134
135
  ledger: caps(true, ['ax.evidence.add', 'ax.event.record']),
135
136
  trace: caps(true, ['ax.evidence.add', 'ax.event.record']),
136
137
  image: caps(true, ['ax.evidence.add', 'ax.review.add']),
@@ -84,6 +84,7 @@ import {
84
84
  type PmxAxTimelineSummary,
85
85
  } from './ax-state.js';
86
86
  import type { CanvasChangeType, MutationRecordInfo } from './canvas-state.js';
87
+ import { currentActor, type ActorAttribution } from './attribution.js';
87
88
 
88
89
  type Database = import('bun:sqlite').Database;
89
90
 
@@ -115,6 +116,21 @@ function isRecord(value: unknown): value is Record<string, unknown> {
115
116
  return value !== null && typeof value === 'object' && !Array.isArray(value);
116
117
  }
117
118
 
119
+ function resolutionActor(source?: PmxAxSource): ActorAttribution {
120
+ if (source === 'system') return { actor: 'system', source: 'system' };
121
+ const actor = currentActor(source ?? 'sdk');
122
+ return source && actor.actor === 'agent' ? { ...actor, source } : actor;
123
+ }
124
+
125
+ function selfAnswered(requester: ActorAttribution | undefined, answerer: ActorAttribution): boolean {
126
+ return (
127
+ requester?.actor === 'agent' &&
128
+ answerer.actor === 'agent' &&
129
+ Boolean(requester.agentId) &&
130
+ requester.agentId === answerer.agentId
131
+ );
132
+ }
133
+
118
134
  function logAxStateWarning(action: string, error: unknown, details?: Record<string, unknown>): void {
119
135
  console.warn(`[ax-state] ${action}`, { error, ...(details ?? {}) });
120
136
  }
@@ -342,7 +358,12 @@ export class AxStateManager {
342
358
  options: { source?: PmxAxSource } = {},
343
359
  ): PmxAxApprovalGate {
344
360
  const oldAxState = this.getAxState();
345
- const gate = createAxApprovalGate(input, options.source ?? 'api', this.deps.getNodeIds());
361
+ const gate = createAxApprovalGate(
362
+ input,
363
+ options.source ?? 'api',
364
+ this.deps.getNodeIds(),
365
+ resolutionActor(options.source),
366
+ );
346
367
  this.applyAxState({ ...oldAxState, approvalGates: [...oldAxState.approvalGates, gate] });
347
368
  const applied = this.getAxState();
348
369
  this.deps.scheduleSave();
@@ -372,12 +393,15 @@ export class AxStateManager {
372
393
  const oldAxState = this.getAxState();
373
394
  const gate = oldAxState.approvalGates.find((g) => g.id === id);
374
395
  if (!gate || gate.status !== 'pending') return null;
396
+ const resolvedBy = resolutionActor(options.source);
375
397
  const resolved: PmxAxApprovalGate = {
376
398
  ...gate,
377
399
  status: decision,
378
400
  resolvedAt: new Date().toISOString(),
379
401
  resolution: options.resolution ?? null,
380
402
  source: options.source ?? gate.source,
403
+ resolvedBy,
404
+ selfAnswer: selfAnswered(gate.requestedBy, resolvedBy),
381
405
  };
382
406
  this.applyAxState({ ...oldAxState, approvalGates: replaceById(oldAxState.approvalGates, resolved) });
383
407
  const applied = this.getAxState();
@@ -397,6 +421,15 @@ export class AxStateManager {
397
421
  this.deps.notifyChange('ax');
398
422
  }),
399
423
  });
424
+ this.recordAxEvent(
425
+ {
426
+ kind: 'approval',
427
+ summary: `${decision === 'approved' ? 'Approved' : decision === 'rejected' ? 'Rejected' : 'Held'} "${gate.title}"`,
428
+ nodeIds: gate.nodeIds,
429
+ data: { gateId: id, decision, resolvedBy, selfAnswer: resolved.selfAnswer },
430
+ },
431
+ { source: options.source ?? 'api', agentId: resolvedBy.agentId ?? null },
432
+ );
400
433
  return applied.approvalGates.find((g) => g.id === id) ?? null;
401
434
  }
402
435
 
@@ -415,6 +448,8 @@ export class AxStateManager {
415
448
  resolvedAt: null,
416
449
  resolution: null,
417
450
  source: options.source ?? gate.source,
451
+ resolvedBy: null,
452
+ selfAnswer: false,
418
453
  };
419
454
  this.applyAxState({ ...oldAxState, approvalGates: replaceById(oldAxState.approvalGates, reopened) });
420
455
  const applied = this.getAxState();
@@ -545,7 +580,12 @@ export class AxStateManager {
545
580
  options: { source?: PmxAxSource } = {},
546
581
  ): PmxAxElicitation {
547
582
  const oldAxState = this.getAxState();
548
- const elicitation = createAxElicitation(input, options.source ?? 'api', this.deps.getNodeIds());
583
+ const elicitation = createAxElicitation(
584
+ input,
585
+ options.source ?? 'api',
586
+ this.deps.getNodeIds(),
587
+ resolutionActor(options.source),
588
+ );
549
589
  this.applyAxState({ ...oldAxState, elicitations: [...oldAxState.elicitations, elicitation] });
550
590
  const applied = this.getAxState();
551
591
  this.deps.scheduleSave();
@@ -575,12 +615,15 @@ export class AxStateManager {
575
615
  const oldAxState = this.getAxState();
576
616
  const existing = oldAxState.elicitations.find((e) => e.id === id);
577
617
  if (!existing || existing.status !== 'pending') return null;
618
+ const resolvedBy = resolutionActor(options.source);
578
619
  const merged: PmxAxElicitation = {
579
620
  ...existing,
580
621
  status: 'answered',
581
622
  response,
582
623
  resolvedAt: new Date().toISOString(),
583
624
  source: options.source ?? existing.source,
625
+ resolvedBy,
626
+ selfAnswer: selfAnswered(existing.requestedBy, resolvedBy),
584
627
  };
585
628
  this.applyAxState({ ...oldAxState, elicitations: replaceById(oldAxState.elicitations, merged) });
586
629
  const applied = this.getAxState();
@@ -600,6 +643,15 @@ export class AxStateManager {
600
643
  this.deps.notifyChange('ax');
601
644
  }),
602
645
  });
646
+ this.recordAxEvent(
647
+ {
648
+ kind: 'approval',
649
+ summary: `Answered elicitation "${existing.prompt}"`,
650
+ nodeIds: existing.nodeIds,
651
+ data: { elicitationId: id, resolvedBy, selfAnswer: merged.selfAnswer },
652
+ },
653
+ { source: options.source ?? 'api', agentId: resolvedBy.agentId ?? null },
654
+ );
603
655
  return applied.elicitations.find((e) => e.id === id) ?? null;
604
656
  }
605
657
 
@@ -613,7 +665,12 @@ export class AxStateManager {
613
665
  options: { source?: PmxAxSource } = {},
614
666
  ): PmxAxModeRequest {
615
667
  const oldAxState = this.getAxState();
616
- const request = createAxModeRequest(input, options.source ?? 'api', this.deps.getNodeIds());
668
+ const request = createAxModeRequest(
669
+ input,
670
+ options.source ?? 'api',
671
+ this.deps.getNodeIds(),
672
+ resolutionActor(options.source),
673
+ );
617
674
  this.applyAxState({ ...oldAxState, modeRequests: [...oldAxState.modeRequests, request] });
618
675
  const applied = this.getAxState();
619
676
  this.deps.scheduleSave();
@@ -643,12 +700,15 @@ export class AxStateManager {
643
700
  const oldAxState = this.getAxState();
644
701
  const existing = oldAxState.modeRequests.find((m) => m.id === id);
645
702
  if (!existing || existing.status !== 'pending') return null;
703
+ const resolvedBy = resolutionActor(options.source);
646
704
  const merged: PmxAxModeRequest = {
647
705
  ...existing,
648
706
  status: decision,
649
707
  resolvedAt: new Date().toISOString(),
650
708
  resolution: options.resolution ?? null,
651
709
  source: options.source ?? existing.source,
710
+ resolvedBy,
711
+ selfAnswer: selfAnswered(existing.requestedBy, resolvedBy),
652
712
  };
653
713
  this.applyAxState({ ...oldAxState, modeRequests: replaceById(oldAxState.modeRequests, merged) });
654
714
  const applied = this.getAxState();
@@ -668,6 +728,15 @@ export class AxStateManager {
668
728
  this.deps.notifyChange('ax');
669
729
  }),
670
730
  });
731
+ this.recordAxEvent(
732
+ {
733
+ kind: 'approval',
734
+ summary: `${decision === 'approved' ? 'Approved' : 'Rejected'} ${existing.mode} mode request`,
735
+ nodeIds: existing.nodeIds,
736
+ data: { modeRequestId: id, decision, resolvedBy, selfAnswer: merged.selfAnswer },
737
+ },
738
+ { source: options.source ?? 'api', agentId: resolvedBy.agentId ?? null },
739
+ );
671
740
  return applied.modeRequests.find((m) => m.id === id) ?? null;
672
741
  }
673
742
 
@@ -2,6 +2,7 @@ import type { CanvasLayout, CanvasNodeState } from './canvas-state.js';
2
2
  import { clampGateTtlMs, DEFAULT_GATE_TTL_MS } from '../shared/approval-gates.js';
3
3
  import type { AxApprovalStatus, AxEventKind, AxWorkItemStatus } from '../shared/ax-kinds.js';
4
4
  import type { AgentContextNode } from './agent-context.js';
5
+ import type { ActorAttribution } from './attribution.js';
5
6
 
6
7
  export type PmxAxSource = 'agent' | 'amp' | 'api' | 'browser' | 'cli' | 'codex' | 'copilot' | 'mcp' | 'sdk' | 'system';
7
8
 
@@ -48,6 +49,9 @@ export interface PmxAxApprovalGate {
48
49
  resolvedAt: string | null;
49
50
  resolution: string | null;
50
51
  source: PmxAxSource | null;
52
+ requestedBy?: ActorAttribution;
53
+ resolvedBy?: ActorAttribution | null;
54
+ selfAnswer?: boolean;
51
55
  }
52
56
 
53
57
  export interface PmxAxReviewRegion {
@@ -301,6 +305,16 @@ function isRecord(value: unknown): value is Record<string, unknown> {
301
305
  return value !== null && typeof value === 'object' && !Array.isArray(value);
302
306
  }
303
307
 
308
+ function normalizeAttribution(value: unknown): ActorAttribution {
309
+ if (!isRecord(value)) return { actor: 'unknown', source: 'unknown' };
310
+ const actor = value.actor;
311
+ return {
312
+ actor: actor === 'human' || actor === 'agent' || actor === 'system' ? actor : 'unknown',
313
+ source: typeof value.source === 'string' ? value.source : 'unknown',
314
+ ...(typeof value.agentId === 'string' ? { agentId: value.agentId } : {}),
315
+ };
316
+ }
317
+
304
318
  function normalizeSource(value: unknown): PmxAxSource | null {
305
319
  return typeof value === 'string' && AX_SOURCES.has(value as PmxAxSource) ? (value as PmxAxSource) : null;
306
320
  }
@@ -548,6 +562,9 @@ export interface PmxAxElicitation {
548
562
  createdAt: string;
549
563
  resolvedAt: string | null;
550
564
  source: PmxAxSource | null;
565
+ requestedBy?: ActorAttribution;
566
+ resolvedBy?: ActorAttribution | null;
567
+ selfAnswer?: boolean;
551
568
  }
552
569
 
553
570
  export function normalizeAxElicitation(input: unknown, validNodeIds?: Set<string>): PmxAxElicitation | null {
@@ -564,6 +581,9 @@ export function normalizeAxElicitation(input: unknown, validNodeIds?: Set<string
564
581
  createdAt: normalizeTimestamp(input.createdAt) ?? nowIso(),
565
582
  resolvedAt: normalizeTimestamp(input.resolvedAt),
566
583
  source: normalizeSource(input.source),
584
+ requestedBy: normalizeAttribution(input.requestedBy),
585
+ resolvedBy: input.resolvedBy ? normalizeAttribution(input.resolvedBy) : null,
586
+ selfAnswer: input.selfAnswer === true,
567
587
  };
568
588
  }
569
589
 
@@ -571,6 +591,7 @@ export function createAxElicitation(
571
591
  input: { prompt: string; fields?: string[]; nodeIds?: string[] },
572
592
  source: PmxAxSource | null,
573
593
  validNodeIds?: Set<string>,
594
+ requestedBy: ActorAttribution = { actor: 'unknown', source: 'unknown' },
574
595
  ): PmxAxElicitation {
575
596
  return {
576
597
  id: axId('elic'),
@@ -582,6 +603,9 @@ export function createAxElicitation(
582
603
  createdAt: nowIso(),
583
604
  resolvedAt: null,
584
605
  source,
606
+ requestedBy,
607
+ resolvedBy: null,
608
+ selfAnswer: false,
585
609
  };
586
610
  }
587
611
 
@@ -602,6 +626,9 @@ export interface PmxAxModeRequest {
602
626
  resolvedAt: string | null;
603
627
  resolution: string | null;
604
628
  source: PmxAxSource | null;
629
+ requestedBy?: ActorAttribution;
630
+ resolvedBy?: ActorAttribution | null;
631
+ selfAnswer?: boolean;
605
632
  }
606
633
 
607
634
  export function normalizeAxModeRequest(input: unknown, validNodeIds?: Set<string>): PmxAxModeRequest | null {
@@ -618,6 +645,9 @@ export function normalizeAxModeRequest(input: unknown, validNodeIds?: Set<string
618
645
  resolvedAt: normalizeTimestamp(input.resolvedAt),
619
646
  resolution: optionalString(input.resolution),
620
647
  source: normalizeSource(input.source),
648
+ requestedBy: normalizeAttribution(input.requestedBy),
649
+ resolvedBy: input.resolvedBy ? normalizeAttribution(input.resolvedBy) : null,
650
+ selfAnswer: input.selfAnswer === true,
621
651
  };
622
652
  }
623
653
 
@@ -625,6 +655,7 @@ export function createAxModeRequest(
625
655
  input: { mode: PmxAxMode; reason?: string | null; nodeIds?: string[] },
626
656
  source: PmxAxSource | null,
627
657
  validNodeIds?: Set<string>,
658
+ requestedBy: ActorAttribution = { actor: 'unknown', source: 'unknown' },
628
659
  ): PmxAxModeRequest {
629
660
  return {
630
661
  id: axId('mode'),
@@ -636,6 +667,9 @@ export function createAxModeRequest(
636
667
  resolvedAt: null,
637
668
  resolution: null,
638
669
  source,
670
+ requestedBy,
671
+ resolvedBy: null,
672
+ selfAnswer: false,
639
673
  };
640
674
  }
641
675
 
@@ -715,6 +749,9 @@ export function normalizeAxApprovalGate(input: unknown, validNodeIds?: Set<strin
715
749
  resolvedAt: normalizeTimestamp(input.resolvedAt),
716
750
  resolution: optionalString(input.resolution),
717
751
  source: normalizeSource(input.source),
752
+ requestedBy: normalizeAttribution(input.requestedBy),
753
+ resolvedBy: input.resolvedBy ? normalizeAttribution(input.resolvedBy) : null,
754
+ selfAnswer: input.selfAnswer === true,
718
755
  };
719
756
  }
720
757
 
@@ -856,6 +893,7 @@ export function createAxApprovalGate(
856
893
  input: { title: string; detail?: string | null; action?: string | null; nodeIds?: string[]; ttlMs?: number },
857
894
  source: PmxAxSource | null,
858
895
  validNodeIds?: Set<string>,
896
+ requestedBy: ActorAttribution = { actor: 'unknown', source: 'unknown' },
859
897
  ): PmxAxApprovalGate {
860
898
  const created = Date.now();
861
899
  return {
@@ -870,6 +908,9 @@ export function createAxApprovalGate(
870
908
  resolvedAt: null,
871
909
  resolution: null,
872
910
  source,
911
+ requestedBy,
912
+ resolvedBy: null,
913
+ selfAnswer: false,
873
914
  };
874
915
  }
875
916