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.
package/src/shells.ts CHANGED
@@ -13,9 +13,19 @@ const MAX_KEPT_SHELLS = 20;
13
13
  const MAX_LINES = 2000;
14
14
  const LINE_PRUNE_BATCH = 128;
15
15
  const MAX_LINE_CHARS = 2000;
16
+ /** Per-job cap. Alone it would let every retained job pin ~1 MiB of logs. */
16
17
  const MAX_RETAINED_CHARS = 1024 * 1024;
18
+ /** All jobs together. Floors below (8 live tails + 20 compact tails) stay well under it. */
19
+ const DEFAULT_BUDGET_CHARS = 4 * 1024 * 1024;
20
+ /** Budget pressure never trims a live job below this, so services stay observable. */
21
+ const DEFAULT_LIVE_TAIL_CHARS = 64 * 1024;
17
22
  const MAX_TOOL_OUTPUT_CHARS = 32 * 1024;
23
+ /** A surfaced finished job keeps exactly what one `output` call can return. */
24
+ const COMPACT_TAIL_CHARS = MAX_TOOL_OUTPUT_CHARS;
25
+ const MAX_OUTPUT_LINES = 400;
18
26
  const KILL_GRACE_MS = 3000;
27
+ /** Trim 1/16 below a cap so a chatty job does not re-trim on every following line. */
28
+ const slack = (chars: number) => chars - (chars >> 4);
19
29
 
20
30
  export type ShellStatus = "running" | "exited" | "killed" | "failed";
21
31
  export interface ShellJob {
@@ -42,6 +52,19 @@ export interface ShellJob {
42
52
  }
43
53
  export type ShellEvent = { kind: "match" | "exit"; job: ShellJob };
44
54
  export interface StartOptions { command: string; cwd: string; name?: string; watch?: string; notify?: boolean; purpose?: "task" | "service" }
55
+ export interface ShellManagerOptions { budgetChars?: number; liveTailChars?: number }
56
+ export interface ShellStats {
57
+ /** Processes still alive, including killed jobs in their grace period and draining trees. */
58
+ live: number;
59
+ finished: number;
60
+ retainedChars: number;
61
+ budgetChars: number;
62
+ /** Live jobs marked purpose: service — expected long-lived processes, not leftovers. */
63
+ services: number;
64
+ oldestLiveStartedAt?: number;
65
+ /** `pid` is reported only while the process group is alive; a finished pid may be reused. */
66
+ jobs: Array<{ id: string; name: string; status: ShellStatus; purpose: "task" | "service"; pid?: number; chars: number }>;
67
+ }
45
68
 
46
69
  const ANSI = /\x1b\][^\x07]*(?:\x07|\x1b\\)?|\x1b\[[0-?]*[ -/]*[@-~]|\x1b./g;
47
70
 
@@ -50,23 +73,33 @@ interface ShellEntry {
50
73
  child?: ChildProcess;
51
74
  pattern?: RegExp;
52
75
  partial: string;
76
+ /** Retained line characters, maintained incrementally with the manager total. */
77
+ chars: number;
53
78
  timer?: ReturnType<typeof setTimeout>;
54
79
  closeTimer?: ReturnType<typeof setTimeout>;
55
80
  finished?: boolean;
56
81
  }
57
82
 
58
- /** Long-running shell commands with bounded output and optional pattern watches. */
83
+ /** Long-running shell commands with bounded output and optional pattern watches. Retained logs
84
+ * are bounded per job and globally; log pressure trims text, never processes. */
59
85
  export class ShellManager {
60
86
  private jobs = new Map<string, ShellEntry>();
61
87
  private next = 1;
62
88
  private pending = new Map<string, ShellEvent>();
63
89
  private listeners = new Set<() => void>();
64
90
  private disposed = false;
65
- private lineChars = new Map<string, number>();
91
+ private retained = 0;
92
+ private readonly budgetChars: number;
93
+ private readonly liveTailChars: number;
66
94
  private readonly onEvent: (event: ShellEvent) => void;
67
95
  private readonly spawnShell: typeof spawn;
68
96
  onChange?: () => void;
69
- constructor(onEvent: (event: ShellEvent) => void, spawnShell: typeof spawn = spawn) { this.onEvent = onEvent; this.spawnShell = spawnShell; }
97
+ constructor(onEvent: (event: ShellEvent) => void, spawnShell: typeof spawn = spawn, options: ShellManagerOptions = {}) {
98
+ this.onEvent = onEvent;
99
+ this.spawnShell = spawnShell;
100
+ this.budgetChars = Math.max(1, Math.floor(options.budgetChars ?? DEFAULT_BUDGET_CHARS));
101
+ this.liveTailChars = Math.max(0, Math.floor(options.liveTailChars ?? DEFAULT_LIVE_TAIL_CHARS));
102
+ }
70
103
 
71
104
  list(): ShellJob[] { return [...this.jobs.values()].map(({ job }) => ({ ...job, lines: [...job.lines] })); }
72
105
  /** Read by the footer on every render: one shallow copy per job, never the output lines. */
@@ -77,12 +110,23 @@ export class ShellManager {
77
110
  });
78
111
  }
