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.
- package/.github/extensions/pmx-canvas/extension.mjs +149 -70
- package/CHANGELOG.md +32 -1
- package/Readme.md +40 -13
- package/dist/canvas/global.css +94 -15
- package/dist/canvas/index.js +76 -76
- package/dist/types/cli/daemon.d.ts +5 -5
- package/dist/types/cli/shared.d.ts +3 -1
- package/dist/types/client/canvas/ExternalWriters.d.ts +3 -3
- package/dist/types/client/icons.d.ts +15 -0
- package/dist/types/client/nodes/ExtAppFrame.d.ts +1 -1
- package/dist/types/client/nodes/FileNode.d.ts +0 -12
- package/dist/types/shared/delimited-text.d.ts +14 -0
- package/docs/cli.md +14 -2
- package/docs/http-api.md +12 -8
- package/docs/mcp.md +8 -1
- package/docs/sdk.md +2 -2
- package/package.json +1 -1
- package/skills/pmx-canvas/SKILL.md +35 -14
- package/skills/pmx-canvas/evals/evals.json +171 -0
- package/skills/pmx-canvas/references/full-reference.md +6 -1
- package/skills/pmx-canvas/references/github-copilot-app-adapter.md +17 -3
- package/skills/pmx-canvas/references/installing-pmx-canvas.md +4 -4
- package/skills/pmx-canvas/references/tours-and-recording.md +69 -0
- package/skills/pmx-canvas-testing/SKILL.md +7 -2
- package/src/cli/commands/apps.ts +1 -1
- package/src/cli/commands/backup.ts +15 -4
- package/src/cli/commands/boards.ts +16 -3
- package/src/cli/commands/export.ts +9 -2
- package/src/cli/daemon.ts +37 -7
- package/src/cli/shared.ts +11 -2
- package/src/client/App.tsx +0 -1
- package/src/client/canvas/AttentionHistory.tsx +2 -1
- package/src/client/canvas/AttentionToast.tsx +5 -3
- package/src/client/canvas/BoardSwitcher.tsx +3 -3
- package/src/client/canvas/CanvasNode.tsx +17 -9
- package/src/client/canvas/CommandBar.tsx +2 -2
- package/src/client/canvas/ContextMenu.tsx +0 -1
- package/src/client/canvas/ContextPinBar.tsx +3 -1
- package/src/client/canvas/ExpandedNodeOverlay.tsx +3 -3
- package/src/client/canvas/ExportDialog.tsx +3 -1
- package/src/client/canvas/ExternalWriters.tsx +4 -3
- package/src/client/canvas/FeedbackDialog.tsx +2 -1
- package/src/client/canvas/IntentLayer.tsx +2 -2
- package/src/client/canvas/Presentation.tsx +39 -9
- package/src/client/canvas/SelectionBar.tsx +2 -4
- package/src/client/canvas/SessionPanel.tsx +6 -4
- package/src/client/canvas/SessionReceipt.tsx +2 -1
- package/src/client/canvas/SnapshotPanel.tsx +3 -2
- package/src/client/canvas/ToolRail.tsx +2 -1
- package/src/client/canvas/TopBar.tsx +2 -2
- package/src/client/icons.tsx +118 -1
- package/src/client/nodes/AttachmentNode.tsx +27 -30
- package/src/client/nodes/BoardNode.tsx +2 -1
- package/src/client/nodes/ExtAppFrame.tsx +3 -1
- package/src/client/nodes/FileNode.tsx +4 -78
- package/src/client/nodes/ImageNode.tsx +2 -1
- package/src/client/nodes/MarkdownNode.tsx +2 -1
- package/src/client/nodes/PromptNode.tsx +3 -1
- package/src/client/state/attention-bridge.ts +3 -1
- package/src/client/state/sse-bridge.ts +3 -1
- package/src/client/theme/global.css +94 -15
- package/src/mcp/server.ts +2 -2
- package/src/server/agent-presence.ts +17 -11
- package/src/server/board-export.ts +97 -32
- package/src/server/index.ts +9 -7
- package/src/server/operations/composites.ts +3 -2
- package/src/server/operations/ops/ax-presence.ts +7 -3
- package/src/server/operations/ops/boards.ts +12 -5
- package/src/server/operations/ops/imports.ts +19 -3
- package/src/server/operations/registry.ts +4 -13
- package/src/server/server.ts +10 -3
- 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(
|
|
31
|
-
|
|
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(
|
|
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
|
|
6
|
-
* human
|
|
7
|
-
*
|
|
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
|
|
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
|
|
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:
|
|
205
|
-
y:
|
|
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
|
-
/**
|
|
409
|
-
const HUMAN_ONLY_OPS = new Set(['board.
|
|
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);
|
package/src/server/server.ts
CHANGED
|
@@ -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
|
-
|
|
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, {
|
|
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
|
+
}
|