@welluable/orch 1.2.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +110 -14
- package/agents/adjust.js +66 -0
- package/agents/ask.js +3 -5
- package/agents/boundaries.js +3 -4
- package/agents/code-writer.js +3 -5
- package/agents/decomposer.js +5 -5
- package/agents/index.js +2 -0
- package/agents/integrator.js +3 -4
- package/agents/planner.js +3 -5
- package/agents/quick-fix.js +3 -5
- package/agents/research.js +3 -5
- package/agents/seq-decomposer.js +58 -0
- package/agents/summary-footer.js +23 -0
- package/agents/test-critic.js +5 -6
- package/agents/test-runner.js +5 -6
- package/agents/test-writer.js +3 -5
- package/agents/triage.js +5 -6
- package/lib/agent-agn.js +4 -2
- package/lib/agent-cursor.js +4 -2
- package/lib/agent-opencode.js +177 -0
- package/lib/agent.js +165 -14
- package/lib/config.js +190 -0
- package/lib/continue.js +150 -0
- package/lib/failure-log.js +72 -0
- package/lib/file-tracker.js +68 -12
- package/lib/jobs.js +209 -7
- package/lib/notify.js +71 -0
- package/lib/resume.js +264 -0
- package/lib/seq.js +281 -0
- package/lib/stage-summary.js +24 -0
- package/lib/tool-status.js +53 -6
- package/main.js +3116 -360
- package/package.json +1 -1
package/lib/agent-cursor.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Agent } from './agent.js';
|
|
1
|
+
import { Agent, appendVerbose } from './agent.js';
|
|
2
2
|
import { normalizeCursorToolEvent } from './tool-status.js';
|
|
3
3
|
|
|
4
4
|
export class AgentCursor extends Agent {
|
|
@@ -26,8 +26,10 @@ export class AgentCursor extends Agent {
|
|
|
26
26
|
case 'thinking': {
|
|
27
27
|
switch (event.subtype) {
|
|
28
28
|
case 'delta': {
|
|
29
|
+
const text = event.text ?? '';
|
|
30
|
+
appendVerbose(text);
|
|
29
31
|
if (verbose) {
|
|
30
|
-
process.stderr.write(
|
|
32
|
+
process.stderr.write(text);
|
|
31
33
|
} else if (this.activeTools.size === 0) {
|
|
32
34
|
this.setStatus('thinking…');
|
|
33
35
|
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { Agent, modelPrintState, appendVerbose } from './agent.js';
|
|
2
|
+
import { normalizeOpencodeToolEvent } from './tool-status.js';
|
|
3
|
+
|
|
4
|
+
const OPENCODE_DENY_PERMISSION = '{"edit":"deny","bash":"deny"}';
|
|
5
|
+
|
|
6
|
+
export class AgentOpencode extends Agent {
|
|
7
|
+
#textBuffer = '';
|
|
8
|
+
#sawStepStart = false;
|
|
9
|
+
#pendingStop = false;
|
|
10
|
+
#settled = false;
|
|
11
|
+
|
|
12
|
+
getSpawnConfig(promptToSend) {
|
|
13
|
+
if (this.readOnly) {
|
|
14
|
+
return {
|
|
15
|
+
command: 'opencode',
|
|
16
|
+
args: ['run', '--format', 'json', '--auto', '--thinking', '--agent', 'plan', promptToSend],
|
|
17
|
+
options: {
|
|
18
|
+
cwd: this.cwd,
|
|
19
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
20
|
+
env: { ...process.env, OPENCODE_PERMISSION: OPENCODE_DENY_PERMISSION },
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
command: 'opencode',
|
|
27
|
+
args: ['run', '--format', 'json', '--auto', '--thinking', promptToSend],
|
|
28
|
+
options: {
|
|
29
|
+
cwd: this.cwd,
|
|
30
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
31
|
+
env: process.env,
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
#printModelOnce(modelId) {
|
|
37
|
+
if (modelPrintState.printed || typeof modelId !== 'string' || !modelId) return;
|
|
38
|
+
modelPrintState.printed = true;
|
|
39
|
+
const wasSpinning = this.spinner?.isSpinning;
|
|
40
|
+
if (wasSpinning) this.spinner.stop?.();
|
|
41
|
+
console.log(`model: ${modelId}`);
|
|
42
|
+
if (wasSpinning) this.spinner.start?.();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
#printUsageLine(part) {
|
|
46
|
+
const tokens = part?.tokens;
|
|
47
|
+
if (!tokens || typeof tokens !== 'object') return;
|
|
48
|
+
const input = tokens.input ?? tokens.in;
|
|
49
|
+
const output = tokens.output ?? tokens.out;
|
|
50
|
+
if (typeof input !== 'number' && typeof output !== 'number') return;
|
|
51
|
+
|
|
52
|
+
let line = ` tokens: in=${input ?? 0} out=${output ?? 0}`;
|
|
53
|
+
const cost = part.cost;
|
|
54
|
+
if (typeof cost === 'number' && cost > 0) {
|
|
55
|
+
line += ` · cost=$${cost}`;
|
|
56
|
+
}
|
|
57
|
+
process.stderr.write(`${line}\n`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
#settleOk(finish, { verbose = false, part = null } = {}) {
|
|
61
|
+
if (this.#settled) return;
|
|
62
|
+
this.#settled = true;
|
|
63
|
+
this.#pendingStop = false;
|
|
64
|
+
this.settleResult(
|
|
65
|
+
{
|
|
66
|
+
is_error: false,
|
|
67
|
+
result: this.#textBuffer,
|
|
68
|
+
duration_ms: undefined,
|
|
69
|
+
},
|
|
70
|
+
finish,
|
|
71
|
+
);
|
|
72
|
+
if (verbose && part) this.#printUsageLine(part);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
#recordApplyPatchFiles(toolEvent) {
|
|
76
|
+
if (toolEvent.name !== 'apply_patch' || !this.fileTracker) return;
|
|
77
|
+
const entries = this.fileTracker.recordApplyPatch(toolEvent.args?.patchText);
|
|
78
|
+
for (const entry of entries) {
|
|
79
|
+
if (!entry.isNew) continue;
|
|
80
|
+
const wasSpinning = this.spinner?.isSpinning;
|
|
81
|
+
if (wasSpinning) this.spinner.stop();
|
|
82
|
+
console.log(` ${entry.marker} ${entry.path}`);
|
|
83
|
+
if (wasSpinning) this.spinner.start();
|
|
84
|
+
this.onFileChange?.(entry);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
handleStreamEvent(event, { verbose, finish }) {
|
|
89
|
+
switch (event.type) {
|
|
90
|
+
case 'message.updated': {
|
|
91
|
+
this.#printModelOnce(event.properties?.info?.modelID);
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
case 'step_start': {
|
|
95
|
+
if (!this.#sawStepStart) {
|
|
96
|
+
this.#sawStepStart = true;
|
|
97
|
+
this.setStatus('connected');
|
|
98
|
+
} else if (this.activeTools.size === 0) {
|
|
99
|
+
this.setStatus('thinking…');
|
|
100
|
+
}
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
case 'tool_use': {
|
|
104
|
+
const toolEvent = normalizeOpencodeToolEvent(event);
|
|
105
|
+
if (!toolEvent) break;
|
|
106
|
+
this.onToolEvent({ ...toolEvent, phase: 'started' });
|
|
107
|
+
this.onToolEvent(toolEvent);
|
|
108
|
+
this.#recordApplyPatchFiles(toolEvent);
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
case 'reasoning': {
|
|
112
|
+
const text = event.part?.text ?? '';
|
|
113
|
+
appendVerbose(text);
|
|
114
|
+
if (verbose) {
|
|
115
|
+
process.stderr.write(text);
|
|
116
|
+
} else if (this.activeTools.size === 0) {
|
|
117
|
+
this.setStatus('thinking…');
|
|
118
|
+
}
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
case 'text': {
|
|
122
|
+
const text = event.part?.text ?? '';
|
|
123
|
+
this.#textBuffer += text;
|
|
124
|
+
appendVerbose(text);
|
|
125
|
+
if (verbose) {
|
|
126
|
+
process.stderr.write(text);
|
|
127
|
+
} else if (this.activeTools.size === 0) {
|
|
128
|
+
this.setStatus('composing response…');
|
|
129
|
+
}
|
|
130
|
+
if (this.#pendingStop && this.#textBuffer !== '') {
|
|
131
|
+
this.#settleOk(finish, { verbose });
|
|
132
|
+
}
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
case 'step_finish': {
|
|
136
|
+
const reason = event.part?.reason;
|
|
137
|
+
if (reason === 'tool-calls') break;
|
|
138
|
+
if (reason === 'stop' || reason == null) {
|
|
139
|
+
if (this.#textBuffer !== '') {
|
|
140
|
+
this.#settleOk(finish, { verbose, part: event.part });
|
|
141
|
+
} else {
|
|
142
|
+
this.#pendingStop = true;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
case 'error': {
|
|
148
|
+
if (this.#settled) break;
|
|
149
|
+
this.#settled = true;
|
|
150
|
+
this.#pendingStop = false;
|
|
151
|
+
const message =
|
|
152
|
+
event.error?.data?.message ||
|
|
153
|
+
event.error?.name ||
|
|
154
|
+
'OpenCode error';
|
|
155
|
+
this.settleResult(
|
|
156
|
+
{
|
|
157
|
+
is_error: true,
|
|
158
|
+
result: message,
|
|
159
|
+
},
|
|
160
|
+
finish,
|
|
161
|
+
);
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
default:
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
handleProcessClose(code, finish) {
|
|
170
|
+
if (this.#settled) return;
|
|
171
|
+
if (code === 0 && this.#textBuffer !== '') {
|
|
172
|
+
this.#settleOk(finish);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
super.handleProcessClose(code, finish);
|
|
176
|
+
}
|
|
177
|
+
}
|
package/lib/agent.js
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import { spawn } from 'child_process';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import ora from 'ora';
|
|
4
|
-
import { formatActiveTools } from './tool-status.js';
|
|
5
|
-
import { patchJob } from './jobs.js';
|
|
4
|
+
import { formatActiveTools, toolPath } from './tool-status.js';
|
|
5
|
+
import { patchJob, buildLastOutcome, jobPaths, readJob } from './jobs.js';
|
|
6
|
+
import {
|
|
7
|
+
appendFailureLog,
|
|
8
|
+
failureLogPointer,
|
|
9
|
+
formatFailureSection,
|
|
10
|
+
} from './failure-log.js';
|
|
6
11
|
|
|
7
12
|
/**
|
|
8
13
|
* Every child process spawned by an agent, tracked so we can reap them all
|
|
@@ -43,6 +48,116 @@ export function setJobSlug(slug) {
|
|
|
43
48
|
activeJobSlug = slug;
|
|
44
49
|
}
|
|
45
50
|
|
|
51
|
+
/** Active job slug: env (detached child) wins over in-process foreground slug. */
|
|
52
|
+
export function getActiveJobSlug() {
|
|
53
|
+
return process.env.ORCH_JOB_SLUG ?? activeJobSlug;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Current stage capture + prior-stage ring for failure.log (independent of -v). */
|
|
57
|
+
let currentCapture = null;
|
|
58
|
+
/** @type {{ phase: *, stage: *, round: *, verbose: string }[]} */
|
|
59
|
+
let priorCaptures = [];
|
|
60
|
+
const PRIOR_RING_MAX = 8;
|
|
61
|
+
|
|
62
|
+
/** Clear failure-log buffers (also invoked from `resetShutdownState`). */
|
|
63
|
+
export function resetFailureLogState() {
|
|
64
|
+
currentCapture = null;
|
|
65
|
+
priorCaptures = [];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Start capturing verbose for a stage. Rotates a non-empty current buffer into
|
|
70
|
+
* the prior-stage ring so recover can see more than the final frame.
|
|
71
|
+
*/
|
|
72
|
+
export function beginStageCapture({ phase = null, stage = null, round = null } = {}) {
|
|
73
|
+
if (currentCapture && currentCapture.verbose) {
|
|
74
|
+
priorCaptures.push({
|
|
75
|
+
phase: currentCapture.phase,
|
|
76
|
+
stage: currentCapture.stage,
|
|
77
|
+
round: currentCapture.round,
|
|
78
|
+
verbose: currentCapture.verbose,
|
|
79
|
+
});
|
|
80
|
+
if (priorCaptures.length > PRIOR_RING_MAX) {
|
|
81
|
+
priorCaptures.splice(0, priorCaptures.length - PRIOR_RING_MAX);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
currentCapture = { phase, stage, round, verbose: '' };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Append a verbose chunk when a job slug is active. Never gated on `-v`
|
|
89
|
+
* (stderr streaming stays verbose-gated in the backends).
|
|
90
|
+
*/
|
|
91
|
+
export function appendVerbose(chunk) {
|
|
92
|
+
if (!getActiveJobSlug()) return;
|
|
93
|
+
if (chunk == null || chunk === '') return;
|
|
94
|
+
if (!currentCapture) {
|
|
95
|
+
currentCapture = { phase: null, stage: null, round: null, verbose: '' };
|
|
96
|
+
}
|
|
97
|
+
currentCapture.verbose += String(chunk);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Append one `=== orch failure ===` section to `.orch/<slug>/failure.log`,
|
|
102
|
+
* patch `run.json.failureLogPath`, and return a pointer string. No-op when
|
|
103
|
+
* `slug` is falsy.
|
|
104
|
+
*/
|
|
105
|
+
export function flushFailureLog({
|
|
106
|
+
cwd,
|
|
107
|
+
slug,
|
|
108
|
+
state,
|
|
109
|
+
exitCode = null,
|
|
110
|
+
finishedAt,
|
|
111
|
+
task = null,
|
|
112
|
+
error = null,
|
|
113
|
+
} = {}) {
|
|
114
|
+
if (!slug) return null;
|
|
115
|
+
|
|
116
|
+
const resolvedCwd = cwd ?? process.cwd();
|
|
117
|
+
let phase = currentCapture?.phase ?? null;
|
|
118
|
+
let stage = currentCapture?.stage ?? null;
|
|
119
|
+
let round = currentCapture?.round ?? null;
|
|
120
|
+
let resolvedTask = task;
|
|
121
|
+
|
|
122
|
+
if (phase == null || stage == null || round == null || resolvedTask == null) {
|
|
123
|
+
try {
|
|
124
|
+
const record = readJob(resolvedCwd, slug);
|
|
125
|
+
if (record) {
|
|
126
|
+
if (phase == null) phase = record.phase ?? null;
|
|
127
|
+
if (stage == null) stage = record.stage ?? null;
|
|
128
|
+
if (round == null) round = record.round ?? null;
|
|
129
|
+
if (resolvedTask == null) resolvedTask = record.task ?? null;
|
|
130
|
+
}
|
|
131
|
+
} catch {
|
|
132
|
+
// Best-effort cursor fill from run.json.
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const { failureLogPath } = jobPaths(resolvedCwd, slug);
|
|
137
|
+
const section = formatFailureSection({
|
|
138
|
+
slug,
|
|
139
|
+
state,
|
|
140
|
+
phase,
|
|
141
|
+
stage,
|
|
142
|
+
round,
|
|
143
|
+
exitCode,
|
|
144
|
+
finishedAt,
|
|
145
|
+
task: resolvedTask,
|
|
146
|
+
error,
|
|
147
|
+
stageVerbose: currentCapture?.verbose ?? '',
|
|
148
|
+
priorStages: priorCaptures,
|
|
149
|
+
});
|
|
150
|
+
appendFailureLog(failureLogPath, section);
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
patchJob(resolvedCwd, slug, { failureLogPath });
|
|
154
|
+
} catch {
|
|
155
|
+
// Best-effort: file is already on disk even if the patch fails.
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return failureLogPointer(slug);
|
|
159
|
+
}
|
|
160
|
+
|
|
46
161
|
/** Conventional shell status: 128 + signal number. */
|
|
47
162
|
export function exitCodeForSignal(signal) {
|
|
48
163
|
if (signal === 'SIGINT') return 130;
|
|
@@ -67,11 +182,32 @@ export function shutdown(signal, { exit = (code) => process.exit(code), jobCwd =
|
|
|
67
182
|
const jobSlug = process.env.ORCH_JOB_SLUG ?? activeJobSlug;
|
|
68
183
|
if (jobSlug) {
|
|
69
184
|
try {
|
|
70
|
-
|
|
185
|
+
const exitCode = exitCodeForSignal(signal);
|
|
186
|
+
const finishedAt = new Date().toISOString();
|
|
187
|
+
const pointer = flushFailureLog({
|
|
188
|
+
cwd: jobCwd,
|
|
189
|
+
slug: jobSlug,
|
|
71
190
|
state: 'stopped',
|
|
72
|
-
exitCode
|
|
73
|
-
finishedAt
|
|
191
|
+
exitCode,
|
|
192
|
+
finishedAt,
|
|
193
|
+
error: signal,
|
|
74
194
|
});
|
|
195
|
+
patchJob(jobCwd, jobSlug, (current) => ({
|
|
196
|
+
state: 'stopped',
|
|
197
|
+
exitCode,
|
|
198
|
+
finishedAt,
|
|
199
|
+
lastOutcome: buildLastOutcome({
|
|
200
|
+
state: 'stopped',
|
|
201
|
+
phase: current.phase,
|
|
202
|
+
stage: current.stage,
|
|
203
|
+
round: current.round,
|
|
204
|
+
exitCode,
|
|
205
|
+
finishedAt,
|
|
206
|
+
task: current.task,
|
|
207
|
+
summary: '',
|
|
208
|
+
error: pointer,
|
|
209
|
+
}),
|
|
210
|
+
}));
|
|
75
211
|
} catch {
|
|
76
212
|
// Best-effort: don't let a job-state write failure block shutdown.
|
|
77
213
|
}
|
|
@@ -112,6 +248,7 @@ export function resetShutdownState() {
|
|
|
112
248
|
shuttingDown = false;
|
|
113
249
|
liveChildren.clear();
|
|
114
250
|
activeJobSlug = null;
|
|
251
|
+
resetFailureLogState();
|
|
115
252
|
}
|
|
116
253
|
|
|
117
254
|
/**
|
|
@@ -196,10 +333,14 @@ export class Agent {
|
|
|
196
333
|
this.refreshSpinnerText();
|
|
197
334
|
}
|
|
198
335
|
|
|
199
|
-
/** @param {{ name: string, args: Record<string, unknown>, phase: 'started'|'completed', callId: string }} toolEvent */
|
|
200
|
-
onToolEvent({ name, args, phase, callId }) {
|
|
336
|
+
/** @param {{ name: string, args: Record<string, unknown>, phase: 'started'|'completed', callId: string, title?: string }} toolEvent */
|
|
337
|
+
onToolEvent({ name, args, phase, callId, title }) {
|
|
201
338
|
if (phase === 'started') {
|
|
202
|
-
this.activeTools.set(callId, {
|
|
339
|
+
this.activeTools.set(callId, {
|
|
340
|
+
name,
|
|
341
|
+
args,
|
|
342
|
+
...(typeof title === 'string' && title ? { title } : {}),
|
|
343
|
+
});
|
|
203
344
|
this.fileTracker?.record({ name, args, phase, callId });
|
|
204
345
|
} else if (phase === 'completed') {
|
|
205
346
|
// Recall before delete — Claude/agn completions often have empty args.
|
|
@@ -207,7 +348,7 @@ export class Agent {
|
|
|
207
348
|
this.activeTools.delete(callId);
|
|
208
349
|
|
|
209
350
|
if (this.fileTracker) {
|
|
210
|
-
const hasPath = Boolean(args
|
|
351
|
+
const hasPath = Boolean(toolPath(args));
|
|
211
352
|
const entry = this.fileTracker.record({
|
|
212
353
|
name: name || prior?.name || '',
|
|
213
354
|
args: hasPath ? args : (prior?.args ?? args ?? {}),
|
|
@@ -266,6 +407,20 @@ export class Agent {
|
|
|
266
407
|
throw new Error('handleStreamEvent must be implemented by subclass');
|
|
267
408
|
}
|
|
268
409
|
|
|
410
|
+
/**
|
|
411
|
+
* Called when the child exits before a settling stream event.
|
|
412
|
+
* Subclasses may override (e.g. defensive success on exit 0 + buffered text).
|
|
413
|
+
* @param {number|null} code
|
|
414
|
+
* @param {(err: Error|null, value?: object) => void} finish
|
|
415
|
+
*/
|
|
416
|
+
handleProcessClose(code, finish) {
|
|
417
|
+
this.stopElapsedTimer();
|
|
418
|
+
if (this.spinner?.isSpinning) {
|
|
419
|
+
this.spinner.fail(`[${this.name}] exited ${code}`);
|
|
420
|
+
}
|
|
421
|
+
finish(new Error(`[${this.name}] exited ${code} before result`));
|
|
422
|
+
}
|
|
423
|
+
|
|
269
424
|
settleResult(event, finish) {
|
|
270
425
|
this.stopElapsedTimer();
|
|
271
426
|
this.activeTools.clear();
|
|
@@ -363,11 +518,7 @@ export class Agent {
|
|
|
363
518
|
liveChildren.delete(child);
|
|
364
519
|
this.process = null;
|
|
365
520
|
if (!settled) {
|
|
366
|
-
this.
|
|
367
|
-
if (this.spinner?.isSpinning) {
|
|
368
|
-
this.spinner.fail(`[${this.name}] exited ${code}`);
|
|
369
|
-
}
|
|
370
|
-
finish(new Error(`[${this.name}] exited ${code} before result`));
|
|
521
|
+
this.handleProcessClose(code, finish);
|
|
371
522
|
}
|
|
372
523
|
});
|
|
373
524
|
});
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
const VALID_AGENTS = new Set(['cursor', 'claude', 'agn', 'opencode']);
|
|
6
|
+
|
|
7
|
+
/** Absolute path to the global orch config file. */
|
|
8
|
+
export function globalConfigPath({ homedir = os.homedir() } = {}) {
|
|
9
|
+
return path.join(homedir, '.orch', 'config');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Absolute path to the project-local orch config file under `cwd`. */
|
|
13
|
+
export function localConfigPath(cwd) {
|
|
14
|
+
return path.join(cwd, '.orch', 'config');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Read a config file. Missing file → `{}`. Bad JSON, unreadable file, or
|
|
19
|
+
* invalid `agent` / `notify` → throws with a message suitable for `Error: …`
|
|
20
|
+
* on stderr. Unknown keys are ignored; `agent` is case-sensitive.
|
|
21
|
+
*/
|
|
22
|
+
export function loadConfig(configPath, displayPath = configPath) {
|
|
23
|
+
if (!fs.existsSync(configPath)) return {};
|
|
24
|
+
|
|
25
|
+
let raw;
|
|
26
|
+
try {
|
|
27
|
+
raw = fs.readFileSync(configPath, 'utf8');
|
|
28
|
+
} catch (err) {
|
|
29
|
+
throw new Error(`could not parse ${displayPath}: ${err.message}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let data;
|
|
33
|
+
try {
|
|
34
|
+
data = JSON.parse(raw);
|
|
35
|
+
} catch (err) {
|
|
36
|
+
throw new Error(`could not parse ${displayPath}: ${err.message}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (data === null || typeof data !== 'object' || Array.isArray(data)) {
|
|
40
|
+
throw new Error(`could not parse ${displayPath}: expected a JSON object`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const out = {};
|
|
44
|
+
|
|
45
|
+
if (Object.prototype.hasOwnProperty.call(data, 'agent') && data.agent !== undefined) {
|
|
46
|
+
if (!VALID_AGENTS.has(data.agent)) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
`invalid agent in ${displayPath}: ${JSON.stringify(data.agent)} (expected "cursor", "claude", "agn", or "opencode")`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
out.agent = data.agent;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (Object.prototype.hasOwnProperty.call(data, 'notify') && data.notify !== undefined) {
|
|
55
|
+
if (typeof data.notify !== 'boolean') {
|
|
56
|
+
throw new Error(
|
|
57
|
+
`invalid notify in ${displayPath}: ${JSON.stringify(data.notify)} (expected true or false)`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
out.notify = data.notify;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Effective agent for a run: `--agent` > local > global > `cursor`.
|
|
68
|
+
* Reads local then global when `cliAgent` is omitted; throws on bad files.
|
|
69
|
+
*/
|
|
70
|
+
export function resolveAgent({ cliAgent, cwd, homedir = os.homedir() } = {}) {
|
|
71
|
+
if (cliAgent) return cliAgent;
|
|
72
|
+
|
|
73
|
+
const local = loadConfig(localConfigPath(cwd), '.orch/config');
|
|
74
|
+
if (local.agent) return local.agent;
|
|
75
|
+
|
|
76
|
+
const global = loadConfig(globalConfigPath({ homedir }), '~/.orch/config');
|
|
77
|
+
if (global.agent) return global.agent;
|
|
78
|
+
|
|
79
|
+
return 'cursor';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Effective notify for a run: CLI > local > global > `true`.
|
|
84
|
+
* `cliNotify` is `true` | `false` | `undefined` (flag omitted).
|
|
85
|
+
*/
|
|
86
|
+
export function resolveNotify({ cliNotify, cwd, homedir = os.homedir() } = {}) {
|
|
87
|
+
if (cliNotify === true || cliNotify === false) return cliNotify;
|
|
88
|
+
|
|
89
|
+
const local = loadConfig(localConfigPath(cwd), '.orch/config');
|
|
90
|
+
if (typeof local.notify === 'boolean') return local.notify;
|
|
91
|
+
|
|
92
|
+
const global = loadConfig(globalConfigPath({ homedir }), '~/.orch/config');
|
|
93
|
+
if (typeof global.notify === 'boolean') return global.notify;
|
|
94
|
+
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Create parent dirs if needed, merge `agent` / `notify` into the existing
|
|
100
|
+
* config object (preserve the other key), overwrite with pretty-printed JSON,
|
|
101
|
+
* return the path written.
|
|
102
|
+
*/
|
|
103
|
+
export function writeConfig(configPath, { agent, notify } = {}) {
|
|
104
|
+
if (agent !== undefined && !VALID_AGENTS.has(agent)) {
|
|
105
|
+
throw new Error(
|
|
106
|
+
`invalid agent: ${JSON.stringify(agent)} (expected "cursor", "claude", "agn", or "opencode")`,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
if (notify !== undefined && typeof notify !== 'boolean') {
|
|
110
|
+
throw new Error(
|
|
111
|
+
`invalid notify: ${JSON.stringify(notify)} (expected true or false)`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
let existing = {};
|
|
116
|
+
if (fs.existsSync(configPath)) {
|
|
117
|
+
try {
|
|
118
|
+
const raw = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
119
|
+
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
|
|
120
|
+
existing = raw;
|
|
121
|
+
}
|
|
122
|
+
} catch {
|
|
123
|
+
// Corrupt file: start fresh; caller already validated inputs.
|
|
124
|
+
existing = {};
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const next = { ...existing };
|
|
129
|
+
if (agent !== undefined) next.agent = agent;
|
|
130
|
+
if (notify !== undefined) next.notify = notify;
|
|
131
|
+
|
|
132
|
+
// Persist only known keys (drop unknown junk from a prior merge? Spec says
|
|
133
|
+
// unknown keys are ignored on load but does not require stripping on write.
|
|
134
|
+
// Keep agent/notify; preserve other keys already present.)
|
|
135
|
+
const out = {};
|
|
136
|
+
if (next.agent !== undefined) out.agent = next.agent;
|
|
137
|
+
if (next.notify !== undefined) out.notify = next.notify;
|
|
138
|
+
for (const [k, v] of Object.entries(next)) {
|
|
139
|
+
if (k === 'agent' || k === 'notify') continue;
|
|
140
|
+
out[k] = v;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
144
|
+
fs.writeFileSync(configPath, `${JSON.stringify(out, null, 2)}\n`);
|
|
145
|
+
return configPath;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Print effective agent/notify and which file(s) contributed (stdout). Throws
|
|
150
|
+
* on invalid existing files — same fail-fast contract as a run.
|
|
151
|
+
*/
|
|
152
|
+
export function printConfig({
|
|
153
|
+
cwd,
|
|
154
|
+
homedir = os.homedir(),
|
|
155
|
+
log = console.log,
|
|
156
|
+
} = {}) {
|
|
157
|
+
const localPath = localConfigPath(cwd);
|
|
158
|
+
const globalPath = globalConfigPath({ homedir });
|
|
159
|
+
const local = loadConfig(localPath, '.orch/config');
|
|
160
|
+
const global = loadConfig(globalPath, '~/.orch/config');
|
|
161
|
+
|
|
162
|
+
let agent = 'cursor';
|
|
163
|
+
let source = 'default (builtin)';
|
|
164
|
+
if (local.agent) {
|
|
165
|
+
agent = local.agent;
|
|
166
|
+
source = `local (${localPath})`;
|
|
167
|
+
} else if (global.agent) {
|
|
168
|
+
agent = global.agent;
|
|
169
|
+
source = `global (${globalPath})`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
let notify = true;
|
|
173
|
+
let notifySource = 'default (builtin)';
|
|
174
|
+
if (typeof local.notify === 'boolean') {
|
|
175
|
+
notify = local.notify;
|
|
176
|
+
notifySource = `local (${localPath})`;
|
|
177
|
+
} else if (typeof global.notify === 'boolean') {
|
|
178
|
+
notify = global.notify;
|
|
179
|
+
notifySource = `global (${globalPath})`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
log(`agent=${agent}`);
|
|
183
|
+
log(`source=${source}`);
|
|
184
|
+
log(`global=${global.agent ?? 'unset'} (${globalPath})`);
|
|
185
|
+
log(`local=${local.agent ?? 'unset'} (${localPath})`);
|
|
186
|
+
log(`notify=${notify}`);
|
|
187
|
+
log(`notifySource=${notifySource}`);
|
|
188
|
+
log(`notifyGlobal=${typeof global.notify === 'boolean' ? global.notify : 'unset'} (${globalPath})`);
|
|
189
|
+
log(`notifyLocal=${typeof local.notify === 'boolean' ? local.notify : 'unset'} (${localPath})`);
|
|
190
|
+
}
|