omp-multi-harness 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.
- package/LICENSE +21 -0
- package/README.md +351 -0
- package/package.json +76 -0
- package/scripts/cli.ts +164 -0
- package/scripts/setup/claude.ts +41 -0
- package/scripts/setup/codex.ts +35 -0
- package/scripts/setup/omp.ts +167 -0
- package/scripts/setup/toolchain.ts +81 -0
- package/scripts/setup/types.ts +76 -0
- package/scripts/setup.ts +116 -0
- package/src/agents/availability.ts +106 -0
- package/src/agents/claude-events.ts +125 -0
- package/src/agents/claude.ts +226 -0
- package/src/agents/codex-events.ts +149 -0
- package/src/agents/codex.ts +236 -0
- package/src/agents/types.ts +81 -0
- package/src/commands/agents.ts +140 -0
- package/src/commands/delegate-command.ts +159 -0
- package/src/commands/harness-setup.ts +94 -0
- package/src/commands/sessions.ts +394 -0
- package/src/config/load.ts +78 -0
- package/src/config/schema.ts +249 -0
- package/src/index.ts +129 -0
- package/src/process/executable.ts +49 -0
- package/src/process/jsonl.ts +124 -0
- package/src/process/process-error.ts +178 -0
- package/src/process/redact.ts +120 -0
- package/src/process/spawn-agent.ts +218 -0
- package/src/routing/handoff.ts +59 -0
- package/src/routing/prompt.ts +72 -0
- package/src/routing/route.ts +286 -0
- package/src/runs/lock.ts +158 -0
- package/src/runs/registry.ts +379 -0
- package/src/runs/ring-buffer.ts +81 -0
- package/src/runs/types.ts +141 -0
- package/src/sessions/resume.ts +163 -0
- package/src/sessions/store.ts +273 -0
- package/src/tools/agent-runs.ts +169 -0
- package/src/tools/ask-agent.ts +230 -0
- package/src/tools/delegate.ts +196 -0
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/sessions` — list, watch, focus, and cancel **delegated runs only**.
|
|
3
|
+
*
|
|
4
|
+
* SCOPE RULE (_spec/07-commands.md): this command never lists or switches OMP's own
|
|
5
|
+
* sessions. OMP already ships `/resume` for that, including `/resume @claude` and
|
|
6
|
+
* `/resume @codex` for importing a foreign worker session. `/sessions` covers only the
|
|
7
|
+
* child-agent runs this extension started, which nothing in OMP tracks. If a future OMP
|
|
8
|
+
* release grows an equivalent run monitor, delete this command and point at theirs rather
|
|
9
|
+
* than keeping two. Do not re-litigate this by adding OMP-session listing here.
|
|
10
|
+
*
|
|
11
|
+
* "Switching sessions" here means switching *focus* between concurrently running workers.
|
|
12
|
+
* Focus is presentation only: this file only ever reads from the registry, so attaching or
|
|
13
|
+
* detaching can never pause, throttle, or reorder a run (_spec/08 §Focus / switching).
|
|
14
|
+
*/
|
|
15
|
+
import type {
|
|
16
|
+
ExtensionAPI,
|
|
17
|
+
ExtensionCommandContext,
|
|
18
|
+
ExtensionContext,
|
|
19
|
+
ExtensionUiComponent,
|
|
20
|
+
} from "@oh-my-pi/pi-coding-agent";
|
|
21
|
+
import type { MultiHarnessConfig } from "../config/schema.ts";
|
|
22
|
+
import { isTerminal, type RunRegistry, type RunStatus, type RunView } from "../runs/types.ts";
|
|
23
|
+
|
|
24
|
+
/** Status bar / widget key. One key for the whole extension so we never leak two. */
|
|
25
|
+
const UI_KEY = "multi-harness";
|
|
26
|
+
/** Widget refresh cadence while a run is focused. */
|
|
27
|
+
const REFRESH_MS = 500;
|
|
28
|
+
/** Output lines kept in the attach widget. */
|
|
29
|
+
const TAIL_LINES = 12;
|
|
30
|
+
|
|
31
|
+
const SUBCOMMANDS = ["list", "attach", "detach", "cancel", "clear"] as const;
|
|
32
|
+
const ID_SUBCOMMANDS = new Set<string>(["attach", "cancel"]);
|
|
33
|
+
|
|
34
|
+
const GLYPHS: Record<RunStatus, string> = {
|
|
35
|
+
running: "●",
|
|
36
|
+
done: "✓",
|
|
37
|
+
failed: "✗",
|
|
38
|
+
cancelled: "⊘",
|
|
39
|
+
queued: "·",
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** Key map, rendered as the interactive footer and documented in _spec/07. */
|
|
43
|
+
const KEY_HINTS = "↑↓ select enter attach d detach c cancel r show output q close";
|
|
44
|
+
|
|
45
|
+
const EMPTY_HINT = "No delegated runs yet — start one with `/codex <task>` or `/claude <task>`.";
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Elapsed time in the spec's `2m14s` form (`1h02m14s` past the hour).
|
|
49
|
+
* Pure: exported so the format is unit-testable without any UI.
|
|
50
|
+
*/
|
|
51
|
+
export function formatElapsed(ms: number): string {
|
|
52
|
+
const total = Math.max(0, Math.floor(ms / 1000));
|
|
53
|
+
const seconds = total % 60;
|
|
54
|
+
const minutes = Math.floor(total / 60) % 60;
|
|
55
|
+
const hours = Math.floor(total / 3600);
|
|
56
|
+
const tail = `${String(minutes).padStart(hours > 0 ? 2 : 1, "0")}m${String(seconds).padStart(2, "0")}s`;
|
|
57
|
+
return hours > 0 ? `${hours}h${tail}` : tail;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The one-line phase summary pushed to the status bar for the focused run. */
|
|
61
|
+
export function renderStatusLine(run: RunView): string {
|
|
62
|
+
const parts = [run.agent, run.mode, formatElapsed(run.elapsedMs), run.phase || run.status].filter(
|
|
63
|
+
(p): p is string => Boolean(p),
|
|
64
|
+
);
|
|
65
|
+
return parts.join(" · ");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Column cells for one run, marker excluded (the caller owns the leading marker). */
|
|
69
|
+
function cells(run: RunView): string[] {
|
|
70
|
+
return [
|
|
71
|
+
GLYPHS[run.status] ?? "·",
|
|
72
|
+
run.id,
|
|
73
|
+
run.agent,
|
|
74
|
+
run.mode ?? "-",
|
|
75
|
+
run.status,
|
|
76
|
+
formatElapsed(run.elapsedMs),
|
|
77
|
+
run.summary,
|
|
78
|
+
];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Aligned rows, marker excluded. Shared by the text table and the interactive view. */
|
|
82
|
+
function alignedRows(runs: RunView[]): string[] {
|
|
83
|
+
const table = runs.map(cells);
|
|
84
|
+
// Last column is free-form, so it is never padded.
|
|
85
|
+
const widths = table[0]?.map((_, col) => Math.max(...table.map((row) => row[col]?.length ?? 0))) ?? [];
|
|
86
|
+
return table.map((row) =>
|
|
87
|
+
row
|
|
88
|
+
.map((cell, col) => (col === row.length - 1 ? cell : cell.padEnd(widths[col] ?? 0)))
|
|
89
|
+
.join(" ")
|
|
90
|
+
.trimEnd(),
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The plain-text run table (print/RPC modes, and `/sessions list` everywhere).
|
|
96
|
+
* Pure: no UI, no registry — hand it a snapshot and it returns the text.
|
|
97
|
+
*/
|
|
98
|
+
export function renderRunTable(runs: RunView[], focusedId?: string): string {
|
|
99
|
+
if (runs.length === 0) return EMPTY_HINT;
|
|
100
|
+
const rows = alignedRows(runs);
|
|
101
|
+
return runs.map((run, i) => `${run.id === focusedId ? " ▸ " : " "}${rows[i]}`).join("\n");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Attach/detach, including the refresh tick. Owns the only timer this command creates. */
|
|
105
|
+
export interface FocusController {
|
|
106
|
+
attach(ctx: ExtensionContext, id: string): void;
|
|
107
|
+
detach(ctx: ExtensionContext): void;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Stream the focused run's tail into the widget and its phase into the status bar.
|
|
112
|
+
*
|
|
113
|
+
* The tick uses `ctx.setInterval` / `ctx.clearTimer` — never raw timers. A raw timer that
|
|
114
|
+
* throws is a process-fatal `uncaughtException` that tears down the whole OMP session
|
|
115
|
+
* (_spec/01 §2). The handle is cleared on every detach so it cannot leak.
|
|
116
|
+
*/
|
|
117
|
+
export function createFocusController(getRegistry: () => RunRegistry | undefined): FocusController {
|
|
118
|
+
let timer: ReturnType<ExtensionContext["setInterval"]> | undefined;
|
|
119
|
+
|
|
120
|
+
const paint = (ctx: ExtensionContext): void => {
|
|
121
|
+
const registry = getRegistry();
|
|
122
|
+
const id = registry?.focused();
|
|
123
|
+
if (!registry || !id) return;
|
|
124
|
+
const run = registry.get(id);
|
|
125
|
+
if (!run) return;
|
|
126
|
+
const tail = registry.tail(id, TAIL_LINES);
|
|
127
|
+
ctx.ui.setWidget(UI_KEY, [`${run.id} ${run.agent}${run.mode ? ` · ${run.mode}` : ""} — ${run.summary}`, ...tail]);
|
|
128
|
+
ctx.ui.setStatus(UI_KEY, renderStatusLine(run));
|
|
129
|
+
// Focus survives completion (08); the widget keeps the final view until detach, so
|
|
130
|
+
// only the tick stops here.
|
|
131
|
+
if (isTerminal(run.status) && timer) {
|
|
132
|
+
ctx.clearTimer(timer);
|
|
133
|
+
timer = undefined;
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
attach(ctx, id) {
|
|
139
|
+
getRegistry()?.focus(id);
|
|
140
|
+
if (timer) ctx.clearTimer(timer);
|
|
141
|
+
// Scheduled before the first paint so that paint can stop the tick itself when the
|
|
142
|
+
// run is already terminal.
|
|
143
|
+
timer = ctx.setInterval(() => paint(ctx), REFRESH_MS);
|
|
144
|
+
paint(ctx);
|
|
145
|
+
},
|
|
146
|
+
detach(ctx) {
|
|
147
|
+
getRegistry()?.focus(undefined);
|
|
148
|
+
if (timer) ctx.clearTimer(timer);
|
|
149
|
+
timer = undefined;
|
|
150
|
+
ctx.ui.setWidget(UI_KEY, undefined);
|
|
151
|
+
ctx.ui.setStatus(UI_KEY, undefined);
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export interface SessionsCommandDeps {
|
|
157
|
+
pi: ExtensionAPI;
|
|
158
|
+
getConfig: () => MultiHarnessConfig;
|
|
159
|
+
/** The registry only exists once a session started, hence the getter rather than a value. */
|
|
160
|
+
getRegistry: () => RunRegistry | undefined;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** What the interactive view asks the command handler to do once it closes. */
|
|
164
|
+
type Intent =
|
|
165
|
+
| { kind: "attach"; id: string }
|
|
166
|
+
| { kind: "detach" }
|
|
167
|
+
| { kind: "cancel"; id: string }
|
|
168
|
+
| { kind: "show"; id: string }
|
|
169
|
+
| { kind: "close" };
|
|
170
|
+
|
|
171
|
+
/** Non-interactive subcommand dispatch. Exported for tests; never throws on bad input. */
|
|
172
|
+
export async function handleSessionsArgs(
|
|
173
|
+
args: string,
|
|
174
|
+
ctx: ExtensionCommandContext,
|
|
175
|
+
registry: RunRegistry,
|
|
176
|
+
focus: FocusController,
|
|
177
|
+
): Promise<void> {
|
|
178
|
+
const argv = args.trim().split(/\s+/).filter(Boolean);
|
|
179
|
+
const sub = argv[0] ?? "list";
|
|
180
|
+
const id = argv[1];
|
|
181
|
+
|
|
182
|
+
const requireRun = (): RunView | undefined => {
|
|
183
|
+
if (!id) {
|
|
184
|
+
ctx.ui.notify(`Usage: /sessions ${sub} <runId>`, "error");
|
|
185
|
+
return undefined;
|
|
186
|
+
}
|
|
187
|
+
const run = registry.get(id);
|
|
188
|
+
if (!run) ctx.ui.notify(`Unknown run id: ${id}. Run \`/sessions list\` to see live runs.`, "error");
|
|
189
|
+
return run;
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
switch (sub) {
|
|
193
|
+
case "list":
|
|
194
|
+
ctx.ui.notify(renderRunTable(registry.list(), registry.focused()), "info");
|
|
195
|
+
return;
|
|
196
|
+
case "attach": {
|
|
197
|
+
const run = requireRun();
|
|
198
|
+
if (!run) return;
|
|
199
|
+
focus.attach(ctx, run.id);
|
|
200
|
+
ctx.ui.notify(`Attached to ${run.id} (${run.agent}). Runs keep going either way.`, "info");
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
case "detach":
|
|
204
|
+
focus.detach(ctx);
|
|
205
|
+
ctx.ui.notify("Detached. Every run keeps executing.", "info");
|
|
206
|
+
return;
|
|
207
|
+
case "cancel": {
|
|
208
|
+
const run = requireRun();
|
|
209
|
+
if (!run) return;
|
|
210
|
+
const cancelled = await registry.cancel(run.id);
|
|
211
|
+
ctx.ui.notify(cancelled ? `Cancelled ${run.id}.` : `${run.id} had already finished (${run.status}).`, "info");
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
case "clear": {
|
|
215
|
+
const dropped = registry.clearFinished();
|
|
216
|
+
ctx.ui.notify(`Dropped ${dropped} finished run${dropped === 1 ? "" : "s"}.`, "info");
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
default:
|
|
220
|
+
ctx.ui.notify(
|
|
221
|
+
`Unknown subcommand "${sub}". Try: ${SUBCOMMANDS.join(", ")} (ids come from \`/sessions list\`).`,
|
|
222
|
+
"error",
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Minimal keyboard list. Kept small on purpose — anything richer belongs in OMP itself. */
|
|
228
|
+
function createListComponent(
|
|
229
|
+
registry: RunRegistry,
|
|
230
|
+
requestRender: () => void,
|
|
231
|
+
done: (intent: Intent) => void,
|
|
232
|
+
): ExtensionUiComponent {
|
|
233
|
+
let selected = 0;
|
|
234
|
+
let rows: RunView[] = registry.list();
|
|
235
|
+
const unsubscribe = registry.subscribe(() => {
|
|
236
|
+
rows = registry.list();
|
|
237
|
+
requestRender();
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
const finish = (intent: Intent): void => {
|
|
241
|
+
unsubscribe();
|
|
242
|
+
done(intent);
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
return {
|
|
246
|
+
debugKind: "MultiHarnessRunList",
|
|
247
|
+
render(): readonly string[] {
|
|
248
|
+
rows = registry.list();
|
|
249
|
+
if (rows.length === 0) return [EMPTY_HINT, "", " q close"];
|
|
250
|
+
selected = Math.min(selected, rows.length - 1);
|
|
251
|
+
const focused = registry.focused();
|
|
252
|
+
const aligned = alignedRows(rows);
|
|
253
|
+
const lines = rows.map((run, i) => {
|
|
254
|
+
const cursor = i === selected ? ">" : " ";
|
|
255
|
+
return `${cursor}${run.id === focused ? "▸" : " "} ${aligned[i]}`;
|
|
256
|
+
});
|
|
257
|
+
return [...lines, "", ` ${KEY_HINTS}`];
|
|
258
|
+
},
|
|
259
|
+
handleInput(data: string): void {
|
|
260
|
+
const current = rows[selected];
|
|
261
|
+
switch (data) {
|
|
262
|
+
case "[A":
|
|
263
|
+
case "k":
|
|
264
|
+
selected = Math.max(0, selected - 1);
|
|
265
|
+
requestRender();
|
|
266
|
+
return;
|
|
267
|
+
case "[B":
|
|
268
|
+
case "j":
|
|
269
|
+
selected = Math.min(Math.max(0, rows.length - 1), selected + 1);
|
|
270
|
+
requestRender();
|
|
271
|
+
return;
|
|
272
|
+
case "\r":
|
|
273
|
+
case "\n":
|
|
274
|
+
if (current) finish({ kind: "attach", id: current.id });
|
|
275
|
+
return;
|
|
276
|
+
case "d":
|
|
277
|
+
finish({ kind: "detach" });
|
|
278
|
+
return;
|
|
279
|
+
case "c":
|
|
280
|
+
if (current) finish({ kind: "cancel", id: current.id });
|
|
281
|
+
return;
|
|
282
|
+
case "r":
|
|
283
|
+
if (current) finish({ kind: "show", id: current.id });
|
|
284
|
+
return;
|
|
285
|
+
case "q":
|
|
286
|
+
case "":
|
|
287
|
+
finish({ kind: "close" });
|
|
288
|
+
return;
|
|
289
|
+
default:
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
},
|
|
293
|
+
dispose(): void {
|
|
294
|
+
unsubscribe();
|
|
295
|
+
},
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Interactive view. Returns false when no custom UI could be shown, so the caller falls
|
|
301
|
+
* back to the text table instead of failing the command.
|
|
302
|
+
*/
|
|
303
|
+
async function showInteractive(
|
|
304
|
+
deps: SessionsCommandDeps,
|
|
305
|
+
ctx: ExtensionCommandContext,
|
|
306
|
+
registry: RunRegistry,
|
|
307
|
+
focus: FocusController,
|
|
308
|
+
): Promise<boolean> {
|
|
309
|
+
if (typeof ctx.ui.custom !== "function") return false;
|
|
310
|
+
// Bounded so a component that immediately re-opens cannot spin forever.
|
|
311
|
+
for (let round = 0; round < 50; round++) {
|
|
312
|
+
let intent: Intent;
|
|
313
|
+
try {
|
|
314
|
+
intent = await ctx.ui.custom<Intent>((tui, _theme, _keybindings, done) =>
|
|
315
|
+
createListComponent(registry, () => tui.requestRender(), done),
|
|
316
|
+
);
|
|
317
|
+
} catch {
|
|
318
|
+
return round > 0; // a later round failing is not a reason to re-print the table
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
switch (intent.kind) {
|
|
322
|
+
case "attach":
|
|
323
|
+
focus.attach(ctx, intent.id);
|
|
324
|
+
return true;
|
|
325
|
+
case "detach":
|
|
326
|
+
focus.detach(ctx);
|
|
327
|
+
break;
|
|
328
|
+
case "cancel": {
|
|
329
|
+
const run = registry.get(intent.id);
|
|
330
|
+
if (!run) break;
|
|
331
|
+
// Killing a writer mid-edit can leave a half-applied tree, so confirm that one.
|
|
332
|
+
const ok =
|
|
333
|
+
run.readOnly ||
|
|
334
|
+
(await ctx.ui.confirm("Cancel run", `${run.id} (${run.agent}) is writing to ${run.cwd}. Cancel it?`));
|
|
335
|
+
if (ok) await registry.cancel(run.id);
|
|
336
|
+
break;
|
|
337
|
+
}
|
|
338
|
+
case "show": {
|
|
339
|
+
const run = registry.get(intent.id);
|
|
340
|
+
if (run) {
|
|
341
|
+
const body = run.output ?? run.errorMessage ?? registry.tail(run.id).join("\n");
|
|
342
|
+
await deps.pi.sendUserMessage(`Run ${run.id} (${run.agent}) — ${run.summary}\n\n${body}`, {
|
|
343
|
+
attribution: "agent",
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
return true;
|
|
347
|
+
}
|
|
348
|
+
default:
|
|
349
|
+
return true;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return true;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Register `/sessions`. Delegated runs only — see the scope rule at the top of this file. */
|
|
356
|
+
export function registerSessionsCommand(deps: SessionsCommandDeps): void {
|
|
357
|
+
const { pi, getRegistry } = deps;
|
|
358
|
+
const focus = createFocusController(getRegistry);
|
|
359
|
+
|
|
360
|
+
pi.registerCommand("sessions", {
|
|
361
|
+
description: "List, attach to, and cancel delegated Codex / Claude runs",
|
|
362
|
+
getArgumentCompletions: (prefix: string) => {
|
|
363
|
+
const match = /^([\s\S]*?)(\S*)$/.exec(prefix);
|
|
364
|
+
const head = match?.[1] ?? "";
|
|
365
|
+
const last = match?.[2] ?? "";
|
|
366
|
+
const wantsIdOnly = ID_SUBCOMMANDS.has(head.trim());
|
|
367
|
+
const items: { value: string; label: string }[] = wantsIdOnly
|
|
368
|
+
? []
|
|
369
|
+
: SUBCOMMANDS.map((s) => ({ value: s, label: s }));
|
|
370
|
+
if (wantsIdOnly || head.trim() === "") {
|
|
371
|
+
for (const run of getRegistry()?.list() ?? []) {
|
|
372
|
+
items.push({ value: run.id, label: `${run.id} ${run.agent} ${run.status} — ${run.summary}` });
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
return items
|
|
376
|
+
.filter((item) => item.value.startsWith(last))
|
|
377
|
+
.map((item) => ({ value: `${head}${item.value}`, label: item.label }));
|
|
378
|
+
},
|
|
379
|
+
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
380
|
+
const registry = getRegistry();
|
|
381
|
+
if (!registry) {
|
|
382
|
+
ctx.ui.notify("No run registry yet — delegated runs appear after the session starts.", "error");
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Bare `/sessions` gets the interactive view; every explicit subcommand stays
|
|
387
|
+
// non-interactive so scripts and print mode behave identically.
|
|
388
|
+
if (args.trim() === "" && ctx.hasUI && ctx.mode === "tui") {
|
|
389
|
+
if (await showInteractive(deps, ctx, registry, focus)) return;
|
|
390
|
+
}
|
|
391
|
+
await handleSessionsArgs(args, ctx, registry, focus);
|
|
392
|
+
},
|
|
393
|
+
});
|
|
394
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads the `multiHarness` block from OMP's config files.
|
|
3
|
+
*
|
|
4
|
+
* OMP's ExtensionAPI exposes no config accessor and its `Settings.get()` is typed to known
|
|
5
|
+
* setting paths, so the extension reads the YAML itself: user config first, project config
|
|
6
|
+
* merged over it (_spec/09-config.md).
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { type MultiHarnessConfig, type NormalizeResult, normalizeConfig } from "./schema.ts";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Active OMP agent directory. Honors PI_CODING_AGENT_DIR; `omp --profile <name>` moves this
|
|
15
|
+
* to ~/.omp/profiles/<name>/agent, so callers must never hard-code ~/.omp/agent.
|
|
16
|
+
*/
|
|
17
|
+
export function agentDir(): string {
|
|
18
|
+
return process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".omp", "agent");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function configPaths(cwd: string): { user: string; project: string } {
|
|
22
|
+
return { user: join(agentDir(), "config.yml"), project: join(cwd, ".omp", "config.yml") };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function readYaml(path: string, warnings: string[]): Record<string, unknown> | null {
|
|
26
|
+
if (!existsSync(path)) return null;
|
|
27
|
+
try {
|
|
28
|
+
const parsed = Bun.YAML.parse(readFileSync(path, "utf8"));
|
|
29
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed as Record<string, unknown>;
|
|
30
|
+
warnings.push(`${path}: not a YAML mapping — ignored`);
|
|
31
|
+
} catch (e) {
|
|
32
|
+
warnings.push(`${path}: ${(e as Error).message} — ignored`);
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
|
38
|
+
return !!v && typeof v === "object" && !Array.isArray(v);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Deep merge, `over` winning. Arrays replace rather than concatenate. */
|
|
42
|
+
export function deepMerge(base: Record<string, unknown>, over: Record<string, unknown>): Record<string, unknown> {
|
|
43
|
+
const out: Record<string, unknown> = { ...base };
|
|
44
|
+
for (const [k, v] of Object.entries(over)) {
|
|
45
|
+
const prev = out[k];
|
|
46
|
+
out[k] = isPlainObject(prev) && isPlainObject(v) ? deepMerge(prev, v) : v;
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface LoadedConfig extends NormalizeResult {
|
|
52
|
+
config: MultiHarnessConfig;
|
|
53
|
+
/** Config files that actually existed, in merge order. */
|
|
54
|
+
sources: string[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function loadConfig(cwd: string): LoadedConfig {
|
|
58
|
+
const warnings: string[] = [];
|
|
59
|
+
const sources: string[] = [];
|
|
60
|
+
const { user, project } = configPaths(cwd);
|
|
61
|
+
|
|
62
|
+
let raw: Record<string, unknown> = {};
|
|
63
|
+
for (const path of [user, project]) {
|
|
64
|
+
const doc = readYaml(path, warnings);
|
|
65
|
+
if (!doc) continue;
|
|
66
|
+
sources.push(path);
|
|
67
|
+
const block = doc.multiHarness;
|
|
68
|
+
if (block === undefined) continue;
|
|
69
|
+
if (!isPlainObject(block)) {
|
|
70
|
+
warnings.push(`${path}: multiHarness must be a mapping — ignored`);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
raw = deepMerge(raw, block);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const normalized = normalizeConfig(raw);
|
|
77
|
+
return { ...normalized, warnings: [...warnings, ...normalized.warnings], sources };
|
|
78
|
+
}
|