pmx-canvas 0.1.29 → 0.1.30

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 (66) hide show
  1. package/CHANGELOG.md +161 -0
  2. package/Readme.md +20 -10
  3. package/dist/canvas/global.css +13 -0
  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 +47 -2
  21. package/dist/types/server/html-surface.d.ts +40 -0
  22. package/dist/types/server/index.d.ts +50 -2
  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/shared/surface.d.ts +19 -0
  26. package/docs/cli.md +30 -0
  27. package/docs/http-api.md +55 -0
  28. package/docs/mcp.md +40 -2
  29. package/docs/node-types.md +26 -0
  30. package/docs/plans/plan-004-pmx-ax-primitives.md +623 -394
  31. package/docs/sdk.md +20 -0
  32. package/package.json +2 -2
  33. package/skills/pmx-canvas/SKILL.md +107 -9
  34. package/src/cli/agent.ts +177 -0
  35. package/src/client/canvas/CanvasNode.tsx +8 -4
  36. package/src/client/canvas/ExpandedNodeOverlay.tsx +12 -0
  37. package/src/client/nodes/ContextNode.tsx +17 -0
  38. package/src/client/nodes/ExtAppFrame.tsx +40 -3
  39. package/src/client/nodes/FileNode.tsx +26 -0
  40. package/src/client/nodes/HtmlNode.tsx +60 -188
  41. package/src/client/nodes/McpAppNode.tsx +47 -2
  42. package/src/client/nodes/StatusNode.tsx +20 -0
  43. package/src/client/nodes/ax-node-actions.ts +39 -0
  44. package/src/client/nodes/surface-url.ts +48 -0
  45. package/src/client/state/attention-bridge.ts +5 -0
  46. package/src/client/state/intent-bridge.ts +33 -0
  47. package/src/client/theme/global.css +13 -0
  48. package/src/client/theme/surface-theme.css +142 -0
  49. package/src/json-render/renderer/index.tsx +31 -0
  50. package/src/json-render/schema.ts +4 -0
  51. package/src/json-render/server.ts +13 -0
  52. package/src/mcp/canvas-access.ts +195 -0
  53. package/src/mcp/server.ts +232 -2
  54. package/src/server/ax-context.ts +3 -0
  55. package/src/server/ax-interaction.ts +549 -0
  56. package/src/server/ax-state.ts +188 -2
  57. package/src/server/canvas-db.ts +20 -0
  58. package/src/server/canvas-operations.ts +11 -0
  59. package/src/server/canvas-serialization.ts +9 -0
  60. package/src/server/canvas-state.ts +177 -16
  61. package/src/server/html-surface.ts +170 -0
  62. package/src/server/index.ts +98 -0
  63. package/src/server/mutation-history.ts +5 -0
  64. package/src/server/placement.ts +5 -1
  65. package/src/server/server.ts +305 -0
  66. 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;
