pmx-canvas 0.3.1 → 0.3.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.
@@ -10,4 +10,12 @@
10
10
  * - Idempotent operations where possible
11
11
  * - --yes for destructive actions, --dry-run for preview
12
12
  */
13
- export declare function runAgentCli(args: string[]): Promise<void>;
13
+ /**
14
+ * Extract the global `--port <n>` / `--server-url <url>` flags (any position,
15
+ * `=` or space-separated value) and set the invocation's target override.
16
+ * Returns the remaining args for command dispatch. Invalid values are a loud
17
+ * `die` — never a silent fallback to the default port. `--server-url` wins
18
+ * over `--port` when both are given.
19
+ */
20
+ export declare function extractGlobalTargetFlags(args: string[]): string[];
21
+ export declare function runAgentCli(rawArgs: string[]): Promise<void>;
@@ -17,6 +17,8 @@ export declare function removePidFile(path: string): void;
17
17
  export interface HealthStatus {
18
18
  responsive: boolean;
19
19
  workspace: string | null;
20
+ /** The serving process's own pid, self-reported by /health (0.3.3+ servers). */
21
+ pid: number | null;
20
22
  }
21
23
  export declare function readHealthStatus(url: string): Promise<HealthStatus>;
22
24
  export declare function isHealthy(url: string): Promise<boolean>;
@@ -65,6 +67,21 @@ export declare function startDaemonMode(options: DaemonPaths & {
65
67
  waitMs: number;
66
68
  entry: string;
67
69
  }): Promise<void>;
70
+ /**
71
+ * Resolve the pid story `serve status` reports. The AUTHORITATIVE pid is the
72
+ * serving process's self-reported /health pid: the pid file can go stale when
73
+ * something other than `serve --daemon` (re)spawned the server on this port —
74
+ * e.g. a host adapter respawn (0.3.2 report Finding P). Reporting the dead
75
+ * file pid as `pid` while `running` was true made agents read a contradiction
76
+ * (`running: true, pidRunning: false`); report the real listener instead and
77
+ * mark the file explicitly stale. Pre-0.3.3 servers don't self-report a pid —
78
+ * for those, fall back to the pid-file view unchanged.
79
+ */
80
+ export declare function resolveDaemonPidView(pidFilePid: number | null, pidFilePidRunning: boolean, health: HealthStatus): {
81
+ pid: number | null;
82
+ pidRunning: boolean;
83
+ pidFileStale: boolean;
84
+ };
68
85
  export declare function showServeStatus(options: DaemonPaths & {
69
86
  entry: string;
70
87
  }): Promise<void>;
