@saifymatteo/pi-extensions 0.1.3 → 0.1.5
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/CHANGELOG.md +28 -0
- package/package.json +2 -1
- package/packages/pi-bg-bash/AGENTS.md +29 -0
- package/packages/pi-bg-bash/CHANGELOG.md +25 -0
- package/packages/pi-bg-bash/package.json +2 -1
- package/packages/pi-bg-bash/src/index.ts +189 -99
- package/packages/pi-bg-bash/src/lib/output.ts +31 -0
- package/packages/pi-bg-bash/src/lib/panel.ts +155 -0
- package/packages/pi-bg-bash/src/lib/process-control.ts +221 -0
- package/packages/pi-bg-bash/src/lib/windows-console.ts +169 -0
- package/packages/pi-bg-bash/test/job-controls.test.mjs +347 -0
- package/packages/pi-bg-bash/test/native-process-control.test.mjs +126 -0
- package/packages/pi-bg-bash/test/output.test.mjs +28 -0
- package/packages/pi-bg-bash/test/panel.test.mjs +146 -0
- package/packages/pi-bg-bash/test/process-control.test.mjs +300 -0
- package/packages/pi-bg-bash/test/windows-console.test.mjs +221 -0
- package/packages/pi-claude-usage/CHANGELOG.md +18 -0
- package/packages/pi-claude-usage/package.json +2 -1
- package/packages/pi-clipboard-image/CHANGELOG.md +18 -0
- package/packages/pi-clipboard-image/package.json +2 -1
- package/packages/pi-codeblock-borders/CHANGELOG.md +18 -0
- package/packages/pi-codeblock-borders/package.json +2 -1
- package/packages/pi-codegraph/CHANGELOG.md +25 -0
- package/packages/pi-codegraph/package.json +2 -1
- package/packages/pi-codegraph/src/index.ts +67 -27
- package/packages/pi-compact-tools/CHANGELOG.md +18 -0
- package/packages/pi-compact-tools/package.json +2 -1
- package/packages/pi-decisions/CHANGELOG.md +18 -0
- package/packages/pi-decisions/package.json +2 -1
- package/packages/pi-load-pi-dotenv/CHANGELOG.md +18 -0
- package/packages/pi-load-pi-dotenv/package.json +2 -1
- package/packages/pi-model-go/CHANGELOG.md +18 -0
- package/packages/pi-model-go/package.json +2 -1
- package/packages/pi-prestart-working/CHANGELOG.md +18 -0
- package/packages/pi-prestart-working/package.json +2 -1
- package/packages/pi-root-guard/CHANGELOG.md +18 -0
- package/packages/pi-root-guard/package.json +2 -1
- package/packages/pi-session-finder/CHANGELOG.md +18 -0
- package/packages/pi-session-finder/package.json +2 -1
- package/packages/pi-session-id/CHANGELOG.md +18 -0
- package/packages/pi-session-id/package.json +2 -1
- package/packages/pi-session-nav/CHANGELOG.md +18 -0
- package/packages/pi-session-nav/package.json +2 -1
- package/packages/pi-session-rename/CHANGELOG.md +18 -0
- package/packages/pi-session-rename/package.json +2 -1
- package/packages/pi-session-retention/AGENTS.md +15 -0
- package/packages/pi-session-retention/CHANGELOG.md +25 -0
- package/packages/pi-session-retention/package.json +2 -1
- package/packages/pi-session-retention/src/index.ts +43 -4
- package/packages/pi-session-retention/test/startup.test.mjs +229 -0
- package/packages/pi-skill-usage-log/CHANGELOG.md +18 -0
- package/packages/pi-skill-usage-log/package.json +2 -1
- package/packages/pi-tool-usage-log/CHANGELOG.md +18 -0
- package/packages/pi-tool-usage-log/package.json +2 -1
- package/packages/pi-tts/CHANGELOG.md +18 -0
- package/packages/pi-tts/package.json +2 -1
- package/packages/pi-user-image-previews/CHANGELOG.md +18 -0
- package/packages/pi-user-image-previews/package.json +2 -1
- package/packages/pi-vision/CHANGELOG.md +18 -0
- package/packages/pi-vision/package.json +2 -1
- package/packages/pi-wait/CHANGELOG.md +18 -0
- package/packages/pi-wait/package.json +2 -1
- package/packages/pi-zvec/CHANGELOG.md +25 -0
- package/packages/pi-zvec/package.json +2 -1
- package/packages/pi-zvec/src/index.ts +81 -39
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
Release notes are recorded from 0.1.3 onward.
|
|
4
|
+
|
|
5
|
+
## [Unreleased]
|
|
6
|
+
|
|
7
|
+
## [0.1.5]
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Include release notes for the collection and all 23 standalone extensions in Pi 1.5.0's Extension Updates.
|
|
12
|
+
- Add an interactive `/bg` manager with live output, running-job status, and separate confirmed graceful-stop and force-kill actions.
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- Verify background-process identities and tracked descendants during cleanup, retaining survivors for retry without duplicating completion delivery.
|
|
17
|
+
|
|
18
|
+
## [0.1.4]
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- Avoid startup pauses by deferring session retention maintenance and CodeGraph/zvec initialization.
|
|
23
|
+
|
|
24
|
+
## [0.1.3]
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- Classify the collection as both an extension and a skill package in the Pi package gallery.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@saifymatteo/pi-extensions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"description": "Independently installable Pi extensions",
|
|
@@ -76,6 +76,7 @@
|
|
|
76
76
|
"!packages/*/screenshot.png",
|
|
77
77
|
"inventory.json",
|
|
78
78
|
"README.md",
|
|
79
|
+
"CHANGELOG.md",
|
|
79
80
|
"NOTICE.md",
|
|
80
81
|
"LICENSE"
|
|
81
82
|
]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Background job controls
|
|
2
|
+
|
|
3
|
+
## Ownership and delivery
|
|
4
|
+
|
|
5
|
+
`/bg` manages only jobs registered in the current runtime/session. It does not discover servers by executable name or port, recover jobs after restart, or stop jobs automatically on shutdown. Shutdown drains stop requests already in flight; unrelated detached jobs retain their existing lifetime.
|
|
6
|
+
|
|
7
|
+
Keep human inspection separate from delivery: opening the list or output leaves wait leases and completion consumption unchanged. Both human and model stop requests use the same stop transition. A claimed running job resolves its existing completion promise so `pi-wait` receives one terminal result. Stopping an unclaimed running job records a custom result with `triggerTurn: false`, avoiding a new idle model request.
|
|
8
|
+
|
|
9
|
+
Command completion is not child-exit evidence. Keep explicit cleanup available for captured descendants after their command finishes; that cleanup preserves the original result/lease and emits no second completion. History eviction waits for identity discovery and retains jobs with unverified descendant cleanup.
|
|
10
|
+
|
|
11
|
+
## Process safety
|
|
12
|
+
|
|
13
|
+
Use the recorded real OS PID and its captured creation identity. Windows launches return native `Start-Process` PIDs, not MSYS `$!` values. A missing identity disables stopping rather than guessing. Capture known descendants at launch and again before stopping; revalidate identities before signaling and verify tracked descendants afterward. Permission failures are not proof of termination. Retain discovered descendant identities across failed attempts, even after reparenting; processes that escape between snapshots remain outside this cleanup guarantee.
|
|
14
|
+
|
|
15
|
+
Graceful stopping requests SIGINT on Unix or a real console Ctrl+C on Windows, waits briefly, and leaves survivors available for explicit Force kill. It never escalates automatically. Force kill skips the interrupt and grace period but retains discovery, identity checks, and final verification. Model `job: "<id>:stop"` requests graceful shutdown; existing `:kill` explicitly forces termination.
|
|
16
|
+
|
|
17
|
+
Windows jobs launch in a separate hidden console. Clear the inherited ignore-Ctrl+C attribute before children start. Bash also needs inheritable `CONIN$` from its own solo console and a .NET process launch: PowerShell's native-command pipeline substitutes a stdin pipe, making MSYS suppress native children's Ctrl+C handlers. Authenticate the solo console before opening input and close owned handles in `finally`; this input must never come from Pi's console.
|
|
18
|
+
|
|
19
|
+
The disposable interrupt helper detaches its own console and authenticates the target console's membership and process birth identities before broadcasting Ctrl+C. Shared consoles (especially Pi's), unknown members, missing consoles, or console API failures fail closed, leaving Force kill available. Never substitute Node's Windows `process.kill(pid, "SIGINT")`: it is forced termination, not genuine Ctrl+C. Older jobs without safe console isolation may support force only. On Unix, background launch enables Bash job control so asynchronous commands do not inherit Bash's usual ignored SIGINT disposition.
|
|
20
|
+
|
|
21
|
+
## UI and output
|
|
22
|
+
|
|
23
|
+
The TUI list refreshes while preserving selection; Enter opens bounded output, `s` asks before graceful stopping, and `x` separately confirms Force kill with a cleanup-hook warning. RPC exposes both actions; print/JSON remain list-only. Session invalidation and component disposal release refresh timers. Human log reads are independent of completion delivery. Keep reads byte-bounded even for a single enormous line, and strip terminal controls before rendering shell output or command labels.
|
|
24
|
+
|
|
25
|
+
## Verification
|
|
26
|
+
|
|
27
|
+
The package tests use isolated fixtures, fake process inventories, and host stubs. Exercise a disposable parent/child process tree on Windows and Unix before claiming native termination coverage; unit tests do not prove CIM, `ps`, signal, terminal, or live-host behavior. Require application and child SIGINT-cleanup markers; successful `GenerateConsoleCtrlEvent` alone proves only API acceptance. Preserve the shared `async-work:providers:v1` contract with `pi-wait`; consolidation is a separate change.
|
|
28
|
+
|
|
29
|
+
The interaction was informed by `gvanderclay/pi-bg-bash` at commit `c31b6a6004062129d40f3c58f90022bfd6ad7af9`. This implementation uses its own UI/process integration; retain upstream MIT notices if copying upstream code in future changes.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
Release notes are recorded from 0.1.3 onward.
|
|
4
|
+
|
|
5
|
+
## [Unreleased]
|
|
6
|
+
|
|
7
|
+
## [0.1.4]
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Include package release notes in Pi 1.5.0's Extension Updates.
|
|
12
|
+
- Add an interactive `/bg` list, bounded live output, and a running-job footer count.
|
|
13
|
+
- Provide separate confirmed graceful-stop and force-kill actions, with no automatic escalation.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- Verify process creation identities and tracked descendant exit, retaining known survivors for retry even after a launcher exits.
|
|
18
|
+
- Isolate Windows job consoles so graceful Ctrl+C can run Bash and PowerShell cleanup handlers without interrupting Pi.
|
|
19
|
+
|
|
20
|
+
## [0.1.3]
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- Classify the package as an extension in the Pi package gallery.
|
|
25
|
+
- Show the package screenshot as its gallery preview.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@saifymatteo/pi-bg-bash",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"license": "AGPL-3.0-only",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
"src",
|
|
12
12
|
"examples",
|
|
13
13
|
"README.md",
|
|
14
|
+
"CHANGELOG.md",
|
|
14
15
|
"NOTICE.md",
|
|
15
16
|
"LICENSE"
|
|
16
17
|
],
|
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
* Offered drafts/queues are not receipts: only matching branch messages
|
|
14
14
|
* confirm delivery, and unconfirmed outcomes remain queryable.
|
|
15
15
|
* job: "<id>" -> OPTIONAL manual check: job "7" = status + output tail,
|
|
16
|
-
* "7:out" = more output, "7:
|
|
16
|
+
* "7:out" = more output, "7:stop" = graceful Ctrl+C,
|
|
17
|
+
* "7:kill" = immediate force kill of the tracked tree.
|
|
17
18
|
* The latest 20 completions stay queryable until shutdown;
|
|
18
19
|
* killing a completed job is a no-op.
|
|
19
20
|
*
|
|
@@ -47,10 +48,9 @@
|
|
|
47
48
|
* job's subshell can die before writing it (process-tree kill by a parent
|
|
48
49
|
* harness, crash, manual taskkill). When the tracked pid is no longer alive
|
|
49
50
|
* and no status file exists, the job is delivered as "finished, exit unknown"
|
|
50
|
-
* instead of being polled forever.
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
* resolved winpid is only ever finished by its status file appearing.
|
|
51
|
+
* instead of being polled forever. Windows Bash and PowerShell jobs use a
|
|
52
|
+
* native launcher PID in a separate hidden console. Unix Bash jobs use a
|
|
53
|
+
* separate job-control group; neither launch path signals Pi's own console.
|
|
54
54
|
*
|
|
55
55
|
* Calls that set background:true are drawn with an inverted "Background" chip
|
|
56
56
|
* prefixed to the shell call line in the transcript, and the delivered result
|
|
@@ -69,6 +69,10 @@ import { homedir, tmpdir } from "node:os";
|
|
|
69
69
|
import { join } from "node:path";
|
|
70
70
|
import { Type } from "typebox";
|
|
71
71
|
import { Box, Text, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
72
|
+
import { captureProcess, hasTrackedDescendants, stopProcessTree, type ProcessIdentity, type StopMode } from "./lib/process-control";
|
|
73
|
+
import { readTail } from "./lib/output";
|
|
74
|
+
import { windowsBashLaunchScript, windowsConsoleInputPrelude } from "./lib/windows-console";
|
|
75
|
+
import { showJobPanel, type JobView } from "./lib/panel";
|
|
72
76
|
|
|
73
77
|
// ---------------------------------------------------------------------------
|
|
74
78
|
// Job registry (module state; jobs survive turns, not app restarts)
|
|
@@ -81,9 +85,15 @@ interface BgJob {
|
|
|
81
85
|
outPath: string;
|
|
82
86
|
errPath?: string; // powershell only (Start-Process cannot merge streams)
|
|
83
87
|
statusPath: string;
|
|
84
|
-
scriptPath?: string; //
|
|
88
|
+
scriptPath?: string; // Windows shell script
|
|
85
89
|
pid: number | null;
|
|
86
|
-
winPid?: number | null; //
|
|
90
|
+
winPid?: number | null; // native OS PID (Windows Start-Process; Unix launch marker)
|
|
91
|
+
identity?: ProcessIdentity;
|
|
92
|
+
identityReady?: Promise<void>;
|
|
93
|
+
stopUnavailable?: string;
|
|
94
|
+
stopping?: Promise<string>;
|
|
95
|
+
stopMode?: StopMode;
|
|
96
|
+
stopFailed?: boolean;
|
|
87
97
|
startedAt: number;
|
|
88
98
|
timer?: ReturnType<typeof setInterval>;
|
|
89
99
|
result?: { exit: string; secs: number };
|
|
@@ -118,13 +128,23 @@ function currentJob(job: BgJob): boolean {
|
|
|
118
128
|
job.sessionFile === currentCtx.sessionManager.getSessionFile();
|
|
119
129
|
}
|
|
120
130
|
|
|
131
|
+
function updateJobStatus(): void {
|
|
132
|
+
try {
|
|
133
|
+
if (!currentCtx?.hasUI) return;
|
|
134
|
+
const running = [...jobs.values()].filter(job => currentJob(job) && !job.result).length;
|
|
135
|
+
currentCtx.ui.setStatus("bg-bash", running ? `BG: ${running} running · /bg` : undefined);
|
|
136
|
+
} catch { /* A stale UI must not affect process execution or delivery. */ }
|
|
137
|
+
}
|
|
138
|
+
|
|
121
139
|
function invalidateSession(ctx?: ExtensionContext, replace = false): void {
|
|
140
|
+
try { if (currentCtx?.hasUI) currentCtx.ui.setStatus("bg-bash", undefined); } catch { /* Stale UI. */ }
|
|
122
141
|
if (idleFlush !== undefined) clearTimeout(idleFlush);
|
|
123
142
|
idleFlush = undefined;
|
|
124
143
|
sessionEpoch.abort(new Error("Shell wait invalidated by session change"));
|
|
125
144
|
sessionEpoch = new AbortController();
|
|
126
145
|
if (replace) sessionScope++;
|
|
127
146
|
currentCtx = ctx;
|
|
147
|
+
updateJobStatus();
|
|
128
148
|
}
|
|
129
149
|
|
|
130
150
|
// ---------------------------------------------------------------------------
|
|
@@ -143,7 +163,7 @@ const bgSchema = Type.Object({
|
|
|
143
163
|
job: Type.Optional(
|
|
144
164
|
Type.String({
|
|
145
165
|
description:
|
|
146
|
-
`Background job id returned by a previous background:true call. "7" = status + last 40 output lines, "7:out" = last 80 output lines, "7:kill" = kill
|
|
166
|
+
`Background job id returned by a previous background:true call. "7" = status + last 40 output lines, "7:out" = last 80 output lines, "7:stop" = graceful Ctrl+C (no automatic escalation), "7:kill" = force kill the tracked process tree without grace (no-op if completed). The latest ${MAX_COMPLETED_JOBS} completed jobs remain queryable until shutdown. When set, command is ignored.`,
|
|
147
167
|
}),
|
|
148
168
|
),
|
|
149
169
|
});
|
|
@@ -156,7 +176,7 @@ const GUIDELINE =
|
|
|
156
176
|
"background:true — you get a job id immediately while the command runs detached, and its exit code and " +
|
|
157
177
|
"output tail are delivered automatically when safe. Do independent work after dispatch; when blocked, " +
|
|
158
178
|
"use wait with explicit shell:<id>/agent:<id> jobs, or end your turn for push delivery. Do not answer redundant known result messages individually. On-demand checks without waiting: " +
|
|
159
|
-
"job:\"<id>\" (status), \"<id>:out\" (more output), \"<id>:kill\". Use wait instead of sleep or polling — " +
|
|
179
|
+
"job:\"<id>\" (status), \"<id>:out\" (more output), \"<id>:stop\" (graceful), \"<id>:kill\" (force). Use wait instead of sleep or polling — " +
|
|
160
180
|
"sleep-waits and poll loops are blocked. Foreground calls without an explicit timeout are killed after " +
|
|
161
181
|
`${DEFAULT_SHELL_TIMEOUT_S}s (runTimeout.maxSeconds) - set timeout for a different cap, or use background:true ` +
|
|
162
182
|
"for anything that may run longer.";
|
|
@@ -243,43 +263,36 @@ function injectCodemodeTimeout(input: { code?: unknown }): void {
|
|
|
243
263
|
// ---------------------------------------------------------------------------
|
|
244
264
|
|
|
245
265
|
function bashLaunchWrap(command: string, outPath: string, statusPath: string): string {
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
//
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
// macOS/Linux bash $! is already a real pid: report it as-is so the
|
|
252
|
-
// watcher's aliveness check keeps working there (BSD ps has different
|
|
253
|
-
// columns, so the MSYS ps lookup must not run there).
|
|
254
|
-
const resolvePid =
|
|
255
|
-
process.platform === "win32"
|
|
256
|
-
? `BG_WINPID=$(ps -p $BG_PID 2>/dev/null | awk -v p=$BG_PID '$1==p{print $4}'); `
|
|
257
|
-
: `BG_WINPID=$BG_PID; `;
|
|
258
|
-
return (
|
|
259
|
-
`( ${command}\necho $? > "${statusPath}" ) > "${outPath}" 2>&1 & ` +
|
|
260
|
-
`BG_PID=$!; ` +
|
|
261
|
-
resolvePid +
|
|
262
|
-
`echo "__PI_BG_PID:$BG_PID __PI_BG_WINPID:\${BG_WINPID:-0}"`
|
|
263
|
-
);
|
|
266
|
+
// Job control gives the detached job its own group and avoids Bash's usual
|
|
267
|
+
// ignored SIGINT disposition for asynchronous commands. Signal verified PIDs,
|
|
268
|
+
// not an unchecked process group. Windows uses a separate native console.
|
|
269
|
+
return `set -m; ( ${command}\necho $? > "${statusPath}" ) > "${outPath}" 2>&1 & ` +
|
|
270
|
+
`BG_PID=$!; echo "__PI_BG_PID:$BG_PID __PI_BG_WINPID:$BG_PID"`;
|
|
264
271
|
}
|
|
265
272
|
|
|
266
273
|
function psScriptFor(command: string, statusPath: string): string {
|
|
267
274
|
return [
|
|
275
|
+
windowsConsoleInputPrelude,
|
|
268
276
|
"$ErrorActionPreference = 'Continue'",
|
|
269
277
|
command,
|
|
270
|
-
`"exit=$LASTEXITCODE ok=$?" | Set-Content -LiteralPath '${statusPath}'`,
|
|
278
|
+
`"exit=$LASTEXITCODE ok=$?" | Set-Content -LiteralPath '${statusPath.replace(/'/g, "''")}'`,
|
|
271
279
|
"",
|
|
272
280
|
].join("\n");
|
|
273
281
|
}
|
|
274
282
|
|
|
275
|
-
function
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
+
function windowsLaunchWrap(scriptPath: string, outPath: string, errPath: string): string {
|
|
284
|
+
const literal = (value: string) => `'${value.replace(/'/g, "''")}'`;
|
|
285
|
+
// Start-Process joins ArgumentList: quote the script argument itself for spaces.
|
|
286
|
+
const scriptArg = literal(`"${scriptPath.replace(/\\/g, "/")}"`);
|
|
287
|
+
const executable = "$command = Get-Command pwsh -ErrorAction SilentlyContinue; " +
|
|
288
|
+
"if (-not $command) { $command = Get-Command powershell -ErrorAction Stop }; $exe = $command.Source; ";
|
|
289
|
+
const args = `@('-NoProfile','-ExecutionPolicy','Bypass','-File',${scriptArg})`;
|
|
290
|
+
// No -NoNewWindow: a new hidden console prevents Ctrl+C reaching Pi. The
|
|
291
|
+
// interrupt helper still authenticates console membership before signaling.
|
|
292
|
+
return "$ErrorActionPreference = 'Stop'; " + executable +
|
|
293
|
+
`$p = Start-Process -FilePath $exe -ArgumentList ${args} -WorkingDirectory (Get-Location).ProviderPath ` +
|
|
294
|
+
`-RedirectStandardOutput ${literal(outPath)} -RedirectStandardError ${literal(errPath)} ` +
|
|
295
|
+
"-WindowStyle Hidden -PassThru; \"__PI_BG_PID:$($p.Id) __PI_BG_WINPID:$($p.Id)\"";
|
|
283
296
|
}
|
|
284
297
|
|
|
285
298
|
// ---------------------------------------------------------------------------
|
|
@@ -298,12 +311,6 @@ function bashOutCmd(job: BgJob): string {
|
|
|
298
311
|
return `tail -n 80 "${job.outPath}" 2>/dev/null || echo "(no output yet)"`;
|
|
299
312
|
}
|
|
300
313
|
|
|
301
|
-
function bashKillCmd(job: BgJob): string {
|
|
302
|
-
// Kill children first (pkill -P), then the subshell itself, so the command
|
|
303
|
-
// inside the subshell does not survive as an orphan.
|
|
304
|
-
return `pkill -P ${job.pid ?? 0} 2>/dev/null; kill ${job.pid ?? 0} 2>/dev/null && echo "kill signal sent to pid ${job.pid}" || echo "process ${job.pid} already gone"`;
|
|
305
|
-
}
|
|
306
|
-
|
|
307
314
|
function psStatusCmd(job: BgJob): string {
|
|
308
315
|
return (
|
|
309
316
|
`if (Test-Path -LiteralPath '${job.statusPath}') { "DONE - " + (Get-Content -LiteralPath '${job.statusPath}' -Raw).Trim() } ` +
|
|
@@ -321,13 +328,6 @@ function psOutCmd(job: BgJob): string {
|
|
|
321
328
|
);
|
|
322
329
|
}
|
|
323
330
|
|
|
324
|
-
function psKillCmd(job: BgJob): string {
|
|
325
|
-
return (
|
|
326
|
-
`Stop-Process -Id ${job.pid ?? 0} -Force -ErrorAction SilentlyContinue; ` +
|
|
327
|
-
`if (Get-Process -Id ${job.pid ?? 0} -ErrorAction SilentlyContinue) { "still running" } else { "killed or already gone" }`
|
|
328
|
-
);
|
|
329
|
-
}
|
|
330
|
-
|
|
331
331
|
// ---------------------------------------------------------------------------
|
|
332
332
|
// Completion watcher: poll the status file, hold outcomes while busy, and
|
|
333
333
|
// deliver only at an actionable settle boundary or an idle fallback. A wait
|
|
@@ -336,13 +336,7 @@ function psKillCmd(job: BgJob): string {
|
|
|
336
336
|
// ---------------------------------------------------------------------------
|
|
337
337
|
|
|
338
338
|
function tailFile(path: string, lines: number): string {
|
|
339
|
-
|
|
340
|
-
const content = readFileSync(path, "utf8");
|
|
341
|
-
const all = content.split("\n");
|
|
342
|
-
return all.slice(Math.max(0, all.length - lines)).join("\n").trimEnd();
|
|
343
|
-
} catch {
|
|
344
|
-
return "(no output)";
|
|
345
|
-
}
|
|
339
|
+
return readTail(path, lines);
|
|
346
340
|
}
|
|
347
341
|
|
|
348
342
|
function stopWatcher(job: BgJob) {
|
|
@@ -405,13 +399,14 @@ function trimHistory() {
|
|
|
405
399
|
} catch {
|
|
406
400
|
// Receipt lookup is best-effort during navigation; retain unconfirmed offers.
|
|
407
401
|
}
|
|
408
|
-
const completed = [...jobs.values()].filter((job) => job.result && !job.owner && (job.consumed || job.confirmed)
|
|
402
|
+
const completed = [...jobs.values()].filter((job) => job.result && !job.owner && (job.consumed || job.confirmed) &&
|
|
403
|
+
!job.identityReady && !(job.identity && hasTrackedDescendants(job.identity)));
|
|
409
404
|
for (const oldest of completed.slice(0, -MAX_COMPLETED_JOBS)) jobs.delete(oldest.id);
|
|
410
405
|
}
|
|
411
406
|
|
|
412
407
|
// One transition for watcher, claim and query: never read/deliver the same completion twice.
|
|
413
408
|
function refreshCompletion(job: BgJob): void {
|
|
414
|
-
if (job.result) return;
|
|
409
|
+
if (job.result || job.stopping || job.stopFailed) return;
|
|
415
410
|
const statusExists = existsSync(job.statusPath);
|
|
416
411
|
const unknownExit = !statusExists && job.winPid != null && job.winPid > 0 && !processAlive(job.winPid);
|
|
417
412
|
if (!statusExists && !unknownExit) return;
|
|
@@ -421,11 +416,15 @@ function refreshCompletion(job: BgJob): void {
|
|
|
421
416
|
// A status file can be visible before its writer writes the exit code.
|
|
422
417
|
if (!exit) return;
|
|
423
418
|
}
|
|
419
|
+
finishJob(job, unknownExit ? "unknown - the process died before writing its status file" : exit);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function finishJob(job: BgJob, exitLabel: string): void {
|
|
423
|
+
if (job.result) return;
|
|
424
424
|
stopWatcher(job);
|
|
425
425
|
const secs = Math.max(1, Math.floor(Date.now() / 1000) - job.startedAt);
|
|
426
426
|
const tail = tailFile(job.outPath, 40);
|
|
427
427
|
const errTail = job.errPath ? tailFile(job.errPath, 20) : "";
|
|
428
|
-
const exitLabel = unknownExit ? "unknown - the process died before writing its status file" : exit;
|
|
429
428
|
job.result = { exit: exitLabel, secs };
|
|
430
429
|
job.outcome = {
|
|
431
430
|
source: "shell", id: job.id,
|
|
@@ -438,12 +437,83 @@ ${tail || "(no output)"}` +
|
|
|
438
437
|
(errTail ? `
|
|
439
438
|
--- stderr ---
|
|
440
439
|
${errTail}` : ""),
|
|
441
|
-
details: { jobId: job.id, exit, secs, command: job.command, outPath: job.outPath, tail, errTail },
|
|
440
|
+
details: { jobId: job.id, exit: exitLabel, secs, command: job.command, outPath: job.outPath, tail, errTail },
|
|
442
441
|
};
|
|
443
442
|
jobs.delete(job.id);
|
|
444
443
|
jobs.set(job.id, job);
|
|
445
444
|
job.resolve(job.outcome);
|
|
446
445
|
trimHistory();
|
|
446
|
+
updateJobStatus();
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** Both the model and /bg use this transition; a wait lease retains delivery ownership. */
|
|
450
|
+
function stopJob(pi: ExtensionAPI, job: BgJob, by: "user" | "agent", mode: StopMode = "graceful"): Promise<string> {
|
|
451
|
+
if (!currentJob(job)) return Promise.reject(new Error("Job belongs to another session"));
|
|
452
|
+
if (job.stopping) {
|
|
453
|
+
// Never report an in-flight graceful request as a completed force kill.
|
|
454
|
+
return mode === "force" && job.stopMode !== "force" ?
|
|
455
|
+
job.stopping.catch(() => {}).then(() => stopJob(pi, job, by, mode)) : job.stopping;
|
|
456
|
+
}
|
|
457
|
+
refreshCompletion(job);
|
|
458
|
+
if (job.result && !job.identityReady && !(job.identity && hasTrackedDescendants(job.identity))) {
|
|
459
|
+
return Promise.resolve(`Background job #${job.id} already finished; no signal sent.`);
|
|
460
|
+
}
|
|
461
|
+
job.stopMode = mode;
|
|
462
|
+
job.stopping = Promise.resolve().then(async () => {
|
|
463
|
+
await job.identityReady;
|
|
464
|
+
if (job.result && !(job.identity && hasTrackedDescendants(job.identity))) {
|
|
465
|
+
return `Background job #${job.id} already finished; no signal sent.`;
|
|
466
|
+
}
|
|
467
|
+
if (!job.identity) throw new Error(job.stopUnavailable ?? "No verified process identity; refusing to stop an unverified PID");
|
|
468
|
+
const hadResult = !!job.result;
|
|
469
|
+
const outcome = await stopProcessTree(job.identity, mode);
|
|
470
|
+
if (outcome === "stuck") throw new Error(mode === "graceful" ?
|
|
471
|
+
"Some tracked processes are still running; use Force kill to terminate survivors. No automatic escalation was performed." :
|
|
472
|
+
"Some tracked processes are still running; cleanup is incomplete and can be retried");
|
|
473
|
+
job.stopping = undefined;
|
|
474
|
+
job.stopFailed = false;
|
|
475
|
+
if (hadResult) {
|
|
476
|
+
// Command completion and child cleanup are distinct. Preserve its original
|
|
477
|
+
// result/lease and never send a second completion for leftover cleanup.
|
|
478
|
+
trimHistory();
|
|
479
|
+
return outcome === "gone" ? `Background job #${job.id}'s tracked processes were already gone; no signal sent.` :
|
|
480
|
+
`Background job #${job.id}'s tracked process tree is gone; its command result is unchanged.`;
|
|
481
|
+
}
|
|
482
|
+
if (outcome === "gone") refreshCompletion(job);
|
|
483
|
+
if (!job.result) finishJob(job, outcome === "gone" ? "unknown - process already gone" :
|
|
484
|
+
`${mode === "force" ? "force killed" : "stopped gracefully"} by ${by}`);
|
|
485
|
+
if (!job.owner && !job.submitted && !job.consumed) {
|
|
486
|
+
if (by === "agent") job.consumed = true;
|
|
487
|
+
else if (currentJob(job)) {
|
|
488
|
+
// Record the stop for the next normal turn, without waking an idle model.
|
|
489
|
+
job.submitted = true;
|
|
490
|
+
const { type: _type, ...message } = resultDraft(job);
|
|
491
|
+
try { pi.sendMessage(message, { triggerTurn: false, deliverAs: "followUp" }); }
|
|
492
|
+
catch { /* Acceptance is uncertain; retain the queryable result without retrying. */ }
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
return outcome === "gone" ? `Background job #${job.id} was already gone; no signal sent.` :
|
|
496
|
+
`Background job #${job.id} stopped; its tracked process tree is gone.`;
|
|
497
|
+
}).catch(error => { if (job.identity) job.stopFailed = true; throw error; })
|
|
498
|
+
.finally(() => { job.stopping = undefined; job.stopMode = undefined; updateJobStatus(); });
|
|
499
|
+
updateJobStatus();
|
|
500
|
+
return job.stopping;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function visibleJobs(): JobView[] {
|
|
504
|
+
const visible = [...jobs.values()].filter(currentJob);
|
|
505
|
+
return visible.sort((a, b) => Number(!b.result) - Number(!a.result) || b.id - a.id).map(job => {
|
|
506
|
+
const children = !!job.identity && hasTrackedDescendants(job.identity);
|
|
507
|
+
return {
|
|
508
|
+
id: job.id, command: job.command, tool: job.tool, running: !job.result,
|
|
509
|
+
stoppable: !job.result || !!job.identityReady || children,
|
|
510
|
+
state: job.stopping ? (job.stopMode === "force" ? "force killing" : "stopping gracefully") : job.result ?
|
|
511
|
+
`done, ${job.result.exit}${job.stopFailed ? " · cleanup incomplete" : children ? " · child cleanup available" : ""}` :
|
|
512
|
+
job.stopFailed ? "cleanup incomplete" : job.identity ? "running" :
|
|
513
|
+
job.identityReady ? "running (verifying PID)" : "running (stop unavailable)",
|
|
514
|
+
secs: job.result?.secs ?? Math.max(0, Math.floor(Date.now() / 1000) - job.startedAt),
|
|
515
|
+
};
|
|
516
|
+
});
|
|
447
517
|
}
|
|
448
518
|
|
|
449
519
|
function resultDraft(job: BgJob) {
|
|
@@ -538,7 +608,7 @@ function registerBgTool(pi: ExtensionAPI, toolName: "bash" | "powershell", built
|
|
|
538
608
|
`For long-running or blocking commands (builds, servers, installs, tests, waits), set background:true: ` +
|
|
539
609
|
`the call returns a job id immediately, the command runs detached, and its exit code and output tail are ` +
|
|
540
610
|
`delivered to you automatically as a new message when it finishes. A background call can be followed up ` +
|
|
541
|
-
`with job:"<id>" (status + output tail), "<id>:out" (more output), or "<id>:kill". ` +
|
|
611
|
+
`with job:"<id>" (status + output tail), "<id>:out" (more output), "<id>:stop" (graceful, no automatic escalation), or "<id>:kill" (immediate force kill of the tracked tree). ` +
|
|
542
612
|
`The latest ${MAX_COMPLETED_JOBS} completed jobs remain queryable until shutdown; killing one is a no-op. ` +
|
|
543
613
|
`Do independent work first; when blocked use wait({jobs:["shell:<id>"]}) or end your turn. Sleep-waits and poll loops are blocked. ` +
|
|
544
614
|
`Foreground calls without an explicit timeout are killed after ${shellTimeoutSeconds()}s - set timeout for ` +
|
|
@@ -564,7 +634,7 @@ function registerBgTool(pi: ExtensionAPI, toolName: "bash" | "powershell", built
|
|
|
564
634
|
|
|
565
635
|
// Job management: build the check command and run it through the real tool.
|
|
566
636
|
if (params.job) {
|
|
567
|
-
const m = params.job.match(/^(\d+)(?::(out|kill))?$/);
|
|
637
|
+
const m = params.job.match(/^(\d+)(?::(out|stop|kill))?$/);
|
|
568
638
|
const job = m ? jobs.get(Number(m[1])) : undefined;
|
|
569
639
|
if (!job || !currentSession(ctx) || !currentJob(job)) {
|
|
570
640
|
const known = [...jobs.values()].filter(currentJob).map((job) => String(job.id)).join(", ") || "none";
|
|
@@ -573,35 +643,27 @@ function registerBgTool(pi: ExtensionAPI, toolName: "bash" | "powershell", built
|
|
|
573
643
|
const action = m![2] ?? "status";
|
|
574
644
|
// The watcher and queries use one transition; queries can consume before any enqueue.
|
|
575
645
|
refreshCompletion(job);
|
|
576
|
-
|
|
646
|
+
const cleanupRequest = (action === "kill" || action === "stop") &&
|
|
647
|
+
(!!job.identityReady || !!job.identity && hasTrackedDescendants(job.identity));
|
|
648
|
+
if (job.result && !cleanupRequest) {
|
|
577
649
|
if (!job.owner && !job.submitted) job.consumed = true;
|
|
578
650
|
trimHistory();
|
|
579
651
|
const errTail = action === "status" && job.errPath ? tailFile(job.errPath, 20) : "";
|
|
580
|
-
const text = action === "kill"
|
|
581
|
-
? `Background job #${job.id} already finished; no
|
|
652
|
+
const text = action === "kill" || action === "stop"
|
|
653
|
+
? `Background job #${job.id} already finished; no signal sent.`
|
|
582
654
|
: action === "out"
|
|
583
655
|
? tailFile(job.outPath, 80)
|
|
584
656
|
: `DONE (exit ${job.result.exit}) after ${job.result.secs}s.\n--- last output ---\n${tailFile(job.outPath, 40)}` +
|
|
585
657
|
(errTail ? `\n--- stderr ---\n${errTail}` : "");
|
|
586
658
|
return { content: [{ type: "text", text }], details: { bgJob: job.id, bgOutputPath: job.outPath, ...job.result } } as never;
|
|
587
659
|
}
|
|
588
|
-
|
|
589
|
-
job
|
|
590
|
-
|
|
591
|
-
? bashOutCmd(job)
|
|
592
|
-
: action === "kill"
|
|
593
|
-
? bashKillCmd(job)
|
|
594
|
-
: bashStatusCmd(job)
|
|
595
|
-
: action === "out"
|
|
596
|
-
? psOutCmd(job)
|
|
597
|
-
: action === "kill"
|
|
598
|
-
? psKillCmd(job)
|
|
599
|
-
: psStatusCmd(job);
|
|
600
|
-
if (action === "kill") {
|
|
601
|
-
if (job.owner) throw new Error(`shell:${job.id} is claimed; release the wait before killing it`);
|
|
602
|
-
stopWatcher(job);
|
|
603
|
-
jobs.delete(job.id);
|
|
660
|
+
if (action === "kill" || action === "stop") {
|
|
661
|
+
const text = await stopJob(pi, job, "agent", action === "kill" ? "force" : "graceful");
|
|
662
|
+
return { content: [{ type: "text", text }], details: { bgJob: job.id, ...job.result } } as never;
|
|
604
663
|
}
|
|
664
|
+
const cmd = job.tool === "bash"
|
|
665
|
+
? action === "out" ? bashOutCmd(job) : bashStatusCmd(job)
|
|
666
|
+
: action === "out" ? psOutCmd(job) : psStatusCmd(job);
|
|
605
667
|
return exec(cmd, 15) as never;
|
|
606
668
|
}
|
|
607
669
|
|
|
@@ -623,7 +685,19 @@ function registerBgTool(pi: ExtensionAPI, toolName: "bash" | "powershell", built
|
|
|
623
685
|
startedAt: Math.floor(Date.now() / 1000),
|
|
624
686
|
};
|
|
625
687
|
if (toolName === "bash") {
|
|
626
|
-
|
|
688
|
+
let launch: string;
|
|
689
|
+
if (process.platform === "win32") {
|
|
690
|
+
job.scriptPath = join(tmpdir(), `pi-bg-${stamp}.ps1`);
|
|
691
|
+
job.errPath = join(tmpdir(), `pi-bg-${stamp}.err`);
|
|
692
|
+
const commandPath = join(tmpdir(), `pi-bg-${stamp}.sh`);
|
|
693
|
+
const status = job.statusPath.replace(/\\/g, "/").replace(/'/g, "'\\''");
|
|
694
|
+
writeFileSync(commandPath, `exec 2>&1\n${params.command}\necho $? > '${status}'\n`, "utf8");
|
|
695
|
+
// The console bootstrap clears inherited Ctrl+C suppression before
|
|
696
|
+
// Bash (and its native child processes) inherit the job console.
|
|
697
|
+
writeFileSync(job.scriptPath, windowsBashLaunchScript(commandPath), "utf8");
|
|
698
|
+
const launcher = windowsLaunchWrap(job.scriptPath, job.outPath, job.errPath);
|
|
699
|
+
launch = `PI_BG_BASH_EXECUTABLE="$(cygpath -w "$BASH")" powershell.exe -NoLogo -NoProfile -NonInteractive -EncodedCommand ${Buffer.from(launcher, "utf16le").toString("base64")}`;
|
|
700
|
+
} else launch = bashLaunchWrap(params.command, job.outPath, job.statusPath);
|
|
627
701
|
const result = await exec(launch, 15) as { content?: Array<{ text?: string }> };
|
|
628
702
|
const text = result?.content?.map((c) => c.text ?? "").join("") ?? "";
|
|
629
703
|
const pid = text.match(/__PI_BG_PID:(\d+)/)?.[1];
|
|
@@ -634,18 +708,30 @@ function registerBgTool(pi: ExtensionAPI, toolName: "bash" | "powershell", built
|
|
|
634
708
|
job.scriptPath = join(tmpdir(), `pi-bg-${stamp}.ps1`);
|
|
635
709
|
job.errPath = join(tmpdir(), `pi-bg-${stamp}.err`);
|
|
636
710
|
writeFileSync(job.scriptPath, psScriptFor(params.command, job.statusPath), "utf8");
|
|
637
|
-
const result = await exec(
|
|
711
|
+
const result = await exec(windowsLaunchWrap(job.scriptPath, job.outPath, job.errPath), 30) as {
|
|
638
712
|
content?: Array<{ text?: string }>;
|
|
639
713
|
};
|
|
640
714
|
const text = result?.content?.map((c) => c.text ?? "").join("") ?? "";
|
|
641
715
|
const pid = text.match(/__PI_BG_PID:(\d+)/)?.[1];
|
|
642
716
|
job.pid = pid ? Number(pid) : null;
|
|
643
|
-
//
|
|
644
|
-
// checkable, unlike the MSYS bash case.
|
|
717
|
+
// Both Windows launch paths return native PIDs, not MSYS identifiers.
|
|
645
718
|
job.winPid = job.pid;
|
|
646
719
|
}
|
|
720
|
+
if (!job.pid || (process.platform === "win32" && !job.winPid)) {
|
|
721
|
+
throw new Error(`Background launch did not return a native PID; inspect ${job.outPath}${job.errPath ? ` and ${job.errPath}` : ""}`);
|
|
722
|
+
}
|
|
723
|
+
const osPid = process.platform === "win32" ? job.winPid : job.pid;
|
|
724
|
+
// Identity discovery must not turn a detached launch back into blocking work.
|
|
725
|
+
job.identityReady = (osPid == null ? Promise.reject(new Error("The shell did not return a real OS PID")) :
|
|
726
|
+
captureProcess(osPid, epoch)).then(identity => { job.identity = identity; })
|
|
727
|
+
.catch(error => { job.stopUnavailable = `Cannot verify this job's process identity: ${String(error)}`; })
|
|
728
|
+
.finally(() => {
|
|
729
|
+
job.identityReady = undefined;
|
|
730
|
+
if (currentJob(job)) { trimHistory(); updateJobStatus(); }
|
|
731
|
+
});
|
|
647
732
|
jobs.set(id, job);
|
|
648
733
|
if (!epoch.aborted && currentJob(job)) startWatcher(pi, job);
|
|
734
|
+
updateJobStatus();
|
|
649
735
|
return {
|
|
650
736
|
content: [
|
|
651
737
|
{
|
|
@@ -654,7 +740,7 @@ function registerBgTool(pi: ExtensionAPI, toolName: "bash" | "powershell", built
|
|
|
654
740
|
`Background job #${id} started${job.pid ? ` (pid ${job.pid})` : ""}.\n` +
|
|
655
741
|
`Output file: ${job.outPath}\n\n` +
|
|
656
742
|
`Its exit code and output tail will be delivered to you automatically as a new message.\n` +
|
|
657
|
-
`End your turn now or keep working on something else; on-demand checks: job:"${id}" (status), "${id}:out" (output), "${id}:kill".`,
|
|
743
|
+
`End your turn now or keep working on something else; on-demand checks: job:"${id}" (status), "${id}:out" (output), "${id}:stop" (graceful), "${id}:kill" (force).`,
|
|
658
744
|
},
|
|
659
745
|
],
|
|
660
746
|
details: { bgJob: id, bgOutputPath: job.outPath },
|
|
@@ -922,6 +1008,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
922
1008
|
pi.on("session_shutdown", async () => {
|
|
923
1009
|
invalidateSession(undefined, true);
|
|
924
1010
|
for (const job of jobs.values()) stopWatcher(job);
|
|
1011
|
+
// Drain only stops already requested by a user/agent; never stop other jobs on exit.
|
|
1012
|
+
await Promise.allSettled([...jobs.values()].flatMap(job => job.stopping ? [job.stopping] : []));
|
|
925
1013
|
jobs.clear();
|
|
926
1014
|
sweepOldJobFiles();
|
|
927
1015
|
});
|
|
@@ -949,21 +1037,23 @@ export default function (pi: ExtensionAPI) {
|
|
|
949
1037
|
}
|
|
950
1038
|
});
|
|
951
1039
|
|
|
952
|
-
// Human-facing job list.
|
|
953
1040
|
pi.registerCommand("bg", {
|
|
954
|
-
description: "
|
|
1041
|
+
description: "Manage background jobs: Enter views output; s stops gracefully; x force kills (confirmed)",
|
|
955
1042
|
handler: async (_args, ctx) => {
|
|
956
|
-
const
|
|
957
|
-
|
|
958
|
-
ctx
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
1043
|
+
const epoch = sessionEpoch.signal;
|
|
1044
|
+
await showJobPanel(ctx, {
|
|
1045
|
+
list: () => currentSession(ctx) ? visibleJobs() : [],
|
|
1046
|
+
output: id => {
|
|
1047
|
+
const job = jobs.get(id);
|
|
1048
|
+
if (!job || !currentJob(job)) return "Job is no longer available.";
|
|
1049
|
+
return readTail(job.outPath) + (job.errPath ? `\n--- stderr ---\n${readTail(job.errPath, 500, 16 * 1024)}` : "");
|
|
1050
|
+
},
|
|
1051
|
+
stop: (id, mode) => {
|
|
1052
|
+
const job = jobs.get(id);
|
|
1053
|
+
if (!job) return Promise.reject(new Error("Job is no longer available"));
|
|
1054
|
+
return stopJob(pi, job, "user", mode);
|
|
1055
|
+
},
|
|
1056
|
+
}, epoch);
|
|
967
1057
|
},
|
|
968
1058
|
});
|
|
969
1059
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { closeSync, fstatSync, openSync, readSync } from "node:fs";
|
|
2
|
+
import { stripVTControlCharacters } from "node:util";
|
|
3
|
+
|
|
4
|
+
/** Logs are untrusted terminal input; keep text, never terminal control sequences. */
|
|
5
|
+
export function cleanOutput(text: string): string {
|
|
6
|
+
return stripVTControlCharacters(text).replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, "").replace(/\t/g, " ");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function oneLine(text: string): string {
|
|
10
|
+
return cleanOutput(text).replace(/\s+/g, " ").trim();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Read only a bounded suffix, including for a single enormous log line. */
|
|
14
|
+
export function readTail(path: string, maxLines = 2000, maxBytes = 48 * 1024): string {
|
|
15
|
+
let fd: number | undefined;
|
|
16
|
+
try {
|
|
17
|
+
fd = openSync(path, "r");
|
|
18
|
+
const size = fstatSync(fd).size;
|
|
19
|
+
const start = Math.max(0, size - maxBytes);
|
|
20
|
+
const buffer = Buffer.alloc(Math.min(size, maxBytes));
|
|
21
|
+
const count = readSync(fd, buffer, 0, buffer.length, start);
|
|
22
|
+
const text = cleanOutput(buffer.subarray(0, count).toString("utf8"));
|
|
23
|
+
const lines = text.split("\n");
|
|
24
|
+
const cut = start > 0 || lines.length > maxLines;
|
|
25
|
+
return (cut ? "(earlier output omitted)\n" : "") + lines.slice(-maxLines).join("\n").trimEnd();
|
|
26
|
+
} catch (error) {
|
|
27
|
+
return (error as NodeJS.ErrnoException).code === "ENOENT" ? "(no output yet)" : "(output unavailable)";
|
|
28
|
+
} finally {
|
|
29
|
+
if (fd !== undefined) closeSync(fd);
|
|
30
|
+
}
|
|
31
|
+
}
|