@astrosheep/keiyaku 0.1.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.
@@ -0,0 +1,289 @@
1
+ import { resolveTermPreset } from "./term-presets.js";
2
+ function formatSubagentFailure(failure) {
3
+ const { identity } = resolveTermPreset();
4
+ const base = `${identity} error: code=${failure.errorCode}, name=${failure.subagentName}, round=${failure.round}, timeoutMs=${failure.timeoutMs ?? "unknown"}, exitCode=${failure.exitCode ?? "none"}`;
5
+ if (failure.errorCode === "SUBAGENT_TIMEOUT") {
6
+ return `${base}. Suggestion: task likely too large; break it into smaller pieces and start another round.`;
7
+ }
8
+ return base;
9
+ }
10
+ function truncateForDisplay(raw, maxChars = 1500) {
11
+ const text = raw.trim();
12
+ if (text.length <= maxChars)
13
+ return text;
14
+ return `${text.slice(0, maxChars)}\n...[truncated ${text.length - maxChars} chars]...`;
15
+ }
16
+ function formatMaybe(label, value, maxChars = 400) {
17
+ if (!value)
18
+ return [];
19
+ const trimmed = value.trim();
20
+ if (!trimmed)
21
+ return [];
22
+ return [`${label}: ${truncateForDisplay(trimmed, maxChars)}`];
23
+ }
24
+ function formatList(label, items, opts) {
25
+ const maxItems = opts?.maxItems ?? 8;
26
+ const maxItemChars = opts?.maxItemChars ?? 180;
27
+ if (items.length === 0)
28
+ return [];
29
+ const head = [`${label} (${items.length}):`];
30
+ const shown = items.slice(0, maxItems).map((item) => `- ${truncateForDisplay(item, maxItemChars)}`);
31
+ const tail = items.length > maxItems ? [`...(+${items.length - maxItems} more)`] : [];
32
+ return [...head, ...shown, ...tail];
33
+ }
34
+ function formatSubagentFailureDetails(failure) {
35
+ const lines = [formatSubagentFailure(failure), `Subagent message: ${failure.message}`];
36
+ if (failure.stderrSnippet) {
37
+ lines.push("Subagent stderr (snippet):");
38
+ lines.push(truncateForDisplay(failure.stderrSnippet));
39
+ }
40
+ return lines;
41
+ }
42
+ export function buildKeiyakuSuccessResponse(result, input) {
43
+ const failureLine = result.subagentFailure ? formatSubagentFailureDetails(result.subagentFailure) : [];
44
+ const diffPreviewLines = [
45
+ `Diff Preview: showing ${result.diff.files.length} of ${result.diff.stats.filesChanged} file(s); omitted=${result.diff.omittedFileCount}`,
46
+ ...result.diff.files.flatMap((f) => {
47
+ const status = f.status === "R" && f.oldPath ? `R (${f.oldPath} -> ${f.path})` : f.status;
48
+ const flags = [f.binary ? "binary" : null, f.truncated ? "truncated" : null].filter(Boolean).join(", ");
49
+ const suffix = flags ? ` (${flags})` : "";
50
+ const header = `- ${status} ${f.path} | +${f.additions} -${f.deletions}${suffix}`;
51
+ if (!f.patch)
52
+ return [header];
53
+ return [header, "```diff", f.patch, "```", ""];
54
+ }),
55
+ ];
56
+ const inputEcho = [
57
+ "Inputs:",
58
+ `Title: ${truncateForDisplay(input.title, 200)}`,
59
+ `Goal: ${truncateForDisplay(input.goal, 500)}`,
60
+ ...formatMaybe("Directive", input.directive, 300),
61
+ ...formatList("Criteria", input.criteria, { maxItems: 10, maxItemChars: 200 }),
62
+ ...formatMaybe("Context", input.context, 600),
63
+ ...formatMaybe("Constraints", input.constraints, 600),
64
+ ...formatMaybe(resolveTermPreset().identity, input.subagentName, 120),
65
+ ...formatMaybe("CWD", input.cwd, 300),
66
+ ];
67
+ const nextHint = [
68
+ "Next: CRITICALLY review the diff and trace. Don't blindly trust the output—ensure these changes align with your actual intent.",
69
+ "Decide whether to start another round to refine, or finalize (invoke_judgment DONE) if it meets your standards.",
70
+ "Suggested commands:",
71
+ ` git diff --stat ${result.baseBranch}...HEAD`,
72
+ ` git diff ${result.baseBranch}...HEAD -- path/to/file`,
73
+ ].join("\n");
74
+ return {
75
+ content: [
76
+ {
77
+ type: "text",
78
+ text: [
79
+ "Keiyaku started successfully.",
80
+ `Round: ${result.round}`,
81
+ `Branch: ${result.branch} (base: ${result.baseBranch})`,
82
+ `Repo state: currently on ${result.branch}; round changes are already committed.`,
83
+ `Diff Stats: files=${result.diff.stats.filesChanged}, +${result.diff.stats.insertions}, -${result.diff.stats.deletions}`,
84
+ ...diffPreviewLines,
85
+ ...failureLine,
86
+ `Summary: ${result.summary}`,
87
+ ...inputEcho,
88
+ "",
89
+ nextHint,
90
+ ].join("\n"),
91
+ },
92
+ ],
93
+ structuredContent: {
94
+ ok: true,
95
+ tool: "summon",
96
+ status: "started",
97
+ nextAction: "review_round_output",
98
+ nextHint,
99
+ inputEcho,
100
+ ...result,
101
+ },
102
+ };
103
+ }
104
+ export function buildDriveResponse(result, input) {
105
+ const failureLine = result.subagentFailure ? formatSubagentFailureDetails(result.subagentFailure) : [];
106
+ const diffPreviewLines = [
107
+ `Diff Preview: showing ${result.diff.files.length} of ${result.diff.stats.filesChanged} file(s); omitted=${result.diff.omittedFileCount}`,
108
+ ...result.diff.files.flatMap((f) => {
109
+ const status = f.status === "R" && f.oldPath ? `R (${f.oldPath} -> ${f.path})` : f.status;
110
+ const flags = [f.binary ? "binary" : null, f.truncated ? "truncated" : null].filter(Boolean).join(", ");
111
+ const suffix = flags ? ` (${flags})` : "";
112
+ const header = `- ${status} ${f.path} | +${f.additions} -${f.deletions}${suffix}`;
113
+ if (!f.patch)
114
+ return [header];
115
+ return [header, "```diff", f.patch, "```", ""];
116
+ }),
117
+ ];
118
+ const inputEcho = [
119
+ "Inputs:",
120
+ `Directive: ${truncateForDisplay(input.directive, 600)}`,
121
+ ...formatMaybe("Context", input.context, 600),
122
+ ...formatMaybe(resolveTermPreset().identity, input.subagentName, 120),
123
+ ...formatMaybe("CWD", input.cwd, 300),
124
+ ];
125
+ const nextHint = [
126
+ "Next: CRITICALLY review the diff and trace. Don't blindly trust the output—ensure these changes align with your actual intent.",
127
+ "Decide whether to start another round to refine, or finalize (invoke_judgment DONE) if it meets your standards.",
128
+ "Suggested commands:",
129
+ ` git diff --stat ${result.baseBranch}...HEAD`,
130
+ ` git diff ${result.baseBranch}...HEAD -- path/to/file`,
131
+ ].join("\n");
132
+ return {
133
+ content: [
134
+ {
135
+ type: "text",
136
+ text: [
137
+ "Drive accepted.",
138
+ `Round: ${result.round}`,
139
+ `Branch: ${result.branch} (base: ${result.baseBranch})`,
140
+ `Repo state: currently on ${result.branch}; this round is already committed.`,
141
+ `Diff Stats: files=${result.diff.stats.filesChanged}, +${result.diff.stats.insertions}, -${result.diff.stats.deletions}`,
142
+ ...diffPreviewLines,
143
+ `Summary: ${result.summary}`,
144
+ ...failureLine,
145
+ ...inputEcho,
146
+ "",
147
+ nextHint,
148
+ ].join("\n"),
149
+ },
150
+ ],
151
+ structuredContent: {
152
+ ok: true,
153
+ tool: "drive",
154
+ status: "iterating",
155
+ nextAction: "review_round_output",
156
+ nextHint,
157
+ inputEcho,
158
+ ...result,
159
+ },
160
+ };
161
+ }
162
+ export function buildAskResponse(result, input) {
163
+ const inputEcho = [
164
+ "Inputs:",
165
+ `Request: ${truncateForDisplay(input.request, 600)}`,
166
+ `Context: ${truncateForDisplay(input.context, 800)}`,
167
+ ...formatMaybe(resolveTermPreset().identity, input.subagentName, 120),
168
+ ...formatMaybe("CWD", input.cwd, 300),
169
+ ];
170
+ const nextHint = "Next: decide whether you need another round, a new keiyaku, or no further action.";
171
+ return {
172
+ content: [
173
+ {
174
+ type: "text",
175
+ text: [
176
+ "Ask completed.",
177
+ `Summary: ${result.summary}`,
178
+ ...inputEcho,
179
+ "",
180
+ nextHint,
181
+ ].join("\n"),
182
+ },
183
+ ],
184
+ structuredContent: {
185
+ ok: true,
186
+ tool: "ask",
187
+ status: "completed",
188
+ nextAction: "continue_normal_development",
189
+ nextHint,
190
+ inputEcho,
191
+ ...result,
192
+ },
193
+ };
194
+ }
195
+ export function buildJudgmentDoneResponse(result, input) {
196
+ const inputEcho = [
197
+ "Inputs:",
198
+ `Result: DONE`,
199
+ ...formatList("Criteria checks", input.criteriaChecks, { maxItems: 10, maxItemChars: 220 }),
200
+ `Quality flags: metPrecise=${input.metPrecise} metMinimal=${input.metMinimal} metIsolated=${input.metIsolated} metIdiomatic=${input.metIdiomatic} metCohesive=${input.metCohesive}`,
201
+ ...formatMaybe("Oath", input.oath, 220),
202
+ ...formatMaybe("CWD", input.cwd, 300),
203
+ ];
204
+ const nextHint = "Next: decide whether you need another keiyaku; if yes, start one. Otherwise continue normal development.";
205
+ return {
206
+ content: [
207
+ {
208
+ type: "text",
209
+ text: [
210
+ "Invoke judgment accepted: DONE.",
211
+ `Branch: ${result.branch} -> ${result.baseBranch}`,
212
+ `Diff Stats: files=${result.diffStats?.filesChanged ?? 0}, +${result.diffStats?.insertions ?? 0}, -${result.diffStats?.deletions ?? 0}`,
213
+ "Merged to base branch and cleaned keiyaku branch.",
214
+ ...inputEcho,
215
+ "",
216
+ nextHint,
217
+ ].join("\n"),
218
+ },
219
+ ],
220
+ structuredContent: {
221
+ ok: true,
222
+ tool: "invoke_judgment",
223
+ nextAction: "continue_normal_development",
224
+ nextHint,
225
+ inputEcho,
226
+ ...result,
227
+ },
228
+ };
229
+ }
230
+ export function buildJudgmentDropResponse(result, input) {
231
+ const inputEcho = [
232
+ "Inputs:",
233
+ `Result: DROP`,
234
+ ...formatList("Criteria checks", input.criteriaChecks, { maxItems: 10, maxItemChars: 220 }),
235
+ `Quality flags: metPrecise=${input.metPrecise} metMinimal=${input.metMinimal} metIsolated=${input.metIsolated} metIdiomatic=${input.metIdiomatic} metCohesive=${input.metCohesive}`,
236
+ ...formatMaybe("Oath", input.oath, 220),
237
+ ...formatMaybe("CWD", input.cwd, 300),
238
+ ];
239
+ const nextHint = "Next: decide whether the task still needs doing; if yes, start a new keiyaku with clarified inputs.";
240
+ return {
241
+ content: [
242
+ {
243
+ type: "text",
244
+ text: [
245
+ "Invoke judgment accepted: DROP.",
246
+ "Discarded keiyaku branch and returned to base branch.",
247
+ ...inputEcho,
248
+ "",
249
+ nextHint,
250
+ ].join("\n"),
251
+ },
252
+ ],
253
+ structuredContent: {
254
+ ok: true,
255
+ tool: "invoke_judgment",
256
+ nextAction: "start_new_keiyaku_if_needed",
257
+ nextHint,
258
+ inputEcho,
259
+ ...result,
260
+ },
261
+ };
262
+ }
263
+ export function buildToolErrorResponse(input) {
264
+ const inputEcho = (input.inputEcho ?? []).map((line) => truncateForDisplay(line, 800));
265
+ return {
266
+ content: [
267
+ {
268
+ type: "text",
269
+ text: [
270
+ `${input.title} failed.`,
271
+ `Reason: ${input.message}`,
272
+ `Hint: ${input.hint}`,
273
+ ...inputEcho,
274
+ ].join("\n"),
275
+ },
276
+ ],
277
+ structuredContent: {
278
+ ok: false,
279
+ tool: input.tool,
280
+ status: "error",
281
+ errorType: input.errorType,
282
+ errorCode: input.errorCode,
283
+ error: input.message,
284
+ hint: input.hint,
285
+ inputEcho,
286
+ },
287
+ isError: true,
288
+ };
289
+ }
@@ -0,0 +1,33 @@
1
+ import { runSubagentProcess, resolvePositiveIntFromEnv, resolvePrefixedCommand } from "./process-runner.js";
2
+ const DEFAULT_CODEX_EXEC_TIMEOUT_MS = 20 * 60 * 1000;
3
+ const DEFAULT_CODEX_EXEC_MAX_CAPTURE_CHARS = 200_000;
4
+ export async function runCodexExec(prompt, cwd, options = {}) {
5
+ const timeoutMs = resolvePositiveIntFromEnv("KEIYAKU_CODEX_EXEC_TIMEOUT_MS", DEFAULT_CODEX_EXEC_TIMEOUT_MS);
6
+ const maxCaptureChars = resolvePositiveIntFromEnv("KEIYAKU_CODEX_EXEC_MAX_CAPTURE_CHARS", DEFAULT_CODEX_EXEC_MAX_CAPTURE_CHARS);
7
+ const codexArgs = ["exec", "--full-auto", "-C", cwd];
8
+ if (options.model) {
9
+ codexArgs.push("-m", options.model);
10
+ }
11
+ if (options.effort) {
12
+ codexArgs.push("-c", `model_reasoning_effort=${options.effort}`);
13
+ }
14
+ codexArgs.push("-");
15
+ const { command, args } = resolvePrefixedCommand("codex", codexArgs, process.env.KEIYAKU_CODEX_EXEC_PREFIX);
16
+ const { stdout, stderr } = await runSubagentProcess({
17
+ runnerName: "runCodexExec",
18
+ provider: "codex",
19
+ command,
20
+ args,
21
+ cwd,
22
+ prompt,
23
+ timeoutMs,
24
+ maxCaptureChars,
25
+ signal: options.signal,
26
+ startDetails: `model=${options.model ?? "default"} effort=${options.effort ?? "default"}`,
27
+ });
28
+ return {
29
+ finalMessage: stdout.trim(),
30
+ stdout,
31
+ stderr,
32
+ };
33
+ }
@@ -0,0 +1,37 @@
1
+ import { runSubagentProcess, resolvePositiveIntFromEnv, resolvePrefixedCommand } from "./process-runner.js";
2
+ const DEFAULT_GEMINI_EXEC_TIMEOUT_MS = 20 * 60 * 1000;
3
+ const DEFAULT_GEMINI_EXEC_MAX_CAPTURE_CHARS = 200_000;
4
+ export async function runGeminiExec(prompt, cwd, options = {}) {
5
+ const timeoutMs = resolvePositiveIntFromEnv("KEIYAKU_GEMINI_EXEC_TIMEOUT_MS", DEFAULT_GEMINI_EXEC_TIMEOUT_MS);
6
+ const maxCaptureChars = resolvePositiveIntFromEnv("KEIYAKU_GEMINI_EXEC_MAX_CAPTURE_CHARS", DEFAULT_GEMINI_EXEC_MAX_CAPTURE_CHARS);
7
+ const geminiArgs = ["--approval-mode", "yolo", "--output-format", "json"];
8
+ if (options.model) {
9
+ geminiArgs.push("-m", options.model);
10
+ }
11
+ // Prompt is passed via stdin to avoid command-line length limits.
12
+ geminiArgs.push("--prompt", "-");
13
+ const { command, args } = resolvePrefixedCommand("gemini", geminiArgs, process.env.KEIYAKU_GEMINI_EXEC_PREFIX);
14
+ const { stdout, stderr } = await runSubagentProcess({
15
+ runnerName: "runGeminiExec",
16
+ provider: "gemini",
17
+ command,
18
+ args,
19
+ cwd,
20
+ prompt,
21
+ timeoutMs,
22
+ maxCaptureChars,
23
+ signal: options.signal,
24
+ startDetails: `model=${options.model ?? "default"}`,
25
+ });
26
+ let finalMessage = stdout.trim();
27
+ try {
28
+ const parsed = JSON.parse(stdout);
29
+ if (parsed.response && typeof parsed.response === "string") {
30
+ finalMessage = parsed.response;
31
+ }
32
+ }
33
+ catch {
34
+ // Fall back to raw stdout when Gemini output is not valid JSON.
35
+ }
36
+ return { finalMessage, stdout, stderr };
37
+ }
@@ -0,0 +1,39 @@
1
+ import { FlowError } from "../errors.js";
2
+ import { runCodexExec } from "./codex-exec.js";
3
+ import { runGeminiExec } from "./gemini-exec.js";
4
+ import { isSubagentExecError, SubagentExecError } from "./types.js";
5
+ const CODEX_MODEL = "gpt-5.3-codex";
6
+ const GEMINI_MODEL = "gemini-3-flash-preview";
7
+ const SUBAGENT_PROFILES = {
8
+ "servant-tier-B": { provider: "codex", model: CODEX_MODEL, effort: "low" },
9
+ "servant-tier-A": { provider: "codex", model: CODEX_MODEL, effort: "medium" },
10
+ "servant-tier-S": { provider: "codex", model: CODEX_MODEL, effort: "high" },
11
+ };
12
+ export { SubagentExecError, isSubagentExecError };
13
+ export function getSubagentNames() {
14
+ return Object.keys(SUBAGENT_PROFILES);
15
+ }
16
+ export function resolveSubagentConfig(name) {
17
+ const config = SUBAGENT_PROFILES[name];
18
+ if (!config) {
19
+ throw new FlowError("UNKNOWN_SUBAGENT", `Unknown subagent '${name}'. Expected one of: ${getSubagentNames().join(", ")}`);
20
+ }
21
+ return config;
22
+ }
23
+ export async function runSubagentExec(name, prompt, cwd, options = {}) {
24
+ const config = resolveSubagentConfig(name);
25
+ if (config.provider === "codex") {
26
+ return runCodexExec(prompt, cwd, {
27
+ model: config.model,
28
+ effort: config.effort,
29
+ signal: options.signal,
30
+ });
31
+ }
32
+ if (config.provider === "gemini") {
33
+ return runGeminiExec(prompt, cwd, {
34
+ model: config.model,
35
+ signal: options.signal,
36
+ });
37
+ }
38
+ throw new SubagentExecError("SUBAGENT_EXEC_ERROR", `Unsupported subagent provider '${String(config.provider)}'.`);
39
+ }
@@ -0,0 +1,207 @@
1
+ import { spawn } from "node:child_process";
2
+ import { appendDebugLog } from "../debug-log.js";
3
+ import { StringTailBuffer } from "./string-tail-buffer.js";
4
+ import { SubagentExecError } from "./types.js";
5
+ function shellEscape(arg) {
6
+ return `'${arg.replace(/'/g, `'\\''`)}'`;
7
+ }
8
+ function createAbortError(message) {
9
+ const error = new Error(message);
10
+ error.name = "AbortError";
11
+ return error;
12
+ }
13
+ function createStderrStreamId() {
14
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
15
+ }
16
+ function renderTruncationSuffix(truncatedChars) {
17
+ if (truncatedChars <= 0)
18
+ return "";
19
+ return `\n...[truncated ${truncatedChars} chars from start]...`;
20
+ }
21
+ function terminateChildTree(pid, detached, signal) {
22
+ if (!pid)
23
+ return;
24
+ if (detached && process.platform !== "win32") {
25
+ try {
26
+ process.kill(-pid, signal);
27
+ return;
28
+ }
29
+ catch {
30
+ // Fall through to direct child kill when process group kill fails.
31
+ }
32
+ }
33
+ try {
34
+ process.kill(pid, signal);
35
+ }
36
+ catch {
37
+ // Best effort shutdown.
38
+ }
39
+ }
40
+ function getCapturedOutput(stdoutBuffer, stderrBuffer) {
41
+ const stdoutSnapshot = stdoutBuffer.snapshot();
42
+ const stderrSnapshot = stderrBuffer.snapshot();
43
+ return {
44
+ stdout: `${stdoutSnapshot.text}${renderTruncationSuffix(stdoutSnapshot.truncatedChars)}`,
45
+ stderr: `${stderrSnapshot.text}${renderTruncationSuffix(stderrSnapshot.truncatedChars)}`,
46
+ stdoutSnapshot,
47
+ stderrSnapshot,
48
+ };
49
+ }
50
+ export function resolvePositiveIntFromEnv(envName, fallback) {
51
+ const value = process.env[envName]?.trim();
52
+ if (!value)
53
+ return fallback;
54
+ const parsed = Number(value);
55
+ if (!Number.isFinite(parsed) || parsed <= 0)
56
+ return fallback;
57
+ return Math.floor(parsed);
58
+ }
59
+ export function resolvePrefixedCommand(binary, binaryArgs, commandPrefix) {
60
+ const prefix = commandPrefix?.trim();
61
+ if (!prefix) {
62
+ return { command: binary, args: binaryArgs };
63
+ }
64
+ return {
65
+ command: "sh",
66
+ args: ["-lc", `${prefix} ${binary} ${binaryArgs.map(shellEscape).join(" ")}`],
67
+ };
68
+ }
69
+ export async function runSubagentProcess(options) {
70
+ const stderrStreamId = createStderrStreamId();
71
+ let stderrStreamed = false;
72
+ const stderrSection = `${options.provider}-stderr`;
73
+ const prefixStderrChunk = (chunk) => {
74
+ const normalized = chunk.replace(/\r/g, "");
75
+ const lines = normalized.split("\n");
76
+ return lines.map((line) => `[stderr:${stderrStreamId}] ${line}`).join("\n");
77
+ };
78
+ try {
79
+ const detailsSuffix = options.startDetails ? ` ${options.startDetails}` : "";
80
+ appendDebugLog(`${options.runnerName} start: command=${options.command} args=${JSON.stringify(options.args)} timeoutMs=${options.timeoutMs} maxCaptureChars=${options.maxCaptureChars}${detailsSuffix}`, { cwd: options.cwd, section: options.provider });
81
+ const { stdout, stderr } = await new Promise((resolve, reject) => {
82
+ const detached = process.platform !== "win32";
83
+ const child = spawn(options.command, options.args, { cwd: options.cwd, detached });
84
+ const stdoutBuffer = new StringTailBuffer(options.maxCaptureChars);
85
+ const stderrBuffer = new StringTailBuffer(options.maxCaptureChars);
86
+ let stdoutTotalBytes = 0;
87
+ let stderrTotalBytes = 0;
88
+ let finished = false;
89
+ let forceKillTimer;
90
+ const finishWithError = (error) => {
91
+ if (finished)
92
+ return;
93
+ finished = true;
94
+ clearTimeout(timeout);
95
+ if (forceKillTimer)
96
+ clearTimeout(forceKillTimer);
97
+ if (options.signal && abortHandler) {
98
+ options.signal.removeEventListener("abort", abortHandler);
99
+ }
100
+ reject(error);
101
+ };
102
+ const requestStop = (error) => {
103
+ if (finished)
104
+ return;
105
+ appendDebugLog(`${options.runnerName} stop requested: ${error.message}`, { cwd: options.cwd, section: options.provider });
106
+ terminateChildTree(child.pid, detached, "SIGTERM");
107
+ forceKillTimer = setTimeout(() => {
108
+ terminateChildTree(child.pid, detached, "SIGKILL");
109
+ }, 5000);
110
+ finishWithError(error);
111
+ };
112
+ const timeout = setTimeout(() => {
113
+ requestStop((() => {
114
+ const captured = getCapturedOutput(stdoutBuffer, stderrBuffer);
115
+ return new SubagentExecError("SUBAGENT_TIMEOUT", `${options.provider} exec timed out`, {
116
+ timeoutMs: options.timeoutMs,
117
+ stdout: captured.stdout,
118
+ stderr: captured.stderr,
119
+ });
120
+ })());
121
+ }, options.timeoutMs);
122
+ const abortHandler = () => {
123
+ requestStop(createAbortError(`${options.provider} exec cancelled by client`));
124
+ };
125
+ if (options.signal) {
126
+ if (options.signal.aborted) {
127
+ requestStop(createAbortError(`${options.provider} exec cancelled by client`));
128
+ return;
129
+ }
130
+ options.signal.addEventListener("abort", abortHandler, { once: true });
131
+ }
132
+ child.stdout.on("data", (chunk) => {
133
+ const text = String(chunk);
134
+ stdoutTotalBytes += Buffer.byteLength(text);
135
+ stdoutBuffer.append(text);
136
+ });
137
+ child.stderr.on("data", (chunk) => {
138
+ const text = String(chunk);
139
+ stderrTotalBytes += Buffer.byteLength(text);
140
+ stderrBuffer.append(text);
141
+ stderrStreamed = true;
142
+ appendDebugLog(prefixStderrChunk(text), { cwd: options.cwd, section: stderrSection });
143
+ });
144
+ child.on("error", (err) => {
145
+ appendDebugLog(`${options.runnerName} spawn error: ${err.message}`, { cwd: options.cwd, section: options.provider });
146
+ finishWithError((() => {
147
+ const captured = getCapturedOutput(stdoutBuffer, stderrBuffer);
148
+ return new SubagentExecError("SUBAGENT_SPAWN_ERROR", `${options.provider} exec spawn error: ${err.message}`, {
149
+ timeoutMs: options.timeoutMs,
150
+ stdout: captured.stdout,
151
+ stderr: captured.stderr,
152
+ cause: err,
153
+ });
154
+ })());
155
+ });
156
+ child.on("close", (code) => {
157
+ clearTimeout(timeout);
158
+ if (forceKillTimer)
159
+ clearTimeout(forceKillTimer);
160
+ if (options.signal) {
161
+ options.signal.removeEventListener("abort", abortHandler);
162
+ }
163
+ if (finished)
164
+ return;
165
+ finished = true;
166
+ const captured = getCapturedOutput(stdoutBuffer, stderrBuffer);
167
+ appendDebugLog(`${options.runnerName} close: code=${code} stdoutBytes=${stdoutTotalBytes} stderrBytes=${stderrTotalBytes} stdoutTruncatedChars=${captured.stdoutSnapshot.truncatedChars} stderrTruncatedChars=${captured.stderrSnapshot.truncatedChars}`, { cwd: options.cwd, section: options.provider });
168
+ if (code === 0) {
169
+ resolve({ stdout: captured.stdout, stderr: captured.stderr });
170
+ return;
171
+ }
172
+ const errorText = captured.stderr || captured.stdout || `${options.provider} exec exited with code ${code}`;
173
+ reject(new SubagentExecError("SUBAGENT_NON_ZERO_EXIT", errorText, {
174
+ timeoutMs: options.timeoutMs,
175
+ stdout: captured.stdout,
176
+ stderr: captured.stderr,
177
+ exitCode: code,
178
+ }));
179
+ });
180
+ child.stdin.end(options.prompt);
181
+ });
182
+ if (!stderrStreamed && stderr.trim()) {
183
+ appendDebugLog(`[stderr:${stderrStreamId}] ${stderr.trim()}`, { cwd: options.cwd, section: stderrSection });
184
+ }
185
+ return { stdout, stderr };
186
+ }
187
+ catch (error) {
188
+ if (error instanceof Error) {
189
+ appendDebugLog(`${options.runnerName} error: ${error.name}: ${error.message}`, { cwd: options.cwd, section: options.provider });
190
+ if (error instanceof SubagentExecError) {
191
+ if (!stderrStreamed && error.stderr.trim()) {
192
+ appendDebugLog(`[stderr:${stderrStreamId}] ${error.stderr.trim()}`, { cwd: options.cwd, section: stderrSection });
193
+ }
194
+ }
195
+ }
196
+ if (error instanceof Error && error.name === "AbortError") {
197
+ throw error;
198
+ }
199
+ if (error instanceof SubagentExecError) {
200
+ throw error;
201
+ }
202
+ const message = error instanceof Error ? error.message : `Unknown ${options.provider} exec error`;
203
+ throw new SubagentExecError("SUBAGENT_EXEC_ERROR", `${options.provider} exec failed: ${message}`, {
204
+ timeoutMs: options.timeoutMs,
205
+ });
206
+ }
207
+ }
@@ -0,0 +1,44 @@
1
+ export class StringTailBuffer {
2
+ maxChars;
3
+ chunks = [];
4
+ keptChars = 0;
5
+ totalChars = 0;
6
+ constructor(maxChars) {
7
+ if (!Number.isFinite(maxChars) || maxChars <= 0) {
8
+ throw new Error(`invalid maxChars: ${maxChars}`);
9
+ }
10
+ this.maxChars = Math.floor(maxChars);
11
+ }
12
+ append(chunk) {
13
+ if (!chunk)
14
+ return;
15
+ this.totalChars += chunk.length;
16
+ if (chunk.length >= this.maxChars) {
17
+ this.chunks.length = 0;
18
+ this.chunks.push(chunk.slice(-this.maxChars));
19
+ this.keptChars = this.maxChars;
20
+ return;
21
+ }
22
+ this.chunks.push(chunk);
23
+ this.keptChars += chunk.length;
24
+ this.trimOverflow();
25
+ }
26
+ snapshot() {
27
+ const text = this.chunks.join("");
28
+ const truncatedChars = Math.max(0, this.totalChars - text.length);
29
+ return { text, totalChars: this.totalChars, truncatedChars };
30
+ }
31
+ trimOverflow() {
32
+ while (this.keptChars > this.maxChars && this.chunks.length > 0) {
33
+ const overflow = this.keptChars - this.maxChars;
34
+ const first = this.chunks[0];
35
+ if (first.length <= overflow) {
36
+ this.chunks.shift();
37
+ this.keptChars -= first.length;
38
+ continue;
39
+ }
40
+ this.chunks[0] = first.slice(overflow);
41
+ this.keptChars -= overflow;
42
+ }
43
+ }
44
+ }
@@ -0,0 +1,19 @@
1
+ export class SubagentExecError extends Error {
2
+ code;
3
+ timeoutMs;
4
+ exitCode;
5
+ stdout;
6
+ stderr;
7
+ constructor(code, message, options = {}) {
8
+ super(message, options.cause === undefined ? undefined : { cause: options.cause });
9
+ this.name = "SubagentExecError";
10
+ this.code = code;
11
+ this.timeoutMs = options.timeoutMs ?? null;
12
+ this.exitCode = options.exitCode ?? null;
13
+ this.stdout = options.stdout ?? "";
14
+ this.stderr = options.stderr ?? "";
15
+ }
16
+ }
17
+ export function isSubagentExecError(error) {
18
+ return error instanceof SubagentExecError;
19
+ }