pi-jar 0.1.7 → 0.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -12
- package/docs/DIFF-REVIEW.md +5 -2
- package/docs/DISCUSSION_BROKER_PLAN.md +333 -0
- package/docs/INTEGRATIONS.md +1 -1
- package/docs/LONG_SESSION_PERFORMANCE_PLAN.md +731 -0
- package/docs/SUBAGENT-TOOLS.md +2 -0
- package/docs/WORKFLOWS.md +2 -2
- package/extensions/index.ts +153 -63
- package/package.json +1 -1
- package/src/activity-view.ts +10 -6
- package/src/changes.ts +147 -12
- package/src/delegate-worktree.ts +71 -9
- package/src/delegate.ts +734 -423
- package/src/democracy.ts +6 -4
- package/src/diff-inline.ts +80 -0
- package/src/diff-view.ts +196 -59
- package/src/discussion-hub.ts +418 -0
- package/src/discussion.ts +419 -149
- package/src/footer-settings.ts +3 -29
- package/src/perf.ts +419 -0
- package/src/quota.ts +77 -26
- package/src/settings.ts +2 -12
- package/src/shells.ts +134 -28
package/src/footer-settings.ts
CHANGED
|
@@ -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(
|
|
8
|
-
|
|
9
|
-
|
|
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;
|
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
|
+
}
|
package/src/quota.ts
CHANGED
|
@@ -3,8 +3,22 @@ export interface QuotaWindow { used: number; resetsAt?: number }
|
|
|
3
3
|
export interface Quota { fiveHour?: QuotaWindow; week?: QuotaWindow }
|
|
4
4
|
|
|
5
5
|
export const QUOTA_TTL_MS = 5 * 60_000;
|
|
6
|
+
/** Consecutive failures double the retry delay from the TTL up to this, so a broken lookup cannot hot-loop. */
|
|
7
|
+
export const QUOTA_MAX_BACKOFF_MS = 30 * 60_000;
|
|
6
8
|
export const QUOTA_PREFIX = "pi-jar.quota.";
|
|
7
9
|
|
|
10
|
+
export interface QuotaProviderStats {
|
|
11
|
+
provider: QuotaProvider;
|
|
12
|
+
pending: boolean;
|
|
13
|
+
/** Consecutive failed lookups. */
|
|
14
|
+
failures: number;
|
|
15
|
+
/** Last settled lookup, if any: whether it produced a value, how long ago it started, when the next may start. */
|
|
16
|
+
ok?: boolean;
|
|
17
|
+
ageMs?: number;
|
|
18
|
+
retryInMs?: number;
|
|
19
|
+
}
|
|
20
|
+
export interface QuotaStats { enabled: boolean; requests: number; failures: number; providers: QuotaProviderStats[] }
|
|
21
|
+
|
|
8
22
|
function windowValue(value: unknown): QuotaWindow | undefined {
|
|
9
23
|
if (!value || typeof value !== "object") return undefined;
|
|
10
24
|
const item = value as Record<string, unknown>;
|
|
@@ -84,7 +98,18 @@ export async function fetchQuota(provider: QuotaProvider, resolveAuth: (provider
|
|
|
84
98
|
const item = window as Record<string, unknown>;
|
|
85
99
|
return windowValue({ used: item.used_percent, resetsAt: typeof item.reset_at === "number" ? item.reset_at * 1000 : undefined });
|
|
86
100
|
};
|
|
87
|
-
|
|
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);
|
|
88
113
|
}
|
|
89
114
|
const parse = (window: unknown): QuotaWindow | undefined => {
|
|
90
115
|
if (!window || typeof window !== "object") return undefined;
|
|
@@ -97,10 +122,16 @@ export async function fetchQuota(provider: QuotaProvider, resolveAuth: (provider
|
|
|
97
122
|
} catch { return undefined; }
|
|
98
123
|
}
|
|
99
124
|
|
|
100
|
-
/**
|
|
125
|
+
/**
|
|
126
|
+
* One in-flight request per provider; failures are negative-cached with exponential backoff;
|
|
127
|
+
* expired values are never shown. Reads never start IO: renders call `get` on every frame, and
|
|
128
|
+
* lookups start only from `refresh` at session boundaries.
|
|
129
|
+
*/
|
|
101
130
|
export class QuotaCache {
|
|
102
|
-
private entries = new Map<QuotaProvider, { value?: Quota; expiresAt: number }>();
|
|
131
|
+
private entries = new Map<QuotaProvider, { value?: Quota; requestedAt: number; expiresAt: number; failures: number }>();
|
|
103
132
|
private pending = new Map<QuotaProvider, AbortController>();
|
|
133
|
+
private requests = 0;
|
|
134
|
+
private failures = 0;
|
|
104
135
|
enabled = false;
|
|
105
136
|
private readonly fetcher: (provider: QuotaProvider, signal: AbortSignal) => Promise<Quota | undefined>;
|
|
106
137
|
private readonly changed: () => void;
|
|
@@ -111,34 +142,54 @@ export class QuotaCache {
|
|
|
111
142
|
get(provider: string | undefined, statuses: ReadonlyMap<string, string>, now: number): Quota | undefined {
|
|
112
143
|
if (provider !== "openai-codex" && provider !== "anthropic") return undefined;
|
|
113
144
|
const published = publishedQuota(statuses, provider, now);
|
|
114
|
-
if (published)
|
|
145
|
+
if (published) return published;
|
|
146
|
+
if (!this.enabled) return undefined;
|
|
147
|
+
const cached = this.entries.get(provider);
|
|
148
|
+
return cached && cached.expiresAt > now ? cached.value : undefined;
|
|
149
|
+
}
|
|
150
|
+
/** Start one background lookup when nothing fresh is cached and no retry delay is pending; true if it started. */
|
|
151
|
+
refresh(provider: string | undefined, statuses: ReadonlyMap<string, string>, now: number): boolean {
|
|
152
|
+
if (provider !== "openai-codex" && provider !== "anthropic" || !this.enabled) return false;
|
|
153
|
+
if (publishedQuota(statuses, provider, now)) {
|
|
154
|
+
// A publisher is authoritative; an authenticated fallback would only duplicate it.
|
|
115
155
|
this.pending.get(provider)?.abort();
|
|
116
156
|
this.pending.delete(provider);
|
|
117
|
-
return
|
|
157
|
+
return false;
|
|
118
158
|
}
|
|
119
|
-
if (!this.enabled) return undefined;
|
|
120
159
|
const cached = this.entries.get(provider);
|
|
121
|
-
if (cached && cached.expiresAt > now) return
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
160
|
+
if (cached && cached.expiresAt > now || this.pending.has(provider)) return false;
|
|
161
|
+
const controller = new AbortController();
|
|
162
|
+
this.pending.set(provider, controller);
|
|
163
|
+
this.requests++;
|
|
164
|
+
// A microtask keeps IO outside the caller's synchronous event handler.
|
|
165
|
+
queueMicrotask(() => {
|
|
166
|
+
if (controller.signal.aborted) {
|
|
167
|
+
if (this.pending.get(provider) === controller) this.pending.delete(provider);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
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) => {
|
|
172
|
+
if (controller.signal.aborted) return;
|
|
173
|
+
const failures = value ? 0 : (this.entries.get(provider)?.failures ?? 0) + 1;
|
|
174
|
+
if (!value) this.failures++;
|
|
175
|
+
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();
|
|
179
|
+
}).finally(() => {
|
|
180
|
+
if (this.pending.get(provider) === controller) this.pending.delete(provider);
|
|
139
181
|
});
|
|
140
|
-
}
|
|
141
|
-
return
|
|
182
|
+
});
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
/** Diagnostics: per-provider cache age and retry delay, computed on demand. */
|
|
186
|
+
stats(now = Date.now()): QuotaStats {
|
|
187
|
+
const providers = new Set<QuotaProvider>([...this.entries.keys(), ...this.pending.keys()]);
|
|
188
|
+
return { enabled: this.enabled, requests: this.requests, failures: this.failures, providers: [...providers].map((provider) => {
|
|
189
|
+
const entry = this.entries.get(provider);
|
|
190
|
+
return { provider, pending: this.pending.has(provider), failures: entry?.failures ?? 0,
|
|
191
|
+
...(entry ? { ok: entry.value !== undefined, ageMs: now - entry.requestedAt, retryInMs: Math.max(0, entry.expiresAt - now) } : {}) };
|
|
192
|
+
}) };
|
|
142
193
|
}
|
|
143
194
|
stop(): void { for (const controller of this.pending.values()) controller.abort(); this.pending.clear(); this.entries.clear(); }
|
|
144
195
|
}
|
package/src/settings.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync, mkdirSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { ACCENT_NAMES } from "./accent.ts";
|
|
4
|
-
import { DEFAULT_FOOTER_SETTINGS, FOOTER_FIELDS,
|
|
4
|
+
import { DEFAULT_FOOTER_SETTINGS, FOOTER_FIELDS, type FooterSettings } from "./footer-settings.ts";
|
|
5
5
|
import { ICON_SETS, type IconSet } from "./icons.ts";
|
|
6
6
|
|
|
7
7
|
export type JarAccent = "follow" | "default" | (typeof ACCENT_NAMES)[number];
|
|
@@ -45,7 +45,7 @@ export function loadVisualSettings(directory: string): JarVisualSettings {
|
|
|
45
45
|
let text: string;
|
|
46
46
|
try { text = readFileSync(join(directory, SETTINGS_FILE), "utf8"); }
|
|
47
47
|
catch (error) {
|
|
48
|
-
if ((error as NodeJS.ErrnoException).code === "ENOENT") return defaultVisualSettings(
|
|
48
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return defaultVisualSettings();
|
|
49
49
|
return defaultVisualSettings();
|
|
50
50
|
}
|
|
51
51
|
let raw: unknown;
|
|
@@ -76,16 +76,6 @@ export function loadVisualSettings(directory: string): JarVisualSettings {
|
|
|
76
76
|
};
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
export function migrateLegacySettings(directory: string): boolean {
|
|
80
|
-
if (existsSync(join(directory, SETTINGS_FILE)) || !existsSync(join(directory, "pi-jar-footer.json"))) return false;
|
|
81
|
-
try {
|
|
82
|
-
const legacy: unknown = JSON.parse(readFileSync(join(directory, "pi-jar-footer.json"), "utf8"));
|
|
83
|
-
if (!legacy || typeof legacy !== "object" || Array.isArray(legacy)) return false;
|
|
84
|
-
saveVisualSettings(directory, defaultVisualSettings(loadFooterSettings(directory)));
|
|
85
|
-
return true;
|
|
86
|
-
} catch { return false; } // Leave malformed legacy preferences untouched for manual repair.
|
|
87
|
-
}
|
|
88
|
-
|
|
89
79
|
export function saveVisualSettings(directory: string, settings: JarVisualSettings): void {
|
|
90
80
|
mkdirSync(directory, { recursive: true });
|
|
91
81
|
const target = join(directory, SETTINGS_FILE);
|