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.
Files changed (40) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +351 -0
  3. package/package.json +76 -0
  4. package/scripts/cli.ts +164 -0
  5. package/scripts/setup/claude.ts +41 -0
  6. package/scripts/setup/codex.ts +35 -0
  7. package/scripts/setup/omp.ts +167 -0
  8. package/scripts/setup/toolchain.ts +81 -0
  9. package/scripts/setup/types.ts +76 -0
  10. package/scripts/setup.ts +116 -0
  11. package/src/agents/availability.ts +106 -0
  12. package/src/agents/claude-events.ts +125 -0
  13. package/src/agents/claude.ts +226 -0
  14. package/src/agents/codex-events.ts +149 -0
  15. package/src/agents/codex.ts +236 -0
  16. package/src/agents/types.ts +81 -0
  17. package/src/commands/agents.ts +140 -0
  18. package/src/commands/delegate-command.ts +159 -0
  19. package/src/commands/harness-setup.ts +94 -0
  20. package/src/commands/sessions.ts +394 -0
  21. package/src/config/load.ts +78 -0
  22. package/src/config/schema.ts +249 -0
  23. package/src/index.ts +129 -0
  24. package/src/process/executable.ts +49 -0
  25. package/src/process/jsonl.ts +124 -0
  26. package/src/process/process-error.ts +178 -0
  27. package/src/process/redact.ts +120 -0
  28. package/src/process/spawn-agent.ts +218 -0
  29. package/src/routing/handoff.ts +59 -0
  30. package/src/routing/prompt.ts +72 -0
  31. package/src/routing/route.ts +286 -0
  32. package/src/runs/lock.ts +158 -0
  33. package/src/runs/registry.ts +379 -0
  34. package/src/runs/ring-buffer.ts +81 -0
  35. package/src/runs/types.ts +141 -0
  36. package/src/sessions/resume.ts +163 -0
  37. package/src/sessions/store.ts +273 -0
  38. package/src/tools/agent-runs.ts +169 -0
  39. package/src/tools/ask-agent.ts +230 -0
  40. package/src/tools/delegate.ts +196 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ahmad Bakhshi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,351 @@
