@esso0428/pi-subagents 0.17.0 → 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.
- package/CHANGELOG.md +12 -0
- package/README.md +3 -2
- package/dist/agent-history.d.ts +6 -0
- package/dist/agent-history.d.ts.map +1 -1
- package/dist/agent-history.js +38 -0
- package/dist/agent-history.js.map +1 -1
- package/dist/agent-manager.d.ts +23 -6
- package/dist/agent-manager.d.ts.map +1 -1
- package/dist/agent-manager.js +165 -27
- package/dist/agent-manager.js.map +1 -1
- package/dist/agent-recovery.d.ts +37 -0
- package/dist/agent-recovery.d.ts.map +1 -0
- package/dist/agent-recovery.js +171 -0
- package/dist/agent-recovery.js.map +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +94 -25
- package/dist/index.js.map +1 -1
- package/dist/ui/agent-widget.d.ts +4 -0
- package/dist/ui/agent-widget.d.ts.map +1 -1
- package/dist/ui/agent-widget.js +45 -14
- package/dist/ui/agent-widget.js.map +1 -1
- package/package.json +1 -1
- package/src/agent-history.ts +38 -0
- package/src/agent-manager.ts +170 -28
- package/src/agent-recovery.ts +185 -0
- package/src/index.ts +109 -23
- package/src/ui/agent-widget.ts +44 -14
|
@@ -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,
|
|
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,35 @@ 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);
|
|
586
|
+
// Attach the panel during TUI startup, after restored records are present,
|
|
587
|
+
// so terminal agents from the session branch are immediately visible.
|
|
588
|
+
if (ctx.mode === "tui") {
|
|
589
|
+
widget.setUICtx(ctx.ui as UICtx);
|
|
590
|
+
widget.update();
|
|
591
|
+
}
|
|
568
592
|
// Guard mirrors the `!scheduler.isActive()` pattern below: session_start
|
|
569
593
|
// fires once per activation, but a double-bind must not leak listeners.
|
|
570
594
|
if (!rpcHandle) {
|
|
@@ -583,6 +607,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
583
607
|
});
|
|
584
608
|
|
|
585
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();
|
|
586
614
|
manager.clearCompleted(true);
|
|
587
615
|
scheduler.stop();
|
|
588
616
|
});
|
|
@@ -590,6 +618,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
590
618
|
// On shutdown, abort all agents immediately and clean up.
|
|
591
619
|
// If the session is going down, there's nothing left to consume agent results.
|
|
592
620
|
pi.on("session_shutdown", async () => {
|
|
621
|
+
resetAgentMenuSelections();
|
|
593
622
|
rpcHandle?.unsubSpawn();
|
|
594
623
|
rpcHandle?.unsubStop();
|
|
595
624
|
rpcHandle?.unsubPing();
|
|
@@ -1157,6 +1186,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1157
1186
|
rec.historyFile = createAgentHistoryPath(ctx.cwd, agentId);
|
|
1158
1187
|
rec.transcriptPath = agentHistoryLocator(ctx.cwd, rec.historyFile);
|
|
1159
1188
|
writeInitialEntry(rec.historyFile, agentId, params.prompt, ctx.cwd);
|
|
1189
|
+
manager.setTranscript(agentId, rec.historyFile, rec.transcriptPath, ctx.cwd);
|
|
1160
1190
|
} catch (err) {
|
|
1161
1191
|
rec.historyFile = undefined;
|
|
1162
1192
|
rec.transcriptPath = undefined;
|
|
@@ -1268,9 +1298,10 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1268
1298
|
const { state: bgState, callbacks: bgCallbacks } = createActivityTracker(effectiveMaxTurns);
|
|
1269
1299
|
|
|
1270
1300
|
// Wrap onSessionCreated to wire output file streaming.
|
|
1271
|
-
// The callback
|
|
1272
|
-
//
|
|
1301
|
+
// The callback reads the transcript paths installed synchronously by
|
|
1302
|
+
// onSpawned before the agent can queue or start.
|
|
1273
1303
|
let id: string;
|
|
1304
|
+
const joinMode = resolveJoinMode(defaultJoinMode, true);
|
|
1274
1305
|
const origBgOnSession = bgCallbacks.onSessionCreated;
|
|
1275
1306
|
bgCallbacks.onSessionCreated = (session: any) => {
|
|
1276
1307
|
origBgOnSession(session);
|
|
@@ -1291,20 +1322,21 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1291
1322
|
isBackground: true,
|
|
1292
1323
|
isolation,
|
|
1293
1324
|
invocation: agentInvocation,
|
|
1325
|
+
onSpawned: (spawnedId) => {
|
|
1326
|
+
attachTranscript(manager.getRecord(spawnedId), spawnedId);
|
|
1327
|
+
},
|
|
1294
1328
|
...bgCallbacks,
|
|
1295
1329
|
});
|
|
1296
1330
|
} catch (err) {
|
|
1297
1331
|
return textResult(err instanceof Error ? err.message : String(err));
|
|
1298
1332
|
}
|
|
1299
1333
|
|
|
1300
|
-
// Set
|
|
1301
|
-
//
|
|
1302
|
-
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.
|
|
1303
1336
|
const record = manager.getRecord(id);
|
|
1304
1337
|
if (record && joinMode) {
|
|
1305
1338
|
record.joinMode = joinMode;
|
|
1306
1339
|
record.toolCallId = toolCallId;
|
|
1307
|
-
attachTranscript(record, id);
|
|
1308
1340
|
}
|
|
1309
1341
|
|
|
1310
1342
|
if (joinMode == null || joinMode === 'async') {
|
|
@@ -1506,6 +1538,9 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1506
1538
|
if (record.promise) await abortable(record.promise, signal);
|
|
1507
1539
|
}
|
|
1508
1540
|
|
|
1541
|
+
const durableResult = !record.result?.trim() && record.transcriptPath && currentCtx?.cwd
|
|
1542
|
+
? readAgentHistoryResult(currentCtx.cwd, record.transcriptPath)
|
|
1543
|
+
: undefined;
|
|
1509
1544
|
const displayName = getDisplayName(record.type);
|
|
1510
1545
|
const duration = formatDuration(record.startedAt, record.completedAt);
|
|
1511
1546
|
const tokens = formatLifetimeTokens(record);
|
|
@@ -1524,9 +1559,9 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1524
1559
|
if (record.status === "running") {
|
|
1525
1560
|
output += "Agent is still running. Use wait: true or check back later.";
|
|
1526
1561
|
} else if (record.status === "error") {
|
|
1527
|
-
output += `Error: ${record.error}${partialOutputSuffix(record)}`;
|
|
1562
|
+
output += `Error: ${record.error}${partialOutputSuffix(record, durableResult)}`;
|
|
1528
1563
|
} else {
|
|
1529
|
-
output += record.result?.trim() || "No output.";
|
|
1564
|
+
output += durableResult || record.result?.trim() || "No output.";
|
|
1530
1565
|
}
|
|
1531
1566
|
|
|
1532
1567
|
// Mark result as consumed — suppresses the completion notification
|
|
@@ -1783,6 +1818,59 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1783
1818
|
});
|
|
1784
1819
|
}
|
|
1785
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
|
+
|
|
1786
1874
|
async function showRunningAgents(ctx: ExtensionCommandContext) {
|
|
1787
1875
|
const { active: agents } = splitAgentRecords(manager.listAgents(), ctx.cwd);
|
|
1788
1876
|
if (agents.length === 0) {
|
|
@@ -1795,15 +1883,13 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1795
1883
|
const dur = formatDuration(record.startedAt, record.completedAt);
|
|
1796
1884
|
return { record, label: `${dn} (${record.description}) · ${record.toolUses} tools · ${record.status} · ${dur}` };
|
|
1797
1885
|
});
|
|
1798
|
-
|
|
1886
|
+
makeUniqueAgentOptionLabels(pairs);
|
|
1799
1887
|
|
|
1800
|
-
const
|
|
1801
|
-
if (!choice) return;
|
|
1802
|
-
const record = pairs.find((pair) => pair.label === choice)?.record;
|
|
1888
|
+
const record = await selectAgentFromReadOnlyList(ctx, "Running agents", pairs, runningAgentSelection);
|
|
1803
1889
|
if (!record) return;
|
|
1804
1890
|
|
|
1805
1891
|
await viewAgentConversation(ctx, record, "live");
|
|
1806
|
-
// Back-navigation: re-show the list
|
|
1892
|
+
// Back-navigation: re-show the list at the previously selected agent.
|
|
1807
1893
|
await showRunningAgents(ctx);
|
|
1808
1894
|
}
|
|
1809
1895
|
|
|
@@ -1815,13 +1901,13 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1815
1901
|
}
|
|
1816
1902
|
|
|
1817
1903
|
const pairs = history.map((record) => ({ record, label: formatAgentHistoryOption(record, Date.now()) }));
|
|
1818
|
-
|
|
1819
|
-
const
|
|
1820
|
-
if (!choice) return;
|
|
1821
|
-
const record = pairs.find((pair) => pair.label === choice)?.record;
|
|
1904
|
+
makeUniqueAgentOptionLabels(pairs);
|
|
1905
|
+
const record = await selectAgentFromReadOnlyList(ctx, "Agent history", pairs, historyAgentSelection);
|
|
1822
1906
|
if (!record) return;
|
|
1823
1907
|
|
|
1824
1908
|
await viewAgentConversation(ctx, record, "history");
|
|
1909
|
+
// Back-navigation: re-show the list at the previously selected agent.
|
|
1910
|
+
await showAgentHistory(ctx);
|
|
1825
1911
|
}
|
|
1826
1912
|
|
|
1827
1913
|
async function viewAgentConversation(
|
package/src/ui/agent-widget.ts
CHANGED
|
@@ -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.
|
|
387
|
-
|
|
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.
|
|
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
|
-
|
|
455
|
+
const records = this.roster();
|
|
456
|
+
if (!matchesKey(data, "down") || (this.uiCtx.getEditorText?.() ?? "") !== "" || records.length === 0) {
|
|
435
457
|
return undefined;
|
|
436
458
|
}
|
|
437
|
-
this.
|
|
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
|
-
|
|
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
|
|
762
|
-
|
|
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
|
}
|