pi-jar 0.2.1 → 0.2.3
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 +17 -14
- package/docs/EFFICIENCY_PLAN.md +79 -0
- package/docs/INTEGRATIONS.md +22 -6
- package/docs/SUBAGENT-TOOLS.md +1 -1
- package/docs/WORKFLOWS.md +3 -3
- package/extensions/index.ts +84 -17
- package/package.json +1 -1
- package/src/advisor.ts +22 -10
- package/src/ask-tool.ts +2 -7
- package/src/compact-tools.ts +148 -8
- package/src/context-budget.ts +226 -0
- package/src/context-diet.ts +158 -17
- package/src/{democracy.ts → council.ts} +19 -22
- package/src/delegate.ts +99 -54
- package/src/discussion-hub.ts +9 -6
- package/src/discussion.ts +31 -22
- package/src/footer.ts +5 -1
- package/src/goal-loop.ts +11 -22
- package/src/info-panels.ts +6 -0
- package/src/model-roles.ts +26 -2
- package/src/perf.ts +21 -4
- package/src/plan.ts +12 -25
- package/src/profile-ui.ts +21 -6
- package/src/profiles.ts +9 -4
- package/src/quota.ts +71 -49
- package/src/roles-ui.ts +4 -2
- package/src/settings-ui.ts +27 -8
- package/src/settings.ts +21 -1
- package/src/shells.ts +77 -15
- package/src/side-model.ts +14 -0
- package/src/subagent-tools.ts +1 -1
- package/src/suggest.ts +4 -11
- package/src/task-tool.ts +3 -9
- package/src/tool-activation.ts +14 -0
- package/src/usage-view.ts +8 -4
- package/src/usage.ts +4 -2
- package/src/workflow-context.ts +37 -0
package/src/delegate.ts
CHANGED
|
@@ -28,7 +28,8 @@ import {
|
|
|
28
28
|
export const DELEGATE_TOOL = "jar_delegate";
|
|
29
29
|
/** Set in child processes so a subagent never delegates again. */
|
|
30
30
|
export const CHILD_ENV = "PI_JAR_CHILD";
|
|
31
|
-
|
|
31
|
+
/** Retained pool when no limit is configured; matches the settings default (the opt-in Swarm profile uses 4). */
|
|
32
|
+
export const MAX_DELEGATES = 2;
|
|
32
33
|
export const SUBAGENT_LIMIT_CHOICES = [2, 4, 6, 8, 16] as const;
|
|
33
34
|
/** Read-only subagents also keep jar_todo so their checklist shows in the activity view. */
|
|
34
35
|
export const READ_ONLY_TOOLS = ["read", "grep", "find", "ls", "jar_todo", "jar_discuss"] as const;
|
|
@@ -41,6 +42,9 @@ const allowedInMode = (mode: DelegateExecutionMode, name: string) => !RECURSIVE_
|
|
|
41
42
|
export type DelegateMode = "scout" | "fork" | "worktree";
|
|
42
43
|
type DelegateExecutionMode = DelegateMode | "direct";
|
|
43
44
|
const MAX_OUTPUT_CHARS = 12_000;
|
|
45
|
+
/** Shared budget for the reports one synchronous jar_delegate result returns; the rest stays one jar_subagent report away. */
|
|
46
|
+
const MAX_RESULT_REPORT_CHARS = 16_000;
|
|
47
|
+
const MIN_RUN_REPORT_CHARS = 2000;
|
|
44
48
|
/** A working child that sends no RPC event for this long is treated as hung and killed. */
|
|
45
49
|
const IDLE_TIMEOUT_MS = 10 * 60_000;
|
|
46
50
|
/** After exit, grandchildren (MCP servers, shells) can hold the stdio pipes open; stop waiting for close. */
|
|
@@ -55,6 +59,8 @@ const STATUS_REFRESH_MS = 20_000;
|
|
|
55
59
|
const LIVE_UPDATE_MS = 1000;
|
|
56
60
|
/** Visible message that wakes the moderator when a resumed subagent's turn ends. */
|
|
57
61
|
export const SUBAGENT_MESSAGE = "pi-jar.subagent";
|
|
62
|
+
/** An event carries at most this much of a report; the full report stays one `jar_subagent report` away. */
|
|
63
|
+
export const EVENT_SUMMARY_CHARS = 400;
|
|
58
64
|
const NOTIFY_COALESCE_MS = 250;
|
|
59
65
|
const MAX_EVENT_TEXT = 16_000;
|
|
60
66
|
/** Transcript entries kept per run; tool output and text are clipped so a run stays well under 2 MiB. */
|
|
@@ -201,7 +207,7 @@ export function delegatePrompt(task: string, write: boolean, mode: DelegateExecu
|
|
|
201
207
|
"Track multi-step work with jar_todo (when available) so your progress is visible. The moderator may steer, pause or resume you; preserve scope across those controls.",
|
|
202
208
|
"Use jar_discuss when available for short cross-agent questions and answers. Keep discussion messages narrow; it is for coordination, not a transcript.",
|
|
203
209
|
"Finish with a self-contained report in these sections:",
|
|
204
|
-
|
|
210
|
+
`## Summary — the outcome in one to three sentences, under ${EVENT_SUMMARY_CHARS} characters: the moderator's notification shows only this.`,
|
|
205
211
|
"## Details — findings or changes, with file paths (and line numbers where useful).",
|
|
206
212
|
"## Verification — what you checked or ran, and the results.",
|
|
207
213
|
"## Open issues — risks, unknowns and follow-ups, or \"none\".",
|
|
@@ -214,6 +220,22 @@ export function delegatePrompt(task: string, write: boolean, mode: DelegateExecu
|
|
|
214
220
|
const cleanBlock = (value: string, limit: number): string => value.slice(0, limit * 2)
|
|
215
221
|
.replace(ANSI, "").replace(/\r\n?/g, "\n").replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, " ").replace(/\n{3,}/g, "\n\n").trim().slice(0, limit);
|
|
216
222
|
|
|
223
|
+
/** One line of at most EVENT_SUMMARY_CHARS, marked with … when clipped. */
|
|
224
|
+
const summaryLine = (text: string): string => {
|
|
225
|
+
const line = cleanText(text, EVENT_SUMMARY_CHARS + 1);
|
|
226
|
+
return line.length > EVENT_SUMMARY_CHARS ? line.slice(0, EVENT_SUMMARY_CHARS - 1) + "…" : line;
|
|
227
|
+
};
|
|
228
|
+
const SUMMARY_HEADING = /^\s*#{1,6}\s*summary\b[\s:—–-]*(.*)$/i;
|
|
229
|
+
/** What an event shows of a report: its `## Summary` section (its opening when the child skipped the format) on one line. */
|
|
230
|
+
export function eventSummary(report: string): string {
|
|
231
|
+
const lines = report.split("\n");
|
|
232
|
+
const start = lines.findIndex((line) => SUMMARY_HEADING.test(line));
|
|
233
|
+
if (start < 0) return summaryLine(report);
|
|
234
|
+
const end = lines.findIndex((line, index) => index > start && /^\s*#{1,6}\s/.test(line));
|
|
235
|
+
const section = [SUMMARY_HEADING.exec(lines[start]!)![1]!, ...lines.slice(start + 1, end < 0 ? undefined : end)].join(" ");
|
|
236
|
+
return summaryLine(section.trim() ? section : report);
|
|
237
|
+
}
|
|
238
|
+
|
|
217
239
|
/** Whether `promise` settles within `ms`; the timer never keeps the process alive. */
|
|
218
240
|
const settlesWithin = (promise: Promise<unknown>, ms: number): Promise<boolean> => {
|
|
219
241
|
let timer: NodeJS.Timeout | undefined;
|
|
@@ -828,7 +850,7 @@ export function startDelegate(run: DelegateRun, args: string[], prompt: string,
|
|
|
828
850
|
try {
|
|
829
851
|
// Cross a settled boundary so the active task's report cannot answer the question.
|
|
830
852
|
if (run.state === "working" && !await pause()) return undefined;
|
|
831
|
-
const instruction =
|
|
853
|
+
const instruction = `Moderator BTW: answer only this question in one compact paragraph under ${EVENT_SUMMARY_CHARS} characters. Preserve your assigned task scope for the next resume. Question: ` + question;
|
|
832
854
|
if (!beginPrompt(instruction, wasPaused)) return undefined;
|
|
833
855
|
const answered = await settlesWithin(waitSettled(), 2 * 60_000);
|
|
834
856
|
if (!answered) { void pause(); return undefined; }
|
|
@@ -1090,6 +1112,12 @@ export class DelegateRegistry {
|
|
|
1090
1112
|
for (const record of records) this.entries.set(record.key, { record });
|
|
1091
1113
|
this.notify();
|
|
1092
1114
|
}
|
|
1115
|
+
/** Key/state of every record in insertion order: a cheap change test for per-tick listeners (no sweep, sort or copies). */
|
|
1116
|
+
stateSignature(): string {
|
|
1117
|
+
let signature = "";
|
|
1118
|
+
for (const [key, { record }] of this.entries) signature += key + ":" + record.run.state + " ";
|
|
1119
|
+
return signature;
|
|
1120
|
+
}
|
|
1093
1121
|
notify(): void {
|
|
1094
1122
|
this.sweep();
|
|
1095
1123
|
for (const listener of this.listeners) {
|
|
@@ -1124,7 +1152,7 @@ const fileList = (files: readonly string[], limit: number) => files.slice(0, lim
|
|
|
1124
1152
|
const todoLine = (todo: Todo) => `${todo.parentId ? " " : " "}${todo.status === "completed" ? "[x]" : todo.status === "in_progress" ? "[~]" : "[ ]"} ${todo.title}`;
|
|
1125
1153
|
|
|
1126
1154
|
/** What the parent agent reads back: the run's facts (time, tools, files, tasks, failures), then its own report. */
|
|
1127
|
-
export function runReport(run: DelegateRun, now = Date.now()): string {
|
|
1155
|
+
export function runReport(run: DelegateRun, now = Date.now(), outputLimit = MAX_OUTPUT_CHARS, key?: string): string {
|
|
1128
1156
|
const counts = Object.entries(run.toolCounts).sort((a, b) => b[1] - a[1]).map(([name, count]) => `${name}×${count}`).join(", ");
|
|
1129
1157
|
const facts = [
|
|
1130
1158
|
run.startedAt !== undefined ? `took ${duration((run.endedAt ?? now) - run.startedAt)}` : "never started",
|
|
@@ -1143,23 +1171,26 @@ export function runReport(run: DelegateRun, now = Date.now()): string {
|
|
|
1143
1171
|
}
|
|
1144
1172
|
const failures = run.transcript.filter((entry): entry is ToolEntry => entry.kind === "tool" && entry.status === "error").slice(-5);
|
|
1145
1173
|
if (failures.length) lines.push(`- failed tool calls: ${failures.map((entry) => cleanText(`${entry.name} ${entry.hint}`, 80)).join("; ")}`);
|
|
1146
|
-
|
|
1174
|
+
const output = run.output.length > outputLimit
|
|
1175
|
+
? run.output.slice(0, outputLimit) + `\n… [report truncated; jar_subagent report ${key ?? run.name} returns the full text]`
|
|
1176
|
+
: run.output;
|
|
1177
|
+
lines.push("", output || "(no report)");
|
|
1147
1178
|
return lines.join("\n");
|
|
1148
1179
|
}
|
|
1149
1180
|
|
|
1150
1181
|
const DelegateModeSchema = Type.Union([Type.Literal("scout"), Type.Literal("fork"), Type.Literal("worktree")]);
|
|
1151
1182
|
const Parameters = Type.Object({
|
|
1152
1183
|
tasks: Type.Array(Type.Object({
|
|
1153
|
-
task: Type.String({ description: "Task instruction. scout sees only this
|
|
1184
|
+
task: Type.String({ description: "Task instruction. scout sees only this; fork/worktree also inherit your conversation." }),
|
|
1154
1185
|
name: Type.Optional(Type.String({ description: "Short label, e.g. \"auth scout\"." })),
|
|
1155
|
-
role: Type.Optional(Type.String({ description: "pi-jar role override
|
|
1186
|
+
role: Type.Optional(Type.String({ description: "pi-jar role override; defaults by mode (scout→scout, fork→reviewer, worktree→worker), then role fallbacks/current model." })),
|
|
1156
1187
|
mode: Type.Optional(DelegateModeSchema),
|
|
1157
1188
|
tools: Type.Optional(Type.Array(Type.String({ minLength: 1, maxLength: 80 }), { minItems: 1, maxItems: 64, uniqueItems: true,
|
|
1158
|
-
description: "Explicit child tool allowlist. scout/fork
|
|
1159
|
-
inheritTools: Type.Optional(Type.Boolean({ description: "Inherit
|
|
1189
|
+
description: "Explicit child tool allowlist; grant narrowly. scout/fork stay read-only; worktree accepts only path-guarded file tools; web/browser tools only when active here." })),
|
|
1190
|
+
inheritTools: Type.Optional(Type.Boolean({ description: "Inherit your active tools, filtered by mode safety; recursive delegation tools are always removed." }))
|
|
1160
1191
|
}), { minItems: 1, maxItems: 16 }),
|
|
1161
1192
|
mode: Type.Optional(DelegateModeSchema),
|
|
1162
|
-
write: Type.Optional(Type.Boolean({ description: "Deprecated
|
|
1193
|
+
write: Type.Optional(Type.Boolean({ description: "Deprecated: true = legacy shared-workspace editing; prefer mode=\"worktree\"." }))
|
|
1163
1194
|
});
|
|
1164
1195
|
|
|
1165
1196
|
const glyph = (state: DelegateState) => state === "done" ? "✔" : state === "failed" ? "✖" : state === "stopped" ? "■" : state === "working" ? "●" : "○";
|
|
@@ -1188,7 +1219,7 @@ export interface DelegateController {
|
|
|
1188
1219
|
export interface DelegateOptions {
|
|
1189
1220
|
/** Snapshot names of tools active in the parent session. */
|
|
1190
1221
|
getActiveToolNames?: () => readonly string[];
|
|
1191
|
-
/** Allowed sizes: 2, 4, 6, 8, 16; defaults to
|
|
1222
|
+
/** Allowed sizes: 2, 4, 6, 8, 16; defaults to MAX_DELEGATES. Read on every launch. */
|
|
1192
1223
|
getMaxSubagents?: () => number;
|
|
1193
1224
|
/** Shares the delegate lifecycle and capacity with scout orchestration. */
|
|
1194
1225
|
onController?: (controller: DelegateController) => void;
|
|
@@ -1203,6 +1234,8 @@ export interface DelegateOptions {
|
|
|
1203
1234
|
discussionRetire?: (key: string) => void;
|
|
1204
1235
|
/** Unread discussion messages waiting for an agent, shown in the moderator's fleet line. */
|
|
1205
1236
|
discussionUnread?: (key: string) => number;
|
|
1237
|
+
/** The moderator's unread discussion mail as one notice, marked read; it rides along with subagent events. */
|
|
1238
|
+
discussionDigest?: () => string | undefined;
|
|
1206
1239
|
/** Settled quiet time before a retained child's process hibernates; defaults to HIBERNATE_AFTER_MS. */
|
|
1207
1240
|
hibernateAfterMs?: number;
|
|
1208
1241
|
}
|
|
@@ -1215,21 +1248,16 @@ const remainingTasks = (run: DelegateRun): string[] => {
|
|
|
1215
1248
|
return leaves.filter((todo) => todo.status !== "completed").map((todo) => todo.title).slice(0, 20);
|
|
1216
1249
|
};
|
|
1217
1250
|
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
};
|
|
1222
|
-
|
|
1223
|
-
const controlReport = (report: SubagentStopReport): string => [
|
|
1251
|
+
/** The stop handoff, compact like every event: summary, what changed and reached /diff, what is left. `kept` is
|
|
1252
|
+
* the workspace still holding unapplied changes, if any; a reconciled or discarded worktree is gone. */
|
|
1253
|
+
const controlReport = (report: SubagentStopReport, kept: string | undefined): string => [
|
|
1224
1254
|
`${report.key} · ${report.name} · ${report.state}`,
|
|
1225
|
-
|
|
1226
|
-
`
|
|
1227
|
-
`workspace: ${
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
`
|
|
1231
|
-
report.error ? `error: ${report.error}` : "",
|
|
1232
|
-
report.last ? `last: ${cleanBlock(report.last, 1200)}` : ""
|
|
1255
|
+
`summary: ${report.last ? eventSummary(report.last) : "(no report)"}`,
|
|
1256
|
+
`changed: ${report.changed.length ? fileList(report.changed, 10) : "none"} · applied: ${report.applied.length ? fileList(report.applied, 10) : "none"}`,
|
|
1257
|
+
kept ? `workspace kept: ${kept} (stop retries reconciliation; discard drops it)` : "",
|
|
1258
|
+
report.remaining.length ? `remaining: ${report.remaining.slice(0, 3).map((title) => cleanText(title, 80)).join("; ")}`
|
|
1259
|
+
+ (report.remaining.length > 3 ? ` (+${report.remaining.length - 3} more)` : "") : "",
|
|
1260
|
+
report.error ? `error: ${cleanText(report.error, 300)}` : ""
|
|
1233
1261
|
].filter(Boolean).join("\n");
|
|
1234
1262
|
|
|
1235
1263
|
/** Peek is task progress only, read from memory: no git, transcript or report reads. */
|
|
@@ -1254,9 +1282,19 @@ const peekRecord = (record: SubagentRecord): string => {
|
|
|
1254
1282
|
] : [])
|
|
1255
1283
|
].filter(Boolean).join("\n");
|
|
1256
1284
|
};
|
|
1257
|
-
/**
|
|
1258
|
-
const
|
|
1259
|
-
|
|
1285
|
+
/** A settled turn as its event shows it: state and progress, the report's summary, changed files. */
|
|
1286
|
+
const turnEvent = (record: SubagentRecord): string => {
|
|
1287
|
+
const run = record.run;
|
|
1288
|
+
const leaves = leafTodos(run.todos);
|
|
1289
|
+
const done = leaves.filter((todo) => todo.status === "completed").length;
|
|
1290
|
+
return [
|
|
1291
|
+
`${record.key} · ${run.name} · ${run.state}${leaves.length ? ` · ${done}/${leaves.length} tasks` : ""}`,
|
|
1292
|
+
`summary: ${run.output ? eventSummary(run.output) : "(no report)"}`,
|
|
1293
|
+
run.filesEdited.length ? `changed: ${fileList(run.filesEdited, 10)}` : "",
|
|
1294
|
+
run.error ? `error: ${cleanText(run.error, 300)}` : "",
|
|
1295
|
+
isRecovery(record) ? `workspace kept: ${run.workspace} (stop retries reconciliation; discard drops it)` : ""
|
|
1296
|
+
].filter(Boolean).join("\n");
|
|
1297
|
+
};
|
|
1260
1298
|
|
|
1261
1299
|
/** One queued moderator notification; several coalesce into a single pi-jar.subagent message. */
|
|
1262
1300
|
interface SubagentEvent { record: SubagentRecord; kind: "turn" | "control"; label: string; content: string; detail: Record<string, unknown> }
|
|
@@ -1302,6 +1340,10 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1302
1340
|
try { return Math.max(0, Math.floor(options.discussionUnread?.(key) ?? 0)) || 0; }
|
|
1303
1341
|
catch { return 0; } // the count is decoration; the fleet line must still render
|
|
1304
1342
|
};
|
|
1343
|
+
const discussionDigest = (): string | undefined => {
|
|
1344
|
+
try { return options.discussionDigest?.(); }
|
|
1345
|
+
catch (error) { console.error("pi-jar: could not read the moderator's discussion mail", error); return undefined; }
|
|
1346
|
+
};
|
|
1305
1347
|
const retireDiscussion = (key: string) => {
|
|
1306
1348
|
try { options.discussionRetire?.(key); } catch (error) { console.error("pi-jar: could not retire a subagent's discussion identity", error); }
|
|
1307
1349
|
};
|
|
@@ -1316,10 +1358,8 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1316
1358
|
}).join("\n");
|
|
1317
1359
|
return { message: { customType: "pi-jar.moderator-context", display: false, content: [
|
|
1318
1360
|
"[PI-JAR MODERATOR MODE]",
|
|
1319
|
-
"
|
|
1320
|
-
"
|
|
1321
|
-
"Do not duplicate work already owned by a retained subagent. Stop completed workers to obtain their handoff and reconcile worktree changes into /diff. Synthesize the final answer from their reports and evidence.",
|
|
1322
|
-
"Hibernated agents are idle with their process closed to save memory; resume and ask relaunch them with context and workspace intact.",
|
|
1361
|
+
"Retained subagents own their delegated tasks: don't duplicate their work. Their turn ends and ask/pause/stop results arrive as pi-jar.subagent events; an accepted receipt is not completion, so keep working and never poll peek.",
|
|
1362
|
+
"Hibernated agents are idle with the process closed; resume and ask relaunch them intact. Stop finished workers to reconcile worktree changes into /diff, then synthesize from their reports.",
|
|
1323
1363
|
"Retained fleet:",
|
|
1324
1364
|
fleet
|
|
1325
1365
|
].join("\n") } };
|
|
@@ -1358,9 +1398,12 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1358
1398
|
pendingEvents.clear();
|
|
1359
1399
|
if (!events.length) return;
|
|
1360
1400
|
const summary = cleanText(`Subagents · ${events.map(event => event.label).join(" · ")}`, 160);
|
|
1401
|
+
// Discussion mail rides along with events delivered anyway; on its own it never wakes the moderator.
|
|
1402
|
+
const mail = discussionDigest();
|
|
1361
1403
|
return {
|
|
1362
1404
|
customType: SUBAGENT_MESSAGE, display: true, details: { summary, events: events.map(event => event.detail) },
|
|
1363
|
-
content: [`[PI-JAR SUBAGENT] ${summary}`, ...events.map(event => event.content),
|
|
1405
|
+
content: [`[PI-JAR SUBAGENT] ${summary}`, ...events.map(event => event.content), ...(mail ? [mail] : []),
|
|
1406
|
+
"Summaries only: jar_subagent report <agent> returns a full report. Resume, ask or stop retained agents as needed."].join("\n\n")
|
|
1364
1407
|
};
|
|
1365
1408
|
};
|
|
1366
1409
|
const scheduleWake = () => {
|
|
@@ -1384,7 +1427,7 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1384
1427
|
pi.registerMessageRenderer?.<{ summary?: string }>(SUBAGENT_MESSAGE, (message, { expanded }, theme) => {
|
|
1385
1428
|
const text = typeof message.content === "string" ? message.content : "Subagents";
|
|
1386
1429
|
const summary = message.details?.summary ?? text.split("\n", 1)[0] ?? "Subagents";
|
|
1387
|
-
return new Text(expanded ? text : theme.fg("accent", cleanText(summary, 160)) + theme.fg("dim", " · expand for
|
|
1430
|
+
return new Text(expanded ? text : theme.fg("accent", cleanText(summary, 160)) + theme.fg("dim", " · expand for details"), 0, 0);
|
|
1388
1431
|
});
|
|
1389
1432
|
pi.on?.("agent_start", () => { moderatorActive = true; moderatorInterrupted = false; clearTimeout(wakeTimer); wakeTimer = undefined; });
|
|
1390
1433
|
pi.on?.("turn_end", boundary);
|
|
@@ -1401,7 +1444,7 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1401
1444
|
// Freeze reports at the settled boundary. A fast resume must not overwrite a queued
|
|
1402
1445
|
// initial report, and multiple turns before delivery must each be delivered.
|
|
1403
1446
|
queueEvent(`${key}:${record.run.resumes}`, { record, kind: "turn", label: `${record.run.name} turn ended`,
|
|
1404
|
-
content:
|
|
1447
|
+
content: turnEvent(record), detail: { key, turn: record.run.resumes } });
|
|
1405
1448
|
}
|
|
1406
1449
|
}
|
|
1407
1450
|
});
|
|
@@ -1409,14 +1452,11 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1409
1452
|
const delegateTool: ToolDefinition<typeof Parameters> = {
|
|
1410
1453
|
name: DELEGATE_TOOL,
|
|
1411
1454
|
label: "delegate",
|
|
1412
|
-
description: `Start retained
|
|
1413
|
-
promptSnippet: "
|
|
1455
|
+
description: `Start retained subagents and return immediately; the pool limit counts idle, paused and hibernated agents. fork and worktree inherit this conversation; a worktree's isolated Git changes reach /diff only when you stop it, and at most ${RECOVERY_LIMIT} worktree workspaces may be active or awaiting recovery (resolve with jar_subagent stop or discard). Turn completions arrive as pi-jar.subagent events with a short summary and changed files; jar_subagent report returns a full report. Settled agents hibernate (process closed, context kept) until resumed.`,
|
|
1456
|
+
promptSnippet: "Start retained parallel subagents: scout (cheap read-only discovery), fork (read-only review with your context), worktree (isolated implementation).",
|
|
1414
1457
|
promptGuidelines: [
|
|
1415
|
-
"
|
|
1416
|
-
"
|
|
1417
|
-
"Retained agents become idle after each turn and hibernate after a quiet period; resume and ask relaunch them with their context. Initial and resumed turn completions arrive as pi-jar.subagent event messages, so keep responding to user steering while they work; do not poll jar_subagent peek. Reuse them with jar_subagent resume instead of spawning replacements; pause them when priorities change; stop completed workers to reconcile their worktree into /diff.",
|
|
1418
|
-
"Use jar_subagent ask for a brief BTW question to one worker. For agent-to-agent questions, have them use jar_discuss so answers stay structured and cheap.",
|
|
1419
|
-
"Respect the configured pool limit, including idle, paused and hibernated agents. Stop agents you no longer need; resolve failed worktree recovery records (stop to retry, discard to drop) before starting more worktree agents."
|
|
1458
|
+
"Delegate only when there are ≥2 independent chunks that each need ≥10 tool calls; otherwise do the work yourself.",
|
|
1459
|
+
"After delegating, moderate: completions arrive as pi-jar.subagent events, so keep working and never poll; don't redo delegated work; reuse retained agents (jar_subagent resume) instead of spawning; stop finished workers to apply worktree changes into /diff."
|
|
1420
1460
|
],
|
|
1421
1461
|
parameters: Parameters,
|
|
1422
1462
|
async execute(_id, params, signal, onUpdate, ctx) {
|
|
@@ -1793,7 +1833,10 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1793
1833
|
signal?.removeEventListener("abort", abortAll);
|
|
1794
1834
|
publish();
|
|
1795
1835
|
toolReturned = true;
|
|
1796
|
-
return { content: [{ type: "text", text:
|
|
1836
|
+
return { content: [{ type: "text", text: [
|
|
1837
|
+
`Subagent launch is asynchronous: ${plural(records.length, "agent")} started; turn reports arrive as pi-jar.subagent events. Keep working or steer with jar_subagent; don't poll.`,
|
|
1838
|
+
...records.map(({ key, run }) => `- ${key} · ${run.name} · ${run.mode} · ${run.role}${run.model ? " · " + run.model : ""}`)
|
|
1839
|
+
].join("\n") }], details: details() };
|
|
1797
1840
|
}
|
|
1798
1841
|
await startup;
|
|
1799
1842
|
publish();
|
|
@@ -1809,7 +1852,9 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1809
1852
|
flushUpdate();
|
|
1810
1853
|
toolReturned = true;
|
|
1811
1854
|
}
|
|
1812
|
-
|
|
1855
|
+
const outputLimit = Math.max(MIN_RUN_REPORT_CHARS, Math.floor(MAX_RESULT_REPORT_CHARS / Math.max(1, runs.length)));
|
|
1856
|
+
const keys = new Map(records.map(({ key, run }) => [run, key]));
|
|
1857
|
+
return { content: [{ type: "text", text: runs.map((run) => runReport(run, Date.now(), outputLimit, keys.get(run))).join("\n\n") }], details: details(),
|
|
1813
1858
|
...(runs.some((run) => run.state === "failed") ? { isError: true } : {}) };
|
|
1814
1859
|
} finally { releaseReservation(); }
|
|
1815
1860
|
},
|
|
@@ -1852,7 +1897,7 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1852
1897
|
if (!launched) throw new Error(result.content.map((part) => "text" in part ? part.text : "").join("\n"));
|
|
1853
1898
|
const record = registry.get(`delegate-${launched}-1`);
|
|
1854
1899
|
if (!record) throw new Error("Scout session was discarded");
|
|
1855
|
-
// This launch is an internal
|
|
1900
|
+
// This launch is an internal council vote; its caller is already awaiting the result,
|
|
1856
1901
|
// so the moderator does not need a duplicate unsolicited completion notification.
|
|
1857
1902
|
watched.delete(record.key);
|
|
1858
1903
|
deleteSettled(record.key);
|
|
@@ -1906,26 +1951,25 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1906
1951
|
const status = result.isError ? "failed" : "completed";
|
|
1907
1952
|
// The stop handoff supersedes any queued turn report for the same agent.
|
|
1908
1953
|
if (operation.action === "stop") deleteSettled(record.key);
|
|
1954
|
+
const text = result.content.map(part => part.text).join("\n");
|
|
1909
1955
|
queueEvent(operation.id, { record, kind: "control", label: `${operation.action} ${record.run.name} ${status}`,
|
|
1910
|
-
|
|
1956
|
+
// A stop handoff is already compact; any other result (a BTW answer, an error) is clipped like a summary.
|
|
1957
|
+
content: `${operation.action} ${operation.id} ${status}:\n${operation.action === "stop" ? text : summaryLine(text)}`,
|
|
1911
1958
|
detail: { operationId: operation.id, action: operation.action, key: record.key, status, report: result.details } });
|
|
1912
1959
|
};
|
|
1913
1960
|
|
|
1914
1961
|
pi.registerTool({
|
|
1915
1962
|
name: "jar_subagent",
|
|
1916
1963
|
label: "subagent",
|
|
1917
|
-
description: "Non-blocking
|
|
1918
|
-
promptSnippet: "
|
|
1964
|
+
description: "Non-blocking control for retained subagents. peek: task progress only (plus workspace and changed files of an unresolved worktree recovery). report: an agent's latest full report, for retained and recently finished agents. steer: redirect a working agent. resume: continue an idle, paused or hibernated agent (hibernated ones relaunch transparently). ask, pause and stop return an accepted receipt; the answer or handoff arrives as a pi-jar.subagent event. stop aborts any active turn, retires the agent, reports changes and remaining work, and reconciles worktree changes into /diff (on a failed recovery record it retries). discard removes an unresolved recovery workspace without applying it.",
|
|
1965
|
+
promptSnippet: "Moderator control for retained subagents: peek, report, steer, ask, pause, resume, stop, discard.",
|
|
1919
1966
|
promptGuidelines: [
|
|
1920
|
-
"
|
|
1921
|
-
"Use steer for active direction and ask for a brief BTW question. ask, pause and stop do not wait on the child; continue responding to the user until their completion event arrives.",
|
|
1922
|
-
"Pause when an agent should stop spending tokens but keep its context/workspace. Resume the same agent later instead of spawning a replacement; a hibernated agent resumes exactly like an idle one.",
|
|
1923
|
-
"Stop when an agent is no longer needed. Stop aborts any active turn, retires the process, reports progress/workspace/changes/remaining work, and reconciles safe worktree changes into /diff. On a failed/conflicted worktree recovery record, stop retries reconciliation; discard it only when its changes are not wanted."
|
|
1967
|
+
"Results arrive as pi-jar.subagent events; never poll. Reuse idle/paused/hibernated agents with resume instead of spawning; ask = one brief BTW question; pause keeps context without spending; read report only when an event summary is not enough."
|
|
1924
1968
|
],
|
|
1925
1969
|
parameters: Type.Object({
|
|
1926
1970
|
action: Type.Union([
|
|
1927
1971
|
Type.Literal("peek"), Type.Literal("steer"), Type.Literal("ask"),
|
|
1928
|
-
Type.Literal("pause"), Type.Literal("resume"), Type.Literal("stop"), Type.Literal("discard")
|
|
1972
|
+
Type.Literal("pause"), Type.Literal("resume"), Type.Literal("stop"), Type.Literal("discard"), Type.Literal("report")
|
|
1929
1973
|
]),
|
|
1930
1974
|
agent: Type.Optional(Type.String({ description: "Subagent key or unique name. Omit only for peek to show all retained agents." })),
|
|
1931
1975
|
message: Type.Optional(Type.String({ description: "Direction, BTW question, or resume instruction." }))
|
|
@@ -1942,6 +1986,7 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1942
1986
|
const agent = params.agent ?? "";
|
|
1943
1987
|
const record = registry.resolve(agent);
|
|
1944
1988
|
if (!record) return { content: [{ type: "text", text: "Subagent not found or name is ambiguous: " + agent }], isError: true };
|
|
1989
|
+
if (params.action === "report") return { content: [{ type: "text", text: runReport(record.run) }] };
|
|
1945
1990
|
const stopping = pendingControls.get(record.key + ":stop");
|
|
1946
1991
|
const controlling = pendingControls.get(record.key + ":control");
|
|
1947
1992
|
if (stopping || (controlling && params.action !== "stop")) {
|
|
@@ -1990,7 +2035,7 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1990
2035
|
}
|
|
1991
2036
|
const report = await registry.stop(record.key);
|
|
1992
2037
|
if (!report) return { content: [{ type: "text", text: "Subagent is already retired: " + record.run.name }] };
|
|
1993
|
-
return { content: [{ type: "text", text: controlReport(report) }], details: report, isError: !!report.error };
|
|
2038
|
+
return { content: [{ type: "text", text: controlReport(report, record.run.workspace) }], details: report, isError: !!report.error };
|
|
1994
2039
|
};
|
|
1995
2040
|
// Internal registry APIs remain awaited for shutdown/reconciliation correctness. Only the
|
|
1996
2041
|
// external tool boundary is detached, so parent steering never waits on a child turn or Git.
|
package/src/discussion-hub.ts
CHANGED
|
@@ -14,6 +14,8 @@ const MAX_DISCUSSION_AGENTS = 64;
|
|
|
14
14
|
/** A list reply never exceeds this, however many messages match; each listed message is clipped too. */
|
|
15
15
|
export const MAX_LIST_CHARS = 4000;
|
|
16
16
|
export const MAX_LISTED_TEXT = 400;
|
|
17
|
+
/** A turn-start notice carries at most this much unread mail; the rest stays one `list` away. */
|
|
18
|
+
export const MAX_NOTICE_CHARS = 2000;
|
|
17
19
|
/** Unread mail piggybacked on an ask/answer reply never exceeds this. */
|
|
18
20
|
const MAX_PIGGYBACK_CHARS = 1000;
|
|
19
21
|
const BROADCAST: Record<string, true> = { all: true, "*": true, everyone: true, broadcast: true };
|
|
@@ -168,11 +170,11 @@ export class DiscussionHub {
|
|
|
168
170
|
|
|
169
171
|
/** Unread mail, oldest first within the reply budget; the cursor moves past what was shown, so paging
|
|
170
172
|
* loses nothing. `since` rereads relevant messages newer than an id, read or not. */
|
|
171
|
-
list(key: string, since?: number): ListResult {
|
|
173
|
+
list(key: string, since?: number, budget = MAX_LIST_CHARS): ListResult {
|
|
172
174
|
const agent = this.agent(key);
|
|
173
175
|
const items = this.messages.filter((message) => this.relevant(agent, message)
|
|
174
176
|
&& (since === undefined ? !this.read(agent, message) : message.id > since));
|
|
175
|
-
const { views, shown } = this.select(items, MAX_LISTED_TEXT);
|
|
177
|
+
const { views, shown } = this.select(items, MAX_LISTED_TEXT, budget);
|
|
176
178
|
this.markRead(agent, shown);
|
|
177
179
|
const latest = this.messages.at(-1)?.id;
|
|
178
180
|
return { messages: views, more: items.length - views.length, ...(latest ? { latest } : {}), unread: this.unreadOf(agent).length };
|
|
@@ -214,8 +216,9 @@ export class DiscussionHub {
|
|
|
214
216
|
return { ...result, agents, unanswered: { total: open.length, questions } };
|
|
215
217
|
}
|
|
216
218
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
+
/** `except` leaves that sender's mail out, so the moderator's view of a subagent's mailbox never echoes its own messages. */
|
|
220
|
+
unread(key: string, except?: string): UnreadResult {
|
|
221
|
+
const unread = this.unreadOf(this.agent(key)).filter((message) => message.from !== except);
|
|
219
222
|
return { unread: unread.length, ...(unread.length ? { newest: unread.at(-1)!.id } : {}) };
|
|
220
223
|
}
|
|
221
224
|
|
|
@@ -334,13 +337,13 @@ export class DiscussionHub {
|
|
|
334
337
|
}
|
|
335
338
|
|
|
336
339
|
/** Oldest first into the budget; at least one message, so paging always progresses. */
|
|
337
|
-
private select(items: readonly Stored[], limit: number): { views: MessageView[]; shown: Stored[] } {
|
|
340
|
+
private select(items: readonly Stored[], limit: number, budget = MAX_LIST_CHARS): { views: MessageView[]; shown: Stored[] } {
|
|
338
341
|
const views: MessageView[] = [];
|
|
339
342
|
let used = 0;
|
|
340
343
|
for (const message of items) {
|
|
341
344
|
const view = this.view(message, limit);
|
|
342
345
|
const size = messageLine(view).length + 1;
|
|
343
|
-
if (views.length && used + size >
|
|
346
|
+
if (views.length && used + size > budget) break;
|
|
344
347
|
views.push(view);
|
|
345
348
|
used += size;
|
|
346
349
|
}
|
package/src/discussion.ts
CHANGED
|
@@ -6,8 +6,8 @@ import { join } from "node:path";
|
|
|
6
6
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
7
7
|
import { Type } from "typebox";
|
|
8
8
|
import {
|
|
9
|
-
DiscussionError, DiscussionHub, MAX_DISCUSSION_BYTES, MAX_DISCUSSION_MESSAGES, MAX_DISCUSSION_TEXT, MAX_LIST_CHARS, messageLine,
|
|
10
|
-
MODERATOR, parseDiscussionId, type ListResult, type PendingResult, type PostResult, type ThreadResult
|
|
9
|
+
DiscussionError, DiscussionHub, MAX_DISCUSSION_BYTES, MAX_DISCUSSION_MESSAGES, MAX_DISCUSSION_TEXT, MAX_LIST_CHARS, MAX_NOTICE_CHARS, messageLine,
|
|
10
|
+
MODERATOR, parseDiscussionId, type ListResult, type PendingResult, type PostResult, type ThreadResult
|
|
11
11
|
} from "./discussion-hub.ts";
|
|
12
12
|
import { cleanText } from "./status.ts";
|
|
13
13
|
|
|
@@ -44,7 +44,8 @@ export interface DiscussionTransport {
|
|
|
44
44
|
list(since?: number): Promise<ListResult>;
|
|
45
45
|
thread(questionId: number, since?: number): Promise<ThreadResult>;
|
|
46
46
|
pending(): Promise<PendingResult>;
|
|
47
|
-
|
|
47
|
+
/** Unread mail for a turn-start notice, marked read: a list within the smaller notice budget. */
|
|
48
|
+
inbox(): Promise<ListResult>;
|
|
48
49
|
}
|
|
49
50
|
|
|
50
51
|
/** A 1600-character message JSON-escapes to well under this; anything larger is not from our client. */
|
|
@@ -65,7 +66,7 @@ const transportOf = (request: Request): DiscussionTransport => ({
|
|
|
65
66
|
list: (since) => request("list", since === undefined ? {} : { since }) as Promise<ListResult>,
|
|
66
67
|
thread: (questionId, since) => request("thread", since === undefined ? { questionId } : { questionId, since }) as Promise<ThreadResult>,
|
|
67
68
|
pending: () => request("pending", {}) as Promise<PendingResult>,
|
|
68
|
-
|
|
69
|
+
inbox: () => request("inbox", {}) as Promise<ListResult>
|
|
69
70
|
});
|
|
70
71
|
|
|
71
72
|
/** Shared by the tool adapter and the broker, so a bad id is rejected the same way on both sides. */
|
|
@@ -147,7 +148,12 @@ export class DiscussionBroker {
|
|
|
147
148
|
this.notify();
|
|
148
149
|
}
|
|
149
150
|
|
|
150
|
-
|
|
151
|
+
/** Unread mail waiting for an agent, minus the moderator's own messages: the moderator's fleet line never
|
|
152
|
+
* echoes its broadcasts or questions back to it. */
|
|
153
|
+
unreadFor(key: string): number { return this.hub.has(key) ? this.hub.unread(key, MODERATOR).unread : 0; }
|
|
154
|
+
|
|
155
|
+
/** The moderator's unread mail as one notice, marked read, to ride along with subagent events delivered anyway. */
|
|
156
|
+
digest(): string | undefined { return inboxNotice(this.call(MODERATOR, "inbox", {}) as ListResult); }
|
|
151
157
|
|
|
152
158
|
stats(): DiscussionStats {
|
|
153
159
|
return {
|
|
@@ -254,7 +260,7 @@ export class DiscussionBroker {
|
|
|
254
260
|
case "list": return this.hub.list(key, idArgument(args.since, "since", false));
|
|
255
261
|
case "thread": return this.hub.thread(key, idArgument(args.questionId, "questionId", true)!, idArgument(args.since, "since", false));
|
|
256
262
|
case "pending": return this.hub.pending(key);
|
|
257
|
-
case "
|
|
263
|
+
case "inbox": return this.hub.list(key, undefined, MAX_NOTICE_CHARS);
|
|
258
264
|
default: throw new DiscussionError("Unknown discussion operation.");
|
|
259
265
|
}
|
|
260
266
|
} catch (error) {
|
|
@@ -363,6 +369,16 @@ export function discussionClientFromEnv(env: NodeJS.ProcessEnv = process.env): D
|
|
|
363
369
|
|
|
364
370
|
const plural = (count: number, word: string) => `${count} ${word}${count === 1 ? "" : "s"}`;
|
|
365
371
|
|
|
372
|
+
/** Unread mail as one notice; it is marked read once fetched, so a message is never announced twice. */
|
|
373
|
+
export function inboxNotice(result: ListResult): string | undefined {
|
|
374
|
+
if (!result.messages.length) return undefined;
|
|
375
|
+
return [
|
|
376
|
+
"Discussion mail (now read; reply with jar_discuss answer):",
|
|
377
|
+
...result.messages.map(messageLine),
|
|
378
|
+
...(result.more ? [`(${plural(result.more, "more unread message")}; jar_discuss list)`] : [])
|
|
379
|
+
].join("\n");
|
|
380
|
+
}
|
|
381
|
+
|
|
366
382
|
export function registerDiscussionTool(pi: ExtensionAPI, transport: () => DiscussionTransport | undefined): void {
|
|
367
383
|
const parameters = Type.Object({
|
|
368
384
|
action: Type.Union([Type.Literal("ask"), Type.Literal("answer"), Type.Literal("list"), Type.Literal("thread"), Type.Literal("pending")]),
|
|
@@ -376,12 +392,8 @@ export function registerDiscussionTool(pi: ExtensionAPI, transport: () => Discus
|
|
|
376
392
|
pi.registerTool?.<typeof parameters, Record<string, never>>({
|
|
377
393
|
name: "jar_discuss",
|
|
378
394
|
label: "discuss",
|
|
379
|
-
description: `Parent-brokered Q/A between this session's moderator and subagents;
|
|
380
|
-
promptSnippet: "
|
|
381
|
-
promptGuidelines: [
|
|
382
|
-
"jar_discuss: one precise question or answer per call. ask to moderator, a subagent key or unique name, or omit to to broadcast; answer needs the questionId. Replies never echo your text.",
|
|
383
|
-
"Answers to your questions also arrive on your next ask/answer. list shows only unread messages for you; thread reads one question in full; pending summarizes open items. Don't poll."
|
|
384
|
-
],
|
|
395
|
+
description: `Parent-brokered Q/A between this session's moderator and subagents; never wakes anyone. ask/answer reply with the new id (never your text) plus your unread mail when it fits. Unread mail (questions to you, broadcasts, answers to your questions; never your own) also arrives once at your next turn start. list: your unread (≤${MAX_LIST_CHARS} chars); thread: one question in full; pending: open counts.`,
|
|
396
|
+
promptSnippet: "One concise cross-agent question or answer per call; unread mail reaches you at turn start, so never poll.",
|
|
385
397
|
parameters,
|
|
386
398
|
async execute(_id, params) {
|
|
387
399
|
const broker = transport();
|
|
@@ -441,19 +453,16 @@ export function registerDiscussionTool(pi: ExtensionAPI, transport: () => Discus
|
|
|
441
453
|
}
|
|
442
454
|
});
|
|
443
455
|
|
|
444
|
-
// At a natural boundary only:
|
|
445
|
-
//
|
|
446
|
-
|
|
456
|
+
// At a natural boundary only: unread mail joins the turn that is starting anyway, as one hidden message
|
|
457
|
+
// that already carries it, so reading it costs no list call. Discussion traffic never sends a message
|
|
458
|
+
// or triggers a turn, so agents cannot wake each other into a loop.
|
|
447
459
|
pi.on?.("before_agent_start", async () => {
|
|
448
460
|
const broker = transport();
|
|
449
461
|
if (!broker) return;
|
|
450
|
-
let
|
|
462
|
+
let inbox: ListResult;
|
|
451
463
|
// Advisory only: a broker failure surfaces on the agent's next jar_discuss call instead of delaying this turn.
|
|
452
|
-
try {
|
|
453
|
-
|
|
454
|
-
if (
|
|
455
|
-
notified = state.newest;
|
|
456
|
-
return { message: { customType: DISCUSSION_NOTICE, display: false,
|
|
457
|
-
content: `You have ${plural(state.unread, "unread discussion message")}. Use jar_discuss list when relevant.` } };
|
|
464
|
+
try { inbox = await broker.inbox(); } catch { return; }
|
|
465
|
+
const content = inboxNotice(inbox);
|
|
466
|
+
if (content) return { message: { customType: DISCUSSION_NOTICE, display: false, content } };
|
|
458
467
|
});
|
|
459
468
|
}
|
package/src/footer.ts
CHANGED
|
@@ -18,6 +18,8 @@ export interface FooterView extends JarStatus {
|
|
|
18
18
|
cwd?: string;
|
|
19
19
|
settings?: FooterSettings;
|
|
20
20
|
context: string;
|
|
21
|
+
/** The last call's prompt is past the context budget: the context metric turns amber. */
|
|
22
|
+
overBudget?: boolean;
|
|
21
23
|
goal?: string;
|
|
22
24
|
/** pi-jar's own short indicators (changes to review), shown before extras. */
|
|
23
25
|
chips?: readonly string[];
|
|
@@ -139,7 +141,7 @@ function footerLines(view: FooterView, width: number, theme: FooterTheme, hits:
|
|
|
139
141
|
const quota = view.quota;
|
|
140
142
|
const windowList = [quota?.fiveHour && `5h ${Math.round(quota.fiveHour.used)}%`, quota?.week && `week ${Math.round(quota.week.used)}%`].filter((value): value is string => !!value);
|
|
141
143
|
const windows = windowList.map((value, index) => index ? value : withIcon("quota", value));
|
|
142
|
-
const cost = view.cost ? metric("cost", "cost", cleanText(view.cost,
|
|
144
|
+
const cost = view.cost ? metric("cost", "cost", cleanText(view.cost, 32)) : undefined;
|
|
143
145
|
const memory = view.memory ? metric("memory", "ram", view.memory) : undefined;
|
|
144
146
|
const prefix = view.demo ? theme.fg("warning", "DEMO") : "";
|
|
145
147
|
const effort = show.effort && view.effort ? theme.fg(EFFORT_COLORS[view.effort], THINKING_ICONS[view.effort] ? withIcon(THINKING_ICONS[view.effort]!, view.effort) : view.effort) : "";
|
|
@@ -151,6 +153,8 @@ function footerLines(view: FooterView, width: number, theme: FooterTheme, hits:
|
|
|
151
153
|
// Context is never dropped; optional metrics move to a second line on medium widths.
|
|
152
154
|
const metrics = [...(show.context ? [context] : []), ...(show.memory && contentWidth >= 90 && memory ? [memory] : []), ...(show.cost && contentWidth >= 52 && cost ? [cost] : []), ...(show.quota && contentWidth >= 90 ? windows : [])];
|
|
153
155
|
let right = theme.fg("dim", metrics.join(" · "));
|
|
156
|
+
// Context always leads the metrics when shown, so only its own color changes past the budget.
|
|
157
|
+
if (view.overBudget && show.context) right = theme.fg("warning", context) + (metrics.length > 1 ? theme.fg("dim", " · " + metrics.slice(1).join(" · ")) : "");
|
|
154
158
|
if (contentWidth < 52 && attention && contentWidth >= 26) {
|
|
155
159
|
const room = Math.max(0, contentWidth - Math.min(visibleWidth(left), 12) - visibleWidth(right) - 4);
|
|
156
160
|
right = theme.fg(primary?.state === "failed" ? "error" : "warning", truncateToWidth(attention, Math.min(room, 14))) + " " + right;
|