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,167 @@
|
|
|
1
|
+
/** OMP host setup: CLI, agent directory, its own provider auth, router model, extension install. */
|
|
2
|
+
import { appendFileSync, existsSync, lstatSync, mkdirSync, readFileSync, symlinkSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { REPO, type SetupGroup, agentDir, executableStep, sh, which } from "./types.ts";
|
|
5
|
+
|
|
6
|
+
function models(): string[] | null {
|
|
7
|
+
const r = sh("omp", ["models", "ls", "--json"], 30_000);
|
|
8
|
+
if (!r.ok) return null;
|
|
9
|
+
try {
|
|
10
|
+
return ((JSON.parse(r.out) as { models?: { id?: string }[] }).models ?? []).map((m) => m.id ?? "");
|
|
11
|
+
} catch {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const ompSetup: SetupGroup = {
|
|
17
|
+
id: "omp",
|
|
18
|
+
title: "OMP host",
|
|
19
|
+
steps: [
|
|
20
|
+
{
|
|
21
|
+
...executableStep({
|
|
22
|
+
id: "omp-cli",
|
|
23
|
+
title: "OMP CLI",
|
|
24
|
+
bin: "omp",
|
|
25
|
+
install: { description: "Install OMP", command: "bun add -g @oh-my-pi/pi-coding-agent" },
|
|
26
|
+
}),
|
|
27
|
+
// Wrap so we can flag the repo-local copy `bun run` puts on PATH.
|
|
28
|
+
run() {
|
|
29
|
+
const p = which("omp");
|
|
30
|
+
if (!p) {
|
|
31
|
+
return {
|
|
32
|
+
status: "fail",
|
|
33
|
+
detail: "not found",
|
|
34
|
+
fix: { description: "Install OMP", command: "bun add -g @oh-my-pi/pi-coding-agent" },
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
const local = p.startsWith(join(REPO, "node_modules"));
|
|
38
|
+
return {
|
|
39
|
+
status: "ok",
|
|
40
|
+
detail: `${sh("omp", ["--version"]).out} (${p})${local ? " — repo-local copy shadowing your global omp" : ""}`,
|
|
41
|
+
};
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
id: "omp-dir",
|
|
46
|
+
title: "OMP agent directory",
|
|
47
|
+
run() {
|
|
48
|
+
const dir = agentDir();
|
|
49
|
+
if (existsSync(dir)) return { status: "ok", detail: dir };
|
|
50
|
+
return {
|
|
51
|
+
status: "warn",
|
|
52
|
+
detail: `${dir} does not exist — OMP creates it on first run`,
|
|
53
|
+
fix: { description: "Run OMP once so it initializes its agent directory", command: "omp" },
|
|
54
|
+
};
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
id: "omp-auth",
|
|
59
|
+
title: "OMP provider auth (supervisor + router)",
|
|
60
|
+
run() {
|
|
61
|
+
if (!which("omp")) return { status: "skip", detail: "omp not installed" };
|
|
62
|
+
const ids = models();
|
|
63
|
+
if (ids === null) return { status: "warn", detail: "could not query `omp models ls --json`" };
|
|
64
|
+
if (ids.length === 0) {
|
|
65
|
+
return {
|
|
66
|
+
status: "fail",
|
|
67
|
+
detail: "no authenticated models — OMP cannot take a supervisor turn",
|
|
68
|
+
fix: {
|
|
69
|
+
description: "Authenticate OMP with your own provider (separate from the worker logins)",
|
|
70
|
+
command: "omp # then use /login",
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
return { status: "ok", detail: `${ids.length} model(s) available` };
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
id: "omp-router",
|
|
79
|
+
title: "Router model (auto mode)",
|
|
80
|
+
run() {
|
|
81
|
+
if (!which("omp")) return { status: "skip", detail: "omp not installed" };
|
|
82
|
+
const ids = models();
|
|
83
|
+
if (!ids || ids.length === 0) {
|
|
84
|
+
return { status: "warn", detail: "cannot verify — OMP has no authenticated models; auto falls back to rules" };
|
|
85
|
+
}
|
|
86
|
+
const preferred = ["claude-haiku", "gpt-5.2-mini", "gemini-2.5-flash", "haiku", "mini", "flash"];
|
|
87
|
+
const hit = ids.find((id) => preferred.some((p) => id.toLowerCase().includes(p)));
|
|
88
|
+
return hit
|
|
89
|
+
? { status: "ok", detail: `${hit} available for routing.model` }
|
|
90
|
+
: { status: "warn", detail: "no small/fast model found — set multiHarness.routing.model explicitly" };
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
id: "omp-link",
|
|
95
|
+
title: "Extension linked into OMP",
|
|
96
|
+
run() {
|
|
97
|
+
const target = join(agentDir(), "extensions", "multi-harness");
|
|
98
|
+
const exists = (() => {
|
|
99
|
+
try {
|
|
100
|
+
return !!lstatSync(target);
|
|
101
|
+
} catch {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
})();
|
|
105
|
+
if (exists) return { status: "ok", detail: target };
|
|
106
|
+
return {
|
|
107
|
+
status: "warn",
|
|
108
|
+
detail: "not linked (fine during development — use `omp -e ./src/index.ts`)",
|
|
109
|
+
fix: {
|
|
110
|
+
description: "Symlink this repo into the OMP extensions directory",
|
|
111
|
+
command: `ln -s "${REPO}" "${target}"`,
|
|
112
|
+
auto: () => {
|
|
113
|
+
mkdirSync(join(agentDir(), "extensions"), { recursive: true });
|
|
114
|
+
symlinkSync(REPO, target, "dir");
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
id: "omp-config",
|
|
122
|
+
title: "multiHarness config block",
|
|
123
|
+
run() {
|
|
124
|
+
const cfg = join(agentDir(), "config.yml");
|
|
125
|
+
if (!existsSync(cfg)) {
|
|
126
|
+
return {
|
|
127
|
+
status: "warn",
|
|
128
|
+
detail: `${cfg} not found — run OMP once first`,
|
|
129
|
+
fix: { description: "Run OMP once so it writes its config", command: "omp" },
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
if (/^multiHarness:/m.test(readFileSync(cfg, "utf8"))) return { status: "ok", detail: cfg };
|
|
133
|
+
return {
|
|
134
|
+
status: "warn",
|
|
135
|
+
detail: "no multiHarness block (defaults will be used)",
|
|
136
|
+
fix: {
|
|
137
|
+
description: `Append a commented default multiHarness block to ${cfg}`,
|
|
138
|
+
auto: () => {
|
|
139
|
+
appendFileSync(
|
|
140
|
+
cfg,
|
|
141
|
+
[
|
|
142
|
+
"",
|
|
143
|
+
"# Added by omp-multi-harness setup. Every option: _spec/09-config.md",
|
|
144
|
+
"multiHarness:",
|
|
145
|
+
" enabled: true",
|
|
146
|
+
" codex:",
|
|
147
|
+
" enabled: true",
|
|
148
|
+
" model: null # null → your ~/.codex/config.toml decides",
|
|
149
|
+
" claude:",
|
|
150
|
+
" enabled: true",
|
|
151
|
+
" model: null # null → your Claude Code config decides",
|
|
152
|
+
" routing:",
|
|
153
|
+
" mode: model # model | rules",
|
|
154
|
+
' model: "@smol" # router model for agent: "auto"',
|
|
155
|
+
" concurrency:",
|
|
156
|
+
" maxConcurrentRuns: 4",
|
|
157
|
+
" debug: false",
|
|
158
|
+
"",
|
|
159
|
+
].join("\n"),
|
|
160
|
+
);
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
],
|
|
167
|
+
};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/** Toolchain and repository hygiene — everything that is not provider-specific. */
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { REPO, type SetupGroup, sh, which } from "./types.ts";
|
|
6
|
+
|
|
7
|
+
export const toolchainSetup: SetupGroup = {
|
|
8
|
+
id: "toolchain",
|
|
9
|
+
title: "Toolchain",
|
|
10
|
+
steps: [
|
|
11
|
+
{
|
|
12
|
+
id: "bun",
|
|
13
|
+
title: "Bun runtime",
|
|
14
|
+
run() {
|
|
15
|
+
const p = which("bun");
|
|
16
|
+
if (!p) {
|
|
17
|
+
return {
|
|
18
|
+
status: "fail",
|
|
19
|
+
detail: "not found — OMP loads extensions with Bun",
|
|
20
|
+
fix: { description: "Install Bun", command: "curl -fsSL https://bun.sh/install | bash" },
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
return { status: "ok", detail: `${sh("bun", ["--version"]).out} (${p})` };
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
id: "git",
|
|
28
|
+
title: "Git repository",
|
|
29
|
+
run() {
|
|
30
|
+
if (!which("git")) return { status: "fail", detail: "git not found" };
|
|
31
|
+
const inRepo = spawnSync("git", ["rev-parse", "--is-inside-work-tree"], { cwd: REPO, encoding: "utf8" }).status === 0;
|
|
32
|
+
if (inRepo) return { status: "ok", detail: "initialized" };
|
|
33
|
+
return {
|
|
34
|
+
status: "warn",
|
|
35
|
+
detail: "not a git repository",
|
|
36
|
+
fix: {
|
|
37
|
+
description: "Initialize the repository",
|
|
38
|
+
command: "git init",
|
|
39
|
+
auto: () => {
|
|
40
|
+
spawnSync("git", ["init"], { cwd: REPO, stdio: "inherit" });
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
id: "deps",
|
|
48
|
+
title: "Project dependencies",
|
|
49
|
+
run() {
|
|
50
|
+
if (existsSync(join(REPO, "node_modules"))) return { status: "ok", detail: "node_modules present" };
|
|
51
|
+
return {
|
|
52
|
+
status: "warn",
|
|
53
|
+
detail: "node_modules missing",
|
|
54
|
+
fix: {
|
|
55
|
+
description: "Install dependencies",
|
|
56
|
+
command: "bun install",
|
|
57
|
+
auto: () => {
|
|
58
|
+
spawnSync("bun", ["install"], { cwd: REPO, stdio: "inherit" });
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
id: "editor",
|
|
66
|
+
title: "Editor config",
|
|
67
|
+
run() {
|
|
68
|
+
const missing = [
|
|
69
|
+
".vscode/settings.json",
|
|
70
|
+
".vscode/extensions.json",
|
|
71
|
+
".vscode/launch.json",
|
|
72
|
+
".vscode/tasks.json",
|
|
73
|
+
".editorconfig",
|
|
74
|
+
].filter((f) => !existsSync(join(REPO, f)));
|
|
75
|
+
return missing.length === 0
|
|
76
|
+
? { status: "ok", detail: ".vscode/* and .editorconfig present" }
|
|
77
|
+
: { status: "warn", detail: `missing: ${missing.join(", ")}` };
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
],
|
|
81
|
+
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/** Shared plumbing for the per-provider setup modules. */
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { homedir, platform } from "node:os";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
|
|
6
|
+
export type Status = "ok" | "warn" | "fail" | "skip";
|
|
7
|
+
|
|
8
|
+
export interface Fix {
|
|
9
|
+
/** Shown to the user. */
|
|
10
|
+
description: string;
|
|
11
|
+
/** Exact command the user can copy/paste. */
|
|
12
|
+
command?: string;
|
|
13
|
+
/** Safe to run unattended (after a prompt). Installs and logins never have one. */
|
|
14
|
+
auto?: () => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface Step {
|
|
18
|
+
id: string;
|
|
19
|
+
title: string;
|
|
20
|
+
run(): { status: Status; detail: string; fix?: Fix };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** One provider's (or one concern's) setup steps. `setup.ts` runs every registered group. */
|
|
24
|
+
export interface SetupGroup {
|
|
25
|
+
id: string;
|
|
26
|
+
title: string;
|
|
27
|
+
steps: Step[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const REPO = resolve(import.meta.dir, "..", "..");
|
|
31
|
+
export const IS_MAC = platform() === "darwin";
|
|
32
|
+
export const IS_WINDOWS = platform() === "win32";
|
|
33
|
+
|
|
34
|
+
export function sh(cmd: string, args: string[], timeout = 15_000) {
|
|
35
|
+
const r = spawnSync(cmd, args, { encoding: "utf8", timeout, shell: false });
|
|
36
|
+
return {
|
|
37
|
+
ok: r.status === 0,
|
|
38
|
+
code: r.status,
|
|
39
|
+
out: (r.stdout ?? "").trim(),
|
|
40
|
+
err: (r.stderr ?? "").trim(),
|
|
41
|
+
missing: (r.error as NodeJS.ErrnoException | undefined)?.code === "ENOENT",
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function which(bin: string): string | null {
|
|
46
|
+
const r = sh(IS_WINDOWS ? "where" : "which", [bin], 5_000);
|
|
47
|
+
return r.ok && r.out ? (r.out.split("\n")[0]?.trim() ?? null) : null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Active OMP agent directory. Honors PI_CODING_AGENT_DIR; `omp --profile <name>` shifts it
|
|
52
|
+
* to ~/.omp/profiles/<name>/agent, so never hard-code this elsewhere.
|
|
53
|
+
*/
|
|
54
|
+
export function agentDir(): string {
|
|
55
|
+
return process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".omp", "agent");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A CLI presence check shared by every provider module. */
|
|
59
|
+
export function executableStep(opts: {
|
|
60
|
+
id: string;
|
|
61
|
+
title: string;
|
|
62
|
+
bin: string;
|
|
63
|
+
versionArgs?: string[];
|
|
64
|
+
install: { description: string; command: string };
|
|
65
|
+
}): Step {
|
|
66
|
+
return {
|
|
67
|
+
id: opts.id,
|
|
68
|
+
title: opts.title,
|
|
69
|
+
run() {
|
|
70
|
+
const p = which(opts.bin);
|
|
71
|
+
if (!p) return { status: "fail", detail: "not found", fix: opts.install };
|
|
72
|
+
const version = sh(opts.bin, opts.versionArgs ?? ["--version"]).out;
|
|
73
|
+
return { status: "ok", detail: `${version} (${p})` };
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
package/scripts/setup.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* omp-multi-harness setup — orchestrator.
|
|
4
|
+
*
|
|
5
|
+
* bun scripts/setup.ts # same as `check`
|
|
6
|
+
* bun scripts/setup.ts check # read-only report of every setup step
|
|
7
|
+
* bun scripts/setup.ts fix # apply the safe automatic fixes (asks first)
|
|
8
|
+
* bun scripts/setup.ts fix --yes # ...without asking
|
|
9
|
+
* bun scripts/setup.ts check --json
|
|
10
|
+
* bun scripts/setup.ts check --only codex,claude
|
|
11
|
+
*
|
|
12
|
+
* Each provider owns its own module under scripts/setup/; this file just runs them all.
|
|
13
|
+
* Rules (see _spec/10 and _spec/14):
|
|
14
|
+
* - never read a credential file, never print a token, never log in for the user
|
|
15
|
+
* - auth state comes only from each CLI's own status command
|
|
16
|
+
* - installs and logins are printed as commands; they are never auto-run
|
|
17
|
+
*/
|
|
18
|
+
import { createInterface } from "node:readline/promises";
|
|
19
|
+
import { claudeSetup } from "./setup/claude.ts";
|
|
20
|
+
import { codexSetup } from "./setup/codex.ts";
|
|
21
|
+
import { ompSetup } from "./setup/omp.ts";
|
|
22
|
+
import { toolchainSetup } from "./setup/toolchain.ts";
|
|
23
|
+
import type { SetupGroup, Status, Step } from "./setup/types.ts";
|
|
24
|
+
|
|
25
|
+
/** Registration order = report order. Add a provider by adding its module here. */
|
|
26
|
+
const GROUPS: SetupGroup[] = [toolchainSetup, ompSetup, codexSetup, claudeSetup];
|
|
27
|
+
|
|
28
|
+
const GLYPH: Record<Status, string> = { ok: "✔", warn: "!", fail: "✘", skip: "–" };
|
|
29
|
+
|
|
30
|
+
interface Outcome {
|
|
31
|
+
group: SetupGroup;
|
|
32
|
+
step: Step;
|
|
33
|
+
status: Status;
|
|
34
|
+
detail: string;
|
|
35
|
+
fix?: { description: string; command?: string; auto?: () => void };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function arg(argv: string[], name: string): string | undefined {
|
|
39
|
+
const i = argv.indexOf(`--${name}`);
|
|
40
|
+
return i >= 0 ? argv[i + 1] : undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function main() {
|
|
44
|
+
const argv = process.argv.slice(2);
|
|
45
|
+
const mode = argv.find((a) => !a.startsWith("--")) ?? "check";
|
|
46
|
+
const yes = argv.includes("--yes") || argv.includes("-y");
|
|
47
|
+
const asJson = argv.includes("--json");
|
|
48
|
+
const only = arg(argv, "only")?.split(",").map((s) => s.trim());
|
|
49
|
+
|
|
50
|
+
const groups = only ? GROUPS.filter((g) => only.includes(g.id)) : GROUPS;
|
|
51
|
+
const results: Outcome[] = groups.flatMap((group) =>
|
|
52
|
+
group.steps.map((step) => ({ group, step, ...step.run() })),
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
if (asJson) {
|
|
56
|
+
console.log(
|
|
57
|
+
JSON.stringify(
|
|
58
|
+
results.map((r) => ({ group: r.group.id, id: r.step.id, status: r.status, detail: r.detail })),
|
|
59
|
+
null,
|
|
60
|
+
2,
|
|
61
|
+
),
|
|
62
|
+
);
|
|
63
|
+
process.exit(results.some((r) => r.status === "fail") ? 1 : 0);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
console.log("\nomp-multi-harness setup\n");
|
|
67
|
+
for (const group of groups) {
|
|
68
|
+
console.log(`${group.title}`);
|
|
69
|
+
for (const r of results.filter((x) => x.group.id === group.id)) {
|
|
70
|
+
console.log(` ${GLYPH[r.status]} ${r.step.title.padEnd(36)} ${r.detail}`);
|
|
71
|
+
}
|
|
72
|
+
console.log();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const actionable = results.filter((r) => r.fix && r.status !== "ok" && r.status !== "skip");
|
|
76
|
+
if (actionable.length === 0) {
|
|
77
|
+
console.log("Everything is set up.\n");
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const auto = actionable.filter((r) => r.fix?.auto);
|
|
82
|
+
const manual = actionable.filter((r) => !r.fix?.auto);
|
|
83
|
+
|
|
84
|
+
if (mode === "fix" && auto.length > 0) {
|
|
85
|
+
const rl = yes ? null : createInterface({ input: process.stdin, output: process.stdout });
|
|
86
|
+
for (const r of auto) {
|
|
87
|
+
if (rl) {
|
|
88
|
+
const a = (await rl.question(`${r.fix!.description}? [y/N] `)).trim().toLowerCase();
|
|
89
|
+
if (a !== "y" && a !== "yes") continue;
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
r.fix!.auto!();
|
|
93
|
+
console.log(` ${GLYPH.ok} ${r.step.title}: fixed`);
|
|
94
|
+
} catch (e) {
|
|
95
|
+
console.log(` ${GLYPH.fail} ${r.step.title}: ${(e as Error).message}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
rl?.close();
|
|
99
|
+
console.log();
|
|
100
|
+
} else if (auto.length > 0) {
|
|
101
|
+
console.log("Automatic fixes available — run `bun scripts/setup.ts fix`:");
|
|
102
|
+
for (const r of auto) console.log(` · ${r.fix!.description}`);
|
|
103
|
+
console.log();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (manual.length > 0) {
|
|
107
|
+
console.log("Run these yourself (installs and logins are never automated):");
|
|
108
|
+
for (const r of manual) {
|
|
109
|
+
console.log(` · [${r.group.title}] ${r.fix!.description}`);
|
|
110
|
+
if (r.fix!.command) console.log(` ${r.fix!.command}`);
|
|
111
|
+
}
|
|
112
|
+
console.log();
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
await main();
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Executable + authentication detection, cached per process.
|
|
3
|
+
*
|
|
4
|
+
* Auth state comes ONLY from each CLI's own status command. No credential file is read, no
|
|
5
|
+
* token is printed, and nothing here ever attempts a login (_spec/10, _spec/14).
|
|
6
|
+
*/
|
|
7
|
+
import { spawnAgent } from "../process/spawn-agent.ts";
|
|
8
|
+
import { resolveExecutable } from "../process/executable.ts";
|
|
9
|
+
import type { AgentConfig, AgentName } from "../config/schema.ts";
|
|
10
|
+
import type { AgentAvailability, AuthState } from "./types.ts";
|
|
11
|
+
|
|
12
|
+
const VERSION_TIMEOUT_MS = 5_000;
|
|
13
|
+
const AUTH_TIMEOUT_MS = 20_000;
|
|
14
|
+
|
|
15
|
+
const cache = new Map<AgentName, AgentAvailability>();
|
|
16
|
+
|
|
17
|
+
async function capture(executable: string, args: string[], cwd: string, timeoutMs: number, env?: NodeJS.ProcessEnv) {
|
|
18
|
+
try {
|
|
19
|
+
const r = await spawnAgent({ command: executable, args, cwd, timeoutMs, killGraceMs: 1_000, env });
|
|
20
|
+
return { ok: r.exitCode === 0, stdout: r.stdout.trim(), stderr: r.stderr.trim() };
|
|
21
|
+
} catch {
|
|
22
|
+
return { ok: false, stdout: "", stderr: "" };
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function codexAuth(executable: string, cwd: string, env?: NodeJS.ProcessEnv): Promise<{ auth: AuthState; authDetail: string }> {
|
|
27
|
+
const r = await capture(executable, ["login", "status"], cwd, AUTH_TIMEOUT_MS, env);
|
|
28
|
+
// `codex login status` prints to STDERR, not stdout — read both (_spec/14).
|
|
29
|
+
const line = [r.stdout, r.stderr].join("\n").split("\n").find((l) => /logged in/i.test(l));
|
|
30
|
+
if (r.ok && line) return { auth: "ok", authDetail: line.trim() };
|
|
31
|
+
return { auth: "logged-out", authDetail: "not authenticated — run `codex login`" };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function claudeAuth(executable: string, cwd: string, env?: NodeJS.ProcessEnv): Promise<{ auth: AuthState; authDetail: string }> {
|
|
35
|
+
const r = await capture(executable, ["auth", "status", "--json"], cwd, AUTH_TIMEOUT_MS, env);
|
|
36
|
+
if (r.ok && r.stdout) {
|
|
37
|
+
try {
|
|
38
|
+
// The payload also carries email, org id and org name. Read only these two fields
|
|
39
|
+
// and never print or persist the rest.
|
|
40
|
+
const parsed = JSON.parse(r.stdout) as { loggedIn?: boolean; authMethod?: string };
|
|
41
|
+
if (parsed.loggedIn) return { auth: "ok", authDetail: `logged in via ${parsed.authMethod ?? "unknown method"}` };
|
|
42
|
+
return { auth: "logged-out", authDetail: "not authenticated — run `claude auth login`" };
|
|
43
|
+
} catch {
|
|
44
|
+
return { auth: "unknown", authDetail: "unexpected `claude auth status` output" };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return { auth: "logged-out", authDetail: "not authenticated — run `claude auth login`" };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface DetectOptions {
|
|
51
|
+
cwd: string;
|
|
52
|
+
/** Skip the auth probe (it costs a process spawn each). */
|
|
53
|
+
skipAuth?: boolean;
|
|
54
|
+
force?: boolean;
|
|
55
|
+
env?: NodeJS.ProcessEnv;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function detect(agent: AgentName, config: AgentConfig, opts: DetectOptions): Promise<AgentAvailability> {
|
|
59
|
+
if (!opts.force) {
|
|
60
|
+
const hit = cache.get(agent);
|
|
61
|
+
if (hit) return hit;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let result: AgentAvailability;
|
|
65
|
+
|
|
66
|
+
if (!config.enabled) {
|
|
67
|
+
result = { agent, available: false, auth: "unknown", authDetail: "-", reason: `disabled in config (multiHarness.${agent}.enabled)` };
|
|
68
|
+
} else {
|
|
69
|
+
const executablePath = resolveExecutable(config.executable, opts.env);
|
|
70
|
+
if (!executablePath) {
|
|
71
|
+
result = {
|
|
72
|
+
agent,
|
|
73
|
+
available: false,
|
|
74
|
+
auth: "unknown",
|
|
75
|
+
authDetail: "-",
|
|
76
|
+
reason: `\`${config.executable}\` not found on PATH`,
|
|
77
|
+
};
|
|
78
|
+
} else {
|
|
79
|
+
const version = await capture(executablePath, ["--version"], opts.cwd, VERSION_TIMEOUT_MS, opts.env);
|
|
80
|
+
const auth = opts.skipAuth
|
|
81
|
+
? { auth: "unknown" as AuthState, authDetail: "not checked" }
|
|
82
|
+
: agent === "codex"
|
|
83
|
+
? await codexAuth(executablePath, opts.cwd, opts.env)
|
|
84
|
+
: await claudeAuth(executablePath, opts.cwd, opts.env);
|
|
85
|
+
result = {
|
|
86
|
+
agent,
|
|
87
|
+
available: true,
|
|
88
|
+
executablePath,
|
|
89
|
+
version: [version.stdout, version.stderr].find((s) => s.length > 0) ?? undefined,
|
|
90
|
+
...auth,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
cache.set(agent, result);
|
|
96
|
+
return result;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Ready = installed, enabled, and authenticated. */
|
|
100
|
+
export function isReady(a: AgentAvailability): boolean {
|
|
101
|
+
return a.available && a.auth === "ok";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function clearAvailabilityCache(): void {
|
|
105
|
+
cache.clear();
|
|
106
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interpretation of `claude -p --output-format stream-json` output.
|
|
3
|
+
*
|
|
4
|
+
* Shapes captured from Claude Code 2.1.274 (2026-09-18):
|
|
5
|
+
* {"type":"system","subtype":"init","session_id":"…"}
|
|
6
|
+
* {"type":"system","subtype":"hook_started"|"hook_response"|"thinking_tokens"|"post_turn_summary",…}
|
|
7
|
+
* {"type":"assistant", …}
|
|
8
|
+
* {"type":"rate_limit_event", …}
|
|
9
|
+
* {"type":"result","subtype":"success","result":"pong","is_error":false,
|
|
10
|
+
* "session_id":"…","num_turns":1,"duration_ms":8243,"total_cost_usd":0.43,
|
|
11
|
+
* "usage":{…},"permission_denials":[…]}
|
|
12
|
+
*
|
|
13
|
+
* A real run is mostly hook noise, so progress reporting deliberately ignores it.
|
|
14
|
+
*/
|
|
15
|
+
export interface ClaudeStreamState {
|
|
16
|
+
sessionId?: string;
|
|
17
|
+
/** Terminal text from the `result` event. */
|
|
18
|
+
result?: string;
|
|
19
|
+
/** Set when the result event reports a failure. */
|
|
20
|
+
failure?: string;
|
|
21
|
+
lastAssistantText?: string;
|
|
22
|
+
turns?: number;
|
|
23
|
+
costUsd?: number;
|
|
24
|
+
/** Tools Claude asked for and was refused — evidence that read-only actually held. */
|
|
25
|
+
permissionDenials: number;
|
|
26
|
+
sawResult: boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Set once the first `result` event has decided success/failure. A real run emits
|
|
29
|
+
* exactly one; a duplicate or out-of-order repeat must not flip an already-decided
|
|
30
|
+
* outcome (see the matching note on CodexStreamState.settled).
|
|
31
|
+
*/
|
|
32
|
+
settled: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ClaudeProgressEvent {
|
|
36
|
+
phase: string;
|
|
37
|
+
detail?: string;
|
|
38
|
+
raw?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
42
|
+
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Pull readable text out of an assistant message's content blocks. */
|
|
46
|
+
function assistantText(event: Record<string, unknown>): string | undefined {
|
|
47
|
+
const message = asRecord(event.message);
|
|
48
|
+
const content = message?.content ?? event.content;
|
|
49
|
+
if (typeof content === "string") return content;
|
|
50
|
+
if (!Array.isArray(content)) return undefined;
|
|
51
|
+
const parts: string[] = [];
|
|
52
|
+
for (const block of content) {
|
|
53
|
+
const b = asRecord(block);
|
|
54
|
+
if (b && b.type === "text" && typeof b.text === "string") parts.push(b.text);
|
|
55
|
+
}
|
|
56
|
+
return parts.length > 0 ? parts.join("") : undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Tool names mentioned by an assistant event, for progress display. */
|
|
60
|
+
function toolName(event: Record<string, unknown>): string | undefined {
|
|
61
|
+
const message = asRecord(event.message);
|
|
62
|
+
const content = message?.content;
|
|
63
|
+
if (!Array.isArray(content)) return undefined;
|
|
64
|
+
for (const block of content) {
|
|
65
|
+
const b = asRecord(block);
|
|
66
|
+
if (b && b.type === "tool_use" && typeof b.name === "string") return b.name;
|
|
67
|
+
}
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function newClaudeStreamState(): ClaudeStreamState {
|
|
72
|
+
return { permissionDenials: 0, sawResult: false, settled: false };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function applyClaudeEvent(state: ClaudeStreamState, value: unknown): ClaudeProgressEvent | null {
|
|
76
|
+
const event = asRecord(value);
|
|
77
|
+
if (!event) return null;
|
|
78
|
+
|
|
79
|
+
if (!state.sessionId && typeof event.session_id === "string") state.sessionId = event.session_id;
|
|
80
|
+
|
|
81
|
+
const type = typeof event.type === "string" ? event.type : "";
|
|
82
|
+
const subtype = typeof event.subtype === "string" ? event.subtype : "";
|
|
83
|
+
|
|
84
|
+
if (type === "system") {
|
|
85
|
+
// Hook chatter and token accounting are noise; only `init` is worth a phase.
|
|
86
|
+
return subtype === "init" ? { phase: "starting", raw: "system:init" } : null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (type === "assistant") {
|
|
90
|
+
const text = assistantText(event);
|
|
91
|
+
if (text) state.lastAssistantText = text;
|
|
92
|
+
const tool = toolName(event);
|
|
93
|
+
return tool ? { phase: `using ${tool}`, raw: "assistant:tool_use" } : { phase: "writing response", raw: "assistant" };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (type === "user") return { phase: "reading tool results", raw: "user" };
|
|
97
|
+
|
|
98
|
+
if (type === "rate_limit_event") return { phase: "waiting on rate limit", raw: type };
|
|
99
|
+
|
|
100
|
+
if (type === "result") {
|
|
101
|
+
state.sawResult = true;
|
|
102
|
+
// Informational fields are harmless to keep refreshing even from a stray repeat.
|
|
103
|
+
if (typeof event.num_turns === "number") state.turns = event.num_turns;
|
|
104
|
+
if (typeof event.total_cost_usd === "number") state.costUsd = event.total_cost_usd;
|
|
105
|
+
if (Array.isArray(event.permission_denials)) state.permissionDenials = event.permission_denials.length;
|
|
106
|
+
|
|
107
|
+
const isError = event.is_error === true || (subtype !== "" && subtype !== "success");
|
|
108
|
+
const text = typeof event.result === "string" ? event.result : undefined;
|
|
109
|
+
|
|
110
|
+
if (!state.settled) {
|
|
111
|
+
state.settled = true;
|
|
112
|
+
if (isError) {
|
|
113
|
+
state.failure = text && text.length > 0 ? text : `the run ended with "${subtype || "an error"}"`;
|
|
114
|
+
} else {
|
|
115
|
+
state.result = text;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return isError
|
|
120
|
+
? { phase: "failed", detail: text || state.failure, raw: `result:${subtype}` }
|
|
121
|
+
: { phase: "completed", raw: `result:${subtype}` };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return null;
|
|
125
|
+
}
|