tau-coding-agent 0.1.6 → 0.2.0

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 (80) hide show
  1. package/README.md +15 -12
  2. package/extensions/answer.ts +129 -79
  3. package/extensions/branch-term/README.md +7 -0
  4. package/extensions/{branch-term.ts → branch-term/index.ts} +113 -104
  5. package/extensions/btw.ts +17 -2
  6. package/extensions/caffeinate/README.md +5 -0
  7. package/extensions/caffeinate/index.ts +144 -0
  8. package/extensions/fast.ts +292 -0
  9. package/extensions/ghostty.ts +214 -211
  10. package/extensions/git-diff-stats.ts +124 -84
  11. package/extensions/git-pr-status.ts +274 -208
  12. package/extensions/insights.ts +109 -191
  13. package/extensions/loop.ts +149 -145
  14. package/extensions/memory.ts +138 -86
  15. package/extensions/notify.ts +14 -26
  16. package/extensions/openai-verbosity.ts +108 -42
  17. package/extensions/review/fix.ts +15 -6
  18. package/extensions/review/git.ts +93 -103
  19. package/extensions/review/index.ts +60 -62
  20. package/extensions/review/interrupt.ts +117 -0
  21. package/extensions/review/message-queue.ts +45 -12
  22. package/extensions/review/models.ts +79 -3
  23. package/extensions/review/prompts.ts +41 -38
  24. package/extensions/review/review.ts +161 -166
  25. package/extensions/review/runner.ts +139 -147
  26. package/extensions/review/runtime.ts +173 -120
  27. package/extensions/review/triage.ts +41 -44
  28. package/extensions/sandbox/bash.ts +775 -0
  29. package/extensions/sandbox/command.ts +605 -0
  30. package/extensions/sandbox/config.ts +764 -0
  31. package/extensions/sandbox/index.ts +132 -3338
  32. package/extensions/sandbox/permissions/dialog.ts +118 -0
  33. package/extensions/sandbox/permissions/filesystem.ts +559 -0
  34. package/extensions/sandbox/permissions/mach-lookup.ts +186 -0
  35. package/extensions/sandbox/permissions/network.ts +164 -0
  36. package/extensions/sandbox/permissions/unsandboxed.ts +270 -0
  37. package/extensions/sandbox/runtime.ts +616 -0
  38. package/extensions/stash.ts +28 -15
  39. package/extensions/subagent/README.md +73 -0
  40. package/extensions/subagent/index.ts +822 -0
  41. package/extensions/subagent/interrupt.ts +117 -0
  42. package/extensions/subagent/permissions.ts +101 -0
  43. package/extensions/subagent/rpc.ts +177 -0
  44. package/extensions/tool-display-mode.ts +267 -64
  45. package/extensions/usage/index.ts +249 -356
  46. package/extensions/usage/openrouter.ts +10 -2
  47. package/extensions/websearch/README.md +12 -47
  48. package/extensions/websearch/config.ts +5 -2
  49. package/extensions/websearch/index.ts +94 -109
  50. package/extensions/websearch/output.ts +51 -0
  51. package/extensions/websearch/providers/anthropic.pi.ts +27 -44
  52. package/extensions/websearch/providers/gemini.browser.ts +68 -77
  53. package/extensions/websearch/providers/gemini.pi.ts +17 -17
  54. package/extensions/websearch/providers/openai-codex.pi.ts +145 -7
  55. package/extensions/websearch/providers/pi-model.shared.ts +33 -39
  56. package/extensions/websearch/providers/shared.ts +87 -2
  57. package/extensions/websearch/types.ts +0 -3
  58. package/extensions/worktree.ts +132 -172
  59. package/package.json +10 -5
  60. package/skills/browser-tools/SKILL.md +29 -234
  61. package/skills/browser-tools/references/cookies.md +36 -0
  62. package/skills/browser-tools/references/interaction.md +90 -0
  63. package/skills/browser-tools/references/logging.md +34 -0
  64. package/skills/git-clean-history/SKILL.md +6 -6
  65. package/skills/git-commit/SKILL.md +5 -3
  66. package/skills/github-pull-request/SKILL.md +60 -0
  67. package/skills/github-pull-request/references/create.md +40 -0
  68. package/skills/github-pull-request/references/stewardship.md +60 -0
  69. package/skills/oracle/SKILL.md +3 -3
  70. package/skills/oracle/scripts/oracle +10 -1
  71. package/skills/sentry/SKILL.md +15 -185
  72. package/skills/sentry/references/events.md +79 -0
  73. package/skills/sentry/references/issues.md +62 -0
  74. package/skills/sentry/references/logs.md +46 -0
  75. package/skills/update-changelog/SKILL.md +27 -121
  76. package/skills/web-design/SKILL.md +16 -105
  77. package/themes/tau-dark.json +4 -0
  78. package/extensions/openai-fast.ts +0 -229
  79. package/extensions/websearch/providers/openai-codex.browser.ts +0 -77
  80. package/extensions/websearch/providers/openai-codex.shared.ts +0 -123
