@aliou/pi-processes 0.9.4 → 0.10.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 (171) hide show
  1. package/CONTRIBUTING.md +40 -42
  2. package/README.md +54 -84
  3. package/extensions/process-tabs.ts +26 -0
  4. package/extensions/processes/client.ts +194 -0
  5. package/extensions/processes/commands/clear.ts +29 -0
  6. package/extensions/processes/commands/kill.ts +152 -0
  7. package/extensions/processes/commands/overview.ts +102 -0
  8. package/extensions/processes/components/overview-component.ts +729 -0
  9. package/extensions/processes/components/overview-panel.ts +177 -0
  10. package/extensions/processes/config/defaults.ts +28 -0
  11. package/extensions/processes/config/index.ts +13 -0
  12. package/extensions/processes/config/loader.ts +62 -0
  13. package/extensions/processes/config/migrations/000-import-legacy-process-config.ts +113 -0
  14. package/extensions/processes/config/migrations/001-v0-9-4-to-v0-10-0-config.ts +184 -0
  15. package/extensions/processes/config/migrations/002-stamp-v0-10-0-config-version.ts +21 -0
  16. package/extensions/processes/config/migrations/index.ts +25 -0
  17. package/extensions/processes/config/schema.ts +9 -0
  18. package/extensions/processes/config/types.ts +72 -0
  19. package/extensions/processes/constants.ts +1 -0
  20. package/extensions/processes/handlers/commands.ts +113 -0
  21. package/extensions/processes/handlers/kill-process.ts +31 -0
  22. package/extensions/processes/handlers/notifications.ts +147 -0
  23. package/extensions/processes/handlers/requests.ts +114 -0
  24. package/extensions/processes/handlers/subscriptions.ts +144 -0
  25. package/extensions/processes/hooks/background-blocker.ts +201 -0
  26. package/extensions/processes/hooks/cleanup.ts +43 -0
  27. package/extensions/processes/hooks/event-bridge.ts +32 -0
  28. package/extensions/processes/i18n/index.ts +3 -0
  29. package/extensions/processes/i18n/messages.ts +27 -0
  30. package/extensions/processes/i18n/translator.ts +49 -0
  31. package/extensions/processes/index.ts +109 -0
  32. package/extensions/processes/message-renderer.ts +117 -0
  33. package/extensions/processes/notification-sender.ts +43 -0
  34. package/extensions/processes/notifications/classify.ts +27 -0
  35. package/extensions/processes/notifications/log-matchers.ts +146 -0
  36. package/extensions/processes/notifications/registry.ts +225 -0
  37. package/extensions/processes/notifications/render-content.ts +122 -0
  38. package/extensions/processes/notifications/service.ts +336 -0
  39. package/extensions/processes/notifications/types.ts +11 -0
  40. package/extensions/processes/settings/apply-setting-change.ts +30 -0
  41. package/extensions/processes/settings/build-sections.ts +420 -0
  42. package/extensions/processes/settings/index.ts +42 -0
  43. package/extensions/processes/tools/clear/index.ts +21 -0
  44. package/extensions/processes/tools/clear/render.ts +40 -0
  45. package/extensions/processes/tools/components/index.ts +4 -0
  46. package/extensions/processes/tools/components/process-action-header.ts +31 -0
  47. package/extensions/processes/tools/components/process-action-title.ts +17 -0
  48. package/extensions/processes/tools/components/tool-layout.ts +70 -0
  49. package/extensions/processes/tools/components/watch.ts +53 -0
  50. package/extensions/processes/tools/index.ts +265 -0
  51. package/extensions/processes/tools/list/index.ts +157 -0
  52. package/extensions/processes/tools/list/render.ts +211 -0
  53. package/extensions/processes/tools/notify.ts +191 -0
  54. package/extensions/processes/tools/output/index.ts +307 -0
  55. package/extensions/processes/tools/output/render.ts +229 -0
  56. package/extensions/processes/tools/schema.ts +263 -0
  57. package/extensions/processes/tools/start/index.ts +64 -0
  58. package/extensions/processes/tools/start/render.ts +67 -0
  59. package/extensions/processes/tools/stop/index.ts +35 -0
  60. package/extensions/processes/tools/stop/render.ts +43 -0
  61. package/extensions/processes/tools/update/index.ts +320 -0
  62. package/extensions/processes/tools/update/render.ts +251 -0
  63. package/extensions/processes/tools/utils.ts +112 -0
  64. package/extensions/processes/tools/watch-format.ts +14 -0
  65. package/extensions/processes/tools/write/index.ts +75 -0
  66. package/extensions/processes/tools/write/render.ts +68 -0
  67. package/extensions/processes/utils/truncate.ts +285 -0
  68. package/extensions/processes-dock/client.ts +60 -0
  69. package/extensions/processes-dock/commands/dock.ts +70 -0
  70. package/extensions/processes-dock/commands/pin.ts +166 -0
  71. package/extensions/processes-dock/components/log-dock-component.ts +375 -0
  72. package/extensions/processes-dock/dock-state.ts +33 -0
  73. package/extensions/processes-dock/index.ts +28 -0
  74. package/extensions/processes-dock/logs-client.ts +5 -0
  75. package/extensions/processes-dock/widget/setup.ts +520 -0
  76. package/extensions/processes-dock/widget/status.ts +149 -0
  77. package/extensions/processes-dock/widget/types.ts +16 -0
  78. package/extensions/processes-logs/client.ts +51 -0
  79. package/extensions/processes-logs/commands/logs.ts +108 -0
  80. package/extensions/processes-logs/completions.ts +31 -0
  81. package/extensions/processes-logs/components/log-file-viewer.ts +274 -0
  82. package/extensions/processes-logs/components/log-overlay-component.ts +685 -0
  83. package/extensions/processes-logs/index.ts +27 -0
  84. package/extensions/processes-logs/logs-client.ts +101 -0
  85. package/extensions/shared/line-buffer.ts +35 -0
  86. package/extensions/shared/output-payload.ts +28 -0
  87. package/extensions/shared/ui.ts +146 -0
  88. package/package.json +47 -20
  89. package/schema.json +134 -0
  90. package/skills/pi-processes/SKILL.md +496 -25
  91. package/src/get-manager.ts +15 -0
  92. package/src/manager/index.ts +182 -0
  93. package/src/manager/internal-types.ts +130 -0
  94. package/src/manager/limits.ts +15 -0
  95. package/src/manager/process-log-store.ts +422 -0
  96. package/src/manager/process-output.ts +345 -0
  97. package/src/manager/process-registry.ts +78 -0
  98. package/src/manager/process-runtime-controller.ts +499 -0
  99. package/src/protocol/broadcasts.ts +14 -0
  100. package/src/protocol/channels.ts +30 -0
  101. package/src/protocol/commands.ts +24 -0
  102. package/src/protocol/index.ts +34 -0
  103. package/src/protocol/logs.ts +23 -0
  104. package/src/protocol/notifications.ts +48 -0
  105. package/src/protocol/requests.ts +76 -0
  106. package/src/types.ts +66 -0
  107. package/src/utils/ansi.ts +2 -4
  108. package/src/utils/format.ts +24 -32
  109. package/src/utils/index.ts +5 -1
  110. package/src/utils/is-record.ts +3 -0
  111. package/src/utils/match-line.ts +32 -0
  112. package/src/utils/platform.ts +13 -0
  113. package/src/utils/shorten-path.ts +11 -0
  114. package/src/utils/signals.ts +22 -0
  115. package/src/commands/clear/command.ts +0 -14
  116. package/src/commands/clear/index.ts +0 -1
  117. package/src/commands/completions.ts +0 -38
  118. package/src/commands/dock/command.ts +0 -29
  119. package/src/commands/dock/index.ts +0 -1
  120. package/src/commands/index.ts +0 -33
  121. package/src/commands/kill/command.ts +0 -69
  122. package/src/commands/kill/index.ts +0 -1
  123. package/src/commands/logs/command.ts +0 -47
  124. package/src/commands/logs/index.ts +0 -1
  125. package/src/commands/pick-process.ts +0 -34
  126. package/src/commands/pin/command.ts +0 -34
  127. package/src/commands/pin/index.ts +0 -1
  128. package/src/commands/processes/command.ts +0 -39
  129. package/src/commands/processes/index.ts +0 -1
  130. package/src/commands/settings/apply-setting-change.ts +0 -72
  131. package/src/commands/settings/build-sections.ts +0 -160
  132. package/src/commands/settings/command.ts +0 -20
  133. package/src/commands/settings/index.ts +0 -1
  134. package/src/components/log-dock-component.test.ts +0 -29
  135. package/src/components/log-dock-component.ts +0 -236
  136. package/src/components/log-file-viewer.ts +0 -317
  137. package/src/components/log-overlay-component.ts +0 -546
  138. package/src/components/panel-helpers.ts +0 -37
  139. package/src/components/process-picker-component.ts +0 -166
  140. package/src/components/processes-component.ts +0 -485
  141. package/src/components/status-format.ts +0 -38
  142. package/src/config.ts +0 -107
  143. package/src/constants/index.ts +0 -16
  144. package/src/constants/types.ts +0 -106
  145. package/src/hooks/background-blocker.ts +0 -66
  146. package/src/hooks/cleanup.ts +0 -10
  147. package/src/hooks/index.ts +0 -32
  148. package/src/hooks/message-renderer.ts +0 -115
  149. package/src/hooks/process-end.ts +0 -69
  150. package/src/hooks/process-watch.ts +0 -85
  151. package/src/hooks/utils.ts +0 -23
  152. package/src/hooks/widget/index.ts +0 -2
  153. package/src/hooks/widget/setup.ts +0 -167
  154. package/src/hooks/widget/status-widget.ts +0 -105
  155. package/src/hooks/widget/types.ts +0 -20
  156. package/src/index.ts +0 -35
  157. package/src/manager.test.ts +0 -331
  158. package/src/manager.ts +0 -758
  159. package/src/tools/actions/clear.ts +0 -20
  160. package/src/tools/actions/debug.ts +0 -148
  161. package/src/tools/actions/index.ts +0 -171
  162. package/src/tools/actions/kill.ts +0 -89
  163. package/src/tools/actions/list.ts +0 -188
  164. package/src/tools/actions/logs.ts +0 -117
  165. package/src/tools/actions/output.ts +0 -269
  166. package/src/tools/actions/start.ts +0 -245
  167. package/src/tools/actions/write.ts +0 -113
  168. package/src/tools/index.ts +0 -207
  169. package/src/utils/ansi.test.ts +0 -22
  170. package/src/utils/command-executor.test.ts +0 -48
  171. package/src/utils/keybindings.ts +0 -71
