@esso0428/pi-subagents 0.17.1 → 0.17.2

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.
@@ -0,0 +1,185 @@
1
+ /** Durable checkpoints for agents that may be interrupted by a catchable lifecycle event. */
2
+
3
+ import { randomUUID } from "node:crypto";
4
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { ensureSubagentsGitignore } from "./agent-history.js";
7
+ import type { AgentInvocation } from "./types.js";
8
+ import type { LifetimeUsage } from "./usage.js";
9
+
10
+ const SUBAGENTS_DIR = ".pi-subagents";
11
+ const CHECKPOINTS_DIR = "agent-checkpoints";
12
+ const CHECKPOINT_VERSION = 1;
13
+ const TERMINAL_STATUSES = new Set(["completed", "steered", "stopped", "aborted", "error"] as const);
14
+ const THINKING_LEVELS = new Set(["minimal", "low", "medium", "high", "xhigh", "max", "off"]);
15
+
16
+ type ActiveStatus = "running" | "queued";
17
+ type TerminalStatus = "completed" | "steered" | "stopped" | "aborted" | "error";
18
+ export type AgentRecoveryStatus = ActiveStatus | TerminalStatus;
19
+
20
+ export interface AgentRecoveryCheckpoint {
21
+ version: 1;
22
+ id: string;
23
+ type: string;
24
+ description: string;
25
+ status: AgentRecoveryStatus;
26
+ startedAt: number;
27
+ completedAt?: number;
28
+ result?: string;
29
+ error?: string;
30
+ toolUses: number;
31
+ lifetimeUsage: LifetimeUsage;
32
+ compactionCount: number;
33
+ transcriptPath?: string;
34
+ invocation?: AgentInvocation;
35
+ }
36
+
37
+ function isSafeString(value: unknown, maxLength: number): value is string {
38
+ return typeof value === "string" && value.length > 0 && value.length <= maxLength && !/[\0\r\n]/.test(value);
39
+ }
40
+
41
+ function isFiniteTimestamp(value: unknown): value is number {
42
+ return typeof value === "number" && Number.isFinite(value) && Number.isInteger(value) && value >= 0;
43
+ }
44
+
45
+ function isUsage(value: unknown): value is LifetimeUsage {
46
+ if (!value || typeof value !== "object") return false;
47
+ const usage = value as Record<string, unknown>;
48
+ return ["input", "output", "cacheWrite"].every((key) => {
49
+ const n = usage[key];
50
+ return typeof n === "number" && Number.isFinite(n) && n >= 0;
51
+ });
52
+ }
53
+
54
+ function isInvocation(value: unknown): value is AgentInvocation {
55
+ if (!value || typeof value !== "object") return false;
56
+ const invocation = value as Record<string, unknown>;
57
+ for (const key of ["modelName", "effectiveModelName"]) {
58
+ if (invocation[key] !== undefined && !isSafeString(invocation[key], 512)) return false;
59
+ }
60
+ for (const key of ["thinking", "effectiveThinking"]) {
61
+ if (invocation[key] !== undefined && (typeof invocation[key] !== "string" || !THINKING_LEVELS.has(invocation[key]))) return false;
62
+ }
63
+ if (invocation.maxTurns !== undefined && (!Number.isInteger(invocation.maxTurns) || (invocation.maxTurns as number) < 0)) return false;
64
+ for (const key of ["isolated", "inheritContext", "runInBackground"]) {
65
+ if (invocation[key] !== undefined && typeof invocation[key] !== "boolean") return false;
66
+ }
67
+ return invocation.isolation === undefined || invocation.isolation === "worktree";
68
+ }
69
+
70
+ function isActiveStatus(value: AgentRecoveryStatus): value is ActiveStatus {
71
+ return value === "running" || value === "queued";
72
+ }
73
+
74
+ function isTerminalStatus(value: AgentRecoveryStatus): value is TerminalStatus {
75
+ return TERMINAL_STATUSES.has(value as TerminalStatus);
76
+ }
77
+
78
+ function isSafeTranscriptPath(value: unknown): value is string {
79
+ return typeof value === "string"
80
+ && /^\.pi-subagents\/agent-transcripts\/[^/]+\.jsonl$/.test(value)
81
+ && !value.includes("..")
82
+ && !value.includes("\\")
83
+ && !value.includes("\0");
84
+ }
85
+
86
+ /** Validate untrusted JSON before it can enter the manager or UI. */
87
+ export function isAgentRecoveryCheckpoint(value: unknown): value is AgentRecoveryCheckpoint {
88
+ if (!value || typeof value !== "object") return false;
89
+ const checkpoint = value as Record<string, unknown>;
90
+ if (checkpoint.version !== CHECKPOINT_VERSION
91
+ || !isSafeString(checkpoint.id, 256)
92
+ || !isSafeString(checkpoint.type, 256)
93
+ || !isSafeString(checkpoint.description, 4096)
94
+ || typeof checkpoint.status !== "string"
95
+ || !isFiniteTimestamp(checkpoint.startedAt)
96
+ || !Number.isInteger(checkpoint.toolUses)
97
+ || (checkpoint.toolUses as number) < 0
98
+ || !isUsage(checkpoint.lifetimeUsage)
99
+ || !Number.isInteger(checkpoint.compactionCount)
100
+ || (checkpoint.compactionCount as number) < 0) return false;
101
+
102
+ const status = checkpoint.status as AgentRecoveryStatus;
103
+ if (!isActiveStatus(status) && !isTerminalStatus(status)) return false;
104
+ if (checkpoint.completedAt !== undefined && !isFiniteTimestamp(checkpoint.completedAt)) return false;
105
+ if (isTerminalStatus(status)
106
+ && (checkpoint.completedAt === undefined || checkpoint.completedAt < checkpoint.startedAt)) return false;
107
+ if (isActiveStatus(status) && checkpoint.completedAt !== undefined) return false;
108
+ if (checkpoint.result !== undefined && !isSafeString(checkpoint.result, 2_000_000)) return false;
109
+ if (checkpoint.error !== undefined && !isSafeString(checkpoint.error, 64_000)) return false;
110
+ if (checkpoint.transcriptPath !== undefined && !isSafeTranscriptPath(checkpoint.transcriptPath)) return false;
111
+ if (checkpoint.invocation !== undefined && !isInvocation(checkpoint.invocation)) return false;
112
+ return true;
113
+ }
114
+
115
+ function checkpointDirectory(cwd: string): string {
116
+ return join(cwd, SUBAGENTS_DIR, CHECKPOINTS_DIR);
117
+ }
118
+
119
+ /** Return the on-disk path for an agent's single deduplicated checkpoint. */
120
+ export function agentRecoveryCheckpointPath(cwd: string, agentId: string): string {
121
+ const safeId = agentId.replace(/[^A-Za-z0-9._-]+/g, "-") || "agent";
122
+ return join(checkpointDirectory(cwd), `${safeId}.json`);
123
+ }
124
+
125
+ /**
126
+ * Atomically write one checkpoint. Repeated writes for the same agent replace
127
+ * the same file; identical payloads are skipped, so shutdown + abort callbacks
128
+ * cannot create duplicate recovery records.
129
+ */
130
+ export function removeAgentRecoveryCheckpoint(cwd: string, agentId: string): boolean {
131
+ try {
132
+ unlinkSync(agentRecoveryCheckpointPath(cwd, agentId));
133
+ return true;
134
+ } catch {
135
+ return false;
136
+ }
137
+ }
138
+
139
+ export function writeAgentRecoveryCheckpoint(cwd: string, checkpoint: AgentRecoveryCheckpoint): boolean {
140
+ if (!isAgentRecoveryCheckpoint(checkpoint)) return false;
141
+ try {
142
+ ensureSubagentsGitignore(cwd);
143
+ const directory = checkpointDirectory(cwd);
144
+ mkdirSync(directory, { recursive: true });
145
+ const path = agentRecoveryCheckpointPath(cwd, checkpoint.id);
146
+ const contents = `${JSON.stringify(checkpoint)}\n`;
147
+ try {
148
+ if (readFileSync(path, "utf8") === contents) return false;
149
+ } catch {
150
+ // The file is new, missing, or unreadable; replace it below.
151
+ }
152
+ const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
153
+ writeFileSync(temporary, contents, { encoding: "utf8", mode: 0o600 });
154
+ renameSync(temporary, path);
155
+ return true;
156
+ } catch {
157
+ // Recovery must never make a spawn or shutdown fail. A later checkpoint
158
+ // gets another chance to persist if the filesystem becomes available.
159
+ return false;
160
+ }
161
+ }
162
+
163
+ /** Load valid checkpoints, ignoring orphan, malformed, and corrupt files. */
164
+ export function readAgentRecoveryCheckpoints(cwd: string): AgentRecoveryCheckpoint[] {
165
+ const directory = checkpointDirectory(cwd);
166
+ if (!existsSync(directory)) return [];
167
+ let names: string[];
168
+ try {
169
+ names = readdirSync(directory).filter((name) => /^[A-Za-z0-9._-]+\.json$/.test(name));
170
+ } catch {
171
+ return [];
172
+ }
173
+
174
+ const latest = new Map<string, AgentRecoveryCheckpoint>();
175
+ for (const name of names) {
176
+ try {
177
+ const value: unknown = JSON.parse(readFileSync(join(directory, name), "utf8"));
178
+ if (!isAgentRecoveryCheckpoint(value)) continue;
179
+ latest.set(value.id, value);
180
+ } catch {
181
+ // Ignore a partially written/corrupt checkpoint and continue indexing.
182
+ }
183
+ }
184
+ return [...latest.values()];
185
+ }
package/src/index.ts CHANGED
@@ -12,10 +12,10 @@
12
12
 
