pi-subagents 0.47.1 → 0.48.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/CHANGELOG.md +23 -0
- package/README.md +2 -0
- package/docs/configuration.md +46 -2
- package/docs/observability.md +1 -1
- package/docs/tool-reference.md +1 -1
- package/package.json +1 -1
- package/src/agents/agents.ts +9 -3
- package/src/extension/config.ts +6 -0
- package/src/extension/doctor.ts +40 -0
- package/src/extension/index.ts +21 -0
- package/src/extension/public-execution.ts +5 -0
- package/src/extension/rpc.ts +3 -9
- package/src/extension/schemas.ts +2 -2
- package/src/intercom/intercom-bridge.ts +4 -1
- package/src/missions/lifecycle.ts +4 -7
- package/src/missions/store.ts +4 -4
- package/src/missions/workflow-state.ts +6 -2
- package/src/runs/background/active-async-capacity.ts +374 -0
- package/src/runs/background/active-run-index.ts +9 -5
- package/src/runs/background/async-execution.ts +117 -29
- package/src/runs/background/async-resume.ts +7 -1
- package/src/runs/background/async-status.ts +11 -5
- package/src/runs/background/chain-append.ts +33 -15
- package/src/runs/background/owned-process-tree.ts +104 -0
- package/src/runs/background/process-terminal.ts +17 -3
- package/src/runs/background/run-status.ts +5 -1
- package/src/runs/background/stale-run-reconciler.ts +3 -3
- package/src/runs/background/subagent-runner.ts +56 -31
- package/src/runs/foreground/chain-execution.ts +37 -2
- package/src/runs/foreground/execution.ts +90 -19
- package/src/runs/foreground/foreground-control.ts +12 -0
- package/src/runs/foreground/prompt-audit.ts +171 -0
- package/src/runs/foreground/subagent-executor.ts +614 -187
- package/src/runs/shared/acceptance.ts +13 -4
- package/src/runs/shared/llm-intent-arbiter.ts +286 -0
- package/src/runs/shared/parallel-utils.ts +2 -0
- package/src/runs/shared/pi-args.ts +44 -1
- package/src/runs/shared/run-fanout-budget.ts +280 -0
- package/src/runs/shared/single-output.ts +4 -2
- package/src/runs/shared/task-intent.ts +19 -3
- package/src/runs/shared/worktree.ts +17 -5
- package/src/shared/types.ts +92 -0
- package/src/shared/utils.ts +3 -1
- package/src/tui/fleet-status.ts +7 -5
- package/src/tui/fleet.ts +225 -12
- package/src/workflows/scripted-workflow.ts +22 -5
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { DEFAULT_FILE_SYSTEM_RETRY_DELAYS_MS, waitForFileSystemRetry } from "../../shared/file-system-retry.ts";
|
|
5
|
+
import {
|
|
6
|
+
TEMP_ROOT_DIR,
|
|
7
|
+
type RunFanoutBudgetDescriptor,
|
|
8
|
+
type RunFanoutBudgetSnapshot,
|
|
9
|
+
type RunFanoutRejection,
|
|
10
|
+
} from "../../shared/types.ts";
|
|
11
|
+
|
|
12
|
+
export const RUN_FANOUT_BUDGET_ENV = "PI_SUBAGENT_RUN_FANOUT_BUDGET";
|
|
13
|
+
const RUN_FANOUT_ROOT = path.join(TEMP_ROOT_DIR, "run-fanout-budgets");
|
|
14
|
+
|
|
15
|
+
interface ManifestV1 {
|
|
16
|
+
version: 1;
|
|
17
|
+
rootRunId: string;
|
|
18
|
+
limit: number;
|
|
19
|
+
createdAt: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface ClaimV1 {
|
|
23
|
+
version: 1;
|
|
24
|
+
claimId: string;
|
|
25
|
+
path: string;
|
|
26
|
+
claimedAt: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class RunFanoutLimitError extends Error {
|
|
30
|
+
readonly rejection: RunFanoutRejection;
|
|
31
|
+
readonly snapshot: RunFanoutBudgetSnapshot;
|
|
32
|
+
|
|
33
|
+
constructor(rejection: RunFanoutRejection) {
|
|
34
|
+
super(formatRunFanoutRejection(rejection));
|
|
35
|
+
this.name = "RunFanoutLimitError";
|
|
36
|
+
this.rejection = rejection;
|
|
37
|
+
this.snapshot = { used: rejection.used, limit: rejection.limit, remaining: rejection.remaining };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function safeRootRunId(rootRunId: string): string {
|
|
42
|
+
return rootRunId.replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 120) || randomUUID();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function parseManifest(value: unknown): ManifestV1 | undefined {
|
|
46
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
47
|
+
const manifest = value as Partial<ManifestV1>;
|
|
48
|
+
if (manifest.version !== 1 || typeof manifest.rootRunId !== "string" || !manifest.rootRunId
|
|
49
|
+
|| !Number.isInteger(manifest.limit) || (manifest.limit ?? 0) <= 0
|
|
50
|
+
|| typeof manifest.createdAt !== "number" || !Number.isFinite(manifest.createdAt)) return undefined;
|
|
51
|
+
return manifest as ManifestV1;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function readManifest(directory: string): ManifestV1 {
|
|
55
|
+
let parsed: unknown;
|
|
56
|
+
try {
|
|
57
|
+
parsed = JSON.parse(fs.readFileSync(path.join(directory, "manifest.json"), "utf-8"));
|
|
58
|
+
} catch (error) {
|
|
59
|
+
throw new Error(`Run fan-out budget manifest is unreadable at '${directory}': ${error instanceof Error ? error.message : String(error)}`);
|
|
60
|
+
}
|
|
61
|
+
const manifest = parseManifest(parsed);
|
|
62
|
+
if (!manifest) throw new Error(`Run fan-out budget manifest is invalid at '${directory}'.`);
|
|
63
|
+
return manifest;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function validateDirectory(directory: string): string {
|
|
67
|
+
let realDirectory: string;
|
|
68
|
+
let realRoot: string;
|
|
69
|
+
try {
|
|
70
|
+
realDirectory = fs.realpathSync(path.resolve(directory));
|
|
71
|
+
realRoot = fs.realpathSync(path.resolve(RUN_FANOUT_ROOT));
|
|
72
|
+
} catch (error) {
|
|
73
|
+
throw new Error(`Run fan-out budget directory is unavailable: ${error instanceof Error ? error.message : String(error)}`);
|
|
74
|
+
}
|
|
75
|
+
if (realDirectory !== realRoot && !realDirectory.startsWith(`${realRoot}${path.sep}`)) {
|
|
76
|
+
throw new Error("Run fan-out budget directory resolves outside the managed budget root.");
|
|
77
|
+
}
|
|
78
|
+
return realDirectory;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function createRunFanoutBudget(rootRunId: string, limit: number): RunFanoutBudgetDescriptor {
|
|
82
|
+
if (!Number.isInteger(limit) || limit <= 0) throw new Error("Run fan-out limit must be a positive integer.");
|
|
83
|
+
fs.mkdirSync(RUN_FANOUT_ROOT, { recursive: true, mode: 0o700 });
|
|
84
|
+
let directory: string;
|
|
85
|
+
do directory = path.join(RUN_FANOUT_ROOT, `${safeRootRunId(rootRunId)}-${randomUUID()}`);
|
|
86
|
+
while (fs.existsSync(directory));
|
|
87
|
+
fs.mkdirSync(path.join(directory, "claims"), { recursive: true, mode: 0o700 });
|
|
88
|
+
const manifest: ManifestV1 = { version: 1, rootRunId, limit, createdAt: Date.now() };
|
|
89
|
+
fs.writeFileSync(path.join(directory, "manifest.json"), `${JSON.stringify(manifest)}\n`, { mode: 0o600, flag: "wx" });
|
|
90
|
+
return { version: 1, rootRunId, directory, limit };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function validateRunFanoutBudgetDescriptor(value: unknown): RunFanoutBudgetDescriptor {
|
|
94
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Run fan-out budget descriptor is missing or invalid.");
|
|
95
|
+
const descriptor = value as Partial<RunFanoutBudgetDescriptor>;
|
|
96
|
+
if (descriptor.version !== 1 || typeof descriptor.rootRunId !== "string" || !descriptor.rootRunId
|
|
97
|
+
|| typeof descriptor.directory !== "string" || !descriptor.directory
|
|
98
|
+
|| !Number.isInteger(descriptor.limit) || (descriptor.limit ?? 0) <= 0
|
|
99
|
+
|| (descriptor.parentPath !== undefined && typeof descriptor.parentPath !== "string")) {
|
|
100
|
+
throw new Error("Run fan-out budget descriptor is invalid.");
|
|
101
|
+
}
|
|
102
|
+
const directory = validateDirectory(descriptor.directory);
|
|
103
|
+
const manifest = readManifest(directory);
|
|
104
|
+
if (manifest.rootRunId !== descriptor.rootRunId || manifest.limit !== descriptor.limit) {
|
|
105
|
+
throw new Error("Run fan-out budget descriptor does not match its manifest.");
|
|
106
|
+
}
|
|
107
|
+
return { version: 1, rootRunId: descriptor.rootRunId, directory, limit: descriptor.limit, ...(descriptor.parentPath ? { parentPath: descriptor.parentPath } : {}) };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function writeRunFanoutBudgetDescriptor(asyncDir: string, descriptor: RunFanoutBudgetDescriptor): void {
|
|
111
|
+
const valid = validateRunFanoutBudgetDescriptor(descriptor);
|
|
112
|
+
fs.mkdirSync(asyncDir, { recursive: true });
|
|
113
|
+
fs.writeFileSync(path.join(asyncDir, "run-fanout-budget.json"), `${JSON.stringify(valid)}\n`, { mode: 0o600 });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function readRunFanoutBudgetDescriptor(asyncDir: string | undefined): RunFanoutBudgetDescriptor | undefined {
|
|
117
|
+
if (!asyncDir) return undefined;
|
|
118
|
+
const descriptorPath = path.join(asyncDir, "run-fanout-budget.json");
|
|
119
|
+
if (!fs.existsSync(descriptorPath)) return undefined;
|
|
120
|
+
try {
|
|
121
|
+
return validateRunFanoutBudgetDescriptor(JSON.parse(fs.readFileSync(descriptorPath, "utf-8")));
|
|
122
|
+
} catch (error) {
|
|
123
|
+
throw new Error(`Invalid persisted run fan-out budget '${descriptorPath}': ${error instanceof Error ? error.message : String(error)}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function encodeRunFanoutBudgetDescriptor(descriptor: RunFanoutBudgetDescriptor): string {
|
|
128
|
+
return Buffer.from(JSON.stringify(validateRunFanoutBudgetDescriptor(descriptor)), "utf-8").toString("base64url");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function decodeRunFanoutBudgetDescriptor(encoded: string | undefined): RunFanoutBudgetDescriptor | undefined {
|
|
132
|
+
if (!encoded) return undefined;
|
|
133
|
+
try {
|
|
134
|
+
return validateRunFanoutBudgetDescriptor(JSON.parse(Buffer.from(encoded, "base64url").toString("utf-8")));
|
|
135
|
+
} catch (error) {
|
|
136
|
+
throw new Error(`Invalid inherited run fan-out budget: ${error instanceof Error ? error.message : String(error)}`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
interface AdmissionLockOwner {
|
|
141
|
+
pid: number;
|
|
142
|
+
token: string;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const ADMISSION_LOCK_STALE_MS = 60_000;
|
|
146
|
+
|
|
147
|
+
function readAdmissionLockOwner(lockPath: string): AdmissionLockOwner | undefined {
|
|
148
|
+
try {
|
|
149
|
+
const owner = JSON.parse(fs.readFileSync(path.join(lockPath, "owner.json"), "utf-8")) as Partial<AdmissionLockOwner>;
|
|
150
|
+
if (Number.isSafeInteger(owner.pid) && (owner.pid ?? 0) > 0 && typeof owner.token === "string" && owner.token) return owner as AdmissionLockOwner;
|
|
151
|
+
} catch {}
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function admissionLockIsStale(lockPath: string): boolean {
|
|
156
|
+
const owner = readAdmissionLockOwner(lockPath);
|
|
157
|
+
if (owner) {
|
|
158
|
+
try {
|
|
159
|
+
process.kill(owner.pid, 0);
|
|
160
|
+
return false;
|
|
161
|
+
} catch (error) {
|
|
162
|
+
return (error as NodeJS.ErrnoException).code !== "EPERM";
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
try { return Date.now() - fs.statSync(lockPath).mtimeMs > ADMISSION_LOCK_STALE_MS; }
|
|
166
|
+
catch (error) {
|
|
167
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
|
|
168
|
+
throw error;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function withAdmissionLock<T>(directory: string, operation: () => T): T {
|
|
173
|
+
const lockPath = path.join(directory, "admission.lock");
|
|
174
|
+
const owner: AdmissionLockOwner = { pid: process.pid, token: randomUUID() };
|
|
175
|
+
for (let attempt = 0; ; attempt++) {
|
|
176
|
+
try {
|
|
177
|
+
fs.mkdirSync(lockPath, { mode: 0o700 });
|
|
178
|
+
try { fs.writeFileSync(path.join(lockPath, "owner.json"), JSON.stringify(owner), { mode: 0o600 }); }
|
|
179
|
+
catch (error) {
|
|
180
|
+
fs.rmSync(lockPath, { recursive: true, force: true });
|
|
181
|
+
throw error;
|
|
182
|
+
}
|
|
183
|
+
break;
|
|
184
|
+
} catch (error) {
|
|
185
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
186
|
+
if (admissionLockIsStale(lockPath)) {
|
|
187
|
+
const stalePath = path.join(directory, `admission.stale-${randomUUID()}`);
|
|
188
|
+
try {
|
|
189
|
+
fs.renameSync(lockPath, stalePath);
|
|
190
|
+
fs.rmSync(stalePath, { recursive: true, force: true });
|
|
191
|
+
continue;
|
|
192
|
+
} catch (reclaimError) {
|
|
193
|
+
if ((reclaimError as NodeJS.ErrnoException).code !== "ENOENT") throw reclaimError;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
const delay = DEFAULT_FILE_SYSTEM_RETRY_DELAYS_MS[attempt];
|
|
197
|
+
if (delay === undefined) throw new Error(`Timed out acquiring run fan-out admission lock at '${directory}'.`);
|
|
198
|
+
waitForFileSystemRetry(delay);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
try { return operation(); }
|
|
202
|
+
finally {
|
|
203
|
+
if (readAdmissionLockOwner(lockPath)?.token === owner.token) fs.rmSync(lockPath, { recursive: true, force: true });
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function claimCount(directory: string): number {
|
|
208
|
+
const claimsDir = path.join(directory, "claims");
|
|
209
|
+
let entries: fs.Dirent[];
|
|
210
|
+
try { entries = fs.readdirSync(claimsDir, { withFileTypes: true }); }
|
|
211
|
+
catch (error) {
|
|
212
|
+
throw new Error(`Run fan-out claims directory is unreadable at '${claimsDir}': ${error instanceof Error ? error.message : String(error)}`);
|
|
213
|
+
}
|
|
214
|
+
return entries.filter((entry) => /^\d{6}\.json$/.test(entry.name)).length;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function getRunFanoutBudgetSnapshot(descriptor: RunFanoutBudgetDescriptor): RunFanoutBudgetSnapshot {
|
|
218
|
+
const valid = validateRunFanoutBudgetDescriptor(descriptor);
|
|
219
|
+
const used = claimCount(valid.directory);
|
|
220
|
+
return { used, limit: valid.limit, remaining: Math.max(0, valid.limit - used) };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function qualifyRunFanoutPaths(descriptor: RunFanoutBudgetDescriptor, paths: string[]): string[] {
|
|
224
|
+
const prefix = descriptor.parentPath?.trim();
|
|
225
|
+
return paths.map((item) => prefix ? `${prefix}/${item}` : item);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function commitRunFanoutBatch<T>(descriptor: RunFanoutBudgetDescriptor, paths: string[], commit: (snapshot: RunFanoutBudgetSnapshot) => T): T {
|
|
229
|
+
const valid = validateRunFanoutBudgetDescriptor(descriptor);
|
|
230
|
+
if (paths.length === 0) return commit(getRunFanoutBudgetSnapshot(valid));
|
|
231
|
+
const qualified = qualifyRunFanoutPaths(valid, paths);
|
|
232
|
+
return withAdmissionLock(valid.directory, () => {
|
|
233
|
+
const before = getRunFanoutBudgetSnapshot(valid);
|
|
234
|
+
if (qualified.length > before.remaining) {
|
|
235
|
+
throw new RunFanoutLimitError({ code: "RUN_FANOUT_LIMIT", path: qualified[before.remaining] ?? qualified[0]!, requested: qualified.length, ...before });
|
|
236
|
+
}
|
|
237
|
+
const created: string[] = [];
|
|
238
|
+
try {
|
|
239
|
+
for (const claimPath of qualified) {
|
|
240
|
+
for (let slot = 0; slot < valid.limit; slot++) {
|
|
241
|
+
const slotPath = path.join(valid.directory, "claims", `${String(slot).padStart(6, "0")}.json`);
|
|
242
|
+
try {
|
|
243
|
+
const fd = fs.openSync(slotPath, "wx", 0o600);
|
|
244
|
+
created.push(slotPath);
|
|
245
|
+
try {
|
|
246
|
+
const claim: ClaimV1 = { version: 1, claimId: randomUUID(), path: claimPath, claimedAt: Date.now() };
|
|
247
|
+
fs.writeFileSync(fd, `${JSON.stringify(claim)}\n`, "utf-8");
|
|
248
|
+
} finally { fs.closeSync(fd); }
|
|
249
|
+
break;
|
|
250
|
+
} catch (error) {
|
|
251
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST") continue;
|
|
252
|
+
throw error;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return commit(getRunFanoutBudgetSnapshot(valid));
|
|
257
|
+
} catch (error) {
|
|
258
|
+
for (const slotPath of created) {
|
|
259
|
+
try { fs.unlinkSync(slotPath); } catch {}
|
|
260
|
+
}
|
|
261
|
+
throw error;
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function claimRunFanoutBatch(descriptor: RunFanoutBudgetDescriptor, paths: string[]): RunFanoutBudgetSnapshot {
|
|
267
|
+
return commitRunFanoutBatch(descriptor, paths, (snapshot) => snapshot);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function claimRunFanoutBatchWithCommit<T>(descriptor: RunFanoutBudgetDescriptor, paths: string[], commit: () => T): T {
|
|
271
|
+
return commitRunFanoutBatch(descriptor, paths, commit);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export function formatRunFanoutBudget(snapshot: RunFanoutBudgetSnapshot): string {
|
|
275
|
+
return `Run fan-out: ${snapshot.used}/${snapshot.limit} used, ${snapshot.remaining} remaining`;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function formatRunFanoutRejection(rejection: RunFanoutRejection): string {
|
|
279
|
+
return `Run fan-out limit reached at ${rejection.path} (${rejection.used}/${rejection.limit} used; ${rejection.requested} requested, ${rejection.remaining} remaining). No children from this admission group were started. Start a new top-level run or raise config.maxSubagentSpawnsPerRun.`;
|
|
280
|
+
}
|
|
@@ -214,17 +214,19 @@ export function finalizeSingleOutput(params: {
|
|
|
214
214
|
outputPath?: string;
|
|
215
215
|
outputMode?: OutputMode;
|
|
216
216
|
exitCode: number;
|
|
217
|
+
/** Keep the saved-output reference when a post-run acceptance gate rejects an otherwise completed child. */
|
|
218
|
+
preserveSavedOutput?: boolean;
|
|
217
219
|
savedPath?: string;
|
|
218
220
|
outputReference?: SavedOutputReference;
|
|
219
221
|
saveError?: string;
|
|
220
222
|
}): { displayOutput: string; savedPath?: string; outputReference?: SavedOutputReference; saveError?: string } {
|
|
221
223
|
let displayOutput = params.truncatedOutput || params.fullOutput;
|
|
222
|
-
if (params.exitCode === 0 && params.savedPath) {
|
|
224
|
+
if ((params.exitCode === 0 || params.preserveSavedOutput) && params.savedPath) {
|
|
223
225
|
const outputReference = params.outputReference ?? formatSavedOutputReference(params.savedPath, params.fullOutput);
|
|
224
226
|
if (params.outputMode === "file-only") {
|
|
225
227
|
return { displayOutput: outputReference.message, savedPath: params.savedPath, outputReference };
|
|
226
228
|
}
|
|
227
|
-
displayOutput += `\n\n${outputReference.message}`;
|
|
229
|
+
if (!displayOutput.includes(outputReference.message)) displayOutput += `\n\n${outputReference.message}`;
|
|
228
230
|
return { displayOutput, savedPath: params.savedPath, outputReference };
|
|
229
231
|
}
|
|
230
232
|
if (params.exitCode === 0 && params.saveError && params.outputPath) {
|
|
@@ -70,6 +70,18 @@ const RESEARCH_AGENT_PATTERNS = [
|
|
|
70
70
|
/\bresearch(?:er)?\b/i,
|
|
71
71
|
];
|
|
72
72
|
|
|
73
|
+
// Review/severity vocabulary like "must-fix items" or "should-fix tests" is not
|
|
74
|
+
// the verb. Rather than dash-guarding every pattern (which would also suppress
|
|
75
|
+
// CLI flags like "--fix" and genuine clause-level dashes like "branch—fix it"),
|
|
76
|
+
// strip the known severity compounds (must|should|needs + dash + verb) from the
|
|
77
|
+
// task text before matching. Dash coverage: ASCII hyphen + U+2010..U+2015.
|
|
78
|
+
const SEVERITY_COMPOUND_PATTERN = /\b(?:must|should|needs)[\-\u2010-\u2015](?:fix|edit|update|add|remove|replace|create|apply|make|do|implement|modify|delete|patch)\b/gi;
|
|
79
|
+
|
|
80
|
+
function stripSeverityCompounds(task: string): string {
|
|
81
|
+
return task.replace(SEVERITY_COMPOUND_PATTERN, " ");
|
|
82
|
+
}
|
|
83
|
+
export { stripSeverityCompounds };
|
|
84
|
+
|
|
73
85
|
const FIX_OR_PATCH_IMPLEMENTATION_PATTERN = /\b(?:fix|patch)\s+(?:(?:it|this|that|them|each|any|all|these|those)\b|(?:(?:a|an|the|any|all)\s+)?(?:(?:failing|failed|broken|flaky|red|cold|start|current|existing|reported|approved|known|regression|unit|integration|e2e|source|typescript|type-?script|ts|type-?check|compiler)\s+)*(?:bug|defect|issues?|problems?|failures?|regressions?|tests?|errors?|items?|typos?|code|source|implementation|component|function|module|class|method|logic|file|files|readme|docs?|changelog|package\.json|config|manifest|extension|prompt|command|lint(?:ing)?|build|ci|type-?check|type\s+checking)\b)/i;
|
|
74
86
|
|
|
75
87
|
const WORKER_IMPLEMENTATION_PATTERNS = [
|
|
@@ -146,7 +158,7 @@ function hasImplementationIntent(agent: string, taskText: string): boolean {
|
|
|
146
158
|
}
|
|
147
159
|
|
|
148
160
|
export function classifyTaskMutationIntent(agent: string, task: string): TaskMutationIntent {
|
|
149
|
-
const taskText = stripFrameworkInstructions(task);
|
|
161
|
+
const taskText = stripSeverityCompounds(stripFrameworkInstructions(task));
|
|
150
162
|
const taskTextWithoutScopedConstraints = stripPatterns(taskText, SCOPED_NO_EDIT_CONSTRAINT_PATTERNS);
|
|
151
163
|
const prohibitions = analyzeNoEditProhibitions(taskTextWithoutScopedConstraints);
|
|
152
164
|
if (prohibitions.present) {
|
|
@@ -164,7 +176,11 @@ export function expectsImplementationMutation(agent: string, task: string): bool
|
|
|
164
176
|
return classifyTaskMutationIntent(agent, task).kind === "implementation";
|
|
165
177
|
}
|
|
166
178
|
|
|
167
|
-
/** Bare write verbs that make a task write-capable for acceptance inference.
|
|
179
|
+
/** Bare write verbs that make a task write-capable for acceptance inference.
|
|
180
|
+
* Severity compounds ("must-fix items") are stripped before matching, so they
|
|
181
|
+
* are not write imperatives, while CLI flags ("--fix", "-w"), clause-level
|
|
182
|
+
* dashes ("branch—fix it"), and hyphenated imperatives ("hot-fix the bug")
|
|
183
|
+
* all stay write-capable. */
|
|
168
184
|
const MAY_MUTATE_VERB_PATTERN = /\b(?:fix|implement|update|write|edit|modify|migrate|delete|remove|refactor|commit)\b/i;
|
|
169
185
|
|
|
170
186
|
/**
|
|
@@ -174,7 +190,7 @@ const MAY_MUTATE_VERB_PATTERN = /\b(?:fix|implement|update|write|edit|modify|mig
|
|
|
174
190
|
* does.
|
|
175
191
|
*/
|
|
176
192
|
export function taskMayMutate(task: string): boolean {
|
|
177
|
-
const taskText = stripPatterns(stripFrameworkInstructions(task), SCOPED_NO_EDIT_CONSTRAINT_PATTERNS);
|
|
193
|
+
const taskText = stripPatterns(stripSeverityCompounds(stripFrameworkInstructions(task)), SCOPED_NO_EDIT_CONSTRAINT_PATTERNS);
|
|
178
194
|
const prohibitions = analyzeNoEditProhibitions(taskText);
|
|
179
195
|
if (prohibitions.blanket) return false;
|
|
180
196
|
return MAY_MUTATE_VERB_PATTERN.test(stripPatterns(prohibitions.strippedText, READ_ONLY_DELIVERABLE_PATTERNS));
|
|
@@ -4,6 +4,7 @@ import * as os from "node:os";
|
|
|
4
4
|
import * as path from "node:path";
|
|
5
5
|
import { resolveAuthorityDecision, type AuthorityPolicyConfig } from "../../policy/authority.ts";
|
|
6
6
|
import { PROJECT_SUBAGENTS_RELATIVE_DIR } from "../../shared/artifacts.ts";
|
|
7
|
+
import { getAgentDir } from "../../shared/utils.ts";
|
|
7
8
|
|
|
8
9
|
export interface WorktreeSetup {
|
|
9
10
|
cwd: string;
|
|
@@ -149,11 +150,17 @@ function resolveRepoState(cwd: string): RepoState {
|
|
|
149
150
|
|
|
150
151
|
function normalizeComparableCwd(cwd: string): string {
|
|
151
152
|
const resolved = path.resolve(cwd);
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
153
|
+
let existing = resolved;
|
|
154
|
+
const missingSegments: string[] = [];
|
|
155
|
+
while (true) {
|
|
156
|
+
try {
|
|
157
|
+
return path.join(fs.realpathSync(existing), ...missingSegments.reverse());
|
|
158
|
+
} catch {
|
|
159
|
+
const parent = path.dirname(existing);
|
|
160
|
+
if (parent === existing) return resolved;
|
|
161
|
+
missingSegments.push(path.basename(existing));
|
|
162
|
+
existing = parent;
|
|
163
|
+
}
|
|
157
164
|
}
|
|
158
165
|
}
|
|
159
166
|
|
|
@@ -196,6 +203,11 @@ function resolveWorktreeBaseDir(configuredBaseDir: string | undefined, repoRoot:
|
|
|
196
203
|
|
|
197
204
|
const expanded = trimmed.startsWith("~/") ? path.join(os.homedir(), trimmed.slice(2)) : trimmed;
|
|
198
205
|
const resolved = path.isAbsolute(expanded) ? expanded : path.resolve(repoRoot, expanded);
|
|
206
|
+
const extensionsDir = normalizeComparableCwd(path.join(getAgentDir(), "extensions"));
|
|
207
|
+
const relativeToExtensions = path.relative(extensionsDir, normalizeComparableCwd(resolved));
|
|
208
|
+
if (!relativeToExtensions || (!relativeToExtensions.startsWith(`..${path.sep}`) && relativeToExtensions !== ".." && !path.isAbsolute(relativeToExtensions))) {
|
|
209
|
+
throw new Error(`worktree base directory cannot be inside Pi extensions directory: ${extensionsDir}. Choose a directory outside it.`);
|
|
210
|
+
}
|
|
199
211
|
try {
|
|
200
212
|
fs.mkdirSync(resolved, { recursive: true });
|
|
201
213
|
} catch (error) {
|
package/src/shared/types.ts
CHANGED
|
@@ -328,6 +328,7 @@ export interface FileMutationEffect {
|
|
|
328
328
|
expected: boolean;
|
|
329
329
|
attempted: boolean;
|
|
330
330
|
message?: string;
|
|
331
|
+
resolvedBy?: "llm-intent-arbiter";
|
|
331
332
|
}
|
|
332
333
|
|
|
333
334
|
export interface EffectsProjection {
|
|
@@ -343,6 +344,7 @@ export type ProcessTerminalReason =
|
|
|
343
344
|
| "runner-candidate-missing"
|
|
344
345
|
| "runner-instance-mismatch"
|
|
345
346
|
| "writer-close-unverified"
|
|
347
|
+
| "process-tree-unverified"
|
|
346
348
|
| "canonical-session-unavailable"
|
|
347
349
|
| "canonical-session-lease-active"
|
|
348
350
|
| "canonical-session-release-unverified"
|
|
@@ -357,6 +359,19 @@ export interface RunnerProcessInstanceExitV1 {
|
|
|
357
359
|
signal: string | null;
|
|
358
360
|
}
|
|
359
361
|
|
|
362
|
+
export type ProcessTreeTerminalV1 =
|
|
363
|
+
| {
|
|
364
|
+
state: "observed";
|
|
365
|
+
mechanism: "posix-process-group";
|
|
366
|
+
processGroupId: number;
|
|
367
|
+
verifiedAt: number;
|
|
368
|
+
}
|
|
369
|
+
| {
|
|
370
|
+
state: "unknown";
|
|
371
|
+
reason: "unsupported-platform" | "signal-failed" | "verification-failed";
|
|
372
|
+
diagnostic?: string;
|
|
373
|
+
};
|
|
374
|
+
|
|
360
375
|
export interface PiWriterProcessInstanceExitV1 {
|
|
361
376
|
processInstanceId: string;
|
|
362
377
|
kind: "pi-writer";
|
|
@@ -364,6 +379,7 @@ export interface PiWriterProcessInstanceExitV1 {
|
|
|
364
379
|
closeObservedAt: number;
|
|
365
380
|
exitCode: number | null;
|
|
366
381
|
signal: string | null;
|
|
382
|
+
processTree: ProcessTreeTerminalV1;
|
|
367
383
|
}
|
|
368
384
|
|
|
369
385
|
export type ProcessInstanceExitV1 = RunnerProcessInstanceExitV1 | PiWriterProcessInstanceExitV1;
|
|
@@ -462,9 +478,30 @@ export interface SteeringNotice {
|
|
|
462
478
|
currentSessionId?: string;
|
|
463
479
|
}
|
|
464
480
|
|
|
481
|
+
export interface RunFanoutBudgetDescriptor {
|
|
482
|
+
version: 1;
|
|
483
|
+
rootRunId: string;
|
|
484
|
+
directory: string;
|
|
485
|
+
limit: number;
|
|
486
|
+
parentPath?: string;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
export interface RunFanoutBudgetSnapshot {
|
|
490
|
+
used: number;
|
|
491
|
+
limit: number;
|
|
492
|
+
remaining: number;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
export interface RunFanoutRejection extends RunFanoutBudgetSnapshot {
|
|
496
|
+
code: "RUN_FANOUT_LIMIT";
|
|
497
|
+
path: string;
|
|
498
|
+
requested: number;
|
|
499
|
+
}
|
|
500
|
+
|
|
465
501
|
export interface SteeringRecoveryDescriptor {
|
|
466
502
|
version: 1;
|
|
467
503
|
launchContractDigest?: string;
|
|
504
|
+
runFanoutBudget: RunFanoutBudgetDescriptor;
|
|
468
505
|
sourceRunId: string;
|
|
469
506
|
agentContract?: AgentContract;
|
|
470
507
|
agent: string;
|
|
@@ -1024,6 +1061,9 @@ export interface Details {
|
|
|
1024
1061
|
// Aggregated cost across all agents in the run
|
|
1025
1062
|
totalCost?: CostSummary;
|
|
1026
1063
|
spawnBudget?: SpawnBudgetSnapshot;
|
|
1064
|
+
runFanoutBudget?: RunFanoutBudgetSnapshot;
|
|
1065
|
+
runFanoutRejection?: RunFanoutRejection;
|
|
1066
|
+
activeAsyncCapacity?: ActiveAsyncCapacitySnapshot;
|
|
1027
1067
|
capabilityCeiling?: ResolvedSubagentCapabilityCeiling;
|
|
1028
1068
|
capabilityAudit?: SubagentCapabilityAudit;
|
|
1029
1069
|
parallelHandoff?: ParallelHandoffReference;
|
|
@@ -1323,6 +1363,8 @@ export interface AsyncStatus {
|
|
|
1323
1363
|
workflowGraph?: WorkflowGraphSnapshot;
|
|
1324
1364
|
checkpoint?: ChainCheckpointState;
|
|
1325
1365
|
processTerminal?: ProcessTerminalV1;
|
|
1366
|
+
runFanoutBudget?: RunFanoutBudgetSnapshot;
|
|
1367
|
+
runFanoutBudgetDescriptor?: RunFanoutBudgetDescriptor;
|
|
1326
1368
|
launchContractDigest?: string;
|
|
1327
1369
|
launchResolvedExtensions?: LaunchResolvedChildExtensionsV1;
|
|
1328
1370
|
runtimeAcknowledgedExtensions?: RuntimeAcknowledgedChildExtensionsV1;
|
|
@@ -1342,6 +1384,10 @@ export interface AsyncStatus {
|
|
|
1342
1384
|
phase?: string;
|
|
1343
1385
|
label?: string;
|
|
1344
1386
|
workflowKey?: string;
|
|
1387
|
+
/** Child run identity for workflow capacity reconciliation. */
|
|
1388
|
+
runId?: string;
|
|
1389
|
+
/** True only when this workflow child owns a detached async runner. */
|
|
1390
|
+
async?: boolean;
|
|
1345
1391
|
parentWorkflowRunId?: string;
|
|
1346
1392
|
outputName?: string;
|
|
1347
1393
|
structured?: boolean;
|
|
@@ -1574,6 +1620,12 @@ export interface ForegroundRunControl {
|
|
|
1574
1620
|
toolCount?: number;
|
|
1575
1621
|
/** Independently tracked children for foreground parallel work and fleet inspection. */
|
|
1576
1622
|
activeChildren?: Map<number, ForegroundChildControl>;
|
|
1623
|
+
/** Live Prompt Audit redo callback. It is current-session memory only. */
|
|
1624
|
+
promptAuditRedo?: (index: number, guidance: string) => Promise<{ text: string; isError?: boolean }>;
|
|
1625
|
+
/** Memory-only source run for Prompt Audit redo. */
|
|
1626
|
+
sourceRunId?: string;
|
|
1627
|
+
/** Memory-only replacement run started by Prompt Audit redo. */
|
|
1628
|
+
supersededByRunId?: string;
|
|
1577
1629
|
/** Scheduling owners that may still launch another child. Removal is safe only at zero. */
|
|
1578
1630
|
schedulingOwners?: number;
|
|
1579
1631
|
nestedRoute?: NestedRouteInfo;
|
|
@@ -1593,6 +1645,12 @@ export interface WaitSubscriptionRecord {
|
|
|
1593
1645
|
expiresAt: number;
|
|
1594
1646
|
}
|
|
1595
1647
|
|
|
1648
|
+
export interface ActiveAsyncCapacitySnapshot {
|
|
1649
|
+
used: number;
|
|
1650
|
+
/** Zero means the opt-in cap is disabled. */
|
|
1651
|
+
limit: number;
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1596
1654
|
export interface SubagentState {
|
|
1597
1655
|
baseCwd: string;
|
|
1598
1656
|
currentSessionId: string | null;
|
|
@@ -1613,6 +1671,8 @@ export interface SubagentState {
|
|
|
1613
1671
|
granted?: number;
|
|
1614
1672
|
grantHistory?: SpawnBudgetGrant[];
|
|
1615
1673
|
};
|
|
1674
|
+
/** Current-session top-level async capacity projection. */
|
|
1675
|
+
activeAsyncCapacity?: ActiveAsyncCapacitySnapshot;
|
|
1616
1676
|
asyncJobs: Map<string, AsyncJobState>;
|
|
1617
1677
|
/** Current-session active and recent async runs for the native fleet inspector. */
|
|
1618
1678
|
fleetJobs?: Map<string, AsyncJobState>;
|
|
@@ -1730,9 +1790,12 @@ export interface RunSyncOptions {
|
|
|
1730
1790
|
/** Effective parent wait-tool setting propagated to the child runtime. */
|
|
1731
1791
|
waitToolEnabled?: boolean;
|
|
1732
1792
|
capabilityCeiling?: ResolvedSubagentCapabilityCeiling;
|
|
1793
|
+
runFanoutBudget?: RunFanoutBudgetDescriptor;
|
|
1733
1794
|
nestedRoute?: NestedRouteInfo;
|
|
1734
1795
|
/** Override the agent's default model (format: "provider/id" or just "id") */
|
|
1735
1796
|
modelOverride?: string;
|
|
1797
|
+
/** LLM intent arbiter for the completion mutation guard (rescues read-only review runs). */
|
|
1798
|
+
llmIntentArbiter?: import("../runs/shared/llm-intent-arbiter.ts").TaskMutationArbiter;
|
|
1736
1799
|
/** Override the agent's default thinking level for this run */
|
|
1737
1800
|
thinkingOverride?: AgentConfig["thinking"];
|
|
1738
1801
|
/** Registry models available for heuristic bare-model resolution */
|
|
@@ -1756,6 +1819,8 @@ export interface RunSyncOptions {
|
|
|
1756
1819
|
dynamic?: boolean;
|
|
1757
1820
|
dynamicGroup?: boolean;
|
|
1758
1821
|
};
|
|
1822
|
+
/** Private live callback for the exact child prompt after runtime acceptance injection. */
|
|
1823
|
+
onEffectivePrompt?: (prompt: string) => void;
|
|
1759
1824
|
}
|
|
1760
1825
|
|
|
1761
1826
|
export type IntercomBridgeMode = "off" | "fork-only" | "always";
|
|
@@ -1840,8 +1905,22 @@ export interface ExtensionConfig {
|
|
|
1840
1905
|
maxSubagentDepth?: number;
|
|
1841
1906
|
/** Optional cumulative session cap. Unset or 0 means unlimited. */
|
|
1842
1907
|
maxSubagentSpawnsPerSession?: number;
|
|
1908
|
+
/** Cumulative logical-child cap for one top-level run tree. Defaults to 64. */
|
|
1909
|
+
maxSubagentSpawnsPerRun?: number;
|
|
1910
|
+
/** Optional active top-level async run cap per parent session. Unset or 0 means unlimited. */
|
|
1911
|
+
maxActiveAsyncRunsPerSession?: number;
|
|
1843
1912
|
/** Global cap on simultaneously-running subagent tasks within a single run. Defaults to 20. */
|
|
1844
1913
|
globalConcurrencyLimit?: number;
|
|
1914
|
+
/**
|
|
1915
|
+
* Global default runtime deadline in milliseconds. It replaces the built-in
|
|
1916
|
+
* 30-minute backstop for single, parallel, and chain launches (foreground, plus
|
|
1917
|
+
* plain single-agent async runs) when neither the call (`timeoutMs`/`maxRuntimeMs`)
|
|
1918
|
+
* nor the selected agent provides a timeout. Explicit call values and agent
|
|
1919
|
+
* frontmatter defaults still win. Composite async runs (chain/parallel/workflow)
|
|
1920
|
+
* stay unbounded at the top level by design — their children are bounded individually.
|
|
1921
|
+
* Must be a positive integer; invalid values are ignored.
|
|
1922
|
+
*/
|
|
1923
|
+
timeoutMs?: number;
|
|
1845
1924
|
control?: ControlConfig;
|
|
1846
1925
|
completionBatch?: CompletionBatchConfig;
|
|
1847
1926
|
turnBudget?: TurnBudgetConfig;
|
|
@@ -2057,6 +2136,19 @@ export function resolveMaxSubagentSpawnsPerSession(configMaxSpawns?: number): nu
|
|
|
2057
2136
|
return configuredMaxSpawns === 0 ? undefined : configuredMaxSpawns;
|
|
2058
2137
|
}
|
|
2059
2138
|
|
|
2139
|
+
export const DEFAULT_MAX_SUBAGENT_SPAWNS_PER_RUN = 64;
|
|
2140
|
+
|
|
2141
|
+
export function normalizeMaxSubagentSpawnsPerRun(value: unknown): number | undefined {
|
|
2142
|
+
const normalized = normalizeNonNegativeInteger(value);
|
|
2143
|
+
return normalized !== undefined && normalized > 0 ? normalized : undefined;
|
|
2144
|
+
}
|
|
2145
|
+
|
|
2146
|
+
export function resolveMaxSubagentSpawnsPerRun(configMaxSpawns?: number): number {
|
|
2147
|
+
return normalizeMaxSubagentSpawnsPerRun(process.env.PI_SUBAGENT_MAX_SPAWNS_PER_RUN)
|
|
2148
|
+
?? normalizeMaxSubagentSpawnsPerRun(configMaxSpawns)
|
|
2149
|
+
?? DEFAULT_MAX_SUBAGENT_SPAWNS_PER_RUN;
|
|
2150
|
+
}
|
|
2151
|
+
|
|
2060
2152
|
// ============================================================================
|
|
2061
2153
|
// Utility Functions
|
|
2062
2154
|
// ============================================================================
|
package/src/shared/utils.ts
CHANGED
|
@@ -17,6 +17,7 @@ import type { AgentProgress, AsyncStatus, Details, DisplayItem, ErrorInfo, Neste
|
|
|
17
17
|
const DEFAULT_CONFIG_DIR_NAME = ".pi";
|
|
18
18
|
const PI_CODING_AGENT_PACKAGE_NAME = "@earendil-works/pi-coding-agent";
|
|
19
19
|
export const PI_CODING_AGENT_PACKAGE_ROOT_ENV = "PI_SUBAGENTS_PI_CODING_AGENT_PACKAGE_ROOT";
|
|
20
|
+
export const PROMPT_REDACTED = "[prompt redacted]";
|
|
20
21
|
|
|
21
22
|
export function resolveWatchPath(
|
|
22
23
|
watchPath: string,
|
|
@@ -354,7 +355,7 @@ function compactCompletedProgress(progress: AgentProgress): AgentProgress {
|
|
|
354
355
|
agent: progress.agent,
|
|
355
356
|
status: progress.status,
|
|
356
357
|
activityState: progress.activityState,
|
|
357
|
-
task:
|
|
358
|
+
task: "[prompt redacted]",
|
|
358
359
|
skills: progress.skills,
|
|
359
360
|
toolCount: progress.toolCount,
|
|
360
361
|
tokens: progress.tokens,
|
|
@@ -428,6 +429,7 @@ export function compactForegroundResult(result: SingleResult): SingleResult {
|
|
|
428
429
|
const toolCalls = result.toolCalls?.length ? result.toolCalls : extractToolCallSummaries(result.messages);
|
|
429
430
|
return {
|
|
430
431
|
...result,
|
|
432
|
+
task: "[prompt redacted]",
|
|
431
433
|
messages: undefined,
|
|
432
434
|
progress: undefined,
|
|
433
435
|
toolCalls: toolCalls.length ? toolCalls : undefined,
|
package/src/tui/fleet-status.ts
CHANGED
|
@@ -318,7 +318,7 @@ export class SubagentFleetStatus {
|
|
|
318
318
|
this.clearWidget();
|
|
319
319
|
return;
|
|
320
320
|
}
|
|
321
|
-
if (this.entries.length === 0) {
|
|
321
|
+
if (this.entries.length === 0 && !(this.state.activeAsyncCapacity?.used)) {
|
|
322
322
|
this.active = false;
|
|
323
323
|
this.selectedKey = "main";
|
|
324
324
|
this.lastRenderKey = "";
|
|
@@ -413,10 +413,13 @@ export class SubagentFleetStatus {
|
|
|
413
413
|
}
|
|
414
414
|
|
|
415
415
|
render(width: number, theme: Theme): string[] {
|
|
416
|
-
if (this.entries.length === 0) return [];
|
|
416
|
+
if (this.entries.length === 0 && !(this.state.activeAsyncCapacity?.used)) return [];
|
|
417
417
|
if (!this.active) {
|
|
418
418
|
const tokens = this.entries.reduce((total, entry) => total + entry.tokens, 0);
|
|
419
|
-
const
|
|
419
|
+
const capacity = this.state.activeAsyncCapacity;
|
|
420
|
+
const asyncRuns = capacity ? `Async runs ${capacity.used}/${capacity.limit || "∞"}` : "";
|
|
421
|
+
const agents = this.entries.length > 0 ? `${this.entries.length} active ${this.entries.length === 1 ? "agent" : "agents"}` : "";
|
|
422
|
+
const label = [agents, asyncRuns].filter(Boolean).join(" · ");
|
|
420
423
|
return [truncateToWidth(` ${theme.fg("muted", label)} · ${theme.fg("dim", `${formatFleetTokens(tokens)} · ↓/← to inspect`)}`, width)];
|
|
421
424
|
}
|
|
422
425
|
const roster = this.rosterKeys();
|
|
@@ -444,9 +447,8 @@ export class SubagentFleetStatus {
|
|
|
444
447
|
}
|
|
445
448
|
|
|
446
449
|
private renderEntry(rosterIndex: number, selectedIndex: number, entry: FleetStatusEntry, width: number, theme: Theme): string {
|
|
447
|
-
const description = entry.description?.replace(/\s+/g, " ").trim();
|
|
448
450
|
const agent = entry.modelThinking ? `${entry.agent} (${entry.modelThinking})` : entry.agent;
|
|
449
|
-
const left = ` ${this.bullet(rosterIndex, selectedIndex, theme)} ${theme.fg("muted", agent)} · ${entry.state}
|
|
451
|
+
const left = ` ${this.bullet(rosterIndex, selectedIndex, theme)} ${theme.fg("muted", agent)} · ${entry.state}`;
|
|
450
452
|
const elapsed = Date.now() - entry.startedAt;
|
|
451
453
|
const right = theme.fg("dim", `${formatFleetElapsed(elapsed)} · ${formatFleetTokens(entry.tokens)}`);
|
|
452
454
|
return rightAlign(left, right, width);
|