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/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
|
|
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
|
|
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
|
-
|
|
81
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
202
|
-
|
|
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(
|
|
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
|
-
|
|
261
|
-
for (const
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
this.
|
|
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
|
|
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.
|
|
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
|
-
|
|
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[] = [];
|