pi-better-background-tasks 0.1.6 → 0.1.8

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 CHANGED
@@ -6,6 +6,10 @@
6
6
 
7
7
  Use `pi-better-background-tasks` when a command should keep running while the foreground Pi session stays free. It is best for dev servers, long scripts, queue watchers, deploy checks, log tails, and other command-driven work.
8
8
 
9
+ ## Screenshots
10
+
11
+ <p><img src="https://raw.githubusercontent.com/1aboveio/pi-better-harness/main/docs/images/package-gallery/pi-better-background-tasks.png" alt="pi-better-background-tasks rendered in Pi" width="49%" /><img src="https://raw.githubusercontent.com/1aboveio/pi-better-harness/main/docs/images/package-gallery/overview/pi-better-background-tasks.png" alt="pi-better-background-tasks package overview" width="49%" /></p>
12
+
9
13
  ## Core Features
10
14
 
11
15
  - Start long-running commands without blocking the current turn.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-background-tasks",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "Pi extension for durable background shell tasks, watchers, logs, and status inspection.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -30,6 +30,8 @@
30
30
  "access": "public"
31
31
  },
32
32
  "scripts": {
33
+ "pretest": "node ../../scripts/sync-shared-log-utils.mjs",
34
+ "prepack": "node ../../scripts/sync-shared-log-utils.mjs",
33
35
  "typecheck": "tsc --noEmit",
34
36
  "test": "vitest run",
35
37
  "test:unit": "vitest run src/conditions.test.ts src/runtime.test.ts",
package/src/logs.ts CHANGED
@@ -1,8 +1,13 @@
1
- import { appendFileSync, readFileSync } from "node:fs";
1
+ import { appendFileSync, closeSync, openSync, readSync, statSync, truncateSync } from "node:fs";
2
2
  import { dirname } from "node:path";
3
3
  import { mkdirSync } from "node:fs";
4
+ import { readBoundedTail, tailTerminalDisplay, terminalDisplayRows } from "./shared-log-utils.js";
4
5
  import type { CommandResult } from "./types.js";
5
6
 
7
+ export const DEFAULT_MAX_LOG_BYTES = 4 * 1024 * 1024;
8
+ export const MAX_LOG_TAIL_READ_BYTES = 512 * 1024;
9
+ const RETAINED_LOG_FRACTION = 0.75;
10
+
6
11
  export function appendWatchResult(logPath: string, result: CommandResult): void {
7
12
  mkdirSync(dirname(logPath), { recursive: true });
8
13
  const header = `\n--- check ${new Date(result.startedAt).toISOString()} exit=${result.exitCode ?? "null"} signal=${result.signal ?? "null"} duration_ms=${result.endedAt - result.startedAt} ---\n`;
@@ -17,14 +22,63 @@ export function appendLine(logPath: string, line: string): void {
17
22
  }
18
23
 
19
24
  export function readLog(logPath: string, tailLines?: number): { text: string; truncated: boolean } {
20
- let text: string;
25
+ const requestedRows = tailLines && tailLines > 0 ? Math.floor(tailLines) : undefined;
26
+ const tail = readBoundedTail(logPath, MAX_LOG_TAIL_READ_BYTES);
27
+ if (!tail.text) return { text: "", truncated: tail.truncated };
28
+ if (!requestedRows) return { text: tail.text, truncated: tail.truncated };
29
+ const rows = terminalDisplayRows(tail.text);
30
+ return {
31
+ text: tailTerminalDisplay(tail.text, requestedRows),
32
+ truncated: tail.truncated || rows.length > requestedRows,
33
+ };
34
+ }
35
+
36
+ export function resolveMaxLogBytes(value: number | undefined): number {
37
+ if (value === undefined || !Number.isFinite(value) || value <= 0) return DEFAULT_MAX_LOG_BYTES;
38
+ return Math.max(64 * 1024, Math.floor(value));
39
+ }
40
+
41
+ /**
42
+ * Compact in place so detached children keep writing to the same inode. A
43
+ * rename-based rotation would leave their inherited descriptor unbounded.
44
+ */
45
+ export function retainLogTail(logPath: string, maxBytes: number): { discardedBytes: number } | undefined {
46
+ let size: number;
47
+ try {
48
+ size = statSync(logPath).size;
49
+ } catch {
50
+ return undefined;
51
+ }
52
+ if (size <= maxBytes) return undefined;
53
+
54
+ const start = Math.max(0, size - Math.floor(maxBytes * RETAINED_LOG_FRACTION));
55
+ let fd: number | undefined;
56
+ let retained: Buffer;
57
+ try {
58
+ fd = openSync(logPath, "r");
59
+ retained = Buffer.allocUnsafe(size - start);
60
+ let offset = 0;
61
+ while (offset < retained.length) {
62
+ const read = readSync(fd, retained, offset, retained.length - offset, start + offset);
63
+ if (read <= 0) break;
64
+ offset += read;
65
+ }
66
+ retained = retained.subarray(0, offset);
67
+ } catch {
68
+ return undefined;
69
+ } finally {
70
+ if (fd !== undefined) {
71
+ try { closeSync(fd); } catch { /* best effort */ }
72
+ }
73
+ }
74
+
75
+ const discardedBytes = Math.max(0, size - retained.length);
21
76
  try {
22
- text = readFileSync(logPath, "utf8");
77
+ truncateSync(logPath, 0);
78
+ if (retained.length) appendFileSync(logPath, retained);
79
+ appendFileSync(logPath, `--- log retention discarded ${discardedBytes} bytes; newest output retained ---\n`);
80
+ return { discardedBytes };
23
81
  } catch {
24
- return { text: "", truncated: false };
82
+ return undefined;
25
83
  }
26
- if (!tailLines || tailLines <= 0) return { text, truncated: false };
27
- const lines = text.split(/\r?\n/);
28
- if (lines.length <= tailLines) return { text, truncated: false };
29
- return { text: lines.slice(-tailLines).join("\n"), truncated: true };
30
84
  }
@@ -133,6 +133,10 @@ function detailFromMeta(meta: BackgroundTaskMeta | undefined, now: number, optio
133
133
  if (meta.lastCheckedAt) metadata.push({ label: "checked", value: `${formatDuration(now - meta.lastCheckedAt)} ago` });
134
134
  if (meta.lastExitCode !== undefined) metadata.push({ label: "exit", value: String(meta.lastExitCode) });
135
135
  if (meta.error) metadata.push({ label: "error", value: meta.error });
136
+ if (meta.logDiscardedBytes) {
137
+ const count = meta.logRetentionEvents ?? 1;
138
+ metadata.push({ label: "log dropped", value: `${formatBytes(meta.logDiscardedBytes)} in ${count} compaction${count === 1 ? "" : "s"}` });
139
+ }
136
140
  return {
137
141
  providerId: "background-tasks",
138
142
  id: meta.id,
@@ -146,6 +150,7 @@ function detailFromMeta(meta: BackgroundTaskMeta | undefined, now: number, optio
146
150
  label: "command",
147
151
  text: command,
148
152
  collapsedText: compactCommandLabel(meta),
153
+ expandedByDefault: true,
149
154
  }],
150
155
  evidence: {
151
156
  label: log.truncated ? "log tail" : "log",
@@ -210,3 +215,9 @@ function formatDuration(ms: number): string {
210
215
  const min = minutes % 60;
211
216
  return `${hours}h ${min.toString().padStart(2, "0")}m`;
212
217
  }
218
+
219
+ function formatBytes(bytes: number): string {
220
+ if (bytes < 1024) return `${bytes} B`;
221
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`;
222
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
223
+ }
package/src/runtime.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import { appendLine, appendWatchResult } from "./logs.js";
2
+ import { appendLine, appendWatchResult, retainLogTail, resolveMaxLogBytes } from "./logs.js";
3
3
  import { evaluateCondition } from "./conditions.js";
4
4
  import { processExists, runCommandOnce, spawnCommand, stopProcessGroup } from "./process.js";
5
5
  import { ensureTaskDir, logPathFor, nextTaskId, readMeta, writeMeta } from "./registry.js";
@@ -8,6 +8,8 @@ import { isTerminalStatus } from "./types.js";
8
8
 
9
9
  const watcherTimers = new Map<string, ReturnType<typeof setTimeout>>();
10
10
  const activePolls = new Set<string>();
11
+ const logRetentionTimers = new Map<string, ReturnType<typeof setInterval>>();
12
+ const LOG_RETENTION_CHECK_MS = 1000;
11
13
 
12
14
  export const DEFAULT_WATCH_TIMEOUT_SECONDS = 15 * 60;
13
15
 
@@ -17,6 +19,7 @@ export interface SpawnTaskParams extends CommandSpec {
17
19
  name?: string;
18
20
  callback?: boolean;
19
21
  timeout_seconds?: number;
22
+ max_log_bytes?: number;
20
23
  }
21
24
 
22
25
  export interface WatchTaskParams extends CommandSpec {
@@ -24,6 +27,7 @@ export interface WatchTaskParams extends CommandSpec {
24
27
  callback?: boolean;
25
28
  interval_seconds?: number;
26
29
  timeout_seconds?: number;
30
+ max_log_bytes?: number;
27
31
  success_when: Condition;
28
32
  failure_when?: Condition;
29
33
  }
@@ -56,15 +60,20 @@ export function spawnTask(
56
60
  shell: params.shell ?? true,
57
61
  cwd,
58
62
  env: params.env,
63
+ maxLogBytes: resolveMaxLogBytes(params.max_log_bytes),
59
64
  pid: spawned.child.pid,
60
65
  pgid: spawned.pgid,
61
66
  spawnPid: process.pid,
62
67
  };
63
68
  writeMeta(meta);
69
+ scheduleLogRetention(id);
64
70
  spawned.child.unref();
65
71
  spawned.child.on("close", (exitCode, signal) => {
72
+ stopLogRetention(id);
66
73
  const latest = readMeta(id);
67
- if (!latest || isTerminalStatus(latest.status)) return;
74
+ if (!latest) return;
75
+ enforceLogRetention(latest);
76
+ if (isTerminalStatus(latest.status)) return;
68
77
  latest.status = exitCode === 0 ? "succeeded" : "failed";
69
78
  latest.endedAt = Date.now();
70
79
  latest.lastExitCode = exitCode;
@@ -104,6 +113,7 @@ export function startWatchTask(
104
113
  shell: params.shell ?? true,
105
114
  cwd,
106
115
  env: params.env,
116
+ maxLogBytes: resolveMaxLogBytes(params.max_log_bytes),
107
117
  spawnPid: process.pid,
108
118
  successWhen: params.success_when,
109
119
  failureWhen: params.failure_when,
@@ -135,6 +145,7 @@ export function resumeRunningTask(
135
145
  scheduleWatch(pi, meta.id, 0, getActiveSession);
136
146
  return meta;
137
147
  }
148
+ if (meta.kind === "process") scheduleLogRetention(meta.id);
138
149
  if (meta.kind === "process" && meta.pid && !processExists(meta.pid)) {
139
150
  meta.status = "failed";
140
151
  meta.endedAt = Date.now();
@@ -158,6 +169,7 @@ export function stopTask(pi: ExtensionAPI, id: string, getActiveSession?: Active
158
169
  meta.result = { reason: "cancelled" };
159
170
  writeMeta(meta);
160
171
  clearWatchTimer(id);
172
+ stopLogRetention(id);
161
173
  if (meta.kind === "process" && meta.pid) {
162
174
  try {
163
175
  stopProcessGroup(meta.pid, meta.pgid);
@@ -198,6 +210,7 @@ async function pollWatch(pi: ExtensionAPI, id: string, getActiveSession?: Active
198
210
  appendWatchResult(meta.logPath, result);
199
211
  const latest = readMeta(id);
200
212
  if (!latest || latest.status !== "running") return;
213
+ enforceLogRetention(latest);
201
214
  latest.lastCheckedAt = Date.now();
202
215
  latest.lastExitCode = result.exitCode;
203
216
  latest.lastSignal = result.signal;
@@ -253,6 +266,7 @@ function finalize(
253
266
  }
254
267
  writeMeta(meta);
255
268
  clearWatchTimer(meta.id);
269
+ stopLogRetention(meta.id);
256
270
  void notifyTerminal(pi, meta, getActiveSession);
257
271
  }
258
272
 
@@ -336,6 +350,34 @@ function commandSpecFromMeta(meta: BackgroundTaskMeta): CommandSpec {
336
350
  };
337
351
  }
338
352
 
353
+ function scheduleLogRetention(id: string): void {
354
+ stopLogRetention(id);
355
+ const timer = setInterval(() => {
356
+ const meta = readMeta(id);
357
+ if (!meta || meta.status !== "running" || meta.kind !== "process") {
358
+ stopLogRetention(id);
359
+ return;
360
+ }
361
+ enforceLogRetention(meta);
362
+ }, LOG_RETENTION_CHECK_MS);
363
+ timer.unref();
364
+ logRetentionTimers.set(id, timer);
365
+ }
366
+
367
+ function stopLogRetention(id: string): void {
368
+ const timer = logRetentionTimers.get(id);
369
+ if (timer) clearInterval(timer);
370
+ logRetentionTimers.delete(id);
371
+ }
372
+
373
+ function enforceLogRetention(meta: BackgroundTaskMeta): void {
374
+ const compacted = retainLogTail(meta.logPath, resolveMaxLogBytes(meta.maxLogBytes));
375
+ if (!compacted) return;
376
+ meta.logDiscardedBytes = (meta.logDiscardedBytes ?? 0) + compacted.discardedBytes;
377
+ meta.logRetentionEvents = (meta.logRetentionEvents ?? 0) + 1;
378
+ writeMeta(meta);
379
+ }
380
+
339
381
  function extractLastState(result: { stdout: string }): unknown {
340
382
  try {
341
383
  return JSON.parse(result.stdout);
@@ -0,0 +1,104 @@
1
+ // Generated from packages/log-utils/index.ts. Do not edit directly.
2
+ import { closeSync, openSync, readFileSync, readSync, statSync } from "node:fs";
3
+
4
+ export interface TailRead {
5
+ text: string;
6
+ truncated: boolean;
7
+ totalBytes: number;
8
+ error?: string;
9
+ }
10
+
11
+ function errorText(error: unknown): string {
12
+ return error instanceof Error ? error.message : String(error);
13
+ }
14
+
15
+ /**
16
+ * Read no more than `maxBytes` from a file's end. This avoids whole-file
17
+ * allocation for live logs that can grow past Node's string-size limit.
18
+ */
19
+ export function readBoundedTail(path: string, maxBytes: number): TailRead {
20
+ const budget = Math.max(1, Math.floor(maxBytes));
21
+ let totalBytes: number;
22
+ try {
23
+ totalBytes = statSync(path).size;
24
+ } catch (error) {
25
+ return { text: "", truncated: false, totalBytes: 0, error: errorText(error) };
26
+ }
27
+ if (totalBytes === 0) return { text: "", truncated: false, totalBytes };
28
+ if (totalBytes <= budget) {
29
+ try {
30
+ return { text: readFileSync(path, "utf8"), truncated: false, totalBytes };
31
+ } catch (error) {
32
+ return { text: "", truncated: false, totalBytes, error: errorText(error) };
33
+ }
34
+ }
35
+
36
+ let fd: number | undefined;
37
+ try {
38
+ fd = openSync(path, "r");
39
+ const buffer = Buffer.allocUnsafe(budget);
40
+ const start = totalBytes - budget;
41
+ let offset = 0;
42
+ while (offset < budget) {
43
+ const read = readSync(fd, buffer, offset, budget - offset, start + offset);
44
+ if (read <= 0) break;
45
+ offset += read;
46
+ }
47
+ return { text: buffer.toString("utf8", 0, offset), truncated: true, totalBytes };
48
+ } catch (error) {
49
+ return { text: "", truncated: true, totalBytes, error: errorText(error) };
50
+ } finally {
51
+ if (fd !== undefined) {
52
+ try { closeSync(fd); } catch { /* best effort */ }
53
+ }
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Convert terminal-like output into display rows. A bare carriage return is a
59
+ * cursor reset, so repeated progress redraws collapse to their latest state;
60
+ * CRLF remains a normal newline. Individual rows are capped defensively.
61
+ */
62
+ export function terminalDisplayRows(text: string, maxRowChars = 8 * 1024): string[] {
63
+ const rowLimit = Math.max(64, Math.floor(maxRowChars));
64
+ const rows: string[] = [];
65
+ let current = "";
66
+ let progress = "";
67
+
68
+ const append = (value: string) => {
69
+ current += value;
70
+ if (current.length > rowLimit) current = `...${current.slice(-(rowLimit - 3))}`;
71
+ };
72
+ const emit = () => {
73
+ const value = current || progress;
74
+ if (value) rows.push(value);
75
+ current = "";
76
+ progress = "";
77
+ };
78
+
79
+ for (let i = 0; i < text.length; i += 1) {
80
+ const char = text[i]!;
81
+ if (char === "\r") {
82
+ if (text[i + 1] === "\n") {
83
+ emit();
84
+ i += 1;
85
+ } else {
86
+ progress = current || progress;
87
+ current = "";
88
+ }
89
+ } else if (char === "\n") {
90
+ emit();
91
+ } else {
92
+ append(char);
93
+ }
94
+ }
95
+ const final = current || progress;
96
+ if (final) rows.push(final);
97
+ return rows;
98
+ }
99
+
100
+ export function tailTerminalDisplay(text: string, rows: number, maxRowChars?: number): string {
101
+ const rendered = terminalDisplayRows(text, maxRowChars);
102
+ const count = Math.max(1, Math.floor(rows));
103
+ return rendered.slice(-count).join("\n");
104
+ }
@@ -35,7 +35,7 @@ export type BackgroundWorkDetail = {
35
35
  statusTone: BackgroundWorkStatusTone;
36
36
  subtitle?: string;
37
37
  metadata: Array<{ label: string; value: string }>;
38
- foldedSections?: Array<{ id: string; label: string; text: string; collapsedText?: string }>;
38
+ foldedSections?: Array<{ id: string; label: string; text: string; collapsedText?: string; expandedByDefault?: boolean }>;
39
39
  evidence: { label: string; text: string };
40
40
  footerActions?: string[];
41
41
  };
@@ -94,7 +94,7 @@ export const MAIN_LIST_WIDGET_KEY = "background-work-list";
94
94
  export const DETAIL_TICK_MS = 1000;
95
95
  export const CLOSE_ARM_MS = 3000;
96
96
  export const DEFAULT_LOG_TAIL_ROWS = 10;
97
- export const LOG_TAIL_ROW_CHOICES = [10, 25, 50, 100] as const;
97
+ export const LOG_TAIL_ROW_CHOICES = [10, 25] as const;
98
98
  // Row rebuild cadence. Each tick asks every provider for rows, which stats and
99
99
  // parses run logs, so this is a per-provider I/O cadence and not a paint rate:
100
100
  // the widget repaints from render() whenever the TUI asks. 1 Hz matches
@@ -671,6 +671,7 @@ function createOverlayComponent(
671
671
  const expandedSections = new Set<string>();
672
672
  let detailId: string | null = initialDetailId ?? null;
673
673
  let detail: BackgroundWorkDetail | null = detailId ? (detailFor(detailId, Date.now(), { logTailLines: logTailRows }) ?? null) : null;
674
+ applyDefaultExpandedSections(detail, expandedSections);
674
675
  if (detailId) {
675
676
  const idx = overlayState.rows.findIndex((row) => row.navigatorId === detailId);
676
677
  if (idx >= 0) overlayState.selected = idx;
@@ -729,6 +730,7 @@ function createOverlayComponent(
729
730
  detailId = row.navigatorId;
730
731
  expandedSections.clear();
731
732
  detail = detailFor(row.navigatorId, Date.now(), { logTailLines: logTailRows }) ?? fallbackDetail(row);
733
+ applyDefaultExpandedSections(detail, expandedSections);
732
734
  mode = "detail";
733
735
  startDetailTimer();
734
736
  requestRender();
@@ -815,16 +817,6 @@ function createOverlayComponent(
815
817
  requestRender();
816
818
  }
817
819
  }
818
- else if (data === "[") {
819
- logTailRows = previousLogTailRows(logTailRows);
820
- if (detailId) detail = detailFor(detailId, Date.now(), { logTailLines: logTailRows }) ?? detail;
821
- requestRender();
822
- }
823
- else if (data === "]") {
824
- logTailRows = nextLogTailRows(logTailRows);
825
- if (detailId) detail = detailFor(detailId, Date.now(), { logTailLines: logTailRows }) ?? detail;
826
- requestRender();
827
- }
828
820
  else if (data === "l" || data === "L") {
829
821
  logTailRows = cycleLogTailRows(logTailRows);
830
822
  if (detailId) detail = detailFor(detailId, Date.now(), { logTailLines: logTailRows }) ?? detail;
@@ -939,7 +931,7 @@ function buildDetailLines(
939
931
  const foldedAction = toggleableSectionId
940
932
  ? (options.expandedSections?.has(toggleableSectionId) ? "Enter collapse" : "Enter expand")
941
933
  : null;
942
- const actions = [foldedAction, ...(detail.footerActions?.length ? detail.footerActions : ["x close"]), "[ fewer", "] more", "l cycle", "Esc close"].filter(Boolean).join(" · ");
934
+ const actions = [foldedAction, ...(detail.footerActions?.length ? detail.footerActions : ["x close"]), "l 10/25", "Esc close"].filter(Boolean).join(" · ");
943
935
  lines.push(dim(` ← back · ${actions}`, fg));
944
936
  lines.push("");
945
937
  lines.push(` status ${fg(toneColor(detail.statusTone, detail.status), detail.status)}`);
@@ -1004,6 +996,12 @@ function firstToggleableSectionId(detail: BackgroundWorkDetail | null | undefine
1004
996
  return detail && isFoldableEvidence(detail) ? EVIDENCE_SECTION_ID : undefined;
1005
997
  }
1006
998
 
999
+ function applyDefaultExpandedSections(detail: BackgroundWorkDetail | null, expandedSections: Set<string>): void {
1000
+ for (const section of detail?.foldedSections ?? []) {
1001
+ if (section.expandedByDefault) expandedSections.add(section.id);
1002
+ }
1003
+ }
1004
+
1007
1005
  function isFoldableEvidence(detail: BackgroundWorkDetail): boolean {
1008
1006
  return !/log/i.test(detail.evidence.label);
1009
1007
  }
@@ -1043,19 +1041,6 @@ function wrapEvidenceText(text: string, width: number): string[] {
1043
1041
  return rows.length ? rows : ["(no output yet)"];
1044
1042
  }
1045
1043
 
1046
- function nextLogTailRows(current: number): number {
1047
- for (const value of LOG_TAIL_ROW_CHOICES) if (value > current) return value;
1048
- return LOG_TAIL_ROW_CHOICES[LOG_TAIL_ROW_CHOICES.length - 1];
1049
- }
1050
-
1051
- function previousLogTailRows(current: number): number {
1052
- for (let i = LOG_TAIL_ROW_CHOICES.length - 1; i >= 0; i -= 1) {
1053
- const value = LOG_TAIL_ROW_CHOICES[i]!;
1054
- if (value < current) return value;
1055
- }
1056
- return LOG_TAIL_ROW_CHOICES[0];
1057
- }
1058
-
1059
1044
  function cycleLogTailRows(current: number): number {
1060
1045
  const idx = LOG_TAIL_ROW_CHOICES.findIndex((value) => value === current);
1061
1046
  return LOG_TAIL_ROW_CHOICES[(idx + 1) % LOG_TAIL_ROW_CHOICES.length];
package/src/tools.ts CHANGED
@@ -22,6 +22,7 @@ const CommandFields = {
22
22
  shell: Type.Optional(Type.Boolean({ description: "Run command through the package's bash-compatible shell. Default true." })),
23
23
  cwd: Type.Optional(Type.String({ description: "Working directory. Defaults to the current pi cwd." })),
24
24
  env: Type.Optional(Type.Record(Type.String(), Type.String(), { description: "Extra environment variables." })),
25
+ max_log_bytes: Type.Optional(Type.Number({ description: "Maximum retained raw-log bytes. Default 4194304 (4 MiB). Older output is compacted while the task runs." })),
25
26
  callback: Type.Optional(Type.Boolean({ description: "Queue a follow-up when the task reaches a terminal state. Default true." })),
26
27
  timeout_seconds: Type.Optional(Type.Number({ description: "Optional timeout in seconds. Command watchers default to 900 seconds when omitted; pass 0 to disable. Spawned processes have no default timeout." })),
27
28
  };
@@ -143,7 +144,7 @@ export function registerTools(pi: ExtensionAPI): void {
143
144
  pi.registerTool({
144
145
  name: "bg_task_log",
145
146
  label: "BG Log",
146
- description: "Read a background task log. Default output is a compact 20-line tail for model ingestion. Pass tail_lines for a bounded tail; pass tail_lines:0 only when the full log is explicitly required. Nonblocking.",
147
+ description: "Read a background task log. Default output is a compact 20-line terminal-aware tail for model ingestion. Pass tail_lines for a bounded tail; tail_lines:0 returns the retained raw log, capped at 512 KiB for safe recovery. Nonblocking.",
147
148
  parameters: LogParams,
148
149
  renderResult(result: unknown, options: unknown, theme: unknown) {
149
150
  return renderBackgroundTaskLogDisplay(result, options, theme);
@@ -299,8 +300,9 @@ function formatCompactStatus(meta: BackgroundTaskMeta): string {
299
300
  if (reason) lines.push(`result: ${reason}`);
300
301
  if (meta.error) lines.push(`error: ${oneLine(meta.error, 500)}`);
301
302
  if (meta.lastState !== undefined) lines.push(`last state: ${oneLine(meta.lastState, 800)}`);
303
+ if (meta.logDiscardedBytes) lines.push(`log retention: ${meta.logDiscardedBytes} bytes discarded in ${meta.logRetentionEvents ?? 1} compaction(s).`);
302
304
  lines.push(`log: ${meta.logPath}`);
303
- lines.push(`For full metadata use bg_task_status id=${meta.id} verbose=true. For logs use bg_task_log id=${meta.id} tail_lines=20, or tail_lines=0 for the full log.`);
305
+ lines.push(`For full metadata use bg_task_status id=${meta.id} verbose=true. For logs use bg_task_log id=${meta.id} tail_lines=20, or tail_lines=0 for the retained raw log.`);
304
306
  return lines.join("\n");
305
307
  }
306
308
 
package/src/types.ts CHANGED
@@ -62,6 +62,9 @@ export interface BackgroundTaskMeta {
62
62
  shell?: boolean;
63
63
  cwd: string;
64
64
  env?: Record<string, string>;
65
+ maxLogBytes?: number;
66
+ logDiscardedBytes?: number;
67
+ logRetentionEvents?: number;
65
68
  pid?: number;
66
69
  pgid?: number;
67
70
  spawnPid: number;