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
|
@@ -0,0 +1,755 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent presence registry (rail-chrome-v2 phase 2).
|
|
3
|
+
*
|
|
4
|
+
* Lives beside IntentRegistry and follows its discipline: in-memory, TTL-swept,
|
|
5
|
+
* count-capped, and emitting through an injected workbench emitter so this
|
|
6
|
+
* module never imports server.ts. One `agent-presence` SSE frame carries the
|
|
7
|
+
* full snapshot on every change, so a reconnecting client is never stale and
|
|
8
|
+
* never needs its own expiry ticker.
|
|
9
|
+
*
|
|
10
|
+
* Presence is derived, not declared: agent-originated mutations (anything
|
|
11
|
+
* `executeOperation` runs without the workbench marker) and AX activity
|
|
12
|
+
* ingests (`tool-start`, `session-start`, …) touch a writer; adapters with
|
|
13
|
+
* richer hooks may `set` a phase, cursor, or focus explicitly.
|
|
14
|
+
*/
|
|
15
|
+
import { z } from 'zod';
|
|
16
|
+
import {
|
|
17
|
+
AGENT_PHASES,
|
|
18
|
+
type AgentActivityEntry,
|
|
19
|
+
type AgentPhase,
|
|
20
|
+
type AgentPresence,
|
|
21
|
+
type AgentPresenceSnapshot,
|
|
22
|
+
CONTEXT_BUDGET_DEFAULT_TOKENS,
|
|
23
|
+
type ContextBudget,
|
|
24
|
+
estimateTokens,
|
|
25
|
+
HUMAN_STARTED_SESSION_LABEL,
|
|
26
|
+
isSessionActive,
|
|
27
|
+
MAX_ACTIVITY_ENTRIES,
|
|
28
|
+
MAX_PRESENCES,
|
|
29
|
+
PRESENCE_ACTIVITY_TTL_MS,
|
|
30
|
+
PRESENCE_ATTACHED_IDLE_TTL_MS,
|
|
31
|
+
PRESENCE_THINKING_SETTLE_MS,
|
|
32
|
+
PRESENCE_TOOLING_SETTLE_MS,
|
|
33
|
+
TRANSPORT_SOURCES,
|
|
34
|
+
} from '../shared/agent-presence.js';
|
|
35
|
+
import { serializeNodeForAgentContext } from './agent-context.js';
|
|
36
|
+
import type { PmxAxActivityKind } from './ax-state.js';
|
|
37
|
+
import { type CanvasNodeState, canvasState } from './canvas-state.js';
|
|
38
|
+
import { OperationError } from './operations/types.js';
|
|
39
|
+
|
|
40
|
+
type PresenceEmitter = (event: string, payload: Record<string, unknown>) => void;
|
|
41
|
+
|
|
42
|
+
/** A legal writer label: short, alphanumeric/dash, letter-first (header and env values). */
|
|
43
|
+
export const SOURCE_LABEL_RE = /^[a-z][a-z0-9-]{0,39}$/i;
|
|
44
|
+
|
|
45
|
+
export interface PresenceTouch {
|
|
46
|
+
source: string;
|
|
47
|
+
agentId?: string | null;
|
|
48
|
+
label?: string;
|
|
49
|
+
phase?: AgentPhase;
|
|
50
|
+
detail?: string | null;
|
|
51
|
+
focusNodeId?: string | null;
|
|
52
|
+
parentAgentId?: string | null;
|
|
53
|
+
/** Op-derived tooling: overlay the phase without clobbering the explicit one. */
|
|
54
|
+
derivedTooling?: boolean;
|
|
55
|
+
cursor?: { x: number; y: number } | null;
|
|
56
|
+
attached?: boolean;
|
|
57
|
+
/** With `attached: false`: who ended it (receipt transparency). Default 'agent'. */
|
|
58
|
+
endedBy?: 'human' | 'agent';
|
|
59
|
+
/** The host's real token usage for this agent, if it knows it. */
|
|
60
|
+
contextUsage?: { used: number; total: number } | null;
|
|
61
|
+
/** Count this touch as an agent write. */
|
|
62
|
+
op?: boolean;
|
|
63
|
+
/** What the write did, for the activity feed (only meaningful with `op`). */
|
|
64
|
+
activity?: { op: string; summary: string; nodeId: string | null };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Title a write's summary can name, or a type-based fallback. */
|
|
68
|
+
function nodeTitle(nodeId: string | null | undefined): string | null {
|
|
69
|
+
const node = nodeId ? canvasState.getNode(nodeId) : undefined;
|
|
70
|
+
if (!node) return null;
|
|
71
|
+
const title = typeof node.data.title === 'string' ? node.data.title.trim() : '';
|
|
72
|
+
return title || `${node.type} node`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function quote(title: string | null, fallback: string): string {
|
|
76
|
+
return title ? `“${title}”` : fallback;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* One sentence for the activity feed, from the op name plus whatever the
|
|
81
|
+
* input/result name. Titles are read AFTER the op ran, so creates and updates
|
|
82
|
+
* resolve; removes describe what is gone by id.
|
|
83
|
+
*/
|
|
84
|
+
export function describeWrite(
|
|
85
|
+
op: string,
|
|
86
|
+
rawInput: unknown,
|
|
87
|
+
result: unknown,
|
|
88
|
+
): { summary: string; nodeId: string | null } {
|
|
89
|
+
const input = rawInput && typeof rawInput === 'object' ? (rawInput as Record<string, unknown>) : {};
|
|
90
|
+
const res = result && typeof result === 'object' ? (result as Record<string, unknown>) : {};
|
|
91
|
+
const resNode = res.node && typeof res.node === 'object' ? (res.node as Record<string, unknown>) : {};
|
|
92
|
+
const nodeId =
|
|
93
|
+
(typeof input.id === 'string' && input.id) ||
|
|
94
|
+
(typeof input.nodeId === 'string' && input.nodeId) ||
|
|
95
|
+
(typeof res.id === 'string' && res.id) ||
|
|
96
|
+
(typeof resNode.id === 'string' && resNode.id) ||
|
|
97
|
+
null;
|
|
98
|
+
const title = nodeTitle(nodeId);
|
|
99
|
+
const str = (value: unknown): string => (typeof value === 'string' ? value : '');
|
|
100
|
+
switch (op) {
|
|
101
|
+
case 'node.add':
|
|
102
|
+
return { summary: `Created ${str(input.type) || 'a'} ${quote(title, 'node')}`, nodeId };
|
|
103
|
+
case 'jsonrender.add':
|
|
104
|
+
case 'graph.add':
|
|
105
|
+
return { summary: `Created ${quote(title, 'a rendered node')}`, nodeId };
|
|
106
|
+
case 'jsonrender.stream':
|
|
107
|
+
return { summary: `Streamed into ${quote(title, 'a rendered node')}`, nodeId };
|
|
108
|
+
case 'node.update':
|
|
109
|
+
return { summary: `Updated ${quote(title, 'a node')}`, nodeId };
|
|
110
|
+
case 'node.remove':
|
|
111
|
+
return { summary: 'Removed a node', nodeId };
|
|
112
|
+
case 'edge.add':
|
|
113
|
+
return {
|
|
114
|
+
summary: `Connected ${quote(nodeTitle(str(input.from)), 'a node')} → ${quote(nodeTitle(str(input.to)), 'a node')}`,
|
|
115
|
+
nodeId: str(input.to) || null,
|
|
116
|
+
};
|
|
117
|
+
case 'edge.remove':
|
|
118
|
+
return { summary: 'Removed an edge', nodeId: null };
|
|
119
|
+
case 'group.create':
|
|
120
|
+
return { summary: `Grouped nodes into ${quote(title, 'a group')}`, nodeId };
|
|
121
|
+
case 'group.add':
|
|
122
|
+
return {
|
|
123
|
+
summary: `Changed membership of ${quote(nodeTitle(str(input.groupId)), 'a group')}`,
|
|
124
|
+
nodeId: str(input.groupId) || null,
|
|
125
|
+
};
|
|
126
|
+
case 'group.remove':
|
|
127
|
+
// The frame is gone by now — the op returns the title it had.
|
|
128
|
+
return { summary: `Dissolved group ${quote(str(res.title) || null, 'a group')}`, nodeId: null };
|
|
129
|
+
case 'arrange':
|
|
130
|
+
return { summary: `Arranged the board${str(input.layout) ? ` (${str(input.layout)})` : ''}`, nodeId: null };
|
|
131
|
+
case 'canvas.clear':
|
|
132
|
+
return { summary: 'Cleared the board', nodeId: null };
|
|
133
|
+
case 'snapshot.restore':
|
|
134
|
+
return { summary: 'Restored a snapshot', nodeId: null };
|
|
135
|
+
case 'annotation.add':
|
|
136
|
+
return { summary: 'Drew an annotation', nodeId: null };
|
|
137
|
+
case 'ax.work.create':
|
|
138
|
+
return { summary: `Opened work item ${quote(str(input.title) || null, '')}`.trimEnd(), nodeId };
|
|
139
|
+
case 'ax.work.update':
|
|
140
|
+
return { summary: `Updated a work item${str(input.status) ? ` → ${str(input.status)}` : ''}`, nodeId };
|
|
141
|
+
case 'ax.approval.request':
|
|
142
|
+
return { summary: `Requested approval: ${quote(str(input.title) || null, 'a gate')}`, nodeId };
|
|
143
|
+
case 'ax.approval.resolve':
|
|
144
|
+
return { summary: `Resolved a gate${str(input.decision) ? ` → ${str(input.decision)}` : ''}`, nodeId };
|
|
145
|
+
case 'ax.evidence.add':
|
|
146
|
+
case 'ax.event.record':
|
|
147
|
+
case 'ax.steer':
|
|
148
|
+
// Already first-class timeline rows (Evidence / the event kind / Steer):
|
|
149
|
+
// an empty summary keeps them out of the activity feed.
|
|
150
|
+
return { summary: '', nodeId };
|
|
151
|
+
case 'intent.signal':
|
|
152
|
+
return {
|
|
153
|
+
summary: `Proposed ${quote(str(input.label) || null, 'a change')}${str(input.kind) ? ` (${str(input.kind)})` : ''}`,
|
|
154
|
+
nodeId,
|
|
155
|
+
};
|
|
156
|
+
case 'intent.update':
|
|
157
|
+
return { summary: 'Updated a proposal', nodeId };
|
|
158
|
+
case 'intent.clear':
|
|
159
|
+
return { summary: 'Withdrew a proposal', nodeId };
|
|
160
|
+
case 'ax.focus.set':
|
|
161
|
+
return { summary: 'Set the AX focus', nodeId: null };
|
|
162
|
+
case 'ax.policy.set':
|
|
163
|
+
return { summary: 'Updated the tool policy', nodeId: null };
|
|
164
|
+
default:
|
|
165
|
+
return { summary: op.replace(/\./g, ' '), nodeId };
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The one validation schema for an explicit presence update. The HTTP/MCP op
|
|
171
|
+
* spreads these fields into its tool shape so the two cannot drift.
|
|
172
|
+
*
|
|
173
|
+
* Identity note: presence is a UX signal, not an authenticated identity — any
|
|
174
|
+
* local process can assert any `source`/`agentId` (the single-workspace trust
|
|
175
|
+
* model). It shows the human WHO claims to be working; it grants no write
|
|
176
|
+
* capability that the caller did not already have.
|
|
177
|
+
*/
|
|
178
|
+
export const PRESENCE_SET_SHAPE = {
|
|
179
|
+
source: z.string().min(1).max(40).optional(),
|
|
180
|
+
agentId: z.string().min(1).max(80).nullable().optional(),
|
|
181
|
+
label: z.string().min(1).max(120).optional(),
|
|
182
|
+
phase: z.enum(AGENT_PHASES as [AgentPhase, ...AgentPhase[]]).optional(),
|
|
183
|
+
detail: z.string().max(200).nullable().optional(),
|
|
184
|
+
focusNodeId: z.string().max(200).nullable().optional(),
|
|
185
|
+
cursor: z.object({ x: z.number().finite(), y: z.number().finite() }).nullable().optional(),
|
|
186
|
+
attached: z.boolean().optional(),
|
|
187
|
+
parentAgentId: z.string().min(1).max(80).nullable().optional(),
|
|
188
|
+
// Who is ending the session when attached:false — the browser's End button
|
|
189
|
+
// declares 'human'; anything else defaults to 'agent'. Self-reported, like
|
|
190
|
+
// the workbench marker (the local trust model).
|
|
191
|
+
endedBy: z.enum(['human', 'agent']).optional(),
|
|
192
|
+
contextUsage: z
|
|
193
|
+
.object({ used: z.number().finite().min(0), total: z.number().finite().positive() })
|
|
194
|
+
.nullable()
|
|
195
|
+
.optional(),
|
|
196
|
+
};
|
|
197
|
+
const presenceSetSchema = z.object(PRESENCE_SET_SHAPE);
|
|
198
|
+
|
|
199
|
+
interface StoredPresence extends AgentPresence {
|
|
200
|
+
lastSeenMs: number;
|
|
201
|
+
/** Explicit phases (`thinking`) hold until the next touch; derived `tooling` decays. */
|
|
202
|
+
toolingUntilMs: number | null;
|
|
203
|
+
lastActivityNodeId: string | null;
|
|
204
|
+
/** Snapshot of the board taken when this session attached (the receipt diffs against it). */
|
|
205
|
+
startSnapshotId: string | null;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function presenceKey(source: string, agentId: string | null | undefined): string {
|
|
209
|
+
return agentId && agentId.trim() ? agentId.trim() : source;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function contextBudgetTotal(): number {
|
|
213
|
+
const raw = Number(process.env.PMX_CANVAS_CONTEXT_BUDGET_TOKENS ?? '');
|
|
214
|
+
return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : CONTEXT_BUDGET_DEFAULT_TOKENS;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Token estimate of the pinned-context payload — the same serialization the MCP resource ships. */
|
|
218
|
+
export function estimateContextBudget(): ContextBudget {
|
|
219
|
+
const nodes = Array.from(canvasState.contextPinnedNodeIds)
|
|
220
|
+
.map((id) => canvasState.getNode(id))
|
|
221
|
+
.filter((node): node is CanvasNodeState => node !== undefined);
|
|
222
|
+
const payload = nodes.map((node) =>
|
|
223
|
+
serializeNodeForAgentContext(node, { defaultTextLength: 700, webpageTextLength: 1600, includePosition: true }),
|
|
224
|
+
);
|
|
225
|
+
return { used: nodes.length === 0 ? 0 : estimateTokens(JSON.stringify(payload)), total: contextBudgetTotal() };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Returns the id of the pre-session snapshot the server took, if any. */
|
|
229
|
+
type SessionStartListener = (presence: AgentPresence) => string | null;
|
|
230
|
+
export type SessionEndReason = 'human' | 'agent' | 'idle-timeout';
|
|
231
|
+
type SessionEndListener = (presence: AgentPresence, startSnapshotId: string | null, endedBy: SessionEndReason) => void;
|
|
232
|
+
|
|
233
|
+
export class AgentPresenceRegistry {
|
|
234
|
+
private readonly presences = new Map<string, StoredPresence>();
|
|
235
|
+
/** Newest first; bounded. Survives a writer fading — the feed is history, the writer list is presence. */
|
|
236
|
+
private activity: AgentActivityEntry[] = [];
|
|
237
|
+
private activitySeq = 0;
|
|
238
|
+
private emit: PresenceEmitter = () => {};
|
|
239
|
+
/** Single slots (like the emitter): server.ts owns the pre-session snapshot + receipt. */
|
|
240
|
+
private onSessionStart: SessionStartListener = () => null;
|
|
241
|
+
private onSessionEnd: SessionEndListener = () => {};
|
|
242
|
+
private sweepTimer: ReturnType<typeof setInterval> | null = null;
|
|
243
|
+
private emitTimer: ReturnType<typeof setTimeout> | null = null;
|
|
244
|
+
|
|
245
|
+
/** Inject the workbench SSE emitter (server.ts wires this at module load). */
|
|
246
|
+
setEmitter(emitter: PresenceEmitter | null): void {
|
|
247
|
+
this.emit = emitter ?? (() => {});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Fires when a session attaches (false → true). The listener may snapshot the
|
|
252
|
+
* board and return the snapshot id; the receipt at session end diffs against it.
|
|
253
|
+
*/
|
|
254
|
+
setSessionStartListener(listener: SessionStartListener | null): void {
|
|
255
|
+
this.onSessionStart = listener ?? (() => null);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Fires once when an ATTACHED session ends — by `session-end`, by an explicit
|
|
260
|
+
* `attached: false`, or by the idle-TTL sweep — with the presence as it was
|
|
261
|
+
* and the pre-session snapshot id. Unattached writers fading away never fire it.
|
|
262
|
+
*/
|
|
263
|
+
setSessionEndListener(listener: SessionEndListener | null): void {
|
|
264
|
+
this.onSessionEnd = listener ?? (() => {});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Attribute an agent-less write to the one attached session, so the
|
|
269
|
+
* session's cursor and phase follow its own work no matter which transport
|
|
270
|
+
* carried it. An agent-started session absorbs transport labels only
|
|
271
|
+
* (api/mcp/sdk/cli); a human-started one (`browser`, *Start agent session*)
|
|
272
|
+
* is a placeholder for whichever agent comes next and absorbs any label.
|
|
273
|
+
* Ambiguous (several sessions) or identified (agentId) writes, and a writer
|
|
274
|
+
* that is itself attached, keep their own key.
|
|
275
|
+
*/
|
|
276
|
+
private attributedKey(input: PresenceTouch): string {
|
|
277
|
+
const own = presenceKey(input.source, input.agentId);
|
|
278
|
+
// Identified writers keep their key; attaches run the twin-merge in touch().
|
|
279
|
+
if (input.agentId?.trim() || input.attached === true || this.presences.get(own)?.attached) return own;
|
|
280
|
+
// A channel that attach-merged into a session stays that session's — its
|
|
281
|
+
// writes AND its detach belong to the merged session, however many other
|
|
282
|
+
// sessions are attached.
|
|
283
|
+
const alias = this.aliases.get(own);
|
|
284
|
+
if (alias && this.presences.get(alias)?.attached) return alias;
|
|
285
|
+
if (input.attached !== undefined) return own;
|
|
286
|
+
const attached = [...this.presences.values()].filter((presence) => presence.attached);
|
|
287
|
+
if (attached.length !== 1) return own;
|
|
288
|
+
const session = attached[0]!;
|
|
289
|
+
return session.source === 'browser' || TRANSPORT_SOURCES.includes(input.source) ? session.sessionId : own;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Fold an unattached writer into a session: its ops and feed entries were
|
|
294
|
+
* the session's work all along. A human-started session still carrying its
|
|
295
|
+
* placeholder label takes the writer's name.
|
|
296
|
+
*/
|
|
297
|
+
private fold(shadowKey: string, session: StoredPresence): void {
|
|
298
|
+
const shadow = this.presences.get(shadowKey);
|
|
299
|
+
if (!shadow || shadow.attached) return;
|
|
300
|
+
this.presences.delete(shadowKey);
|
|
301
|
+
if (session.label === HUMAN_STARTED_SESSION_LABEL && !TRANSPORT_SOURCES.includes(shadow.source)) {
|
|
302
|
+
session.label = shadow.label;
|
|
303
|
+
}
|
|
304
|
+
session.opCount += shadow.opCount;
|
|
305
|
+
for (const entry of this.activity) {
|
|
306
|
+
if (entry.sessionId === shadowKey) {
|
|
307
|
+
entry.sessionId = session.sessionId;
|
|
308
|
+
entry.label = session.label;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Aliased channels: a key that attach-merged into another session (extension + its MCP server). */
|
|
314
|
+
private aliases = new Map<string, string>();
|
|
315
|
+
|
|
316
|
+
/** Consumer keys that have claimed steering deliveries this server-run → last claim time. */
|
|
317
|
+
private steeringConsumers = new Map<string, number>();
|
|
318
|
+
|
|
319
|
+
/** A delivery claim proves this consumer polls steering — its presence turns
|
|
320
|
+
* steerable, and the claim time is the roster's proof-of-polling freshness. */
|
|
321
|
+
noteSteeringConsumer(consumer: string): void {
|
|
322
|
+
if (!consumer) return;
|
|
323
|
+
const isNew = !this.steeringConsumers.has(consumer);
|
|
324
|
+
this.steeringConsumers.set(consumer, Date.now());
|
|
325
|
+
if (isNew) this.scheduleEmit();
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** Drop aliases that point at a session that no longer exists. */
|
|
329
|
+
private dropAliasesTo(sessionId: string): void {
|
|
330
|
+
for (const [channel, target] of this.aliases) {
|
|
331
|
+
if (target === sessionId) this.aliases.delete(channel);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Touch a writer: upsert, bump lastSeen, apply the patch. Derived `tooling` decays on its own. */
|
|
336
|
+
touch(input: PresenceTouch, now = Date.now()): AgentPresence {
|
|
337
|
+
let key = this.attributedKey(input);
|
|
338
|
+
const own = presenceKey(input.source, input.agentId);
|
|
339
|
+
if (input.attached === true && !input.agentId?.trim()) {
|
|
340
|
+
// One agent, two channels: an attach whose label matches an ALREADY
|
|
341
|
+
// attached session (Copilot's extension presence + its MCP server both
|
|
342
|
+
// announcing "GitHub Copilot") is the same agent — merge instead of
|
|
343
|
+
// showing a second session, and remember the alias so this channel's
|
|
344
|
+
// later writes and detach land on the merged session too.
|
|
345
|
+
const label = input.label ?? input.source;
|
|
346
|
+
const twin = [...this.presences.values()].find(
|
|
347
|
+
(presence) => presence.attached && presence.sessionId !== own && !presence.agentId && presence.label === label,
|
|
348
|
+
);
|
|
349
|
+
if (twin) {
|
|
350
|
+
this.aliases.set(own, twin.sessionId);
|
|
351
|
+
this.fold(own, twin);
|
|
352
|
+
key = twin.sessionId;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
if (key !== own) {
|
|
356
|
+
const session = this.presences.get(key);
|
|
357
|
+
if (session) {
|
|
358
|
+
// A human-started session takes the name of the agent that fills it.
|
|
359
|
+
if (session.label === HUMAN_STARTED_SESSION_LABEL && !TRANSPORT_SOURCES.includes(input.source)) {
|
|
360
|
+
session.label = input.label ?? input.source;
|
|
361
|
+
}
|
|
362
|
+
// The write was attributed to the session: fold any unattached shadow
|
|
363
|
+
// writer the same label left behind (pre-attach writes) into it.
|
|
364
|
+
this.fold(own, session);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
const existing = this.presences.get(key);
|
|
368
|
+
const stored: StoredPresence = existing ?? {
|
|
369
|
+
sessionId: key,
|
|
370
|
+
source: input.source,
|
|
371
|
+
agentId: input.agentId?.trim() || null,
|
|
372
|
+
label: input.label ?? input.agentId?.trim() ?? input.source,
|
|
373
|
+
phase: 'idle',
|
|
374
|
+
detail: null,
|
|
375
|
+
focusNodeId: null,
|
|
376
|
+
cursor: null,
|
|
377
|
+
attached: false,
|
|
378
|
+
parentAgentId: null,
|
|
379
|
+
lastActivityNodeId: null,
|
|
380
|
+
opCount: 0,
|
|
381
|
+
contextUsage: null,
|
|
382
|
+
lastSeenAt: new Date(now).toISOString(),
|
|
383
|
+
lastSeenMs: now,
|
|
384
|
+
toolingUntilMs: null,
|
|
385
|
+
startSnapshotId: null,
|
|
386
|
+
};
|
|
387
|
+
const wasAttached = stored.attached;
|
|
388
|
+
stored.lastSeenMs = now;
|
|
389
|
+
stored.lastSeenAt = new Date(now).toISOString();
|
|
390
|
+
if (input.label) stored.label = input.label;
|
|
391
|
+
if (input.op) {
|
|
392
|
+
stored.opCount += 1;
|
|
393
|
+
if (input.activity?.summary) {
|
|
394
|
+
this.activitySeq += 1;
|
|
395
|
+
this.activity.unshift({
|
|
396
|
+
id: `act-${this.activitySeq}`,
|
|
397
|
+
at: stored.lastSeenAt,
|
|
398
|
+
sessionId: key,
|
|
399
|
+
label: stored.label,
|
|
400
|
+
op: input.activity.op,
|
|
401
|
+
summary: input.activity.summary,
|
|
402
|
+
nodeId: input.activity.nodeId,
|
|
403
|
+
});
|
|
404
|
+
if (input.activity.nodeId) stored.lastActivityNodeId = input.activity.nodeId;
|
|
405
|
+
if (this.activity.length > MAX_ACTIVITY_ENTRIES) this.activity.length = MAX_ACTIVITY_ENTRIES;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
if (input.attached !== undefined) stored.attached = input.attached;
|
|
409
|
+
if (!wasAttached && input.attached === true) {
|
|
410
|
+
// *Start agent session* with one agent-less writer already on the board
|
|
411
|
+
// adopts that writer — the human is claiming the agent they can see.
|
|
412
|
+
if (stored.source === 'browser') {
|
|
413
|
+
const loose = [...this.presences.values()].filter((presence) => !presence.attached && !presence.agentId);
|
|
414
|
+
const adopted = loose.length === 1 ? loose[0]! : null;
|
|
415
|
+
if (adopted) this.fold(adopted.sessionId, stored);
|
|
416
|
+
// Start must SAY what it adopted — "Idle · Agent session" with no
|
|
417
|
+
// explanation left the human guessing whether anything was picked up.
|
|
418
|
+
try {
|
|
419
|
+
canvasState.recordAxEvent(
|
|
420
|
+
{
|
|
421
|
+
kind: 'note',
|
|
422
|
+
summary: adopted
|
|
423
|
+
? `Started agent session — adopted writer "${adopted.label}" (${adopted.opCount} op${adopted.opCount === 1 ? '' : 's'} folded in).`
|
|
424
|
+
: loose.length === 0
|
|
425
|
+
? 'Started agent session — no loose writer to adopt; agents that attach appear as their own chips.'
|
|
426
|
+
: `Started agent session — ${loose.length} loose writers on the board, none adopted (adoption needs exactly one).`,
|
|
427
|
+
nodeIds: [],
|
|
428
|
+
data: { session: 'start-adoption', adopted: adopted?.sessionId ?? null, looseCount: loose.length },
|
|
429
|
+
},
|
|
430
|
+
{ source: 'browser' },
|
|
431
|
+
);
|
|
432
|
+
} catch {
|
|
433
|
+
// presence bookkeeping must never fail an attach
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
// The human's still-unused placeholder TURNS INTO the first real agent
|
|
437
|
+
// session that attaches (user call 2026-08-24): a third "Agent session ·
|
|
438
|
+
// Idle" chip next to the agent's own is noise, and the human's Start
|
|
439
|
+
// moment stays the receipt baseline via the inherited snapshot. A
|
|
440
|
+
// placeholder that already adopted work is a real session and stays.
|
|
441
|
+
let inheritedSnapshotId: string | null = null;
|
|
442
|
+
if (stored.source !== 'browser') {
|
|
443
|
+
const placeholder = [...this.presences.values()].find(
|
|
444
|
+
(presence) =>
|
|
445
|
+
presence.attached &&
|
|
446
|
+
presence.sessionId !== key &&
|
|
447
|
+
presence.source === 'browser' &&
|
|
448
|
+
presence.label === HUMAN_STARTED_SESSION_LABEL &&
|
|
449
|
+
presence.opCount === 0,
|
|
450
|
+
);
|
|
451
|
+
if (placeholder) {
|
|
452
|
+
this.presences.delete(placeholder.sessionId);
|
|
453
|
+
for (const [channel, target] of this.aliases) {
|
|
454
|
+
if (target === placeholder.sessionId) this.aliases.set(channel, key);
|
|
455
|
+
}
|
|
456
|
+
inheritedSnapshotId = placeholder.startSnapshotId;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
stored.startSnapshotId = inheritedSnapshotId ?? this.onSessionStart(this.publicView(stored, now));
|
|
460
|
+
}
|
|
461
|
+
if (wasAttached && input.attached === false) {
|
|
462
|
+
this.onSessionEnd(this.publicView(stored, now), stored.startSnapshotId, input.endedBy ?? 'agent');
|
|
463
|
+
// An ended session is gone — like `session-end` on the activity feed, it
|
|
464
|
+
// must not linger as an "external writer" until the activity TTL.
|
|
465
|
+
this.presences.delete(key);
|
|
466
|
+
this.dropAliasesTo(key);
|
|
467
|
+
this.maybeStopSweeper();
|
|
468
|
+
this.scheduleEmit();
|
|
469
|
+
return this.publicView({ ...stored, startSnapshotId: null }, now);
|
|
470
|
+
}
|
|
471
|
+
if (input.focusNodeId !== undefined) stored.focusNodeId = input.focusNodeId;
|
|
472
|
+
if (input.parentAgentId !== undefined) stored.parentAgentId = input.parentAgentId;
|
|
473
|
+
if (input.cursor !== undefined) stored.cursor = input.cursor;
|
|
474
|
+
if (input.contextUsage !== undefined) stored.contextUsage = input.contextUsage;
|
|
475
|
+
if (input.phase === 'tooling' && input.derivedTooling) {
|
|
476
|
+
// Copilot's finding: an op-derived tooling touch used to CLOBBER the
|
|
477
|
+
// explicit phase, and the sweep then decayed to idle — an agent whose
|
|
478
|
+
// host said "thinking" read as Idle while hard at work. Derived tooling
|
|
479
|
+
// is an OVERLAY: bump the window + detail, keep the explicit phase.
|
|
480
|
+
stored.toolingUntilMs = now + PRESENCE_TOOLING_SETTLE_MS;
|
|
481
|
+
if (input.detail !== undefined) stored.detail = input.detail;
|
|
482
|
+
} else if (input.phase !== undefined) {
|
|
483
|
+
stored.phase = input.phase;
|
|
484
|
+
stored.detail = input.detail !== undefined ? input.detail : input.phase === 'tooling' ? stored.detail : null;
|
|
485
|
+
stored.toolingUntilMs = input.phase === 'tooling' ? now + PRESENCE_TOOLING_SETTLE_MS : null;
|
|
486
|
+
} else if (input.detail !== undefined) {
|
|
487
|
+
stored.detail = input.detail;
|
|
488
|
+
}
|
|
489
|
+
this.presences.set(key, stored);
|
|
490
|
+
this.evictOverflow();
|
|
491
|
+
this.ensureSweeper();
|
|
492
|
+
this.scheduleEmit();
|
|
493
|
+
return this.publicView(stored, now);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* A host reported the agent's real context window without naming the
|
|
498
|
+
* writer (the legacy `context-usage` workbench event): it belongs to the
|
|
499
|
+
* single attached session, if there is exactly one. Returns false otherwise.
|
|
500
|
+
*/
|
|
501
|
+
reportContextUsage(usage: { used: number; total: number }): boolean {
|
|
502
|
+
const attached = [...this.presences.values()].filter((presence) => presence.attached);
|
|
503
|
+
if (attached.length !== 1) return false;
|
|
504
|
+
const session = attached[0]!;
|
|
505
|
+
this.touch({ source: session.source, agentId: session.agentId, contextUsage: usage });
|
|
506
|
+
return true;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/** Explicit update from an adapter / MCP client (validated). */
|
|
510
|
+
set(raw: unknown, fallbackSource: string): AgentPresence {
|
|
511
|
+
const parsed = presenceSetSchema.safeParse(raw);
|
|
512
|
+
if (!parsed.success) {
|
|
513
|
+
throw new OperationError(`Invalid presence: ${parsed.error.issues.map((issue) => issue.message).join('; ')}`);
|
|
514
|
+
}
|
|
515
|
+
const input = parsed.data;
|
|
516
|
+
const source = input.source ?? fallbackSource;
|
|
517
|
+
if (input.focusNodeId && !canvasState.getNode(input.focusNodeId)) {
|
|
518
|
+
throw new OperationError(`focusNodeId "${input.focusNodeId}" does not exist.`, 404);
|
|
519
|
+
}
|
|
520
|
+
return this.touch({
|
|
521
|
+
source,
|
|
522
|
+
agentId: input.agentId ?? null,
|
|
523
|
+
...(input.label !== undefined ? { label: input.label } : {}),
|
|
524
|
+
...(input.phase !== undefined ? { phase: input.phase } : {}),
|
|
525
|
+
...(input.detail !== undefined ? { detail: input.detail } : {}),
|
|
526
|
+
...(input.focusNodeId !== undefined ? { focusNodeId: input.focusNodeId } : {}),
|
|
527
|
+
...(input.parentAgentId !== undefined ? { parentAgentId: input.parentAgentId } : {}),
|
|
528
|
+
...(input.cursor !== undefined ? { cursor: input.cursor } : {}),
|
|
529
|
+
...(input.attached !== undefined ? { attached: input.attached } : {}),
|
|
530
|
+
...(input.endedBy !== undefined ? { endedBy: input.endedBy } : {}),
|
|
531
|
+
...(input.contextUsage !== undefined ? { contextUsage: input.contextUsage } : {}),
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/** Map an AX activity ingest onto presence — the feed that already exists. */
|
|
536
|
+
observeActivity(
|
|
537
|
+
kind: PmxAxActivityKind,
|
|
538
|
+
input: { source: string; agentId?: string | null; title: string },
|
|
539
|
+
now = Date.now(),
|
|
540
|
+
): void {
|
|
541
|
+
const base = { source: input.source, agentId: input.agentId ?? null };
|
|
542
|
+
switch (kind) {
|
|
543
|
+
case 'session-start':
|
|
544
|
+
this.touch({ ...base, attached: true, phase: 'idle', detail: null, label: input.title }, now);
|
|
545
|
+
return;
|
|
546
|
+
case 'session-end':
|
|
547
|
+
this.detach(presenceKey(base.source, base.agentId));
|
|
548
|
+
return;
|
|
549
|
+
case 'tool-start':
|
|
550
|
+
this.touch({ ...base, phase: 'tooling', detail: input.title }, now);
|
|
551
|
+
return;
|
|
552
|
+
case 'tool-result':
|
|
553
|
+
case 'failure':
|
|
554
|
+
case 'error':
|
|
555
|
+
this.touch({ ...base, phase: 'idle', detail: null }, now);
|
|
556
|
+
return;
|
|
557
|
+
default:
|
|
558
|
+
this.touch(base, now);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Re-emit the snapshot after something the phase is DERIVED from changed
|
|
564
|
+
* (a gate opened or resolved) without any writer being touched.
|
|
565
|
+
*/
|
|
566
|
+
refresh(): void {
|
|
567
|
+
if (this.presences.size > 0) this.scheduleEmit();
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/** Remove a writer (session-end). */
|
|
571
|
+
detach(sessionId: string): boolean {
|
|
572
|
+
const stored = this.presences.get(sessionId);
|
|
573
|
+
const removed = this.presences.delete(sessionId);
|
|
574
|
+
if (removed) {
|
|
575
|
+
this.dropAliasesTo(sessionId);
|
|
576
|
+
if (stored?.attached) this.onSessionEnd(this.publicView(stored, Date.now()), stored.startSnapshotId, 'agent');
|
|
577
|
+
this.scheduleEmit();
|
|
578
|
+
}
|
|
579
|
+
this.maybeStopSweeper();
|
|
580
|
+
return removed;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
snapshot(now = Date.now()): AgentPresenceSnapshot {
|
|
584
|
+
this.sweep(now, { emit: false });
|
|
585
|
+
const presences = [...this.presences.values()].map((stored) => this.publicView(stored, now));
|
|
586
|
+
return {
|
|
587
|
+
presences,
|
|
588
|
+
budget: estimateContextBudget(),
|
|
589
|
+
sessionActive: isSessionActive(presences),
|
|
590
|
+
activity: this.activity.map((entry) => ({ ...entry })),
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/** Test / shutdown hook. */
|
|
595
|
+
reset(): void {
|
|
596
|
+
this.steeringConsumers.clear();
|
|
597
|
+
this.presences.clear();
|
|
598
|
+
this.aliases.clear();
|
|
599
|
+
this.activity = [];
|
|
600
|
+
if (this.emitTimer) {
|
|
601
|
+
clearTimeout(this.emitTimer);
|
|
602
|
+
this.emitTimer = null;
|
|
603
|
+
}
|
|
604
|
+
this.maybeStopSweeper();
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
private publicView(stored: StoredPresence, now: number): AgentPresence {
|
|
608
|
+
const {
|
|
609
|
+
lastSeenMs: _lastSeenMs,
|
|
610
|
+
toolingUntilMs,
|
|
611
|
+
startSnapshotId: _startSnapshotId,
|
|
612
|
+
lastActivityNodeId,
|
|
613
|
+
...presence
|
|
614
|
+
} = stored;
|
|
615
|
+
let phase = presence.phase;
|
|
616
|
+
let detail = presence.detail;
|
|
617
|
+
if (toolingUntilMs !== null && toolingUntilMs > now) {
|
|
618
|
+
// Live tooling overlay (derived or explicit).
|
|
619
|
+
phase = 'tooling';
|
|
620
|
+
} else if (phase === 'tooling' && toolingUntilMs !== null && toolingUntilMs <= now) {
|
|
621
|
+
// Explicit tooling settles to idle; a derived overlay just falls away
|
|
622
|
+
// and the explicit phase (thinking, waiting…) stands.
|
|
623
|
+
phase = 'idle';
|
|
624
|
+
detail = null;
|
|
625
|
+
}
|
|
626
|
+
// Park-at fallback (joint-gaps #3): an attached-but-focusless writer is
|
|
627
|
+
// invisible everywhere — fall back to the last node its activity touched.
|
|
628
|
+
if (!presence.focusNodeId && lastActivityNodeId) presence.focusNodeId = lastActivityNodeId;
|
|
629
|
+
// An attached session blocked on a human decision reads as waiting — the
|
|
630
|
+
// gate itself lives in the AX state, this is only the presence view of it.
|
|
631
|
+
// It overrides tooling too: the gate REQUEST is itself a write, and the
|
|
632
|
+
// human's decision is the state that matters while it is pending.
|
|
633
|
+
if (presence.attached && this.hasPendingGate()) {
|
|
634
|
+
phase = 'waiting-approval';
|
|
635
|
+
detail = null;
|
|
636
|
+
}
|
|
637
|
+
// Steerable = a steer addressed to this writer can actually reach it: an
|
|
638
|
+
// attached session (its adapter loop reads steering), or a consumer key
|
|
639
|
+
// that has claimed deliveries this server-run. A one-shot writer (curl,
|
|
640
|
+
// the CLI) is presence without an inbox — offering it as a steer target
|
|
641
|
+
// reads as steerable while the steer would sit unclaimed forever.
|
|
642
|
+
const steerable =
|
|
643
|
+
presence.attached ||
|
|
644
|
+
this.steeringConsumers.has(presence.sessionId) ||
|
|
645
|
+
this.steeringConsumers.has(presence.source);
|
|
646
|
+
// Pump health: how many steers sit unclaimed in this writer's queue (a
|
|
647
|
+
// growing count on a steerable row means its loop is not actually polling)
|
|
648
|
+
// plus when its consumer key last claimed — attached ≠ polling (a desktop
|
|
649
|
+
// session can present as steerable while nothing reads its inbox).
|
|
650
|
+
const pendingSteers = steerable ? canvasState.getPendingSteeringCount(presence.sessionId) : undefined;
|
|
651
|
+
const lastClaimMs = this.steeringConsumers.get(presence.sessionId) ?? this.steeringConsumers.get(presence.source);
|
|
652
|
+
return {
|
|
653
|
+
...presence,
|
|
654
|
+
phase,
|
|
655
|
+
detail,
|
|
656
|
+
steerable,
|
|
657
|
+
...(pendingSteers !== undefined ? { pendingSteers } : {}),
|
|
658
|
+
...(lastClaimMs !== undefined ? { lastClaimAt: new Date(lastClaimMs).toISOString() } : {}),
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
private hasPendingGate(): boolean {
|
|
663
|
+
return canvasState.getAxState().approvalGates.some((gate) => gate.status === 'pending');
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
private evictOverflow(): void {
|
|
667
|
+
while (this.presences.size > MAX_PRESENCES) {
|
|
668
|
+
let oldestKey: string | null = null;
|
|
669
|
+
let oldestMs = Number.POSITIVE_INFINITY;
|
|
670
|
+
for (const [key, presence] of this.presences) {
|
|
671
|
+
if (presence.lastSeenMs < oldestMs) {
|
|
672
|
+
oldestMs = presence.lastSeenMs;
|
|
673
|
+
oldestKey = key;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
if (!oldestKey) break;
|
|
677
|
+
this.presences.delete(oldestKey);
|
|
678
|
+
this.dropAliasesTo(oldestKey);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
private sweep(now = Date.now(), options: { emit?: boolean } = {}): void {
|
|
683
|
+
let changed = false;
|
|
684
|
+
for (const [key, presence] of this.presences) {
|
|
685
|
+
const ttl = presence.attached ? PRESENCE_ATTACHED_IDLE_TTL_MS : PRESENCE_ACTIVITY_TTL_MS;
|
|
686
|
+
if (presence.lastSeenMs + ttl <= now) {
|
|
687
|
+
this.presences.delete(key);
|
|
688
|
+
this.dropAliasesTo(key);
|
|
689
|
+
if (presence.attached)
|
|
690
|
+
this.onSessionEnd(this.publicView(presence, now), presence.startSnapshotId, 'idle-timeout');
|
|
691
|
+
changed = true;
|
|
692
|
+
continue;
|
|
693
|
+
}
|
|
694
|
+
// Explicit thinking that nothing refreshed settles to idle: the host set
|
|
695
|
+
// it at turn start and never sent the turn-end idle (three "Thinking"
|
|
696
|
+
// chips on a silent board). Any touch bumps lastSeenMs, so an agent
|
|
697
|
+
// that IS doing canvas work keeps its thinking chip.
|
|
698
|
+
if (presence.phase === 'thinking' && now - presence.lastSeenMs > PRESENCE_THINKING_SETTLE_MS) {
|
|
699
|
+
presence.phase = 'idle';
|
|
700
|
+
presence.detail = null;
|
|
701
|
+
changed = true;
|
|
702
|
+
}
|
|
703
|
+
// A decayed `tooling` is a visible change too — emit so the chip settles.
|
|
704
|
+
if (presence.phase === 'tooling' && presence.toolingUntilMs !== null && presence.toolingUntilMs <= now) {
|
|
705
|
+
presence.phase = 'idle';
|
|
706
|
+
presence.detail = null;
|
|
707
|
+
presence.toolingUntilMs = null;
|
|
708
|
+
changed = true;
|
|
709
|
+
} else if (presence.toolingUntilMs !== null && presence.toolingUntilMs <= now) {
|
|
710
|
+
// A DERIVED overlay over an explicit phase (thinking, waiting…):
|
|
711
|
+
// publicView already falls back to the stored phase after expiry, but
|
|
712
|
+
// without an emit no client ever hears — the chip showed "Running
|
|
713
|
+
// ax.delivery.mark" forever (0.5.0 reverify, Copilot root-cause).
|
|
714
|
+
presence.toolingUntilMs = null;
|
|
715
|
+
changed = true;
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
if (changed && options.emit !== false) this.scheduleEmit();
|
|
719
|
+
this.maybeStopSweeper();
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/** Coalesce bursts (batch churn) into one frame per tick. */
|
|
723
|
+
private scheduleEmit(): void {
|
|
724
|
+
if (this.emitTimer) return;
|
|
725
|
+
this.emitTimer = setTimeout(() => {
|
|
726
|
+
this.emitTimer = null;
|
|
727
|
+
this.emit('agent-presence', this.snapshot() as unknown as Record<string, unknown>);
|
|
728
|
+
}, 50);
|
|
729
|
+
(this.emitTimer as { unref?: () => void }).unref?.();
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
private ensureSweeper(): void {
|
|
733
|
+
if (this.sweepTimer || this.presences.size === 0) return;
|
|
734
|
+
this.sweepTimer = setInterval(() => this.sweep(), 1000);
|
|
735
|
+
(this.sweepTimer as { unref?: () => void }).unref?.();
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
private maybeStopSweeper(): void {
|
|
739
|
+
if (this.sweepTimer && this.presences.size === 0) {
|
|
740
|
+
clearInterval(this.sweepTimer);
|
|
741
|
+
this.sweepTimer = null;
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/** Process-wide singleton, shared across HTTP handlers, MCP ops, and the SDK. */
|
|
747
|
+
export const agentPresence = new AgentPresenceRegistry();
|
|
748
|
+
|
|
749
|
+
// The attached session's phase and budget DERIVE from AX state (pending gates)
|
|
750
|
+
// and context pins. Re-emit on every AX / pin change — whatever transport made
|
|
751
|
+
// it — so no caller has to remember to refresh, and the SDK paths that bypass
|
|
752
|
+
// the operation registry are covered too.
|
|
753
|
+
canvasState.onChange((type) => {
|
|
754
|
+
if (type === 'ax' || type === 'pins') agentPresence.refresh();
|
|
755
|
+
});
|