brl-subagent 2.4.0 → 2.5.0
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/README.md +2 -2
- package/package.json +5 -5
- package/src/event-bus.ts +7 -2
- package/src/git.ts +67 -0
- package/src/history.ts +58 -1
- package/src/index.ts +184 -32
- package/src/kill-escalation.ts +94 -0
- package/src/logging.ts +37 -3
- package/src/preflight.ts +29 -14
- package/src/proc.ts +140 -0
- package/src/recovery-engine.ts +262 -0
- package/src/recovery.ts +325 -0
- package/src/run-registry.ts +269 -0
- package/src/runner.ts +206 -16
- package/src/sanitize.ts +53 -3
- package/src/session-manager.ts +106 -23
- package/src/state.ts +51 -7
- package/src/transcript-path.ts +41 -0
- package/src/transcript.ts +13 -12
- package/src/tui.ts +228 -88
- package/src/types.ts +107 -0
- package/src/unit-run.ts +8 -0
package/src/state.ts
CHANGED
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
CIRCUIT_DEGRADED_THINKING,
|
|
36
36
|
} from "./types";
|
|
37
37
|
import { cleanupRuns } from "./history";
|
|
38
|
+
import { persistRunRecord, type InflightRunKind } from "./run-registry";
|
|
38
39
|
import type { Logger } from "./logging";
|
|
39
40
|
|
|
40
41
|
// ---------------------------------------------------------------------------
|
|
@@ -69,14 +70,21 @@ export const STALE_FINALIZE_GRACE_MS = POLLER_TICK_MS + 1000;
|
|
|
69
70
|
|
|
70
71
|
/**
|
|
71
72
|
* Terminal-preferring run-entry lookup — the ONE implementation of the
|
|
72
|
-
* preference rule.
|
|
73
|
+
* preference rule. Recovery's `dedupeRunEntriesById` delegates here, so the
|
|
74
|
+
* boot scan and every display/retry lookup agree by construction.
|
|
73
75
|
*
|
|
74
76
|
* Each run writes TWO custom entries sharing its id, in append order: a spawn
|
|
75
77
|
* entry (status "running", carrying `originalParams`) FIRST, then the final
|
|
76
78
|
* entry (status "done"/"failed", stamped with fullOutput/cost/duration) at
|
|
77
|
-
* settle.
|
|
78
|
-
*
|
|
79
|
-
* entry
|
|
79
|
+
* settle. Preference, in order:
|
|
80
|
+
*
|
|
81
|
+
* 1. a TERMINAL entry (status !== "running") — the run is resolved;
|
|
82
|
+
* 2. among non-terminal entries, one carrying the D3 `interruptedAt` mark —
|
|
83
|
+
* append-only stores keep the original spawn entry forever, so the marked
|
|
84
|
+
* clone is the authoritative RECOVERED state U3's shared lookup reads;
|
|
85
|
+
* 3. otherwise the first match (the pre-finalize spawn shape).
|
|
86
|
+
*
|
|
87
|
+
* Returns undefined when no entry matches.
|
|
80
88
|
*/
|
|
81
89
|
export function resolveTerminalRunEntry(
|
|
82
90
|
entries: SubagentRun[],
|
|
@@ -84,7 +92,37 @@ export function resolveTerminalRunEntry(
|
|
|
84
92
|
): SubagentRun | undefined {
|
|
85
93
|
const matching = entries.filter((r) => r.id === id);
|
|
86
94
|
if (matching.length === 0) return undefined;
|
|
87
|
-
return
|
|
95
|
+
return (
|
|
96
|
+
matching.find((r) => r.status !== "running") ??
|
|
97
|
+
matching.find((r) => r.interruptedAt) ??
|
|
98
|
+
matching[0]
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Run-history display projection (issue #259): ONE record per SETTLED run.
|
|
104
|
+
*
|
|
105
|
+
* Each run appends two entries sharing its id — a spawn entry (status
|
|
106
|
+
* "running") first, then the terminal entry at settle — so a raw entry list
|
|
107
|
+
* renders every run twice and the spawn row shows a stale "running" forever.
|
|
108
|
+
* This collapses each id to its terminal-preferred entry via the ONE shared
|
|
109
|
+
* rule (`resolveTerminalRunEntry`). Runs still in flight (spawn entry, no
|
|
110
|
+
* terminal entry yet) are omitted: live state belongs to the monitor.
|
|
111
|
+
*
|
|
112
|
+
* Ordering is preserved from `entries`; callers pass `getRunEntries` output
|
|
113
|
+
* (newest-first via `cleanupRuns`), which stays the single ordering authority —
|
|
114
|
+
* no re-sort is performed here.
|
|
115
|
+
*/
|
|
116
|
+
export function collapseRunsForHistory(entries: SubagentRun[]): SubagentRun[] {
|
|
117
|
+
const display: SubagentRun[] = [];
|
|
118
|
+
const seen = new Set<string>();
|
|
119
|
+
for (const entry of entries) {
|
|
120
|
+
if (seen.has(entry.id)) continue;
|
|
121
|
+
seen.add(entry.id);
|
|
122
|
+
const resolved = resolveTerminalRunEntry(entries, entry.id);
|
|
123
|
+
if (resolved && resolved.status !== "running") display.push(resolved);
|
|
124
|
+
}
|
|
125
|
+
return display;
|
|
88
126
|
}
|
|
89
127
|
|
|
90
128
|
// ---------------------------------------------------------------------------
|
|
@@ -194,8 +232,14 @@ export class SessionState {
|
|
|
194
232
|
});
|
|
195
233
|
}
|
|
196
234
|
|
|
197
|
-
|
|
198
|
-
|
|
235
|
+
/**
|
|
236
|
+
* Persist a run record through the ONE choke point (`persistRunRecord`),
|
|
237
|
+
* which appends the session entry AND mirrors the record into the durable
|
|
238
|
+
* in-flight registry. `kind` is supplied at dispatch; terminal writes clear
|
|
239
|
+
* the registry entry.
|
|
240
|
+
*/
|
|
241
|
+
persistRun(pi: ExtensionAPI, run: SubagentRun, kind?: InflightRunKind): void {
|
|
242
|
+
persistRunRecord(pi, run, kind);
|
|
199
243
|
}
|
|
200
244
|
|
|
201
245
|
// -------------------------------------------------------------------
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Purpose: The single declaration of the transcript path format — filesystem (`join`) and display (POSIX) forms.
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { assertSafeAgentId } from "./sanitize";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The transcript directory, relative to the project root. This is the ONE
|
|
7
|
+
* declaration of the format; every other module derives its path from here
|
|
8
|
+
* rather than re-inlining `.pi/output` in a template string (issue #276).
|
|
9
|
+
*/
|
|
10
|
+
export const TRANSCRIPT_DIR = ".pi/output";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Filesystem path to an agent's transcript: `<dir>/agent-<id>.jsonl`.
|
|
14
|
+
*
|
|
15
|
+
* SECURITY (F24): the id is validated through `assertSafeAgentId` BEFORE it
|
|
16
|
+
* reaches `path.join` — join does NOT sanitize `../` or absolute segments, so
|
|
17
|
+
* a traversal id would otherwise escape `dir`. Keep this chokepoint for every
|
|
18
|
+
* filesystem caller (`transcript.ts`, the `get_agent_result` re-export).
|
|
19
|
+
*
|
|
20
|
+
* `dir` is a parameter so `transcript.ts` can forward its mutable test-only
|
|
21
|
+
* `OUTPUT_DIR`; production callers take the `TRANSCRIPT_DIR` default.
|
|
22
|
+
*/
|
|
23
|
+
export function transcriptPath(agentId: string, dir: string = TRANSCRIPT_DIR): string {
|
|
24
|
+
assertSafeAgentId(agentId);
|
|
25
|
+
return join(dir, `agent-${agentId}.jsonl`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Display form of the transcript path: `<TRANSCRIPT_DIR>/agent-<id>.jsonl`,
|
|
30
|
+
* always with forward slashes so the rendered panel is identical on every
|
|
31
|
+
* platform.
|
|
32
|
+
*
|
|
33
|
+
* This is deliberately format-only: it does NOT call `assertSafeAgentId` and
|
|
34
|
+
* must never throw. It is called during TUI rendering (`tui.ts` →
|
|
35
|
+
* `buildOutputHonestyLines`) from a stored run record, where a throw would
|
|
36
|
+
* break the panel. Display text is POSIX while filesystem access uses `join` —
|
|
37
|
+
* the two forms intentionally differ (settled in issue #276).
|
|
38
|
+
*/
|
|
39
|
+
export function transcriptDisplayPath(agentId: string): string {
|
|
40
|
+
return `${TRANSCRIPT_DIR}/agent-${agentId}.jsonl`;
|
|
41
|
+
}
|
package/src/transcript.ts
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
// Purpose: JSONL transcript recording for every agent run (`.pi/output/agent-<id>.jsonl`).
|
|
2
|
-
import { join } from 'path';
|
|
3
2
|
import { mkdirSync, appendFileSync, readFileSync, existsSync } from 'fs';
|
|
4
3
|
import type { TranscriptEntry, TranscriptEntryType } from './types';
|
|
5
|
-
import {
|
|
4
|
+
import { transcriptPath, TRANSCRIPT_DIR } from './transcript-path';
|
|
5
|
+
import { createLogger } from './logging';
|
|
6
|
+
|
|
7
|
+
// Issue #265: route the settle-path warning through the logger (file-only by
|
|
8
|
+
// default) instead of a raw console.warn that corrupts the pi TUI.
|
|
9
|
+
const log = createLogger('brl-subagent');
|
|
6
10
|
|
|
7
11
|
// Output directory — overridable for tests (issue #52): unit tests must NOT
|
|
8
|
-
// write into the real repo .pi/ dir; the setter is test-only.
|
|
9
|
-
|
|
12
|
+
// write into the real repo .pi/ dir; the setter is test-only. Defaults to the
|
|
13
|
+
// single-sourced TRANSCRIPT_DIR (issue #276).
|
|
14
|
+
let OUTPUT_DIR = TRANSCRIPT_DIR;
|
|
10
15
|
|
|
11
16
|
/**
|
|
12
17
|
* TEST-ONLY: point the transcript output at an alternate directory.
|
|
@@ -30,13 +35,9 @@ function ensureOutputDir(): void {
|
|
|
30
35
|
* Get transcript file path for an agent
|
|
31
36
|
*/
|
|
32
37
|
export function getTranscriptPath(agentId: string): string {
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
|
|
36
|
-
// getTranscript. path.join does not sanitize "../" or absolute segments, so
|
|
37
|
-
// throw on anything that is not a UUID before it reaches the filesystem.
|
|
38
|
-
assertSafeAgentId(agentId);
|
|
39
|
-
return join(OUTPUT_DIR, `agent-${agentId}.jsonl`);
|
|
38
|
+
// Issue #276: the format and the F24 `assertSafeAgentId` chokepoint live in
|
|
39
|
+
// transcript-path.ts; here we only forward the mutable test-only OUTPUT_DIR.
|
|
40
|
+
return transcriptPath(agentId, OUTPUT_DIR);
|
|
40
41
|
}
|
|
41
42
|
|
|
42
43
|
/**
|
|
@@ -118,7 +119,7 @@ export function completeTranscript(agentId: string, status: string): void {
|
|
|
118
119
|
// appending — that invariant is load-bearing for caller validation).
|
|
119
120
|
const path = getTranscriptPath(agentId);
|
|
120
121
|
if (!existsSync(path)) {
|
|
121
|
-
|
|
122
|
+
log.warn(`completeTranscript: transcript missing for agent ${agentId} — skipping completion entry`);
|
|
122
123
|
return;
|
|
123
124
|
}
|
|
124
125
|
appendEntry(agentId, 'system', `Transcript completed: ${status}`);
|
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 {
|
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
COLLAPSED_OUTPUT_LINES,
|
|
42
42
|
COLLAPSED_DIFF_FILES_PREVIEW,
|
|
43
43
|
EXPANDED_HUNKS_PER_FILE,
|
|
44
|
+
DEFAULT_OUTPUT_CAP_BYTES,
|
|
44
45
|
formatTokens,
|
|
45
46
|
formatUsageStats,
|
|
46
47
|
formatModel,
|
|
@@ -49,15 +50,16 @@ import {
|
|
|
49
50
|
isSubagentError,
|
|
50
51
|
isMultiSubagentDetails,
|
|
51
52
|
isGraphDetails,
|
|
53
|
+
isUnsettledPartial,
|
|
52
54
|
} from "./types";
|
|
53
55
|
import { buildFileAccessReport, buildSecretsExposureReport, generateComplianceSummary } from "./reports";
|
|
54
56
|
import { extractParamNames } from "./templates";
|
|
55
57
|
import { parseDiff } from "./diff";
|
|
56
58
|
import { formatPresetSummary, getPreset, loadCustomPresets } from "./presets";
|
|
57
|
-
import { formatRunDuration } from "./history";
|
|
59
|
+
import { buildOutputHonestyLines, formatRunDuration } from "./history";
|
|
58
60
|
import * as fs from "node:fs";
|
|
59
61
|
import * as path from "node:path";
|
|
60
|
-
import { sweepStaleLiveSubagents, type SessionState } from "./state";
|
|
62
|
+
import { collapseRunsForHistory, sweepStaleLiveSubagents, type SessionState } from "./state";
|
|
61
63
|
import { getAgent } from "./session-manager";
|
|
62
64
|
import { computeSLAMetrics, computeCostTrend, formatSparkline } from "./metrics";
|
|
63
65
|
import { formatElapsed, liveRowName, liveSpinner, formatLiveRowDim } from "./tui-format";
|
|
@@ -1088,127 +1090,253 @@ export async function showRunHistory(
|
|
|
1088
1090
|
state: SessionState,
|
|
1089
1091
|
persistState: () => void,
|
|
1090
1092
|
): 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
1093
|
const statusIcon: Record<string, string> = {
|
|
1099
1094
|
running: ctx.ui.theme.fg("accent", "◉"),
|
|
1100
1095
|
done: ctx.ui.theme.fg("success", "✓"),
|
|
1101
1096
|
failed: ctx.ui.theme.fg("error", "✗"),
|
|
1102
1097
|
};
|
|
1103
1098
|
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
const
|
|
1111
|
-
|
|
1112
|
-
|
|
1099
|
+
// Browse loop (issue #260): list → detail → list … until the LIST is exited
|
|
1100
|
+
// with Esc. Closing a detail returns here, not to the /brl-subagent menu.
|
|
1101
|
+
while (true) {
|
|
1102
|
+
// One row per SETTLED run (issue #259): collapse each run's spawn+terminal
|
|
1103
|
+
// entry pair to its terminal-preferred record; in-flight runs are omitted
|
|
1104
|
+
// (the live monitor owns running state).
|
|
1105
|
+
const runs = collapseRunsForHistory(state.getRunEntries(ctx));
|
|
1106
|
+
|
|
1107
|
+
if (runs.length === 0) {
|
|
1108
|
+
ctx.ui.notify("No subagent runs recorded yet. Delegate a task to see history.", "info");
|
|
1109
|
+
return;
|
|
1110
|
+
}
|
|
1113
1111
|
|
|
1114
|
-
|
|
1115
|
-
|
|
1112
|
+
const items: SelectItem[] = runs.map((r) => {
|
|
1113
|
+
const icon = statusIcon[r.status] || "·";
|
|
1114
|
+
const name = r.label || r.task.slice(0, 60);
|
|
1115
|
+
const model = r.model.split("/").pop() || r.model;
|
|
1116
|
+
const when = r.finishedAt ? formatRunDuration(r.durationMs || 0) : "running...";
|
|
1117
|
+
const cost = r.cost ? ` $${r.cost.toFixed(4)}` : "";
|
|
1118
|
+
const desc = `${r.thinkingLevel} · ${model} · ${when}${cost}`;
|
|
1119
|
+
return { value: r.id, label: `${icon} ${name}`, description: desc };
|
|
1120
|
+
});
|
|
1116
1121
|
|
|
1117
|
-
|
|
1118
|
-
|
|
1122
|
+
const selectedId = await showSelectList(ctx, "Subagent History", items, 15);
|
|
1123
|
+
if (!selectedId) return; // Esc at the list exits to the menu
|
|
1119
1124
|
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1125
|
+
const run = runs.find((r) => r.id === selectedId);
|
|
1126
|
+
if (!run) continue; // selected run pruned since listing — re-show the list
|
|
1127
|
+
|
|
1128
|
+
// Mark as seen
|
|
1129
|
+
if (state.markRunSeen(run.id)) {
|
|
1130
|
+
persistState();
|
|
1131
|
+
import("./concurrency").then(({ updateProgressStatus }) => {
|
|
1132
|
+
updateProgressStatus(state, ctx);
|
|
1133
|
+
});
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
await showRunDetail(ctx, run);
|
|
1126
1137
|
}
|
|
1138
|
+
}
|
|
1127
1139
|
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1140
|
+
/**
|
|
1141
|
+
* Number of output lines a run-detail viewport shows at once (issue #261):
|
|
1142
|
+
* the stored text can be up to the 100 KB cap, so the panel is height-capped
|
|
1143
|
+
* and scrolled rather than dumped whole (which would push the footer and
|
|
1144
|
+
* header off-screen).
|
|
1145
|
+
*/
|
|
1146
|
+
const RUN_DETAIL_OUTPUT_VIEWPORT_LINES = 12;
|
|
1132
1147
|
|
|
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
|
-
|
|
1148
|
+
/**
|
|
1149
|
+
* Detail view for one settled run (issue #260, #261): ↑/↓ and PgUp/PgDn scroll
|
|
1150
|
+
* the stored output; any OTHER key — including Esc — closes it. The
|
|
1151
|
+
* showRunHistory browse loop re-shows the list on return.
|
|
1152
|
+
*
|
|
1153
|
+
* The output region follows the approval dialog's full-diff view structure
|
|
1154
|
+
* (see `showApprovalDialog.buildDiffView` / `withDiffKeybinding`: a bordered
|
|
1155
|
+
* Container with a title, the content, and a return footer) but adds the
|
|
1156
|
+
* height cap + scroll state that view lacks. Output is wrapped through the
|
|
1157
|
+
* same `Text` component those views use, so wrapping/padding stay consistent.
|
|
1158
|
+
*/
|
|
1159
|
+
async function showRunDetail(ctx: ExtensionContext, run: SubagentRun): Promise<void> {
|
|
1160
|
+
const outputText = run.fullOutput ?? run.outputSummary ?? "";
|
|
1161
|
+
await ctx.ui.custom<void>((tui, theme, kb, done) => {
|
|
1162
|
+
// First line of the viewport (0 = top). Mutated by scroll keys and
|
|
1163
|
+
// clamped to the wrapped-content length on every render.
|
|
1164
|
+
let scrollTop = 0;
|
|
1165
|
+
// Wrapped output lines for the current width, and the width they were
|
|
1166
|
+
// produced at, so a resize re-wraps and a redraw does not.
|
|
1167
|
+
let wrappedLines: string[] = [];
|
|
1168
|
+
let wrappedWidth = -1;
|
|
1169
|
+
|
|
1170
|
+
const wrapOutput = (width: number): string[] => {
|
|
1171
|
+
if (width === wrappedWidth) return wrappedLines;
|
|
1172
|
+
wrappedLines = new Text(theme.fg("toolOutput", outputText), 1, 0).render(width);
|
|
1173
|
+
wrappedWidth = width;
|
|
1174
|
+
return wrappedLines;
|
|
1175
|
+
};
|
|
1176
|
+
|
|
1177
|
+
const buildOutputSection = (width: number): Container => {
|
|
1178
|
+
const section = new Container();
|
|
1179
|
+
const lines = wrapOutput(width);
|
|
1180
|
+
const total = lines.length;
|
|
1181
|
+
const maxScroll = Math.max(0, total - RUN_DETAIL_OUTPUT_VIEWPORT_LINES);
|
|
1182
|
+
if (scrollTop > maxScroll) scrollTop = maxScroll;
|
|
1183
|
+
if (scrollTop < 0) scrollTop = 0;
|
|
1184
|
+
const from = total === 0 ? 0 : Math.min(scrollTop + 1, total);
|
|
1185
|
+
const to = Math.min(scrollTop + RUN_DETAIL_OUTPUT_VIEWPORT_LINES, total);
|
|
1186
|
+
const indicator =
|
|
1187
|
+
total > RUN_DETAIL_OUTPUT_VIEWPORT_LINES
|
|
1188
|
+
? ` (lines ${from}\u2013${to} of ${total} \u00b7 \u2191\u2193 / PgUp\u00b7PgDn)`
|
|
1189
|
+
: ` (${total} line${total === 1 ? "" : "s"})`;
|
|
1190
|
+
|
|
1191
|
+
section.addChild(
|
|
1159
1192
|
new Text(
|
|
1160
|
-
theme.fg(
|
|
1161
|
-
"dim",
|
|
1162
|
-
`Finished: ${run.finishedAt} (${formatRunDuration(run.durationMs || 0)})`,
|
|
1163
|
-
),
|
|
1193
|
+
theme.fg("muted", "\u2500\u2500\u2500 Output \u2500\u2500\u2500") + theme.fg("dim", indicator),
|
|
1164
1194
|
1,
|
|
1165
1195
|
0,
|
|
1166
1196
|
),
|
|
1167
1197
|
);
|
|
1168
|
-
|
|
1169
|
-
|
|
1198
|
+
|
|
1199
|
+
if (total === 0) {
|
|
1200
|
+
section.addChild(new Text(theme.fg("dim", "(no output recorded)"), 1, 0));
|
|
1201
|
+
return section;
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
// Height-capped viewport: the output was already wrapped to `width`
|
|
1205
|
+
// above, so hand the sliced lines back directly instead of feeding them
|
|
1206
|
+
// through another Text (which would re-wrap at the padded width).
|
|
1207
|
+
const viewport: Component = {
|
|
1208
|
+
render: () => lines.slice(scrollTop, scrollTop + RUN_DETAIL_OUTPUT_VIEWPORT_LINES),
|
|
1209
|
+
invalidate: () => {
|
|
1210
|
+
wrappedWidth = -1;
|
|
1211
|
+
},
|
|
1212
|
+
};
|
|
1213
|
+
section.addChild(viewport);
|
|
1214
|
+
return section;
|
|
1215
|
+
};
|
|
1216
|
+
|
|
1217
|
+
const buildDetailView = (width: number): Container => {
|
|
1218
|
+
const container = new Container();
|
|
1219
|
+
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
1220
|
+
|
|
1221
|
+
const statusLabel =
|
|
1222
|
+
run.status === "done" ? "Completed" : run.status === "failed" ? "Failed" : "Running";
|
|
1170
1223
|
container.addChild(
|
|
1171
1224
|
new Text(
|
|
1172
1225
|
theme.fg(
|
|
1173
|
-
"
|
|
1174
|
-
|
|
1226
|
+
"toolTitle",
|
|
1227
|
+
theme.bold(`${run.label || "Subagent"} — ${statusLabel}`),
|
|
1175
1228
|
),
|
|
1176
1229
|
1,
|
|
1177
1230
|
0,
|
|
1178
1231
|
),
|
|
1179
1232
|
);
|
|
1180
|
-
|
|
1181
|
-
if (run.errorMessage) {
|
|
1182
|
-
container.addChild(new Spacer(1));
|
|
1233
|
+
container.addChild(new Text("", 0, 0));
|
|
1183
1234
|
container.addChild(
|
|
1184
|
-
new Text(theme.fg("
|
|
1235
|
+
new Text(theme.fg("dim", `Task: ${run.task.slice(0, 200)}`), 1, 0),
|
|
1185
1236
|
);
|
|
1186
|
-
}
|
|
1187
|
-
if (run.outputSummary) {
|
|
1188
|
-
container.addChild(new Spacer(1));
|
|
1189
1237
|
container.addChild(
|
|
1190
1238
|
new Text(
|
|
1191
|
-
theme.fg("
|
|
1239
|
+
theme.fg("dim", `Model: ${run.model} · Thinking: ${run.thinkingLevel}`),
|
|
1192
1240
|
1,
|
|
1193
1241
|
0,
|
|
1194
1242
|
),
|
|
1195
1243
|
);
|
|
1196
|
-
container.addChild(
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1244
|
+
container.addChild(new Text(theme.fg("dim", `Started: ${run.startedAt}`), 1, 0));
|
|
1245
|
+
if (run.finishedAt) {
|
|
1246
|
+
container.addChild(
|
|
1247
|
+
new Text(
|
|
1248
|
+
theme.fg(
|
|
1249
|
+
"dim",
|
|
1250
|
+
`Finished: ${run.finishedAt} (${formatRunDuration(run.durationMs || 0)})`,
|
|
1251
|
+
),
|
|
1252
|
+
1,
|
|
1253
|
+
0,
|
|
1254
|
+
),
|
|
1255
|
+
);
|
|
1256
|
+
}
|
|
1257
|
+
if (run.cost) {
|
|
1258
|
+
container.addChild(
|
|
1259
|
+
new Text(
|
|
1260
|
+
theme.fg(
|
|
1261
|
+
"dim",
|
|
1262
|
+
`Cost: $${run.cost.toFixed(4)} · ↑${formatTokens(run.tokensIn || 0)} ↓${formatTokens(run.tokensOut || 0)}`,
|
|
1263
|
+
),
|
|
1264
|
+
1,
|
|
1265
|
+
0,
|
|
1266
|
+
),
|
|
1267
|
+
);
|
|
1268
|
+
}
|
|
1269
|
+
if (run.errorMessage) {
|
|
1270
|
+
container.addChild(new Spacer(1));
|
|
1271
|
+
container.addChild(
|
|
1272
|
+
new Text(theme.fg("error", `Error: ${run.errorMessage}`), 1, 0),
|
|
1273
|
+
);
|
|
1201
1274
|
}
|
|
1202
|
-
}
|
|
1203
1275
|
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1276
|
+
container.addChild(new Spacer(1));
|
|
1277
|
+
container.addChild(buildOutputSection(width));
|
|
1278
|
+
// Honest disclosures (issue #261): the transcript is the only place the
|
|
1279
|
+
// uncapped text lives, and the cap is stated when it was hit.
|
|
1280
|
+
for (const line of buildOutputHonestyLines(run, DEFAULT_OUTPUT_CAP_BYTES)) {
|
|
1281
|
+
container.addChild(new Text(theme.fg("dim", line), 1, 0));
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
container.addChild(new Spacer(1));
|
|
1285
|
+
container.addChild(
|
|
1286
|
+
new Text(
|
|
1287
|
+
theme.fg("dim", "\u2191\u2193 scroll \u00b7 alt+\u2191\u2193 page \u00b7 any other key to close"),
|
|
1288
|
+
1,
|
|
1289
|
+
0,
|
|
1290
|
+
),
|
|
1291
|
+
);
|
|
1292
|
+
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
1293
|
+
return container;
|
|
1294
|
+
};
|
|
1207
1295
|
|
|
1208
1296
|
return {
|
|
1209
|
-
render: (w: number) =>
|
|
1210
|
-
invalidate: () =>
|
|
1211
|
-
handleInput: (
|
|
1297
|
+
render: (w: number) => buildDetailView(w).render(w),
|
|
1298
|
+
invalidate: () => {},
|
|
1299
|
+
handleInput: (data: string) => {
|
|
1300
|
+
// Paging: alt+↑ / alt+↓. Checked BEFORE the plain arrows so an
|
|
1301
|
+
// alt-modified sequence can never be consumed as a line step.
|
|
1302
|
+
// PgUp/PgDn are unusable here: the host binds them to the
|
|
1303
|
+
// alt-screen transcript scroll (`tui.altScreen.pageUp/pageDown`)
|
|
1304
|
+
// and consumes them before a `ctx.ui.custom` overlay sees input —
|
|
1305
|
+
// pi 1.0.2 has no viewport-owner registration for overlays. The
|
|
1306
|
+
// PgUp/PgDn checks below stay for inline mode, where they do
|
|
1307
|
+
// reach the overlay.
|
|
1308
|
+
if (matchesKey(data, "alt+up")) {
|
|
1309
|
+
scrollTop -= RUN_DETAIL_OUTPUT_VIEWPORT_LINES;
|
|
1310
|
+
tui.requestRender();
|
|
1311
|
+
return;
|
|
1312
|
+
}
|
|
1313
|
+
if (matchesKey(data, "alt+down")) {
|
|
1314
|
+
scrollTop += RUN_DETAIL_OUTPUT_VIEWPORT_LINES;
|
|
1315
|
+
tui.requestRender();
|
|
1316
|
+
return;
|
|
1317
|
+
}
|
|
1318
|
+
if (kb.matches(data, "tui.select.up")) {
|
|
1319
|
+
scrollTop -= 1;
|
|
1320
|
+
tui.requestRender();
|
|
1321
|
+
return;
|
|
1322
|
+
}
|
|
1323
|
+
if (kb.matches(data, "tui.select.down")) {
|
|
1324
|
+
scrollTop += 1;
|
|
1325
|
+
tui.requestRender();
|
|
1326
|
+
return;
|
|
1327
|
+
}
|
|
1328
|
+
if (kb.matches(data, "tui.select.pageUp")) {
|
|
1329
|
+
scrollTop -= RUN_DETAIL_OUTPUT_VIEWPORT_LINES;
|
|
1330
|
+
tui.requestRender();
|
|
1331
|
+
return;
|
|
1332
|
+
}
|
|
1333
|
+
if (kb.matches(data, "tui.select.pageDown")) {
|
|
1334
|
+
scrollTop += RUN_DETAIL_OUTPUT_VIEWPORT_LINES;
|
|
1335
|
+
tui.requestRender();
|
|
1336
|
+
return;
|
|
1337
|
+
}
|
|
1338
|
+
done();
|
|
1339
|
+
},
|
|
1212
1340
|
};
|
|
1213
1341
|
});
|
|
1214
1342
|
}
|
|
@@ -2803,7 +2931,19 @@ export function renderDelegateResult(
|
|
|
2803
2931
|
}
|
|
2804
2932
|
|
|
2805
2933
|
// Single subagent mode (original)
|
|
2806
|
-
|
|
2934
|
+
// Issue #298 (C2): the partial-vs-settled decision lives in ONE place —
|
|
2935
|
+
// `isUnsettledPartial` (src/types.ts) owns the overloaded `exitCode === -1`
|
|
2936
|
+
// disambiguation (the #206 streaming sentinel vs the #295 settled signal
|
|
2937
|
+
// death). Do not re-derive it here; the terminal-status structural ratchet
|
|
2938
|
+
// fails a local copy. A genuine partial (or a result with no details yet)
|
|
2939
|
+
// renders the raw running text; a classified -1 is a settled FAILURE and
|
|
2940
|
+
// takes the verdict branch below (✗ + error line + expanded-transcript
|
|
2941
|
+
// binding), never raw text.
|
|
2942
|
+
//
|
|
2943
|
+
// The leading `!details` is a type-narrowing aid for the settled branch below
|
|
2944
|
+
// (TS cannot narrow through a function call); it is NOT a second copy of the
|
|
2945
|
+
// decision — the predicate owns the no-details case too.
|
|
2946
|
+
if (!details || isUnsettledPartial(details)) {
|
|
2807
2947
|
const text = result.content[0];
|
|
2808
2948
|
return new Text(text?.type === "text" ? text.text : "(running\u2026)", 0, 0);
|
|
2809
2949
|
}
|