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/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,8 +110,17 @@ 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
  }
@@ -135,9 +177,8 @@ export class ShellManager {
135
177
  const id = "s" + this.next++;
136
178
  const job: ShellJob = { id, name: cleanText(options.name || command, 40), command, cwd: options.cwd, startedAt: Date.now(), status: "running",
137
179
  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 } : {}) };
180
+ const entry: ShellEntry = { job, partial: "", chars: 0, ...(pattern ? { pattern } : {}) };
139
181
  this.jobs.set(id, entry);
140
- this.lineChars.set(id, 0);
141
182
  // Own process group so kill() stops the whole tree (dev servers spawn children).
142
183
  const child = this.spawnShell("/bin/sh", ["-c", command], { cwd: options.cwd, detached: process.platform !== "win32", stdio: ["ignore", "pipe", "pipe"], env: process.env });
143
184
  entry.child = child;
@@ -184,34 +225,67 @@ export class ShellManager {
184
225
  return { ...job, lines: [] };
185
226
  }
186
227
 
187
- private receive(entry: { job: ShellJob; pattern?: RegExp; partial: string }, text: string): void {
228
+ private receive(entry: ShellEntry, text: string): void {
188
229
  const parts = (entry.partial + text.replace(/\r\n/g, "\n")).split("\n");
189
230
  entry.partial = parts.pop() ?? "";
190
231
  if (entry.partial.length > MAX_LINE_CHARS) { this.push(entry, entry.partial); entry.partial = ""; }
191
232
  for (const line of parts) this.push(entry, line);
192
233
  }
193
234
 
194
- private push(entry: { job: ShellJob; pattern?: RegExp }, raw: string): void {
235
+ private push(entry: ShellEntry, raw: string): void {
236
+ // Disposed jobs are forgotten while their trees wind down; late output must not be counted.
237
+ if (this.disposed) return;
195
238
  const line = raw.replace(ANSI, "").replace(/\r/g, "").slice(0, MAX_LINE_CHARS);
196
239
  const { job } = entry;
197
240
  job.lines.push(line);
198
- this.lineChars.set(job.id, (this.lineChars.get(job.id) ?? 0) + line.length);
241
+ entry.chars += line.length;
242
+ this.retained += line.length;
199
243
  // Bound both line count and retained characters. A shell that prints very wide lines should
200
244
  // 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
- }
245
+ if (job.lines.length > MAX_LINES + LINE_PRUNE_BATCH || entry.chars > MAX_RETAINED_CHARS) this.trim(entry, slack(MAX_RETAINED_CHARS), MAX_LINES);
246
+ if (this.retained > this.budgetChars) this.enforceBudget();
209
247
  if (entry.pattern && !job.matched && entry.pattern.test(line)) {
210
248
  job.matched = line;
211
249
  this.emitEvent("match", job);
212
250
  }
213
251
  }
214
252
 
253
+ /** Drop oldest lines until at most `chars` characters and `lines` lines remain. One splice. */
254
+ private trim(entry: ShellEntry, chars: number, lines = Infinity): void {
255
+ const buffer = entry.job.lines;
256
+ let count = 0;
257
+ let released = 0;
258
+ while (count < buffer.length && (entry.chars - released > chars || buffer.length - count > lines)) released += buffer[count++]!.length;
259
+ if (!count) return;
260
+ buffer.splice(0, count);
261
+ entry.chars -= released;
262
+ this.retained -= released;
263
+ entry.job.dropped += count;
264
+ }
265
+
266
+ /** Over budget: shrink oldest finished jobs to their compact tail, then oldest live jobs to
267
+ * their protected tail, then empty finished jobs. Jobs (≤ MAX_KEPT_SHELLS) iterate oldest
268
+ * first; the slack target keeps this off the per-line path. Processes are never touched. */
269
+ private enforceBudget(): void {
270
+ const target = slack(this.budgetChars);
271
+ for (const [floor, finished] of [[COMPACT_TAIL_CHARS, true], [this.liveTailChars, false], [0, true]] as const) {
272
+ for (const entry of this.jobs.values()) {
273
+ if (this.retained <= target) return;
274
+ if (!!entry.finished === finished) this.trim(entry, Math.max(floor, entry.chars - (this.retained - target)));
275
+ }
276
+ }
277
+ }
278
+
279
+ /** Keep exit status, command, timestamps, watch match and a bounded tail once surfaced. */
280
+ private compact(id: string): void {
281
+ const entry = this.jobs.get(id);
282
+ if (!entry?.finished) return;
283
+ this.trim(entry, COMPACT_TAIL_CHARS, MAX_OUTPUT_LINES);
284
+ // A finished job never reads its stream or matches again; release the process handle.
285
+ delete entry.child;
286
+ delete entry.pattern;
287
+ }
288
+
215
289
  private finish(entry: ShellEntry): void {
216
290
  if (entry.finished) return;
217
291
  entry.finished = true;
@@ -231,12 +305,34 @@ export class ShellManager {
231
305
  output(id: string, count = 40): string[] {
232
306
  const entry = this.jobs.get(id);
233
307
  if (!entry) throw new Error("no shell " + id);
234
- const limit = Math.max(1, Math.min(400, Math.floor(count)));
308
+ const limit = Math.max(1, Math.min(MAX_OUTPUT_LINES, Math.floor(count)));
235
309
  if (!entry.partial) return entry.job.lines.slice(-limit);
236
310
  if (limit === 1) return [entry.partial];
237
311
  return [...entry.job.lines.slice(-(limit - 1)), entry.partial];
238
312
  }
239
313
 
314
+ /** Diagnostics snapshot, computed on demand in one pass over ≤ MAX_KEPT_SHELLS jobs. */
315
+ stats(): ShellStats {
316
+ let live = 0;
317
+ let services = 0;
318
+ let partials = 0;
319
+ let oldestLiveStartedAt: number | undefined;
320
+ const jobs: ShellStats["jobs"] = [];
321
+ for (const entry of this.jobs.values()) {
322
+ const { job } = entry;
323
+ const purpose = job.purpose ?? "task";
324
+ partials += entry.partial.length;
325
+ if (!entry.finished) {
326
+ live++;
327
+ if (purpose === "service") services++;
328
+ if (oldestLiveStartedAt === undefined || job.startedAt < oldestLiveStartedAt) oldestLiveStartedAt = job.startedAt;
329
+ }
330
+ 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 } : {}) });
331
+ }
332
+ return { live, finished: this.jobs.size - live, retainedChars: this.retained + partials, budgetChars: this.budgetChars, services,
333
+ ...(oldestLiveStartedAt !== undefined ? { oldestLiveStartedAt } : {}), jobs };
334
+ }
335
+
240
336
  kill(id: string): boolean {
241
337
  const entry = this.jobs.get(id);
242
338
  if (!entry) throw new Error("no shell " + id);
@@ -255,13 +351,17 @@ export class ShellManager {
255
351
  return true;
256
352
  }
257
353
 
258
- /** Forget finished jobs beyond the retention limit (oldest first). */
354
+ /** Forget finished jobs beyond the retention limit (oldest first). A killed job still in its
355
+ * grace period or a draining tree is alive and stays managed until it finishes. */
259
356
  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);