@@ -19,7 +19,15 @@ interface ExtAppHostDimensionsTarget {
19
19
  * everywhere we can test (Chrome / Codex / Playwright).
20
20
  */
21
21
  export declare function isWebKitOnlyHost(userAgent: string): boolean;
22
- export declare function nextWebkitRepaintSlot(): number;
22
+ export declare const WEBKIT_REMOUNT_SETTLE_MS = 1000;
23
+ export interface WebkitRemountTask {
24
+ /** Perform the remount. Return false if the node no longer needs it (skips the boot wait). */
25
+ remount: () => boolean;
26
+ /** Resolves when the remounted app genuinely boots AND finishes its bootstrap replay, or after a bounded timeout. */
27
+ awaitBoot: () => Promise<void>;
28
+ }
29
+ export declare function extAppRecoveryLog(nodeId: string, event: string): void;
30
+ export declare function enqueueWebkitRemount(task: WebkitRemountTask): void;
23
31
  export declare function shouldScheduleWebKitRepaint(status: ExtAppFrameStatus, hasReplayToolResult: boolean): boolean;
24
32
  export declare function getExtAppBridgeInitKey(node: CanvasNodeState, retryKey: number): string;
25
33
  export declare function resolveExtAppDisplayModeRequest(requestedMode: DisplayMode, isExpanded: boolean): {
@@ -30,6 +38,19 @@ export declare function resolveExtAppDisplayModeRequest(requestedMode: DisplayMo
30
38
  export declare function sendExtAppBootstrapState(bridge: ExtAppBridgeNotifications, toolInput: Record<string, unknown>, toolResult: CallToolResult | undefined): Promise<void>;
31
39
  export declare function resolveExtAppSandbox(value: unknown): string;
32
40
  export declare function buildExtAppAxBridgeScript(axToken: string, nodeId: string): string;
41
+ /**
42
+ * Boot beacon injected into EVERY ext-app document (not gated on AX): posts one
43
+ * parent message the moment the iframe's scripts execute. This is the liveness
44
+ * signal the WebKit never-booted watchdog keys on — it distinguishes an app
45
+ * that is alive but boots via the 1200ms bootstrap fallback (e.g. a non-SDK
46
+ * widget that never sends ui/notifications/initialized) from a dead window
47
+ * whose scripts never ran. Without it, the watchdog treated every
48
+ * fallback-booted app as never-booted and remounted it up to the attempt cap —
49
+ * a reboot/flicker loop for exactly the apps the fallback exists to support
50
+ * (0.3.2 pre-release review). The token scopes the message to this component;
51
+ * the host also checks event.source against the live iframe.
52
+ */
53
+ export declare function buildExtAppBootBeaconScript(frameToken: string, nodeId: string): string;
33
54
  export declare function injectExtAppAxBridgeScript(html: string, axBridgeScript: string): string;
34
55
  export declare function resolveExtAppContainerDimensions(target: ExtAppHostDimensionsTarget | null | undefined, fallback: {
35
56
  width: number;
@@ -81,6 +81,14 @@ export declare function setPrimaryWorkbenchAutoOpenEnabled(enabled: boolean): vo
81
81
  export declare function isPrimaryWorkbenchAutoOpenEnabled(): boolean;
82
82
  export declare function hasWorkbenchSubscribers(): boolean;
83
83
  export declare function setPrimaryWorkbenchCanvasPromptHandler(handler: PrimaryWorkbenchCanvasPromptHandler | null): void;
84
+ /**
85
+ * Containment check for bundle-asset serving. Separator-agnostic: on Windows,
86
+ * `resolve()` returns backslash paths, so comparing against a `${dir}/` template
87
+ * rejected every asset — /canvas/index.js 404'd and the SPA never booted (the
88
+ * 0.3.1 Windows report). Normalizing both sides keeps the check exact on POSIX
89
+ * and correct on win32, and testable with win32-shaped fixtures on any host.
90
+ */
91
+ export declare function isCanvasBundlePath(distPath: string, bundleDir: string): boolean;
84
92
  export declare function buildMacBrowserOpenScript(appName: string, url: string): string;
85
93
  export declare function openUrlInExternalBrowser(url: string): boolean;
86
94
  /**
package/docs/RELEASE.md CHANGED
@@ -124,6 +124,17 @@ If this is the first release from your machine, run `bunx npm login`
124
124
  once so Bun can reuse your npm credentials. CI does not need this —
125
125
  it uses `NPM_TOKEN` directly.
126
126
 
127
+ ## Sync consumer skill mirrors
128
+
129
+ Every release since 0.3.0 has been retested against **stale** skill mirrors:
130
+ consumer workspaces (e.g. `personalclaude`) carry copies of the pmx-canvas
131
+ skill under `.github/`, `.codex/`, `.claude/`, and `.agents/`, and testers
132
+ repeatedly had to sync them mid-cycle before results were trustworthy. After
133
+ publishing, update the installed package in the consumer workspace and re-sync
134
+ its four mirrors from
135
+ `<global npm root>/pmx-canvas/skills/pmx-canvas/` so the first test pass runs
136
+ against the released skill, not the previous version's guidance.
137
+
127
138
  ## GitHub release
128
139
 
129
140
  After `npm publish` succeeds:
package/docs/cli.md CHANGED
@@ -2,7 +2,16 @@
2
2
 
3
3
  The CLI is the shell-native way to run and control PMX Canvas. It targets
4
4
  `http://localhost:4313` by default — override with `PMX_CANVAS_URL` or
5
- `PMX_CANVAS_PORT` when the server runs elsewhere.
5
+ `PMX_CANVAS_PORT` when the server runs elsewhere, or per invocation with the
6
+ global `--port <n>` / `--server-url <url>` flags (any position; `--server-url`
7
+ wins over `--port`, and both win over the environment variables). An invalid
8
+ value for either flag is a hard error — the CLI never falls back silently to
9
+ the default port.
10
+
11
+ ```bash
12
+ pmx-canvas node list --port 4750 # target a non-default daemon
13
+ pmx-canvas --server-url http://127.0.0.1:4750 status # same, by URL
14
+ ```
6
15
 
7
16
  ## Server lifecycle
8
17
 
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmx-canvas",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
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",
@@ -24,7 +24,8 @@ Humans curate agent context by pinning nodes; agents read that curation through
24
24
  `workspace` must equal the intended absolute workspace root. A healthy listener on port 4313
25
25
  may belong to another project.
26
26
  3. **Read before write.** Search with `canvas_query { action: "search", query }` before creating
27
- nodes. Read the full layout only when necessary.
27
+ nodes. Read the full layout only when necessary. The MCP parameter is `query` — passing the
28
+ HTTP API's `q` is silently ignored and returns zero results.
28
29
  4. **Snapshot before destructive changes.** Use `canvas_snapshot` (deprecated standalone; folds into
29
30
  the `canvas_snapshot` composite's `save` action in v0.4) before clear, restore, or a major
30
31
  reorganization.
@@ -33,7 +34,8 @@ Humans curate agent context by pinning nodes; agents read that curation through
33
34
  or edit, then pass the returned `intent.id` as `intentId` on the mutation so the ghost settles
34
35
  into the result. Use it as much as possible to make your next move and your work visible: the
35
36
  human watches intent form and can veto mid-thought. Skip it only for trivial in-place tweaks or
36
- high-frequency batch churn.
37
+ high-frequency batch churn. The default TTL (~8s) expires between agent turns: signal with
38
+ `ttlMs: 30000` and settle by passing `intentId` on the mutation in the same or next call.
37
39
  6. **Mutate through current composites.** Prefer the 15 composite MCP tools below.
38
40
  7. **Arrange and validate.** After batch changes, use `canvas_view { action: "arrange" }` when
39
41
  appropriate and always finish with `canvas_query { action: "validate" }`.
@@ -215,13 +217,17 @@ Prefer `canvas_query { action: "search" }` over parsing the full layout.
215
217
  loads (e.g. the GitHub Copilot app's embedded WKWebView) can render as a black tile — a host
216
218
  compositor paint race on the nested iframe, **not** a broken node (the session is healthy and
217
219
  `sessionStatus` is `ready`; it renders fine in Chrome, the Codex browser, and for nodes created
218
- live after the panel hydrates). The canvas forces a one-time post-boot repaint remount under
219
- WebKit, which reliably repaints a **single** present-at-load
220
- ext-app but a board with **several** ext-apps present at WebKit panel-load can still black out
221
- (the simultaneous cold-hydration burst overwhelms the WebKit compositor). Recovery is
222
- deterministic: **expand-then-close** any black tile (forces a fresh mount in the fullscreen
223
- overlay, which always paints), or open the workbench in a normal browser (Chrome). Do not
224
- diagnose a healthy app session as a broken node; the durable fix is upstream in the host panel.
220
+ live after the panel hydrates). Under WebKit the canvas ATTEMPTS auto-recovery: after boot,
221
+ each present-at-load ext-app is remounted through a serialized, boot-aware queue (one app at a
222
+ time), with a watchdog retry for iframes that never boot and a re-arm when a panel that loaded
223
+ hidden becomes visible. Treat the auto-recovery as best-effort, NOT guaranteed the 0.3.2
224
+ Copilot-host report shows tiles can still come up black in real WKWebView panels (compositing
225
+ success is not observable from page JS, so retries are bounded). Keep the cautionary behavior:
226
+ recovery is deterministic via **expand-then-close** of the black tile (forces a fresh mount in
227
+ the fullscreen overlay, which always paints), or open the workbench in a normal browser
228
+ (Chrome). Do not diagnose a healthy app session as a broken node. When reporting a black tile,
229
+ fetch `GET /api/canvas/debug/ext-app-recovery` from the daemon and attach the trace — it shows
230
+ what the recovery queue actually did in that panel.
225
231
  - Graph and json-render standalone surfaces use `display=site` and fill the browser viewport, and
226
232
  reflow on a live window resize in a normal browser. Some single-tab host browsers (e.g. the
227
233
  Codex in-app browser) don't deliver live-resize events, so a resized standalone chart can look
@@ -510,6 +510,13 @@ surfaces): `canvas_batch`, `canvas_pin_nodes`, `canvas_screenshot`, `canvas_ax_i
510
510
  `canvas_diff` — deprecated pending a `canvas_snapshot` composite in v0.4; the name collides with
511
511
  the current save-snapshot tool, so the composite cannot land additively).
512
512
 
513
+ `canvas_batch` supports exactly these ops: `node.add`, `node.update`, `node.remove`, `graph.add`,
514
+ `edge.add`, `edge.remove`, `group.create`, `group.add`, `group.remove`, `pin.set`/`pin.add`/
515
+ `pin.remove`, `snapshot.save`, and `arrange`. Anything else fails with "Unsupported canvas_batch
516
+ operation" — batch is non-atomic, so earlier ops stay applied and the response carries
517
+ `failedIndex`. `canvas_screenshot` requires an active automation WebView: call
518
+ `canvas_webview { action: "start" }` first.
519
+
513
520
  > **Removed in v0.3.0 → use the composite instead**: `canvas_open_mcp_app` / `canvas_add_diagram` /
514
521
  > `canvas_build_web_artifact` → **`canvas_app`**; `canvas_webview_start` / `canvas_webview_status` /
515
522
  > `canvas_webview_stop` / `canvas_resize` / `canvas_evaluate` → **`canvas_webview`**;
package/src/cli/agent.ts CHANGED
@@ -89,13 +89,77 @@ interface CanvasSchemaResponse {
89
89
  };
90
90
  }
91
91
 
92
+ // Per-invocation target override from the global --server-url / --port flags.
93
+ // Before these existed, `--port 4750` on any agent command was SILENTLY ignored
94
+ // and the command hit the default 4313 daemon — which once pointed a test
95
+ // automation WebView at a live production board.
96
+ let cliTargetOverride: string | null = null;
97
+
92
98
  function getBaseUrl(): string {
99
+ if (cliTargetOverride) return cliTargetOverride;
93
100
  const envUrl = process.env.PMX_CANVAS_URL;
94
101
  if (envUrl) return envUrl.replace(/\/$/, '');
95
102
  const port = process.env.PMX_CANVAS_PORT || DEFAULT_PORT;
96
103
  return `http://localhost:${port}`;
97
104
  }
98
105
 
106
+ /**
107
+ * Extract the global `--port <n>` / `--server-url <url>` flags (any position,
108
+ * `=` or space-separated value) and set the invocation's target override.
109
+ * Returns the remaining args for command dispatch. Invalid values are a loud
110
+ * `die` — never a silent fallback to the default port. `--server-url` wins
111
+ * over `--port` when both are given.
112
+ */
113
+ export function extractGlobalTargetFlags(args: string[]): string[] {
114
+ cliTargetOverride = null;
115
+ let portOverride: number | null = null;
116
+ let urlOverride: string | null = null;
117
+ const rest: string[] = [];
118
+
119
+ for (let i = 0; i < args.length; i++) {
120
+ const arg = args[i];
121
+ let name: 'port' | 'server-url' | null = null;
122
+ let value: string | undefined;
123
+ if (arg === '--port' || arg === '--server-url') {
124
+ name = arg.slice(2) as 'port' | 'server-url';
125
+ value = args[i + 1];
126
+ i += 1;
127
+ } else if (arg.startsWith('--port=')) {
128
+ name = 'port';
129
+ value = arg.slice('--port='.length);
130
+ } else if (arg.startsWith('--server-url=')) {
131
+ name = 'server-url';
132
+ value = arg.slice('--server-url='.length);
133
+ } else {
134
+ rest.push(arg);
135
+ continue;
136
+ }
137
+
138
+ if (name === 'port') {
139
+ const port = Number(value);
140
+ if (!value || !Number.isInteger(port) || port <= 0 || port > 65535) {
141
+ die(
142
+ `--port requires a port number, got ${value === undefined ? 'no value' : JSON.stringify(value)}.`,
143
+ 'Example: --port 4313. Without the flag, PMX_CANVAS_PORT / PMX_CANVAS_URL pick the target.',
144
+ );
145
+ }
146
+ portOverride = port;
147
+ } else {
148
+ if (!value || !/^https?:\/\//.test(value)) {
149
+ die(
150
+ `--server-url requires an http(s) URL, got ${value === undefined ? 'no value' : JSON.stringify(value)}.`,
151
+ 'Example: --server-url http://localhost:4313. Without the flag, PMX_CANVAS_URL picks the target.',
152
+ );
153
+ }
154
+ urlOverride = value.replace(/\/$/, '');
155
+ }
156
+ }
157
+
158
+ if (urlOverride) cliTargetOverride = urlOverride;
159
+ else if (portOverride) cliTargetOverride = `http://localhost:${portOverride}`;
160
+ return rest;
161
+ }
162
+
99
163
  function die(message: string, hint?: string): never {
100
164
  const out: Record<string, string> = { error: message };
101
165
  if (hint) out.hint = hint;
@@ -3535,6 +3599,8 @@ Analysis:
3535
3599
 
3536
3600
  Global flags:
3537
3601
  --help, -h Show help for any command
3602
+ --port <n> Target daemon port for this invocation (overrides PMX_CANVAS_PORT)
3603
+ --server-url <url> Target server URL for this invocation (overrides PMX_CANVAS_URL; wins over --port)
3538
3604
 
3539
3605
  Environment:
3540
3606
  PMX_CANVAS_URL Server URL (default: http://localhost:4313)
@@ -3592,7 +3658,8 @@ async function readStdin(): Promise<string> {
3592
3658
 
3593
3659
  // ── Router ───────────────────────────────────────────────────
3594
3660
 
3595
- export async function runAgentCli(args: string[]): Promise<void> {
3661
+ export async function runAgentCli(rawArgs: string[]): Promise<void> {
3662
+ const args = extractGlobalTargetFlags(rawArgs);
3596
3663
  if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
3597
3664
  showTopLevelHelp();
3598
3665
  return;
package/src/cli/daemon.ts CHANGED
@@ -88,20 +88,26 @@ export function removePidFile(path: string): void {
88
88
  export interface HealthStatus {
89
89
  responsive: boolean;
90
90
  workspace: string | null;
91
+ /** The serving process's own pid, self-reported by /health (0.3.3+ servers). */
92
+ pid: number | null;
91
93
  }
92
94
 
93
95
  export async function readHealthStatus(url: string): Promise<HealthStatus> {
94
96
  try {
95
97
  const response = await fetch(url);
96
- if (!response.ok) return { responsive: false, workspace: null };
98
+ if (!response.ok) return { responsive: false, workspace: null, pid: null };
97
99
  const payload = (await response.json().catch(() => null)) as unknown;
98
100
  const workspace =
99
101
  payload && typeof payload === 'object' && 'workspace' in payload && typeof payload.workspace === 'string'
100
102
  ? payload.workspace
101
103
  : null;
102
- return { responsive: true, workspace };
104
+ const pid =
105
+ payload && typeof payload === 'object' && 'pid' in payload && typeof payload.pid === 'number'
106
+ ? payload.pid
107
+ : null;
108
+ return { responsive: true, workspace, pid };
103
109
  } catch {
104
- return { responsive: false, workspace: null };
110
+ return { responsive: false, workspace: null, pid: null };
105
111
  }
106
112
  }
107
113
 
@@ -335,17 +341,41 @@ export async function startDaemonMode(
335
341
  process.exit(0);
336
342
  }
337
343
 
344
+ /**
345
+ * Resolve the pid story `serve status` reports. The AUTHORITATIVE pid is the
346
+ * serving process's self-reported /health pid: the pid file can go stale when
347
+ * something other than `serve --daemon` (re)spawned the server on this port —
348
+ * e.g. a host adapter respawn (0.3.2 report Finding P). Reporting the dead
349
+ * file pid as `pid` while `running` was true made agents read a contradiction
350
+ * (`running: true, pidRunning: false`); report the real listener instead and
351
+ * mark the file explicitly stale. Pre-0.3.3 servers don't self-report a pid —
352
+ * for those, fall back to the pid-file view unchanged.
353
+ */
354
+ export function resolveDaemonPidView(
355
+ pidFilePid: number | null,
356
+ pidFilePidRunning: boolean,
357
+ health: HealthStatus,
358
+ ): { pid: number | null; pidRunning: boolean; pidFileStale: boolean } {
359
+ const authoritative = health.responsive && health.pid !== null;
360
+ return {
361
+ pid: authoritative ? health.pid : pidFilePid,
362
+ pidRunning: authoritative ? true : pidFilePidRunning,
363
+ pidFileStale: pidFilePid !== null && !pidFilePidRunning && (!health.responsive || health.pid !== pidFilePid),
364
+ };
365
+ }
366
+
338
367
  export async function showServeStatus(options: DaemonPaths & { entry: string }): Promise<void> {
339
368
  const healthUrl = `http://localhost:${options.port}/health`;
340
369
  const url = `http://localhost:${options.port}/workbench`;
341
- const pid = readPidFile(options.pidFile);
342
- const pidRunning = pid ? isOwnDaemonProcess(pid, options.entry) : false;
370
+ const pidFilePid = readPidFile(options.pidFile);
371
+ const pidFilePidRunning = pidFilePid ? isOwnDaemonProcess(pidFilePid, options.entry) : false;
343
372
  const health = await readHealthStatus(healthUrl);
344
373
  const responsive = health.responsive;
345
- const running = responsive || pidRunning;
374
+ const running = responsive || pidFilePidRunning;
375
+ const { pid, pidRunning, pidFileStale } = resolveDaemonPidView(pidFilePid, pidFilePidRunning, health);
346
376
  // Clean up a stale pid file, but never a concurrent starter's fresh empty
347
377
  // spawn lock (that would let a racing `serve --daemon` double-spawn).
348
- if (!running && existsSync(options.pidFile) && !pidRunning && !isFreshEmptyLock(options.pidFile)) {
378
+ if (!running && existsSync(options.pidFile) && !pidFilePidRunning && !isFreshEmptyLock(options.pidFile)) {
349
379
  removePidFile(options.pidFile);
350
380
  }
351
381
 
@@ -357,6 +387,8 @@ export async function showServeStatus(options: DaemonPaths & { entry: string }):
357
387
  workspace: health.workspace,
358
388
  pid,
359
389
  pidRunning,
390
+ pidFilePid,
391
+ pidFileStale,
360
392
  url,
361
393
  healthUrl,
362
394
  logFile: options.logFile,
package/src/cli/index.ts CHANGED
@@ -120,6 +120,25 @@ function stripOption(argv: string[], name: string): string[] {
120
120
  return stripped;
121
121
  }
122
122
 
123
+ // Global target flags (--port / --server-url) may precede the subcommand, e.g.
124
+ // `pmx-canvas --port 4750 node list`. Skip them (and their values) when picking
125
+ // the subcommand to dispatch, so the agent CLI — which strips these flags via
126
+ // extractGlobalTargetFlags and validates them loudly — is reached instead of the
127
+ // server-startup fallback. Only these two flags are skipped; other leading flags
128
+ // (--mcp, --demo, …) still route exactly as before.
129
+ function commandAfterGlobalTargetFlags(argv: string[]): string {
130
+ for (let index = 0; index < argv.length; index++) {
131
+ const arg = argv[index];
132
+ if (arg === '--port' || arg === '--server-url') {
133
+ if (index + 1 < argv.length && !argv[index + 1].startsWith('-')) index++;
134
+ continue;
135
+ }
136
+ if (arg.startsWith('--port=') || arg.startsWith('--server-url=')) continue;
137
+ return arg;
138
+ }
139
+ return '';
140
+ }
141
+
123
142
  function runMcpServerProcess(): Promise<void> {
124
143
  return new Promise((resolvePromise, rejectPromise) => {
125
144
  const child = spawn(process.execPath, ['run', mcpServerEntry], {
@@ -181,7 +200,8 @@ if (firstArg === 'serve') {
181
200
  args.shift();
182
201
  }
183
202
 
184
- if (AGENT_COMMANDS.has(firstArg) && firstArg !== 'serve') {
203
+ const routedCommand = commandAfterGlobalTargetFlags(args);
204
+ if (AGENT_COMMANDS.has(routedCommand) && routedCommand !== 'serve') {
185
205
  await runAgentCli(args);
186
206
  } else if (args.includes('--mcp')) {
187
207
  // MCP server mode: stdio transport, auto-starts canvas on first tool call