sidecar-cli 0.1.5-beta.1 → 0.1.5-beta.2

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.
@@ -1,3 +1,4 @@
1
+ import { loadPromptPreferences } from '../runners/config.js';
1
2
  import { createRunRecordEntry, updateRunRecordEntry } from '../runs/run-service.js';
2
3
  import { getTaskPacket } from '../tasks/task-service.js';
3
4
  import { compilePromptMarkdown, saveCompiledPrompt } from './prompt-compiler.js';
@@ -10,6 +11,11 @@ export function compileTaskPrompt(input) {
10
11
  status: 'preparing',
11
12
  branch: task.tracking.branch,
12
13
  worktree: task.tracking.worktree,
14
+ ...(input.parentRunId ? { parent_run_id: input.parentRunId } : {}),
15
+ ...(input.replayReason ? { replay_reason: input.replayReason } : {}),
16
+ ...(input.pipelineId ? { pipeline_id: input.pipelineId } : {}),
17
+ ...(input.pipelineStep ? { pipeline_step: input.pipelineStep } : {}),
18
+ ...(input.pipelineTotal ? { pipeline_total: input.pipelineTotal } : {}),
13
19
  });
14
20
  const compiledPrompt = compilePromptMarkdown({
15
21
  task,
@@ -17,6 +23,7 @@ export function compileTaskPrompt(input) {
17
23
  runner: input.runner,
18
24
  agentRole: input.agentRole,
19
25
  linkedContext: input.linkedContext,
26
+ budget: loadPromptPreferences(input.rootPath),
20
27
  });
21
28
  const promptPath = saveCompiledPrompt(input.rootPath, created.run.run_id, compiledPrompt.markdown);
22
29
  updateRunRecordEntry(input.rootPath, created.run.run_id, {
@@ -0,0 +1,128 @@
1
+ // Freestanding prompt spec loader. Parses a `.yaml`/`.yml`/`.json` file into
2
+ // the `CompileSectionsInput` shape used by the core compiler — no TaskPacket
3
+ // required. Intended for quick prompt iteration (`sidecar prompt compile
4
+ // prompt.yaml`) and as an agent-facing primitive for composing prompts
5
+ // programmatically.
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { parse as parseYaml } from 'yaml';
9
+ import { z } from 'zod';
10
+ import { PROMPT_PREFERENCE_DEFAULTS } from '../runners/config.js';
11
+ const trimPolicySchema = z.enum(['keep', 'trim-last', 'drop']);
12
+ const trimConfigSchema = z
13
+ .object({
14
+ policy: trimPolicySchema.optional(),
15
+ limit: z.number().int().positive().optional(),
16
+ limit_strict: z.number().int().positive().optional(),
17
+ overflow_label: z.string().optional(),
18
+ })
19
+ .strict();
20
+ // `content` accepts either a text block (string or string[]) or a list
21
+ // (`list: []`). A section is a text section when `content` is a string or
22
+ // single-item array without `list`; a list section when `list` is present.
23
+ const sectionSpecSchema = z
24
+ .object({
25
+ id: z.string().min(1).optional(),
26
+ title: z.string().min(1),
27
+ required: z.boolean().optional(),
28
+ // text content:
29
+ content: z.union([z.string(), z.array(z.string())]).optional(),
30
+ // list content:
31
+ list: z.array(z.string()).optional(),
32
+ empty_placeholder: z.string().optional(),
33
+ trim: z.union([trimPolicySchema, trimConfigSchema]).optional(),
34
+ })
35
+ .strict();
36
+ const budgetSchema = z
37
+ .object({
38
+ target: z.number().int().positive().optional(),
39
+ max: z.number().int().positive().optional(),
40
+ })
41
+ .strict();
42
+ export const promptSpecSchema = z
43
+ .object({
44
+ header: z.union([z.string(), z.array(z.string())]).optional(),
45
+ sections: z.array(sectionSpecSchema).min(1),
46
+ budget: budgetSchema.optional(),
47
+ policy_overrides: z.record(z.string(), trimPolicySchema).optional(),
48
+ })
49
+ .strict();
50
+ function toLines(value) {
51
+ if (value == null)
52
+ return [];
53
+ if (typeof value === 'string')
54
+ return value.split('\n').map((l) => l.replace(/\s+$/, ''));
55
+ return value;
56
+ }
57
+ function slugifyTitle(title) {
58
+ return title
59
+ .toLowerCase()
60
+ .replace(/[^a-z0-9]+/g, '_')
61
+ .replace(/^_+|_+$/g, '')
62
+ .slice(0, 40) || 'section';
63
+ }
64
+ function resolveTrim(entry, isList) {
65
+ if (entry.required)
66
+ return { policy: 'keep' };
67
+ const t = entry.trim;
68
+ if (t == null)
69
+ return { policy: 'keep' };
70
+ if (typeof t === 'string')
71
+ return { policy: t };
72
+ const policy = t.policy ?? (isList ? 'trim-last' : 'keep');
73
+ return { policy, config: t };
74
+ }
75
+ function toSection(entry, index) {
76
+ const id = entry.id?.trim() || `${slugifyTitle(entry.title)}_${index + 1}`;
77
+ const isList = entry.list != null;
78
+ const trim = resolveTrim(entry, isList);
79
+ if (isList) {
80
+ const items = entry.list ?? [];
81
+ const section = {
82
+ id,
83
+ title: entry.title,
84
+ kind: 'list',
85
+ items,
86
+ ...(entry.empty_placeholder ? { empty_placeholder: entry.empty_placeholder } : {}),
87
+ trim: {
88
+ policy: trim.policy,
89
+ ...(trim.config?.limit ? { limit: trim.config.limit } : {}),
90
+ ...(trim.config?.limit_strict ? { limit_strict: trim.config.limit_strict } : {}),
91
+ ...(trim.config?.overflow_label ? { overflow_label: trim.config.overflow_label } : {}),
92
+ },
93
+ };
94
+ return section;
95
+ }
96
+ const section = {
97
+ id,
98
+ title: entry.title,
99
+ kind: 'text',
100
+ content: toLines(entry.content),
101
+ ...(trim.policy === 'keep' || trim.policy === 'drop' ? { trim: trim.policy } : { trim: 'keep' }),
102
+ };
103
+ return section;
104
+ }
105
+ export function specToCompileInput(spec) {
106
+ const header = toLines(spec.header);
107
+ const sections = spec.sections.map((s, i) => toSection(s, i));
108
+ const target = spec.budget?.target ?? PROMPT_PREFERENCE_DEFAULTS.budget_target;
109
+ const max = spec.budget?.max ?? Math.max(target, PROMPT_PREFERENCE_DEFAULTS.budget_max);
110
+ return {
111
+ ...(header.length > 0 ? { header } : {}),
112
+ sections,
113
+ budget: { target, max },
114
+ ...(spec.policy_overrides ? { policy_overrides: spec.policy_overrides } : {}),
115
+ };
116
+ }
117
+ export function parsePromptSpec(raw, format) {
118
+ const parsed = format === 'json' ? JSON.parse(raw) : parseYaml(raw);
119
+ return promptSpecSchema.parse(parsed);
120
+ }
121
+ export function loadPromptSpec(specPath) {
122
+ const abs = path.resolve(specPath);
123
+ const raw = fs.readFileSync(abs, 'utf8');
124
+ const ext = path.extname(abs).toLowerCase();
125
+ const format = ext === '.json' ? 'json' : 'yaml';
126
+ const spec = parsePromptSpec(raw, format);
127
+ return { spec, input: specToCompileInput(spec) };
128
+ }
@@ -0,0 +1,194 @@
1
+ // Runner-agnostic, packet-agnostic prompt compiler.
2
+ //
3
+ // A section is either a block of raw text (`kind: 'text'`) or a bulleted list
4
+ // (`kind: 'list'`). Each section carries a stable `id` so callers — and the
5
+ // `--section-policy`/`--explain` CLI surfaces — can reference it without
6
+ // depending on the rendered title. The core does three jobs: (1) render to
7
+ // markdown, (2) apply per-section trim policies under a token budget,
8
+ // (3) produce a trace explaining what happened to each section.
9
+ //
10
+ // The `TaskPacket` adapter lives in `packet-sections.ts` and re-uses this
11
+ // primitive. Freestanding `.yaml`/`.json` spec files use the same type surface
12
+ // via `prompt-spec.ts`.
13
+ export function estimateTokens(text) {
14
+ return Math.ceil(text.length / 4);
15
+ }
16
+ function dedupeList(items) {
17
+ const seen = new Set();
18
+ const out = [];
19
+ for (const item of items.map((v) => v.trim()).filter(Boolean)) {
20
+ const key = item.toLowerCase();
21
+ if (seen.has(key))
22
+ continue;
23
+ seen.add(key);
24
+ out.push(item);
25
+ }
26
+ return out;
27
+ }
28
+ function renderSection(section, items) {
29
+ if (section.kind === 'text') {
30
+ const lines = [`## ${section.title}`, ...section.content, ''];
31
+ return lines.join('\n');
32
+ }
33
+ const rendered = items ?? section.items;
34
+ const empty = section.empty_placeholder ?? '- none';
35
+ const body = rendered.length === 0 ? [empty] : rendered.map((v) => (v.startsWith('- ') ? v : `- ${v}`));
36
+ return [`## ${section.title}`, ...body, ''].join('\n');
37
+ }
38
+ function renderAll(input, listItemsById, droppedIds) {
39
+ const lines = [];
40
+ if (input.header && input.header.length > 0) {
41
+ lines.push(...input.header);
42
+ lines.push('');
43
+ }
44
+ for (const section of input.sections) {
45
+ if (droppedIds.has(section.id))
46
+ continue;
47
+ if (section.kind === 'list') {
48
+ lines.push(renderSection(section, listItemsById.get(section.id) ?? section.items));
49
+ }
50
+ else {
51
+ lines.push(renderSection(section));
52
+ }
53
+ }
54
+ return `${lines.join('\n').trim()}\n`;
55
+ }
56
+ function applyListTrim(section, limit) {
57
+ const deduped = dedupeList(section.items);
58
+ if (limit == null || deduped.length <= limit)
59
+ return deduped;
60
+ const kept = deduped.slice(0, limit);
61
+ const overflow = deduped.length - kept.length;
62
+ if (overflow > 0 && section.trim?.overflow_label) {
63
+ kept.push(`+ ${overflow} more ${section.trim.overflow_label} (see task packet for full list)`);
64
+ }
65
+ return kept;
66
+ }
67
+ function sectionTokens(section, items) {
68
+ return estimateTokens(renderSection(section, items));
69
+ }
70
+ function effectivePolicy(section, overrides) {
71
+ const override = overrides?.[section.id];
72
+ if (override)
73
+ return override;
74
+ if (section.kind === 'text')
75
+ return section.trim ?? 'keep';
76
+ return section.trim?.policy ?? 'keep';
77
+ }
78
+ export function compileSections(input) {
79
+ const { target, max } = input.budget;
80
+ // Pass 1 — baseline: dedupe lists, no trimming.
81
+ const baseItems = new Map();
82
+ for (const section of input.sections) {
83
+ if (section.kind === 'list')
84
+ baseItems.set(section.id, dedupeList(section.items));
85
+ }
86
+ const baselineMarkdown = renderAll(input, baseItems, new Set());
87
+ const baselineTokens = estimateTokens(baselineMarkdown);
88
+ // Fast path — fits within target, nothing to trim.
89
+ if (baselineTokens <= target) {
90
+ const traces = input.sections.map((section) => {
91
+ const items = section.kind === 'list' ? baseItems.get(section.id) ?? [] : undefined;
92
+ return {
93
+ id: section.id,
94
+ title: section.title,
95
+ kind: section.kind,
96
+ policy_applied: effectivePolicy(section, input.policy_overrides),
97
+ ...(section.kind === 'list' ? { total_items: dedupeList(section.items).length, kept_items: items?.length ?? 0 } : {}),
98
+ was_trimmed: false,
99
+ was_dropped: false,
100
+ estimated_tokens: sectionTokens(section, items),
101
+ };
102
+ });
103
+ return {
104
+ markdown: baselineMarkdown,
105
+ metadata: {
106
+ estimated_tokens_before: baselineTokens,
107
+ estimated_tokens_after: baselineTokens,
108
+ budget_target: target,
109
+ budget_max: max,
110
+ trimmed_sections: [],
111
+ dropped_sections: [],
112
+ sections: traces,
113
+ },
114
+ };
115
+ }
116
+ // Pass 2 — target trim: apply `limit` to each trim-last list (except keep).
117
+ const passItems = new Map();
118
+ const droppedIds = new Set();
119
+ for (const section of input.sections) {
120
+ if (section.kind !== 'list')
121
+ continue;
122
+ const policy = effectivePolicy(section, input.policy_overrides);
123
+ if (policy === 'keep') {
124
+ passItems.set(section.id, dedupeList(section.items));
125
+ }
126
+ else if (policy === 'drop') {
127
+ // leave droppedIds decision for the strict pass
128
+ passItems.set(section.id, dedupeList(section.items));
129
+ }
130
+ else {
131
+ // trim-last
132
+ passItems.set(section.id, applyListTrim(section, section.trim?.limit));
133
+ }
134
+ }
135
+ let markdown = renderAll(input, passItems, droppedIds);
136
+ let tokens = estimateTokens(markdown);
137
+ // Pass 3 — strict pass: apply limit_strict, then drop `drop`-policy sections.
138
+ if (tokens > max) {
139
+ for (const section of input.sections) {
140
+ const policy = effectivePolicy(section, input.policy_overrides);
141
+ if (policy === 'drop') {
142
+ droppedIds.add(section.id);
143
+ continue;
144
+ }
145
+ if (section.kind === 'list' && policy === 'trim-last') {
146
+ passItems.set(section.id, applyListTrim(section, section.trim?.limit_strict ?? section.trim?.limit));
147
+ }
148
+ }
149
+ markdown = renderAll(input, passItems, droppedIds);
150
+ tokens = estimateTokens(markdown);
151
+ }
152
+ const traces = input.sections.map((section) => {
153
+ const policy = effectivePolicy(section, input.policy_overrides);
154
+ const isDropped = droppedIds.has(section.id);
155
+ if (section.kind === 'list') {
156
+ const total = dedupeList(section.items).length;
157
+ const kept = isDropped ? 0 : passItems.get(section.id)?.length ?? 0;
158
+ const overflowLine = section.trim?.overflow_label ? 1 : 0;
159
+ const realKept = Math.max(0, kept - overflowLine);
160
+ return {
161
+ id: section.id,
162
+ title: section.title,
163
+ kind: 'list',
164
+ policy_applied: policy,
165
+ total_items: total,
166
+ kept_items: realKept,
167
+ was_trimmed: !isDropped && realKept < total,
168
+ was_dropped: isDropped,
169
+ estimated_tokens: isDropped ? 0 : sectionTokens(section, passItems.get(section.id)),
170
+ };
171
+ }
172
+ return {
173
+ id: section.id,
174
+ title: section.title,
175
+ kind: 'text',
176
+ policy_applied: policy,
177
+ was_trimmed: false,
178
+ was_dropped: isDropped,
179
+ estimated_tokens: isDropped ? 0 : sectionTokens(section),
180
+ };
181
+ });
182
+ return {
183
+ markdown,
184
+ metadata: {
185
+ estimated_tokens_before: baselineTokens,
186
+ estimated_tokens_after: tokens,
187
+ budget_target: target,
188
+ budget_max: max,
189
+ trimmed_sections: traces.filter((t) => t.was_trimmed).map((t) => t.id),
190
+ dropped_sections: traces.filter((t) => t.was_dropped).map((t) => t.id),
191
+ sections: traces,
192
+ },
193
+ };
194
+ }
@@ -1,36 +1,15 @@
1
+ import { buildPreparedCommand, runSpawn } from './runner-exec.js';
1
2
  export class ClaudeRunnerAdapter {
2
3
  runner = 'claude';
3
4
  prepare(input) {
4
- const args = ['run', '--prompt-file', input.promptPath, '--role', input.agentRole];
5
- return {
5
+ return buildPreparedCommand(input, 'claude', {
6
6
  command: 'claude',
7
- args,
8
- shellLine: `claude ${args.join(' ')}`,
9
- };
7
+ buildArgs: (prompt) => ['-p', prompt, '--permission-mode', 'acceptEdits'],
8
+ shellLine: (_prompt, promptPath) => `claude -p "<prompt from ${promptPath}>"`,
9
+ });
10
10
  }
11
- execute(input) {
12
- if (input.dryRun) {
13
- return {
14
- ok: true,
15
- executed: false,
16
- exitCode: 0,
17
- summary: 'Dry run: prepared Claude command only.',
18
- commandsRun: [input.prepared.shellLine],
19
- validationResults: ['dry-run'],
20
- blockers: [],
21
- followUps: [],
22
- };
23
- }
24
- return {
25
- ok: true,
26
- executed: false,
27
- exitCode: 0,
28
- summary: 'Prepared Claude command. Live execution is placeholder behavior in v1.',
29
- commandsRun: [input.prepared.shellLine],
30
- validationResults: ['runner execute placeholder'],
31
- blockers: [],
32
- followUps: ['Integrate real Claude command execution in runner adapter.'],
33
- };
11
+ async execute(input) {
12
+ return runSpawn(input, 'claude', 'Claude');
34
13
  }
35
14
  collectResult(result) {
36
15
  return result;
@@ -1,36 +1,15 @@
1
+ import { buildPreparedCommand, runSpawn } from './runner-exec.js';
1
2
  export class CodexRunnerAdapter {
2
3
  runner = 'codex';
3
4
  prepare(input) {
4
- const args = ['run', '--prompt-file', input.promptPath, '--role', input.agentRole];
5
- return {
5
+ return buildPreparedCommand(input, 'codex', {
6
6
  command: 'codex',
7
- args,
8
- shellLine: `codex ${args.join(' ')}`,
9
- };
7
+ buildArgs: (prompt) => ['exec', prompt],
8
+ shellLine: (_prompt, promptPath) => `codex exec "<prompt from ${promptPath}>"`,
9
+ });
10
10
  }
11
- execute(input) {
12
- if (input.dryRun) {
13
- return {
14
- ok: true,
15
- executed: false,
16
- exitCode: 0,
17
- summary: 'Dry run: prepared Codex command only.',
18
- commandsRun: [input.prepared.shellLine],
19
- validationResults: ['dry-run'],
20
- blockers: [],
21
- followUps: [],
22
- };
23
- }
24
- return {
25
- ok: true,
26
- executed: false,
27
- exitCode: 0,
28
- summary: 'Prepared Codex command. Live execution is placeholder behavior in v1.',
29
- commandsRun: [input.prepared.shellLine],
30
- validationResults: ['runner execute placeholder'],
31
- blockers: [],
32
- followUps: ['Integrate real Codex command execution in runner adapter.'],
33
- };
11
+ async execute(input) {
12
+ return runSpawn(input, 'codex', 'Codex');
34
13
  }
35
14
  collectResult(result) {
36
15
  return result;
@@ -1,10 +1,20 @@
1
1
  import fs from 'node:fs';
2
2
  import { getSidecarPaths } from '../lib/paths.js';
3
+ export const REVIEW_PREFERENCE_DEFAULTS = {
4
+ auto_approve_on_all_green: false,
5
+ };
3
6
  const DEFAULTS = {
4
7
  default_runner: 'codex',
5
8
  preferred_runners: ['codex', 'claude'],
6
9
  default_agent_role: 'builder-app',
7
10
  };
11
+ export const PROMPT_PREFERENCE_DEFAULTS = {
12
+ budget_target: 1200,
13
+ budget_max: 1500,
14
+ };
15
+ // Hard safety bounds. Prevents misconfiguration from producing empty or absurdly large prompts.
16
+ export const PROMPT_BUDGET_MIN = 200;
17
+ export const PROMPT_BUDGET_CEILING = 20000;
8
18
  export function loadRunnerPreferences(rootPath) {
9
19
  const prefsPath = getSidecarPaths(rootPath).preferencesPath;
10
20
  if (!fs.existsSync(prefsPath))
@@ -14,6 +24,7 @@ export function loadRunnerPreferences(rootPath) {
14
24
  const defaultRunner = raw.runner?.defaultRunner;
15
25
  const preferredRunners = raw.runner?.preferredRunners;
16
26
  const defaultAgentRole = raw.runner?.defaultAgentRole;
27
+ const runnerCommands = parseRunnerCommands(raw.runner?.runnerCommands);
17
28
  return {
18
29
  default_runner: defaultRunner === 'codex' || defaultRunner === 'claude' ? defaultRunner : DEFAULTS.default_runner,
19
30
  preferred_runners: Array.isArray(preferredRunners) && preferredRunners.every((r) => r === 'codex' || r === 'claude')
@@ -31,9 +42,73 @@ export function loadRunnerPreferences(rootPath) {
31
42
  }
32
43
  return DEFAULTS.default_agent_role;
33
44
  })(),
45
+ ...(runnerCommands ? { runner_commands: runnerCommands } : {}),
34
46
  };
35
47
  }
36
48
  catch {
37
49
  return DEFAULTS;
38
50
  }
39
51
  }
52
+ function parseRunnerCommandOverride(raw) {
53
+ if (!raw || typeof raw !== 'object')
54
+ return undefined;
55
+ const r = raw;
56
+ const override = {};
57
+ if (typeof r.command === 'string' && r.command.length > 0)
58
+ override.command = r.command;
59
+ if (Array.isArray(r.args) && r.args.every((a) => typeof a === 'string'))
60
+ override.args = r.args;
61
+ return override.command || override.args ? override : undefined;
62
+ }
63
+ function parseRunnerCommands(raw) {
64
+ if (!raw || typeof raw !== 'object')
65
+ return undefined;
66
+ const r = raw;
67
+ const claude = parseRunnerCommandOverride(r.claude);
68
+ const codex = parseRunnerCommandOverride(r.codex);
69
+ if (!claude && !codex)
70
+ return undefined;
71
+ return {
72
+ ...(claude ? { claude } : {}),
73
+ ...(codex ? { codex } : {}),
74
+ };
75
+ }
76
+ function clampBudget(value, fallback) {
77
+ if (!Number.isFinite(value))
78
+ return fallback;
79
+ return Math.max(PROMPT_BUDGET_MIN, Math.min(PROMPT_BUDGET_CEILING, Math.floor(value)));
80
+ }
81
+ export function loadReviewPreferences(rootPath) {
82
+ const prefsPath = getSidecarPaths(rootPath).preferencesPath;
83
+ if (!fs.existsSync(prefsPath))
84
+ return { ...REVIEW_PREFERENCE_DEFAULTS };
85
+ try {
86
+ const raw = JSON.parse(fs.readFileSync(prefsPath, 'utf8'));
87
+ const flag = raw.review?.autoApproveOnAllGreen;
88
+ return {
89
+ auto_approve_on_all_green: flag === true,
90
+ };
91
+ }
92
+ catch {
93
+ return { ...REVIEW_PREFERENCE_DEFAULTS };
94
+ }
95
+ }
96
+ export function loadPromptPreferences(rootPath) {
97
+ const prefsPath = getSidecarPaths(rootPath).preferencesPath;
98
+ if (!fs.existsSync(prefsPath))
99
+ return { ...PROMPT_PREFERENCE_DEFAULTS };
100
+ try {
101
+ const raw = JSON.parse(fs.readFileSync(prefsPath, 'utf8'));
102
+ const rawTarget = typeof raw.prompt?.budgetTarget === 'number' ? raw.prompt.budgetTarget : NaN;
103
+ const rawMax = typeof raw.prompt?.budgetMax === 'number' ? raw.prompt.budgetMax : NaN;
104
+ const target = clampBudget(rawTarget, PROMPT_PREFERENCE_DEFAULTS.budget_target);
105
+ let max = clampBudget(rawMax, PROMPT_PREFERENCE_DEFAULTS.budget_max);
106
+ // Max must be >= target; otherwise the safety valve can never trigger correctly.
107
+ if (max < target)
108
+ max = target;
109
+ return { budget_target: target, budget_max: max };
110
+ }
111
+ catch {
112
+ return { ...PROMPT_PREFERENCE_DEFAULTS };
113
+ }
114
+ }
@@ -0,0 +1,152 @@
1
+ import { spawn } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { loadRunnerPreferences } from './config.js';
5
+ export function buildPreparedCommand(input, runner, defaults) {
6
+ const prompt = readPromptFile(input.promptPath);
7
+ const override = loadOverride(input.projectRoot, runner);
8
+ if (override) {
9
+ const command = override.command ?? defaults.command;
10
+ const args = override.args
11
+ ? override.args.map((a) => substituteTokens(a, prompt, input.promptPath, input.agentRole))
12
+ : defaults.buildArgs(prompt, input.promptPath, input.agentRole);
13
+ return {
14
+ command,
15
+ args,
16
+ shellLine: defaults.shellLine(prompt, input.promptPath, input.agentRole),
17
+ };
18
+ }
19
+ return {
20
+ command: defaults.command,
21
+ args: defaults.buildArgs(prompt, input.promptPath, input.agentRole),
22
+ shellLine: defaults.shellLine(prompt, input.promptPath, input.agentRole),
23
+ };
24
+ }
25
+ function readPromptFile(promptPath) {
26
+ try {
27
+ return fs.readFileSync(promptPath, 'utf8');
28
+ }
29
+ catch {
30
+ return '';
31
+ }
32
+ }
33
+ function loadOverride(projectRoot, runner) {
34
+ try {
35
+ const prefs = loadRunnerPreferences(projectRoot);
36
+ return prefs.runner_commands?.[runner];
37
+ }
38
+ catch {
39
+ return undefined;
40
+ }
41
+ }
42
+ function substituteTokens(value, prompt, promptPath, role) {
43
+ return value
44
+ .replace(/\{\{prompt\}\}/g, prompt)
45
+ .replace(/\{\{promptPath\}\}/g, promptPath)
46
+ .replace(/\{\{role\}\}/g, role);
47
+ }
48
+ export async function runSpawn(input, runner, runnerLabel) {
49
+ if (input.dryRun) {
50
+ return {
51
+ ok: true,
52
+ executed: false,
53
+ exitCode: 0,
54
+ summary: `Dry run: prepared ${runnerLabel} command only.`,
55
+ commandsRun: [input.prepared.shellLine],
56
+ validationResults: ['dry-run'],
57
+ blockers: [],
58
+ followUps: [],
59
+ durationMs: 0,
60
+ };
61
+ }
62
+ const started = Date.now();
63
+ const header = `# ${runner} run at ${new Date().toISOString()}\n# cmd: ${input.prepared.shellLine}\n# cwd: ${input.cwd}\n\n`;
64
+ let logStream = null;
65
+ try {
66
+ fs.mkdirSync(path.dirname(input.logPath), { recursive: true });
67
+ logStream = fs.createWriteStream(input.logPath, { flags: 'w' });
68
+ logStream.write(header);
69
+ }
70
+ catch {
71
+ logStream = null;
72
+ }
73
+ const env = { ...process.env, ...(input.env ?? {}) };
74
+ try {
75
+ const child = spawn(input.prepared.command, input.prepared.args, {
76
+ cwd: input.cwd,
77
+ env,
78
+ stdio: ['inherit', 'pipe', 'pipe'],
79
+ });
80
+ const streamTarget = input.streamOutput ?? 'stdout';
81
+ const stdoutSink = streamTarget === 'stdout'
82
+ ? process.stdout
83
+ : streamTarget === 'stderr'
84
+ ? process.stderr
85
+ : null;
86
+ const stderrSink = streamTarget === 'none' ? null : process.stderr;
87
+ child.stdout?.on('data', (chunk) => {
88
+ stdoutSink?.write(chunk);
89
+ logStream?.write(chunk);
90
+ });
91
+ child.stderr?.on('data', (chunk) => {
92
+ stderrSink?.write(chunk);
93
+ logStream?.write(chunk);
94
+ });
95
+ const exitCode = await new Promise((resolve, reject) => {
96
+ child.on('error', reject);
97
+ child.on('close', (code) => resolve(code === null ? -1 : code));
98
+ });
99
+ const durationMs = Date.now() - started;
100
+ logStream?.end();
101
+ const seconds = Math.round(durationMs / 1000);
102
+ if (exitCode === 0) {
103
+ return {
104
+ ok: true,
105
+ executed: true,
106
+ exitCode,
107
+ summary: `${runnerLabel} run completed (exit 0) in ${seconds}s.`,
108
+ commandsRun: [input.prepared.shellLine],
109
+ validationResults: [],
110
+ blockers: [],
111
+ followUps: [],
112
+ logPath: input.logPath,
113
+ durationMs,
114
+ };
115
+ }
116
+ return {
117
+ ok: false,
118
+ executed: true,
119
+ exitCode,
120
+ summary: `${runnerLabel} run failed (exit ${exitCode}) in ${seconds}s. See log: ${input.logPath}`,
121
+ commandsRun: [input.prepared.shellLine],
122
+ validationResults: [],
123
+ blockers: [`runner exited with code ${exitCode}`],
124
+ followUps: [],
125
+ logPath: input.logPath,
126
+ durationMs,
127
+ };
128
+ }
129
+ catch (err) {
130
+ const durationMs = Date.now() - started;
131
+ const message = err instanceof Error ? err.message : String(err);
132
+ try {
133
+ logStream?.write(`\n# spawn error: ${message}\n`);
134
+ logStream?.end();
135
+ }
136
+ catch {
137
+ /* ignore */
138
+ }
139
+ return {
140
+ ok: false,
141
+ executed: false,
142
+ exitCode: -1,
143
+ summary: `Failed to launch ${runnerLabel}: ${message}`,
144
+ commandsRun: [input.prepared.shellLine],
145
+ validationResults: [],
146
+ blockers: [message],
147
+ followUps: [`Install the ${runnerLabel} CLI or override the command in .sidecar/preferences.json`],
148
+ logPath: input.logPath,
149
+ durationMs,
150
+ };
151
+ }
152
+ }