@haywoodmarx/agent-pager 0.1.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 (178) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +63 -0
  3. package/dist/src/broker/address.d.ts +49 -0
  4. package/dist/src/broker/address.js +110 -0
  5. package/dist/src/broker/broker.d.ts +665 -0
  6. package/dist/src/broker/broker.js +2270 -0
  7. package/dist/src/broker/database.d.ts +81 -0
  8. package/dist/src/broker/database.js +69 -0
  9. package/dist/src/broker/operator-projection.d.ts +291 -0
  10. package/dist/src/broker/operator-projection.js +722 -0
  11. package/dist/src/broker/protocol.d.ts +237 -0
  12. package/dist/src/broker/protocol.js +311 -0
  13. package/dist/src/broker/schema.d.ts +94 -0
  14. package/dist/src/broker/schema.js +454 -0
  15. package/dist/src/broker/snapshot.d.ts +171 -0
  16. package/dist/src/broker/snapshot.js +78 -0
  17. package/dist/src/configuration-defaults.d.ts +64 -0
  18. package/dist/src/configuration-defaults.js +48 -0
  19. package/dist/src/configuration.d.ts +76 -0
  20. package/dist/src/configuration.js +156 -0
  21. package/dist/src/implementations/claude/environment.d.ts +8 -0
  22. package/dist/src/implementations/claude/environment.js +9 -0
  23. package/dist/src/implementations/claude/gateway-cli.d.ts +2 -0
  24. package/dist/src/implementations/claude/gateway-cli.js +136 -0
  25. package/dist/src/implementations/claude/gateway-identity.d.ts +134 -0
  26. package/dist/src/implementations/claude/gateway-identity.js +103 -0
  27. package/dist/src/implementations/claude/gateway.d.ts +148 -0
  28. package/dist/src/implementations/claude/gateway.js +236 -0
  29. package/dist/src/implementations/claude/instructions.d.ts +33 -0
  30. package/dist/src/implementations/claude/instructions.js +64 -0
  31. package/dist/src/implementations/claude/manifest.d.ts +33 -0
  32. package/dist/src/implementations/claude/manifest.js +108 -0
  33. package/dist/src/implementations/claude/plugin-directory.d.ts +80 -0
  34. package/dist/src/implementations/claude/plugin-directory.js +137 -0
  35. package/dist/src/implementations/claude/session-continuation.d.ts +25 -0
  36. package/dist/src/implementations/claude/session-continuation.js +125 -0
  37. package/dist/src/implementations/claude/session-identity.d.ts +25 -0
  38. package/dist/src/implementations/claude/session-identity.js +31 -0
  39. package/dist/src/implementations/claude/session-start-cli.d.ts +2 -0
  40. package/dist/src/implementations/claude/session-start-cli.js +16 -0
  41. package/dist/src/implementations/claude/socket-delivery.d.ts +52 -0
  42. package/dist/src/implementations/claude/socket-delivery.js +159 -0
  43. package/dist/src/implementations/model.d.ts +162 -0
  44. package/dist/src/implementations/model.js +1 -0
  45. package/dist/src/implementations/opencode/adapter.d.ts +71 -0
  46. package/dist/src/implementations/opencode/adapter.js +94 -0
  47. package/dist/src/implementations/opencode/environment.d.ts +8 -0
  48. package/dist/src/implementations/opencode/environment.js +9 -0
  49. package/dist/src/implementations/opencode/manifest.d.ts +30 -0
  50. package/dist/src/implementations/opencode/manifest.js +93 -0
  51. package/dist/src/implementations/opencode/plugin-api.d.ts +120 -0
  52. package/dist/src/implementations/opencode/plugin-api.js +1 -0
  53. package/dist/src/implementations/opencode/plugin.d.ts +38 -0
  54. package/dist/src/implementations/opencode/plugin.js +303 -0
  55. package/dist/src/implementations/opencode/self-close-tool.d.ts +9 -0
  56. package/dist/src/implementations/opencode/self-close-tool.js +31 -0
  57. package/dist/src/implementations/orca/terminal.d.ts +48 -0
  58. package/dist/src/implementations/orca/terminal.js +154 -0
  59. package/dist/src/implementations/pi/broker-client.d.ts +56 -0
  60. package/dist/src/implementations/pi/broker-client.js +156 -0
  61. package/dist/src/implementations/pi/environment.d.ts +8 -0
  62. package/dist/src/implementations/pi/environment.js +9 -0
  63. package/dist/src/implementations/pi/extension.d.ts +57 -0
  64. package/dist/src/implementations/pi/extension.js +253 -0
  65. package/dist/src/implementations/pi/host-slots.d.ts +18 -0
  66. package/dist/src/implementations/pi/host-slots.js +27 -0
  67. package/dist/src/implementations/pi/manifest.d.ts +11 -0
  68. package/dist/src/implementations/pi/manifest.js +61 -0
  69. package/dist/src/implementations/pi/self-close-tool.d.ts +7 -0
  70. package/dist/src/implementations/pi/self-close-tool.js +27 -0
  71. package/dist/src/implementations/registry.d.ts +47 -0
  72. package/dist/src/implementations/registry.js +157 -0
  73. package/dist/src/implementations/session-key.d.ts +13 -0
  74. package/dist/src/implementations/session-key.js +19 -0
  75. package/dist/src/index.d.ts +27 -0
  76. package/dist/src/index.js +26 -0
  77. package/dist/src/launch/broker-lock.d.ts +257 -0
  78. package/dist/src/launch/broker-lock.js +644 -0
  79. package/dist/src/launch/cli.d.ts +37 -0
  80. package/dist/src/launch/cli.js +317 -0
  81. package/dist/src/launch/environment.d.ts +45 -0
  82. package/dist/src/launch/environment.js +48 -0
  83. package/dist/src/launch/launch.d.ts +235 -0
  84. package/dist/src/launch/launch.js +223 -0
  85. package/dist/src/launch/on-demand.d.ts +202 -0
  86. package/dist/src/launch/on-demand.js +435 -0
  87. package/dist/src/launch/runtime-composition.d.ts +88 -0
  88. package/dist/src/launch/runtime-composition.js +253 -0
  89. package/dist/src/launch/runtime.d.ts +79 -0
  90. package/dist/src/launch/runtime.js +196 -0
  91. package/dist/src/launch/service-system.d.ts +11 -0
  92. package/dist/src/launch/service-system.js +39 -0
  93. package/dist/src/launch/service.d.ts +173 -0
  94. package/dist/src/launch/service.js +492 -0
  95. package/dist/src/launch/skill.d.ts +57 -0
  96. package/dist/src/launch/skill.js +81 -0
  97. package/dist/src/launch/terminal.d.ts +26 -0
  98. package/dist/src/launch/terminal.js +43 -0
  99. package/dist/src/launch/workspace-branch.d.ts +10 -0
  100. package/dist/src/launch/workspace-branch.js +29 -0
  101. package/dist/src/messaging/delivery-client.d.ts +144 -0
  102. package/dist/src/messaging/delivery-client.js +261 -0
  103. package/dist/src/messaging/delivery.d.ts +75 -0
  104. package/dist/src/messaging/delivery.js +22 -0
  105. package/dist/src/messaging/inbox-socket.d.ts +98 -0
  106. package/dist/src/messaging/inbox-socket.js +278 -0
  107. package/dist/src/messaging/mcp.d.ts +53 -0
  108. package/dist/src/messaging/mcp.js +501 -0
  109. package/dist/src/messaging/notifications.d.ts +231 -0
  110. package/dist/src/messaging/notifications.js +489 -0
  111. package/dist/src/messaging/push-delivery.d.ts +77 -0
  112. package/dist/src/messaging/push-delivery.js +187 -0
  113. package/dist/src/messaging/tools.d.ts +11 -0
  114. package/dist/src/messaging/tools.js +102 -0
  115. package/dist/src/messaging/wake.d.ts +23 -0
  116. package/dist/src/messaging/wake.js +39 -0
  117. package/dist/src/operator/agent-projection.d.ts +102 -0
  118. package/dist/src/operator/agent-projection.js +249 -0
  119. package/dist/src/operator/commands.d.ts +50 -0
  120. package/dist/src/operator/commands.js +199 -0
  121. package/dist/src/operator/diagnostics.d.ts +21 -0
  122. package/dist/src/operator/diagnostics.js +22 -0
  123. package/dist/src/operator/protocol.d.ts +511 -0
  124. package/dist/src/operator/protocol.js +49 -0
  125. package/dist/src/operator/repository-system.d.ts +14 -0
  126. package/dist/src/operator/repository-system.js +150 -0
  127. package/dist/src/operator/repository.d.ts +79 -0
  128. package/dist/src/operator/repository.js +72 -0
  129. package/dist/src/operator/traffic-projection.d.ts +19 -0
  130. package/dist/src/operator/traffic-projection.js +229 -0
  131. package/dist/src/operator/transport.d.ts +87 -0
  132. package/dist/src/operator/transport.js +549 -0
  133. package/dist/src/operator/update-store.d.ts +53 -0
  134. package/dist/src/operator/update-store.js +162 -0
  135. package/dist/src/operator/updates.d.ts +94 -0
  136. package/dist/src/operator/updates.js +280 -0
  137. package/dist/src/operator/validation.d.ts +114 -0
  138. package/dist/src/operator/validation.js +916 -0
  139. package/dist/src/shared/guards.d.ts +59 -0
  140. package/dist/src/shared/guards.js +119 -0
  141. package/dist/src/shared/http.d.ts +30 -0
  142. package/dist/src/shared/http.js +50 -0
  143. package/dist/src/shared/row.d.ts +2 -0
  144. package/dist/src/shared/row.js +1 -0
  145. package/dist/src/shared/shell.d.ts +6 -0
  146. package/dist/src/shared/shell.js +8 -0
  147. package/dist/src/tui/actions.d.ts +166 -0
  148. package/dist/src/tui/actions.js +253 -0
  149. package/dist/src/tui/agent-tree.d.ts +125 -0
  150. package/dist/src/tui/agent-tree.js +663 -0
  151. package/dist/src/tui/app.d.ts +38 -0
  152. package/dist/src/tui/app.js +378 -0
  153. package/dist/src/tui/chrome.d.ts +86 -0
  154. package/dist/src/tui/chrome.js +85 -0
  155. package/dist/src/tui/client.d.ts +41 -0
  156. package/dist/src/tui/client.js +279 -0
  157. package/dist/src/tui/composition.d.ts +41 -0
  158. package/dist/src/tui/composition.js +146 -0
  159. package/dist/src/tui/message-views.d.ts +94 -0
  160. package/dist/src/tui/message-views.js +515 -0
  161. package/dist/src/tui/model.d.ts +414 -0
  162. package/dist/src/tui/model.js +758 -0
  163. package/dist/src/tui/presentation.d.ts +350 -0
  164. package/dist/src/tui/presentation.js +541 -0
  165. package/dist/src/tui/production.d.ts +79 -0
  166. package/dist/src/tui/production.js +253 -0
  167. package/dist/src/tui/run-terminal.d.ts +32 -0
  168. package/dist/src/tui/run-terminal.js +54 -0
  169. package/dist/src/tui/runtime.d.ts +113 -0
  170. package/dist/src/tui/runtime.js +189 -0
  171. package/dist/src/tui/theme.d.ts +49 -0
  172. package/dist/src/tui/theme.js +55 -0
  173. package/dist/src/tui/transport.d.ts +31 -0
  174. package/dist/src/tui/transport.js +255 -0
  175. package/dist/src/version.d.ts +11 -0
  176. package/dist/src/version.js +52 -0
  177. package/package.json +142 -0
  178. package/skills/pager/SKILL.md +112 -0
