pmx-canvas 0.7.0 → 0.7.2

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 (72) hide show
  1. package/.github/extensions/pmx-canvas/extension.mjs +149 -70
  2. package/CHANGELOG.md +32 -1
  3. package/Readme.md +40 -13
  4. package/dist/canvas/global.css +94 -15
  5. package/dist/canvas/index.js +76 -76
  6. package/dist/types/cli/daemon.d.ts +5 -5
  7. package/dist/types/cli/shared.d.ts +3 -1
  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/shared/delimited-text.d.ts +14 -0
  13. package/docs/cli.md +14 -2
  14. package/docs/http-api.md +12 -8
  15. package/docs/mcp.md +8 -1
  16. package/docs/sdk.md +2 -2
  17. package/package.json +1 -1
  18. package/skills/pmx-canvas/SKILL.md +35 -14
  19. package/skills/pmx-canvas/evals/evals.json +171 -0
  20. package/skills/pmx-canvas/references/full-reference.md +6 -1
  21. package/skills/pmx-canvas/references/github-copilot-app-adapter.md +17 -3
  22. package/skills/pmx-canvas/references/installing-pmx-canvas.md +4 -4
  23. package/skills/pmx-canvas/references/tours-and-recording.md +69 -0
  24. package/skills/pmx-canvas-testing/SKILL.md +7 -2
  25. package/src/cli/commands/apps.ts +1 -1
  26. package/src/cli/commands/backup.ts +15 -4
  27. package/src/cli/commands/boards.ts +16 -3
  28. package/src/cli/commands/export.ts +9 -2
  29. package/src/cli/daemon.ts +37 -7
  30. package/src/cli/shared.ts +11 -2
  31. package/src/client/App.tsx +0 -1
  32. package/src/client/canvas/AttentionHistory.tsx +2 -1
  33. package/src/client/canvas/AttentionToast.tsx +5 -3
  34. package/src/client/canvas/BoardSwitcher.tsx +3 -3
  35. package/src/client/canvas/CanvasNode.tsx +17 -9
  36. package/src/client/canvas/CommandBar.tsx +2 -2
  37. package/src/client/canvas/ContextMenu.tsx +0 -1
  38. package/src/client/canvas/ContextPinBar.tsx +3 -1
  39. package/src/client/canvas/ExpandedNodeOverlay.tsx +3 -3
  40. package/src/client/canvas/ExportDialog.tsx +3 -1
  41. package/src/client/canvas/ExternalWriters.tsx +4 -3
  42. package/src/client/canvas/FeedbackDialog.tsx +2 -1
  43. package/src/client/canvas/IntentLayer.tsx +2 -2
  44. package/src/client/canvas/Presentation.tsx +39 -9
  45. package/src/client/canvas/SelectionBar.tsx +2 -4
  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 +2 -1
  50. package/src/client/canvas/TopBar.tsx +2 -2
  51. package/src/client/icons.tsx +118 -1
  52. package/src/client/nodes/AttachmentNode.tsx +27 -30
  53. package/src/client/nodes/BoardNode.tsx +2 -1
  54. package/src/client/nodes/ExtAppFrame.tsx +3 -1
  55. package/src/client/nodes/FileNode.tsx +4 -78
  56. package/src/client/nodes/ImageNode.tsx +2 -1
  57. package/src/client/nodes/MarkdownNode.tsx +2 -1
  58. package/src/client/nodes/PromptNode.tsx +3 -1
  59. package/src/client/state/attention-bridge.ts +3 -1
  60. package/src/client/state/sse-bridge.ts +3 -1
  61. package/src/client/theme/global.css +94 -15
  62. package/src/mcp/server.ts +2 -2
  63. package/src/server/agent-presence.ts +17 -11
  64. package/src/server/board-export.ts +97 -32
  65. package/src/server/index.ts +9 -7
  66. package/src/server/operations/composites.ts +3 -2
  67. package/src/server/operations/ops/ax-presence.ts +7 -3
  68. package/src/server/operations/ops/boards.ts +12 -5
  69. package/src/server/operations/ops/imports.ts +19 -3
  70. package/src/server/operations/registry.ts +4 -13
  71. package/src/server/server.ts +10 -3
  72. package/src/shared/delimited-text.ts +75 -0
@@ -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);
@@ -1,6 +1,8 @@
1
1
  import { z } from 'zod';
2
2
  import { canvasState } from '../../canvas-state.js';
