@saifymatteo/pi-extensions 0.1.4 → 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.
Files changed (60) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/package.json +2 -1
  3. package/packages/pi-bg-bash/AGENTS.md +29 -0
  4. package/packages/pi-bg-bash/CHANGELOG.md +25 -0
  5. package/packages/pi-bg-bash/package.json +2 -1
  6. package/packages/pi-bg-bash/src/index.ts +189 -99
  7. package/packages/pi-bg-bash/src/lib/output.ts +31 -0
  8. package/packages/pi-bg-bash/src/lib/panel.ts +155 -0
  9. package/packages/pi-bg-bash/src/lib/process-control.ts +221 -0
  10. package/packages/pi-bg-bash/src/lib/windows-console.ts +169 -0
  11. package/packages/pi-bg-bash/test/job-controls.test.mjs +347 -0
  12. package/packages/pi-bg-bash/test/native-process-control.test.mjs +126 -0
  13. package/packages/pi-bg-bash/test/output.test.mjs +28 -0
  14. package/packages/pi-bg-bash/test/panel.test.mjs +146 -0
  15. package/packages/pi-bg-bash/test/process-control.test.mjs +300 -0
  16. package/packages/pi-bg-bash/test/windows-console.test.mjs +221 -0
  17. package/packages/pi-claude-usage/CHANGELOG.md +18 -0
  18. package/packages/pi-claude-usage/package.json +2 -1
  19. package/packages/pi-clipboard-image/CHANGELOG.md +18 -0
  20. package/packages/pi-clipboard-image/package.json +2 -1
  21. package/packages/pi-codeblock-borders/CHANGELOG.md +18 -0
  22. package/packages/pi-codeblock-borders/package.json +2 -1
  23. package/packages/pi-codegraph/CHANGELOG.md +25 -0
  24. package/packages/pi-codegraph/package.json +2 -1
  25. package/packages/pi-compact-tools/CHANGELOG.md +18 -0
  26. package/packages/pi-compact-tools/package.json +2 -1
  27. package/packages/pi-decisions/CHANGELOG.md +18 -0
  28. package/packages/pi-decisions/package.json +2 -1
  29. package/packages/pi-load-pi-dotenv/CHANGELOG.md +18 -0
  30. package/packages/pi-load-pi-dotenv/package.json +2 -1
  31. package/packages/pi-model-go/CHANGELOG.md +18 -0
  32. package/packages/pi-model-go/package.json +2 -1
  33. package/packages/pi-prestart-working/CHANGELOG.md +18 -0
  34. package/packages/pi-prestart-working/package.json +2 -1
  35. package/packages/pi-root-guard/CHANGELOG.md +18 -0
  36. package/packages/pi-root-guard/package.json +2 -1
  37. package/packages/pi-session-finder/CHANGELOG.md +18 -0
  38. package/packages/pi-session-finder/package.json +2 -1
  39. package/packages/pi-session-id/CHANGELOG.md +18 -0
  40. package/packages/pi-session-id/package.json +2 -1
  41. package/packages/pi-session-nav/CHANGELOG.md +18 -0
  42. package/packages/pi-session-nav/package.json +2 -1
  43. package/packages/pi-session-rename/CHANGELOG.md +18 -0
  44. package/packages/pi-session-rename/package.json +2 -1
  45. package/packages/pi-session-retention/CHANGELOG.md +25 -0
  46. package/packages/pi-session-retention/package.json +2 -1
  47. package/packages/pi-skill-usage-log/CHANGELOG.md +18 -0
  48. package/packages/pi-skill-usage-log/package.json +2 -1
  49. package/packages/pi-tool-usage-log/CHANGELOG.md +18 -0
  50. package/packages/pi-tool-usage-log/package.json +2 -1
  51. package/packages/pi-tts/CHANGELOG.md +18 -0
  52. package/packages/pi-tts/package.json +2 -1
  53. package/packages/pi-user-image-previews/CHANGELOG.md +18 -0
  54. package/packages/pi-user-image-previews/package.json +2 -1
  55. package/packages/pi-vision/CHANGELOG.md +18 -0
  56. package/packages/pi-vision/package.json +2 -1
  57. package/packages/pi-wait/CHANGELOG.md +18 -0
  58. package/packages/pi-wait/package.json +2 -1
  59. package/packages/pi-zvec/CHANGELOG.md +25 -0
  60. package/packages/pi-zvec/package.json +2 -1
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.4",
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",
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:kill" = kill the job.
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. The aliveness check runs against the real
51
- * Windows pid (resolved via MSYS ps at launch); on Windows, bash's $! is a
52
- * synthetic MSYS pid that Node's process.kill cannot see, so a job without a
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; // powershell only
88
+ scriptPath?: string; // Windows shell script
85
89
  pid: number | null;
86
- winPid?: number | null; // bash on Windows: real Windows pid (MSYS $! is synthetic)
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 a running job (no-op if completed). The latest ${MAX_COMPLETED_JOBS} completed jobs remain queryable until shutdown. When set, command is ignored.`,
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
- // Subshell: run the command, capture its exit code into the status file,
247
- // all output into the out file, background the subshell, print its pid.
248
- // On MSYS bash (Git Bash) $! is a synthetic MSYS pid Node's process.kill
249
- // cannot check (a Windows pid lookup on it fails or hits an unrelated
250
- // process by reuse), so the wrapper resolves the WINPID via ps. On
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 psLaunchWrap(scriptPath: string, outPath: string, errPath: string): string {
276
- return (
277
- "$exe = Get-Command pwsh -ErrorAction SilentlyContinue; " +
278
- "if (-not $exe) { $exe = Get-Command powershell -ErrorAction Stop }; " +
279
- "$p = Start-Process -FilePath $exe.Source -ArgumentList '-NoProfile','-ExecutionPolicy','Bypass','-File', " +
280
- `'${scriptPath}' -RedirectStandardOutput '${outPath}' -RedirectStandardError '${errPath}' ` +
281
- "-WindowStyle Hidden -PassThru; \"__PI_BG_PID:$($p.Id)\""
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
- try {
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
- if (job.result) {
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 kill signal sent.`
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
- const cmd =
589
- job.tool === "bash"
590
- ? action === "out"
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
- const launch = bashLaunchWrap(params.command, job.outPath, job.statusPath);
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(psLaunchWrap(job.scriptPath, job.outPath, job.errPath), 30) as {
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
- // Start-Process returns a real Windows pid: aliveness is directly
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: "List background jobs started via bash/powershell background:true",
1041
+ description: "Manage background jobs: Enter views output; s stops gracefully; x force kills (confirmed)",
955
1042
  handler: async (_args, ctx) => {
956
- const visible = currentSession(ctx) ? [...jobs.values()].filter(currentJob) : [];
957
- if (visible.length === 0) {
958
- ctx.ui.notify("No background jobs.", "info");
959
- return;
960
- }
961
- const lines = visible.map((job) => {
962
- const secs = job.result?.secs ?? Math.floor(Date.now() / 1000) - job.startedAt;
963
- const done = job.result ? `done, exit ${job.result.exit}` : existsSync(job.statusPath) ? "done" : "running";
964
- return `#${job.id} [${job.tool}] ${done} ${secs}s pid=${job.pid ?? "?"} :: ${job.command.slice(0, 80)}`;
965
- });
966
- ctx.ui.notify(`Background jobs:\n${lines.join("\n")}`, "info");
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
+ }