pi-jar 0.2.2 → 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 +15 -12
- package/docs/EFFICIENCY_PLAN.md +20 -0
- package/docs/INTEGRATIONS.md +22 -6
- package/docs/SUBAGENT-TOOLS.md +1 -1
- package/docs/WORKFLOWS.md +2 -2
- package/extensions/index.ts +83 -16
- package/package.json +1 -1
- package/src/advisor.ts +21 -9
- 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 +80 -50
- package/src/discussion-hub.ts +9 -6
- package/src/discussion.ts +31 -22
- package/src/footer.ts +5 -1
- package/src/goal-loop.ts +3 -0
- package/src/info-panels.ts +6 -0
- package/src/model-roles.ts +26 -2
- package/src/plan.ts +4 -2
- package/src/profile-ui.ts +21 -6
- package/src/profiles.ts +7 -3
- 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/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/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;
|
|
@@ -58,6 +59,8 @@ const STATUS_REFRESH_MS = 20_000;
|
|
|
58
59
|
const LIVE_UPDATE_MS = 1000;
|
|
59
60
|
/** Visible message that wakes the moderator when a resumed subagent's turn ends. */
|
|
60
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;
|
|
61
64
|
const NOTIFY_COALESCE_MS = 250;
|
|
62
65
|
const MAX_EVENT_TEXT = 16_000;
|
|
63
66
|
/** Transcript entries kept per run; tool output and text are clipped so a run stays well under 2 MiB. */
|
|
@@ -204,7 +207,7 @@ export function delegatePrompt(task: string, write: boolean, mode: DelegateExecu
|
|
|
204
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.",
|
|
205
208
|
"Use jar_discuss when available for short cross-agent questions and answers. Keep discussion messages narrow; it is for coordination, not a transcript.",
|
|
206
209
|
"Finish with a self-contained report in these sections:",
|
|
207
|
-
|
|
210
|
+
`## Summary — the outcome in one to three sentences, under ${EVENT_SUMMARY_CHARS} characters: the moderator's notification shows only this.`,
|
|
208
211
|
"## Details — findings or changes, with file paths (and line numbers where useful).",
|
|
209
212
|
"## Verification — what you checked or ran, and the results.",
|
|
210
213
|
"## Open issues — risks, unknowns and follow-ups, or \"none\".",
|
|
@@ -217,6 +220,22 @@ export function delegatePrompt(task: string, write: boolean, mode: DelegateExecu
|
|
|
217
220
|
const cleanBlock = (value: string, limit: number): string => value.slice(0, limit * 2)
|
|
218
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);
|
|
219
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
|
+
|
|
220
239
|
/** Whether `promise` settles within `ms`; the timer never keeps the process alive. */
|
|
221
240
|
const settlesWithin = (promise: Promise<unknown>, ms: number): Promise<boolean> => {
|
|
222
241
|
let timer: NodeJS.Timeout | undefined;
|
|
@@ -831,7 +850,7 @@ export function startDelegate(run: DelegateRun, args: string[], prompt: string,
|
|
|
831
850
|
try {
|
|
832
851
|
// Cross a settled boundary so the active task's report cannot answer the question.
|
|
833
852
|
if (run.state === "working" && !await pause()) return undefined;
|
|
834
|
-
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;
|
|
835
854
|
if (!beginPrompt(instruction, wasPaused)) return undefined;
|
|
836
855
|
const answered = await settlesWithin(waitSettled(), 2 * 60_000);
|
|
837
856
|
if (!answered) { void pause(); return undefined; }
|
|
@@ -1162,16 +1181,16 @@ export function runReport(run: DelegateRun, now = Date.now(), outputLimit = MAX_
|
|
|
1162
1181
|
const DelegateModeSchema = Type.Union([Type.Literal("scout"), Type.Literal("fork"), Type.Literal("worktree")]);
|
|
1163
1182
|
const Parameters = Type.Object({
|
|
1164
1183
|
tasks: Type.Array(Type.Object({
|
|
1165
|
-
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." }),
|
|
1166
1185
|
name: Type.Optional(Type.String({ description: "Short label, e.g. \"auth scout\"." })),
|
|
1167
|
-
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." })),
|
|
1168
1187
|
mode: Type.Optional(DelegateModeSchema),
|
|
1169
1188
|
tools: Type.Optional(Type.Array(Type.String({ minLength: 1, maxLength: 80 }), { minItems: 1, maxItems: 64, uniqueItems: true,
|
|
1170
|
-
description: "Explicit child tool allowlist. scout/fork
|
|
1171
|
-
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." }))
|
|
1172
1191
|
}), { minItems: 1, maxItems: 16 }),
|
|
1173
1192
|
mode: Type.Optional(DelegateModeSchema),
|
|
1174
|
-
write: Type.Optional(Type.Boolean({ description: "Deprecated
|
|
1193
|
+
write: Type.Optional(Type.Boolean({ description: "Deprecated: true = legacy shared-workspace editing; prefer mode=\"worktree\"." }))
|
|
1175
1194
|
});
|
|
1176
1195
|
|
|
1177
1196
|
const glyph = (state: DelegateState) => state === "done" ? "✔" : state === "failed" ? "✖" : state === "stopped" ? "■" : state === "working" ? "●" : "○";
|
|
@@ -1200,7 +1219,7 @@ export interface DelegateController {
|
|
|
1200
1219
|
export interface DelegateOptions {
|
|
1201
1220
|
/** Snapshot names of tools active in the parent session. */
|
|
1202
1221
|
getActiveToolNames?: () => readonly string[];
|
|
1203
|
-
/** 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. */
|
|
1204
1223
|
getMaxSubagents?: () => number;
|
|
1205
1224
|
/** Shares the delegate lifecycle and capacity with scout orchestration. */
|
|
1206
1225
|
onController?: (controller: DelegateController) => void;
|
|
@@ -1215,6 +1234,8 @@ export interface DelegateOptions {
|
|
|
1215
1234
|
discussionRetire?: (key: string) => void;
|
|
1216
1235
|
/** Unread discussion messages waiting for an agent, shown in the moderator's fleet line. */
|
|
1217
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;
|
|
1218
1239
|
/** Settled quiet time before a retained child's process hibernates; defaults to HIBERNATE_AFTER_MS. */
|
|
1219
1240
|
hibernateAfterMs?: number;
|
|
1220
1241
|
}
|
|
@@ -1227,21 +1248,16 @@ const remainingTasks = (run: DelegateRun): string[] => {
|
|
|
1227
1248
|
return leaves.filter((todo) => todo.status !== "completed").map((todo) => todo.title).slice(0, 20);
|
|
1228
1249
|
};
|
|
1229
1250
|
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
};
|
|
1234
|
-
|
|
1235
|
-
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 => [
|
|
1236
1254
|
`${report.key} · ${report.name} · ${report.state}`,
|
|
1237
|
-
|
|
1238
|
-
`
|
|
1239
|
-
`workspace: ${
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
`
|
|
1243
|
-
report.error ? `error: ${report.error}` : "",
|
|
1244
|
-
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)}` : ""
|
|
1245
1261
|
].filter(Boolean).join("\n");
|
|
1246
1262
|
|
|
1247
1263
|
/** Peek is task progress only, read from memory: no git, transcript or report reads. */
|
|
@@ -1266,9 +1282,19 @@ const peekRecord = (record: SubagentRecord): string => {
|
|
|
1266
1282
|
] : [])
|
|
1267
1283
|
].filter(Boolean).join("\n");
|
|
1268
1284
|
};
|
|
1269
|
-
/**
|
|
1270
|
-
const
|
|
1271
|
-
|
|
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
|
+
};
|
|
1272
1298
|
|
|
1273
1299
|
/** One queued moderator notification; several coalesce into a single pi-jar.subagent message. */
|
|
1274
1300
|
interface SubagentEvent { record: SubagentRecord; kind: "turn" | "control"; label: string; content: string; detail: Record<string, unknown> }
|
|
@@ -1314,6 +1340,10 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1314
1340
|
try { return Math.max(0, Math.floor(options.discussionUnread?.(key) ?? 0)) || 0; }
|
|
1315
1341
|
catch { return 0; } // the count is decoration; the fleet line must still render
|
|
1316
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
|
+
};
|
|
1317
1347
|
const retireDiscussion = (key: string) => {
|
|
1318
1348
|
try { options.discussionRetire?.(key); } catch (error) { console.error("pi-jar: could not retire a subagent's discussion identity", error); }
|
|
1319
1349
|
};
|
|
@@ -1328,10 +1358,8 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1328
1358
|
}).join("\n");
|
|
1329
1359
|
return { message: { customType: "pi-jar.moderator-context", display: false, content: [
|
|
1330
1360
|
"[PI-JAR MODERATOR MODE]",
|
|
1331
|
-
"
|
|
1332
|
-
"
|
|
1333
|
-
"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.",
|
|
1334
|
-
"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.",
|
|
1335
1363
|
"Retained fleet:",
|
|
1336
1364
|
fleet
|
|
1337
1365
|
].join("\n") } };
|
|
@@ -1370,9 +1398,12 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1370
1398
|
pendingEvents.clear();
|
|
1371
1399
|
if (!events.length) return;
|
|
1372
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();
|
|
1373
1403
|
return {
|
|
1374
1404
|
customType: SUBAGENT_MESSAGE, display: true, details: { summary, events: events.map(event => event.detail) },
|
|
1375
|
-
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")
|
|
1376
1407
|
};
|
|
1377
1408
|
};
|
|
1378
1409
|
const scheduleWake = () => {
|
|
@@ -1396,7 +1427,7 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1396
1427
|
pi.registerMessageRenderer?.<{ summary?: string }>(SUBAGENT_MESSAGE, (message, { expanded }, theme) => {
|
|
1397
1428
|
const text = typeof message.content === "string" ? message.content : "Subagents";
|
|
1398
1429
|
const summary = message.details?.summary ?? text.split("\n", 1)[0] ?? "Subagents";
|
|
1399
|
-
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);
|
|
1400
1431
|
});
|
|
1401
1432
|
pi.on?.("agent_start", () => { moderatorActive = true; moderatorInterrupted = false; clearTimeout(wakeTimer); wakeTimer = undefined; });
|
|
1402
1433
|
pi.on?.("turn_end", boundary);
|
|
@@ -1413,7 +1444,7 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1413
1444
|
// Freeze reports at the settled boundary. A fast resume must not overwrite a queued
|
|
1414
1445
|
// initial report, and multiple turns before delivery must each be delivered.
|
|
1415
1446
|
queueEvent(`${key}:${record.run.resumes}`, { record, kind: "turn", label: `${record.run.name} turn ended`,
|
|
1416
|
-
content:
|
|
1447
|
+
content: turnEvent(record), detail: { key, turn: record.run.resumes } });
|
|
1417
1448
|
}
|
|
1418
1449
|
}
|
|
1419
1450
|
});
|
|
@@ -1421,14 +1452,11 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1421
1452
|
const delegateTool: ToolDefinition<typeof Parameters> = {
|
|
1422
1453
|
name: DELEGATE_TOOL,
|
|
1423
1454
|
label: "delegate",
|
|
1424
|
-
description: `Start retained
|
|
1425
|
-
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).",
|
|
1426
1457
|
promptGuidelines: [
|
|
1427
|
-
"
|
|
1428
|
-
"
|
|
1429
|
-
"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.",
|
|
1430
|
-
"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.",
|
|
1431
|
-
"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."
|
|
1432
1460
|
],
|
|
1433
1461
|
parameters: Parameters,
|
|
1434
1462
|
async execute(_id, params, signal, onUpdate, ctx) {
|
|
@@ -1805,7 +1833,10 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1805
1833
|
signal?.removeEventListener("abort", abortAll);
|
|
1806
1834
|
publish();
|
|
1807
1835
|
toolReturned = true;
|
|
1808
|
-
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() };
|
|
1809
1840
|
}
|
|
1810
1841
|
await startup;
|
|
1811
1842
|
publish();
|
|
@@ -1866,7 +1897,7 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1866
1897
|
if (!launched) throw new Error(result.content.map((part) => "text" in part ? part.text : "").join("\n"));
|
|
1867
1898
|
const record = registry.get(`delegate-${launched}-1`);
|
|
1868
1899
|
if (!record) throw new Error("Scout session was discarded");
|
|
1869
|
-
// This launch is an internal
|
|
1900
|
+
// This launch is an internal council vote; its caller is already awaiting the result,
|
|
1870
1901
|
// so the moderator does not need a duplicate unsolicited completion notification.
|
|
1871
1902
|
watched.delete(record.key);
|
|
1872
1903
|
deleteSettled(record.key);
|
|
@@ -1920,21 +1951,20 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1920
1951
|
const status = result.isError ? "failed" : "completed";
|
|
1921
1952
|
// The stop handoff supersedes any queued turn report for the same agent.
|
|
1922
1953
|
if (operation.action === "stop") deleteSettled(record.key);
|
|
1954
|
+
const text = result.content.map(part => part.text).join("\n");
|
|
1923
1955
|
queueEvent(operation.id, { record, kind: "control", label: `${operation.action} ${record.run.name} ${status}`,
|
|
1924
|
-
|
|
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)}`,
|
|
1925
1958
|
detail: { operationId: operation.id, action: operation.action, key: record.key, status, report: result.details } });
|
|
1926
1959
|
};
|
|
1927
1960
|
|
|
1928
1961
|
pi.registerTool({
|
|
1929
1962
|
name: "jar_subagent",
|
|
1930
1963
|
label: "subagent",
|
|
1931
|
-
description: "Non-blocking
|
|
1932
|
-
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.",
|
|
1933
1966
|
promptGuidelines: [
|
|
1934
|
-
"
|
|
1935
|
-
"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.",
|
|
1936
|
-
"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.",
|
|
1937
|
-
"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."
|
|
1938
1968
|
],
|
|
1939
1969
|
parameters: Type.Object({
|
|
1940
1970
|
action: Type.Union([
|
|
@@ -2005,7 +2035,7 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
2005
2035
|
}
|
|
2006
2036
|
const report = await registry.stop(record.key);
|
|
2007
2037
|
if (!report) return { content: [{ type: "text", text: "Subagent is already retired: " + record.run.name }] };
|
|
2008
|
-
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 };
|
|
2009
2039
|
};
|
|
2010
2040
|
// Internal registry APIs remain awaited for shutdown/reconciliation correctness. Only the
|
|
2011
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;
|
package/src/goal-loop.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type { Goal, GoalStore } from "./goals.ts";
|
|
|
5
5
|
import type { ModelRoleManager } from "./model-roles.ts";
|
|
6
6
|
import { isSafePlanCommand } from "./plan-utils.ts";
|
|
7
7
|
import type { Todo, TodoStore } from "./tasks.ts";
|
|
8
|
+
import { setToolActive } from "./tool-activation.ts";
|
|
8
9
|
import { projectWorkflowMessages } from "./workflow-context.ts";
|
|
9
10
|
|
|
10
11
|
export const GOAL_TOOL = "jar_goal";
|
|
@@ -263,6 +264,8 @@ export class GoalLoop {
|
|
|
263
264
|
|
|
264
265
|
this.pi.on("before_agent_start", async (_event, ctx) => {
|
|
265
266
|
const goal = this.goal();
|
|
267
|
+
// jar_goal is only useful while a goal runs; inactive, it costs no schema or rules tokens.
|
|
268
|
+
setToolActive(this.pi, GOAL_TOOL, !!goal && goal.status !== "complete");
|
|
266
269
|
if (!goal || goal.status === "complete") return;
|
|
267
270
|
if (goal.status === "active" && !this.options.planActive()) await this.useRole(goal.phase === "audit" ? "advisor" : "implement", ctx);
|
|
268
271
|
return { message: { customType: CONTEXT_TYPE, content: this.context(goal), display: false } };
|
package/src/info-panels.ts
CHANGED
|
@@ -12,7 +12,10 @@ const DEFAULT_RESERVE = 16_384;
|
|
|
12
12
|
export interface InfoPanelDeps {
|
|
13
13
|
side: SideUsage;
|
|
14
14
|
quota(ctx: ExtensionContext): Quota | undefined;
|
|
15
|
+
quotaFailure(ctx: ExtensionContext): { error: string; retryInMs: number } | undefined;
|
|
15
16
|
quotaEnabled(): boolean;
|
|
17
|
+
/** Start a limit lookup when nothing fresh is cached: opening `/usage` is an explicit request. */
|
|
18
|
+
refreshQuota(ctx: ExtensionContext): void;
|
|
16
19
|
}
|
|
17
20
|
|
|
18
21
|
function reserveTokens(ctx: ExtensionContext): number {
|
|
@@ -61,10 +64,12 @@ export function registerInfoPanels(pi: ExtensionAPI, deps: InfoPanelDeps): void
|
|
|
61
64
|
{ name: "Usage", render: (width, fg) => {
|
|
62
65
|
usage ??= collectUsage(sessionBranch(ctx) as never, deps.side.all());
|
|
63
66
|
const quota = deps.quota(ctx);
|
|
67
|
+
const quotaFailure = quota ? undefined : deps.quotaFailure(ctx);
|
|
64
68
|
return usageLines({
|
|
65
69
|
stats: usage,
|
|
66
70
|
...(ctx.model?.provider ? { provider: ctx.model.provider } : {}),
|
|
67
71
|
...(quota ? { quota } : {}),
|
|
72
|
+
...(quotaFailure ? { quotaFailure } : {}),
|
|
68
73
|
quotaEnabled: deps.quotaEnabled(), now: Date.now()
|
|
69
74
|
}, width, fg);
|
|
70
75
|
} },
|
|
@@ -78,6 +83,7 @@ export function registerInfoPanels(pi: ExtensionAPI, deps: InfoPanelDeps): void
|
|
|
78
83
|
const open = async (ctx: ExtensionContext, tab: number) => {
|
|
79
84
|
try {
|
|
80
85
|
const pages = tabs(ctx);
|
|
86
|
+
if (tab === 0) deps.refreshQuota(ctx);
|
|
81
87
|
if (ctx.hasUI && ctx.mode === "tui") await openPanel(ctx, pages, tab);
|
|
82
88
|
else ctx.ui.notify(pages[tab]!.render(100, plain).join("\n"), "info");
|
|
83
89
|
} catch (error) { ctx.ui.notify("pi-jar: " + (error instanceof Error ? error.message : String(error)), "error"); }
|
package/src/model-roles.ts
CHANGED
|
@@ -153,6 +153,30 @@ export function describeRole(row: RoleRow): string {
|
|
|
153
153
|
return row.role + " · " + (row.spec?.startsWith("@") ? row.spec + " → " : "") + target + (row.scope === "project" ? " · project" : "");
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
+
/** Delegated roles meant for a cheaper model than the main session: scouts discover, reviewers check. */
|
|
157
|
+
export const ECONOMY_ROLES = ["scout", "reviewer"] as const;
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Economy roles whose subagents run on the main session model although a cheaper model is available, as
|
|
161
|
+
* jar_delegate picks it: the role's first available candidate, then the default role's, then the current
|
|
162
|
+
* model. The fix names @smol when the Fast role already points at a cheaper available model.
|
|
163
|
+
*/
|
|
164
|
+
export function premiumRoleWarnings(roles: Pick<ModelRoleManager, "resolveCandidates">, ctx: Pick<ExtensionContext, "model" | "modelRegistry">): string[] {
|
|
165
|
+
const main = ctx.model;
|
|
166
|
+
if (!main) return [];
|
|
167
|
+
const mainId = main.provider + "/" + main.id;
|
|
168
|
+
const mainPrice = main.cost.input + main.cost.output;
|
|
169
|
+
const models = ctx.modelRegistry.getAvailable();
|
|
170
|
+
const cheaper = new Set(models.filter((model) => model.cost.input + model.cost.output < mainPrice).map((model) => model.provider + "/" + model.id));
|
|
171
|
+
// Without a cheaper option there is nothing to recommend: a main model that is already economical is fine.
|
|
172
|
+
if (!cheaper.size) return [];
|
|
173
|
+
const available = new Set(models.map((model) => model.provider + "/" + model.id));
|
|
174
|
+
const pick = (role: string) => roles.resolveCandidates(role).map((item) => item.provider + "/" + item.model).find((id) => available.has(id));
|
|
175
|
+
const fix = cheaper.has(pick("smol") ?? "") ? "@smol" : "provider/model";
|
|
176
|
+
return ECONOMY_ROLES.filter((role) => (pick(role) ?? pick("default") ?? mainId) === mainId)
|
|
177
|
+
.map((role) => `⚠ ${role} subagents run on the main model ${mainId}; use a mini/haiku-class model: /roles set ${role} ${fix}`);
|
|
178
|
+
}
|
|
179
|
+
|
|
156
180
|
export class ModelRoleManager {
|
|
157
181
|
private global: RoleConfig = readConfig(join(getAgentDir(), ROLE_FILE));
|
|
158
182
|
private project: RoleConfig = { version: 2, roles: {} };
|
|
@@ -392,8 +416,8 @@ export class ModelRoleManager {
|
|
|
392
416
|
const verb = (parts[0] ?? "").toLowerCase();
|
|
393
417
|
if (!verb || verb === "list") {
|
|
394
418
|
if (!verb && ctx.hasUI && ctx.mode === "tui" && openUi) { await openUi(ctx); return; }
|
|
395
|
-
ctx.ui.notify(this.list().map((row) => describeRole(row) + (this.fallbackSpecs(row.role).length
|
|
396
|
-
? " · fallbacks: " + this.fallbackSpecs(row.role).join(" → ") : "")).join("\n"), "info");
|
|
419
|
+
ctx.ui.notify([...this.list().map((row) => describeRole(row) + (this.fallbackSpecs(row.role).length
|
|
420
|
+
? " · fallbacks: " + this.fallbackSpecs(row.role).join(" → ") : "")), ...premiumRoleWarnings(this, ctx)].join("\n"), "info");
|
|
397
421
|
return;
|
|
398
422
|
}
|
|
399
423
|
if (verb === "fallback") {
|
package/src/plan.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { extractApproachSteps, isSafePlanCommand, PLAN_TEMPLATE, planTextFromSte
|
|
|
11
11
|
import { cleanText } from "./status.ts";
|
|
12
12
|
import { sessionBranch } from "./session-branch.ts";
|
|
13
13
|
import { type TodoStore } from "./tasks.ts";
|
|
14
|
+
import { setToolActive } from "./tool-activation.ts";
|
|
14
15
|
import { projectWorkflowMessages } from "./workflow-context.ts";
|
|
15
16
|
|
|
16
17
|
export const PLAN_ENTRY = "pi-jar.plan";
|
|
@@ -139,7 +140,8 @@ export class PlanMode {
|
|
|
139
140
|
this.enabled = true;
|
|
140
141
|
this.pendingReview = false;
|
|
141
142
|
this.reminders = 0;
|
|
142
|
-
|
|
143
|
+
// The submit tool is active only in plan mode; never restore it with the pre-plan loadout.
|
|
144
|
+
this.toolsBefore = this.pi.getActiveTools().filter((name) => name !== PLAN_SUBMIT_TOOL);
|
|
143
145
|
this.pi.setActiveTools(this.planTools(this.toolsBefore));
|
|
144
146
|
this.onEnter?.(ctx);
|
|
145
147
|
this.restoreRole = await this.roles.activateTemporary("plan", ctx);
|
|
@@ -414,7 +416,7 @@ export class PlanMode {
|
|
|
414
416
|
});
|
|
415
417
|
|
|
416
418
|
this.pi.on("before_agent_start", async () => {
|
|
417
|
-
if (!this.enabled) return;
|
|
419
|
+
if (!this.enabled) { setToolActive(this.pi, PLAN_SUBMIT_TOOL, false); return; }
|
|
418
420
|
return { message: { customType: "pi-jar.plan-context", content: this.context(), display: false } };
|
|
419
421
|
});
|
|
420
422
|
|