shariq-pi-extensions 0.1.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.
Files changed (123) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +94 -0
  3. package/THIRD_PARTY_NOTICES.md +11 -0
  4. package/docs/ARCHITECTURE.md +54 -0
  5. package/docs/DEVELOPMENT.md +88 -0
  6. package/docs/EXTENSIONS.md +89 -0
  7. package/extensions/antigravity-provider/LICENSE +21 -0
  8. package/extensions/antigravity-provider/README.md +23 -0
  9. package/extensions/antigravity-provider/antigravity/cloud-code-assist.ts +519 -0
  10. package/extensions/antigravity-provider/antigravity/index.ts +67 -0
  11. package/extensions/antigravity-provider/antigravity/models.ts +184 -0
  12. package/extensions/antigravity-provider/antigravity/oauth.ts +499 -0
  13. package/extensions/antigravity-provider/index.ts +3 -0
  14. package/extensions/ask-user/README.md +21 -0
  15. package/extensions/ask-user/index.ts +417 -0
  16. package/extensions/background-terminals/README.md +43 -0
  17. package/extensions/background-terminals/index.ts +400 -0
  18. package/extensions/background-terminals/src/manager.ts +480 -0
  19. package/extensions/background-terminals/src/output-buffer.ts +110 -0
  20. package/extensions/background-terminals/src/presentation.ts +74 -0
  21. package/extensions/background-terminals/src/types.ts +47 -0
  22. package/extensions/background-terminals/src/ui.ts +478 -0
  23. package/extensions/context-usage/LICENSE +21 -0
  24. package/extensions/context-usage/README.md +20 -0
  25. package/extensions/context-usage/index.ts +2006 -0
  26. package/extensions/context-usage/lib/ansi.ts +60 -0
  27. package/extensions/context-usage/lib/boundary.ts +27 -0
  28. package/extensions/context-usage/lib/chat.ts +190 -0
  29. package/extensions/context-usage/lib/config.ts +30 -0
  30. package/extensions/context-usage/lib/fmt.ts +59 -0
  31. package/extensions/context-usage/lib/style.ts +260 -0
  32. package/extensions/copy-all/README.md +15 -0
  33. package/extensions/copy-all/index.ts +72 -0
  34. package/extensions/factory-provider/API_KEYS.md +17 -0
  35. package/extensions/factory-provider/AUTH.md +15 -0
  36. package/extensions/factory-provider/README.md +90 -0
  37. package/extensions/factory-provider/REFRESH_MODELS.md +185 -0
  38. package/extensions/factory-provider/UPDATING.md +97 -0
  39. package/extensions/factory-provider/factory/api-keys.ts +407 -0
  40. package/extensions/factory-provider/factory/auth.ts +132 -0
  41. package/extensions/factory-provider/factory/constants.ts +15 -0
  42. package/extensions/factory-provider/factory/dashboard.ts +363 -0
  43. package/extensions/factory-provider/factory/droid.ts +84 -0
  44. package/extensions/factory-provider/factory/limits.ts +342 -0
  45. package/extensions/factory-provider/factory/models.ts +283 -0
  46. package/extensions/factory-provider/factory/responses.ts +331 -0
  47. package/extensions/factory-provider/factory/warm-theme.ts +53 -0
  48. package/extensions/factory-provider/factory/websocket.ts +130 -0
  49. package/extensions/factory-provider/index.ts +204 -0
  50. package/extensions/factory-provider/scripts/audit-reasoning-efforts.py +84 -0
  51. package/extensions/firecrawl-web/README.md +31 -0
  52. package/extensions/firecrawl-web/auth.ts +92 -0
  53. package/extensions/firecrawl-web/client.ts +238 -0
  54. package/extensions/firecrawl-web/index.ts +96 -0
  55. package/extensions/firecrawl-web/output.ts +125 -0
  56. package/extensions/git-info/README.md +25 -0
  57. package/extensions/git-info/index.ts +162 -0
  58. package/extensions/git-info/src/git.ts +206 -0
  59. package/extensions/git-info/src/ui.ts +291 -0
  60. package/extensions/goal/README.md +44 -0
  61. package/extensions/goal/constants.ts +4 -0
  62. package/extensions/goal/extension.ts +1064 -0
  63. package/extensions/goal/index.ts +2 -0
  64. package/extensions/goal/types.ts +56 -0
  65. package/extensions/goal/ui.ts +334 -0
  66. package/extensions/orchestration/README.md +58 -0
  67. package/extensions/orchestration/dashboard.ts +192 -0
  68. package/extensions/orchestration/engine.ts +706 -0
  69. package/extensions/orchestration/index.ts +243 -0
  70. package/extensions/orchestration/prompts.ts +124 -0
  71. package/extensions/orchestration/protocol.ts +116 -0
  72. package/extensions/orchestration/settings-ui.ts +41 -0
  73. package/extensions/orchestration/settings.ts +59 -0
  74. package/extensions/orchestration/storage.ts +49 -0
  75. package/extensions/orchestration/types.ts +105 -0
  76. package/extensions/performance-status/README.md +5 -0
  77. package/extensions/performance-status/index.ts +171 -0
  78. package/extensions/pi-memory/README.md +53 -0
  79. package/extensions/pi-memory/index.ts +326 -0
  80. package/extensions/pi-memory/src/config.ts +75 -0
  81. package/extensions/pi-memory/src/database.ts +617 -0
  82. package/extensions/pi-memory/src/extraction.ts +178 -0
  83. package/extensions/pi-memory/src/project.ts +80 -0
  84. package/extensions/pi-memory/src/projection.ts +59 -0
  85. package/extensions/pi-memory/src/redaction.ts +60 -0
  86. package/extensions/pi-memory/src/retrieval.ts +30 -0
  87. package/extensions/pi-memory/src/service.ts +194 -0
  88. package/extensions/pi-memory/src/session.ts +146 -0
  89. package/extensions/pi-memory/src/types.ts +82 -0
  90. package/extensions/shared/README.md +18 -0
  91. package/extensions/shared/activity-status.ts +31 -0
  92. package/extensions/shared/child-session.ts +150 -0
  93. package/extensions/shared/context-utilization.ts +47 -0
  94. package/extensions/shared/dashboard-state.ts +99 -0
  95. package/extensions/shared/tool-call-timeout.ts +104 -0
  96. package/extensions/shared/tui-dashboard.ts +139 -0
  97. package/extensions/shell-shortcuts/README.md +17 -0
  98. package/extensions/shell-shortcuts/features/exit-alias.ts +10 -0
  99. package/extensions/shell-shortcuts/index.ts +6 -0
  100. package/extensions/subagents/README.md +89 -0
  101. package/extensions/subagents/index.ts +1386 -0
  102. package/extensions/subagents/src/backend.ts +69 -0
  103. package/extensions/subagents/src/backends/pi.ts +781 -0
  104. package/extensions/subagents/src/catalog.ts +85 -0
  105. package/extensions/subagents/src/config.ts +241 -0
  106. package/extensions/subagents/src/context.ts +57 -0
  107. package/extensions/subagents/src/coordinator.ts +64 -0
  108. package/extensions/subagents/src/domain.ts +294 -0
  109. package/extensions/subagents/src/format.ts +74 -0
  110. package/extensions/subagents/src/manager.ts +813 -0
  111. package/extensions/subagents/src/prompt.ts +94 -0
  112. package/extensions/subagents/src/result-delivery.ts +20 -0
  113. package/extensions/subagents/src/runtime.ts +48 -0
  114. package/extensions/subagents/src/ui/takeover.ts +695 -0
  115. package/extensions/subagents/src/ui/transcript.ts +182 -0
  116. package/extensions/subagents/src/worktree.ts +175 -0
  117. package/extensions/web-fetch/README.md +13 -0
  118. package/extensions/web-fetch/index.ts +441 -0
  119. package/package.json +117 -0
  120. package/skills/background-terminals/SKILL.md +42 -0
  121. package/skills/orchestration/SKILL.md +33 -0
  122. package/skills/subagents/SKILL.md +89 -0
  123. package/themes/ember-warm-dark.json +85 -0
