pmx-canvas 0.4.8 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/extensions/pmx-canvas/extension.mjs +154 -5
- package/.github/extensions/pmx-canvas/steering-delivery.mjs +79 -0
- package/CHANGELOG.md +139 -0
- package/Readme.md +72 -23
- package/dist/canvas/global.css +3069 -796
- package/dist/canvas/index.js +104 -104
- package/dist/json-render/index.css +1 -1
- package/dist/types/cli/agent.d.ts +1 -0
- package/dist/types/cli/commands/pump.d.ts +9 -0
- package/dist/types/client/App.d.ts +1 -1
- package/dist/types/client/canvas/AgentPresenceLayer.d.ts +11 -0
- package/dist/types/client/canvas/BarHint.d.ts +22 -0
- package/dist/types/client/canvas/CanvasNode.d.ts +5 -0
- package/dist/types/client/canvas/CanvasViewport.d.ts +4 -4
- package/dist/types/client/canvas/CommandBar.d.ts +10 -0
- package/dist/types/client/canvas/ConnectionBanner.d.ts +13 -0
- package/dist/types/client/canvas/ContextMenu.d.ts +6 -0
- package/dist/types/client/canvas/EdgeLayer.d.ts +2 -1
- package/dist/types/client/canvas/EmptyState.d.ts +11 -0
- package/dist/types/client/canvas/ExternalWriters.d.ts +5 -0
- package/dist/types/client/canvas/HumanPresenceLayer.d.ts +8 -0
- package/dist/types/client/canvas/Minimap.d.ts +12 -1
- package/dist/types/client/canvas/ScopeFenceLayer.d.ts +8 -0
- package/dist/types/client/canvas/SelectionBar.d.ts +6 -0
- package/dist/types/client/canvas/SessionPanel.d.ts +1 -0
- package/dist/types/client/canvas/SessionReceipt.d.ts +17 -0
- package/dist/types/client/canvas/TextPrompt.d.ts +25 -0
- package/dist/types/client/canvas/ToolRail.d.ts +21 -0
- package/dist/types/client/canvas/TopBar.d.ts +7 -0
- package/dist/types/client/canvas/canvas-area.d.ts +25 -0
- package/dist/types/client/canvas/create-in-view.d.ts +18 -0
- package/dist/types/client/canvas/import-files.d.ts +8 -0
- package/dist/types/client/canvas/kind-colors.d.ts +3 -0
- package/dist/types/client/canvas/use-focus-trap.d.ts +14 -0
- package/dist/types/client/canvas/use-now.d.ts +6 -0
- package/dist/types/client/icons.d.ts +11 -4
- package/dist/types/client/nodes/GroupNode.d.ts +6 -1
- package/dist/types/client/nodes/MarkdownNode.d.ts +2 -0
- package/dist/types/client/nodes/inline-editor-commands.d.ts +3 -4
- package/dist/types/client/state/canvas-store.d.ts +49 -5
- package/dist/types/client/state/human-store.d.ts +26 -0
- package/dist/types/client/state/iframe-mode.d.ts +1 -0
- package/dist/types/client/state/intent-bridge.d.ts +37 -5
- package/dist/types/client/state/node-factory.d.ts +0 -1
- package/dist/types/client/state/presence-store.d.ts +78 -0
- package/dist/types/client/state/session-store.d.ts +176 -0
- package/dist/types/client/types.d.ts +2 -1
- package/dist/types/client/utils/platform.d.ts +2 -0
- package/dist/types/server/agent-presence.d.ts +192 -0
- package/dist/types/server/ax-gate-ttl.d.ts +6 -0
- package/dist/types/server/ax-interaction.d.ts +4 -10
- package/dist/types/server/ax-state-manager.d.ts +21 -2
- package/dist/types/server/ax-state.d.ts +49 -3
- package/dist/types/server/ax-wait.d.ts +13 -0
- package/dist/types/server/canvas-db.d.ts +9 -1
- package/dist/types/server/canvas-operations.d.ts +1 -0
- package/dist/types/server/canvas-state.d.ts +27 -13
- package/dist/types/server/canvas-validation.d.ts +0 -14
- package/dist/types/server/html-surface.d.ts +1 -1
- package/dist/types/server/human-presence.d.ts +40 -0
- package/dist/types/server/index.d.ts +29 -3
- package/dist/types/server/intent-registry.d.ts +7 -0
- package/dist/types/server/mutation-history.d.ts +15 -2
- package/dist/types/server/operations/invoker.d.ts +13 -2
- package/dist/types/server/operations/ops/ax-presence.d.ts +2 -0
- package/dist/types/server/operations/ops/human-presence.d.ts +3 -0
- package/dist/types/server/operations/registry.d.ts +11 -0
- package/dist/types/server/operations/types.d.ts +1 -1
- package/dist/types/server/scope-fence.d.ts +62 -0
- package/dist/types/server/server.d.ts +0 -12
- package/dist/types/shared/agent-presence.d.ts +130 -0
- package/dist/types/shared/approval-gates.d.ts +19 -0
- package/dist/types/shared/auto-arrange.d.ts +0 -1
- package/dist/types/shared/ax-kinds.d.ts +11 -0
- package/dist/types/shared/human-presence.d.ts +31 -0
- package/dist/types/shared/iframe-probe.d.ts +8 -0
- package/dist/types/shared/scope-fence.d.ts +21 -0
- package/docs/RELEASE.md +6 -0
- package/docs/ax-host-adapter-contract.md +30 -14
- package/docs/cli.md +37 -1
- package/docs/environment.md +3 -0
- package/docs/http-api.md +222 -5
- package/docs/mcp.md +6 -5
- package/docs/node-types.md +3 -1
- package/docs/plans/plan-009-tech-debt-backlog.md +13 -0
- package/docs/screenshot.png +0 -0
- package/docs/screenshots/multi-agent-live.png +0 -0
- package/docs/screenshots/workbench-live-dark.png +0 -0
- package/docs/screenshots/workbench-live-light.png +0 -0
- package/docs/sdk.md +1 -1
- package/package.json +1 -1
- package/skills/pmx-canvas/SKILL.md +103 -16
- package/skills/pmx-canvas/references/codex-app-adapter.md +28 -0
- package/skills/pmx-canvas/references/full-reference.md +55 -16
- package/skills/pmx-canvas/references/github-copilot-app-adapter.md +41 -24
- package/skills/pmx-canvas-orchestration/SKILL.md +56 -2
- package/skills/pmx-canvas-testing/SKILL.md +17 -0
- package/src/cli/agent.ts +1 -0
- package/src/cli/commands/copilot.ts +15 -8
- package/src/cli/commands/nodes.ts +1 -19
- package/src/cli/commands/pump.ts +252 -0
- package/src/cli/index.ts +1 -5
- package/src/cli/shared.ts +1 -1
- package/src/client/App.tsx +209 -585
- package/src/client/canvas/AgentPresenceLayer.tsx +74 -0
- package/src/client/canvas/AttentionHistory.tsx +0 -10
- package/src/client/canvas/BarHint.tsx +49 -0
- package/src/client/canvas/CanvasNode.tsx +488 -136
- package/src/client/canvas/CanvasViewport.tsx +190 -94
- package/src/client/canvas/CommandBar.tsx +140 -0
- package/src/client/canvas/CommandPalette.tsx +133 -58
- package/src/client/canvas/ConnectionBanner.tsx +65 -0
- package/src/client/canvas/ContextMenu.tsx +88 -27
- package/src/client/canvas/ContextPinBar.tsx +2 -2
- package/src/client/canvas/EdgeLayer.tsx +121 -38
- package/src/client/canvas/EmptyState.tsx +93 -0
- package/src/client/canvas/ExpandedNodeOverlay.tsx +64 -118
- package/src/client/canvas/ExternalWriters.tsx +308 -0
- package/src/client/canvas/FocusFieldLayer.tsx +12 -1
- package/src/client/canvas/HumanPresenceLayer.tsx +45 -0
- package/src/client/canvas/IntentLayer.tsx +1 -1
- package/src/client/canvas/Minimap.tsx +117 -190
- package/src/client/canvas/ScopeFenceLayer.tsx +38 -0
- package/src/client/canvas/SelectionBar.tsx +175 -23
- package/src/client/canvas/SessionPanel.tsx +481 -0
- package/src/client/canvas/SessionReceipt.tsx +141 -0
- package/src/client/canvas/ShortcutOverlay.tsx +21 -6
- package/src/client/canvas/SnapshotPanel.tsx +166 -80
- package/src/client/canvas/TextPrompt.tsx +128 -0
- package/src/client/canvas/ToolRail.tsx +447 -0
- package/src/client/canvas/TopBar.tsx +308 -0
- package/src/client/canvas/auto-fit.ts +1 -5
- package/src/client/canvas/canvas-area.ts +41 -0
- package/src/client/canvas/create-in-view.ts +48 -0
- package/src/client/canvas/import-files.ts +64 -0
- package/src/client/canvas/kind-colors.ts +22 -0
- package/src/client/canvas/snap-guides.ts +1 -1
- package/src/client/canvas/use-focus-trap.ts +54 -0
- package/src/client/canvas/use-now.ts +17 -0
- package/src/client/canvas/use-pan-zoom.ts +13 -2
- package/src/client/icons.tsx +57 -18
- package/src/client/nodes/GroupNode.tsx +9 -28
- package/src/client/nodes/InlineFormatBar.tsx +4 -3
- package/src/client/nodes/InlineMarkdownEditor.tsx +27 -0
- package/src/client/nodes/MarkdownNode.tsx +59 -24
- package/src/client/nodes/MdFormatBar.tsx +2 -4
- package/src/client/nodes/inline-editor-commands.ts +17 -6
- package/src/client/nodes/use-surface-frame.ts +3 -1
- package/src/client/state/attention-bridge.ts +3 -0
- package/src/client/state/canvas-store.ts +273 -84
- package/src/client/state/human-store.ts +175 -0
- package/src/client/state/iframe-mode.ts +33 -9
- package/src/client/state/intent-bridge.ts +75 -13
- package/src/client/state/node-factory.ts +2 -8
- package/src/client/state/presence-store.ts +168 -0
- package/src/client/state/session-store.ts +419 -0
- package/src/client/state/sse-bridge.ts +93 -30
- package/src/client/theme/global.css +3069 -796
- package/src/client/types.ts +3 -1
- package/src/client/utils/platform.ts +6 -1
- package/src/mcp/canvas-access.ts +2 -2
- package/src/server/agent-presence.ts +755 -0
- package/src/server/ax-context.ts +45 -0
- package/src/server/ax-gate-ttl.ts +61 -0
- package/src/server/ax-interaction.ts +29 -1
- package/src/server/ax-state-manager.ts +78 -6
- package/src/server/ax-state.ts +82 -15
- package/src/server/ax-wait.ts +33 -0
- package/src/server/canvas-db.ts +103 -33
- package/src/server/canvas-operations.ts +17 -8
- package/src/server/canvas-state.ts +131 -58
- package/src/server/canvas-validation.ts +3 -53
- package/src/server/demo-state.json +2 -70
- package/src/server/html-surface.ts +11 -2
- package/src/server/human-presence.ts +160 -0
- package/src/server/index.ts +107 -4
- package/src/server/intent-registry.ts +13 -0
- package/src/server/mutation-history.ts +47 -2
- package/src/server/operations/composites.ts +6 -3
- package/src/server/operations/http.ts +10 -1
- package/src/server/operations/index.ts +4 -0
- package/src/server/operations/invoker.ts +33 -6
- package/src/server/operations/ops/ax-presence.ts +62 -0
- package/src/server/operations/ops/ax-read.ts +8 -0
- package/src/server/operations/ops/ax-state.ts +106 -1
- package/src/server/operations/ops/ax-timeline.ts +54 -5
- package/src/server/operations/ops/ax-work.ts +46 -0
- package/src/server/operations/ops/batch.ts +3 -1
- package/src/server/operations/ops/edges.ts +88 -2
- package/src/server/operations/ops/groups.ts +4 -3
- package/src/server/operations/ops/human-presence.ts +35 -0
- package/src/server/operations/ops/json-render.ts +11 -1
- package/src/server/operations/ops/nodes.ts +65 -16
- package/src/server/operations/ops/query.ts +1 -0
- package/src/server/operations/ops/snapshots.ts +16 -0
- package/src/server/operations/registry.ts +196 -1
- package/src/server/operations/types.ts +1 -1
- package/src/server/scope-fence.ts +207 -0
- package/src/server/server.ts +177 -79
- package/src/server/trace-manager.ts +0 -2
- package/src/server/web-artifacts.ts +0 -1
- package/src/shared/agent-presence.ts +162 -0
- package/src/shared/approval-gates.ts +35 -0
- package/src/shared/auto-arrange.ts +121 -13
- package/src/shared/ax-kinds.ts +23 -0
- package/src/shared/human-presence.ts +30 -0
- package/src/shared/iframe-probe.ts +8 -0
- package/src/shared/scope-fence.ts +31 -0
- package/dist/types/client/canvas/DockedNode.d.ts +0 -4
- package/src/client/canvas/DockedNode.tsx +0 -165
package/src/server/ax-context.ts
CHANGED
|
@@ -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
|
-
|
|
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: {
|
|
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) {
|
package/src/server/ax-state.ts
CHANGED
|
@@ -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 =
|
|
26
|
-
export type PmxAxApprovalStatus =
|
|
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:
|
|
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,
|
package/src/server/ax-wait.ts
CHANGED
|
@@ -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
|
+
}
|