brl-subagent 2.4.0 → 2.5.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.
- package/AGENT.md +9 -2
- package/README.md +6 -3
- package/package.json +6 -6
- package/src/event-bus.ts +7 -2
- package/src/git.ts +67 -0
- package/src/history.ts +77 -10
- package/src/index.ts +355 -63
- package/src/kill-escalation.ts +426 -0
- package/src/logging.ts +37 -3
- package/src/metrics.ts +5 -3
- package/src/notify-completion.ts +23 -0
- package/src/preflight.ts +29 -14
- package/src/proc.ts +390 -0
- package/src/recovery-engine.ts +348 -0
- package/src/recovery-surface.ts +374 -0
- package/src/recovery.ts +343 -0
- package/src/run-registry.ts +330 -0
- package/src/runner.ts +409 -16
- package/src/sanitize.ts +53 -3
- package/src/session-manager.ts +234 -29
- package/src/state.ts +81 -7
- package/src/transcript-path.ts +41 -0
- package/src/transcript.ts +13 -12
- package/src/tui-format.ts +57 -1
- package/src/tui.ts +323 -103
- package/src/types.ts +173 -1
- package/src/unit-run.ts +16 -1
package/src/tui-format.ts
CHANGED
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
* without a TUI harness.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { formatTokens } from "./types";
|
|
10
|
+
import { formatTokens, isInterruptedRun } from "./types";
|
|
11
|
+
import type { SubagentRun } from "./types";
|
|
11
12
|
|
|
12
13
|
/** Braille spinner frames, indexed by Math.floor(now / 150) % 10. */
|
|
13
14
|
export const LIVE_SPINNER_FRAMES: readonly string[] = [
|
|
@@ -50,3 +51,58 @@ export function formatLiveRowDim(
|
|
|
50
51
|
): string {
|
|
51
52
|
return ` [${id.slice(0, 8)}] \u2191${formatTokens(usage.input)} \u2193${formatTokens(usage.output)} ${elapsedStr}`;
|
|
52
53
|
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Display name for a Run History / retry-surface row, shared by BOTH surfaces
|
|
57
|
+
* so they can never name the same run differently (Fix B item 5). Explicit
|
|
58
|
+
* label wins, else the task truncated to 60 chars, else the short id — the last
|
|
59
|
+
* case being a task-less foreground-interrupted row (no task persisted).
|
|
60
|
+
*/
|
|
61
|
+
export function runDisplayName(run: Pick<SubagentRun, "id" | "label" | "task">): string {
|
|
62
|
+
return run.label || run.task.slice(0, 60) || run.id.slice(0, 8);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// U3 (D1/D3): Run History / retry-surface row projection
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The human status of a Run History row. Keys on `isInterruptedRun` FIRST, so a
|
|
71
|
+
* run marked interrupted renders as `interrupted` and NEVER falls through to its
|
|
72
|
+
* raw `\"running\"` status (D1). The raw status is only consulted for an
|
|
73
|
+
* unmarked record.
|
|
74
|
+
*/
|
|
75
|
+
export type RunHistoryStatus = "interrupted" | "running" | "done" | "failed";
|
|
76
|
+
|
|
77
|
+
export function runHistoryStatus(run: SubagentRun): RunHistoryStatus {
|
|
78
|
+
if (isInterruptedRun(run)) return "interrupted";
|
|
79
|
+
if (run.status === "done" || run.status === "failed") return run.status;
|
|
80
|
+
return "running";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Human label for a Run History row's status. */
|
|
84
|
+
export function runHistoryStatusLabel(run: SubagentRun): string {
|
|
85
|
+
switch (runHistoryStatus(run)) {
|
|
86
|
+
case "interrupted":
|
|
87
|
+
return "Interrupted";
|
|
88
|
+
case "done":
|
|
89
|
+
return "Completed";
|
|
90
|
+
case "failed":
|
|
91
|
+
return "Failed";
|
|
92
|
+
default:
|
|
93
|
+
return "Running";
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The offer text shown when a retry-surface row is selected. Names the run id
|
|
99
|
+
* and the `retryRunId` parameter explicitly (D3), so the conductor re-dispatches
|
|
100
|
+
* the recorded task/params without a new delegate_task parameter.
|
|
101
|
+
*/
|
|
102
|
+
export function buildRetryOfferMessage(run: SubagentRun): string {
|
|
103
|
+
const statusWord = isInterruptedRun(run) ? "interrupted" : "failed";
|
|
104
|
+
return (
|
|
105
|
+
`Ask the LLM to retry: "Use delegate_task with retryRunId: ${run.id} ` +
|
|
106
|
+
`to retry the ${statusWord} run ${run.id.slice(0, 8)} with its recorded params."`
|
|
107
|
+
);
|
|
108
|
+
}
|
package/src/tui.ts
CHANGED
|
@@ -14,7 +14,7 @@ import type {
|
|
|
14
14
|
ToolRenderResultOptions,
|
|
15
15
|
} from "@earendil-works/pi-coding-agent";
|
|
16
16
|
import { DynamicBorder, getMarkdownTheme } from "@earendil-works/pi-coding-agent";
|
|
17
|
-
import { Container, type SelectItem, SelectList, Spacer, Text, Markdown } from "@earendil-works/pi-tui";
|
|
17
|
+
import { Container, type Component, type SelectItem, SelectList, Spacer, Text, Markdown, matchesKey } from "@earendil-works/pi-tui";
|
|
18
18
|
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
19
19
|
import type { AgentToolResult } from "@earendil-works/pi-agent-core";
|
|
20
20
|
import type {
|
|
@@ -32,15 +32,18 @@ import type {
|
|
|
32
32
|
UsageStats,
|
|
33
33
|
FileDiff,
|
|
34
34
|
CompletionNotifyMode,
|
|
35
|
+
RecoveryMode,
|
|
35
36
|
} from "./types";
|
|
36
37
|
import {
|
|
37
38
|
THINKING_LEVELS,
|
|
38
39
|
NAV_FOOTER,
|
|
39
40
|
COMPLETION_NOTIFY_MODES,
|
|
41
|
+
RECOVERY_MODES,
|
|
40
42
|
TASK_PREVIEW_MAX_LENGTH,
|
|
41
43
|
COLLAPSED_OUTPUT_LINES,
|
|
42
44
|
COLLAPSED_DIFF_FILES_PREVIEW,
|
|
43
45
|
EXPANDED_HUNKS_PER_FILE,
|
|
46
|
+
DEFAULT_OUTPUT_CAP_BYTES,
|
|
44
47
|
formatTokens,
|
|
45
48
|
formatUsageStats,
|
|
46
49
|
formatModel,
|
|
@@ -49,18 +52,21 @@ import {
|
|
|
49
52
|
isSubagentError,
|
|
50
53
|
isMultiSubagentDetails,
|
|
51
54
|
isGraphDetails,
|
|
55
|
+
isUnsettledPartial,
|
|
56
|
+
isInterruptedRun,
|
|
52
57
|
} from "./types";
|
|
53
58
|
import { buildFileAccessReport, buildSecretsExposureReport, generateComplianceSummary } from "./reports";
|
|
54
59
|
import { extractParamNames } from "./templates";
|
|
55
60
|
import { parseDiff } from "./diff";
|
|
56
61
|
import { formatPresetSummary, getPreset, loadCustomPresets } from "./presets";
|
|
57
|
-
import { formatRunDuration } from "./history";
|
|
62
|
+
import { buildOutputHonestyLines, formatRunDuration } from "./history";
|
|
58
63
|
import * as fs from "node:fs";
|
|
59
64
|
import * as path from "node:path";
|
|
60
|
-
import { sweepStaleLiveSubagents, type SessionState } from "./state";
|
|
65
|
+
import { collapseRunsForHistory, retryableRuns, sweepStaleLiveSubagents, type SessionState } from "./state";
|
|
61
66
|
import { getAgent } from "./session-manager";
|
|
67
|
+
import { visibleRunEntriesForSession } from "./recovery-surface";
|
|
62
68
|
import { computeSLAMetrics, computeCostTrend, formatSparkline } from "./metrics";
|
|
63
|
-
import { formatElapsed, liveRowName, liveSpinner, formatLiveRowDim } from "./tui-format";
|
|
69
|
+
import { formatElapsed, liveRowName, liveSpinner, formatLiveRowDim, runHistoryStatus, runHistoryStatusLabel, buildRetryOfferMessage, runDisplayName } from "./tui-format";
|
|
64
70
|
import {
|
|
65
71
|
renderTranscriptMessages,
|
|
66
72
|
type TranscriptLineStyle,
|
|
@@ -334,6 +340,32 @@ export async function showCompletionNotifySelector(
|
|
|
334
340
|
onConfigChanged(ctx, `Completion notify mode set to ${result}`);
|
|
335
341
|
}
|
|
336
342
|
|
|
343
|
+
/**
|
|
344
|
+
* Option B U3 (D4): recovery posture selector. Modeled on the completionNotify
|
|
345
|
+
* selector; `RECOVERY_MODES` excludes "auto" by construction.
|
|
346
|
+
*/
|
|
347
|
+
export async function showRecoverySelector(
|
|
348
|
+
ctx: ExtensionContext,
|
|
349
|
+
state: SessionState,
|
|
350
|
+
onConfigChanged: (ctx: ExtensionContext, msg: string) => void,
|
|
351
|
+
): Promise<void> {
|
|
352
|
+
const modeDescriptions: Record<RecoveryMode, string> = {
|
|
353
|
+
offer: "Surface interrupted runs (Run History row + one boot notice) - default",
|
|
354
|
+
off: "Do not surface interrupted runs",
|
|
355
|
+
};
|
|
356
|
+
const items: SelectItem[] = RECOVERY_MODES.map((mode) => ({
|
|
357
|
+
value: mode,
|
|
358
|
+
label: mode,
|
|
359
|
+
description: modeDescriptions[mode],
|
|
360
|
+
}));
|
|
361
|
+
|
|
362
|
+
const result = await showSelectList(ctx, "Recovery Posture", items, 5);
|
|
363
|
+
if (!result) return;
|
|
364
|
+
|
|
365
|
+
state.config.recovery = result as RecoveryMode;
|
|
366
|
+
onConfigChanged(ctx, `Recovery posture set to ${result}`);
|
|
367
|
+
}
|
|
368
|
+
|
|
337
369
|
// ---------------------------------------------------------------------------
|
|
338
370
|
// E4: SLA tracking configuration
|
|
339
371
|
// ---------------------------------------------------------------------------
|
|
@@ -936,6 +968,13 @@ export function getConfigMenuItems(state: SessionState): SelectItem[] {
|
|
|
936
968
|
? "Wake only on failed/stopped"
|
|
937
969
|
: "Never wake (passive delivery)",
|
|
938
970
|
},
|
|
971
|
+
{
|
|
972
|
+
value: "recovery",
|
|
973
|
+
label: "Set Recovery Posture",
|
|
974
|
+
description: state.config.recovery === "offer"
|
|
975
|
+
? "Surface interrupted runs (default)"
|
|
976
|
+
: "Do not surface interrupted runs",
|
|
977
|
+
},
|
|
939
978
|
|
|
940
979
|
{
|
|
941
980
|
value: "historyentries",
|
|
@@ -1083,132 +1122,274 @@ export async function showConfigMenu(
|
|
|
1083
1122
|
// Run history viewer
|
|
1084
1123
|
// ---------------------------------------------------------------------------
|
|
1085
1124
|
|
|
1125
|
+
/**
|
|
1126
|
+
* U3 Fix A: the recovery visibility bridge wired for a TUI surface. Session
|
|
1127
|
+
* run entries UNIONED with synthetic durable interrupted records the session
|
|
1128
|
+
* has never seen (a genuine fresh boot), deduped by id. Both the history list
|
|
1129
|
+
* and the retry menu read THIS, so they can never disagree about an interrupted
|
|
1130
|
+
* run that exists only in the durable registry.
|
|
1131
|
+
*/
|
|
1132
|
+
function recoverableRunEntries(ctx: ExtensionContext, state: SessionState): SubagentRun[] {
|
|
1133
|
+
return visibleRunEntriesForSession(state.getRunEntries(ctx), getAgent);
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1086
1136
|
export async function showRunHistory(
|
|
1087
1137
|
ctx: ExtensionContext,
|
|
1088
1138
|
state: SessionState,
|
|
1089
1139
|
persistState: () => void,
|
|
1090
1140
|
): Promise<void> {
|
|
1091
|
-
const runs = state.getRunEntries(ctx).reverse();
|
|
1092
|
-
|
|
1093
|
-
if (runs.length === 0) {
|
|
1094
|
-
ctx.ui.notify("No subagent runs recorded yet. Delegate a task to see history.", "info");
|
|
1095
|
-
return;
|
|
1096
|
-
}
|
|
1097
|
-
|
|
1098
1141
|
const statusIcon: Record<string, string> = {
|
|
1099
1142
|
running: ctx.ui.theme.fg("accent", "◉"),
|
|
1100
1143
|
done: ctx.ui.theme.fg("success", "✓"),
|
|
1101
1144
|
failed: ctx.ui.theme.fg("error", "✗"),
|
|
1145
|
+
interrupted: ctx.ui.theme.fg("warning", "◌"),
|
|
1102
1146
|
};
|
|
1103
1147
|
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
const
|
|
1111
|
-
|
|
1112
|
-
|
|
1148
|
+
// Browse loop (issue #260): list → detail → list … until the LIST is exited
|
|
1149
|
+
// with Esc. Closing a detail returns here, not to the /brl-subagent menu.
|
|
1150
|
+
while (true) {
|
|
1151
|
+
// One row per SETTLED run (issue #259): collapse each run's spawn+terminal
|
|
1152
|
+
// entry pair to its terminal-preferred record; in-flight runs are omitted
|
|
1153
|
+
// (the live monitor owns running state).
|
|
1154
|
+
const runs = collapseRunsForHistory(recoverableRunEntries(ctx, state));
|
|
1155
|
+
|
|
1156
|
+
if (runs.length === 0) {
|
|
1157
|
+
ctx.ui.notify("No subagent runs recorded yet. Delegate a task to see history.", "info");
|
|
1158
|
+
return;
|
|
1159
|
+
}
|
|
1113
1160
|
|
|
1114
|
-
|
|
1115
|
-
|
|
1161
|
+
const items: SelectItem[] = runs.map((r) => {
|
|
1162
|
+
const status = runHistoryStatus(r);
|
|
1163
|
+
const icon = statusIcon[status] || "·";
|
|
1164
|
+
const name = runDisplayName(r);
|
|
1165
|
+
const model = r.model.split("/").pop() || r.model;
|
|
1166
|
+
const when = r.finishedAt
|
|
1167
|
+
? formatRunDuration(r.durationMs || 0)
|
|
1168
|
+
: status === "interrupted"
|
|
1169
|
+
? "interrupted"
|
|
1170
|
+
: "running...";
|
|
1171
|
+
const cost = r.cost ? ` $${r.cost.toFixed(4)}` : "";
|
|
1172
|
+
const desc = `${r.thinkingLevel} · ${model} · ${when}${cost}`;
|
|
1173
|
+
return { value: r.id, label: `${icon} ${name}`, description: desc };
|
|
1174
|
+
});
|
|
1116
1175
|
|
|
1117
|
-
|
|
1118
|
-
|
|
1176
|
+
const selectedId = await showSelectList(ctx, "Subagent History", items, 15);
|
|
1177
|
+
if (!selectedId) return; // Esc at the list exits to the menu
|
|
1119
1178
|
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1179
|
+
const run = runs.find((r) => r.id === selectedId);
|
|
1180
|
+
if (!run) continue; // selected run pruned since listing — re-show the list
|
|
1181
|
+
|
|
1182
|
+
// Mark as seen
|
|
1183
|
+
if (state.markRunSeen(run.id)) {
|
|
1184
|
+
persistState();
|
|
1185
|
+
import("./concurrency").then(({ updateProgressStatus }) => {
|
|
1186
|
+
updateProgressStatus(state, ctx);
|
|
1187
|
+
});
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
await showRunDetail(ctx, run);
|
|
1126
1191
|
}
|
|
1192
|
+
}
|
|
1127
1193
|
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1194
|
+
/**
|
|
1195
|
+
* Number of output lines a run-detail viewport shows at once (issue #261):
|
|
1196
|
+
* the stored text can be up to the 100 KB cap, so the panel is height-capped
|
|
1197
|
+
* and scrolled rather than dumped whole (which would push the footer and
|
|
1198
|
+
* header off-screen).
|
|
1199
|
+
*/
|
|
1200
|
+
const RUN_DETAIL_OUTPUT_VIEWPORT_LINES = 12;
|
|
1132
1201
|
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
)
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1202
|
+
/**
|
|
1203
|
+
* Detail view for one settled run (issue #260, #261): ↑/↓ and PgUp/PgDn scroll
|
|
1204
|
+
* the stored output; any OTHER key — including Esc — closes it. The
|
|
1205
|
+
* showRunHistory browse loop re-shows the list on return.
|
|
1206
|
+
*
|
|
1207
|
+
* The output region follows the approval dialog's full-diff view structure
|
|
1208
|
+
* (see `showApprovalDialog.buildDiffView` / `withDiffKeybinding`: a bordered
|
|
1209
|
+
* Container with a title, the content, and a return footer) but adds the
|
|
1210
|
+
* height cap + scroll state that view lacks. Output is wrapped through the
|
|
1211
|
+
* same `Text` component those views use, so wrapping/padding stay consistent.
|
|
1212
|
+
*/
|
|
1213
|
+
async function showRunDetail(ctx: ExtensionContext, run: SubagentRun): Promise<void> {
|
|
1214
|
+
const outputText = run.fullOutput ?? run.outputSummary ?? "";
|
|
1215
|
+
await ctx.ui.custom<void>((tui, theme, kb, done) => {
|
|
1216
|
+
// First line of the viewport (0 = top). Mutated by scroll keys and
|
|
1217
|
+
// clamped to the wrapped-content length on every render.
|
|
1218
|
+
let scrollTop = 0;
|
|
1219
|
+
// Wrapped output lines for the current width, and the width they were
|
|
1220
|
+
// produced at, so a resize re-wraps and a redraw does not.
|
|
1221
|
+
let wrappedLines: string[] = [];
|
|
1222
|
+
let wrappedWidth = -1;
|
|
1223
|
+
|
|
1224
|
+
const wrapOutput = (width: number): string[] => {
|
|
1225
|
+
if (width === wrappedWidth) return wrappedLines;
|
|
1226
|
+
wrappedLines = new Text(theme.fg("toolOutput", outputText), 1, 0).render(width);
|
|
1227
|
+
wrappedWidth = width;
|
|
1228
|
+
return wrappedLines;
|
|
1229
|
+
};
|
|
1230
|
+
|
|
1231
|
+
const buildOutputSection = (width: number): Container => {
|
|
1232
|
+
const section = new Container();
|
|
1233
|
+
const lines = wrapOutput(width);
|
|
1234
|
+
const total = lines.length;
|
|
1235
|
+
const maxScroll = Math.max(0, total - RUN_DETAIL_OUTPUT_VIEWPORT_LINES);
|
|
1236
|
+
if (scrollTop > maxScroll) scrollTop = maxScroll;
|
|
1237
|
+
if (scrollTop < 0) scrollTop = 0;
|
|
1238
|
+
const from = total === 0 ? 0 : Math.min(scrollTop + 1, total);
|
|
1239
|
+
const to = Math.min(scrollTop + RUN_DETAIL_OUTPUT_VIEWPORT_LINES, total);
|
|
1240
|
+
const indicator =
|
|
1241
|
+
total > RUN_DETAIL_OUTPUT_VIEWPORT_LINES
|
|
1242
|
+
? ` (lines ${from}\u2013${to} of ${total} \u00b7 \u2191\u2193 / PgUp\u00b7PgDn)`
|
|
1243
|
+
: ` (${total} line${total === 1 ? "" : "s"})`;
|
|
1244
|
+
|
|
1245
|
+
section.addChild(
|
|
1159
1246
|
new Text(
|
|
1160
|
-
theme.fg(
|
|
1161
|
-
"dim",
|
|
1162
|
-
`Finished: ${run.finishedAt} (${formatRunDuration(run.durationMs || 0)})`,
|
|
1163
|
-
),
|
|
1247
|
+
theme.fg("muted", "\u2500\u2500\u2500 Output \u2500\u2500\u2500") + theme.fg("dim", indicator),
|
|
1164
1248
|
1,
|
|
1165
1249
|
0,
|
|
1166
1250
|
),
|
|
1167
1251
|
);
|
|
1168
|
-
|
|
1169
|
-
|
|
1252
|
+
|
|
1253
|
+
if (total === 0) {
|
|
1254
|
+
section.addChild(new Text(theme.fg("dim", "(no output recorded)"), 1, 0));
|
|
1255
|
+
return section;
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
// Height-capped viewport: the output was already wrapped to `width`
|
|
1259
|
+
// above, so hand the sliced lines back directly instead of feeding them
|
|
1260
|
+
// through another Text (which would re-wrap at the padded width).
|
|
1261
|
+
const viewport: Component = {
|
|
1262
|
+
render: () => lines.slice(scrollTop, scrollTop + RUN_DETAIL_OUTPUT_VIEWPORT_LINES),
|
|
1263
|
+
invalidate: () => {
|
|
1264
|
+
wrappedWidth = -1;
|
|
1265
|
+
},
|
|
1266
|
+
};
|
|
1267
|
+
section.addChild(viewport);
|
|
1268
|
+
return section;
|
|
1269
|
+
};
|
|
1270
|
+
|
|
1271
|
+
const buildDetailView = (width: number): Container => {
|
|
1272
|
+
const container = new Container();
|
|
1273
|
+
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
1274
|
+
|
|
1275
|
+
const statusLabel = runHistoryStatusLabel(run);
|
|
1170
1276
|
container.addChild(
|
|
1171
1277
|
new Text(
|
|
1172
1278
|
theme.fg(
|
|
1173
|
-
"
|
|
1174
|
-
|
|
1279
|
+
"toolTitle",
|
|
1280
|
+
theme.bold(`${run.label || "Subagent"} — ${statusLabel}`),
|
|
1175
1281
|
),
|
|
1176
1282
|
1,
|
|
1177
1283
|
0,
|
|
1178
1284
|
),
|
|
1179
1285
|
);
|
|
1180
|
-
|
|
1181
|
-
if (run.errorMessage) {
|
|
1182
|
-
container.addChild(new Spacer(1));
|
|
1286
|
+
container.addChild(new Text("", 0, 0));
|
|
1183
1287
|
container.addChild(
|
|
1184
|
-
new Text(theme.fg("
|
|
1288
|
+
new Text(theme.fg("dim", `Task: ${run.task.slice(0, 200)}`), 1, 0),
|
|
1185
1289
|
);
|
|
1186
|
-
}
|
|
1187
|
-
if (run.outputSummary) {
|
|
1188
|
-
container.addChild(new Spacer(1));
|
|
1189
1290
|
container.addChild(
|
|
1190
1291
|
new Text(
|
|
1191
|
-
theme.fg("
|
|
1292
|
+
theme.fg("dim", `Model: ${run.model} · Thinking: ${run.thinkingLevel}`),
|
|
1192
1293
|
1,
|
|
1193
1294
|
0,
|
|
1194
1295
|
),
|
|
1195
1296
|
);
|
|
1196
|
-
container.addChild(
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1297
|
+
container.addChild(new Text(theme.fg("dim", `Started: ${run.startedAt}`), 1, 0));
|
|
1298
|
+
if (run.finishedAt) {
|
|
1299
|
+
container.addChild(
|
|
1300
|
+
new Text(
|
|
1301
|
+
theme.fg(
|
|
1302
|
+
"dim",
|
|
1303
|
+
`Finished: ${run.finishedAt} (${formatRunDuration(run.durationMs || 0)})`,
|
|
1304
|
+
),
|
|
1305
|
+
1,
|
|
1306
|
+
0,
|
|
1307
|
+
),
|
|
1308
|
+
);
|
|
1309
|
+
}
|
|
1310
|
+
if (run.cost) {
|
|
1311
|
+
container.addChild(
|
|
1312
|
+
new Text(
|
|
1313
|
+
theme.fg(
|
|
1314
|
+
"dim",
|
|
1315
|
+
`Cost: $${run.cost.toFixed(4)} · ↑${formatTokens(run.tokensIn || 0)} ↓${formatTokens(run.tokensOut || 0)}`,
|
|
1316
|
+
),
|
|
1317
|
+
1,
|
|
1318
|
+
0,
|
|
1319
|
+
),
|
|
1320
|
+
);
|
|
1321
|
+
}
|
|
1322
|
+
if (run.errorMessage) {
|
|
1323
|
+
container.addChild(new Spacer(1));
|
|
1324
|
+
container.addChild(
|
|
1325
|
+
new Text(theme.fg("error", `Error: ${run.errorMessage}`), 1, 0),
|
|
1326
|
+
);
|
|
1201
1327
|
}
|
|
1202
|
-
}
|
|
1203
1328
|
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1329
|
+
container.addChild(new Spacer(1));
|
|
1330
|
+
container.addChild(buildOutputSection(width));
|
|
1331
|
+
// Honest disclosures (issue #261): the transcript is the only place the
|
|
1332
|
+
// uncapped text lives, and the cap is stated when it was hit.
|
|
1333
|
+
for (const line of buildOutputHonestyLines(run, DEFAULT_OUTPUT_CAP_BYTES)) {
|
|
1334
|
+
container.addChild(new Text(theme.fg("dim", line), 1, 0));
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
container.addChild(new Spacer(1));
|
|
1338
|
+
container.addChild(
|
|
1339
|
+
new Text(
|
|
1340
|
+
theme.fg("dim", "\u2191\u2193 scroll \u00b7 alt+\u2191\u2193 page \u00b7 any other key to close"),
|
|
1341
|
+
1,
|
|
1342
|
+
0,
|
|
1343
|
+
),
|
|
1344
|
+
);
|
|
1345
|
+
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
1346
|
+
return container;
|
|
1347
|
+
};
|
|
1207
1348
|
|
|
1208
1349
|
return {
|
|
1209
|
-
render: (w: number) =>
|
|
1210
|
-
invalidate: () =>
|
|
1211
|
-
handleInput: (
|
|
1350
|
+
render: (w: number) => buildDetailView(w).render(w),
|
|
1351
|
+
invalidate: () => {},
|
|
1352
|
+
handleInput: (data: string) => {
|
|
1353
|
+
// Paging: alt+↑ / alt+↓. Checked BEFORE the plain arrows so an
|
|
1354
|
+
// alt-modified sequence can never be consumed as a line step.
|
|
1355
|
+
// PgUp/PgDn are unusable here: the host binds them to the
|
|
1356
|
+
// alt-screen transcript scroll (`tui.altScreen.pageUp/pageDown`)
|
|
1357
|
+
// and consumes them before a `ctx.ui.custom` overlay sees input —
|
|
1358
|
+
// pi 1.0.2 has no viewport-owner registration for overlays. The
|
|
1359
|
+
// PgUp/PgDn checks below stay for inline mode, where they do
|
|
1360
|
+
// reach the overlay.
|
|
1361
|
+
if (matchesKey(data, "alt+up")) {
|
|
1362
|
+
scrollTop -= RUN_DETAIL_OUTPUT_VIEWPORT_LINES;
|
|
1363
|
+
tui.requestRender();
|
|
1364
|
+
return;
|
|
1365
|
+
}
|
|
1366
|
+
if (matchesKey(data, "alt+down")) {
|
|
1367
|
+
scrollTop += RUN_DETAIL_OUTPUT_VIEWPORT_LINES;
|
|
1368
|
+
tui.requestRender();
|
|
1369
|
+
return;
|
|
1370
|
+
}
|
|
1371
|
+
if (kb.matches(data, "tui.select.up")) {
|
|
1372
|
+
scrollTop -= 1;
|
|
1373
|
+
tui.requestRender();
|
|
1374
|
+
return;
|
|
1375
|
+
}
|
|
1376
|
+
if (kb.matches(data, "tui.select.down")) {
|
|
1377
|
+
scrollTop += 1;
|
|
1378
|
+
tui.requestRender();
|
|
1379
|
+
return;
|
|
1380
|
+
}
|
|
1381
|
+
if (kb.matches(data, "tui.select.pageUp")) {
|
|
1382
|
+
scrollTop -= RUN_DETAIL_OUTPUT_VIEWPORT_LINES;
|
|
1383
|
+
tui.requestRender();
|
|
1384
|
+
return;
|
|
1385
|
+
}
|
|
1386
|
+
if (kb.matches(data, "tui.select.pageDown")) {
|
|
1387
|
+
scrollTop += RUN_DETAIL_OUTPUT_VIEWPORT_LINES;
|
|
1388
|
+
tui.requestRender();
|
|
1389
|
+
return;
|
|
1390
|
+
}
|
|
1391
|
+
done();
|
|
1392
|
+
},
|
|
1212
1393
|
};
|
|
1213
1394
|
});
|
|
1214
1395
|
}
|
|
@@ -1865,32 +2046,59 @@ export async function showDashboard(
|
|
|
1865
2046
|
// Retry menu
|
|
1866
2047
|
// ---------------------------------------------------------------------------
|
|
1867
2048
|
|
|
1868
|
-
export async function showRetryMenu(
|
|
1869
|
-
|
|
2049
|
+
export async function showRetryMenu(
|
|
2050
|
+
ctx: ExtensionContext,
|
|
2051
|
+
state: SessionState,
|
|
2052
|
+
// U3 (D5): the abandon action is injected by the extension entry point (it
|
|
2053
|
+
// owns the session-manager/registry wiring). Omitted in read-only contexts.
|
|
2054
|
+
onAbandon?: (id: string) => void | Promise<void>,
|
|
2055
|
+
): Promise<void> {
|
|
2056
|
+
// U3 (D3): failed AND unresolved interrupted runs share this surface, one row
|
|
2057
|
+
// per run, built on the same terminal-preferred collapse the history uses.
|
|
2058
|
+
const candidates = retryableRuns(recoverableRunEntries(ctx, state));
|
|
1870
2059
|
|
|
1871
|
-
if (
|
|
1872
|
-
ctx.ui.notify("No failed subagent runs to retry.", "info");
|
|
2060
|
+
if (candidates.length === 0) {
|
|
2061
|
+
ctx.ui.notify("No failed or interrupted subagent runs to retry.", "info");
|
|
1873
2062
|
return;
|
|
1874
2063
|
}
|
|
1875
2064
|
|
|
1876
|
-
const items: SelectItem[] =
|
|
1877
|
-
const
|
|
2065
|
+
const items: SelectItem[] = candidates.map((r) => {
|
|
2066
|
+
const interrupted = isInterruptedRun(r);
|
|
2067
|
+
const name = runDisplayName(r);
|
|
1878
2068
|
const model = r.model.split("/").pop() || r.model;
|
|
1879
|
-
const err = r.errorMessage ? r.errorMessage.slice(0, 40) : "error";
|
|
2069
|
+
const err = r.errorMessage ? r.errorMessage.slice(0, 40) : interrupted ? "interrupted" : "error";
|
|
1880
2070
|
return {
|
|
1881
2071
|
value: r.id,
|
|
1882
|
-
label:
|
|
1883
|
-
description: `${model} · ${err}`,
|
|
2072
|
+
label: `${interrupted ? "◌" : "✗"} ${name}`,
|
|
2073
|
+
description: `${interrupted ? "interrupted · " : ""}${model} · ${err}`,
|
|
1884
2074
|
};
|
|
1885
2075
|
});
|
|
1886
2076
|
|
|
1887
|
-
const selectedId = await showSelectList(ctx, "Retry Failed Run", items, 10);
|
|
2077
|
+
const selectedId = await showSelectList(ctx, "Retry Failed or Interrupted Run", items, 10);
|
|
1888
2078
|
if (!selectedId) return;
|
|
1889
2079
|
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
)
|
|
2080
|
+
const run = candidates.find((r) => r.id === selectedId);
|
|
2081
|
+
if (!run) return;
|
|
2082
|
+
|
|
2083
|
+
if (isInterruptedRun(run) && onAbandon) {
|
|
2084
|
+
const action = await showSelectList(
|
|
2085
|
+
ctx,
|
|
2086
|
+
"Interrupted Run",
|
|
2087
|
+
[
|
|
2088
|
+
{ value: "retry", label: "Retry", description: "Re-dispatch with its recorded params (retryRunId)" },
|
|
2089
|
+
{ value: "abandon", label: "Abandon", description: "Finalize as stopped and prune its session file" },
|
|
2090
|
+
],
|
|
2091
|
+
5,
|
|
2092
|
+
);
|
|
2093
|
+
if (!action) return;
|
|
2094
|
+
if (action === "abandon") {
|
|
2095
|
+
await onAbandon(run.id);
|
|
2096
|
+
ctx.ui.notify(`Abandoned interrupted run ${run.id.slice(0, 8)}.`, "info");
|
|
2097
|
+
return;
|
|
2098
|
+
}
|
|
2099
|
+
}
|
|
2100
|
+
|
|
2101
|
+
ctx.ui.notify(buildRetryOfferMessage(run), "info");
|
|
1894
2102
|
}
|
|
1895
2103
|
|
|
1896
2104
|
// ---------------------------------------------------------------------------
|
|
@@ -2803,7 +3011,19 @@ export function renderDelegateResult(
|
|
|
2803
3011
|
}
|
|
2804
3012
|
|
|
2805
3013
|
// Single subagent mode (original)
|
|
2806
|
-
|
|
3014
|
+
// Issue #298 (C2): the partial-vs-settled decision lives in ONE place —
|
|
3015
|
+
// `isUnsettledPartial` (src/types.ts) owns the overloaded `exitCode === -1`
|
|
3016
|
+
// disambiguation (the #206 streaming sentinel vs the #295 settled signal
|
|
3017
|
+
// death). Do not re-derive it here; the terminal-status structural ratchet
|
|
3018
|
+
// fails a local copy. A genuine partial (or a result with no details yet)
|
|
3019
|
+
// renders the raw running text; a classified -1 is a settled FAILURE and
|
|
3020
|
+
// takes the verdict branch below (✗ + error line + expanded-transcript
|
|
3021
|
+
// binding), never raw text.
|
|
3022
|
+
//
|
|
3023
|
+
// The leading `!details` is a type-narrowing aid for the settled branch below
|
|
3024
|
+
// (TS cannot narrow through a function call); it is NOT a second copy of the
|
|
3025
|
+
// decision — the predicate owns the no-details case too.
|
|
3026
|
+
if (!details || isUnsettledPartial(details)) {
|
|
2807
3027
|
const text = result.content[0];
|
|
2808
3028
|
return new Text(text?.type === "text" ? text.text : "(running\u2026)", 0, 0);
|
|
2809
3029
|
}
|