pmx-canvas 0.1.34 → 0.1.36

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.
@@ -48,6 +48,37 @@ panel.
48
48
  - Reads `/api/canvas/ax/context` and injects pinned/focused context from
49
49
  `onUserPromptSubmitted`.
50
50
  - Exposes adapter actions for status, AX context refresh, AX focus, and explicit session steering.
51
+
52
+ ### Agent behavior — steering is gated, not pushed
53
+
54
+ `onUserPromptSubmitted` injects the whole `/api/canvas/ax/context` (pins, focus, work
55
+ items, approval gates, and `timeline.pendingSteering`) as hidden context — but only
56
+ when the **pin/focus gate is open** (`pinned.count > 0 || focus.nodeIds.length > 0`),
57
+ and it is clipped to a char budget. Three consequences the adapter/agent must honor:
58
+
59
+ 1. A steering board must **stay pinned** (or its button must also emit `ax.focus.set`
60
+ on the board node) to hold the gate open.
61
+ 2. A sandbox button click does **not** wake a turn — a human message does. The click
62
+ only enqueues the steer.
63
+ 3. The agent must **act on injected `pendingSteering` / `pendingActivity` and then ack**
64
+ (`canvas_mark_ax_delivery`), or it re-injects every gated turn.
65
+
66
+ To be robust to the char clip, prefer injecting the compact loop-safe lead block from
67
+ `GET /api/canvas/ax/context?consumer=copilot` (`delivery.pendingSteering` +
68
+ `delivery.pendingActivity`) **above** the full dump.
69
+
70
+ ### Closing the loop (optional, recommended)
71
+
72
+ - **Forward tool/session hooks** (`onPreToolUse` / `onPostToolUse` /
73
+ `onPostToolUseFailure` / `onSessionStart` / `onSessionEnd` / `onErrorOccurred`) to
74
+ `POST /api/canvas/ax/activity` (`canvas_ingest_activity`) so the board reflects the
75
+ agent's real work automatically (a failed tool → a blocked work item + review +
76
+ evidence).
77
+ - **Await gates** with `canvas_await_approval` / `canvas_await_elicitation` /
78
+ `canvas_await_mode` (or surface a native modal and await the PMX result) so an
79
+ approval gate actually blocks the agent until the human resolves it.
80
+
81
+ See [`docs/ax-host-adapter-contract.md`](../../../docs/ax-host-adapter-contract.md).
51
82
  - Keeps all persistent PMX state in `.pmx-canvas/canvas.db`; the extension does not own canvas
52
83
  state.
53
84
 
@@ -97,6 +97,66 @@ export function resolveExtAppSandbox(value: unknown): string {
97
97
  : DEFAULT_EXT_APP_SANDBOX;
98
98
  }
99
99
 
