@haywoodmarx/agent-pager 0.1.3 → 0.2.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.
Files changed (77) hide show
  1. package/README.md +4 -4
  2. package/dist/src/broker/address.d.ts +7 -0
  3. package/dist/src/broker/address.js +9 -0
  4. package/dist/src/broker/broker.d.ts +112 -24
  5. package/dist/src/broker/broker.js +604 -132
  6. package/dist/src/broker/operator-projection.d.ts +22 -0
  7. package/dist/src/broker/operator-projection.js +43 -5
  8. package/dist/src/broker/protocol.d.ts +1 -1
  9. package/dist/src/broker/protocol.js +1 -1
  10. package/dist/src/broker/schema.d.ts +4 -0
  11. package/dist/src/broker/schema.js +20 -8
  12. package/dist/src/broker/snapshot.d.ts +4 -2
  13. package/dist/src/implementations/claude/gateway-cli.js +1 -2
  14. package/dist/src/implementations/claude/gateway.d.ts +11 -4
  15. package/dist/src/implementations/claude/gateway.js +44 -14
  16. package/dist/src/implementations/claude/instructions.js +1 -1
  17. package/dist/src/implementations/model.d.ts +6 -3
  18. package/dist/src/implementations/opencode/plugin.d.ts +7 -2
  19. package/dist/src/implementations/opencode/plugin.js +103 -7
  20. package/dist/src/implementations/pi/broker-client.d.ts +13 -4
  21. package/dist/src/implementations/pi/broker-client.js +71 -9
  22. package/dist/src/implementations/pi/extension.d.ts +7 -2
  23. package/dist/src/implementations/pi/extension.js +15 -3
  24. package/dist/src/index.d.ts +2 -2
  25. package/dist/src/index.js +2 -2
  26. package/dist/src/launch/broker-lock.d.ts +58 -4
  27. package/dist/src/launch/broker-lock.js +140 -7
  28. package/dist/src/launch/launch.d.ts +1 -1
  29. package/dist/src/launch/on-demand.d.ts +8 -8
  30. package/dist/src/launch/on-demand.js +54 -35
  31. package/dist/src/launch/runtime-composition.js +2 -0
  32. package/dist/src/launch/runtime.js +4 -14
  33. package/dist/src/launch/service.d.ts +1 -0
  34. package/dist/src/launch/service.js +23 -1
  35. package/dist/src/launch/workspace-branch.d.ts +3 -3
  36. package/dist/src/launch/workspace-branch.js +7 -15
  37. package/dist/src/launch/workspace-root.d.ts +13 -0
  38. package/dist/src/launch/workspace-root.js +27 -0
  39. package/dist/src/messaging/delivery-client.d.ts +17 -0
  40. package/dist/src/messaging/delivery-client.js +31 -1
  41. package/dist/src/messaging/identity-statement.d.ts +7 -0
  42. package/dist/src/messaging/identity-statement.js +8 -0
  43. package/dist/src/messaging/mcp.d.ts +8 -2
  44. package/dist/src/messaging/mcp.js +54 -1
  45. package/dist/src/messaging/notifications.d.ts +31 -3
  46. package/dist/src/messaging/notifications.js +25 -0
  47. package/dist/src/messaging/push-delivery.d.ts +2 -1
  48. package/dist/src/messaging/push-delivery.js +24 -7
  49. package/dist/src/messaging/tools.js +22 -2
  50. package/dist/src/messaging/wake.js +7 -0
  51. package/dist/src/operator/agent-projection.d.ts +8 -5
  52. package/dist/src/operator/agent-projection.js +29 -9
  53. package/dist/src/operator/commands.d.ts +2 -0
  54. package/dist/src/operator/commands.js +57 -21
  55. package/dist/src/operator/protocol.d.ts +34 -2
  56. package/dist/src/operator/protocol.js +9 -0
  57. package/dist/src/operator/repository-system.d.ts +29 -0
  58. package/dist/src/operator/repository-system.js +39 -12
  59. package/dist/src/operator/repository.d.ts +28 -11
  60. package/dist/src/operator/repository.js +37 -4
  61. package/dist/src/operator/transport.js +10 -5
  62. package/dist/src/operator/updates.js +7 -1
  63. package/dist/src/operator/validation.js +35 -1
  64. package/dist/src/shared/versions.d.ts +18 -0
  65. package/dist/src/shared/versions.js +26 -0
  66. package/dist/src/tui/agent-tree.d.ts +8 -5
  67. package/dist/src/tui/agent-tree.js +195 -22
  68. package/dist/src/tui/app.js +6 -2
  69. package/dist/src/tui/message-views.js +4 -3
  70. package/dist/src/tui/model.d.ts +25 -1
  71. package/dist/src/tui/model.js +33 -9
  72. package/dist/src/tui/presentation.d.ts +11 -2
  73. package/dist/src/tui/presentation.js +26 -4
  74. package/dist/src/version.d.ts +7 -0
  75. package/dist/src/version.js +14 -6
  76. package/package.json +1 -1
  77. package/skills/pager/SKILL.md +35 -45
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Read the `major.minor.patch` numbers of a version string.
3
+ * @param version - Version text, such as a runtime's reported version or a package release.
4
+ * @param extent - `prefix` accepts trailing text such as a prerelease or build suffix and ignores it;
5
+ * `exact` rejects any version carrying one.
6
+ * @returns - The three numbers, or undefined when the text does not start with, or is not exactly, a
7
+ * numeric version.
8
+ */
9
+ export function parseNumericVersion(version, extent) {
10
+ const match = (extent === "exact" ? /^(\d+)\.(\d+)\.(\d+)$/u : /^(\d+)\.(\d+)\.(\d+)/u).exec(version);
11
+ return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : undefined;
12
+ }
13
+ /**
14
+ * Order two numeric versions.
15
+ * @param left - First version.
16
+ * @param right - Second version.
17
+ * @returns - Negative when `left` is lower, positive when it is higher, zero when they are equal.
18
+ */
19
+ export function compareNumericVersions(left, right) {
20
+ for (let index = 0; index < left.length; index += 1) {
21
+ const difference = (left[index] ?? 0) - (right[index] ?? 0);
22
+ if (difference !== 0)
23
+ return difference;
24
+ }
25
+ return 0;
26
+ }
@@ -1,6 +1,6 @@
1
1
  import type { AgentId } from "../broker/protocol.js";
2
- import type { OperatorAgent, OperatorCommand, OperatorRepository } from "../operator/protocol.js";
3
- import type { TuiOperatorModel, TuiOperatorModelState, TuiOperatorViewState } from "./model.js";
2
+ import { type OperatorAgent, type OperatorCommand, type OperatorRepository } from "../operator/protocol.js";
3
+ import { type TuiOperatorModel, type TuiOperatorModelState, type TuiOperatorViewState } from "./model.js";
4
4
  import type { TuiTheme } from "./theme.js";
