@pify/shell-background 0.3.0 → 0.4.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.
package/README.md CHANGED
@@ -60,6 +60,10 @@ Put these in `.pi/shell-background.json` (project) or `<agentDir>/shell-backgrou
60
60
 
61
61
  `autoBackgroundMs` is how long a foreground command may run before it auto-backgrounds; set it to `0` to disable auto-background (explicit `background: true` still works). `PIFY_SHELL_BG_MS` overrides it for one run or in CI. `tailBytes` bounds how much of a job's log a status result shows. `maxBackground` bounds how many jobs may be alive at once: over it, a `background: true` request is refused with a message naming the limit (the command can still run in the foreground), and a command that crosses the auto-background threshold simply stays in the foreground instead of moving — a command is never refused, only the decision to background it. Bad values fall back to the defaults with a warning rather than taking the tool down.
62
62
 
63
+ Since pi 0.99 the bash tool also returns a machine-readable result — `structuredContent` — which codemode scripts and tools calling `ctx.executeTool("bash", …)` receive instead of the text. This package keeps pi's shape so such a script reads either tool unchanged: `output` (the whole log, up to 1 MiB, head and tail around an omission marker), `truncated`, `full_output_path`, `exit_code`, `wall_time_seconds` — plus `status` and `job_id`, because a command here can still be running when the tool returns; then `exit_code` is absent and `shell_status {id}` collects it, with the same structured result.
64
+
65
+ A backgrounded command runs with no one to answer it, so its environment says so: `GIT_TERMINAL_PROMPT=0` and `GIT_EDITOR`/`EDITOR`/`VISUAL=true` are forced (a `git commit` without `-m` fails with "empty message" instead of waiting on an editor that never comes, and an https `git fetch` fails instead of prompting for a password), and pagers default to `cat` unless you set them. `CI` and `TERM` are left alone on purpose — dev servers and watchers read them.
66
+
63
67
  The tail a result carries is cleaned the way pi's own bash cleans what the model sees — ANSI escapes, control characters and carriage-return progress frames stripped — so a chatty build or dev server does not spend tokens on colour codes and thousands of overwritten progress lines. The log file on disk keeps every byte.
64
68
 
65
69
  ## Coexistence with @pify/pretty
@@ -48,13 +48,21 @@ import { DEFAULT_SETTINGS, resolveSettings, type ShellBgSettings } from "../src/
48
48
  import { backgroundedResult, deliveryMessage, DELIVERY_TYPE } from "../src/pending.ts";
49
49
  import { formatResult, formatList, header } from "../src/format.ts";
50
50
  import { sanitizeOutput } from "../src/sanitize.ts";
51
+ import { readFull } from "../src/tail.ts";
52
+ import { STRUCTURED_OUTPUT_MAX_BYTES, bashOutputSchema, buildStructured, type BashStructured } from "../src/structured.ts";
51
53
  import { buildWidgetLines } from "../src/widget.ts";
52
54
  import { isFinished } from "../src/types.ts";
53
55
  import type { Job } from "../src/types.ts";
54
56
 
55
57
  type UiContext = ExtensionContext;
56
58
  type AnyTool = { name: string; execute: (...a: never[]) => unknown; [k: string]: unknown };
57
- type ToolResult = { content: Array<{ type: "text"; text: string }>; details: Record<string, unknown>; isError?: boolean };
59
+ type ToolResult = {
60
+ content: Array<{ type: "text"; text: string }>;
61
+ details: Record<string, unknown>;
62
+ /** pi 0.99: what codemode scripts and ctx.executeTool() callers receive instead of the text. */
63
+ structuredContent?: BashStructured;
64
+ isError?: boolean;
65
+ };
58
66
 
59
67
  const WIDGET = "shell-bg";
60
68
 
@@ -193,10 +201,16 @@ export default function shellBackground(pi: ExtensionAPI) {
193
201
  };
194
202
  }
195
203
 
204
+ /** The structured half of any result about a job: the whole log (bounded) plus status, for scripts. */
205
+ function structured(job: Job): BashStructured {
206
+ return buildStructured(job, readFull(job.logPath, STRUCTURED_OUTPUT_MAX_BYTES));
207
+ }
208
+
196
209
  function finished(job: Job): ToolResult {
197
210
  return {
198
211
  content: [{ type: "text", text: formatResult(job, settings.tailBytes) }],
199
212
  details: { id: job.id, status: job.status, exitCode: job.exitCode, signal: job.signal, background: false },
213
+ structuredContent: structured(job),
200
214
  isError: job.status === "failed",
201
215
  };
202
216
  }
@@ -314,7 +328,7 @@ export default function shellBackground(pi: ExtensionAPI) {
314
328
  interactive: ctx.hasUI,
315
329
  collectWith: "shell_status",
316
330
  });
317
- return { content: [{ type: "text", text: r.text }], details: r.details };
331
+ return { content: [{ type: "text", text: r.text }], details: r.details, structuredContent: structured(job) };
318
332
  }
319
333
 
320
334
  // Foreground: race the process against the auto-background threshold, an
@@ -387,7 +401,7 @@ export default function shellBackground(pi: ExtensionAPI) {
387
401
  interactive: ctx.hasUI,
388
402
  collectWith: "shell_status",
389
403
  });
390
- return { content: [{ type: "text", text: r.text }], details: r.details };
404
+ return { content: [{ type: "text", text: r.text }], details: r.details, structuredContent: structured(job) };
391
405
  }
392
406
 
393
407
  // timeout or abort: stop the tree, let the record settle, report partial.
@@ -411,6 +425,7 @@ export default function shellBackground(pi: ExtensionAPI) {
411
425
  },
412
426
  ],
413
427
  details: { id: job.id, status: job.status },
428
+ structuredContent: structured(job),
414
429
  isError: true,
415
430
  };
416
431
  }
@@ -421,6 +436,7 @@ export default function shellBackground(pi: ExtensionAPI) {
421
436
  return {
422
437
  content: [{ type: "text", text: formatResult(job, settings.tailBytes) + note }],
423
438
  details: { id: job.id, status: job.status },
439
+ structuredContent: structured(job),
424
440
  isError: true,
425
441
  };
426
442
  } finally {
@@ -466,6 +482,10 @@ export default function shellBackground(pi: ExtensionAPI) {
466
482
  ),
467
483
  }),
468
484
  promptGuidelines: guidelines,
485
+ // pi 0.99: scripts and ctx.executeTool() callers receive structuredContent
486
+ // instead of the text. Same fields as pi's bash, plus status/job_id;
487
+ // exit_code is absent while the command still runs in the background.
488
+ outputSchema: bashOutputSchema,
469
489
  // Let pi render the result with its default text renderer; keep the call
470
490
  // renderer (it only needs the command) if the original had one.
471
491
  renderResult: undefined,
@@ -514,6 +534,7 @@ export default function shellBackground(pi: ExtensionAPI) {
514
534
  return {
515
535
  content: [{ type: "text", text: formatResult(job, settings.tailBytes) }],
516
536
  details: { id: job.id, status: job.status, exitCode: job.exitCode },
537
+ structuredContent: structured(job),
517
538
  };
518
539
  },
519
540
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pify/shell-background",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Long-running bash goes async: background: true launches detached, and any command still running after 30s auto-backgrounds and delivers its result when it finishes",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -63,9 +63,9 @@
63
63
  }
64
64
  },
65
65
  "devDependencies": {
66
- "@earendil-works/pi-ai": "^0.87.0",
67
- "@earendil-works/pi-coding-agent": "^0.87.0",
68
- "@earendil-works/pi-tui": "^0.87.0",
66
+ "@earendil-works/pi-ai": "^0.99.0",
67
+ "@earendil-works/pi-coding-agent": "^0.99.0",
68
+ "@earendil-works/pi-tui": "^0.99.0",
69
69
  "@types/node": "^22.10.2",
70
70
  "typebox": "^1.1.38",
71
71
  "typescript": "^5.7.2"
package/src/env.ts CHANGED
@@ -17,6 +17,37 @@
17
17
  */
18
18
  import { delimiter } from "node:path";
19
19
 
20
+ /**
21
+ * Defaults that keep an unattended child from waiting on a human. FORCED —
22
+ * they win over the inherited value — because a background job has no one
23
+ * to answer: `git commit` without -m must not open $EDITOR, `git fetch` over
24
+ * https must not prompt for a password. With stdin ignored such a prompt
25
+ * either hangs forever or sits until the tree is killed, and while it waits
26
+ * it holds one of the maxBackground slots. (Set from oh-my-pi's
27
+ * non-interactive-env; the editor becomes `true`, which exits 0 with the
28
+ * message untouched, so git aborts with "empty commit message" instead.)
29
+ */
30
+ export const NON_INTERACTIVE_FORCED: Readonly<Record<string, string>> = {
31
+ GIT_TERMINAL_PROMPT: "0",
32
+ GIT_EDITOR: "true",
33
+ EDITOR: "true",
34
+ VISUAL: "true",
35
+ };
36
+
37
+ /**
38
+ * Formatting-only defaults, applied UNDER the inherited env so an explicit
39
+ * value still wins: a pager only ever changes how output is shown, and to a
40
+ * pipe it is dead weight. Deliberately NOT here: CI=true and TERM=dumb —
41
+ * this package also runs dev servers and watchers, and those flags make many
42
+ * of them drop watch mode, change ports or strip the output the user wants.
43
+ */
44
+ export const NON_INTERACTIVE_DEFAULTS: Readonly<Record<string, string>> = {
45
+ PAGER: "cat",
46
+ GIT_PAGER: "cat",
47
+ GH_PAGER: "cat",
48
+ LESS: "FRX",
49
+ };
50
+
20
51
  /** The slice of the pi ExtensionContext this needs, kept structural for tests. */
21
52
  export interface EnvCtx {
22
53
  sessionManager?: { getSessionId?(): string; getSessionFile?(): string | undefined };
@@ -25,16 +56,18 @@ export interface EnvCtx {
25
56
  }
26
57
 
27
58
  /**
28
- * Copy `base`, prepend `binDir` to PATH when absent, and set the PI_* session
29
- * variables from `ctx`. The ctx reads are wrapped so a stale ctx after a
30
- * `/reload` degrades to plain env rather than failing the spawn.
59
+ * Copy `base`, prepend `binDir` to PATH when absent, set the PI_* session
60
+ * variables from `ctx`, and layer the non-interactive defaults (formatting
61
+ * ones under the base, the anti-hang ones over it). The ctx reads are
62
+ * wrapped so a stale ctx after a `/reload` degrades to plain env rather than
63
+ * failing the spawn.
31
64
  */
32
65
  export function buildEnv(
33
66
  ctx: EnvCtx,
34
67
  binDir: string,
35
68
  base: NodeJS.ProcessEnv = process.env,
36
69
  ): NodeJS.ProcessEnv {
37
- const env: NodeJS.ProcessEnv = { ...base };
70
+ const env: NodeJS.ProcessEnv = { ...NON_INTERACTIVE_DEFAULTS, ...base, ...NON_INTERACTIVE_FORCED };
38
71
 
39
72
  // PATH is case-insensitive on Windows; find the real key so we do not create a
40
73
  // second, ignored "PATH" alongside an inherited "Path". Only prepend when the
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The machine-readable half of a result.
3
+ *
4
+ * pi 0.99's bash declares an `outputSchema` and returns `structuredContent`:
5
+ * the model still sees the text, but programmatic callers — codemode scripts,
6
+ * tools calling `ctx.executeTool("bash", …)` — receive the structured value
7
+ * (up to 1 MiB of output, not the model-facing tail). This package replaces
8
+ * that tool, so it has to speak the same shape or every script that reads
9
+ * `result.exit_code` breaks the moment it is installed. The fields pi's bash
10
+ * has are kept with the same names and meanings; `status` and `job_id` are
11
+ * added because a command here can still be running when the tool returns —
12
+ * in which case `exit_code` is absent, which is how a script tells.
13
+ */
14
+ import { Type, type Static } from "typebox";
15
+ import type { Job } from "./types.ts";
16
+ import type { FullOutput } from "./tail.ts";
17
+
18
+ export const STRUCTURED_OUTPUT_MAX_BYTES = 1024 * 1024;
19
+
20
+ export const bashOutputSchema = Type.Object({
21
+ output: Type.String({
22
+ description: "Combined stdout and stderr, up to 1 MiB. Longer output keeps its first and last 512 KiB around an omission marker.",
23
+ }),
24
+ truncated: Type.Boolean({ description: "Whether `output` omits part of the command output" }),
25
+ full_output_path: Type.Optional(Type.String({ description: "The log file with the complete output, when truncated" })),
26
+ exit_code: Type.Optional(Type.Number({ description: "Absent while the command is still running in the background" })),
27
+ wall_time_seconds: Type.Number(),
28
+ status: Type.Union([
29
+ Type.Literal("running"),
30
+ Type.Literal("done"),
31
+ Type.Literal("failed"),
32
+ Type.Literal("killed"),
33
+ Type.Literal("orphaned"),
34
+ ]),
35
+ job_id: Type.String({ description: "Pass to shell_status to collect or check a running command" }),
36
+ });
37
+
38
+ export type BashStructured = Static<typeof bashOutputSchema>;
39
+
40
+ /** Build the structured value for a job from its full log; `now` only matters while it runs. */
41
+ export function buildStructured(job: Job, full: FullOutput, now: number = Date.now()): BashStructured {
42
+ const end = job.endedAt ?? now;
43
+ return {
44
+ output: full.content,
45
+ truncated: full.truncated,
46
+ ...(full.truncated ? { full_output_path: job.logPath } : {}),
47
+ ...(job.status !== "running" && typeof job.exitCode === "number" ? { exit_code: job.exitCode } : {}),
48
+ wall_time_seconds: Math.round(Math.max(0, end - job.startedAt) / 100) / 10,
49
+ status: job.status,
50
+ job_id: job.id,
51
+ };
52
+ }
package/src/tail.ts CHANGED
@@ -58,6 +58,57 @@ export function readTail(path: string, maxBytes: number = DEFAULT_TAIL_BYTES): T
58
58
  }
59
59
  }
60
60
 
61
+ export interface FullOutput {
62
+ content: string;
63
+ /** True when `content` omits the middle of the log. */
64
+ truncated: boolean;
65
+ bytes: number;
66
+ }
67
+
68
+ /**
69
+ * The whole log, for callers that can take more than the model-facing tail
70
+ * (pi 0.99 hands a tool's `structuredContent` to codemode scripts). A log
71
+ * over `maxBytes` keeps its first and last `maxBytes / 2` bytes around an
72
+ * omission marker, cut at character boundaries — the same shape pi's own
73
+ * bash gives scripts, so a script written for it reads ours unchanged.
74
+ */
75
+ export function readFull(path: string, maxBytes: number): FullOutput {
76
+ let fd: number;
77
+ try {
78
+ fd = openSync(path, "r");
79
+ } catch {
80
+ return { content: "", truncated: false, bytes: 0 };
81
+ }
82
+ try {
83
+ const size = fstatSync(fd).size;
84
+ if (size === 0) return { content: "", truncated: false, bytes: 0 };
85
+ const readAt = (start: number, len: number): Buffer => {
86
+ const buf = Buffer.allocUnsafe(len);
87
+ let read = 0;
88
+ while (read < len) {
89
+ const n = readSync(fd, buf, read, len - read, start + read);
90
+ if (n <= 0) break;
91
+ read += n;
92
+ }
93
+ return buf.subarray(0, read);
94
+ };
95
+ if (size <= maxBytes) return { content: readAt(0, size).toString("utf8"), truncated: false, bytes: size };
96
+ const headBytes = Math.floor(maxBytes / 2);
97
+ const tailBytes = maxBytes - headBytes;
98
+ const head = new TextDecoder().decode(readAt(0, headBytes), { stream: true });
99
+ let tail = readAt(size - tailBytes, tailBytes);
100
+ let i = 0;
101
+ while (i < tail.length && (tail[i]! & 0xc0) === 0x80) i++;
102
+ tail = tail.subarray(i);
103
+ const omitted = size - headBytes - tailBytes;
104
+ return { content: `${head}\n\n[... ${omitted} bytes omitted ...]\n\n${tail.toString("utf8")}`, truncated: true, bytes: size };
105
+ } catch {
106
+ return { content: "", truncated: false, bytes: 0 };
107
+ } finally {
108
+ closeSync(fd);
109
+ }
110
+ }
111
+
61
112
  /** Non-empty line count of a chunk of text. */
62
113
  export function countLines(text: string): number {
63
114
  if (!text) return 0;