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.
- package/.github/extensions/pmx-canvas/extension.mjs +154 -5
- package/.github/extensions/pmx-canvas/steering-delivery.mjs +79 -0
- package/CHANGELOG.md +156 -0
- package/Readme.md +72 -23
- package/dist/canvas/global.css +3071 -796
- package/dist/canvas/index.js +122 -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 +39 -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/ExtAppFrame.d.ts +2 -0
- 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 +11 -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 +20 -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 +4 -4
- package/skills/pmx-canvas/SKILL.md +112 -17
- 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 +263 -0
- package/src/cli/index.ts +2 -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 +74 -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/ExtAppFrame.tsx +86 -20
- 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 +285 -90
- 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 +3071 -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 +58 -14
- 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 +111 -6
- 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 +77 -16
- package/src/server/operations/ops/query.ts +1 -0
- package/src/server/operations/ops/snapshots.ts +7 -4
- 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
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* JSON body the HTTP route serves), so MCP/CLI callers format one shape
|
|
4
4
|
* regardless of transport. Failures throw `OperationError`.
|
|
5
5
|
*/
|
|
6
|
+
import { SOURCE_LABEL_RE } from '../agent-presence.js';
|
|
6
7
|
import { executeOperation, getOperation } from './registry.js';
|
|
7
8
|
import { OperationError, type OperationErrorStatus } from './types.js';
|
|
8
9
|
|
|
@@ -10,26 +11,52 @@ export interface OperationInvoker {
|
|
|
10
11
|
invoke(name: string, input: Record<string, unknown>): Promise<unknown>;
|
|
11
12
|
}
|
|
12
13
|
|
|
14
|
+
/**
|
|
15
|
+
* The agent-presence label for a transport: `PMX_CANVAS_AGENT_SOURCE` lets a
|
|
16
|
+
* host name its agent ('codex', 'claude-code', …) so writes, attach and cursor
|
|
17
|
+
* all key on one identity; otherwise the transport's own label.
|
|
18
|
+
*/
|
|
19
|
+
export function agentSourceLabel(fallback: string): string {
|
|
20
|
+
const raw = process.env.PMX_CANVAS_AGENT_SOURCE?.trim();
|
|
21
|
+
return raw && SOURCE_LABEL_RE.test(raw) ? raw : fallback;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** `ax.presence.set` without an explicit source attaches under the caller's own label. */
|
|
25
|
+
function defaultPresenceSource(name: string, input: Record<string, unknown>, source: string): Record<string, unknown> {
|
|
26
|
+
return name === 'ax.presence.set' && typeof input.source !== 'string' ? { ...input, source } : input;
|
|
27
|
+
}
|
|
28
|
+
|
|
13
29
|
/** Runs operations in-process against the shared canvasState singleton. */
|
|
14
30
|
export class LocalOperationInvoker implements OperationInvoker {
|
|
31
|
+
private readonly source: string;
|
|
32
|
+
|
|
33
|
+
/** `source` labels this caller's agent presence ('mcp', 'sdk', …). */
|
|
34
|
+
constructor(source = 'api') {
|
|
35
|
+
this.source = agentSourceLabel(source);
|
|
36
|
+
}
|
|
37
|
+
|
|
15
38
|
async invoke(name: string, input: Record<string, unknown>): Promise<unknown> {
|
|
16
|
-
return await executeOperation(name, input);
|
|
39
|
+
return await executeOperation(name, defaultPresenceSource(name, input, this.source), { source: this.source });
|
|
17
40
|
}
|
|
18
41
|
}
|
|
19
42
|
|
|
20
43
|
function toOperationErrorStatus(status: number): OperationErrorStatus {
|
|
21
|
-
return status === 404 ? 404 : status === 409 ? 409 : 400;
|
|
44
|
+
return status === 404 ? 404 : status === 409 ? 409 : status === 403 ? 403 : 400;
|
|
22
45
|
}
|
|
23
46
|
|
|
24
47
|
/** Builds the HTTP request from the op's route template (`:id` from input, GET flags to query). */
|
|
25
48
|
export class HttpOperationInvoker implements OperationInvoker {
|
|
26
49
|
private readonly baseUrl: string;
|
|
50
|
+
private readonly source: string;
|
|
27
51
|
|
|
28
|
-
|
|
52
|
+
/** `source` labels this caller's agent presence on the server ('cli', 'mcp', …). */
|
|
53
|
+
constructor(baseUrl: string, source = 'api') {
|
|
29
54
|
this.baseUrl = baseUrl.replace(/\/$/, '');
|
|
55
|
+
this.source = agentSourceLabel(source);
|
|
30
56
|
}
|
|
31
57
|
|
|
32
|
-
async invoke(name: string,
|
|
58
|
+
async invoke(name: string, rawInput: Record<string, unknown>): Promise<unknown> {
|
|
59
|
+
const input = defaultPresenceSource(name, rawInput, this.source);
|
|
33
60
|
const op = getOperation(name);
|
|
34
61
|
const route = op.http;
|
|
35
62
|
if (!route) throw new OperationError(`Operation "${name}" has no HTTP route.`, 400);
|
|
@@ -52,7 +79,7 @@ export class HttpOperationInvoker implements OperationInvoker {
|
|
|
52
79
|
}
|
|
53
80
|
|
|
54
81
|
let url = `${this.baseUrl}${path}`;
|
|
55
|
-
const init: RequestInit = { method: route.method };
|
|
82
|
+
const init: RequestInit = { method: route.method, headers: { 'x-pmx-source': this.source } };
|
|
56
83
|
if (route.method === 'GET' || route.method === 'DELETE') {
|
|
57
84
|
const params = new URLSearchParams();
|
|
58
85
|
for (const [key, value] of Object.entries(rest)) {
|
|
@@ -61,7 +88,7 @@ export class HttpOperationInvoker implements OperationInvoker {
|
|
|
61
88
|
const query = params.toString();
|
|
62
89
|
if (query) url += `?${query}`;
|
|
63
90
|
} else {
|
|
64
|
-
init.headers = { 'Content-Type': 'application/json' };
|
|
91
|
+
init.headers = { 'Content-Type': 'application/json', 'x-pmx-source': this.source };
|
|
65
92
|
init.body = JSON.stringify(rest);
|
|
66
93
|
}
|
|
67
94
|
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent presence operations (rail-chrome-v2 phase 2): the connect-time read
|
|
3
|
+
* and the explicit update for adapters with richer hooks than the derived
|
|
4
|
+
* feed provides (`thinking`, cursor, focus, attach/detach).
|
|
5
|
+
*
|
|
6
|
+
* This module must never import server.ts or index.ts.
|
|
7
|
+
*/
|
|
8
|
+
import { z } from 'zod';
|
|
9
|
+
import { agentPresence, SOURCE_LABEL_RE, PRESENCE_SET_SHAPE } from '../../agent-presence.js';
|
|
10
|
+
import { defineOperation, type Operation, type OperationContext } from '../types.js';
|
|
11
|
+
|
|
12
|
+
const emptyShape = {};
|
|
13
|
+
const emptySchema = z.looseObject(emptyShape);
|
|
14
|
+
|
|
15
|
+
const presenceGetOperation = defineOperation<z.infer<typeof emptySchema>, Record<string, unknown>>({
|
|
16
|
+
name: 'ax.presence.get',
|
|
17
|
+
mutates: false,
|
|
18
|
+
input: emptySchema,
|
|
19
|
+
inputShape: emptyShape,
|
|
20
|
+
http: {
|
|
21
|
+
method: 'GET',
|
|
22
|
+
path: '/api/canvas/ax/presence',
|
|
23
|
+
},
|
|
24
|
+
handler: () => ({ ok: true, ...agentPresence.snapshot() }),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// One schema (agent-presence.ts) — only the descriptions are added here.
|
|
28
|
+
const presenceSetShape = {
|
|
29
|
+
source: PRESENCE_SET_SHAPE.source.describe('Host label (copilot, codex, mcp, …); defaults to the transport'),
|
|
30
|
+
agentId: PRESENCE_SET_SHAPE.agentId.describe('Per-agent identity within the host (sub-agents keep their own cursor)'),
|
|
31
|
+
label: PRESENCE_SET_SHAPE.label.describe('Display name for this writer'),
|
|
32
|
+
phase: PRESENCE_SET_SHAPE.phase.describe('idle | thinking | tooling | waiting-approval'),
|
|
33
|
+
detail: PRESENCE_SET_SHAPE.detail.describe('Tool / step name shown beside the phase'),
|
|
34
|
+
focusNodeId: PRESENCE_SET_SHAPE.focusNodeId.describe('Node the agent is working on'),
|
|
35
|
+
cursor: PRESENCE_SET_SHAPE.cursor.describe('Agent cursor in world coordinates'),
|
|
36
|
+
attached: PRESENCE_SET_SHAPE.attached.describe('true attaches a session (Focus Session chrome); false detaches it'),
|
|
37
|
+
};
|
|
38
|
+
const presenceSetSchema = z.looseObject(presenceSetShape);
|
|
39
|
+
|
|
40
|
+
const presenceSetOperation = defineOperation<z.infer<typeof presenceSetSchema>, Record<string, unknown>>({
|
|
41
|
+
name: 'ax.presence.set',
|
|
42
|
+
// Presence is not canvas state: no layout event, no history entry, no auto-ghost.
|
|
43
|
+
mutates: false,
|
|
44
|
+
input: presenceSetSchema,
|
|
45
|
+
inputShape: presenceSetShape,
|
|
46
|
+
http: {
|
|
47
|
+
method: 'POST',
|
|
48
|
+
path: '/api/canvas/ax/presence',
|
|
49
|
+
},
|
|
50
|
+
handler: (input, _ctx: OperationContext) => {
|
|
51
|
+
const { source, ...rest } = input;
|
|
52
|
+
// The presence label is a WRITER label, not an AX source enum value: an
|
|
53
|
+
// adapter or an MCP server running with PMX_CANVAS_AGENT_SOURCE=claude-code
|
|
54
|
+
// must attach under the same label its writes carry (SOURCE_LABEL_RE), or
|
|
55
|
+
// the session and the writer split into two cursors.
|
|
56
|
+
const label = typeof source === 'string' && SOURCE_LABEL_RE.test(source) ? source : 'api';
|
|
57
|
+
const presence = agentPresence.set(rest, label);
|
|
58
|
+
return { ok: true, presence, ...agentPresence.snapshot() };
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
export const axPresenceOperations: Operation[] = [presenceGetOperation, presenceSetOperation];
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*
|
|
12
12
|
* This module must never import server.ts or index.ts.
|
|
13
13
|
*/
|
|
14
|
+
import { agentPresence } from '../../agent-presence.js';
|
|
14
15
|
import { z } from 'zod';
|
|
15
16
|
import { buildAgentContextPreamble, serializeNodeForAgentContext } from '../../agent-context.js';
|
|
16
17
|
import { buildCanvasAxContext, buildCanvasAxSurfaceSnapshot } from '../../ax-context.js';
|
|
@@ -248,6 +249,13 @@ const activityIngestOperation = defineOperation<z.infer<typeof activitySchema>,
|
|
|
248
249
|
if (result.workItem) ctx.emit('ax-state-changed', { workItem: result.workItem });
|
|
249
250
|
if (result.evidence) ctx.emit('ax-event-created', { evidence: result.evidence });
|
|
250
251
|
if (result.review) ctx.emit('ax-state-changed', { reviewAnnotation: result.review });
|
|
252
|
+
// Presence is derived from this feed: session-start attaches, session-end
|
|
253
|
+
// detaches, tool-start/-result drive the phase (rail-chrome-v2 phase 2).
|
|
254
|
+
agentPresence.observeActivity(body.kind, {
|
|
255
|
+
source: normalizeAxSource(body.source, 'api'),
|
|
256
|
+
agentId: typeof body.agentId === 'string' ? body.agentId : null,
|
|
257
|
+
title: body.title,
|
|
258
|
+
});
|
|
251
259
|
return { ok: true, ...result };
|
|
252
260
|
},
|
|
253
261
|
});
|
|
@@ -140,11 +140,42 @@ const axFocusSetOperation = defineOperation<z.infer<typeof axFocusSetSchema>, Re
|
|
|
140
140
|
const axPolicySetShape = {
|
|
141
141
|
tools: z.unknown().optional().describe('Tool policy patch: allowed/excluded/approvalRequired arrays.'),
|
|
142
142
|
prompt: z.unknown().optional().describe('Prompt policy patch: systemAppend/mode.'),
|
|
143
|
+
scope: z
|
|
144
|
+
.unknown()
|
|
145
|
+
.optional()
|
|
146
|
+
.describe(
|
|
147
|
+
'Scope fence (human-owned, set from the workbench): { nodeIds, padding? } limits agent WRITES to those nodes plus padding px around them; null clears it. Agent calls that include it are refused (403) — read policy.scope instead.',
|
|
148
|
+
),
|
|
149
|
+
agentScopes: z
|
|
150
|
+
.unknown()
|
|
151
|
+
.optional()
|
|
152
|
+
.describe(
|
|
153
|
+
'Per-agent territories (fleet orchestration): { "<agentId or source>": { nodeIds, padding? } | null }. Each entry overrides the global fence for that writer; null clears that entry. An agent may fence OTHER writers (its workers) but never its own key.',
|
|
154
|
+
),
|
|
143
155
|
source: z.unknown().optional().describe('Optional host/source label. Defaults to mcp.'),
|
|
144
156
|
};
|
|
145
157
|
|
|
146
158
|
const axPolicySetSchema = z.looseObject(axPolicySetShape);
|
|
147
159
|
|
|
160
|
+
/**
|
|
161
|
+
* A fence is a granted REGION: fencing a group frame grants everything inside
|
|
162
|
+
* it, so group ids expand to their members (nested groups included). Without
|
|
163
|
+
* this, "Fence to selection" on a frame lets the agent touch the frame only.
|
|
164
|
+
*/
|
|
165
|
+
function withGroupMembers(nodeIds: string[]): string[] {
|
|
166
|
+
const out = new Set<string>();
|
|
167
|
+
const visit = (id: string) => {
|
|
168
|
+
if (out.has(id)) return;
|
|
169
|
+
out.add(id);
|
|
170
|
+
const node = canvasState.getNode(id);
|
|
171
|
+
if (node?.type === 'group' && Array.isArray(node.data.children)) {
|
|
172
|
+
for (const child of node.data.children) if (typeof child === 'string') visit(child);
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
for (const id of nodeIds) visit(id);
|
|
176
|
+
return [...out];
|
|
177
|
+
}
|
|
178
|
+
|
|
148
179
|
const axPolicySetOperation = defineOperation<z.infer<typeof axPolicySetSchema>, Record<string, unknown>>({
|
|
149
180
|
name: 'ax.policy.set',
|
|
150
181
|
mutates: false,
|
|
@@ -167,12 +198,25 @@ const axPolicySetOperation = defineOperation<z.infer<typeof axPolicySetSchema>,
|
|
|
167
198
|
})
|
|
168
199
|
.optional(),
|
|
169
200
|
prompt: z.object({ systemAppend: z.string().optional(), mode: z.string().optional() }).optional(),
|
|
201
|
+
agentScopes: z
|
|
202
|
+
.record(
|
|
203
|
+
z.string(),
|
|
204
|
+
z.union([z.object({ nodeIds: z.array(z.string()), padding: z.number().optional() }), z.null()]),
|
|
205
|
+
)
|
|
206
|
+
.optional()
|
|
207
|
+
.describe(
|
|
208
|
+
'Per-agent territories: writer key (agentId or source) → { nodeIds, padding? } | null. The orchestration surface — fence each worker to its lane; you cannot set your own key.',
|
|
209
|
+
),
|
|
170
210
|
source: z.enum(AX_SOURCES).optional(),
|
|
171
211
|
},
|
|
172
212
|
// Legacy MCP tool forwarded only the present tools/prompt fields (source split out).
|
|
213
|
+
// The GLOBAL scope fence is human-owned (see checkScopeOwnership) — MCP
|
|
214
|
+
// callers are agents, so the tool neither advertises nor forwards `scope`.
|
|
215
|
+
// Per-agent territories ARE the agent surface and forward.
|
|
173
216
|
buildInput: (input) => ({
|
|
174
217
|
...(input.tools ? { tools: input.tools } : {}),
|
|
175
218
|
...(input.prompt ? { prompt: input.prompt } : {}),
|
|
219
|
+
...(input.agentScopes ? { agentScopes: input.agentScopes } : {}),
|
|
176
220
|
source: normalizeAxSource(input.source, 'mcp'),
|
|
177
221
|
}),
|
|
178
222
|
formatResult: (result) => {
|
|
@@ -183,11 +227,72 @@ const axPolicySetOperation = defineOperation<z.infer<typeof axPolicySetSchema>,
|
|
|
183
227
|
},
|
|
184
228
|
},
|
|
185
229
|
handler: (input, ctx) => {
|
|
186
|
-
const patch: {
|
|
230
|
+
const patch: {
|
|
231
|
+
tools?: Partial<PmxAxPolicy['tools']>;
|
|
232
|
+
prompt?: Partial<PmxAxPolicy['prompt']>;
|
|
233
|
+
scope?: { nodeIds: string[]; padding?: number } | null;
|
|
234
|
+
agentScopes?: Record<string, { nodeIds: string[]; padding?: number } | null>;
|
|
235
|
+
} = {};
|
|
187
236
|
if (isRecord(input.tools)) patch.tools = input.tools as Partial<PmxAxPolicy['tools']>;
|
|
188
237
|
if (isRecord(input.prompt)) patch.prompt = input.prompt as Partial<PmxAxPolicy['prompt']>;
|
|
238
|
+
if (isRecord(input.agentScopes)) {
|
|
239
|
+
const territories: Record<string, { nodeIds: string[]; padding?: number } | null> = {};
|
|
240
|
+
for (const [key, value] of Object.entries(input.agentScopes)) {
|
|
241
|
+
if (value === null) {
|
|
242
|
+
territories[key] = null;
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
if (!isRecord(value)) continue;
|
|
246
|
+
const ids = withGroupMembers(
|
|
247
|
+
Array.isArray(value.nodeIds) ? value.nodeIds.filter((id): id is string => typeof id === 'string') : [],
|
|
248
|
+
);
|
|
249
|
+
const pad = Number(value.padding);
|
|
250
|
+
territories[key] = ids.length > 0 ? { nodeIds: ids, ...(Number.isFinite(pad) ? { padding: pad } : {}) } : null;
|
|
251
|
+
}
|
|
252
|
+
patch.agentScopes = territories;
|
|
253
|
+
}
|
|
254
|
+
if (input.scope === null) patch.scope = null;
|
|
255
|
+
else if (isRecord(input.scope)) {
|
|
256
|
+
const nodeIds = withGroupMembers(
|
|
257
|
+
Array.isArray(input.scope.nodeIds)
|
|
258
|
+
? input.scope.nodeIds.filter((id): id is string => typeof id === 'string')
|
|
259
|
+
: [],
|
|
260
|
+
);
|
|
261
|
+
const padding = Number(input.scope.padding);
|
|
262
|
+
patch.scope = nodeIds.length > 0 ? { nodeIds, ...(Number.isFinite(padding) ? { padding } : {}) } : null;
|
|
263
|
+
}
|
|
189
264
|
const policy = canvasState.setPolicy(patch, { source: normalizeAxSource(input.source, 'api') });
|
|
190
265
|
ctx.emit('ax-state-changed', { policy });
|
|
266
|
+
if (patch.agentScopes && Object.keys(patch.agentScopes).length > 0) {
|
|
267
|
+
const parts = Object.entries(patch.agentScopes).map(([key, fence]) =>
|
|
268
|
+
fence ? `${key}: ${fence.nodeIds.length} node${fence.nodeIds.length === 1 ? '' : 's'}` : `${key}: cleared`,
|
|
269
|
+
);
|
|
270
|
+
canvasState.recordAxEvent(
|
|
271
|
+
{
|
|
272
|
+
kind: 'policy',
|
|
273
|
+
summary: `Territories updated — ${parts.join('; ')}.`,
|
|
274
|
+
nodeIds: [],
|
|
275
|
+
data: { policy: 'agent-scopes', keys: Object.keys(patch.agentScopes) },
|
|
276
|
+
},
|
|
277
|
+
{ source: normalizeAxSource(input.source, 'api') },
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
if (patch.scope !== undefined) {
|
|
281
|
+
// The agent reads the timeline — tell it the fence moved, and why its
|
|
282
|
+
// next write outside it will be refused.
|
|
283
|
+
const event = canvasState.recordAxEvent(
|
|
284
|
+
{
|
|
285
|
+
kind: 'policy',
|
|
286
|
+
summary: policy.scope
|
|
287
|
+
? `Scope fence set: agent writes limited to ${policy.scope.nodeIds.length} node${policy.scope.nodeIds.length === 1 ? '' : 's'} (reads unaffected).`
|
|
288
|
+
: 'Scope fence cleared: the agent may write anywhere on the board.',
|
|
289
|
+
nodeIds: policy.scope?.nodeIds ?? [],
|
|
290
|
+
data: { policy: 'scope-fence', nodeIds: policy.scope?.nodeIds ?? [] },
|
|
291
|
+
},
|
|
292
|
+
{ source: normalizeAxSource(input.source, 'browser') },
|
|
293
|
+
);
|
|
294
|
+
ctx.emit('ax-event-created', { event });
|
|
295
|
+
}
|
|
191
296
|
return { ok: true, policy } as unknown as Record<string, unknown>;
|
|
192
297
|
},
|
|
193
298
|
});
|
|
@@ -46,9 +46,11 @@
|
|
|
46
46
|
* This module must never import server.ts or index.ts.
|
|
47
47
|
*/
|
|
48
48
|
import { z } from 'zod';
|
|
49
|
+
import { agentPresence } from '../../agent-presence.js';
|
|
49
50
|
import { canvasState } from '../../canvas-state.js';
|
|
50
51
|
import { buildPendingAxActivity, isAxEventKind, isAxEvidenceKind } from '../../ax-state.js';
|
|
51
52
|
import type { PmxAxEventKind, PmxAxEvidenceKind } from '../../ax-state.js';
|
|
53
|
+
import { waitForAxCondition } from '../../ax-wait.js';
|
|
52
54
|
import { defineOperation, OperationError, type Operation } from '../types.js';
|
|
53
55
|
import { isRecord } from './nodes.js';
|
|
54
56
|
import {
|
|
@@ -238,6 +240,10 @@ const axSteerOperation = defineOperation<z.infer<typeof axSteerSchema>, Record<s
|
|
|
238
240
|
...(typeof input.target === 'string' ? { target: input.target } : {}),
|
|
239
241
|
});
|
|
240
242
|
ctx.emit('ax-event-created', { steering });
|
|
243
|
+
// Roster rows show each steerable writer's unclaimed-queue depth — a new
|
|
244
|
+
// steer changes that count even when the sender is the human (whose write
|
|
245
|
+
// never touches agent presence), so re-emit the snapshot explicitly.
|
|
246
|
+
agentPresence.refresh();
|
|
241
247
|
return { ok: true, steering } as unknown as Record<string, unknown>;
|
|
242
248
|
},
|
|
243
249
|
});
|
|
@@ -283,6 +289,10 @@ const axDeliveryPendingShape = {
|
|
|
283
289
|
consumer: z.unknown().optional().describe('Consumer/source label to exclude from results (e.g. copilot, mcp).'),
|
|
284
290
|
limit: z.unknown().optional().describe('Max steering messages to return.'),
|
|
285
291
|
order: z.unknown().optional().describe('"oldest" (FIFO, default) or "newest" first.'),
|
|
292
|
+
timeoutMs: z
|
|
293
|
+
.unknown()
|
|
294
|
+
.optional()
|
|
295
|
+
.describe('Max ms to block until steering arrives (0/omitted = immediate read; capped at 120000).'),
|
|
286
296
|
};
|
|
287
297
|
|
|
288
298
|
const axDeliveryPendingSchema = z.looseObject(axDeliveryPendingShape);
|
|
@@ -295,11 +305,22 @@ const axDeliveryPendingOperation = defineOperation<z.infer<typeof axDeliveryPend
|
|
|
295
305
|
http: {
|
|
296
306
|
method: 'GET',
|
|
297
307
|
path: '/api/canvas/ax/delivery/pending',
|
|
308
|
+
// `?waitMs` long-polls like the gate awaits: park until steering for this
|
|
309
|
+
// consumer arrives or the timeout elapses. Custom readInput replaces the
|
|
310
|
+
// default query merge, so re-read the standard params here too.
|
|
311
|
+
readInput: (_req, params, url) => {
|
|
312
|
+
const raw = Number(url.searchParams.get('waitMs') ?? '');
|
|
313
|
+
return {
|
|
314
|
+
...Object.fromEntries(url.searchParams),
|
|
315
|
+
...params,
|
|
316
|
+
timeoutMs: Number.isFinite(raw) && raw > 0 ? raw : 0,
|
|
317
|
+
};
|
|
318
|
+
},
|
|
298
319
|
},
|
|
299
320
|
mcp: {
|
|
300
321
|
toolName: 'canvas_claim_ax_delivery',
|
|
301
322
|
description:
|
|
302
|
-
'Claim pending PMX AX deliveries for a consumer (adapterless delivery). Returns `pending` undelivered steering (mark each with canvas_mark_ax_delivery after acting) AND `pendingActivity`: open canvas-bound AX items awaiting the agent (open work items, pending approval gates / elicitations / mode requests) — typically created by the human in the browser. Both exclude items the consumer itself originated (loop prevention). `pending` defaults to oldest-first (FIFO, for ordered processing); pass `order:"newest"` to surface the human\'s LATEST in-canvas steering first when a small `limit` would otherwise bury it behind a stale backlog (report #68). pendingActivity is read-only here: resolve each via its own tool (canvas_resolve_approval / canvas_respond_elicitation / canvas_resolve_mode / canvas_update_work_item), not canvas_mark_ax_delivery.',
|
|
323
|
+
'Claim pending PMX AX deliveries for a consumer (adapterless delivery). Returns `pending` undelivered steering (mark each with canvas_mark_ax_delivery after acting) AND `pendingActivity`: open canvas-bound AX items awaiting the agent (open work items, pending approval gates / elicitations / mode requests) — typically created by the human in the browser. Both exclude items the consumer itself originated (loop prevention). `pending` defaults to oldest-first (FIFO, for ordered processing); pass `order:"newest"` to surface the human\'s LATEST in-canvas steering first when a small `limit` would otherwise bury it behind a stale backlog (report #68). pendingActivity is read-only here: resolve each via its own tool (canvas_resolve_approval / canvas_respond_elicitation / canvas_resolve_mode / canvas_update_work_item), not canvas_mark_ax_delivery. Pass `timeoutMs` to LONG-POLL: the call blocks until steering for the consumer arrives or the timeout elapses (max 120000) — loop on this to react to canvas steering promptly without burning your turn on tight polls.',
|
|
303
324
|
extraShape: {
|
|
304
325
|
consumer: z.string().optional().describe('Consumer/source label to exclude from results (e.g. copilot, mcp).'),
|
|
305
326
|
limit: z.number().optional().describe('Max steering messages to return.'),
|
|
@@ -309,12 +330,21 @@ const axDeliveryPendingOperation = defineOperation<z.infer<typeof axDeliveryPend
|
|
|
309
330
|
.describe(
|
|
310
331
|
'Order of returned steering: "oldest" (FIFO, default) for ordered processing, or "newest" first to see the latest browser action when limited.',
|
|
311
332
|
),
|
|
333
|
+
timeoutMs: z
|
|
334
|
+
.number()
|
|
335
|
+
.min(0)
|
|
336
|
+
.max(120000)
|
|
337
|
+
.optional()
|
|
338
|
+
.describe('Max ms to block until steering arrives (0/omitted = immediate read).'),
|
|
312
339
|
},
|
|
313
340
|
// `consumer` is a loop-safety scope, not a source label — never defaulted.
|
|
314
341
|
formatResult: axJsonResult,
|
|
315
342
|
},
|
|
316
|
-
handler: (input) => {
|
|
343
|
+
handler: async (input) => {
|
|
317
344
|
const consumer = typeof input.consumer === 'string' ? input.consumer : undefined;
|
|
345
|
+
// A claim is proof this consumer polls steering — its presence (session or
|
|
346
|
+
// external writer) becomes a legitimate composer steer target.
|
|
347
|
+
if (consumer) agentPresence.noteSteeringConsumer(consumer);
|
|
318
348
|
const limitRaw = Number(input.limit ?? '');
|
|
319
349
|
const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? limitRaw : undefined;
|
|
320
350
|
// #68: default FIFO (oldest-first) for ordered processing; `order:"newest"`
|
|
@@ -323,7 +353,18 @@ const axDeliveryPendingOperation = defineOperation<z.infer<typeof axDeliveryPend
|
|
|
323
353
|
// queries apply the same loop-safe consumer filter before the limit.
|
|
324
354
|
const newest = input.order === 'newest';
|
|
325
355
|
const scope = { ...(consumer ? { consumer } : {}), ...(limit ? { limit } : {}) };
|
|
326
|
-
const
|
|
356
|
+
const readPending = () =>
|
|
357
|
+
newest ? canvasState.getPendingSteeringForContext(scope) : canvasState.getPendingSteering(scope);
|
|
358
|
+
let pending = readPending();
|
|
359
|
+
const timeoutRaw = Number(input.timeoutMs ?? '');
|
|
360
|
+
const timeoutMs = Number.isFinite(timeoutRaw) && timeoutRaw > 0 ? timeoutRaw : 0;
|
|
361
|
+
if (pending.length === 0 && timeoutMs > 0) {
|
|
362
|
+
// Long-poll: hosts that cannot be woken from outside (Copilot, Codex —
|
|
363
|
+
// their model runs only while the host gives it a turn) park here and
|
|
364
|
+
// wake the moment steering lands, instead of burning the turn polling.
|
|
365
|
+
await waitForAxCondition({ ready: () => readPending().length > 0, timeoutMs });
|
|
366
|
+
pending = readPending();
|
|
367
|
+
}
|
|
327
368
|
// The MCP tool aggregated pendingActivity; one wire body now serves it over
|
|
328
369
|
// HTTP too (documented broadening). Loop-safe: consumer scopes both queries.
|
|
329
370
|
const pendingActivity = buildPendingAxActivity(canvasState.getAxState(), consumer);
|
|
@@ -335,6 +376,12 @@ const axDeliveryPendingOperation = defineOperation<z.infer<typeof axDeliveryPend
|
|
|
335
376
|
|
|
336
377
|
const axDeliveryMarkShape = {
|
|
337
378
|
id: z.string().optional().catch(undefined).describe('The steering message id to mark delivered.'),
|
|
379
|
+
consumer: z
|
|
380
|
+
.unknown()
|
|
381
|
+
.optional()
|
|
382
|
+
.describe(
|
|
383
|
+
'The marking consumer. Broadcasts are delivered PER consumer — pass your label so the message stays pending for the other agents.',
|
|
384
|
+
),
|
|
338
385
|
};
|
|
339
386
|
|
|
340
387
|
const axDeliveryMarkSchema = z.looseObject(axDeliveryMarkShape);
|
|
@@ -351,15 +398,17 @@ const axDeliveryMarkOperation = defineOperation<z.infer<typeof axDeliveryMarkSch
|
|
|
351
398
|
mcp: {
|
|
352
399
|
toolName: 'canvas_mark_ax_delivery',
|
|
353
400
|
description:
|
|
354
|
-
'Mark a PMX AX steering message as delivered so it is not handed out again.
|
|
401
|
+
'Mark a PMX AX steering message as delivered so it is not handed out again. ADDRESSED steers: compare-and-set — `delivered:true` only on the actual undelivered→delivered transition. BROADCASTS are delivered PER consumer: pass `consumer` and your mark removes it from YOUR queue only (every other agent still receives it) — an "all workers: stop" reaches the whole fleet. A false means the id was unknown or you (or, for addressed steers, someone) already marked it.',
|
|
355
402
|
extraShape: {
|
|
356
403
|
id: z.string().describe('The steering message id to mark delivered.'),
|
|
404
|
+
consumer: z.string().optional().describe('Your consumer label — required for per-consumer broadcast delivery.'),
|
|
357
405
|
},
|
|
358
406
|
formatResult: axJsonResult,
|
|
359
407
|
},
|
|
360
408
|
handler: (input, ctx) => {
|
|
361
409
|
const id = typeof input.id === 'string' ? input.id : '';
|
|
362
|
-
const
|
|
410
|
+
const consumer = typeof input.consumer === 'string' ? input.consumer : undefined;
|
|
411
|
+
const delivered = canvasState.markSteeringDelivered(id, consumer);
|
|
363
412
|
// Legacy handleAxDeliveryMark only broadcasts when a message was marked.
|
|
364
413
|
if (delivered) {
|
|
365
414
|
ctx.emit('ax-event-created', { steeringDelivered: id });
|
|
@@ -378,6 +378,10 @@ const axApprovalRequestShape = {
|
|
|
378
378
|
detail: z.unknown().optional().describe('Optional explanation of the action and its impact.'),
|
|
379
379
|
action: z.unknown().optional().describe('Optional machine-readable action identifier the approval gates.'),
|
|
380
380
|
nodeIds: z.unknown().optional().describe('Optional node IDs this approval relates to.'),
|
|
381
|
+
ttlMs: z
|
|
382
|
+
.unknown()
|
|
383
|
+
.optional()
|
|
384
|
+
.describe('Unattended-approval TTL in ms; the gate auto-holds if unanswered (default 5 min, max 24 h).'),
|
|
381
385
|
source: z.unknown().optional().describe('Optional host/source label. Defaults to mcp.'),
|
|
382
386
|
};
|
|
383
387
|
|
|
@@ -401,6 +405,10 @@ const axApprovalRequestOperation = defineOperation<z.infer<typeof axApprovalRequ
|
|
|
401
405
|
detail: z.string().optional().describe('Optional explanation of the action and its impact.'),
|
|
402
406
|
action: z.string().optional().describe('Optional machine-readable action identifier the approval gates.'),
|
|
403
407
|
nodeIds: z.array(z.string()).optional().describe('Optional node IDs this approval relates to.'),
|
|
408
|
+
ttlMs: z
|
|
409
|
+
.number()
|
|
410
|
+
.optional()
|
|
411
|
+
.describe('Unattended-approval TTL in ms; the gate auto-holds if unanswered (default 5 min, max 24 h).'),
|
|
404
412
|
source: AX_SOURCE_SHAPE,
|
|
405
413
|
},
|
|
406
414
|
buildInput: (input) => ({ ...input, source: normalizeAxSource(input.source, 'mcp') }),
|
|
@@ -410,12 +418,14 @@ const axApprovalRequestOperation = defineOperation<z.infer<typeof axApprovalRequ
|
|
|
410
418
|
if (typeof input.title !== 'string' || !input.title.trim()) {
|
|
411
419
|
throw new OperationError('approval request requires a title.');
|
|
412
420
|
}
|
|
421
|
+
const ttlMs = Number(input.ttlMs);
|
|
413
422
|
const approvalGate = canvasState.requestApproval(
|
|
414
423
|
{
|
|
415
424
|
title: input.title,
|
|
416
425
|
...(typeof input.detail === 'string' ? { detail: input.detail } : {}),
|
|
417
426
|
...(typeof input.action === 'string' ? { action: input.action } : {}),
|
|
418
427
|
...(Array.isArray(input.nodeIds) ? { nodeIds: normalizeAxNodeIds(input.nodeIds) } : {}),
|
|
428
|
+
...(Number.isFinite(ttlMs) && ttlMs > 0 ? { ttlMs } : {}),
|
|
419
429
|
},
|
|
420
430
|
{ source: normalizeAxSource(input.source, 'api') },
|
|
421
431
|
);
|
|
@@ -424,6 +434,41 @@ const axApprovalRequestOperation = defineOperation<z.infer<typeof axApprovalRequ
|
|
|
424
434
|
},
|
|
425
435
|
});
|
|
426
436
|
|
|
437
|
+
// ── ax.approval.reopen (session panel; human-facing) ──────────
|
|
438
|
+
|
|
439
|
+
const axApprovalReopenShape = {
|
|
440
|
+
id: z.unknown().optional().describe('Approval gate id.'),
|
|
441
|
+
ttlMs: z.unknown().optional().describe('Fresh TTL in ms (default 5 min).'),
|
|
442
|
+
source: z.unknown().optional().describe('Optional host/source label.'),
|
|
443
|
+
};
|
|
444
|
+
const axApprovalReopenSchema = z.looseObject(axApprovalReopenShape);
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Reopen a resolved gate — the unattended-approval policy's "auto-held" exit.
|
|
448
|
+
* HTTP + SDK only: reopening is the HUMAN answering late, not an agent op.
|
|
449
|
+
*/
|
|
450
|
+
const axApprovalReopenOperation = defineOperation<z.infer<typeof axApprovalReopenSchema>, Record<string, unknown>>({
|
|
451
|
+
name: 'ax.approval.reopen',
|
|
452
|
+
mutates: false,
|
|
453
|
+
input: axApprovalReopenSchema,
|
|
454
|
+
inputShape: axApprovalReopenShape,
|
|
455
|
+
http: {
|
|
456
|
+
method: 'POST',
|
|
457
|
+
path: '/api/canvas/ax/approval/:id/reopen',
|
|
458
|
+
},
|
|
459
|
+
handler: (input, ctx) => {
|
|
460
|
+
const id = typeof input.id === 'string' ? input.id : '';
|
|
461
|
+
const ttlMs = Number(input.ttlMs);
|
|
462
|
+
const approvalGate = canvasState.reopenApproval(id, {
|
|
463
|
+
...(Number.isFinite(ttlMs) && ttlMs > 0 ? { ttlMs } : {}),
|
|
464
|
+
source: normalizeAxSource(input.source, 'browser'),
|
|
465
|
+
});
|
|
466
|
+
if (!approvalGate) throw new OperationError(`Approval gate "${id}" is not resolved (or does not exist).`, 404);
|
|
467
|
+
ctx.emit('ax-state-changed', { approvalGate });
|
|
468
|
+
return { ok: true, approvalGate } as unknown as Record<string, unknown>;
|
|
469
|
+
},
|
|
470
|
+
});
|
|
471
|
+
|
|
427
472
|
// ── ax.approval.resolve (canvas_resolve_approval) ─────────────
|
|
428
473
|
|
|
429
474
|
const axApprovalResolveShape = {
|
|
@@ -676,6 +721,7 @@ export const axWorkOperations: Operation[] = [
|
|
|
676
721
|
axReviewUpdateOperation,
|
|
677
722
|
axApprovalRequestOperation,
|
|
678
723
|
axApprovalResolveOperation,
|
|
724
|
+
axApprovalReopenOperation,
|
|
679
725
|
axElicitationRequestOperation,
|
|
680
726
|
axElicitationRespondOperation,
|
|
681
727
|
axModeRequestOperation,
|
|
@@ -239,7 +239,9 @@ async function runBatch(operations: BatchEntry[]): Promise<BatchEnvelope> {
|
|
|
239
239
|
}
|
|
240
240
|
const dispatch = resolveDispatch(operation.op, args);
|
|
241
241
|
// Batch churn is exempt from auto-ghosts (matches the skill's explicit
|
|
242
|
-
// ghost-cursor exemption for batch operations).
|
|
242
|
+
// ghost-cursor exemption for batch operations). No `fromWorkbench`: the
|
|
243
|
+
// browser never issues batches, so inner writes are agent writes and the
|
|
244
|
+
// scope fence applies to each of them.
|
|
243
245
|
const raw = await executeOperation(dispatch.name, dispatch.args, { suppressAutoGhost: true });
|
|
244
246
|
const result = shapeBatchEntry(operation.op, raw);
|
|
245
247
|
results.push(result);
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { z } from 'zod';
|
|
7
7
|
import type { CanvasEdge } from '../../canvas-state.js';
|
|
8
|
-
import { addCanvasEdge, removeCanvasEdge } from '../../canvas-operations.js';
|
|
8
|
+
import { addCanvasEdge, removeCanvasEdge, updateCanvasEdge } from '../../canvas-operations.js';
|
|
9
9
|
import { readJsonValue } from '../http.js';
|
|
10
10
|
import { defineOperation, OperationError, type Operation } from '../types.js';
|
|
11
11
|
import { isRecord } from './nodes.js';
|
|
@@ -124,6 +124,92 @@ const edgeAddOperation = defineOperation<z.infer<typeof edgeAddSchema>, CanvasEd
|
|
|
124
124
|
serialize: (edge) => ({ ok: true, ...edge }),
|
|
125
125
|
});
|
|
126
126
|
|
|
127
|
+
// ── edge.update ───────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
const edgeUpdateShape = {
|
|
130
|
+
id: z.string().optional().catch(undefined).describe('Edge ID to update'),
|
|
131
|
+
type: z.unknown().optional().describe('New edge type: flow, depends-on, relation, or references'),
|
|
132
|
+
label: z.unknown().optional().describe('New label text; empty string or null clears the label'),
|
|
133
|
+
style: z.unknown().optional().describe('New stroke style: solid, dashed, or dotted'),
|
|
134
|
+
animated: z.unknown().optional().describe('Animate the edge stroke'),
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const edgeUpdateSchema = z.looseObject(edgeUpdateShape);
|
|
138
|
+
|
|
139
|
+
const edgeUpdateOperation = defineOperation<z.infer<typeof edgeUpdateSchema>, CanvasEdge>({
|
|
140
|
+
name: 'edge.update',
|
|
141
|
+
mutates: true,
|
|
142
|
+
input: edgeUpdateSchema,
|
|
143
|
+
inputShape: edgeUpdateShape,
|
|
144
|
+
http: {
|
|
145
|
+
method: 'PATCH',
|
|
146
|
+
path: '/api/canvas/edge/:id',
|
|
147
|
+
},
|
|
148
|
+
mcp: {
|
|
149
|
+
toolName: 'canvas_update_edge',
|
|
150
|
+
description: 'Update an existing edge in place: change its label, type, stroke style, or animation.',
|
|
151
|
+
extraShape: {
|
|
152
|
+
id: z.string().describe('Edge ID to update'),
|
|
153
|
+
type: z.enum(['flow', 'depends-on', 'relation', 'references']).optional().describe('New edge type'),
|
|
154
|
+
label: z.string().nullable().optional().describe('New label text; empty string or null clears the label'),
|
|
155
|
+
style: z.enum(['solid', 'dashed', 'dotted']).optional().describe('New stroke style'),
|
|
156
|
+
animated: z.boolean().optional().describe('Animate the edge stroke'),
|
|
157
|
+
},
|
|
158
|
+
formatResult: (result) => {
|
|
159
|
+
const body = isRecord(result) ? result : {};
|
|
160
|
+
return {
|
|
161
|
+
content: [
|
|
162
|
+
{
|
|
163
|
+
type: 'text' as const,
|
|
164
|
+
text: JSON.stringify(
|
|
165
|
+
{ id: body.id, type: body.type, label: body.label, style: body.style, animated: body.animated },
|
|
166
|
+
null,
|
|
167
|
+
2,
|
|
168
|
+
),
|
|
169
|
+
},
|
|
170
|
+
],
|
|
171
|
+
};
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
handler: (input) => {
|
|
175
|
+
const body: Record<string, unknown> = input;
|
|
176
|
+
const id = typeof body.id === 'string' ? body.id : '';
|
|
177
|
+
if (!id) {
|
|
178
|
+
throw new OperationError('Missing required field: id.');
|
|
179
|
+
}
|
|
180
|
+
const patch: Partial<Pick<CanvasEdge, 'type' | 'label' | 'style' | 'animated'>> = {};
|
|
181
|
+
if ('type' in body && body.type !== undefined) {
|
|
182
|
+
if (typeof body.type !== 'string' || !VALID_EDGE_TYPES.has(body.type)) {
|
|
183
|
+
throw new OperationError(`Invalid edge type: "${String(body.type)}".`);
|
|
184
|
+
}
|
|
185
|
+
patch.type = body.type as CanvasEdge['type'];
|
|
186
|
+
}
|
|
187
|
+
if ('label' in body) {
|
|
188
|
+
// Empty string / null clears the label; the state layer drops undefined.
|
|
189
|
+
patch.label = typeof body.label === 'string' && body.label.length > 0 ? body.label : undefined;
|
|
190
|
+
}
|
|
191
|
+
if ('style' in body && body.style !== undefined) {
|
|
192
|
+
if (typeof body.style !== 'string' || !VALID_EDGE_STYLES.has(body.style)) {
|
|
193
|
+
throw new OperationError(`Invalid edge style: "${String(body.style)}". Use solid, dashed, or dotted.`);
|
|
194
|
+
}
|
|
195
|
+
patch.style = body.style as CanvasEdge['style'];
|
|
196
|
+
}
|
|
197
|
+
if ('animated' in body && body.animated !== undefined) {
|
|
198
|
+
patch.animated = Boolean(body.animated);
|
|
199
|
+
}
|
|
200
|
+
if (Object.keys(patch).length === 0) {
|
|
201
|
+
throw new OperationError('Nothing to update: provide type, label, style, or animated.');
|
|
202
|
+
}
|
|
203
|
+
try {
|
|
204
|
+
return updateCanvasEdge(id, patch);
|
|
205
|
+
} catch (error) {
|
|
206
|
+
const message = error instanceof Error ? error.message : 'Edge update failed.';
|
|
207
|
+
throw new OperationError(message, message.includes('not found') ? 404 : 400);
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
serialize: (edge) => ({ ok: true, ...edge }),
|
|
211
|
+
});
|
|
212
|
+
|
|
127
213
|
// ── edge.remove ───────────────────────────────────────────────
|
|
128
214
|
|
|
129
215
|
const edgeRemoveShape = {
|
|
@@ -176,4 +262,4 @@ const edgeRemoveOperation = defineOperation<z.infer<typeof edgeRemoveSchema>, Re
|
|
|
176
262
|
},
|
|
177
263
|
});
|
|
178
264
|
|
|
179
|
-
export const edgeOperations: Operation[] = [edgeAddOperation, edgeRemoveOperation];
|
|
265
|
+
export const edgeOperations: Operation[] = [edgeAddOperation, edgeUpdateOperation, edgeRemoveOperation];
|