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.
@@ -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.28",
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
- // Render key-value pairs from ledger summary
7
- const entries = Object.entries(data).filter(([key]) => key !== 'title' && key !== '__type');
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
- chartProps.valueKey = input.valueKey ?? input.yKey ?? 'value';
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;
@@ -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> {
@@ -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
  }