3
- import { addCanvasNode } from '../../canvas-operations.js';
3
+ import { addCanvasNode, createCanvasGroup } from '../../canvas-operations.js';
4
+ import { findOpenCanvasPosition, overlapsAny } from '../../../shared/placement.js';
5
+ import { GROUP_PAD, GROUP_TITLEBAR_HEIGHT } from '../../placement.js';
4
6
  import {
5
7
  MAX_IMPORT_AGENT_DESCRIPTION_LENGTH,
6
8
  MAX_IMPORT_REASON_LENGTH,
@@ -192,6 +194,17 @@ const commit = defineOperation({
192
194
  throw new OperationError('Open the captured source board before committing this import.', 409);
193
195
  const a = canvasState.getAttachment(job.attachmentId);
194
196
  if (!a) throw new OperationError('Attachment not found.', 404);
197
+ const grouped = job.sections.length > 1;
198
+ const padX = grouped ? GROUP_PAD : 0;
199
+ const padY = grouped ? GROUP_PAD + GROUP_TITLEBAR_HEIGHT : 0;
200
+ const width = Math.min(job.sections.length, 2) * 440 - 40 + padX * 2;
201
+ const height = Math.ceil(job.sections.length / 2) * 340 - 60 + padY + padX;
202
+ // Reserve the whole grid, including its group frame, against current board
203
+ // geometry. Another import or edit may have occupied the drop-time anchor.
204
+ const obstacles = canvasState.getLayout().nodes;
205
+ const origin = overlapsAny(job.position, width, height, obstacles, 48)
206
+ ? findOpenCanvasPosition(obstacles, width, height, 48)
207
+ : job.position;
195
208
  const nodeIds = job.sections.map(
196
209
  (s, index) =>
197
210
  addCanvasNode({
@@ -201,17 +214,20 @@ const commit = defineOperation({
201
214
  data: {
202
215
  source: { attachmentId: a.id, filename: a.name, ...(s.reference ? { reference: s.reference } : {}) },
203
216
  },
204
- x: job.position.x + (index % 2) * 440,
205
- y: job.position.y + Math.floor(index / 2) * 340,
217
+ x: origin.x + padX + (index % 2) * 440,
218
+ y: origin.y + padY + Math.floor(index / 2) * 340,
206
219
  defaultWidth: 400,
207
220
  defaultHeight: 280,
208
221
  fileMode: 'auto',
209
222
  }).id,
210
223
  );
224
+ let groupId: string | undefined;
211
225
  try {
226
+ if (grouped) groupId = createCanvasGroup({ title: a.name, childIds: nodeIds }).id;
212
227
  const committed = canvasState.commitDocumentImport(i.id, nodeIds);
213
228
  return { ok: true, nodeIds, import: committed };
214
229
  } catch (error) {
230
+ if (groupId) canvasState.removeNode(groupId);
215
231
  for (const nodeId of nodeIds) canvasState.removeNode(nodeId);
216
232
  throw error;
217
233
  }
@@ -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';
@@ -3474,19 +3475,25 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
3474
3475
  canvasState.storeAttachment({ boardId, name, mime, bytes }),
3475
3476
  );
3476
3477
  if (!attachment) return responseJson({ ok: false, error: 'Board not found.' }, 404);
3478
+ const obstacles = canvasState.getLayout().nodes;
3479
+ const position = overlapsAny({ x, y }, 440, 360, obstacles, 48)
3480
+ ? findOpenCanvasPosition(obstacles, 440, 360, 48)
3481
+ : { x, y };
3477
3482
  const { node } = withCurrentActor({ actor: 'human', source: 'browser' }, () =>
3478
3483
  addCanvasNode({
3479
3484
  type: 'file',
3480
3485
  title: name,
3481
3486
  data: { attachmentId: attachment.id, mime, size: attachment.size },
3482
- x,
3483
- y,
3487
+ ...position,
3484
3488
  defaultWidth: 440,
3485
3489
  defaultHeight: 360,
3486
3490
  fileMode: 'auto',
3487
3491
  }),
3488
3492
  );
3489
- const created = canvasState.createDocumentImport(attachment.id, { x, y: y + 400 });
3493
+ const created = canvasState.createDocumentImport(attachment.id, {
3494
+ x: node.position.x,
3495
+ y: node.position.y + 400,
3496
+ });
3490
3497
  const documentImport = created;
3491
3498
  emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
3492
3499
  return responseJson({ ok: true, attachment, nodeId: node.id, import: documentImport }, 201);
@@ -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
+ }