pmx-canvas 0.4.7 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (212) hide show
  1. package/.github/extensions/pmx-canvas/extension.mjs +154 -5
  2. package/.github/extensions/pmx-canvas/steering-delivery.mjs +79 -0
  3. package/CHANGELOG.md +156 -0
  4. package/Readme.md +72 -23
  5. package/dist/canvas/global.css +3069 -796
  6. package/dist/canvas/index.js +104 -104
  7. package/dist/json-render/index.css +1 -1
  8. package/dist/types/cli/agent.d.ts +1 -0
  9. package/dist/types/cli/commands/pump.d.ts +9 -0
  10. package/dist/types/client/App.d.ts +1 -1
  11. package/dist/types/client/canvas/AgentPresenceLayer.d.ts +11 -0
  12. package/dist/types/client/canvas/BarHint.d.ts +22 -0
  13. package/dist/types/client/canvas/CanvasNode.d.ts +5 -0
  14. package/dist/types/client/canvas/CanvasViewport.d.ts +4 -4
  15. package/dist/types/client/canvas/CommandBar.d.ts +10 -0
  16. package/dist/types/client/canvas/ConnectionBanner.d.ts +13 -0
  17. package/dist/types/client/canvas/ContextMenu.d.ts +6 -0
  18. package/dist/types/client/canvas/EdgeLayer.d.ts +2 -1
  19. package/dist/types/client/canvas/EmptyState.d.ts +11 -0
  20. package/dist/types/client/canvas/ExternalWriters.d.ts +5 -0
  21. package/dist/types/client/canvas/HumanPresenceLayer.d.ts +8 -0
  22. package/dist/types/client/canvas/Minimap.d.ts +12 -1
  23. package/dist/types/client/canvas/ScopeFenceLayer.d.ts +8 -0
  24. package/dist/types/client/canvas/SelectionBar.d.ts +6 -0
  25. package/dist/types/client/canvas/SessionPanel.d.ts +1 -0
  26. package/dist/types/client/canvas/SessionReceipt.d.ts +17 -0
  27. package/dist/types/client/canvas/TextPrompt.d.ts +25 -0
  28. package/dist/types/client/canvas/ToolRail.d.ts +21 -0
  29. package/dist/types/client/canvas/TopBar.d.ts +7 -0
  30. package/dist/types/client/canvas/canvas-area.d.ts +25 -0
  31. package/dist/types/client/canvas/create-in-view.d.ts +18 -0
  32. package/dist/types/client/canvas/import-files.d.ts +8 -0
  33. package/dist/types/client/canvas/kind-colors.d.ts +3 -0
  34. package/dist/types/client/canvas/use-focus-trap.d.ts +14 -0
  35. package/dist/types/client/canvas/use-now.d.ts +6 -0
  36. package/dist/types/client/icons.d.ts +11 -4
  37. package/dist/types/client/nodes/ExtAppFrame.d.ts +32 -0
  38. package/dist/types/client/nodes/GroupNode.d.ts +6 -1
  39. package/dist/types/client/nodes/MarkdownNode.d.ts +2 -0
  40. package/dist/types/client/nodes/inline-editor-commands.d.ts +3 -4
  41. package/dist/types/client/state/canvas-store.d.ts +49 -5
  42. package/dist/types/client/state/human-store.d.ts +26 -0
  43. package/dist/types/client/state/iframe-mode.d.ts +1 -0
  44. package/dist/types/client/state/intent-bridge.d.ts +37 -5
  45. package/dist/types/client/state/node-factory.d.ts +0 -1
  46. package/dist/types/client/state/presence-store.d.ts +78 -0
  47. package/dist/types/client/state/session-store.d.ts +176 -0
  48. package/dist/types/client/types.d.ts +2 -1
  49. package/dist/types/client/utils/platform.d.ts +2 -0
  50. package/dist/types/server/agent-presence.d.ts +192 -0
  51. package/dist/types/server/ax-gate-ttl.d.ts +6 -0
  52. package/dist/types/server/ax-interaction.d.ts +4 -10
  53. package/dist/types/server/ax-state-manager.d.ts +21 -2
  54. package/dist/types/server/ax-state.d.ts +49 -3
  55. package/dist/types/server/ax-wait.d.ts +13 -0
  56. package/dist/types/server/canvas-db.d.ts +23 -1
  57. package/dist/types/server/canvas-operations.d.ts +1 -0
  58. package/dist/types/server/canvas-state.d.ts +27 -13
  59. package/dist/types/server/canvas-validation.d.ts +0 -14
  60. package/dist/types/server/html-surface.d.ts +1 -1
  61. package/dist/types/server/human-presence.d.ts +40 -0
  62. package/dist/types/server/index.d.ts +29 -3
  63. package/dist/types/server/intent-registry.d.ts +7 -0
  64. package/dist/types/server/mutation-history.d.ts +15 -2
  65. package/dist/types/server/operations/invoker.d.ts +13 -2
  66. package/dist/types/server/operations/ops/ax-presence.d.ts +2 -0
  67. package/dist/types/server/operations/ops/human-presence.d.ts +3 -0
  68. package/dist/types/server/operations/registry.d.ts +11 -0
  69. package/dist/types/server/operations/types.d.ts +1 -1
  70. package/dist/types/server/scope-fence.d.ts +62 -0
  71. package/dist/types/server/server.d.ts +0 -12
  72. package/dist/types/shared/agent-presence.d.ts +130 -0
  73. package/dist/types/shared/approval-gates.d.ts +19 -0
  74. package/dist/types/shared/auto-arrange.d.ts +0 -1
  75. package/dist/types/shared/ax-kinds.d.ts +11 -0
  76. package/dist/types/shared/human-presence.d.ts +31 -0
  77. package/dist/types/shared/iframe-probe.d.ts +8 -0
  78. package/dist/types/shared/scope-fence.d.ts +21 -0
  79. package/docs/RELEASE.md +54 -0
  80. package/docs/ax-host-adapter-contract.md +30 -14
  81. package/docs/cli.md +37 -1
  82. package/docs/environment.md +3 -0
  83. package/docs/http-api.md +222 -5
  84. package/docs/mcp.md +6 -5
  85. package/docs/node-types.md +3 -1
  86. package/docs/plans/plan-009-tech-debt-backlog.md +13 -0
  87. package/docs/screenshot.png +0 -0
  88. package/docs/screenshots/multi-agent-live.png +0 -0
  89. package/docs/screenshots/workbench-live-dark.png +0 -0
  90. package/docs/screenshots/workbench-live-light.png +0 -0
  91. package/docs/sdk.md +1 -1
  92. package/package.json +1 -1
  93. package/skills/pmx-canvas/SKILL.md +109 -18
  94. package/skills/pmx-canvas/references/codex-app-adapter.md +28 -0
  95. package/skills/pmx-canvas/references/full-reference.md +55 -16
  96. package/skills/pmx-canvas/references/github-copilot-app-adapter.md +41 -24
  97. package/skills/pmx-canvas-orchestration/SKILL.md +56 -2
  98. package/skills/pmx-canvas-testing/SKILL.md +57 -0
  99. package/src/cli/agent.ts +1 -0
  100. package/src/cli/commands/copilot.ts +15 -8
  101. package/src/cli/commands/nodes.ts +1 -19
  102. package/src/cli/commands/pump.ts +252 -0
  103. package/src/cli/index.ts +1 -5
  104. package/src/cli/shared.ts +1 -1
  105. package/src/client/App.tsx +209 -585
  106. package/src/client/canvas/AgentPresenceLayer.tsx +74 -0
  107. package/src/client/canvas/AttentionHistory.tsx +0 -10
  108. package/src/client/canvas/BarHint.tsx +49 -0
  109. package/src/client/canvas/CanvasNode.tsx +488 -136
  110. package/src/client/canvas/CanvasViewport.tsx +190 -94
  111. package/src/client/canvas/CommandBar.tsx +140 -0
  112. package/src/client/canvas/CommandPalette.tsx +133 -58
  113. package/src/client/canvas/ConnectionBanner.tsx +65 -0
  114. package/src/client/canvas/ContextMenu.tsx +88 -27
  115. package/src/client/canvas/ContextPinBar.tsx +2 -2
  116. package/src/client/canvas/EdgeLayer.tsx +121 -38
  117. package/src/client/canvas/EmptyState.tsx +93 -0
  118. package/src/client/canvas/ExpandedNodeOverlay.tsx +64 -118
  119. package/src/client/canvas/ExternalWriters.tsx +308 -0
  120. package/src/client/canvas/FocusFieldLayer.tsx +12 -1
  121. package/src/client/canvas/HumanPresenceLayer.tsx +45 -0
  122. package/src/client/canvas/IntentLayer.tsx +1 -1
  123. package/src/client/canvas/Minimap.tsx +117 -190
  124. package/src/client/canvas/ScopeFenceLayer.tsx +38 -0
  125. package/src/client/canvas/SelectionBar.tsx +175 -23
  126. package/src/client/canvas/SessionPanel.tsx +481 -0
  127. package/src/client/canvas/SessionReceipt.tsx +141 -0
  128. package/src/client/canvas/ShortcutOverlay.tsx +21 -6
  129. package/src/client/canvas/SnapshotPanel.tsx +166 -80
  130. package/src/client/canvas/TextPrompt.tsx +128 -0
  131. package/src/client/canvas/ToolRail.tsx +447 -0
  132. package/src/client/canvas/TopBar.tsx +308 -0
  133. package/src/client/canvas/auto-fit.ts +1 -5
  134. package/src/client/canvas/canvas-area.ts +41 -0
  135. package/src/client/canvas/create-in-view.ts +48 -0
  136. package/src/client/canvas/import-files.ts +64 -0
  137. package/src/client/canvas/kind-colors.ts +22 -0
  138. package/src/client/canvas/snap-guides.ts +1 -1
  139. package/src/client/canvas/use-focus-trap.ts +54 -0
  140. package/src/client/canvas/use-now.ts +17 -0
  141. package/src/client/canvas/use-pan-zoom.ts +13 -2
  142. package/src/client/icons.tsx +57 -18
  143. package/src/client/nodes/ExtAppFrame.tsx +79 -2
  144. package/src/client/nodes/GroupNode.tsx +9 -28
  145. package/src/client/nodes/InlineFormatBar.tsx +4 -3
  146. package/src/client/nodes/InlineMarkdownEditor.tsx +27 -0
  147. package/src/client/nodes/MarkdownNode.tsx +59 -24
  148. package/src/client/nodes/MdFormatBar.tsx +2 -4
  149. package/src/client/nodes/inline-editor-commands.ts +17 -6
  150. package/src/client/nodes/use-surface-frame.ts +3 -1
  151. package/src/client/state/attention-bridge.ts +3 -0
  152. package/src/client/state/canvas-store.ts +273 -84
  153. package/src/client/state/human-store.ts +175 -0
  154. package/src/client/state/iframe-mode.ts +33 -9
  155. package/src/client/state/intent-bridge.ts +75 -13
  156. package/src/client/state/node-factory.ts +2 -8
  157. package/src/client/state/presence-store.ts +168 -0
  158. package/src/client/state/session-store.ts +419 -0
  159. package/src/client/state/sse-bridge.ts +93 -30
  160. package/src/client/theme/global.css +3069 -796
  161. package/src/client/types.ts +3 -1
  162. package/src/client/utils/platform.ts +6 -1
  163. package/src/mcp/canvas-access.ts +2 -2
  164. package/src/server/agent-presence.ts +755 -0
  165. package/src/server/ax-context.ts +45 -0
  166. package/src/server/ax-gate-ttl.ts +61 -0
  167. package/src/server/ax-interaction.ts +29 -1
  168. package/src/server/ax-state-manager.ts +78 -6
  169. package/src/server/ax-state.ts +82 -15
  170. package/src/server/ax-wait.ts +33 -0
  171. package/src/server/canvas-db.ts +237 -61
  172. package/src/server/canvas-operations.ts +17 -8
  173. package/src/server/canvas-state.ts +131 -58
  174. package/src/server/canvas-validation.ts +3 -53
  175. package/src/server/demo-state.json +2 -70
  176. package/src/server/html-surface.ts +11 -2
  177. package/src/server/human-presence.ts +160 -0
  178. package/src/server/index.ts +107 -4
  179. package/src/server/intent-registry.ts +13 -0
  180. package/src/server/mutation-history.ts +47 -2
  181. package/src/server/operations/composites.ts +6 -3
  182. package/src/server/operations/http.ts +10 -1
  183. package/src/server/operations/index.ts +4 -0
  184. package/src/server/operations/invoker.ts +33 -6
  185. package/src/server/operations/ops/ax-presence.ts +62 -0
  186. package/src/server/operations/ops/ax-read.ts +8 -0
  187. package/src/server/operations/ops/ax-state.ts +106 -1
  188. package/src/server/operations/ops/ax-timeline.ts +54 -5
  189. package/src/server/operations/ops/ax-work.ts +46 -0
  190. package/src/server/operations/ops/batch.ts +3 -1
  191. package/src/server/operations/ops/edges.ts +88 -2
  192. package/src/server/operations/ops/groups.ts +4 -3
  193. package/src/server/operations/ops/human-presence.ts +35 -0
  194. package/src/server/operations/ops/json-render.ts +11 -1
  195. package/src/server/operations/ops/nodes.ts +65 -16
  196. package/src/server/operations/ops/query.ts +1 -0
  197. package/src/server/operations/ops/snapshots.ts +16 -0
  198. package/src/server/operations/registry.ts +196 -1
  199. package/src/server/operations/types.ts +1 -1
  200. package/src/server/scope-fence.ts +207 -0
  201. package/src/server/server.ts +177 -79
  202. package/src/server/trace-manager.ts +0 -2
  203. package/src/server/web-artifacts.ts +0 -1
  204. package/src/shared/agent-presence.ts +162 -0
  205. package/src/shared/approval-gates.ts +35 -0
  206. package/src/shared/auto-arrange.ts +121 -13
  207. package/src/shared/ax-kinds.ts +23 -0
  208. package/src/shared/human-presence.ts +30 -0
  209. package/src/shared/iframe-probe.ts +8 -0
  210. package/src/shared/scope-fence.ts +31 -0
  211. package/dist/types/client/canvas/DockedNode.d.ts +0 -4
  212. package/src/client/canvas/DockedNode.tsx +0 -165
@@ -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
+ });