@haywoodmarx/agent-pager 0.2.0 → 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.
@@ -3,9 +3,8 @@ import { StringDecoder } from "node:string_decoder";
3
3
  import { randomBytes } from "node:crypto";
4
4
  import { LOOPBACK_HOST } from "../configuration-defaults.js";
5
5
  import { closeServer, listenServer, urlHost } from "../shared/http.js";
6
- import { OPERATOR_PROTOCOL_VERSION, OPERATOR_SNAPSHOT_CHUNK_BYTES, OPERATOR_TRANSPORT_FRAME_LIMIT_BYTES, } from "./protocol.js";
6
+ import { OPERATOR_PROTOCOL_VERSION, OPERATOR_SNAPSHOT_CHUNK_BYTES, OPERATOR_TRANSPORT_FRAME_LIMIT_BYTES, OPERATOR_TRANSPORT_REQUEST_LIMIT_BYTES, } from "./protocol.js";
7
7
  import { OperatorValidationError, validateOperatorCommandResultForCommand, validateOperatorSnapshotByteLength, validateOperatorTransportRequest, } from "./validation.js";
8
- const INPUT_LIMIT_BYTES = 64 * 1_024;
9
8
  const LIVE_QUEUE_LIMIT = 32;
10
9
  const DIAGNOSTIC_LIMIT = 256;
11
10
  const DEFAULT_MAX_CONNECTIONS = 64;
@@ -214,10 +213,16 @@ class OperatorConnection {
214
213
  this.#socket.destroy();
215
214
  }
216
215
  #receive(chunk) {