100
+ export function buildExtAppAxBridgeScript(axToken: string, nodeId: string): string {
101
+ return `<script data-pmx-canvas-ax-bridge>
102
+ (function () {
103
+ const PMX_AX_TOKEN = ${JSON.stringify(axToken)};
104
+ const PMX_AX_NODE_ID = ${JSON.stringify(nodeId)};
105
+ window.PMX_AX = window.PMX_AX || {};
106
+ const pending = new Map();
107
+ const ackListeners = [];
108
+ let seq = 0;
109
+ window.PMX_AX.emit = function (type, payload) {
110
+ seq += 1;
111
+ const correlationId = PMX_AX_NODE_ID + '-' + seq + '-' + (Date.now ? Date.now() : 0);
112
+ return new Promise(function (resolve) {
113
+ const timer = setTimeout(function () {
114
+ pending.delete(correlationId);
115
+ resolve({ ok: false, status: 504, code: 'ax-ack-timeout', error: 'ax-ack-timeout' });
116
+ }, 10000);
117
+ pending.set(correlationId, function (result) { clearTimeout(timer); resolve(result); });
118
+ window.parent.postMessage({
119
+ source: 'pmx-canvas-ax',
120
+ token: PMX_AX_TOKEN,
121
+ nodeId: PMX_AX_NODE_ID,
122
+ correlationId: correlationId,
123
+ interaction: { type: String(type), payload: payload && typeof payload === 'object' ? payload : {} },
124
+ }, '*');
125
+ });
126
+ };
127
+ window.PMX_AX.on = function (eventType, cb) {
128
+ if (eventType === 'ack' && typeof cb === 'function') ackListeners.push(cb);
129
+ };
130
+ window.addEventListener('message', function (event) {
131
+ const m = event.data;
132
+ if (!m || m.source !== 'pmx-canvas-ax-ack' || m.token !== PMX_AX_TOKEN) return;
133
+ const result = m.result || { ok: false };
134
+ const resolver = m.correlationId ? pending.get(m.correlationId) : undefined;
135
+ if (resolver) { pending.delete(m.correlationId); resolver(result); }
136
+ for (let i = 0; i < ackListeners.length; i += 1) {
137
+ try { ackListeners[i](result, m.interaction || null); } catch (e) {}
138
+ }
139
+ try { window.dispatchEvent(new CustomEvent('pmx-ax-ack', { detail: { result: result, interaction: m.interaction || null } })); } catch (e) {}
140
+ });
141
+ })();
142
+ </script>`;
143
+ }
144
+
145
+ export function injectExtAppAxBridgeScript(html: string, axBridgeScript: string): string {
146
+ if (!axBridgeScript) return html;
147
+ const headMatch = /<head\b[^>]*>/i.exec(html);
148
+ if (headMatch?.index !== undefined) {
149
+ const insertAt = headMatch.index + headMatch[0].length;
150
+ return `${html.slice(0, insertAt)}${axBridgeScript}${html.slice(insertAt)}`;
151
+ }
152
+ const bodyMatch = /<body\b[^>]*>/i.exec(html);
153
+ if (bodyMatch?.index !== undefined) {
154
+ const insertAt = bodyMatch.index + bodyMatch[0].length;
155
+ return `${html.slice(0, insertAt)}${axBridgeScript}${html.slice(insertAt)}`;
156
+ }
157
+ return `${axBridgeScript}${html}`;
158
+ }
159
+
100
160
  function positiveDimension(value: number, fallback: number): number {
101
161
  if (Number.isFinite(value) && value > 0) return Math.round(value);
102
162
  if (Number.isFinite(fallback) && fallback > 0) return Math.round(fallback);
@@ -164,31 +224,39 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
164
224
  const axEnabled = axCaps?.enabled === true && typeof html === 'string' && html.length > 0;
165
225
  const axToken = useMemo(() => `ax-${crypto.randomUUID()}`, []);
166
226
  const axBridgeScript = axEnabled
167
- ? `<script data-pmx-canvas-ax-bridge>window.PMX_AX={emit:function(t,p){window.parent.postMessage({source:'pmx-canvas-ax',token:${JSON.stringify(axToken)},nodeId:${JSON.stringify(nodeId)},interaction:{type:String(t),payload:p&&typeof p==='object'?p:{}}},'*');}};</script>`
227
+ ? buildExtAppAxBridgeScript(axToken, nodeId)
168
228
  : '';
169
- const iframeDocument = useIframeDocument((html ?? '') + axBridgeScript, iframeSandbox);
229
+ const iframeDocument = useIframeDocument(injectExtAppAxBridgeScript(html ?? '', axBridgeScript), iframeSandbox);
170
230
 
171
231
  useEffect(() => {
172
232
  if (!axEnabled) return;
173
233
  function onAxMessage(event: MessageEvent) {
174
234
  if (event.source !== iframeRef.current?.contentWindow) return;
175
235
  const data = event.data as {
176
- source?: string; token?: string; nodeId?: string;
236
+ source?: string; token?: string; nodeId?: string; correlationId?: string;
177
237
  interaction?: { type?: unknown; payload?: unknown };
178
238
  } | null;
179
239
  if (!data || data.source !== 'pmx-canvas-ax' || data.token !== axToken || data.nodeId !== nodeId) return;
180
240
  const interaction = data.interaction;
181
241
  if (!interaction || typeof interaction.type !== 'string') return;
242
+ const interactionType = interaction.type;
182
243
  void submitAxInteractionFromClient({
183
- type: interaction.type,
244
+ type: interactionType,
184
245
  sourceNodeId: nodeId,
185
246
  sourceSurface: 'mcp-app',
186
247
  ...(interaction.payload && typeof interaction.payload === 'object'
187
248
  ? { payload: interaction.payload as Record<string, unknown> }
188
249
  : {}),
189
250
  }).then((res) => {
190
- if (res.ok) showToast('context', 'AX interaction', interaction.type as string, [nodeId]);
251
+ if (res.ok) showToast('context', 'AX interaction', interactionType, [nodeId]);
191
252
  else showToast('remove', 'AX interaction rejected', res.error ?? res.code ?? '', [nodeId]);
253
+ iframeRef.current?.contentWindow?.postMessage({
254
+ source: 'pmx-canvas-ax-ack',
255
+ token: axToken,
256
+ ...(data.correlationId ? { correlationId: data.correlationId } : {}),
257
+ interaction: { type: interactionType },
258
+ result: res,
259
+ }, '*');
192
260
  });
193
261
  }
194
262
  window.addEventListener('message', onAxMessage);
@@ -57,22 +57,31 @@ export function HtmlNode({
57
57
  // nodeId are a second gate, not the only one.
58
58
  if (event.source !== iframeRef.current?.contentWindow) return;
59
59
  const data = event.data as {
60
- source?: string; token?: string; nodeId?: string;
60
+ source?: string; token?: string; nodeId?: string; correlationId?: string;
61
61
  interaction?: { type?: unknown; payload?: unknown };
62
62
  } | null;
63
63
  if (!data || data.source !== 'pmx-canvas-ax' || data.token !== axToken || data.nodeId !== node.id) return;
64
64
  const interaction = data.interaction;
65
65
  if (!interaction || typeof interaction.type !== 'string') return;
66
+ const interactionType = interaction.type;
66
67
  void submitAxInteractionFromClient({
67
- type: interaction.type,
68
+ type: interactionType,
68
69
  sourceNodeId: node.id,
69
70
  sourceSurface: 'html-node',
70
71
  ...(interaction.payload && typeof interaction.payload === 'object'
71
72
  ? { payload: interaction.payload as Record<string, unknown> }
72
73
  : {}),
73
74
  }).then((res) => {
74
- if (res.ok) showToast('context', 'AX interaction', interaction.type as string, [node.id]);
75
+ if (res.ok) showToast('context', 'AX interaction', interactionType, [node.id]);
75
76
  else showToast('remove', 'AX interaction rejected', res.error ?? res.code ?? '', [node.id]);
77
+ // Report #55: ack back to the surface so it can self-confirm (e.g. "queued ✓").
78
+ iframeRef.current?.contentWindow?.postMessage({
79
+ source: 'pmx-canvas-ax-ack',
80
+ token: axToken,
81
+ ...(data.correlationId ? { correlationId: data.correlationId } : {}),
82
+ interaction: { type: interactionType },
83
+ result: res,
84
+ }, '*');
76
85
  });
77
86
  }
78
87
  window.addEventListener('message', onAxMessage);
@@ -89,22 +89,31 @@ function McpAppViewer({ node, expanded }: { node: CanvasNodeState; expanded: boo
89
89
  function onAxMessage(event: MessageEvent) {
90
90
  if (event.source !== iframeRef.current?.contentWindow) return;
91
91
  const data = event.data as {
92
- source?: string; token?: string; nodeId?: string;
92
+ source?: string; token?: string; nodeId?: string; correlationId?: string;
93
93
  interaction?: { type?: unknown; payload?: unknown };
94
94
  } | null;
95
95
  if (!data || data.source !== 'pmx-canvas-ax' || data.token !== axToken || data.nodeId !== node.id) return;
96
96
  const interaction = data.interaction;
97
97
  if (!interaction || typeof interaction.type !== 'string') return;
98
+ const interactionType = interaction.type;
98
99
  void submitAxInteractionFromClient({
99
- type: interaction.type,
100
+ type: interactionType,
100
101
  sourceNodeId: node.id,
101
102
  sourceSurface: axSurface,
102
103
  ...(interaction.payload && typeof interaction.payload === 'object'
103
104
  ? { payload: interaction.payload as Record<string, unknown> }
104
105
  : {}),
105
106
  }).then((res) => {
106
- if (res.ok) showToast('context', 'AX interaction', interaction.type as string, [node.id]);
107
+ if (res.ok) showToast('context', 'AX interaction', interactionType, [node.id]);
107
108
  else showToast('remove', 'AX interaction rejected', res.error ?? res.code ?? '', [node.id]);
109
+ // Report #55: ack back to the viewer so the surface can self-confirm.
110
+ iframeRef.current?.contentWindow?.postMessage({
111
+ source: 'pmx-canvas-ax-ack',
112
+ token: axToken,
113
+ ...(data.correlationId ? { correlationId: data.correlationId } : {}),
114
+ interaction: { type: interactionType },
115
+ result: res,
116
+ }, '*');
108
117
  });
109
118
  }
110
119
  window.addEventListener('message', onAxMessage);
@@ -121,6 +121,9 @@ function buildAxHandlers(): Record<string, (params: Record<string, unknown>) =>
121
121
  const token = window.__PMX_CANVAS_AX_TOKEN__;
122
122
  const handlers: Record<string, (params: Record<string, unknown>) => void> = {};
123
123
  if (!nodeId || !token) return handlers;
124
+ // Declarative json-render boards are reflect-only: a spec action is fire-and-forget
125
+ // and confirmation arrives as a live `pmx-ax-update` (the work item appears). There
126
+ // is no JS surface for a Promise-style ack here, so we don't stamp a correlationId.
124
127
  for (const type of AX_INTERACTION_HANDLER_NAMES) {
125
128
  handlers[type] = (params: Record<string, unknown>) => {
126
129
  window.parent.postMessage({
@@ -51,6 +51,11 @@ type ListModeRequestsResult = ReturnType<PmxCanvas['listModeRequests']>;
51
51
  type RequestModeInput = Parameters<PmxCanvas['requestMode']>[0];
52
52
  type RequestModeResult = ReturnType<PmxCanvas['requestMode']>;
53
53
  type ResolveModeRequestResult = ReturnType<PmxCanvas['resolveModeRequest']>;
54
+ type IngestActivityInput = Parameters<PmxCanvas['ingestActivity']>[0];
55
+ type IngestActivityResult = ReturnType<PmxCanvas['ingestActivity']>;
56
+ type AwaitApprovalResult = Awaited<ReturnType<PmxCanvas['awaitApproval']>>;
57
+ type AwaitElicitationResult = Awaited<ReturnType<PmxCanvas['awaitElicitation']>>;
58
+ type AwaitModeResult = Awaited<ReturnType<PmxCanvas['awaitMode']>>;
54
59
  type GetCommandRegistryResult = ReturnType<PmxCanvas['getCommandRegistry']>;
55
60
  type InvokeCommandResult = ReturnType<PmxCanvas['invokeCommand']>;
56
61
  type GetPolicyResult = ReturnType<PmxCanvas['getPolicy']>;
@@ -165,7 +170,7 @@ export interface CanvasAccess {
165
170
  focusNode(id: string, options?: { noPan?: boolean }): Promise<FocusNodeResult>;
166
171
  fitView(options?: FitViewOptions): Promise<FitViewResult>;
167
172
  getAxState(): Promise<AxStateResult>;
168
- getAxContext(): Promise<AxContextResult>;
173
+ getAxContext(options?: { consumer?: string }): Promise<AxContextResult>;
169
174
  setAxFocus(nodeIds: string[], options?: { source?: PmxAxSource }): Promise<SetAxFocusResult>;
170
175
  recordAxEvent(input: RecordAxEventInput, options?: { source?: PmxAxSource }): Promise<RecordAxEventResult>;
171
176
  sendSteering(message: string, options?: { source?: PmxAxSource }): Promise<SendSteeringResult>;
@@ -191,6 +196,10 @@ export interface CanvasAccess {
191
196
  listModeRequests(): Promise<ListModeRequestsResult>;
192
197
  requestMode(input: RequestModeInput, options?: { source?: PmxAxSource }): Promise<RequestModeResult>;
193
198
  resolveModeRequest(id: string, decision: 'approved' | 'rejected', options?: { resolution?: string; source?: PmxAxSource }): Promise<ResolveModeRequestResult>;
199
+ ingestActivity(input: IngestActivityInput, options?: { source?: PmxAxSource }): Promise<IngestActivityResult>;
200
+ awaitApproval(id: string, options?: { timeoutMs?: number }): Promise<AwaitApprovalResult>;
201
+ awaitElicitation(id: string, options?: { timeoutMs?: number }): Promise<AwaitElicitationResult>;
202
+ awaitMode(id: string, options?: { timeoutMs?: number }): Promise<AwaitModeResult>;
194
203
  getCommandRegistry(): Promise<GetCommandRegistryResult>;
195
204
  invokeCommand(name: string, args?: Record<string, unknown> | null, options?: { source?: PmxAxSource }): Promise<InvokeCommandResult>;
196
205
  getPolicy(): Promise<GetPolicyResult>;
@@ -336,8 +345,8 @@ class LocalCanvasAccess implements CanvasAccess {
336
345
  return this.canvas.getAxState();
337
346
  }
338
347
 
339
- async getAxContext(): Promise<AxContextResult> {
340
- return this.canvas.getAxContext();
348
+ async getAxContext(options?: { consumer?: string }): Promise<AxContextResult> {
349
+ return this.canvas.getAxContext(options);
341
350
  }
342
351
 
343
352
  async setAxFocus(nodeIds: string[], options?: { source?: PmxAxSource }): Promise<SetAxFocusResult> {
@@ -396,6 +405,22 @@ class LocalCanvasAccess implements CanvasAccess {
396
405
  return this.canvas.resolveModeRequest(id, decision, { ...(options ?? {}), source: options?.source ?? 'mcp' });
397
406
  }
398
407
 
408
+ async ingestActivity(input: IngestActivityInput, options?: { source?: PmxAxSource }): Promise<IngestActivityResult> {
409
+ return this.canvas.ingestActivity(input, { source: options?.source ?? 'mcp' });
410
+ }
411
+
412
+ async awaitApproval(id: string, options?: { timeoutMs?: number }): Promise<AwaitApprovalResult> {
413
+ return this.canvas.awaitApproval(id, options);
414
+ }
415
+
416
+ async awaitElicitation(id: string, options?: { timeoutMs?: number }): Promise<AwaitElicitationResult> {
417
+ return this.canvas.awaitElicitation(id, options);
418
+ }
419
+
420
+ async awaitMode(id: string, options?: { timeoutMs?: number }): Promise<AwaitModeResult> {
421
+ return this.canvas.awaitMode(id, options);
422
+ }
423
+
399
424
  async getCommandRegistry(): Promise<GetCommandRegistryResult> {
400
425
  return this.canvas.getCommandRegistry();
401
426
  }
@@ -831,8 +856,9 @@ class RemoteCanvasAccess implements CanvasAccess {
831
856
  return response.state;
832
857
  }
833
858
 
834
- async getAxContext(): Promise<AxContextResult> {
835
- return await this.requestJson<AxContextResult>('GET', '/api/canvas/ax/context');
859
+ async getAxContext(options?: { consumer?: string }): Promise<AxContextResult> {
860
+ const qs = options?.consumer ? `?consumer=${encodeURIComponent(options.consumer)}` : '';
861
+ return await this.requestJson<AxContextResult>('GET', `/api/canvas/ax/context${qs}`);
836
862
  }
837
863
 
838
864
  async setAxFocus(nodeIds: string[], options?: { source?: PmxAxSource }): Promise<SetAxFocusResult> {
@@ -961,6 +987,49 @@ class RemoteCanvasAccess implements CanvasAccess {
961
987
  return (await res.json() as { modeRequest?: ResolveModeRequestResult }).modeRequest ?? null;
962
988
  }
963
989
 
990
+ async ingestActivity(input: IngestActivityInput, options?: { source?: PmxAxSource }): Promise<IngestActivityResult> {
991
+ return await this.requestJson<IngestActivityResult>('POST', '/api/canvas/ax/activity', {
992
+ ...input,
993
+ source: options?.source ?? 'mcp',
994
+ });
995
+ }
996
+
997
+ async awaitApproval(id: string, options?: { timeoutMs?: number }): Promise<AwaitApprovalResult> {
998
+ // Mirror PmxCanvas's `?? 30000` default so the remote transport blocks like the
999
+ // local one (an omitted timeout must still long-poll). Explicit 0 = immediate read.
1000
+ const ms = options?.timeoutMs ?? 30000;
1001
+ const qs = ms > 0 ? `?waitMs=${ms}` : '';
1002
+ const res = await fetch(`${this.remoteBaseUrl}/api/canvas/ax/approval/${encodeURIComponent(id)}${qs}`);
1003
+ if (res.status === 404) return { approvalGate: null, pending: false };
1004
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
1005
+ const body = await res.json() as { approvalGate?: AwaitApprovalResult['approvalGate']; pending?: boolean };
1006
+ return { approvalGate: body.approvalGate ?? null, pending: body.pending ?? false };
1007
+ }
1008
+
1009
+ async awaitElicitation(id: string, options?: { timeoutMs?: number }): Promise<AwaitElicitationResult> {
1010
+ // Mirror PmxCanvas's `?? 30000` default so the remote transport blocks like the
1011
+ // local one (an omitted timeout must still long-poll). Explicit 0 = immediate read.
1012
+ const ms = options?.timeoutMs ?? 30000;
1013
+ const qs = ms > 0 ? `?waitMs=${ms}` : '';
1014
+ const res = await fetch(`${this.remoteBaseUrl}/api/canvas/ax/elicitation/${encodeURIComponent(id)}${qs}`);
1015
+ if (res.status === 404) return { elicitation: null, pending: false };
1016
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
1017
+ const body = await res.json() as { elicitation?: AwaitElicitationResult['elicitation']; pending?: boolean };
1018
+ return { elicitation: body.elicitation ?? null, pending: body.pending ?? false };
1019
+ }
1020
+
1021
+ async awaitMode(id: string, options?: { timeoutMs?: number }): Promise<AwaitModeResult> {
1022
+ // Mirror PmxCanvas's `?? 30000` default so the remote transport blocks like the
1023
+ // local one (an omitted timeout must still long-poll). Explicit 0 = immediate read.
1024
+ const ms = options?.timeoutMs ?? 30000;
1025
+ const qs = ms > 0 ? `?waitMs=${ms}` : '';
1026
+ const res = await fetch(`${this.remoteBaseUrl}/api/canvas/ax/mode/${encodeURIComponent(id)}${qs}`);
1027
+ if (res.status === 404) return { modeRequest: null, pending: false };
1028
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
1029
+ const body = await res.json() as { modeRequest?: AwaitModeResult['modeRequest']; pending?: boolean };
1030
+ return { modeRequest: body.modeRequest ?? null, pending: body.pending ?? false };
1031
+ }
1032
+
964
1033
  async getCommandRegistry(): Promise<GetCommandRegistryResult> {
965
1034
  const r = await this.requestJson<{ commands?: GetCommandRegistryResult }>('GET', '/api/canvas/ax/command');
966
1035
  return r.commands ?? [];
package/src/mcp/server.ts CHANGED
@@ -26,6 +26,7 @@ import { isAbsolute, relative, resolve } from 'node:path';
26
26
  import { z } from 'zod';
27
27
  import { canvasState, describeCanvasSchema, validateStructuredCanvasPayload } from '../server/index.js';
28
28
  import { AX_INTERACTION_TYPES } from '../server/ax-interaction.js';
29
+ import { buildPendingAxActivity } from '../server/ax-state.js';
29
30
  import { isHtmlPrimitiveKind } from '../server/html-primitives.js';
30
31
  import type { HtmlPrimitiveKind } from '../server/html-primitives.js';
31
32
  import { createCanvasAccess, refreshCanvasAccess, type CanvasAccess } from './canvas-access.js';
@@ -200,56 +201,6 @@ function wantsFullPayload(input: { full?: boolean; verbose?: boolean; includeDat
200
201
  return input.full === true || input.verbose === true || input.includeData === true;
201
202
  }
202
203
 
203
- interface PendingAxActivityItem {
204
- kind: 'work-item' | 'approval-gate' | 'elicitation' | 'mode-request';
205
- id: string;
206
- title: string;
207
- status: string;
208
- nodeIds: string[];
209
- source: string | null;
210
- }
211
-
212
- const OPEN_AX_WORK_STATUSES = new Set(['todo', 'in-progress', 'blocked']);
213
-
214
- /**
215
- * Open, agent-actionable canvas-bound AX items (open work items + pending approval
216
- * gates / elicitations / mode requests). Unlike steering (a directive routed through
217
- * the claim/ack delivery queue), these are STATE the human curates in the browser —
218
- * they fire `ax-state-changed` (so resource-subscribers are pushed canvas://ax-work),
219
- * but an adapterless client that only POLLS the delivery surface never saw them.
220
- * Surfacing this digest there closes report #43 without conflating state with steering.
221
- * Optionally excludes items the consumer itself originated (loop prevention), mirroring
222
- * getPendingSteering.
223
- */
224
- function buildPendingAxActivity(
225
- state: Awaited<ReturnType<CanvasAccess['getAxState']>>,
226
- consumer?: string,
227
- ): PendingAxActivityItem[] {
228
- const notMine = (source: string | null) => !consumer || source !== consumer;
229
- const out: PendingAxActivityItem[] = [];
230
- for (const w of state.workItems ?? []) {
231
- if (OPEN_AX_WORK_STATUSES.has(w.status) && notMine(w.source)) {
232
- out.push({ kind: 'work-item', id: w.id, title: w.title, status: w.status, nodeIds: w.nodeIds ?? [], source: w.source });
233
- }
234
- }
235
- for (const g of state.approvalGates ?? []) {
236
- if (g.status === 'pending' && notMine(g.source)) {
237
- out.push({ kind: 'approval-gate', id: g.id, title: g.title, status: g.status, nodeIds: g.nodeIds ?? [], source: g.source });
238
- }
239
- }
240
- for (const e of state.elicitations ?? []) {
241
- if (e.status === 'pending' && notMine(e.source)) {
242
- out.push({ kind: 'elicitation', id: e.id, title: e.prompt, status: e.status, nodeIds: e.nodeIds ?? [], source: e.source });
243
- }
244
- }
245
- for (const m of state.modeRequests ?? []) {
246
- if (m.status === 'pending' && notMine(m.source)) {
247
- out.push({ kind: 'mode-request', id: m.id, title: m.reason ? `${m.mode}: ${m.reason}` : `mode: ${m.mode}`, status: m.status, nodeIds: m.nodeIds ?? [], source: m.source });
248
- }
249
- }
250
- return out;
251
- }
252
-
253
204
  function compactNodePayload(node: Awaited<ReturnType<CanvasAccess['getNode']>>): Record<string, unknown> | null {
254
205
  if (!node) return null;
255
206
  const serialized = serializeCanvasNode(node);
@@ -1361,7 +1312,7 @@ export async function startMcpServer(): Promise<void> {
1361
1312
  const c = await ensureCanvas();
1362
1313
  const state = await c.getAxState();
1363
1314
  const host = await c.getHostCapability();
1364
- const context = includeContext === false ? undefined : await c.getAxContext();
1315
+ const context = includeContext === false ? undefined : await c.getAxContext({ consumer: 'mcp' });
1365
1316
  return {
1366
1317
  content: [
1367
1318
  {
@@ -1839,6 +1790,96 @@ export async function startMcpServer(): Promise<void> {
1839
1790
  },
1840
1791
  );
1841
1792
 
1793
+ server.tool(
1794
+ 'canvas_ingest_activity',
1795
+ 'Ingest a normalized agent activity (a tool/session event your harness forwards) so the board reacts automatically — primitive A, makes AX bidirectional. Always records a timeline event; kind-driven default reactions (overridable per call via `reactions`): failure/error → work item (blocked) + review finding + evidence (logs); tool-result + outcome:"success" → evidence (tool-result); everything else (tool-start, session-*, command, note) → event only. Set any reaction to false to suppress it, or to an object to override its fields. Returns { event, workItem, evidence, review }.',
1796
+ {
1797
+ kind: z.enum(['tool-start', 'tool-result', 'failure', 'error', 'session-start', 'session-end', 'command', 'note']),
1798
+ title: z.string(),
1799
+ summary: z.string().optional(),
1800
+ outcome: z.enum(['success', 'failure']).optional(),
1801
+ ref: z.string().optional().describe('A file path, URL, or commit the activity refers to (used as the review file anchor for failures).'),
1802
+ nodeIds: z.array(z.string()).optional(),
1803
+ data: z.record(z.string(), z.unknown()).optional(),
1804
+ reactions: z.object({
1805
+ workItem: z.union([z.literal(false), z.object({
1806
+ status: z.enum(['todo', 'in-progress', 'blocked', 'done', 'cancelled']).optional(),
1807
+ detail: z.string().nullable().optional(),
1808
+ })]).optional(),
1809
+ evidence: z.union([z.literal(false), z.object({
1810
+ kind: z.enum(['logs', 'tool-result', 'screenshot', 'file', 'diff', 'test-output']).optional(),
1811
+ body: z.string().nullable().optional(),
1812
+ })]).optional(),
1813
+ review: z.union([z.literal(false), z.object({
1814
+ severity: z.enum(['info', 'warning', 'error']).optional(),
1815
+ kind: z.enum(['comment', 'finding']).optional(),
1816
+ anchorType: z.enum(['node', 'file', 'region']).optional(),
1817
+ nodeId: z.string().nullable().optional(),
1818
+ })]).optional(),
1819
+ }).optional().describe('Override or suppress the kind-driven default reactions.'),
1820
+ source: z.enum(['agent', 'api', 'browser', 'cli', 'codex', 'copilot', 'mcp', 'sdk', 'system']).optional(),
1821
+ },
1822
+ async ({ kind, title, summary, outcome, ref, nodeIds, data, reactions, source }) => {
1823
+ const c = await ensureCanvas();
1824
+ const result = await c.ingestActivity(
1825
+ {
1826
+ kind,
1827
+ title,
1828
+ ...(summary !== undefined ? { summary } : {}),
1829
+ ...(outcome !== undefined ? { outcome } : {}),
1830
+ ...(ref !== undefined ? { ref } : {}),
1831
+ ...(nodeIds !== undefined ? { nodeIds } : {}),
1832
+ ...(data !== undefined ? { data } : {}),
1833
+ ...(reactions !== undefined ? { reactions } : {}),
1834
+ },
1835
+ { source: source ?? 'mcp' },
1836
+ );
1837
+ return { content: [{ type: 'text', text: JSON.stringify({ ok: true, ...result }) }] };
1838
+ },
1839
+ );
1840
+
1841
+ server.tool(
1842
+ 'canvas_await_approval',
1843
+ 'Block until an approval gate resolves (the human approves/rejects it in the browser) or the timeout elapses — primitive D, gates that actually gate. timeoutMs 0 = read immediately without waiting. Returns { approvalGate, pending } (pending=true → still unresolved after the wait).',
1844
+ {
1845
+ id: z.string(),
1846
+ timeoutMs: z.number().int().min(0).max(120000).optional().describe('Max ms to block (default 30000; 0 = immediate read; capped at 120000).'),
1847
+ },
1848
+ async ({ id, timeoutMs }) => {
1849
+ const c = await ensureCanvas();
1850
+ const result = await c.awaitApproval(id, timeoutMs !== undefined ? { timeoutMs } : {});
1851
+ return { content: [{ type: 'text', text: JSON.stringify({ ok: result.approvalGate !== null, ...result }) }] };
1852
+ },
1853
+ );
1854
+
1855
+ server.tool(
1856
+ 'canvas_await_elicitation',
1857
+ 'Block until an elicitation is answered (the human responds in the browser) or the timeout elapses — primitive D. timeoutMs 0 = read immediately. Returns { elicitation, pending }.',
1858
+ {
1859
+ id: z.string(),
1860
+ timeoutMs: z.number().int().min(0).max(120000).optional().describe('Max ms to block (default 30000; 0 = immediate read; capped at 120000).'),
1861
+ },
1862
+ async ({ id, timeoutMs }) => {
1863
+ const c = await ensureCanvas();
1864
+ const result = await c.awaitElicitation(id, timeoutMs !== undefined ? { timeoutMs } : {});
1865
+ return { content: [{ type: 'text', text: JSON.stringify({ ok: result.elicitation !== null, ...result }) }] };
1866
+ },
1867
+ );
1868
+
1869
+ server.tool(
1870
+ 'canvas_await_mode',
1871
+ 'Block until a mode request resolves (approved/rejected in the browser) or the timeout elapses — primitive D. timeoutMs 0 = read immediately. Returns { modeRequest, pending }.',
1872
+ {
1873
+ id: z.string(),
1874
+ timeoutMs: z.number().int().min(0).max(120000).optional().describe('Max ms to block (default 30000; 0 = immediate read; capped at 120000).'),
1875
+ },
1876
+ async ({ id, timeoutMs }) => {
1877
+ const c = await ensureCanvas();
1878
+ const result = await c.awaitMode(id, timeoutMs !== undefined ? { timeoutMs } : {});
1879
+ return { content: [{ type: 'text', text: JSON.stringify({ ok: result.modeRequest !== null, ...result }) }] };
1880
+ },
1881
+ );
1882
+
1842
1883
  server.tool(
1843
1884
  'canvas_invoke_command',
1844
1885
  'Invoke a registry-gated PMX command intent (pmx.plan | pmx.execute | pmx.promote-context | pmx.summarize | pmx.review). Records a timeline event a host/agent can observe — NOT arbitrary execution; unknown names are rejected.',
@@ -2283,7 +2324,7 @@ export async function startMcpServer(): Promise<void> {
2283
2324
  },
2284
2325
  async () => {
2285
2326
  const c = await ensureCanvas();
2286
- const context = await c.getAxContext();
2327
+ const context = await c.getAxContext({ consumer: 'mcp' });
2287
2328
  return {
2288
2329
  contents: [
2289
2330
  {
@@ -2394,7 +2435,7 @@ export async function startMcpServer(): Promise<void> {
2394
2435
  'Inject the current PMX Canvas AX context (pins, focus, work items, approvals, review, timeline) so an MCP-aware client can ground its next action without a host-native adapter.',
2395
2436
  async () => {
2396
2437
  const c = await ensureCanvas();
2397
- const context = await c.getAxContext();
2438
+ const context = await c.getAxContext({ consumer: 'mcp' });
2398
2439
  return {
2399
2440
  messages: [
2400
2441
  {
@@ -1,6 +1,8 @@
1
1
  import { buildAgentContextPreamble, serializeNodeForAgentContext } from './agent-context.js';
2
2
  import {
3
3
  buildAxContext,
4
+ buildPendingAxActivity,
5
+ AX_CONTEXT_STEERING_LIMIT,
4
6
  type PmxAxContext,
5
7
  type PmxAxPinnedContext,
6
8
  type PmxAxWorkItem,
@@ -71,7 +73,7 @@ export function buildCanvasAxPinnedContext(): PmxAxPinnedContext {
71
73
  };
72
74
  }
73
75
 
74
- export function buildCanvasAxContext(): PmxAxContext {
76
+ export function buildCanvasAxContext(consumer?: string): PmxAxContext {
75
77
  const layout = canvasState.getLayout();
76
78
  const ax = canvasState.getAxState();
77
79
  const focusNodes = ax.focus.nodeIds
@@ -79,6 +81,10 @@ export function buildCanvasAxContext(): PmxAxContext {
79
81
  .filter((node): node is CanvasNodeState => node !== undefined);
80
82
  return buildAxContext({
81
83
  layout,
84
+ delivery: {
85
+ pendingSteering: canvasState.getPendingSteering({ consumer, limit: AX_CONTEXT_STEERING_LIMIT }),
86
+ pendingActivity: buildPendingAxActivity(ax, consumer),
87
+ },
82
88
  pinned: buildCanvasAxPinnedContext(),
83
89
  focus: ax.focus,
84
90
  focusNodes: serializeNodes(focusNodes),