@@ -0,0 +1,480 @@
1
+ import * as fs from "node:fs";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ import { spawn as spawnPty, type IDisposable, type IPty } from "@lydell/node-pty";
5
+ import { OutputBuffer } from "./output-buffer.ts";
6
+ import type {
7
+ StartTerminalOptions,
8
+ TerminalReadResult,
9
+ TerminalSnapshot,
10
+ TerminalStatus,
11
+ } from "./types.ts";
12
+
13
+ export const MAX_RUNNING_TERMINALS = 8;
14
+ export const MAX_TRACKED_TERMINALS = 32;
15
+ export const RETAINED_OUTPUT_BYTES = 2 * 1024 * 1024;
16
+ export const MAX_FULL_LOG_BYTES = 64 * 1024 * 1024;
17
+ const TERM_GRACE_MS = 2_000;
18
+ const KILL_GRACE_MS = 750;
19
+ const FLUSH_GRACE_MS = 1_000;
20
+
21
+ interface MutableSnapshot {
22
+ id: string;
23
+ title: string;
24
+ command: string;
25
+ cwd: string;
26
+ pid: number;
27
+ status: TerminalStatus;
28
+ createdAt: number;
29
+ settledAt?: number;
30
+ exitCode?: number;
31
+ signal?: number;
32
+ errorText?: string;
33
+ readonly output: ReturnType<OutputBuffer["view"]>;
34
+ cols: number;
35
+ rows: number;
36
+ }
37
+
38
+ interface Entry {
39
+ readonly snapshot: MutableSnapshot;
40
+ readonly pty: IPty;
41
+ readonly output: OutputBuffer;
42
+ readonly spill: fs.WriteStream;
43
+ readonly dataSubscription: IDisposable;
44
+ readonly exitSubscription: IDisposable;
45
+ readonly settled: Promise<TerminalSnapshot>;
46
+ resolveSettled(snapshot: TerminalSnapshot): void;
47
+ killRequested: boolean;
48
+ settling: boolean;
49
+ settledFinal: boolean;
50
+ }
51
+
52
+ export interface TerminalManagerView {
53
+ list(): ReadonlyArray<TerminalSnapshot>;
54
+ get(id: string): TerminalSnapshot | undefined;
55
+ subscribe(listener: () => void): () => void;
56
+ subscribeTo(id: string, listener: () => void): () => void;
57
+ requestKill(id: string): void;
58
+ requestWrite(id: string, input: string): void;
59
+ requestResize(id: string, cols: number, rows: number): void;
60
+ }
61
+
62
+ function asSnapshot(snapshot: MutableSnapshot): TerminalSnapshot {
63
+ return snapshot;
64
+ }
65
+
66
+ function cleanError(error: unknown): string {
67
+ return (error instanceof Error ? error.message : String(error)).slice(0, 4_096);
68
+ }
69
+
70
+ function shellInvocation(command: string): { file: string; args: string[] } {
71
+ if (process.platform === "win32") {
72
+ return {
73
+ file: process.env.ComSpec ?? "cmd.exe",
74
+ args: ["/d", "/s", "/c", command],
75
+ };
76
+ }
77
+ const configured = process.env.SHELL;
78
+ const file = configured && path.isAbsolute(configured) && fs.existsSync(configured)
79
+ ? configured
80
+ : "/bin/sh";
81
+ return { file, args: ["-c", command] };
82
+ }
83
+
84
+ function wait(milliseconds: number): Promise<void> {
85
+ return new Promise((resolve) => {
86
+ const timer = setTimeout(resolve, milliseconds);
87
+ timer.unref?.();
88
+ });
89
+ }
90
+
91
+ export class TerminalManager {
92
+ private readonly entries = new Map<string, Entry>();
93
+ private readonly listeners = new Set<() => void>();
94
+ private readonly idListeners = new Map<string, Set<() => void>>();
95
+ private readonly spillDir: string;
96
+ private counter = 0;
97
+ private starting = 0;
98
+ private disposed = false;
99
+ private settledListener?: (snapshot: TerminalSnapshot) => void;
100
+ private readonly environment: NodeJS.ProcessEnv;
101
+ private readonly maxFullLogBytes: number;
102
+
103
+ readonly view: TerminalManagerView;
104
+
105
+ constructor(
106
+ environment: NodeJS.ProcessEnv = process.env,
107
+ options: { maxFullLogBytes?: number } = {},
108
+ ) {
109
+ this.environment = environment;
110
+ this.maxFullLogBytes = Math.max(1, options.maxFullLogBytes ?? MAX_FULL_LOG_BYTES);
111
+ const base = path.join(os.tmpdir(), "pi-background-terminals");
112
+ fs.mkdirSync(base, { recursive: true, mode: 0o700 });
113
+ fs.chmodSync(base, 0o700);
114
+ this.spillDir = fs.mkdtempSync(path.join(base, "session-"));
115
+ fs.chmodSync(this.spillDir, 0o700);
116
+ this.view = {
117
+ list: () => this.list(),
118
+ get: (id) => this.get(id),
119
+ subscribe: (listener) => this.subscribe(listener),
120
+ subscribeTo: (id, listener) => this.subscribeTo(id, listener),
121
+ requestKill: (id) => {
122
+ void this.kill([id]);
123
+ },
124
+ requestWrite: (id, input) => {
125
+ try {
126
+ this.write(id, input);
127
+ } catch {
128
+ // The detail view rerenders the settled/error state.
129
+ }
130
+ },
131
+ requestResize: (id, cols, rows) => {
132
+ try {
133
+ this.resize(id, cols, rows);
134
+ } catch {
135
+ // Resize races with process exit are harmless.
136
+ }
137
+ },
138
+ };
139
+ }
140
+
141
+ setOnSettled(listener: ((snapshot: TerminalSnapshot) => void) | undefined): void {
142
+ this.settledListener = listener;
143
+ }
144
+
145
+ list(): ReadonlyArray<TerminalSnapshot> {
146
+ return [...this.entries.values()]
147
+ .map((entry) => asSnapshot(entry.snapshot))
148
+ .sort((left, right) => right.createdAt - left.createdAt);
149
+ }
150
+
151
+ get(id: string): TerminalSnapshot | undefined {
152
+ return this.entries.get(id)?.snapshot;
153
+ }
154
+
155
+ private notify(id?: string): void {
156
+ for (const listener of [...this.listeners]) {
157
+ try {
158
+ listener();
159
+ } catch {
160
+ // UI observers cannot own process lifecycle.
161
+ }
162
+ }
163
+ if (!id) return;
164
+ for (const listener of [...(this.idListeners.get(id) ?? [])]) {
165
+ try {
166
+ listener();
167
+ } catch {
168
+ // Same isolation for detail observers.
169
+ }
170
+ }
171
+ }
172
+
173
+ private subscribe(listener: () => void): () => void {
174
+ this.listeners.add(listener);
175
+ return () => this.listeners.delete(listener);
176
+ }
177
+
178
+ private subscribeTo(id: string, listener: () => void): () => void {
179
+ const listeners = this.idListeners.get(id) ?? new Set<() => void>();
180
+ listeners.add(listener);
181
+ this.idListeners.set(id, listeners);
182
+ return () => {
183
+ listeners.delete(listener);
184
+ if (listeners.size === 0) this.idListeners.delete(id);
185
+ };
186
+ }
187
+
188
+ start(options: StartTerminalOptions): TerminalSnapshot {
189
+ if (this.disposed) throw new Error("Background terminal manager is shutting down.");
190
+ const running = this.list().filter((snapshot) => snapshot.status === "running").length;
191
+ if (running + this.starting >= MAX_RUNNING_TERMINALS) {
192
+ throw new Error(`At most ${MAX_RUNNING_TERMINALS} background terminals can run at once.`);
193
+ }
194
+ this.starting++;
195
+ try {
196
+ const id = `term-${++this.counter}`;
197
+ const cols = Math.min(500, Math.max(20, options.cols ?? 120));
198
+ const rows = Math.min(200, Math.max(8, options.rows ?? 30));
199
+ const spillPath = path.join(this.spillDir, `${id}.log`);
200
+ const spill = fs.createWriteStream(spillPath, { flags: "a", mode: 0o600 });
201
+ let spillHealthy = true;
202
+ let spillBytes = 0;
203
+ let spillTruncated = false;
204
+ let pausedForSpill = false;
205
+ let pty: IPty | undefined;
206
+ let snapshot: MutableSnapshot | undefined;
207
+ const markSpillTruncated = () => {
208
+ if (spillTruncated) return;
209
+ spillTruncated = true;
210
+ output.spillTruncated = true;
211
+ };
212
+ const output = new OutputBuffer(RETAINED_OUTPUT_BYTES, (chunk) => {
213
+ if (!spillHealthy || spill.writableEnded || spillTruncated) return;
214
+ const bytes = Buffer.from(chunk, "utf8");
215
+ const remaining = this.maxFullLogBytes - spillBytes;
216
+ if (remaining <= 0) {
217
+ markSpillTruncated();
218
+ return;
219
+ }
220
+ const payload = bytes.length > remaining ? bytes.subarray(0, remaining) : bytes;
221
+ spillBytes += payload.length;
222
+ const accepted = spill.write(payload);
223
+ if (payload.length < bytes.length || spillBytes >= this.maxFullLogBytes) markSpillTruncated();
224
+ if (!accepted && pty && !pausedForSpill) {
225
+ pausedForSpill = true;
226
+ pty.pause();
227
+ }
228
+ });
229
+ output.spillPath = spillPath;
230
+ spill.on("drain", () => {
231
+ if (!pausedForSpill) return;
232
+ pausedForSpill = false;
233
+ try {
234
+ pty?.resume();
235
+ } catch {
236
+ // Exit may have won the drain race.
237
+ }
238
+ });
239
+ spill.on("error", (error) => {
240
+ spillHealthy = false;
241
+ output.spillPath = undefined;
242
+ if (pausedForSpill) {
243
+ pausedForSpill = false;
244
+ try {
245
+ pty?.resume();
246
+ } catch {
247
+ // Exit may have won the error race.
248
+ }
249
+ }
250
+ const entry = this.entries.get(id);
251
+ if (entry) entry.snapshot.errorText ??= `Full-log capture failed: ${cleanError(error)}`;
252
+ });
253
+
254
+ const shell = shellInvocation(options.command);
255
+ try {
256
+ pty = spawnPty(shell.file, shell.args, {
257
+ name: "xterm-256color",
258
+ cols,
259
+ rows,
260
+ cwd: options.cwd,
261
+ env: { ...this.environment, TERM: "xterm-256color" },
262
+ });
263
+ } catch (error) {
264
+ spill.end();
265
+ throw new Error(`Failed to start background terminal: ${cleanError(error)}`);
266
+ }
267
+
268
+ let resolveSettled!: (snapshot: TerminalSnapshot) => void;
269
+ const settled = new Promise<TerminalSnapshot>((resolve) => {
270
+ resolveSettled = resolve;
271
+ });
272
+ snapshot = {
273
+ id,
274
+ title: options.title,
275
+ command: options.command,
276
+ cwd: options.cwd,
277
+ pid: pty.pid,
278
+ status: "running",
279
+ createdAt: Date.now(),
280
+ cols,
281
+ rows,
282
+ get output() {
283
+ return output.view();
284
+ },
285
+ };
286
+ const activePty = pty!;
287
+ const activeSnapshot = snapshot!;
288
+ const entry = {} as Entry;
289
+ const dataSubscription = activePty.onData((chunk) => {
290
+ if (entry.settledFinal) return;
291
+ output.push(chunk);
292
+ this.notify(id);
293
+ });
294
+ const exitSubscription = activePty.onExit(({ exitCode, signal }) => {
295
+ void this.finalize(entry, exitCode, signal);
296
+ });
297
+ Object.assign(entry, {
298
+ snapshot: activeSnapshot,
299
+ pty: activePty,
300
+ output,
301
+ spill,
302
+ dataSubscription,
303
+ exitSubscription,
304
+ settled,
305
+ resolveSettled,
306
+ killRequested: false,
307
+ settling: false,
308
+ settledFinal: false,
309
+ } satisfies Entry);
310
+ this.entries.set(id, entry);
311
+ this.notify(id);
312
+ return activeSnapshot;
313
+ } finally {
314
+ this.starting--;
315
+ }
316
+ }
317
+
318
+ private async finalize(entry: Entry, exitCode: number, signal?: number): Promise<void> {
319
+ if (entry.settledFinal || entry.settling) return;
320
+ entry.settling = true;
321
+ let flushed = entry.spill.writableFinished;
322
+ await Promise.race([
323
+ new Promise<void>((resolve) => {
324
+ if (flushed) {
325
+ resolve();
326
+ return;
327
+ }
328
+ entry.spill.end(() => {
329
+ flushed = true;
330
+ resolve();
331
+ });
332
+ }),
333
+ wait(FLUSH_GRACE_MS),
334
+ ]);
335
+ if (!flushed) {
336
+ entry.snapshot.errorText ??= "Full-log flush timed out; the spill file may be incomplete.";
337
+ entry.output.spillPath = undefined;
338
+ entry.spill.destroy();
339
+ }
340
+ if (entry.settledFinal) return;
341
+ entry.settledFinal = true;
342
+ entry.snapshot.exitCode = exitCode;
343
+ entry.snapshot.signal = signal || undefined;
344
+ entry.snapshot.status = entry.killRequested
345
+ ? "killed"
346
+ : exitCode === 0
347
+ ? "done"
348
+ : "failed";
349
+ entry.snapshot.settledAt = Date.now();
350
+ entry.dataSubscription.dispose();
351
+ entry.exitSubscription.dispose();
352
+ entry.resolveSettled(entry.snapshot);
353
+ this.notify(entry.snapshot.id);
354
+ if (!this.disposed) this.settledListener?.(entry.snapshot);
355
+ this.prune();
356
+ }
357
+
358
+ private signal(entry: Entry, signal: "SIGTERM" | "SIGKILL"): void {
359
+ if (process.platform !== "win32") {
360
+ try {
361
+ process.kill(-entry.snapshot.pid, signal);
362
+ return;
363
+ } catch {
364
+ // node-pty still knows how to terminate its direct child.
365
+ }
366
+ }
367
+ try {
368
+ entry.pty.kill(signal);
369
+ } catch {
370
+ // Exit may have won the race.
371
+ }
372
+ }
373
+
374
+ async kill(ids: ReadonlyArray<string>): Promise<ReadonlyArray<TerminalSnapshot>> {
375
+ const unique = [...new Set(ids)];
376
+ const entries = unique.map((id) => {
377
+ const entry = this.entries.get(id);
378
+ if (!entry) throw new Error(`Unknown background terminal "${id}".`);
379
+ return entry;
380
+ });
381
+ await Promise.all(entries.map(async (entry) => {
382
+ if (entry.snapshot.status !== "running") return;
383
+ entry.killRequested = true;
384
+ this.signal(entry, "SIGTERM");
385
+ await Promise.race([entry.settled, wait(TERM_GRACE_MS)]);
386
+ if (entry.snapshot.status !== "running") return;
387
+ this.signal(entry, "SIGKILL");
388
+ await Promise.race([entry.settled, wait(KILL_GRACE_MS)]);
389
+ if (entry.snapshot.status === "running") {
390
+ entry.snapshot.errorText ??= "Process exit was not observed after SIGKILL; output may be incomplete.";
391
+ await this.finalize(entry, 137, 9);
392
+ }
393
+ }));
394
+ return entries.map((entry) => entry.snapshot);
395
+ }
396
+
397
+ write(id: string, input: string): void {
398
+ const entry = this.entries.get(id);
399
+ if (!entry) throw new Error(`Unknown background terminal "${id}".`);
400
+ if (entry.snapshot.status !== "running") throw new Error(`Background terminal ${id} is ${entry.snapshot.status}.`);
401
+ if (Buffer.byteLength(input, "utf8") > 64 * 1024) throw new Error("Terminal input is limited to 64KB per write.");
402
+ entry.pty.write(input);
403
+ }
404
+
405
+ resize(id: string, cols: number, rows: number): void {
406
+ const entry = this.entries.get(id);
407
+ if (!entry || entry.snapshot.status !== "running") return;
408
+ const nextCols = Math.min(500, Math.max(20, Math.floor(cols)));
409
+ const nextRows = Math.min(200, Math.max(8, Math.floor(rows)));
410
+ if (nextCols === entry.snapshot.cols && nextRows === entry.snapshot.rows) return;
411
+ entry.snapshot.cols = nextCols;
412
+ entry.snapshot.rows = nextRows;
413
+ entry.pty.resize(nextCols, nextRows);
414
+ }
415
+
416
+ async read(
417
+ id: string,
418
+ options: { cursor?: number; waitMs?: number; signal?: AbortSignal } = {},
419
+ ): Promise<TerminalReadResult> {
420
+ const entry = this.entries.get(id);
421
+ if (!entry) throw new Error(`Unknown background terminal "${id}".`);
422
+ const cursor = options.cursor;
423
+ if (
424
+ options.waitMs &&
425
+ options.waitMs > 0 &&
426
+ entry.snapshot.status === "running" &&
427
+ cursor === entry.output.totalBytes
428
+ ) {
429
+ await this.waitForChange(id, Math.min(10_000, options.waitMs), options.signal);
430
+ }
431
+ const result = entry.output.readSince(cursor);
432
+ return { snapshot: entry.snapshot, ...result };
433
+ }
434
+
435
+ private waitForChange(id: string, waitMs: number, signal?: AbortSignal): Promise<void> {
436
+ if (signal?.aborted) return Promise.reject(new Error("Terminal read aborted."));
437
+ return new Promise((resolve, reject) => {
438
+ let finished = false;
439
+ const finish = (error?: Error) => {
440
+ if (finished) return;
441
+ finished = true;
442
+ clearTimeout(timer);
443
+ unsubscribe();
444
+ signal?.removeEventListener("abort", onAbort);
445
+ if (error) reject(error);
446
+ else resolve();
447
+ };
448
+ const unsubscribe = this.subscribeTo(id, () => finish());
449
+ const timer = setTimeout(() => finish(), waitMs);
450
+ timer.unref?.();
451
+ const onAbort = () => finish(new Error("Terminal read aborted."));
452
+ signal?.addEventListener("abort", onAbort, { once: true });
453
+ });
454
+ }
455
+
456
+ private prune(): void {
457
+ if (this.entries.size <= MAX_TRACKED_TERMINALS) return;
458
+ const settled = [...this.entries.values()]
459
+ .filter((entry) => entry.snapshot.status !== "running")
460
+ .sort((left, right) => (left.snapshot.settledAt ?? 0) - (right.snapshot.settledAt ?? 0));
461
+ for (const entry of settled) {
462
+ if (this.entries.size <= MAX_TRACKED_TERMINALS) break;
463
+ this.entries.delete(entry.snapshot.id);
464
+ this.idListeners.delete(entry.snapshot.id);
465
+ fs.rmSync(entry.output.spillPath ?? entry.spill.path.toString(), { force: true });
466
+ }
467
+ }
468
+
469
+ async dispose(): Promise<void> {
470
+ if (this.disposed) return;
471
+ this.disposed = true;
472
+ this.settledListener = undefined;
473
+ const running = this.list().filter((snapshot) => snapshot.status === "running").map((snapshot) => snapshot.id);
474
+ if (running.length > 0) await this.kill(running);
475
+ this.listeners.clear();
476
+ this.idListeners.clear();
477
+ this.entries.clear();
478
+ fs.rmSync(this.spillDir, { recursive: true, force: true });
479
+ }
480
+ }
@@ -0,0 +1,110 @@
1
+ import type { TerminalOutputView } from "./types.ts";
2
+
3
+ interface Chunk {
4
+ readonly start: number;
5
+ readonly end: number;
6
+ readonly text: string;
7
+ }
8
+
9
+ export class OutputBuffer {
10
+ private chunks: Chunk[] = [];
11
+ private retainedBytes = 0;
12
+ private cachedText = "";
13
+ private cacheValid = true;
14
+ private _totalBytes = 0;
15
+ private _truncatedBytes = 0;
16
+ private _version = 0;
17
+ spillPath?: string;
18
+ spillTruncated = false;
19
+ private readonly maxRetainedBytes: number;
20
+ private readonly spill?: (chunk: string) => void;
21
+
22
+ constructor(maxRetainedBytes: number, spill?: (chunk: string) => void) {
23
+ this.maxRetainedBytes = maxRetainedBytes;
24
+ this.spill = spill;
25
+ }
26
+
27
+ get totalBytes(): number {
28
+ return this._totalBytes;
29
+ }
30
+
31
+ push(text: string): void {
32
+ if (!text) return;
33
+ this.spill?.(text);
34
+ const bytes = Buffer.byteLength(text, "utf8");
35
+ const chunk: Chunk = {
36
+ start: this._totalBytes,
37
+ end: this._totalBytes + bytes,
38
+ text,
39
+ };
40
+ this._totalBytes += bytes;
41
+ this._version++;
42
+
43
+ if (bytes >= this.maxRetainedBytes) {
44
+ const raw = Buffer.from(text, "utf8");
45
+ let start = Math.max(0, raw.length - this.maxRetainedBytes);
46
+ while (start < raw.length && (raw[start]! & 0xc0) === 0x80) start++;
47
+ const retained = raw.subarray(start).toString("utf8");
48
+ this.chunks = [{
49
+ start: chunk.end - Buffer.byteLength(retained, "utf8"),
50
+ end: chunk.end,
51
+ text: retained,
52
+ }];
53
+ this.retainedBytes = Buffer.byteLength(retained, "utf8");
54
+ this._truncatedBytes = this._totalBytes - this.retainedBytes;
55
+ this.cacheValid = false;
56
+ return;
57
+ }
58
+
59
+ this.chunks.push(chunk);
60
+ this.retainedBytes += bytes;
61
+ while (this.retainedBytes > this.maxRetainedBytes && this.chunks.length > 1) {
62
+ const removed = this.chunks.shift();
63
+ if (!removed) break;
64
+ this.retainedBytes -= removed.end - removed.start;
65
+ this._truncatedBytes = removed.end;
66
+ }
67
+ this.cacheValid = false;
68
+ }
69
+
70
+ readSince(cursor: number | undefined): {
71
+ text: string;
72
+ cursor: number;
73
+ omittedBytes: number;
74
+ } {
75
+ const requested = Number.isSafeInteger(cursor) && (cursor ?? 0) >= 0
76
+ ? Math.min(cursor!, this._totalBytes)
77
+ : Math.max(0, this._totalBytes - this.retainedBytes);
78
+ const firstAvailable = this.chunks[0]?.start ?? this._totalBytes;
79
+ const effective = Math.max(requested, firstAvailable);
80
+ const omittedBytes = Math.max(0, firstAvailable - requested);
81
+ const text = this.chunks
82
+ .filter((chunk) => chunk.end > effective)
83
+ .map((chunk) => {
84
+ if (chunk.start >= effective) return chunk.text;
85
+ const skip = effective - chunk.start;
86
+ const raw = Buffer.from(chunk.text, "utf8");
87
+ let offset = Math.min(skip, raw.length);
88
+ while (offset < raw.length && (raw[offset]! & 0xc0) === 0x80) offset++;
89
+ return raw.subarray(offset).toString("utf8");
90
+ })
91
+ .join("");
92
+ return { text, cursor: this._totalBytes, omittedBytes };
93
+ }
94
+
95
+ view(): TerminalOutputView {
96
+ if (!this.cacheValid) {
97
+ this.cachedText = this.chunks.map((chunk) => chunk.text).join("");
98
+ this.cacheValid = true;
99
+ }
100
+ return {
101
+ text: this.cachedText,
102
+ totalBytes: this._totalBytes,
103
+ truncatedBytes: this._truncatedBytes,
104
+ cursor: this._totalBytes,
105
+ version: this._version,
106
+ spillPath: this.spillPath,
107
+ spillTruncated: this.spillTruncated,
108
+ };
109
+ }
110
+ }
@@ -0,0 +1,74 @@
1
+ import {
2
+ DEFAULT_MAX_BYTES,
3
+ DEFAULT_MAX_LINES,
4
+ formatSize,
5
+ truncateTail,
6
+ } from "@earendil-works/pi-coding-agent";
7
+ import { formatDurationMs, oneLine, sanitizeTerminalText } from "../../shared/tui-dashboard.ts";
8
+ import type { TerminalReadResult, TerminalSnapshot } from "./types.ts";
9
+ import { terminalElapsedMs } from "./types.ts";
10
+
11
+ export const MODEL_OUTPUT_MAX_BYTES = 24 * 1024;
12
+ export const MODEL_OUTPUT_MAX_LINES = 500;
13
+ export const COMPLETION_OUTPUT_MAX_BYTES = 12 * 1024;
14
+ export const COMPLETION_OUTPUT_MAX_LINES = 120;
15
+
16
+ export function formatTerminal(snapshot: TerminalSnapshot): string {
17
+ const outcome = snapshot.status === "running"
18
+ ? "running"
19
+ : snapshot.status === "killed"
20
+ ? "killed"
21
+ : `exit ${snapshot.exitCode ?? "?"}`;
22
+ return `${snapshot.id} [${snapshot.status}] "${oneLine(snapshot.title)}" (pid ${snapshot.pid}, ${formatDurationMs(terminalElapsedMs(snapshot))}, ${outcome}, output ${formatSize(snapshot.output.totalBytes)}, ${snapshot.cwd})`;
23
+ }
24
+
25
+ function boundedOutput(
26
+ text: string,
27
+ maxBytes: number,
28
+ maxLines: number,
29
+ ): { text: string; truncated: boolean } {
30
+ const clean = sanitizeTerminalText(text).replaceAll("\r", "");
31
+ const truncation = truncateTail(clean, {
32
+ maxBytes: Math.min(maxBytes, DEFAULT_MAX_BYTES),
33
+ maxLines: Math.min(maxLines, DEFAULT_MAX_LINES),
34
+ });
35
+ return { text: truncation.content, truncated: truncation.truncated };
36
+ }
37
+
38
+ export function formatReadResult(result: TerminalReadResult): string {
39
+ const { snapshot } = result;
40
+ let text = formatTerminal(snapshot);
41
+ if (snapshot.errorText) text += `\nError: ${snapshot.errorText}`;
42
+ if (snapshot.output.spillTruncated) text += "\nNotice: the private output log reached its size limit.";
43
+ const bounded = boundedOutput(result.text, MODEL_OUTPUT_MAX_BYTES, MODEL_OUTPUT_MAX_LINES);
44
+ text += `\nCursor: ${result.cursor}`;
45
+ if (result.omittedBytes > 0) {
46
+ text += `\n[${formatSize(result.omittedBytes)} omitted before the retained output window]`;
47
+ }
48
+ text += `\n\n${bounded.text || "(no new output)"}`;
49
+ if (bounded.truncated) {
50
+ text += `\n\n[Output tail-truncated for model context. Private log: ${snapshot.output.spillPath ?? "unavailable"}]`;
51
+ }
52
+ return text;
53
+ }
54
+
55
+ export function formatCompletion(snapshot: TerminalSnapshot): string {
56
+ const result = snapshot.status === "killed"
57
+ ? "was stopped"
58
+ : snapshot.exitCode === 0
59
+ ? "completed successfully"
60
+ : `failed with exit ${snapshot.exitCode ?? "?"}`;
61
+ let text = `Background terminal ${snapshot.id} "${oneLine(snapshot.title)}" ${result} after ${formatDurationMs(terminalElapsedMs(snapshot))}.`;
62
+ if (snapshot.errorText) text += `\nError: ${snapshot.errorText}`;
63
+ if (snapshot.output.spillTruncated) text += "\nNotice: the private output log reached its size limit.";
64
+ const bounded = boundedOutput(
65
+ snapshot.output.text,
66
+ COMPLETION_OUTPUT_MAX_BYTES,
67
+ COMPLETION_OUTPUT_MAX_LINES,
68
+ );
69
+ text += `\n\n${bounded.text || "(no output)"}`;
70
+ if (bounded.truncated || snapshot.output.truncatedBytes > 0) {
71
+ text += `\n\n[Final output tail-truncated. Private log: ${snapshot.output.spillPath ?? "unavailable"}]`;
72
+ }
73
+ return text;
74
+ }