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.
- package/CHANGELOG.md +65 -0
- package/Readme.md +14 -2
- package/dist/canvas/index.js +82 -41
- package/dist/types/client/nodes/ExtAppFrame.d.ts +2 -0
- package/dist/types/mcp/canvas-access.d.ts +20 -1
- package/dist/types/server/ax-context.d.ts +1 -1
- package/dist/types/server/ax-state.d.ts +28 -0
- package/dist/types/server/ax-wait.d.ts +23 -0
- package/dist/types/server/canvas-state.d.ts +55 -3
- package/dist/types/server/html-surface.d.ts +7 -0
- package/dist/types/server/index.d.ts +60 -2
- package/docs/ax-host-adapter-contract.md +65 -0
- package/docs/http-api.md +34 -2
- package/docs/mcp.md +5 -1
- package/docs/screenshot.png +0 -0
- package/package.json +1 -1
- package/skills/pmx-canvas/SKILL.md +50 -8
- package/skills/pmx-canvas/references/codex-app-adapter.md +15 -1
- package/skills/pmx-canvas/references/github-copilot-app-adapter.md +31 -0
- package/src/client/nodes/ExtAppFrame.tsx +73 -5
- package/src/client/nodes/HtmlNode.tsx +12 -3
- package/src/client/nodes/McpAppNode.tsx +12 -3
- package/src/json-render/renderer/index.tsx +3 -0
- package/src/mcp/canvas-access.ts +74 -5
- package/src/mcp/server.ts +94 -53
- package/src/server/ax-context.ts +7 -1
- package/src/server/ax-state.ts +87 -0
- package/src/server/ax-wait.ts +56 -0
- package/src/server/canvas-state.ts +131 -3
- package/src/server/html-surface.ts +49 -11
- package/src/server/index.ts +82 -2
- package/src/server/server.ts +223 -11
|
@@ -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
|
-
?
|
|
227
|
+
? buildExtAppAxBridgeScript(axToken, nodeId)
|
|
168
228
|
: '';
|
|
169
|
-
const iframeDocument = useIframeDocument((html ?? ''
|
|
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:
|
|
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',
|
|
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:
|
|
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',
|
|
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:
|
|
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',
|
|
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({
|
package/src/mcp/canvas-access.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
{
|
package/src/server/ax-context.ts
CHANGED
|
@@ -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),
|