@aliou/pi-processes 0.9.3 → 0.9.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aliou/pi-processes",
3
- "version": "0.9.3",
3
+ "version": "0.9.5",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "private": false,
@@ -35,12 +35,13 @@
35
35
  "dependencies": {
36
36
  "@aliou/pi-utils-settings": "^0.15.1",
37
37
  "@aliou/pi-utils-ui": "^0.4.1",
38
- "@aliou/sh": "^0.1.0",
39
- "typebox": "^1.1.38"
38
+ "@aliou/sh": "^0.1.0"
40
39
  },
41
40
  "peerDependencies": {
42
- "@earendil-works/pi-coding-agent": ">0.70.0 <1",
43
- "@earendil-works/pi-tui": ">0.70.0 <1"
41
+ "@earendil-works/pi-ai": "*",
42
+ "@earendil-works/pi-coding-agent": "*",
43
+ "@earendil-works/pi-tui": "*",
44
+ "typebox": "*"
44
45
  },
45
46
  "devDependencies": {
46
47
  "@aliou/biome-plugins": "^0.8.1",
@@ -49,27 +50,32 @@
49
50
  "@types/node": "^25.9.1",
50
51
  "husky": "^9.1.7",
51
52
  "typescript": "^6.0.3",
52
- "vitest": "^4.1.7",
53
- "@earendil-works/pi-ai": "^0.75.3",
54
- "@earendil-works/pi-coding-agent": "^0.75.3",
55
- "@earendil-works/pi-tui": "^0.75.3"
56
- },
57
- "peerDependenciesMeta": {
58
- "@earendil-works/pi-coding-agent": {
59
- "optional": true
60
- },
61
- "@earendil-works/pi-tui": {
62
- "optional": true
63
- }
53
+ "vitest": "^4.1.7"
64
54
  },
65
55
  "scripts": {
66
56
  "typecheck": "tsc --noEmit",
67
57
  "lint": "biome check",
68
58
  "format": "biome check --write",
69
59
  "check:lockfile": "pnpm install --frozen-lockfile --ignore-scripts",
60
+ "prepare": "[ -d .git ] && husky || true",
70
61
  "changeset": "changeset",
71
62
  "version": "changeset version",
72
63
  "test": "vitest run",
73
64
  "release": "pnpm changeset publish"
65
+ },
66
+ "packageManager": "pnpm@10.26.1",
67
+ "peerDependenciesMeta": {
68
+ "@earendil-works/pi-ai": {
69
+ "optional": true
70
+ },
71
+ "@earendil-works/pi-coding-agent": {
72
+ "optional": true
73
+ },
74
+ "@earendil-works/pi-tui": {
75
+ "optional": true
76
+ },
77
+ "typebox": {
78
+ "optional": true
79
+ }
74
80
  }
75
- }
81
+ }
@@ -89,14 +89,27 @@ export interface StartOptions {
89
89
  logWatches?: LogWatch[];
90
90
  }
91
91
 
92
+ import type { TruncationDetails } from "../tools/actions/output-truncate";
93
+
92
94
  export interface ProcessesDetails {
93
95
  action: ProcessAction;
94
96
  success: boolean;
95
97
  message: string;
96
98
  process?: ProcessInfo;
97
99
  processes?: ProcessInfo[];
100
+ /**
101
+ * Legacy-only raw output arrays retained so historical session results can
102
+ * still render. New executions must never populate this field; the bounded
103
+ * preview lives in tool-result `content` and a truncation summary lives in
104
+ * `truncation`.
105
+ */
98
106
  output?: { stdout: string[]; stderr: string[]; status: string };
99
107
  logFiles?: { stdoutFile: string; stderrFile: string };
108
+ /**
109
+ * Metadata only (no raw output) describing how `content` was bounded.
110
+ * Present when the body exceeded the byte or line limits.
111
+ */
112
+ truncation?: TruncationDetails;
100
113
  cleared?: number;
101
114
  }
102
115
 
@@ -101,26 +101,33 @@ export function executeDebugPreview(params: DebugParams): ExecuteResult {
101
101
  }
102
102
 
