pi-better-harness 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.
Files changed (61) hide show
  1. package/README.md +4 -0
  2. package/node_modules/pi-better-background-tasks/LICENSE +21 -0
  3. package/node_modules/pi-better-background-tasks/README.md +57 -0
  4. package/node_modules/pi-better-background-tasks/package.json +58 -0
  5. package/node_modules/pi-better-background-tasks/src/conditions.ts +92 -0
  6. package/node_modules/pi-better-background-tasks/src/goal-provider.ts +116 -0
  7. package/node_modules/pi-better-background-tasks/src/index.ts +23 -0
  8. package/node_modules/pi-better-background-tasks/src/logs.ts +84 -0
  9. package/node_modules/pi-better-background-tasks/src/navigator-provider.ts +223 -0
  10. package/node_modules/pi-better-background-tasks/src/process.ts +99 -0
  11. package/node_modules/pi-better-background-tasks/src/registry.ts +77 -0
  12. package/node_modules/pi-better-background-tasks/src/runtime.ts +387 -0
  13. package/node_modules/pi-better-background-tasks/src/shared-log-utils.ts +104 -0
  14. package/node_modules/pi-better-background-tasks/src/shared-navigator.ts +1140 -0
  15. package/node_modules/pi-better-background-tasks/src/tools.ts +478 -0
  16. package/node_modules/pi-better-background-tasks/src/types.ts +87 -0
  17. package/node_modules/pi-better-goal/LICENSE +21 -0
  18. package/node_modules/pi-better-goal/README.md +58 -0
  19. package/node_modules/pi-better-goal/package.json +69 -0
  20. package/node_modules/pi-better-goal/src/activity.ts +158 -0
  21. package/node_modules/pi-better-goal/src/goal-clock.ts +76 -0
  22. package/node_modules/pi-better-goal/src/goal-state.ts +217 -0
  23. package/node_modules/pi-better-goal/src/index.ts +546 -0
  24. package/node_modules/pi-better-goal/src/subagents.ts +134 -0
  25. package/node_modules/pi-better-goal/src/types.ts +81 -0
  26. package/node_modules/pi-better-subagents/LICENSE +21 -0
  27. package/node_modules/pi-better-subagents/README.md +59 -0
  28. package/node_modules/pi-better-subagents/batch.mjs +208 -0
  29. package/node_modules/pi-better-subagents/capacity.mjs +112 -0
  30. package/node_modules/pi-better-subagents/cleanup.ts +403 -0
  31. package/node_modules/pi-better-subagents/completion.mjs +165 -0
  32. package/node_modules/pi-better-subagents/completion.ts +11 -0
  33. package/node_modules/pi-better-subagents/config.json +14 -0
  34. package/node_modules/pi-better-subagents/config.ts +114 -0
  35. package/node_modules/pi-better-subagents/extensions.mjs +147 -0
  36. package/node_modules/pi-better-subagents/extensions.ts +19 -0
  37. package/node_modules/pi-better-subagents/finalization.ts +145 -0
  38. package/node_modules/pi-better-subagents/git-remotes.ts +413 -0
  39. package/node_modules/pi-better-subagents/git-workspace.ts +430 -0
  40. package/node_modules/pi-better-subagents/health-observation.ts +815 -0
  41. package/node_modules/pi-better-subagents/health-surface.mjs +276 -0
  42. package/node_modules/pi-better-subagents/health.ts +344 -0
  43. package/node_modules/pi-better-subagents/index.ts +1378 -0
  44. package/node_modules/pi-better-subagents/lifecycle.ts +333 -0
  45. package/node_modules/pi-better-subagents/list.mjs +123 -0
  46. package/node_modules/pi-better-subagents/list.ts +17 -0
  47. package/node_modules/pi-better-subagents/log-cursor.ts +192 -0
  48. package/node_modules/pi-better-subagents/navigator.mjs +1188 -0
  49. package/node_modules/pi-better-subagents/navigator.ts +38 -0
  50. package/node_modules/pi-better-subagents/package.json +51 -0
  51. package/node_modules/pi-better-subagents/parse.ts +1262 -0
  52. package/node_modules/pi-better-subagents/registry.ts +250 -0
  53. package/node_modules/pi-better-subagents/sandbox.ts +164 -0
  54. package/node_modules/pi-better-subagents/shared-log-utils.ts +104 -0
  55. package/node_modules/pi-better-subagents/shared-navigator.ts +1140 -0
  56. package/node_modules/pi-better-subagents/spawn.ts +78 -0
  57. package/node_modules/pi-better-subagents/stop.ts +155 -0
  58. package/node_modules/pi-better-subagents/tools.ts +399 -0
  59. package/node_modules/pi-better-subagents/widget.mjs +250 -0
  60. package/node_modules/pi-better-subagents/widget.ts +30 -0
  61. package/package.json +13 -5