217
- if (this.#closed)
216
+ // A connection that is closing reads nothing more, so input from a peer that has stopped reading
217
+ // cannot pile up while its last frames wait to flush.
218
+ if (this.#closed || this.#closeAfterFlush) {
219
+ this.#buffer = "";
218
220
  return;
221
+ }
219
222
  this.#buffer += chunk;
220
- if (Buffer.byteLength(this.#buffer, "utf8") > INPUT_LIMIT_BYTES) {
223
+ // One read can carry several whole requests, so only the unterminated one is measured here.
224
+ const unterminated = this.#buffer.slice(this.#buffer.lastIndexOf("\n") + 1);
225
+ if (Buffer.byteLength(unterminated, "utf8") > OPERATOR_TRANSPORT_REQUEST_LIMIT_BYTES) {
221
226
  this.#malformed();
222
227
  return;
223
228
  }
@@ -227,7 +232,7 @@ class OperatorConnection {
227
232
  return;
228
233
  const line = this.#buffer.slice(0, newline);
229
234
  this.#buffer = this.#buffer.slice(newline + 1);
230
- if (!line || Buffer.byteLength(line, "utf8") > INPUT_LIMIT_BYTES) {
235
+ if (!line || Buffer.byteLength(line, "utf8") > OPERATOR_TRANSPORT_REQUEST_LIMIT_BYTES) {
231
236
  this.#malformed();
232
237
  return;
233
238
  }
@@ -8,7 +8,13 @@ const UPDATE_DIAGNOSTIC_LIMIT = 64;
8
8
  /** Broker tables whose rows feed the repository and agent halves of an operator snapshot. */
9
9
  const AGENT_SOURCE_TABLES = ["agents", "sessions", "workspaces", "alias_history"];
10
10
  /** Broker tables whose rows feed the traffic half of an operator snapshot. */
11
- const TRAFFIC_SOURCE_TABLES = ["messages", "message_events", "claims", "traffic_positions"];
11
+ const TRAFFIC_SOURCE_TABLES = [
12
+ "messages",
13
+ "message_events",
14
+ "claims",
15
+ "traffic_positions",
16
+ "operator_ping_audits",
17
+ ];
12
18
  /** Produces complete snapshots and durable strictly ordered projection changes. */
13
19
  export class OperatorUpdateService {
14
20
  #source;
@@ -1,4 +1,4 @@
1
- import { OPERATOR_DIAGNOSTIC_TEXT_LIMIT, OPERATOR_PROTOCOL_VERSION, OPERATOR_REJECTION_REASONS, OPERATOR_SNAPSHOT_TOTAL_LIMIT_BYTES, } from "./protocol.js";
1
+ import { OPERATOR_DIAGNOSTIC_TEXT_LIMIT, OPERATOR_PROTOCOL_VERSION, OPERATOR_PURGE_TARGET_LIMIT, OPERATOR_REJECTION_REASONS, OPERATOR_SNAPSHOT_TOTAL_LIMIT_BYTES, } from "./protocol.js";
2
2
  import { BROKER_OPERATOR_TRAFFIC_LIMIT, hasControlCharacter, isAgentId, isImplementationId, isOpaqueId, lifecycleEventState, MESSAGE_BODY_LIMIT_BYTES, MESSAGE_SUBJECT_LIMIT_CHARACTERS, MESSAGE_TYPE_LIMIT_CHARACTERS, } from "../broker/protocol.js";
3
3
  import { MESSAGE_STATES } from "../broker/schema.js";
4
4
  import { exactFields as guardExactFields, readArray as guardReadArray, readOwnEnumerableEntries as guardReadOwnEnumerableEntries, readRecord as guardReadRecord, } from "../shared/guards.js";
@@ -407,6 +407,22 @@ export function validateOperatorCommandResult(value) {
407
407
  commandId: readOpaqueId(record.commandId, "commandResult.commandId"),
408
408
  acceptedAt: readTimestamp(record.acceptedAt, "commandResult.acceptedAt"),
409
409
  };
410
+ case "purge_accepted":
411
+ exactFields({
412
+ record,
413
+ required: ["protocolVersion", "type", "status", "commandId", "acceptedAt"],
414
+ optional: [],
415
+ path: "commandResult",
416
+ });
417
+ if (record.status !== "accepted")
418
+ throw new OperatorValidationError("commandResult.status", "purge result must be accepted");
419
+ return {
420
+ protocolVersion: readProtocolVersion(record.protocolVersion, "commandResult.protocolVersion"),
421
+ type: "purge_accepted",
422
+ status: "accepted",
423
+ commandId: readOpaqueId(record.commandId, "commandResult.commandId"),
424
+ acceptedAt: readTimestamp(record.acceptedAt, "commandResult.acceptedAt"),
425
+ };
410
426
  case "command_rejected":
411
427
  exactFields({
412
428
  record,
@@ -452,6 +468,10 @@ export function validateOperatorCommandResultForCommand(commandValue, resultValu
452
468
  if (result.type === "archive_accepted" || result.type === "command_rejected")
453
469
  return { command: { ...command, command: command.command }, result };
454
470
  break;
471
+ case "purge_agents":
472
+ if (result.type === "purge_accepted" || result.type === "command_rejected")
473
+ return { command: { ...command, command: command.command }, result };
474
+ break;
455
475
  }
456
476
  throw new OperatorValidationError("commandResult.type", `${resultDescriptions[result.type]} cannot answer ${commandDescriptions[command.command.type]}`);
457
477
  }
@@ -708,11 +728,13 @@ const commandDescriptions = {
708
728
  ping_agent: "a ping command",
709
729
  expire_message: "an expiry command",
710
730
  archive_agent: "an archive command",
731
+ purge_agents: "a purge command",
711
732
  };
712
733
  const resultDescriptions = {
713
734
  ping_accepted: "ping acceptance",
714
735
  expiry_accepted: "expiry acceptance",
715
736
  archive_accepted: "archive acceptance",
737
+ purge_accepted: "purge acceptance",
716
738
  command_rejected: "rejection",
717
739
  };
718
740
  function validateCommandMember(value) {
@@ -727,6 +749,9 @@ function validateCommandMember(value) {
727
749
  case "archive_agent":
728
750
  exactFields({ record, required: ["type", "targetAgentId"], optional: [], path: "commandEnvelope.command" });
729
751
  return { type: "archive_agent", targetAgentId: readAgentId(record.targetAgentId, "command.targetAgentId") };
752
+ case "purge_agents":
753
+ exactFields({ record, required: ["type", "targetAgentIds"], optional: [], path: "commandEnvelope.command" });
754
+ return { type: "purge_agents", targetAgentIds: readPurgeTargets(record.targetAgentIds, "command.targetAgentIds") };
730
755
  default:
731
756
  throw new OperatorValidationError("commandEnvelope.command.type", "unknown command");
732
757
  }
@@ -858,6 +883,15 @@ function readAgentId(value, path) {
858
883
  throw new OperatorValidationError(path, "invalid agent identity");
859
884
  return id;
860
885
  }
886
+ function readPurgeTargets(value, path) {
887
+ const entries = readArray(value, path);
888
+ if (entries.length === 0 || entries.length > OPERATOR_PURGE_TARGET_LIMIT)
889
+ throw new OperatorValidationError(path, "must name between one and the bounded number of agents");
890
+ const ids = entries.map((entry, index) => readAgentId(entry, `${path}[${String(index)}]`));
891
+ if (new Set(ids).size !== ids.length)
892
+ throw new OperatorValidationError(path, "must not repeat an agent");
893
+ return ids;
894
+ }
861
895
  function readOpaqueId(value, path) {
862
896
  const id = readNonemptyString(value, path);
863
897
  if (!isOpaqueId(id))
@@ -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.
@@ -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;