103
103
  if (preview === "output") {
104
+ const message =
105
+ '"demo-server" (proc_42) [running]: 4 stdout lines, 2 stderr lines';
106
+ const content = [
107
+ message,
108
+ "stdout:",
109
+ "starting...",
110
+ "loading config",
111
+ "ready on http://localhost:3000",
112
+ "watching for changes",
113
+ "",
114
+ "stderr:",
115
+ "warn: deprecated option in config",
116
+ "error: simulated stack trace line",
117
+ "",
118
+ "Process is still running. Use watches instead of polling.",
119
+ "",
120
+ "[Complete currently-retained logs:",
121
+ "stdout=/tmp/pi-processes-demo/proc_42-stdout.log",
122
+ "stderr=/tmp/pi-processes-demo/proc_42-stderr.log]",
123
+ ].join("\n");
124
+
104
125
  return {
105
- content: [{ type: "text", text: "Debug preview: output" }],
126
+ content: [{ type: "text", text: content }],
106
127
  details: {
107
128
  action: "output",
108
129
  success: true,
109
- message:
110
- '"demo-server" (proc_42) [running]: 4 stdout lines, 2 stderr lines',
111
- output: {
112
- status: "running",
113
- stdout: [
114
- "starting...",
115
- "loading config",
116
- "ready on http://localhost:3000",
117
- "watching for changes",
118
- ],
119
- stderr: [
120
- "warn: deprecated option in config",
121
- "error: simulated stack trace line",
122
- ],
123
- },
130
+ message,
124
131
  logFiles: {
125
132
  stdoutFile: "/tmp/pi-processes-demo/proc_42-stdout.log",
126
133
  stderrFile: "/tmp/pi-processes-demo/proc_42-stderr.log",
@@ -30,6 +30,7 @@ interface ActionParams {
30
30
  id?: string;
31
31
  input?: string;
32
32
  end?: boolean;
33
+ cwd?: string;
33
34
  alertOnSuccess?: boolean;
34
35
  alertOnFailure?: boolean;
35
36
  alertOnKill?: boolean;
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Local tail-truncation for the `process output` action.
3
+ *
4
+ * The installed Pi version (0.75.x) does not export its shared truncation
5
+ * helper, so this module keeps a local implementation that mirrors the v0.10
6
+ * behavior the agent depends on.
7
+ *
8
+ * Invariants:
9
+ * - Always keeps the newest output within the byte and line budgets.
10
+ * - A single oversized line yields a UTF-8-safe suffix instead of being
11
+ * dropped entirely.
12
+ * - ANSI and terminal control characters must be stripped by callers before
13
+ * truncation; this module operates on already-cleaned text.
14
+ *
15
+ * The `truncation` metadata returned alongside the content matches the shape
16
+ * Pi 0.10 exposes through `TruncationResult`, minus the `content` field, so it
17
+ * can be persisted in `details` without re-embedding raw output.
18
+ */
19
+
20
+ export const MAX_OUTPUT_BYTES = 50 * 1024;
21
+
22
+ /**
23
+ * Ceiling applied to the JSON-escaped serialized content. Newline-heavy or
24
+ * tab-heavy output inflates under `JSON.stringify`, so the composed result is
25
+ * measured against this tighter budget in addition to the raw byte limit.
26
+ */
27
+ export const MAX_OUTPUT_JSON_BYTES = 96 * 1024;
28
+
29
+ export interface TruncationResult {
30
+ content: string;
31
+ truncated: boolean;
32
+ truncatedBy: "bytes" | "lines" | null;
33
+ lastLinePartial: boolean;
34
+ outputLines: number;
35
+ outputBytes: number;
36
+ totalLines: number;
37
+ totalBytes: number;
38
+ maxBytes: number;
39
+ maxLines: number;
40
+ }
41
+
42
+ /**
43
+ * Metadata persisted in tool-result `details`. Mirrors `TruncationResult`
44
+ * minus the `content` field, so the bounded preview stays in `content` only.
45
+ */
46
+ export type TruncationDetails = Omit<TruncationResult, "content">;
47
+
48
+ interface TruncateOptions {
49
+ maxBytes: number;
50
+ maxLines: number;
51
+ }
52
+
53
+ /**
54
+ * Truncate `text` from the tail, keeping the newest lines within the byte and
55
+ * line budgets. A single oversized line is sliced to a UTF-8-safe suffix
56
+ * rather than dropped entirely.
57
+ */
58
+ export function truncateTail(
59
+ text: string,
60
+ options: TruncateOptions,
61
+ ): TruncationResult {
62
+ const { maxBytes, maxLines } = options;
63
+ const totalBytes = Buffer.byteLength(text, "utf-8");
64
+ const lines = text.split("\n");
65
+ const totalLines = lines.length;
66
+
67
+ if (totalLines <= maxLines && totalBytes <= maxBytes) {
68
+ return {
69
+ content: text,
70
+ truncated: false,
71
+ truncatedBy: null,
72
+ lastLinePartial: false,
73
+ outputLines: totalLines,
74
+ outputBytes: totalBytes,
75
+ totalLines,
76
+ totalBytes,
77
+ maxBytes,
78
+ maxLines,
79
+ };
80
+ }
81
+
82
+ // Walk backwards, keeping whole lines that fit. Track whether the byte
83
+ // budget was the binding constraint so the notice wording is accurate.
84
+ const kept: string[] = [];
85
+ let keptBytes = 0;
86
+ let hitBytes = false;
87
+
88
+ for (let i = lines.length - 1; i >= 0 && kept.length < maxLines; i--) {
89
+ const line = lines[i] ?? "";
90
+ const lineBytes =
91
+ Buffer.byteLength(line, "utf-8") + (kept.length > 0 ? 1 : 0);
92
+
93
+ if (keptBytes + lineBytes > maxBytes) {
94
+ hitBytes = true;
95
+ break;
96
+ }
97
+
98
+ kept.unshift(line);
99
+ keptBytes += lineBytes;
100
+ }
101
+
102
+ let lastLinePartial = false;
103
+
104
+ if (kept.length === 0) {
105
+ // The newest line alone exceeds the byte budget. Keep a UTF-8-safe suffix
106
+ // instead of returning an empty body, so the agent still sees recent
107
+ // output. The single byte budget minus one byte for the skipped joiner.
108
+ const lastLine = lines[lines.length - 1] ?? "";
109
+ const suffix = utf8Suffix(lastLine, maxBytes - 1);
110
+ kept.push(suffix);
111
+ keptBytes = Buffer.byteLength(suffix, "utf-8");
112
+ lastLinePartial = true;
113
+ }
114
+
115
+ const content = kept.join("\n");
116
+
117
+ return {
118
+ content,
119
+ truncated: true,
120
+ truncatedBy: hitBytes ? "bytes" : "lines",
121
+ lastLinePartial,
122
+ outputLines: kept.length,
123
+ outputBytes: keptBytes,
124
+ totalLines,
125
+ totalBytes,
126
+ maxBytes,
127
+ maxLines,
128
+ };
129
+ }
130
+
131
+ /**
132
+ * Slice a UTF-8-safe suffix from `line` at most `maxBytes` long. The suffix
133
+ * keeps the newest content (the tail of the line) and is prefixed with an
134
+ * ellipsis marker. The cut point walks forward past any leading continuation
135
+ * bytes (0b10xxxxxx) so the result never starts mid-code-point.
136
+ */
137
+ function utf8Suffix(line: string, maxBytes: number): string {
138
+ const buffer = Buffer.from(line, "utf-8");
139
+ if (buffer.length <= maxBytes) return line;
140
+
141
+ const marker = "…";
142
+ const markerBytes = Buffer.byteLength(marker, "utf-8");
143
+ const budget = Math.max(0, maxBytes - markerBytes);
144
+ if (budget <= 0) return marker;
145
+
146
+ // Start the cut so the suffix is the last `budget` bytes of the line.
147
+ let start = buffer.length - budget;
148
+ // If the cut lands inside a multibyte code point, the first byte is a
149
+ // continuation byte (0b10xxxxxx). Walk forward to the next lead byte.
150
+ while (start < buffer.length && (buffer[start] ?? 0) >> 6 === 0b10) {
151
+ start++;
152
+ }
153
+ if (start >= buffer.length) return marker;
154
+
155
+ return `${marker}${buffer.subarray(start).toString("utf-8")}`;
156
+ }
157
+
158
+ export function formatSize(bytes: number): string {
159
+ if (bytes < 1024) return `${bytes}B`;
160
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
161
+ return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
162
+ }
163
+
164
+ /**
165
+ * Count logical lines, matching `text.split("\n").length`. Empty text has
166
+ * zero lines so the line budget is not consumed by a stray newline.
167
+ */
168
+ export function countLines(text: string): number {
169
+ if (text.length === 0) return 0;
170
+ return text.split("\n").length;
171
+ }
172
+
173
+ /**
174
+ * Compose a truncation notice string matching the agent-facing wording other
175
+ * tools use, so renderers can locate it reliably.
176
+ */
177
+ export function formatTruncationNotice(truncation: TruncationResult): string {
178
+ if (!truncation.truncated) return "";
179
+ const limit =
180
+ truncation.truncatedBy === "bytes"
181
+ ? `${formatSize(truncation.maxBytes)} byte limit`
182
+ : `${truncation.maxLines} line limit`;
183
+ const partialNote = truncation.lastLinePartial
184
+ ? " (final line is a partial suffix)"
185
+ : "";
186
+ return `[Preview truncated by ${limit}${partialNote}; showing ${truncation.outputLines} lines / ${formatSize(truncation.outputBytes)} of ${truncation.totalLines} lines / ${formatSize(truncation.totalBytes)}.]`;
187
+ }
@@ -0,0 +1,392 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { beforeAll, describe, expect, it, vi } from "vitest";
3
+ import { configLoader } from "../../config";
4
+ import type { ExecuteResult, ProcessInfo } from "../../constants";
5
+ import type { ProcessManager } from "../../manager";
6
+ import { executeOutput, renderOutputResult } from "./output";
7
+ import { MAX_OUTPUT_BYTES, truncateTail } from "./output-truncate";
8
+
9
+ const STDOUT_FILE = "/tmp/proc_1-stdout.log";
10
+ const STDERR_FILE = "/tmp/proc_1-stderr.log";
11
+
12
+ interface OutputSnapshot {
13
+ stdout: string[];
14
+ stderr: string[];
15
+ status: string;
16
+ }
17
+
18
+ function mockProcess(overrides: Partial<ProcessInfo> = {}): ProcessInfo {
19
+ return {
20
+ id: "proc_1",
21
+ name: "server",
22
+ pid: 1234,
23
+ command: "npm start",
24
+ cwd: "/project",
25
+ startTime: 1,
26
+ endTime: null,
27
+ status: "running",
28
+ exitCode: null,
29
+ success: null,
30
+ stdoutFile: STDOUT_FILE,
31
+ stderrFile: STDERR_FILE,
32
+ alertOnSuccess: false,
33
+ alertOnFailure: true,
34
+ alertOnKill: false,
35
+ ...overrides,
36
+ };
37
+ }
38
+
39
+ function mockManager(
40
+ output: OutputSnapshot | null,
41
+ processOverrides: Partial<ProcessInfo> = {},
42
+ ): ProcessManager {
43
+ const process = mockProcess(processOverrides);
44
+ return {
45
+ get: vi.fn().mockReturnValue(process),
46
+ getOutput: vi.fn().mockReturnValue(output),
47
+ getLogFiles: vi.fn().mockReturnValue({
48
+ stdoutFile: STDOUT_FILE,
49
+ stderrFile: STDERR_FILE,
50
+ combinedFile: "/tmp/proc_1-combined.log",
51
+ }),
52
+ } as unknown as ProcessManager;
53
+ }
54
+
55
+ function runOutput(output: OutputSnapshot): ExecuteResult {
56
+ return executeOutput({ id: "proc_1" }, mockManager(output));
57
+ }
58
+
59
+ beforeAll(async () => {
60
+ await configLoader.load();
61
+ });
62
+
63
+ function contentText(result: ExecuteResult): string {
64
+ const block = result.content[0];
65
+ return block && block.type === "text" ? block.text : "";
66
+ }
67
+
68
+ describe("executeOutput", () => {
69
+ it("formats normal stdout and stderr", () => {
70
+ const result = runOutput({
71
+ stdout: ["line 1", "line 2"],
72
+ stderr: ["err 1"],
73
+ status: "running",
74
+ });
75
+
76
+ const content = contentText(result);
77
+ expect(content).toContain("stdout:");
78
+ expect(content).toContain("line 1");
79
+ expect(content).toContain("line 2");
80
+ expect(content).toContain("stderr:");
81
+ expect(content).toContain("err 1");
82
+ expect(content).toContain("2 stdout lines, 1 stderr lines");
83
+ });
84
+
85
+ it("strips ANSI and terminal control characters before persistence", () => {
86
+ const result = runOutput({
87
+ stdout: ["\u001b[31mred\u001b[0m", "step 1\rstep 2\b done"],
88
+ stderr: [],
89
+ status: "exited",
90
+ });
91
+
92
+ const content = contentText(result);
93
+ expect(content).not.toContain("\u001b[31m");
94
+ expect(content).not.toContain("\r");
95
+ expect(content).not.toContain("\b");
96
+ expect(content).toContain("red");
97
+ expect(content).toContain("step 1step 2 done");
98
+ });
99
+
100
+ it("never persists stdout, stderr, or output arrays in new result details", () => {
101
+ const result = runOutput({
102
+ stdout: ["line 1"],
103
+ stderr: ["err 1"],
104
+ status: "running",
105
+ });
106
+
107
+ const { details } = result;
108
+ expect(details.output).toBeUndefined();
109
+ });
110
+
111
+ it("does not embed raw process output in serialized details", () => {
112
+ const result = runOutput({
113
+ stdout: ["SECRET-stdout-token"],
114
+ stderr: ["SECRET-stderr-token"],
115
+ status: "running",
116
+ });
117
+
118
+ const serialized = JSON.stringify(result.details);
119
+ expect(serialized).not.toContain("SECRET-stdout-token");
120
+ expect(serialized).not.toContain("SECRET-stderr-token");
121
+ // The raw-output property is absent from new result details.
122
+ expect(result.details.output).toBeUndefined();
123
+ expect("output" in JSON.parse(serialized)).toBe(false);
124
+ });
125
+
126
+ it("bounds a 2 MiB single line without growing the session entry", () => {
127
+ const huge = "x".repeat(2 * 1024 * 1024);
128
+ const result = runOutput({ stdout: [huge], stderr: [], status: "running" });
129
+
130
+ const serialized = JSON.stringify(result);
131
+ // Serialized result (content + details) stays below a fixed safe ceiling.
132
+ expect(Buffer.byteLength(serialized, "utf-8")).toBeLessThan(128 * 1024);
133
+ // The newest output still surfaces a partial suffix.
134
+ expect(contentText(result)).toContain("x");
135
+ expect(result.details.truncation?.truncated).toBe(true);
136
+ expect(result.details.truncation?.lastLinePartial).toBe(true);
137
+ });
138
+
139
+ it("handles CR-only progress output without session growth", () => {
140
+ // CR-only progress collapses into a single line carrying the final state.
141
+ const progress = `${Array.from({ length: 1000 }, (_, i) => `phase ${i}\r`).join("")}done`;
142
+ const result = runOutput({
143
+ stdout: [progress],
144
+ stderr: [],
145
+ status: "running",
146
+ });
147
+
148
+ const serialized = JSON.stringify(result);
149
+ expect(Buffer.byteLength(serialized, "utf-8")).toBeLessThan(128 * 1024);
150
+ expect(contentText(result)).not.toContain("\r");
151
+ expect(contentText(result)).toContain("done");
152
+ });
153
+
154
+ it("accounts for JSON-escaping expansion for a tab-heavy line", () => {
155
+ // Tabs survive stripAnsi but expand under JSON.stringify.
156
+ const tabHeavy = `${"\t".repeat(200_000)}tail-marker`;
157
+ const result = runOutput({
158
+ stdout: [tabHeavy],
159
+ stderr: [],
160
+ status: "running",
161
+ });
162
+
163
+ const serialized = JSON.stringify(result);
164
+ expect(Buffer.byteLength(serialized, "utf-8")).toBeLessThan(128 * 1024);
165
+ expect(contentText(result)).toContain("tail-marker");
166
+ });
167
+
168
+ it("keeps a UTF-8-safe suffix when an oversized multibyte line is truncated", () => {
169
+ // Use a 4-byte UTF-8 codepoint (U+1F680) so an arbitrary mid-byte cut
170
+ // would produce replacement characters. Keep enough multibyte content so
171
+ // the suffix definitely lands inside a code point run.
172
+ const emoji = "\u{1F680}".repeat(50_000); // ~200 KiB
173
+ const result = runOutput({
174
+ stdout: [emoji],
175
+ stderr: [],
176
+ status: "running",
177
+ });
178
+
179
+ expect(result.details.truncation?.lastLinePartial).toBe(true);
180
+ });
181
+
182
+ it("bounds combined large stdout and stderr", () => {
183
+ const stdout = Array.from({ length: 5000 }, (_, i) => `out ${i}`);
184
+ const stderr = Array.from({ length: 5000 }, (_, i) => `err ${i}`);
185
+ const result = runOutput({ stdout, stderr, status: "running" });
186
+
187
+ const serialized = JSON.stringify(result);
188
+ expect(Buffer.byteLength(serialized, "utf-8")).toBeLessThan(128 * 1024);
189
+ // Newest stderr lines are kept (they form the tail of the combined body).
190
+ expect(contentText(result)).toContain("err 4999");
191
+ });
192
+
193
+ it("keeps the final serialized tool result below a fixed safe ceiling", () => {
194
+ const stdout = Array.from({ length: 10_000 }, (_, i) => `line ${i}`);
195
+ const stderr = Array.from({ length: 10_000 }, (_, i) => `err ${i}`);
196
+ const result = runOutput({ stdout, stderr, status: "running" });
197
+
198
+ expect(Buffer.byteLength(JSON.stringify(result), "utf-8")).toBeLessThan(
199
+ 128 * 1024,
200
+ );
201
+ });
202
+
203
+ it("always retains complete log file paths in the textual result", () => {
204
+ const stdout = Array.from({ length: 10_000 }, (_, i) => `line ${i}`);
205
+ const result = runOutput({ stdout, stderr: [], status: "running" });
206
+
207
+ const content = contentText(result);
208
+ expect(content).toContain(STDOUT_FILE);
209
+ expect(content).toContain(STDERR_FILE);
210
+ });
211
+
212
+ it("returns failure details without log files when the process is missing", () => {
213
+ const manager = {
214
+ get: vi.fn().mockReturnValue(null),
215
+ getOutput: vi.fn(),
216
+ getLogFiles: vi.fn(),
217
+ } as unknown as ProcessManager;
218
+
219
+ const result = executeOutput({ id: "missing" }, manager);
220
+ expect(result.details.success).toBe(false);
221
+ expect(result.details.logFiles).toBeUndefined();
222
+ expect(result.details.output).toBeUndefined();
223
+ });
224
+
225
+ it("includes truncation metadata in details when content is truncated", () => {
226
+ const result = runOutput({
227
+ stdout: Array.from({ length: 5000 }, (_, i) => `line ${i}`),
228
+ stderr: [],
229
+ status: "running",
230
+ });
231
+
232
+ expect(result.details.truncation).toBeDefined();
233
+ expect(result.details.truncation?.truncated).toBe(true);
234
+ expect(result.details.truncation?.totalLines).toBeGreaterThan(
235
+ result.details.truncation?.outputLines ?? 0,
236
+ );
237
+ // Raw output never leaks through truncation metadata.
238
+ expect(JSON.stringify(result.details.truncation)).not.toContain(
239
+ "line 4999",
240
+ );
241
+ });
242
+ });
243
+
244
+ describe("truncateTail (hardening)", () => {
245
+ it("keeps the newest lines within the byte and line budgets", () => {
246
+ const text = Array.from({ length: 100 }, (_, i) => `line ${i}`).join("\n");
247
+ const result = truncateTail(text, {
248
+ maxBytes: MAX_OUTPUT_BYTES,
249
+ maxLines: 10,
250
+ });
251
+
252
+ expect(result.truncated).toBe(true);
253
+ expect(result.content).toContain("line 99");
254
+ expect(result.content).not.toContain("line 0");
255
+ expect(result.outputLines).toBeLessThanOrEqual(10);
256
+ });
257
+
258
+ it("returns a UTF-8-safe suffix from a single oversized line", () => {
259
+ const huge = "\u{1F680}".repeat(50_000);
260
+ const result = truncateTail(huge, { maxBytes: 1024, maxLines: 10 });
261
+
262
+ expect(result.truncated).toBe(true);
263
+ expect(result.lastLinePartial).toBe(true);
264
+ expect(result.content.length).toBeGreaterThan(0);
265
+ // No partial UTF-8 sequences reach the output.
266
+ expect(Buffer.from(result.content, "utf-8").toString("utf-8")).toBe(
267
+ result.content,
268
+ );
269
+ expect(result.content).not.toContain("\uFFFD");
270
+ });
271
+ });
272
+
273
+ // --- Renderer coverage ---
274
+
275
+ function mockTheme(): Theme {
276
+ return {
277
+ fg: (_color: string, text: string) => text,
278
+ bg: (_color: string, text: string) => text,
279
+ bold: (text: string) => text,
280
+ italic: (text: string) => text,
281
+ underline: (text: string) => text,
282
+ inverse: (text: string) => text,
283
+ strikethrough: (text: string) => text,
284
+ getFgAnsi: () => "",
285
+ getBgAnsi: () => "",
286
+ getColorMode: () => "truecolor",
287
+ getThinkingBorderColor: () => (text: string) => text,
288
+ getBashModeBorderColor: () => (text: string) => text,
289
+ } as unknown as Theme;
290
+ }
291
+
292
+ function render(
293
+ result: ExecuteResult,
294
+ options: { expanded?: boolean } = {},
295
+ ): string[] {
296
+ const body = renderOutputResult(
297
+ result as never,
298
+ { expanded: options.expanded ?? false } as never,
299
+ mockTheme(),
300
+ );
301
+ return body.render(120);
302
+ }
303
+
304
+ describe("renderOutputResult", () => {
305
+ it("renders expanded content-based output from tool-result content", () => {
306
+ const result = runOutput({
307
+ stdout: ["ready on http://localhost:3000"],
308
+ stderr: [],
309
+ status: "running",
310
+ });
311
+
312
+ const lines = render(result, { expanded: true });
313
+ const joined = lines.join("\n");
314
+
315
+ expect(joined).toContain("ready on http://localhost:3000");
316
+ expect(joined).toContain("Log files:");
317
+ expect(joined).toContain(STDOUT_FILE);
318
+ expect(joined).toContain(STDERR_FILE);
319
+ });
320
+
321
+ it("renders a collapsed preview from the bounded content", () => {
322
+ const result = runOutput({
323
+ stdout: ["first", "second", "third"],
324
+ stderr: [],
325
+ status: "running",
326
+ });
327
+
328
+ const lines = render(result, { expanded: false });
329
+ const joined = lines.join("\n");
330
+
331
+ expect(joined).toContain("third");
332
+ expect(joined).toContain("Output");
333
+ });
334
+
335
+ it("surfaces the truncation notice and log paths in expanded view", () => {
336
+ const result = runOutput({
337
+ stdout: Array.from({ length: 5000 }, (_, i) => `line ${i}`),
338
+ stderr: [],
339
+ status: "running",
340
+ });
341
+
342
+ const lines = render(result, { expanded: true });
343
+ const joined = lines.join("\n");
344
+
345
+ expect(result.details.truncation?.truncated).toBe(true);
346
+ expect(joined).toContain("Preview truncated");
347
+ expect(joined).toContain(STDOUT_FILE);
348
+ expect(joined).toContain(STDERR_FILE);
349
+ });
350
+
351
+ it("renders legacy session results that still carry details.output", () => {
352
+ const legacy = {
353
+ content: [{ type: "text" as const, text: "legacy content" }],
354
+ details: {
355
+ action: "output" as const,
356
+ success: true,
357
+ message: '"server" (proc_1) [running]: 1 stdout lines, 0 stderr lines',
358
+ output: {
359
+ stdout: ["legacy stdout line"],
360
+ stderr: [],
361
+ status: "running",
362
+ },
363
+ logFiles: {
364
+ stdoutFile: STDOUT_FILE,
365
+ stderrFile: STDERR_FILE,
366
+ },
367
+ },
368
+ };
369
+
370
+ const lines = render(legacy, { expanded: true });
371
+ const joined = lines.join("\n");
372
+
373
+ expect(joined).toContain("legacy stdout line");
374
+ expect(joined).toContain("Log files:");
375
+ expect(joined).toContain(STDOUT_FILE);
376
+ });
377
+
378
+ it("does not mutate result details during rendering", () => {
379
+ const result = runOutput({
380
+ stdout: ["line 1"],
381
+ stderr: ["err 1"],
382
+ status: "running",
383
+ });
384
+
385
+ const before = JSON.stringify(result.details);
386
+ render(result, { expanded: true });
387
+ render(result, { expanded: false });
388
+ const after = JSON.stringify(result.details);
389
+
390
+ expect(after).toBe(before);
391
+ });
392
+ });
@@ -9,13 +9,23 @@ import { configLoader } from "../../config";
9
9
  import type { ExecuteResult, ProcessesDetails } from "../../constants";
10
10
  import type { ProcessManager } from "../../manager";
11
11
  import { formatStatus, hasAnsi, stripAnsi } from "../../utils";
12
-
13
- const MAX_BYTES = 50 * 1024; // 50KB
12
+ import {
13
+ countLines,
14
+ formatTruncationNotice,
15
+ MAX_OUTPUT_BYTES,
16
+ MAX_OUTPUT_JSON_BYTES,
17
+ type TruncationDetails,
18
+ type TruncationResult,
19
+ truncateTail,
20
+ } from "./output-truncate";
14
21
 
15
22
  interface OutputParams {
16
23
  id?: string;
17
24
  }
18
25
 
26
+ /** Marker delimiting the always-present complete-log footer in content. */
27
+ const LOG_FOOTER_MARKER = "[Complete currently-retained logs:";
28
+
19
29
  export function renderOutputCall(
20
30
  args: OutputParams,
21
31
  theme: Theme,
@@ -35,28 +45,83 @@ export function renderOutputResult(
35
45
  options: ToolRenderResultOptions,
36
46
  theme: Theme,
37
47
  ): ToolBody {
38
- const { details } = result;
39
-
40
- if (!details.output) {
41
- return new ToolBody(
42
- {
43
- fields: [
44
- {
45
- label: "Error",
46
- value: "Missing output details",
47
- showCollapsed: true,
48
- },
49
- ],
50
- },
51
- options,
52
- theme,
48
+ const { details, content } = result;
49
+
50
+ const textBlock = Array.isArray(content)
51
+ ? content.find((block) => block.type === "text")
52
+ : undefined;
53
+ const contentText =
54
+ textBlock && textBlock.type === "text" ? textBlock.text : "";
55
+
56
+ // Legacy session results still carry raw stdout/stderr arrays in details.
57
+ // Render them so historical entries remain visible without errors.
58
+ if (details.output) {
59
+ return renderLegacyOutput(details, theme, options);
60
+ }
61
+
62
+ const bodyLines = extractOutputBody(contentText, details);
63
+ let hadAnsi = false;
64
+
65
+ const lines: string[] = [theme.fg("muted", details.message)];
66
+
67
+ if (bodyLines.length > 0) {
68
+ lines.push("");
69
+ for (const line of bodyLines) {
70
+ if (!hadAnsi && hasAnsi(line)) hadAnsi = true;
71
+ lines.push(line);
72
+ }
73
+ } else {
74
+ lines.push("", theme.fg("muted", "(no output)"));
75
+ }
76
+
77
+ if (details.truncation) {
78
+ lines.push(
79
+ "",
80
+ theme.fg("muted", buildTruncationSummary(details.truncation)),
81
+ );
82
+ }
83
+
84
+ if (details.logFiles) {
85
+ lines.push(
86
+ "",
87
+ theme.fg("success", "Log files:"),
88
+ ` stdout: ${theme.fg("accent", details.logFiles.stdoutFile)}`,
89
+ ` stderr: ${theme.fg("accent", details.logFiles.stderrFile)}`,
90
+ );
91
+ }
92
+
93
+ if (hadAnsi) {
94
+ lines.push(
95
+ "",
96
+ theme.fg("muted", "ANSI escape codes were stripped from output"),
53
97
  );
54
98
  }
55
99
 
100
+ const fields: Array<
101
+ { label: string; value: string; showCollapsed?: boolean } | Text
102
+ > = [new Text(lines.join("\n"), 0, 0)];
103
+
104
+ // Collapsed preview: the last couple of body lines.
105
+ const preview =
106
+ bodyLines.slice(-2).join("\n") || theme.fg("muted", "(empty)");
107
+ fields.push({
108
+ label: "Output",
109
+ value: theme.fg("muted", preview),
110
+ showCollapsed: true,
111
+ });
112
+
113
+ return new ToolBody({ fields }, options, theme);
114
+ }
115
+
116
+ function renderLegacyOutput(
117
+ details: ProcessesDetails,
118
+ theme: Theme,
119
+ options: ToolRenderResultOptions,
120
+ ): ToolBody {
56
121
  const lines: string[] = [theme.fg("muted", details.message)];
57
122
  let hadAnsi = false;
58
123
 
59
- if (details.output.stdout.length > 0) {
124
+ if (details.output?.stdout.length) {
60
125
  lines.push("", theme.fg("accent", "stdout:"));
61
126
  for (const line of details.output.stdout.slice(-20)) {
62
127
  if (!hadAnsi && hasAnsi(line)) hadAnsi = true;
@@ -72,7 +137,7 @@ export function renderOutputResult(
72
137
  }
73
138
  }
74
139
 
75
- if (details.output.stderr.length > 0) {
140
+ if (details.output?.stderr.length) {
76
141
  lines.push("", theme.fg("warning", "stderr:"));
77
142
  for (const line of details.output.stderr.slice(-10)) {
78
143
  if (!hadAnsi && hasAnsi(line)) hadAnsi = true;
@@ -104,30 +169,102 @@ export function renderOutputResult(
104
169
  );
105
170
  }
106
171
 
107
- const fields: Array<
108
- { label: string; value: string; showCollapsed?: boolean } | Text
109
- > = [new Text(lines.join("\n"), 0, 0)];
110
-
111
- // Collapsed summary
112
- const previewSource =
113
- details.output.stdout.length > 0
114
- ? details.output.stdout
115
- : details.output.stderr;
172
+ const previewSource = details.output?.stdout.length
173
+ ? details.output.stdout
174
+ : (details.output?.stderr ?? []);
116
175
  const preview = previewSource
117
176
  .slice(-2)
118
177
  .map((l) => stripAnsi(l))
119
178
  .join("\n");
120
- fields.push({
121
- label: "Output",
122
- value: preview
123
- ? `${theme.fg("muted", preview)}`
124
- : theme.fg("muted", "(empty)"),
125
- showCollapsed: true,
126
- });
179
+
180
+ const fields: Array<
181
+ { label: string; value: string; showCollapsed?: boolean } | Text
182
+ > = [
183
+ new Text(lines.join("\n"), 0, 0),
184
+ {
185
+ label: "Output",
186
+ value: preview
187
+ ? theme.fg("muted", preview)
188
+ : theme.fg("muted", "(empty)"),
189
+ showCollapsed: true,
190
+ },
191
+ ];
127
192
 
128
193
  return new ToolBody({ fields }, options, theme);
129
194
  }
130
195
 
196
+ /**
197
+ * Extract the bounded process-output body from tool-result content text.
198
+ *
199
+ * Content is structured as:
200
+ * - a one-line header (`details.message`);
201
+ * - the bounded output body (already ANSI-stripped);
202
+ * - an optional truncation notice line;
203
+ * - an always-present complete-log footer.
204
+ *
205
+ * Only the body is returned. The renderer uses `details` for metadata,
206
+ * truncation state, and log paths; this function never re-parses stream
207
+ * labels, so a real log line containing `stderr:` cannot confuse it.
208
+ *
209
+ * Legacy content that lost its header through tail truncation is accepted: if
210
+ * the first line does not match the expected header, the whole content is
211
+ * treated as body up to the footer.
212
+ */
213
+ function extractOutputBody(
214
+ contentText: string,
215
+ details: ProcessesDetails,
216
+ ): string[] {
217
+ if (!contentText) return [];
218
+
219
+ const lines = contentText.split("\n");
220
+ const header = details.message;
221
+
222
+ let bodyStart = lines[0] === header ? 1 : 0;
223
+ // Skip the blank separator following the header.
224
+ if (bodyStart > 0 && lines[bodyStart] === "") {
225
+ bodyStart++;
226
+ }
227
+
228
+ const footerStart = findLastLineIndex(
229
+ lines,
230
+ (line) => line === LOG_FOOTER_MARKER,
231
+ );
232
+ let bodyEnd = footerStart >= 0 ? footerStart : lines.length;
233
+
234
+ // Exclude the running-guidance line, which sits between the body and the
235
+ // footer/notice. It is metadata, not process output.
236
+ const guidance = "Process is still running. Use watches instead of polling.";
237
+ const guidanceIndex = findLastLineIndex(
238
+ lines,
239
+ (line, index) => index < bodyEnd && line === guidance,
240
+ );
241
+ if (guidanceIndex >= bodyStart) {
242
+ bodyEnd = guidanceIndex;
243
+ }
244
+
245
+ // Exclude a preceding blank line that separated body from the footer/notice.
246
+ while (bodyEnd > bodyStart && (lines[bodyEnd - 1] ?? "") === "") {
247
+ bodyEnd--;
248
+ }
249
+
250
+ return lines.slice(bodyStart, bodyEnd);
251
+ }
252
+
253
+ function buildTruncationSummary(truncation: TruncationDetails): string {
254
+ const partialNote = truncation.lastLinePartial ? " · partial final line" : "";
255
+ return `Preview truncated · ${truncation.outputLines}/${truncation.totalLines} lines${partialNote}`;
256
+ }
257
+
258
+ function findLastLineIndex(
259
+ lines: string[],
260
+ predicate: (line: string, index: number) => boolean,
261
+ ): number {
262
+ for (let i = lines.length - 1; i >= 0; i--) {
263
+ if (predicate(lines[i] ?? "", i)) return i;
264
+ }
265
+ return -1;
266
+ }
267
+
131
268
  export function executeOutput(
132
269
  params: OutputParams,
133
270
  manager: ProcessManager,
@@ -156,7 +293,7 @@ export function executeOutput(
156
293
  };
157
294
  }
158
295
 
159
- const { defaultTailLines } = configLoader.getConfig().output;
296
+ const { defaultTailLines, maxOutputLines } = configLoader.getConfig().output;
160
297
  const output = manager.getOutput(proc.id, defaultTailLines);
161
298
  if (!output) {
162
299
  const message = `Could not read output for "${proc.name}" (${proc.id})`;
@@ -175,95 +312,136 @@ export function executeOutput(
175
312
  const stderrLines = output.stderr.length;
176
313
  const message = `"${proc.name}" (${proc.id}) [${formatStatus(proc)}]: ${stdoutLines} stdout lines, ${stderrLines} stderr lines`;
177
314
 
178
- // Build the full text content (ANSI-stripped), then truncate from the tail
179
- // like bash does, so the agent sees the most recent output.
180
- const outputParts: string[] = [message];
315
+ // Build the stripped body text. stdout/stderr stay local and are never
316
+ // persisted in `details`; only the bounded preview survives in `content`.
317
+ const bodyLines: string[] = [];
181
318
  if (output.stdout.length > 0) {
182
- outputParts.push("\nstdout:");
183
- outputParts.push(...output.stdout.map(stripAnsi));
319
+ bodyLines.push("stdout:");
320
+ bodyLines.push(...output.stdout.map(stripAnsi));
184
321
  }
185
322
  if (output.stderr.length > 0) {
186
- outputParts.push("\nstderr:");
187
- outputParts.push(...output.stderr.map(stripAnsi));
323
+ if (bodyLines.length > 0) bodyLines.push("");
324
+ bodyLines.push("stderr:");
325
+ bodyLines.push(...output.stderr.map(stripAnsi));
188
326
  }
189
327
 
190
- const fullText = outputParts.join("\n");
191
- const { maxOutputLines } = configLoader.getConfig().output;
192
- const contentText = truncateTail(fullText, logFiles, maxOutputLines);
328
+ const guidance =
329
+ output.status === "running"
330
+ ? "Process is still running. Use watches instead of polling."
331
+ : null;
332
+
333
+ const { contentText, truncation } = buildBoundedOutput(
334
+ message,
335
+ bodyLines.join("\n"),
336
+ guidance,
337
+ logFiles,
338
+ maxOutputLines,
339
+ );
340
+
341
+ const details: ProcessesDetails = {
342
+ action: "output",
343
+ success: true,
344
+ message,
345
+ logFiles: logFiles
346
+ ? {
347
+ stdoutFile: logFiles.stdoutFile,
348
+ stderrFile: logFiles.stderrFile,
349
+ }
350
+ : undefined,
351
+ };
352
+
353
+ if (truncation.truncated) {
354
+ const { content: _content, ...rest } = truncation;
355
+ details.truncation = rest;
356
+ }
193
357
 
194
358
  return {
195
359
  content: [{ type: "text", text: contentText }],
196
- details: {
197
- action: "output",
198
- success: true,
199
- message,
200
- output,
201
- logFiles: logFiles
202
- ? {
203
- stdoutFile: logFiles.stdoutFile,
204
- stderrFile: logFiles.stderrFile,
205
- }
206
- : undefined,
207
- },
360
+ details,
208
361
  };
209
362
  }
210
363
 
211
364
  /**
212
- * Truncate text from the tail (keep last N lines / MAX_BYTES), matching
213
- * the behaviour of pi's built-in bash tool. When truncated, appends a
214
- * notice pointing the agent to the full log files.
365
+ * Compose the bounded content text. The header, optional guidance, truncation
366
+ * notice, and complete-log footer live outside the truncation window, so the
367
+ * agent always receives log paths even when the body is truncated.
368
+ *
369
+ * The byte and line budgets apply to the composed+JSON-escaped result. If the
370
+ * first pass overflows (because JSON escaping expands control characters or
371
+ * the fixed metadata consumes the budget), the body budget is shrunk and the
372
+ * body re-truncated while keeping the newest output.
215
373
  */
216
- function truncateTail(
217
- text: string,
374
+ function buildBoundedOutput(
375
+ header: string,
376
+ body: string,
377
+ guidance: string | null,
218
378
  logFiles: { stdoutFile: string; stderrFile: string } | null,
219
379
  maxLines: number,
220
- ): string {
221
- const totalBytes = Buffer.byteLength(text, "utf-8");
222
- const lines = text.split("\n");
223
- const totalLines = lines.length;
224
-
225
- if (totalLines <= maxLines && totalBytes <= MAX_BYTES) {
226
- return text;
227
- }
228
-
229
- // Work backwards, collecting lines that fit
230
- const kept: string[] = [];
231
- let keptBytes = 0;
232
- let hitBytes = false;
380
+ ): { contentText: string; truncation: TruncationResult } {
381
+ let maxBodyBytes = MAX_OUTPUT_BYTES;
382
+ let maxBodyLines = maxLines;
233
383
 
234
- for (let i = lines.length - 1; i >= 0 && kept.length < maxLines; i--) {
235
- const line = lines[i] ?? "";
236
- const lineBytes =
237
- Buffer.byteLength(line, "utf-8") + (kept.length > 0 ? 1 : 0);
384
+ let truncation = truncateTail(body, {
385
+ maxBytes: maxBodyBytes,
386
+ maxLines: maxBodyLines,
387
+ });
388
+ let contentText = composeContent(header, truncation, guidance, logFiles);
389
+
390
+ for (let attempt = 0; attempt < 10; attempt++) {
391
+ const excessBytes =
392
+ Buffer.byteLength(contentText, "utf-8") - MAX_OUTPUT_BYTES;
393
+ const excessLines = countLines(contentText) - maxLines;
394
+ const excessJsonBytes =
395
+ Buffer.byteLength(JSON.stringify(contentText), "utf-8") -
396
+ MAX_OUTPUT_JSON_BYTES;
397
+
398
+ if (excessBytes <= 0 && excessLines <= 0 && excessJsonBytes <= 0) {
399
+ return { contentText, truncation };
400
+ }
238
401
 
239
- if (keptBytes + lineBytes > MAX_BYTES) {
240
- hitBytes = true;
241
- break;
402
+ maxBodyBytes = Math.max(0, maxBodyBytes - Math.max(0, excessBytes));
403
+ if (excessJsonBytes > 0) {
404
+ maxBodyBytes = Math.floor(maxBodyBytes / 2);
242
405
  }
406
+ maxBodyLines = Math.max(1, maxBodyLines - Math.max(0, excessLines));
243
407
 
244
- kept.unshift(line);
245
- keptBytes += lineBytes;
408
+ truncation = truncateTail(body, {
409
+ maxBytes: maxBodyBytes,
410
+ maxLines: maxBodyLines,
411
+ });
412
+ contentText = composeContent(header, truncation, guidance, logFiles);
246
413
  }
247
414
 
248
- let result = kept.join("\n");
415
+ // Pathological metadata can still consume the whole budget. Bound the final
416
+ // composed string so a session entry cannot grow unbounded.
417
+ const finalTruncation = truncateTail(contentText, {
418
+ maxBytes: MAX_OUTPUT_BYTES,
419
+ maxLines: maxLines,
420
+ });
421
+ return { contentText: finalTruncation.content, truncation };
422
+ }
249
423
 
250
- // Append a notice so the agent knows output was truncated
251
- const shownLines = kept.length;
252
- const startLine = totalLines - shownLines + 1;
253
- const sizeNote = hitBytes ? ` (${formatSize(MAX_BYTES)} limit)` : "";
254
- result += `\n\n[Showing lines ${startLine}-${totalLines} of ${totalLines}${sizeNote}.`;
424
+ function composeContent(
425
+ header: string,
426
+ truncation: TruncationResult,
427
+ guidance: string | null,
428
+ logFiles: { stdoutFile: string; stderrFile: string } | null,
429
+ ): string {
430
+ const sections: string[] = [header, truncation.content];
255
431
 
256
- if (logFiles) {
257
- result += ` Full logs: ${logFiles.stdoutFile} , ${logFiles.stderrFile}`;
432
+ if (guidance) {
433
+ sections.push(guidance);
258
434
  }
259
435
 
260
- result += "]";
436
+ if (truncation.truncated) {
437
+ sections.push(formatTruncationNotice(truncation));
438
+ }
261
439
 
262
- return result;
263
- }
440
+ if (logFiles) {
441
+ sections.push(
442
+ `${LOG_FOOTER_MARKER}\nstdout=${logFiles.stdoutFile}\nstderr=${logFiles.stderrFile}]`,
443
+ );
444
+ }
264
445
 
265
- function formatSize(bytes: number): string {
266
- if (bytes < 1024) return `${bytes}B`;
267
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
268
- return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
446
+ return sections.filter((section) => section.length > 0).join("\n\n");
269
447
  }
@@ -20,6 +20,7 @@ interface StartLogWatch {
20
20
  interface StartParams {
21
21
  name?: string;
22
22
  command?: string;
23
+ cwd?: string;
23
24
  alertOnSuccess?: boolean;
24
25
  alertOnFailure?: boolean;
25
26
  alertOnKill?: boolean;
@@ -160,7 +161,7 @@ export function executeStart(
160
161
 
161
162
  let proc: ReturnType<ProcessManager["start"]>;
162
163
  try {
163
- proc = manager.start(params.name, params.command, ctx.cwd, {
164
+ proc = manager.start(params.name, params.command, params.cwd ?? ctx.cwd, {
164
165
  alertOnSuccess: params.alertOnSuccess,
165
166
  alertOnFailure: params.alertOnFailure,
166
167
  alertOnKill: params.alertOnKill,
@@ -81,6 +81,12 @@ const ProcessesParams = Type.Object({
81
81
  "For action=debug_preview only: which rendered result variant to preview (default: start)",
82
82
  }),
83
83
  ),
84
+ cwd: Type.Optional(
85
+ Type.String({
86
+ description:
87
+ "Working directory for the command (for start action). Defaults to the session working directory. Prefer this over 'cd dir && command' shell wrappers.",
88
+ }),
89
+ ),
84
90
  logWatches: Type.Optional(
85
91
  Type.Array(
86
92
  Type.Object(
@@ -116,6 +122,7 @@ export function setupProcessesTools(pi: ExtensionAPI, manager: ProcessManager) {
116
122
  label: "Process",
117
123
  description: `Manage background processes. Actions:
118
124
  - start: Run command in background (requires 'name' and 'command')
125
+ - cwd (optional): Working directory for the command. Defaults to the session working directory. Use the cwd parameter instead of 'cd dir && command' shell wrappers.
119
126
  - alertOnSuccess (default: false): Get a turn to react when process completes successfully
120
127
  - alertOnFailure (default: true): Get a turn to react when process crashes/fails
121
128
  - alertOnKill (default: false): Get a turn to react if killed by external signal (killing via tool never triggers a turn)