pi-jar 0.1.8 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,32 +1,6 @@
1
- import { readFileSync, mkdirSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
2
- import { join } from "node:path";
3
-
4
1
  export const FOOTER_FIELDS = ["model", "effort", "sessionName", "cwd", "context", "memory", "cost", "quota", "roles", "extras", "branch"] as const;
5
2
  export type FooterField = (typeof FOOTER_FIELDS)[number];
6
3
  export type FooterSettings = Record<FooterField, boolean>;
7
- export const DEFAULT_FOOTER_SETTINGS: FooterSettings = Object.fromEntries(FOOTER_FIELDS.map((field) => [field, true])) as FooterSettings;
8
-
9
- export function loadFooterSettings(directory: string): FooterSettings {
10
- try {
11
- const value: unknown = JSON.parse(readFileSync(join(directory, "pi-jar-footer.json"), "utf8"));
12
- if (!value || typeof value !== "object" || Array.isArray(value)) return { ...DEFAULT_FOOTER_SETTINGS };
13
- const entries = value as Record<string, unknown>;
14
- return Object.fromEntries(FOOTER_FIELDS.map((field) => [field,
15
- typeof entries[field] === "boolean" ? entries[field] : true])) as FooterSettings;
16
- } catch {
17
- return { ...DEFAULT_FOOTER_SETTINGS };
18
- }
19
- }
20
-
21
- export function saveFooterSettings(directory: string, settings: FooterSettings): void {
22
- mkdirSync(directory, { recursive: true });
23
- const target = join(directory, "pi-jar-footer.json");
24
- const temporary = `${target}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
25
- try {
26
- writeFileSync(temporary, JSON.stringify(settings, null, 2) + "\n", { flag: "wx" });
27
- renameSync(temporary, target);
28
- } catch (error) {
29
- try { unlinkSync(temporary); } catch { /* No temporary file to clean up. */ }
30
- throw error;
31
- }
32
- }
4
+ export const DEFAULT_FOOTER_SETTINGS: FooterSettings = Object.fromEntries(
5
+ FOOTER_FIELDS.map((field) => [field, true])
6
+ ) as FooterSettings;
@@ -139,7 +139,12 @@ function writeConfig(file: string, config: RoleConfig): void {
139
139
  }
140
140
  }
141
141
 
142
- export const projectRoleFile = (cwd: string) => join(cwd, ".pi", ROLE_FILE);
142
+ function profileRolePath(directory: string, profileId = "default"): string {
143
+ if (!/^[a-z0-9-]+$/.test(profileId)) throw new Error("Invalid profile ID");
144
+ return profileId === "default" ? join(directory, ROLE_FILE) : join(directory, "pi-jar-profiles", `${profileId}-roles.json`);
145
+ }
146
+ export const profileRoleFile = (directory: string, profileId?: string) => profileRolePath(directory, profileId);
147
+ export const projectRoleFile = (cwd: string, profileId?: string) => profileRolePath(join(cwd, ".pi"), profileId);
143
148
 
144
149
  export function describeRole(row: RoleRow): string {
145
150
  const target = row.error ? "⚠ " + row.error
@@ -152,6 +157,7 @@ export class ModelRoleManager {
152
157
  private global: RoleConfig = readConfig(join(getAgentDir(), ROLE_FILE));
153
158
  private project: RoleConfig = { version: 2, roles: {} };
154
159
  private cwd: string | undefined;
160
+ private profileId = "default";
155
161
  private active: string | undefined;
156
162
  private readonly pi: ExtensionAPI;
157
163
  /** True while pi-jar itself is switching models, so its own changes are not seen as manual. */
@@ -166,11 +172,21 @@ export class ModelRoleManager {
166
172
  }
167
173
 
168
174
  /** Reload global and project files; project assignments override global ones per role. */
169
- load(cwd?: string): void {
170
- this.global = readConfig(join(getAgentDir(), ROLE_FILE));
175
+ load(cwd?: string, profileRoles?: RoleConfig, profileId = "default"): void {
176
+ const profileFile = profileRoleFile(getAgentDir(), profileId);
177
+ this.global = profileRoles && !existsSync(profileFile) ? parseRoleConfig(profileRoles) : readConfig(profileFile);
171
178
  this.cwd = cwd;
172
- this.project = cwd ? readConfig(projectRoleFile(cwd)) : { version: 2, roles: {} };
179
+ this.profileId = profileId;
180
+ this.project = cwd ? readConfig(projectRoleFile(cwd, profileId)) : { version: 2, roles: {} };
181
+ }
182
+ activeProfileId(): string { return this.profileId; }
183
+ /** Restore the label only, after a failed profile transaction restored model and effort. */
184
+ restoreActiveRole(role: string | undefined, ctx: ExtensionContext): void {
185
+ if (role !== undefined && !isRoleName(role)) throw new Error("Invalid role name");
186
+ this.active = role;
187
+ this.status(ctx);
173
188
  }
189
+ profileRoleConfig(): RoleConfig { return structuredClone(this.global); }
174
190
 
175
191
  private merged(): Record<string, string> { return { ...this.global.roles, ...this.project.roles }; }
176
192
  activeRole(): string | undefined { return this.active; }
@@ -288,7 +304,7 @@ export class ModelRoleManager {
288
304
  const epoch = this.manualEpoch;
289
305
  return async () => {
290
306
  // A model or effort the user picked during the workflow wins over the saved one.
291
- if (this.manualEpoch !== epoch) { this.active = undefined; this.status(ctx); return; }
307
+ if (this.manualEpoch !== epoch) return;
292
308
  this.applying = true;
293
309
  try { if (previousModel) await this.pi.setModel(previousModel); this.pi.setThinkingLevel(previousThinking); }
294
310
  finally { this.applying = false; }
@@ -310,6 +326,9 @@ export class ModelRoleManager {
310
326
  return await this.activate(next, ctx) ? next : undefined;
311
327
  }
312
328
 
329
+ /** Invalidate temporary role restorations after a profile change. */
330
+ invalidateTemporaryRestores(): void { this.manualEpoch++; this.active = undefined; }
331
+
313
332
  /** Assign (or clear with undefined) a role in the chosen scope. */
314
333
  update(role: string, spec: string | undefined, scope: RoleScope = this.scopeOf(role) ?? "global"): void {
315
334
  if (!isRoleName(role)) throw new Error("Invalid role name: " + role);
@@ -320,7 +339,7 @@ export class ModelRoleManager {
320
339
  const roles = { ...target.roles };
321
340
  if (normalized) roles[role] = normalized; else delete roles[role];
322
341
  const next: RoleConfig = { ...target, version: 2, roles };
323
- writeConfig(scope === "project" ? projectRoleFile(this.cwd!) : join(getAgentDir(), ROLE_FILE), next);
342
+ writeConfig(scope === "project" ? projectRoleFile(this.cwd!, this.profileId) : profileRoleFile(getAgentDir(), this.profileId), next);
324
343
  if (scope === "project") this.project = next; else this.global = next;
325
344
  }
326
345
 
@@ -346,11 +365,11 @@ export class ModelRoleManager {
346
365
  if (scope === "project" && !this.cwd) throw new Error("Project roles need a working directory");
347
366
  const target = scope === "project" ? this.project : this.global;
348
367
  const next: RoleConfig = { ...target, fallbacks: { ...target.fallbacks, [role]: [...new Set(normalized)] } };
349
- writeConfig(scope === "project" ? projectRoleFile(this.cwd!) : join(getAgentDir(), ROLE_FILE), next);
368
+ writeConfig(scope === "project" ? projectRoleFile(this.cwd!, this.profileId) : profileRoleFile(getAgentDir(), this.profileId), next);
350
369
  if (scope === "project") this.project = next; else this.global = next;
351
370
  }
352
371
 
353
- register(openUi?: (ctx: ExtensionContext) => Promise<void>, options: { activateDefault?: () => boolean } = {}): void {
372
+ register(openUi?: (ctx: ExtensionContext) => Promise<void>, options: { activateDefault?: () => boolean; beforeSessionStart?: (ctx: ExtensionContext) => { roles?: RoleConfig; profileId?: string } | undefined } = {}): void {
354
373
  const manual = (_event: unknown, ctx: ExtensionContext) => {
355
374
  if (this.applying) return;
356
375
  this.manualEpoch++;
@@ -359,7 +378,8 @@ export class ModelRoleManager {
359
378
  this.pi.on("model_select", manual);
360
379
  this.pi.on("thinking_level_select", manual);
361
380
  this.pi.on("session_start", async (_event, ctx) => {
362
- this.load(ctx.cwd);
381
+ const profile = options.beforeSessionStart?.(ctx);
382
+ this.load(ctx.cwd, profile?.roles, profile?.profileId);
363
383
  this.active = undefined;
364
384
  // Delegated children already received their role model/effort via CLI arguments.
365
385
  if (options.activateDefault?.() !== false && this.resolveCandidates("default").length) await this.activate("default", ctx, true);
package/src/perf.ts ADDED
@@ -0,0 +1,419 @@
1
+ import { execFile } from "node:child_process";
2
+ import type { SubagentStats } from "./delegate.ts";
3
+ import type { DiscussionStats } from "./discussion.ts";
4
+ import type { QuotaStats } from "./quota.ts";
5
+ import type { ShellStats } from "./shells.ts";
6
+
7
+ /**
8
+ * Long-session budgets. Every frame Pi draws re-renders the whole transcript, so the cost of one
9
+ * background repaint grows with the session: background sources share one adaptive cadence here,
10
+ * and facts that walk the session (context usage) are cached and refreshed on boundaries.
11
+ * Diagnostics are in-memory counters read on demand by `/jar perf`; nothing here polls.
12
+ */
13
+
14
+ /** Branch size where Pi's regular TUI redraw cost becomes noticeable (also the large-session hint). */
15
+ export const LARGE_SESSION_ENTRIES = 800;
16
+
17
+ /** `foreground`: a user action; `transition`: a visible state change; `background`: a progress delta. */
18
+ export type RenderKind = "foreground" | "transition" | "background";
19
+ export interface RenderTier {
20
+ name: "small" | "medium" | "large" | "very large";
21
+ /** First branch entry count in this tier. */
22
+ minEntries: number;
23
+ /** Minimum gap between background repaints; undefined: state transitions only. */
24
+ backgroundMs?: number;
25
+ /** Live-update coalescing for the Activity view, which also re-renders the transcript beneath it. */
26
+ focusedMs: number;
27
+ }
28
+ export const RENDER_TIERS: readonly RenderTier[] = [
29
+ { name: "small", minEntries: 0, backgroundMs: 250, focusedMs: 100 },
30
+ { name: "medium", minEntries: 300, backgroundMs: 500, focusedMs: 150 },
31
+ { name: "large", minEntries: LARGE_SESSION_ENTRIES, backgroundMs: 1000, focusedMs: 250 },
32
+ { name: "very large", minEntries: 2000, focusedMs: 500 }
33
+ ];
34
+ export function renderTier(entries: number): RenderTier {
35
+ let tier = RENDER_TIERS[0]!;
36
+ for (const candidate of RENDER_TIERS) if (entries >= candidate.minEntries) tier = candidate;
37
+ return tier;
38
+ }
39
+
40
+ export interface RenderStats {
41
+ entries: number;
42
+ tier: RenderTier["name"];
43
+ /** Current background gap; undefined while background repaints wait for the next frame. */
44
+ intervalMs?: number;
45
+ focusedMs: number;
46
+ focused: boolean;
47
+ requested: number;
48
+ kinds: Record<RenderKind, number>;
49
+ /** Background requests folded into another frame: a pending repaint, ours or Pi's own. */
50
+ coalesced: number;
51
+ /** Background requests left for the next frame (very large sessions, focused Activity view). */
52
+ deferred: number;
53
+ /** Frames requested while no view was attached. */
54
+ skipped: number;
55
+ /** Frames requested from Pi by pi-jar, `background` of them from background sources. */
56
+ actual: number;
57
+ background: number;
58
+ /** Footer paints, including Pi's own frames (streaming, typing, overlays). */
59
+ frames: number;
60
+ backgroundLastMinute: number;
61
+ /** Requests per source, most first. */
62
+ sources: Array<[string, number]>;
63
+ /** Sources of the latest frames pi-jar requested, oldest first. */
64
+ recent: string[];
65
+ pending: boolean;
66
+ dirty: boolean;
67
+ ageMs: number;
68
+ }
69
+
70
+ const MAX_SOURCES = 16;
71
+ const RECENT_FRAMES = 8;
72
+ /** Enough timestamps for the fastest tier's full minute (250 ms → 240). */
73
+ const RATE_SAMPLES = 256;
74
+
75
+ /**
76
+ * Central footer repaint budget. User actions and visible state transitions render at once (Pi
77
+ * still coalesces them per tick); background deltas mark the footer dirty and share one repaint
78
+ * per tier interval, measured from the last frame of any origin. Any footer paint, including Pi's
79
+ * own, satisfies pending background work. Very large sessions and a focused Activity view leave
80
+ * background deltas to the next frame instead.
81
+ */
82
+ export class RenderScheduler {
83
+ private entryCount = 0;
84
+ private focused = false;
85
+ private disposed = false;
86
+ private timer: NodeJS.Timeout | undefined;
87
+ private readonly pendingSources = new Set<string>();
88
+ private lastFrameAt = Number.NEGATIVE_INFINITY;
89
+ private dirty = false;
90
+ private readonly startedAt: number;
91
+ private readonly counts = { requested: 0, coalesced: 0, deferred: 0, skipped: 0, actual: 0, background: 0, frames: 0 };
92
+ private readonly kinds: Record<RenderKind, number> = { foreground: 0, transition: 0, background: 0 };
93
+ private readonly sources = new Map<string, number>();
94
+ private readonly recent: string[] = [];
95
+ private readonly backgroundTimes: number[] = [];
96
+ private readonly render: () => boolean;
97
+ private readonly now: () => number;
98
+
99
+ /** `render` asks Pi for a frame and reports whether a view was attached to draw it. */
100
+ constructor(render: () => boolean, now: () => number = () => Date.now()) {
101
+ this.render = render;
102
+ this.now = now;
103
+ this.startedAt = now();
104
+ }
105
+
106
+ /** Branch entries: set on session start/tree/compact, then counted per message, never re-walked. */
107
+ get entries(): number { return this.entryCount; }
108
+ setEntries(count: number): void { this.entryCount = Math.max(0, Math.floor(count)); }
109
+ addEntries(count = 1): void { this.entryCount += count; }
110
+ get tier(): RenderTier { return renderTier(this.entryCount); }
111
+ intervalMs(): number | undefined { return this.focused ? undefined : this.tier.backgroundMs; }
112
+ focusedMs(): number { return this.tier.focusedMs; }
113
+ /** The Activity view repaints its own live updates, and with them the footer beneath it. */
114
+ setFocused(focused: boolean): void { this.focused = focused; }
115
+
116
+ request(source: string, kind: RenderKind = "background"): void {
117
+ if (this.disposed) return;
118
+ this.counts.requested++;
119
+ this.kinds[kind]++;
120
+ const key = this.sources.has(source) || this.sources.size < MAX_SOURCES ? source : "other";
121
+ this.sources.set(key, (this.sources.get(key) ?? 0) + 1);
122
+ if (kind !== "background") { this.flush(source, kind); return; }
123
+ this.dirty = true;
124
+ const interval = this.intervalMs();
125
+ if (interval === undefined) { this.counts.deferred++; return; }
126
+ this.pendingSources.add(key);
127
+ if (this.timer) { this.counts.coalesced++; return; }
128
+ this.timer = setTimeout(() => { this.timer = undefined; this.flush(undefined, "background"); },
129
+ Math.max(0, this.lastFrameAt + interval - this.now()));
130
+ this.timer.unref?.();
131
+ }
132
+
133
+ /** The footer drew a frame (ours or Pi's own); call before reading state so pending deltas are on screen. */
134
+ painted(): void {
135
+ this.counts.frames++;
136
+ this.lastFrameAt = this.now();
137
+ this.dirty = false;
138
+ if (!this.timer) return;
139
+ clearTimeout(this.timer);
140
+ this.timer = undefined;
141
+ this.pendingSources.clear();
142
+ this.counts.coalesced++;
143
+ }
144
+
145
+ stats(): RenderStats {
146
+ const now = this.now();
147
+ const tier = this.tier;
148
+ const interval = this.intervalMs();
149
+ let lastMinute = 0;
150
+ for (const at of this.backgroundTimes) if (now - at <= 60_000) lastMinute++;
151
+ return {
152
+ entries: this.entryCount, tier: tier.name, ...(interval !== undefined ? { intervalMs: interval } : {}),
153
+ focusedMs: tier.focusedMs, focused: this.focused, ...this.counts, kinds: { ...this.kinds },
154
+ backgroundLastMinute: lastMinute, sources: [...this.sources].sort((a, b) => b[1] - a[1]), recent: [...this.recent],
155
+ pending: this.timer !== undefined, dirty: this.dirty, ageMs: now - this.startedAt
156
+ };
157
+ }
158
+
159
+ dispose(): void {
160
+ this.disposed = true;
161
+ clearTimeout(this.timer);
162
+ this.timer = undefined;
163
+ this.pendingSources.clear();
164
+ }
165
+
166
+ private flush(source: string | undefined, kind: RenderKind): void {
167
+ // A prompt frame also shows whatever a pending background repaint was waiting to show.
168
+ if (this.timer) { clearTimeout(this.timer); this.timer = undefined; this.counts.coalesced++; }
169
+ const label = source ?? ([...this.pendingSources].join("+") || "background");
170
+ this.pendingSources.clear();
171
+ if (!this.render()) { this.counts.skipped++; return; }
172
+ const at = this.now();
173
+ this.counts.actual++;
174
+ this.lastFrameAt = at;
175
+ this.dirty = false;
176
+ if (kind === "background") {
177
+ this.counts.background++;
178
+ this.backgroundTimes.push(at);
179
+ if (this.backgroundTimes.length > RATE_SAMPLES) this.backgroundTimes.shift();
180
+ }
181
+ this.recent.push(label);
182
+ if (this.recent.length > RECENT_FRAMES) this.recent.shift();
183
+ }
184
+ }
185
+
186
+ /** Mid-turn context recomputation happens at most this often; boundaries refresh at once. */
187
+ export const CONTEXT_SAMPLE_MS = 750;
188
+ export interface ContextUsageLike { percent?: number | null }
189
+ export interface ContextSamplingStats {
190
+ label: string;
191
+ calls: number;
192
+ errors: number;
193
+ /** message_end marks that asked for a sample. */
194
+ marks: number;
195
+ totalMs: number;
196
+ maxMs: number;
197
+ /** Median over the latest samples. */
198
+ medianMs?: number;
199
+ lastSampleAgeMs?: number;
200
+ dirty: boolean;
201
+ pending: boolean;
202
+ }
203
+ const DURATION_SAMPLES = 32;
204
+
205
+ export function contextLabel(usage: ContextUsageLike | undefined): string {
206
+ return usage?.percent == null || !Number.isFinite(usage.percent) ? "ctx ?" : `ctx ${Math.round(usage.percent)}%`;
207
+ }
208
+
209
+ /**
210
+ * Cached `getContextUsage()`. Pi ends a message for every user, assistant and tool-result message,
211
+ * and the computation can walk the session, so message ends only mark the cache dirty; one sample
212
+ * per throttle window follows. The footer and welcome read the cached label only.
213
+ */
214
+ export class ContextSampler {
215
+ private current = "ctx ?";
216
+ private read: (() => ContextUsageLike | undefined) | undefined;
217
+ private timer: NodeJS.Timeout | undefined;
218
+ private lastSampleAt: number | undefined;
219
+ private stale = false;
220
+ private readonly counts = { calls: 0, errors: 0, marks: 0, totalMs: 0, maxMs: 0 };
221
+ private readonly durations: number[] = [];
222
+ private readonly changed: () => void;
223
+ private readonly throttleMs: number;
224
+ private readonly now: () => number;
225
+
226
+ /** `changed` runs when a throttled sample changed the label; boundary refreshes leave repainting to the caller. */
227
+ constructor(changed: () => void, throttleMs = CONTEXT_SAMPLE_MS, now: () => number = () => Date.now()) {
228
+ this.changed = changed;
229
+ this.throttleMs = throttleMs;
230
+ this.now = now;
231
+ }
232
+
233
+ get label(): string { return this.current; }
234
+ get dirty(): boolean { return this.stale; }
235
+
236
+ /** Lifecycle boundary (start, tree, compact, model, rename, settle): recompute now. */
237
+ refresh(read: () => ContextUsageLike | undefined): void {
238
+ clearTimeout(this.timer);
239
+ this.timer = undefined;
240
+ this.read = read;
241
+ this.sample();
242
+ }
243
+
244
+ /** A message moved the context window: recompute once the throttle window allows. */
245
+ markDirty(read: () => ContextUsageLike | undefined): void {
246
+ this.read = read;
247
+ this.stale = true;
248
+ this.counts.marks++;
249
+ if (this.timer) return;
250
+ this.timer = setTimeout(() => {
251
+ this.timer = undefined;
252
+ if (!this.stale) return;
253
+ const before = this.current;
254
+ this.sample();
255
+ if (this.current !== before) this.changed();
256
+ }, Math.max(0, (this.lastSampleAt ?? Number.NEGATIVE_INFINITY) + this.throttleMs - this.now()));
257
+ this.timer.unref?.();
258
+ }
259
+
260
+ stats(): ContextSamplingStats {
261
+ const sorted = [...this.durations].sort((a, b) => a - b);
262
+ return {
263
+ label: this.current, ...this.counts,
264
+ ...(sorted.length ? { medianMs: sorted[Math.floor(sorted.length / 2)]! } : {}),
265
+ ...(this.lastSampleAt !== undefined ? { lastSampleAgeMs: this.now() - this.lastSampleAt } : {}),
266
+ dirty: this.stale, pending: this.timer !== undefined
267
+ };
268
+ }
269
+
270
+ /** Session shutdown: no timer, and no reference to the old session's context. */
271
+ dispose(): void {
272
+ clearTimeout(this.timer);
273
+ this.timer = undefined;
274
+ this.read = undefined;
275
+ this.stale = false;
276
+ }
277
+
278
+ private sample(): void {
279
+ const read = this.read;
280
+ if (!read) return;
281
+ const started = performance.now();
282
+ try { this.current = contextLabel(read()); }
283
+ catch { this.current = "ctx ?"; this.counts.errors++; }
284
+ const elapsed = performance.now() - started;
285
+ this.counts.calls++;
286
+ this.counts.totalMs += elapsed;
287
+ this.counts.maxMs = Math.max(this.counts.maxMs, elapsed);
288
+ this.durations.push(elapsed);
289
+ if (this.durations.length > DURATION_SAMPLES) this.durations.shift();
290
+ this.lastSampleAt = this.now();
291
+ this.stale = false;
292
+ }
293
+ }
294
+
295
+ /** Summed RSS of `pids` in bytes from one `ps` call; undefined on Windows, failure or no live pid. */
296
+ export function processRss(pids: readonly number[]): Promise<number | undefined> {
297
+ const valid = pids.filter((pid) => Number.isSafeInteger(pid) && pid > 0);
298
+ if (!valid.length || process.platform === "win32") return Promise.resolve(undefined);
299
+ return new Promise((resolve) => {
300
+ execFile("ps", ["-o", "rss=", "-p", valid.join(",")], { timeout: 1000, encoding: "utf8" }, (_error, stdout) => {
301
+ // ps exits 1 when one of the pids already exited, yet still reports the others.
302
+ let kib = 0;
303
+ let found = 0;
304
+ for (const line of String(stdout ?? "").split("\n")) {
305
+ const value = Number(line.trim());
306
+ if (line.trim() && Number.isFinite(value) && value >= 0) { kib += value; found++; }
307
+ }
308
+ resolve(found ? kib * 1024 : undefined);
309
+ });
310
+ });
311
+ }
312
+
313
+ export interface PerfSnapshot {
314
+ entries: number;
315
+ context: string;
316
+ rssBytes: number;
317
+ render?: RenderStats;
318
+ sampling?: ContextSamplingStats;
319
+ subagents?: SubagentStats;
320
+ childRssBytes?: number;
321
+ shells?: ShellStats;
322
+ discussion?: DiscussionStats;
323
+ quota?: QuotaStats;
324
+ sideCalls?: number;
325
+ now: number;
326
+ }
327
+
328
+ const count = (value: number) => Math.round(value).toLocaleString("en-US");
329
+ const mib = (bytes: number) => `${(bytes / 1048576).toFixed(bytes < 10 * 1048576 ? 1 : 0)} MiB`;
330
+ const kib = (bytes: number) => bytes < 1048576 ? `${(bytes / 1024).toFixed(1)} KiB` : mib(bytes);
331
+ const chars = (value: number) => value < 10_000 ? count(value) : value < 1_000_000 ? `${(value / 1000).toFixed(1)}k` : `${(value / 1_000_000).toFixed(1)}M`;
332
+ const ms = (value: number) => value < 10 ? `${value.toFixed(2)} ms` : value < 1000 ? `${Math.round(value)} ms` : `${(value / 1000).toFixed(1)} s`;
333
+ function age(value: number): string {
334
+ const elapsed = Math.max(0, value);
335
+ if (elapsed < 1000) return `${Math.round(elapsed)} ms`;
336
+ if (elapsed < 60_000) return `${(elapsed / 1000).toFixed(1)} s`;
337
+ const seconds = Math.floor(elapsed / 1000);
338
+ return seconds < 3600 ? `${Math.floor(seconds / 60)}m ${seconds % 60}s` : `${Math.floor(seconds / 3600)}h ${Math.floor(seconds / 60) % 60}m`;
339
+ }
340
+ /** Consecutive repeats collapse: `subagents ×3 · shells`. */
341
+ function runs(labels: readonly string[]): string {
342
+ const parts: string[] = [];
343
+ for (let index = 0; index < labels.length;) {
344
+ let end = index;
345
+ while (end < labels.length && labels[end] === labels[index]) end++;
346
+ parts.push(end - index > 1 ? `${labels[index]} ×${end - index}` : labels[index]!);
347
+ index = end;
348
+ }
349
+ return parts.join(" · ") || "none";
350
+ }
351
+
352
+ /** `/jar perf`: one on-demand snapshot of the long-session budgets; sections without data are omitted. */
353
+ export function formatPerf(snapshot: PerfSnapshot): string {
354
+ const lines = ["pi-jar perf"];
355
+ const section = (title: string, rows: ReadonlyArray<readonly [string, string] | undefined>) => {
356
+ lines.push("", title);
357
+ for (const row of rows) if (row) lines.push(` ${row[0].padEnd(20)}${row[1]}`);
358
+ };
359
+ const { render, sampling, subagents, shells, discussion, quota } = snapshot;
360
+ section("session", [
361
+ ["branch entries", count(snapshot.entries)],
362
+ ["context", snapshot.context.replace(/^ctx /, "")],
363
+ ["parent rss", mib(snapshot.rssBytes)]
364
+ ]);
365
+ if (subagents) section("subagents", [
366
+ ["retained", count(subagents.retained)],
367
+ ["live children", count(subagents.live) + (subagents.pids.length ? ` (pid ${subagents.pids.slice(0, 8).join(", ")}${subagents.pids.length > 8 ? ", …" : ""})` : "")],
368
+ ["hibernated", count(subagents.hibernated)],
369
+ ["child rss", snapshot.childRssBytes !== undefined ? mib(snapshot.childRssBytes) : subagents.live ? "unavailable" : "none"],
370
+ ["recovery worktrees", `${count(subagents.recovery)} / ${count(subagents.recoveryLimit)}`]
371
+ ]);
372
+ if (render) section("render", [
373
+ ["tier", `${render.tier} · ${render.focused ? "activity view focused" : render.intervalMs !== undefined ? `background ≥ ${render.intervalMs} ms` : "transitions only"} · activity ${render.focusedMs} ms`],
374
+ ["requested", `${count(render.requested)} (foreground ${count(render.kinds.foreground)} · transition ${count(render.kinds.transition)} · background ${count(render.kinds.background)})`],
375
+ ["coalesced", count(render.coalesced)],
376
+ ["deferred", count(render.deferred)],
377
+ ["actual", `${count(render.actual)} (background ${count(render.background)})` + (render.skipped ? ` · ${count(render.skipped)} without a view` : "")],
378
+ ["frames painted", count(render.frames)],
379
+ ["background rate", `${count(render.backgroundLastMinute)} / last minute`],
380
+ ["sources", render.sources.slice(0, 5).map(([source, value]) => `${source} ${count(value)}`).join(" · ") || "none"],
381
+ ["last sources", runs(render.recent)],
382
+ ["pending", render.pending ? "repaint scheduled" : render.dirty ? "next frame" : "no"]
383
+ ]);
384
+ if (sampling) section("context sampling", [
385
+ ["calls", count(sampling.calls) + (sampling.errors ? ` (${count(sampling.errors)} failed)` : "")],
386
+ ["dirty marks", count(sampling.marks)],
387
+ ["total time", ms(sampling.totalMs)],
388
+ ["max time", ms(sampling.maxMs) + (sampling.medianMs !== undefined ? ` (median ${ms(sampling.medianMs)})` : "")],
389
+ ["last sample age", sampling.lastSampleAgeMs !== undefined ? age(sampling.lastSampleAgeMs) : "never"],
390
+ ["pending", sampling.pending ? "sample scheduled" : sampling.dirty ? "yes" : "no"]
391
+ ]);
392
+ if (shells) section("shells", [
393
+ ["live", count(shells.live) + (shells.services ? ` (${count(shells.services)} service${shells.services === 1 ? "" : "s"})` : "")],
394
+ ["retained finished", count(shells.finished)],
395
+ ["output retained", `${chars(shells.retainedChars)} / ${chars(shells.budgetChars)} chars`],
396
+ shells.oldestLiveStartedAt !== undefined ? ["oldest live", age(snapshot.now - shells.oldestLiveStartedAt)] : undefined
397
+ ]);
398
+ if (discussion) section("discussion", [
399
+ ["transport", discussion.transport + (discussion.listening ? "" : " (not listening)")],
400
+ ["agents", count(discussion.agents)],
401
+ ["messages", `${count(discussion.messages)} / ${count(discussion.maxMessages)}`
402
+ + (discussion.oldestId !== undefined && discussion.newestId !== undefined ? ` (#${discussion.oldestId}–#${discussion.newestId})` : "")],
403
+ ["bytes", `${kib(discussion.bytes)} / ${kib(discussion.maxBytes)}`],
404
+ ["unanswered", count(discussion.unanswered) + (discussion.unread ? ` (${count(discussion.unread)} unread)` : "")],
405
+ ["requests", count(discussion.requests) + (discussion.errors ? ` (${count(discussion.errors)} failed)` : "")]
406
+ ]);
407
+ if (quota) section("quota", [
408
+ ["lookups", quota.enabled ? "on" : "off"],
409
+ ...(quota.providers.length ? quota.providers.map((item) => [item.provider, [
410
+ item.ageMs !== undefined ? `cached ${age(item.ageMs)} ago` : "not cached",
411
+ item.ok === false ? `failed ×${item.failures}` : item.ok ? "ok" : "",
412
+ item.retryInMs ? `next in ${age(item.retryInMs)}` : ""
413
+ ].filter(Boolean).join(" · ")] as const) : [["provider cache", "empty"] as const]),
414
+ ["pending", quota.providers.some((item) => item.pending) ? "yes" : "no"],
415
+ ["requests", count(quota.requests) + (quota.failures ? ` (${count(quota.failures)} failed)` : "")],
416
+ snapshot.sideCalls !== undefined ? ["side calls", count(snapshot.sideCalls)] : undefined
417
+ ]);
418
+ return lines.join("\n");
419
+ }
@@ -0,0 +1,61 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { JarVisualSettings } from "./settings.ts";
3
+ import type { JarProfile, ProfileStore } from "./profiles.ts";
4
+ import { isRoleName, normalizeSpec, type RoleConfig, type ModelRoleManager } from "./model-roles.ts";
5
+
6
+ export interface ProfileWizardOptions {
7
+ themes: readonly string[];
8
+ settings: JarVisualSettings;
9
+ roles: RoleConfig;
10
+ roleManager?: ModelRoleManager;
11
+ create(name: string, theme: string, settings: JarVisualSettings, roles: RoleConfig): JarProfile;
12
+ }
13
+
14
+ /** Every step is cancellable; creation happens only after the final confirmation. */
15
+ export async function createProfileWizard(ctx: ExtensionContext, store: ProfileStore, options: ProfileWizardOptions): Promise<JarProfile | undefined> {
16
+ if (!ctx.hasUI || ctx.mode !== "tui") return undefined;
17
+ const name = await ctx.ui.input("New profile name (1–32 characters)", "");
18
+ if (name === undefined) return undefined;
19
+ if (/[\u0000-\u001f\u007f-\u009f]/.test(name)) { ctx.ui.notify("Profile name cannot contain control characters", "warning"); return undefined; }
20
+ const normalized = name.trim().replace(/\s+/g, " ");
21
+ if (!normalized || normalized.length > 32 || /[\\/\0]/.test(normalized)) {
22
+ ctx.ui.notify("Profile name must be 1–32 characters and cannot contain slashes", "warning"); return undefined;
23
+ }
24
+ if (store.list().some((profile) => profile.name.toLocaleLowerCase() === normalized.toLocaleLowerCase())) {
25
+ ctx.ui.notify("A profile with that name already exists", "warning"); return undefined;
26
+ }
27
+ if (!options.themes.length) { ctx.ui.notify("No themes are available for profiles", "warning"); return undefined; }
28
+ const selectedTheme = await ctx.ui.select("Choose a theme for " + normalized, ["Follow Pi", ...options.themes.filter((item) => item !== "follow")]);
29
+ if (!selectedTheme) return undefined;
30
+ const theme = selectedTheme === "Follow Pi" ? "follow" : selectedTheme;
31
+ const roles = structuredClone(options.roles);
32
+ const roleNames = options.roleManager?.list().map((role) => role.role) ?? [];
33
+ if (options.roleManager) {
34
+ for (const role of roleNames) {
35
+ const current = roles.roles[role];
36
+ const answer = await ctx.ui.input(`${role} role assignment (blank keeps current${current ? `: ${current}` : ""}; none clears)`, "");
37
+ if (answer === undefined) return undefined;
38
+ if (!answer.trim()) continue;
39
+ if (answer.trim().toLowerCase() === "none") { delete roles.roles[role]; continue; }
40
+ const model = normalizeSpec(answer.trim());
41
+ if (!model) { ctx.ui.notify("Enter a valid provider/model, @role, *, or none for " + role, "warning"); return undefined; }
42
+ roles.roles[role] = model;
43
+ }
44
+ } else {
45
+ const roleInput = await ctx.ui.input("Role assignments as role=provider/model, comma-separated (optional)", "");
46
+ if (roleInput === undefined) return undefined;
47
+ for (const assignment of roleInput.split(",").map((item) => item.trim()).filter(Boolean)) {
48
+ const split = assignment.indexOf("=");
49
+ if (split <= 0) { ctx.ui.notify("Use role=provider/model for each assignment", "warning"); return undefined; }
50
+ const role = assignment.slice(0, split).trim().toLowerCase();
51
+ const model = normalizeSpec(assignment.slice(split + 1).trim());
52
+ if (!isRoleName(role) || !model) { ctx.ui.notify("Invalid role assignment: " + assignment, "warning"); return undefined; }
53
+ roles.roles[role] = model;
54
+ }
55
+ }
56
+ const roleSummary = Object.entries(roles.roles).map(([role, target]) => `${role} → ${target}`).join("\n") || "No roles assigned";
57
+ const confirmed = await ctx.ui.confirm("Create profile", `${normalized}\nTheme: ${theme === "follow" ? "Follow Pi" : theme}\n${roleSummary}`);
58
+ if (!confirmed) return undefined;
59
+ try { return options.create(normalized, theme, options.settings, roles); }
60
+ catch (error) { ctx.ui.notify("Could not create profile: " + (error as Error).message, "error"); return undefined; }
61
+ }