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/settings-ui.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Key, matchesKey, truncateToWidth, visibleWidth, type TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
3
3
|
import { FOOTER_FIELDS, type FooterField } from "./footer-settings.ts";
|
|
4
|
+
import { formatTokens } from "./context-budget.ts";
|
|
4
5
|
import { ICON_SETS } from "./icons.ts";
|
|
5
|
-
import { GOAL_ROUND_CHOICES, MAX_SUBAGENT_CHOICES, type JarAccent, type JarVisualSettings } from "./settings.ts";
|
|
6
|
+
import { CONTEXT_BUDGET_ACTIONS, CONTEXT_BUDGET_CHOICES, GOAL_ROUND_CHOICES, MAX_SUBAGENT_CHOICES, type JarAccent, type JarVisualSettings } from "./settings.ts";
|
|
6
7
|
import type { ProfileAction } from "./profile-ui.ts";
|
|
7
8
|
|
|
8
9
|
const LABELS: Record<FooterField, string> = {
|
|
@@ -39,10 +40,11 @@ export async function openJarSettings(
|
|
|
39
40
|
let width = 64;
|
|
40
41
|
const accents: JarAccent[] = ["follow", ...availableAccents.filter((name): name is JarAccent =>
|
|
41
42
|
name === "default" || ["gray", "pink", "teal", "azure", "violet", "amber"].includes(name))];
|
|
42
|
-
let choosing: "goalRounds" | "maxSubagents" | undefined;
|
|
43
|
-
const choices = () => choosing === "maxSubagents" ? MAX_SUBAGENT_CHOICES : GOAL_ROUND_CHOICES;
|
|
44
|
-
const fields = () => choosing ? choices().length : page === "footer" ? FOOTER_FIELDS.length : page === "pi" ?
|
|
45
|
-
|
|
43
|
+
let choosing: "goalRounds" | "maxSubagents" | "contextBudget" | undefined;
|
|
44
|
+
const choices = () => choosing === "maxSubagents" ? MAX_SUBAGENT_CHOICES : choosing === "contextBudget" ? CONTEXT_BUDGET_CHOICES : GOAL_ROUND_CHOICES;
|
|
45
|
+
const fields = () => choosing ? choices().length : page === "footer" ? FOOTER_FIELDS.length : page === "pi" ? 10 : page === "profiles" ? 2 : 7;
|
|
46
|
+
// Back on the row that opened the picker.
|
|
47
|
+
const finishChoice = () => { selected = choosing === "maxSubagents" ? 3 : choosing === "contextBudget" ? 8 : 2; choosing = undefined; };
|
|
46
48
|
const piError = (error: unknown) => ctx.ui.notify("Could not change Pi setting: " + (error as Error).message, "error");
|
|
47
49
|
const explainPool = () => choosing === "maxSubagents" || (page === "pi" && !choosing && selected === 3);
|
|
48
50
|
const pageSize = () => Math.min(fields(), Math.max(4, (process.stdout.rows ?? 24) - (explainPool() ? 10 : 9)));
|
|
@@ -52,6 +54,7 @@ export async function openJarSettings(
|
|
|
52
54
|
const state = current();
|
|
53
55
|
if (choosing) {
|
|
54
56
|
if (choosing === "maxSubagents") update({ ...state, maxSubagents: MAX_SUBAGENT_CHOICES[index]! });
|
|
57
|
+
else if (choosing === "contextBudget") update({ ...state, contextBudget: { ...state.contextBudget, softTokens: CONTEXT_BUDGET_CHOICES[index]! } });
|
|
55
58
|
else update({ ...state, goalRounds: GOAL_ROUND_CHOICES[index]! });
|
|
56
59
|
finishChoice();
|
|
57
60
|
} else if (page === "profiles") {
|
|
@@ -75,6 +78,15 @@ export async function openJarSettings(
|
|
|
75
78
|
else if (index === 4) update({ ...state, advisor: !state.advisor });
|
|
76
79
|
else if (index === 5) update({ ...state, advisorGates: !state.advisorGates });
|
|
77
80
|
else if (index === 6) update({ ...state, contextDiet: !state.contextDiet });
|
|
81
|
+
else if (index === 7) {
|
|
82
|
+
const at = CONTEXT_BUDGET_ACTIONS.indexOf(state.contextBudget.action);
|
|
83
|
+
update({ ...state, contextBudget: { ...state.contextBudget, action: CONTEXT_BUDGET_ACTIONS[(at + 1) % CONTEXT_BUDGET_ACTIONS.length]! } });
|
|
84
|
+
}
|
|
85
|
+
else if (index === 8) {
|
|
86
|
+
choosing = "contextBudget";
|
|
87
|
+
selected = Math.max(0, CONTEXT_BUDGET_CHOICES.indexOf(state.contextBudget.softTokens as never));
|
|
88
|
+
}
|
|
89
|
+
else if (index === 9) update({ ...state, readCache: !state.readCache });
|
|
78
90
|
} else if (index === 0) {
|
|
79
91
|
const at = Math.max(0, accents.indexOf(state.accent));
|
|
80
92
|
update({ ...state, accent: accents[(at + 1) % accents.length]! });
|
|
@@ -150,8 +162,10 @@ export async function openJarSettings(
|
|
|
150
162
|
+ (profiles ? " " + theme.fg(page === "profiles" ? "accent" : "muted", "[ Profiles ]") : ""));
|
|
151
163
|
const prefs = pi?.get();
|
|
152
164
|
const divider = theme.fg("dim", "├" + "─".repeat(Math.max(0, width - 2)) + "┤");
|
|
165
|
+
const budgetOn = state.contextBudget.action !== "off";
|
|
166
|
+
const chosen = choosing === "contextBudget" ? state.contextBudget.softTokens : choosing ? state[choosing] : undefined;
|
|
153
167
|
const allRows = choosing
|
|
154
|
-
? choices().map((choice, index) => row(index, String(choice),
|
|
168
|
+
? choices().map((choice, index) => row(index, choosing === "contextBudget" ? formatTokens(choice) + " tokens" : String(choice), chosen === choice ? "CURRENT" : "", true))
|
|
155
169
|
: page === "footer"
|
|
156
170
|
? FOOTER_FIELDS.map((field, index) => row(index, LABELS[field], state.footer[field] ? "ON" : "OFF", state.footer[field]))
|
|
157
171
|
: page === "pi" ? [
|
|
@@ -161,7 +175,10 @@ export async function openJarSettings(
|
|
|
161
175
|
row(3, "Max subagents (retained live pool)", String(state.maxSubagents), true),
|
|
162
176
|
row(4, "Advisor (jar_advisor, /advisor)", state.advisor ? "ON" : "OFF", state.advisor),
|
|
163
177
|
row(5, "Advisor gates (loops, repeated failures)", state.advisorGates ? "ON" : "OFF", state.advisorGates),
|
|
164
|
-
row(6, "Context diet (prior-turn reasoning)", state.contextDiet ? "ON" : "OFF", state.contextDiet)
|
|
178
|
+
row(6, "Context diet (prior-turn reasoning)", state.contextDiet ? "ON" : "OFF", state.contextDiet),
|
|
179
|
+
row(7, "Context budget (past the limit)", state.contextBudget.action.toUpperCase(), budgetOn),
|
|
180
|
+
row(8, "Context budget limit", formatTokens(state.contextBudget.softTokens), budgetOn),
|
|
181
|
+
row(9, "Repeated-read stub (unchanged re-reads)", state.readCache ? "ON" : "OFF", state.readCache)
|
|
165
182
|
] : page === "profiles" ? [
|
|
166
183
|
row(0, "Switch profile…", profiles!.activeName(), true),
|
|
167
184
|
row(1, "Create profile…", "OPEN", true)
|
|
@@ -178,11 +195,13 @@ export async function openJarSettings(
|
|
|
178
195
|
const rows = allRows.slice(start, start + pageSize());
|
|
179
196
|
return [top, tabs, divider,
|
|
180
197
|
line(theme.fg("accent", choosing === "maxSubagents" ? " MAX SUBAGENTS · RETAINED LIVE POOL"
|
|
198
|
+
: choosing === "contextBudget" ? " CONTEXT BUDGET · SOFT LIMIT"
|
|
181
199
|
: choosing === "goalRounds" ? " GOAL AUTO ROUNDS" : page === "footer" ? " FOOTER VISIBILITY" : page === "pi" ? " PI & WORKFLOWS" : page === "profiles" ? " PROFILES" : " APPEARANCE & MOTION")),
|
|
182
200
|
line(theme.fg("dim", explainPool() ? " Live pool includes idle/paused subagents"
|
|
183
201
|
: choosing ? " Select a value; Enter/click to save, Esc to cancel"
|
|
184
202
|
: page === "footer" ? ` Footer fields ${start + 1}–${start + rows.length}/${fields()}`
|
|
185
|
-
: page === "pi" ? "
|
|
203
|
+
: page === "pi" ? (selected === 7 || selected === 8 ? " Past the limit: suggest /compact or /new, or compact at a safe point"
|
|
204
|
+
: selected === 9 ? " An unchanged re-read of the same range returns a short stub" : " Fullscreen mode enables clicks and copy-on-select")
|
|
186
205
|
: page === "profiles" ? " Switch only in a fresh, idle session; roles via /roles" : " Accent cycles through loaded themes")),
|
|
187
206
|
...rows,
|
|
188
207
|
line(theme.fg("dim", choosing ? " ↑↓: choose · Enter/click: save · Esc: cancel"
|
package/src/settings.ts
CHANGED
|
@@ -25,11 +25,29 @@ export interface JarVisualSettings {
|
|
|
25
25
|
advisorGates: boolean;
|
|
26
26
|
/** Opt-in provider-context pruning of reasoning from completed earlier user turns. */
|
|
27
27
|
contextDiet: boolean;
|
|
28
|
+
/** Context-size guard: past softTokens, suggest /compact or /new, or compact at the next safe point. */
|
|
29
|
+
contextBudget: ContextBudget;
|
|
30
|
+
/** Opt-in: an unchanged repeated `read` of the same range returns a short stub instead of the content. */
|
|
31
|
+
readCache: boolean;
|
|
28
32
|
/** Glyph set for footer, composer and views: unicode, Nerd Font icons, or plain ascii. */
|
|
29
33
|
icons: IconSet;
|
|
30
34
|
footer: FooterSettings;
|
|
31
35
|
}
|
|
32
36
|
|
|
37
|
+
export const CONTEXT_BUDGET_ACTIONS = ["off", "suggest", "compact"] as const;
|
|
38
|
+
export type ContextBudgetAction = (typeof CONTEXT_BUDGET_ACTIONS)[number];
|
|
39
|
+
export interface ContextBudget { softTokens: number; action: ContextBudgetAction }
|
|
40
|
+
export const CONTEXT_BUDGET_CHOICES = [80_000, 120_000, 160_000, 200_000] as const;
|
|
41
|
+
export const DEFAULT_CONTEXT_BUDGET: ContextBudget = { softTokens: 120_000, action: "suggest" };
|
|
42
|
+
/** Accepts any stored integer budget in a sane range, not only the picker's choices. */
|
|
43
|
+
export function parseContextBudget(value: unknown): ContextBudget | undefined {
|
|
44
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
45
|
+
const { softTokens, action } = value as Record<string, unknown>;
|
|
46
|
+
if (typeof softTokens !== "number" || !Number.isInteger(softTokens) || softTokens < 10_000 || softTokens > 2_000_000) return undefined;
|
|
47
|
+
if (!CONTEXT_BUDGET_ACTIONS.some((choice) => choice === action)) return undefined;
|
|
48
|
+
return { softTokens, action: action as ContextBudgetAction };
|
|
49
|
+
}
|
|
50
|
+
|
|
33
51
|
export const GOAL_ROUND_CHOICES = [4, 8, 12, 20] as const;
|
|
34
52
|
export const MAX_SUBAGENT_CHOICES = [2, 4, 6, 8, 16] as const;
|
|
35
53
|
export type MaxSubagents = (typeof MAX_SUBAGENT_CHOICES)[number];
|
|
@@ -39,7 +57,7 @@ export function isMaxSubagents(value: unknown): value is MaxSubagents {
|
|
|
39
57
|
|
|
40
58
|
export const SETTINGS_FILE = "pi-jar-settings.json";
|
|
41
59
|
export function defaultVisualSettings(footer: FooterSettings = DEFAULT_FOOTER_SETTINGS): JarVisualSettings {
|
|
42
|
-
return { version: 1, accent: "follow", animations: true, ui: true, composer: true, mascot: true, suggestions: true, goalRounds: 8, maxSubagents:
|
|
60
|
+
return { version: 1, accent: "follow", animations: true, ui: true, composer: true, mascot: true, suggestions: true, goalRounds: 8, maxSubagents: 2, advisor: true, advisorGates: false, contextDiet: false, contextBudget: { ...DEFAULT_CONTEXT_BUDGET }, readCache: false, icons: "unicode", footer: { ...footer } };
|
|
43
61
|
}
|
|
44
62
|
|
|
45
63
|
/** Legacy footer choices are imported only while the new file is absent. Never modify the old file. */
|
|
@@ -73,6 +91,8 @@ export function loadVisualSettings(directory: string): JarVisualSettings {
|
|
|
73
91
|
advisor: typeof value.advisor === "boolean" ? value.advisor : fallback.advisor,
|
|
74
92
|
advisorGates: typeof value.advisorGates === "boolean" ? value.advisorGates : fallback.advisorGates,
|
|
75
93
|
contextDiet: typeof value.contextDiet === "boolean" ? value.contextDiet : fallback.contextDiet,
|
|
94
|
+
contextBudget: parseContextBudget(value.contextBudget) ?? fallback.contextBudget,
|
|
95
|
+
readCache: typeof value.readCache === "boolean" ? value.readCache : fallback.readCache,
|
|
76
96
|
icons: ICON_SETS.some((set) => set === value.icons) ? value.icons as IconSet : fallback.icons,
|
|
77
97
|
footer: Object.fromEntries(FOOTER_FIELDS.map((field) => [field,
|
|
78
98
|
typeof footer[field] === "boolean" ? footer[field] : fallback.footer[field]])) as FooterSettings
|
package/src/shells.ts
CHANGED
|
@@ -53,6 +53,9 @@ export interface ShellJob {
|
|
|
53
53
|
export type ShellEvent = { kind: "match" | "exit"; job: ShellJob };
|
|
54
54
|
export interface StartOptions { command: string; cwd: string; name?: string; watch?: string; notify?: boolean; purpose?: "task" | "service" }
|
|
55
55
|
export interface ShellManagerOptions { budgetChars?: number; liveTailChars?: number }
|
|
56
|
+
/** One caller read: `missed` lines were trimmed before they were read, `skipped` unread lines
|
|
57
|
+
* were retained but fell outside the requested line limit. */
|
|
58
|
+
export interface ShellRead { lines: string[]; missed: number; skipped: number }
|
|
56
59
|
export interface ShellStats {
|
|
57
60
|
/** Processes still alive, including killed jobs in their grace period and draining trees. */
|
|
58
61
|
live: number;
|
|
@@ -75,6 +78,11 @@ interface ShellEntry {
|
|
|
75
78
|
partial: string;
|
|
76
79
|
/** Retained line characters, maintained incrementally with the manager total. */
|
|
77
80
|
chars: number;
|
|
81
|
+
/** Absolute index (`dropped` + offset) of the first line the agent has not read. Absolute, so
|
|
82
|
+
* trimming never invalidates it; a cursor below `dropped` means trimmed lines went unread. */
|
|
83
|
+
cursor: number;
|
|
84
|
+
/** Length of the unterminated `partial` already shown; reset once that line is committed. */
|
|
85
|
+
partialRead: number;
|
|
78
86
|
timer?: ReturnType<typeof setTimeout>;
|
|
79
87
|
closeTimer?: ReturnType<typeof setTimeout>;
|
|
80
88
|
finished?: boolean;
|
|
@@ -179,7 +187,7 @@ export class ShellManager {
|
|
|
179
187
|
const id = "s" + this.next++;
|
|
180
188
|
const job: ShellJob = { id, name: cleanText(options.name || command, 40), command, cwd: options.cwd, startedAt: Date.now(), status: "running",
|
|
181
189
|
notify: options.notify ?? true, complete: false, purpose: options.purpose ?? (options.watch ? "service" : "task"), lines: [], dropped: 0, ...(options.watch ? { watch: options.watch } : {}) };
|
|
182
|
-
const entry: ShellEntry = { job, partial: "", chars: 0, ...(pattern ? { pattern } : {}) };
|
|
190
|
+
const entry: ShellEntry = { job, partial: "", chars: 0, cursor: 0, partialRead: 0, ...(pattern ? { pattern } : {}) };
|
|
183
191
|
this.jobs.set(id, entry);
|
|
184
192
|
// Own process group so kill() stops the whole tree (dev servers spawn children).
|
|
185
193
|
const child = this.spawnShell("/bin/sh", ["-c", command], { cwd: options.cwd, detached: process.platform !== "win32", stdio: ["ignore", "pipe", "pipe"], env: process.env });
|
|
@@ -230,7 +238,9 @@ export class ShellManager {
|
|
|
230
238
|
private receive(entry: ShellEntry, text: string): void {
|
|
231
239
|
const parts = (entry.partial + text.replace(/\r\n/g, "\n")).split("\n");
|
|
232
240
|
entry.partial = parts.pop() ?? "";
|
|
233
|
-
|
|
241
|
+
// A committed line repeats any prefix shown as partial; the new partial is unread.
|
|
242
|
+
if (parts.length) entry.partialRead = 0;
|
|
243
|
+
if (entry.partial.length > MAX_LINE_CHARS) { this.push(entry, entry.partial); entry.partial = ""; entry.partialRead = 0; }
|
|
234
244
|
for (const line of parts) this.push(entry, line);
|
|
235
245
|
}
|
|
236
246
|
|
|
@@ -303,7 +313,7 @@ export class ShellManager {
|
|
|
303
313
|
this.onChange?.();
|
|
304
314
|
}
|
|
305
315
|
|
|
306
|
-
/** Last `count` lines (1–400). */
|
|
316
|
+
/** Last `count` lines (1–400) for display; never moves the agent's read cursor. */
|
|
307
317
|
output(id: string, count = 40): string[] {
|
|
308
318
|
const entry = this.jobs.get(id);
|
|
309
319
|
if (!entry) throw new Error("no shell " + id);
|
|
@@ -313,6 +323,29 @@ export class ShellManager {
|
|
|
313
323
|
return [...entry.job.lines.slice(-(limit - 1)), entry.partial];
|
|
314
324
|
}
|
|
315
325
|
|
|
326
|
+
/** The agent's read: lines since its cursor (newest `count` kept), or the trailing window with
|
|
327
|
+
* `all`. Either way the cursor moves to the end, so the next read returns only newer lines.
|
|
328
|
+
* An unterminated last line is shown only when it grew since the previous read. */
|
|
329
|
+
read(id: string, count = 40, all = false): ShellRead {
|
|
330
|
+
const entry = this.jobs.get(id);
|
|
331
|
+
if (!entry) throw new Error("no shell " + id);
|
|
332
|
+
const { job } = entry;
|
|
333
|
+
let result: ShellRead;
|
|
334
|
+
if (all) result = { lines: this.output(id, count), missed: 0, skipped: 0 };
|
|
335
|
+
else {
|
|
336
|
+
const limit = Math.max(1, Math.min(MAX_OUTPUT_LINES, Math.floor(count)));
|
|
337
|
+
const partial = entry.partial.length > entry.partialRead;
|
|
338
|
+
const first = Math.max(0, entry.cursor - job.dropped);
|
|
339
|
+
const skipped = Math.max(0, job.lines.length - first + (partial ? 1 : 0) - limit);
|
|
340
|
+
const lines = job.lines.slice(first + skipped);
|
|
341
|
+
if (partial) lines.push(entry.partial);
|
|
342
|
+
result = { lines, missed: Math.max(0, job.dropped - entry.cursor), skipped };
|
|
343
|
+
}
|
|
344
|
+
entry.cursor = job.dropped + job.lines.length;
|
|
345
|
+
entry.partialRead = entry.partial.length;
|
|
346
|
+
return result;
|
|
347
|
+
}
|
|
348
|
+
|
|
316
349
|
/** Diagnostics snapshot, computed on demand in one pass over ≤ MAX_KEPT_SHELLS jobs. */
|
|
317
350
|
stats(): ShellStats {
|
|
318
351
|
let live = 0;
|
|
@@ -407,6 +440,14 @@ const boundedTail = (lines: readonly string[], limit = MAX_TOOL_OUTPUT_CHARS): s
|
|
|
407
440
|
return (omitted ? `… ${omitted} earlier line(s) omitted\n` : "") + kept.join("\n");
|
|
408
441
|
};
|
|
409
442
|
|
|
443
|
+
/** A read as tool text: notes for lines the caller will not see, then the bounded lines. */
|
|
444
|
+
const readText = (read: ShellRead): string => {
|
|
445
|
+
const notes: string[] = [];
|
|
446
|
+
if (read.missed) notes.push(`… ${read.missed} unread line(s) were trimmed from the log`);
|
|
447
|
+
if (read.skipped) notes.push(`… ${read.skipped} older unread line(s) skipped; raise lines to see more`);
|
|
448
|
+
return [...notes, boundedTail(read.lines)].join("\n");
|
|
449
|
+
};
|
|
450
|
+
|
|
410
451
|
/** The message that wakes the agent when a watched shell matches or ends. */
|
|
411
452
|
export function shellEventMessage(event: ShellEvent): string {
|
|
412
453
|
const { job } = event;
|
|
@@ -426,8 +467,9 @@ const Parameters = Type.Object({
|
|
|
426
467
|
purpose: Type.Optional(Type.Union([Type.Literal("task"), Type.Literal("service")], { description: "task is finite and must be checked before claiming completion; service is intentionally long-lived (default with watch)." })),
|
|
427
468
|
id: Type.Optional(Type.String({ description: "Shell id for output/kill/wait/peek, e.g. s1." })),
|
|
428
469
|
ids: Type.Optional(Type.Array(Type.String(), { maxItems: 20, description: "Selected ids for wait/peek. Omit to inspect all jobs or wait for finite tasks only." })),
|
|
429
|
-
waitMs: Type.Optional(Type.Number({ minimum: 0, maximum: 30000, description: "
|
|
430
|
-
lines: Type.Optional(Type.Number({ description: "
|
|
470
|
+
waitMs: Type.Optional(Type.Number({ minimum: 0, maximum: 30000, description: "start: wait up to this long (0–30000ms) for the job to exit; returns its exit code and output in one call, or the output so far if still running. wait: bounded event-driven wait (default 1000ms). Esc cancels the wait, not the shell." })),
|
|
471
|
+
lines: Type.Optional(Type.Number({ description: "Line limit for output and start with waitMs (default 40, max 400): the newest unread lines, or the trailing window with all." })),
|
|
472
|
+
all: Type.Optional(Type.Boolean({ description: "output: return the trailing window again instead of only lines new since your last read." }))
|
|
431
473
|
});
|
|
432
474
|
|
|
433
475
|
export function registerShells(pi: ExtensionAPI, shells: () => ShellManager | undefined): void {
|
|
@@ -435,13 +477,11 @@ export function registerShells(pi: ExtensionAPI, shells: () => ShellManager | un
|
|
|
435
477
|
pi.registerTool({
|
|
436
478
|
name: SHELL_TOOL,
|
|
437
479
|
label: "shell",
|
|
438
|
-
description: "
|
|
439
|
-
promptSnippet: "
|
|
480
|
+
description: "Background jobs: services, watchers and long commands; finite commands under ~2 min (build, test, git) belong in bash. start returns immediately, or with waitMs waits (≤30s) for exit and returns the exit code and output in one call. output returns only lines new since your last read (all: true for the trailing window). peek/list read status, wait blocks boundedly on selected finite jobs, kill stops the process tree. Completion notifications are compact, coalesced and arrive at the next safe turn boundary; they cannot interrupt an in-flight response, so when no useful work remains call wait instead of idling. Results read via output/peek/list/wait/start-with-waitMs are acknowledged and do not replay. Set watch to a regex for the line you wait for (e.g. \"listening|ready|error\"); mark never-ending jobs purpose: service, never wait on services, and kill shells you no longer need.",
|
|
481
|
+
promptSnippet: "Services, watchers and long jobs in the background.",
|
|
440
482
|
promptGuidelines: [
|
|
441
|
-
"Use
|
|
442
|
-
"
|
|
443
|
-
"Completion events cannot interrupt an in-flight model response. If no useful work remains, call wait for the relevant finite jobs immediately instead of thinking or waiting for an automatic wake-up.",
|
|
444
|
-
"Notifications contain coalesced status, not log dumps. Inspect output only for relevant diagnostics. Mark never-ending jobs purpose: service and kill shells you no longer need."
|
|
483
|
+
"Use bash for finite commands expected under ~2 min (build, test, git); jar_shell only for services, watchers or long jobs; short-but-uncertain: jar_shell start with waitMs.",
|
|
484
|
+
"Don't poll jar_shell; keep working, then wait on explicit ids before the final summary. Never claim a check passed without its exit code."
|
|
445
485
|
],
|
|
446
486
|
parameters: Parameters,
|
|
447
487
|
async execute(_id, params, signal, _update, ctx) {
|
|
@@ -454,15 +494,37 @@ export function registerShells(pi: ExtensionAPI, shells: () => ShellManager | un
|
|
|
454
494
|
try {
|
|
455
495
|
switch (params.action) {
|
|
456
496
|
case "start": {
|
|
497
|
+
// Validate before spawning: failing after the spawn would hide the running job's id.
|
|
498
|
+
const waitMs = params.waitMs === undefined ? undefined : Math.max(0, Math.min(30_000, params.waitMs));
|
|
499
|
+
if (waitMs !== undefined && !Number.isFinite(waitMs)) return reply("start failed: waitMs must be a number of milliseconds.");
|
|
457
500
|
const job = manager.start({ command: params.command ?? "", cwd: ctx.cwd, ...(params.name ? { name: params.name } : {}), ...(params.watch ? { watch: params.watch } : {}),
|
|
458
501
|
...(params.notify !== undefined ? { notify: params.notify } : {}), ...(params.purpose ? { purpose: params.purpose } : {}) });
|
|
459
|
-
|
|
502
|
+
const started = `Started ${describe(job)} (pid ${job.pid ?? "?"}).`;
|
|
503
|
+
const later = job.notify ? "Completion reaches the model at the next safe turn boundary (or wakes it when idle). If no other useful work remains, use wait now." : "Notifications are off; check it with output.";
|
|
504
|
+
if (waitMs === undefined) return reply(`${started} ${later}`);
|
|
505
|
+
let completed: boolean;
|
|
506
|
+
try { completed = await manager.wait([job.id], waitMs, signal); }
|
|
507
|
+
catch (error) {
|
|
508
|
+
// Esc ends the wait, never the job; the caller still needs its id.
|
|
509
|
+
if (!signal?.aborted) throw error;
|
|
510
|
+
return reply(`${started} Wait cancelled; the job keeps running. ${later}`);
|
|
511
|
+
}
|
|
512
|
+
const read = manager.read(job.id, params.lines ?? 40);
|
|
513
|
+
const output = read.lines.length || read.missed ? "\n" + readText(read) : " (no output)";
|
|
514
|
+
// These lines and (when finished) the exit code are surfaced now: a later completion
|
|
515
|
+
// notification would only repeat them.
|
|
516
|
+
manager.acknowledge([job.id]);
|
|
517
|
+
if (!completed) return reply(`${started} Still running after ${waitMs}ms. ${later}${output}`);
|
|
518
|
+
return reply(describe(manager.summaries().find(summary => summary.id === job.id) ?? job) + output);
|
|
460
519
|
}
|
|
461
520
|
case "output": {
|
|
462
521
|
if (!params.id) return reply("output needs an id.");
|
|
463
|
-
const
|
|
464
|
-
manager.
|
|
465
|
-
|
|
522
|
+
const { id } = params;
|
|
523
|
+
const read = manager.read(id, params.lines ?? 40, params.all === true);
|
|
524
|
+
manager.acknowledge([id]);
|
|
525
|
+
const head = describe(manager.summaries().find(summary => summary.id === id)!);
|
|
526
|
+
if (read.lines.length || read.missed) return reply(`${head}\n${readText(read)}`);
|
|
527
|
+
return reply(`${head} · ${params.all ? "no output" : "no new output; all: true repeats the last lines"}`);
|
|
466
528
|
}
|
|
467
529
|
case "kill":
|
|
468
530
|
if (!params.id) return reply("kill needs an id.");
|
package/src/side-model.ts
CHANGED
|
@@ -26,6 +26,19 @@ export class SideUsage {
|
|
|
26
26
|
|
|
27
27
|
export interface SideAnswer { text: string; model: string }
|
|
28
28
|
|
|
29
|
+
/** Roles already told, this process, that they ran on the current (main) model. */
|
|
30
|
+
const unsetRoleNotices = new Set<string>();
|
|
31
|
+
|
|
32
|
+
/** An unassigned side role silently costs main-model prices; say so once per role. */
|
|
33
|
+
function noteCurrentModel(ctx: ExtensionContext, role: string, model: string): void {
|
|
34
|
+
if (unsetRoleNotices.has(role)) return;
|
|
35
|
+
unsetRoleNotices.add(role);
|
|
36
|
+
try {
|
|
37
|
+
ctx.ui?.notify(`${role} role is unset, so it used the current model (${model}). `
|
|
38
|
+
+ `Assign a cheaper one with /roles set ${role} PROVIDER/MODEL.`, "info");
|
|
39
|
+
} catch { /* the notice is advisory */ }
|
|
40
|
+
}
|
|
41
|
+
|
|
29
42
|
/**
|
|
30
43
|
* Ask the model assigned to `role` (or the current model) a one-shot question outside the
|
|
31
44
|
* conversation. Tries configured fallbacks in order on failure; cancellation never retries.
|
|
@@ -71,6 +84,7 @@ export async function askRole(ctx: ExtensionContext, roles: ModelRoleManager, us
|
|
|
71
84
|
}
|
|
72
85
|
text = text.trim();
|
|
73
86
|
if (!text) throw new Error(`${name} returned an empty answer`);
|
|
87
|
+
if (!assigned) noteCurrentModel(ctx, role, name);
|
|
74
88
|
return { text, model: name };
|
|
75
89
|
} catch (error) {
|
|
76
90
|
signal?.throwIfAborted();
|
package/src/subagent-tools.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { workspacePathAllowed } from "./delegate-worktree.ts";
|
|
|
2
2
|
|
|
3
3
|
export const CHILD_TOOLS_ENV = "PI_JAR_CHILD_TOOLS";
|
|
4
4
|
export const WEB_TOOLS = ["web_enable", "web_search", "fetch_content", "get_search_content", "source_check"] as const;
|
|
5
|
-
export const RECURSIVE_TOOLS = new Set(["jar_delegate", "jar_subagent", "
|
|
5
|
+
export const RECURSIVE_TOOLS = new Set(["jar_delegate", "jar_subagent", "jar_council"]);
|
|
6
6
|
|
|
7
7
|
/** Multi-file tools must validate each effective target, not just the top-level default path. */
|
|
8
8
|
export function worktreeToolViolation(root: string, cwd: string, name: string, args: unknown): string | undefined {
|
package/src/suggest.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
2
2
|
import { Text } from "@earendil-works/pi-tui";
|
|
3
3
|
import { Type } from "typebox";
|
|
4
4
|
import { cleanText } from "./status.ts";
|
|
5
|
+
import { setToolActive } from "./tool-activation.ts";
|
|
5
6
|
|
|
6
7
|
export const SUGGEST_TOOL = "jar_suggest";
|
|
7
8
|
const MAX_SUGGESTION = 160;
|
|
@@ -31,12 +32,8 @@ export function registerSuggestions(pi: ExtensionAPI, state: SuggestionState, op
|
|
|
31
32
|
pi.registerTool?.({
|
|
32
33
|
name: SUGGEST_TOOL,
|
|
33
34
|
label: "suggest",
|
|
34
|
-
description: "Optionally offer one useful next prompt as ghost text in the input box; Tab accepts it for editing.",
|
|
35
|
-
promptSnippet: "Optionally
|
|
36
|
-
promptGuidelines: [
|
|
37
|
-
"jar_suggest is best-effort, not mandatory. Use it only when there is one clear next action worth surfacing; skip it for routine completions.",
|
|
38
|
-
"When used, call it once as the final action with one imperative line under 120 characters. Do not mention jar_suggest in your reply."
|
|
39
|
-
],
|
|
35
|
+
description: "Optionally offer one useful next prompt (one imperative line under 120 characters) as ghost text in the input box; Tab accepts it for editing. Call once as the final action; do not mention it in your reply.",
|
|
36
|
+
promptSnippet: "Optionally end with one jar_suggest call only when one clear next prompt is worth surfacing; skip it for routine completions.",
|
|
40
37
|
parameters: Type.Object({ suggestion: Type.String({ description: "The user's likely next prompt, one line." }) }),
|
|
41
38
|
execute: async (_id, params) => {
|
|
42
39
|
const ok = state.set(params.suggestion ?? "");
|
|
@@ -50,11 +47,7 @@ export function registerSuggestions(pi: ExtensionAPI, state: SuggestionState, op
|
|
|
50
47
|
});
|
|
51
48
|
|
|
52
49
|
const sync = () => {
|
|
53
|
-
|
|
54
|
-
const active = pi.getActiveTools();
|
|
55
|
-
const has = active.includes(SUGGEST_TOOL);
|
|
56
|
-
if (options.enabled() && !has && pi.getAllTools().some((tool) => tool.name === SUGGEST_TOOL)) pi.setActiveTools([...active, SUGGEST_TOOL]);
|
|
57
|
-
else if (!options.enabled() && has) pi.setActiveTools(active.filter((name) => name !== SUGGEST_TOOL));
|
|
50
|
+
setToolActive(pi, SUGGEST_TOOL, options.enabled());
|
|
58
51
|
if (!options.enabled()) state.clear();
|
|
59
52
|
};
|
|
60
53
|
|
package/src/task-tool.ts
CHANGED
|
@@ -87,16 +87,10 @@ export function registerTaskTool(
|
|
|
87
87
|
pi.registerTool({
|
|
88
88
|
name: "jar_todo",
|
|
89
89
|
label: "tasks",
|
|
90
|
-
description: "Maintain session tasks
|
|
91
|
-
promptSnippet: "Track
|
|
90
|
+
description: "Maintain session tasks. write sets the complete current plan (omitted tasks are removed; each item has content, status, optional activeForm and one level of `subtasks`, whose completion completes the parent; progress counts leaves). update changes one task by stable id, append adds genuinely new work mid-run (not a new plan), remove deletes one task tree; start/done/open/edit are concise status operations. Stable IDs are returned for every task.",
|
|
91
|
+
promptSnippet: "Track work with 3+ steps or several requests in jar_todo (skip trivial requests and questions): write the complete plan for a new request, start fresh once the old list is completed.",
|
|
92
92
|
promptGuidelines: [
|
|
93
|
-
"
|
|
94
|
-
"Prefer write when establishing the task plan for a new request. If the existing checklist is fully completed, start the next request with a fresh write rather than appending onto old completed work.",
|
|
95
|
-
"Use update with a stable id to revise one existing task and remove to delete one task tree. Use append mainly when the user steers or adds genuinely new requirements while an active checklist is already in progress; do not use append as the default way to create a new plan.",
|
|
96
|
-
"write with todos is the complete current task plan and removes omitted tasks. Each item has content, status, and optional activeForm/subtasks.",
|
|
97
|
-
"Break a large task into `subtasks` (same shape, one level deep). A parent's status follows its subtasks: it is completed when all of them are. Progress counts leaf tasks (subtasks and tasks without subtasks).",
|
|
98
|
-
"Keep exactly one leaf task in_progress at a time. Mark a task in_progress before you start it and completed immediately after it is verified; do not batch completions.",
|
|
99
|
-
"Only mark a task completed when it is fully done. If tests fail, work is partial or you are blocked, keep it in_progress and add a task for what must be resolved. Remove tasks that are no longer relevant."
|
|
93
|
+
"Keep exactly one leaf in_progress; mark it completed right after verifying it. Never batch completions or complete partial, failing or blocked work: keep it open and add a task for the blocker."
|
|
100
94
|
],
|
|
101
95
|
parameters: Parameters,
|
|
102
96
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Activate or deactivate one registered tool. Inactive tools send neither their schema nor their
|
|
5
|
+
* prompt rules, so mode-specific tools are active only while their mode is. Pi applies a change
|
|
6
|
+
* made in before_agent_start to that run; toggling only at mode boundaries keeps the prompt stable.
|
|
7
|
+
*/
|
|
8
|
+
export function setToolActive(pi: ExtensionAPI, name: string, wanted: boolean): void {
|
|
9
|
+
if (typeof pi.getActiveTools !== "function" || typeof pi.setActiveTools !== "function") return;
|
|
10
|
+
const active = pi.getActiveTools();
|
|
11
|
+
const has = active.includes(name);
|
|
12
|
+
if (wanted && !has && pi.getAllTools().some((tool) => tool.name === name)) pi.setActiveTools([...active, name]);
|
|
13
|
+
else if (!wanted && has) pi.setActiveTools(active.filter((tool) => tool !== name));
|
|
14
|
+
}
|
package/src/usage-view.ts
CHANGED
|
@@ -53,7 +53,7 @@ function resetText(window: QuotaWindow, now: number): string {
|
|
|
53
53
|
return `Resets ${when} (in ${duration(Math.max(0, at - now))})`;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
export interface UsageView { stats: UsageStats; provider?: string; quota?: Quota; quotaEnabled: boolean; now: number }
|
|
56
|
+
export interface UsageView { stats: UsageStats; provider?: string; quota?: Quota; quotaFailure?: { error: string; retryInMs: number }; quotaEnabled: boolean; now: number }
|
|
57
57
|
|
|
58
58
|
/** Claude-style usage page: session totals, per-model breakdown, then plan limit bars. */
|
|
59
59
|
export function usageLines(view: UsageView, width: number, fg: Paint): string[] {
|
|
@@ -79,9 +79,13 @@ export function usageLines(view: UsageView, width: number, fg: Paint): string[]
|
|
|
79
79
|
lines.push("", heading(`Plan usage limits${view.provider ? " · " + view.provider : ""}`));
|
|
80
80
|
const windows: [string, QuotaWindow | undefined][] = [["Current session (5h)", view.quota?.fiveHour], ["Current week", view.quota?.week]];
|
|
81
81
|
if (!windows.some(([, window]) => window)) {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
82
|
+
const supported = view.provider === "anthropic" || view.provider === "openai-codex";
|
|
83
|
+
const failure = view.quotaFailure;
|
|
84
|
+
lines.push(fg(supported && view.quotaEnabled && failure ? "warning" : "dim", !supported
|
|
85
|
+
? " Plan limits are available for anthropic and openai-codex subscriptions."
|
|
86
|
+
: !view.quotaEnabled ? " Limit lookup is off; enable it with /jar quota on."
|
|
87
|
+
: failure ? ` Limits unavailable: ${failure.error}. Retrying in ${duration(failure.retryInMs)}.`
|
|
88
|
+
: " Loading limits…"));
|
|
85
89
|
return lines;
|
|
86
90
|
}
|
|
87
91
|
for (const [label, window] of windows) {
|
package/src/usage.ts
CHANGED
|
@@ -12,6 +12,8 @@ export function sessionCost(ctx: Pick<ExtensionContext, "sessionManager">): numb
|
|
|
12
12
|
return total;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
/** `cost $1.23`, plus the recent average per provider call when there is one: `cost $1.23 · $0.04/call`. */
|
|
16
|
+
export function formatCost(value: number, perCall?: number): string {
|
|
17
|
+
const money = (amount: number) => `$${amount < 0.01 && amount > 0 ? amount.toFixed(4) : amount.toFixed(2)}`;
|
|
18
|
+
return `cost ${money(value)}` + (perCall !== undefined && perCall > 0 ? ` · ${money(perCall)}/call` : "");
|
|
17
19
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** How one hidden workflow message type is projected into provider context. */
|
|
2
|
+
export interface WorkflowMessageRule {
|
|
3
|
+
/** False drops every message of this type (its mode is off). */
|
|
4
|
+
keep: boolean;
|
|
5
|
+
/** Drop a message whose content repeats the previously kept one of the same type. */
|
|
6
|
+
dedupe?: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Provider-context projection for hidden workflow prompts (plan/goal context, reminders,
|
|
11
|
+
* continuations). Pi caches the conversation prefix, so a message once sent must keep being sent:
|
|
12
|
+
* every keep/drop decision depends only on the messages before it, never on later ones. Dropping
|
|
13
|
+
* an older copy when a newer one arrives would change the prefix and re-bill the whole previous
|
|
14
|
+
* run uncached. Returns undefined when nothing changes.
|
|
15
|
+
*/
|
|
16
|
+
export function projectWorkflowMessages<T>(messages: T[], rules: Readonly<Record<string, WorkflowMessageRule>>): T[] | undefined {
|
|
17
|
+
const last = new Map<string, string>();
|
|
18
|
+
let projected: T[] | undefined;
|
|
19
|
+
for (let index = 0; index < messages.length; index++) {
|
|
20
|
+
const message = messages[index]!;
|
|
21
|
+
const type = (message as { customType?: string }).customType;
|
|
22
|
+
const rule = type === undefined ? undefined : rules[type];
|
|
23
|
+
let keep = true;
|
|
24
|
+
if (rule) {
|
|
25
|
+
if (!rule.keep) keep = false;
|
|
26
|
+
else if (rule.dedupe) {
|
|
27
|
+
const raw = (message as { content?: unknown }).content;
|
|
28
|
+
const content = typeof raw === "string" ? raw : JSON.stringify(raw ?? "");
|
|
29
|
+
if (last.get(type!) === content) keep = false;
|
|
30
|
+
else last.set(type!, content);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (!keep) projected ??= messages.slice(0, index);
|
|
34
|
+
else projected?.push(message);
|
|
35
|
+
}
|
|
36
|
+
return projected;
|
|
37
|
+
}
|