pmx-canvas 0.1.28 → 0.1.29
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/CHANGELOG.md +32 -0
- package/dist/canvas/index.js +28 -28
- package/dist/types/server/index.d.ts +6 -0
- package/package.json +1 -1
- package/src/cli/agent.ts +13 -0
- package/src/client/nodes/LedgerNode.tsx +39 -5
- package/src/json-render/server.ts +18 -1
- package/src/mcp/canvas-access.ts +17 -1
- package/src/mcp/server.ts +6 -3
- package/src/server/index.ts +7 -1
|
@@ -21,6 +21,12 @@ export declare class PmxCanvas extends EventEmitter {
|
|
|
21
21
|
start(options?: {
|
|
22
22
|
open?: boolean;
|
|
23
23
|
automationWebView?: boolean | CanvasAutomationWebViewOptions;
|
|
24
|
+
/**
|
|
25
|
+
* Bind a nearby free port when the preferred one is taken instead of
|
|
26
|
+
* failing. Default false (an explicit SDK port is honored exactly); the
|
|
27
|
+
* MCP auto-start opts in so a daemon already on the port can't crash it.
|
|
28
|
+
*/
|
|
29
|
+
allowPortFallback?: boolean;
|
|
24
30
|
}): Promise<void>;
|
|
25
31
|
stop(): void;
|
|
26
32
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmx-canvas",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.29",
|
|
4
4
|
"description": "Spatial canvas workbench for coding agents — infinite 2D canvas with agent-native CLI, MCP integration, nodes, edges, file watching, and snapshots",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/server/index.ts",
|
package/src/cli/agent.ts
CHANGED
|
@@ -1082,11 +1082,24 @@ const RESOURCE_COMMAND_ALIASES: Record<string, Record<string, string>> = {
|
|
|
1082
1082
|
delete: 'remove',
|
|
1083
1083
|
rm: 'remove',
|
|
1084
1084
|
},
|
|
1085
|
+
ax: {
|
|
1086
|
+
// Single-subcommand AX groups: the bare verb maps to its only action so
|
|
1087
|
+
// `ax event` / `ax evidence` suggest the full command instead of erroring.
|
|
1088
|
+
event: 'event add',
|
|
1089
|
+
evidence: 'evidence add',
|
|
1090
|
+
},
|
|
1085
1091
|
};
|
|
1086
1092
|
const RESOURCE_SUBCOMMAND_HINTS: Record<string, Record<string, string>> = {
|
|
1087
1093
|
node: {
|
|
1088
1094
|
pin: 'Use the top-level pin command instead: pmx-canvas pin <node-id>',
|
|
1089
1095
|
},
|
|
1096
|
+
ax: {
|
|
1097
|
+
// Multi-subcommand AX groups: point at the available actions.
|
|
1098
|
+
host: 'Pick an action: pmx-canvas ax host report | pmx-canvas ax host status',
|
|
1099
|
+
work: 'Pick an action: pmx-canvas ax work add | update | list',
|
|
1100
|
+
approval: 'Pick an action: pmx-canvas ax approval request | resolve | list',
|
|
1101
|
+
review: 'Pick an action: pmx-canvas ax review add | list',
|
|
1102
|
+
},
|
|
1090
1103
|
};
|
|
1091
1104
|
|
|
1092
1105
|
function cmd(
|
|
@@ -1,12 +1,25 @@
|
|
|
1
1
|
import type { CanvasNodeState } from '../types';
|
|
2
2
|
|
|
3
|
+
// Layout/internal metadata that lives in node.data but is not ledger content.
|
|
4
|
+
const HIDDEN_LEDGER_KEYS = new Set(['title', '__type', 'content', 'strictSize', 'arrangeLocked']);
|
|
5
|
+
|
|
3
6
|
export function LedgerNode({ node }: { node: CanvasNodeState }) {
|
|
4
7
|
const data = node.data as Record<string, unknown>;
|
|
5
8
|
|
|
6
|
-
//
|
|
7
|
-
|
|
9
|
+
// Body text renders as a log: one line per entry. CLI flags frequently deliver
|
|
10
|
+
// a literal "\n" (backslash-n, the shell does not expand it inside quotes)
|
|
11
|
+
// rather than a real newline, so split on both — plus CR/CRLF — instead of
|
|
12
|
+
// dropping the whole string on one wrapped line.
|
|
13
|
+
const rawContent = typeof data.content === 'string' ? data.content : '';
|
|
14
|
+
const lines = rawContent
|
|
15
|
+
.split(/\r\n|\r|\n|\\n/)
|
|
16
|
+
.map((line) => line.trimEnd())
|
|
17
|
+
.filter((line) => line.length > 0);
|
|
18
|
+
|
|
19
|
+
// Any remaining non-internal keys render as structured key/value rows.
|
|
20
|
+
const entries = Object.entries(data).filter(([key]) => !HIDDEN_LEDGER_KEYS.has(key));
|
|
8
21
|
|
|
9
|
-
if (entries.length === 0) {
|
|
22
|
+
if (lines.length === 0 && entries.length === 0) {
|
|
10
23
|
return (
|
|
11
24
|
<div style={{ color: 'var(--c-dim)', fontSize: '12px', fontStyle: 'italic' }}>No ledger data</div>
|
|
12
25
|
);
|
|
@@ -14,20 +27,41 @@ export function LedgerNode({ node }: { node: CanvasNodeState }) {
|
|
|
14
27
|
|
|
15
28
|
return (
|
|
16
29
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px', fontSize: '12px' }}>
|
|
30
|
+
{lines.length > 0 && (
|
|
31
|
+
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
|
32
|
+
{lines.map((line, i) => (
|
|
33
|
+
<div
|
|
34
|
+
key={i}
|
|
35
|
+
style={{
|
|
36
|
+
padding: '3px 0',
|
|
37
|
+
borderBottom: i < lines.length - 1 ? '1px solid rgba(45,55,90,0.3)' : 'none',
|
|
38
|
+
color: 'var(--c-text)',
|
|
39
|
+
fontFamily: 'var(--mono)',
|
|
40
|
+
fontSize: '11px',
|
|
41
|
+
whiteSpace: 'pre-wrap',
|
|
42
|
+
wordBreak: 'break-word',
|
|
43
|
+
}}
|
|
44
|
+
>
|
|
45
|
+
{line}
|
|
46
|
+
</div>
|
|
47
|
+
))}
|
|
48
|
+
</div>
|
|
49
|
+
)}
|
|
17
50
|
{entries.map(([key, value]) => (
|
|
18
51
|
<div
|
|
19
52
|
key={key}
|
|
20
53
|
style={{
|
|
21
54
|
display: 'flex',
|
|
22
55
|
justifyContent: 'space-between',
|
|
56
|
+
gap: '8px',
|
|
23
57
|
padding: '3px 0',
|
|
24
58
|
borderBottom: '1px solid rgba(45,55,90,0.3)',
|
|
25
59
|
}}
|
|
26
60
|
>
|
|
27
|
-
<span style={{ color: 'var(--c-muted)', fontSize: '11px' }}>
|
|
61
|
+
<span style={{ color: 'var(--c-muted)', fontSize: '11px', flexShrink: 0 }}>
|
|
28
62
|
{key.replace(/([A-Z])/g, ' $1').replace(/^./, (s) => s.toUpperCase())}
|
|
29
63
|
</span>
|
|
30
|
-
<span style={{ color: 'var(--c-text)', fontFamily: 'var(--mono)', fontSize: '11px' }}>
|
|
64
|
+
<span style={{ color: 'var(--c-text)', fontFamily: 'var(--mono)', fontSize: '11px', textAlign: 'right', wordBreak: 'break-word' }}>
|
|
31
65
|
{typeof value === 'object' ? JSON.stringify(value) : String(value ?? '—')}
|
|
32
66
|
</span>
|
|
33
67
|
</div>
|
|
@@ -563,6 +563,20 @@ function collectDataKeys(data: Array<Record<string, unknown>>): Set<string> {
|
|
|
563
563
|
return keys;
|
|
564
564
|
}
|
|
565
565
|
|
|
566
|
+
/**
|
|
567
|
+
* Pick the first candidate key that actually exists in the data. Lets a chart
|
|
568
|
+
* accept a conventional synonym (e.g. a bullet chart's "actual" measure as well
|
|
569
|
+
* as "value") without an explicit valueKey, instead of failing the data-key
|
|
570
|
+
* check on a reasonable guess.
|
|
571
|
+
*/
|
|
572
|
+
function firstPresentDataKey(
|
|
573
|
+
data: Array<Record<string, unknown>>,
|
|
574
|
+
candidates: string[],
|
|
575
|
+
): string | undefined {
|
|
576
|
+
const keys = collectDataKeys(data);
|
|
577
|
+
return candidates.find((key) => keys.has(key));
|
|
578
|
+
}
|
|
579
|
+
|
|
566
580
|
function assertGraphDataKeys(
|
|
567
581
|
data: Array<Record<string, unknown>>,
|
|
568
582
|
chartType: GraphChartType,
|
|
@@ -729,7 +743,10 @@ export function buildGraphSpec(input: GraphNodeInput): JsonRenderSpec {
|
|
|
729
743
|
}
|
|
730
744
|
case 'BulletChart': {
|
|
731
745
|
chartProps.labelKey = input.labelKey ?? input.xKey ?? null;
|
|
732
|
-
|
|
746
|
+
// The measure column is conventionally "value", but bullet charts also
|
|
747
|
+
// call it "actual" — accept either when no valueKey is given.
|
|
748
|
+
chartProps.valueKey =
|
|
749
|
+
input.valueKey ?? input.yKey ?? firstPresentDataKey(input.data, ['value', 'actual']) ?? 'value';
|
|
733
750
|
chartProps.targetKey = input.targetKey ?? null;
|
|
734
751
|
chartProps.rangesKey = input.rangesKey ?? null;
|
|
735
752
|
chartProps.color = input.color ?? null;
|
package/src/mcp/canvas-access.ts
CHANGED
|
@@ -1063,7 +1063,23 @@ export async function createCanvasAccess(): Promise<CanvasAccess> {
|
|
|
1063
1063
|
const remoteBaseUrl = await findExistingCanvasServer(workspaceRoot, port);
|
|
1064
1064
|
if (remoteBaseUrl) return new RemoteCanvasAccess(remoteBaseUrl);
|
|
1065
1065
|
|
|
1066
|
+
// No same-workspace server to attach to. Allow a port fallback so a daemon
|
|
1067
|
+
// already holding the preferred port (e.g. one serving a *different*
|
|
1068
|
+
// workspace) doesn't crash this MCP/SDK session with EADDRINUSE — start our
|
|
1069
|
+
// own canvas on a free port instead, and explain how to share one if intended.
|
|
1066
1070
|
const canvas = createCanvas({ port });
|
|
1067
|
-
await canvas.start({ open: true });
|
|
1071
|
+
await canvas.start({ open: true, allowPortFallback: true });
|
|
1072
|
+
const boundPort = canvas.port;
|
|
1073
|
+
if (boundPort !== port) {
|
|
1074
|
+
const occupant = await readHealth(`http://127.0.0.1:${port}`);
|
|
1075
|
+
const occupantWorkspace =
|
|
1076
|
+
typeof occupant?.workspace === 'string' ? ` (serving ${occupant.workspace})` : '';
|
|
1077
|
+
// stderr only — stdout is the MCP stdio JSON-RPC channel.
|
|
1078
|
+
process.stderr.write(
|
|
1079
|
+
`[pmx-canvas] preferred port ${port} was in use${occupantWorkspace}; ` +
|
|
1080
|
+
`started this canvas on port ${boundPort} instead. To share one canvas, run the daemon ` +
|
|
1081
|
+
`from this workspace or set PMX_CANVAS_URL / PMX_CANVAS_PORT to point at it.\n`,
|
|
1082
|
+
);
|
|
1083
|
+
}
|
|
1068
1084
|
return new LocalCanvasAccess(canvas, workspaceRoot, port);
|
|
1069
1085
|
}
|
package/src/mcp/server.ts
CHANGED
|
@@ -263,13 +263,16 @@ function compactBatchResult(result: { ok: boolean; results: Array<Record<string,
|
|
|
263
263
|
}
|
|
264
264
|
|
|
265
265
|
async function createdNodePayload(c: CanvasAccess, id: string, options: { full?: boolean; verbose?: boolean; includeData?: boolean } = {}): Promise<Record<string, unknown>> {
|
|
266
|
+
// Expose both `id` and a `nodeId` alias on every node-create response so
|
|
267
|
+
// agents using either key (or a cached schema) work — matching the
|
|
268
|
+
// external-app / web-artifact responses that already return both.
|
|
266
269
|
const node = await c.getNode(id);
|
|
267
|
-
if (!node) return { ok: true, id };
|
|
270
|
+
if (!node) return { ok: true, id, nodeId: id };
|
|
268
271
|
if (!wantsFullPayload(options)) {
|
|
269
|
-
return { ok: true, node: compactNodePayload(node), id };
|
|
272
|
+
return { ok: true, node: compactNodePayload(node), id, nodeId: id };
|
|
270
273
|
}
|
|
271
274
|
const serialized = serializeCanvasNodeForAgent(node);
|
|
272
|
-
return { ok: true, node: serialized, ...serialized };
|
|
275
|
+
return { ok: true, node: serialized, ...serialized, nodeId: node.id };
|
|
273
276
|
}
|
|
274
277
|
|
|
275
278
|
function buildSummaryFromLayout(layout: Awaited<ReturnType<CanvasAccess['getLayout']>>, pinnedIds: string[]): Record<string, unknown> {
|
package/src/server/index.ts
CHANGED
|
@@ -130,8 +130,14 @@ export class PmxCanvas extends EventEmitter {
|
|
|
130
130
|
async start(options?: {
|
|
131
131
|
open?: boolean;
|
|
132
132
|
automationWebView?: boolean | CanvasAutomationWebViewOptions;
|
|
133
|
+
/**
|
|
134
|
+
* Bind a nearby free port when the preferred one is taken instead of
|
|
135
|
+
* failing. Default false (an explicit SDK port is honored exactly); the
|
|
136
|
+
* MCP auto-start opts in so a daemon already on the port can't crash it.
|
|
137
|
+
*/
|
|
138
|
+
allowPortFallback?: boolean;
|
|
133
139
|
}): Promise<void> {
|
|
134
|
-
const base = startCanvasServer({ port: this._port, allowPortFallback: false });
|
|
140
|
+
const base = startCanvasServer({ port: this._port, allowPortFallback: options?.allowPortFallback ?? false });
|
|
135
141
|
if (!base) {
|
|
136
142
|
throw new Error(`Failed to start canvas server on port ${this._port}`);
|
|
137
143
|
}
|