@@ -1366,11 +1378,11 @@ class CanvasStateManager {
1366
1378
  this.nodes.set(id, nextNode);
1367
1379
  const parentGroupId = existing.data.parentGroup as string | undefined;
1368
1380
  if (parentGroupId) {
1369
- if (patch.size) {
1370
- this.compactGroupChildren(parentGroupId);
1371
- } else {
1372
- this.recomputeParentGroupBounds(parentGroupId);
1373
- }
1381
+ // Moving or resizing a grouped child re-fits the group frame but must NOT
1382
+ // repack siblings — that would discard their explicit positions and the
1383
+ // moved child's requested coordinates. Compaction is opt-in (group
1384
+ // create/add with childLayout, or arrange).
1385
+ this.recomputeParentGroupBounds(parentGroupId);
1374
1386
  this.reflowAllGroups();
1375
1387
  }
1376
1388
  this.scheduleSave();
@@ -1561,7 +1573,7 @@ class CanvasStateManager {
1561
1573
  applyUpdates(updates: CanvasNodeUpdate[], options: ApplyUpdatesOptions = {}): { applied: number; skipped: number } {
1562
1574
  let applied = 0;
1563
1575
  let skipped = 0;
1564
- const touchedParentGroups = new Map<string, { compact: boolean }>();
1576
+ const touchedParentGroups = new Set<string>();
1565
1577
  const oldSnapshots = new Map<string, CanvasNodeState>();
1566
1578
  const appliedUpdates: CanvasNodeUpdate[] = [];
1567
1579
  const explicitPositionUpdateIds = new Set(
@@ -1615,19 +1627,17 @@ class CanvasStateManager {
1615
1627
  }));
1616
1628
  const parentGroupId = existing.data.parentGroup as string | undefined;
1617
1629
  if (parentGroupId) {
1618
- const entry = touchedParentGroups.get(parentGroupId) ?? { compact: false };
1619
- entry.compact = entry.compact || nextPatch.size !== undefined;
1620
- touchedParentGroups.set(parentGroupId, entry);
1630
+ touchedParentGroups.add(parentGroupId);
1621
1631
  }
1622
1632
  applied++;
1623
1633
  }
1624
1634
 
1625
- for (const [groupId, entry] of touchedParentGroups) {
1626
- if (entry.compact) {
1627
- this.compactGroupChildren(groupId);
1628
- } else {
1629
- this.recomputeParentGroupBounds(groupId);
1630
- }
1635
+ // Moving or resizing a grouped child re-fits the group frame, but must NOT
1636
+ // repack siblings — that would discard their explicit positions and the
1637
+ // moved child's requested coordinates. Compaction is opt-in, applied only
1638
+ // through an explicit layout (group create/add with childLayout, or arrange).
1639
+ for (const groupId of touchedParentGroups) {
1640
+ this.recomputeParentGroupBounds(groupId);
1631
1641
  }
1632
1642
  if (touchedParentGroups.size > 0) this.reflowAllGroups();
1633
1643
 
@@ -1918,6 +1928,148 @@ class CanvasStateManager {
1918
1928
  return this._axHostCapability;
1919
1929
  }
1920
1930
 
1931
+ getElicitations(): PmxAxElicitation[] {
1932
+ return this.getAxState().elicitations;
1933
+ }
1934
+
1935
+ requestElicitation(
1936
+ input: { prompt: string; fields?: string[]; nodeIds?: string[] },
1937
+ options: { source?: PmxAxSource } = {},
1938
+ ): PmxAxElicitation {
1939
+ const oldAxState = this.getAxState();
1940
+ const elicitation = createAxElicitation(input, options.source ?? 'api', this.currentNodeIdSet());
1941
+ this.applyAxState({ ...oldAxState, elicitations: [...oldAxState.elicitations, elicitation] });
1942
+ const applied = this.getAxState();
1943
+ this.scheduleSave();
1944
+ this.notifyChange('ax');
1945
+ this.recordMutation({
1946
+ operationType: 'requestElicitation',
1947
+ description: `Requested elicitation "${elicitation.prompt}"`,
1948
+ forward: this.suppressed(() => { this.applyAxState(applied); this.scheduleSave(); this.notifyChange('ax'); }),
1949
+ inverse: this.suppressed(() => { this.applyAxState(oldAxState); this.scheduleSave(); this.notifyChange('ax'); }),
1950
+ });
1951
+ return applied.elicitations.find((e) => e.id === elicitation.id) ?? elicitation;
1952
+ }
1953
+
1954
+ respondElicitation(
1955
+ id: string,
1956
+ response: Record<string, unknown>,
1957
+ options: { source?: PmxAxSource } = {},
1958
+ ): PmxAxElicitation | null {
1959
+ const oldAxState = this.getAxState();
1960
+ const existing = oldAxState.elicitations.find((e) => e.id === id);
1961
+ if (!existing || existing.status !== 'pending') return null;
1962
+ const merged: PmxAxElicitation = {
1963
+ ...existing,
1964
+ status: 'answered',
1965
+ response,
1966
+ resolvedAt: new Date().toISOString(),
1967
+ source: options.source ?? existing.source,
1968
+ };
1969
+ this.applyAxState({ ...oldAxState, elicitations: replaceById(oldAxState.elicitations, merged) });
1970
+ const applied = this.getAxState();
1971
+ this.scheduleSave();
1972
+ this.notifyChange('ax');
1973
+ this.recordMutation({
1974
+ operationType: 'respondElicitation',
1975
+ description: `Answered elicitation ${id}`,
1976
+ forward: this.suppressed(() => { this.applyAxState(applied); this.scheduleSave(); this.notifyChange('ax'); }),
1977
+ inverse: this.suppressed(() => { this.applyAxState(oldAxState); this.scheduleSave(); this.notifyChange('ax'); }),
1978
+ });
1979
+ return applied.elicitations.find((e) => e.id === id) ?? null;
1980
+ }
1981
+
1982
+ getModeRequests(): PmxAxModeRequest[] {
1983
+ return this.getAxState().modeRequests;
1984
+ }
1985
+
1986
+ requestMode(
1987
+ input: { mode: PmxAxMode; reason?: string | null; nodeIds?: string[] },
1988
+ options: { source?: PmxAxSource } = {},
1989
+ ): PmxAxModeRequest {
1990
+ const oldAxState = this.getAxState();
1991
+ const request = createAxModeRequest(input, options.source ?? 'api', this.currentNodeIdSet());
1992
+ this.applyAxState({ ...oldAxState, modeRequests: [...oldAxState.modeRequests, request] });
1993
+ const applied = this.getAxState();
1994
+ this.scheduleSave();
1995
+ this.notifyChange('ax');
1996
+ this.recordMutation({
1997
+ operationType: 'requestMode',
1998
+ description: `Requested mode "${request.mode}"`,
1999
+ forward: this.suppressed(() => { this.applyAxState(applied); this.scheduleSave(); this.notifyChange('ax'); }),
2000
+ inverse: this.suppressed(() => { this.applyAxState(oldAxState); this.scheduleSave(); this.notifyChange('ax'); }),
2001
+ });
2002
+ return applied.modeRequests.find((m) => m.id === request.id) ?? request;
2003
+ }
2004
+
2005
+ resolveModeRequest(
2006
+ id: string,
2007
+ decision: 'approved' | 'rejected',
2008
+ options: { resolution?: string; source?: PmxAxSource } = {},
2009
+ ): PmxAxModeRequest | null {
2010
+ const oldAxState = this.getAxState();
2011
+ const existing = oldAxState.modeRequests.find((m) => m.id === id);
2012
+ if (!existing || existing.status !== 'pending') return null;
2013
+ const merged: PmxAxModeRequest = {
2014
+ ...existing,
2015
+ status: decision,
2016
+ resolvedAt: new Date().toISOString(),
2017
+ resolution: options.resolution ?? null,
2018
+ source: options.source ?? existing.source,
2019
+ };
2020
+ this.applyAxState({ ...oldAxState, modeRequests: replaceById(oldAxState.modeRequests, merged) });
2021
+ const applied = this.getAxState();
2022
+ this.scheduleSave();
2023
+ this.notifyChange('ax');
2024
+ this.recordMutation({
2025
+ operationType: 'resolveModeRequest',
2026
+ description: `Resolved mode request ${id} -> ${decision}`,
2027
+ forward: this.suppressed(() => { this.applyAxState(applied); this.scheduleSave(); this.notifyChange('ax'); }),
2028
+ inverse: this.suppressed(() => { this.applyAxState(oldAxState); this.scheduleSave(); this.notifyChange('ax'); }),
2029
+ });
2030
+ return applied.modeRequests.find((m) => m.id === id) ?? null;
2031
+ }
2032
+
2033
+ getCommandRegistry(): PmxAxCommandDescriptor[] {
2034
+ return listAxCommands();
2035
+ }
2036
+
2037
+ /** Invoke a registry-gated PMX command intent — records a timeline event (no execution). */
2038
+ invokeCommand(name: string, args: Record<string, unknown> | null = null, options: { source?: PmxAxSource } = {}): PmxAxEvent | null {
2039
+ if (!isAxCommand(name)) return null;
2040
+ return this.recordAxEvent(
2041
+ { kind: 'command', summary: name, detail: AX_COMMAND_REGISTRY[name].description, data: { command: name, ...(args ? { args } : {}) } },
2042
+ options,
2043
+ );
2044
+ }
2045
+
2046
+ getPolicy(): PmxAxPolicy {
2047
+ return this.getAxState().policy;
2048
+ }
2049
+
2050
+ /** Merge a declarative tool/prompt policy patch (canvas-bound, snapshotted). */
2051
+ setPolicy(
2052
+ patch: { tools?: Partial<PmxAxPolicy['tools']>; prompt?: Partial<PmxAxPolicy['prompt']> },
2053
+ _options: { source?: PmxAxSource } = {},
2054
+ ): PmxAxPolicy {
2055
+ const oldAxState = this.getAxState();
2056
+ const merged = normalizeAxPolicy({
2057
+ tools: { ...oldAxState.policy.tools, ...(patch.tools ?? {}) },
2058
+ prompt: { ...oldAxState.policy.prompt, ...(patch.prompt ?? {}) },
2059
+ });
2060
+ this.applyAxState({ ...oldAxState, policy: merged });
2061
+ const applied = this.getAxState();
2062
+ this.scheduleSave();
2063
+ this.notifyChange('ax');
2064
+ this.recordMutation({
2065
+ operationType: 'setPolicy',
2066
+ description: 'Updated AX policy',
2067
+ forward: this.suppressed(() => { this.applyAxState(applied); this.scheduleSave(); this.notifyChange('ax'); }),
2068
+ inverse: this.suppressed(() => { this.applyAxState(oldAxState); this.scheduleSave(); this.notifyChange('ax'); }),
2069
+ });
2070
+ return applied.policy;
2071
+ }
2072
+
1921
2073
  setHostCapability(input: unknown, _options: { source?: PmxAxSource } = {}): PmxAxHostCapability {
1922
2074
  const cap = normalizeAxHostCapability(
1923
2075
  isRecord(input)
@@ -2012,6 +2164,15 @@ class CanvasStateManager {
2012
2164
  return this._db ? loadAxSteeringFromDB(this._db, q) : [];
2013
2165
  }
2014
2166
 
2167
+ /**
2168
+ * Undelivered steering for a consumer (Phase 4 delivery). Excludes messages
2169
+ * whose source equals the consumer to prevent delivery loops (e.g. Copilot
2170
+ * should not be handed back steering it originated).
2171
+ */
2172
+ getPendingSteering(options: { consumer?: string; limit?: number } = {}): PmxAxSteeringMessage[] {
2173
+ return this._db ? loadPendingAxSteeringFromDB(this._db, options) : [];
2174
+ }
2175
+
2015
2176
  getAxTimelineSummary(): PmxAxTimelineSummary {
2016
2177
  return this._db
2017
2178
  ? loadAxTimelineSummaryFromDB(this._db)