pmx-canvas 0.4.4 → 0.4.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.
- package/CHANGELOG.md +52 -0
- package/Readme.md +33 -2
- package/dist/canvas/global.css +47 -11
- package/dist/canvas/index.js +53 -53
- package/dist/canvas/surface-theme.css +2 -2
- package/dist/types/cli/commands/skills.d.ts +1 -1
- package/dist/types/client/state/intent-store.d.ts +6 -0
- package/dist/types/server/ax-state-manager.d.ts +5 -0
- package/dist/types/server/ax-state.d.ts +9 -3
- package/dist/types/server/canvas-db.d.ts +3 -2
- package/dist/types/server/canvas-state.d.ts +12 -0
- package/dist/types/server/operations/ops/ax-shared.d.ts +4 -1
- package/dist/types/server/operations/registry.d.ts +9 -1
- package/dist/types/shared/ax-intent.d.ts +6 -0
- package/docs/environment.md +1 -0
- package/docs/screenshot.png +0 -0
- package/docs/screenshots/amp-orb-portal.png +0 -0
- package/docs/screenshots/claude-code-desktop.png +0 -0
- package/docs/screenshots/codex-app.png +0 -0
- package/docs/screenshots/github-copilot-app.png +0 -0
- package/package.json +1 -1
- package/skills/pmx-canvas/SKILL.md +15 -5
- package/skills/pmx-canvas-orchestration/SKILL.md +82 -0
- package/src/cli/commands/skills.ts +1 -1
- package/src/cli/index.ts +14 -2
- package/src/client/canvas/CanvasNode.tsx +12 -1
- package/src/client/canvas/IntentLayer.tsx +8 -6
- package/src/client/state/intent-bridge.ts +12 -3
- package/src/client/state/intent-store.ts +47 -2
- package/src/client/theme/global.css +47 -11
- package/src/client/theme/surface-theme.css +2 -2
- package/src/mcp/server.ts +3 -5
- package/src/server/ax-state-manager.ts +27 -6
- package/src/server/ax-state.ts +36 -3
- package/src/server/canvas-db.ts +83 -60
- package/src/server/canvas-state.ts +52 -6
- package/src/server/intent-registry.ts +2 -0
- package/src/server/operations/http.ts +5 -1
- package/src/server/operations/ops/ax-shared.ts +20 -1
- package/src/server/operations/ops/ax-state.ts +4 -7
- package/src/server/operations/ops/ax-timeline.ts +26 -3
- package/src/server/operations/ops/ax-work.ts +27 -1
- package/src/server/operations/ops/batch.ts +3 -1
- package/src/server/operations/ops/nodes.ts +15 -1
- package/src/server/operations/registry.ts +122 -1
- package/src/server/server.ts +10 -2
- package/src/shared/ax-intent.ts +6 -0
- package/src/shared/themes.ts +1 -1
|
@@ -1716,18 +1716,61 @@ class CanvasStateManager {
|
|
|
1716
1716
|
}
|
|
1717
1717
|
|
|
1718
1718
|
addWorkItem(
|
|
1719
|
-
input: {
|
|
1719
|
+
input: {
|
|
1720
|
+
title: string;
|
|
1721
|
+
status?: PmxAxWorkItemStatus;
|
|
1722
|
+
detail?: string | null;
|
|
1723
|
+
nodeIds?: string[];
|
|
1724
|
+
agentId?: string | null;
|
|
1725
|
+
},
|
|
1720
1726
|
options: { source?: PmxAxSource } = {},
|
|
1721
1727
|
): PmxAxWorkItem {
|
|
1722
|
-
|
|
1728
|
+
const item = this.ax.addWorkItem(input, options);
|
|
1729
|
+
this.mirrorAxWorkStatusToNodes(item.nodeIds, [], item.status);
|
|
1730
|
+
return item;
|
|
1723
1731
|
}
|
|
1724
1732
|
|
|
1725
1733
|
updateWorkItem(
|
|
1726
1734
|
id: string,
|
|
1727
|
-
patch: {
|
|
1735
|
+
patch: {
|
|
1736
|
+
title?: string;
|
|
1737
|
+
status?: PmxAxWorkItemStatus;
|
|
1738
|
+
detail?: string | null;
|
|
1739
|
+
nodeIds?: string[];
|
|
1740
|
+
agentId?: string | null;
|
|
1741
|
+
},
|
|
1728
1742
|
options: { source?: PmxAxSource } = {},
|
|
1729
1743
|
): PmxAxWorkItem | null {
|
|
1730
|
-
|
|
1744
|
+
const previousNodeIds = this.ax.getWorkItems().find((w) => w.id === id)?.nodeIds ?? [];
|
|
1745
|
+
const item = this.ax.updateWorkItem(id, patch, options);
|
|
1746
|
+
if (!item) return null;
|
|
1747
|
+
this.mirrorAxWorkStatusToNodes(item.nodeIds, previousNodeIds, item.status);
|
|
1748
|
+
return item;
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
/**
|
|
1752
|
+
* Mirror a work item's status onto every linked canvas node's `data.axWorkStatus`,
|
|
1753
|
+
* through the standard node-update path (persistence + undo/redo, same as any other
|
|
1754
|
+
* node data change). Nodes unlinked by an updateWorkItem nodeIds change have the
|
|
1755
|
+
* mirrored status cleared.
|
|
1756
|
+
*/
|
|
1757
|
+
private mirrorAxWorkStatusToNodes(nodeIds: string[], previousNodeIds: string[], status: PmxAxWorkItemStatus): void {
|
|
1758
|
+
for (const nodeId of nodeIds) {
|
|
1759
|
+
const node = this.nodes.get(nodeId);
|
|
1760
|
+
if (!node) continue;
|
|
1761
|
+
// No-op guard: a title-/agentId-only work-item patch must not push
|
|
1762
|
+
// history entries and SSE churn for chips that aren't changing.
|
|
1763
|
+
if (node.data.axWorkStatus === status) continue;
|
|
1764
|
+
this.updateNode(nodeId, { data: { ...node.data, axWorkStatus: status } });
|
|
1765
|
+
}
|
|
1766
|
+
for (const nodeId of previousNodeIds) {
|
|
1767
|
+
if (nodeIds.includes(nodeId)) continue;
|
|
1768
|
+
const node = this.nodes.get(nodeId);
|
|
1769
|
+
if (!node || node.data.axWorkStatus === undefined) continue;
|
|
1770
|
+
const data = { ...node.data };
|
|
1771
|
+
delete data.axWorkStatus;
|
|
1772
|
+
this.updateNode(nodeId, { data });
|
|
1773
|
+
}
|
|
1731
1774
|
}
|
|
1732
1775
|
|
|
1733
1776
|
// ── Approval gates (canvas-bound) ─────────────────────────────────
|
|
@@ -1873,7 +1916,7 @@ class CanvasStateManager {
|
|
|
1873
1916
|
nodeIds?: string[];
|
|
1874
1917
|
data?: Record<string, unknown> | null;
|
|
1875
1918
|
},
|
|
1876
|
-
options: { source?: PmxAxSource } = {},
|
|
1919
|
+
options: { source?: PmxAxSource; agentId?: string | null } = {},
|
|
1877
1920
|
): PmxAxEvent {
|
|
1878
1921
|
return this.ax.recordAxEvent(input, options);
|
|
1879
1922
|
}
|
|
@@ -1892,7 +1935,10 @@ class CanvasStateManager {
|
|
|
1892
1935
|
return this.ax.addEvidence(input, options);
|
|
1893
1936
|
}
|
|
1894
1937
|
|
|
1895
|
-
recordSteeringMessage(
|
|
1938
|
+
recordSteeringMessage(
|
|
1939
|
+
message: string,
|
|
1940
|
+
options: { source?: PmxAxSource; agentId?: string | null; target?: string | null } = {},
|
|
1941
|
+
): PmxAxSteeringMessage {
|
|
1896
1942
|
return this.ax.recordSteeringMessage(message, options);
|
|
1897
1943
|
}
|
|
1898
1944
|
|
|
@@ -50,6 +50,7 @@ const intentSignalSchema = z.looseObject({
|
|
|
50
50
|
seq: z.number().int().min(0).max(9999).optional(),
|
|
51
51
|
ttlMs: z.number().positive().max(MAX_INTENT_TTL_MS).optional(),
|
|
52
52
|
source: z.string().max(60).optional(),
|
|
53
|
+
auto: z.boolean().optional(),
|
|
53
54
|
});
|
|
54
55
|
|
|
55
56
|
const intentUpdateSchema = z.looseObject({
|
|
@@ -146,6 +147,7 @@ export class IntentRegistry {
|
|
|
146
147
|
...(typeof input.confidence === 'number' ? { confidence: input.confidence } : {}),
|
|
147
148
|
...(typeof input.seq === 'number' ? { seq: input.seq } : {}),
|
|
148
149
|
...(input.source ? { source: input.source } : {}),
|
|
150
|
+
...(input.auto === true ? { auto: true } : {}),
|
|
149
151
|
createdAt: existing?.createdAt ?? now,
|
|
150
152
|
expiresAt: now + ttl,
|
|
151
153
|
};
|
|
@@ -84,7 +84,11 @@ export async function dispatchOperationRoute(req: Request, url: URL): Promise<Re
|
|
|
84
84
|
const input = route.readInput
|
|
85
85
|
? await route.readInput(req, params, url)
|
|
86
86
|
: await defaultReadInput(req, params, url);
|
|
87
|
-
|
|
87
|
+
// The workbench marks its own HTTP calls: a human dragging/editing in
|
|
88
|
+
// the browser is not agent activity, so it never synthesizes an
|
|
89
|
+
// auto-ghost. Everything else (CLI, scripts, adapters) counts as agent.
|
|
90
|
+
const fromWorkbench = req.headers.get('x-pmx-workbench') === '1';
|
|
91
|
+
const result = await executeOperation(op.name, input, { suppressAutoGhost: fromWorkbench });
|
|
88
92
|
return responseJson(result, route.status ? route.status(result) : 200);
|
|
89
93
|
} catch (error) {
|
|
90
94
|
if (error instanceof OperationError) {
|
|
@@ -12,11 +12,30 @@ import { z } from 'zod';
|
|
|
12
12
|
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
|
13
13
|
import type { PmxAxSource } from '../../ax-state.js';
|
|
14
14
|
|
|
15
|
-
export const AX_SOURCES = [
|
|
15
|
+
export const AX_SOURCES = [
|
|
16
|
+
'agent',
|
|
17
|
+
'amp',
|
|
18
|
+
'api',
|
|
19
|
+
'browser',
|
|
20
|
+
'cli',
|
|
21
|
+
'codex',
|
|
22
|
+
'copilot',
|
|
23
|
+
'mcp',
|
|
24
|
+
'sdk',
|
|
25
|
+
'system',
|
|
26
|
+
] as const;
|
|
16
27
|
|
|
17
28
|
/** Zod schema for the optional `source` field shared by AX MCP tool shapes. */
|
|
18
29
|
export const AX_SOURCE_SHAPE = z.enum(AX_SOURCES).optional().describe('Optional host/source label. Defaults to mcp.');
|
|
19
30
|
|
|
31
|
+
/** Zod schema for the optional `agentId` field shared by AX MCP tool shapes. */
|
|
32
|
+
export const AX_AGENT_ID_SHAPE = z
|
|
33
|
+
.string()
|
|
34
|
+
.min(1)
|
|
35
|
+
.max(80)
|
|
36
|
+
.optional()
|
|
37
|
+
.describe('Per-agent identity within the host (orchestration); source stays the host label.');
|
|
38
|
+
|
|
20
39
|
/** An absent or unrecognized source falls back to the per-surface default. */
|
|
21
40
|
export function normalizeAxSource(value: unknown, fallback: PmxAxSource): PmxAxSource {
|
|
22
41
|
return (AX_SOURCES as readonly string[]).includes(value as string) ? (value as PmxAxSource) : fallback;
|
|
@@ -27,7 +27,7 @@ import { buildCanvasAxContext } from '../../ax-context.js';
|
|
|
27
27
|
import type { PmxAxPolicy } from '../../ax-state.js';
|
|
28
28
|
import { defineOperation, type Operation } from '../types.js';
|
|
29
29
|
import { isRecord } from './nodes.js';
|
|
30
|
-
import { normalizeAxNodeIds, normalizeAxSource } from './ax-shared.js';
|
|
30
|
+
import { AX_SOURCES, normalizeAxNodeIds, normalizeAxSource } from './ax-shared.js';
|
|
31
31
|
|
|
32
32
|
// ── ax.get (canvas_get_ax) ────────────────────────────────────
|
|
33
33
|
|
|
@@ -112,7 +112,7 @@ const axFocusSetOperation = defineOperation<z.infer<typeof axFocusSetSchema>, Re
|
|
|
112
112
|
extraShape: {
|
|
113
113
|
nodeIds: z.array(z.string()).describe('Node IDs to place in the AX focus field. Missing nodes are ignored.'),
|
|
114
114
|
source: z
|
|
115
|
-
.enum(
|
|
115
|
+
.enum(AX_SOURCES)
|
|
116
116
|
.optional()
|
|
117
117
|
.describe(
|
|
118
118
|
'Optional host/source label for adapter-originated focus. Defaults to mcp. Use codex from the Codex app adapter.',
|
|
@@ -167,7 +167,7 @@ const axPolicySetOperation = defineOperation<z.infer<typeof axPolicySetSchema>,
|
|
|
167
167
|
})
|
|
168
168
|
.optional(),
|
|
169
169
|
prompt: z.object({ systemAppend: z.string().optional(), mode: z.string().optional() }).optional(),
|
|
170
|
-
source: z.enum(
|
|
170
|
+
source: z.enum(AX_SOURCES).optional(),
|
|
171
171
|
},
|
|
172
172
|
// Legacy MCP tool forwarded only the present tools/prompt fields (source split out).
|
|
173
173
|
buildInput: (input) => ({
|
|
@@ -236,10 +236,7 @@ const axHostCapabilityReportOperation = defineOperation<
|
|
|
236
236
|
files: z.boolean().optional(),
|
|
237
237
|
uiPrompts: z.boolean().optional(),
|
|
238
238
|
raw: z.record(z.string(), z.unknown()).optional().describe('Optional raw capability payload for diagnostics.'),
|
|
239
|
-
source: z
|
|
240
|
-
.enum(['agent', 'api', 'browser', 'cli', 'codex', 'copilot', 'mcp', 'sdk', 'system'])
|
|
241
|
-
.optional()
|
|
242
|
-
.describe('Optional host/source label. Defaults to mcp.'),
|
|
239
|
+
source: z.enum(AX_SOURCES).optional().describe('Optional host/source label. Defaults to mcp.'),
|
|
243
240
|
},
|
|
244
241
|
buildInput: (input) => ({ ...input, source: normalizeAxSource(input.source, 'mcp') }),
|
|
245
242
|
formatResult: (result) => {
|
|
@@ -51,7 +51,13 @@ import { buildPendingAxActivity, isAxEventKind, isAxEvidenceKind } from '../../a
|
|
|
51
51
|
import type { PmxAxEventKind, PmxAxEvidenceKind } from '../../ax-state.js';
|
|
52
52
|
import { defineOperation, OperationError, type Operation } from '../types.js';
|
|
53
53
|
import { isRecord } from './nodes.js';
|
|
54
|
-
import {
|
|
54
|
+
import {
|
|
55
|
+
AX_AGENT_ID_SHAPE,
|
|
56
|
+
AX_SOURCE_SHAPE,
|
|
57
|
+
axJsonResult,
|
|
58
|
+
normalizeAxNodeIds,
|
|
59
|
+
normalizeAxSource,
|
|
60
|
+
} from './ax-shared.js';
|
|
55
61
|
|
|
56
62
|
const AX_EVENT_KINDS = [
|
|
57
63
|
'prompt',
|
|
@@ -73,6 +79,7 @@ const axEventRecordShape = {
|
|
|
73
79
|
nodeIds: z.unknown().optional().describe('Optional node IDs this event relates to.'),
|
|
74
80
|
data: z.unknown().optional().describe('Optional structured data payload.'),
|
|
75
81
|
source: z.unknown().optional().describe('Optional host/source label. Defaults to mcp.'),
|
|
82
|
+
agentId: z.unknown().optional().describe('Optional per-agent identity within the host.'),
|
|
76
83
|
};
|
|
77
84
|
|
|
78
85
|
const axEventRecordSchema = z.looseObject(axEventRecordShape);
|
|
@@ -97,6 +104,7 @@ const axEventRecordOperation = defineOperation<z.infer<typeof axEventRecordSchem
|
|
|
97
104
|
nodeIds: z.array(z.string()).optional().describe('Optional node IDs this event relates to.'),
|
|
98
105
|
data: z.record(z.string(), z.unknown()).optional().describe('Optional structured data payload.'),
|
|
99
106
|
source: AX_SOURCE_SHAPE,
|
|
107
|
+
agentId: AX_AGENT_ID_SHAPE,
|
|
100
108
|
},
|
|
101
109
|
buildInput: (input) => ({ ...input, source: normalizeAxSource(input.source, 'mcp') }),
|
|
102
110
|
formatResult: axJsonResult,
|
|
@@ -113,7 +121,10 @@ const axEventRecordOperation = defineOperation<z.infer<typeof axEventRecordSchem
|
|
|
113
121
|
nodeIds: normalizeAxNodeIds(input.nodeIds),
|
|
114
122
|
data: isRecord(input.data) ? input.data : null,
|
|
115
123
|
},
|
|
116
|
-
{
|
|
124
|
+
{
|
|
125
|
+
source: normalizeAxSource(input.source, 'api'),
|
|
126
|
+
...(typeof input.agentId === 'string' ? { agentId: input.agentId } : {}),
|
|
127
|
+
},
|
|
117
128
|
);
|
|
118
129
|
ctx.emit('ax-event-created', { event });
|
|
119
130
|
return { ok: true, event } as unknown as Record<string, unknown>;
|
|
@@ -184,6 +195,8 @@ const axEvidenceAddOperation = defineOperation<z.infer<typeof axEvidenceAddSchem
|
|
|
184
195
|
const axSteerShape = {
|
|
185
196
|
message: z.unknown().optional().describe('The steering instruction to deliver to the active agent session.'),
|
|
186
197
|
source: z.unknown().optional().describe('Optional host/source label. Defaults to mcp.'),
|
|
198
|
+
agentId: z.unknown().optional().describe('Optional per-agent identity within the host.'),
|
|
199
|
+
target: z.unknown().optional().describe('Optional agentId/consumer this steer is addressed to. Omit to broadcast.'),
|
|
187
200
|
};
|
|
188
201
|
|
|
189
202
|
const axSteerSchema = z.looseObject(axSteerShape);
|
|
@@ -204,6 +217,13 @@ const axSteerOperation = defineOperation<z.infer<typeof axSteerSchema>, Record<s
|
|
|
204
217
|
extraShape: {
|
|
205
218
|
message: z.string().describe('The steering instruction to deliver to the active agent session.'),
|
|
206
219
|
source: AX_SOURCE_SHAPE,
|
|
220
|
+
agentId: AX_AGENT_ID_SHAPE,
|
|
221
|
+
target: z
|
|
222
|
+
.string()
|
|
223
|
+
.min(1)
|
|
224
|
+
.max(80)
|
|
225
|
+
.optional()
|
|
226
|
+
.describe('The agentId/consumer this steer is addressed to. Omit to broadcast to all consumers.'),
|
|
207
227
|
},
|
|
208
228
|
buildInput: (input) => ({ ...input, source: normalizeAxSource(input.source, 'mcp') }),
|
|
209
229
|
formatResult: axJsonResult,
|
|
@@ -214,6 +234,8 @@ const axSteerOperation = defineOperation<z.infer<typeof axSteerSchema>, Record<s
|
|
|
214
234
|
}
|
|
215
235
|
const steering = canvasState.recordSteeringMessage(input.message, {
|
|
216
236
|
source: normalizeAxSource(input.source, 'api'),
|
|
237
|
+
...(typeof input.agentId === 'string' ? { agentId: input.agentId } : {}),
|
|
238
|
+
...(typeof input.target === 'string' ? { target: input.target } : {}),
|
|
217
239
|
});
|
|
218
240
|
ctx.emit('ax-event-created', { steering });
|
|
219
241
|
return { ok: true, steering } as unknown as Record<string, unknown>;
|
|
@@ -328,7 +350,8 @@ const axDeliveryMarkOperation = defineOperation<z.infer<typeof axDeliveryMarkSch
|
|
|
328
350
|
},
|
|
329
351
|
mcp: {
|
|
330
352
|
toolName: 'canvas_mark_ax_delivery',
|
|
331
|
-
description:
|
|
353
|
+
description:
|
|
354
|
+
'Mark a PMX AX steering message as delivered so it is not handed out again. Compare-and-set: `delivered:true` only on the actual undelivered→delivered transition — a false means the id was unknown OR another consumer already marked it, so do NOT treat the message as your delivery.',
|
|
332
355
|
extraShape: {
|
|
333
356
|
id: z.string().describe('The steering message id to mark delivered.'),
|
|
334
357
|
},
|
|
@@ -57,7 +57,14 @@ import type {
|
|
|
57
57
|
} from '../../ax-state.js';
|
|
58
58
|
import { defineOperation, OperationError, type Operation } from '../types.js';
|
|
59
59
|
import { isRecord } from './nodes.js';
|
|
60
|
-
import {
|
|
60
|
+
import {
|
|
61
|
+
AX_AGENT_ID_SHAPE,
|
|
62
|
+
AX_SOURCE_SHAPE,
|
|
63
|
+
AX_SOURCES,
|
|
64
|
+
axJsonResult,
|
|
65
|
+
normalizeAxNodeIds,
|
|
66
|
+
normalizeAxSource,
|
|
67
|
+
} from './ax-shared.js';
|
|
61
68
|
|
|
62
69
|
const AX_WORK_ITEM_STATUSES = new Set(['todo', 'in-progress', 'blocked', 'done', 'cancelled']);
|
|
63
70
|
function normalizeAxWorkItemStatus(value: unknown): PmxAxWorkItemStatus | undefined {
|
|
@@ -98,6 +105,7 @@ const axWorkCreateShape = {
|
|
|
98
105
|
detail: z.unknown().optional().describe('Optional longer description.'),
|
|
99
106
|
nodeIds: z.unknown().optional().describe('Optional node IDs this work item is tied to.'),
|
|
100
107
|
source: z.unknown().optional().describe('Optional host/source label. Defaults to mcp.'),
|
|
108
|
+
agentId: z.unknown().optional().describe('Optional per-agent identity within the host.'),
|
|
101
109
|
};
|
|
102
110
|
|
|
103
111
|
const axWorkCreateSchema = z.looseObject(axWorkCreateShape);
|
|
@@ -124,6 +132,7 @@ const axWorkCreateOperation = defineOperation<z.infer<typeof axWorkCreateSchema>
|
|
|
124
132
|
detail: z.string().optional().describe('Optional longer description.'),
|
|
125
133
|
nodeIds: z.array(z.string()).optional().describe('Optional node IDs this work item is tied to.'),
|
|
126
134
|
source: AX_SOURCE_SHAPE,
|
|
135
|
+
agentId: AX_AGENT_ID_SHAPE,
|
|
127
136
|
},
|
|
128
137
|
buildInput: (input) => ({ ...input, source: normalizeAxSource(input.source, 'mcp') }),
|
|
129
138
|
formatResult: axJsonResult,
|
|
@@ -146,10 +155,18 @@ const axWorkCreateOperation = defineOperation<z.infer<typeof axWorkCreateSchema>
|
|
|
146
155
|
...(status ? { status } : {}),
|
|
147
156
|
...(typeof input.detail === 'string' ? { detail: input.detail } : {}),
|
|
148
157
|
...(Array.isArray(input.nodeIds) ? { nodeIds: normalizeAxNodeIds(input.nodeIds) } : {}),
|
|
158
|
+
...(typeof input.agentId === 'string' ? { agentId: input.agentId } : {}),
|
|
149
159
|
},
|
|
150
160
|
{ source: normalizeAxSource(input.source, 'api') },
|
|
151
161
|
);
|
|
152
162
|
ctx.emit('ax-state-changed', { workItem });
|
|
163
|
+
// The status mirror writes data.axWorkStatus onto linked nodes, but this
|
|
164
|
+
// op is mutates:false (AX writes must not spam layout frames) — so emit
|
|
165
|
+
// the ONE layout frame ourselves when links exist, or open boards never
|
|
166
|
+
// see the chip until an unrelated mutation happens to refresh them.
|
|
167
|
+
if (workItem.nodeIds.length > 0) {
|
|
168
|
+
ctx.emit('canvas-layout-update', { layout: canvasState.getLayout() });
|
|
169
|
+
}
|
|
153
170
|
return { ok: true, workItem } as unknown as Record<string, unknown>;
|
|
154
171
|
},
|
|
155
172
|
});
|
|
@@ -163,6 +180,7 @@ const axWorkUpdateShape = {
|
|
|
163
180
|
detail: z.unknown().optional().describe('New detail text.'),
|
|
164
181
|
nodeIds: z.unknown().optional().describe('Replacement node IDs.'),
|
|
165
182
|
source: z.unknown().optional().describe('Optional host/source label. Defaults to mcp.'),
|
|
183
|
+
agentId: z.unknown().optional().describe('Optional per-agent identity within the host.'),
|
|
166
184
|
};
|
|
167
185
|
|
|
168
186
|
const axWorkUpdateSchema = z.looseObject(axWorkUpdateShape);
|
|
@@ -187,6 +205,7 @@ const axWorkUpdateOperation = defineOperation<z.infer<typeof axWorkUpdateSchema>
|
|
|
187
205
|
detail: z.string().optional().describe('New detail text.'),
|
|
188
206
|
nodeIds: z.array(z.string()).optional().describe('Replacement node IDs.'),
|
|
189
207
|
source: AX_SOURCE_SHAPE,
|
|
208
|
+
agentId: AX_AGENT_ID_SHAPE,
|
|
190
209
|
},
|
|
191
210
|
buildInput: (input) => ({ ...input, source: normalizeAxSource(input.source, 'mcp') }),
|
|
192
211
|
formatResult: axJsonResult,
|
|
@@ -207,11 +226,18 @@ const axWorkUpdateOperation = defineOperation<z.infer<typeof axWorkUpdateSchema>
|
|
|
207
226
|
...(status ? { status } : {}),
|
|
208
227
|
...(typeof input.detail === 'string' || input.detail === null ? { detail: input.detail as string | null } : {}),
|
|
209
228
|
...(Array.isArray(input.nodeIds) ? { nodeIds: normalizeAxNodeIds(input.nodeIds) } : {}),
|
|
229
|
+
...(typeof input.agentId === 'string' ? { agentId: input.agentId } : {}),
|
|
210
230
|
},
|
|
211
231
|
{ source: normalizeAxSource(input.source, 'api') },
|
|
212
232
|
);
|
|
213
233
|
if (!workItem) throw new OperationError('work item not found.', 404);
|
|
214
234
|
ctx.emit('ax-state-changed', { workItem });
|
|
235
|
+
// Same as create: the mirror touched node data only if the patch carried
|
|
236
|
+
// status or nodeIds — emit the layout frame those chips need (this op is
|
|
237
|
+
// mutates:false, so nothing else will).
|
|
238
|
+
if (input.status !== undefined || input.nodeIds !== undefined) {
|
|
239
|
+
ctx.emit('canvas-layout-update', { layout: canvasState.getLayout() });
|
|
240
|
+
}
|
|
215
241
|
return { ok: true, workItem } as unknown as Record<string, unknown>;
|
|
216
242
|
},
|
|
217
243
|
});
|
|
@@ -238,7 +238,9 @@ async function runBatch(operations: BatchEntry[]): Promise<BatchEnvelope> {
|
|
|
238
238
|
continue;
|
|
239
239
|
}
|
|
240
240
|
const dispatch = resolveDispatch(operation.op, args);
|
|
241
|
-
|
|
241
|
+
// Batch churn is exempt from auto-ghosts (matches the skill's explicit
|
|
242
|
+
// ghost-cursor exemption for batch operations).
|
|
243
|
+
const raw = await executeOperation(dispatch.name, dispatch.args, { suppressAutoGhost: true });
|
|
242
244
|
const result = shapeBatchEntry(operation.op, raw);
|
|
243
245
|
results.push(result);
|
|
244
246
|
if (typeof operation.assign === 'string' && operation.assign.trim().length > 0) {
|
|
@@ -69,6 +69,11 @@ export function defaultNodeSize(type: string): { width: number; height: number }
|
|
|
69
69
|
switch (type) {
|
|
70
70
|
case 'html':
|
|
71
71
|
return { width: 720, height: 640 };
|
|
72
|
+
case 'file':
|
|
73
|
+
// Code preview needs real width and enough height for the header bar +
|
|
74
|
+
// a useful snippet — the generic default rendered as a content-less bar
|
|
75
|
+
// (0.4.4 portal feedback).
|
|
76
|
+
return { width: 520, height: 360 };
|
|
72
77
|
case 'markdown':
|
|
73
78
|
return MARKDOWN_NODE_DEFAULT_SIZE;
|
|
74
79
|
case 'mcp-app':
|
|
@@ -415,7 +420,16 @@ export function createBasicCanvasNode(
|
|
|
415
420
|
try {
|
|
416
421
|
const { node, needsCodeGraphRecompute } = addCanvasNode({
|
|
417
422
|
type: type as CanvasNodeState['type'],
|
|
418
|
-
...(typeof body.title === 'string'
|
|
423
|
+
...(typeof body.title === 'string'
|
|
424
|
+
? { title: body.title }
|
|
425
|
+
: // Untitled file nodes get the filename — otherwise the title bar
|
|
426
|
+
// reads as a bare "FILE" chip with nothing identifying the file.
|
|
427
|
+
type === 'file' && typeof content === 'string' && content.trim()
|
|
428
|
+
? // Separator-agnostic: Windows paths use backslashes (0.3.2's
|
|
429
|
+
// basename fix class — a '/'-only split makes the whole path
|
|
430
|
+
// the title there).
|
|
431
|
+
{ title: content.trim().split(/[\\/]/).pop() }
|
|
432
|
+
: {}),
|
|
419
433
|
...(typeof content === 'string' ? { content } : {}),
|
|
420
434
|
...(htmlMergedData && Object.keys(htmlMergedData).length > 0 ? { data: htmlMergedData } : {}),
|
|
421
435
|
...(type === 'trace' && typeof body.toolName === 'string' ? { toolName: body.toolName } : {}),
|
|
@@ -105,6 +105,19 @@ function allowedIntentKinds(name: string, rawInput: unknown): readonly PmxAxInte
|
|
|
105
105
|
return INTENT_KINDS_BY_OPERATION[name];
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
/**
|
|
109
|
+
* Ops exempt from auto-ghost SYNTHESIS only (explicit intent linking above is
|
|
110
|
+
* unaffected). Stream appends arrive in rapid succession — a fresh ghost per
|
|
111
|
+
* chunk is exactly the high-frequency churn class the batch exemption exists
|
|
112
|
+
* for; the stream's CREATING call (no nodeId yet) still ghosts once.
|
|
113
|
+
*/
|
|
114
|
+
function autoGhostExempt(name: string, rawInput: unknown): boolean {
|
|
115
|
+
if (name !== 'jsonrender.stream') return false;
|
|
116
|
+
const input =
|
|
117
|
+
rawInput && typeof rawInput === 'object' && !Array.isArray(rawInput) ? (rawInput as Record<string, unknown>) : {};
|
|
118
|
+
return typeof input.nodeId === 'string' && input.nodeId.length > 0;
|
|
119
|
+
}
|
|
120
|
+
|
|
108
121
|
function settledNodeId(result: unknown, intent: PmxAxIntent): string | undefined {
|
|
109
122
|
if (intent.kind === 'connect' || intent.kind === 'remove') return undefined;
|
|
110
123
|
if (!result || typeof result !== 'object' || Array.isArray(result)) return undefined;
|
|
@@ -118,7 +131,75 @@ function settledNodeId(result: unknown, intent: PmxAxIntent): string | undefined
|
|
|
118
131
|
return typeof record.id === 'string' ? record.id : undefined;
|
|
119
132
|
}
|
|
120
133
|
|
|
121
|
-
|
|
134
|
+
/** Verb for the synthesized auto-ghost chip, per intent kind. */
|
|
135
|
+
const AUTO_GHOST_VERBS: Record<string, string> = {
|
|
136
|
+
create: 'Adding',
|
|
137
|
+
move: 'Moving',
|
|
138
|
+
edit: 'Updating',
|
|
139
|
+
connect: 'Connecting',
|
|
140
|
+
remove: 'Removing',
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const AUTO_GHOST_EDGE_TYPES = new Set(['flow', 'depends-on', 'relation', 'references']);
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Build a valid signal input for a synthesized ghost, or null when the raw
|
|
147
|
+
* input can't satisfy the kind's required fields (e.g. a create with server
|
|
148
|
+
* auto-placement has no position to ghost at) — in that case the mutation
|
|
149
|
+
* simply runs unghosted.
|
|
150
|
+
*/
|
|
151
|
+
function autoGhostInput(name: string, rawInput: unknown, kind: string): Record<string, unknown> | null {
|
|
152
|
+
const body =
|
|
153
|
+
rawInput && typeof rawInput === 'object' && !Array.isArray(rawInput) ? (rawInput as Record<string, unknown>) : {};
|
|
154
|
+
const subject =
|
|
155
|
+
typeof body.title === 'string' && body.title.trim()
|
|
156
|
+
? body.title.trim().slice(0, 80)
|
|
157
|
+
: typeof body.type === 'string'
|
|
158
|
+
? `${body.type} node`
|
|
159
|
+
: typeof body.id === 'string'
|
|
160
|
+
? body.id
|
|
161
|
+
: name.split('.')[0];
|
|
162
|
+
const base = {
|
|
163
|
+
kind,
|
|
164
|
+
label: `${AUTO_GHOST_VERBS[kind] ?? 'Changing'} ${subject}`,
|
|
165
|
+
...(typeof body.type === 'string' ? { nodeType: body.type } : {}),
|
|
166
|
+
source: 'auto',
|
|
167
|
+
auto: true,
|
|
168
|
+
ttlMs: 6000,
|
|
169
|
+
};
|
|
170
|
+
const position = typeof body.x === 'number' && typeof body.y === 'number' ? { x: body.x, y: body.y } : undefined;
|
|
171
|
+
switch (kind) {
|
|
172
|
+
case 'create':
|
|
173
|
+
return position ? { ...base, position } : null;
|
|
174
|
+
case 'move':
|
|
175
|
+
return typeof body.id === 'string' && position ? { ...base, nodeId: body.id, position } : null;
|
|
176
|
+
case 'edit':
|
|
177
|
+
case 'remove':
|
|
178
|
+
return typeof body.id === 'string' ? { ...base, nodeId: body.id } : null;
|
|
179
|
+
case 'connect': {
|
|
180
|
+
if (typeof body.from !== 'string' || typeof body.to !== 'string') return null;
|
|
181
|
+
const edgeType = typeof body.type === 'string' && AUTO_GHOST_EDGE_TYPES.has(body.type) ? body.type : 'flow';
|
|
182
|
+
return { ...base, nodeType: undefined, edge: { from: body.from, to: body.to, type: edgeType } };
|
|
183
|
+
}
|
|
184
|
+
default:
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export interface ExecuteOperationMeta {
|
|
190
|
+
/**
|
|
191
|
+
* Skip the synthesized auto-ghost: set by the workbench's own HTTP calls
|
|
192
|
+
* (a human dragging a node is not agent activity) and by canvas.batch for
|
|
193
|
+
* its inner dispatches (batch churn is exempt, matching the skill contract).
|
|
194
|
+
*/
|
|
195
|
+
suppressAutoGhost?: boolean;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export async function executeOperation(
|
|
199
|
+
name: string,
|
|
200
|
+
rawInput: unknown,
|
|
201
|
+
meta: ExecuteOperationMeta = {},
|
|
202
|
+
): Promise<unknown> {
|
|
122
203
|
const op = getOperation(name);
|
|
123
204
|
const intentId = linkedIntentId(rawInput);
|
|
124
205
|
const allowedKinds = intentId ? allowedIntentKinds(name, rawInput) : undefined;
|
|
@@ -140,6 +221,46 @@ export async function executeOperation(name: string, rawInput: unknown): Promise
|
|
|
140
221
|
);
|
|
141
222
|
}
|
|
142
223
|
|
|
224
|
+
// Auto-ghost: an agent-originated visible mutation with NO explicit intent
|
|
225
|
+
// still shows its move on the canvas — a server-synthesized ghost that
|
|
226
|
+
// settles the moment the mutation lands (the client's minimum-dwell keeps
|
|
227
|
+
// the flash perceptible). Explicit canvas_intent signalling stays the
|
|
228
|
+
// richer path (labels, reasons, a real veto window before the mutation).
|
|
229
|
+
const autoKinds = allowedIntentKinds(name, rawInput);
|
|
230
|
+
if (
|
|
231
|
+
autoKinds &&
|
|
232
|
+
!meta.suppressAutoGhost &&
|
|
233
|
+
!autoGhostExempt(name, rawInput) &&
|
|
234
|
+
process.env.PMX_CANVAS_AUTO_INTENT !== '0'
|
|
235
|
+
) {
|
|
236
|
+
// Ghost synthesis must NEVER break the mutation: skip silently when the
|
|
237
|
+
// input can't satisfy a kind's required fields or signalling throws.
|
|
238
|
+
let ghost: PmxAxIntent | null = null;
|
|
239
|
+
try {
|
|
240
|
+
for (const kind of autoKinds) {
|
|
241
|
+
const input = autoGhostInput(name, rawInput, kind);
|
|
242
|
+
if (input) {
|
|
243
|
+
ghost = intentRegistry.signal(input);
|
|
244
|
+
break;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
} catch {
|
|
248
|
+
ghost = null;
|
|
249
|
+
}
|
|
250
|
+
if (ghost) {
|
|
251
|
+
const ghostId = ghost.id;
|
|
252
|
+
try {
|
|
253
|
+
const result = await op.execute(rawInput, operationContext);
|
|
254
|
+
if (op.mutates) emitOperationEvent('canvas-layout-update', { layout: canvasState.getLayout() });
|
|
255
|
+
intentRegistry.clear(ghostId, { settledNodeId: settledNodeId(result, ghost) ?? undefined });
|
|
256
|
+
return result;
|
|
257
|
+
} catch (error) {
|
|
258
|
+
intentRegistry.clear(ghostId);
|
|
259
|
+
throw error;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
143
264
|
const result = await op.execute(rawInput, operationContext);
|
|
144
265
|
if (op.mutates) emitOperationEvent('canvas-layout-update', { layout: canvasState.getLayout() });
|
|
145
266
|
return result;
|
package/src/server/server.ts
CHANGED
|
@@ -455,7 +455,7 @@ export async function startCanvasAutomationWebView(
|
|
|
455
455
|
return runCanvasAutomationWebViewTask(async () => {
|
|
456
456
|
const WebView = getCanvasWebViewConstructor();
|
|
457
457
|
if (!WebView) {
|
|
458
|
-
const message =
|
|
458
|
+
const message = `Bun.WebView is not available in this Bun runtime (Bun ${Bun.version}). Bun >=1.3.12 is required — the canvas itself keeps working; only WebView automation/screenshots are unavailable.`;
|
|
459
459
|
canvasAutomationWebViewStatus = {
|
|
460
460
|
...canvasAutomationWebViewStatus,
|
|
461
461
|
lastError: message,
|
|
@@ -2999,7 +2999,15 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
|
|
|
2999
2999
|
// M11: PMX_WEB_CANVAS_PORT (server-specific) wins, then PMX_CANVAS_PORT — so
|
|
3000
3000
|
// setting only the client var no longer splits the client and server ports.
|
|
3001
3001
|
const preferredPort =
|
|
3002
|
-
options.port ??
|
|
3002
|
+
options.port ??
|
|
3003
|
+
Number(
|
|
3004
|
+
process.env.PMX_WEB_CANVAS_PORT ??
|
|
3005
|
+
process.env.PMX_CANVAS_PORT ??
|
|
3006
|
+
// Amp orbs assign the portal port via $PORT (gated on AMP_ORB so a
|
|
3007
|
+
// stray PORT in normal shells never shifts the default).
|
|
3008
|
+
(process.env.AMP_ORB ? process.env.PORT || undefined : undefined) ??
|
|
3009
|
+
DEFAULT_PORT,
|
|
3010
|
+
);
|
|
3003
3011
|
const portCandidates =
|
|
3004
3012
|
options.port === 0
|
|
3005
3013
|
? [0]
|
package/src/shared/ax-intent.ts
CHANGED
|
@@ -44,6 +44,12 @@ export interface PmxAxIntent {
|
|
|
44
44
|
seq?: number;
|
|
45
45
|
/** Source label of the surface that signalled the intent (mcp/api/sdk/...). */
|
|
46
46
|
source?: string;
|
|
47
|
+
/**
|
|
48
|
+
* Server-synthesized ghost for an agent mutation that arrived WITHOUT an
|
|
49
|
+
* explicit canvas_intent signal (auto-ghost). Rendered visually lighter than
|
|
50
|
+
* a deliberate signal, and not vetoable — it settles immediately.
|
|
51
|
+
*/
|
|
52
|
+
auto?: boolean;
|
|
47
53
|
/** Epoch ms when the intent was first signalled. */
|
|
48
54
|
createdAt: number;
|
|
49
55
|
/** Epoch ms when the intent auto-dissolves if not settled/cleared first. */
|
package/src/shared/themes.ts
CHANGED
|
@@ -51,7 +51,7 @@ export const CANVAS_THEME_META: Record<CanvasThemeName, CanvasThemeMeta> = {
|
|
|
51
51
|
arctic: { label: 'Arctic', scheme: 'dark', swatchBg: '#20242E', swatchAccent: '#7FC8DE' },
|
|
52
52
|
ember: { label: 'Ember', scheme: 'dark', swatchBg: '#0B0B0C', swatchAccent: '#F34E1C' },
|
|
53
53
|
forest: { label: 'Forest', scheme: 'dark', swatchBg: '#0C1712', swatchAccent: '#5CCF8F' },
|
|
54
|
-
volt: { label: 'Volt', scheme: 'dark', swatchBg: '#0B0D0B', swatchAccent: '#
|
|
54
|
+
volt: { label: 'Volt', scheme: 'dark', swatchBg: '#0B0D0B', swatchAccent: '#DFDFC1' },
|
|
55
55
|
};
|
|
56
56
|
|
|
57
57
|
/** Collapse a theme to the dark/light scheme embedded dark|light-only viewers understand. */
|