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 +4 -0
- package/package.json +3 -1
- package/src/logs.ts +62 -8
- package/src/navigator-provider.ts +11 -0
- package/src/runtime.ts +44 -2
- package/src/shared-log-utils.ts +104 -0
- package/src/shared-navigator.ts +11 -26
- package/src/tools.ts +4 -2
- package/src/types.ts +3 -0
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.
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
+
}
|
package/src/shared-navigator.ts
CHANGED
|
@@ -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
|
|
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"]), "
|
|
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;
|
|
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
|
|
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