pmx-canvas 0.2.3 → 0.2.5

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.
@@ -8,6 +8,16 @@ interface ExtAppHostDimensionsTarget {
8
8
  clientHeight?: number;
9
9
  getBoundingClientRect(): Pick<DOMRectReadOnly, 'width' | 'height'>;
10
10
  }
11
+ /**
12
+ * Finding F (0.2.4): detect a WebKit-only host — Safari or a WKWebView (e.g. the
13
+ * GitHub Copilot app's embedded panel). Blink engines (Chrome / Chromium / Edge /
14
+ * the Codex browser, all of which carry a Chrome/Chromium/CriOS/Edg token) and
15
+ * Android WebView are excluded, as is Gecko (no `AppleWebKit`). Used to gate the
16
+ * one-time ext-app iframe repaint remount to the only engine that exhibits the
17
+ * present-at-load black-tile paint race, so the remount is a strict no-op
18
+ * everywhere we can test (Chrome / Codex / Playwright).
19
+ */
20
+ export declare function isWebKitOnlyHost(userAgent: string): boolean;
11
21
  export declare function getExtAppBridgeInitKey(node: CanvasNodeState, retryKey: number): string;
12
22
  export declare function resolveExtAppDisplayModeRequest(requestedMode: DisplayMode, isExpanded: boolean): {
13
23
  nextMode: DisplayMode;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmx-canvas",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
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",
@@ -27,8 +27,12 @@ Humans curate agent context by pinning nodes; agents read that curation through
27
27
  nodes. Read the full layout only when necessary.
28
28
  4. **Snapshot before destructive changes.** Use `canvas_snapshot` before clear, restore, or a major
29
29
  reorganization.
30
- 5. **Signal substantial spatial changes.** Use `canvas_intent { action: "signal", ... }` before a
31
- visible create, move, connect, remove, or edit when human steering would be useful.
30
+ 5. **Show intent with the Ghost Cursor by default.** Signal with
31
+ `canvas_intent { action: "signal", ... }` before every meaningful create, move, connect, remove,
32
+ or edit, then pass the returned `intent.id` as `intentId` on the mutation so the ghost settles
33
+ into the result. Use it as much as possible to make your next move and your work visible: the
34
+ human watches intent form and can veto mid-thought. Skip it only for trivial in-place tweaks or
35
+ high-frequency batch churn.
32
36
  6. **Mutate through current composites.** Prefer the 15 composite MCP tools below.
33
37
  7. **Arrange and validate.** After batch changes, use `canvas_view { action: "arrange" }` when
34
38
  appropriate and always finish with `canvas_query { action: "validate" }`.
@@ -191,6 +195,13 @@ Prefer `canvas_query { action: "search" }` over parsing the full layout.
191
195
  - Hosted MCP-app/ext-app nodes such as Excalidraw require the in-canvas host bridge and are not
192
196
  standalone **Open as site** targets. URL-backed viewers and bundled web artifacts remain
193
197
  openable.
198
+ - A hosted ext-app (Excalidraw) node that is already on the board when a **WebKit** host panel
199
+ loads (e.g. the GitHub Copilot app's embedded WKWebView) can render as a black tile — a host
200
+ paint race on the nested iframe, not a broken node (the session is healthy and `sessionStatus`
201
+ is `ready`; it renders fine in Chrome, the Codex browser, and for nodes created live after the
202
+ panel hydrates). The canvas auto-remounts the iframe once on load under WebKit to force a
203
+ repaint; if a tile is still black, expand-then-close it (forces a remount) or open the workbench
204
+ in a normal browser. Do not diagnose a healthy app session as a broken node.
194
205
  - Graph and json-render standalone surfaces use `display=site` and fill the browser viewport, and
195
206
  reflow on a live window resize in a normal browser. Some single-tab host browsers (e.g. the
196
207
  Codex in-app browser) don't deliver live-resize events, so a resized standalone chart can look
@@ -394,7 +394,7 @@
394
394
  "id": 16,
395
395
  "name": "ghost-cursor-intent",
396
396
  "prompt": "Add an 'Auth design' status node to the review area. Before adding it, signal what you're about to do so I can veto it if I disagree, then make the change.",
397
- "expected_output": "Signals a create intent first with canvas_intent {action:signal, kind:\"create\", position, nodeType:\"status\", label, reason, confidence}, then creates the 'Auth design' node at that position with canvas_node {action:\"add\", type:\"status\", x, y, intentId}. Understands that a vetoed intent rejects its linked mutation. Does not silently mutate without signalling when steering would help.",
397
+ "expected_output": "Signals a create intent first with canvas_intent {action:signal, kind:\"create\", position, nodeType:\"status\", label, reason, confidence}, then creates the 'Auth design' node at that position with canvas_node {action:\"add\", type:\"status\", x, y, intentId}. Understands that a vetoed intent rejects its linked mutation. Treats signalling intent as the default before visible mutations rather than an optional step, and does not silently mutate.",
398
398
  "assertions": [
399
399
  {
400
400
  "name": "signals-intent-first",
@@ -75,8 +75,8 @@ section below.
75
75
  you need the full board.
76
76
  4. **Snapshot before destructive work** — `canvas_snapshot { name }` before clear/major
77
77
  reorg; restore if needed.
78
- 5. **Signal then mutate** — optionally `canvas_intent { action: "signal", … }` to telegraph the
79
- move, then create with the right composite: `canvas_node` (markdown/status/file/webpage/html
78
+ 5. **Signal then mutate (default behavior)** — signal with `canvas_intent { action: "signal", … }`
79
+ to telegraph the move before nearly every mutation, then create with the right composite: `canvas_node` (markdown/status/file/webpage/html
80
80
  incl. primitives), `canvas_render` (json-render/graph), `canvas_app` (mcp-app/diagram/
81
81
  web-artifact). Prefer composites — the legacy single-purpose tools are deprecated (removed in
82
82
  v0.3).
@@ -441,6 +441,10 @@ identifier is passed as `approvalAction`, since `action` is the lifecycle discri
441
441
 
442
442
  #### Narrate your next move with `canvas_intent` (Ghost Cursor of Intent)
443
443
 
444
+ **Use the Ghost Cursor as much as possible** — it is the primary way to make your intent and your
445
+ work visible on a shared board. Default to signaling before you act; skip it only for trivial
446
+ in-place tweaks or high-frequency batch churn.
447
+
444
448
  Before you create/move/connect/edit/remove on the canvas, **signal the move** so a
445
449
  faint placeholder forms where you're about to act — the human sees the next move
446
450
  coming and can veto it mid-thought. Intents are ephemeral presence: never
@@ -455,12 +459,25 @@ Narrate → linked mutation → automatic settle:
455
459
  Use `canvas_intent { action: "clear", id }` only when abandoning a plan without
456
460
  performing the linked mutation.
457
461
 
462
+ **Linked settle is scoped to node, edge, and group mutations** (`canvas_node`,
463
+ `canvas_edge`, `canvas_group` and their ops). `canvas_app` opens (diagram /
464
+ mcp-app) and `canvas_webview` do **not** accept an `intentId` and reject it with a
465
+ 400 — to telegraph one of those, signal a ghost, then `clear` it (or let it
466
+ expire) and run the open *without* an `intentId`.
467
+
458
468
  Per kind, pass the anchor it renders against: `position` for `create`/`move`,
459
469
  `nodeId` for `move`/`edit`/`remove`, `edge: { from, to, type }` for `connect`. The
460
- payoff is **legibility** — `reason` is shown beneath the ghost. For a planned
461
- batch, signal all intents up front (with `seq` for ordering), then commit them one
462
- by one with the corresponding `intentId` so the human watches the layout wireframe
463
- in before it fills.
470
+ payoff is **legibility** — `reason` is shown beneath the ghost.
471
+
472
+ **When to use vs skip.** Signal for adds, removes, and moves of visible nodes;
473
+ connecting nodes; creating groups; layout reorganizations; meaningful title/content
474
+ edits; destructive actions (clear/restore/remove); and creating
475
+ artifact/report/dashboard nodes. Skip for tiny metadata fixes, API-only pin/unpin
476
+ verification, deterministic report-node refreshes the human just asked for,
477
+ post-restore cleanup, and bulk fixture churn. **For batch work, signal one ghost
478
+ per human-meaningful move, not one per low-level op** — e.g. one "lay out the
479
+ investigation board" intent, then run the batch with that linked `intentId` (use
480
+ `seq` to order staged previews) so the human watches the wireframe before it fills.
464
481
 
465
482
  ### Standalones (first-class — not deprecated)
466
483
 
@@ -747,7 +764,8 @@ tools below operate on the live canvas state.
747
764
  > "evaluate" | "resize" }` respectively. Field names are unchanged.
748
765
 
749
766
  **`canvas_webview_status`** — Inspect the current automation session
750
- - Returns `{ supported, active, backend, viewportWidth, viewportHeight, url, lastError }`
767
+ - Returns `{ supported, active, headlessOnly, url, backend, width, height, dataStoreDir, startedAt, lastError }`
768
+ (the viewport size is `width` / `height`, not `viewportWidth` / `viewportHeight`)
751
769
  - Call before `start` to check whether a session is already alive
752
770
 
753
771
  **`canvas_webview_start`** — Start (or replace) the automation session
package/src/cli/agent.ts CHANGED
@@ -249,6 +249,16 @@ function optionalNumberFlag(flags: Record<string, string | true>, name: string,
249
249
  return Math.floor(parsed);
250
250
  }
251
251
 
252
+ /**
253
+ * AX `source` for a CLI-originated action. Defaults to `cli`, but honors an
254
+ * explicit `--source <label>` so an adapterless agent using the CLI as a fallback
255
+ * transport (e.g. `--source codex`) attributes its actions correctly — keeping
256
+ * loop-safety (a consumer never gets back its own steering) accurate (report #69).
257
+ */
258
+ function resolveAxSource(flags: Record<string, string | true>): string {
259
+ return getStringFlag(flags, 'source') ?? 'cli';
260
+ }
261
+
252
262
  function optionalFiniteFlag(flags: Record<string, string | true>, name: string, hint: string): number | undefined {
253
263
  const val = flags[name];
254
264
  if (!val || val === true) return undefined;
@@ -1884,7 +1894,7 @@ cmd('ax focus', 'Set or clear PMX AX focus without moving the viewport', [
1884
1894
  die('Missing node ID', 'pmx-canvas ax focus <node-id> [more-node-ids]');
1885
1895
  }
1886
1896
 
1887
- output(await api('POST', '/api/canvas/ax/focus', { nodeIds, source: 'cli' }));
1897
+ output(await api('POST', '/api/canvas/ax/focus', { nodeIds, source: resolveAxSource(flags) }));
1888
1898
  });
1889
1899
 
1890
1900
  cmd('ax event add', 'Record a normalized AX timeline event', [
@@ -1903,7 +1913,7 @@ cmd('ax event add', 'Record a normalized AX timeline event', [
1903
1913
  summary,
1904
1914
  ...(detail ? { detail } : {}),
1905
1915
  ...(positional.length > 0 ? { nodeIds: positional } : {}),
1906
- source: 'cli',
1916
+ source: resolveAxSource(flags),
1907
1917
  }));
1908
1918
  });
1909
1919
 
@@ -1919,7 +1929,7 @@ cmd('ax steer', 'Send a steering message to the active agent session', [
1919
1929
  die('Missing steering message', 'pmx-canvas ax steer <message>');
1920
1930
  }
1921
1931
 
1922
- output(await api('POST', '/api/canvas/ax/steer', { message, source: 'cli' }));
1932
+ output(await api('POST', '/api/canvas/ax/steer', { message, source: resolveAxSource(flags) }));
1923
1933
  });
1924
1934
 
1925
1935
  cmd('ax interaction', 'Submit a node-originated AX interaction (capability-gated)', [
@@ -1948,21 +1958,27 @@ cmd('ax interaction', 'Submit a node-originated AX interaction (capability-gated
1948
1958
  type,
1949
1959
  sourceNodeId,
1950
1960
  ...(payload !== undefined ? { payload } : {}),
1951
- source: 'cli',
1961
+ source: resolveAxSource(flags),
1952
1962
  }));
1953
1963
  });
1954
1964
 
1955
1965
  cmd('ax delivery list', 'List pending AX steering for a consumer (loop-safe)', [
1956
1966
  'pmx-canvas ax delivery list',
1957
1967
  'pmx-canvas ax delivery list --consumer copilot --limit 20',
1968
+ 'pmx-canvas ax delivery list --order newest # latest browser steering first (#68)',
1958
1969
  ], async (args) => {
1959
1970
  const { flags } = parseFlags(args);
1960
1971
  if (flags.help || flags.h) return showCommandHelp('ax delivery list');
1961
1972
  const consumer = getStringFlag(flags, 'consumer');
1962
1973
  const limit = optionalNumberFlag(flags, 'limit', 'pmx-canvas ax delivery list --limit <n>');
1974
+ const order = getStringFlag(flags, 'order');
1975
+ if (order !== undefined && order !== 'newest' && order !== 'oldest') {
1976
+ die('Invalid --order', 'pmx-canvas ax delivery list --order newest|oldest');
1977
+ }
1963
1978
  const params = new URLSearchParams();
1964
1979
  if (consumer) params.set('consumer', consumer);
1965
1980
  if (limit) params.set('limit', String(limit));
1981
+ if (order) params.set('order', order);
1966
1982
  const qs = params.toString();
1967
1983
  output(await api('GET', `/api/canvas/ax/delivery/pending${qs ? `?${qs}` : ''}`));
1968
1984
  });
@@ -1988,7 +2004,7 @@ cmd('ax elicitation request', 'Request structured human input', [
1988
2004
  output(await api('POST', '/api/canvas/ax/elicitation', {
1989
2005
  prompt,
1990
2006
  ...(fields ? { fields: fields.split(',').map((f) => f.trim()).filter(Boolean) } : {}),
1991
- source: 'cli',
2007
+ source: resolveAxSource(flags),
1992
2008
  }));
1993
2009
  });
1994
2010
 
@@ -2004,7 +2020,7 @@ cmd('ax elicitation respond', 'Answer a pending elicitation', [
2004
2020
  if (raw) {
2005
2021
  try { response = JSON.parse(raw); } catch { die('Invalid --response JSON', '--response \'{"k":"v"}\''); }
2006
2022
  }
2007
- output(await api('POST', `/api/canvas/ax/elicitation/${encodeURIComponent(id)}/respond`, { response, source: 'cli' }));
2023
+ output(await api('POST', `/api/canvas/ax/elicitation/${encodeURIComponent(id)}/respond`, { response, source: resolveAxSource(flags) }));
2008
2024
  });
2009
2025
 
2010
2026
  cmd('ax elicitation list', 'List elicitations', ['pmx-canvas ax elicitation list'], async (args) => {
@@ -2020,7 +2036,7 @@ cmd('ax mode request', 'Request a workflow mode transition (plan/execute/autonom
2020
2036
  if (flags.help || flags.h) return showCommandHelp('ax mode request');
2021
2037
  const mode = requireFlag(flags, 'mode', 'pmx-canvas ax mode request --mode plan|execute|autonomous');
2022
2038
  const reason = getStringFlag(flags, 'reason');
2023
- output(await api('POST', '/api/canvas/ax/mode', { mode, ...(reason ? { reason } : {}), source: 'cli' }));
2039
+ output(await api('POST', '/api/canvas/ax/mode', { mode, ...(reason ? { reason } : {}), source: resolveAxSource(flags) }));
2024
2040
  });
2025
2041
 
2026
2042
  cmd('ax mode resolve', 'Resolve a pending mode request', [
@@ -2036,7 +2052,7 @@ cmd('ax mode resolve', 'Resolve a pending mode request', [
2036
2052
  output(await api('POST', `/api/canvas/ax/mode/${encodeURIComponent(id)}/resolve`, {
2037
2053
  decision,
2038
2054
  ...(resolution ? { resolution } : {}),
2039
- source: 'cli',
2055
+ source: resolveAxSource(flags),
2040
2056
  }));
2041
2057
  });
2042
2058
 
@@ -2065,7 +2081,7 @@ cmd('ax command invoke', 'Invoke a registry-gated PMX command intent', [
2065
2081
  if (raw) {
2066
2082
  try { cmdArgs = JSON.parse(raw); } catch { die('Invalid --args JSON', '--args \'{"k":"v"}\''); }
2067
2083
  }
2068
- output(await api('POST', '/api/canvas/ax/command', { name, ...(cmdArgs !== undefined ? { args: cmdArgs } : {}), source: 'cli' }));
2084
+ output(await api('POST', '/api/canvas/ax/command', { name, ...(cmdArgs !== undefined ? { args: cmdArgs } : {}), source: resolveAxSource(flags) }));
2069
2085
  });
2070
2086
 
2071
2087
  cmd('ax policy get', 'Show the current declarative AX policy', ['pmx-canvas ax policy get'], async (args) => {
@@ -2091,7 +2107,7 @@ cmd('ax policy set', 'Set the declarative AX policy (stored by PMX, enforced by
2091
2107
  const prompt = (mode || systemAppend)
2092
2108
  ? { ...(mode ? { mode } : {}), ...(systemAppend ? { systemAppend } : {}) }
2093
2109
  : undefined;
2094
- output(await api('POST', '/api/canvas/ax/policy', { ...(tools ? { tools } : {}), ...(prompt ? { prompt } : {}), source: 'cli' }));
2110
+ output(await api('POST', '/api/canvas/ax/policy', { ...(tools ? { tools } : {}), ...(prompt ? { prompt } : {}), source: resolveAxSource(flags) }));
2095
2111
  });
2096
2112
 
2097
2113
  cmd('ax timeline', 'Read the bounded AX timeline (events, evidence, steering)', [
@@ -2121,7 +2137,7 @@ cmd('ax work add', 'Add a canvas-bound AX work item', [
2121
2137
  ...(status ? { status } : {}),
2122
2138
  ...(detail ? { detail } : {}),
2123
2139
  ...(positional.length > 0 ? { nodeIds: positional } : {}),
2124
- source: 'cli',
2140
+ source: resolveAxSource(flags),
2125
2141
  }));
2126
2142
  });
2127
2143
 
@@ -2143,7 +2159,7 @@ cmd('ax work update', 'Update a canvas-bound AX work item by ID', [
2143
2159
  ...(status ? { status } : {}),
2144
2160
  ...(detail ? { detail } : {}),
2145
2161
  ...(positional.length > 1 ? { nodeIds: positional.slice(1) } : {}),
2146
- source: 'cli',
2162
+ source: resolveAxSource(flags),
2147
2163
  }));
2148
2164
  });
2149
2165
 
@@ -2172,7 +2188,7 @@ cmd('ax approval request', 'Request a canvas-bound AX approval gate', [
2172
2188
  ...(detail ? { detail } : {}),
2173
2189
  ...(action ? { action } : {}),
2174
2190
  ...(positional.length > 0 ? { nodeIds: positional } : {}),
2175
- source: 'cli',
2191
+ source: resolveAxSource(flags),
2176
2192
  }));
2177
2193
  });
2178
2194
 
@@ -2194,7 +2210,7 @@ cmd('ax approval resolve', 'Resolve a pending AX approval gate by ID', [
2194
2210
  output(await api('POST', `/api/canvas/ax/approval/${encodeURIComponent(id)}/resolve`, {
2195
2211
  decision,
2196
2212
  ...(resolution ? { resolution } : {}),
2197
- source: 'cli',
2213
+ source: resolveAxSource(flags),
2198
2214
  }));
2199
2215
  });
2200
2216
 
@@ -2225,7 +2241,7 @@ cmd('ax evidence add', 'Record an AX evidence item on the timeline', [
2225
2241
  ...(body ? { body } : {}),
2226
2242
  ...(ref ? { ref } : {}),
2227
2243
  ...(positional.length > 0 ? { nodeIds: positional } : {}),
2228
- source: 'cli',
2244
+ source: resolveAxSource(flags),
2229
2245
  }));
2230
2246
  });
2231
2247
 
@@ -2252,7 +2268,7 @@ cmd('ax review add', 'Add a canvas-bound AX review annotation', [
2252
2268
  ...(nodeId ? { nodeId } : {}),
2253
2269
  ...(file ? { file } : {}),
2254
2270
  ...(author ? { author } : {}),
2255
- source: 'cli',
2271
+ source: resolveAxSource(flags),
2256
2272
  }));
2257
2273
  });
2258
2274
 
@@ -2283,7 +2299,7 @@ cmd('ax host report', 'Report host/session capability to the canvas', [
2283
2299
  permissions: flags.permissions === true,
2284
2300
  files: flags.files === true,
2285
2301
  uiPrompts: flags['ui-prompts'] === true,
2286
- source: 'cli',
2302
+ source: resolveAxSource(flags),
2287
2303
  }));
2288
2304
  });
2289
2305
 
@@ -45,6 +45,19 @@ async function postJson<T>(url: string, body: Record<string, unknown>): Promise<
45
45
  return json.result as T;
46
46
  }
47
47
 
48
+ /**
49
+ * Finding F (0.2.4): detect a WebKit-only host — Safari or a WKWebView (e.g. the
50
+ * GitHub Copilot app's embedded panel). Blink engines (Chrome / Chromium / Edge /
51
+ * the Codex browser, all of which carry a Chrome/Chromium/CriOS/Edg token) and
52
+ * Android WebView are excluded, as is Gecko (no `AppleWebKit`). Used to gate the
53
+ * one-time ext-app iframe repaint remount to the only engine that exhibits the
54
+ * present-at-load black-tile paint race, so the remount is a strict no-op
55
+ * everywhere we can test (Chrome / Codex / Playwright).
56
+ */
57
+ export function isWebKitOnlyHost(userAgent: string): boolean {
58
+ return /AppleWebKit/.test(userAgent) && !/Chrome|Chromium|CriOS|Edg|Android/.test(userAgent);
59
+ }
60
+
48
61
  export function getExtAppBridgeInitKey(node: CanvasNodeState, retryKey: number): string {
49
62
  const html = typeof node.data.html === 'string' ? node.data.html : '';
50
63
  const serverName = typeof node.data.serverName === 'string' ? node.data.serverName : '';
@@ -194,6 +207,7 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
194
207
  const toolResultSendingRef = useRef<Promise<void> | null>(null);
195
208
  const bridgeReadyRef = useRef(false);
196
209
  const themeUnsubRef = useRef<(() => void) | null>(null);
210
+ const webkitRepaintDoneRef = useRef(false);
197
211
  const [status, setStatus] = useState<'loading' | 'ready' | 'done'>('loading');
198
212
  const [error, setError] = useState<string | null>(null);
199
213
  const [retryKey, setRetryKey] = useState(0);
@@ -262,6 +276,29 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
262
276
  window.addEventListener('message', onAxMessage);
263
277
  return () => window.removeEventListener('message', onAxMessage);
264
278
  }, [axEnabled, axToken, nodeId]);
279
+
280
+ // Finding F (0.2.4): in a WebKit host panel (e.g. the GitHub Copilot app's
281
+ // WKWebView), the doubly-nested ext-app iframe (workbench iframe → mcp-app.html
282
+ // iframe) can lose the initial-paint race during board hydration and come up as a
283
+ // black tile for nodes already present at panel-load — clean in Blink (Chrome /
284
+ // Codex / our Playwright e2e) and clean for nodes created live after hydration.
285
+ // The deterministic recovery is an iframe remount (exactly what expand+close, and
286
+ // the error-retry button, already do). Replicate that recovery ONCE, on mount, in
287
+ // WebKit-only — gated on the engine so it is a strict no-op in Blink and cannot
288
+ // regress the engine we test against. Inline board instance only (the one that
289
+ // loads black); the expanded overlay already remounts on open.
290
+ useEffect(() => {
291
+ if (expanded || webkitRepaintDoneRef.current) return;
292
+ if (typeof navigator === 'undefined' || typeof window === 'undefined') return;
293
+ if (!isWebKitOnlyHost(navigator.userAgent)) return;
294
+ webkitRepaintDoneRef.current = true;
295
+ // Let the initial hydration burst settle, then force one remount so the nested
296
+ // iframe repaints (mirrors the post-hydration expand+close recovery).
297
+ const timer = window.setTimeout(() => setRetryKey((k) => k + 1), 250);
298
+ return () => window.clearTimeout(timer);
299
+ // eslint-disable-next-line react-hooks/exhaustive-deps
300
+ }, []);
301
+
265
302
  const toMcpTheme = (theme: string): McpUiTheme => (theme === 'light' ? 'light' : 'dark');
266
303
  const isExpanded = expanded || expandedNodeId.value === nodeId;
267
304
 
@@ -60,6 +60,7 @@ const intentUpdateSchema = z.looseObject({
60
60
  confidence: z.number().min(0).max(1).optional(),
61
61
  seq: z.number().int().min(0).max(9999).optional(),
62
62
  ttlMs: z.number().positive().max(MAX_INTENT_TTL_MS).optional(),
63
+ vetoed: z.boolean().optional(),
63
64
  });
64
65
 
65
66
  function parseOrThrow<T>(schema: z.ZodType<T>, raw: unknown, label: string): T {
@@ -165,6 +166,21 @@ export class IntentRegistry {
165
166
  if (!existing) throw new OperationError(`No live intent "${id}".`, 404);
166
167
  const patch = parseOrThrow(intentUpdateSchema, raw, 'intent update');
167
168
  const now = Date.now();
169
+
170
+ // A vetoed update is a veto, not a patch: poison the id and dissolve the ghost
171
+ // so a later linked settle (beginCommit) is rejected — for a live, non-committing
172
+ // intent the same authoritative outcome as clear({ vetoed:true }). (A committing
173
+ // intent is already gated above with a 409, so the commit wins the race.) Without
174
+ // this, vetoed-via-update only dissolved the ghost visually while the linked
175
+ // mutation still landed (#C, 0.2.4).
176
+ if (patch.vetoed === true) {
177
+ this.rememberVeto(id);
178
+ this.intents.delete(id);
179
+ this.emit('ax-intent-clear', { id, vetoed: true });
180
+ this.maybeStopSweeper();
181
+ return { ...existing, expiresAt: now };
182
+ }
183
+
168
184
  const ttl = typeof patch.ttlMs === 'number' ? patch.ttlMs : DEFAULT_INTENT_TTL_MS;
169
185
 
170
186
  const intent: PmxAxIntent = {
@@ -250,6 +250,7 @@ const axTimelineGetOperation = defineOperation<z.infer<typeof axTimelineGetSchem
250
250
  const axDeliveryPendingShape = {
251
251
  consumer: z.unknown().optional().describe('Consumer/source label to exclude from results (e.g. copilot, mcp).'),
252
252
  limit: z.unknown().optional().describe('Max steering messages to return.'),
253
+ order: z.unknown().optional().describe('"oldest" (FIFO, default) or "newest" first.'),
253
254
  };
254
255
 
255
256
  const axDeliveryPendingSchema = z.looseObject(axDeliveryPendingShape);
@@ -265,10 +266,11 @@ const axDeliveryPendingOperation = defineOperation<z.infer<typeof axDeliveryPend
265
266
  },
266
267
  mcp: {
267
268
  toolName: 'canvas_claim_ax_delivery',
268
- description: 'Claim pending PMX AX deliveries for a consumer (adapterless delivery). Returns `pending` undelivered steering (mark each with canvas_mark_ax_delivery after acting) AND `pendingActivity`: open canvas-bound AX items awaiting the agent (open work items, pending approval gates / elicitations / mode requests) — typically created by the human in the browser. Both exclude items the consumer itself originated (loop prevention). pendingActivity is read-only here: resolve each via its own tool (canvas_resolve_approval / canvas_respond_elicitation / canvas_resolve_mode / canvas_update_work_item), not canvas_mark_ax_delivery.',
269
+ description: 'Claim pending PMX AX deliveries for a consumer (adapterless delivery). Returns `pending` undelivered steering (mark each with canvas_mark_ax_delivery after acting) AND `pendingActivity`: open canvas-bound AX items awaiting the agent (open work items, pending approval gates / elicitations / mode requests) — typically created by the human in the browser. Both exclude items the consumer itself originated (loop prevention). `pending` defaults to oldest-first (FIFO, for ordered processing); pass `order:"newest"` to surface the human\'s LATEST in-canvas steering first when a small `limit` would otherwise bury it behind a stale backlog (report #68). pendingActivity is read-only here: resolve each via its own tool (canvas_resolve_approval / canvas_respond_elicitation / canvas_resolve_mode / canvas_update_work_item), not canvas_mark_ax_delivery.',
269
270
  extraShape: {
270
271
  consumer: z.string().optional().describe('Consumer/source label to exclude from results (e.g. copilot, mcp).'),
271
272
  limit: z.number().optional().describe('Max steering messages to return.'),
273
+ order: z.enum(['newest', 'oldest']).optional().describe('Order of returned steering: "oldest" (FIFO, default) for ordered processing, or "newest" first to see the latest browser action when limited.'),
272
274
  },
273
275
  // `consumer` is a loop-safety scope, not a source label — never defaulted.
274
276
  formatResult: axJsonResult,
@@ -277,10 +279,15 @@ const axDeliveryPendingOperation = defineOperation<z.infer<typeof axDeliveryPend
277
279
  const consumer = typeof input.consumer === 'string' ? input.consumer : undefined;
278
280
  const limitRaw = Number(input.limit ?? '');
279
281
  const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? limitRaw : undefined;
280
- const pending = canvasState.getPendingSteering({
281
- ...(consumer ? { consumer } : {}),
282
- ...(limit ? { limit } : {}),
283
- });
282
+ // #68: default FIFO (oldest-first) for ordered processing; `order:"newest"`
283
+ // surfaces the latest browser-originated steering first so a small `limit`
284
+ // can't bury the human's current action behind stale undelivered rows. Both
285
+ // queries apply the same loop-safe consumer filter before the limit.
286
+ const newest = input.order === 'newest';
287
+ const scope = { ...(consumer ? { consumer } : {}), ...(limit ? { limit } : {}) };
288
+ const pending = newest
289
+ ? canvasState.getPendingSteeringForContext(scope)
290
+ : canvasState.getPendingSteering(scope);
284
291
  // The MCP tool aggregated pendingActivity; one wire body now serves it over
285
292
  // HTTP too (documented broadening). Loop-safe: consumer scopes both queries.
286
293
  const pendingActivity = buildPendingAxActivity(canvasState.getAxState(), consumer);
@@ -66,6 +66,7 @@ const intentUpdateShape = {
66
66
  confidence: z.number().optional().describe('0..1 → ghost opacity/solidity.'),
67
67
  seq: z.number().optional().describe('New ordering hint.'),
68
68
  ttlMs: z.number().optional().describe('Reset the TTL to this many ms from now.'),
69
+ vetoed: z.boolean().optional().describe('Veto the intent: dissolves the ghost AND poisons the id so a later linked settle is rejected (same as clear { vetoed:true }).'),
69
70
  };
70
71
 
71
72
  const intentUpdateSchema = z.looseObject(intentUpdateShape);