pmx-canvas 0.7.1 → 0.7.3

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 (76) hide show
  1. package/.github/extensions/pmx-canvas/extension.mjs +79 -61
  2. package/CHANGELOG.md +29 -1
  3. package/Readme.md +48 -9
  4. package/dist/canvas/global.css +144 -45
  5. package/dist/canvas/index.js +90 -90
  6. package/dist/json-render/index.js +55 -55
  7. package/dist/types/client/canvas/ChoiceMenu.d.ts +13 -0
  8. package/dist/types/client/canvas/ExternalWriters.d.ts +3 -3
  9. package/dist/types/client/icons.d.ts +15 -0
  10. package/dist/types/client/nodes/ExtAppFrame.d.ts +1 -1
  11. package/dist/types/client/nodes/FileNode.d.ts +0 -12
  12. package/dist/types/client/nodes/McpAppNode.d.ts +2 -1
  13. package/dist/types/client/nodes/viewer-runtime-state.d.ts +1 -0
  14. package/dist/types/client/state/canvas-store.d.ts +2 -0
  15. package/dist/types/client/state/intent-bridge.d.ts +1 -1
  16. package/dist/types/json-render/renderer/index.d.ts +2 -1
  17. package/dist/types/json-render/server.d.ts +2 -0
  18. package/dist/types/shared/ax-surface-protocol.d.ts +2 -0
  19. package/dist/types/shared/delimited-text.d.ts +14 -0
  20. package/docs/ax-host-adapter-contract.md +13 -2
  21. package/docs/cli.md +9 -2
  22. package/docs/http-api.md +12 -8
  23. package/docs/mcp.md +8 -1
  24. package/docs/sdk.md +2 -2
  25. package/package.json +1 -1
  26. package/skills/pmx-canvas/SKILL.md +41 -16
  27. package/skills/pmx-canvas/evals/evals.json +112 -2
  28. package/skills/pmx-canvas/references/full-reference.md +9 -4
  29. package/skills/pmx-canvas/references/github-copilot-app-adapter.md +17 -3
  30. package/skills/pmx-canvas/references/installing-pmx-canvas.md +76 -4
  31. package/skills/pmx-canvas-testing/SKILL.md +49 -0
  32. package/src/cli/commands/backup.ts +15 -4
  33. package/src/cli/commands/boards.ts +16 -3
  34. package/src/client/canvas/AttentionHistory.tsx +2 -1
  35. package/src/client/canvas/BoardSwitcher.tsx +3 -3
  36. package/src/client/canvas/CanvasNode.tsx +211 -197
  37. package/src/client/canvas/CanvasViewport.tsx +6 -0
  38. package/src/client/canvas/ChoiceMenu.tsx +122 -0
  39. package/src/client/canvas/CommandBar.tsx +27 -25
  40. package/src/client/canvas/ContextPinBar.tsx +3 -1
  41. package/src/client/canvas/ExpandedNodeOverlay.tsx +3 -3
  42. package/src/client/canvas/ExternalWriters.tsx +4 -3
  43. package/src/client/canvas/FeedbackDialog.tsx +12 -9
  44. package/src/client/canvas/IntentLayer.tsx +2 -2
  45. package/src/client/canvas/SelectionBar.tsx +2 -2
  46. package/src/client/canvas/SessionPanel.tsx +6 -4
  47. package/src/client/canvas/SessionReceipt.tsx +2 -1
  48. package/src/client/canvas/SnapshotPanel.tsx +3 -2
  49. package/src/client/canvas/ToolRail.tsx +48 -100
  50. package/src/client/canvas/TopBar.tsx +2 -2
  51. package/src/client/icons.tsx +118 -1
  52. package/src/client/nodes/BoardNode.tsx +2 -1
  53. package/src/client/nodes/ExtAppFrame.tsx +3 -1
  54. package/src/client/nodes/FileNode.tsx +4 -78
  55. package/src/client/nodes/ImageNode.tsx +2 -1
  56. package/src/client/nodes/MarkdownNode.tsx +2 -1
  57. package/src/client/nodes/McpAppNode.tsx +16 -0
  58. package/src/client/nodes/PromptNode.tsx +3 -1
  59. package/src/client/nodes/use-ax-surface-bridge.ts +4 -2
  60. package/src/client/nodes/viewer-runtime-state.ts +78 -0
  61. package/src/client/state/canvas-store.ts +3 -0
  62. package/src/client/state/intent-bridge.ts +14 -2
  63. package/src/client/theme/global.css +144 -45
  64. package/src/json-render/renderer/index.tsx +58 -38
  65. package/src/json-render/server.ts +8 -1
  66. package/src/mcp/server.ts +2 -2
  67. package/src/server/agent-presence.ts +17 -11
  68. package/src/server/board-export.ts +97 -32
  69. package/src/server/index.ts +9 -7
  70. package/src/server/operations/composites.ts +3 -2
  71. package/src/server/operations/ops/ax-presence.ts +7 -3
  72. package/src/server/operations/ops/boards.ts +12 -5
  73. package/src/server/operations/registry.ts +4 -13
  74. package/src/server/server.ts +13 -4
  75. package/src/shared/ax-surface-protocol.ts +3 -0
  76. package/src/shared/delimited-text.ts +75 -0
@@ -11,8 +11,9 @@
11
11
  */
