pmx-canvas 0.4.8 → 0.5.1

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 (212) hide show
  1. package/.github/extensions/pmx-canvas/extension.mjs +154 -5
  2. package/.github/extensions/pmx-canvas/steering-delivery.mjs +79 -0
  3. package/CHANGELOG.md +156 -0
  4. package/Readme.md +72 -23
  5. package/dist/canvas/global.css +3071 -796
  6. package/dist/canvas/index.js +122 -104
  7. package/dist/json-render/index.css +1 -1
  8. package/dist/types/cli/agent.d.ts +1 -0
  9. package/dist/types/cli/commands/pump.d.ts +9 -0
  10. package/dist/types/client/App.d.ts +1 -1
  11. package/dist/types/client/canvas/AgentPresenceLayer.d.ts +11 -0
  12. package/dist/types/client/canvas/BarHint.d.ts +22 -0
  13. package/dist/types/client/canvas/CanvasNode.d.ts +5 -0
  14. package/dist/types/client/canvas/CanvasViewport.d.ts +4 -4
  15. package/dist/types/client/canvas/CommandBar.d.ts +10 -0
  16. package/dist/types/client/canvas/ConnectionBanner.d.ts +13 -0
  17. package/dist/types/client/canvas/ContextMenu.d.ts +6 -0
  18. package/dist/types/client/canvas/EdgeLayer.d.ts +2 -1
  19. package/dist/types/client/canvas/EmptyState.d.ts +11 -0
  20. package/dist/types/client/canvas/ExternalWriters.d.ts +5 -0
  21. package/dist/types/client/canvas/HumanPresenceLayer.d.ts +8 -0
  22. package/dist/types/client/canvas/Minimap.d.ts +12 -1
  23. package/dist/types/client/canvas/ScopeFenceLayer.d.ts +8 -0
  24. package/dist/types/client/canvas/SelectionBar.d.ts +6 -0
  25. package/dist/types/client/canvas/SessionPanel.d.ts +1 -0
  26. package/dist/types/client/canvas/SessionReceipt.d.ts +17 -0
  27. package/dist/types/client/canvas/TextPrompt.d.ts +25 -0
  28. package/dist/types/client/canvas/ToolRail.d.ts +21 -0
  29. package/dist/types/client/canvas/TopBar.d.ts +7 -0
  30. package/dist/types/client/canvas/canvas-area.d.ts +39 -0
  31. package/dist/types/client/canvas/create-in-view.d.ts +18 -0
  32. package/dist/types/client/canvas/import-files.d.ts +8 -0
  33. package/dist/types/client/canvas/kind-colors.d.ts +3 -0
  34. package/dist/types/client/canvas/use-focus-trap.d.ts +14 -0
  35. package/dist/types/client/canvas/use-now.d.ts +6 -0
  36. package/dist/types/client/icons.d.ts +11 -4
  37. package/dist/types/client/nodes/ExtAppFrame.d.ts +2 -0
  38. package/dist/types/client/nodes/GroupNode.d.ts +6 -1
  39. package/dist/types/client/nodes/MarkdownNode.d.ts +2 -0
  40. package/dist/types/client/nodes/inline-editor-commands.d.ts +3 -4
  41. package/dist/types/client/state/canvas-store.d.ts +49 -5
  42. package/dist/types/client/state/human-store.d.ts +26 -0
  43. package/dist/types/client/state/iframe-mode.d.ts +1 -0
  44. package/dist/types/client/state/intent-bridge.d.ts +37 -5
  45. package/dist/types/client/state/node-factory.d.ts +0 -1
  46. package/dist/types/client/state/presence-store.d.ts +78 -0
  47. package/dist/types/client/state/session-store.d.ts +176 -0
  48. package/dist/types/client/types.d.ts +2 -1
  49. package/dist/types/client/utils/platform.d.ts +2 -0
  50. package/dist/types/server/agent-presence.d.ts +192 -0
  51. package/dist/types/server/ax-gate-ttl.d.ts +6 -0
  52. package/dist/types/server/ax-interaction.d.ts +4 -10
  53. package/dist/types/server/ax-state-manager.d.ts +21 -2
  54. package/dist/types/server/ax-state.d.ts +49 -3
  55. package/dist/types/server/ax-wait.d.ts +13 -0
  56. package/dist/types/server/canvas-db.d.ts +9 -1
  57. package/dist/types/server/canvas-operations.d.ts +11 -0
  58. package/dist/types/server/canvas-state.d.ts +27 -13
  59. package/dist/types/server/canvas-validation.d.ts +0 -14
  60. package/dist/types/server/html-surface.d.ts +1 -1
  61. package/dist/types/server/human-presence.d.ts +40 -0
  62. package/dist/types/server/index.d.ts +29 -3
  63. package/dist/types/server/intent-registry.d.ts +7 -0
  64. package/dist/types/server/mutation-history.d.ts +15 -2
  65. package/dist/types/server/operations/invoker.d.ts +13 -2
  66. package/dist/types/server/operations/ops/ax-presence.d.ts +2 -0
  67. package/dist/types/server/operations/ops/human-presence.d.ts +3 -0
  68. package/dist/types/server/operations/registry.d.ts +11 -0
  69. package/dist/types/server/operations/types.d.ts +1 -1
  70. package/dist/types/server/scope-fence.d.ts +62 -0
  71. package/dist/types/server/server.d.ts +0 -12
  72. package/dist/types/shared/agent-presence.d.ts +130 -0
  73. package/dist/types/shared/approval-gates.d.ts +19 -0
  74. package/dist/types/shared/auto-arrange.d.ts +0 -1
  75. package/dist/types/shared/ax-kinds.d.ts +11 -0
  76. package/dist/types/shared/human-presence.d.ts +31 -0
  77. package/dist/types/shared/iframe-probe.d.ts +8 -0
  78. package/dist/types/shared/scope-fence.d.ts +21 -0
  79. package/docs/RELEASE.md +20 -0
  80. package/docs/ax-host-adapter-contract.md +30 -14
  81. package/docs/cli.md +37 -1
  82. package/docs/environment.md +3 -0
  83. package/docs/http-api.md +222 -5
  84. package/docs/mcp.md +6 -5
  85. package/docs/node-types.md +3 -1
  86. package/docs/plans/plan-009-tech-debt-backlog.md +13 -0
  87. package/docs/screenshot.png +0 -0
  88. package/docs/screenshots/multi-agent-live.png +0 -0
  89. package/docs/screenshots/workbench-live-dark.png +0 -0
  90. package/docs/screenshots/workbench-live-light.png +0 -0
  91. package/docs/sdk.md +1 -1
  92. package/package.json +4 -4
  93. package/skills/pmx-canvas/SKILL.md +112 -17
  94. package/skills/pmx-canvas/references/codex-app-adapter.md +28 -0
  95. package/skills/pmx-canvas/references/full-reference.md +55 -16
  96. package/skills/pmx-canvas/references/github-copilot-app-adapter.md +41 -24
  97. package/skills/pmx-canvas-orchestration/SKILL.md +56 -2
  98. package/skills/pmx-canvas-testing/SKILL.md +17 -0
  99. package/src/cli/agent.ts +1 -0
  100. package/src/cli/commands/copilot.ts +15 -8
  101. package/src/cli/commands/nodes.ts +1 -19
  102. package/src/cli/commands/pump.ts +263 -0
  103. package/src/cli/index.ts +2 -5
  104. package/src/cli/shared.ts +1 -1
  105. package/src/client/App.tsx +209 -585
  106. package/src/client/canvas/AgentPresenceLayer.tsx +74 -0
  107. package/src/client/canvas/AttentionHistory.tsx +0 -10
  108. package/src/client/canvas/BarHint.tsx +49 -0
  109. package/src/client/canvas/CanvasNode.tsx +488 -136
  110. package/src/client/canvas/CanvasViewport.tsx +190 -94
  111. package/src/client/canvas/CommandBar.tsx +140 -0
  112. package/src/client/canvas/CommandPalette.tsx +133 -58
  113. package/src/client/canvas/ConnectionBanner.tsx +65 -0
  114. package/src/client/canvas/ContextMenu.tsx +88 -27
  115. package/src/client/canvas/ContextPinBar.tsx +2 -2
  116. package/src/client/canvas/EdgeLayer.tsx +121 -38
  117. package/src/client/canvas/EmptyState.tsx +93 -0
  118. package/src/client/canvas/ExpandedNodeOverlay.tsx +64 -118
  119. package/src/client/canvas/ExternalWriters.tsx +308 -0
  120. package/src/client/canvas/FocusFieldLayer.tsx +12 -1
  121. package/src/client/canvas/HumanPresenceLayer.tsx +45 -0
  122. package/src/client/canvas/IntentLayer.tsx +1 -1
  123. package/src/client/canvas/Minimap.tsx +117 -190
  124. package/src/client/canvas/ScopeFenceLayer.tsx +38 -0
  125. package/src/client/canvas/SelectionBar.tsx +175 -23
  126. package/src/client/canvas/SessionPanel.tsx +481 -0
  127. package/src/client/canvas/SessionReceipt.tsx +141 -0
  128. package/src/client/canvas/ShortcutOverlay.tsx +21 -6
  129. package/src/client/canvas/SnapshotPanel.tsx +166 -80
  130. package/src/client/canvas/TextPrompt.tsx +128 -0
  131. package/src/client/canvas/ToolRail.tsx +447 -0
  132. package/src/client/canvas/TopBar.tsx +308 -0
  133. package/src/client/canvas/auto-fit.ts +1 -5
  134. package/src/client/canvas/canvas-area.ts +74 -0
  135. package/src/client/canvas/create-in-view.ts +48 -0
  136. package/src/client/canvas/import-files.ts +64 -0
  137. package/src/client/canvas/kind-colors.ts +22 -0
  138. package/src/client/canvas/snap-guides.ts +1 -1
  139. package/src/client/canvas/use-focus-trap.ts +54 -0
  140. package/src/client/canvas/use-now.ts +17 -0
  141. package/src/client/canvas/use-pan-zoom.ts +13 -2
  142. package/src/client/icons.tsx +57 -18
  143. package/src/client/nodes/ExtAppFrame.tsx +86 -20
  144. package/src/client/nodes/GroupNode.tsx +9 -28
  145. package/src/client/nodes/InlineFormatBar.tsx +4 -3
  146. package/src/client/nodes/InlineMarkdownEditor.tsx +27 -0
  147. package/src/client/nodes/MarkdownNode.tsx +59 -24
  148. package/src/client/nodes/MdFormatBar.tsx +2 -4
  149. package/src/client/nodes/inline-editor-commands.ts +17 -6
  150. package/src/client/nodes/use-surface-frame.ts +3 -1
  151. package/src/client/state/attention-bridge.ts +3 -0
  152. package/src/client/state/canvas-store.ts +285 -90
  153. package/src/client/state/human-store.ts +175 -0
  154. package/src/client/state/iframe-mode.ts +33 -9
  155. package/src/client/state/intent-bridge.ts +75 -13
  156. package/src/client/state/node-factory.ts +2 -8
  157. package/src/client/state/presence-store.ts +168 -0
  158. package/src/client/state/session-store.ts +419 -0
  159. package/src/client/state/sse-bridge.ts +93 -30
  160. package/src/client/theme/global.css +3071 -796
  161. package/src/client/types.ts +3 -1
  162. package/src/client/utils/platform.ts +6 -1
  163. package/src/mcp/canvas-access.ts +2 -2
  164. package/src/server/agent-presence.ts +755 -0
  165. package/src/server/ax-context.ts +45 -0
  166. package/src/server/ax-gate-ttl.ts +61 -0
  167. package/src/server/ax-interaction.ts +29 -1
  168. package/src/server/ax-state-manager.ts +78 -6
  169. package/src/server/ax-state.ts +82 -15
  170. package/src/server/ax-wait.ts +33 -0
  171. package/src/server/canvas-db.ts +103 -33
  172. package/src/server/canvas-operations.ts +58 -14
  173. package/src/server/canvas-state.ts +131 -58
  174. package/src/server/canvas-validation.ts +3 -53
  175. package/src/server/demo-state.json +2 -70
  176. package/src/server/html-surface.ts +11 -2
  177. package/src/server/human-presence.ts +160 -0
  178. package/src/server/index.ts +111 -6
  179. package/src/server/intent-registry.ts +13 -0
  180. package/src/server/mutation-history.ts +47 -2
  181. package/src/server/operations/composites.ts +6 -3
  182. package/src/server/operations/http.ts +10 -1
  183. package/src/server/operations/index.ts +4 -0
  184. package/src/server/operations/invoker.ts +33 -6
  185. package/src/server/operations/ops/ax-presence.ts +62 -0
  186. package/src/server/operations/ops/ax-read.ts +8 -0
  187. package/src/server/operations/ops/ax-state.ts +106 -1
  188. package/src/server/operations/ops/ax-timeline.ts +54 -5
  189. package/src/server/operations/ops/ax-work.ts +46 -0
  190. package/src/server/operations/ops/batch.ts +3 -1
  191. package/src/server/operations/ops/edges.ts +88 -2
  192. package/src/server/operations/ops/groups.ts +4 -3
  193. package/src/server/operations/ops/human-presence.ts +35 -0
  194. package/src/server/operations/ops/json-render.ts +11 -1
  195. package/src/server/operations/ops/nodes.ts +77 -16
  196. package/src/server/operations/ops/query.ts +1 -0
  197. package/src/server/operations/ops/snapshots.ts +7 -4
  198. package/src/server/operations/registry.ts +196 -1
  199. package/src/server/operations/types.ts +1 -1
  200. package/src/server/scope-fence.ts +207 -0
  201. package/src/server/server.ts +177 -79
  202. package/src/server/trace-manager.ts +0 -2
  203. package/src/server/web-artifacts.ts +0 -1
  204. package/src/shared/agent-presence.ts +162 -0
  205. package/src/shared/approval-gates.ts +35 -0
  206. package/src/shared/auto-arrange.ts +121 -13
  207. package/src/shared/ax-kinds.ts +23 -0
  208. package/src/shared/human-presence.ts +30 -0
  209. package/src/shared/iframe-probe.ts +8 -0
  210. package/src/shared/scope-fence.ts +31 -0
  211. package/dist/types/client/canvas/DockedNode.d.ts +0 -4
  212. package/src/client/canvas/DockedNode.tsx +0 -165