5
5
  /** Agent rows grouped by their projected repository without creating broker scope. */
6
6
  export type TuiRepositoryAgentGroup = {
@@ -12,7 +12,7 @@ export type TuiRepositoryAgentGroup = {
12
12
  readonly archived: readonly OperatorAgent[];
13
13
  };
14
14
  /** One focusable action offered for a selected agent row. */
15
- export type TuiAgentContextAction = "ping" | "details" | "archive" | "collapse";
15
+ export type TuiAgentContextAction = "ping" | "details" | "archive" | "purge" | "collapse";
16
16
  /** One focusable action offered for a selected workspace row. */
17
17
  export type TuiWorkspaceContextAction = "hide" | "unhide";
18
18
  /** A keyboard-reachable projected tree row. */
@@ -34,7 +34,7 @@ export type AgentTreeProps = {
34
34
  readonly model: TuiOperatorModel;
35
35
  /** Semantic terminal palette. */
36
36
  readonly theme: Readonly<TuiTheme>;
37
- /** Submits the broker command a tree action raised, a ping or an archive. */
37
+ /** Submits the broker command a tree action raised. */
38
38
  readonly onCommand: (command: OperatorCommand) => void;
39
39
  /** Enables terminal colours without changing textual distinctions. */
40
40
  readonly colorEnabled?: boolean;
@@ -43,6 +43,9 @@ export type AgentTreeProps = {
43
43
  };
44
44
  /**
45
45
  * Group projected agents under their projected repositories in stable registration order.
46
+ *
47
+ * A repository with no projected agent has no group. It stays in the projection, so the view state
48
+ * kept for it outlives the time it spends empty.
46
49
  * @param snapshot - Authoritative projection currently installed by the client model.
47
50
  * @returns Repository groups suitable for the tree presentation.
48
51
  */
@@ -100,7 +103,7 @@ export declare function reduceAgentTreeContextAction(state: TuiOperatorModelStat
100
103
  * Reduce one keyboard command without depending on an OpenTUI input source.
101
104
  * @param state - Current authoritative projection and local presentation state.
102
105
  * @param key - Normalized OpenTUI key name.
103
- * @returns The next model-owned view and an optional broker command, a ping or an archive.
106
+ * @returns The next model-owned view and an optional broker command.
104
107
  */
105
108
  export declare function reduceAgentTreeKey(state: TuiOperatorModelState, key: string): TuiAgentTreeKeyResult;
106
109
  /**
@@ -1,10 +1,15 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "@opentui/react/jsx-runtime";
2
2
  import { useKeyboard, useTerminalDimensions } from "@opentui/react";
3
3
  import { archivedOperatorRepositoryIds, isOperatorAgentArchived, operatorArchiveReason, } from "../operator/agent-projection.js";
4
+ import { OPERATOR_PURGE_TARGET_LIMIT, } from "../operator/protocol.js";
5
+ import { selectTuiPurgeableAgents, } from "./model.js";
4
6
  import { Line, Overlay, fitOverlayColumns, PaneBar, Rule, SCROLLBAR_RESERVED_COLUMNS, VERTICAL_SCROLL_STYLE, useTuiModel, } from "./chrome.js";
5
7
  import { AGENT_TREE_COLUMNS, IDENTITY_LABEL_GUTTER, agentColor, agentDetail, agentHandleText, formatClock, padCells, SELECTION_GUTTER_GLYPH, stepWrappingIndex, truncateCells, } from "./presentation.js";
6
8
  /**
7
9
  * Group projected agents under their projected repositories in stable registration order.
10
+ *
11
+ * A repository with no projected agent has no group. It stays in the projection, so the view state
12
+ * kept for it outlives the time it spends empty.
8
13
  * @param snapshot - Authoritative projection currently installed by the client model.
9
14
  * @returns Repository groups suitable for the tree presentation.
10
15
  */
@@ -17,13 +22,17 @@ export function selectTuiRepositoryAgentGroups(snapshot) {
17
22
  agents.push(agent);
18
23
  agentsByRepository.set(agent.repository.repositoryId, agents);
19
24
  }
20
- const groups = snapshot.repositories.map((repository) => {
25
+ const groups = snapshot.repositories.flatMap((repository) => {
21
26
  const agents = agentsByRepository.get(repository.id) ?? [];
22
- return {
23
- repository,
24
- active: agents.filter((agent) => !isOperatorAgentArchived(agent)),
25
- archived: agents.filter((agent) => isOperatorAgentArchived(agent)),
26
- };
27
+ if (agents.length === 0)
28
+ return [];
29
+ return [
30
+ {
31
+ repository,
32
+ active: agents.filter((agent) => !isOperatorAgentArchived(agent)),
33
+ archived: agents.filter((agent) => isOperatorAgentArchived(agent)),
34
+ },
35
+ ];
27
36
  });
28
37
  // Archived workspaces sink below the ones still working, the same way an archived agent sits below
29
38
  // its peers. Neither ordering is stable-sorted by anything else, so the projection's order holds
@@ -158,7 +167,7 @@ export function executeAgentTreeResult(model, onCommand, result) {
158
167
  */
159
168
  function reduceAgentTreeArchivedToggle(state, repositoryId) {
160
169
  const view = state.view;
161
- const group = selectTuiRepositoryAgentGroups(state.snapshot).find((candidate) => candidate.repository.id === repositoryId);
170
+ const group = repositoryGroup(state, repositoryId);
162
171
  if (!group)
163
172
  return { view };
164
173
  const sections = new Map(view.archivedSections);
@@ -192,6 +201,8 @@ export function reduceAgentTreeContextAction(state, action) {
192
201
  // selected it.
193
202
  if (action === "archive")
194
203
  return { view: { ...view, overlay: { kind: "archive_confirm", agentId: agent.id } } };
204
+ if (action === "purge")
205
+ return { view: { ...view, overlay: { kind: "purge_confirm", target: { kind: "agent", agentId: agent.id } } } };
195
206
  const archived = isOperatorAgentArchived(agent);
196
207
  const collapsedRepositories = new Set(view.collapsedRepositories);
197
208
  if (!archived)
@@ -207,11 +218,39 @@ export function reduceAgentTreeContextAction(state, action) {
207
218
  },
208
219
  };
209
220
  }
221
+ /**
222
+ * Open the purge confirmation for the archive section the menu was opened on.
223
+ * @param state - Current authoritative facts and local view state.
224
+ * @returns - The confirmation, naming the section's agents up to the purge limit, or the menu closed
225
+ * when the section has emptied.
226
+ */
227
+ function reduceArchivedContextAction(state) {
228
+ const view = state.view;
229
+ const overlay = view.overlay;
230
+ if (overlay.kind !== "context_menu" || overlay.target !== "archived")
231
+ return { view };
232
+ const archived = archivedSection(state, overlay.repositoryId);
233
+ if (archived.length === 0)
234
+ return { view: { ...view, focus: view.overlayReturnFocus, overlay: { kind: "none" }, contextActionIndex: 0 } };
235
+ const agentIds = archived.slice(0, OPERATOR_PURGE_TARGET_LIMIT).map((agent) => agent.id);
236
+ return {
237
+ view: {
238
+ ...view,
239
+ overlay: { kind: "purge_confirm", target: { kind: "archived", repositoryId: overlay.repositoryId, agentIds } },
240
+ },
241
+ };
242
+ }
243
+ function repositoryGroup(state, repositoryId) {
244
+ return selectTuiRepositoryAgentGroups(state.snapshot).find((group) => group.repository.id === repositoryId);
245
+ }
246
+ function archivedSection(state, repositoryId) {
247
+ return repositoryGroup(state, repositoryId)?.archived ?? [];
248
+ }
210
249
  /**
211
250
  * Reduce one keyboard command without depending on an OpenTUI input source.
212
251
  * @param state - Current authoritative projection and local presentation state.
213
252
  * @param key - Normalized OpenTUI key name.
214
- * @returns The next model-owned view and an optional broker command, a ping or an archive.
253
+ * @returns The next model-owned view and an optional broker command.
215
254
  */
216
255
  export function reduceAgentTreeKey(state, key) {
217
256
  const view = state.view;
@@ -237,6 +276,23 @@ export function reduceAgentTreeKey(state, key) {
237
276
  command: { type: "archive_agent", targetAgentId: target },
238
277
  };
239
278
  }
279
+ if (view.overlay.kind === "purge_confirm") {
280
+ if (key === "escape")
281
+ return close();
282
+ if (!activates(key))
283
+ return { view };
284
+ const targetAgentIds = selectTuiPurgeableAgents(view.overlay.target, state.snapshot?.agents ?? []).map((entry) => entry.id);
285
+ if (targetAgentIds.length === 0)
286
+ return close();
287
+ return {
288
+ view: {
289
+ ...close().view,
290
+ ...rescopedAgentLog,
291
+ selection: selectionAfterPurge(state, view.overlay.target, targetAgentIds),
292
+ },
293
+ command: { type: "purge_agents", targetAgentIds },
294
+ };
295
+ }
240
296
  if (view.overlay.kind === "context_menu" && view.overlay.target === "workspace") {
241
297
  if (key === "escape")
242
298
  return close();
@@ -244,6 +300,13 @@ export function reduceAgentTreeKey(state, key) {
244
300
  return { view };
245
301
  return reduceWorkspaceContextAction(state, workspaceContextActions(state)[0] ?? "hide");
246
302
  }
303
+ if (view.overlay.kind === "context_menu" && view.overlay.target === "archived") {
304
+ if (key === "escape")
305
+ return close();
306
+ if (!activates(key))
307
+ return { view };
308
+ return reduceArchivedContextAction(state);
309
+ }
247
310
  // The message menu shares this overlay kind, and its own component answers its keys.
248
311
  if (view.overlay.kind === "context_menu" && view.overlay.target === "agent") {
249
312
  if (key === "escape")
@@ -275,7 +338,7 @@ export function reduceAgentTreeKey(state, key) {
275
338
  },
276
339
  });
277
340
  if (key === "up" || key === "down") {
278
- const current = rows.findIndex((row) => JSON.stringify(rowSelection(row)) === JSON.stringify(view.selection));
341
+ const current = selectedRowIndex(rows, view.selection);
279
342
  const step = current < 0 ? 0 : current + (key === "up" ? -1 : 1);
280
343
  const next = rows[Math.min(Math.max(step, 0), rows.length - 1)];
281
344
  return next ? select(next) : { view };
@@ -293,11 +356,11 @@ export function reduceAgentTreeKey(state, key) {
293
356
  contextActionIndex: 0,
294
357
  },
295
358
  };
296
- if (key === "a" && view.selection?.kind === "workspace")
359
+ if (key === "a" && (view.selection?.kind === "workspace" || view.selection?.kind === "archived"))
297
360
  return {
298
361
  view: {
299
362
  ...view,
300
- overlay: { kind: "context_menu", target: "workspace", repositoryId: view.selection.repositoryId },
363
+ overlay: { kind: "context_menu", target: view.selection.kind, repositoryId: view.selection.repositoryId },
301
364
  overlayReturnFocus: view.focus,
302
365
  contextActionIndex: 0,
303
366
  },
@@ -359,13 +422,26 @@ export function AgentTree({ model, theme, onCommand, colorEnabled = true, width
359
422
  return;
360
423
  // Every pane's handler receives this same key, and a reduction carries a whole view, so one
361
424
  // taken from the rendered snapshot overwrites whatever another handler decided.
425
+ const overlayOpen = model.state.view.overlay.kind !== "none";
362
426
  const result = reduceAgentTreeKey(model.state, key.name);
427
+ // A key that closes the tree's overlay is spent on it; the shell's handler, when it runs after this
428
+ // one, would otherwise read it again against the closed view.
429
+ if (overlayOpen && result.view.overlay.kind === "none")
430
+ key.preventDefault();
363
431
  executeAgentTreeResult(model, onCommand, result);
364
432
  });
365
433
  // A tree whose every workspace is hidden still draws the hidden section, the only way to bring one back.
366
434
  if (all.length === 0)
367
435
  return (_jsx("box", { flexGrow: 1, justifyContent: "center", alignItems: "center", children: _jsx("text", { fg: theme.structure.rulesAndLabels, children: "no agents connected" }) }));
368
- return (_jsxs("box", { flexDirection: "column", flexGrow: 1, flexShrink: 1, overflow: "hidden", children: [_jsx("box", { paddingLeft: 1, paddingRight: 1, flexShrink: 0, children: _jsx(Line, { children: _jsx("text", { fg: theme.structure.rulesAndLabels, children: "AGENTS" }) }) }), _jsx(Line, {}), _jsxs("scrollbox", { flexGrow: 1, focused: focus, paddingLeft: 1, paddingRight: 1, style: VERTICAL_SCROLL_STYLE, children: [groups.map((group) => (_jsx(RepositoryRows, { group: group, state: state, theme: theme, colorEnabled: colorEnabled, width: width, onToggleArchived: toggleArchivedSection, onSelectRepository: (repository) => update((view) => ({
436
+ return (_jsxs("box", { flexDirection: "column", flexGrow: 1, flexShrink: 1, overflow: "hidden", children: [_jsx("box", { paddingLeft: 1, paddingRight: 1, flexShrink: 0, children: _jsx(Line, { children: _jsx("text", { fg: theme.structure.rulesAndLabels, children: "AGENTS" }) }) }), _jsx(Line, {}), _jsxs("scrollbox", { flexGrow: 1, focused: focus, paddingLeft: 1, paddingRight: 1, style: VERTICAL_SCROLL_STYLE, children: [groups.map((group) => (_jsx(RepositoryRows, { group: group, state: state, theme: theme, colorEnabled: colorEnabled, width: width, onToggleArchived: toggleArchivedSection, onArchivedContext: (repository) => update((view) => ({
437
+ ...view,
438
+ focus: "tree",
439
+ ...rescopedAgentLog,
440
+ selection: { kind: "archived", repositoryId: repository.id },
441
+ overlay: { kind: "context_menu", target: "archived", repositoryId: repository.id },
442
+ overlayReturnFocus: view.focus,
443
+ contextActionIndex: 0,
444
+ })), onSelectRepository: (repository) => update((view) => ({
369
445
  ...view,
370
446
  focus: "tree",
371
447
  ...rescopedAgentLog,
@@ -420,11 +496,21 @@ export function AgentTreeOverlay({ model, theme, onCommand, colorEnabled = true
420
496
  state.snapshot?.agents.find((agent) => agent.id === overlay.agentId)?.repository.repositoryId), theme: theme, colorEnabled: colorEnabled }));
421
497
  if (overlay.kind === "archive_confirm")
422
498
  return (_jsx(ArchiveConfirmOverlay, { agent: state.snapshot?.agents.find((agent) => agent.id === overlay.agentId), theme: theme }));
423
- if (overlay.kind === "context_menu" && overlay.target === "workspace")
424
- return (_jsx(WorkspaceContextOverlay, { actions: workspaceContextActions(state), theme: theme, onAction: (action) => executeAgentTreeResult(model, onCommand, reduceWorkspaceContextAction(model.state, action)) }));
499
+ if (overlay.kind === "purge_confirm")
500
+ return (_jsx(PurgeConfirmOverlay, { target: overlay.target, agents: selectTuiPurgeableAgents(overlay.target, state.snapshot?.agents ?? []), sectionSize: overlay.target.kind === "archived" ? archivedSection(state, overlay.target.repositoryId).length : 1, theme: theme }));
501
+ if (overlay.kind === "context_menu" && overlay.target === "archived")
502
+ return (_jsx(SingleActionOverlay, { label: "purge archived", theme: theme, onAction: () => executeAgentTreeResult(model, onCommand, reduceArchivedContextAction(model.state)) }));
503
+ if (overlay.kind === "context_menu" && overlay.target === "workspace") {
504
+ const [action] = workspaceContextActions(state);
505
+ return action === undefined ? null : (_jsx(SingleActionOverlay, { label: workspaceContextActionLabels[action], theme: theme, onAction: () => {
506
+ const [current] = workspaceContextActions(model.state);
507
+ if (current !== undefined)
508
+ executeAgentTreeResult(model, onCommand, reduceWorkspaceContextAction(model.state, current));
509
+ } }));
510
+ }
425
511
  return overlay.kind === "context_menu" && overlay.target === "agent" ? (_jsx(AgentContextOverlay, { actionIndex: state.view.contextActionIndex, theme: theme, onAction: (action) => executeAgentTreeResult(model, onCommand, reduceAgentTreeContextAction(model.state, action)) })) : null;
426
512
  }
427
- function RepositoryRows({ group, state, theme, colorEnabled, width, onToggleArchived, onSelectRepository, onSelectAgent, onContext, }) {
513
+ function RepositoryRows({ group, state, theme, colorEnabled, width, onToggleArchived, onArchivedContext, onSelectRepository, onSelectAgent, onContext, }) {
428
514
  const expanded = !state.view.collapsedRepositories.has(group.repository.id);
429
515
  const archivedExpanded = isTuiArchivedSectionOpen(state.view, group);
430
516
  const focused = state.view.focus === "tree";
@@ -438,7 +524,12 @@ function RepositoryRows({ group, state, theme, colorEnabled, width, onToggleArch
438
524
  const archivedColor = archivedSelected && focused ? theme.structure.selection.text : theme.structure.rulesAndLabels;
439
525
  return (_jsxs("box", { flexDirection: "column", children: [_jsxs("box", { height: 1, flexDirection: "row", onMouseDown: () => onSelectRepository(group.repository), children: [_jsx(SelectionGutter, { marked: selectedRepository, theme: theme }), _jsx("box", { flexGrow: 1, flexDirection: "row", ...(selectedRepository && focused ? { backgroundColor: theme.structure.selection.background } : {}), children: _jsx("text", { fg: repositoryColor, children: `${expanded ? "▾" : "▸"} ${truncateCells(group.repository.name, width - 6 - SCROLLBAR_RESERVED_COLUMNS)}` }) })] }), expanded
440
526
  ? group.active.map((agent, index) => (_jsx(AgentRow, { agent: agent, state: state, theme: theme, colorEnabled: colorEnabled, width: width, last: group.archived.length === 0 && index === group.active.length - 1, onSelect: onSelectAgent, onContext: onContext }, agent.id)))
441
- : null, expanded && group.archived.length > 0 ? (_jsxs(_Fragment, { children: [_jsxs("box", { height: 1, flexDirection: "row", onMouseDown: () => onToggleArchived(group.repository), children: [_jsx(SelectionGutter, { marked: archivedSelected, theme: theme }), _jsx("box", { flexGrow: 1, flexDirection: "row", ...(archivedSelected && focused ? { backgroundColor: theme.structure.selection.background } : {}), children: _jsx("text", { fg: archivedColor, children: `└ ${archivedExpanded ? "▾" : "▸"} archived · ${String(group.archived.length)}` }) })] }), archivedExpanded
527
+ : null, expanded && group.archived.length > 0 ? (_jsxs(_Fragment, { children: [_jsxs("box", { height: 1, flexDirection: "row", onMouseDown: (event) => {
528
+ if (event.button === 2)
529
+ onArchivedContext(group.repository);
530
+ else
531
+ onToggleArchived(group.repository);
532
+ }, children: [_jsx(SelectionGutter, { marked: archivedSelected, theme: theme }), _jsx("box", { flexGrow: 1, flexDirection: "row", ...(archivedSelected && focused ? { backgroundColor: theme.structure.selection.background } : {}), children: _jsx("text", { fg: archivedColor, children: `└ ${archivedExpanded ? "▾" : "▸"} archived · ${String(group.archived.length)}` }) })] }), archivedExpanded
442
533
  ? group.archived.map((agent, index) => (_jsx(ArchivedAgentRow, { agent: agent, last: index === group.archived.length - 1, state: state, theme: theme, width: width, onSelect: onSelectAgent, onContext: onContext }, agent.id)))
443
534
  : null] })) : null, _jsx("box", { height: 1 })] }));
444
535
  }
@@ -525,7 +616,32 @@ function ArchiveConfirmOverlay({ agent, theme, }) {
525
616
  return null;
526
617
  return (_jsxs(Overlay, { theme: theme, width: ARCHIVE_OVERLAY_COLUMNS, children: [_jsx(PaneBar, { left: _jsx("text", { fg: theme.states.failed, children: `archive ${agentHandleText(agent.alias)}?` }), right: null }), _jsx(Rule, { theme: theme }), _jsx(PaneBar, { left: _jsx("text", { fg: theme.structure.rulesAndLabels, children: ARCHIVE_CONFIRM_FOOTER_KEYS }), right: null })] }));
527
618
  }
528
- const contextActions = ["ping", "details", "archive", "collapse"];
619
+ /** Keys the purge confirmation advertises in its footer. */
620
+ const PURGE_CONFIRM_FOOTER_KEYS = "⏎ purge esc cancel";
621
+ const PURGE_OVERLAY_COLUMNS = 44;
622
+ /**
623
+ * Confirm a purge, naming the agent it deletes or how many archived agents it deletes.
624
+ * @param properties - What the confirmation names, the agents it would delete now, how many agents
625
+ * the archive section holds now, and the palette.
626
+ * @returns - Confirmation overlay, or nothing once there is nothing left to purge.
627
+ */
628
+ function PurgeConfirmOverlay({ target, agents, sectionSize, theme, }) {
629
+ const [only] = agents;
630
+ if (only === undefined)
631
+ return null;
632
+ const plural = agents.length !== 1;
633
+ const partial = sectionSize > agents.length;
634
+ const count = partial ? `${String(agents.length)} of ${String(sectionSize)}` : String(agents.length);
635
+ const subject = target.kind === "agent" ? agentHandleText(only.alias) : `${count} archived agent${plural || partial ? "s" : ""}`;
636
+ const running = agents.filter((agent) => agent.adapter.state === "connected").length;
637
+ return (_jsxs(Overlay, { theme: theme, width: PURGE_OVERLAY_COLUMNS, children: [_jsx(PaneBar, { left: _jsx("text", { fg: theme.states.failed, children: `purge ${subject}?` }), right: null }), _jsx(Rule, { theme: theme }), _jsxs("box", { flexDirection: "column", paddingLeft: 1, paddingRight: 1, children: [_jsx(Line, { children: _jsx("text", { fg: theme.structure.bodyText, children: plural ? "deletes the agents and their history," : "deletes the agent and its history," }) }), _jsx(Line, { children: _jsx("text", { fg: theme.structure.bodyText, children: "except messages with remaining agents;" }) }), running > 0 ? (_jsx(Line, { children: _jsx("text", { fg: theme.structure.bodyText, children: !plural
638
+ ? "ends its running session;"
639
+ : `ends ${String(running)} running session${running === 1 ? "" : "s"};` }) })) : null, _jsx(Line, { children: _jsx("text", { fg: theme.structure.bodyText, children: "this cannot be undone" }) })] }), _jsx(Rule, { theme: theme }), _jsx(PaneBar, { left: _jsx("text", { fg: theme.structure.rulesAndLabels, children: PURGE_CONFIRM_FOOTER_KEYS }), right: null })] }));
640
+ }
641
+ function SingleActionOverlay({ label, theme, onAction, }) {
642
+ return (_jsxs(Overlay, { theme: theme, width: CONTEXT_OVERLAY_COLUMNS, children: [_jsx(PaneBar, { left: _jsx("text", { fg: theme.structure.emphasis, children: "Actions" }), right: _jsx("text", { fg: theme.structure.rulesAndLabels, children: "esc" }) }), _jsx(Rule, { theme: theme }), _jsx("box", { height: 1, backgroundColor: theme.structure.selection.background, onMouseDown: onAction, children: _jsx("text", { fg: theme.structure.selection.text, children: ` ${label}` }) }), _jsx(Rule, { theme: theme }), _jsx(PaneBar, { left: _jsx("text", { fg: theme.structure.rulesAndLabels, children: CONTEXT_FOOTER_KEYS }), right: null })] }));
643
+ }
644
+ const contextActions = ["ping", "details", "archive", "purge", "collapse"];
529
645
  /**
530
646
  * List the actions offered for the workspace the menu was opened on.
531
647
  * @param state - Current model state, holding what is hidden.
@@ -545,11 +661,9 @@ const contextActionLabels = {
545
661
  ping: "ping inbox",
546
662
  details: "details",
547
663
  archive: "archive agent",
664
+ purge: "purge agent",
548
665
  collapse: "collapse workspace",
549
666
  };
550
- function WorkspaceContextOverlay({ actions, theme, onAction, }) {
551
- return (_jsxs(Overlay, { theme: theme, width: CONTEXT_OVERLAY_COLUMNS, children: [_jsx(PaneBar, { left: _jsx("text", { fg: theme.structure.emphasis, children: "Actions" }), right: _jsx("text", { fg: theme.structure.rulesAndLabels, children: "esc" }) }), _jsx(Rule, { theme: theme }), actions.map((action) => (_jsx("box", { height: 1, backgroundColor: theme.structure.selection.background, onMouseDown: () => onAction(action), children: _jsx("text", { fg: theme.structure.selection.text, children: ` ${workspaceContextActionLabels[action]}` }) }, action))), _jsx(Rule, { theme: theme }), _jsx(PaneBar, { left: _jsx("text", { fg: theme.structure.rulesAndLabels, children: CONTEXT_FOOTER_KEYS }), right: null })] }));
552
- }
553
667
  function AgentContextOverlay({ actionIndex, theme, onAction, }) {
554
668
  return (_jsxs(Overlay, { theme: theme, width: CONTEXT_OVERLAY_COLUMNS, children: [_jsx(PaneBar, { left: _jsx("text", { fg: theme.structure.emphasis, children: "Actions" }), right: _jsx("text", { fg: theme.structure.rulesAndLabels, children: "esc" }) }), _jsx(Rule, { theme: theme }), contextActions.map((action, index) => (_jsxs("box", { flexDirection: "column", children: [action === "collapse" ? _jsx(Rule, { theme: theme }) : null, _jsx("box", { height: 1, ...(index === actionIndex ? { backgroundColor: theme.structure.selection.background } : {}), onMouseDown: () => onAction(action), children: _jsx("text", { fg: index === actionIndex ? theme.structure.selection.text : theme.structure.emphasis, children: ` ${contextActionLabels[action]}` }) })] }, action))), _jsx(Rule, { theme: theme }), _jsx(PaneBar, { left: _jsx("text", { fg: theme.structure.rulesAndLabels, children: CONTEXT_FOOTER_KEYS }), right: null })] }));
555
669
  }
@@ -586,6 +700,65 @@ function selectionAfterArchive(state, agentId) {
586
700
  const next = index < 0 ? undefined : rows[index + 1];
587
701
  return next ? rowSelection(next) : { kind: "workspace", repositoryId };
588
702
  }
703
+ /**
704
+ * Choose what the tree selects once a purge lands.
705
+ *
706
+ * A section purge selects the section while it keeps an agent, or else its workspace. An agent purge
707
+ * selects the workspace when it takes the workspace's last working agent, since the workspace then
708
+ * collapses. Neither selects a workspace the purge leaves with no agent, since the tree has no row
709
+ * for it; the selection then moves to the nearest row below that outlives the purge, or else the
710
+ * nearest above.
711
+ * @param state - Current authoritative facts and local view state.
712
+ * @param target - What the confirmed purge named.
713
+ * @param agentIds - Agents about to be purged.
714
+ * @returns - Selection to hold after the purge lands.
715
+ */
716
+ function selectionAfterPurge(state, target, agentIds) {
717
+ const purged = new Set(agentIds);
718
+ const agents = state.snapshot?.agents ?? [];
719
+ const kept = (group) => group.some((agent) => !purged.has(agent.id));
720
+ const groups = new Map(selectTuiRepositoryAgentGroups(state.snapshot).map((group) => [group.repository.id, group]));
721
+ const keepsArchived = (repositoryId) => kept(groups.get(repositoryId)?.archived ?? []);
722
+ const keepsAgents = (repositoryId) => keepsArchived(repositoryId) || kept(groups.get(repositoryId)?.active ?? []);
723
+ if (target.kind === "archived") {
724
+ if (keepsArchived(target.repositoryId))
725
+ return { kind: "archived", repositoryId: target.repositoryId };
726
+ if (keepsAgents(target.repositoryId))
727
+ return { kind: "workspace", repositoryId: target.repositoryId };
728
+ }
729
+ else {
730
+ const agent = agents.find((entry) => entry.id === target.agentId);
731
+ if (agent && !isOperatorAgentArchived(agent)) {
732
+ const repositoryId = agent.repository.repositoryId;
733
+ const working = agents.filter((entry) => entry.repository.repositoryId === repositoryId && !isOperatorAgentArchived(entry) && !purged.has(entry.id));
734
+ if (working.length === 0 && keepsAgents(repositoryId))
735
+ return { kind: "workspace", repositoryId };
736
+ }
737
+ }
738
+ const rows = selectVisibleTuiAgentTreeRows(state);
739
+ const survives = (row) => {
740
+ if (row.kind === "agent")
741
+ return !purged.has(row.agentId);
742
+ if (row.kind === "archived")
743
+ return keepsArchived(row.repositoryId);
744
+ if (row.kind === "workspace")
745
+ return keepsAgents(row.repositoryId);
746
+ return true;
747
+ };
748
+ const current = selectedRowIndex(rows, state.view.selection);
749
+ const from = current < 0 ? 0 : current;
750
+ const next = rows.slice(from).find(survives) ?? rows.slice(0, from).reverse().find(survives);
751
+ return next ? rowSelection(next) : null;
752
+ }
753
+ /**
754
+ * Find the visible row a selection names.
755
+ * @param rows - Visible rows in tree order.
756
+ * @param selection - Selection the tree holds, which may name a row that is collapsed or hidden.
757
+ * @returns - The row's index, or -1 when no visible row is selected.
758
+ */
759
+ function selectedRowIndex(rows, selection) {
760
+ return rows.findIndex((row) => JSON.stringify(rowSelection(row)) === JSON.stringify(selection));
761
+ }
589
762
  /**
590
763
  * Report the selection one tree row stands for.
591
764
  * @param row - Row the operator moved onto.
@@ -611,8 +784,8 @@ function archiveStatusLabel(agent) {
611
784
  return "archived by operator";
612
785
  case "lifecycle":
613
786
  return agent.lifecycle;
614
- case "workspace_removed":
615
- return "workspace removed";
787
+ case "directory_removed":
788
+ return "directory removed";
616
789
  }
617
790
  }
618
791
  const TREE_STATE_COLUMN = 2;
@@ -6,7 +6,7 @@ import { AgentTree, AgentTreeOverlay, executeAgentTreeResult, reduceAgentTreeKey
6
6
  import { ActionRunOverlay, ActionsOverlay, reduceOperatorActionsKey, skillInstallTerminalSize, } from "./actions.js";
7
7
  import { Line, Overlay, PaneBar, Rule, VERTICAL_SCROLL_STYLE, useTuiModel } from "./chrome.js";
8
8
  import { AgentTrafficLog, MessageDetail, MessageOverlays, selectNewestTuiTraffic, stepTuiTrafficSelection, } from "./message-views.js";
9
- import { AGENT_TREE_COLUMNS, sidePaneWidths, MESSAGE_LOG_COLUMNS, PORTRAIT_BREAKPOINT_COLUMNS, agentColor, activeAgentCount, agentLogScope, brokerIndicator, COLLAPSED_LOG_ROWS, describeCommandOutcome, diagnosticColor, fitBarLeading, formatClock, formatDiagnostic, selectedMessage, selectMessageBodyRows, selectTuiTraffic, } from "./presentation.js";
9
+ import { AGENT_TREE_COLUMNS, sidePaneWidths, MESSAGE_LOG_COLUMNS, PORTRAIT_BREAKPOINT_COLUMNS, agentColor, activeAgentCount, agentLogScope, brokerIndicator, COLLAPSED_LOG_ROWS, describeCommandOutcome, describeCommandTarget, diagnosticColor, fitBarLeading, formatClock, formatDiagnostic, selectedMessage, selectMessageBodyRows, selectTuiTraffic, } from "./presentation.js";
10
10
  import { dimTuiTheme } from "./theme.js";
11
11
  import { PAGER_VERSION } from "../version.js";
12
12
  /* eslint-enable jsdoc/require-jsdoc -- The collaborator declarations end here. */
@@ -143,6 +143,9 @@ export function OperatorTui(properties) {
143
143
  };
144
144
  }, [properties.model]);
145
145
  useKeyboard((key) => {
146
+ // A pane whose handler ran first marks a key it spent closing its own overlay.
147
+ if (key.defaultPrevented)
148
+ return;
146
149
  const current = properties.model.state;
147
150
  if (current.view.overlay.kind === "help" || current.view.overlay.kind === "log") {
148
151
  // The key that opened the log closes it, so it reads as a toggle rather than a one-way door.
@@ -357,8 +360,9 @@ function DividerCell({ theme, glyph }) {
357
360
  }
358
361
  function submitCommand(model, commands) {
359
362
  return (command) => {
363
+ const target = describeCommandTarget(model.state, command);
360
364
  const report = (result) => {
361
- const outcome = describeCommandOutcome(model.state, command, result);
365
+ const outcome = describeCommandOutcome(target, command, result);
362
366
  model.reportDiagnostic(outcome.severity, "command", outcome.summary);
363
367
  };
364
368
  void model
@@ -4,7 +4,7 @@ import { useState } from "react";
4
4
  import { isTuiExpirable } from "../operator/agent-projection.js";
5
5
  import { OPERATOR_PROTOCOL_VERSION, } from "../operator/protocol.js";
6
6
  import { Line, Overlay, PaneBar, Rule, SCROLLBAR_RESERVED_COLUMNS, VERTICAL_SCROLL_STYLE, WRAPPED_TEXT_SCROLL_STYLE, fitOverlayColumns, useTuiModel, } from "./chrome.js";
7
- import { AGENT_TRAFFIC_COLUMNS, MESSAGE_LABEL_GUTTER, MINIMUM_MESSAGE_BODY_ROWS, MESSAGE_LOG_COLUMNS, SELECTION_GUTTER_GLYPH, WORKSPACE_TRAFFIC_COLUMNS, agentColor, agentHandle, agentAddress, agentLogScope, deliveryColor, deliveryGlyph, describeCommandOutcome, findTuiMessage, fitBarLeading, formatClock, formatClockSeconds, PANE_BAR_INSET_COLUMNS, padCells, padCellsBeforeColumn, padCellsEnd, selectedMessage, selectTuiTraffic, stepWrappingIndex, trafficSubject, trafficTime, trafficType, trafficTypeColumnWidth, truncateCells, } from "./presentation.js";
7
+ import { AGENT_TRAFFIC_COLUMNS, MESSAGE_LABEL_GUTTER, MINIMUM_MESSAGE_BODY_ROWS, MESSAGE_LOG_COLUMNS, SELECTION_GUTTER_GLYPH, WORKSPACE_TRAFFIC_COLUMNS, agentColor, agentHandle, agentAddress, agentLogScope, deliveryColor, deliveryGlyph, describeCommandOutcome, describeCommandTarget, findTuiMessage, fitBarLeading, formatClock, formatClockSeconds, PANE_BAR_INSET_COLUMNS, padCells, padCellsBeforeColumn, padCellsEnd, selectedMessage, selectTuiTraffic, stepWrappingIndex, trafficSubject, trafficTime, trafficType, trafficTypeColumnWidth, truncateCells, } from "./presentation.js";
8
8
  /* eslint-enable jsdoc/require-jsdoc -- The port and property declarations end here. */
9
9
  /**
10
10
  * Render a distinct, renderer-neutral description for each traffic protocol member.
@@ -380,6 +380,7 @@ function ExpireConfirmOverlay({ model, theme, commands, message, }) {
380
380
  */
381
381
  async function submitExpiry(model, commands, messageId) {
382
382
  const command = { type: "expire_message", messageId };
383
+ const target = describeCommandTarget(model.state, command);
383
384
  let result;
384
385
  try {
385
386
  result = await model.execute({
@@ -390,11 +391,11 @@ async function submitExpiry(model, commands, messageId) {
390
391
  });
391
392
  }
392
393
  catch {
393
- const outcome = describeCommandOutcome(model.state, command, null);
394
+ const outcome = describeCommandOutcome(target, command, null);
394
395
  model.reportDiagnostic(outcome.severity, "command", outcome.summary);
395
396
  return;
396
397
  }
397
- const outcome = describeCommandOutcome(model.state, command, result);
398
+ const outcome = describeCommandOutcome(target, command, result);
398
399
  model.reportDiagnostic(outcome.severity, "command", outcome.summary);
399
400
  try {
400
401
  commands.observe(result);
@@ -1,5 +1,5 @@
1
1
  import { type OperatorAgentHealth } from "../operator/agent-projection.js";
2
- import { type OperatorCommandEnvelope, type OperatorCommandResult, type OperatorCursor, type OperatorDiagnostic, type OperatorSnapshot, type OperatorTraffic } from "../operator/protocol.js";
2
+ import { type OperatorAgent, type OperatorCommandEnvelope, type OperatorCommandResult, type OperatorCursor, type OperatorDiagnostic, type OperatorSnapshot, type OperatorTraffic } from "../operator/protocol.js";
3
3
  import type { AgentId } from "../broker/protocol.js";
4
4
  /** A renderer-neutral terminal pane. */
5
5
  export type TuiPane = "tree" | "agent_log" | "message_log";
@@ -37,9 +37,16 @@ export type TuiOverlay = {
37
37
  kind: "context_menu";
38
38
  target: "workspace";
39
39
  repositoryId: string;
40
+ } | {
41
+ kind: "context_menu";
42
+ target: "archived";
43
+ repositoryId: string;
40
44
  } | {
41
45
  kind: "archive_confirm";
42
46
  agentId: AgentId;
47
+ } | {
48
+ kind: "purge_confirm";
49
+ target: TuiPurgeTarget;
43
50
  } | {
44
51
  kind: "expire_confirm";
45
52
  messageId: string;
@@ -53,6 +60,23 @@ export type TuiOverlay = {
53
60
  } | {
54
61
  kind: "log";
55
62
  };
63
+ /** What a purge confirmation would delete: one agent, or the agents one workspace's archive section held when it opened. */
64
+ export type TuiPurgeTarget = {
65
+ kind: "agent";
66
+ agentId: AgentId;
67
+ } | {
68
+ kind: "archived";
69
+ repositoryId: string;
70
+ agentIds: readonly AgentId[];
71
+ };
72
+ /**
73
+ * List the agents a purge confirmation still deletes.
74
+ * @param target - What the confirmation names.
75
+ * @param agents - Agents the current projection holds.
76
+ * @returns - The named agent while it is projected, or those the archive section held when the
77
+ * confirmation opened that are still archived in that workspace.
78
+ */
79
+ export declare function selectTuiPurgeableAgents(target: TuiPurgeTarget, agents: readonly OperatorAgent[]): OperatorAgent[];
56
80
  /** Local operator state kept outside authoritative broker facts. */
57
81
  export type TuiOperatorViewState = {
58
82
  focus: TuiPane;
@@ -1,7 +1,20 @@
1
- import { archivedOperatorRepositoryIds, selectOperatorAgentHealth, } from "../operator/agent-projection.js";
1
+ import { archivedOperatorRepositoryIds, isOperatorAgentArchived, selectOperatorAgentHealth, } from "../operator/agent-projection.js";
2
2
  import { OPERATOR_PROTOCOL_VERSION, } from "../operator/protocol.js";
3
3
  import { classifyOperatorEvent, validateOperatorSnapshot } from "../operator/validation.js";
4
4
  import { isTuiExpirable } from "../operator/agent-projection.js";
5
+ /**
6
+ * List the agents a purge confirmation still deletes.
7
+ * @param target - What the confirmation names.
8
+ * @param agents - Agents the current projection holds.
9
+ * @returns - The named agent while it is projected, or those the archive section held when the
10
+ * confirmation opened that are still archived in that workspace.
11
+ */
12
+ export function selectTuiPurgeableAgents(target, agents) {
13
+ if (target.kind === "agent")
14
+ return agents.filter((agent) => agent.id === target.agentId);
15
+ const named = new Set(target.agentIds);
16
+ return agents.filter((agent) => named.has(agent.id) && agent.repository.repositoryId === target.repositoryId && isOperatorAgentArchived(agent));
17
+ }
5
18
  /* eslint-enable jsdoc/require-jsdoc -- The state field declarations end here. */
6
19
  /** Entries the diagnostics log keeps before discarding its oldest. */
7
20
  const TUI_DIAGNOSTIC_LIMIT = 200;
@@ -657,14 +670,19 @@ function reconcileView(view, snapshot, previous) {
657
670
  const messageIds = acceptedIds(snapshot.traffic);
658
671
  const trafficIds = new Set(snapshot.traffic.map((traffic) => traffic.trafficId));
659
672
  const hiddenRepositories = new Set([...view.hiddenRepositories].filter((id) => repositoryIds.has(id)));
660
- const selection = selectionSurviving(view.selection, repositoryIds, agentIds, hiddenRepositories);
673
+ // A repository with no agent keeps its view state but has no row to select.
674
+ const occupied = new Set(snapshot.agents.map((agent) => agent.repository.repositoryId));
675
+ const sections = new Set(snapshot.agents.filter(isOperatorAgentArchived).map((agent) => agent.repository.repositoryId));
676
+ const selection = selectionSurviving(view.selection, occupied, sections, agentIds, new Set([...hiddenRepositories].filter((id) => occupied.has(id))));
661
677
  const trafficId = view.trafficId !== null && !trafficIds.has(view.trafficId) ? null : view.trafficId;
662
678
  return {
663
679
  ...view,
664
680
  selection,
665
681
  trafficId,
666
- overlay: reconcileOverlay(view.overlay, repositoryIds, agentIds, messageIds, expirableIds(snapshot.traffic)),
667
- collapsedRepositories: new Set([...collapsedRepositories].filter((id) => repositoryIds.has(id))),
682
+ overlay: reconcileOverlay(view.overlay, snapshot.agents, agentIds, occupied, sections, messageIds, expirableIds(snapshot.traffic)),
683
+ // An empty workspace's collapse is dropped, so one that was collapsed for being archived opens for
684
+ // the agent that next starts in it.
685
+ collapsedRepositories: new Set([...collapsedRepositories].filter((id) => occupied.has(id))),
668
686
  hiddenRepositories,
669
687
  archivedSections: new Map([...view.archivedSections].filter(([id]) => repositoryIds.has(id))),
670
688
  };
@@ -672,16 +690,18 @@ function reconcileView(view, snapshot, previous) {
672
690
  /**
673
691
  * Keep a tree selection only while the row it names can still be shown.
674
692
  * @param selection - Selection held before the projection changed.
675
- * @param repositoryIds - Repositories the new projection holds.
693
+ * @param repositoryIds - Repositories the new projection shows a row for.
694
+ * @param archivedSections - Repositories the new projection shows an archive section for.
676
695
  * @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.
696
+ * @param hiddenRepositories - Hidden repositories the new projection shows; the hidden section exists only while any do.
678
697
  * @returns The same selection, or null when its row is gone.
679
698
  */
680
- function selectionSurviving(selection, repositoryIds, agentIds, hiddenRepositories) {
699
+ function selectionSurviving(selection, repositoryIds, archivedSections, agentIds, hiddenRepositories) {
681
700
  switch (selection?.kind) {
682
701
  case "workspace":
683
- case "archived":
684
702
  return repositoryIds.has(selection.repositoryId) ? selection : null;
703
+ case "archived":
704
+ return archivedSections.has(selection.repositoryId) ? selection : null;
685
705
  case "agent":
686
706
  return agentIds.has(selection.agentId) ? selection : null;
687
707
  case "hidden":
@@ -693,13 +713,15 @@ function selectionSurviving(selection, repositoryIds, agentIds, hiddenRepositori
693
713
  function expirableIds(traffic) {
694
714
  return new Set(traffic.flatMap((entry) => (entry.kind === "accepted_delivery" && isTuiExpirable(entry) ? [entry.messageId] : [])));
695
715
  }
696
- function reconcileOverlay(overlay, repositoryIds, agentIds, messageIds, expirable) {
716
+ function reconcileOverlay(overlay, agents, agentIds, repositoryIds, archivedSections, messageIds, expirable) {
697
717
  const closed = { kind: "none" };
698
718
  switch (overlay.kind) {
699
719
  case "agent_identity":
700
720
  return agentIds.has(overlay.agentId) ? overlay : closed;
701
721
  case "archive_confirm":
702
722
  return agentIds.has(overlay.agentId) ? overlay : closed;
723
+ case "purge_confirm":
724
+ return selectTuiPurgeableAgents(overlay.target, agents).length > 0 ? overlay : closed;
703
725
  case "full_message":
704
726
  return messageIds.has(overlay.messageId) ? overlay : closed;
705
727
  case "expire_confirm":
@@ -710,6 +732,8 @@ function reconcileOverlay(overlay, repositoryIds, agentIds, messageIds, expirabl
710
732
  return agentIds.has(overlay.agentId) ? overlay : closed;
711
733
  if (overlay.target === "message")
712
734
  return messageIds.has(overlay.messageId) ? overlay : closed;
735
+ if (overlay.target === "archived")
736
+ return archivedSections.has(overlay.repositoryId) ? overlay : closed;
713
737
  return repositoryIds.has(overlay.repositoryId) ? overlay : closed;
714
738
  case "none":
715
739
  case "actions":