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/src/profile-ui.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import type { JarProfile, ProfileStore } from "./profiles.ts";
3
+ import type { JarVisualSettings } from "./settings.ts";
3
4
 
4
5
  export type ProfileAction = "create" | "switch";
5
6
 
@@ -14,11 +15,16 @@ export interface ProfileUiOptions {
14
15
  openRoles(): Promise<void>;
15
16
  }
16
17
 
18
+ /** Opt-in preset for heavy parallel work: the current settings with a larger retained subagent pool. */
19
+ export const SWARM_SETTINGS = { maxSubagents: 4 } as const satisfies Partial<JarVisualSettings>;
17
20
  const CREATE = "+ Create profile…";
21
+ const SWARM = `+ Create Swarm profile (${SWARM_SETTINGS.maxSubagents} subagents)…`;
18
22
  const FOLLOW = "Follow Pi";
19
23
 
20
- /** Name and theme are the only creation questions; roles are configured afterwards in the roles UI. */
21
- export async function promptNewProfile(ctx: ExtensionContext, options: Pick<ProfileUiOptions, "store" | "themes" | "create">): Promise<JarProfile | undefined> {
24
+ /** Name and theme are the only creation questions; roles are configured afterwards in the roles UI.
25
+ * A preset patches the copied settings once the copy is saved. */
26
+ export async function promptNewProfile(ctx: ExtensionContext, options: Pick<ProfileUiOptions, "store" | "themes" | "create">,
27
+ preset?: Partial<JarVisualSettings>): Promise<JarProfile | undefined> {
22
28
  const name = await ctx.ui.input("New profile name (1–32 characters)", "");
23
29
  if (name === undefined) return undefined;
24
30
  if (/[\u0000-\u001f\u007f-\u009f]/.test(name)) { ctx.ui.notify("Profile name cannot contain control characters", "warning"); return undefined; }
@@ -31,21 +37,30 @@ export async function promptNewProfile(ctx: ExtensionContext, options: Pick<Prof
31
37
  }
32
38
  const selected = await ctx.ui.select("Choose a theme for " + normalized, [FOLLOW, ...options.themes.filter((item) => item !== "follow")]);
33
39
  if (!selected) return undefined;
34
- try { return options.create(normalized, selected === FOLLOW ? "follow" : selected); }
40
+ let profile: JarProfile;
41
+ try { profile = options.create(normalized, selected === FOLLOW ? "follow" : selected); }
35
42
  catch (error) { ctx.ui.notify("Could not create profile: " + (error as Error).message, "error"); return undefined; }
43
+ if (!preset) return profile;
44
+ try { return options.store.update(profile.name, { settings: { ...profile.settings, ...preset } }); }
45
+ catch (error) {
46
+ ctx.ui.notify(`Created profile ${profile.name}, but could not apply its preset: ${(error as Error).message}`, "warning");
47
+ return profile;
48
+ }
36
49
  }
37
50
 
38
51
  /** /profiles and the settings Profiles tab: create a profile or switch to one, nothing else. */
39
52
  export async function openProfiles(ctx: ExtensionContext, options: ProfileUiOptions, action?: ProfileAction): Promise<void> {
40
53
  if (!ctx.hasUI || ctx.mode !== "tui") return;
41
54
  const active = options.store.activeName;
55
+ let preset: Partial<JarVisualSettings> | undefined;
42
56
  if (action !== "create") {
43
57
  const labels = new Map(options.store.list().map((profile) => [profile.name === active ? `${profile.name} (active)` : profile.name, profile.name]));
44
- const choice = await ctx.ui.select(action === "switch" ? "Switch profile" : "Profiles", action === "switch" ? [...labels.keys()] : [CREATE, ...labels.keys()]);
58
+ const choice = await ctx.ui.select(action === "switch" ? "Switch profile" : "Profiles", action === "switch" ? [...labels.keys()] : [CREATE, SWARM, ...labels.keys()]);
45
59
  if (!choice) return;
46
- if (choice !== CREATE) { await options.select(labels.get(choice)!); return; }
60
+ if (choice !== CREATE && choice !== SWARM) { await options.select(labels.get(choice)!); return; }
61
+ if (choice === SWARM) preset = SWARM_SETTINGS;
47
62
  }
48
- const profile = await promptNewProfile(ctx, options);
63
+ const profile = await promptNewProfile(ctx, options, preset);
49
64
  if (!profile) return;
50
65
  if (options.switchBlocked()) {
51
66
  ctx.ui.notify(`Created profile ${profile.name} with ${active}'s model roles. Switch to it in a fresh session with /profiles, then set its roles with /roles.`, "info");
package/src/profiles.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { isRoleName, normalizeSpec, parseRoleConfig, type RoleConfig } from "./model-roles.ts";
4
- import { isMaxSubagents, defaultVisualSettings, loadVisualSettings, type JarVisualSettings } from "./settings.ts";
4
+ import { isMaxSubagents, defaultVisualSettings, loadVisualSettings, parseContextBudget, DEFAULT_CONTEXT_BUDGET, type JarVisualSettings } from "./settings.ts";
5
5
  import { ACCENT_NAMES } from "./accent.ts";
6
6
  import { FOOTER_FIELDS } from "./footer-settings.ts";
7
7
  import { ICON_SETS } from "./icons.ts";
@@ -41,7 +41,9 @@ function normalizeProfile(value: unknown): JarProfile | undefined {
41
41
  || !isPlainObject(settings.footer) || typeof settings.animations !== "boolean" || typeof settings.ui !== "boolean"
42
42
  || typeof settings.composer !== "boolean" || typeof settings.mascot !== "boolean" || typeof settings.suggestions !== "boolean"
43
43
  || typeof settings.advisor !== "boolean" || typeof settings.advisorGates !== "boolean"
44
- || (settings.contextDiet !== undefined && typeof settings.contextDiet !== "boolean") || !Number.isInteger(settings.goalRounds)
44
+ || (settings.contextDiet !== undefined && typeof settings.contextDiet !== "boolean")
45
+ || (settings.contextBudget !== undefined && !parseContextBudget(settings.contextBudget))
46
+ || (settings.readCache !== undefined && typeof settings.readCache !== "boolean") || !Number.isInteger(settings.goalRounds)
45
47
  || (settings.goalRounds as number) < 1 || (settings.goalRounds as number) > 50
46
48
  || !isMaxSubagents(settings.maxSubagents) || typeof settings.accent !== "string"
47
49
  || !(settings.accent === "follow" || settings.accent === "default" || ACCENT_NAMES.includes(settings.accent as never))
@@ -50,7 +52,9 @@ function normalizeProfile(value: unknown): JarProfile | undefined {
50
52
  if (raw.name === DEFAULT_PROFILE && raw.id !== "default") return undefined;
51
53
  if (raw.name !== DEFAULT_PROFILE && (typeof raw.id !== "string" || !/^profile-[a-z0-9-]{8,64}$/.test(raw.id))) return undefined;
52
54
  for (const [role, spec] of Object.entries(raw.roles.roles)) if (!isRoleName(role) || typeof spec !== "string" || !normalizeSpec(spec)) return undefined;
53
- return { id: raw.id as string, name: raw.name, theme: raw.theme, settings: { ...raw.settings, contextDiet: settings.contextDiet ?? false } as unknown as JarVisualSettings, roles: parseRoleConfig(raw.roles) };
55
+ const contextBudget = parseContextBudget(settings.contextBudget) ?? { ...DEFAULT_CONTEXT_BUDGET };
56
+ return { id: raw.id as string, name: raw.name, theme: raw.theme, settings: { ...raw.settings, contextDiet: settings.contextDiet ?? false,
57
+ contextBudget, readCache: settings.readCache ?? false } as unknown as JarVisualSettings, roles: parseRoleConfig(raw.roles) };
54
58
  }
55
59
  function atomicSave(file: string, document: ProfileDocument): string {
56
60
  mkdirSync(join(file, ".."), { recursive: true });
package/src/quota.ts CHANGED
@@ -69,57 +69,70 @@ function codexAccountId(token: string): string | undefined {
69
69
  } catch { return undefined; }
70
70
  }
71
71
 
72
- /** Never persist credentials; resolve through Pi's public model registry only on explicit opt-in. */
72
+ /**
73
+ * Never persist credentials; resolve through Pi's public model registry only on explicit opt-in.
74
+ * Resolves undefined only when cancelled; every other miss throws a short, user-facing reason.
75
+ */
73
76
  export async function fetchQuota(provider: QuotaProvider, resolveAuth: (provider: string) => Promise<{ auth: { apiKey?: string }; source?: string } | undefined>, signal: AbortSignal): Promise<Quota | undefined> {
77
+ const resolved = await resolveAuth(provider);
78
+ if (signal.aborted) return undefined;
79
+ if (!resolved?.source?.toLowerCase().includes("oauth") || !resolved.auth.apiKey) throw new Error("needs a subscription login (/login), not an API key");
80
+ const token = resolved.auth.apiKey;
81
+ const headers: Record<string, string> = { Authorization: `Bearer ${token}`, Accept: "application/json" };
82
+ let url: string;
83
+ if (provider === "openai-codex") {
84
+ const id = codexAccountId(token);
85
+ if (!id) throw new Error("login token has no ChatGPT account id");
86
+ headers["ChatGPT-Account-Id"] = id;
87
+ url = "https://chatgpt.com/backend-api/wham/usage";
88
+ } else {
89
+ headers["anthropic-beta"] = "oauth-2025-04-20";
90
+ url = "https://api.anthropic.com/api/oauth/usage";
91
+ }
92
+ let response: Response;
74
93
  try {
75
- const resolved = await resolveAuth(provider);
76
- if (signal.aborted || !resolved?.source?.toLowerCase().includes("oauth") || !resolved.auth.apiKey) return undefined;
77
- const token = resolved.auth.apiKey;
78
- const headers: Record<string, string> = { Authorization: `Bearer ${token}`, Accept: "application/json" };
79
- let url: string;
80
- if (provider === "openai-codex") {
81
- const id = codexAccountId(token);
82
- if (!id) return undefined;
83
- headers["ChatGPT-Account-Id"] = id;
84
- url = "https://chatgpt.com/backend-api/wham/usage";
85
- } else {
86
- headers["anthropic-beta"] = "oauth-2025-04-20";
87
- url = "https://api.anthropic.com/api/oauth/usage";
88
- }
89
- const response = await fetch(url, { method: "GET", headers, signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]) });
90
- if (!response.ok) return undefined;
91
- const data: unknown = await response.json();
92
- if (!data || typeof data !== "object") return undefined;
93
- const body = data as Record<string, unknown>;
94
- if (provider === "openai-codex") {
95
- const rate = body.rate_limit && typeof body.rate_limit === "object" ? body.rate_limit as Record<string, unknown> : {};
96
- const parse = (window: unknown): QuotaWindow | undefined => {
97
- if (!window || typeof window !== "object") return undefined;
98
- const item = window as Record<string, unknown>;
99
- return windowValue({ used: item.used_percent, resetsAt: typeof item.reset_at === "number" ? item.reset_at * 1000 : undefined });
100
- };
101
- // Window slots are not fixed: Plus accounts get a single weekly `primary_window` and a null
102
- // secondary. Classify by `limit_window_seconds` (≤ 1 day is the short window), falling back
103
- // to position only when the duration is absent.
104
- const quota: Quota = {};
105
- for (const [window, fallback] of [[rate.primary_window, "fiveHour"], [rate.secondary_window, "week"]] as const) {
106
- const value = parse(window);
107
- if (!value) continue;
108
- const seconds = (window as Record<string, unknown>).limit_window_seconds;
109
- const slot = typeof seconds === "number" && seconds > 0 ? (seconds <= 24 * 60 * 60 ? "fiveHour" : "week") : fallback;
110
- if (!quota[slot]) quota[slot] = value;
111
- }
112
- return parseQuota(quota);
94
+ response = await fetch(url, { method: "GET", headers, signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]) });
95
+ } catch (error) {
96
+ if (signal.aborted) return undefined;
97
+ throw new Error(error instanceof Error && error.name === "TimeoutError" ? "request timed out" : "request failed: " + String(error instanceof Error ? error.message : error));
98
+ }
99
+ // The Anthropic usage endpoint is shared with Claude Code and rate-limits aggressively.
100
+ if (!response.ok) throw new Error(response.status === 429 ? "rate limited by the provider (HTTP 429)" : `HTTP ${response.status}`);
101
+ const data: unknown = await response.json().catch(() => undefined);
102
+ if (!data || typeof data !== "object") throw new Error("unreadable usage response");
103
+ const body = data as Record<string, unknown>;
104
+ let quota: Quota | undefined;
105
+ if (provider === "openai-codex") {
106
+ const rate = body.rate_limit && typeof body.rate_limit === "object" ? body.rate_limit as Record<string, unknown> : {};
107
+ const parse = (window: unknown): QuotaWindow | undefined => {
108
+ if (!window || typeof window !== "object") return undefined;
109
+ const item = window as Record<string, unknown>;
110
+ return windowValue({ used: item.used_percent, resetsAt: typeof item.reset_at === "number" ? item.reset_at * 1000 : undefined });
111
+ };
112
+ // Window slots are not fixed: Plus accounts get a single weekly `primary_window` and a null
113
+ // secondary. Classify by `limit_window_seconds` (≤ 1 day is the short window), falling back
114
+ // to position only when the duration is absent.
115
+ const windows: Quota = {};
116
+ for (const [window, fallback] of [[rate.primary_window, "fiveHour"], [rate.secondary_window, "week"]] as const) {
117
+ const value = parse(window);
118
+ if (!value) continue;
119
+ const seconds = (window as Record<string, unknown>).limit_window_seconds;
120
+ const slot = typeof seconds === "number" && seconds > 0 ? (seconds <= 24 * 60 * 60 ? "fiveHour" : "week") : fallback;
121
+ if (!windows[slot]) windows[slot] = value;
113
122
  }
123
+ quota = parseQuota(windows);
124
+ } else {
125
+ // `utilization` is already a percentage (7.0 means 7%), not a fraction.
114
126
  const parse = (window: unknown): QuotaWindow | undefined => {
115
127
  if (!window || typeof window !== "object") return undefined;
116
128
  const item = window as Record<string, unknown>;
117
- const utilization = item.utilization;
118
129
  const resetsAt = typeof item.resets_at === "string" ? Date.parse(item.resets_at) : undefined;
119
- return windowValue({ used: typeof utilization === "number" && utilization <= 1 ? utilization * 100 : utilization, resetsAt });
130
+ return windowValue({ used: item.utilization, resetsAt });
120
131
  };
121
- return parseQuota({ fiveHour: parse(body.five_hour), week: parse(body.seven_day) });
122
- } catch { return undefined; }
132
+ quota = parseQuota({ fiveHour: parse(body.five_hour), week: parse(body.seven_day) });
133
+ }
134
+ if (!quota) throw new Error("no plan limits in the usage response");
135
+ return quota;
123
136
  }