@@ -1,5 +1,6 @@
1
1
  import { buildAgentContextPreamble, serializeNodeForAgentContext } from './agent-context.js';
2
2
  import {
3
+ type PmxAxHumanAnnotation,
3
4
  buildAxContext,
4
5
  buildPendingAxActivity,
5
6
  AX_CONTEXT_STEERING_LIMIT,
@@ -12,6 +13,7 @@ import {
12
13
  type PmxAxModeRequest,
13
14
  type PmxAxPolicy,
14
15
  } from './ax-state.js';
16
+ import type { CanvasLayout } from './canvas-state.js';
15
17
  import { canvasState, type CanvasNodeState } from './canvas-state.js';
16
18
 
17
19
  /**
@@ -75,6 +77,48 @@ export function buildCanvasAxPinnedContext(): PmxAxPinnedContext {
75
77
  };
76
78
  }
77
79
 
80
+ /**
81
+ * Human ink → agent context: each annotation, anchored to the nodes whose
82
+ * padded frame it overlaps. Without this the human's strokes and notes were
83
+ * invisible to every agent-facing surface — the annotate tool IS the human
84
+ * writing to the agent (the pins principle, in ink).
85
+ */
86
+ function buildHumanAnnotations(layout: CanvasLayout): PmxAxHumanAnnotation[] {
87
+ const PAD = 40;
88
+ return canvasState.getAnnotations().map((annotation) => {
89
+ const xs = annotation.points.map((point) => point.x);
90
+ const ys = annotation.points.map((point) => point.y);
91
+ const center = xs.length
92
+ ? {
93
+ x: Math.round((Math.min(...xs) + Math.max(...xs)) / 2),
94
+ y: Math.round((Math.min(...ys) + Math.max(...ys)) / 2),
95
+ }
96
+ : { x: 0, y: 0 };
97
+ const onNodes = layout.nodes
98
+ .filter((node) => {
99
+ const left = node.position.x - PAD;
100
+ const top = node.position.y - PAD;
101
+ const right = node.position.x + node.size.width + PAD;
102
+ const bottom = node.position.y + node.size.height + PAD;
103
+ return (
104
+ xs.length > 0 &&
105
+ Math.min(...xs) <= right &&
106
+ Math.max(...xs) >= left &&
107
+ Math.min(...ys) <= bottom &&
108
+ Math.max(...ys) >= top
109
+ );
110
+ })
111
+ .map((node) => ({ id: node.id, title: typeof node.data.title === 'string' ? node.data.title : null }));
112
+ const text =
113
+ typeof annotation.text === 'string' && annotation.text
114
+ ? annotation.text
115
+ : typeof annotation.label === 'string' && annotation.label
116
+ ? annotation.label
117
+ : null;
118
+ return { id: annotation.id, type: annotation.type, text, onNodes, center };
119
+ });
120
+ }
121
+
78
122
  export function buildCanvasAxContext(consumer?: string): PmxAxContext {
79
123
  const layout = canvasState.getLayout();
80
124
  const ax = canvasState.getAxState();
@@ -100,6 +144,7 @@ export function buildCanvasAxContext(consumer?: string): PmxAxContext {
100
144
  workItems: ax.workItems,
101
145
  approvalGates: ax.approvalGates,
102
146
  reviewAnnotations: ax.reviewAnnotations,
147
+ humanAnnotations: buildHumanAnnotations(layout),
103
148
  elicitations: ax.elicitations,
104
149
  modeRequests: ax.modeRequests,
105
150
  policy: ax.policy,
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Unattended-approval sweeper (rail-chrome-v2 phase 4, design item 3).
3
+ *
4
+ * A pending gate the human has not answered by `expiresAt` resolves to
5
+ * `held` — the safe default: the awaiting agent is released with a
6
+ * non-approval, the action does NOT proceed, and a `policy` event lands in
7
+ * the timeline so both sides can see why. The gate can be reopened from the
8
+ * session panel. Same discipline as IntentRegistry's sweeper: a 1s unref'd
9
+ * interval that only runs while a server is up, emitting through an injected
10
+ * workbench emitter so this module never imports server.ts.
11
+ */
12
+ import { formatCountdown } from '../shared/approval-gates.js';
13
+ import { canvasState } from './canvas-state.js';
14
+
15
+ type GateEmitter = (event: string, payload: Record<string, unknown>) => void;
16
+
17
+ let sweepTimer: ReturnType<typeof setInterval> | null = null;
18
+ let emit: GateEmitter = () => {};
19
+
20
+ /** Hold every pending gate whose TTL has elapsed. Returns the gates it held. */
21
+ export function sweepExpiredGates(now = Date.now()): string[] {
22
+ const held: string[] = [];
23
+ for (const gate of canvasState.getAxState().approvalGates) {
24
+ if (gate.status !== 'pending' || !gate.expiresAt) continue;
25
+ const expires = Date.parse(gate.expiresAt);
26
+ if (!Number.isFinite(expires) || expires > now) continue;
27
+ const ttl = Math.max(0, expires - Date.parse(gate.createdAt));
28
+ const resolved = canvasState.resolveApproval(gate.id, 'held', {
29
+ resolution: `auto-held: no answer within ${formatCountdown(ttl)}`,
30
+ source: 'system',
31
+ });
32
+ if (!resolved) continue;
33
+ held.push(gate.id);
34
+ const event = canvasState.recordAxEvent(
35
+ {
36
+ kind: 'policy',
37
+ summary: `Auto-held "${gate.title}" — no answer within ${formatCountdown(ttl)}; the action did not proceed.`,
38
+ detail: 'Reopen it from the session panel to answer.',
39
+ nodeIds: gate.nodeIds,
40
+ data: { gateId: gate.id, policy: 'unattended-approval', outcome: 'held' },
41
+ },
42
+ { source: 'system' },
43
+ );
44
+ emit('ax-state-changed', { approvalGate: resolved });
45
+ emit('ax-event-created', { event });
46
+ }
47
+ return held;
48
+ }
49
+
50
+ export function startGateTtlSweeper(emitter: GateEmitter): void {
51
+ emit = emitter;
52
+ if (sweepTimer) return;
53
+ sweepTimer = setInterval(() => sweepExpiredGates(), 1000);
54
+ (sweepTimer as { unref?: () => void }).unref?.();
55
+ }
56
+
57
+ export function stopGateTtlSweeper(): void {
58
+ if (sweepTimer) clearInterval(sweepTimer);
59
+ sweepTimer = null;
60
+ emit = () => {};
61
+ }
@@ -626,7 +626,6 @@ function materializeFlow(
626
626
  zIndex: 1,
627
627
  collapsed: false,
628
628
  pinned: false,
629
- dockPosition: null,
630
629
  data: nodeData,
631
630
  });
632
631
  const workItem = manager.addWorkItem(
@@ -720,6 +719,28 @@ function materializeFlow(
720
719
  * underlying AX state event). Never throws on bad input — returns an `ok: false`
721
720
  * result with an appropriate HTTP-ish status.
722
721
  */
722
+ // ── ax.work.create idempotency window ─────────────
723
+ const WORK_CREATE_DEDUPE_MS = 2_500;
724
+ const recentWorkCreates = new Map<string, { item: PmxAxWorkItem; at: number }>();
725
+
726
+ function recentWorkCreate(sourceNodeId: string, title: string): PmxAxWorkItem | null {
727
+ const key = `${sourceNodeId}\u0000${title}`;
728
+ const hit = recentWorkCreates.get(key);
729
+ if (hit && Date.now() - hit.at <= WORK_CREATE_DEDUPE_MS) return hit.item;
730
+ return null;
731
+ }
732
+
733
+ function rememberWorkCreate(sourceNodeId: string, title: string, item: PmxAxWorkItem): void {
734
+ const key = `${sourceNodeId}\u0000${title}`;
735
+ recentWorkCreates.set(key, { item, at: Date.now() });
736
+ if (recentWorkCreates.size > 64) {
737
+ const now = Date.now();
738
+ for (const [k, v] of recentWorkCreates) {
739
+ if (now - v.at > WORK_CREATE_DEDUPE_MS) recentWorkCreates.delete(k);
740
+ }
741
+ }
742
+ }
743
+
723
744
  export function applyAxInteraction(
724
745
  manager: AxInteractionManager,
725
746
  rawBody: unknown,
@@ -825,6 +846,12 @@ export function applyAxInteraction(
825
846
  detail?: string | null;
826
847
  nodeIds?: string[];
827
848
  };
849
+ // Idempotency window (joint-gaps bug: a json-render button double-emit
850
+ // booked two identical work items ~1s apart): the SAME node creating a
851
+ // work item with the SAME title inside the window gets the first item
852
+ // back instead of a duplicate.
853
+ const dupe = recentWorkCreate(sourceNodeId, p.title);
854
+ if (dupe) return accept(type, sourceNodeId, dupe, 'ax-state-changed', { workItem: dupe });
828
855
  const workItem = manager.addWorkItem(
829
856
  {
830
857
  title: p.title,
@@ -834,6 +861,7 @@ export function applyAxInteraction(
834
861
  },
835
862
  opts,
836
863
  );
864
+ rememberWorkCreate(sourceNodeId, p.title, workItem);
837
865
  return withLayoutBroadcast(accept(type, sourceNodeId, workItem, 'ax-state-changed', { workItem }), manager, [
838
866
  workItem,
839
867
  ]);
@@ -21,6 +21,7 @@
21
21
  * `recordMutation`, and a `suppressed` wrapper for history closures.
22
22
  */
23
23
 
24
+ import { clampGateTtlMs } from '../shared/approval-gates.js';
24
25
  import {
25
26
  appendAxEventToDB,
26
27
  appendAxEvidenceToDB,
@@ -39,6 +40,7 @@ import {
39
40
  } from './canvas-db.js';
40
41
  import {
41
42
  createEmptyAxState,
43
+ defaultGateTtlMs,
42
44
  createEmptyAxHostCapability,
43
45
  normalizeAxState,
44
46
  normalizeAxHostCapability,
@@ -141,7 +143,20 @@ export class AxStateManager {
141
143
 
142
144
  /** Replace the canvas-bound partition from a persisted/restored blob, normalized against current nodes. */
143
145
  applyPersistedAx(ax: unknown): void {
144
- this._axState = this.normalizeForCurrentNodes(ax);
146
+ const normalized = this.normalizeForCurrentNodes(ax);
147
+ // A pending gate restored from a snapshot (or loaded on boot) may carry an
148
+ // already-elapsed TTL; holding it on the next sweeper tick would be safe
149
+ // but would rob the human who just restored it of any window to answer.
150
+ // Restart the clock from now.
151
+ const now = Date.now();
152
+ this._axState = {
153
+ ...normalized,
154
+ approvalGates: normalized.approvalGates.map((gate) =>
155
+ gate.status === 'pending' && (!gate.expiresAt || Date.parse(gate.expiresAt) <= now)
156
+ ? { ...gate, expiresAt: new Date(now + defaultGateTtlMs()).toISOString() }
157
+ : gate,
158
+ ),
159
+ };
145
160
  }
146
161
 
147
162
  /** Load the host-capability row from its own table (own partition; not snapshotted). */
@@ -323,7 +338,7 @@ export class AxStateManager {
323
338
  }
324
339
 
325
340
  requestApproval(
326
- input: { title: string; detail?: string | null; action?: string | null; nodeIds?: string[] },
341
+ input: { title: string; detail?: string | null; action?: string | null; nodeIds?: string[]; ttlMs?: number },
327
342
  options: { source?: PmxAxSource } = {},
328
343
  ): PmxAxApprovalGate {
329
344
  const oldAxState = this.getAxState();
@@ -351,7 +366,7 @@ export class AxStateManager {
351
366
 
352
367
  resolveApproval(
353
368
  id: string,
354
- decision: 'approved' | 'rejected',
369
+ decision: 'approved' | 'rejected' | 'held',
355
370
  options: { resolution?: string; source?: PmxAxSource } = {},
356
371
  ): PmxAxApprovalGate | null {
357
372
  const oldAxState = this.getAxState();
@@ -385,6 +400,43 @@ export class AxStateManager {
385
400
  return applied.approvalGates.find((g) => g.id === id) ?? null;
386
401
  }
387
402
 
403
+ /**
404
+ * Reopen a resolved gate (typically one the unattended-approval policy
405
+ * auto-held): back to pending with a fresh TTL, so the human can answer it.
406
+ */
407
+ reopenApproval(id: string, options: { ttlMs?: number; source?: PmxAxSource } = {}): PmxAxApprovalGate | null {
408
+ const oldAxState = this.getAxState();
409
+ const gate = oldAxState.approvalGates.find((g) => g.id === id);
410
+ if (!gate || gate.status === 'pending') return null;
411
+ const reopened: PmxAxApprovalGate = {
412
+ ...gate,
413
+ status: 'pending',
414
+ expiresAt: new Date(Date.now() + clampGateTtlMs(options.ttlMs, defaultGateTtlMs())).toISOString(),
415
+ resolvedAt: null,
416
+ resolution: null,
417
+ source: options.source ?? gate.source,
418
+ };
419
+ this.applyAxState({ ...oldAxState, approvalGates: replaceById(oldAxState.approvalGates, reopened) });
420
+ const applied = this.getAxState();
421
+ this.deps.scheduleSave();
422
+ this.deps.notifyChange('ax');
423
+ this.deps.recordMutation({
424
+ operationType: 'resolveApproval',
425
+ description: `Reopened approval ${id}`,
426
+ forward: this.deps.suppressed(() => {
427
+ this.applyAxState(applied);
428
+ this.deps.scheduleSave();
429
+ this.deps.notifyChange('ax');
430
+ }),
431
+ inverse: this.deps.suppressed(() => {
432
+ this.applyAxState(oldAxState);
433
+ this.deps.scheduleSave();
434
+ this.deps.notifyChange('ax');
435
+ }),
436
+ });
437
+ return applied.approvalGates.find((g) => g.id === id) ?? null;
438
+ }
439
+
388
440
  // ── Review annotations (canvas-bound) ─────────────────────────────
389
441
  getReviewAnnotations(): PmxAxReviewAnnotation[] {
390
442
  return this.getAxState().reviewAnnotations;
@@ -660,13 +712,33 @@ export class AxStateManager {
660
712
 
661
713
  /** Merge a declarative tool/prompt policy patch (canvas-bound, snapshotted). */
662
714
  setPolicy(
663
- patch: { tools?: Partial<PmxAxPolicy['tools']>; prompt?: Partial<PmxAxPolicy['prompt']> },
715
+ patch: {
716
+ tools?: Partial<PmxAxPolicy['tools']>;
717
+ prompt?: Partial<PmxAxPolicy['prompt']>;
718
+ /** Replace semantics: an object sets the fence, `null` clears it, absent leaves it. */
719
+ scope?: { nodeIds: string[]; padding?: number } | null;
720
+ /** Per-key merge: an object sets that agent's territory, `null` clears it, absent keys keep theirs. */
721
+ agentScopes?: Record<string, { nodeIds: string[]; padding?: number } | null>;
722
+ },
664
723
  _options: { source?: PmxAxSource } = {},
665
724
  ): PmxAxPolicy {
666
725
  const oldAxState = this.getAxState();
726
+ const nodeIds = this.deps.getNodeIds();
727
+ const mergedAgentScopes: Record<string, unknown> = { ...oldAxState.policy.agentScopes };
728
+ for (const [key, fence] of Object.entries(patch.agentScopes ?? {})) {
729
+ if (fence === null) delete mergedAgentScopes[key];
730
+ else mergedAgentScopes[key] = { ...fence, nodeIds: fence.nodeIds.filter((id) => nodeIds.has(id)) };
731
+ }
667
732
  const merged = normalizeAxPolicy({
668
733
  tools: { ...oldAxState.policy.tools, ...(patch.tools ?? {}) },
669
734
  prompt: { ...oldAxState.policy.prompt, ...(patch.prompt ?? {}) },
735
+ scope:
736
+ patch.scope === undefined
737
+ ? oldAxState.policy.scope
738
+ : patch.scope === null
739
+ ? null
740
+ : { ...patch.scope, nodeIds: patch.scope.nodeIds.filter((id) => nodeIds.has(id)) },
741
+ agentScopes: mergedAgentScopes,
670
742
  });
671
743
  this.applyAxState({ ...oldAxState, policy: merged });
672
744
  const applied = this.getAxState();
@@ -783,11 +855,11 @@ export class AxStateManager {
783
855
  return { ...draft, seq: 0 };
784
856
  }
785
857
 
786
- markSteeringDelivered(id: string): boolean {
858
+ markSteeringDelivered(id: string, consumer?: string | null): boolean {
787
859
  const db = this.deps.getDb();
788
860
  if (!db) return false;
789
861
  try {
790
- const ok = markAxSteeringDeliveredInDB(db, id);
862
+ const ok = markAxSteeringDeliveredInDB(db, id, consumer);
791
863
  if (ok) this.deps.notifyChange('ax-timeline');
792
864
  return ok;
793
865
  } catch (error) {
@@ -1,4 +1,6 @@
1
1
  import type { CanvasLayout, CanvasNodeState } from './canvas-state.js';
2
+ import { clampGateTtlMs, DEFAULT_GATE_TTL_MS } from '../shared/approval-gates.js';
3
+ import type { AxApprovalStatus, AxEventKind, AxWorkItemStatus } from '../shared/ax-kinds.js';
2
4
  import type { AgentContextNode } from './agent-context.js';
3
5
 
4
6
  export type PmxAxSource = 'agent' | 'amp' | 'api' | 'browser' | 'cli' | 'codex' | 'copilot' | 'mcp' | 'sdk' | 'system';
@@ -11,19 +13,10 @@ export interface PmxAxFocusState {
11
13
  }
12
14
 
13
15
  // ── New enums ──────────────────────────────────────────────────────
14
- export type PmxAxEventKind =
15
- | 'prompt'
16
- | 'assistant-message'
17
- | 'tool-start'
18
- | 'tool-result'
19
- | 'failure'
20
- | 'approval'
21
- | 'steering'
22
- | 'command'
23
- | 'note';
16
+ export type PmxAxEventKind = AxEventKind;
24
17
  export type PmxAxEvidenceKind = 'logs' | 'tool-result' | 'screenshot' | 'file' | 'diff' | 'test-output';
25
- export type PmxAxWorkItemStatus = 'todo' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
26
- export type PmxAxApprovalStatus = 'pending' | 'approved' | 'rejected';
18
+ export type PmxAxWorkItemStatus = AxWorkItemStatus;
19
+ export type PmxAxApprovalStatus = AxApprovalStatus;
27
20
  export type PmxAxReviewKind = 'comment' | 'finding';
28
21
  export type PmxAxReviewSeverity = 'info' | 'warning' | 'error';
29
22
  export type PmxAxReviewStatus = 'open' | 'resolved' | 'dismissed';
@@ -50,6 +43,8 @@ export interface PmxAxApprovalGate {
50
43
  status: PmxAxApprovalStatus;
51
44
  nodeIds: string[];
52
45
  createdAt: string;
46
+ /** When a still-pending gate auto-holds (unattended-approval policy). */
47
+ expiresAt: string | null;
53
48
  resolvedAt: string | null;
54
49
  resolution: string | null;
55
50
  source: PmxAxSource | null;
@@ -109,6 +104,8 @@ export interface PmxAxSteeringMessage {
109
104
  seq: number;
110
105
  message: string;
111
106
  delivered: boolean;
107
+ /** Broadcast pickups: which consumers marked this steer (per-consumer delivery). */
108
+ deliveredTo?: string[];
112
109
  createdAt: string;
113
110
  source: PmxAxSource | null;
114
111
  agentId: string | null;
@@ -192,6 +189,17 @@ export interface PmxAxDeliveryContext {
192
189
  pendingActivity: PendingAxActivityItem[];
193
190
  }
194
191
 
192
+ /** Human ink on the board — freehand strokes and text notes, anchored to the nodes they overlap. */
193
+ export interface PmxAxHumanAnnotation {
194
+ id: string;
195
+ type: string;
196
+ /** The human's words, when this is a text note (or a labelled stroke). */
197
+ text: string | null;
198
+ /** Nodes whose padded frame the annotation overlaps — what it is "on". */
199
+ onNodes: Array<{ id: string; title: string | null }>;
200
+ center: { x: number; y: number };
201
+ }
202
+
195
203
  export interface PmxAxContext {
196
204
  version: 1;
197
205
  generatedAt: string;
@@ -208,6 +216,8 @@ export interface PmxAxContext {
208
216
  workItems: PmxAxWorkItem[];
209
217
  approvalGates: PmxAxApprovalGate[];
210
218
  reviewAnnotations: PmxAxReviewAnnotation[];
219
+ /** What the human drew/wrote on the board (the annotate tool) — not agent review comments. */
220
+ humanAnnotations: PmxAxHumanAnnotation[];
211
221
  elicitations: PmxAxElicitation[];
212
222
  modeRequests: PmxAxModeRequest[];
213
223
  policy: PmxAxPolicy;
@@ -322,6 +332,8 @@ const AX_EVENT_KINDS = new Set<PmxAxEventKind>([
322
332
  'steering',
323
333
  'command',
324
334
  'note',
335
+ 'policy',
336
+ 'yield',
325
337
  ]);
326
338
 
327
339
  // ── Activity ingestion (harness-forwarded tool/session events) ─────
@@ -396,15 +408,47 @@ export function listAxCommands(): PmxAxCommandDescriptor[] {
396
408
  // Declarative policy PMX stores and exposes; host adapters READ and enforce it.
397
409
  // PMX does not mutate host prompts or block tools itself. Default = empty (no
398
410
  // restrictions) — that is the conservative answer to "how much by default".
411
+ /**
412
+ * Scope fence (rail-chrome-v2 design item 4): the region an attached agent
413
+ * may WRITE to — the fenced node set plus `padding` px around its bounding
414
+ * box for new nodes. Reads are never fenced. `null` = unscoped.
415
+ */
416
+ export interface PmxAxScopeFence {
417
+ nodeIds: string[];
418
+ padding: number;
419
+ }
420
+
421
+ export const DEFAULT_SCOPE_FENCE_PADDING = 40;
422
+
399
423
  export interface PmxAxPolicy {
400
424
  tools: { allowed: string[]; excluded: string[]; approvalRequired: string[] };
401
425
  prompt: { systemAppend: string | null; mode: string | null };
426
+ scope: PmxAxScopeFence | null;
427
+ /**
428
+ * Per-agent territories (fleet orchestration): writer key (agentId when the
429
+ * write carries one, else its source label) → fence. Overrides `scope` for
430
+ * that writer; agents without an entry fall under the global fence.
431
+ */
432
+ agentScopes: Record<string, PmxAxScopeFence>;
402
433
  }
403
434
 
404
435
  export function createEmptyAxPolicy(): PmxAxPolicy {
405
436
  return {
406
437
  tools: { allowed: [], excluded: [], approvalRequired: [] },
407
438
  prompt: { systemAppend: null, mode: null },
439
+ scope: null,
440
+ agentScopes: {},
441
+ };
442
+ }
443
+
444
+ export function normalizeAxScopeFence(input: unknown): PmxAxScopeFence | null {
445
+ if (!isRecord(input)) return null;
446
+ const nodeIds = normalizeStringArray(input.nodeIds);
447
+ if (nodeIds.length === 0) return null;
448
+ const padding = Number(input.padding);
449
+ return {
450
+ nodeIds,
451
+ padding: Number.isFinite(padding) && padding >= 0 ? Math.min(400, padding) : DEFAULT_SCOPE_FENCE_PADDING,
408
452
  };
409
453
  }
410
454
 
@@ -426,8 +470,21 @@ export function normalizeAxPolicy(input: unknown): PmxAxPolicy {
426
470
  systemAppend: optionalString(prompt.systemAppend),
427
471
  mode: optionalString(prompt.mode),
428
472
  },
473
+ scope: normalizeAxScopeFence(input.scope),
474
+ agentScopes: normalizeAxAgentScopes(input.agentScopes),
429
475
  };
430
476
  }
477
+
478
+ export function normalizeAxAgentScopes(input: unknown): Record<string, PmxAxScopeFence> {
479
+ if (!isRecord(input)) return {};
480
+ const out: Record<string, PmxAxScopeFence> = {};
481
+ for (const [key, value] of Object.entries(input)) {
482
+ if (!key.trim()) continue;
483
+ const fence = normalizeAxScopeFence(value);
484
+ if (fence) out[key.trim()] = fence;
485
+ }
486
+ return out;
487
+ }
431
488
  const AX_EVIDENCE_KINDS = new Set<PmxAxEvidenceKind>([
432
489
  'logs',
433
490
  'tool-result',
@@ -437,7 +494,7 @@ const AX_EVIDENCE_KINDS = new Set<PmxAxEvidenceKind>([
437
494
  'test-output',
438
495
  ]);
439
496
  const AX_WORK_STATUSES = new Set<PmxAxWorkItemStatus>(['todo', 'in-progress', 'blocked', 'done', 'cancelled']);
440
- const AX_APPROVAL_STATUSES = new Set<PmxAxApprovalStatus>(['pending', 'approved', 'rejected']);
497
+ const AX_APPROVAL_STATUSES = new Set<PmxAxApprovalStatus>(['pending', 'approved', 'rejected', 'held']);
441
498
  const AX_REVIEW_KINDS = new Set<PmxAxReviewKind>(['comment', 'finding']);
442
499
  const AX_REVIEW_SEVERITIES = new Set<PmxAxReviewSeverity>(['info', 'warning', 'error']);
443
500
  const AX_REVIEW_STATUSES = new Set<PmxAxReviewStatus>(['open', 'resolved', 'dismissed']);
@@ -654,6 +711,7 @@ export function normalizeAxApprovalGate(input: unknown, validNodeIds?: Set<strin
654
711
  : 'pending',
655
712
  nodeIds: normalizeNodeIds(input.nodeIds, validNodeIds),
656
713
  createdAt: normalizeTimestamp(input.createdAt) ?? nowIso(),
714
+ expiresAt: normalizeTimestamp(input.expiresAt),
657
715
  resolvedAt: normalizeTimestamp(input.resolvedAt),
658
716
  resolution: optionalString(input.resolution),
659
717
  source: normalizeSource(input.source),
@@ -795,10 +853,11 @@ export function createAxWorkItem(
795
853
  }
796
854
 
797
855
  export function createAxApprovalGate(
798
- input: { title: string; detail?: string | null; action?: string | null; nodeIds?: string[] },
856
+ input: { title: string; detail?: string | null; action?: string | null; nodeIds?: string[]; ttlMs?: number },
799
857
  source: PmxAxSource | null,
800
858
  validNodeIds?: Set<string>,
801
859
  ): PmxAxApprovalGate {
860
+ const created = Date.now();
802
861
  return {
803
862
  id: axId('appr'),
804
863
  title: input.title,
@@ -806,13 +865,19 @@ export function createAxApprovalGate(
806
865
  action: input.action ?? null,
807
866
  status: 'pending',
808
867
  nodeIds: normalizeNodeIds(input.nodeIds, validNodeIds),
809
- createdAt: nowIso(),
868
+ createdAt: new Date(created).toISOString(),
869
+ expiresAt: new Date(created + clampGateTtlMs(input.ttlMs, defaultGateTtlMs())).toISOString(),
810
870
  resolvedAt: null,
811
871
  resolution: null,
812
872
  source,
813
873
  };
814
874
  }
815
875
 
876
+ /** Server default for a gate's TTL: `PMX_CANVAS_GATE_TTL_MS`, else five minutes. */
877
+ export function defaultGateTtlMs(): number {
878
+ return clampGateTtlMs(process.env.PMX_CANVAS_GATE_TTL_MS, DEFAULT_GATE_TTL_MS);
879
+ }
880
+
816
881
  export function createAxReviewAnnotation(
817
882
  input: {
818
883
  body: string;
@@ -953,6 +1018,7 @@ export function buildAxContext(input: {
953
1018
  workItems: PmxAxWorkItem[];
954
1019
  approvalGates: PmxAxApprovalGate[];
955
1020
  reviewAnnotations: PmxAxReviewAnnotation[];
1021
+ humanAnnotations: PmxAxHumanAnnotation[];
956
1022
  elicitations: PmxAxElicitation[];
957
1023
  modeRequests: PmxAxModeRequest[];
958
1024
  policy: PmxAxPolicy;
@@ -975,6 +1041,7 @@ export function buildAxContext(input: {
975
1041
  workItems: input.workItems,
976
1042
  approvalGates: input.approvalGates,
977
1043
  reviewAnnotations: input.reviewAnnotations,
1044
+ humanAnnotations: input.humanAnnotations,
978
1045
  elicitations: input.elicitations,
979
1046
  modeRequests: input.modeRequests,
980
1047
  policy: input.policy,
@@ -54,3 +54,36 @@ export async function waitForAxResolution<T extends { status: string }>(opts: {
54
54
  opts.signal?.addEventListener('abort', onSettle, { once: true });
55
55
  });
56
56
  }
57
+
58
+ /**
59
+ * Block until `ready()` turns true after a change on `channel` (default
60
+ * 'ax-timeline'), or the timeout elapses. The steering long-poll: the gate
61
+ * waiter above is item/status-shaped; this one waits on a plain condition —
62
+ * the reactive loop for agent hosts that cannot be woken from outside (their
63
+ * model only runs while their host gives it a turn, so one call parks here
64
+ * instead of burning that turn on tight polling).
65
+ */
66
+ export async function waitForAxCondition(opts: {
67
+ ready: () => boolean;
68
+ timeoutMs: number;
69
+ channel?: string;
70
+ }): Promise<void> {
71
+ const timeoutMs = Math.max(0, Math.min(opts.timeoutMs, AX_WAIT_MAX_MS));
72
+ if (timeoutMs === 0 || opts.ready()) return;
73
+ const channel = opts.channel ?? 'ax-timeline';
74
+ await new Promise<void>((resolve) => {
75
+ let done = false;
76
+ const finish = (): void => {
77
+ if (done) return;
78
+ done = true;
79
+ clearTimeout(timer);
80
+ dispose();
81
+ resolve();
82
+ };
83
+ const check = (type: string): void => {
84
+ if (type === channel && opts.ready()) finish();
85
+ };
86
+ const timer = setTimeout(finish, timeoutMs);
87
+ const dispose = canvasState.onChange(check);
88
+ });
89
+ }