1
+ # omp-multi-harness
2
+
3
+ An [OMP](https://github.com/can1357/oh-my-pi) extension that turns OMP into a **supervisor**
4
+ delegating work to two coding agents you already have:
5
+
6
+ - **Codex CLI** — `codex`
7
+ - **Claude Code CLI** — `claude`
8
+
9
+ It drives their real binaries through their real non-interactive entry points, using the
10
+ logins you already did. **No API keys. No token extraction. No reimplementation of either
11
+ agent.**
12
+
13
+ ```text
14
+ OMP (supervisor)
15
+
16
+ ┌───────────────┼───────────────┐
17
+ ▼ ▼ ▼
18
+ ask_codex ask_claude /sessions
19
+ │ │ (watch, switch,
20
+ ▼ ▼ cancel runs)
21
+ codex CLI claude CLI
22
+ │ │
23
+ your Codex your Claude
24
+ login login
25
+ ```
26
+
27
+ > **Status — Phases 0–5 of 6 complete, Phase 6 substantially done.** Everything in the
28
+ > tables below is implemented: delegation, routing, parallel runs, `/sessions`, session
29
+ > continuation, and the background (`--bg`) path. Claude delegation is verified end-to-end
30
+ > against the real CLI; **Codex passes its fake-CLI suite but has never been verified live**,
31
+ > because that account is out of credits — treat it as unproven against the real binary.
32
+ > Model-based `auto` routing is likewise unverified live: OMP itself has no authenticated
33
+ > model here, so it silently falls back to the rule table (which is tested).
34
+ > Progress: [`_plan/PROGRESS.md`](_plan/PROGRESS.md).
35
+
36
+ ---
37
+
38
+ ## Setup
39
+
40
+ ### 1. Install the three CLIs
41
+
42
+ | | install | check |
43
+ |---|---|---|
44
+ | OMP | `bun add -g @oh-my-pi/pi-coding-agent` | `omp --version` |
45
+ | Codex | `brew install --cask codex` · `npm i -g @openai/codex` | `codex --version` |
46
+ | Claude Code | `npm i -g @anthropic-ai/claude-code` | `claude --version` |
47
+
48
+ OMP loads extensions with [Bun](https://bun.sh), so Bun is required
49
+ (`curl -fsSL https://bun.sh/install | bash`).
50
+
51
+ ### 2. Log in to each — three separate, non-transferable logins
52
+
53
+ ```bash
54
+ omp # then /login — OMP's own model (supervisor turns + auto routing)
55
+ codex login # — everything ask_codex does
56
+ claude auth login # — everything ask_claude does
57
+ ```
58
+
59
+ This is not redundancy. Claude Code will not accept credentials supplied by another tool,
60
+ so each CLI keeps its own login and this extension never touches any of them — it only
61
+ reads `codex login status` and `claude auth status`, which print no secrets.
62
+
63
+ If OMP itself is not logged in, delegation still works; only OMP's own turns and
64
+ model-based `auto` routing are affected (routing falls back to rules).
65
+
66
+ ### 3. Install the extension
67
+
68
+ OMP discovers extensions from its **agent dir** (`<agentDir>/extensions/`) and from a
69
+ project's `.omp/extensions/`, resolving each directory through that directory's
70
+ `package.json` → `omp.extensions`. It does **not** scan `node_modules`. So installing from
71
+ npm is two steps: install the package, then register it. The bundled CLI does step two.
72
+
73
+ #### From npm — global, for every project (recommended)
74
+
75
+ ```bash
76
+ npm install -g omp-multi-harness
77
+ omp-multi-harness link
78
+ ```
79
+
80
+ `link` symlinks the installed package into `<agentDir>/extensions/multi-harness`. It only
81
+ ever manages its own symlink: it refuses to replace anything that is not a symlink it
82
+ created, and it never edits your OMP config, logs you in, or installs a CLI.
83
+
84
+ #### From npm — one project only
85
+
86
+ ```bash
87
+ npm install --save-dev omp-multi-harness
88
+ npx omp-multi-harness link --project
89
+ ```
90
+
91
+ That links into `./.omp/extensions/multi-harness`, so the extension loads for this
92
+ repository and nowhere else.
93
+
94
+ #### Using a profile
95
+
96
+ ```bash
97
+ omp-multi-harness link --profile work # ~/.omp/profiles/work/agent/extensions/
98
+ ```
99
+
100
+ `PI_CODING_AGENT_DIR` overrides both the default and `--profile`, and is honored by the CLI
101
+ and by the extension at runtime — nothing hard-codes `~/.omp/agent`.
102
+
103
+ #### Check, and undo
104
+
105
+ ```bash
106
+ omp-multi-harness status # where it is linked, globally and for this project
107
+ omp-multi-harness unlink # remove the symlink (add --project for the project one)
108
+ omp-multi-harness doctor # the full setup check
109
+ ```
110
+
111
+ #### From source
112
+
113
+ ```bash
114
+ git clone https://github.com/abdevts/omp-multi-harness.git
115
+ cd omp-multi-harness
116
+ bun install
117
+ bun scripts/cli.ts link # same linking, straight from the checkout
118
+ ```
119
+
120
+ Or skip installation entirely and load it per run:
121
+
122
+ ```bash
123
+ omp -e /path/to/omp-multi-harness/src/index.ts
124
+ ```
125
+
126
+ > **No build step.** OMP loads TypeScript directly, so the package ships its sources and
127
+ > `omp.extensions` points at `./src/index.ts`. There is nothing to compile.
128
+
129
+ ### Using it from VS Code
130
+
131
+ The extension lives inside `omp`, not inside VS Code — so "using it in VS Code" means
132
+ running `omp` in VS Code's integrated terminal with the extension registered. Once
133
+ `omp-multi-harness link` has been run, every terminal session picks it up automatically.
134
+
135
+ This repository ships `.vscode/` with the loop already wired:
136
+
137
+ | file | what it gives you |
138
+ |---|---|
139
+ | `tasks.json` | **Terminal → Run Task** for `doctor`, `test`, and `typecheck` |
140
+ | `launch.json` | debug `omp` with the extension loaded via `-e` |
141
+ | `settings.json` | formatting and TypeScript settings matching this codebase |
142
+ | `extensions.json` | the editor extensions this project expects |
143
+
144
+ For a project that merely *consumes* the package, you do not need any of that — install,
145
+ link, and open a terminal:
146
+
147
+ ```bash
148
+ npm install --save-dev omp-multi-harness
149
+ npx omp-multi-harness link --project
150
+ omp # in VS Code's integrated terminal
151
+ ```
152
+
153
+ Then `/agents` inside OMP confirms it loaded. If you keep a workspace-local agent dir, set
154
+ `PI_CODING_AGENT_DIR` in VS Code's terminal environment (`terminal.integrated.env.osx`,
155
+ `.linux`, or `.windows`) and both the CLI and the extension will follow it.
156
+
157
+ ### 4. Verify
158
+
159
+ ```bash
160
+ bun run doctor # 14 checks: tools, all three logins, router model, install, config
161
+ ```
162
+
163
+ ```text
164
+ Codex
165
+ ✔ Codex CLI codex-cli 0.155.0 (/opt/homebrew/bin/codex)
166
+ ✔ Codex authentication Logged in using ChatGPT
167
+ ```
168
+
169
+ `bun scripts/setup.ts fix` applies the safe repairs (symlink, config block, dependencies).
170
+ Installs and logins are printed for you to run — never executed automatically.
171
+
172
+ Inside OMP, `/agents` and `/harness-setup` report the same thing.
173
+
174
+ ### 5. Configure (optional — the defaults are sensible)
175
+
176
+ Add to `~/.omp/agent/config.yml`, or `<project>/.omp/config.yml` to override per project:
177
+
178
+ ```yaml
179
+ multiHarness:
180
+ enabled: true
181
+
182
+ codex:
183
+ enabled: true
184
+ model: null # null → your ~/.codex/config.toml decides
185
+ timeoutMs: 1800000
186
+
187
+ claude:
188
+ enabled: true
189
+ model: null # null → your Claude Code config decides
190
+ acceptEdits: false
191
+
192
+ routing:
193
+ mode: model # model | rules — how `auto` picks an agent
194
+ model: "@smol" # router model: role alias, provider/id, or bare id
195
+ modeMap:
196
+ plan: claude
197
+ review: claude
198
+ implement: codex
199
+ debug: codex
200
+
201
+ concurrency:
202
+ maxConcurrentRuns: 4
203
+ allowParallelWrites: false # two agents never write one tree at once
204
+
205
+ debug: false
206
+ ```
207
+
208
+ Every option: [`_spec/09-config.md`](_spec/09-config.md).
209
+
210
+ ---
211
+
212
+ ## Usage
213
+
214
+ Flags for `/codex` and `/claude`: `--read-only`, `--write`, `--new`, `--bg`,
215
+ `--mode <mode>`, `--model <id>`.
216
+
217
+ `--bg` returns a run id immediately and keeps working in the background; track it with
218
+ `/sessions`. Many runs can be alive at once — see [Parallel runs](#parallel-runs).
219
+
220
+ ### Commands
221
+
222
+ | command | status | what it does |
223
+ |---|---|---|
224
+ | `/agents` | ✅ | Availability, version, auth, and readiness for both agents |
225
+ | `/agents auth [codex\|claude]` | ✅ | Auth status plus the exact login command |
226
+ | `/harness-setup` | ✅ | The setup checklist, inside a session |
227
+ | `/codex <task>` | ✅ | Delegate straight to Codex |
228
+ | `/claude <task>` | ✅ | Delegate straight to Claude Code |
229
+ | `/sessions` | ✅ | List, watch, switch focus between, and cancel running delegations |
230
+
231
+ ### Tools the supervisor calls on its own
232
+
233
+ | tool | status | typical use |
234
+ |---|---|---|
235
+ | `ask_codex` | ✅ | implementation, debugging, refactors, tests |
236
+ | `ask_claude` | ✅ | architecture, planning, design review, second opinions |
237
+ | `delegate` | ✅ | `agent: "auto"` routing |
238
+ | `agent_runs` | ✅ | fan several runs out, then join them |
239
+
240
+ ### Parallel runs
241
+
242
+ Every delegation — whether from a slash command or a tool — becomes a *run* in a
243
+ session-scoped registry. Runs execute concurrently; `/sessions` is the view over them.
244
+
245
+ ```text
246
+ /claude --bg review the auth architecture
247
+ /codex --bg --read-only summarize the test suite
248
+ /sessions # both listed, elapsed ticking
249
+ /sessions attach r7c1 # stream that run's output into the widget
250
+ /sessions cancel r7c2 # stops just that run
251
+ ```
252
+
253
+ Attaching changes only what you are shown. It never pauses, throttles, or reorders a run —
254
+ unfocused runs keep executing and keep filling their own output buffers.
255
+
256
+ What may run at once:
257
+
258
+ | situation | parallel? |
259
+ |---|---|
260
+ | N read-only runs, same repo | yes |
261
+ | read-only + writer, same repo | yes — the reader may observe a moving tree, and the result says so |
262
+ | two writers, same repo | no — serialized by a FIFO workspace write lock |
263
+ | two writers, different repos | yes |
264
+
265
+ `concurrency.maxConcurrentRuns` (default 4) caps live child processes; the rest wait in
266
+ `queued`. Quitting OMP cancels every live run and waits for it to die — no child or
267
+ grandchild outlives the session.
268
+
269
+ > `/sessions` covers **delegated runs only**. OMP's own `/resume` already lists and switches
270
+ > OMP sessions, including `/resume @claude` and `/resume @codex` to import a worker session,
271
+ > so this extension does not duplicate it.
272
+
273
+ ### Session continuation
274
+
275
+ Each `(OMP session, repo)` pair remembers the worker session it was talking to, so a second
276
+ `/codex` continues the first one's thread rather than starting cold. `--new` forces a fresh
277
+ one. If a resume fails, the run retries **once** with a compact handoff summary rather than
278
+ replaying history, and says so in the result. Two parallel runs against the same agent and
279
+ repo cannot share one worker session: the second forks (Claude) or starts fresh (Codex).
280
+
281
+ The mapping holds ids, paths, and timestamps only — never task text, tokens, or
282
+ environment — in `0600` files under a `0700` directory in the active agent dir (honoring
283
+ `--profile` and `PI_CODING_AGENT_DIR`).
284
+
285
+ ### Model selection
286
+
287
+ Three independent choices:
288
+
289
+ - **Worker models** stay yours. Set nothing and no `-m`/`--model` is passed at all — each
290
+ CLI's own config decides. Override per agent (`codex.model`) or per call (`--model`).
291
+ - **Router model** (`routing.model`, default `@smol`) resolves `agent: "auto"` with one
292
+ small classification call. Any failure falls back to the rule table, so it can never
293
+ block a delegation. `routing.mode: rules` turns it off entirely.
294
+
295
+ ---
296
+
297
+ ## Troubleshooting
298
+
299
+ | symptom | cause and fix |
300
+ |---|---|
301
+ | `/agents` says *not authenticated* for Codex | `codex login`. (Note `codex login status` prints to stderr — tools that read only stdout get this wrong.) |
302
+ | `/agents` says *not authenticated* for Claude | `claude auth login` |
303
+ | `auto` always picks by rules | OMP has no authenticated model — `omp` → `/login`, or set `routing.model` |
304
+ | `unavailable — \`codex\` not found on PATH` | install it, or set `multiHarness.codex.executable` to the full path |
305
+ | `PROVIDER_LIMIT: … out of credits` | the provider account, not the task — top up or switch accounts. Retrying will not help. |
306
+ | doctor shows *repo-local copy shadowing your global omp* | harmless: `bun run` puts `node_modules/.bin` first, and the dev dependency ships an `omp` |
307
+ | Extension not loading | confirm the symlink target, or run `omp -e ./src/index.ts` directly |
308
+
309
+ ---
310
+
311
+ ## Security posture
312
+
313
+ - Never reads OAuth or token files; auth state comes only from each CLI's own status command.
314
+ - Never prints credentials. `claude auth status --json` returns your email and org — only
315
+ `loggedIn` and `authMethod` are read.
316
+ - Never forwards one provider's credentials to the other.
317
+ - No `shell: true`, ever. Arguments are arrays; prompts go over **stdin**, so task text
318
+ never lands in `ps` or shell history.
319
+ - Permission-bypass flags (`--dangerously-bypass-approvals-and-sandbox`,
320
+ `--dangerously-skip-permissions`) are never passed, and no config option enables them.
321
+ - Working directories are realpath-validated; a run cannot silently target another repo.
322
+ - Two write-capable agents never touch the same working tree at once.
323
+ - `readOnlyEnforced` reports what the adapter's **argv actually enforced**, not what the
324
+ caller requested. An adapter that cannot prove enforcement reports `false`.
325
+ - Every CLI-derived string embedded in an error is redacted first (API keys, tokens, JWTs,
326
+ PEM blocks, `KEY=value` assignments), so a credential a worker echoes back cannot reach
327
+ an error message or a log. Environment views are allowlist-based — a denylist fails open.
328
+
329
+ Details: [`_spec/10-errors-and-security.md`](_spec/10-errors-and-security.md).
330
+
331
+ ---
332
+
333
+ ## Development
334
+
335
+ ```bash
336
+ bun install
337
+ bun test # 110 tests, no live provider calls
338
+ MULTI_HARNESS_LIVE_TESTS=1 bun test test/live.test.ts # opt-in, calls the real CLIs
339
+ bun run typecheck
340
+ bun run dev # omp --no-extensions -e ./src/index.ts
341
+ bun run doctor
342
+ ```
343
+
344
+ VS Code config ships in `.vscode/` (tasks for doctor/typecheck/test, launch configs, and
345
+ recommended extensions).
346
+
347
+ - [`_spec/`](_spec/README.md) — the normative specification, 15 documents
348
+ - [`_plan/`](_plan/README.md) — phased plan, decision log, and the task tracker
349
+
350
+ Built and verified against `omp 18.2.6` · `codex-cli 0.155.0` · `claude 2.1.274` · `bun 1.4.2`
351
+ ([`_spec/01-environment-findings.md`](_spec/01-environment-findings.md)).
package/package.json ADDED
@@ -0,0 +1,76 @@
1
+ {
2
+ "name": "omp-multi-harness",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "OMP extension that delegates work to the Codex and Claude Code CLIs \u2014 parallel runs, /sessions, and automatic routing, using each CLI's own login",
6
+ "license": "MIT",
7
+ "author": "Ahmad Bakhshi",
8
+ "homepage": "https://github.com/abdevts/omp-multi-harness#readme",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/abdevts/omp-multi-harness.git"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/abdevts/omp-multi-harness/issues"
15
+ },
16
+ "keywords": [
17
+ "omp",
18
+ "omp-extension",
19
+ "pi-coding-agent",
20
+ "codex",
21
+ "claude-code",
22
+ "ai",
23
+ "agent",
24
+ "cli",
25
+ "delegation"
26
+ ],
27
+ "main": "src/index.ts",
28
+ "exports": {
29
+ ".": "./src/index.ts",
30
+ "./package.json": "./package.json"
31
+ },
32
+ "omp": {
33
+ "extensions": [
34
+ "./src/index.ts"
35
+ ]
36
+ },
37
+ "bin": {
38
+ "omp-multi-harness": "scripts/cli.ts"
39
+ },
40
+ "files": [
41
+ "src",
42
+ "scripts/cli.ts",
43
+ "scripts/setup",
44
+ "scripts/setup.ts",
45
+ "README.md",
46
+ "LICENSE"
47
+ ],
48
+ "engines": {
49
+ "bun": ">=1.4.0"
50
+ },
51
+ "scripts": {
52
+ "setup": "bun scripts/setup.ts",
53
+ "doctor": "bun scripts/setup.ts check",
54
+ "typecheck": "tsc --noEmit",
55
+ "test": "bun test",
56
+ "dev": "omp --no-extensions -e ./src/index.ts",
57
+ "link-extension": "bun scripts/cli.ts link",
58
+ "prepublishOnly": "bun run typecheck && bun test"
59
+ },
60
+ "peerDependencies": {
61
+ "@oh-my-pi/pi-coding-agent": ">=18.2.6"
62
+ },
63
+ "peerDependenciesMeta": {
64
+ "@oh-my-pi/pi-coding-agent": {
65
+ "optional": true
66
+ }
67
+ },
68
+ "devDependencies": {
69
+ "@oh-my-pi/pi-coding-agent": "^18.2.6",
70
+ "@types/bun": "^1.4.2",
71
+ "typescript": "^7.0.2"
72
+ },
73
+ "publishConfig": {
74
+ "access": "public"
75
+ }
76
+ }
package/scripts/cli.ts ADDED
@@ -0,0 +1,164 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * `omp-multi-harness` CLI — registers this package with OMP.
4
+ *
5
+ * Installing from npm puts the package in node_modules, which OMP does **not** scan. OMP
6
+ * discovers extensions from its agent dir (`<agentDir>/extensions/`) and from a project's
7
+ * `.omp/extensions/`, resolving each directory through its `package.json` `omp.extensions`
8
+ * manifest. So the install step is a symlink from one of those directories to this package.
9
+ *
10
+ * Symlinks only — this never edits your OMP config, never logs in, and never installs a CLI.
11
+ */
12
+ import { existsSync, lstatSync, mkdirSync, readlinkSync, rmSync, symlinkSync } from "node:fs";
13
+ import { homedir } from "node:os";
14
+ import { dirname, join, resolve } from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+
17
+ const LINK_NAME = "multi-harness";
18
+
19
+ /** This package's root — the directory containing its package.json. */
20
+ function packageRoot(): string {
21
+ return resolve(dirname(fileURLToPath(import.meta.url)), "..");
22
+ }
23
+
24
+ /**
25
+ * The active OMP agent dir, resolved the same way OMP resolves it: `PI_CODING_AGENT_DIR`
26
+ * wins, then `--profile <name>`, then the default. Never hard-coded to `~/.omp/agent`.
27
+ */
28
+ function agentDir(profile?: string): string {
29
+ const fromEnv = process.env.PI_CODING_AGENT_DIR;
30
+ if (fromEnv) return resolve(fromEnv);
31
+ if (profile) return join(homedir(), ".omp", "profiles", profile, "agent");
32
+ return join(homedir(), ".omp", "agent");
33
+ }
34
+
35
+ function targetDir(scope: "global" | "project", profile?: string, cwd = process.cwd()): string {
36
+ return scope === "global" ? join(agentDir(profile), "extensions") : join(resolve(cwd), ".omp", "extensions");
37
+ }
38
+
39
+ /** Where this package is (or would be) linked from. */
40
+ function linkPath(scope: "global" | "project", profile?: string): string {
41
+ return join(targetDir(scope, profile), LINK_NAME);
42
+ }
43
+
44
+ function describeExisting(path: string): { kind: "absent" | "symlink" | "other"; target?: string } {
45
+ if (!existsSync(path) && !isDanglingSymlink(path)) return { kind: "absent" };
46
+ try {
47
+ if (lstatSync(path).isSymbolicLink()) return { kind: "symlink", target: readlinkSync(path) };
48
+ } catch {
49
+ // fall through — treat an unreadable entry as "other" so we never clobber it
50
+ }
51
+ return { kind: "other" };
52
+ }
53
+
54
+ /** `existsSync` follows symlinks, so a broken link reads as absent without this. */
55
+ function isDanglingSymlink(path: string): boolean {
56
+ try {
57
+ return lstatSync(path).isSymbolicLink();
58
+ } catch {
59
+ return false;
60
+ }
61
+ }
62
+
63
+ function link(scope: "global" | "project", profile?: string): number {
64
+ const root = packageRoot();
65
+ const dir = targetDir(scope, profile);
66
+ const dest = linkPath(scope, profile);
67
+ const existing = describeExisting(dest);
68
+
69
+ if (existing.kind === "other") {
70
+ console.error(`Refusing to replace ${dest} — it exists and is not a symlink.`);
71
+ console.error("Move it aside yourself, then re-run. This tool only ever manages its own symlink.");
72
+ return 1;
73
+ }
74
+ if (existing.kind === "symlink" && resolve(dirname(dest), existing.target ?? "") === root) {
75
+ console.log(`Already linked: ${dest} -> ${root}`);
76
+ return 0;
77
+ }
78
+
79
+ mkdirSync(dir, { recursive: true });
80
+ if (existing.kind === "symlink") rmSync(dest);
81
+ symlinkSync(root, dest, "dir");
82
+
83
+ console.log(`Linked: ${dest} -> ${root}`);
84
+ console.log("Start `omp` and run /agents to confirm it loaded.");
85
+ return 0;
86
+ }
87
+
88
+ function unlink(scope: "global" | "project", profile?: string): number {
89
+ const dest = linkPath(scope, profile);
90
+ const existing = describeExisting(dest);
91
+
92
+ if (existing.kind === "absent") {
93
+ console.log(`Nothing to remove at ${dest}.`);
94
+ return 0;
95
+ }
96
+ if (existing.kind === "other") {
97
+ console.error(`Refusing to remove ${dest} — it is not a symlink.`);
98
+ return 1;
99
+ }
100
+ rmSync(dest);
101
+ console.log(`Unlinked: ${dest}`);
102
+ return 0;
103
+ }
104
+
105
+ function status(profile?: string): number {
106
+ for (const scope of ["global", "project"] as const) {
107
+ const dest = linkPath(scope, profile);
108
+ const existing = describeExisting(dest);
109
+ const state =
110
+ existing.kind === "absent"
111
+ ? "not linked"
112
+ : existing.kind === "other"
113
+ ? "occupied by a non-symlink"
114
+ : `-> ${existing.target}`;
115
+ console.log(`${scope.padEnd(8)} ${dest}\n ${state}`);
116
+ }
117
+ return 0;
118
+ }
119
+
120
+ const USAGE = `omp-multi-harness — register this OMP extension
121
+
122
+ Usage:
123
+ omp-multi-harness link [--project] [--profile <name>] symlink into OMP's extensions dir
124
+ omp-multi-harness unlink [--project] [--profile <name>] remove that symlink
125
+ omp-multi-harness status [--profile <name>] show where it is linked
126
+ omp-multi-harness doctor run the full setup check
127
+
128
+ --project links into ./.omp/extensions (this repo only) instead of the agent dir.
129
+ --profile <name> targets ~/.omp/profiles/<name>/agent. PI_CODING_AGENT_DIR overrides both.`;
130
+
131
+ const args = process.argv.slice(2);
132
+ const command = args[0] ?? "help";
133
+ const scope: "global" | "project" = args.includes("--project") ? "project" : "global";
134
+ const profileIndex = args.indexOf("--profile");
135
+ const profile = profileIndex >= 0 ? args[profileIndex + 1] : undefined;
136
+
137
+ if (profileIndex >= 0 && !profile) {
138
+ console.error("--profile needs a name");
139
+ process.exit(1);
140
+ }
141
+
142
+ switch (command) {
143
+ case "link":
144
+ process.exit(link(scope, profile));
145
+ break;
146
+ case "unlink":
147
+ process.exit(unlink(scope, profile));
148
+ break;
149
+ case "status":
150
+ process.exit(status(profile));
151
+ break;
152
+ case "doctor": {
153
+ // setup.ts runs its checks on import via its own entrypoint, and does not export a
154
+ // callable main — so spawn it rather than pretending it has an API it does not.
155
+ const proc = Bun.spawn(["bun", join(packageRoot(), "scripts", "setup.ts"), "check"], {
156
+ stdio: ["inherit", "inherit", "inherit"],
157
+ });
158
+ process.exit(await proc.exited);
159
+ break;
160
+ }
161
+ default:
162
+ console.log(USAGE);
163
+ process.exit(command === "help" || command === "--help" || command === "-h" ? 0 : 1);
164
+ }
@@ -0,0 +1,41 @@
1
+ /** Claude Code CLI setup: presence + authentication. Mirrors src/agents/claude.ts. */
2
+ import { type SetupGroup, executableStep, sh, which } from "./types.ts";
3
+
4
+ export const claudeSetup: SetupGroup = {
5
+ id: "claude",
6
+ title: "Claude Code",
7
+ steps: [
8
+ executableStep({
9
+ id: "claude-cli",
10
+ title: "Claude Code CLI",
11
+ bin: "claude",
12
+ install: {
13
+ description: "Install Claude Code",
14
+ command: "npm install -g @anthropic-ai/claude-code",
15
+ },
16
+ }),
17
+ {
18
+ id: "claude-auth",
19
+ title: "Claude Code authentication",
20
+ run() {
21
+ if (!which("claude")) return { status: "skip", detail: "claude not installed" };
22
+ const r = sh("claude", ["auth", "status", "--json"], 20_000);
23
+ if (r.ok) {
24
+ try {
25
+ // The payload also carries email, org id and org name — deliberately
26
+ // read only these two fields, and never persist or print the rest.
27
+ const j = JSON.parse(r.out) as { loggedIn?: boolean; authMethod?: string };
28
+ if (j.loggedIn) return { status: "ok", detail: `logged in via ${j.authMethod ?? "unknown method"}` };
29
+ } catch {
30
+ return { status: "warn", detail: "unexpected `claude auth status` output" };
31
+ }
32
+ }
33
+ return {
34
+ status: "fail",
35
+ detail: "not authenticated",
36
+ fix: { description: "Complete Claude Code's own login flow", command: "claude auth login" },
37
+ };
38
+ },
39
+ },
40
+ ],
41
+ };
@@ -0,0 +1,35 @@
1
+ /** Codex CLI setup: presence + authentication. Mirrors src/agents/codex.ts. */
2
+ import { IS_MAC, type SetupGroup, executableStep, sh, which } from "./types.ts";
3
+
4
+ export const codexSetup: SetupGroup = {
5
+ id: "codex",
6
+ title: "Codex",
7
+ steps: [
8
+ executableStep({
9
+ id: "codex-cli",
10
+ title: "Codex CLI",
11
+ bin: "codex",
12
+ install: {
13
+ description: "Install the Codex CLI",
14
+ command: IS_MAC ? "brew install --cask codex" : "npm install -g @openai/codex",
15
+ },
16
+ }),
17
+ {
18
+ id: "codex-auth",
19
+ title: "Codex authentication",
20
+ run() {
21
+ if (!which("codex")) return { status: "skip", detail: "codex not installed" };
22
+ const r = sh("codex", ["login", "status"], 20_000);
23
+ // `codex login status` prints to STDERR, not stdout — read both (_spec/14).
24
+ // Only the CLI's own status line is read; no credential file is ever touched.
25
+ const line = [r.out, r.err].join("\n").split("\n").find((l) => /logged in/i.test(l));
26
+ if (r.ok && line) return { status: "ok", detail: line.trim() };
27
+ return {
28
+ status: "fail",
29
+ detail: "not authenticated",
30
+ fix: { description: "Complete Codex's own login flow", command: "codex login" },
31
+ };
32
+ },
33
+ },
34
+ ],
35
+ };