124
137
 
125
138
  /**
@@ -128,7 +141,7 @@ export async function fetchQuota(provider: QuotaProvider, resolveAuth: (provider
128
141
  * lookups start only from `refresh` at session boundaries.
129
142
  */
130
143
  export class QuotaCache {
131
- private entries = new Map<QuotaProvider, { value?: Quota; requestedAt: number; expiresAt: number; failures: number }>();
144
+ private entries = new Map<QuotaProvider, { value?: Quota; error?: string; requestedAt: number; expiresAt: number; failures: number }>();
132
145
  private pending = new Map<QuotaProvider, AbortController>();
133
146
  private requests = 0;
134
147
  private failures = 0;
@@ -147,6 +160,12 @@ export class QuotaCache {
147
160
  const cached = this.entries.get(provider);
148
161
  return cached && cached.expiresAt > now ? cached.value : undefined;
149
162
  }
163
+ /** Why the last lookup failed and when the next may start; undefined while pending, fresh, or never tried. */
164
+ failure(provider: string | undefined, now: number): { error: string; retryInMs: number } | undefined {
165
+ if (provider !== "openai-codex" && provider !== "anthropic" || !this.enabled || this.pending.has(provider)) return undefined;
166
+ const cached = this.entries.get(provider);
167
+ return cached?.error !== undefined && cached.expiresAt > now ? { error: cached.error, retryInMs: cached.expiresAt - now } : undefined;
168
+ }
150
169
  /** Start one background lookup when nothing fresh is cached and no retry delay is pending; true if it started. */
151
170
  refresh(provider: string | undefined, statuses: ReadonlyMap<string, string>, now: number): boolean {
152
171
  if (provider !== "openai-codex" && provider !== "anthropic" || !this.enabled) return false;
@@ -167,15 +186,18 @@ export class QuotaCache {
167
186
  if (this.pending.get(provider) === controller) this.pending.delete(provider);
168
187
  return;
169
188
  }
170
- // A rejected lookup is a failure like an empty one: both back off instead of retrying at once.
171
- void this.fetcher(provider, controller.signal).catch(() => undefined).then((value) => {
189
+ // A rejected lookup backs off instead of retrying at once; its reason is kept for the usage panel.
190
+ void this.fetcher(provider, controller.signal).then(
191
+ (value) => ({ value, error: value ? undefined : "no usage returned" }),
192
+ (error: unknown) => ({ value: undefined, error: error instanceof Error ? error.message : String(error) })
193
+ ).then(({ value, error }) => {
172
194
  if (controller.signal.aborted) return;
173
195
  const failures = value ? 0 : (this.entries.get(provider)?.failures ?? 0) + 1;
174
196
  if (!value) this.failures++;
175
197
  const ttl = value ? QUOTA_TTL_MS : Math.min(QUOTA_MAX_BACKOFF_MS, QUOTA_TTL_MS * 2 ** (failures - 1));
176
- this.entries.set(provider, { ...(value ? { value } : {}), requestedAt: now, expiresAt: now + ttl, failures });
177
- // An expired value was already hidden, so only a new value changes what renders show.
178
- if (value) this.changed();
198
+ this.entries.set(provider, { ...(value ? { value } : { error: error ?? "no usage returned" }), requestedAt: now, expiresAt: now + ttl, failures });
199
+ // Either outcome replaces the "loading" state renders show.
200
+ this.changed();
179
201
  }).finally(() => {
180
202
  if (this.pending.get(provider) === controller) this.pending.delete(provider);
181
203
  });
package/src/roles-ui.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { ExtensionContext, ThemeColor } from "@earendil-works/pi-coding-agent";
2
2
  import { Key, matchesKey, stripTerminalSequences, type TuiMouseEvent } from "@earendil-works/pi-tui";
3
3
  import { promptText } from "./dialogs.ts";
4
- import { isRoleName, normalizeSpec, THINKING, type ModelRoleManager, type RoleRow } from "./model-roles.ts";
4
+ import { isRoleName, normalizeSpec, premiumRoleWarnings, THINKING, type ModelRoleManager, type RoleRow } from "./model-roles.ts";
5
5
  import { contentRows, optionList, sidebarWidth, splitFrame } from "./split-view.ts";
6
6
 
7
7
  type RoleAction = "model" | "fallback" | "alias" | "thinking" | "scope" | "activate" | "clear" | "new" | "delete";
@@ -44,6 +44,8 @@ async function roleScreen(ctx: ExtensionContext, roles: ModelRoleManager, initia
44
44
  let layout = { top: 1, rows: 0, leftWidth: 0, bodyX: 2, footerTop: 0 };
45
45
  let width = 80;
46
46
  const rows = () => roles.list();
47
+ // Read once per screen: every action closes the screen, and reopening it re-checks against the current model.
48
+ const warnings = premiumRoleWarnings(roles, ctx);
47
49
  const finish = (action: RoleAction) => done({ action, index: selected });
48
50
  return {
49
51
  invalidate() {},
@@ -98,7 +100,7 @@ async function roleScreen(ctx: ExtensionContext, roles: ModelRoleManager, initia
98
100
  const header = narrow ? [theme.fg("accent", `‹ ${selected + 1}/${list.length} ${row.role} ›`)] : [];
99
101
  const actions = optionList(theme, ACTIONS.map((item) => item.label), -1, ACTIONS.map((item) => item.hint))
100
102
  .map((line, at) => ACTIONS[at]!.action === "delete" && !row.custom ? theme.fg("dim", stripTerminalSequences(line)) : line);
101
- const body = [...header, ...info];
103
+ const body = [...header, ...warnings.map((text) => theme.fg("warning", text)), ...info];
102
104
  const split = splitFrame(theme, width, "pi-jar · roles", narrow ? [] : left, body, [
103
105
  ...actions,
104
106
  theme.fg("dim", "↑↓ choose role · press a key or click an action · Esc close")
@@ -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" ? 7 : page === "profiles" ? 2 : 7;
45
- const finishChoice = () => { selected = choosing === "maxSubagents" ? 3 : 2; choosing = undefined; };
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), state[choosing!] === choice ? "CURRENT" : "", true))
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" ? " Fullscreen mode enables clicks and copy-on-select"
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: 4, advisor: true, advisorGates: true, contextDiet: false, icons: "unicode", footer: { ...footer } };
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
- if (entry.partial.length > MAX_LINE_CHARS) { this.push(entry, entry.partial); entry.partial = ""; }
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: "Bounded event-driven wait (default 1000ms, maximum 30000). Esc cancels the wait, not the shell." })),
430
- lines: Type.Optional(Type.Number({ description: "How many trailing output lines to return (default 40, max 400)." }))
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: "Run background jobs. start returns immediately; notifications are compact and coalesced. peek reads status, wait checks selected tasks with a bounded event-driven wait, output loads logs and kill stops a process. Observed results are acknowledged so stale notifications do not replay. Verify relevant checks before claiming completion; do not wait for long-lived services.",
439
- promptSnippet: "Use jar_shell for long-running or never-ending commands instead of blocking bash.",
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 jar_shell start for servers, watchers and slow commands; set watch to a regex for the line you are waiting for (e.g. \"listening|ready|error\").",
442
- "Do not poll: work while commands run, then use wait with explicit ids for relevant checks before the final summary. A bounded wait returns pending jobs honestly; never claim success without their exit codes. Do not wait for services/watchers. output, peek, list and wait acknowledge observed events, preventing stale replay.",
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
- return reply(`Started ${describe(job)} (pid ${job.pid ?? "?"}). ${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."}`);
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 lines = manager.output(params.id, params.lines ?? 40);
464
- manager.acknowledge([params.id]);
465
- return reply(`${describe(manager.get(params.id)!)}\n${lines.length ? boundedTail(lines) : "(no output yet)"}`);
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.");
@@ -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", "jar_democracy"]);
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 {