@agntn/harnesses 0.0.1 → 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/README.md CHANGED
@@ -1,12 +1,14 @@
1
1
  # @agntn/harnesses
2
2
 
3
- [![npm version](https://img.shields.io/npm/v/%40agntn%2Fharnesses?style=flat&colorA=130f40&colorB=474787)](https://npmjs.com/package/@agntn/harnesses)
4
- [![npm downloads](https://img.shields.io/npm/dm/%40agntn%2Fharnesses?style=flat&colorA=130f40&colorB=474787)](https://npm.chart.dev/@agntn/harnesses)
5
- [![license](https://img.shields.io/github/license/agntn/harnesses?style=flat&colorA=130f40&colorB=474787)](https://github.com/agntn/harnesses/blob/main/LICENSE)
3
+ [![npm version](https://npmx.dev/api/registry/badge/version/@agntn/harnesses)](https://npmx.dev/package/@agntn/harnesses)
4
+ [![npm downloads](https://npmx.dev/api/registry/badge/downloads/@agntn/harnesses)](https://npmx.dev/package/@agntn/harnesses)
5
+ [![license](https://npmx.dev/api/registry/badge/license/@agntn/harnesses)](https://npmx.dev/package/@agntn/harnesses)
6
6
  [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/agntn/harnesses)
7
7
 
8
8
  Metadata toolkit for AI coding harnesses. One registry of paths, formats, and detection rules for every major CLI.
9
9
 
10
+ Docs: [harnesses.agntn.dev](https://harnesses.agntn.dev)
11
+
10
12
  ## Install
11
13
 
12
14
  ```bash
@@ -24,12 +26,12 @@ console.log(claude.hooks); // [{ path: ".claude/hooks/", scope: "project", ... }
24
26
  console.log(claude.invocationModes); // advisor and full agent modes, no read-only mode
25
27
 
26
28
  const codex = getHarness("codex");
27
- await codex.invoke("Review this patch", { readOnly: true });
29
+ await codex.invoke("Review this patch", { readOnly: true, timeoutMs: 60_000 });
28
30
 
29
31
  const pi = getHarness("pi");
30
32
  const { models } = await pi.listModels({ search: "gpt-5.4" });
31
33
  console.log(models); // [{ provider: "openai-codex", id: "gpt-5.4", ... }]
32
- await pi.invoke("Review this patch", { model: "openai-codex/gpt-5.4" });
34
+ await pi.invoke("Review this patch", { model: "openai-codex/gpt-5.4", readOnly: true });
33
35
 
34
36
  // Resolve to absolute paths for current platform
35
37
  const paths = claude.resolve({ platform: "linux", homeDir: "/home/dev" });
@@ -89,6 +91,24 @@ import type { ClaudeSessionEntry, CodexThread, GeminiConversationRecord } from "
89
91
 
90
92
  Primary references: [Antigravity prompting](https://antigravity.google/docs/cli/prompting/), [Antigravity changelog](https://github.com/google-antigravity/antigravity-cli/blob/main/CHANGELOG.md), [Gemini CLI tools](https://github.com/google-gemini/gemini-cli/blob/main/docs/reference/tools.md), [Gemini CLI video request](https://github.com/google-gemini/gemini-cli/issues/27194), [OpenCode attachments](https://opencode.ai/v2/docs/attachments/), [Cursor prompting](https://cursor.com/docs/agent/prompting), and [Copilot CLI voice input](https://docs.github.com/en/copilot/how-tos/copilot-cli/use-copilot-cli/voice-input).
91
93
 
94
+ `invoke()` and `listModels()` accept `timeoutMs` and `signal?: AbortSignal`. Unset or `0` means no deadline. An already aborted signal skips spawning. Otherwise, the first cancellation or deadline starts cleanup: Linux and macOS use a dedicated process group, with `SIGTERM` followed by `SIGKILL` after 500 ms even if the root has exited. Windows uses `taskkill /T /F` immediately, with a 2 s budget for that command. Cleanup failures reject the call.
95
+
96
+ Stopped results retain captured output and set `exitCode: null`. Caller cancellation sets `aborted: true`, a deadline sets `timedOut: true`, and the first reason wins. Both flags are false on normal completion. Later aborts do nothing, and cancelled model listings return no parsed models. Pi, OMP, and MCP tools forward their host request signal, not a JSON argument supplied by the model.
97
+
98
+ ```ts
99
+ const controller = new AbortController();
100
+ const pending = getHarness("pi").invoke("Review this change", {
101
+ tools: true,
102
+ readOnly: true,
103
+ signal: controller.signal,
104
+ });
105
+ controller.abort();
106
+ const result = await pending;
107
+ console.log(result.aborted);
108
+ ```
109
+
110
+ This is command cleanup, not a sandbox. Descendants that leave the POSIX process group, or outlive an already exited root on Windows, cannot be reliably reached by these mechanisms. Inherited output pipes do not extend the wait after cleanup. Scheduling and OS delays can exceed the stated budgets.
111
+
92
112
  Each agent is a concrete subclass of the abstract `Harness` class. Custom subclasses can be added with `registerHarness`. Every harness exposes config paths, session locations, instruction files, skills dirs, hooks, commands, persistence formats, capabilities (MCP, vision, audio, video, tools, streaming), detection rules, a normalized non-interactive invocation (`harness.invoke(prompt, { model })`) where the CLI has a headless mode, native model listing (`harness.listModels()`) where the CLI supports it, and its MCP server config files (`listMcpServers`/`addMcpServer`/`removeMcpServer` normalize the dialects; writes rewrite JSON and surgically edit TOML with comments preserved). `syncMcpServers` treats `~/.config/agntn/mcp.jsonc` (JSONC, XDG-aware) as the single source of truth and resets every harness's user-scope MCP config to exactly that list; a top-level `"excludes": ["codex"]` array opts individual harnesses out of the sync (their own servers stay, master-listed names are withdrawn), and `~`/`${HOME}` in commands, args, and env values expand to absolute paths at sync time (harnesses spawn MCP servers without a shell). `syncAgentsFiles` links every harness's global instructions file (CLAUDE.md/AGENTS.md/GEMINI.md) to one master file as symlinks, so an edit made through any harness lands in the single physical copy; `~/.config/agntn/agents.jsonc` sets the `source`, `companions`, and `excludes`, diverged regular files are backed up and relinked, and check mode reports without writing. Companion paths are relative to the source directory and are linked at the same relative path beside each harness target.
93
113
 
94
114
  ```jsonc
@@ -125,7 +145,7 @@ harnesses agents sync --check # doctor: link global AGENTS.md files to one mas
125
145
  harnesses mcp # run the MCP server over stdio
126
146
  ```
127
147
 
128
- `tools` defaults to `false` in the library and CLI. The MCP, Pi, and OMP tools require agents to choose it explicitly. `false` must use a native CLI flag that removes tools from the model context; it is a lightweight advisor, not an agent constrained only by prompt wording. Set `tools: true` (or CLI `--tools`) whenever the task needs harness tools, including Grok's native X search. Add `readOnly: true` when those tools must stay inside a sandbox enforced by the harness CLI; the agent tools pass it beside `tools: true`, while the library and CLI let it imply tools. Read-only mode is rejected when a harness has no verified native recipe, so it never falls back to broader access. Harnesses whose CLI cannot disable tools reject advisor mode instead of silently running an agent and return an explicit `tools` retry when their full agent mode can handle the request.
148
+ `tools` defaults to `false` in the library and CLI. The MCP, Pi, and OMP tools require agents to choose it explicitly. `false` must use a native CLI flag that removes tools from the model context; it is a lightweight advisor, not an agent constrained only by prompt wording. Set `tools: true` (or CLI `--tools`) whenever the task needs harness tools, including Grok's native X search. Add `readOnly: true` when those tools must stay inside a sandbox enforced by the harness CLI; the agent tools pass it beside `tools: true`, while the library and CLI let it imply tools. Read-only mode is rejected when a harness has no verified native recipe, so it never falls back to broader access. A recipe can also carry the lowest CLI version whose enforcement was verified, and `invoke()` rejects read-only runs on older or unknown versions: Grok runs `--sandbox read-only` from 1.0.13, and Claude Code keeps read-only runs on `Read`, `Glob` and `Grep` with `--strict-mcp-config` from 2.1.175. Harnesses whose CLI cannot disable tools reject advisor mode instead of silently running an agent and return an explicit `tools` retry when their full agent mode can handle the request.
129
149
 
130
150
  ## How harnesses compares to unagent
131
151
 
@@ -40,6 +40,8 @@ interface HarnessInvocation {
40
40
  readOnlyArgs?: string[];
41
41
  /** Structured agent argument template with read-only tool access. */
42
42
  readOnlyJsonArgs?: string[];
43
+ /** Lowest CLI version whose read-only enforcement was verified; older or unknown versions reject read-only runs. */
44
+ readOnlyMinVersion?: string;
43
45
  /** Arguments appended when a model is selected; every "{model}" is replaced. */
44
46
  modelArgs?: string[];
45
47
  level: EvidenceLevel;
@@ -77,8 +79,10 @@ interface ListModelsOptions {
77
79
  search?: string;
78
80
  cwd?: string;
79
81
  env?: Record<string, string>;
80
- /** Kill the model-listing command after this many milliseconds; unset means no timeout. */
82
+ /** Milliseconds before cleanup starts. Unset or 0 disables the deadline. */
81
83
  timeoutMs?: number;
84
+ /** Cancel with the same process cleanup as a timeout. */
85
+ signal?: AbortSignal;
82
86
  }
83
87
  interface InvokeOptions {
84
88
  cwd?: string;
@@ -89,8 +93,10 @@ interface InvokeOptions {
89
93
  tools?: boolean;
90
94
  /** Require native enforcement of read-only tool access. Implies `tools: true`. */
91
95
  readOnly?: boolean;
92
- /** Kill the harness after this many milliseconds; unset means no timeout. */
96
+ /** Milliseconds before cleanup starts. Unset or 0 disables the deadline. */
93
97
  timeoutMs?: number;
98
+ /** Cancel with the same process cleanup as a timeout. */
99
+ signal?: AbortSignal;
94
100
  /** Use the harness's structured (JSON) output mode instead of plain text. */
95
101
  structured?: boolean;
96
102
  }
@@ -99,9 +105,11 @@ interface InvokeResult {
99
105
  args: string[];
100
106
  stdout: string;
101
107
  stderr: string;
102
- /** Process exit code; null when the run hit `timeoutMs` and was killed. */
108
+ /** Process exit code; null when terminated by a signal, timeout, or cancellation. */
103
109
  exitCode: number | null;
104
110
  timedOut: boolean;
111
+ /** True when caller cancellation wins over the deadline or completion. */
112
+ aborted: boolean;
105
113
  }
106
114
  /** Result of one native model-listing command. */
107
115
  interface ListModelsResult extends InvokeResult {
@@ -214,10 +222,19 @@ declare abstract class Harness {
214
222
  * exits instead of waiting forever.
215
223
  *
216
224
  * @param prompt - Prompt sent to the harness.
217
- * @param options - Invocation, environment, and timeout options.
225
+ * @param options - Invocation, environment, timeout, and cancellation options.
218
226
  * @returns {Promise<InvokeResult>} The completed process result.
219
227
  */
220
228
  invoke(prompt: string, options?: InvokeOptions): Promise<InvokeResult>;
229
+ /**
230
+ * Explains why the installed CLI cannot run a read-only recipe that carries a
231
+ * version floor, or returns null when no floor applies or the installed
232
+ * version meets it. An unknown version fails closed, like a missing recipe.
233
+ *
234
+ * @param options - Requested execution mode.
235
+ * @returns {string | null} The version incompatibility, or null when the run may proceed.
236
+ */
237
+ private readOnlyVersionError;
221
238
  /**
222
239
  * Expands the native model-listing recipe without spawning anything.
223
240
  *
@@ -232,7 +249,7 @@ declare abstract class Harness {
232
249
  * Runs the harness's native model-listing command and normalizes its output.
233
250
  * stdin is closed for the same reason as {@link invoke}.
234
251
  *
235
- * @param options - Search, environment, and timeout options.
252
+ * @param options - Search, environment, timeout, and cancellation options.
236
253
  * @returns {Promise<ListModelsResult>} The normalized command and model result.
237
254
  */
238
255
  listModels(options?: ListModelsOptions): Promise<ListModelsResult>;
@@ -1 +1 @@
1
- {"version":3,"file":"agents-sync.d.mts","names":[],"sources":["../../src/types.ts","../../src/harness.ts","../../src/mcp-servers.ts","../../src/agents-sync.ts"],"mappings":"AAEA,cAAa;KAED;KAaA;KACA;UAEK;EACf;EACA;EACA,OAAO;EACP,YAAY;EACZ;;UAGe;EACf;EACA,OAAO;EACP;;UAGe;EACf;EACA;;EAEA;;EAEA;EACA;EACA;;;UAIe;;EAEf;;EAEA;;EAEA;;EAEA;;EAEA;;EAEA;;EAEA;;EAEA;EACA,OAAO;EACP;;;UAIe;EACf;EACA;EACA;EACA;EACA;EACA;;;UAIe;;EAEf;;EAEA;EACA,OAAO;EACP;;;UAIe;EACf;EACA;EACA;EACA;EACA;EACA;;;UAIe;EACf;EACA;EACA,MAAM;;EAEN;;UAGe;EACf;EACA,MAAM;;EAEN;;EAEA;;EAEA;;EAEA;;EAEA;;UAGe;EACf;EACA;EACA;EACA;;EAEA;EACA;;;UAIe,yBAAyB;;EAExC,QAAQ;;;UAIO;EACf;EACA;EACA;EACA;EACA,MAAM;EACN;EACA,UAAU;;EAEV;;;UAIe,sBAAsB;EACrC;;EAEA;;EAEA;;UAGe;;EAEf;;EAEA;;UAGe;EACf;EACA;EACA,WAAW;;UAGI;EACf,QAAQ;EACR,UAAU;EACV,cAAc;EACd,QAAQ;EACR,UAAU;EACV,OAAO;;KC9IJ,oBAAoB;EACvB;EACA;EACA;EACA;;uBAqHoB;oBACF,IAAI;oBACJ;oBACA;oBACA,QAAQ;oBACR,UAAU;oBACV,aAAa;oBACb,cAAc;oBACd,QAAQ;oBACR,UAAU;oBACV,OAAO;oBACP,cAAc;oBACd,WAAW;;oBAEX,YAAY;;WAErB,cAAc;;WAEd,YAAY;;;;;;WAMZ;EAET;EAYA;EAIA,cAAc;MAKV;;;;;;MAsBA,mBAAmB;;;;;;;;;;EAoBvB,gBACE,gBACA,UAAS;IACN;IAAiB;;;;;;;;EAmBtB,gBAAgB,UAAS;;;;;;;;;;EAwBzB,OAAO,gBAAgB,UAAS,gBAAqB,QAAQ;;;;;;;EAuB7D,yBAAyB;IAAoB;IAAiB;;;;;;;;;EAmBxD,WAAW,UAAS,oBAAyB,QAAQ;;;;;;YAyBjD,wBAAwB,kBAAkB;;;;;;;;;;EAapD,kBAAkB,UAAU,eAC1B,kBAAkB,KAClB,UAAS,iBACR;EAcH,QAAQ,UAAS,iBAAsB;;KAY7B,+BAA+B;;UC9X1B;EACf;EACA,OAAO;EACP,QAAQ;EACR,OAAO;EACP;EACA;EACA,SAAS;;EAET;;;;;;;;;;iBAmRc,eAAe,SAAS,SAAS,UAAS,iBAAsB;;;;;;;;;;;;iBAiLhE,aACd,SAAS,SACT,QAAQ,iBACR,4BACA,UAAS;EACN;EAAc;;;;;;;;;;;iBA0BH,gBACd,SAAS,SACT,cACA,4BACA,UAAS;EACN;EAAc;;;;;;;;;iBA8FH,WAAW;;;;;;;iBAwBX,cAAc,UAAS;;UAKtB;EACf;EACA;;EAEA;;EAEA;EACA,SAAS;IAAQ;IAAc;;;;UAIhB;EACf;EACA;EACA,SAAS;;;;;;;;iBAuEK,qBAAqB,UAAS;EAC5C;EACA,SAAS;;EAET;;;;;;;;;;;iBA2Hc,eACd,oBAAoB,WACpB,UAAS,iBACR;;UC1zBc;;EAEf;;EAEA;;EAEA;;EAEA;;KAGU;;UAGK;WACN;WACA;WACA,QAAQ,QAAQ;;WAEhB;;;UAIM;EACf;EACA;EACA,QAAQ;;EAER;;EAEA,sBAAsB;;;UAIP;EACf;EACA;EACA,SAAS;;;;;;;;iBAkFK,iBAAiB,UAAS,iBAAsB;;;;;;;;;;iBAiMhD,gBACd,oBAAoB,WACpB,iBACA,UAAS,iBACR"}
1
+ {"version":3,"file":"agents-sync.d.mts","names":[],"sources":["../../src/types.ts","../../src/harness.ts","../../src/mcp-servers.ts","../../src/agents-sync.ts"],"mappings":"AAEA,cAAa;KAED;KAaA;KACA;UAEK;EACf;EACA;EACA,OAAO;EACP,YAAY;EACZ;;UAGe;EACf;EACA,OAAO;EACP;;UAGe;EACf;EACA;;EAEA;;EAEA;EACA;EACA;;;UAIe;;EAEf;;EAEA;;EAEA;;EAEA;;EAEA;;EAEA;;EAEA;;EAEA;;EAEA;EACA,OAAO;EACP;;;UAIe;EACf;EACA;EACA;EACA;EACA;EACA;;;UAIe;;EAEf;;EAEA;EACA,OAAO;EACP;;;UAIe;EACf;EACA;EACA;EACA;EACA;EACA;;;UAIe;EACf;EACA;EACA,MAAM;;EAEN;;EAEA,SAAS;;UAGM;EACf;EACA,MAAM;;EAEN;;EAEA;;EAEA;;EAEA;;EAEA,SAAS;;EAET;;UAGe;EACf;EACA;EACA;EACA;;EAEA;EACA;;EAEA;;;UAIe,yBAAyB;;EAExC,QAAQ;;;UAIO;EACf;EACA;EACA;EACA;EACA,MAAM;EACN;EACA,UAAU;;EAEV;;;UAIe,sBAAsB;EACrC;;EAEA;;EAEA;;UAGe;;EAEf;;EAEA;;UAGe;EACf;EACA;EACA,WAAW;;UAGI;EACf,QAAQ;EACR,UAAU;EACV,cAAc;EACd,QAAQ;EACR,UAAU;EACV,OAAO;;KCnJJ,oBAAoB;EACvB;EACA;EACA;EACA;;uBA2OoB;oBACF,IAAI;oBACJ;oBACA;oBACA,QAAQ;oBACR,UAAU;oBACV,aAAa;oBACb,cAAc;oBACd,QAAQ;oBACR,UAAU;oBACV,OAAO;oBACP,cAAc;oBACd,WAAW;;oBAEX,YAAY;;WAErB,cAAc;;WAEd,YAAY;;;;;;WAMZ;EAET;EAYA;EAIA,cAAc;MAKV;;;;;;MAsBA,mBAAmB;;;;;;;;;;EAoBvB,gBACE,gBACA,UAAS;IACN;IAAiB;;;;;;;;EAmBtB,gBAAgB,UAAS;;;;;;;;;;EAqBzB,OAAO,gBAAgB,UAAS,gBAAqB,QAAQ;;;;;;;;;UA2BrD;;;;;;;EAgBR,yBAAyB;IAAoB;IAAiB;;;;;;;;;EAmBxD,WAAW,UAAS,oBAAyB,QAAQ;;;;;;YAyBjD,wBAAwB,kBAAkB;;;;;;;;;;EAapD,kBAAkB,UAAU,eAC1B,kBAAkB,KAClB,UAAS,iBACR;EAcH,QAAQ,UAAS,iBAAsB;;KAY7B,+BAA+B;;UCxgB1B;EACf;EACA,OAAO;EACP,QAAQ;EACR,OAAO;EACP;EACA;EACA,SAAS;;EAET;;;;;;;;;;iBAmRc,eAAe,SAAS,SAAS,UAAS,iBAAsB;;;;;;;;;;;;iBAiLhE,aACd,SAAS,SACT,QAAQ,iBACR,4BACA,UAAS;EACN;EAAc;;;;;;;;;;;iBA0BH,gBACd,SAAS,SACT,cACA,4BACA,UAAS;EACN;EAAc;;;;;;;;;iBA8FH,WAAW;;;;;;;iBAwBX,cAAc,UAAS;;UAKtB;EACf;EACA;;EAEA;;EAEA;EACA,SAAS;IAAQ;IAAc;;;;UAIhB;EACf;EACA;EACA,SAAS;;;;;;;;iBAuEK,qBAAqB,UAAS;EAC5C;EACA,SAAS;;EAET;;;;;;;;;;;iBA2Hc,eACd,oBAAoB,WACpB,UAAS,iBACR;;UC1zBc;;EAEf;;EAEA;;EAEA;;EAEA;;KAGU;;UAGK;WACN;WACA;WACA,QAAQ,QAAQ;;WAEhB;;;UAIM;EACf;EACA;EACA,QAAQ;;EAER;;EAEA,sBAAsB;;;UAIP;EACf;EACA;EACA,SAAS;;;;;;;;iBA2FK,iBAAiB,UAAS,iBAAsB;;;;;;;;;;iBAiMhD,gBACd,oBAAoB,WACpB,iBACA,UAAS,iBACR"}
@@ -1,13 +1,20 @@
1
+ import { addAbortListener } from "node:events";
1
2
  import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, renameSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
2
- import { execFileSync, spawn } from "node:child_process";
3
- import { basename, dirname, isAbsolute, join, normalize, resolve, sep } from "node:path";
3
+ import { execFile, execFileSync, spawn } from "node:child_process";
4
+ import { setTimeout as setTimeout$1 } from "node:timers/promises";
5
+ import { basename, dirname, isAbsolute, join, posix, resolve, win32 } from "node:path";
4
6
  import os, { homedir } from "node:os";
5
7
  import { stripVTControlCharacters } from "node:util";
6
8
  import { parse } from "smol-toml";
9
+ const PATH_MARKERS = /^~(?=\/|$)|\$\{HOME\}|\$\{PROJECT_ROOT\}|%([^%]+)%/g;
7
10
  function resolvePathTemplate(template, options = {}) {
8
11
  const homeDir = options.homeDir ?? os.homedir();
9
12
  const projectRoot = options.projectRoot ?? process.cwd();
10
- return template.replace(/^~(?=\/|$)/, homeDir).replaceAll("${HOME}", homeDir).replaceAll("${PROJECT_ROOT}", projectRoot).replaceAll(/%([^%]+)%/g, (match, name) => process.env[name] ?? match);
13
+ return template.replaceAll(PATH_MARKERS, (match, name) => {
14
+ if (match === "${PROJECT_ROOT}") return projectRoot;
15
+ if (name === void 0) return homeDir;
16
+ return process.env[name] ?? match;
17
+ });
11
18
  }
12
19
  function agntnConfigDir(options = {}) {
13
20
  const base = process.env.XDG_CONFIG_HOME && process.env.XDG_CONFIG_HOME !== "" ? process.env.XDG_CONFIG_HOME : join(options.homeDir ?? os.homedir(), ".config");
@@ -53,13 +60,75 @@ function invocationTemplate(invocation, mode) {
53
60
  case "agentStructured": return invocation.jsonArgs;
54
61
  }
55
62
  }
63
+ function invocationRetryHint(invocation, mode, options) {
64
+ const withoutStructured = requestedInvocationMode({
65
+ ...options,
66
+ structured: false
67
+ });
68
+ if (options.structured === true) {
69
+ if (invocationTemplate(invocation, withoutStructured)) return "; retry with structured: false";
70
+ const alternateWithoutStructured = ALTERNATE_INVOCATION_MODE[withoutStructured];
71
+ if (alternateWithoutStructured !== void 0 && invocationTemplate(invocation, alternateWithoutStructured)) return `; retry with structured: false and tools: ${!options.tools}`;
72
+ }
73
+ const alternateMode = ALTERNATE_INVOCATION_MODE[mode];
74
+ return (alternateMode === void 0 ? void 0 : invocationTemplate(invocation, alternateMode)) ? INVOCATION_RETRY_HINT[mode] ?? "" : "";
75
+ }
76
+ function compareVersions(a, b) {
77
+ const [aBase = "", aPre] = a.split("-", 2);
78
+ const [bBase = "", bPre] = b.split("-", 2);
79
+ const left = aBase.split(".").map(Number);
80
+ const right = bBase.split(".").map(Number);
81
+ for (let i = 0; i < Math.max(left.length, right.length); i++) {
82
+ const diff = (left[i] ?? 0) - (right[i] ?? 0);
83
+ if (diff !== 0) return diff;
84
+ }
85
+ return Number(aPre === void 0) - Number(bPre === void 0);
86
+ }
56
87
  function buildInvocationArgs(template, prompt, modelArgs, model) {
57
- const args = template.map((arg) => arg.replaceAll("{prompt}", prompt));
58
- if (model !== void 0 && modelArgs) args.push(...modelArgs.map((arg) => arg.replaceAll("{model}", model)));
88
+ const args = template.map((arg) => arg.replaceAll("{prompt}", () => prompt));
89
+ if (model !== void 0 && modelArgs) args.push(...modelArgs.map((arg) => arg.replaceAll("{model}", () => model)));
59
90
  return args;
60
91
  }
92
+ function signalProcessGroup(pid, signal) {
93
+ try {
94
+ process.kill(-pid, signal);
95
+ } catch (error) {
96
+ if (!(error instanceof Error && "code" in error && error.code === "ESRCH")) throw error;
97
+ }
98
+ }
99
+ async function terminateCommand(pid) {
100
+ if (process.platform === "win32") {
101
+ await new Promise((resolve, reject) => {
102
+ execFile(join(process.env.SystemRoot ?? "C:\\Windows", "System32", "taskkill.exe"), [
103
+ "/PID",
104
+ String(pid),
105
+ "/T",
106
+ "/F"
107
+ ], {
108
+ windowsHide: true,
109
+ timeout: 2e3,
110
+ killSignal: "SIGKILL"
111
+ }, (error) => error ? reject(error) : resolve());
112
+ });
113
+ return;
114
+ }
115
+ signalProcessGroup(pid, "SIGTERM");
116
+ await setTimeout$1(500);
117
+ signalProcessGroup(pid, "SIGKILL");
118
+ }
61
119
  function executeCommand(command, args, options) {
62
120
  return new Promise((resolve, reject) => {
121
+ let stdout = "";
122
+ let stderr = "";
123
+ let stopped;
124
+ let settled = false;
125
+ let timer;
126
+ let abortListener;
127
+ if (options.signal?.aborted) {
128
+ stopped = "abort";
129
+ finish(null);
130
+ return;
131
+ }
63
132
  const child = spawn(command, args, {
64
133
  cwd: options.cwd,
65
134
  env: options.env ? {
@@ -70,36 +139,60 @@ function executeCommand(command, args, options) {
70
139
  "ignore",
71
140
  "pipe",
72
141
  "pipe"
73
- ]
142
+ ],
143
+ detached: process.platform !== "win32" && (Boolean(options.timeoutMs) || options.signal !== void 0)
74
144
  });
75
- let stdout = "";
76
- let stderr = "";
77
- let timedOut = false;
78
- const timer = options.timeoutMs ? setTimeout(() => {
79
- timedOut = true;
80
- child.kill("SIGTERM");
81
- }, options.timeoutMs) : void 0;
145
+ if (options.timeoutMs) timer = setTimeout(() => stop("timeout"), options.timeoutMs);
146
+ if (options.signal) abortListener = addAbortListener(options.signal, () => stop("abort"));
82
147
  child.stdout.on("data", (chunk) => {
83
148
  stdout += chunk.toString("utf8");
84
149
  });
85
150
  child.stderr.on("data", (chunk) => {
86
151
  stderr += chunk.toString("utf8");
87
152
  });
88
- child.on("error", (error) => {
89
- if (timer) clearTimeout(timer);
90
- reject(error);
91
- });
153
+ child.on("error", fail);
92
154
  child.on("close", (code) => {
155
+ if (stopped === void 0) finish(code);
156
+ });
157
+ function cleanup() {
93
158
  if (timer) clearTimeout(timer);
159
+ abortListener?.[Symbol.dispose]();
160
+ }
161
+ function fail(error) {
162
+ if (settled) return;
163
+ settled = true;
164
+ cleanup();
165
+ reject(error);
166
+ }
167
+ function stop(reason) {
168
+ if (settled || stopped !== void 0) return;
169
+ stopped = reason;
170
+ cleanup();
171
+ if (child.pid === void 0) return;
172
+ terminateCommand(child.pid).then(() => {
173
+ child.stdout.destroy();
174
+ child.stderr.destroy();
175
+ finish(null);
176
+ }, (error) => {
177
+ child.stdout.destroy();
178
+ child.stderr.destroy();
179
+ fail(error);
180
+ });
181
+ }
182
+ function finish(code) {
183
+ if (settled) return;
184
+ settled = true;
185
+ cleanup();
94
186
  resolve({
95
187
  command,
96
188
  args: [...args],
97
189
  stdout,
98
190
  stderr,
99
- exitCode: timedOut ? null : code,
100
- timedOut
191
+ exitCode: stopped === void 0 ? code : null,
192
+ timedOut: stopped === "timeout",
193
+ aborted: stopped === "abort"
101
194
  });
102
- });
195
+ }
103
196
  });
104
197
  }
105
198
  var Harness = class {
@@ -168,9 +261,7 @@ var Harness = class {
168
261
  if (options.model !== void 0 && !this.invocation.modelArgs) return `Harness ${this.id} does not support model selection`;
169
262
  const mode = requestedInvocationMode(options);
170
263
  if (invocationTemplate(this.invocation, mode)) return null;
171
- const alternateMode = ALTERNATE_INVOCATION_MODE[mode];
172
- const retry = (alternateMode === void 0 ? void 0 : invocationTemplate(this.invocation, alternateMode)) ? INVOCATION_RETRY_HINT[mode] ?? "" : "";
173
- return `Harness ${this.id} has no ${INVOCATION_MODE_DESCRIPTION[mode]} invocation${retry}`;
264
+ return `${`Harness ${this.id} has no ${INVOCATION_MODE_DESCRIPTION[mode]} invocation`}${invocationRetryHint(this.invocation, mode, options)}`;
174
265
  }
175
266
  invoke(prompt, options = {}) {
176
267
  const invocationOptions = {
@@ -181,8 +272,19 @@ var Harness = class {
181
272
  };
182
273
  const built = this.buildInvocation(prompt, invocationOptions);
183
274
  if (!built) return Promise.reject(new Error(this.invocationError(invocationOptions) ?? "Invalid invocation"));
275
+ const versionError = this.readOnlyVersionError(invocationOptions);
276
+ if (versionError) return Promise.reject(new Error(versionError));
184
277
  return executeCommand(built.command, built.args, options);
185
278
  }
279
+ readOnlyVersionError(options) {
280
+ const floor = this.invocation?.readOnlyMinVersion;
281
+ if (floor === void 0 || options.readOnly !== true) return null;
282
+ const installed = this.version;
283
+ const error = `Harness ${this.id} requires version ${floor} or newer for read-only runs`;
284
+ if (installed === null) return `${error}; installed version unknown`;
285
+ if (compareVersions(installed, floor) < 0) return `${error}; installed ${installed}`;
286
+ return null;
287
+ }
186
288
  buildModelListInvocation(search) {
187
289
  if (!this.modelListing) return null;
188
290
  const command = this.binaries[0];
@@ -191,7 +293,7 @@ var Harness = class {
191
293
  if (!template) return null;
192
294
  return {
193
295
  command,
194
- args: template.map((arg) => arg.replaceAll("{search}", search ?? ""))
296
+ args: template.map((arg) => arg.replaceAll("{search}", () => search ?? ""))
195
297
  };
196
298
  }
197
299
  async listModels(options = {}) {
@@ -200,7 +302,7 @@ var Harness = class {
200
302
  const result = await executeCommand(built.command, built.args, options);
201
303
  return {
202
304
  ...result,
203
- models: !result.timedOut && result.exitCode === 0 ? this.parseModelListingOutput(result.stdout) : []
305
+ models: !result.timedOut && !result.aborted && result.exitCode === 0 ? this.parseModelListingOutput(result.stdout) : []
204
306
  };
205
307
  }
206
308
  parseModelListingOutput(_stdout) {
@@ -531,9 +633,26 @@ var Claude = class extends Harness {
531
633
  "--tools",
532
634
  ""
533
635
  ],
636
+ readOnlyArgs: [
637
+ "-p",
638
+ "{prompt}",
639
+ "--strict-mcp-config",
640
+ "--tools",
641
+ "Read,Glob,Grep"
642
+ ],
643
+ readOnlyJsonArgs: [
644
+ "-p",
645
+ "--output-format",
646
+ "json",
647
+ "{prompt}",
648
+ "--strict-mcp-config",
649
+ "--tools",
650
+ "Read,Glob,Grep"
651
+ ],
652
+ readOnlyMinVersion: "2.1.175",
534
653
  modelArgs: ["--model", "{model}"],
535
654
  level: "official",
536
- note: "Headless print mode; add --output-format json for structured output."
655
+ note: "Headless print mode; add --output-format json for structured output. Read-only runs keep the built-in Read, Glob and Grep tools and --strict-mcp-config drops every configured MCP server, so no write tool exists whatever permission mode the settings carry; verified on 2.1.175 and 2.1.268. --permission-mode plan does not qualify: its shell commands run through the auto mode classifier, which let a file write through."
537
656
  };
538
657
  mcpConfigs = [{
539
658
  path: "~/.claude.json",
@@ -691,20 +810,27 @@ var Codex = class extends Harness {
691
810
  commands = [];
692
811
  hooks = [];
693
812
  invocation = {
694
- args: ["exec", "{prompt}"],
813
+ args: [
814
+ "exec",
815
+ "--skip-git-repo-check",
816
+ "{prompt}"
817
+ ],
695
818
  jsonArgs: [
696
819
  "exec",
820
+ "--skip-git-repo-check",
697
821
  "--json",
698
822
  "{prompt}"
699
823
  ],
700
824
  readOnlyArgs: [
701
825
  "exec",
826
+ "--skip-git-repo-check",
702
827
  "--sandbox",
703
828
  "read-only",
704
829
  "{prompt}"
705
830
  ],
706
831
  readOnlyJsonArgs: [
707
832
  "exec",
833
+ "--skip-git-repo-check",
708
834
  "--sandbox",
709
835
  "read-only",
710
836
  "--json",
@@ -1069,7 +1195,7 @@ var Gemini = class extends Harness {
1069
1195
  var GitHubCopilot = class extends Harness {
1070
1196
  id = "github-copilot";
1071
1197
  name = "GitHub Copilot";
1072
- binaries = [];
1198
+ binaries = ["copilot"];
1073
1199
  capabilities = {
1074
1200
  mcp: true,
1075
1201
  vision: true,
@@ -1247,9 +1373,24 @@ var Grok = class extends Harness {
1247
1373
  "--output-format",
1248
1374
  "json"
1249
1375
  ],
1376
+ readOnlyArgs: [
1377
+ "-p",
1378
+ "{prompt}",
1379
+ "--sandbox",
1380
+ "read-only"
1381
+ ],
1382
+ readOnlyJsonArgs: [
1383
+ "-p",
1384
+ "{prompt}",
1385
+ "--sandbox",
1386
+ "read-only",
1387
+ "--output-format",
1388
+ "json"
1389
+ ],
1390
+ readOnlyMinVersion: "1.0.13",
1250
1391
  modelArgs: ["--model", "{model}"],
1251
1392
  level: "official",
1252
- note: "-p is short for --single; add --output-format json for structured output."
1393
+ note: "-p is short for --single; add --output-format json for structured output. --sandbox read-only is kernel-enforced (Landlock, Seatbelt) regardless of the inherited permission mode and still allows writes to ~/.grok and temp dirs; verified on Linux with 1.0.13 and 1.0.25."
1253
1394
  };
1254
1395
  mcpConfigs = [{
1255
1396
  path: "~/.grok/config.toml",
@@ -1576,21 +1717,9 @@ var Omp = class extends Harness {
1576
1717
  "json",
1577
1718
  "{prompt}"
1578
1719
  ],
1579
- noToolsArgs: [
1580
- "-p",
1581
- "--no-tools",
1582
- "{prompt}"
1583
- ],
1584
- noToolsJsonArgs: [
1585
- "-p",
1586
- "--no-tools",
1587
- "--mode",
1588
- "json",
1589
- "{prompt}"
1590
- ],
1591
1720
  modelArgs: ["--model={model}"],
1592
1721
  level: "official",
1593
- note: "Add --mode json for structured event output."
1722
+ note: "Add --mode json for structured event output. --no-tools disables only OMP's bundled tools, so it cannot provide an advisor mode without tools."
1594
1723
  };
1595
1724
  mcpConfigs = [{
1596
1725
  path: "~/.omp/agent/mcp.json",
@@ -1883,6 +2012,20 @@ var Pi = class extends Harness {
1883
2012
  "json",
1884
2013
  "{prompt}"
1885
2014
  ],
2015
+ readOnlyArgs: [
2016
+ "-p",
2017
+ "--tools",
2018
+ "read,grep,find,ls",
2019
+ "{prompt}"
2020
+ ],
2021
+ readOnlyJsonArgs: [
2022
+ "-p",
2023
+ "--tools",
2024
+ "read,grep,find,ls",
2025
+ "--mode",
2026
+ "json",
2027
+ "{prompt}"
2028
+ ],
1886
2029
  modelArgs: ["--model", "{model}"],
1887
2030
  level: "official",
1888
2031
  note: "Add --mode json for structured event output."
@@ -2406,7 +2549,7 @@ function canonical(server) {
2406
2549
  return JSON.stringify(Object.fromEntries(Object.entries(server).sort(([a], [b]) => a.localeCompare(b))));
2407
2550
  }
2408
2551
  function expandHome(value, home) {
2409
- return value.replace(/^~(?=\/|$)/, home).replaceAll("${HOME}", home);
2552
+ return value.replaceAll(/^~(?=\/|$)|\$\{HOME\}/g, () => home);
2410
2553
  }
2411
2554
  function expandServerHome(server, home) {
2412
2555
  return compact({
@@ -2544,8 +2687,9 @@ function agentsConfigRecord(raw, configPath) {
2544
2687
  if (typeof parsed !== "object" || parsed === null) throw new Error(`Agents config at ${configPath} is not an object`);
2545
2688
  return parsed;
2546
2689
  }
2547
- function agentsSource(value, defaultSource, options) {
2548
- if (typeof value !== "string") return defaultSource;
2690
+ function agentsSource(value, defaultSource, options, configPath) {
2691
+ if (value === void 0) return defaultSource;
2692
+ if (typeof value !== "string" || value.length === 0) throw new Error(`Agents config at ${configPath} has an invalid source: expected a path string`);
2549
2693
  const expanded = resolvePathTemplate(value, options);
2550
2694
  return isAbsolute(expanded) ? expanded : join(agntnConfigDir(options), expanded);
2551
2695
  }
@@ -2555,9 +2699,9 @@ function agentsExcludes(value, configPath) {
2555
2699
  return value;
2556
2700
  }
2557
2701
  function companionPath(value, configPath) {
2558
- const normalized = normalize(value);
2559
- if (normalized === "." || normalized === ".." || normalized.startsWith(`..${sep}`) || isAbsolute(normalized)) throw new Error(`Agents config at ${configPath} has an unsafe companion path: ${JSON.stringify(normalized)}`);
2560
- return normalized.endsWith(sep) ? normalized.slice(0, -sep.length) : normalized;
2702
+ const normalized = posix.normalize(value.replaceAll("\\", "/"));
2703
+ if (normalized === "." || normalized === ".." || normalized.startsWith("../") || posix.isAbsolute(normalized) || win32.parse(value).root !== "") throw new Error(`Agents config at ${configPath} has an unsafe companion path: ${JSON.stringify(normalized)}`);
2704
+ return normalized.endsWith("/") ? normalized.slice(0, -1) : normalized;
2561
2705
  }
2562
2706
  function comparablePath(path) {
2563
2707
  return resolve(path).toLowerCase();
@@ -2581,7 +2725,7 @@ function readAgentsConfig(options = {}) {
2581
2725
  if (raw === null) return defaults;
2582
2726
  const record = agentsConfigRecord(raw, configPath);
2583
2727
  return {
2584
- source: agentsSource(record.source, defaults.source, options),
2728
+ source: agentsSource(record.source, defaults.source, options, configPath),
2585
2729
  companions: agentsCompanions(record.companions, configPath),
2586
2730
  excludes: agentsExcludes(record.excludes, configPath),
2587
2731
  configPath