12
12
  import { existsSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs';
13
13
  import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
14
- import { Marked, type RendererThis, type Tokens } from 'marked';
14
+ import { Marked, Renderer, type RendererThis, type Tokens } from 'marked';
15
15
  import { canvasThemeScheme } from '../shared/themes.js';
16
+ import { delimiterForPath, parseDelimitedText } from '../shared/delimited-text.js';
16
17
  import { buildJsonRenderViewerHtml, escapeInlineScriptSource, readJsonRenderBundle } from '../json-render/server.js';
17
18
  import type { JsonRenderSpec } from '../json-render/server.js';
18
19
  import { getCanvasNodeTitle } from './canvas-serialization.js';
@@ -89,6 +90,9 @@ function createMarkdown(remoteImages: Set<string>, links: Set<string>): Marked {
89
90
  html({ text: raw }: Tokens.HTML | Tokens.Tag) {
90
91
  return escapeHtml(raw);
91
92
  },
93
+ table(this: RendererThis, token: Tokens.Table) {
94
+ return tableScroll(Renderer.prototype.table.call(this, token));
95
+ },
92
96
  link(this: RendererThis, { href, tokens }: Tokens.Link) {
93
97
  const inner = this.parser.parseInline(tokens);
94
98
  if (/^https?:/i.test(href)) links.add(href);
@@ -136,6 +140,24 @@ function pre(value: string, className = ''): string {
136
140
  return `<pre class="${className}">${escapeHtml(value)}</pre>`;
137
141
  }
138
142
 
143
+ function tableScroll(html: string): string {
144
+ return `<div class="table-scroll" role="region" aria-label="Table" tabindex="0">${html}</div>`;
145
+ }
146
+
147
+ function fileBody(value: string, name: string): string {
148
+ const delimiter = delimiterForPath(name);
149
+ if (delimiter) {
150
+ const table = parseDelimitedText(value, delimiter);
151
+ if (table.header.length > 1) {
152
+ // Match the workbench's bounded table preview without losing the remainder.
153
+ const preview = table.rows.slice(0, 500);
154
+ const html = `<table><thead><tr>${table.header.map((cell) => `<th scope="col">${escapeHtml(cell)}</th>`).join('')}</tr></thead><tbody>${preview.map((row) => `<tr>${row.map((cell) => `<td>${escapeHtml(cell)}</td>`).join('')}</tr>`).join('')}</tbody></table>`;
155
+ return `${tableScroll(html)}${table.rows.length > preview.length ? `<details><summary>Showing 500 of ${table.rows.length} rows — full text</summary>${pre(value, 'code')}</details>` : ''}`;
156
+ }
157
+ }
158
+ return pre(value, 'code');
159
+ }
160
+
139
161
  function rows(entries: Array<[string, unknown]>): string {
140
162
  const cells = entries
141
163
  .filter(([, value]) => value !== undefined && value !== null && value !== '')
@@ -258,20 +280,27 @@ async function collect(
258
280
  break;
259
281
  case 'status':
260
282
  card.html = rows([
261
- ['Phase', data.phase],
283
+ ['Phase', text(data.phase).trim() || text(data.content).trim() || text(data.status).trim() || 'idle'],
262
284
  ['Message', data.message],
263
285
  ['Detail', data.detail],
286
+ ['Active tool', data.activeTool],
264
287
  ]);
265
288
  break;
266
289
  case 'context': {
267
290
  const items = Array.isArray(data.cards) ? (data.cards as Array<Record<string, unknown>>) : [];
268
- card.html = `<ul class="list">${items
269
- .map((item) => `<li><strong>${escapeHtml(text(item.title))}</strong> ${escapeHtml(text(item.summary))}</li>`)
270
- .join('')}</ul>`;
291
+ card.html = `${rows([
292
+ ['Tokens', data.currentTokens],
293
+ ['Token limit', data.tokenLimit],
294
+ ['Messages', data.messagesLength],
295
+ ])}${
296
+ items.length
297
+ ? `<ul class="list">${items.map((item) => `<li><strong>${escapeHtml(text(item.title) || text(item.label))}</strong><p>${escapeHtml(text(item.summary))}</p></li>`).join('')}</ul>`
298
+ : `<p class="prose">${escapeHtml(text(data.content) || text(data.rendered).replace(/<[^>]+>/g, ' '))}</p>`
299
+ }`;
271
300
  break;
272
301
  }
273
302
  case 'ledger':
274
- card.html = `${text(data.content) ? pre(text(data.content)) : ''}${rows(
303
+ card.html = `${text(data.content) ? pre(text(data.content).replaceAll('\\n', '\n')) : ''}${rows(
275
304
  Object.entries(data).filter(
276
305
  ([key]) => !['title', 'content', '__type', 'strictSize', 'arrangeLocked'].includes(key),
277
306
  ),
@@ -279,10 +308,11 @@ async function collect(
279
308
  break;
280
309
  case 'trace':
281
310
  card.html = rows([
282
- ['Tool', data.toolName],
283
- ['Status', data.status],
311
+ ['Tool', text(data.toolName) || title],
312
+ ['Status', text(data.status) || 'running'],
284
313
  ['Duration', data.duration],
285
- ['Result', data.resultSummary],
314
+ ['Result', text(data.resultSummary) || text(data.content)],
315
+ ['Error', data.error],
286
316
  ]);
287
317
  break;
288
318
  case 'diff':
@@ -301,7 +331,7 @@ async function collect(
301
331
  .join('\n')}</pre>`;
302
332
  break;
303
333
  case 'prompt':
304
- card.html = pre(text(data.text));
334
+ card.html = `<div class="prose">${escapeHtml(text(data.text))}</div>`;
305
335
  break;
306
336
  case 'response':
307
337
  card.html = `<div class="md">${await markdown.parse(text(data.content))}</div>`;
@@ -327,9 +357,9 @@ async function collect(
327
357
  if (img) manifest.embeddedImages += 1;
328
358
  }
329
359
  card.html = img
330
- ? `<img class="image" src="${escapeHtml(img)}" alt="${escapeHtml(alt)}" referrerpolicy="no-referrer">${
331
- text(data.caption) ? `<p class="dim">${escapeHtml(text(data.caption))}</p>` : ''
332
- }`
360
+ ? `<figure class="image-figure"><img class="image" src="${escapeHtml(img)}" alt="${escapeHtml(alt)}" referrerpolicy="no-referrer">${
361
+ text(data.caption) ? `<figcaption class="dim">${escapeHtml(text(data.caption))}</figcaption>` : ''
362
+ }</figure>`
333
363
  : `<div class="placeholder">Image not available: ${escapeHtml(src)}</div>`;
334
364
  break;
335
365
  }
@@ -337,14 +367,14 @@ async function collect(
337
367
  const path = text(data.path);
338
368
  if (!path) {
339
369
  // Inline content the board itself holds, not a file on disk.
340
- card.html = pre(text(data.fileContent) || text(data.content), 'code');
370
+ card.html = fileBody(text(data.fileContent) || text(data.content), title);
341
371
  break;
342
372
  }
343
373
  const body = includeFiles ? fileText(node, path) : null;
344
374
  manifest.files.push({ nodeId: node.id, path, included: body !== null });
345
375
  card.html = `<p class="dim">${escapeHtml(path)}</p>${
346
376
  body !== null
347
- ? pre(body, 'code')
377
+ ? fileBody(body, path)
348
378
  : '<div class="placeholder">File contents not included in this export.</div>'
349
379
  }`;
350
380
  break;
@@ -527,7 +557,7 @@ function exportPage(input: {
527
557
  </div>
528
558
  </header>
529
559
  <main id="stage" aria-label="Board"><div id="world"><svg id="edges" aria-hidden="true"></svg></div></main>
530
- <div id="overlay" hidden><div class="sheet" role="dialog" aria-modal="true"><header><strong id="overlay-title"></strong><button type="button" id="overlay-close" aria-label="Close">×</button></header><div id="overlay-body"></div></div></div>
560
+ <div id="overlay" hidden><div class="sheet" role="dialog" aria-modal="true" aria-labelledby="overlay-title"><header><strong id="overlay-title"></strong><button type="button" id="overlay-close" aria-label="Close">×</button></header><div id="overlay-body"></div></div></div>
531
561
  <script type="application/json" id="pmx-board">${scriptJson(input.board)}</script>
532
562
  <script type="application/json" id="pmx-assets">${scriptJson(input.assets)}</script>
533
563
  <script>${EXPORT_VIEWER_JS}</script>
@@ -540,7 +570,7 @@ const EXPORT_CSS = `
540
570
  :root{--bg:#0b1016;--panel:#121a23;--line:#263241;--text:#e8edf2;--soft:#9aa7b4;--accent:#4bbcff;--add:#2ea66f;--del:#e25d6e}
541
571
  [data-scheme=light]{--bg:#f4f6f8;--panel:#ffffff;--line:#d5dbe1;--text:#17202a;--soft:#5b6875;--accent:#0a6fc2;--add:#1c7a4f;--del:#b3263a}
542
572
  *{box-sizing:border-box}
543
- html,body{margin:0;height:100%;background:var(--bg);color:var(--text);font:14px/1.45 system-ui,-apple-system,Segoe UI,sans-serif}
573
+ html,body{margin:0;height:100%;background:var(--bg);color:var(--text);font:15px/1.65 system-ui,-apple-system,Segoe UI,sans-serif}
544
574
  .bar{position:fixed;top:0;left:0;right:0;height:48px;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:0 16px;background:var(--panel);border-bottom:1px solid var(--line);z-index:5}
545
575
  .bar .name{display:flex;flex-direction:column;min-width:0}
546
576
  .bar .name strong,.bar .name span{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
@@ -548,6 +578,7 @@ html,body{margin:0;height:100%;background:var(--bg);color:var(--text);font:14px/
548
578
  .tools{display:flex;gap:6px;flex-shrink:0}
549
579
  button{font:inherit;color:var(--text);background:none;border:1px solid var(--line);border-radius:7px;padding:4px 10px;cursor:pointer}
550
580
  button:hover{border-color:var(--accent)}
581
+ button:focus-visible,a:focus-visible,[tabindex]:focus-visible{outline:2px solid var(--accent);outline-offset:-3px}
551
582
  #stage{position:fixed;top:48px;left:0;right:0;bottom:0;overflow:hidden;cursor:grab;touch-action:none}
552
583
  #stage.panning{cursor:grabbing}
553
584
  #world{position:absolute;left:0;top:0;transform-origin:0 0}
@@ -556,25 +587,43 @@ button:hover{border-color:var(--accent)}
556
587
  #edges text{fill:var(--soft);font-size:12px;text-anchor:middle}
557
588
  .card{position:absolute;display:flex;flex-direction:column;background:var(--panel);border:1px solid var(--line);border-radius:10px;overflow:hidden;box-shadow:0 6px 24px rgba(0,0,0,.18)}
558
589
  .card.group{background:color-mix(in srgb,var(--accent) 5%,transparent);border-style:dashed;box-shadow:none}
559
- .card-title{flex-shrink:0;padding:7px 10px;font-weight:600;font-size:13px;border-bottom:1px solid var(--line);cursor:zoom-in;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
590
+ .card-title{position:relative;flex-shrink:0;width:100%;text-align:left;padding:12px 42px 12px 18px;font-weight:600;font-size:13px;border:0;border-bottom:1px solid var(--line);border-radius:0;cursor:zoom-in;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;background:color-mix(in srgb,var(--soft) 5%,var(--panel))}
591
+ button.card-title:after{content:'↗';position:absolute;right:16px;color:var(--soft);font-size:16px}
560
592
  .card.group .card-title{border-bottom:none;cursor:default;color:var(--soft)}
561
- .card-body{flex:1;min-height:0;overflow:auto;padding:8px 10px}
562
- .card iframe,#overlay-body iframe{flex:1;width:100%;height:100%;border:0;background:var(--bg)}
563
- .md h1,.md h2,.md h3{margin:.4em 0 .3em;line-height:1.2}.md p{margin:.4em 0}.md img{max-width:100%}
564
- .md code,pre{font:12px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace}
565
- pre{margin:0;white-space:pre-wrap;word-break:break-word}
566
- pre.diff .add{color:var(--add)}pre.diff .del{color:var(--del)}pre.diff .hunk{color:var(--accent)}
567
- a{color:var(--accent)}
593
+ .card-body{flex:1;min-height:0;min-width:0;overflow:auto;padding:20px;overflow-wrap:anywhere;overscroll-behavior:contain;cursor:auto}
594
+ .card-body>:first-child,.md>:first-child{margin-top:0}.card-body>:last-child,.md>:last-child{margin-bottom:0}
595
+ .card iframe,#overlay-body iframe{flex:1;min-height:0;width:100%;height:100%;border:0;background:var(--bg)}
596
+ .md h1,.md h2,.md h3,.md h4,.md h5,.md h6{margin:1.4em 0 .6em;line-height:1.3;font-weight:650;letter-spacing:-.02em}
597
+ .md h1{font-size:26px}.md h2{font-size:21px}.md h3{font-size:18px}.md h4,.md h5,.md h6{font-size:15px}
598
+ .md p{margin:0 0 1em}.md img{max-width:100%;height:auto;border-radius:6px}
599
+ .md ul,.md ol{padding-left:1.6em;margin:0 0 1em}.md li+li{margin-top:.35em}.md li>ul,.md li>ol{margin-top:.4em;margin-bottom:.4em}
600
+ .md input[type=checkbox]{accent-color:var(--accent);margin-right:.5em}
601
+ .md blockquote{margin:1em 0;padding:10px 16px;border-left:3px solid var(--accent);background:color-mix(in srgb,var(--accent) 7%,var(--panel));color:var(--soft)}
602
+ .md blockquote>:last-child{margin-bottom:0}.md hr{border:0;border-top:1px solid var(--line);margin:24px 0}
603
+ .md code,pre{font:13px/1.65 ui-monospace,SFMono-Regular,Menlo,monospace;tab-size:2}
604
+ .md :not(pre)>code{padding:2px 5px;border:1px solid var(--line);border-radius:4px;background:color-mix(in srgb,var(--soft) 8%,var(--panel))}
605
+ pre{margin:0 0 16px;padding:14px 16px;border:1px solid var(--line);border-radius:8px;background:color-mix(in srgb,var(--soft) 5%,var(--panel));white-space:pre;overflow:auto;overflow-wrap:normal;overscroll-behavior:contain}
606
+ .prose{white-space:pre-wrap}.table-scroll{width:100%;max-width:100%;overflow:auto;margin:16px 0;border:1px solid var(--line);border-radius:8px;overscroll-behavior:contain}
607
+ .table-scroll table{border-collapse:separate;border-spacing:0;width:100%;font-size:13px;line-height:1.55;font-variant-numeric:tabular-nums}
608
+ .table-scroll th,.table-scroll td{padding:10px 14px;min-width:90px;max-width:36ch;border-bottom:1px solid var(--line);border-right:1px solid var(--line);vertical-align:top;overflow-wrap:normal;white-space:pre-wrap}
609
+ .table-scroll th{font-weight:650;background:color-mix(in srgb,var(--soft) 10%,var(--panel))}.table-scroll th:not([align]){text-align:left}
610
+ .table-scroll tr:nth-child(even) td{background:color-mix(in srgb,var(--soft) 4%,var(--panel))}
611
+ .table-scroll tr:last-child td{border-bottom:0}.table-scroll tr>:last-child{border-right:0}
612
+ pre.diff .add{color:var(--add);background:color-mix(in srgb,var(--add) 9%,transparent)}pre.diff .del{color:var(--del);background:color-mix(in srgb,var(--del) 9%,transparent)}pre.diff .hunk{color:var(--accent)}
613
+ a{color:var(--accent);text-underline-offset:3px}
568
614
  .dim{color:var(--soft)}
569
- .kv{border-collapse:collapse;font-size:12px}.kv th{text-align:left;color:var(--soft);padding:2px 10px 2px 0;vertical-align:top}.kv td{padding:2px 0}
570
- .list{margin:0;padding-left:18px}
571
- .image{max-width:100%;max-height:100%;object-fit:contain;display:block;margin:auto}
572
- .placeholder{padding:12px;border:1px dashed var(--line);border-radius:8px;color:var(--soft);font-size:12px}
615
+ .kv{border-collapse:collapse;width:100%;font-size:14px;table-layout:fixed}.kv th{text-align:left;color:var(--soft);width:30%;font-weight:500}.kv th,.kv td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:top;white-space:pre-wrap}.kv tr:last-child>*{border-bottom:0}
616
+ .list{list-style:none;margin:0;padding:0}.list li{padding:12px 0;border-bottom:1px solid var(--line)}.list li:last-child{border-bottom:0}.list p{margin:6px 0 0;color:var(--soft)}
617
+ .image-figure{margin:0;height:100%;display:flex;flex-direction:column;gap:12px}.image{flex:1;min-height:0;max-width:100%;width:100%;object-fit:contain;display:block}.image-figure figcaption{flex-shrink:0;font-size:13px;text-align:center}
618
+ .placeholder{padding:16px;border:1px dashed var(--line);border-radius:8px;color:var(--soft);font-size:14px;line-height:1.6}
619
+ details{margin-top:16px}summary{cursor:pointer;color:var(--soft);margin-bottom:12px}
573
620
  #overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;padding:24px;z-index:10}
574
621
  #overlay[hidden]{display:none}
575
622
  #overlay .sheet{width:min(1100px,100%);height:min(90vh,100%);display:flex;flex-direction:column;background:var(--panel);border:1px solid var(--line);border-radius:12px;overflow:hidden}
576
623
  #overlay .sheet>header{display:flex;justify-content:space-between;align-items:center;padding:10px 14px;border-bottom:1px solid var(--line)}
577
- #overlay-body{flex:1;min-height:0;overflow:auto;padding:14px;display:flex;flex-direction:column}
624
+ #overlay-body{flex:1;min-height:0;overflow:hidden;display:flex;flex-direction:column}
625
+ #overlay-body .card-body{padding:28px 32px}#overlay-body .md{max-width:960px;margin:0 auto}
626
+ @media(max-width:600px){#overlay{padding:8px}#overlay .sheet{height:100%}#overlay-body .card-body{padding:18px}.bar{padding:0 10px}.bar .name span{display:none}}
578
627
  `;
579
628
 
580
629
  const EXPORT_VIEWER_JS = `(() => {
@@ -606,12 +655,15 @@ for (const card of ordered) {
606
655
  const el = document.createElement('section');
607
656
  el.className = 'card' + (card.group ? ' group' : '');
608
657
  el.dataset.nodeId = card.id;
658
+ el.dataset.nodeType = card.kind;
609
659
  Object.assign(el.style, { left: card.x + 'px', top: card.y + 'px', width: card.w + 'px', height: card.h + 'px' });
610
- const title = document.createElement('div');
660
+ const title = document.createElement(card.group ? 'div' : 'button');
611
661
  title.className = 'card-title';
612
662
  title.textContent = card.title;
613
663
  el.appendChild(title);
614
664
  if (!card.group) {
665
+ title.type = 'button';
666
+ title.setAttribute('aria-label', 'Expand ' + card.title);
615
667
  el.appendChild(body(card));
616
668
  title.addEventListener('click', () => expand(card));
617
669
  }
@@ -653,6 +705,7 @@ const zoomAt = (factor, cx, cy) => {
653
705
  apply();
654
706
  };
655
707
  stage.addEventListener('wheel', (e) => {
708
+ if (e.target.closest('.card-body') && !e.ctrlKey && !e.metaKey) return;
656
709
  e.preventDefault();
657
710
  const r = stage.getBoundingClientRect();
658
711
  zoomAt(Math.exp(-e.deltaY * 0.0015), e.clientX - r.left, e.clientY - r.top);
@@ -673,11 +726,23 @@ stage.addEventListener('pointerup', () => { drag = null; stage.classList.remove(
673
726
  document.querySelector('[data-zoom=in]').addEventListener('click', () => zoomAt(1.25, stage.clientWidth / 2, stage.clientHeight / 2));
674
727
  document.querySelector('[data-zoom=out]').addEventListener('click', () => zoomAt(0.8, stage.clientWidth / 2, stage.clientHeight / 2));
675
728
  document.querySelector('[data-zoom=fit]').addEventListener('click', fit);
676
- const close = () => { overlay.hidden = true; document.getElementById('overlay-body').replaceChildren(); };
729
+ let invoker = null;
730
+ const close = () => {
731
+ if (overlay.hidden) return;
732
+ overlay.hidden = true;
733
+ document.getElementById('overlay-body').replaceChildren();
734
+ stage.inert = false;
735
+ document.querySelector('.bar').inert = false;
736
+ invoker?.focus({ preventScroll: true });
737
+ };
677
738
  function expand(card) {
739
+ invoker = document.activeElement;
678
740
  document.getElementById('overlay-title').textContent = card.title;
679
741
  document.getElementById('overlay-body').replaceChildren(body(card));
680
742
  overlay.hidden = false;
743
+ stage.inert = true;
744
+ document.querySelector('.bar').inert = true;
745
+ document.getElementById('overlay-close').focus();
681
746
  }
682
747
  document.getElementById('overlay-close').addEventListener('click', close);
683
748
  overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
@@ -44,6 +44,7 @@ import type { BackupFile, BackupStatus } from './backup.js';
44
44
  import type { ExportManifest } from './board-export.js';
45
45
  import { onFileNodeChanged } from './file-watcher.js';
46
46
  import { findOpenCanvasPosition, computeGroupBounds } from './placement.js';
47
+ import { overlapsAny } from '../shared/placement.js';
47
48
  import { searchNodes, buildSpatialContext } from './spatial-analysis.js';
48
49
  import { mutationHistory, diffLayouts, formatDiff } from './mutation-history.js';
49
50
  import { recomputeCodeGraph, buildCodeGraphSummary, formatCodeGraph } from './code-graph.js';
@@ -741,14 +742,19 @@ export class PmxCanvas extends EventEmitter {
741
742
  } {
742
743
  if (canvasState.activeBoardId !== input.boardId) throw new Error('The captured target board is not active.');
743
744
  assertInsideFence('node.add', createTarget(input));
745
+ const obstacles = canvasState.getLayout().nodes;
746
+ const requested = { x: input.x ?? 40, y: input.y ?? 80 };
747
+ const position = overlapsAny(requested, 440, 360, obstacles, 48)
748
+ ? findOpenCanvasPosition(obstacles, 440, 360, 48)
749
+ : requested;
750
+ assertInsideFence('node.add', createTarget(position));
744
751
  const attachment = canvasState.storeAttachment({ ...input, mime: input.mime ?? 'application/octet-stream' });
745
752
  if (!attachment) throw new Error('Board not found.');
746
753
  const { id: nodeId, node } = addCanvasNode({
747
754
  type: 'file',
748
755
  title: input.name,
749
756
  data: { attachmentId: attachment.id, mime: attachment.mime, size: attachment.size },
750
- x: input.x,
751
- y: input.y,
757
+ ...position,
752
758
  defaultWidth: 440,
753
759
  defaultHeight: 360,
754
760
  fileMode: 'auto',
@@ -840,11 +846,7 @@ export class PmxCanvas extends EventEmitter {
840
846
 
841
847
  /** Open a board, or Home with null. */
842
848
  async openBoard(id: string | null): Promise<void> {
843
- await executeOperation(
844
- 'board.open',
845
- { id },
846
- { source: 'sdk', fromWorkbench: true, humanAuthor: true, suppressAutoGhost: true },
847
- );
849
+ await executeOperation('board.open', { id }, { source: 'sdk', suppressAutoGhost: true });
848
850
  }
849
851
 
850
852
  async deleteBoard(id: string): Promise<void> {
@@ -151,13 +151,14 @@ export const compositeToolDefinitions: CompositeToolDefinition[] = [
151
151
  {
152
152
  toolName: 'canvas_board',
153
153
  description:
154
- 'Boards in this workspace — the library of the human\'s and your working memory, filed in categories on Home. Your writes always go to the board the human has open (activeBoardId); on Home (no board open) your first write opens their most recent board, or a new one when there are none. Action "list" returns every board (with its category), most recently opened first; "get" reads one (default: the open board); "create" makes a new empty board WITHOUT opening it (optional category); "update" renames a board, files it under a category (category "" removes it), or designates its README via readmeNodeId. As part of normal board authoring, read the board metadata; if readmeNodeId is null, choose or create a concise markdown introduction explaining its purpose and key context, then designate that node with "update". Preserve an existing README unless the human asks to change it; humans can override the designation manually. Only the human opens, switches or deletes boards — if you need another board, ask them in the chat to open it.',
155
- actionSummary: 'list | get | create | create-from | update',
154
+ 'Boards in this workspace — the library of the human\'s and your working memory, filed in categories on Home. Your writes always go to the open board (activeBoardId); on Home (no board open) your first write opens the most recent board, or a new one when there are none. Action "list" returns every board (with its category), most recently opened first; "get" reads one (default: the open board); "create" makes a new empty board WITHOUT opening it (optional category); "open" explicitly switches the shared workbench to id (null or omitted opens Home), changing the visible board and subsequent write target. After creating a board, open its returned id and verify activeBoardId before writing; another participant can switch the board later. "update" renames a board, files it under a category (category "" removes it), or designates its README via readmeNodeId. As part of normal board authoring, read the board metadata; if readmeNodeId is null, choose or create a concise markdown introduction explaining its purpose and key context, then designate that node with "update". Preserve an existing README unless the human asks to change it; humans can override the designation manually. Only the human deletes boards.',
155
+ actionSummary: 'list | get | create | create-from | open | update',
156
156
  actions: {
157
157
  list: 'board.list',
158
158
  get: 'board.get',
159
159
  create: 'board.create',
160
160
  'create-from': 'board.create-from',
161
+ open: 'board.open',
161
162
  update: 'board.update',
162
163
  },
163
164
  },
@@ -27,13 +27,17 @@ const presenceGetOperation = defineOperation<z.infer<typeof emptySchema>, Record
27
27
  // One schema (agent-presence.ts) — only the descriptions are added here.
28
28
  const presenceSetShape = {
29
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'),
30
+ agentId: PRESENCE_SET_SHAPE.agentId.describe(
31
+ 'Stable identity for a genuinely separate agent, not a task name. Omit for the main agent when a host adapter already owns its session.',
32
+ ),
33
+ label: PRESENCE_SET_SHAPE.label.describe('Display name for this writer; use detail for the current task'),
32
34
  phase: PRESENCE_SET_SHAPE.phase.describe('idle | thinking | tooling | waiting-approval'),
33
35
  detail: PRESENCE_SET_SHAPE.detail.describe('Tool / step name shown beside the phase'),
34
36
  focusNodeId: PRESENCE_SET_SHAPE.focusNodeId.describe('Node the agent is working on'),
35
37
  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'),
38
+ attached: PRESENCE_SET_SHAPE.attached.describe(
39
+ 'true attaches a session; false detaches it. Read presence first and reuse your host adapter session instead of attaching another. Only detach sessions you own.',
40
+ ),
37
41
  contextUsage: PRESENCE_SET_SHAPE.contextUsage.describe(
38
42
  "The agent's real token window as {used,total} — shown on the board's context meter instead of the pinned-context estimate (null clears it)",
39
43
  ),
@@ -2,9 +2,9 @@
2
2
  * Many boards (plan 012): board.list / board.get / board.create /
3
3
  * board.update / board.open / board.delete.
4
4
  *
5
- * Agents list, read and create boards. Opening, switching and deleting are the
6
- * human's (HUMAN_ONLY_OPS in registry.ts): an agent that wants another board
7
- * asks. Every change broadcasts `boards-changed` with the full list.
5
+ * Agents can explicitly open boards as well as list, read and create them.
6
+ * Deletion requires the human (HUMAN_ONLY_OPS in registry.ts).
7
+ * Every change broadcasts `boards-changed` with the full list.
8
8
  *
9
9
  * Route-order note: board.open (POST /api/canvas/boards/open) is registered
10
10
  * before any `/api/canvas/boards/:id` POST route so ':id' cannot swallow it.
@@ -171,7 +171,7 @@ const boardCreateOperation = defineOperation<z.infer<typeof createSchema>, Recor
171
171
  mcp: {
172
172
  toolName: 'canvas_create_board',
173
173
  description:
174
- 'Create a new empty board. It is NOT opened: only the human opens boards, so ask them to open it before writing to it.',
174
+ 'Create a new empty board without opening it. Use canvas_board action "open" with the returned board id before writing to it.',
175
175
  extraShape: {
176
176
  name: z.string().describe('Board name'),
177
177
  category: z.string().optional().describe('Folder path on Home, e.g. Engineering/Canvas'),
@@ -186,7 +186,7 @@ const boardCreateOperation = defineOperation<z.infer<typeof createSchema>, Recor
186
186
  },
187
187
  });
188
188
 
189
- // ── board.open (human only) ───────────────────────────────────
189
+ // ── board.open ────────────────────────────────────────────────
190
190
 
191
191
  const openShape = {
192
192
  id: z.unknown().optional().describe('Board id to open; null or omitted opens Home'),
@@ -199,6 +199,13 @@ const boardOpenOperation = defineOperation<z.infer<typeof openSchema>, Record<st
199
199
  input: openSchema,
200
200
  inputShape: openShape,
201
201
  http: { method: 'POST', path: '/api/canvas/boards/open' },
202
+ mcp: {
203
+ toolName: 'canvas_open_board',
204
+ description:
205
+ 'Open a board in the shared workbench, or Home with null. Changes the visible board and the target of subsequent writes. Verify activeBoardId in the response before authoring.',
206
+ extraShape: { id: z.string().nullable().optional().describe('Board id to open; null or omitted opens Home') },
207
+ formatResult: jsonResult,
208
+ },
202
209
  handler: (input, ctx) => {
203
210
  const id = typeof input.id === 'string' && input.id.trim() ? input.id.trim() : null;
204
211
  if (!openCanvasBoard(id).ok) throw new OperationError(`Board "${id}" not found.`, 404);
@@ -405,15 +405,9 @@ export async function executeOperation(
405
405
  );
406
406
  }
407
407
 
408
- /** Opening, switching and deleting boards is the human's; an agent asks for another board. */
409
- const HUMAN_ONLY_OPS = new Set(['board.open', 'board.delete', 'import.request', 'import.cancel', 'import.commit']);
410
- const TRUSTED_HUMAN_ONLY_OPS = new Set([
411
- 'board.open',
412
- 'board.delete',
413
- 'import.request',
414
- 'import.cancel',
415
- 'import.commit',
416
- ]);
408
+ /** Board deletion and import review actions require the human in the workbench. */
409
+ const HUMAN_ONLY_OPS = new Set(['board.delete', 'import.request', 'import.cancel', 'import.commit']);
410
+ const TRUSTED_HUMAN_ONLY_OPS = new Set(['board.delete', 'import.request', 'import.cancel', 'import.commit']);
417
411
 
418
412
  /**
419
413
  * Writes that need a board beyond the layout mutations: canvas-bound AX items,
@@ -443,10 +437,7 @@ function needsOpenBoard(op: Operation): boolean {
443
437
  async function executeOperationInner(name: string, rawInput: unknown, meta: ExecuteOperationMeta): Promise<unknown> {
444
438
  const op = getOperation(name);
445
439
  if (HUMAN_ONLY_OPS.has(name) && !meta.fromWorkbench) {
446
- throw new OperationError(
447
- 'Only the human opens or deletes boards. Ask them in the chat to open the board you need.',
448
- 403,
449
- );
440
+ throw new OperationError('This action requires the human in the workbench.', 403);
450
441
  }
451
442
  if (TRUSTED_HUMAN_ONLY_OPS.has(name) && !meta.humanAuthor) {
452
443
  throw new OperationError('This action requires trusted human authority.', 403);
@@ -61,6 +61,7 @@ import { normalizeExtAppToolResult } from './ext-app-tool-result.js';
61
61
  import { getMcpAppHostSnapshot } from './mcp-app-host.js';
62
62
  import { closeMcpAppSession, closeAllMcpAppSessions } from './mcp-app-runtime.js';
63
63
  import { findOpenCanvasPosition } from './placement.js';
64
+ import { overlapsAny } from '../shared/placement.js';
64
65
  import { mutationHistory } from './mutation-history.js';
65
66
  import { buildAgentContextPreamble } from './agent-context.js';
66
67
  import { buildCanvasAxSurfaceSnapshot } from './ax-context.js';
@@ -1736,10 +1737,12 @@ async function handleJsonRenderView(url: URL): Promise<Response> {
1736
1737
  const html = await buildJsonRenderViewerHtml({
1737
1738
  title,
1738
1739
  spec,
1740
+ nodeId,
1741
+ stateToken: url.searchParams.get('stateToken') ?? undefined,
1739
1742
  ...(theme ? { theme } : {}),
1740
1743
  ...(display ? { display } : {}),
1741
1744
  ...(devtoolsEnabled ? { devtools: true } : {}),
1742
- ...(axToken ? { nodeId, axToken } : {}),
1745
+ ...(axToken ? { axToken } : {}),
1743
1746
  // Seed the read-side AX state (only for AX-enabled nodes) so specs can bind /ax.
1744
1747
  ...(axToken && axEnabled ? { axState: buildCanvasAxSurfaceSnapshot() } : {}),
1745
1748
  // Content-fit: report natural height (charts render intrinsic) so the node grows.
@@ -3474,19 +3477,25 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
3474
3477
  canvasState.storeAttachment({ boardId, name, mime, bytes }),
3475
3478
  );
3476
3479
  if (!attachment) return responseJson({ ok: false, error: 'Board not found.' }, 404);
3480
+ const obstacles = canvasState.getLayout().nodes;
3481
+ const position = overlapsAny({ x, y }, 440, 360, obstacles, 48)
3482
+ ? findOpenCanvasPosition(obstacles, 440, 360, 48)
3483
+ : { x, y };
3477
3484
  const { node } = withCurrentActor({ actor: 'human', source: 'browser' }, () =>
3478
3485
  addCanvasNode({
3479
3486
  type: 'file',
3480
3487
  title: name,
3481
3488
  data: { attachmentId: attachment.id, mime, size: attachment.size },
3482
- x,
3483
- y,
3489
+ ...position,
3484
3490
  defaultWidth: 440,
3485
3491
  defaultHeight: 360,
3486
3492
  fileMode: 'auto',
3487
3493
  }),
3488
3494
  );
3489
- const created = canvasState.createDocumentImport(attachment.id, { x, y: y + 400 });
3495
+ const created = canvasState.createDocumentImport(attachment.id, {
3496
+ x: node.position.x,
3497
+ y: node.position.y + 400,
3498
+ });
3490
3499
  const documentImport = created;
3491
3500
  emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
3492
3501
  return responseJson({ ok: true, attachment, nodeId: node.id, import: documentImport }, 201);
@@ -18,6 +18,9 @@ export const AX_SURFACE_ACK_SOURCE = 'pmx-canvas-ax-ack';
18
18
  /** Parent→surface pushes (theme updates, live AX state) into html/viewer iframes. */
19
19
  export const HTML_SURFACE_PUSH_SOURCE = 'pmx-canvas-html-node';
20
20
 
21
+ /** Tab-local JSON viewer state handoff, independent of AX capabilities. */
22
+ export const JSON_VIEWER_STATE_SOURCE = 'pmx-canvas-json-state';
23
+
21
24
  /** Ext-app boot beacon: the iframe's scripts executed (WebKit watchdog liveness). */
22
25
  export const EXT_APP_BOOT_BEACON_SOURCE = 'pmx-canvas-ext-app-alive';
23
26
 
@@ -0,0 +1,75 @@
1
+ export interface DelimitedTable {
2
+ header: string[];
3
+ /** Rectangular: every row padded (short) or truncated (long) to the header width. */
4
+ rows: string[][];
5
+ }
6
+
7
+ /** Column delimiter for a delimited-data path, or null when the file is not one. */
8
+ export function delimiterForPath(path: string): string | null {
9
+ const lower = path.toLowerCase();
10
+ if (lower.endsWith('.csv')) return ',';
11
+ if (lower.endsWith('.tsv')) return '\t';
12
+ return null;
13
+ }
14
+
15
+ /**
16
+ * RFC4180-ish delimited-text parser. Quoted fields may contain the delimiter,
17
+ * newlines, and `""` escapes; CRLF, LF and lone CR all end a record; a trailing
18
+ * newline produces no phantom empty row. Ragged records are squared off against
19
+ * the header width instead of throwing.
20
+ */
21
+ export function parseDelimitedText(text: string, delimiter: string): DelimitedTable {
22
+ const records: string[][] = [];
23
+ let row: string[] = [];
24
+ let field = '';
25
+ let quoted = false;
26
+ // Tracks whether the current record consumed anything — distinguishes a real
27
+ // empty-quoted record from the dangling state after a terminating newline.
28
+ let started = false;
29
+
30
+ for (let i = 0; i < text.length; i++) {
31
+ const char = text[i];
32
+ started = true;
33
+ if (quoted) {
34
+ if (char === '"') {
35
+ if (text[i + 1] === '"') {
36
+ field += '"';
37
+ i++;
38
+ } else {
39
+ quoted = false;
40
+ }
41
+ } else if (char === '\r' && text[i + 1] === '\n') {
42
+ field += '\n';
43
+ i++;
44
+ } else {
45
+ field += char;
46
+ }
47
+ continue;
48
+ }
49
+ if (char === '"' && field === '') {
50
+ quoted = true;
51
+ } else if (char === delimiter) {
52
+ row.push(field);
53
+ field = '';
54
+ } else if (char === '\n' || char === '\r') {
55
+ if (char === '\r' && text[i + 1] === '\n') i++;
56
+ row.push(field);
57
+ field = '';
58
+ records.push(row);
59
+ row = [];
60
+ started = false;
61
+ } else {
62
+ field += char;
63
+ }
64
+ }
65
+ if (started) {
66
+ row.push(field);
67
+ records.push(row);
68
+ }
69
+
70
+ const header = records.shift() ?? [];
71
+ return {
72
+ header,
73
+ rows: records.map((record) => Array.from({ length: header.length }, (_, i) => record[i] ?? '')),
74
+ };
75
+ }