13
13
  import { existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
14
14
  import { join } from "node:path";
15
- import { defineTool, type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext, getAgentDir, getSettingsListTheme } from "@earendil-works/pi-coding-agent";
16
- import { Container, Key, matchesKey, type SettingItem, SettingsList, Spacer, Text } from "@earendil-works/pi-tui";
15
+ import { defineTool, type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext, getAgentDir, getSelectListTheme, getSettingsListTheme } from "@earendil-works/pi-coding-agent";
16
+ import { Container, Key, matchesKey, SelectList, type SettingItem, SettingsList, Spacer, Text } from "@earendil-works/pi-tui";
17
17
  import { Type } from "@sinclair/typebox";
18
- import { agentHistoryLocator, createAgentHistoryPath, readAgentHistory } from "./agent-history.js";
18
+ import { agentHistoryLocator, createAgentHistoryPath, readAgentHistory, readAgentHistoryResult } from "./agent-history.js";
19
19
  import { buildAgentStatusMenuEntries, canOpenActiveAgent, canOpenAgentHistory, formatAgentHistoryOption, splitAgentRecords } from "./agent-history-list.js";
20
20
  import { AgentManager } from "./agent-manager.js";
21
21
  import { getAgentConversation, getDefaultMaxTurns, getGraceTurns, normalizeMaxTurns, SUBAGENT_TOOL_NAMES, setDefaultMaxTurns, setGraceTurns, steerAgent } from "./agent-runner.js";
@@ -171,8 +171,8 @@ const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "ma
171
171
  * surfaces (or "" if the run produced nothing). `record.result` is bounded to
172
172
  * the run's own turns, so this is never a stale earlier answer (#144).
173
173
  */
174
- function partialOutputSuffix(record: AgentRecord): string {
175
- const partial = record.result?.trim();
174
+ function partialOutputSuffix(record: AgentRecord, fallback?: string): string {
175
+ const partial = record.result?.trim() || fallback?.trim();
176
176
  return partial ? `\n\nPartial output before the failure:\n${partial}` : "";
177
177
  }
178
178
 
@@ -450,7 +450,12 @@ export default function (pi: ExtensionAPI) {
450
450
  // Persist final record for cross-extension history reconstruction
451
451
  pi.appendEntry("subagents:record", {
452
452
  id: record.id, type: record.type, description: record.description,
453
- status: record.status, result: record.result, error: record.error,
453
+ status: record.status,
454
+ // Durable transcripts are the source of truth for full output. Avoid
455
+ // copying a potentially large result into the parent session branch;
456
+ // get_subagent_result reloads it on demand after cleanup/restart.
457
+ result: record.transcriptPath ? undefined : record.result,
458
+ error: record.error,
454
459
  startedAt: record.startedAt, completedAt: record.completedAt,
455
460
  toolUses: record.toolUses,
456
461
  lifetimeUsage: record.lifetimeUsage,
@@ -555,16 +560,29 @@ export default function (pi: ExtensionAPI) {
555
560
  }
556
561
  }
557
562
 
563
+ type AgentMenuSelection = { id?: string; index: number };
564
+ let runningAgentSelection: AgentMenuSelection = { index: 0 };
565
+ let historyAgentSelection: AgentMenuSelection = { index: 0 };
566
+
567
+ function resetAgentMenuSelections() {
568
+ runningAgentSelection = { index: 0 };
569
+ historyAgentSelection = { index: 0 };
570
+ }
571
+
558
572
  // Capture ctx from session_start for RPC spawn handler + start the scheduler.
559
573
  // This also wires the RPC handlers and broadcasts readiness — on the first
560
574
  // bound session_start, so a filtered-out activation never advertises (#142).
561
575
  pi.on("session_start", async (_event, ctx) => {
576
+ resetAgentMenuSelections();
562
577
  currentCtx = ctx;
563
578
  manager.clearCompleted(true);
564
579
  const branch = ctx.sessionManager?.getBranch?.() ?? [];
565
580
  manager.restoreCompleted(branch
566
581
  .filter((entry: any) => entry?.type === "custom" && entry?.customType === "subagents:record")
567
582
  .map((entry: any) => entry.data));
583
+ // Checkpoint files cover agents whose parent session never got a terminal
584
+ // branch entry (shutdown, session switch, or a process restart).
585
+ manager.restoreRecovered(ctx.cwd);
568
586
  // Attach the panel during TUI startup, after restored records are present,
569
587
  // so terminal agents from the session branch are immediately visible.
570
588
  if (ctx.mode === "tui") {
@@ -589,6 +607,10 @@ export default function (pi: ExtensionAPI) {
589
607
  });
590
608
 
591
609
  pi.on("session_before_switch", () => {
610
+ resetAgentMenuSelections();
611
+ // A switch is catchable. Stop and checkpoint live/queued agents before the
612
+ // old session context is discarded, then retain their unread history.
613
+ manager.abortAll();
592
614
  manager.clearCompleted(true);
593
615
  scheduler.stop();
594
616
  });
@@ -596,6 +618,7 @@ export default function (pi: ExtensionAPI) {
596
618
  // On shutdown, abort all agents immediately and clean up.
597
619
  // If the session is going down, there's nothing left to consume agent results.
598
620
  pi.on("session_shutdown", async () => {
621
+ resetAgentMenuSelections();
599
622
  rpcHandle?.unsubSpawn();
600
623
  rpcHandle?.unsubStop();
601
624
  rpcHandle?.unsubPing();
@@ -1163,6 +1186,7 @@ Terse command-style prompts produce shallow, generic work.
1163
1186
  rec.historyFile = createAgentHistoryPath(ctx.cwd, agentId);
1164
1187
  rec.transcriptPath = agentHistoryLocator(ctx.cwd, rec.historyFile);
1165
1188
  writeInitialEntry(rec.historyFile, agentId, params.prompt, ctx.cwd);
1189
+ manager.setTranscript(agentId, rec.historyFile, rec.transcriptPath, ctx.cwd);
1166
1190
  } catch (err) {
1167
1191
  rec.historyFile = undefined;
1168
1192
  rec.transcriptPath = undefined;
@@ -1274,9 +1298,10 @@ Terse command-style prompts produce shallow, generic work.
1274
1298
  const { state: bgState, callbacks: bgCallbacks } = createActivityTracker(effectiveMaxTurns);
1275
1299
 
1276
1300
  // Wrap onSessionCreated to wire output file streaming.
1277
- // The callback lazily reads record.outputFile (set right after spawn)
1278
- // rather than closing over a value that doesn't exist yet.
1301
+ // The callback reads the transcript paths installed synchronously by
1302
+ // onSpawned before the agent can queue or start.
1279
1303
  let id: string;
1304
+ const joinMode = resolveJoinMode(defaultJoinMode, true);
1280
1305
  const origBgOnSession = bgCallbacks.onSessionCreated;
1281
1306
  bgCallbacks.onSessionCreated = (session: any) => {
1282
1307
  origBgOnSession(session);
@@ -1297,20 +1322,21 @@ Terse command-style prompts produce shallow, generic work.
1297
1322
  isBackground: true,
1298
1323
  isolation,
1299
1324
  invocation: agentInvocation,
1325
+ onSpawned: (spawnedId) => {
1326
+ attachTranscript(manager.getRecord(spawnedId), spawnedId);
1327
+ },
1300
1328
  ...bgCallbacks,
1301
1329
  });
1302
1330
  } catch (err) {
1303
1331
  return textResult(err instanceof Error ? err.message : String(err));
1304
1332
  }
1305
1333
 
1306
- // Set output file + join mode synchronously after spawn, before the
1307
- // event loop yields — onSessionCreated is async so this is safe.
1308
- const joinMode = resolveJoinMode(defaultJoinMode, true);
1334
+ // Set join metadata after spawn. Transcript metadata was installed by
1335
+ // the manager's synchronous onSpawned callback before this point.
1309
1336
  const record = manager.getRecord(id);
1310
1337
  if (record && joinMode) {
1311
1338
  record.joinMode = joinMode;
1312
1339
  record.toolCallId = toolCallId;
1313
- attachTranscript(record, id);
1314
1340
  }
1315
1341
 
1316
1342
  if (joinMode == null || joinMode === 'async') {
@@ -1512,6 +1538,9 @@ Terse command-style prompts produce shallow, generic work.
1512
1538
  if (record.promise) await abortable(record.promise, signal);
1513
1539
  }
1514
1540
 
1541
+ const durableResult = !record.result?.trim() && record.transcriptPath && currentCtx?.cwd
1542
+ ? readAgentHistoryResult(currentCtx.cwd, record.transcriptPath)
1543
+ : undefined;
1515
1544
  const displayName = getDisplayName(record.type);
1516
1545
  const duration = formatDuration(record.startedAt, record.completedAt);
1517
1546
  const tokens = formatLifetimeTokens(record);
@@ -1530,9 +1559,9 @@ Terse command-style prompts produce shallow, generic work.
1530
1559
  if (record.status === "running") {
1531
1560
  output += "Agent is still running. Use wait: true or check back later.";
1532
1561
  } else if (record.status === "error") {
1533
- output += `Error: ${record.error}${partialOutputSuffix(record)}`;
1562
+ output += `Error: ${record.error}${partialOutputSuffix(record, durableResult)}`;
1534
1563
  } else {
1535
- output += record.result?.trim() || "No output.";
1564
+ output += durableResult || record.result?.trim() || "No output.";
1536
1565
  }
1537
1566
 
1538
1567
  // Mark result as consumed — suppresses the completion notification
@@ -1789,6 +1818,59 @@ Terse command-style prompts produce shallow, generic work.
1789
1818
  });
1790
1819
  }
1791
1820
 
1821
+ async function selectAgentFromReadOnlyList(
1822
+ ctx: ExtensionCommandContext,
1823
+ title: string,
1824
+ pairs: Array<{ record: AgentRecord; label: string }>,
1825
+ selection: AgentMenuSelection,
1826
+ ): Promise<AgentRecord | undefined> {
1827
+ const options = pairs.map(({ record, label }) => ({ value: record.id, label }));
1828
+ const rememberedIndex = selection.id
1829
+ ? pairs.findIndex(({ record }) => record.id === selection.id)
1830
+ : -1;
1831
+ const initialIndex = rememberedIndex >= 0
1832
+ ? rememberedIndex
1833
+ : Math.max(0, Math.min(selection.index, pairs.length - 1));
1834
+
1835
+ const remember = (id: string) => {
1836
+ const index = pairs.findIndex(({ record }) => record.id === id);
1837
+ if (index >= 0) {
1838
+ selection.id = id;
1839
+ selection.index = index;
1840
+ }
1841
+ };
1842
+
1843
+ const choice = await ctx.ui.custom<string | undefined>((_tui, _theme, _kb, done) => {
1844
+ const list = new SelectList(
1845
+ options,
1846
+ Math.min(options.length, 10),
1847
+ getSelectListTheme(),
1848
+ );
1849
+ list.setSelectedIndex(initialIndex);
1850
+ const initialItem = options[initialIndex];
1851
+ if (initialItem) remember(initialItem.value);
1852
+ list.onSelectionChange = item => remember(item.value);
1853
+ list.onSelect = item => {
1854
+ remember(item.value);
1855
+ done(item.value);
1856
+ };
1857
+ list.onCancel = () => done(undefined);
1858
+
1859
+ const container = new Container();
1860
+ container.addChild(new Text(title, 0, 0));
1861
+ container.addChild(new Spacer(1));
1862
+ container.addChild(list);
1863
+ return {
1864
+ render: (w: number) => container.render(w),
1865
+ invalidate: () => container.invalidate(),
1866
+ handleInput: (data: string) => list.handleInput(data),
1867
+ };
1868
+ });
1869
+
1870
+ if (!choice) return undefined;
1871
+ return pairs.find(({ record }) => record.id === choice)?.record;
1872
+ }
1873
+
1792
1874
  async function showRunningAgents(ctx: ExtensionCommandContext) {
1793
1875
  const { active: agents } = splitAgentRecords(manager.listAgents(), ctx.cwd);
1794
1876
  if (agents.length === 0) {
@@ -1801,15 +1883,13 @@ Terse command-style prompts produce shallow, generic work.
1801
1883
  const dur = formatDuration(record.startedAt, record.completedAt);
1802
1884
  return { record, label: `${dn} (${record.description}) · ${record.toolUses} tools · ${record.status} · ${dur}` };
1803
1885
  });
1804
- const options = makeUniqueAgentOptionLabels(pairs);
1886
+ makeUniqueAgentOptionLabels(pairs);
1805
1887
 
1806
- const choice = await ctx.ui.select("Running agents", options);
1807
- if (!choice) return;
1808
- const record = pairs.find((pair) => pair.label === choice)?.record;
1888
+ const record = await selectAgentFromReadOnlyList(ctx, "Running agents", pairs, runningAgentSelection);
1809
1889
  if (!record) return;
1810
1890
 
1811
1891
  await viewAgentConversation(ctx, record, "live");
1812
- // Back-navigation: re-show the list
1892
+ // Back-navigation: re-show the list at the previously selected agent.
1813
1893
  await showRunningAgents(ctx);
1814
1894
  }
1815
1895
 
@@ -1821,13 +1901,13 @@ Terse command-style prompts produce shallow, generic work.
1821
1901
  }
1822
1902
 
1823
1903
  const pairs = history.map((record) => ({ record, label: formatAgentHistoryOption(record, Date.now()) }));
1824
- const options = makeUniqueAgentOptionLabels(pairs);
1825
- const choice = await ctx.ui.select("Agent history", options);
1826
- if (!choice) return;
1827
- const record = pairs.find((pair) => pair.label === choice)?.record;
1904
+ makeUniqueAgentOptionLabels(pairs);
1905
+ const record = await selectAgentFromReadOnlyList(ctx, "Agent history", pairs, historyAgentSelection);
1828
1906
  if (!record) return;
1829
1907
 
1830
1908
  await viewAgentConversation(ctx, record, "history");
1909
+ // Back-navigation: re-show the list at the previously selected agent.
1910
+ await showAgentHistory(ctx);
1831
1911
  }
1832
1912
 
1833
1913
  async function viewAgentConversation(
@@ -249,6 +249,8 @@ export class AgentWidget {
249
249
  private navigationActive = false;
250
250
  /** Stable identity of the selected row, so roster changes do not jump selection. */
251
251
  private selectedAgentId: string | undefined;
252
+ /** Last logical roster index of the selected row, used when it disappears. */
253
+ private selectedRosterIndex = 0;
252
254
  /** First logical row currently represented by the bounded viewport. */
253
255
  private viewportStart = 0;
254
256
 
@@ -307,6 +309,7 @@ export class AgentWidget {
307
309
  this.lastRenderKey = undefined;
308
310
  this.navigationActive = false;
309
311
  this.selectedAgentId = undefined;
312
+ this.selectedRosterIndex = 0;
310
313
  this.viewportStart = 0;
311
314
  // Print/RPC tests and lightweight embedders may provide only the widget
312
315
  // surface; real interactive contexts always implement this hook.
@@ -382,9 +385,29 @@ export class AgentWidget {
382
385
  }
383
386
 
384
387
  private deactivate(): void {
388
+ // Keep the selected row and viewport so re-entering navigation can resume
389
+ // where the user left off. Lifecycle resets (context, dispose, empty
390
+ // roster) clear this state explicitly instead of treating every exit as a
391
+ // reset.
385
392
  this.navigationActive = false;
386
- this.selectedAgentId = undefined;
387
- this.viewportStart = 0;
393
+ this.update();
394
+ }
395
+
396
+ /** Resume navigation from the retained row, or select the first row. */
397
+ private activate(records: readonly AgentRecord[]): void {
398
+ this.navigationActive = true;
399
+ const selectedIndex = this.selectedIndexOf(records);
400
+ if (selectedIndex >= 0) {
401
+ this.selectedRosterIndex = selectedIndex;
402
+ } else {
403
+ // The previously selected row may have been cleaned up while navigation
404
+ // was inactive. Resume at its old logical position, clamped to the new
405
+ // roster, rather than jumping back to the first row.
406
+ const fallbackIndex = Math.max(0, Math.min(this.selectedRosterIndex, records.length - 1));
407
+ this.selectedAgentId = records[fallbackIndex].id;
408
+ this.selectedRosterIndex = fallbackIndex;
409
+ this.viewportStart = Math.min(this.viewportStart, Math.max(0, records.length - 1));
410
+ }
388
411
  this.update();
389
412
  }
390
413
 
@@ -396,10 +419,7 @@ export class AgentWidget {
396
419
 
397
420
  if (!this.navigationActive) {
398
421
  if (direction !== 1 || !this.editorHasFocus() || (ui.getEditorText?.() ?? "") !== "") return false;
399
- this.navigationActive = true;
400
- this.selectedAgentId = records[0].id;
401
- this.viewportStart = 0;
402
- this.update();
422
+ this.activate(records);
403
423
  return true;
404
424
  }
405
425
 
@@ -410,6 +430,7 @@ export class AgentWidget {
410
430
  }
411
431
  const nextIndex = Math.max(0, Math.min(records.length - 1, currentIndex + direction));
412
432
  this.selectedAgentId = records[nextIndex].id;
433
+ this.selectedRosterIndex = nextIndex;
413
434
  this.update();
414
435
  return true;
415
436
  }
@@ -431,13 +452,11 @@ export class AgentWidget {
431
452
  }
432
453
 
433
454
  if (!this.navigationActive) {
434
- if (!matchesKey(data, "down") || (this.uiCtx.getEditorText?.() ?? "") !== "" || this.roster().length === 0) {
455
+ const records = this.roster();
456
+ if (!matchesKey(data, "down") || (this.uiCtx.getEditorText?.() ?? "") !== "" || records.length === 0) {
435
457
  return undefined;
436
458
  }
437
- this.navigationActive = true;
438
- this.selectedAgentId = this.roster()[0]?.id;
439
- this.viewportStart = 0;
440
- this.update();
459
+ this.activate(records);
441
460
  return { consume: true };
442
461
  }
443
462
 
@@ -729,6 +748,7 @@ export class AgentWidget {
729
748
  this.lastRenderKey = undefined;
730
749
  this.navigationActive = false;
731
750
  this.selectedAgentId = undefined;
751
+ this.selectedRosterIndex = 0;
732
752
  this.viewportStart = 0;
733
753
  this.syncTimer(false);
734
754
  return;
@@ -753,13 +773,22 @@ export class AgentWidget {
753
773
  // an otherwise idle widget advance.
754
774
  if (advanceSpinner && runningCount > 0) this.widgetFrame++;
755
775
  this.syncTimer(runningCount > 0);
756
- if (this.navigationActive) {
776
+ const selectedIndex = this.selectedIndexOf(roster);
777
+ if (selectedIndex >= 0) {
778
+ this.selectedRosterIndex = selectedIndex;
779
+ } else if (this.navigationActive) {
757
780
  if (roster.length === 0) {
758
781
  this.navigationActive = false;
759
782
  this.selectedAgentId = undefined;
783
+ this.selectedRosterIndex = 0;
760
784
  this.viewportStart = 0;
761
- } else if (this.selectedIndexOf(roster) < 0) {
762
- this.selectedAgentId = roster[Math.min(this.viewportStart, roster.length - 1)].id;
785
+ } else {
786
+ // Keep the selection near the row that disappeared. Both the saved
787
+ // logical index and viewport are clamped as the roster shrinks.
788
+ const fallbackIndex = Math.max(0, Math.min(this.selectedRosterIndex, roster.length - 1));
789
+ this.selectedAgentId = roster[fallbackIndex].id;
790
+ this.selectedRosterIndex = fallbackIndex;
791
+ this.viewportStart = Math.min(this.viewportStart, roster.length - 1);
763
792
  }
764
793
  }
765
794
  const renderKey = this.renderKey(allAgents);
@@ -805,6 +834,7 @@ export class AgentWidget {
805
834
  this.lastRenderKey = undefined;
806
835
  this.navigationActive = false;
807
836
  this.selectedAgentId = undefined;
837
+ this.selectedRosterIndex = 0;
808
838
  this.viewportStart = 0;
809
839
  this.uiCtx = undefined;
810
840
  }