357
+ let excess = this.jobs.size - MAX_KEPT_SHELLS + 1;
358
+ for (const [id, entry] of this.jobs) {
359
+ if (excess <= 0) return;
360
+ if (!entry.finished) continue;
361
+ this.jobs.delete(id);
362
+ this.retained -= entry.chars;
363
+ this.pending.delete(id);
364
+ excess--;
265
365
  }
266
366
  }
267
367
 
@@ -269,9 +369,14 @@ export class ShellManager {
269
369
  this.disposed = true;
270
370
  this.pending.clear();
271
371
  for (const listener of [...this.listeners]) listener();
272
- for (const id of this.jobs.keys()) { try { this.kill(id); } catch { /* already gone */ } }
372
+ for (const entry of this.jobs.values()) {
373
+ if (entry.finished) continue;
374
+ // Running trees get SIGTERM plus the bounded SIGKILL grace. A draining tree (leader gone,
375
+ // descendants holding pipes) is reclaimed now rather than after its drain timer.
376
+ if (!this.kill(entry.job.id) && entry.job.status !== "killed") this.finish(entry);
377
+ }
273
378
  this.jobs.clear();
274
- this.lineChars.clear();
379
+ this.retained = 0;
275
380
  }
276
381
  }
277
382
 
@@ -283,7 +388,8 @@ export const elapsed = (span: { startedAt: number; endedAt?: number }, now = Dat
283
388
  };
284
389
  export const shellState = (job: ShellMeta | ShellJob) => job.status === "running" ? "running"
285
390
  : 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" : ""}` : ""}`;
391
+ // Services are labelled so a long-lived process reads as intended, not as a forgotten leftover.
392
+ 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
393
  const boundedTail = (lines: readonly string[], limit = MAX_TOOL_OUTPUT_CHARS): string => {
288
394
  let size = 0;
289
395
  const kept: string[] = [];