79
112
  get(id: string): ShellJob | undefined { const entry = this.jobs.get(id); return entry && { ...entry.job, lines: [...entry.job.lines] }; }
80
- acknowledge(ids: readonly string[]): void { for (const id of ids) this.pending.delete(id); }
81
- takeNotifications(): ShellEvent[] { const events = [...this.pending.values()]; this.pending.clear(); return events; }
113
+ /** The agent saw these results: finished jobs drop everything but their compact summary. */
114
+ acknowledge(ids: readonly string[]): void {
115
+ for (const id of ids) { this.pending.delete(id); this.compact(id); }
116
+ }
117
+ /** Taken events are being delivered to the agent, which surfaces their results. */
118
+ takeNotifications(): ShellEvent[] {
119
+ const events = [...this.pending.values()];
120
+ this.pending.clear();
121
+ for (const event of events) this.compact(event.job.id);
122
+ return events;
123
+ }
82
124
  restoreNotifications(events: readonly ShellEvent[]): void {
83
125
  for (const event of events) if (this.jobs.has(event.job.id) && !this.pending.has(event.job.id)) this.pending.set(event.job.id, event);
84
126
  }
85
127
  pendingNotifications(): number { return this.pending.size; }
128
+ /** Non-consuming status snapshots for immediate UI feedback; never copy or expose log lines. */
129
+ notificationSnapshot(): ShellEvent[] { return [...this.pending.values()].map(event => ({ ...event, job: { ...event.job, lines: [] } })); }
86
130
  verificationPending(): Array<Omit<ShellJob, "lines">> {
87
131
  return [...this.jobs.values()].filter(entry => !entry.finished && entry.job.purpose !== "service").map(({ job }) => {
88
132
  const { lines: _lines, ...summary } = job;
@@ -135,9 +179,8 @@ export class ShellManager {
135
179
  const id = "s" + this.next++;
136
180
  const job: ShellJob = { id, name: cleanText(options.name || command, 40), command, cwd: options.cwd, startedAt: Date.now(), status: "running",
137
181
  notify: options.notify ?? true, complete: false, purpose: options.purpose ?? (options.watch ? "service" : "task"), lines: [], dropped: 0, ...(options.watch ? { watch: options.watch } : {}) };
138
- const entry: ShellEntry = { job, partial: "", ...(pattern ? { pattern } : {}) };
182
+ const entry: ShellEntry = { job, partial: "", chars: 0, ...(pattern ? { pattern } : {}) };
139
183
  this.jobs.set(id, entry);
140
- this.lineChars.set(id, 0);
141
184
  // Own process group so kill() stops the whole tree (dev servers spawn children).
142
185
  const child = this.spawnShell("/bin/sh", ["-c", command], { cwd: options.cwd, detached: process.platform !== "win32", stdio: ["ignore", "pipe", "pipe"], env: process.env });
143
186
  entry.child = child;
@@ -184,34 +227,67 @@ export class ShellManager {
184
227
  return { ...job, lines: [] };
185
228
  }
186
229
 
187
- private receive(entry: { job: ShellJob; pattern?: RegExp; partial: string }, text: string): void {
230
+ private receive(entry: ShellEntry, text: string): void {
188
231
  const parts = (entry.partial + text.replace(/\r\n/g, "\n")).split("\n");
189
232
  entry.partial = parts.pop() ?? "";
190
233
  if (entry.partial.length > MAX_LINE_CHARS) { this.push(entry, entry.partial); entry.partial = ""; }
191
234
  for (const line of parts) this.push(entry, line);
192
235
  }
193
236
 
194
- private push(entry: { job: ShellJob; pattern?: RegExp }, raw: string): void {
237
+ private push(entry: ShellEntry, raw: string): void {
238
+ // Disposed jobs are forgotten while their trees wind down; late output must not be counted.
239
+ if (this.disposed) return;
195
240
  const line = raw.replace(ANSI, "").replace(/\r/g, "").slice(0, MAX_LINE_CHARS);
196
241
  const { job } = entry;
197
242
  job.lines.push(line);
198
- this.lineChars.set(job.id, (this.lineChars.get(job.id) ?? 0) + line.length);
243
+ entry.chars += line.length;
244
+ this.retained += line.length;
199
245
  // Bound both line count and retained characters. A shell that prints very wide lines should
200
246
  // not quietly reserve several MiB forever just because it has not reached MAX_LINES yet.
201
- while (job.lines.length > MAX_LINES + LINE_PRUNE_BATCH || (this.lineChars.get(job.id) ?? 0) > MAX_RETAINED_CHARS) {
202
- const removed = job.lines.splice(0, Math.min(LINE_PRUNE_BATCH, job.lines.length));
203
- let removedChars = 0;
204
- for (const item of removed) removedChars += item.length;
205
- this.lineChars.set(job.id, Math.max(0, (this.lineChars.get(job.id) ?? 0) - removedChars));
206
- job.dropped += removed.length;
207
- if (!removed.length) break;
208
- }
247
+ if (job.lines.length > MAX_LINES + LINE_PRUNE_BATCH || entry.chars > MAX_RETAINED_CHARS) this.trim(entry, slack(MAX_RETAINED_CHARS), MAX_LINES);
248
+ if (this.retained > this.budgetChars) this.enforceBudget();
209
249
  if (entry.pattern && !job.matched && entry.pattern.test(line)) {
210
250
  job.matched = line;
211
251
  this.emitEvent("match", job);
212
252
  }
213
253
  }
214
254
 
255
+ /** Drop oldest lines until at most `chars` characters and `lines` lines remain. One splice. */
256
+ private trim(entry: ShellEntry, chars: number, lines = Infinity): void {
257
+ const buffer = entry.job.lines;
258
+ let count = 0;
259
+ let released = 0;
260
+ while (count < buffer.length && (entry.chars - released > chars || buffer.length - count > lines)) released += buffer[count++]!.length;
261
+ if (!count) return;
262
+ buffer.splice(0, count);
263
+ entry.chars -= released;
264
+ this.retained -= released;
265
+ entry.job.dropped += count;
266
+ }
267
+
268
+ /** Over budget: shrink oldest finished jobs to their compact tail, then oldest live jobs to
269
+ * their protected tail, then empty finished jobs. Jobs (≤ MAX_KEPT_SHELLS) iterate oldest
270
+ * first; the slack target keeps this off the per-line path. Processes are never touched. */
271
+ private enforceBudget(): void {
272
+ const target = slack(this.budgetChars);
273
+ for (const [floor, finished] of [[COMPACT_TAIL_CHARS, true], [this.liveTailChars, false], [0, true]] as const) {
274
+ for (const entry of this.jobs.values()) {
275
+ if (this.retained <= target) return;
276
+ if (!!entry.finished === finished) this.trim(entry, Math.max(floor, entry.chars - (this.retained - target)));
277
+ }
278
+ }
279
+ }
280
+
281
+ /** Keep exit status, command, timestamps, watch match and a bounded tail once surfaced. */
282
+ private compact(id: string): void {
283
+ const entry = this.jobs.get(id);
284
+ if (!entry?.finished) return;
285
+ this.trim(entry, COMPACT_TAIL_CHARS, MAX_OUTPUT_LINES);
286
+ // A finished job never reads its stream or matches again; release the process handle.
287
+ delete entry.child;
288
+ delete entry.pattern;
289
+ }
290
+
215
291
  private finish(entry: ShellEntry): void {
216
292
  if (entry.finished) return;
217
293
  entry.finished = true;
@@ -231,12 +307,34 @@ export class ShellManager {
231
307
  output(id: string, count = 40): string[] {
232
308
  const entry = this.jobs.get(id);
233
309
  if (!entry) throw new Error("no shell " + id);
234
- const limit = Math.max(1, Math.min(400, Math.floor(count)));
310
+ const limit = Math.max(1, Math.min(MAX_OUTPUT_LINES, Math.floor(count)));
235
311
  if (!entry.partial) return entry.job.lines.slice(-limit);
236
312
  if (limit === 1) return [entry.partial];
237
313
  return [...entry.job.lines.slice(-(limit - 1)), entry.partial];
238
314
  }
239
315
 
316
+ /** Diagnostics snapshot, computed on demand in one pass over ≤ MAX_KEPT_SHELLS jobs. */
317
+ stats(): ShellStats {
318
+ let live = 0;
319
+ let services = 0;
320
+ let partials = 0;
321
+ let oldestLiveStartedAt: number | undefined;
322
+ const jobs: ShellStats["jobs"] = [];
323
+ for (const entry of this.jobs.values()) {
324
+ const { job } = entry;
325
+ const purpose = job.purpose ?? "task";
326
+ partials += entry.partial.length;
327
+ if (!entry.finished) {
328
+ live++;
329
+ if (purpose === "service") services++;
330
+ if (oldestLiveStartedAt === undefined || job.startedAt < oldestLiveStartedAt) oldestLiveStartedAt = job.startedAt;
331
+ }
332
+ jobs.push({ id: job.id, name: job.name, status: job.status, purpose, chars: entry.chars + entry.partial.length, ...(!entry.finished && job.pid !== undefined ? { pid: job.pid } : {}) });
333
+ }
334
+ return { live, finished: this.jobs.size - live, retainedChars: this.retained + partials, budgetChars: this.budgetChars, services,
335
+ ...(oldestLiveStartedAt !== undefined ? { oldestLiveStartedAt } : {}), jobs };
336
+ }
337
+
240
338
  kill(id: string): boolean {
241
339
  const entry = this.jobs.get(id);
242
340
  if (!entry) throw new Error("no shell " + id);
@@ -255,13 +353,17 @@ export class ShellManager {
255
353
  return true;
256
354
  }
257
355
 
258
- /** Forget finished jobs beyond the retention limit (oldest first). */
356
+ /** Forget finished jobs beyond the retention limit (oldest first). A killed job still in its
357
+ * grace period or a draining tree is alive and stays managed until it finishes. */
259
358
  private prune(): void {
260
- const finished = [...this.jobs.values()].filter(({ job }) => job.status !== "running");
261
- for (const { job } of finished.slice(0, Math.max(0, this.jobs.size - MAX_KEPT_SHELLS + 1))) {
262
- this.jobs.delete(job.id);
263
- this.lineChars.delete(job.id);
264
- this.pending.delete(job.id);
359
+ let excess = this.jobs.size - MAX_KEPT_SHELLS + 1;
360
+ for (const [id, entry] of this.jobs) {
361
+ if (excess <= 0) return;
362
+ if (!entry.finished) continue;
363
+ this.jobs.delete(id);
364
+ this.retained -= entry.chars;
365
+ this.pending.delete(id);
366
+ excess--;
265
367
  }
266
368
  }
267
369
 
@@ -269,9 +371,14 @@ export class ShellManager {
269
371
  this.disposed = true;
270
372
  this.pending.clear();
271
373
  for (const listener of [...this.listeners]) listener();
272
- for (const id of this.jobs.keys()) { try { this.kill(id); } catch { /* already gone */ } }
374
+ for (const entry of this.jobs.values()) {
375
+ if (entry.finished) continue;
376
+ // Running trees get SIGTERM plus the bounded SIGKILL grace. A draining tree (leader gone,
377
+ // descendants holding pipes) is reclaimed now rather than after its drain timer.
378
+ if (!this.kill(entry.job.id) && entry.job.status !== "killed") this.finish(entry);
379
+ }
273
380
  this.jobs.clear();
274
- this.lineChars.clear();
381
+ this.retained = 0;
275
382
  }
276
383
  }
277
384
 
@@ -283,7 +390,8 @@ export const elapsed = (span: { startedAt: number; endedAt?: number }, now = Dat
283
390
  };
284
391
  export const shellState = (job: ShellMeta | ShellJob) => job.status === "running" ? "running"
285
392
  : job.status === "exited" ? `exited ${job.exitCode ?? "?"}${job.complete === false ? " (draining)" : ""}` : job.status === "failed" ? `failed: ${cleanText(job.error ?? "", 60)}` : "killed";
286
- export const describe = (job: ShellMeta | ShellJob) => `${job.id} · ${job.name} · ${shellState(job)} · ${elapsed(job)}${job.watch ? ` · watch /${job.watch}/${job.matched ? " matched" : ""}` : ""}`;
393
+ // Services are labelled so a long-lived process reads as intended, not as a forgotten leftover.
394
+ export const describe = (job: ShellMeta | ShellJob) => `${job.id} · ${job.name} · ${shellState(job)} · ${elapsed(job)}${job.purpose === "service" ? " · service" : ""}${job.watch ? ` · watch /${job.watch}/${job.matched ? " matched" : ""}` : ""}`;
287
395
  const boundedTail = (lines: readonly string[], limit = MAX_TOOL_OUTPUT_CHARS): string => {
288
396
  let size = 0;
289
397
  const kept: string[] = [];
@@ -332,6 +440,7 @@ export function registerShells(pi: ExtensionAPI, shells: () => ShellManager | un
332
440
  promptGuidelines: [
333
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\").",
334
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.",
335
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."
336
445
  ],
337
446
  parameters: Parameters,
@@ -347,7 +456,7 @@ export function registerShells(pi: ExtensionAPI, shells: () => ShellManager | un
347
456
  case "start": {
348
457
  const job = manager.start({ command: params.command ?? "", cwd: ctx.cwd, ...(params.name ? { name: params.name } : {}), ...(params.watch ? { watch: params.watch } : {}),
349
458
  ...(params.notify !== undefined ? { notify: params.notify } : {}), ...(params.purpose ? { purpose: params.purpose } : {}) });
350
- return reply(`Started ${describe(job)} (pid ${job.pid ?? "?"}). ${job.notify ? "You will be woken when it " + (job.watch ? "matches or " : "") + "exits." : "Notifications are off; check it with output."}`);
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."}`);
351
460
  }
352
461
  case "output": {
353
462
  if (!params.id) return reply("output needs an id.");
package/src/side-model.ts CHANGED
@@ -30,7 +30,7 @@ export interface SideAnswer { text: string; model: string }
30
30
  * Ask the model assigned to `role` (or the current model) a one-shot question outside the
31
31
  * conversation. Tries configured fallbacks in order on failure; cancellation never retries.
32
32
  */
33
- export async function askRole(ctx: ExtensionContext, roles: ModelRoleManager, usage: SideUsage | undefined, role: string,
33
+ export async function askRole(ctx: ExtensionContext, roles: ModelRoleManager, usage: Pick<SideUsage, "add"> | undefined, role: string,
34
34
  systemPrompt: string, prompt: string, signal?: AbortSignal): Promise<SideAnswer> {
35
35
  const fallbacks = roles.fallbackSpecs?.(role) ?? [];
36
36
  const failures: string[] = [];
@@ -52,6 +52,8 @@ export async function askRole(ctx: ExtensionContext, roles: ModelRoleManager, us
52
52
  const result = await ctx.modelRegistry.streamSimple(model,
53
53
  { systemPrompt, messages: [{ role: "user", content: prompt, timestamp: Date.now() }] },
54
54
  { ...(reasoning ? { reasoning } : {}), ...(signal ? { signal } : {}) }).result();
55
+ // Callers with detached work can supply a session-generation-safe usage sink.
56
+ // Returned attempts (including cancelled attempts) still account for actual usage.
55
57
  const cost = result.usage?.cost?.total;
56
58
  usage?.add({ role, model: name, input: result.usage?.input ?? 0, output: result.usage?.output ?? 0, cacheRead: result.usage?.cacheRead ?? 0,
57
59
  cacheWrite: result.usage?.cacheWrite ?? 0, cost: Number.isFinite(cost) ? cost! : 0, at: Date.now() });
package/src/welcome.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { sliceByColumn, stripTerminalSequences, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
1
+ import { sliceByColumn, stripTerminalSequences, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
2
2
  import { FLAME_RAMP, FLAME_WIDTH, flameFrame, rgb, supportsTruecolor } from "./flame.ts";
3
3
  import { cleanText } from "./status.ts";
4
4
  import { iconSet, withIcon } from "./icons.ts";
@@ -10,6 +10,8 @@ export interface WelcomeInfo {
10
10
  model?: string;
11
11
  effort?: string;
12
12
  activeRole?: string;
13
+ /** Active configuration profile, shown even before a session is named. */
14
+ profile?: string;
13
15
  project?: string;
14
16
  context?: string;
15
17
  cost?: string;
@@ -189,6 +191,8 @@ function card(width: number, fg: Paint, info: WelcomeInfo): string[] {
189
191
  cell(center(fg("dim", "— welcome to ") + fg("accent", "pi-jar") + fg("dim", " —"), inner)),
190
192
  cell(""),
191
193
  divider,
194
+ ...wrapTextWithAnsi(cleanText(info.profile ?? "Default", 32) || "Default", Math.max(1, inner - labelWidth))
195
+ .map((line, index) => cell(index ? fg("dim", " ".repeat(labelWidth)) + fg("accent", line) : label("PROFILE", line, "accent"))),
192
196
  cell(label("PROJECT", withIcon("folder", info.project || "unavailable") + " · " + git, info.dirty ? "warning" : "muted")),
193
197
  cell(label("SESSION", [info.context ?? "ctx ?", quota, info.cost ?? ""].filter(Boolean).join(" · "))),
194
198
  divider,
@@ -262,6 +266,7 @@ function renderWelcome(width: number, frame: number, fg: Paint, info: WelcomeInf
262
266
  ...flame.slice(4).map((line) => fit(sliceByColumn(line, start, Math.min(width, FLAME_WIDTH)))),
263
267
  ...PI_COMPACT.map((line) => fit(fg("accent", line))),
264
268
  fit(fg("accent", "pi-jar")),
269
+ fit(fg("accent", "Profile: " + (cleanText(info.profile ?? "Default", 32) || "Default"))),
265
270
  ...wrap(cleanText(info.message ?? HOPEFUL_WELCOME_MESSAGES[0], 100), width).map((line) => fit("\x1b[1m" + fg("warning", line) + "\x1b[22m")),
266
271
  fit(fg(info.settingsClickable === false ? "dim" : "accent", info.settingsClickable === false ? "ctrl+alt+s settings" : "[ ⚙ Settings ]")),
267
272
  ""