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.
Files changed (69) hide show
  1. package/CHANGELOG.md +219 -0
  2. package/Readme.md +20 -10
  3. package/dist/canvas/global.css +51 -56
  4. package/dist/canvas/index.js +80 -163
  5. package/dist/canvas/surface-theme.css +142 -0
  6. package/dist/json-render/index.js +103 -103
  7. package/dist/types/client/nodes/HtmlNode.d.ts +0 -7
  8. package/dist/types/client/nodes/ax-node-actions.d.ts +18 -0
  9. package/dist/types/client/nodes/surface-url.d.ts +22 -0
  10. package/dist/types/client/state/attention-bridge.d.ts +3 -0
  11. package/dist/types/client/state/intent-bridge.d.ts +17 -0
  12. package/dist/types/json-render/renderer/index.d.ts +2 -0
  13. package/dist/types/json-render/schema.d.ts +2 -0
  14. package/dist/types/json-render/server.d.ts +2 -0
  15. package/dist/types/mcp/canvas-access.d.ts +47 -0
  16. package/dist/types/server/ax-interaction.d.ts +210 -0
  17. package/dist/types/server/ax-state.d.ts +67 -1
  18. package/dist/types/server/canvas-db.d.ts +4 -0
  19. package/dist/types/server/canvas-serialization.d.ts +2 -0
  20. package/dist/types/server/canvas-state.d.ts +54 -2
  21. package/dist/types/server/html-surface.d.ts +46 -0
  22. package/dist/types/server/index.d.ts +63 -5
  23. package/dist/types/server/mutation-history.d.ts +1 -1
  24. package/dist/types/server/placement.d.ts +1 -1
  25. package/dist/types/server/server.d.ts +12 -0
  26. package/dist/types/shared/surface.d.ts +19 -0
  27. package/docs/cli.md +30 -0
  28. package/docs/http-api.md +55 -0
  29. package/docs/mcp.md +40 -2
  30. package/docs/node-types.md +26 -0
  31. package/docs/plans/plan-004-pmx-ax-primitives.md +623 -394
  32. package/docs/sdk.md +23 -1
  33. package/package.json +2 -2
  34. package/skills/pmx-canvas/SKILL.md +107 -9
  35. package/src/cli/agent.ts +177 -0
  36. package/src/cli/index.ts +8 -1
  37. package/src/client/canvas/CanvasNode.tsx +8 -4
  38. package/src/client/canvas/DockedNode.tsx +38 -38
  39. package/src/client/canvas/ExpandedNodeOverlay.tsx +12 -0
  40. package/src/client/nodes/ContextNode.tsx +17 -0
  41. package/src/client/nodes/ExtAppFrame.tsx +40 -3
  42. package/src/client/nodes/FileNode.tsx +26 -0
  43. package/src/client/nodes/HtmlNode.tsx +60 -188
  44. package/src/client/nodes/McpAppNode.tsx +47 -2
  45. package/src/client/nodes/StatusNode.tsx +20 -0
  46. package/src/client/nodes/ax-node-actions.ts +39 -0
  47. package/src/client/nodes/surface-url.ts +48 -0
  48. package/src/client/state/attention-bridge.ts +5 -0
  49. package/src/client/state/intent-bridge.ts +33 -0
  50. package/src/client/theme/global.css +51 -56
  51. package/src/client/theme/surface-theme.css +142 -0
  52. package/src/json-render/renderer/index.tsx +31 -0
  53. package/src/json-render/schema.ts +4 -0
  54. package/src/json-render/server.ts +13 -0
  55. package/src/mcp/canvas-access.ts +198 -1
  56. package/src/mcp/server.ts +232 -2
  57. package/src/server/ax-context.ts +3 -0
  58. package/src/server/ax-interaction.ts +549 -0
  59. package/src/server/ax-state.ts +188 -2
  60. package/src/server/canvas-db.ts +20 -0
  61. package/src/server/canvas-operations.ts +11 -0
  62. package/src/server/canvas-serialization.ts +9 -0
  63. package/src/server/canvas-state.ts +201 -26
  64. package/src/server/html-surface.ts +190 -0
  65. package/src/server/index.ts +122 -7
  66. package/src/server/mutation-history.ts +5 -0
  67. package/src/server/placement.ts +5 -1
  68. package/src/server/server.ts +360 -0
  69. package/src/shared/surface.ts +38 -0
@@ -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
  };
@@ -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
- const normalized: CanvasNodeState = {
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
- const cloned = structuredClone(this.normalizeNode(node));
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
- if (patch.size) {
1370
- this.compactGroupChildren(parentGroupId);
1371
- } else {
1372
- this.recomputeParentGroupBounds(parentGroupId);
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 Map<string, { compact: boolean }>();
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
- const entry = touchedParentGroups.get(parentGroupId) ?? { compact: false };
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
- for (const [groupId, entry] of touchedParentGroups) {
1626
- if (entry.compact) {
1627
- this.compactGroupChildren(groupId);
1628
- } else {
1629
- this.recomputeParentGroupBounds(groupId);
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)