@@ -0,0 +1,191 @@
1
+ import { isRecord } from "../../../src/utils/is-record";
2
+ import {
3
+ MAX_LOG_MATCH_PATTERN_LENGTH,
4
+ MAX_LOG_MATCHERS_PER_PROCESS,
5
+ } from "../notifications/log-matchers";
6
+ import type { LogMatcherConfig, NotifyConfig } from "../notifications/registry";
7
+ import type { Attention } from "../notifications/types";
8
+
9
+ const ATTENTIONS = ["turn", "context", "ignore"] as const;
10
+ const LOG_MATCH_MODES = ["literal", "regex"] as const;
11
+ const LOG_MATCH_STREAMS = ["stdout", "stderr", "both"] as const;
12
+
13
+ export const MAX_NOTIFY_LOG_MATCHERS = MAX_LOG_MATCHERS_PER_PROCESS;
14
+ export const MAX_NOTIFY_PATTERN_LENGTH = MAX_LOG_MATCH_PATTERN_LENGTH;
15
+
16
+ const DEFAULT_NOTIFY_CONFIG = {
17
+ onSuccess: "context",
18
+ onFailure: "turn",
19
+ // External kills (outside this manager) surface as context by default so
20
+ // the agent and user learn that a managed process disappeared. Intentional
21
+ // stops via the tool/command path are classified separately and never reach
22
+ // this branch.
23
+ onKilled: "context",
24
+ } as const satisfies Pick<NotifyConfig, "onSuccess" | "onFailure" | "onKilled">;
25
+
26
+ export function normalizeNotifyConfig(input: unknown): NotifyConfig {
27
+ if (input === undefined || input === null) {
28
+ return { ...DEFAULT_NOTIFY_CONFIG, logMatches: [] };
29
+ }
30
+
31
+ if (!isRecord(input)) {
32
+ throw new Error("process start notify must be an object");
33
+ }
34
+
35
+ const logMatches = input.logMatches;
36
+
37
+ return {
38
+ onSuccess:
39
+ normalizeAttention(input.onSuccess, "notify.onSuccess") ?? "context",
40
+ onFailure:
41
+ normalizeAttention(input.onFailure, "notify.onFailure") ?? "turn",
42
+ onKilled:
43
+ normalizeAttention(input.onKilled, "notify.onKilled") ?? "context",
44
+ logMatches: normalizeLogMatches(logMatches),
45
+ };
46
+ }
47
+
48
+ function normalizeLogMatches(input: unknown): LogMatcherConfig[] {
49
+ if (input === undefined || input === null) return [];
50
+
51
+ if (!Array.isArray(input)) {
52
+ throw new Error("process start notify.logMatches must be an array");
53
+ }
54
+
55
+ if (input.length > MAX_NOTIFY_LOG_MATCHERS) {
56
+ throw new Error(
57
+ `process start notify.logMatches supports at most ${MAX_NOTIFY_LOG_MATCHERS} matchers`,
58
+ );
59
+ }
60
+
61
+ return input.map((entry, index) => normalizeLogMatch(entry, index));
62
+ }
63
+
64
+ export function normalizeLogMatchItems(
65
+ input: unknown,
66
+ options: {
67
+ actionLabel: string;
68
+ pathPrefix: string;
69
+ maxItems?: number;
70
+ },
71
+ ): LogMatcherConfig[] {
72
+ const { actionLabel, pathPrefix } = options;
73
+ const maxItems = options.maxItems ?? MAX_NOTIFY_LOG_MATCHERS;
74
+
75
+ if (input === undefined || input === null) {
76
+ throw new Error(`${actionLabel} ${pathPrefix} is required`);
77
+ }
78
+
79
+ if (!Array.isArray(input)) {
80
+ throw new Error(`${actionLabel} ${pathPrefix} must be an array`);
81
+ }
82
+
83
+ if (input.length === 0) {
84
+ throw new Error(`${actionLabel} ${pathPrefix} must not be empty`);
85
+ }
86
+
87
+ if (input.length > maxItems) {
88
+ throw new Error(
89
+ `${actionLabel} ${pathPrefix} supports at most ${maxItems} items`,
90
+ );
91
+ }
92
+
93
+ return input.map((entry, index) =>
94
+ normalizeLogMatch(entry, index, { actionLabel, pathPrefix }),
95
+ );
96
+ }
97
+
98
+ export function normalizeLogMatch(
99
+ input: unknown,
100
+ index: number,
101
+ options?: { actionLabel?: string; pathPrefix?: string },
102
+ ): LogMatcherConfig {
103
+ const actionLabel = options?.actionLabel ?? "process start";
104
+ const path = `${options?.pathPrefix ?? "notify.logMatches"}[${index}]`;
105
+
106
+ if (!isRecord(input)) {
107
+ throw new Error(`${actionLabel} ${path} must be an object`);
108
+ }
109
+
110
+ if (typeof input.pattern !== "string") {
111
+ throw new Error(`${actionLabel} ${path}.pattern must be a string`);
112
+ }
113
+
114
+ // An empty or whitespace-only literal pattern matches every line
115
+ // (String#includes("")), and an empty regex matches every line too. Reject
116
+ // early so a stray "" from the model does not fire a notification per line.
117
+ if (input.pattern.trim().length === 0) {
118
+ throw new Error(
119
+ `${actionLabel} ${path}.pattern must not be empty or whitespace-only`,
120
+ );
121
+ }
122
+
123
+ if (input.pattern.length > MAX_NOTIFY_PATTERN_LENGTH) {
124
+ throw new Error(
125
+ `${actionLabel} ${path}.pattern must be at most ${MAX_NOTIFY_PATTERN_LENGTH} characters`,
126
+ );
127
+ }
128
+
129
+ const mode =
130
+ normalizeStringEnum(input.mode, LOG_MATCH_MODES, `${path}.mode`) ??
131
+ "literal";
132
+ const stream =
133
+ normalizeStringEnum(input.stream, LOG_MATCH_STREAMS, `${path}.stream`) ??
134
+ "both";
135
+ const repeat = normalizeBoolean(input.repeat, `${path}.repeat`) ?? false;
136
+ const on = normalizeAttention(input.on, `${path}.on`) ?? "turn";
137
+
138
+ if (mode === "regex") {
139
+ validateRegex(input.pattern, path);
140
+ }
141
+
142
+ return {
143
+ pattern: input.pattern,
144
+ mode,
145
+ stream,
146
+ repeat,
147
+ on,
148
+ };
149
+ }
150
+
151
+ function normalizeAttention(
152
+ input: unknown,
153
+ path: string,
154
+ ): Attention | undefined {
155
+ return normalizeStringEnum(input, ATTENTIONS, path);
156
+ }
157
+
158
+ function normalizeStringEnum<const T extends readonly string[]>(
159
+ input: unknown,
160
+ allowed: T,
161
+ path: string,
162
+ ): T[number] | undefined {
163
+ if (input === undefined || input === null) return undefined;
164
+
165
+ if (typeof input !== "string" || !allowed.includes(input)) {
166
+ throw new Error(`${path} must be one of: ${allowed.join(", ")}`);
167
+ }
168
+
169
+ return input;
170
+ }
171
+
172
+ function normalizeBoolean(input: unknown, path: string): boolean | undefined {
173
+ if (input === undefined || input === null) return undefined;
174
+
175
+ if (typeof input !== "boolean") {
176
+ throw new Error(`${path} must be a boolean`);
177
+ }
178
+
179
+ return input;
180
+ }
181
+
182
+ function validateRegex(pattern: string, path: string): void {
183
+ try {
184
+ new RegExp(pattern);
185
+ } catch (error) {
186
+ const message = error instanceof Error ? error.message : String(error);
187
+ throw new Error(
188
+ `${path}.pattern is not a valid regular expression: ${message}`,
189
+ );
190
+ }
191
+ }
@@ -0,0 +1,307 @@
1
+ import {
2
+ formatSize,
3
+ type TruncationResult,
4
+ truncateTail,
5
+ } from "@earendil-works/pi-coding-agent";
6
+
7
+ import type { ProcessManager } from "../../../../src/manager";
8
+ import { stripAnsi } from "../../../../src/utils";
9
+ import type { LineMatchMode } from "../../../../src/utils/match-line";
10
+ import { compileLineMatcher } from "../../../../src/utils/match-line";
11
+ import type { ProcessesParamsType } from "../schema";
12
+ import {
13
+ DEFAULT_OUTPUT_TAIL_LINES,
14
+ MAX_OUTPUT_BYTES,
15
+ MAX_OUTPUT_SCAN_LINES,
16
+ MAX_OUTPUT_TAIL_LINES,
17
+ type ProcessOutputMatchMode,
18
+ type ProcessOutputStream,
19
+ } from "../schema";
20
+
21
+ const MAX_OUTPUT_CONTENT_JSON_BYTES = 96 * 1024;
22
+ const MAX_OUTPUT_PROCESS_NAME_BYTES = 256;
23
+
24
+ export interface OutputDetails {
25
+ action: "output";
26
+ id: string;
27
+ processName: string;
28
+ processStatus: string;
29
+ stream: ProcessOutputStream;
30
+ tailLines: number;
31
+ pattern: string | null;
32
+ mode: ProcessOutputMatchMode;
33
+ stdoutFile: string;
34
+ stderrFile: string;
35
+ truncation?: OutputTruncationDetails;
36
+ }
37
+
38
+ export type OutputTruncationDetails = Omit<TruncationResult, "content">;
39
+
40
+ interface OutputSelection {
41
+ stdout: string[];
42
+ stderr: string[];
43
+ }
44
+
45
+ export interface OutputExecutionResult {
46
+ content: string;
47
+ details: OutputDetails;
48
+ }
49
+
50
+ export function executeOutput(
51
+ params: ProcessesParamsType,
52
+ manager: ProcessManager,
53
+ ): OutputExecutionResult {
54
+ if (!params.id) {
55
+ throw new Error("process output requires id");
56
+ }
57
+
58
+ const process = manager.get(params.id);
59
+ if (!process) {
60
+ throw new Error(`process not found: ${params.id}`);
61
+ }
62
+
63
+ const stream = params.stream ?? "both";
64
+ const tailLines = clampTailLines(params.tailLines);
65
+ const mode = (params.mode ?? "literal") as LineMatchMode;
66
+ const pattern = params.pattern ?? null;
67
+
68
+ if (pattern && mode === "regex") {
69
+ validateRegex(pattern);
70
+ }
71
+
72
+ // Determine scan window: if filtering by pattern, read a larger bounded
73
+ // window so we can find matches beyond the narrow tail.
74
+ const scanLines = pattern ? MAX_OUTPUT_SCAN_LINES : tailLines;
75
+
76
+ const output = manager.getOutput(process.id, scanLines);
77
+ if (!output) {
78
+ throw new Error(
79
+ `could not read output for "${process.name}" (${process.id})`,
80
+ );
81
+ }
82
+
83
+ // Apply stream filter
84
+ let stdout: string[] = stream === "stderr" ? [] : output.stdout;
85
+ let stderr: string[] = stream === "stdout" ? [] : output.stderr;
86
+
87
+ // Apply pattern filter (filter first, then tail)
88
+ if (pattern) {
89
+ const lineMatcher = compileLineMatcher(pattern, mode);
90
+ stdout = stdout.filter(lineMatcher);
91
+ stderr = stderr.filter(lineMatcher);
92
+ }
93
+
94
+ // Tail to requested line count
95
+ stdout = stdout.slice(-tailLines);
96
+ stderr = stderr.slice(-tailLines);
97
+
98
+ const selection: OutputSelection = { stdout, stderr };
99
+ const processName = formatProcessName(process.name);
100
+
101
+ const contentParts = formatOutputContent({
102
+ processName,
103
+ id: process.id,
104
+ processStatus: output.status,
105
+ stream,
106
+ tailLines,
107
+ pattern,
108
+ mode,
109
+ ...selection,
110
+ });
111
+
112
+ const { content, truncation } = buildBoundedOutput(contentParts, {
113
+ stdoutFile: process.stdoutFile,
114
+ stderrFile: process.stderrFile,
115
+ });
116
+
117
+ const details: OutputDetails = {
118
+ action: "output",
119
+ id: process.id,
120
+ processName,
121
+ processStatus: output.status,
122
+ stream,
123
+ tailLines,
124
+ pattern,
125
+ mode,
126
+ stdoutFile: process.stdoutFile,
127
+ stderrFile: process.stderrFile,
128
+ };
129
+
130
+ if (truncation.truncated) {
131
+ const { content: _content, ...truncationDetails } = truncation;
132
+ details.truncation = truncationDetails;
133
+ }
134
+
135
+ return { content, details };
136
+ }
137
+
138
+ interface OutputContentInput extends OutputSelection {
139
+ processName: string;
140
+ id: string;
141
+ processStatus: string;
142
+ stream: ProcessOutputStream;
143
+ tailLines: number;
144
+ pattern: string | null;
145
+ mode: ProcessOutputMatchMode;
146
+ }
147
+
148
+ interface OutputContentParts {
149
+ header: string;
150
+ body: string;
151
+ guidance: string | null;
152
+ }
153
+
154
+ function formatOutputContent(input: OutputContentInput): OutputContentParts {
155
+ const header: string[] = [];
156
+ header.push(`"${input.processName}" (${input.id}) [${input.processStatus}]`);
157
+
158
+ if (input.pattern) {
159
+ const modeTag = input.mode === "regex" ? " (regex)" : "";
160
+ header.push(`filter: ${input.pattern}${modeTag}`);
161
+ }
162
+
163
+ const body: string[] = [];
164
+ const { stdout, stderr } = input;
165
+
166
+ if (stdout.length > 0) {
167
+ body.push("stdout:");
168
+ body.push(...stdout.map(stripAnsi));
169
+ }
170
+
171
+ if (stderr.length > 0) {
172
+ if (body.length > 0) body.push("");
173
+ body.push("stderr:");
174
+ body.push(...stderr.map(stripAnsi));
175
+ }
176
+
177
+ if (stdout.length === 0 && stderr.length === 0) {
178
+ body.push(input.pattern ? "No matching lines found." : "No output yet.");
179
+ }
180
+
181
+ return {
182
+ header: header.join("\n"),
183
+ body: body.join("\n"),
184
+ guidance:
185
+ input.processStatus === "running"
186
+ ? "Process is still running. Use watches instead of polling."
187
+ : null,
188
+ };
189
+ }
190
+
191
+ function buildBoundedOutput(
192
+ parts: OutputContentParts,
193
+ logFiles: { stdoutFile: string; stderrFile: string },
194
+ ): { content: string; truncation: TruncationResult } {
195
+ let maxBodyBytes = MAX_OUTPUT_BYTES;
196
+ let maxBodyLines = MAX_OUTPUT_TAIL_LINES;
197
+ let truncation = truncateTail(parts.body, {
198
+ maxBytes: maxBodyBytes,
199
+ maxLines: maxBodyLines,
200
+ });
201
+ let content = composeOutputContent(parts, truncation, logFiles);
202
+
203
+ // The header, guidance, truncation notice, and log paths are part of the
204
+ // persisted content limit. JSON escaping can expand control characters, so
205
+ // keep the serialized content bounded too. Reduce the body budget until the
206
+ // complete result fits while keeping the newest output.
207
+ for (let attempt = 0; attempt < 10; attempt++) {
208
+ const excessBytes = Buffer.byteLength(content, "utf8") - MAX_OUTPUT_BYTES;
209
+ const excessLines = countLines(content) - MAX_OUTPUT_TAIL_LINES;
210
+ const excessJsonBytes =
211
+ Buffer.byteLength(JSON.stringify(content), "utf8") -
212
+ MAX_OUTPUT_CONTENT_JSON_BYTES;
213
+ if (excessBytes <= 0 && excessLines <= 0 && excessJsonBytes <= 0) {
214
+ return { content, truncation };
215
+ }
216
+
217
+ maxBodyBytes = Math.max(0, maxBodyBytes - Math.max(0, excessBytes));
218
+ if (excessJsonBytes > 0) {
219
+ maxBodyBytes = Math.floor(maxBodyBytes / 2);
220
+ }
221
+ maxBodyLines = Math.max(1, maxBodyLines - Math.max(0, excessLines));
222
+ truncation = truncateTail(parts.body, {
223
+ maxBytes: maxBodyBytes,
224
+ maxLines: maxBodyLines,
225
+ });
226
+ content = composeOutputContent(parts, truncation, logFiles);
227
+ }
228
+
229
+ // Pathological metadata can consume the whole budget. Keep the final tail,
230
+ // which contains the truncation notice and complete-log paths, bounded.
231
+ const finalTruncation = truncateTail(content, {
232
+ maxBytes: MAX_OUTPUT_BYTES,
233
+ maxLines: MAX_OUTPUT_TAIL_LINES,
234
+ });
235
+ return { content: finalTruncation.content, truncation };
236
+ }
237
+
238
+ function composeOutputContent(
239
+ parts: OutputContentParts,
240
+ truncation: TruncationResult,
241
+ logFiles: { stdoutFile: string; stderrFile: string },
242
+ ): string {
243
+ const sections = [parts.header, truncation.content];
244
+
245
+ if (parts.guidance) {
246
+ sections.push(parts.guidance);
247
+ }
248
+
249
+ if (truncation.truncated) {
250
+ sections.push(formatTruncationNotice(truncation));
251
+ }
252
+
253
+ sections.push(
254
+ `[Complete currently-retained logs:\nstdout=${logFiles.stdoutFile}\nstderr=${logFiles.stderrFile}]`,
255
+ );
256
+ return sections.filter((section) => section.length > 0).join("\n\n");
257
+ }
258
+
259
+ function formatTruncationNotice(truncation: TruncationResult): string {
260
+ const limit =
261
+ truncation.truncatedBy === "bytes"
262
+ ? `${formatSize(truncation.maxBytes)} byte limit`
263
+ : `${truncation.maxLines} line limit`;
264
+ const partialNote = truncation.lastLinePartial
265
+ ? " (final line is a partial suffix)"
266
+ : "";
267
+ return `[Preview truncated by ${limit}${partialNote}; showing ${truncation.outputLines} lines / ${formatSize(truncation.outputBytes)} of ${truncation.totalLines} lines / ${formatSize(truncation.totalBytes)}.]`;
268
+ }
269
+
270
+ function countLines(text: string): number {
271
+ if (text.length === 0) return 0;
272
+ return text.split("\n").length;
273
+ }
274
+
275
+ function formatProcessName(name: string): string {
276
+ const clean = stripAnsi(name).replace(/\s+/gu, " ").trim() || "(unnamed)";
277
+ if (Buffer.byteLength(clean, "utf8") <= MAX_OUTPUT_PROCESS_NAME_BYTES) {
278
+ return clean;
279
+ }
280
+
281
+ const suffix = "…";
282
+ const buffer = Buffer.from(clean, "utf8");
283
+ let end = MAX_OUTPUT_PROCESS_NAME_BYTES - Buffer.byteLength(suffix, "utf8");
284
+ while (end > 0 && (buffer[end] ?? 0) >> 6 === 0b10) {
285
+ end--;
286
+ }
287
+ return `${buffer.subarray(0, end).toString("utf8")}${suffix}`;
288
+ }
289
+
290
+ function clampTailLines(value: number | undefined): number {
291
+ if (value === undefined || value === null) return DEFAULT_OUTPUT_TAIL_LINES;
292
+ const n = Math.floor(value);
293
+ if (n < 1) return DEFAULT_OUTPUT_TAIL_LINES;
294
+ if (n > MAX_OUTPUT_TAIL_LINES) return MAX_OUTPUT_TAIL_LINES;
295
+ return n;
296
+ }
297
+
298
+ function validateRegex(pattern: string): void {
299
+ try {
300
+ new RegExp(pattern);
301
+ } catch (error) {
302
+ const message = error instanceof Error ? error.message : String(error);
303
+ throw new Error(
304
+ `process output pattern is not a valid regular expression: ${message}`,
305
+ );
306
+ }
307
+ }