@@ -1,14 +1,7 @@
1
1
  import { spawn, spawnSync } from "node:child_process";
2
- import { randomUUID } from "node:crypto";
3
2
  import * as fs from "node:fs";
4
- import * as path from "node:path";
5
3
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
6
- import {
7
- CURRENT_SESSION_VERSION,
8
- SessionManager,
9
- highlightCode,
10
- type SessionHeader,
11
- } from "@earendil-works/pi-coding-agent";
4
+ import { SessionManager, highlightCode, type SessionHeader } from "@earendil-works/pi-coding-agent";
12
5
  import { Box, Text } from "@earendil-works/pi-tui";
13
6
 
14
7
  const TERMINAL_FLAG = "branch-term";
@@ -46,7 +39,7 @@ const TMUX_LAYOUT_CONFIG: Record<
46
39
  };
47
40
 
48
41
  function getStringFlag(pi: ExtensionAPI, flagName: string): string | undefined {
49
- const value = pi.getFlag(`--${flagName}`);
42
+ const value = pi.getFlag(flagName);
50
43
  if (typeof value !== "string") return undefined;
51
44
  const trimmed = value.trim();
52
45
  return trimmed.length > 0 ? trimmed : undefined;
@@ -55,7 +48,7 @@ function getStringFlag(pi: ExtensionAPI, flagName: string): string | undefined {
55
48
  function parseTmuxLayout(value: string | undefined): TmuxLayout | undefined {
56
49
  if (!value) return undefined;
57
50
  const normalized = value.trim().toLowerCase();
58
- if (normalized in TMUX_LAYOUT_CONFIG) {
51
+ if (Object.hasOwn(TMUX_LAYOUT_CONFIG, normalized)) {
59
52
  return normalized as TmuxLayout;
60
53
  }
61
54
  return undefined;
@@ -103,73 +96,23 @@ function getBranchArgumentCompletions(
103
96
  return matches;
104
97
  }
105
98
 
106
- function renderTerminalCommand(template: string, cwd: string, sessionFile: string): string {
107
- let command = template;
108
- command = command.split("{cwd}").join(cwd);
109
-
110
- if (command.includes("{command}")) {
111
- const piCommand = `pi --session ${shellQuote(sessionFile)}`;
112
- command = command.split("{command}").join(piCommand);
113
- }
114
-
115
- if (command.includes("{session}")) {
116
- command = command.split("{session}").join(sessionFile);
117
- }
118
-
119
- if (template.includes("{command}") || template.includes("{session}")) {
120
- return command;
121
- }
122
-
123
- return `${command} ${sessionFile}`;
99
+ function renderTerminalCommand(template: string): string {
100
+ // Paths stay data even when they contain shell syntax or placeholder-like text.
101
+ const command = template.replace(
102
+ /\{(cwd|session|command)\}/g,
103
+ (_match, name: string) => `"$TAU_BRANCH_${name.toUpperCase()}"`,
104
+ );
105
+ return /\{(command|session)\}/.test(template) ? command : `${command} "$TAU_BRANCH_SESSION"`;
124
106
  }
125
107
 
126
- function spawnDetached(command: string, args: string[], onError?: (error: Error) => void): void {
127
- const child = spawn(command, args, { detached: true, stdio: "ignore" });
128
- child.unref();
129
- if (onError) child.on("error", onError);
130
- }
108
+ // Shared outside the factory so detached children cannot retain a session through this listener.
109
+ function ignoreDetachedLauncherError(): void {}
131
110
 
132
111
  function shellQuote(value: string): string {
133
112
  if (value.length === 0) return "''";
134
113
  return `'${value.replace(/'/g, "'\\''")}'`;
135
114
  }
136
115
 
137
- function escapeForDoubleQuotes(value: string): string {
138
- return value.replace(/(["\\$`!])/g, "\\$1");
139
- }
140
-
141
- function shellQuoteCompact(value: string): string {
142
- const home = process.env.HOME;
143
- if (!home) return shellQuote(value);
144
-
145
- if (value === home) return "$HOME";
146
-
147
- if (value.startsWith(`${home}/`)) {
148
- const suffix = value.slice(home.length + 1);
149
- return `"$HOME/${escapeForDoubleQuotes(suffix)}"`;
150
- }
151
-
152
- return shellQuote(value);
153
- }
154
-
155
- function parseSessionIdFromFile(sessionFile: string): string | undefined {
156
- const fileName = path.basename(sessionFile, ".jsonl");
157
- const separatorIndex = fileName.lastIndexOf("_");
158
- if (separatorIndex <= 0) return undefined;
159
-
160
- const candidate = fileName.slice(separatorIndex + 1);
161
- return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(candidate)
162
- ? candidate
163
- : undefined;
164
- }
165
-
166
- function formatResumeSessionArgument(sessionFile: string): string {
167
- const sessionId = parseSessionIdFromFile(sessionFile);
168
- if (sessionId) return sessionId;
169
-
170
- return shellQuoteCompact(sessionFile);
171
- }
172
-
173
116
  function runClipboardCommand(command: string, args: string[], text: string): boolean {
174
117
  try {
175
118
  const result = spawnSync(command, args, {
@@ -241,26 +184,32 @@ function hasValidSessionFile(sessionFile: string): boolean {
241
184
  }
242
185
  }
243
186
 
244
- function createFreshSessionFile(cwd: string, sessionDir: string): string {
245
- fs.mkdirSync(sessionDir, { recursive: true });
246
-
247
- const sessionId = randomUUID();
248
- const timestamp = new Date().toISOString();
249
- const fileTimestamp = timestamp.replace(/[:.]/g, "-");
250
- const sessionFile = path.join(sessionDir, `${fileTimestamp}_${sessionId}.jsonl`);
251
- const header: SessionHeader = {
252
- type: "session",
253
- version: CURRENT_SESSION_VERSION,
254
- id: sessionId,
255
- timestamp,
256
- cwd,
257
- };
258
-
259
- fs.writeFileSync(sessionFile, `${JSON.stringify(header)}\n`);
187
+ /** Write a standalone handoff snapshot when Pi defers persistence; discard the manager afterward. */
188
+ function persistFork(manager: SessionManager): string {
189
+ const sessionFile = manager.getSessionFile();
190
+ const header = manager.getHeader();
191
+ if (!sessionFile || !header) throw new Error("Failed to create branched session");
192
+ if (!fs.existsSync(sessionFile)) {
193
+ const contents =
194
+ [header, ...manager.getEntries()].map((entry) => JSON.stringify(entry)).join("\n") + "\n";
195
+ const fd = fs.openSync(sessionFile, "wx");
196
+ try {
197
+ fs.writeFileSync(fd, contents);
198
+ } catch (error) {
199
+ fs.rmSync(sessionFile, { force: true });
200
+ throw error;
201
+ } finally {
202
+ fs.closeSync(fd);
203
+ }
204
+ }
260
205
  return sessionFile;
261
206
  }
262
207
 
263
208
  export default function (pi: ExtensionAPI) {
209
+ const launchObservers = new Set<() => void>();
210
+ pi.on("session_start", releaseLaunchObservers);
211
+ pi.on("session_shutdown", releaseLaunchObservers);
212
+
264
213
  pi.registerEntryRenderer<CommandMessageDetails>(BRANCH_ENTRY_TYPE, (entry, _options, theme) => {
265
214
  const details = entry.data;
266
215
  if (!details || typeof details.intro !== "string" || typeof details.command !== "string") {
@@ -287,7 +236,7 @@ export default function (pi: ExtensionAPI) {
287
236
 
288
237
  pi.registerFlag(TERMINAL_FLAG, {
289
238
  description:
290
- "Command to open a new terminal. Use {cwd} for working directory and optional {command} for the pi command.",
239
+ "Bash launcher template. Unquoted {cwd}, {session}, and {command} become single arguments; {command} is a command string for e.g. bash -c.",
291
240
  type: "string",
292
241
  });
293
242
 
@@ -326,38 +275,38 @@ export default function (pi: ExtensionAPI) {
326
275
 
327
276
  let forkFile: string;
328
277
  if (leafId && hasValidSessionFile(sessionFile)) {
329
- const forkManager = SessionManager.open(sessionFile);
330
- const branchedSessionFile = forkManager.createBranchedSession(leafId);
331
- if (!branchedSessionFile) {
278
+ const forkManager = SessionManager.open(
279
+ sessionFile,
280
+ ctx.sessionManager.getSessionDir(),
281
+ ctx.cwd,
282
+ );
283
+ if (!forkManager.createBranchedSession(leafId)) {
332
284
  throw new Error("Failed to create branched session");
333
285
  }
334
- forkFile = branchedSessionFile;
286
+ forkFile = persistFork(forkManager);
335
287
  } else {
336
- if (hasAssistantReply) {
288
+ if (leafId && hasAssistantReply) {
337
289
  throw new Error(`Current session file is missing or invalid: ${sessionFile}`);
338
290
  }
339
291
 
340
- const message = "Current session has no persisted history yet. Opening a fresh session.";
292
+ const message = leafId
293
+ ? "Current session has no persisted history yet. Opening a fresh session."
294
+ : "Selected conversation is empty. Opening a fresh session.";
341
295
  if (ctx.hasUI) ctx.ui.notify(message, "warning");
342
296
  else console.log(message);
343
297
 
344
- forkFile = createFreshSessionFile(ctx.cwd, ctx.sessionManager.getSessionDir());
298
+ forkFile = persistFork(
299
+ SessionManager.create(ctx.cwd, ctx.sessionManager.getSessionDir(), {
300
+ parentSession: sessionFile,
301
+ }),
302
+ );
345
303
  }
346
304
 
347
- const resumeCommand = `cd ${shellQuoteCompact(ctx.cwd)} && pi --session ${formatResumeSessionArgument(forkFile)}`;
305
+ const resumeCommand = `cd ${shellQuote(ctx.cwd)} && pi --session ${shellQuote(forkFile)}`;
348
306
 
349
307
  const terminalFlag = getStringFlag(pi, TERMINAL_FLAG);
350
308
  if (terminalFlag) {
351
- const command = renderTerminalCommand(terminalFlag, ctx.cwd, forkFile);
352
- spawnDetached("bash", ["-lc", command], (error) => {
353
- if (ctx.hasUI) {
354
- ctx.ui.notify(`Terminal command failed: ${error.message}`, "error");
355
- } else {
356
- console.error(`Terminal command failed: ${error.message}`);
357
- }
358
- showCommandMessage(pi, ctx, MANUAL_RESUME_INTRO, resumeCommand);
359
- });
360
- if (ctx.hasUI) ctx.ui.notify("Opened fork in new terminal", "info");
309
+ launchTerminal(terminalFlag, forkFile, resumeCommand, ctx);
361
310
  return;
362
311
  }
363
312
 
@@ -397,4 +346,64 @@ export default function (pi: ExtensionAPI) {
397
346
  showCommandMessage(pi, ctx, MANUAL_RESUME_INTRO, resumeCommand);
398
347
  },
399
348
  });
349
+
350
+ function launchTerminal(
351
+ template: string,
352
+ forkFile: string,
353
+ resumeCommand: string,
354
+ ctx: ExtensionCommandContext,
355
+ ) {
356
+ const reportFailure = (message: string) => {
357
+ try {
358
+ if (ctx.hasUI) ctx.ui.notify(`Terminal command failed: ${message}`, "error");
359
+ else console.error(`Terminal command failed: ${message}`);
360
+ showCommandMessage(pi, ctx, MANUAL_RESUME_INTRO, resumeCommand);
361
+ } catch (error) {
362
+ console.error(
363
+ `Could not show terminal recovery instructions: ${error instanceof Error ? error.message : String(error)}`,
364
+ );
365
+ console.error(`${MANUAL_RESUME_INTRO}:\n${resumeCommand}`);
366
+ }
367
+ };
368
+ try {
369
+ const child = spawn("bash", ["-c", renderTerminalCommand(template)], {
370
+ cwd: ctx.cwd,
371
+ env: {
372
+ ...process.env,
373
+ TAU_BRANCH_CWD: ctx.cwd,
374
+ TAU_BRANCH_SESSION: forkFile,
375
+ TAU_BRANCH_COMMAND: `pi --session ${shellQuote(forkFile)}`,
376
+ },
377
+ detached: true,
378
+ stdio: "ignore",
379
+ });
380
+ const stopObserving = () => {
381
+ child.removeListener("error", onError);
382
+ child.removeListener("close", onClose);
383
+ launchObservers.delete(stopObserving);
384
+ };
385
+ const onError = (error: Error) => {
386
+ stopObserving();
387
+ reportFailure(error.message);
388
+ };
389
+ const onClose = (code: number | null, signal: NodeJS.Signals | null) => {
390
+ stopObserving();
391
+ if (code !== 0) reportFailure(signal ? `terminated by ${signal}` : `exit code ${code}`);
392
+ };
393
+ // A detached, user-owned launcher may still emit an error after Pi stops observing it.
394
+ child.on("error", ignoreDetachedLauncherError);
395
+ child.once("error", onError);
396
+ child.once("close", onClose);
397
+ launchObservers.add(stopObserving);
398
+ child.unref();
399
+ } catch (error) {
400
+ reportFailure(error instanceof Error ? error.message : String(error));
401
+ return;
402
+ }
403
+ if (ctx.hasUI) ctx.ui.notify("Terminal launch requested", "info");
404
+ }
405
+
406
+ function releaseLaunchObservers() {
407
+ for (const stopObserving of launchObservers) stopObserving();
408
+ }
400
409
  }
package/extensions/btw.ts CHANGED
@@ -31,6 +31,7 @@ import {
31
31
 
32
32
  const STATUS_KEY = "0-btw";
33
33
  const RESULT_MARKDOWN_THEME = getMarkdownTheme();
34
+ const MIN_RESULT_WIDTH = 50;
34
35
  const STATUS_SPINNER_INTERVAL_MS = 80;
35
36
  const STATUS_SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
36
37
 
@@ -380,6 +381,7 @@ function extractText(content: AssistantMessage["content"]): string {
380
381
  class BtwResultComponent implements Component {
381
382
  private readonly markdown: Markdown;
382
383
  private scrollOffset = 0;
384
+ private renderWidth?: number;
383
385
  private cachedBodyWidth?: number;
384
386
  private cachedBodyLines?: string[];
385
387
 
@@ -409,8 +411,11 @@ class BtwResultComponent implements Component {
409
411
  return;
410
412
  }
411
413
 
414
+ const width = this.renderWidth ?? this.tui.terminal.columns;
415
+ if (width < MIN_RESULT_WIDTH) return;
416
+
412
417
  const bodyHeight = this.getBodyHeight();
413
- const boxWidth = this.getBoxWidth(this.tui.terminal.columns);
418
+ const boxWidth = this.getBoxWidth(width);
414
419
  const bodyLines = this.getBodyLines(this.getContentWidth(boxWidth));
415
420
  const maxScroll = Math.max(0, bodyLines.length - bodyHeight);
416
421
 
@@ -434,6 +439,16 @@ class BtwResultComponent implements Component {
434
439
  }
435
440
 
436
441
  render(width: number): string[] {
442
+ this.renderWidth = width;
443
+ if (width < MIN_RESULT_WIDTH) {
444
+ return [
445
+ truncateToWidth(
446
+ this.theme.fg("muted", `Resize to ${MIN_RESULT_WIDTH}+ columns to view answer.`),
447
+ width,
448
+ ),
449
+ ];
450
+ }
451
+
437
452
  const boxWidth = this.getBoxWidth(width);
438
453
  const contentWidth = this.getContentWidth(boxWidth);
439
454
  const questionLines = wrapTextWithAnsi(
@@ -486,7 +501,7 @@ class BtwResultComponent implements Component {
486
501
  }
487
502
 
488
503
  private getBoxWidth(width: number): number {
489
- return Math.max(50, Math.min(width - 2, 140));
504
+ return Math.max(MIN_RESULT_WIDTH, Math.min(width - 2, 140));
490
505
  }
491
506
 
492
507
  private getContentWidth(boxWidth: number): number {
@@ -0,0 +1,5 @@
1
+ # Caffeinate
2
+
3
+ `caffeinate` prevents system sleep during agent runs, including retries, automatic compaction, and queued follow-ups. It releases the inhibitor when the agent settles or the session closes or reloads. Display sleep settings remain unchanged.
4
+
5
+ macOS uses `caffeinate -i`; Linux uses `systemd-inhibit --what=sleep` and requires an accessible, authorized logind service. Linux's sleep lock can also block manual suspend; lid-close and forced-sleep behavior remain OS-policy decisions. If inhibition is unavailable, Pi warns once per load, on the first failed attempt, and continues normally. There are no commands or settings; disable the extension through Pi's package configuration if unwanted.
@@ -0,0 +1,144 @@
1
+ /** Keep the system awake during agent runs without inhibiting display sleep. */
2
+
3
+ import { spawn } from "node:child_process";
4
+ import { platform } from "node:os";
5
+ import { stripVTControlCharacters } from "node:util";
6
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
7
+
8
+ interface InhibitCommand {
9
+ command: string;
10
+ args: string[];
11
+ waitForStdin: boolean;
12
+ }
13
+
14
+ export default function (pi: ExtensionAPI) {
15
+ let inhibitor: ReturnType<typeof startInhibitor> | undefined;
16
+ let releasing = Promise.resolve();
17
+ let shutdown = false;
18
+ let warned = false;
19
+
20
+ pi.on("agent_start", async (_event, ctx) => {
21
+ await releasing;
22
+ if (shutdown || inhibitor) return;
23
+ const command = inhibitCommand();
24
+ if (!command) return;
25
+ try {
26
+ inhibitor = startInhibitor(command, ctx.cwd, (reason) => warn(ctx, reason));
27
+ } catch (error) {
28
+ warn(ctx, error instanceof Error ? error.message : String(error));
29
+ }
30
+ });
31
+
32
+ pi.on("agent_settled", async (_event, ctx) => {
33
+ if (!shutdown && ctx.isIdle()) await release();
34
+ });
35
+
36
+ pi.on("session_shutdown", async () => {
37
+ shutdown = true;
38
+ await release();
39
+ });
40
+
41
+ async function release() {
42
+ if (inhibitor) {
43
+ releasing = inhibitor.stop();
44
+ inhibitor = undefined;
45
+ }
46
+ await releasing;
47
+ }
48
+
49
+ function warn(ctx: ExtensionContext, reason: string) {
50
+ if (shutdown || warned) return;
51
+ warned = true;
52
+ const message = `Caffeinate: sleep prevention unavailable. ${stripVTControlCharacters(reason)}`;
53
+ try {
54
+ if (ctx.hasUI) {
55
+ ctx.ui.notify(message, "warning");
56
+ return;
57
+ }
58
+ } catch {
59
+ // A missing UI must not turn an optional inhibitor failure into an uncaught error.
60
+ }
61
+ console.warn(message);
62
+ }
63
+ }
64
+
65
+ function inhibitCommand(): InhibitCommand | undefined {
66
+ switch (platform()) {
67
+ case "darwin":
68
+ return {
69
+ command: "/usr/bin/caffeinate",
70
+ args: ["-i", "-w", String(process.pid)],
71
+ waitForStdin: false,
72
+ };
73
+ case "linux":
74
+ return {
75
+ command: "systemd-inhibit",
76
+ args: [
77
+ "--what=sleep",
78
+ "--mode=block",
79
+ "--who=Pi",
80
+ "--why=Pi agent is running",
81
+ "--",
82
+ "cat",
83
+ ],
84
+ waitForStdin: true,
85
+ };
86
+ }
87
+ }
88
+
89
+ /** Own the inhibitor through close; a parent PID watch or stdin EOF also releases it if Pi exits. */
90
+ function startInhibitor(command: InhibitCommand, cwd: string, onFailure: (reason: string) => void) {
91
+ const child = spawn(command.command, command.args, {
92
+ cwd,
93
+ detached: true,
94
+ stdio: ["pipe", "ignore", "pipe"],
95
+ });
96
+ let stderr = "";
97
+ let error: string | undefined;
98
+ let stopped = false;
99
+ let closed = false;
100
+ let escalation: NodeJS.Timeout | undefined;
101
+ child.stderr.setEncoding("utf8");
102
+ child.stderr.on("data", (chunk: string) => {
103
+ stderr = (stderr + chunk).slice(0, 2048);
104
+ });
105
+ child.on("error", (cause: Error) => {
106
+ error = cause.message;
107
+ });
108
+ child.stdin.on("error", (cause: Error) => {
109
+ error = cause.message;
110
+ });
111
+ const completion = new Promise<void>((resolve) => {
112
+ child.once("close", (code, signal) => {
113
+ closed = true;
114
+ clearTimeout(escalation);
115
+ if (!stopped) {
116
+ onFailure(
117
+ error || stderr.trim() || `${command.command} exited unexpectedly (${signal ?? code}).`,
118
+ );
119
+ }
120
+ resolve();
121
+ });
122
+ });
123
+
124
+ return {
125
+ stop() {
126
+ if (!stopped && !closed) {
127
+ stopped = true;
128
+ child.stdin.end();
129
+ if (!command.waitForStdin) sendSignal("SIGTERM");
130
+ escalation = setTimeout(() => sendSignal("SIGKILL"), 500);
131
+ }
132
+ return completion;
133
+ },
134
+ };
135
+
136
+ function sendSignal(signal: "SIGTERM" | "SIGKILL") {
137
+ if (closed || child.pid === undefined) return;
138
+ try {
139
+ process.kill(-child.pid, signal);
140
+ } catch (cause) {
141
+ if ((cause as NodeJS.ErrnoException).code !== "ESRCH") child.kill(signal);
142
+ }
143
+ }
144
+ }