@agntn/harnesses 0.2.1 → 0.3.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.
@@ -1,6 +1,6 @@
1
1
  import { createRequire } from "node:module";
2
2
  import { addAbortListener } from "node:events";
3
- import { copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { copyFileSync, cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, rmdirSync, statSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
4
4
  import { execFile, execFileSync, spawn } from "node:child_process";
5
5
  import { setTimeout as setTimeout$1 } from "node:timers/promises";
6
6
  import { basename, dirname, extname, isAbsolute, join, posix, relative, resolve, win32 } from "node:path";
@@ -8,13 +8,13 @@ import os, { homedir } from "node:os";
8
8
  import { stripVTControlCharacters } from "node:util";
9
9
  import { parse } from "smol-toml";
10
10
  import { randomUUID } from "node:crypto";
11
- import { parse as parse$1 } from "yaml";
12
- const PATH_MARKERS = /^~(?=\/|$)|\$\{HOME\}|\$\{PROJECT_ROOT\}|%([^%]+)%/g;
11
+ const PATH_MARKERS = /^~(?=\/|$)|\$\{HOME\}|\$\{PROJECT_ROOT\}|\$\{TMPDIR\}|%([^%]+)%/g;
13
12
  function resolvePathTemplate(template, options = {}) {
14
13
  const homeDir = options.homeDir ?? os.homedir();
15
14
  const projectRoot = options.projectRoot ?? process.cwd();
16
15
  return template.replaceAll(PATH_MARKERS, (match, name) => {
17
16
  if (match === "${PROJECT_ROOT}") return projectRoot;
17
+ if (match === "${TMPDIR}") return options.tempDir ?? os.tmpdir();
18
18
  if (name === void 0) return homeDir;
19
19
  return process.env[name] ?? match;
20
20
  });
@@ -27,6 +27,11 @@ function agntnDataDir(options = {}) {
27
27
  const base = process.env.XDG_DATA_HOME && process.env.XDG_DATA_HOME !== "" ? process.env.XDG_DATA_HOME : join(options.homeDir ?? os.homedir(), ".local", "share");
28
28
  return join(base, "agntn");
29
29
  }
30
+ function moveRoot(path, root, value) {
31
+ if (path === root) return value;
32
+ const rest = path.slice(root.length);
33
+ return path.startsWith(root) && (rest.startsWith("/") || rest.startsWith("\\")) ? value + rest : path;
34
+ }
30
35
  const SUPPORTED_PLATFORMS = {
31
36
  linux: true,
32
37
  darwin: true,
@@ -57,6 +62,17 @@ function requestedInvocationMode(options) {
57
62
  if (options.tools === true) return options.structured === true ? "agentStructured" : "agent";
58
63
  return options.structured === true ? "advisorStructured" : "advisor";
59
64
  }
65
+ function accessConflictError(id, invocation, options) {
66
+ if (options.readOnly !== true || options.tools !== false) return null;
67
+ const retries = [];
68
+ if (invocationTemplate(invocation, requestedInvocationMode(options))) retries.push("tools: true to keep its read-only tools");
69
+ if (invocationTemplate(invocation, requestedInvocationMode({
70
+ ...options,
71
+ readOnly: false
72
+ }))) retries.push("readOnly: false to use its advisor without tools");
73
+ const error = `Harness ${id} cannot run readOnly with tools: false, since read-only access still uses tools`;
74
+ return retries.length === 0 ? error : `${error}; retry with ${retries.join(", or with ")}`;
75
+ }
60
76
  function invocationTemplate(invocation, mode) {
61
77
  switch (mode) {
62
78
  case "advisor": return invocation.noToolsArgs;
@@ -123,11 +139,29 @@ async function terminateCommand(pid) {
123
139
  await setTimeout$1(500);
124
140
  signalProcessGroup(pid, "SIGKILL");
125
141
  }
142
+ function spawnError(command, cwd, error) {
143
+ if (!(error instanceof Error && "code" in error)) return error;
144
+ if (error.code !== "ENOENT" && error.code !== "ENOTDIR") return error;
145
+ let message;
146
+ if (cwd !== void 0 && !isDirectory(cwd)) message = existsSync(cwd) ? `Working directory is not a directory: ${cwd}` : `Working directory not found: ${cwd}`;
147
+ else if (error.code === "ENOENT") message = `Command not found: ${command}. Install it or add it to PATH`;
148
+ else return error;
149
+ return Object.assign(new Error(message, { cause: error }), { code: error.code });
150
+ }
151
+ function isDirectory(path) {
152
+ try {
153
+ return statSync(path).isDirectory();
154
+ } catch {
155
+ return false;
156
+ }
157
+ }
126
158
  function executeCommand(command, args, options) {
127
159
  return new Promise((resolve, reject) => {
128
160
  let stdout = "";
129
161
  let stderr = "";
130
162
  let stopped;
163
+ let lastOutputAt = performance.now();
164
+ let idleMs;
131
165
  let settled = false;
132
166
  let timer;
133
167
  let abortListener;
@@ -136,28 +170,38 @@ function executeCommand(command, args, options) {
136
170
  finish(null);
137
171
  return;
138
172
  }
139
- const child = spawn(command, args, {
140
- cwd: options.cwd,
141
- env: options.env ? {
142
- ...process.env,
143
- ...options.env
144
- } : process.env,
145
- stdio: [
146
- "ignore",
147
- "pipe",
148
- "pipe"
149
- ],
150
- detached: process.platform !== "win32" && (Boolean(options.timeoutMs) || options.signal !== void 0)
151
- });
173
+ let child;
174
+ try {
175
+ child = spawn(command, args, {
176
+ cwd: options.cwd,
177
+ env: options.env ? {
178
+ ...process.env,
179
+ ...options.env
180
+ } : process.env,
181
+ stdio: [
182
+ "ignore",
183
+ "pipe",
184
+ "pipe"
185
+ ],
186
+ detached: process.platform !== "win32" && (Boolean(options.timeoutMs) || options.signal !== void 0)
187
+ });
188
+ } catch (error) {
189
+ fail(spawnError(command, options.cwd, error));
190
+ return;
191
+ }
152
192
  if (options.timeoutMs) timer = setTimeout(() => stop("timeout"), options.timeoutMs);
153
193
  if (options.signal) abortListener = addAbortListener(options.signal, () => stop("abort"));
194
+ child.stdout.setEncoding("utf8");
195
+ child.stderr.setEncoding("utf8");
154
196
  child.stdout.on("data", (chunk) => {
155
- stdout += chunk.toString("utf8");
197
+ stdout += chunk;
198
+ lastOutputAt = performance.now();
156
199
  });
157
200
  child.stderr.on("data", (chunk) => {
158
- stderr += chunk.toString("utf8");
201
+ stderr += chunk;
202
+ lastOutputAt = performance.now();
159
203
  });
160
- child.on("error", fail);
204
+ child.on("error", (error) => fail(spawnError(command, options.cwd, error)));
161
205
  child.on("close", (code) => {
162
206
  if (stopped === void 0) finish(code);
163
207
  });
@@ -174,6 +218,7 @@ function executeCommand(command, args, options) {
174
218
  function stop(reason) {
175
219
  if (settled || stopped !== void 0) return;
176
220
  stopped = reason;
221
+ idleMs = Math.round(performance.now() - lastOutputAt);
177
222
  cleanup();
178
223
  if (child.pid === void 0) return;
179
224
  terminateCommand(child.pid).then(() => {
@@ -197,7 +242,8 @@ function executeCommand(command, args, options) {
197
242
  stderr,
198
243
  exitCode: stopped === void 0 ? code : null,
199
244
  timedOut: stopped === "timeout",
200
- aborted: stopped === "abort"
245
+ aborted: stopped === "abort",
246
+ ...idleMs === void 0 ? {} : { idleMs }
201
247
  });
202
248
  }
203
249
  });
@@ -205,6 +251,8 @@ function executeCommand(command, args, options) {
205
251
  var Harness = class {
206
252
  promptTemplates = [];
207
253
  promptTemplateSyncTarget = null;
254
+ temp = [];
255
+ envOverrides = [];
208
256
  modelListing = null;
209
257
  mcpConfigs = [];
210
258
  agentsFile = null;
@@ -258,6 +306,7 @@ var Harness = class {
258
306
  const command = this.invocation.binary ?? this.binaries[0];
259
307
  if (!command) return null;
260
308
  if (options.model !== void 0 && !this.invocation.modelArgs) return null;
309
+ if (accessConflictError(this.id, this.invocation, options) !== null) return null;
261
310
  const template = invocationTemplate(this.invocation, requestedInvocationMode(options));
262
311
  if (!template) return null;
263
312
  return {
@@ -268,6 +317,8 @@ var Harness = class {
268
317
  invocationError(options = {}) {
269
318
  if (!this.invocation) return `Harness ${this.id} has no non-interactive invocation`;
270
319
  if (options.model !== void 0 && !this.invocation.modelArgs) return `Harness ${this.id} does not support model selection`;
320
+ const conflict = accessConflictError(this.id, this.invocation, options);
321
+ if (conflict !== null) return conflict;
271
322
  const mode = requestedInvocationMode(options);
272
323
  if (invocationTemplate(this.invocation, mode)) return null;
273
324
  return `${`Harness ${this.id} has no ${INVOCATION_MODE_DESCRIPTION[mode]} invocation`}${invocationRetryHint(this.invocation, mode, options)}`;
@@ -283,7 +334,15 @@ var Harness = class {
283
334
  if (!built) return Promise.reject(new Error(this.invocationError(invocationOptions) ?? "Invalid invocation"));
284
335
  const versionError = this.readOnlyVersionError(invocationOptions);
285
336
  if (versionError) return Promise.reject(new Error(versionError));
286
- return executeCommand(built.command, built.args, options);
337
+ const streamArgs = options.structured ? void 0 : this.invocation?.streamArgs;
338
+ if (!streamArgs) return executeCommand(built.command, built.args, options);
339
+ return executeCommand(built.command, [...built.args, ...streamArgs], options).then((result) => ({
340
+ ...result,
341
+ stdout: this.foldStreamOutput(result.stdout, !result.timedOut && !result.aborted)
342
+ }));
343
+ }
344
+ foldStreamOutput(_stdout, _complete) {
345
+ throw new Error(`Harness ${this.id} does not implement stream output folding`);
287
346
  }
288
347
  readOnlyVersionError(options) {
289
348
  const floor = this.invocation?.readOnlyMinVersion;
@@ -298,22 +357,33 @@ var Harness = class {
298
357
  if (!this.modelListing) return null;
299
358
  const command = this.binaries[0];
300
359
  if (!command) return null;
301
- const template = search === void 0 ? this.modelListing.args : this.modelListing.searchArgs;
302
- if (!template) return null;
303
360
  return {
304
361
  command,
305
- args: template.map((arg) => arg.replaceAll("{search}", () => search ?? ""))
362
+ args: (search === void 0 ? this.modelListing.args : this.modelListing.searchArgs ?? this.modelListing.args).map((arg) => arg.replaceAll("{search}", () => search ?? ""))
306
363
  };
307
364
  }
308
365
  async listModels(options = {}) {
309
366
  const built = this.buildModelListInvocation(options.search);
310
- if (!built) throw new Error(this.modelListing ? `Harness ${this.id} does not support filtering its model listing` : `Harness ${this.id} does not support model listing`);
367
+ if (!built) throw new Error(`Harness ${this.id} does not support model listing`);
311
368
  const result = await executeCommand(built.command, built.args, options);
369
+ if (result.timedOut || result.aborted || result.exitCode !== 0) return {
370
+ ...result,
371
+ models: []
372
+ };
373
+ const models = this.parseModelListingOutput(result.stdout);
374
+ if (options.search === void 0 || this.modelListing?.searchArgs) return {
375
+ ...result,
376
+ models
377
+ };
378
+ const search = options.search.toLowerCase();
312
379
  return {
313
380
  ...result,
314
- models: !result.timedOut && !result.aborted && result.exitCode === 0 ? this.parseModelListingOutput(result.stdout) : []
381
+ models: models.filter((model) => model.id.toLowerCase().includes(search))
315
382
  };
316
383
  }
384
+ modelSelector(model) {
385
+ return `${model.provider}/${model.id}`;
386
+ }
317
387
  parseModelListingOutput(_stdout) {
318
388
  throw new Error(`Harness ${this.id} does not implement model-list output parsing`);
319
389
  }
@@ -335,10 +405,33 @@ var Harness = class {
335
405
  commands: this.resolveCandidates(this.commands, options),
336
406
  promptTemplates: this.resolveCandidates(this.promptTemplates, options),
337
407
  promptTemplateSyncTarget: this.promptTemplateSyncTarget === null ? null : this.resolveCandidates([this.promptTemplateSyncTarget], options)[0] ?? null,
338
- hooks: this.resolveCandidates(this.hooks, options)
408
+ hooks: this.resolveCandidates(this.hooks, options),
409
+ temp: this.resolveTemp(options)
339
410
  };
340
411
  }
412
+ resolveTemp(options) {
413
+ const temp = this.resolveCandidates(this.temp, options);
414
+ const override = this.resolveCandidates(this.envOverrides, options).find((entry) => entry.relocates.includes("temp"));
415
+ const value = override ? process.env[override.variable] : void 0;
416
+ if (!override || !value) return temp;
417
+ return temp.map((entry) => ({
418
+ ...entry,
419
+ path: moveRoot(entry.path, override.path, value)
420
+ }));
421
+ }
341
422
  };
423
+ function parseAntigravityModels(stdout) {
424
+ const lines = stripVTControlCharacters(stdout).split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
425
+ if (lines.length === 0) throw new Error("Unexpected empty Antigravity model-list output");
426
+ return lines.map((line) => {
427
+ const match = /^(\S+)\t.+$/.exec(line);
428
+ if (!match?.[1]) throw new Error(`Unexpected Antigravity model-list row: ${JSON.stringify(line)}`);
429
+ return {
430
+ provider: "google",
431
+ id: match[1]
432
+ };
433
+ });
434
+ }
342
435
  var Antigravity = class extends Harness {
343
436
  id = "antigravity";
344
437
  name = "Google Antigravity CLI";
@@ -482,6 +575,11 @@ var Antigravity = class extends Harness {
482
575
  level: "official",
483
576
  note: "Non-interactive print mode."
484
577
  };
578
+ modelListing = {
579
+ args: ["models"],
580
+ level: "official",
581
+ note: "Prints one id and display name per row, with no default marked and no filter; verified with 1.2.5."
582
+ };
485
583
  mcpConfigs = [{
486
584
  path: "~/.gemini/config/mcp_config.json",
487
585
  scope: "user",
@@ -495,7 +593,60 @@ var Antigravity = class extends Harness {
495
593
  envVars: [],
496
594
  projectMarkers: [".antigravitycli"]
497
595
  };
596
+ parseModelListingOutput(stdout) {
597
+ return parseAntigravityModels(stdout);
598
+ }
599
+ modelSelector(model) {
600
+ return model.id;
601
+ }
602
+ };
603
+ function parseStreamLine(line) {
604
+ try {
605
+ const parsed = JSON.parse(line);
606
+ return typeof parsed === "object" && parsed !== null && "type" in parsed && typeof parsed.type === "string" ? parsed : void 0;
607
+ } catch {
608
+ return;
609
+ }
610
+ }
611
+ function startsTextBlock(event) {
612
+ return event.type === "stream_event" && event.event?.type === "content_block_start" && event.event.content_block?.type === "text";
613
+ }
614
+ function textDelta(event) {
615
+ if (event.type !== "stream_event" || event.event?.type !== "content_block_delta") return void 0;
616
+ const delta = event.event.delta;
617
+ return delta?.type === "text_delta" && typeof delta.text === "string" ? delta.text : void 0;
618
+ }
619
+ var ClaudeStreamFold = class {
620
+ blocks = [];
621
+ other = [];
622
+ result;
623
+ add(line) {
624
+ if (line.trim().length === 0) return;
625
+ const event = parseStreamLine(line);
626
+ if (!event) {
627
+ this.other.push(line);
628
+ return;
629
+ }
630
+ if (event.type === "result" && typeof event.result === "string") this.result = event.result;
631
+ if (startsTextBlock(event)) this.blocks.push("");
632
+ const text = textDelta(event);
633
+ if (text !== void 0) this.blocks.push((this.blocks.pop() ?? "") + text);
634
+ }
635
+ text(complete) {
636
+ const prefix = this.other.map((line) => `${line}\n`).join("");
637
+ if (complete && this.result !== void 0) return `${prefix}${this.result}\n`;
638
+ const text = this.blocks.filter((block) => block.length > 0).join("\n\n");
639
+ return text.length > 0 ? `${prefix}${text}\n` : prefix;
640
+ }
498
641
  };
642
+ function foldClaudeStream(stdout, complete) {
643
+ const lines = stdout.split("\n");
644
+ const tail = lines.at(-1);
645
+ if (!complete && tail?.startsWith("{") && parseStreamLine(tail) === void 0) lines.pop();
646
+ const fold = new ClaudeStreamFold();
647
+ for (const line of lines) fold.add(line);
648
+ return fold.text(complete);
649
+ }
499
650
  var Claude = class extends Harness {
500
651
  id = "claude";
501
652
  name = "Anthropic Claude Code";
@@ -671,8 +822,14 @@ var Claude = class extends Harness {
671
822
  ],
672
823
  readOnlyMinVersion: "2.1.175",
673
824
  modelArgs: ["--model", "{model}"],
825
+ streamArgs: [
826
+ "--output-format",
827
+ "stream-json",
828
+ "--verbose",
829
+ "--include-partial-messages"
830
+ ],
674
831
  level: "official",
675
- note: "Headless print mode; add --output-format json for structured output. --tools only covers the built-in set, so every mode without the full agent adds --strict-mcp-config to drop configured MCP servers too; without it an advisor still sees every user and project MCP tool, checked on 2.1.280. Read-only runs keep the built-in Read, Glob and Grep tools, 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."
832
+ note: "Headless print mode; add --output-format json for structured output. Plain text runs stream events instead and fold them back, since text mode prints nothing until the answer is complete and a timeout would lose all of it; --include-partial-messages streams a single long answer too, checked on 2.1.280. --tools only covers the built-in set, so every mode without the full agent adds --strict-mcp-config to drop configured MCP servers too; without it an advisor still sees every user and project MCP tool, checked on 2.1.280. Read-only runs keep the built-in Read, Glob and Grep tools, 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."
676
833
  };
677
834
  mcpConfigs = [{
678
835
  path: "~/.claude.json",
@@ -689,6 +846,63 @@ var Claude = class extends Harness {
689
846
  key: ["mcpServers"],
690
847
  dialect: "standard"
691
848
  }];
849
+ temp = [
850
+ {
851
+ path: "${TMPDIR}/claude-<uid>/",
852
+ scope: "data",
853
+ level: "official",
854
+ platforms: ["linux"],
855
+ note: "Session scratchpads sit under <dash-encoded-cwd>/<session-id>/scratchpad."
856
+ },
857
+ {
858
+ path: "/tmp/claude-<uid>/",
859
+ scope: "data",
860
+ level: "official",
861
+ platforms: ["darwin"]
862
+ },
863
+ {
864
+ path: "${TMPDIR}/claude/",
865
+ scope: "data",
866
+ level: "official",
867
+ platforms: ["win32"]
868
+ }
869
+ ];
870
+ envOverrides = [
871
+ {
872
+ variable: "CLAUDE_CONFIG_DIR",
873
+ path: "~/.claude",
874
+ scope: "user",
875
+ level: "official",
876
+ relocates: [
877
+ "config",
878
+ "sessions",
879
+ "instructions",
880
+ "skills",
881
+ "commands",
882
+ "promptTemplates",
883
+ "hooks"
884
+ ],
885
+ note: "Read from the shell or the env block of user or managed settings, not project settings."
886
+ },
887
+ {
888
+ variable: "CLAUDE_CODE_TMPDIR",
889
+ path: "${TMPDIR}",
890
+ scope: "data",
891
+ level: "official",
892
+ platforms: ["linux", "win32"],
893
+ relocates: ["temp"],
894
+ note: "Read from the shell or the env block of user or managed settings, not project settings."
895
+ },
896
+ {
897
+ variable: "CLAUDE_CODE_TMPDIR",
898
+ path: "/tmp",
899
+ scope: "data",
900
+ level: "official",
901
+ platforms: ["darwin"],
902
+ relocates: ["temp"],
903
+ note: "Read from the shell or the env block of user or managed settings, not project settings."
904
+ }
905
+ ];
692
906
  agentsFile = "~/.claude/CLAUDE.md";
693
907
  detection = {
694
908
  envVars: [
@@ -704,6 +918,9 @@ var Claude = class extends Harness {
704
918
  "CLAUDE.md"
705
919
  ]
706
920
  };
921
+ foldStreamOutput(stdout, complete) {
922
+ return foldClaudeStream(stdout, complete);
923
+ }
707
924
  };
708
925
  var Codex = class extends Harness {
709
926
  id = "codex";
@@ -831,7 +1048,7 @@ var Codex = class extends Harness {
831
1048
  path: "~/.codex/prompts/",
832
1049
  scope: "user",
833
1050
  level: "official",
834
- note: "Deprecated Markdown prompts invoked as /prompts:name. Top-level files only; use skills for shared prompts. Base directory follows CODEX_HOME."
1051
+ note: "Deprecated Markdown prompts invoked as /prompts:name. Top-level files only; use skills for shared prompts."
835
1052
  }];
836
1053
  promptTemplates = this.commands;
837
1054
  promptTemplateSyncTarget = {
@@ -887,6 +1104,20 @@ var Codex = class extends Harness {
887
1104
  key: ["mcp_servers"],
888
1105
  dialect: "standard"
889
1106
  }];
1107
+ envOverrides = [{
1108
+ variable: "CODEX_HOME",
1109
+ path: "~/.codex",
1110
+ scope: "user",
1111
+ level: "official",
1112
+ relocates: [
1113
+ "config",
1114
+ "sessions",
1115
+ "instructions",
1116
+ "skills",
1117
+ "commands",
1118
+ "promptTemplates"
1119
+ ]
1120
+ }];
890
1121
  agentsFile = "~/.codex/AGENTS.md";
891
1122
  detection = {
892
1123
  envVars: [],
@@ -1311,6 +1542,20 @@ var GitHubCopilot = class extends Harness {
1311
1542
  ]
1312
1543
  };
1313
1544
  };
1545
+ function parseGrokModels(stdout) {
1546
+ const lines = stripVTControlCharacters(stdout).split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
1547
+ const start = lines.indexOf("Available models:");
1548
+ if (start === -1) throw new Error(`Unexpected Grok model-list output: ${JSON.stringify(stdout)}`);
1549
+ return lines.slice(start + 1).map((line) => {
1550
+ const match = /^([*-])\s+(\S+)(?:\s+\(default\))?$/.exec(line);
1551
+ if (!match?.[2]) throw new Error(`Unexpected Grok model-list row: ${JSON.stringify(line)}`);
1552
+ return {
1553
+ provider: "xai",
1554
+ id: match[2],
1555
+ ...match[1] === "*" ? { default: true } : {}
1556
+ };
1557
+ });
1558
+ }
1314
1559
  var Grok = class extends Harness {
1315
1560
  id = "grok";
1316
1561
  name = "xAI Grok CLI";
@@ -1443,6 +1688,11 @@ var Grok = class extends Harness {
1443
1688
  level: "official",
1444
1689
  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."
1445
1690
  };
1691
+ modelListing = {
1692
+ args: ["models"],
1693
+ level: "official",
1694
+ note: "Prints bare ids with the default marked; verified with 1.0.41."
1695
+ };
1446
1696
  mcpConfigs = [{
1447
1697
  path: "~/.grok/config.toml",
1448
1698
  scope: "user",
@@ -1467,6 +1717,9 @@ var Grok = class extends Harness {
1467
1717
  ],
1468
1718
  projectMarkers: [".grok"]
1469
1719
  };
1720
+ parseModelListingOutput(stdout) {
1721
+ return parseGrokModels(stdout);
1722
+ }
1470
1723
  };
1471
1724
  function packageVersion(commandPath) {
1472
1725
  try {
@@ -1730,7 +1983,7 @@ var Omp = class extends Harness {
1730
1983
  path: "~/.omp/agent/config.yml",
1731
1984
  scope: "user",
1732
1985
  level: "official",
1733
- note: "Agent dir overridable with PI_CODING_AGENT_DIR; profiles use ~/.omp/profiles/<name>/agent."
1986
+ note: "Profiles use ~/.omp/profiles/<name>/agent."
1734
1987
  },
1735
1988
  {
1736
1989
  path: "~/.omp/agent/settings.json",
@@ -1912,6 +2165,21 @@ var Omp = class extends Harness {
1912
2165
  key: ["mcpServers"],
1913
2166
  dialect: "standard"
1914
2167
  }];
2168
+ envOverrides = [{
2169
+ variable: "PI_CODING_AGENT_DIR",
2170
+ path: "~/.omp/agent",
2171
+ scope: "user",
2172
+ level: "official",
2173
+ relocates: [
2174
+ "config",
2175
+ "sessions",
2176
+ "instructions",
2177
+ "skills",
2178
+ "commands",
2179
+ "promptTemplates"
2180
+ ],
2181
+ note: "Ignored under a named profile (OMP_PROFILE). On Linux, after omp config init-xdg, data, state and cache live under $XDG_*_HOME/omp instead."
2182
+ }];
1915
2183
  agentsFile = "~/.omp/agent/AGENTS.md";
1916
2184
  detection = {
1917
2185
  envVars: ["OMP_PROFILE", "PI_CODING_AGENT_DIR"],
@@ -2136,8 +2404,7 @@ var Pi = class extends Harness {
2136
2404
  config = [{
2137
2405
  path: "~/.pi/agent/settings.json",
2138
2406
  scope: "user",
2139
- level: "official",
2140
- note: "Config dir overridable with PI_CODING_AGENT_DIR."
2407
+ level: "official"
2141
2408
  }, {
2142
2409
  path: ".pi/settings.json",
2143
2410
  scope: "project",
@@ -2147,7 +2414,7 @@ var Pi = class extends Harness {
2147
2414
  path: "~/.pi/agent/sessions/<dash-encoded-cwd>/<timestamp>_<session-id>.jsonl",
2148
2415
  scope: "data",
2149
2416
  level: "official",
2150
- note: "Per-project session transcripts in JSONL format; session ids are UUIDv7. Overridable with PI_CODING_AGENT_SESSION_DIR or --session-dir."
2417
+ note: "Per-project session transcripts in JSONL format; session ids are UUIDv7. Overridable with --session-dir."
2151
2418
  }];
2152
2419
  persistence = [{
2153
2420
  format: "JSON",
@@ -2257,6 +2524,28 @@ var Pi = class extends Harness {
2257
2524
  level: "official",
2258
2525
  note: "Lists models with configured provider authentication; accepts an optional fuzzy search."
2259
2526
  };
2527
+ envOverrides = [{
2528
+ variable: "PI_CODING_AGENT_DIR",
2529
+ path: "~/.pi/agent",
2530
+ scope: "user",
2531
+ level: "official",
2532
+ relocates: [
2533
+ "config",
2534
+ "sessions",
2535
+ "instructions",
2536
+ "skills",
2537
+ "commands",
2538
+ "promptTemplates"
2539
+ ],
2540
+ note: "A leading ~ in the value is expanded."
2541
+ }, {
2542
+ variable: "PI_CODING_AGENT_SESSION_DIR",
2543
+ path: "~/.pi/agent/sessions",
2544
+ scope: "data",
2545
+ level: "official",
2546
+ relocates: ["sessions"],
2547
+ note: "Takes precedence over PI_CODING_AGENT_DIR for sessions; --session-dir beats both."
2548
+ }];
2260
2549
  agentsFile = "~/.pi/agent/AGENTS.md";
2261
2550
  detection = {
2262
2551
  envVars: [
@@ -3235,6 +3524,14 @@ function moveFile(from, to) {
3235
3524
  renameSync(from, to);
3236
3525
  } catch (error) {
3237
3526
  if (error.code !== "EXDEV") throw error;
3527
+ if (lstatSync(from).isDirectory()) {
3528
+ cpSync(from, to, {
3529
+ recursive: true,
3530
+ verbatimSymlinks: true
3531
+ });
3532
+ rmSync(from, { recursive: true });
3533
+ return;
3534
+ }
3238
3535
  copyFileSync(from, to);
3239
3536
  unlinkSync(from);
3240
3537
  }
@@ -3344,16 +3641,12 @@ const GENERATED_MARKER = "# Generated by @agntn/harnesses from ";
3344
3641
  const PORTABLE_PROMPT_NAME = /^[A-Za-z0-9][A-Za-z0-9_-]*$/u;
3345
3642
  const GEMINI_ACTION = {
3346
3643
  missing: "generated",
3347
- "correct-link": "adopted",
3348
- "wrong-link": "adopted",
3349
3644
  "owned-file": "replaced",
3350
3645
  "unmanaged-file": "adopted",
3351
3646
  unsupported: "skipped"
3352
3647
  };
3353
3648
  const GEMINI_BACKUP = {
3354
3649
  missing: false,
3355
- "correct-link": true,
3356
- "wrong-link": true,
3357
3650
  "owned-file": true,
3358
3651
  "unmanaged-file": true,
3359
3652
  unsupported: false
@@ -3391,7 +3684,11 @@ function generatedSource(content) {
3391
3684
  return null;
3392
3685
  }
3393
3686
  }
3394
- function inspectDestination(path, source) {
3687
+ function linkTarget(path) {
3688
+ const target = readlinkSync(path);
3689
+ return isAbsolute(target) ? target : resolve(dirname(path), target);
3690
+ }
3691
+ function inspectDestination(path) {
3395
3692
  let stats;
3396
3693
  try {
3397
3694
  stats = lstatSync(path);
@@ -3399,11 +3696,7 @@ function inspectDestination(path, source) {
3399
3696
  if (error.code === "ENOENT") return { kind: "missing" };
3400
3697
  throw error;
3401
3698
  }
3402
- if (stats.isSymbolicLink()) {
3403
- if (source === void 0) return { kind: "unmanaged-file" };
3404
- const target = readlinkSync(path);
3405
- return (isAbsolute(target) ? target : resolve(dirname(path), target)) === source ? { kind: "correct-link" } : { kind: "wrong-link" };
3406
- }
3699
+ if (stats.isSymbolicLink()) return { kind: "unmanaged-file" };
3407
3700
  if (!stats.isFile()) return { kind: "unsupported" };
3408
3701
  const content = readFileSync(path, "utf8");
3409
3702
  if (generatedSource(content) !== null) return {
@@ -3415,10 +3708,10 @@ function inspectDestination(path, source) {
3415
3708
  function tempPath(path, kind) {
3416
3709
  return join(dirname(path), `.${basename(path)}-${randomUUID()}.${kind}.tmp`);
3417
3710
  }
3418
- function linkTemplate(path, source) {
3711
+ function linkDirectory(path, source) {
3419
3712
  mkdirSync(dirname(path), { recursive: true });
3420
3713
  const temp = tempPath(path, "prompt-link");
3421
- symlinkSync(source, temp);
3714
+ symlinkSync(source, temp, "junction");
3422
3715
  renameSync(temp, path);
3423
3716
  }
3424
3717
  function writeTemplate(path, content) {
@@ -3432,8 +3725,7 @@ function backupDestination(id, path, options) {
3432
3725
  mkdirSync(backupDir, { recursive: true });
3433
3726
  const backup = join(backupDir, `${basename(path)}-${Date.now()}-${randomUUID()}`);
3434
3727
  if (lstatSync(path).isSymbolicLink()) {
3435
- const target = readlinkSync(path);
3436
- symlinkSync(isAbsolute(target) ? target : resolve(dirname(path), target), backup);
3728
+ symlinkSync(linkTarget(path), backup);
3437
3729
  unlinkSync(path);
3438
3730
  return backup;
3439
3731
  }
@@ -3443,44 +3735,68 @@ function backupDestination(id, path, options) {
3443
3735
  function checkDetail() {
3444
3736
  return "check mode: not applied";
3445
3737
  }
3446
- function syncMarkdownTemplate(id, targetDir, template, check, options) {
3447
- const path = join(targetDir, `${template.name}.md`);
3448
- const state = inspectDestination(path, template.path);
3449
- if (state.kind === "correct-link") return {
3450
- name: template.name,
3451
- source: template.path,
3452
- path,
3453
- action: "unchanged"
3454
- };
3455
- if (state.kind === "unsupported") return {
3456
- name: template.name,
3457
- source: template.path,
3458
- path,
3459
- action: "skipped",
3460
- detail: "destination exists and is not a file or symlink"
3738
+ function isInsideDirectory(path, directory) {
3739
+ const remainder = relative(directory, path);
3740
+ return remainder !== "" && !remainder.startsWith("..") && !isAbsolute(remainder);
3741
+ }
3742
+ function isLegacyTemplateLink(path, sourceDir) {
3743
+ return lstatSync(path).isSymbolicLink() && isInsideDirectory(linkTarget(path), sourceDir);
3744
+ }
3745
+ const DIRECTORY_ACTION = {
3746
+ missing: "linked",
3747
+ "correct-link": "unchanged",
3748
+ "wrong-link": "relinked",
3749
+ "legacy-links": "relinked",
3750
+ "local-files": "adopted",
3751
+ "not-directory": "skipped"
3752
+ };
3753
+ function inspectDirectory(targetDir, sourceDir) {
3754
+ let stats;
3755
+ try {
3756
+ stats = lstatSync(targetDir);
3757
+ } catch (error) {
3758
+ if (error.code === "ENOENT") return { kind: "missing" };
3759
+ throw error;
3760
+ }
3761
+ if (stats.isSymbolicLink()) return { kind: linkTarget(targetDir) === sourceDir ? "correct-link" : "wrong-link" };
3762
+ if (!stats.isDirectory()) return { kind: "not-directory" };
3763
+ const entries = readdirSync(targetDir).map((entry) => join(targetDir, entry));
3764
+ return entries.every((entry) => isLegacyTemplateLink(entry, sourceDir)) ? {
3765
+ kind: "legacy-links",
3766
+ entries
3767
+ } : { kind: "local-files" };
3768
+ }
3769
+ function clearDirectory(id, targetDir, state, options) {
3770
+ if (state.kind === "wrong-link" || state.kind === "local-files") return backupDestination(id, targetDir, options);
3771
+ if (state.kind === "legacy-links") {
3772
+ for (const entry of state.entries) unlinkSync(entry);
3773
+ rmdirSync(targetDir);
3774
+ }
3775
+ }
3776
+ function syncMarkdownDirectory(id, targetDir, sourceDir, check, options) {
3777
+ const state = inspectDirectory(targetDir, sourceDir);
3778
+ const action = DIRECTORY_ACTION[state.kind];
3779
+ if (state.kind === "correct-link") return { action };
3780
+ if (state.kind === "not-directory") return {
3781
+ action,
3782
+ detail: "destination exists and is not a directory or symlink"
3461
3783
  };
3462
- const action = state.kind === "missing" ? "linked" : state.kind === "wrong-link" ? "relinked" : "adopted";
3463
3784
  if (check) return {
3464
- name: template.name,
3465
- source: template.path,
3466
- path,
3467
3785
  action,
3468
3786
  detail: checkDetail()
3469
3787
  };
3470
- let detail;
3471
- if (state.kind === "wrong-link") unlinkSync(path);
3472
- if (state.kind === "owned-file" || state.kind === "unmanaged-file") detail = backupDestination(id, path, options);
3473
- linkTemplate(path, template.path);
3788
+ const detail = clearDirectory(id, targetDir, state, options);
3789
+ linkDirectory(targetDir, sourceDir);
3474
3790
  return {
3475
- name: template.name,
3476
- source: template.path,
3477
- path,
3478
3791
  action,
3479
3792
  detail
3480
3793
  };
3481
3794
  }
3795
+ function parseYaml(raw) {
3796
+ return createRequire(import.meta.url)("yaml").parse(raw);
3797
+ }
3482
3798
  function frontmatterDescription(raw, path) {
3483
- const frontmatter = parse$1(raw);
3799
+ const frontmatter = parseYaml(raw);
3484
3800
  if (frontmatter === null) return void 0;
3485
3801
  if (typeof frontmatter !== "object" || Array.isArray(frontmatter)) throw new TypeError(`Prompt template frontmatter is not an object: ${path}`);
3486
3802
  const description = frontmatter.description;
@@ -3541,23 +3857,6 @@ function syncGeminiTemplate(id, targetDir, template, check, options) {
3541
3857
  detail
3542
3858
  };
3543
3859
  }
3544
- function isInsideDirectory(path, directory) {
3545
- const remainder = relative(directory, path);
3546
- return remainder !== "" && !remainder.startsWith("..") && !isAbsolute(remainder);
3547
- }
3548
- function staleMarkdownSource(path, sourceDir) {
3549
- if (inspectDestination(path).kind !== "unmanaged-file") return null;
3550
- let stats;
3551
- try {
3552
- stats = lstatSync(path);
3553
- } catch {
3554
- return null;
3555
- }
3556
- if (!stats.isSymbolicLink()) return null;
3557
- const target = readlinkSync(path);
3558
- const resolved = isAbsolute(target) ? target : resolve(dirname(path), target);
3559
- return isInsideDirectory(resolved, sourceDir) ? resolved : null;
3560
- }
3561
3860
  function staleGeminiSource(path, sourceDir) {
3562
3861
  let content;
3563
3862
  try {
@@ -3569,28 +3868,40 @@ function staleGeminiSource(path, sourceDir) {
3569
3868
  if (source === null) return null;
3570
3869
  return isInsideDirectory(source, sourceDir) ? source : null;
3571
3870
  }
3572
- function pruneTarget(targetDir, format, sourceDir, activeSources, check) {
3871
+ function pruneGeminiTarget(id, targetDir, sourceDir, activeNames, check, options) {
3573
3872
  let entries;
3574
3873
  try {
3575
- entries = readdirSync(targetDir, { withFileTypes: true });
3874
+ entries = readdirSync(targetDir);
3576
3875
  } catch (error) {
3577
3876
  if (error.code === "ENOENT") return [];
3578
3877
  throw error;
3579
3878
  }
3580
- const extension = format === "markdown" ? ".md" : ".toml";
3581
- const staleSource = format === "markdown" ? staleMarkdownSource : staleGeminiSource;
3582
- return entries.filter((entry) => entry.name.toLowerCase().endsWith(extension)).flatMap((entry) => {
3583
- const path = join(targetDir, entry.name);
3584
- const source = staleSource(path, sourceDir);
3585
- if (source === null || activeSources.has(source)) return [];
3586
- if (!check) unlinkSync(path);
3587
- return [{
3588
- name: basename(entry.name, extension),
3589
- source,
3879
+ return entries.filter((entry) => !activeNames.has(entry)).map((entry) => {
3880
+ const path = join(targetDir, entry);
3881
+ const name = basename(entry, extname(entry));
3882
+ const source = staleGeminiSource(path, sourceDir);
3883
+ if (check) return {
3884
+ name,
3885
+ ...source === null ? {} : { source },
3590
3886
  path,
3591
3887
  action: "removed",
3592
- ...check ? { detail: checkDetail() } : {}
3593
- }];
3888
+ detail: checkDetail()
3889
+ };
3890
+ if (source !== null) {
3891
+ unlinkSync(path);
3892
+ return {
3893
+ name,
3894
+ source,
3895
+ path,
3896
+ action: "removed"
3897
+ };
3898
+ }
3899
+ return {
3900
+ name,
3901
+ path,
3902
+ action: "removed",
3903
+ detail: backupDestination(id, path, options)
3904
+ };
3594
3905
  });
3595
3906
  }
3596
3907
  function syncTarget(harness, templates, sourceDir, check, options) {
@@ -3606,11 +3917,17 @@ function syncTarget(harness, templates, sourceDir, check, options) {
3606
3917
  detail: "prompt template destination is unavailable on this platform",
3607
3918
  templates: []
3608
3919
  };
3609
- const targetDir = resolved.path;
3610
- const syncTemplate = resolved.format === "markdown" ? syncMarkdownTemplate : syncGeminiTemplate;
3611
- const results = templates.map((template) => syncTemplate(harness.id, targetDir, template, check, options));
3612
- const activeSources = new Set(templates.map((template) => template.path));
3613
- results.push(...pruneTarget(targetDir, resolved.format, sourceDir, activeSources, check));
3920
+ const targetDir = resolve(resolved.path);
3921
+ if (resolved.format === "markdown") return {
3922
+ id: harness.id,
3923
+ path: targetDir,
3924
+ format: resolved.format,
3925
+ ...syncMarkdownDirectory(harness.id, targetDir, sourceDir, check, options),
3926
+ templates: []
3927
+ };
3928
+ const results = templates.map((template) => syncGeminiTemplate(harness.id, targetDir, template, check, options));
3929
+ const activeNames = new Set(templates.map((template) => `${template.name}.toml`));
3930
+ results.push(...pruneGeminiTarget(harness.id, targetDir, sourceDir, activeNames, check, options));
3614
3931
  return {
3615
3932
  id: harness.id,
3616
3933
  path: targetDir,