@@ -0,0 +1,758 @@
1
+ import { archivedOperatorRepositoryIds, selectOperatorAgentHealth, } from "../operator/agent-projection.js";
2
+ import { OPERATOR_PROTOCOL_VERSION, } from "../operator/protocol.js";
3
+ import { classifyOperatorEvent, validateOperatorSnapshot } from "../operator/validation.js";
4
+ import { isTuiExpirable } from "../operator/agent-projection.js";
5
+ /* eslint-enable jsdoc/require-jsdoc -- The state field declarations end here. */
6
+ /** Entries the diagnostics log keeps before discarding its oldest. */
7
+ const TUI_DIAGNOSTIC_LIMIT = 200;
8
+ /** Delay before the first reconnect attempt, in milliseconds. */
9
+ const TUI_RECONNECT_BASE_DELAY_MS = 200;
10
+ /** Ceiling the doubling reconnect delay never exceeds, in milliseconds. */
11
+ const TUI_RECONNECT_MAX_DELAY_MS = 5_000;
12
+ /** Time a server has to deliver the replacement snapshot it owes before recovery restarts, in milliseconds. */
13
+ const TUI_REPLACEMENT_TIMEOUT_MS = 5_000;
14
+ /**
15
+ * Total time one outstanding replacement transfer may spend extending its deadline before recovery
16
+ * restarts regardless of continuing progress, in milliseconds.
17
+ *
18
+ * Bounds wall-clock time since the wait began rather than a count of extensions, since chunk size and
19
+ * cadence are server-chosen and unbounded per-chunk resets alone would let a slow-but-steady transfer
20
+ * hold the wait open indefinitely.
21
+ */
22
+ const TUI_REPLACEMENT_TOTAL_LIMIT_MS = 60_000;
23
+ /** Attempt count the backoff stops counting past, so the doubling cannot overflow. */
24
+ const TUI_RECONNECT_MAX_ATTEMPT = 16;
25
+ /**
26
+ * Drop whole chunks from the front until the replay buffer fits its budget.
27
+ *
28
+ * Chunks are never split, because half a terminal escape sequence would leave the emulator misreading
29
+ * every byte that follows it.
30
+ * @param chunks - Replay buffer with the newest chunk appended.
31
+ * @param truncated - Whether an earlier chunk had already been dropped.
32
+ * @returns - Bounded buffer and whether anything has ever been dropped from it.
33
+ */
34
+ function bounded(chunks, truncated) {
35
+ let total = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0);
36
+ let first = 0;
37
+ while (first < chunks.length - 1 && total > TUI_RUN_OUTPUT_BYTE_LIMIT) {
38
+ const dropped = chunks[first];
39
+ if (!dropped)
40
+ break;
41
+ total -= dropped.byteLength;
42
+ first += 1;
43
+ }
44
+ return first === 0 ? { chunks, truncated } : { chunks: chunks.slice(first), truncated: true };
45
+ }
46
+ /** Bytes a run's replay buffer keeps before it drops its oldest chunks. */
47
+ export const TUI_RUN_OUTPUT_BYTE_LIMIT = 256 * 1024;
48
+ /**
49
+ * Renderer-independent authoritative-state controller.
50
+ *
51
+ * Data flows broker → model → view. The model holds the authoritative facts and one map derived from
52
+ * them, `healthByAgentId`; every other derivation is computed at read time, in `presentation.ts` or
53
+ * beside the component that reads it, and never stored here. A view renders immutable state read
54
+ * through `useSyncExternalStore` and calls model intents, but a key or pointer handler reads
55
+ * `model.state` instead of the state its component last rendered: a reduction carries a whole view, so
56
+ * one built from a rendered snapshot overwrites what another handler decided since.
57
+ *
58
+ * Transport, local process, and scheduling reach the model as injected ports, so a component never
59
+ * opens a socket. The clipboard and the command-id and issue-time ports are the shell's, not the
60
+ * model's: they reach components as props, and components call them directly. Diagnostics go through
61
+ * this model's log, not `console.*` from a component; the runtime reports an abnormal exit's cause
62
+ * once the terminal is restored.
63
+ */
64
+ export class TuiOperatorModel {
65
+ dependencies;
66
+ #state = initialState();
67
+ #listeners = new Set();
68
+ #streamCancel;
69
+ #recoveryCancel;
70
+ #replacementCancel;
71
+ #replacementArmedAt;
72
+ #snapshotCancel;
73
+ #commandCancels = new Set();
74
+ #runSession = null;
75
+ #runGeneration = 0;
76
+ #knownMessageIds = new Set();
77
+ #disposed = false;
78
+ #generation = 0;
79
+ /**
80
+ * Consecutive recovery attempts since authoritative data last landed, driving the backoff delay.
81
+ *
82
+ * The reset is tied to applied data rather than to a healthy connection because a server can
83
+ * accept a subscription and heartbeat without serving anything: resetting on the transition would
84
+ * pin a crash-looping broker at the base delay for as long as it kept flapping.
85
+ */
86
+ #recoveryAttempts = 0;
87
+ /**
88
+ * Construct a controller from explicit IO and lifecycle collaborators.
89
+ * @param dependencies - Injected transport, command, scheduling, and disposal ports.
90
+ */
91
+ constructor(dependencies) {
92
+ this.dependencies = dependencies;
93
+ const stored = dependencies.layout?.read();
94
+ if (stored)
95
+ this.#state = { ...this.#state, view: { ...this.#state.view, ...stored } };
96
+ }
97
+ /**
98
+ * Read the current immutable model state.
99
+ * @returns Current broker facts and local view state.
100
+ */
101
+ get state() {
102
+ return this.#state;
103
+ }
104
+ /**
105
+ * Observe state transitions.
106
+ * @param listener - Listener invoked after each state change.
107
+ * @returns Removal callback.
108
+ */
109
+ subscribe(listener) {
110
+ if (this.#disposed)
111
+ return () => undefined;
112
+ this.#listeners.add(listener);
113
+ return () => this.#listeners.delete(listener);
114
+ }
115
+ /**
116
+ * Hydrate authoritative facts and begin ordered reduction.
117
+ * @returns Completion after the initial snapshot is installed.
118
+ */
119
+ async start() {
120
+ if (this.#disposed || this.#state.connection === "incompatible")
121
+ return;
122
+ const generation = ++this.#generation;
123
+ this.#snapshotCancel?.();
124
+ this.#snapshotCancel = undefined;
125
+ this.#streamCancel?.();
126
+ this.#streamCancel = undefined;
127
+ this.#recoveryCancel?.();
128
+ this.#recoveryCancel = undefined;
129
+ this.#replacementCancel?.();
130
+ this.#replacementCancel = undefined;
131
+ try {
132
+ await this.#replace(generation);
133
+ if (!this.#active(generation) || this.#state.connection !== "healthy")
134
+ return;
135
+ this.#openStream(generation);
136
+ }
137
+ catch {
138
+ if (this.#active(generation))
139
+ this.#recover();
140
+ }
141
+ }
142
+ /**
143
+ * Submit a command without locally changing broker facts.
144
+ * @param command - Versioned operator command.
145
+ * @returns Broker admission result.
146
+ */
147
+ execute(command) {
148
+ if (this.#disposed)
149
+ return Promise.reject(new Error("TUI model is disposed"));
150
+ const request = this.dependencies.command.execute(command);
151
+ const cancel = () => {
152
+ request.cancel();
153
+ };
154
+ this.#commandCancels.add(cancel);
155
+ return request.promise.finally(() => this.#commandCancels.delete(cancel));
156
+ }
157
+ /**
158
+ * Start one local command and record its output as it arrives.
159
+ *
160
+ * Ignored while a run is already in progress, so a repeated key cannot start a second child.
161
+ * @param command - Executable to run.
162
+ * @param args - Arguments passed to the executable.
163
+ * @param size - Terminal dimensions the child lays its output out for.
164
+ */
165
+ startLocalRun(command, args, size) {
166
+ if (this.#disposed || this.#state.run?.outcome.kind === "running")
167
+ return;
168
+ const port = this.dependencies.localCommand;
169
+ const line = [command, ...args].join(" ");
170
+ if (!port) {
171
+ this.#set({
172
+ ...this.#state,
173
+ run: {
174
+ id: ++this.#runGeneration,
175
+ command: line,
176
+ chunks: [],
177
+ received: 0,
178
+ truncated: false,
179
+ outcome: { kind: "failed", reason: "no local command port is configured" },
180
+ },
181
+ });
182
+ return;
183
+ }
184
+ // The child's own generation, not the broker connection's: a stream that drops and recovers has
185
+ // no bearing on a process already running on this machine.
186
+ const generation = ++this.#runGeneration;
187
+ this.#set({
188
+ ...this.#state,
189
+ run: { id: generation, command: line, chunks: [], received: 0, truncated: false, outcome: { kind: "running" } },
190
+ });
191
+ this.#runSession = port.run(command, args, size, (event) => {
192
+ if (this.#disposed || this.#runGeneration !== generation)
193
+ return;
194
+ const run = this.#state.run;
195
+ if (!run || run.outcome.kind !== "running")
196
+ return;
197
+ if (event.type === "output") {
198
+ this.#set({
199
+ ...this.#state,
200
+ run: { ...run, received: run.received + 1, ...bounded([...run.chunks, event.bytes], run.truncated) },
201
+ });
202
+ return;
203
+ }
204
+ this.#runSession = null;
205
+ this.#set({
206
+ ...this.#state,
207
+ run: {
208
+ ...run,
209
+ outcome: event.type === "exit" ? { kind: "exited", code: event.code } : { kind: "failed", reason: event.reason },
210
+ },
211
+ });
212
+ });
213
+ }
214
+ /**
215
+ * Send operator input to the running child.
216
+ *
217
+ * Ignored unless a child is running, so a key pressed as the run ends cannot reach a dead process.
218
+ * @param bytes - Sequence already encoded for the child's terminal.
219
+ */
220
+ writeToLocalRun(bytes) {
221
+ if (this.#disposed || this.#state.run?.outcome.kind !== "running")
222
+ return;
223
+ this.#runSession?.write(bytes);
224
+ }
225
+ /**
226
+ * Tell the running child its terminal has been resized.
227
+ *
228
+ * Ignored unless a child is running, for the same reason input is.
229
+ * @param columns - Columns the child now has.
230
+ * @param rows - Rows the child now has.
231
+ */
232
+ resizeLocalRun(columns, rows) {
233
+ if (this.#disposed || this.#state.run?.outcome.kind !== "running")
234
+ return;
235
+ this.#runSession?.resize(columns, rows);
236
+ }
237
+ /**
238
+ * Discard a finished run, leaving a run still in progress alone.
239
+ */
240
+ clearLocalRun() {
241
+ if (this.#disposed || this.#state.run?.outcome.kind === "running")
242
+ return;
243
+ this.#runSession = null;
244
+ this.#set({ ...this.#state, run: null });
245
+ }
246
+ /**
247
+ * End the running child and report the run as abandoned.
248
+ *
249
+ * The outcome is recorded here rather than awaited from the port, because the operator asked to stop
250
+ * waiting: a child that ignores the signal would otherwise keep them in the overlay it is stuck in.
251
+ * Whatever that child reports afterwards is dropped, because the run is no longer running.
252
+ */
253
+ cancelLocalRun() {
254
+ const run = this.#state.run;
255
+ if (this.#disposed || run?.outcome.kind !== "running")
256
+ return;
257
+ this.#runSession?.stop();
258
+ this.#runSession = null;
259
+ this.#set({ ...this.#state, run: { ...run, outcome: { kind: "cancelled" } } });
260
+ }
261
+ /**
262
+ * Set one side pane's dragged width.
263
+ *
264
+ * Storing is deliberately not done here: this runs once per cell of pointer travel, and the store
265
+ * is a blocking write.
266
+ *
267
+ * The pane on the far side is pinned to the width it currently renders at. Left as a preference the
268
+ * layout has not yet honoured, it would be re-clamped against the new request and shift under a
269
+ * pointer that never touched it.
270
+ * @param pane - Which side pane the operator dragged.
271
+ * @param columns - Cells the dragged pane should occupy, before any clamping the layout applies.
272
+ * @param oppositeColumns - Cells the pane on the far side of the agent log currently renders at.
273
+ */
274
+ resizePane(pane, columns, oppositeColumns) {
275
+ if (this.#disposed)
276
+ return;
277
+ this.updateView((view) => ({
278
+ ...view,
279
+ ...(pane === "tree"
280
+ ? { treeColumns: columns, messageLogColumns: oppositeColumns }
281
+ : { treeColumns: oppositeColumns, messageLogColumns: columns }),
282
+ }));
283
+ }
284
+ /** Store the pane widths the operator has finished dragging, for later runs to adopt. */
285
+ commitPaneLayout() {
286
+ if (this.#disposed)
287
+ return;
288
+ const { treeColumns, messageLogColumns } = this.#state.view;
289
+ this.dependencies.layout?.write({ treeColumns, messageLogColumns });
290
+ }
291
+ /**
292
+ * Update local view state only.
293
+ * @param update - Pure local view transformation.
294
+ */
295
+ updateView(update) {
296
+ if (this.#disposed)
297
+ return;
298
+ this.#set({ ...this.#state, view: update(this.#state.view) });
299
+ }
300
+ /**
301
+ * Select one traffic row, marking an accepted delivery read by the act of showing it.
302
+ * @param entry - Traffic row the operator moved onto.
303
+ */
304
+ viewTraffic(entry) {
305
+ if (this.#disposed)
306
+ return;
307
+ const unread = new Set(this.#state.unreadMessageIds);
308
+ if (entry.kind === "accepted_delivery")
309
+ unread.delete(entry.messageId);
310
+ this.#set({
311
+ ...this.#state,
312
+ unreadMessageIds: unread,
313
+ view: { ...this.#state.view, trafficId: entry.trafficId },
314
+ });
315
+ }
316
+ /** Dispose stream, recovery work, listeners, and composition resources once. */
317
+ dispose() {
318
+ if (this.#disposed)
319
+ return;
320
+ this.#disposed = true;
321
+ this.#generation += 1;
322
+ this.#runGeneration += 1;
323
+ this.#runSession?.stop();
324
+ this.#runSession = null;
325
+ this.#snapshotCancel?.();
326
+ this.#snapshotCancel = undefined;
327
+ this.#streamCancel?.();
328
+ this.#recoveryCancel?.();
329
+ this.#recoveryCancel = undefined;
330
+ this.#replacementCancel?.();
331
+ this.#replacementCancel = undefined;
332
+ for (const cancel of this.#commandCancels)
333
+ cancel();
334
+ this.#commandCancels.clear();
335
+ this.dependencies.disposal.dispose();
336
+ this.#set({ ...this.#state, connection: "disposed" });
337
+ this.#listeners.clear();
338
+ }
339
+ async #replace(generation) {
340
+ const request = this.dependencies.snapshot.fetch();
341
+ const cancel = () => {
342
+ request.cancel();
343
+ };
344
+ this.#snapshotCancel?.();
345
+ this.#snapshotCancel = cancel;
346
+ try {
347
+ const value = await request.promise;
348
+ const snapshot = validateOperatorSnapshot(value);
349
+ if (!this.#active(generation))
350
+ return;
351
+ if (this.#state.connection !== "healthy")
352
+ this.reportDiagnostic("info", "connection", `connected: snapshot at sequence ${String(snapshot.cursor.sequence)}`);
353
+ // A fetched snapshot is installed as a fresh start rather than a replacement, so unread identities
354
+ // begin empty from it.
355
+ this.observeSnapshot(structuredClone(snapshot), false);
356
+ }
357
+ finally {
358
+ if (this.#snapshotCancel === cancel)
359
+ this.#snapshotCancel = undefined;
360
+ }
361
+ }
362
+ #openStream(generation) {
363
+ if (!this.#active(generation))
364
+ return;
365
+ const cursor = this.#state.snapshot?.cursor ?? null;
366
+ const cancel = this.dependencies.stream.open(cursor, (observation) => {
367
+ if (!this.#active(generation))
368
+ return;
369
+ switch (observation.type) {
370
+ case "event":
371
+ this.#receive(observation.event);
372
+ return;
373
+ case "snapshot":
374
+ this.observeSnapshot(observation.snapshot, observation.replacement);
375
+ return;
376
+ case "diagnostic":
377
+ this.#observeDiagnostic(observation.diagnostic);
378
+ return;
379
+ case "resync":
380
+ this.#recover();
381
+ return;
382
+ case "incompatible":
383
+ this.observeIncompatible();
384
+ return;
385
+ case "live":
386
+ this.#observeLive();
387
+ return;
388
+ case "resync_pending":
389
+ this.#awaitReplacement();
390
+ return;
391
+ case "resync_progress":
392
+ this.#extendReplacement();
393
+ return;
394
+ default: {
395
+ const unhandled = observation;
396
+ return unhandled;
397
+ }
398
+ }
399
+ }, () => {
400
+ if (this.#active(generation))
401
+ this.#recover();
402
+ });
403
+ if (!this.#active(generation))
404
+ cancel();
405
+ else
406
+ this.#streamCancel = cancel;
407
+ }
408
+ #receive(event) {
409
+ const snapshot = this.#state.snapshot;
410
+ if (!snapshot || this.#disposed || this.#state.connection === "incompatible")
411
+ return;
412
+ const result = classifyOperatorEvent(snapshot.cursor, event);
413
+ if (result.type === "duplicate")
414
+ return;
415
+ if (result.type === "incompatible_version") {
416
+ this.observeIncompatible();
417
+ return;
418
+ }
419
+ if (result.type !== "apply") {
420
+ this.#recover();
421
+ return;
422
+ }
423
+ this.#recoveryAttempts = 0;
424
+ const next = reduce(snapshot, result.event);
425
+ const unread = new Set(this.#state.unreadMessageIds);
426
+ if (result.event.event.type === "traffic_upserted" &&
427
+ result.event.event.traffic.kind === "accepted_delivery" &&
428
+ !acceptedIds(snapshot.traffic).has(result.event.event.traffic.messageId)) {
429
+ unread.add(result.event.event.traffic.messageId);
430
+ this.#knownMessageIds.add(result.event.event.traffic.messageId);
431
+ }
432
+ const ids = acceptedIds(next.traffic);
433
+ for (const id of unread)
434
+ if (!ids.has(id))
435
+ unread.delete(id);
436
+ this.#knownMessageIds = ids;
437
+ this.#set({
438
+ ...this.#state,
439
+ snapshot: next,
440
+ unreadMessageIds: unread,
441
+ healthByAgentId: health(next),
442
+ view: reconcileView(this.#state.view, next, this.#state.snapshot),
443
+ });
444
+ }
445
+ #recover() {
446
+ if (this.#disposed || this.#state.connection === "incompatible" || this.#recoveryCancel)
447
+ return;
448
+ this.#replacementCancel?.();
449
+ this.#replacementCancel = undefined;
450
+ // Every failed reconnect re-enters here; only the entry into recovering reports that the stream stopped.
451
+ if (this.#state.connection !== "recovering")
452
+ this.reportDiagnostic("warning", "connection", "recovering: the authoritative stream stopped");
453
+ this.#set({ ...this.#state, connection: "recovering" });
454
+ this.#streamCancel?.();
455
+ this.#streamCancel = undefined;
456
+ const generation = ++this.#generation;
457
+ const delayMs = reconnectDelayMs(this.#recoveryAttempts);
458
+ this.#recoveryAttempts = Math.min(this.#recoveryAttempts + 1, TUI_RECONNECT_MAX_ATTEMPT);
459
+ this.#recoveryCancel = this.dependencies.scheduler.schedule(() => {
460
+ this.#recoveryCancel = undefined;
461
+ if (!this.#active(generation))
462
+ return;
463
+ this.dependencies.reconnect.reconnect();
464
+ // A model holding a snapshot resumes from its cursor: the broker replays what was missed, or
465
+ // answers with a replacement snapshot when the journal cannot reach that position. Only a model
466
+ // that never hydrated has no cursor to offer and must pay for a whole transfer.
467
+ if (this.#state.snapshot) {
468
+ this.#openStream(generation);
469
+ return;
470
+ }
471
+ void this.#replace(generation)
472
+ .then(() => {
473
+ if (this.#active(generation) && this.#state.connection === "healthy")
474
+ this.#openStream(generation);
475
+ })
476
+ .catch(() => {
477
+ if (this.#active(generation))
478
+ this.#recover();
479
+ });
480
+ }, delayMs);
481
+ }
482
+ #awaitReplacement() {
483
+ if (this.#disposed || this.#state.connection === "incompatible" || this.#replacementCancel)
484
+ return;
485
+ this.reportDiagnostic("warning", "connection", "resyncing: the broker refused the resumed cursor");
486
+ this.#replacementArmedAt = this.dependencies.now();
487
+ this.#armReplacementTimeout();
488
+ }
489
+ /**
490
+ * Push the replacement deadline back out because a chunk of the owed transfer just landed.
491
+ *
492
+ * Without this, a transfer that legitimately outruns one fixed window (a large snapshot over a
493
+ * backpressured socket) can never finish: recovery tears it down at the deadline, reconnects,
494
+ * resumes at the same cursor, and is offered the same replacement — a livelock. A no-op when no
495
+ * replacement is currently owed, since a stray progress signal then names nothing to extend, and
496
+ * once TUI_REPLACEMENT_TOTAL_LIMIT_MS has passed since the wait began, so continuing progress alone
497
+ * cannot hold the wait open forever.
498
+ */
499
+ #extendReplacement() {
500
+ if (this.#disposed || this.#state.connection === "incompatible" || !this.#replacementCancel)
501
+ return;
502
+ const armedAt = this.#replacementArmedAt ?? this.dependencies.now();
503
+ if (this.dependencies.now() - armedAt >= TUI_REPLACEMENT_TOTAL_LIMIT_MS)
504
+ return;
505
+ this.#replacementCancel();
506
+ this.#armReplacementTimeout();
507
+ }
508
+ #armReplacementTimeout() {
509
+ const generation = this.#generation;
510
+ this.#replacementCancel = this.dependencies.scheduler.schedule(() => {
511
+ this.#replacementCancel = undefined;
512
+ if (!this.#active(generation))
513
+ return;
514
+ this.#recover();
515
+ }, TUI_REPLACEMENT_TIMEOUT_MS);
516
+ }
517
+ #observeLive() {
518
+ if (this.#disposed || this.#state.connection !== "recovering")
519
+ return;
520
+ this.reportDiagnostic("info", "connection", "resumed: the authoritative stream is live");
521
+ this.#set({ ...this.#state, connection: "healthy" });
522
+ }
523
+ /**
524
+ * Record one operator-visible condition, whoever observed it.
525
+ * @param diagnostic - Content-free description of what was observed.
526
+ */
527
+ #observeDiagnostic(diagnostic) {
528
+ if (this.#disposed)
529
+ return;
530
+ const diagnostics = [...this.#state.diagnostics, diagnostic];
531
+ this.#set({ ...this.#state, diagnostics: diagnostics.slice(-TUI_DIAGNOSTIC_LIMIT) });
532
+ }
533
+ /**
534
+ * Record a condition this client observed, stamped from the model's own clock.
535
+ * @param severity - Attention the condition asks for.
536
+ * @param scope - Subsystem the condition arose in.
537
+ * @param summary - Content-free description, in the words the diagnostics log shows.
538
+ */
539
+ reportDiagnostic(severity, scope, summary) {
540
+ this.#observeDiagnostic({ at: this.dependencies.now(), origin: "client", severity, scope, summary });
541
+ }
542
+ #set(state) {
543
+ this.#state = state;
544
+ for (const listener of this.#listeners) {
545
+ listener(state);
546
+ }
547
+ }
548
+ #active(generation) {
549
+ return !this.#disposed && generation === this.#generation;
550
+ }
551
+ /**
552
+ * Install a complete transport snapshot through the same authoritative replacement path.
553
+ * @param snapshot - Validated operator snapshot, held by the model from here on.
554
+ * @param replacement - Whether the snapshot answers a resync, so unread identities that survived the gap are kept.
555
+ */
556
+ observeSnapshot(snapshot, replacement) {
557
+ if (this.#disposed || this.#state.connection === "incompatible")
558
+ return;
559
+ const ids = acceptedIds(snapshot.traffic);
560
+ const unread = replacement
561
+ ? new Set([...this.#state.unreadMessageIds].filter((id) => ids.has(id)))
562
+ : new Set();
563
+ if (replacement)
564
+ for (const id of ids)
565
+ if (!this.#knownMessageIds.has(id))
566
+ unread.add(id);
567
+ this.#knownMessageIds = ids;
568
+ this.#recoveryAttempts = 0;
569
+ if (replacement) {
570
+ this.#replacementCancel?.();
571
+ this.#replacementCancel = undefined;
572
+ this.reportDiagnostic("info", "connection", `resynced: replacement snapshot installed at sequence ${String(snapshot.cursor.sequence)}`);
573
+ }
574
+ this.#set({
575
+ ...this.#state,
576
+ snapshot,
577
+ unreadMessageIds: unread,
578
+ healthByAgentId: health(snapshot),
579
+ connection: "healthy",
580
+ view: reconcileView(this.#state.view, snapshot, this.#state.snapshot),
581
+ });
582
+ }
583
+ /**
584
+ * Apply one transport event using normal ordered reduction.
585
+ * @param event - Untrusted event envelope from the operator stream, validated here before it is applied.
586
+ */
587
+ observeEvent(event) {
588
+ this.#receive(event);
589
+ }
590
+ /** Start bounded recovery after an explicit transport resync. */
591
+ observeResync() {
592
+ this.#recover();
593
+ }
594
+ /** End stream application after an incompatible transport observation. */
595
+ observeIncompatible() {
596
+ if (!this.#disposed && this.#state.connection !== "incompatible") {
597
+ this.#generation += 1;
598
+ this.#snapshotCancel?.();
599
+ this.#snapshotCancel = undefined;
600
+ this.#recoveryCancel?.();
601
+ this.#recoveryCancel = undefined;
602
+ this.#replacementCancel?.();
603
+ this.#replacementCancel = undefined;
604
+ this.#streamCancel?.();
605
+ this.#streamCancel = undefined;
606
+ this.reportDiagnostic("error", "connection", "incompatible: the broker serves another protocol version");
607
+ this.#set({ ...this.#state, connection: "incompatible" });
608
+ }
609
+ }
610
+ }
611
+ function initialState() {
612
+ return {
613
+ snapshot: null,
614
+ unreadMessageIds: new Set(),
615
+ healthByAgentId: new Map(),
616
+ connection: "cold",
617
+ run: null,
618
+ diagnostics: [],
619
+ view: {
620
+ focus: "tree",
621
+ selection: null,
622
+ trafficId: null,
623
+ collapsedRepositories: new Set(),
624
+ hiddenRepositories: new Set(),
625
+ hiddenExpanded: false,
626
+ archivedSections: new Map(),
627
+ followAgentLog: true,
628
+ treeColumns: null,
629
+ messageLogColumns: null,
630
+ overlay: { kind: "none" },
631
+ overlayReturnFocus: "tree",
632
+ contextActionIndex: 0,
633
+ actionIndex: 0,
634
+ },
635
+ };
636
+ }
637
+ function reconnectDelayMs(attempt) {
638
+ return Math.min(TUI_RECONNECT_BASE_DELAY_MS * 2 ** attempt, TUI_RECONNECT_MAX_DELAY_MS);
639
+ }
640
+ function acceptedIds(traffic) {
641
+ return new Set(traffic.flatMap((entry) => (entry.kind === "accepted_delivery" ? [entry.messageId] : [])));
642
+ }
643
+ function health(snapshot) {
644
+ return new Map(snapshot.agents.map((agent) => [agent.id, selectOperatorAgentHealth(agent, snapshot.traffic)]));
645
+ }
646
+ function reconcileView(view, snapshot, previous) {
647
+ // A workspace collapses as it becomes archived, and every archived one is collapsed on the first
648
+ // snapshot. Only the transition collapses, so an operator who opens one keeps it open.
649
+ const archivedBefore = previous ? archivedOperatorRepositoryIds(previous.agents) : new Set();
650
+ const collapsedRepositories = new Set(view.collapsedRepositories);
651
+ for (const id of archivedOperatorRepositoryIds(snapshot.agents)) {
652
+ if (!archivedBefore.has(id))
653
+ collapsedRepositories.add(id);
654
+ }
655
+ const repositoryIds = new Set(snapshot.repositories.map((repository) => repository.id));
656
+ const agentIds = new Set(snapshot.agents.map((agent) => agent.id));
657
+ const messageIds = acceptedIds(snapshot.traffic);
658
+ const trafficIds = new Set(snapshot.traffic.map((traffic) => traffic.trafficId));
659
+ const hiddenRepositories = new Set([...view.hiddenRepositories].filter((id) => repositoryIds.has(id)));
660
+ const selection = selectionSurviving(view.selection, repositoryIds, agentIds, hiddenRepositories);
661
+ const trafficId = view.trafficId !== null && !trafficIds.has(view.trafficId) ? null : view.trafficId;
662
+ return {
663
+ ...view,
664
+ selection,
665
+ trafficId,
666
+ overlay: reconcileOverlay(view.overlay, repositoryIds, agentIds, messageIds, expirableIds(snapshot.traffic)),
667
+ collapsedRepositories: new Set([...collapsedRepositories].filter((id) => repositoryIds.has(id))),
668
+ hiddenRepositories,
669
+ archivedSections: new Map([...view.archivedSections].filter(([id]) => repositoryIds.has(id))),
670
+ };
671
+ }
672
+ /**
673
+ * Keep a tree selection only while the row it names can still be shown.
674
+ * @param selection - Selection held before the projection changed.
675
+ * @param repositoryIds - Repositories the new projection holds.
676
+ * @param agentIds - Agents the new projection holds.
677
+ * @param hiddenRepositories - Hidden repositories that survive in the new projection; the hidden section exists only while any do.
678
+ * @returns The same selection, or null when its row is gone.
679
+ */
680
+ function selectionSurviving(selection, repositoryIds, agentIds, hiddenRepositories) {
681
+ switch (selection?.kind) {
682
+ case "workspace":
683
+ case "archived":
684
+ return repositoryIds.has(selection.repositoryId) ? selection : null;
685
+ case "agent":
686
+ return agentIds.has(selection.agentId) ? selection : null;
687
+ case "hidden":
688
+ return hiddenRepositories.size > 0 ? selection : null;
689
+ case undefined:
690
+ return selection;
691
+ }
692
+ }
693
+ function expirableIds(traffic) {
694
+ return new Set(traffic.flatMap((entry) => (entry.kind === "accepted_delivery" && isTuiExpirable(entry) ? [entry.messageId] : [])));
695
+ }
696
+ function reconcileOverlay(overlay, repositoryIds, agentIds, messageIds, expirable) {
697
+ const closed = { kind: "none" };
698
+ switch (overlay.kind) {
699
+ case "agent_identity":
700
+ return agentIds.has(overlay.agentId) ? overlay : closed;
701
+ case "archive_confirm":
702
+ return agentIds.has(overlay.agentId) ? overlay : closed;
703
+ case "full_message":
704
+ return messageIds.has(overlay.messageId) ? overlay : closed;
705
+ case "expire_confirm":
706
+ // A delivery that settled while the operator was being asked would only be refused by the broker.
707
+ return expirable.has(overlay.messageId) ? overlay : closed;
708
+ case "context_menu":
709
+ if (overlay.target === "agent")
710
+ return agentIds.has(overlay.agentId) ? overlay : closed;
711
+ if (overlay.target === "message")
712
+ return messageIds.has(overlay.messageId) ? overlay : closed;
713
+ return repositoryIds.has(overlay.repositoryId) ? overlay : closed;
714
+ case "none":
715
+ case "actions":
716
+ case "action_run":
717
+ case "help":
718
+ case "log":
719
+ return overlay;
720
+ }
721
+ }
722
+ function reduce(snapshot, envelope) {
723
+ const next = structuredClone(snapshot);
724
+ const event = envelope.event;
725
+ const apply = (values, value, id) => {
726
+ const index = values.findIndex((candidate) => id(candidate) === id(value));
727
+ if (index < 0)
728
+ values.push(value);
729
+ else
730
+ values[index] = value;
731
+ };
732
+ switch (event.type) {
733
+ case "repository_upserted":
734
+ apply(next.repositories, event.repository, (value) => value.id);
735
+ next.repositories.sort((a, b) => a.rootPath.localeCompare(b.rootPath) || a.id.localeCompare(b.id));
736
+ break;
737
+ case "repository_removed":
738
+ next.repositories = next.repositories.filter((value) => value.id !== event.repositoryId);
739
+ break;
740
+ case "agent_upserted":
741
+ apply(next.agents, event.agent, (value) => value.id);
742
+ next.agents.sort((a, b) => a.registeredAt - b.registeredAt || a.id.localeCompare(b.id));
743
+ break;
744
+ case "agent_removed":
745
+ next.agents = next.agents.filter((value) => value.id !== event.agentId);
746
+ break;
747
+ case "traffic_upserted":
748
+ apply(next.traffic, event.traffic, (value) => value.trafficId);
749
+ next.traffic.sort((a, b) => a.trafficPosition - b.trafficPosition);
750
+ break;
751
+ case "traffic_removed":
752
+ next.traffic = next.traffic.filter((value) => value.trafficId !== event.trafficId);
753
+ break;
754
+ }
755
+ next.cursor = { protocolVersion: OPERATOR_PROTOCOL_VERSION, epoch: envelope.epoch, sequence: envelope.sequence };
756
+ next.epoch = envelope.epoch;
757
+ return next;
758
+ }