package/README.md CHANGED
@@ -12,6 +12,10 @@ Use `pi-better-harness` when you want the full background-work bundle for Pi in
12
12
 
13
13
  `pi-better-read-aloud` is intentionally not included yet.
14
14
 
15
+ ## Screenshots
16
+
17
+ <p><img src="https://raw.githubusercontent.com/1aboveio/pi-better-harness/main/docs/images/package-gallery/pi-better-harness.png" alt="pi-better-harness rendered in Pi" width="49%" /><img src="https://raw.githubusercontent.com/1aboveio/pi-better-harness/main/docs/images/package-gallery/overview/pi-better-harness.png" alt="pi-better-harness package overview" width="49%" /></p>
18
+
15
19
  ## Install
16
20
 
17
21
  ```sh
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 1aboveio
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,57 @@
1
+ # pi-better-background-tasks
2
+
3
+ `pi-better-background-tasks` is a Pi extension for durable background shell tasks, watchers, logs, and status inspection.
4
+
5
+ ## Quick Answer
6
+
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
+
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
+
13
+ ## Core Features
14
+
15
+ - Start long-running commands without blocking the current turn.
16
+ - Watch commands until success, failure, or timeout.
17
+ - Keep task metadata and logs available across reloads.
18
+ - Show active work in Pi's background-work navigator.
19
+
20
+ ## Install
21
+
22
+ ```sh
23
+ pi install npm:pi-better-background-tasks
24
+ ```
25
+
26
+ Try it for one run:
27
+
28
+ ```sh
29
+ pi -e npm:pi-better-background-tasks
30
+ ```
31
+
32
+ ## When To Use
33
+
34
+ Use this package for shell commands that need logs, status, cancellation, or completion notifications across a Pi turn.
35
+
36
+ Do not use it for short commands where the foreground session should wait for the result directly.
37
+
38
+ ## Compatibility
39
+
40
+ | Requirement | Support |
41
+ |-------------|---------|
42
+ | Pi | Required |
43
+ | Install method | `pi install npm:pi-better-background-tasks` |
44
+ | Development runtime | Node.js 22+ |
45
+
46
+ ## Update Or Remove
47
+
48
+ ```sh
49
+ pi update npm:pi-better-background-tasks
50
+ pi remove npm:pi-better-background-tasks
51
+ ```
52
+
53
+ ## More Detail
54
+
55
+ - Repository: https://github.com/1aboveio/pi-better-harness
56
+ - Detailed notes: https://github.com/1aboveio/pi-better-harness/blob/main/packages/pi-better-background-tasks/docs/usage.md
57
+ - License: https://github.com/1aboveio/pi-better-harness/blob/main/LICENSE
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "pi-better-background-tasks",
3
+ "version": "0.1.9",
4
+ "description": "Pi extension for durable background shell tasks, watchers, logs, and status inspection.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "keywords": [
8
+ "pi-package",
9
+ "pi-extension",
10
+ "pi",
11
+ "background-tasks",
12
+ "watchers"
13
+ ],
14
+ "pi": {
15
+ "extensions": [
16
+ "./src/index.ts"
17
+ ],
18
+ "image": "https://raw.githubusercontent.com/1aboveio/pi-better-harness/main/docs/images/package-gallery/pi-better-background-tasks.png"
19
+ },
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/1aboveio/pi-better-harness.git",
23
+ "directory": "packages/pi-better-background-tasks"
24
+ },
25
+ "bugs": {
26
+ "url": "https://github.com/1aboveio/pi-better-harness/issues"
27
+ },
28
+ "homepage": "https://github.com/1aboveio/pi-better-harness/tree/main/packages/pi-better-background-tasks#readme",
29
+ "publishConfig": {
30
+ "access": "public"
31
+ },
32
+ "scripts": {
33
+ "pretest": "node ../../scripts/sync-shared-log-utils.mjs",
34
+ "prepack": "node ../../scripts/sync-shared-log-utils.mjs",
35
+ "typecheck": "tsc --noEmit",
36
+ "test": "vitest run",
37
+ "test:unit": "vitest run src/conditions.test.ts src/runtime.test.ts",
38
+ "test:e2e": "vitest run src/e2e.test.ts",
39
+ "test:cross-session": "vitest run src/e2e.test.ts -t \"different session\"",
40
+ "test:golden": "vitest run src/golden-path.test.ts",
41
+ "test:integration": "npm run test:e2e && npm run test:golden"
42
+ },
43
+ "files": [
44
+ "src/**/*.ts",
45
+ "!src/**/*.test.ts",
46
+ "README.md"
47
+ ],
48
+ "peerDependencies": {
49
+ "@earendil-works/pi-coding-agent": "*",
50
+ "typebox": "*"
51
+ },
52
+ "devDependencies": {
53
+ "@types/node": "^22.10.0",
54
+ "typebox": "^1.1.38",
55
+ "typescript": "^5.7.0",
56
+ "vitest": "^3.0.0"
57
+ }
58
+ }
@@ -0,0 +1,92 @@
1
+ import type { CommandResult, Condition } from "./types.js";
2
+
3
+ export interface ConditionMatch {
4
+ matched: boolean;
5
+ value?: unknown;
6
+ error?: string;
7
+ }
8
+
9
+ export function evaluateCondition(condition: Condition, result: CommandResult): ConditionMatch {
10
+ switch (condition.type) {
11
+ case "exit_code":
12
+ return { matched: result.exitCode === condition.equals, value: result.exitCode };
13
+ case "stdout_contains":
14
+ return { matched: result.stdout.includes(condition.value) };
15
+ case "stderr_contains":
16
+ return { matched: result.stderr.includes(condition.value) };
17
+ case "json_path_equals": {
18
+ const resolved = resolveJsonPath(result.stdout, condition.path);
19
+ if (!resolved.ok) return { matched: false, error: resolved.error };
20
+ return { matched: deepEqual(resolved.value, condition.value), value: resolved.value };
21
+ }
22
+ case "json_path_exists": {
23
+ const resolved = resolveJsonPath(result.stdout, condition.path);
24
+ return resolved.ok ? { matched: true, value: resolved.value } : { matched: false, error: resolved.error };
25
+ }
26
+ }
27
+ }
28
+
29
+ export function resolveJsonPath(jsonText: string, path: string): { ok: true; value: unknown } | { ok: false; error: string } {
30
+ let current: unknown;
31
+ try {
32
+ current = JSON.parse(jsonText);
33
+ } catch (error) {
34
+ return { ok: false, error: `stdout is not valid JSON: ${error instanceof Error ? error.message : String(error)}` };
35
+ }
36
+
37
+ const tokens = parseJsonPath(path);
38
+ if (!tokens) return { ok: false, error: `unsupported JSON path: ${path}` };
39
+
40
+ for (const token of tokens) {
41
+ if (typeof token === "number") {
42
+ if (!Array.isArray(current) || token < 0 || token >= current.length) {
43
+ return { ok: false, error: `JSON path not found: ${path}` };
44
+ }
45
+ current = current[token];
46
+ continue;
47
+ }
48
+ if (current === null || typeof current !== "object" || !(token in current)) {
49
+ return { ok: false, error: `JSON path not found: ${path}` };
50
+ }
51
+ current = (current as Record<string, unknown>)[token];
52
+ }
53
+
54
+ return { ok: true, value: current };
55
+ }
56
+
57
+ function parseJsonPath(path: string): Array<string | number> | undefined {
58
+ if (!path.startsWith("$")) return undefined;
59
+ const tokens: Array<string | number> = [];
60
+ let i = 1;
61
+ while (i < path.length) {
62
+ if (path[i] === ".") {
63
+ i += 1;
64
+ const start = i;
65
+ while (i < path.length && /[A-Za-z0-9_$-]/.test(path[i]!)) i += 1;
66
+ if (i === start) return undefined;
67
+ tokens.push(path.slice(start, i));
68
+ continue;
69
+ }
70
+ if (path[i] === "[") {
71
+ const end = path.indexOf("]", i);
72
+ if (end === -1) return undefined;
73
+ const raw = path.slice(i + 1, end).trim();
74
+ const quoted = raw.match(/^['\"](.+)['\"]$/);
75
+ if (quoted) {
76
+ tokens.push(quoted[1]!);
77
+ } else if (/^\d+$/.test(raw)) {
78
+ tokens.push(Number(raw));
79
+ } else {
80
+ return undefined;
81
+ }
82
+ i = end + 1;
83
+ continue;
84
+ }
85
+ return undefined;
86
+ }
87
+ return tokens;
88
+ }
89
+
90
+ function deepEqual(a: unknown, b: unknown): boolean {
91
+ return JSON.stringify(a) === JSON.stringify(b);
92
+ }
@@ -0,0 +1,116 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { listMetas } from "./registry.js";
3
+ import type { BackgroundTaskMeta } from "./types.js";
4
+ import { isTerminalStatus } from "./types.js";
5
+
6
+ const GOAL_READY_EVENT = "pi-better-goal:ready";
7
+ const GOAL_REGISTER_PROVIDER_EVENT = "pi-better-goal:register-provider";
8
+ const readySubscriptions = new WeakSet<ExtensionAPI>();
9
+
10
+ export interface GoalBackgroundWorkItem {
11
+ id: string;
12
+ label?: string;
13
+ status: string;
14
+ active: boolean;
15
+ unhealthy?: boolean;
16
+ terminal?: boolean;
17
+ attention?: boolean;
18
+ startedAt?: number;
19
+ endedAt?: number;
20
+ details?: Record<string, unknown>;
21
+ }
22
+
23
+ export interface GoalBackgroundProviderSnapshot {
24
+ providerId: string;
25
+ label?: string;
26
+ items: GoalBackgroundWorkItem[];
27
+ }
28
+
29
+ export function collectBackgroundTaskGoalActivity(
30
+ metas: BackgroundTaskMeta[] = listMetas(),
31
+ ctx?: ExtensionContext,
32
+ now = Date.now(),
33
+ ): GoalBackgroundProviderSnapshot {
34
+ return {
35
+ providerId: "background-tasks",
36
+ label: "Background Tasks",
37
+ items: metas
38
+ .filter((meta) => isVisibleGoalActivity(meta, ctx, now))
39
+ .map(backgroundTaskToGoalItem),
40
+ };
41
+ }
42
+
43
+ export function registerBackgroundTasksGoalProvider(pi: ExtensionAPI): void {
44
+ const emitProvider = (): void => {
45
+ pi.events?.emit(GOAL_REGISTER_PROVIDER_EVENT, {
46
+ id: "background-tasks",
47
+ label: "Background Tasks",
48
+ getActivity: (ctx: ExtensionContext) => collectBackgroundTaskGoalActivity(listMetas(), ctx),
49
+ });
50
+ };
51
+
52
+ if (!readySubscriptions.has(pi)) {
53
+ pi.events?.on?.(GOAL_READY_EVENT, emitProvider);
54
+ readySubscriptions.add(pi);
55
+ }
56
+ emitProvider();
57
+ }
58
+
59
+ const TERMINAL_GOAL_ACTIVITY_RETENTION_MS = 30_000;
60
+
61
+ function isVisibleGoalActivity(meta: BackgroundTaskMeta, ctx: ExtensionContext | undefined, now: number): boolean {
62
+ if (meta.dismissedAt !== undefined) return false;
63
+ if (ctx && !belongsToGoalActivitySession(meta, ctx)) return false;
64
+ if (meta.status === "running") return true;
65
+ if (!ctx) return true;
66
+ if (typeof meta.endedAt !== "number") return true;
67
+ return now - meta.endedAt < TERMINAL_GOAL_ACTIVITY_RETENTION_MS;
68
+ }
69
+
70
+ function belongsToGoalActivitySession(meta: BackgroundTaskMeta, ctx: ExtensionContext): boolean {
71
+ const active = getGoalActivityOrigin(ctx);
72
+ const origin = meta.callbackOrigin;
73
+ if (origin) {
74
+ if (origin.cwd !== active.cwd) return false;
75
+ if (origin.sessionId || active.sessionId) return origin.sessionId === active.sessionId;
76
+ return true;
77
+ }
78
+ if (active.sessionId) return false;
79
+ return meta.cwd === active.cwd;
80
+ }
81
+
82
+ function getGoalActivityOrigin(ctx: ExtensionContext) {
83
+ let sessionId: string | undefined;
84
+ try {
85
+ sessionId = ctx.sessionManager?.getSessionId();
86
+ } catch {
87
+ sessionId = undefined;
88
+ }
89
+ return { cwd: ctx.cwd, sessionId };
90
+ }
91
+
92
+ function backgroundTaskToGoalItem(meta: BackgroundTaskMeta): GoalBackgroundWorkItem {
93
+ const active = meta.status === "running";
94
+ const terminal = isTerminalStatus(meta.status);
95
+ const attention = terminal && meta.status !== "succeeded";
96
+ const item: GoalBackgroundWorkItem = {
97
+ id: meta.id,
98
+ status: meta.status,
99
+ active,
100
+ terminal,
101
+ attention,
102
+ startedAt: meta.startedAt,
103
+ details: {
104
+ kind: meta.kind,
105
+ callback: meta.callback !== false,
106
+ cwd: meta.cwd,
107
+ command: meta.command,
108
+ argv: meta.argv,
109
+ logPath: meta.logPath,
110
+ spawnPid: meta.spawnPid,
111
+ },
112
+ };
113
+ if (meta.name !== undefined) item.label = meta.name;
114
+ if (meta.endedAt !== undefined) item.endedAt = meta.endedAt;
115
+ return item;
116
+ }
@@ -0,0 +1,23 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { disposeBackgroundWorkNavigator } from "./shared-navigator.ts";
3
+ import { registerBackgroundTasksGoalProvider } from "./goal-provider.js";
4
+ import { clearBackgroundTasksNavigatorSession, ensureBackgroundTasksNavigator, ensureBackgroundTasksNavigatorProvider } from "./navigator-provider.js";
5
+ import { registerTools } from "./tools.js";
6
+
7
+ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
8
+ registerBackgroundTasksGoalProvider(pi);
9
+ ensureBackgroundTasksNavigatorProvider(pi);
10
+ pi.on("session_start", async (_event, ctx) => {
11
+ registerBackgroundTasksGoalProvider(pi);
12
+ ensureBackgroundTasksNavigator(ctx);
13
+ });
14
+ pi.on("session_before_switch", async () => {
15
+ clearBackgroundTasksNavigatorSession();
16
+ disposeBackgroundWorkNavigator();
17
+ });
18
+ pi.on("session_shutdown", async (_event, ctx) => {
19
+ clearBackgroundTasksNavigatorSession();
20
+ disposeBackgroundWorkNavigator(ctx);
21
+ });
22
+ registerTools(pi);
23
+ }
@@ -0,0 +1,84 @@
1
+ import { appendFileSync, closeSync, openSync, readSync, statSync, truncateSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import { mkdirSync } from "node:fs";
4
+ import { readBoundedTail, tailTerminalDisplay, terminalDisplayRows } from "./shared-log-utils.js";
5
+ import type { CommandResult } from "./types.js";
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
+
11
+ export function appendWatchResult(logPath: string, result: CommandResult): void {
12
+ mkdirSync(dirname(logPath), { recursive: true });
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`;
14
+ appendFileSync(logPath, header);
15
+ if (result.stdout) appendFileSync(logPath, result.stdout.endsWith("\n") ? result.stdout : `${result.stdout}\n`);
16
+ if (result.stderr) appendFileSync(logPath, `[stderr]\n${result.stderr.endsWith("\n") ? result.stderr : `${result.stderr}\n`}`);
17
+ }
18
+
19
+ export function appendLine(logPath: string, line: string): void {
20
+ mkdirSync(dirname(logPath), { recursive: true });
21
+ appendFileSync(logPath, `${line}\n`);
22
+ }
23
+
24
+ export function readLog(logPath: string, tailLines?: number): { text: string; truncated: boolean } {
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);
76
+ try {
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 };
81
+ } catch {
82
+ return undefined;
83
+ }
84
+ }