pmx-canvas 0.1.29 → 0.1.31
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 +219 -0
- package/Readme.md +20 -10
- package/dist/canvas/global.css +51 -56
- package/dist/canvas/index.js +80 -163
- package/dist/canvas/surface-theme.css +142 -0
- package/dist/json-render/index.js +103 -103
- package/dist/types/client/nodes/HtmlNode.d.ts +0 -7
- package/dist/types/client/nodes/ax-node-actions.d.ts +18 -0
- package/dist/types/client/nodes/surface-url.d.ts +22 -0
- package/dist/types/client/state/attention-bridge.d.ts +3 -0
- package/dist/types/client/state/intent-bridge.d.ts +17 -0
- package/dist/types/json-render/renderer/index.d.ts +2 -0
- package/dist/types/json-render/schema.d.ts +2 -0
- package/dist/types/json-render/server.d.ts +2 -0
- package/dist/types/mcp/canvas-access.d.ts +47 -0
- package/dist/types/server/ax-interaction.d.ts +210 -0
- package/dist/types/server/ax-state.d.ts +67 -1
- package/dist/types/server/canvas-db.d.ts +4 -0
- package/dist/types/server/canvas-serialization.d.ts +2 -0
- package/dist/types/server/canvas-state.d.ts +54 -2
- package/dist/types/server/html-surface.d.ts +46 -0
- package/dist/types/server/index.d.ts +63 -5
- package/dist/types/server/mutation-history.d.ts +1 -1
- package/dist/types/server/placement.d.ts +1 -1
- package/dist/types/server/server.d.ts +12 -0
- package/dist/types/shared/surface.d.ts +19 -0
- package/docs/cli.md +30 -0
- package/docs/http-api.md +55 -0
- package/docs/mcp.md +40 -2
- package/docs/node-types.md +26 -0
- package/docs/plans/plan-004-pmx-ax-primitives.md +623 -394
- package/docs/sdk.md +23 -1
- package/package.json +2 -2
- package/skills/pmx-canvas/SKILL.md +107 -9
- package/src/cli/agent.ts +177 -0
- package/src/cli/index.ts +8 -1
- package/src/client/canvas/CanvasNode.tsx +8 -4
- package/src/client/canvas/DockedNode.tsx +38 -38
- package/src/client/canvas/ExpandedNodeOverlay.tsx +12 -0
- package/src/client/nodes/ContextNode.tsx +17 -0
- package/src/client/nodes/ExtAppFrame.tsx +40 -3
- package/src/client/nodes/FileNode.tsx +26 -0
- package/src/client/nodes/HtmlNode.tsx +60 -188
- package/src/client/nodes/McpAppNode.tsx +47 -2
- package/src/client/nodes/StatusNode.tsx +20 -0
- package/src/client/nodes/ax-node-actions.ts +39 -0
- package/src/client/nodes/surface-url.ts +48 -0
- package/src/client/state/attention-bridge.ts +5 -0
- package/src/client/state/intent-bridge.ts +33 -0
- package/src/client/theme/global.css +51 -56
- package/src/client/theme/surface-theme.css +142 -0
- package/src/json-render/renderer/index.tsx +31 -0
- package/src/json-render/schema.ts +4 -0
- package/src/json-render/server.ts +13 -0
- package/src/mcp/canvas-access.ts +198 -1
- package/src/mcp/server.ts +232 -2
- package/src/server/ax-context.ts +3 -0
- package/src/server/ax-interaction.ts +549 -0
- package/src/server/ax-state.ts +188 -2
- package/src/server/canvas-db.ts +20 -0
- package/src/server/canvas-operations.ts +11 -0
- package/src/server/canvas-serialization.ts +9 -0
- package/src/server/canvas-state.ts +201 -26
- package/src/server/html-surface.ts +190 -0
- package/src/server/index.ts +122 -7
- package/src/server/mutation-history.ts +5 -0
- package/src/server/placement.ts +5 -1
- package/src/server/server.ts +360 -0
- package/src/shared/surface.ts +38 -0
package/src/server/ax-state.ts
CHANGED
|
@@ -13,7 +13,7 @@ export interface PmxAxFocusState {
|
|
|
13
13
|
// ── New enums ──────────────────────────────────────────────────────
|
|
14
14
|
export type PmxAxEventKind =
|
|
15
15
|
| 'prompt' | 'assistant-message' | 'tool-start' | 'tool-result'
|
|
16
|
-
| 'failure' | 'approval' | 'steering';
|
|
16
|
+
| 'failure' | 'approval' | 'steering' | 'command';
|
|
17
17
|
export type PmxAxEvidenceKind =
|
|
18
18
|
| 'logs' | 'tool-result' | 'screenshot' | 'file' | 'diff' | 'test-output';
|
|
19
19
|
export type PmxAxWorkItemStatus = 'todo' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
|
|
@@ -141,6 +141,9 @@ export interface PmxAxState {
|
|
|
141
141
|
workItems: PmxAxWorkItem[];
|
|
142
142
|
approvalGates: PmxAxApprovalGate[];
|
|
143
143
|
reviewAnnotations: PmxAxReviewAnnotation[];
|
|
144
|
+
elicitations: PmxAxElicitation[];
|
|
145
|
+
modeRequests: PmxAxModeRequest[];
|
|
146
|
+
policy: PmxAxPolicy;
|
|
144
147
|
}
|
|
145
148
|
|
|
146
149
|
export interface PmxAxPinnedContext {
|
|
@@ -166,6 +169,9 @@ export interface PmxAxContext {
|
|
|
166
169
|
workItems: PmxAxWorkItem[];
|
|
167
170
|
approvalGates: PmxAxApprovalGate[];
|
|
168
171
|
reviewAnnotations: PmxAxReviewAnnotation[];
|
|
172
|
+
elicitations: PmxAxElicitation[];
|
|
173
|
+
modeRequests: PmxAxModeRequest[];
|
|
174
|
+
policy: PmxAxPolicy;
|
|
169
175
|
timeline: PmxAxTimelineSummary;
|
|
170
176
|
host: PmxAxHostCapability | null;
|
|
171
177
|
}
|
|
@@ -199,7 +205,69 @@ function normalizeNodeIds(value: unknown, validNodeIds?: Set<string>): string[]
|
|
|
199
205
|
return ids;
|
|
200
206
|
}
|
|
201
207
|
|
|
202
|
-
const AX_EVENT_KINDS = new Set<PmxAxEventKind>(['prompt', 'assistant-message', 'tool-start', 'tool-result', 'failure', 'approval', 'steering']);
|
|
208
|
+
const AX_EVENT_KINDS = new Set<PmxAxEventKind>(['prompt', 'assistant-message', 'tool-start', 'tool-result', 'failure', 'approval', 'steering', 'command']);
|
|
209
|
+
|
|
210
|
+
// ── Command registry (plan-004 Phase 5) ────────────────────────
|
|
211
|
+
// Named, registry-gated PMX intents — NOT arbitrary execution. Invoking a command
|
|
212
|
+
// records a timeline event a host/agent can observe and act on; unknown names are
|
|
213
|
+
// rejected. Seeded with conservative built-ins; project-extensible later.
|
|
214
|
+
export interface PmxAxCommandDescriptor {
|
|
215
|
+
name: string;
|
|
216
|
+
description: string;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export const AX_COMMAND_REGISTRY: Record<string, PmxAxCommandDescriptor> = {
|
|
220
|
+
'pmx.plan': { name: 'pmx.plan', description: 'Enter planning: outline the approach before executing.' },
|
|
221
|
+
'pmx.execute': { name: 'pmx.execute', description: 'Proceed from plan to execution.' },
|
|
222
|
+
'pmx.promote-context': { name: 'pmx.promote-context', description: 'Promote focused/pinned nodes into durable agent context.' },
|
|
223
|
+
'pmx.summarize': { name: 'pmx.summarize', description: 'Summarize the current canvas/work state.' },
|
|
224
|
+
'pmx.review': { name: 'pmx.review', description: 'Start a review pass over the focused nodes.' },
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
export function isAxCommand(name: unknown): name is string {
|
|
228
|
+
return typeof name === 'string' && Object.prototype.hasOwnProperty.call(AX_COMMAND_REGISTRY, name);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function listAxCommands(): PmxAxCommandDescriptor[] {
|
|
232
|
+
return Object.values(AX_COMMAND_REGISTRY);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ── Tool/prompt policy (plan-004 Phase 5) ──────────────────────
|
|
236
|
+
// Declarative policy PMX stores and exposes; host adapters READ and enforce it.
|
|
237
|
+
// PMX does not mutate host prompts or block tools itself. Default = empty (no
|
|
238
|
+
// restrictions) — that is the conservative answer to "how much by default".
|
|
239
|
+
export interface PmxAxPolicy {
|
|
240
|
+
tools: { allowed: string[]; excluded: string[]; approvalRequired: string[] };
|
|
241
|
+
prompt: { systemAppend: string | null; mode: string | null };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function createEmptyAxPolicy(): PmxAxPolicy {
|
|
245
|
+
return {
|
|
246
|
+
tools: { allowed: [], excluded: [], approvalRequired: [] },
|
|
247
|
+
prompt: { systemAppend: null, mode: null },
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function normalizeStringArray(value: unknown): string[] {
|
|
252
|
+
return Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string') : [];
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export function normalizeAxPolicy(input: unknown): PmxAxPolicy {
|
|
256
|
+
if (!isRecord(input)) return createEmptyAxPolicy();
|
|
257
|
+
const tools = isRecord(input.tools) ? input.tools : {};
|
|
258
|
+
const prompt = isRecord(input.prompt) ? input.prompt : {};
|
|
259
|
+
return {
|
|
260
|
+
tools: {
|
|
261
|
+
allowed: normalizeStringArray(tools.allowed),
|
|
262
|
+
excluded: normalizeStringArray(tools.excluded),
|
|
263
|
+
approvalRequired: normalizeStringArray(tools.approvalRequired),
|
|
264
|
+
},
|
|
265
|
+
prompt: {
|
|
266
|
+
systemAppend: optionalString(prompt.systemAppend),
|
|
267
|
+
mode: optionalString(prompt.mode),
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
}
|
|
203
271
|
const AX_EVIDENCE_KINDS = new Set<PmxAxEvidenceKind>(['logs', 'tool-result', 'screenshot', 'file', 'diff', 'test-output']);
|
|
204
272
|
const AX_WORK_STATUSES = new Set<PmxAxWorkItemStatus>(['todo', 'in-progress', 'blocked', 'done', 'cancelled']);
|
|
205
273
|
const AX_APPROVAL_STATUSES = new Set<PmxAxApprovalStatus>(['pending', 'approved', 'rejected']);
|
|
@@ -241,6 +309,108 @@ export function createEmptyAxFocusState(): PmxAxFocusState {
|
|
|
241
309
|
};
|
|
242
310
|
}
|
|
243
311
|
|
|
312
|
+
// ── Elicitation (structured human input request) — plan-004 Phase 5 ──
|
|
313
|
+
|
|
314
|
+
export type PmxAxElicitationStatus = 'pending' | 'answered' | 'cancelled';
|
|
315
|
+
const AX_ELICITATION_STATUSES = new Set<PmxAxElicitationStatus>(['pending', 'answered', 'cancelled']);
|
|
316
|
+
|
|
317
|
+
export interface PmxAxElicitation {
|
|
318
|
+
id: string;
|
|
319
|
+
prompt: string;
|
|
320
|
+
fields: string[];
|
|
321
|
+
status: PmxAxElicitationStatus;
|
|
322
|
+
response: Record<string, unknown> | null;
|
|
323
|
+
nodeIds: string[];
|
|
324
|
+
createdAt: string;
|
|
325
|
+
resolvedAt: string | null;
|
|
326
|
+
source: PmxAxSource | null;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export function normalizeAxElicitation(input: unknown, validNodeIds?: Set<string>): PmxAxElicitation | null {
|
|
330
|
+
if (!isRecord(input) || typeof input.id !== 'string') return null;
|
|
331
|
+
return {
|
|
332
|
+
id: input.id,
|
|
333
|
+
prompt: typeof input.prompt === 'string' ? input.prompt : '(input requested)',
|
|
334
|
+
fields: Array.isArray(input.fields) ? input.fields.filter((f): f is string => typeof f === 'string') : [],
|
|
335
|
+
status: AX_ELICITATION_STATUSES.has(input.status as PmxAxElicitationStatus) ? input.status as PmxAxElicitationStatus : 'pending',
|
|
336
|
+
response: isRecord(input.response) ? input.response : null,
|
|
337
|
+
nodeIds: normalizeNodeIds(input.nodeIds, validNodeIds),
|
|
338
|
+
createdAt: normalizeTimestamp(input.createdAt) ?? nowIso(),
|
|
339
|
+
resolvedAt: normalizeTimestamp(input.resolvedAt),
|
|
340
|
+
source: normalizeSource(input.source),
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export function createAxElicitation(
|
|
345
|
+
input: { prompt: string; fields?: string[]; nodeIds?: string[] },
|
|
346
|
+
source: PmxAxSource | null,
|
|
347
|
+
validNodeIds?: Set<string>,
|
|
348
|
+
): PmxAxElicitation {
|
|
349
|
+
return {
|
|
350
|
+
id: axId('elic'),
|
|
351
|
+
prompt: input.prompt,
|
|
352
|
+
fields: Array.isArray(input.fields) ? input.fields.filter((f) => typeof f === 'string') : [],
|
|
353
|
+
status: 'pending',
|
|
354
|
+
response: null,
|
|
355
|
+
nodeIds: normalizeNodeIds(input.nodeIds, validNodeIds),
|
|
356
|
+
createdAt: nowIso(),
|
|
357
|
+
resolvedAt: null,
|
|
358
|
+
source,
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// ── Mode request (plan/execute/autonomous transition) — plan-004 Phase 5 ──
|
|
363
|
+
|
|
364
|
+
export type PmxAxMode = 'plan' | 'execute' | 'autonomous';
|
|
365
|
+
export type PmxAxModeRequestStatus = 'pending' | 'approved' | 'rejected';
|
|
366
|
+
const AX_MODES = new Set<PmxAxMode>(['plan', 'execute', 'autonomous']);
|
|
367
|
+
const AX_MODE_REQUEST_STATUSES = new Set<PmxAxModeRequestStatus>(['pending', 'approved', 'rejected']);
|
|
368
|
+
|
|
369
|
+
export interface PmxAxModeRequest {
|
|
370
|
+
id: string;
|
|
371
|
+
mode: PmxAxMode;
|
|
372
|
+
reason: string | null;
|
|
373
|
+
status: PmxAxModeRequestStatus;
|
|
374
|
+
nodeIds: string[];
|
|
375
|
+
createdAt: string;
|
|
376
|
+
resolvedAt: string | null;
|
|
377
|
+
resolution: string | null;
|
|
378
|
+
source: PmxAxSource | null;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
export function normalizeAxModeRequest(input: unknown, validNodeIds?: Set<string>): PmxAxModeRequest | null {
|
|
382
|
+
if (!isRecord(input) || typeof input.id !== 'string') return null;
|
|
383
|
+
return {
|
|
384
|
+
id: input.id,
|
|
385
|
+
mode: AX_MODES.has(input.mode as PmxAxMode) ? input.mode as PmxAxMode : 'execute',
|
|
386
|
+
reason: optionalString(input.reason),
|
|
387
|
+
status: AX_MODE_REQUEST_STATUSES.has(input.status as PmxAxModeRequestStatus) ? input.status as PmxAxModeRequestStatus : 'pending',
|
|
388
|
+
nodeIds: normalizeNodeIds(input.nodeIds, validNodeIds),
|
|
389
|
+
createdAt: normalizeTimestamp(input.createdAt) ?? nowIso(),
|
|
390
|
+
resolvedAt: normalizeTimestamp(input.resolvedAt),
|
|
391
|
+
resolution: optionalString(input.resolution),
|
|
392
|
+
source: normalizeSource(input.source),
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
export function createAxModeRequest(
|
|
397
|
+
input: { mode: PmxAxMode; reason?: string | null; nodeIds?: string[] },
|
|
398
|
+
source: PmxAxSource | null,
|
|
399
|
+
validNodeIds?: Set<string>,
|
|
400
|
+
): PmxAxModeRequest {
|
|
401
|
+
return {
|
|
402
|
+
id: axId('mode'),
|
|
403
|
+
mode: input.mode,
|
|
404
|
+
reason: input.reason ?? null,
|
|
405
|
+
status: 'pending',
|
|
406
|
+
nodeIds: normalizeNodeIds(input.nodeIds, validNodeIds),
|
|
407
|
+
createdAt: nowIso(),
|
|
408
|
+
resolvedAt: null,
|
|
409
|
+
resolution: null,
|
|
410
|
+
source,
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
|
|
244
414
|
export function createEmptyAxState(): PmxAxState {
|
|
245
415
|
return {
|
|
246
416
|
version: 1,
|
|
@@ -248,6 +418,9 @@ export function createEmptyAxState(): PmxAxState {
|
|
|
248
418
|
workItems: [],
|
|
249
419
|
approvalGates: [],
|
|
250
420
|
reviewAnnotations: [],
|
|
421
|
+
elicitations: [],
|
|
422
|
+
modeRequests: [],
|
|
423
|
+
policy: createEmptyAxPolicy(),
|
|
251
424
|
};
|
|
252
425
|
}
|
|
253
426
|
|
|
@@ -538,6 +711,13 @@ export function normalizeAxState(input: unknown, validNodeIds?: Set<string>): Pm
|
|
|
538
711
|
reviewAnnotations: Array.isArray(input.reviewAnnotations)
|
|
539
712
|
? input.reviewAnnotations.map((r) => normalizeAxReviewAnnotation(r, validNodeIds)).filter((r): r is PmxAxReviewAnnotation => r !== null)
|
|
540
713
|
: [],
|
|
714
|
+
elicitations: Array.isArray(input.elicitations)
|
|
715
|
+
? input.elicitations.map((e) => normalizeAxElicitation(e, validNodeIds)).filter((e): e is PmxAxElicitation => e !== null)
|
|
716
|
+
: [],
|
|
717
|
+
modeRequests: Array.isArray(input.modeRequests)
|
|
718
|
+
? input.modeRequests.map((m) => normalizeAxModeRequest(m, validNodeIds)).filter((m): m is PmxAxModeRequest => m !== null)
|
|
719
|
+
: [],
|
|
720
|
+
policy: normalizeAxPolicy(input.policy),
|
|
541
721
|
};
|
|
542
722
|
}
|
|
543
723
|
|
|
@@ -549,6 +729,9 @@ export function buildAxContext(input: {
|
|
|
549
729
|
workItems: PmxAxWorkItem[];
|
|
550
730
|
approvalGates: PmxAxApprovalGate[];
|
|
551
731
|
reviewAnnotations: PmxAxReviewAnnotation[];
|
|
732
|
+
elicitations: PmxAxElicitation[];
|
|
733
|
+
modeRequests: PmxAxModeRequest[];
|
|
734
|
+
policy: PmxAxPolicy;
|
|
552
735
|
timeline: PmxAxTimelineSummary;
|
|
553
736
|
host: PmxAxHostCapability | null;
|
|
554
737
|
}): PmxAxContext {
|
|
@@ -567,6 +750,9 @@ export function buildAxContext(input: {
|
|
|
567
750
|
workItems: input.workItems,
|
|
568
751
|
approvalGates: input.approvalGates,
|
|
569
752
|
reviewAnnotations: input.reviewAnnotations,
|
|
753
|
+
elicitations: input.elicitations,
|
|
754
|
+
modeRequests: input.modeRequests,
|
|
755
|
+
policy: input.policy,
|
|
570
756
|
timeline: input.timeline,
|
|
571
757
|
host: input.host,
|
|
572
758
|
};
|
package/src/server/canvas-db.ts
CHANGED
|
@@ -901,6 +901,26 @@ export function loadAxSteeringFromDB(db: Database, q: AxTimelineQuery & { onlyPe
|
|
|
901
901
|
.filter((s): s is PmxAxSteeringMessage => s !== null);
|
|
902
902
|
}
|
|
903
903
|
|
|
904
|
+
export function loadPendingAxSteeringFromDB(
|
|
905
|
+
db: Database,
|
|
906
|
+
options: { consumer?: string; limit?: number } = {},
|
|
907
|
+
): PmxAxSteeringMessage[] {
|
|
908
|
+
interface Row { seq: number; id: string; message: string; delivered: number; created_at: string; source: string | null }
|
|
909
|
+
// FIFO (oldest undelivered first); exclude the consumer's own steering in SQL
|
|
910
|
+
// so the LIMIT is applied AFTER loop-prevention, not before.
|
|
911
|
+
const limit = clampTimelineLimit(options.limit);
|
|
912
|
+
const rows = options.consumer
|
|
913
|
+
? db.query<Row, [string, number]>(
|
|
914
|
+
'SELECT * FROM ax_steering WHERE delivered = 0 AND (source IS NULL OR source != ?) ORDER BY seq ASC LIMIT ?',
|
|
915
|
+
).all(options.consumer, limit)
|
|
916
|
+
: db.query<Row, [number]>(
|
|
917
|
+
'SELECT * FROM ax_steering WHERE delivered = 0 ORDER BY seq ASC LIMIT ?',
|
|
918
|
+
).all(limit);
|
|
919
|
+
return rows
|
|
920
|
+
.map((r) => normalizeAxSteeringMessage({ ...r, createdAt: r.created_at, delivered: r.delivered === 1 }))
|
|
921
|
+
.filter((s): s is PmxAxSteeringMessage => s !== null);
|
|
922
|
+
}
|
|
923
|
+
|
|
904
924
|
function countRows(db: Database, table: 'ax_events' | 'ax_evidence' | 'ax_steering'): number {
|
|
905
925
|
return Number(db.query<{ n: number }, []>(`SELECT COUNT(*) AS n FROM ${table}`).get()?.n ?? 0);
|
|
906
926
|
}
|
|
@@ -1467,6 +1467,10 @@ export function groupCanvasNodes(
|
|
|
1467
1467
|
): { ok: boolean } {
|
|
1468
1468
|
return {
|
|
1469
1469
|
ok: canvasState.groupNodes(groupId, childIds, {
|
|
1470
|
+
// Preserve existing child positions unless an explicit layout is asked
|
|
1471
|
+
// for — matching createCanvasGroup and the batch group.add path. Without
|
|
1472
|
+
// this, grouping silently auto-packs the nodes into a grid.
|
|
1473
|
+
preservePositions: options.childLayout === undefined,
|
|
1470
1474
|
...(options.childLayout ? { layout: options.childLayout } : {}),
|
|
1471
1475
|
}),
|
|
1472
1476
|
};
|
|
@@ -1836,6 +1840,13 @@ export async function executeCanvasBatch(
|
|
|
1836
1840
|
result = { ok: true, ...(updated ? serializeCreatedNode(updated) : { id }) };
|
|
1837
1841
|
break;
|
|
1838
1842
|
}
|
|
1843
|
+
case 'node.remove': {
|
|
1844
|
+
const id = typeof args.id === 'string' ? args.id : '';
|
|
1845
|
+
const { removed } = removeCanvasNode(id);
|
|
1846
|
+
if (!removed) throw new Error(`Node "${id}" not found.`);
|
|
1847
|
+
result = { ok: true, id, removed: true };
|
|
1848
|
+
break;
|
|
1849
|
+
}
|
|
1839
1850
|
case 'graph.add': {
|
|
1840
1851
|
const created = createCanvasGraphNode({
|
|
1841
1852
|
graphType: String(args.graphType ?? 'line'),
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
type CanvasNodeProvenance,
|
|
7
7
|
} from './canvas-provenance.js';
|
|
8
8
|
import { getCanvasNodeKind as getSharedCanvasNodeKind } from '../shared/canvas-node-kind.js';
|
|
9
|
+
import { canOpenNodeAsSurface } from '../shared/surface.js';
|
|
9
10
|
|
|
10
11
|
export interface SerializedCanvasNode extends CanvasNodeState {
|
|
11
12
|
kind: string;
|
|
@@ -13,6 +14,7 @@ export interface SerializedCanvasNode extends CanvasNodeState {
|
|
|
13
14
|
content: string | null;
|
|
14
15
|
path: string | null;
|
|
15
16
|
url: string | null;
|
|
17
|
+
surfaceUrl: string | null;
|
|
16
18
|
provenance: CanvasNodeProvenance | null;
|
|
17
19
|
}
|
|
18
20
|
|
|
@@ -101,6 +103,12 @@ export function getCanvasNodeContent(node: CanvasNodeState): string | null {
|
|
|
101
103
|
?? null;
|
|
102
104
|
}
|
|
103
105
|
|
|
106
|
+
export function getCanvasNodeSurfaceUrl(node: CanvasNodeState, data: Record<string, unknown>): string | null {
|
|
107
|
+
return canOpenNodeAsSurface(node.type, data)
|
|
108
|
+
? `/api/canvas/surface/${encodeURIComponent(node.id)}`
|
|
109
|
+
: null;
|
|
110
|
+
}
|
|
111
|
+
|
|
104
112
|
export function serializeCanvasNode(node: CanvasNodeState): SerializedCanvasNode {
|
|
105
113
|
const data = normalizeCanvasNodeData(node.type, node.data);
|
|
106
114
|
const normalizedNode = { ...node, data };
|
|
@@ -112,6 +120,7 @@ export function serializeCanvasNode(node: CanvasNodeState): SerializedCanvasNode
|
|
|
112
120
|
content: getCanvasNodeContent(normalizedNode),
|
|
113
121
|
path: pickString(data.path),
|
|
114
122
|
url: pickString(data.url),
|
|
123
|
+
surfaceUrl: getCanvasNodeSurfaceUrl(node, data),
|
|
115
124
|
provenance: pickProvenance(data.provenance),
|
|
116
125
|
};
|
|
117
126
|
}
|
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
loadAxEventsFromDB,
|
|
37
37
|
loadAxEvidenceFromDB,
|
|
38
38
|
loadAxSteeringFromDB,
|
|
39
|
+
loadPendingAxSteeringFromDB,
|
|
39
40
|
loadAxTimelineSummaryFromDB,
|
|
40
41
|
upsertAxHostCapabilityToDB,
|
|
41
42
|
loadAxHostCapabilityFromDB,
|
|
@@ -63,6 +64,17 @@ import {
|
|
|
63
64
|
createAxEvent,
|
|
64
65
|
createAxEvidence,
|
|
65
66
|
createAxSteeringMessage,
|
|
67
|
+
createAxElicitation,
|
|
68
|
+
createAxModeRequest,
|
|
69
|
+
isAxCommand,
|
|
70
|
+
listAxCommands,
|
|
71
|
+
AX_COMMAND_REGISTRY,
|
|
72
|
+
normalizeAxPolicy,
|
|
73
|
+
type PmxAxElicitation,
|
|
74
|
+
type PmxAxModeRequest,
|
|
75
|
+
type PmxAxMode,
|
|
76
|
+
type PmxAxCommandDescriptor,
|
|
77
|
+
type PmxAxPolicy,
|
|
66
78
|
type PmxAxFocusState,
|
|
67
79
|
type PmxAxSource,
|
|
68
80
|
type PmxAxState,
|
|
@@ -247,7 +259,7 @@ export interface CanvasNodeUpdate {
|
|
|
247
259
|
export type CanvasChangeType = 'pins' | 'nodes' | 'ax' | 'ax-timeline';
|
|
248
260
|
|
|
249
261
|
export interface MutationRecordInfo {
|
|
250
|
-
operationType: 'addNode' | 'updateNode' | 'removeNode' | 'addEdge' | 'removeEdge' | 'addAnnotation' | 'removeAnnotation' | 'clear' | 'restoreSnapshot' | 'setPins' | 'setAxFocus' | 'addWorkItem' | 'updateWorkItem' | 'requestApproval' | 'resolveApproval' | 'addReviewAnnotation' | 'updateReviewAnnotation' | 'arrange' | 'batch' | 'groupNodes' | 'ungroupNodes' | 'viewport';
|
|
262
|
+
operationType: 'addNode' | 'updateNode' | 'removeNode' | 'addEdge' | 'removeEdge' | 'addAnnotation' | 'removeAnnotation' | 'clear' | 'restoreSnapshot' | 'setPins' | 'setAxFocus' | 'addWorkItem' | 'updateWorkItem' | 'requestApproval' | 'resolveApproval' | 'addReviewAnnotation' | 'updateReviewAnnotation' | 'requestElicitation' | 'respondElicitation' | 'requestMode' | 'resolveModeRequest' | 'setPolicy' | 'arrange' | 'batch' | 'groupNodes' | 'ungroupNodes' | 'viewport';
|
|
251
263
|
description: string;
|
|
252
264
|
forward: () => void;
|
|
253
265
|
inverse: () => void;
|
|
@@ -430,18 +442,14 @@ class CanvasStateManager {
|
|
|
430
442
|
}
|
|
431
443
|
|
|
432
444
|
private normalizeNode(node: CanvasNodeState): CanvasNodeState {
|
|
433
|
-
|
|
445
|
+
// Context nodes default to a right-docked, collapsed pill (see DockedNode.tsx),
|
|
446
|
+
// but that default is applied at CREATE time only — it must not be re-forced on
|
|
447
|
+
// every write, or the node could never be undocked. Undocking (dockPosition →
|
|
448
|
+
// null) is a deliberate user action and is respected here.
|
|
449
|
+
return {
|
|
434
450
|
...node,
|
|
435
451
|
data: normalizeCanvasNodeData(node.type, node.data),
|
|
436
452
|
};
|
|
437
|
-
// Context nodes are always docked to the right side as a pill/panel widget
|
|
438
|
-
// (see DockedNode.tsx). They start collapsed so the user sees the slim
|
|
439
|
-
// pill first; expanding reveals the full context overview panel.
|
|
440
|
-
if (normalized.type === 'context' && normalized.dockPosition !== 'right') {
|
|
441
|
-
normalized.dockPosition = 'right';
|
|
442
|
-
normalized.collapsed = true;
|
|
443
|
-
}
|
|
444
|
-
return normalized;
|
|
445
453
|
}
|
|
446
454
|
|
|
447
455
|
private nodeForRead(node: CanvasNodeState): CanvasNodeState {
|
|
@@ -918,6 +926,16 @@ class CanvasStateManager {
|
|
|
918
926
|
return false;
|
|
919
927
|
}
|
|
920
928
|
|
|
929
|
+
/**
|
|
930
|
+
* Whether this workspace's canvas DB already holds saved state. Used to gate
|
|
931
|
+
* brand-new-workspace seeding (e.g. the default docked status/context widgets)
|
|
932
|
+
* so we never add nodes to a canvas that already has content. Reflects the
|
|
933
|
+
* pre-run persisted flag until the next save.
|
|
934
|
+
*/
|
|
935
|
+
hasPersistedState(): boolean {
|
|
936
|
+
return this._db ? isDbPopulated(this._db) : false;
|
|
937
|
+
}
|
|
938
|
+
|
|
921
939
|
/** Debounced save — coalesces rapid mutations into a single write. */
|
|
922
940
|
private scheduleSave(): void {
|
|
923
941
|
if (!this._db) return;
|
|
@@ -1331,7 +1349,15 @@ class CanvasStateManager {
|
|
|
1331
1349
|
}
|
|
1332
1350
|
|
|
1333
1351
|
addNode(node: CanvasNodeState): void {
|
|
1334
|
-
|
|
1352
|
+
// Context nodes default to a right-docked, collapsed pill when created without
|
|
1353
|
+
// an explicit dock position. CREATE-time default only — once placed, updates
|
|
1354
|
+
// (including undock → dockPosition null) are respected (see normalizeNode).
|
|
1355
|
+
// Skip during suppressed replay (undo/redo re-add) so a deliberately-undocked
|
|
1356
|
+
// context node is restored verbatim instead of being snapped back to the dock.
|
|
1357
|
+
const seeded = node.type === 'context' && node.dockPosition == null && this._suppressRecordingDepth === 0
|
|
1358
|
+
? { ...node, dockPosition: 'right' as const, collapsed: true }
|
|
1359
|
+
: node;
|
|
1360
|
+
const cloned = structuredClone(this.normalizeNode(seeded));
|
|
1335
1361
|
this.nodes.set(node.id, cloned);
|
|
1336
1362
|
this.scheduleSave();
|
|
1337
1363
|
this.notifyChange('nodes');
|
|
@@ -1366,11 +1392,11 @@ class CanvasStateManager {
|
|
|
1366
1392
|
this.nodes.set(id, nextNode);
|
|
1367
1393
|
const parentGroupId = existing.data.parentGroup as string | undefined;
|
|
1368
1394
|
if (parentGroupId) {
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1395
|
+
// Moving or resizing a grouped child re-fits the group frame but must NOT
|
|
1396
|
+
// repack siblings — that would discard their explicit positions and the
|
|
1397
|
+
// moved child's requested coordinates. Compaction is opt-in (group
|
|
1398
|
+
// create/add with childLayout, or arrange).
|
|
1399
|
+
this.recomputeParentGroupBounds(parentGroupId);
|
|
1374
1400
|
this.reflowAllGroups();
|
|
1375
1401
|
}
|
|
1376
1402
|
this.scheduleSave();
|
|
@@ -1561,7 +1587,7 @@ class CanvasStateManager {
|
|
|
1561
1587
|
applyUpdates(updates: CanvasNodeUpdate[], options: ApplyUpdatesOptions = {}): { applied: number; skipped: number } {
|
|
1562
1588
|
let applied = 0;
|
|
1563
1589
|
let skipped = 0;
|
|
1564
|
-
const touchedParentGroups = new
|
|
1590
|
+
const touchedParentGroups = new Set<string>();
|
|
1565
1591
|
const oldSnapshots = new Map<string, CanvasNodeState>();
|
|
1566
1592
|
const appliedUpdates: CanvasNodeUpdate[] = [];
|
|
1567
1593
|
const explicitPositionUpdateIds = new Set(
|
|
@@ -1615,19 +1641,17 @@ class CanvasStateManager {
|
|
|
1615
1641
|
}));
|
|
1616
1642
|
const parentGroupId = existing.data.parentGroup as string | undefined;
|
|
1617
1643
|
if (parentGroupId) {
|
|
1618
|
-
|
|
1619
|
-
entry.compact = entry.compact || nextPatch.size !== undefined;
|
|
1620
|
-
touchedParentGroups.set(parentGroupId, entry);
|
|
1644
|
+
touchedParentGroups.add(parentGroupId);
|
|
1621
1645
|
}
|
|
1622
1646
|
applied++;
|
|
1623
1647
|
}
|
|
1624
1648
|
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1649
|
+
// Moving or resizing a grouped child re-fits the group frame, but must NOT
|
|
1650
|
+
// repack siblings — that would discard their explicit positions and the
|
|
1651
|
+
// moved child's requested coordinates. Compaction is opt-in, applied only
|
|
1652
|
+
// through an explicit layout (group create/add with childLayout, or arrange).
|
|
1653
|
+
for (const groupId of touchedParentGroups) {
|
|
1654
|
+
this.recomputeParentGroupBounds(groupId);
|
|
1631
1655
|
}
|
|
1632
1656
|
if (touchedParentGroups.size > 0) this.reflowAllGroups();
|
|
1633
1657
|
|
|
@@ -1918,6 +1942,148 @@ class CanvasStateManager {
|
|
|
1918
1942
|
return this._axHostCapability;
|
|
1919
1943
|
}
|
|
1920
1944
|
|
|
1945
|
+
getElicitations(): PmxAxElicitation[] {
|
|
1946
|
+
return this.getAxState().elicitations;
|
|
1947
|
+
}
|
|
1948
|
+
|
|
1949
|
+
requestElicitation(
|
|
1950
|
+
input: { prompt: string; fields?: string[]; nodeIds?: string[] },
|
|
1951
|
+
options: { source?: PmxAxSource } = {},
|
|
1952
|
+
): PmxAxElicitation {
|
|
1953
|
+
const oldAxState = this.getAxState();
|
|
1954
|
+
const elicitation = createAxElicitation(input, options.source ?? 'api', this.currentNodeIdSet());
|
|
1955
|
+
this.applyAxState({ ...oldAxState, elicitations: [...oldAxState.elicitations, elicitation] });
|
|
1956
|
+
const applied = this.getAxState();
|
|
1957
|
+
this.scheduleSave();
|
|
1958
|
+
this.notifyChange('ax');
|
|
1959
|
+
this.recordMutation({
|
|
1960
|
+
operationType: 'requestElicitation',
|
|
1961
|
+
description: `Requested elicitation "${elicitation.prompt}"`,
|
|
1962
|
+
forward: this.suppressed(() => { this.applyAxState(applied); this.scheduleSave(); this.notifyChange('ax'); }),
|
|
1963
|
+
inverse: this.suppressed(() => { this.applyAxState(oldAxState); this.scheduleSave(); this.notifyChange('ax'); }),
|
|
1964
|
+
});
|
|
1965
|
+
return applied.elicitations.find((e) => e.id === elicitation.id) ?? elicitation;
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
respondElicitation(
|
|
1969
|
+
id: string,
|
|
1970
|
+
response: Record<string, unknown>,
|
|
1971
|
+
options: { source?: PmxAxSource } = {},
|
|
1972
|
+
): PmxAxElicitation | null {
|
|
1973
|
+
const oldAxState = this.getAxState();
|
|
1974
|
+
const existing = oldAxState.elicitations.find((e) => e.id === id);
|
|
1975
|
+
if (!existing || existing.status !== 'pending') return null;
|
|
1976
|
+
const merged: PmxAxElicitation = {
|
|
1977
|
+
...existing,
|
|
1978
|
+
status: 'answered',
|
|
1979
|
+
response,
|
|
1980
|
+
resolvedAt: new Date().toISOString(),
|
|
1981
|
+
source: options.source ?? existing.source,
|
|
1982
|
+
};
|
|
1983
|
+
this.applyAxState({ ...oldAxState, elicitations: replaceById(oldAxState.elicitations, merged) });
|
|
1984
|
+
const applied = this.getAxState();
|
|
1985
|
+
this.scheduleSave();
|
|
1986
|
+
this.notifyChange('ax');
|
|
1987
|
+
this.recordMutation({
|
|
1988
|
+
operationType: 'respondElicitation',
|
|
1989
|
+
description: `Answered elicitation ${id}`,
|
|
1990
|
+
forward: this.suppressed(() => { this.applyAxState(applied); this.scheduleSave(); this.notifyChange('ax'); }),
|
|
1991
|
+
inverse: this.suppressed(() => { this.applyAxState(oldAxState); this.scheduleSave(); this.notifyChange('ax'); }),
|
|
1992
|
+
});
|
|
1993
|
+
return applied.elicitations.find((e) => e.id === id) ?? null;
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
getModeRequests(): PmxAxModeRequest[] {
|
|
1997
|
+
return this.getAxState().modeRequests;
|
|
1998
|
+
}
|
|
1999
|
+
|
|
2000
|
+
requestMode(
|
|
2001
|
+
input: { mode: PmxAxMode; reason?: string | null; nodeIds?: string[] },
|
|
2002
|
+
options: { source?: PmxAxSource } = {},
|
|
2003
|
+
): PmxAxModeRequest {
|
|
2004
|
+
const oldAxState = this.getAxState();
|
|
2005
|
+
const request = createAxModeRequest(input, options.source ?? 'api', this.currentNodeIdSet());
|
|
2006
|
+
this.applyAxState({ ...oldAxState, modeRequests: [...oldAxState.modeRequests, request] });
|
|
2007
|
+
const applied = this.getAxState();
|
|
2008
|
+
this.scheduleSave();
|
|
2009
|
+
this.notifyChange('ax');
|
|
2010
|
+
this.recordMutation({
|
|
2011
|
+
operationType: 'requestMode',
|
|
2012
|
+
description: `Requested mode "${request.mode}"`,
|
|
2013
|
+
forward: this.suppressed(() => { this.applyAxState(applied); this.scheduleSave(); this.notifyChange('ax'); }),
|
|
2014
|
+
inverse: this.suppressed(() => { this.applyAxState(oldAxState); this.scheduleSave(); this.notifyChange('ax'); }),
|
|
2015
|
+
});
|
|
2016
|
+
return applied.modeRequests.find((m) => m.id === request.id) ?? request;
|
|
2017
|
+
}
|
|
2018
|
+
|
|
2019
|
+
resolveModeRequest(
|
|
2020
|
+
id: string,
|
|
2021
|
+
decision: 'approved' | 'rejected',
|
|
2022
|
+
options: { resolution?: string; source?: PmxAxSource } = {},
|
|
2023
|
+
): PmxAxModeRequest | null {
|
|
2024
|
+
const oldAxState = this.getAxState();
|
|
2025
|
+
const existing = oldAxState.modeRequests.find((m) => m.id === id);
|
|
2026
|
+
if (!existing || existing.status !== 'pending') return null;
|
|
2027
|
+
const merged: PmxAxModeRequest = {
|
|
2028
|
+
...existing,
|
|
2029
|
+
status: decision,
|
|
2030
|
+
resolvedAt: new Date().toISOString(),
|
|
2031
|
+
resolution: options.resolution ?? null,
|
|
2032
|
+
source: options.source ?? existing.source,
|
|
2033
|
+
};
|
|
2034
|
+
this.applyAxState({ ...oldAxState, modeRequests: replaceById(oldAxState.modeRequests, merged) });
|
|
2035
|
+
const applied = this.getAxState();
|
|
2036
|
+
this.scheduleSave();
|
|
2037
|
+
this.notifyChange('ax');
|
|
2038
|
+
this.recordMutation({
|
|
2039
|
+
operationType: 'resolveModeRequest',
|
|
2040
|
+
description: `Resolved mode request ${id} -> ${decision}`,
|
|
2041
|
+
forward: this.suppressed(() => { this.applyAxState(applied); this.scheduleSave(); this.notifyChange('ax'); }),
|
|
2042
|
+
inverse: this.suppressed(() => { this.applyAxState(oldAxState); this.scheduleSave(); this.notifyChange('ax'); }),
|
|
2043
|
+
});
|
|
2044
|
+
return applied.modeRequests.find((m) => m.id === id) ?? null;
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2047
|
+
getCommandRegistry(): PmxAxCommandDescriptor[] {
|
|
2048
|
+
return listAxCommands();
|
|
2049
|
+
}
|
|
2050
|
+
|
|
2051
|
+
/** Invoke a registry-gated PMX command intent — records a timeline event (no execution). */
|
|
2052
|
+
invokeCommand(name: string, args: Record<string, unknown> | null = null, options: { source?: PmxAxSource } = {}): PmxAxEvent | null {
|
|
2053
|
+
if (!isAxCommand(name)) return null;
|
|
2054
|
+
return this.recordAxEvent(
|
|
2055
|
+
{ kind: 'command', summary: name, detail: AX_COMMAND_REGISTRY[name].description, data: { command: name, ...(args ? { args } : {}) } },
|
|
2056
|
+
options,
|
|
2057
|
+
);
|
|
2058
|
+
}
|
|
2059
|
+
|
|
2060
|
+
getPolicy(): PmxAxPolicy {
|
|
2061
|
+
return this.getAxState().policy;
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2064
|
+
/** Merge a declarative tool/prompt policy patch (canvas-bound, snapshotted). */
|
|
2065
|
+
setPolicy(
|
|
2066
|
+
patch: { tools?: Partial<PmxAxPolicy['tools']>; prompt?: Partial<PmxAxPolicy['prompt']> },
|
|
2067
|
+
_options: { source?: PmxAxSource } = {},
|
|
2068
|
+
): PmxAxPolicy {
|
|
2069
|
+
const oldAxState = this.getAxState();
|
|
2070
|
+
const merged = normalizeAxPolicy({
|
|
2071
|
+
tools: { ...oldAxState.policy.tools, ...(patch.tools ?? {}) },
|
|
2072
|
+
prompt: { ...oldAxState.policy.prompt, ...(patch.prompt ?? {}) },
|
|
2073
|
+
});
|
|
2074
|
+
this.applyAxState({ ...oldAxState, policy: merged });
|
|
2075
|
+
const applied = this.getAxState();
|
|
2076
|
+
this.scheduleSave();
|
|
2077
|
+
this.notifyChange('ax');
|
|
2078
|
+
this.recordMutation({
|
|
2079
|
+
operationType: 'setPolicy',
|
|
2080
|
+
description: 'Updated AX policy',
|
|
2081
|
+
forward: this.suppressed(() => { this.applyAxState(applied); this.scheduleSave(); this.notifyChange('ax'); }),
|
|
2082
|
+
inverse: this.suppressed(() => { this.applyAxState(oldAxState); this.scheduleSave(); this.notifyChange('ax'); }),
|
|
2083
|
+
});
|
|
2084
|
+
return applied.policy;
|
|
2085
|
+
}
|
|
2086
|
+
|
|
1921
2087
|
setHostCapability(input: unknown, _options: { source?: PmxAxSource } = {}): PmxAxHostCapability {
|
|
1922
2088
|
const cap = normalizeAxHostCapability(
|
|
1923
2089
|
isRecord(input)
|
|
@@ -2012,6 +2178,15 @@ class CanvasStateManager {
|
|
|
2012
2178
|
return this._db ? loadAxSteeringFromDB(this._db, q) : [];
|
|
2013
2179
|
}
|
|
2014
2180
|
|
|
2181
|
+
/**
|
|
2182
|
+
* Undelivered steering for a consumer (Phase 4 delivery). Excludes messages
|
|
2183
|
+
* whose source equals the consumer to prevent delivery loops (e.g. Copilot
|
|
2184
|
+
* should not be handed back steering it originated).
|
|
2185
|
+
*/
|
|
2186
|
+
getPendingSteering(options: { consumer?: string; limit?: number } = {}): PmxAxSteeringMessage[] {
|
|
2187
|
+
return this._db ? loadPendingAxSteeringFromDB(this._db, options) : [];
|
|
2188
|
+
}
|
|
2189
|
+
|
|
2015
2190
|
getAxTimelineSummary(): PmxAxTimelineSummary {
|
|
2016
2191
|
return this._db
|
|
2017
2192
|
? loadAxTimelineSummaryFromDB(this._db)
|