@ref0/cli 0.8.0 → 0.9.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 (2) hide show
  1. package/dist/main.mjs +2086 -501
  2. package/package.json +3 -2
package/dist/main.mjs CHANGED
@@ -18337,11 +18337,29 @@ const CodexProfile = Schema.Struct({
18337
18337
  ])))),
18338
18338
  network: Schema.optionalKey(Schema.NullOr(Schema.Struct({ enabled: Schema.optionalKey(Schema.NullOr(Schema.Boolean)) })))
18339
18339
  });
18340
- /** `config/read`'s result, as much of it as ref0 reads: the default profile, and the profiles. */
18341
- const CodexConfigRead = Schema.Struct({ config: Schema.Struct({
18342
- default_permissions: Schema.optionalKey(Schema.NullOr(Schema.String)),
18343
- permissions: Schema.optionalKey(Schema.NullOr(Schema.Record(Schema.String, CodexProfile)))
18344
- }) });
18340
+ /** Codex applies excludes before `set`, then applies either form of allowlist after it. */
18341
+ const CodexShellEnvironment = Schema.Struct({
18342
+ set: Schema.optionalKey(Schema.NullOr(Schema.Record(Schema.String, Schema.String))),
18343
+ exclude: Schema.optionalKey(Schema.NullOr(Schema.Array(Schema.String))),
18344
+ include_only: Schema.optionalKey(Schema.NullOr(Schema.Array(Schema.String))),
18345
+ filters: Schema.optionalKey(Schema.NullOr(Schema.Record(Schema.String, Schema.Literals(["include", "exclude"]))))
18346
+ });
18347
+ /** The permission and environment policies, with user-layer versions and raw policy for rollback. */
18348
+ const CodexConfigRead = Schema.Struct({
18349
+ config: Schema.Struct({
18350
+ default_permissions: Schema.optionalKey(Schema.NullOr(Schema.String)),
18351
+ permissions: Schema.optionalKey(Schema.NullOr(Schema.Record(Schema.String, CodexProfile))),
18352
+ shell_environment_policy: Schema.optionalKey(Schema.NullOr(CodexShellEnvironment))
18353
+ }),
18354
+ layers: Schema.optionalKey(Schema.NullOr(Schema.Array(Schema.Struct({
18355
+ name: Schema.Struct({
18356
+ type: Schema.String,
18357
+ file: Schema.optionalKey(Schema.String)
18358
+ }),
18359
+ version: Schema.String,
18360
+ config: Schema.optionalKey(Schema.Struct({ shell_environment_policy: Schema.optionalKey(Schema.Record(Schema.String, Schema.Json)) }))
18361
+ }))))
18362
+ });
18345
18363
  /**
18346
18364
  * `config/batchWrite`'s result, as much of it as ref0 holds Codex to: that it wrote, and whether
18347
18365
  * a layer above the person's own config overrides what it wrote.
@@ -18597,12 +18615,15 @@ const CommandHookInput = Schema.Struct({
18597
18615
  });
18598
18616
  /**
18599
18617
  * `SessionStart`'s input, as far as ref0 reads it: the session, where it started, and why
18600
- * (`startup`, `resume`, `clear` or `compact`, which both harnesses send).
18618
+ * (`startup`, `resume`, `clear` or `compact`, which both harnesses send); and, for the trailer
18619
+ * on the agent's commits, its transcript and the model it started with, which Claude Code sends.
18601
18620
  */
18602
18621
  const SessionStartHookInput = Schema.Struct({
18603
18622
  session_id: HarnessSessionId,
18604
18623
  cwd: Schema.optionalKey(Schema.String.check(Schema.isBetweenLength(1, 4096))),
18605
- source: Schema.optionalKey(Schema.String.check(Schema.isBetweenLength(1, 32)))
18624
+ source: Schema.optionalKey(Schema.String.check(Schema.isBetweenLength(1, 32))),
18625
+ transcript_path: Schema.optionalKey(Schema.NullOr(Schema.String)),
18626
+ model: Schema.optionalKey(Schema.NullOr(Schema.String))
18606
18627
  });
18607
18628
  /**
18608
18629
  * A frame on Claude Code's own inbox socket, as `ref0 mcp` writes one, a line each: the token
@@ -19079,7 +19100,7 @@ const minute = (at) => `${DateTime.formatIso(at).slice(0, 16).replace("T", " ")}
19079
19100
  const styled = (text, code, terminal) => terminal ? `\u001b[${code}m${printable(text)}\u001b[0m` : printable(text);
19080
19101
  const renderLine = (line, terminal) => {
19081
19102
  if (Predicate.isString(line)) return printable(line);
19082
- if ("label" in line) return styled(line.label, 1, terminal) + (line.text ? ` ${printable(line.text)}` : "");
19103
+ if ("label" in line) return (line.before ? `${printable(line.before)} ` : "") + styled(line.label, 1, terminal) + (line.text ? ` ${printable(line.text)}` : "");
19083
19104
  if ("command" in line) return printable(line.command).split("\n").map((part) => ` ${part}`).join("\n");
19084
19105
  if (line.status === "unknown") return `? unknown ${printable(line.text)}`;
19085
19106
  return `${line.status === "yes" ? styled("✓", 32, terminal) : styled("✗", 31, terminal)} ${line.status} ${printable(line.text)}`;
@@ -19172,11 +19193,13 @@ const accountCommands = [Command.make("whoami", { json }, ({ json: asJson }) =>
19172
19193
  * stays the committer, with their own signature. Git keeps every commit for good, and anyone
19173
19194
  * who reads the repo reads its author, so these name the agent and nothing about its sessions.
19174
19195
  */
19196
+ /** The domain of every agent's commit email, which tells an agent's commits from a person's. */
19197
+ const agentCommitDomain = "ref0.work";
19175
19198
  /**
19176
19199
  * An agent's commit email: built from its agent ID alone, so it never changes, and never passes
19177
19200
  * to another agent. Nobody reads mail there.
19178
19201
  */
19179
- const agentCommitEmail = (agent) => `${agent}@ref0.work`;
19202
+ const agentCommitEmail = (agent) => `${agent}@${agentCommitDomain}`;
19180
19203
  /**
19181
19204
  * An agent's name as its commits' author: its owner, then its handle, as commands write it. A
19182
19205
  * handle renamed later stays as it was in the commits made before.
@@ -19471,6 +19494,67 @@ function freeTextProblem(text, maxCharacters) {
19471
19494
  */
19472
19495
  const forkPath = /^\/([^/]+)(?:\/([^/]+))?\/threads\/([1-9]\d{0,9})(?:\/sessions\/([1-9]\d{0,9})|\/forks\/(fork_[a-z0-9]+))?\/([^/]+)\.git\/(info\/refs|git-upload-pack|git-receive-pack)$/;
19473
19496
  //#endregion
19497
+ //#region ../model/src/path-glob.ts
19498
+ /** Charges `cells` to the budget: whether it had them. */
19499
+ const spend = (budget, cells) => {
19500
+ budget.left -= cells;
19501
+ return budget.left >= 0;
19502
+ };
19503
+ /**
19504
+ * Whether one segment matches one segment pattern, `*` and `?` within it; none once the budget
19505
+ * runs out.
19506
+ */
19507
+ function segmentMatches(pattern, segment, budget) {
19508
+ const characters = Array.from(segment);
19509
+ const width = characters.length + 1;
19510
+ let reach = new Uint8Array(width);
19511
+ let next = new Uint8Array(width);
19512
+ reach[0] = 1;
19513
+ for (const symbol of pattern) {
19514
+ if (!spend(budget, width)) return void 0;
19515
+ next.fill(0);
19516
+ if (symbol === "*") {
19517
+ let any = 0;
19518
+ for (let j = 0; j < width; j += 1) {
19519
+ any |= reach[j] ?? 0;
19520
+ next[j] = any;
19521
+ }
19522
+ } else for (let j = 1; j < width; j += 1) next[j] = (reach[j - 1] ?? 0) & (symbol === "?" || characters[j - 1] === symbol ? 1 : 0);
19523
+ [reach, next] = [next, reach];
19524
+ }
19525
+ return reach[width - 1] === 1;
19526
+ }
19527
+ /**
19528
+ * Whether `path`, a repo path like `src/a.ts`, matches `pattern`, within `budget`: none once it
19529
+ * runs out.
19530
+ */
19531
+ function pathMatchesWithin(pattern, path, budget) {
19532
+ const parts = pattern.split("/");
19533
+ const segments = path.split("/");
19534
+ const width = segments.length + 1;
19535
+ let reach = new Uint8Array(width);
19536
+ let next = new Uint8Array(width);
19537
+ reach[0] = 1;
19538
+ for (const part of parts) {
19539
+ if (!spend(budget, width)) return void 0;
19540
+ next.fill(0);
19541
+ if (part === "**") {
19542
+ let any = 0;
19543
+ for (let j = 0; j < width; j += 1) {
19544
+ any |= reach[j] ?? 0;
19545
+ next[j] = any;
19546
+ }
19547
+ } else for (let j = 1; j < width; j += 1) {
19548
+ if (reach[j - 1] !== 1) continue;
19549
+ const matched = segmentMatches(part, segments[j - 1] ?? "", budget);
19550
+ if (matched === void 0) return void 0;
19551
+ next[j] = matched ? 1 : 0;
19552
+ }
19553
+ [reach, next] = [next, reach];
19554
+ }
19555
+ return reach[width - 1] === 1;
19556
+ }
19557
+ //#endregion
19474
19558
  //#region ../model/src/thread-workflows.ts
19475
19559
  const minuteMillis = 6e4;
19476
19560
  const hourMillis = 60 * minuteMillis;
@@ -20383,11 +20467,15 @@ const watchVariables = {
20383
20467
  };
20384
20468
  /**
20385
20469
  * What ref0 puts in the environment of an agent's commands for its commits, beside Git's own
20386
- * author variables: the harness, for the trailer, and what a forwarder in ref0's hooks folder
20387
- * sets for the repo's own hook it runs, so a forwarder that hook reaches runs nothing.
20470
+ * author variables: for the trailer, the harness, the session's transcript, whose last model it
20471
+ * names, and the model the session started with, for a transcript that names none; and what a
20472
+ * forwarder in ref0's hooks folder sets for the repo's own hook it runs, so a forwarder that hook
20473
+ * reaches forwards nothing.
20388
20474
  */
20389
20475
  const gitHookVariables = {
20390
20476
  harness: "REF0_HARNESS",
20477
+ transcript: "REF0_TRANSCRIPT",
20478
+ model: "REF0_MODEL",
20391
20479
  forwarded: "REF0_GIT_HOOK"
20392
20480
  };
20393
20481
  //#endregion
@@ -20920,7 +21008,7 @@ const grantForAgent = Effect.fnUntraced(function* (owner, agent) {
20920
21008
  * an update installs anew. Node, run from source, loads JSON only with the `type` attribute,
20921
21009
  * and only as a default export.
20922
21010
  */
20923
- const cliVersion = "0.8.0";
21011
+ const cliVersion = "0.9.0";
20924
21012
  //#endregion
20925
21013
  //#region src/harness/hook-protocol.ts
20926
21014
  /**
@@ -21076,14 +21164,98 @@ var OldHostGrants = class OldHostGrants extends Context.Service()("ref0/cli/OldH
21076
21164
  }));
21077
21165
  };
21078
21166
  //#endregion
21167
+ //#region src/yes-or-no.ts
21168
+ /**
21169
+ * How long what a terminal held while nobody read it takes to arrive once a question reads: one
21170
+ * read, well within this.
21171
+ */
21172
+ const settle = Duration.millis(50);
21173
+ const again$2 = "Type y or yes, or n or no. Enter alone is no.";
21174
+ /** A line's answer, or none when it's neither yes nor no. */
21175
+ const answerOf$1 = (line) => {
21176
+ const word = line.trim().toLowerCase();
21177
+ if (word === "y" || word === "yes") return Option.some(true);
21178
+ if (word === "" || word === "n" || word === "no") return Option.some(false);
21179
+ return Option.none();
21180
+ };
21181
+ /** What Backspace takes back: one character as the person sees it, an emoji's parts included. */
21182
+ const characters = new Intl.Segmenter();
21183
+ /** `typed` with Backspace pressed once. */
21184
+ const takeBack = (typed) => typed.slice(0, [...characters.segment(typed)].at(-1)?.index);
21185
+ /** Whether a key types text: none of its characters moves the cursor or controls the terminal. */
21186
+ const typesText = (text) => text !== "" && !/\p{Cc}/u.test(text);
21187
+ /** Ctrl-C or Ctrl-D, which a terminal in raw mode hands ref0 as keys, never as a signal. */
21188
+ const quits = (key) => key.ctrl && (key.name === "c" || key.name === "d");
21189
+ /** The explanation goes to stderr, the question to stdout and its answer comes from stdin. */
21190
+ const allTerminals = () => process.stdin.isTTY === true && process.stdout.isTTY === true && process.stderr.isTTY === true;
21191
+ /**
21192
+ * Asks yes-or-no questions in this terminal, one after another: each shows `explained` on stderr,
21193
+ * then the question, and waits for a line. What was typed before a question showed doesn't answer
21194
+ * it, unless another question was asked when it arrived: a line typed after the one that answered
21195
+ * a question answers the next. The answer is no, unasked, unless stdin, stdout and stderr are each
21196
+ * a terminal, and on Ctrl-C or Ctrl-D.
21197
+ */
21198
+ const yesOrNoQuestions = Effect.gen(function* () {
21199
+ const terminal = yield* Terminal.Terminal;
21200
+ const ahead = yield* Ref.make([]);
21201
+ const ask = (explained, question) => Effect.gen(function* () {
21202
+ if (!allTerminals()) return false;
21203
+ const keys = yield* terminal.readInput;
21204
+ yield* Effect.sleep(settle);
21205
+ yield* Queue.clear(keys);
21206
+ const pending = [...yield* Ref.getAndSet(ahead, [])];
21207
+ const next = Effect.suspend(() => {
21208
+ const key = pending.shift();
21209
+ return key === void 0 ? Queue.take(keys) : Effect.succeed(key);
21210
+ });
21211
+ yield* printBlocks([explained]);
21212
+ yield* terminal.display(questionLine(question));
21213
+ let typed = "";
21214
+ let previous = "";
21215
+ while (true) {
21216
+ const pressed = yield* Effect.option(next);
21217
+ if (Option.isNone(pressed) || quits(pressed.value.key)) {
21218
+ yield* terminal.display("\n");
21219
+ return false;
21220
+ }
21221
+ const { input, key } = pressed.value;
21222
+ const ended = key.name === "enter" && previous === "return";
21223
+ previous = key.name;
21224
+ if (ended) continue;
21225
+ if (key.name === "backspace") {
21226
+ if (typed === "") continue;
21227
+ typed = takeBack(typed);
21228
+ yield* terminal.display("\b \b");
21229
+ continue;
21230
+ }
21231
+ if (key.name !== "return" && key.name !== "enter") {
21232
+ const text = Option.getOrElse(input, () => "");
21233
+ if (!typesText(text)) continue;
21234
+ typed += text;
21235
+ yield* terminal.display(text);
21236
+ continue;
21237
+ }
21238
+ yield* terminal.display("\n");
21239
+ const answer = answerOf$1(typed);
21240
+ if (Option.isSome(answer)) {
21241
+ const rest = [...pending, ...yield* Queue.clear(keys)];
21242
+ const ended = key.name === "return" && rest[0]?.key.name === "enter";
21243
+ yield* Ref.set(ahead, ended ? rest.slice(1) : rest);
21244
+ return answer.value;
21245
+ }
21246
+ typed = "";
21247
+ yield* terminal.display(`${again$2}\n${questionLine(question)}`);
21248
+ }
21249
+ }).pipe(Effect.scoped, Effect.orElseSucceed(() => false));
21250
+ return ask;
21251
+ });
21252
+ //#endregion
21079
21253
  //#region src/attachment-request.ts
21080
21254
  /**
21081
21255
  * The machine prints its link and Find code, then collects with its own secret and the answer
21082
21256
  * from the approving browser. It keeps and confirms each folder's grant before installing.
21083
21257
  */
21084
21258
  const isAnswerCode = Schema.is(TypedApprovalCode);
21085
- /** A key's escape code, such as an arrow's, as a terminal sends it. */
21086
- const escapeCode = /\u001b\[[\d;]*[A-Za-z~]/gu;
21087
21259
  const decodeTokenId$1 = Schema.decodeUnknownOption(TokenId);
21088
21260
  const decodeSecretHash$1 = Schema.decodeUnknownEffect(SecretHash);
21089
21261
  /** How often the machine asks whether its person has answered. */
@@ -21125,14 +21297,14 @@ const machineNameOf = (hostName) => {
21125
21297
  const said = (refusal) => sayFailed(refusal.nextStep);
21126
21298
  const isEnded = (state) => state.state !== "waiting";
21127
21299
  /** The line that asks again. */
21128
- const again$2 = "ref0 connect";
21300
+ const again$1 = "ref0 connect";
21129
21301
  /** What each way a request can end without connecting says, with the line that asks again. */
21130
21302
  const endedWords$1 = (state) => {
21131
21303
  switch (state.state) {
21132
- case "refused": return `It was refused, and nothing was connected. To ask again, run: ${again$2}`;
21133
- case "aborted": return `It was aborted, and nothing was connected. To ask again, run: ${again$2}`;
21134
- case "expired": return `Nobody approved it in time, and nothing was connected. For a new code, run: ${again$2}`;
21135
- case "failed": return `${state.nextStep} To connect this machine, run: ${again$2}`;
21304
+ case "refused": return `It was refused, and nothing was connected. To ask again, run: ${again$1}`;
21305
+ case "aborted": return `It was aborted, and nothing was connected. To ask again, run: ${again$1}`;
21306
+ case "expired": return `Nobody approved it in time, and nothing was connected. For a new code, run: ${again$1}`;
21307
+ case "failed": return `${state.nextStep} To connect this machine, run: ${again$1}`;
21136
21308
  }
21137
21309
  };
21138
21310
  /** The grant a kept record holds, by its ID. */
@@ -21148,6 +21320,7 @@ const requestAttachment = Effect.fnUntraced(function* (asked) {
21148
21320
  const hostGrant = yield* HostGrant;
21149
21321
  const oldGrants = yield* OldHostGrants;
21150
21322
  const http = yield* HttpClient.HttpClient;
21323
+ const terminal = yield* Terminal.Terminal;
21151
21324
  const home = yield* Config.String("HOME").pipe(Config.withDefault("."));
21152
21325
  const collecting = yield* freshSecret;
21153
21326
  const link = yield* freshLinkSecret;
@@ -21203,12 +21376,20 @@ const requestAttachment = Effect.fnUntraced(function* (asked) {
21203
21376
  while: Predicate.isTagged("Unanswered"),
21204
21377
  times: 2
21205
21378
  }), Effect.catchTag("Unanswered", unreachable));
21206
- yield* Console.error([
21379
+ yield* printBlocks([[
21207
21380
  `Open ${config.api}/connect#${link.secret}`,
21208
- `Or type ${started.code} in Find under Waiting on you, by ${minute(started.expiresAt)}.`,
21381
+ {
21382
+ before: "Or type",
21383
+ label: started.code,
21384
+ text: `in Find under Waiting on you, by ${minute(started.expiresAt)}.`
21385
+ },
21209
21386
  "Approve it only if you ran this ref0 connect yourself, just now: a yes gives this machine a lasting credential for each agent it connects.",
21210
21387
  "Waiting for the answer. Ctrl-C stops it."
21211
- ].join("\n"));
21388
+ ]]);
21389
+ let typed = "";
21390
+ const show = (text) => Effect.ignore(terminal.display(text));
21391
+ const endWait = yield* Effect.cached(Effect.suspend(() => typed === "" ? Effect.void : show("\n")).pipe(Effect.andThen(Console.error(""))));
21392
+ const saidAfterWait = (refusal) => Effect.andThen(endWait, said(refusal));
21212
21393
  if (opener !== void 0) yield* openBrowser(opener, `${config.api}/connect#${link.secret}`);
21213
21394
  const withSecret = HttpApiMiddleware.layerClient(ApprovalSecret, ({ request, next }) => next(HttpClientRequest.bearerToken(request, collecting.secret)));
21214
21395
  const collector = yield* ref0Client(config.api).pipe(Effect.provide(Layer.merge(anonymousCredential, withSecret)), Effect.provideService(HttpClient.HttpClient, http));
@@ -21220,8 +21401,8 @@ const requestAttachment = Effect.fnUntraced(function* (asked) {
21220
21401
  duration: requestTimeout,
21221
21402
  orElse: () => Effect.succeedNone
21222
21403
  }), Effect.catchTags({
21223
- AttachmentNotFound: said,
21224
- Unauthenticated: said,
21404
+ AttachmentNotFound: saidAfterWait,
21405
+ Unauthenticated: saidAfterWait,
21225
21406
  AuthorityUnavailable: () => Effect.succeedNone,
21226
21407
  HttpClientError: () => Effect.succeedNone,
21227
21408
  SchemaError: () => Effect.succeedNone
@@ -21231,7 +21412,10 @@ const requestAttachment = Effect.fnUntraced(function* (asked) {
21231
21412
  while (true) {
21232
21413
  const state = yield* collect;
21233
21414
  if (Option.isSome(state) && isEnded(state.value)) return state.value;
21234
- if ((yield* Clock.currentTimeMillis) > giveUpAt) return yield* sayFailed(`ref0 hasn’t said how it ended, so this machine kept nothing. If it was approved, ref0 revokes its grants 10 minutes after it connected. To connect this machine, run: ${again$2}`);
21415
+ if ((yield* Clock.currentTimeMillis) > giveUpAt) {
21416
+ yield* endWait;
21417
+ return yield* sayFailed(`ref0 hasn’t said how it ended, so this machine kept nothing. If it was approved, ref0 revokes its grants 10 minutes after it connected. To connect this machine, run: ${again$1}`);
21418
+ }
21235
21419
  yield* Effect.sleep(pollMillis);
21236
21420
  }
21237
21421
  });
@@ -21295,12 +21479,12 @@ const requestAttachment = Effect.fnUntraced(function* (asked) {
21295
21479
  "It connected, but this machine couldn’t keep what it needs to act:",
21296
21480
  ...notKept.map((each) => `${shownConfigFolder(home, each.configFolder)}: ${each.nextStep}`),
21297
21481
  "This machine never confirmed the grants, so ref0 revokes them 10 minutes after they connected, even if the keychain kept them after all. To connect this machine, run:",
21298
- { command: again$2 }
21482
+ { command: again$1 }
21299
21483
  ]]);
21300
- const unconfirmed = () => sayFailed(`It connected, and this machine kept its grants, but ref0 didn’t answer when it said so. If ref0 never heard, it revokes them 10 minutes after they connected: then run ${again$2}`);
21484
+ const unconfirmed = () => sayFailed(`It connected, and this machine kept its grants, but ref0 didn’t answer when it said so. If ref0 never heard, it revokes them 10 minutes after they connected: then run ${again$1}`);
21301
21485
  const confirmId = yield* newRequestId;
21302
21486
  const answerCode = yield* Ref.get(pasted);
21303
- if (answerCode === void 0) return yield* sayFailed(`The answer code is missing. Run ${again$2} again.`);
21487
+ if (answerCode === void 0) return yield* sayFailed(`The answer code is missing. Run ${again$1} again.`);
21304
21488
  if (!(yield* collector.machineAttachments.confirm({
21305
21489
  params,
21306
21490
  headers: { "idempotency-key": confirmId },
@@ -21323,7 +21507,7 @@ const requestAttachment = Effect.fnUntraced(function* (asked) {
21323
21507
  schedule: Schedule.spaced("1 second")
21324
21508
  }), Effect.catchTag("Unanswered", unconfirmed))).confirmed) {
21325
21509
  for (const folder of kept) yield* hostGrant.remove(folder.configFolder).pipe(Effect.ignore);
21326
- return yield* sayFailed(`ref0 revoked the grants, since this machine didn’t confirm in time that it kept them. They’re deleted here, and nothing is connected. To connect this machine, run: ${again$2}`);
21510
+ return yield* sayFailed(`ref0 revoked the grants, since this machine didn’t confirm in time that it kept them. They’re deleted here, and nothing is connected. To connect this machine, run: ${again$1}`);
21327
21511
  }
21328
21512
  return {
21329
21513
  owner: state.owner,
@@ -21361,23 +21545,42 @@ const requestAttachment = Effect.fnUntraced(function* (asked) {
21361
21545
  const connected = yield* finish(isEnded(state) ? state : yield* answered);
21362
21546
  yield* printBlocks([[{ label: `Connected as ${connected.owner}’s agents` }, ...connected.kept.map((folder) => `${shownConfigFolder(home, folder.configFolder)} ${folder.handle}`)], ["Stopped, so nothing was installed. To install ref0’s plugin into each, run:", ...connected.kept.map((folder) => ({ command: installLine(folder.configFolder) }))]]);
21363
21547
  });
21364
- const terminal = yield* Terminal.Terminal;
21365
- const readAnswer = Effect.gen(function* () {
21366
- yield* Console.error(`Paste the code from your own page on ${config.api}.`);
21548
+ const typedCode = Effect.gen(function* () {
21549
+ const keys = yield* terminal.readInput;
21367
21550
  while (true) {
21368
- const typed = (yield* terminal.readLine).replaceAll(escapeCode, "").trim();
21369
- if (typed === "") continue;
21370
- if (!isAnswerCode(typed)) {
21371
- yield* Console.error(`Paste the 8-character code from your own page on ${config.api}.`);
21551
+ const pressed = yield* Effect.option(Queue.take(keys));
21552
+ if (Option.isNone(pressed) || quits(pressed.value.key)) return yield* Effect.interrupt;
21553
+ const { input, key } = pressed.value;
21554
+ if (key.name === "backspace") {
21555
+ if (typed === "") continue;
21556
+ typed = takeBack(typed);
21557
+ yield* show("\b \b");
21558
+ continue;
21559
+ }
21560
+ if (key.name !== "return" && key.name !== "enter") {
21561
+ const text = Option.getOrElse(input, () => "");
21562
+ if (!typesText(text)) continue;
21563
+ typed += text;
21564
+ yield* show(text);
21372
21565
  continue;
21373
21566
  }
21374
- yield* Ref.update(pasted, (first) => first ?? typed);
21375
- if (listener !== void 0) yield* listener.stop;
21376
- return yield* Effect.never;
21567
+ const line = typed.trim();
21568
+ if (typed !== "") yield* show("\n");
21569
+ typed = "";
21570
+ if (line === "") continue;
21571
+ if (isAnswerCode(line)) return line;
21572
+ yield* printBlocks([[`Paste the 8-character code from your own page on ${config.api}.`]]);
21377
21573
  }
21378
- }).pipe(Effect.catchTag("QuitError", () => Effect.interrupt));
21574
+ }).pipe(Effect.scoped);
21575
+ const readAnswer = Effect.gen(function* () {
21576
+ yield* printBlocks([[`Paste the code from your own page on ${config.api}.`]]);
21577
+ const code = yield* typedCode;
21578
+ yield* Ref.update(pasted, (first) => first ?? code);
21579
+ if (listener !== void 0) yield* listener.stop;
21580
+ return yield* Effect.never;
21581
+ });
21379
21582
  const receiveAnswer = listener === void 0 ? readAnswer : Effect.raceFirst(readAnswer, listener.answer.pipe(Effect.flatMap((code) => Ref.update(pasted, (first) => first ?? code)), Effect.andThen(Effect.never)));
21380
- return yield* Effect.uninterruptibleMask((restore) => restore(Effect.raceFirst(answered, receiveAnswer)).pipe(Effect.ensuring(listener === void 0 ? Effect.void : listener.stop), Effect.onInterrupt(() => Effect.ignore(stopped)), Effect.flatMap(finish)));
21583
+ return yield* Effect.uninterruptibleMask((restore) => restore(Effect.raceFirst(answered, receiveAnswer)).pipe(Effect.ensuring(listener === void 0 ? Effect.void : listener.stop), Effect.ensuring(endWait), Effect.onInterrupt(() => Effect.ignore(stopped)), Effect.flatMap(finish)));
21381
21584
  }, Effect.scoped);
21382
21585
  //#endregion
21383
21586
  //#region src/connect-lock.ts
@@ -21512,117 +21715,1417 @@ var ConnectLock = class ConnectLock extends Context.Service()("ref0/cli/ConnectL
21512
21715
  }));
21513
21716
  };
21514
21717
  //#endregion
21515
- //#region src/yes-or-no.ts
21516
- /**
21517
- * How long what a terminal held while nobody read it takes to arrive once a question reads: one
21518
- * read, well within this.
21519
- */
21520
- const settle = Duration.millis(50);
21521
- const again$1 = "Type y or yes, or n or no. Enter alone is no.";
21522
- /** A line's answer, or none when it's neither yes nor no. */
21523
- const answerOf$1 = (line) => {
21524
- const word = line.trim().toLowerCase();
21525
- if (word === "y" || word === "yes") return Option.some(true);
21526
- if (word === "" || word === "n" || word === "no") return Option.some(false);
21527
- return Option.none();
21528
- };
21529
- /** What Backspace takes back: one character as the person sees it, an emoji's parts included. */
21530
- const characters = new Intl.Segmenter();
21531
- /** Whether a key types text: none of its characters moves the cursor or controls the terminal. */
21532
- const typesText = (text) => text !== "" && !/\p{Cc}/u.test(text);
21533
- const quits = (key) => key.ctrl && (key.name === "c" || key.name === "d");
21534
- /** The explanation goes to stderr, the question to stdout and its answer comes from stdin. */
21535
- const allTerminals = () => process.stdin.isTTY === true && process.stdout.isTTY === true && process.stderr.isTTY === true;
21536
- /**
21537
- * Asks yes-or-no questions in this terminal, one after another: each shows `explained` on stderr,
21538
- * then the question, and waits for a line. What was typed before a question showed doesn't answer
21539
- * it, unless another question was asked when it arrived: a line typed after the one that answered
21540
- * a question answers the next. The answer is no, unasked, unless stdin, stdout and stderr are each
21541
- * a terminal, and on Ctrl-C or Ctrl-D.
21542
- */
21543
- const yesOrNoQuestions = Effect.gen(function* () {
21544
- const terminal = yield* Terminal.Terminal;
21545
- const ahead = yield* Ref.make([]);
21546
- const ask = (explained, question) => Effect.gen(function* () {
21547
- if (!allTerminals()) return false;
21548
- const keys = yield* terminal.readInput;
21549
- yield* Effect.sleep(settle);
21550
- yield* Queue.clear(keys);
21551
- const pending = [...yield* Ref.getAndSet(ahead, [])];
21552
- const next = Effect.suspend(() => {
21553
- const key = pending.shift();
21554
- return key === void 0 ? Queue.take(keys) : Effect.succeed(key);
21718
+ //#region ../../node_modules/.pnpm/smol-toml@1.9.1/node_modules/smol-toml/dist/error.js
21719
+ /*!
21720
+ * Copyright (c) Squirrel Chat et al., All rights reserved.
21721
+ * SPDX-License-Identifier: BSD-3-Clause
21722
+ *
21723
+ * Redistribution and use in source and binary forms, with or without
21724
+ * modification, are permitted provided that the following conditions are met:
21725
+ *
21726
+ * 1. Redistributions of source code must retain the above copyright notice, this
21727
+ * list of conditions and the following disclaimer.
21728
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
21729
+ * this list of conditions and the following disclaimer in the
21730
+ * documentation and/or other materials provided with the distribution.
21731
+ * 3. Neither the name of the copyright holder nor the names of its contributors
21732
+ * may be used to endorse or promote products derived from this software without
21733
+ * specific prior written permission.
21734
+ *
21735
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
21736
+ * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
21737
+ * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
21738
+ * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
21739
+ * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
21740
+ * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
21741
+ * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
21742
+ * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
21743
+ * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
21744
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
21745
+ */
21746
+ function getLineColFromPtr(string, ptr) {
21747
+ let lines = string.slice(0, ptr).split(/\r?\n/);
21748
+ return [lines.length, lines.pop().length + 1];
21749
+ }
21750
+ function makeCodeBlock(string, line, column) {
21751
+ let lines = string.split(/\r?\n/);
21752
+ let codeblock = "";
21753
+ let numberLen = (Math.log10(line + 1) | 0) + 1;
21754
+ for (let i = line - 1; i <= line + 1; i++) {
21755
+ let l = lines[i - 1];
21756
+ if (!l) continue;
21757
+ codeblock += i.toString().padEnd(numberLen, " ");
21758
+ codeblock += ": ";
21759
+ codeblock += l;
21760
+ codeblock += "\n";
21761
+ if (i === line) {
21762
+ codeblock += " ".repeat(numberLen + column + 2);
21763
+ codeblock += "^\n";
21764
+ }
21765
+ }
21766
+ return codeblock;
21767
+ }
21768
+ var TomlError = class TomlError extends Error {
21769
+ line;
21770
+ column;
21771
+ codeblock;
21772
+ constructor(message, options) {
21773
+ const [line, column] = getLineColFromPtr(options.toml, options.ptr);
21774
+ const codeblock = makeCodeBlock(options.toml, line, column);
21775
+ super(`Invalid TOML document: ${message}\n\n${codeblock}`, options);
21776
+ this.line = line;
21777
+ this.column = column;
21778
+ this.codeblock = codeblock;
21779
+ }
21780
+ /** @internal */
21781
+ static x(message, ctx, ptr) {
21782
+ throw new TomlError(message, {
21783
+ toml: ctx.s,
21784
+ ptr: ptr ?? ctx.p
21555
21785
  });
21556
- yield* printBlocks([explained]);
21557
- yield* terminal.display(questionLine(question));
21558
- let typed = "";
21559
- let previous = "";
21560
- while (true) {
21561
- const pressed = yield* Effect.option(next);
21562
- if (Option.isNone(pressed) || quits(pressed.value.key)) {
21563
- yield* terminal.display("\n");
21564
- return false;
21786
+ }
21787
+ };
21788
+ //#endregion
21789
+ //#region ../../node_modules/.pnpm/smol-toml@1.9.1/node_modules/smol-toml/dist/primitive.js
21790
+ /*!
21791
+ * Copyright (c) Squirrel Chat et al., All rights reserved.
21792
+ * SPDX-License-Identifier: BSD-3-Clause
21793
+ *
21794
+ * Redistribution and use in source and binary forms, with or without
21795
+ * modification, are permitted provided that the following conditions are met:
21796
+ *
21797
+ * 1. Redistributions of source code must retain the above copyright notice, this
21798
+ * list of conditions and the following disclaimer.
21799
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
21800
+ * this list of conditions and the following disclaimer in the
21801
+ * documentation and/or other materials provided with the distribution.
21802
+ * 3. Neither the name of the copyright holder nor the names of its contributors
21803
+ * may be used to endorse or promote products derived from this software without
21804
+ * specific prior written permission.
21805
+ *
21806
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
21807
+ * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
21808
+ * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
21809
+ * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
21810
+ * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
21811
+ * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
21812
+ * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
21813
+ * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
21814
+ * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
21815
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
21816
+ */
21817
+ /** @internal */
21818
+ function parseString(ctx) {
21819
+ let startPtr = ctx.p;
21820
+ let c = ctx.s.charCodeAt(ctx.p++);
21821
+ let first = c;
21822
+ let isLiteral = c === 39;
21823
+ let isMultiline = c === ctx.s.charCodeAt(ctx.p) && c === ctx.s.charCodeAt(ctx.p + 1);
21824
+ if (isMultiline) {
21825
+ if ((c = ctx.s.charCodeAt(ctx.p += 2)) === 10) ctx.p++;
21826
+ else if (c === 13 && ctx.s.charCodeAt(ctx.p + 1) === 10) ctx.p += 2;
21827
+ }
21828
+ let parsed = "";
21829
+ let sliceStart = ctx.p;
21830
+ let state = 0;
21831
+ for (; ctx.p < ctx.s.length; ctx.p++) {
21832
+ c = ctx.s.charCodeAt(ctx.p);
21833
+ if (isMultiline && (c === 10 || c === 13 && ctx.s.charCodeAt(ctx.p + 1) === 10)) state = state && 3;
21834
+ else if (c < 32 && c !== 9 || c === 127) TomlError.x("control characters are not allowed in strings", ctx);
21835
+ else if ((!state || state === 3) && c === first && (!isMultiline || ctx.s.charCodeAt(ctx.p + 1) === first && ctx.s.charCodeAt(ctx.p + 2) === first)) {
21836
+ if (isMultiline) {
21837
+ if (ctx.s.charCodeAt(ctx.p + 3) === first) ctx.p++;
21838
+ if (ctx.s.charCodeAt(ctx.p + 3) === first) ctx.p++;
21565
21839
  }
21566
- const { input, key } = pressed.value;
21567
- const ended = key.name === "enter" && previous === "return";
21568
- previous = key.name;
21569
- if (ended) continue;
21570
- if (key.name === "backspace") {
21571
- if (typed === "") continue;
21572
- typed = typed.slice(0, [...characters.segment(typed)].at(-1)?.index);
21573
- yield* terminal.display("\b \b");
21574
- continue;
21840
+ if (!state) {
21841
+ let s = ctx.s.slice(sliceStart, ctx.p);
21842
+ parsed = parsed ? parsed + s : s;
21575
21843
  }
21576
- if (key.name !== "return" && key.name !== "enter") {
21577
- const text = Option.getOrElse(input, () => "");
21578
- if (!typesText(text)) continue;
21579
- typed += text;
21580
- yield* terminal.display(text);
21581
- continue;
21844
+ ctx.p += isMultiline ? 3 : 1;
21845
+ return parsed;
21846
+ } else if (!state) {
21847
+ if (!isLiteral && c === 92) {
21848
+ parsed += ctx.s.slice(sliceStart, sliceStart = ctx.p);
21849
+ state = 1;
21582
21850
  }
21583
- yield* terminal.display("\n");
21584
- const answer = answerOf$1(typed);
21585
- if (Option.isSome(answer)) {
21586
- const rest = [...pending, ...yield* Queue.clear(keys)];
21587
- const ended = key.name === "return" && rest[0]?.key.name === "enter";
21588
- yield* Ref.set(ahead, ended ? rest.slice(1) : rest);
21589
- return answer.value;
21851
+ } else if (state === 1) {
21852
+ if (c === 120 || c === 117 || c === 85) {
21853
+ let errPtr = ctx.p++ - 1;
21854
+ let value = 0;
21855
+ let len = c === 120 ? 2 : c === 117 ? 4 : 8;
21856
+ for (let j = 0; j < len; j++, ctx.p++) {
21857
+ let hex = ctx.s.charCodeAt(ctx.p);
21858
+ let digit = hex >= 48 && hex <= 57 ? hex - 48 : hex >= 65 && hex <= 70 ? hex - 65 + 10 : hex >= 97 && hex <= 102 ? hex - 97 + 10 : -1;
21859
+ if (digit < 0) TomlError.x("invalid non-hex character in unicode escape", ctx);
21860
+ value = value << 4 | digit;
21861
+ }
21862
+ if (value < 0 || value > 1114111 || value >= 55296 && value <= 57343) TomlError.x("invalid unicode escape", ctx, errPtr);
21863
+ parsed += String.fromCodePoint(value);
21864
+ sliceStart = ctx.p--;
21865
+ state = 0;
21866
+ } else if (isMultiline && (c === 32 || c === 9)) state = 2;
21867
+ else {
21868
+ if (c === 98) parsed += "\b";
21869
+ else if (c === 116) parsed += " ";
21870
+ else if (c === 110) parsed += "\n";
21871
+ else if (c === 102) parsed += "\f";
21872
+ else if (c === 114) parsed += "\r";
21873
+ else if (c === 101) parsed += "\x1B";
21874
+ else if (c === 34) parsed += "\"";
21875
+ else if (c === 92) parsed += "\\";
21876
+ else TomlError.x("unrecognised escape sequence", ctx);
21877
+ sliceStart = ctx.p + 1;
21878
+ state = 0;
21590
21879
  }
21591
- typed = "";
21592
- yield* terminal.display(`${again$1}\n${questionLine(question)}`);
21880
+ } else if (c !== 32 && c !== 9) {
21881
+ if (state === 2) TomlError.x("invalid escape: only line-ending whitespace may be escaped", ctx, sliceStart);
21882
+ state = !isLiteral && c === 92 ? 1 : 0;
21883
+ sliceStart = ctx.p;
21593
21884
  }
21594
- }).pipe(Effect.scoped, Effect.orElseSucceed(() => false));
21595
- return ask;
21596
- });
21885
+ }
21886
+ TomlError.x("unfinished string", ctx, startPtr);
21887
+ }
21597
21888
  //#endregion
21598
- //#region src/harness/install-claude-code.ts
21599
- /** Install into one config folder, from the release Claude Code fetched and ref0 checked. */
21600
- const installClaudeCode = Effect.fnUntraced(function* (release, configFolder) {
21601
- const run = yield* release.into(configFolder);
21602
- yield* run([
21603
- "plugin",
21604
- "install",
21605
- release.id,
21606
- "--scope",
21607
- "user"
21608
- ]);
21609
- yield* run([
21610
- "plugin",
21611
- "update",
21612
- release.id,
21613
- "--scope",
21614
- "user"
21615
- ]);
21616
- return release.folder;
21617
- });
21889
+ //#region ../../node_modules/.pnpm/smol-toml@1.9.1/node_modules/smol-toml/dist/date.js
21890
+ /*!
21891
+ * Copyright (c) Squirrel Chat et al., All rights reserved.
21892
+ * SPDX-License-Identifier: BSD-3-Clause
21893
+ *
21894
+ * Redistribution and use in source and binary forms, with or without
21895
+ * modification, are permitted provided that the following conditions are met:
21896
+ *
21897
+ * 1. Redistributions of source code must retain the above copyright notice, this
21898
+ * list of conditions and the following disclaimer.
21899
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
21900
+ * this list of conditions and the following disclaimer in the
21901
+ * documentation and/or other materials provided with the distribution.
21902
+ * 3. Neither the name of the copyright holder nor the names of its contributors
21903
+ * may be used to endorse or promote products derived from this software without
21904
+ * specific prior written permission.
21905
+ *
21906
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
21907
+ * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
21908
+ * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
21909
+ * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
21910
+ * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
21911
+ * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
21912
+ * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
21913
+ * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
21914
+ * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
21915
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
21916
+ */
21917
+ let DATE_TIME_RE = /^(\d{4}-\d{2}-\d{2})?[Tt ]?(?:(\d{2}):\d{2}(?::\d{2}(?:\.\d+)?)?)?(Z|z|[-+]\d{2}:\d{2})?$/i;
21918
+ var TomlDate = class TomlDate extends Date {
21919
+ #hasDate = false;
21920
+ #hasTime = false;
21921
+ #offset = null;
21922
+ constructor(date, fasttype, unsafeDelim) {
21923
+ let hasDate = true;
21924
+ let hasTime = true;
21925
+ let offset = "Z";
21926
+ let c;
21927
+ if (typeof date === "string") {
21928
+ if (fasttype) prep: {
21929
+ if (fasttype < 3) {
21930
+ if (+date.slice(11, 13) > 23) {
21931
+ date = "";
21932
+ break prep;
21933
+ }
21934
+ if (fasttype === 2) {
21935
+ offset = null;
21936
+ date += "Z";
21937
+ } else if ((c = date.charCodeAt(date.length - 1)) !== 90 && c !== 122) offset = date.slice(date.length - 6);
21938
+ if (unsafeDelim) date = date.slice(0, 10) + "T" + date.slice(11);
21939
+ } else if (fasttype === 4) date = +date.slice(0, 2) > 23 ? "" : `0000-01-01T${date}Z`;
21940
+ hasDate = fasttype !== 4;
21941
+ hasTime = fasttype !== 3;
21942
+ }
21943
+ else {
21944
+ let match = date.match(DATE_TIME_RE);
21945
+ if (match) {
21946
+ if (!match[1]) {
21947
+ hasDate = false;
21948
+ date = `0000-01-01T${date}`;
21949
+ }
21950
+ hasTime = !!match[2];
21951
+ hasTime && date[10] === " " && (date = date.replace(" ", "T"));
21952
+ if (match[2] && +match[2] > 23) date = "";
21953
+ else {
21954
+ offset = match[3] || null;
21955
+ if (!offset && hasTime) date += "Z";
21956
+ }
21957
+ } else date = "";
21958
+ }
21959
+ }
21960
+ super(date);
21961
+ if (!isNaN(this.getTime())) {
21962
+ this.#hasDate = hasDate;
21963
+ this.#hasTime = hasTime;
21964
+ this.#offset = offset;
21965
+ }
21966
+ }
21967
+ isDateTime() {
21968
+ return this.#hasDate && this.#hasTime;
21969
+ }
21970
+ isLocal() {
21971
+ return !this.#hasDate || !this.#hasTime || !this.#offset;
21972
+ }
21973
+ isDate() {
21974
+ return this.#hasDate && !this.#hasTime;
21975
+ }
21976
+ isTime() {
21977
+ return this.#hasTime && !this.#hasDate;
21978
+ }
21979
+ isValid() {
21980
+ return this.#hasDate || this.#hasTime;
21981
+ }
21982
+ toISOString() {
21983
+ let iso = super.toISOString();
21984
+ if (this.isDate()) return iso.slice(0, 10);
21985
+ if (this.isTime()) return iso.slice(11, 23);
21986
+ if (this.#offset === null) return iso.slice(0, -1);
21987
+ if (this.#offset === "Z" || this.#offset === "z") return iso;
21988
+ let offset = +this.#offset.slice(1, 3) * 60 + +this.#offset.slice(4, 6);
21989
+ offset = this.#offset[0] === "-" ? offset : -offset;
21990
+ return (/* @__PURE__ */ new Date(this.getTime() - offset * 6e4)).toISOString().slice(0, -1) + this.#offset;
21991
+ }
21992
+ static wrapAsOffsetDateTime(jsDate, offset = "Z") {
21993
+ let date = new TomlDate(jsDate);
21994
+ date.#offset = offset;
21995
+ return date;
21996
+ }
21997
+ static wrapAsLocalDateTime(jsDate) {
21998
+ let date = new TomlDate(jsDate);
21999
+ date.#offset = null;
22000
+ return date;
22001
+ }
22002
+ static wrapAsLocalDate(jsDate) {
22003
+ let date = new TomlDate(jsDate);
22004
+ date.#hasTime = false;
22005
+ date.#offset = null;
22006
+ return date;
22007
+ }
22008
+ static wrapAsLocalTime(jsDate) {
22009
+ let date = new TomlDate(jsDate);
22010
+ date.#hasDate = false;
22011
+ date.#offset = null;
22012
+ return date;
22013
+ }
22014
+ };
21618
22015
  //#endregion
21619
- //#region src/harness/hook-fields.ts
21620
- /**
21621
- * The same for Codex 0.160.0, which skips a hook outright if its template names a field the event
21622
- * lacks, so each list holds only fields every hook of its event carried in the prototypes, and
21623
- * only events they saw, each a `HookEvent`. Codex puts the model in every hook but `SessionEnd`.
21624
- * Its `UserPromptSubmit` carries the prompt (`spikes/harness/results/codex/catalog.json`), which
21625
- * no list names, as for Claude Code.
22016
+ //#region ../../node_modules/.pnpm/smol-toml@1.9.1/node_modules/smol-toml/dist/extract.js
22017
+ /*!
22018
+ * Copyright (c) Squirrel Chat et al., All rights reserved.
22019
+ * SPDX-License-Identifier: BSD-3-Clause
22020
+ *
22021
+ * Redistribution and use in source and binary forms, with or without
22022
+ * modification, are permitted provided that the following conditions are met:
22023
+ *
22024
+ * 1. Redistributions of source code must retain the above copyright notice, this
22025
+ * list of conditions and the following disclaimer.
22026
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
22027
+ * this list of conditions and the following disclaimer in the
22028
+ * documentation and/or other materials provided with the distribution.
22029
+ * 3. Neither the name of the copyright holder nor the names of its contributors
22030
+ * may be used to endorse or promote products derived from this software without
22031
+ * specific prior written permission.
22032
+ *
22033
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
22034
+ * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
22035
+ * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22036
+ * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
22037
+ * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
22038
+ * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
22039
+ * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
22040
+ * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
22041
+ * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
22042
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
22043
+ */
22044
+ function isDigit(char, base = 10) {
22045
+ return base === 16 ? char > 47 && char < 58 || char > 64 && char < 71 || char > 96 && char < 103 : char > 47 && char < 48 + base;
22046
+ }
22047
+ function isEndOfValue(char, delim) {
22048
+ return char === 32 || char === 9 || char === 10 || char === 13 || delim && (char === delim || char === 44) || char === 35;
22049
+ }
22050
+ /** @internal */
22051
+ function extractValue(ctx, end) {
22052
+ let errPtr = ctx.p;
22053
+ let c = ctx.s.charCodeAt(ctx.p);
22054
+ if (c === 91 || c === 123) {
22055
+ ctx.d-- || TomlError.x("document contains excessively nested structures. aborting.", ctx);
22056
+ let value = c === 91 ? parseArray(ctx) : parseInlineTable(ctx);
22057
+ ctx.d++;
22058
+ return value;
22059
+ }
22060
+ if (c === 34 || c === 39) return parseString(ctx);
22061
+ if (c === 116) {
22062
+ if (ctx.s.charCodeAt(++ctx.p) !== 114 || ctx.s.charCodeAt(++ctx.p) !== 117 || ctx.s.charCodeAt(++ctx.p) !== 101) TomlError.x("invalid value", ctx, errPtr);
22063
+ return ctx.p++, true;
22064
+ }
22065
+ if (c === 102) {
22066
+ if (ctx.s.charCodeAt(++ctx.p) !== 97 || ctx.s.charCodeAt(++ctx.p) !== 108 || ctx.s.charCodeAt(++ctx.p) !== 115 || ctx.s.charCodeAt(++ctx.p) !== 101) TomlError.x("invalid value", ctx, errPtr);
22067
+ return ctx.p++, false;
22068
+ }
22069
+ if (c === 43 || c === 45) return parseNumber(ctx, ctx.p, ctx.s.charCodeAt(++ctx.p), 44 - c, end);
22070
+ if (ctx.s.charCodeAt(ctx.p + 4) === 45 && ctx.s.charCodeAt(ctx.p + 7) === 45) return parseDate(ctx, c, end);
22071
+ if (ctx.s.charCodeAt(ctx.p + 2) === 58) return parseTime(ctx, c, end);
22072
+ return parseNumber(ctx, ctx.p, c, 0, end);
22073
+ }
22074
+ function parseNumber(ctx, startPtr, startChr, sign, endChr) {
22075
+ let c = startChr;
22076
+ let state = 0;
22077
+ let hasUnderscores = false;
22078
+ if (c === 105) {
22079
+ if (ctx.s.charCodeAt(++ctx.p) !== 110 || ctx.s.charCodeAt(++ctx.p) !== 102) TomlError.x("invalid value", ctx, startPtr);
22080
+ return ctx.p++, (sign || 1) / 0;
22081
+ }
22082
+ if (c === 110) {
22083
+ if (ctx.s.charCodeAt(++ctx.p) !== 97 || ctx.s.charCodeAt(++ctx.p) !== 110) TomlError.x("invalid value", ctx, startPtr);
22084
+ return ctx.p++, NaN;
22085
+ }
22086
+ if (c === 48) {
22087
+ if (++ctx.p >= ctx.s.length || isEndOfValue(c = ctx.s.charCodeAt(ctx.p), endChr)) return ctx.bi === true ? 0n : 0;
22088
+ if (!sign) {
22089
+ if (c === 120) return parseIntegerBaseN(ctx, startPtr, 16, endChr);
22090
+ else if (c === 98) return parseIntegerBaseN(ctx, startPtr, 2, endChr);
22091
+ else if (c === 111) return parseIntegerBaseN(ctx, startPtr, 8, endChr);
22092
+ }
22093
+ if (c === 46) state = 2;
22094
+ else if (c === 101 || c === 69) state = 4;
22095
+ else TomlError.x("illegal leading zero", ctx, startPtr);
22096
+ } else if (!isDigit(c)) TomlError.x("invalid value", ctx, startPtr);
22097
+ while (++ctx.p < ctx.s.length && (c = ctx.s.charCodeAt(ctx.p), !isEndOfValue(c, endChr))) {
22098
+ if (!state) state = 1;
22099
+ if (c === 95) {
22100
+ if (!(state & 1)) TomlError.x("illegal underscore", ctx);
22101
+ state += 11;
22102
+ hasUnderscores = true;
22103
+ } else if (state === 1 && c === 46) state = 2;
22104
+ else if ((state === 1 || state === 3) && (c === 101 || c === 69)) state = 4;
22105
+ else if (state === 4 && (c === 43 || c === 45)) state = 6;
22106
+ else if (!isDigit(c)) TomlError.x(`illegal character in numeric literal`, ctx);
22107
+ else if (state > 9) state -= 11;
22108
+ else if (!(state & 1)) state++;
22109
+ }
22110
+ if (!state) {
22111
+ let val = (startChr - 48) * (sign || 1);
22112
+ return ctx.bi === true ? BigInt(val) : val;
22113
+ }
22114
+ if (!(state & 1)) TomlError.x("unfinished numeric value", ctx, startPtr);
22115
+ let str = ctx.s.slice(startPtr, ctx.p);
22116
+ if (hasUnderscores) str = str.replaceAll("_", "");
22117
+ return state > 1 ? parseFloat(str) : parseInteger(ctx, str, 10, startPtr);
22118
+ }
22119
+ function parseIntegerBaseN(ctx, startPtr, base, endChr) {
22120
+ let c, underscore = 1;
22121
+ while (++ctx.p < ctx.s.length && (c = ctx.s.charCodeAt(ctx.p), !isEndOfValue(c, endChr))) if (c === 95) {
22122
+ if (underscore & 1) TomlError.x("illegal underscore", ctx);
22123
+ underscore = 3;
22124
+ } else if (!isDigit(c, base)) TomlError.x(`illegal character in numeric literal`, ctx);
22125
+ else if (underscore & 1) underscore--;
22126
+ if (underscore & 1) TomlError.x("unfinished numeric value", ctx);
22127
+ let str = ctx.s.slice(startPtr + 2, ctx.p);
22128
+ if (underscore) str = str.replaceAll("_", "");
22129
+ return parseInteger(ctx, str, base, startPtr);
22130
+ }
22131
+ function parseInteger(ctx, str, base, startPtr) {
22132
+ if (ctx.bi !== true) int: {
22133
+ let val = parseInt(str, base);
22134
+ if (!Number.isSafeInteger(val)) {
22135
+ if (ctx.bi) break int;
22136
+ TomlError.x("integer value cannot be represented losslessly", ctx, startPtr);
22137
+ }
22138
+ return val;
22139
+ }
22140
+ return base === 10 ? BigInt(str) : BigInt((base === 2 ? "0b" : base === 8 ? "0o" : "0x") + str);
22141
+ }
22142
+ function parseDate(ctx, c, endChr) {
22143
+ let startPtr = ctx.p++, unsafeSeparator;
22144
+ if (!isDigit(c) || !isDigit(ctx.s.charCodeAt(ctx.p++)) || !isDigit(ctx.s.charCodeAt(ctx.p++)) || !isDigit(ctx.s.charCodeAt(ctx.p++))) return parseNumber(ctx, ctx.p = startPtr, c, 0, endChr);
22145
+ ctx.p += 5;
22146
+ if (!isDigit(ctx.s.charCodeAt(ctx.p++))) TomlError.x("invalid date-time: date part is malformed", ctx, startPtr);
22147
+ if (ctx.p >= ctx.s.length || ((c = ctx.s.charCodeAt(ctx.p)) !== 32 || (unsafeSeparator = true, !isDigit(ctx.s.charCodeAt(ctx.p + 1)))) && c !== 84 && c !== 116) return readDate(ctx, ctx.s.slice(startPtr, ctx.p), 3, false, startPtr);
22148
+ if (ctx.s.charCodeAt(ctx.p += 3) !== 58) TomlError.x("invalid date-time: time part is malformed", ctx, startPtr);
22149
+ if (ctx.s.charCodeAt(ctx.p += 3) === 58) ctx.p += 3;
22150
+ if (ctx.s.charCodeAt(ctx.p) === 46) while (isDigit(ctx.s.charCodeAt(++ctx.p)));
22151
+ if (c = ctx.s.charCodeAt(ctx.p)) {
22152
+ if (c === 90 || c === 122) return readDate(ctx, ctx.s.slice(startPtr, ++ctx.p), 1, unsafeSeparator, startPtr, "[+00:00]");
22153
+ if (c === 43 || c === 45) return readDate(ctx, ctx.s.slice(startPtr, ctx.p += 6), 1, unsafeSeparator, startPtr, !ctx.ld && "[" + ctx.s.slice(ctx.p - 6, ctx.p) + "]");
22154
+ }
22155
+ return readDate(ctx, ctx.s.slice(startPtr, ctx.p), 2, unsafeSeparator, startPtr);
22156
+ }
22157
+ function parseTime(ctx, c, endChr) {
22158
+ let start = ctx.p;
22159
+ if (!isDigit(c) || !isDigit(ctx.s.charCodeAt(++ctx.p))) return parseNumber(ctx, --ctx.p, c, 0, endChr);
22160
+ if (ctx.s.charCodeAt(ctx.p += 4) === 58) ctx.p += 3;
22161
+ if (ctx.s.charCodeAt(ctx.p) === 46) while (isDigit(ctx.s.charCodeAt(++ctx.p)));
22162
+ return readDate(ctx, ctx.s.slice(start, ctx.p), 4, false, start);
22163
+ }
22164
+ function readDate(ctx, str, type, unsafeDelim, errPtr, temporalSuffix) {
22165
+ if (ctx.ld) {
22166
+ let date = new TomlDate(str, type, unsafeDelim);
22167
+ if (!date.isValid()) TomlError.x("invalid date", ctx, errPtr);
22168
+ return date;
22169
+ }
22170
+ try {
22171
+ if (temporalSuffix) str += temporalSuffix;
22172
+ switch (type) {
22173
+ case 1: return Temporal.ZonedDateTime.from(str);
22174
+ case 2: return Temporal.PlainDateTime.from(str);
22175
+ case 3: return Temporal.PlainDate.from(str);
22176
+ case 4: return Temporal.PlainTime.from(str);
22177
+ }
22178
+ } catch (e) {
22179
+ TomlError.x(e instanceof Error ? e.message : "" + e, ctx, errPtr);
22180
+ }
22181
+ }
22182
+ //#endregion
22183
+ //#region ../../node_modules/.pnpm/smol-toml@1.9.1/node_modules/smol-toml/dist/util.js
22184
+ /*!
22185
+ * Copyright (c) Squirrel Chat et al., All rights reserved.
22186
+ * SPDX-License-Identifier: BSD-3-Clause
22187
+ *
22188
+ * Redistribution and use in source and binary forms, with or without
22189
+ * modification, are permitted provided that the following conditions are met:
22190
+ *
22191
+ * 1. Redistributions of source code must retain the above copyright notice, this
22192
+ * list of conditions and the following disclaimer.
22193
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
22194
+ * this list of conditions and the following disclaimer in the
22195
+ * documentation and/or other materials provided with the distribution.
22196
+ * 3. Neither the name of the copyright holder nor the names of its contributors
22197
+ * may be used to endorse or promote products derived from this software without
22198
+ * specific prior written permission.
22199
+ *
22200
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
22201
+ * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
22202
+ * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22203
+ * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
22204
+ * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
22205
+ * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
22206
+ * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
22207
+ * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
22208
+ * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
22209
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
22210
+ */
22211
+ /** @internal */
22212
+ function skipComment(ctx) {
22213
+ for (; ctx.p < ctx.s.length; ctx.p++) {
22214
+ let c = ctx.s.charCodeAt(ctx.p);
22215
+ if (c === 10) break;
22216
+ if (c === 13 && ctx.s.charCodeAt(ctx.p + 1) === 10) {
22217
+ ctx.p++;
22218
+ break;
22219
+ }
22220
+ if (c < 32 && c !== 9 || c === 127) TomlError.x("control characters are not allowed in comments", ctx);
22221
+ }
22222
+ }
22223
+ /** @internal */
22224
+ function skipVoid(ctx, banNewLines, banComments) {
22225
+ let c;
22226
+ while (ctx.p < ctx.s.length) {
22227
+ while (ctx.p < ctx.s.length && ((c = ctx.s.charCodeAt(ctx.p)) === 32 || c === 9 || !banNewLines && (c === 10 || c === 13 && ctx.s.charCodeAt(ctx.p + 1) === 10))) ctx.p++;
22228
+ if (banComments || c !== 35) break;
22229
+ skipComment(ctx);
22230
+ }
22231
+ }
22232
+ //#endregion
22233
+ //#region ../../node_modules/.pnpm/smol-toml@1.9.1/node_modules/smol-toml/dist/struct.js
22234
+ /*!
22235
+ * Copyright (c) Squirrel Chat et al., All rights reserved.
22236
+ * SPDX-License-Identifier: BSD-3-Clause
22237
+ *
22238
+ * Redistribution and use in source and binary forms, with or without
22239
+ * modification, are permitted provided that the following conditions are met:
22240
+ *
22241
+ * 1. Redistributions of source code must retain the above copyright notice, this
22242
+ * list of conditions and the following disclaimer.
22243
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
22244
+ * this list of conditions and the following disclaimer in the
22245
+ * documentation and/or other materials provided with the distribution.
22246
+ * 3. Neither the name of the copyright holder nor the names of its contributors
22247
+ * may be used to endorse or promote products derived from this software without
22248
+ * specific prior written permission.
22249
+ *
22250
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
22251
+ * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
22252
+ * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22253
+ * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
22254
+ * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
22255
+ * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
22256
+ * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
22257
+ * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
22258
+ * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
22259
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
22260
+ */
22261
+ /** @internal */
22262
+ function parseKey(ctx, end = 61) {
22263
+ let startPtr;
22264
+ let state = 0;
22265
+ let parsed = [];
22266
+ let sliceStart;
22267
+ let c = ctx.s.charCodeAt(startPtr = ctx.p);
22268
+ do
22269
+ if (c === end) {
22270
+ if (!state) TomlError.x("unexpected end of key", ctx);
22271
+ if (state === 1) parsed.push(ctx.s.slice(sliceStart, ctx.p));
22272
+ return ctx.p++, parsed;
22273
+ } else if (c === 46) {
22274
+ if (!state) TomlError.x("illegal empty bare key", ctx);
22275
+ if (state === 1) parsed.push(ctx.s.slice(sliceStart, ctx.p));
22276
+ state = 0;
22277
+ } else if (!state && (c === 34 || c === 39)) {
22278
+ if (c === ctx.s.charCodeAt(ctx.p + 1) && c === ctx.s.charCodeAt(ctx.p + 2)) TomlError.x("illegal quoted key: multiline strings are not allowed", ctx);
22279
+ parsed.push(parseString(ctx));
22280
+ state = 2;
22281
+ ctx.p--;
22282
+ } else if (c === 32 || c === 9) {
22283
+ if (state === 1) {
22284
+ parsed.push(ctx.s.slice(sliceStart, ctx.p));
22285
+ state = 2;
22286
+ }
22287
+ } else if (state === 2 || c < 48 && c !== 45 || c > 57 && c < 65 || c > 90 && c < 97 && c !== 95 || c > 122) TomlError.x("illegal character in key", ctx);
22288
+ else if (!state) {
22289
+ state = 1;
22290
+ sliceStart = ctx.p;
22291
+ }
22292
+ while (c = ctx.s.charCodeAt(++ctx.p));
22293
+ TomlError.x("incomplete key-value: cannot find end of key", ctx, startPtr);
22294
+ }
22295
+ /** @internal */
22296
+ function parseInlineTable(ctx) {
22297
+ let startPtr = ctx.p++;
22298
+ let res = Object.create(null);
22299
+ let seen = /* @__PURE__ */ new Set();
22300
+ let c;
22301
+ while (ctx.p < ctx.s.length) {
22302
+ skipVoid(ctx);
22303
+ if ((c = ctx.s.charCodeAt(ctx.p)) === 125) {
22304
+ ctx.p++;
22305
+ return res;
22306
+ }
22307
+ let k;
22308
+ let t = res;
22309
+ let hasOwn = false;
22310
+ let errPtr = ctx.p;
22311
+ let key = parseKey(ctx);
22312
+ for (let i = 0; i < key.length; i++) {
22313
+ if (i) t = hasOwn ? t[k] : t[k] = Object.create(null);
22314
+ k = key[i];
22315
+ if ((hasOwn = Object.hasOwn(t, k)) && (typeof t[k] !== "object" || seen.has(t[k]))) TomlError.x("trying to redefine an already defined value", ctx, errPtr);
22316
+ let unsafe = k === "__proto__";
22317
+ if (ctx.uk && (unsafe || k === "constructor")) {
22318
+ t = ctx.uk !== 1 && TomlError.x("document contains an unsafe property", ctx, errPtr);
22319
+ break;
22320
+ }
22321
+ if (!hasOwn && unsafe) Object.defineProperty(t, k, {
22322
+ enumerable: true,
22323
+ configurable: true,
22324
+ writable: true
22325
+ });
22326
+ }
22327
+ if (hasOwn) TomlError.x("trying to redefine an already defined value", ctx, errPtr);
22328
+ skipVoid(ctx, true, true);
22329
+ let value = extractValue(ctx, 125);
22330
+ if (t && typeof (t[k] = value) === "object") seen.add(value);
22331
+ skipVoid(ctx);
22332
+ if ((c = ctx.s.charCodeAt(ctx.p++)) === 125) return res;
22333
+ if (c !== 44) TomlError.x("expected comma or end of structure", ctx, ctx.p - 1);
22334
+ }
22335
+ TomlError.x("unfinished table", ctx, startPtr);
22336
+ }
22337
+ /** @internal */
22338
+ function parseArray(ctx) {
22339
+ let startPtr = ctx.p++;
22340
+ let res = [];
22341
+ let c;
22342
+ while (ctx.p < ctx.s.length) {
22343
+ skipVoid(ctx);
22344
+ if ((c = ctx.s.charCodeAt(ctx.p)) === 93) {
22345
+ ctx.p++;
22346
+ return res;
22347
+ }
22348
+ res.push(extractValue(ctx, 93));
22349
+ skipVoid(ctx);
22350
+ if ((c = ctx.s.charCodeAt(ctx.p++)) === 93) return res;
22351
+ if (c !== 44) TomlError.x("expected comma or end of structure", ctx, ctx.p - 1);
22352
+ }
22353
+ TomlError.x("unfinished array", ctx, startPtr);
22354
+ }
22355
+ //#endregion
22356
+ //#region ../../node_modules/.pnpm/smol-toml@1.9.1/node_modules/smol-toml/dist/parse.js
22357
+ /*!
22358
+ * Copyright (c) Squirrel Chat et al., All rights reserved.
22359
+ * SPDX-License-Identifier: BSD-3-Clause
22360
+ *
22361
+ * Redistribution and use in source and binary forms, with or without
22362
+ * modification, are permitted provided that the following conditions are met:
22363
+ *
22364
+ * 1. Redistributions of source code must retain the above copyright notice, this
22365
+ * list of conditions and the following disclaimer.
22366
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
22367
+ * this list of conditions and the following disclaimer in the
22368
+ * documentation and/or other materials provided with the distribution.
22369
+ * 3. Neither the name of the copyright holder nor the names of its contributors
22370
+ * may be used to endorse or promote products derived from this software without
22371
+ * specific prior written permission.
22372
+ *
22373
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
22374
+ * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
22375
+ * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22376
+ * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
22377
+ * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
22378
+ * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
22379
+ * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
22380
+ * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
22381
+ * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
22382
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
22383
+ */
22384
+ function peekTable(ctx, key, table, meta, type) {
22385
+ let t = table;
22386
+ let m = meta;
22387
+ let k;
22388
+ let hasOwn = false;
22389
+ let state;
22390
+ for (let i = 0; i < key.length; i++) {
22391
+ if (i) {
22392
+ t = hasOwn ? t[k] : t[k] = Object.create(null);
22393
+ m = (state = m[k]).c;
22394
+ if (type === 0 && (state.t === 1 || state.t === 2)) return null;
22395
+ if (state.t === 2) {
22396
+ let l = t.length - 1;
22397
+ t = t[l];
22398
+ m = m[l].c;
22399
+ }
22400
+ }
22401
+ k = key[i];
22402
+ if ((hasOwn = Object.hasOwn(t, k)) && m[k]?.t === 0 && m[k]?.d) return null;
22403
+ if (!hasOwn) {
22404
+ let unsafe = k === "__proto__";
22405
+ if (ctx.uk && (unsafe || k === "constructor")) return false;
22406
+ if (unsafe) {
22407
+ Object.defineProperty(t, k, {
22408
+ enumerable: true,
22409
+ configurable: true,
22410
+ writable: true
22411
+ });
22412
+ Object.defineProperty(m, k, {
22413
+ enumerable: true,
22414
+ configurable: true,
22415
+ writable: true
22416
+ });
22417
+ }
22418
+ m[k] = {
22419
+ t: i < key.length - 1 && type === 2 ? 3 : type,
22420
+ d: false,
22421
+ i: 0,
22422
+ c: Object.create(null)
22423
+ };
22424
+ }
22425
+ }
22426
+ state = m[k];
22427
+ if (state.t !== type && !(type === 1 && state.t === 3)) return null;
22428
+ if (type === 2) {
22429
+ if (!state.d) {
22430
+ state.d = true;
22431
+ t[k] = [];
22432
+ }
22433
+ t[k].push(t = Object.create(null));
22434
+ state.c[state.i++] = state = {
22435
+ t: 1,
22436
+ d: false,
22437
+ i: 0,
22438
+ c: Object.create(null)
22439
+ };
22440
+ }
22441
+ if (state.d) return null;
22442
+ state.d = true;
22443
+ if (type === 1) t = hasOwn ? t[k] : t[k] = Object.create(null);
22444
+ else if (type === 0 && hasOwn) return null;
22445
+ return [
22446
+ k,
22447
+ t,
22448
+ state.c
22449
+ ];
22450
+ }
22451
+ function validateTablePeek(ctx, peek, ptr) {
22452
+ if (peek === null || ctx.uk === 2) TomlError.x(peek === null ? "trying to redefine an already defined table or value" : "document contains an unsafe property", ctx, ptr);
22453
+ }
22454
+ function parse(toml, options = {}) {
22455
+ let ctx = {
22456
+ s: toml,
22457
+ p: 0,
22458
+ d: options.maxDepth ?? 1e3,
22459
+ bi: options.integersAsBigInt ?? false,
22460
+ ld: options.useLegacyDate ?? true,
22461
+ uk: options.unsafeKeyBehaviour === "throw" ? 2 : options.unsafeKeyBehaviour === "drop" ? 1 : 0
22462
+ };
22463
+ let res = Object.create(null);
22464
+ let meta = Object.create(null);
22465
+ let tmp;
22466
+ let skipping = false;
22467
+ let tbl = res;
22468
+ let m = meta;
22469
+ if (toml.charCodeAt(0) === 65279) ctx.p++;
22470
+ skipVoid(ctx);
22471
+ while (ctx.p < toml.length) {
22472
+ if (toml.charCodeAt(ctx.p) === 91) {
22473
+ let isTableArray = toml.charCodeAt(++ctx.p) === 91;
22474
+ tmp = ctx.p += +isTableArray;
22475
+ skipping = false;
22476
+ let k = parseKey(ctx, 93);
22477
+ if (isTableArray) {
22478
+ if (toml.charCodeAt(ctx.p) !== 93) TomlError.x("expected end of table array declaration", ctx);
22479
+ ctx.p++;
22480
+ }
22481
+ let p = peekTable(ctx, k, res, meta, isTableArray ? 2 : 1);
22482
+ if (!p) {
22483
+ validateTablePeek(ctx, p, tmp);
22484
+ skipping = true;
22485
+ } else {
22486
+ m = p[2];
22487
+ tbl = p[1];
22488
+ }
22489
+ } else {
22490
+ tmp = ctx.p;
22491
+ let p = peekTable(ctx, parseKey(ctx), tbl, m, 0);
22492
+ if (!p && !skipping) validateTablePeek(ctx, p, tmp);
22493
+ skipVoid(ctx, true, true);
22494
+ let v = extractValue(ctx, void 0);
22495
+ if (p && !skipping) p[1][p[0]] = v;
22496
+ }
22497
+ skipVoid(ctx, true);
22498
+ if (ctx.p < toml.length && (tmp = toml.charCodeAt(ctx.p)) !== 10 && (tmp !== 13 || toml.charCodeAt(ctx.p + 1) !== 10)) TomlError.x("each key-value declaration must be followed by an end-of-line", ctx);
22499
+ skipVoid(ctx);
22500
+ }
22501
+ return res;
22502
+ }
22503
+ /*!
22504
+ * Copyright (c) Squirrel Chat et al., All rights reserved.
22505
+ * SPDX-License-Identifier: BSD-3-Clause
22506
+ *
22507
+ * Redistribution and use in source and binary forms, with or without
22508
+ * modification, are permitted provided that the following conditions are met:
22509
+ *
22510
+ * 1. Redistributions of source code must retain the above copyright notice, this
22511
+ * list of conditions and the following disclaimer.
22512
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
22513
+ * this list of conditions and the following disclaimer in the
22514
+ * documentation and/or other materials provided with the distribution.
22515
+ * 3. Neither the name of the copyright holder nor the names of its contributors
22516
+ * may be used to endorse or promote products derived from this software without
22517
+ * specific prior written permission.
22518
+ *
22519
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
22520
+ * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
22521
+ * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22522
+ * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
22523
+ * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
22524
+ * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
22525
+ * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
22526
+ * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
22527
+ * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
22528
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
22529
+ */
22530
+ "".isWellFormed;
22531
+ //#endregion
22532
+ //#region ../../node_modules/.pnpm/smol-toml@1.9.1/node_modules/smol-toml/dist/index.js
22533
+ /*!
22534
+ * Copyright (c) Squirrel Chat et al., All rights reserved.
22535
+ * SPDX-License-Identifier: BSD-3-Clause
22536
+ *
22537
+ * Redistribution and use in source and binary forms, with or without
22538
+ * modification, are permitted provided that the following conditions are met:
22539
+ *
22540
+ * 1. Redistributions of source code must retain the above copyright notice, this
22541
+ * list of conditions and the following disclaimer.
22542
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
22543
+ * this list of conditions and the following disclaimer in the
22544
+ * documentation and/or other materials provided with the distribution.
22545
+ * 3. Neither the name of the copyright holder nor the names of its contributors
22546
+ * may be used to endorse or promote products derived from this software without
22547
+ * specific prior written permission.
22548
+ *
22549
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
22550
+ * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
22551
+ * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22552
+ * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
22553
+ * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
22554
+ * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
22555
+ * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
22556
+ * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
22557
+ * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
22558
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
22559
+ */
22560
+ //#endregion
22561
+ //#region src/harness/codex-checkouts.ts
22562
+ const codexGitFolder = (configFolder, pluginId) => {
22563
+ const at = pluginId.indexOf("@");
22564
+ return `${configFolder}/plugins/cache/${pluginId.slice(at + 1)}/${pluginId.slice(0, at)}/@git`;
22565
+ };
22566
+ const codexCheckoutsFolder = (gitFolder) => `${gitFolder}/checkouts`;
22567
+ /** Parse Git's quoted parameters as data, never shell code, and keep every other `-c`. */
22568
+ const withoutCodexInclude = String.raw`
22569
+ use Text::ParseWords qw(shellwords);
22570
+ my @parameters = shellwords($ENV{GIT_CONFIG_PARAMETERS} // '');
22571
+ my $root = '';
22572
+ @parameters = grep { if (m{^include\.path=(.*/plugins/cache/[^/]+/[^/]+/\@git)/agents/[^/]+\.gitconfig$}) { $root = $1; 0 } else { 1 } } @parameters;
22573
+ if (($ARGV[0] // '') eq 'root') { print $root; exit; }
22574
+ for (@parameters) { s/'/'\\''/g; $_ = "'$_'"; }
22575
+ print join(' ', @parameters);
22576
+ `;
22577
+ /**
22578
+ * Both session start and the sign-off refusal use this writer. Perl's flock works on Linux
22579
+ * and macOS and is released even after a crash. One lock covers reading the shared includes,
22580
+ * writing a checkout's overrides, replacing the includes, and restoring the agent's file.
22581
+ * Git writes the include keys so unusual checkout names can't become config syntax. Its
22582
+ * gitdir conditions escape wildcard characters and cover the main checkout and worktrees.
22583
+ */
22584
+ const codexCheckoutWriter = String.raw`
22585
+ use strict;
22586
+ use warnings;
22587
+ use Fcntl qw(LOCK_EX);
22588
+ use File::Path qw(make_path);
22589
+ use File::Temp qw(tempfile);
22590
+ use Digest::SHA qw(sha256_hex);
22591
+ umask 0077;
22592
+ my ($directory, $common, $hooks, $signs_off, $author_file, $author_text, $clear_author, $activate_author) = @ARGV;
22593
+ make_path($directory);
22594
+ open my $lock, '>>', "$directory/lock" or die "open checkout lock: $!\n";
22595
+ $SIG{ALRM} = sub { die "checkout lock timed out\n" };
22596
+ alarm 5;
22597
+ flock($lock, LOCK_EX) or die "lock checkouts: $!\n";
22598
+ alarm 0;
22599
+ sub quoted {
22600
+ my $value = shift;
22601
+ $value =~ s/\\/\\\\/g;
22602
+ $value =~ s/"/\\"/g;
22603
+ $value =~ s/\n/\\n/g;
22604
+ $value =~ s/\t/\\t/g;
22605
+ $value =~ s/\x08/\\b/g;
22606
+ return qq{"$value"};
22607
+ }
22608
+ sub replace_file {
22609
+ my ($file, $text) = @_;
22610
+ my ($out, $next) = tempfile('.ref0-XXXXXX', DIR => $directory, UNLINK => 1);
22611
+ print {$out} $text or die "write config: $!\n";
22612
+ close $out or die "close config: $!\n";
22613
+ rename $next, $file or die "replace config: $!\n";
22614
+ }
22615
+ my $disabled = "# ref0: author disabled until commit setup succeeds\n";
22616
+ if (defined $clear_author && length $clear_author) {
22617
+ my $text = $author_text // '';
22618
+ if (-e $clear_author) {
22619
+ open my $in, '<', $clear_author or die "read author: $!\n";
22620
+ local $/;
22621
+ $text = <$in>;
22622
+ close $in or die "close author: $!\n";
22623
+ }
22624
+ $text =~ s/^\[author\]\n.*?(?=^\[|\z)//msg;
22625
+ $text = $disabled . $text unless index($text, $disabled) == 0;
22626
+ replace_file($clear_author, $text);
22627
+ exit;
22628
+ }
22629
+ if (length $common) {
22630
+ my $checkout = "$directory/" . sha256_hex($common) . '.gitconfig';
22631
+ my $text = "[core]\n\thooksPath = " . quoted($hooks) . "\n";
22632
+ $text .= "[author]\n\tname = \"\"\n\temail = \"\"\n" if $signs_off;
22633
+ my $includes = "$directory/checkouts.gitconfig";
22634
+ my $before = '';
22635
+ if (-e $includes) {
22636
+ open my $in, '<', $includes or die "read checkouts: $!\n";
22637
+ local $/;
22638
+ $before = <$in>;
22639
+ close $in or die "close checkouts: $!\n";
22640
+ }
22641
+ my ($out, $next) = tempfile('.ref0-XXXXXX', DIR => $directory, UNLINK => 1);
22642
+ print {$out} $before or die "write checkouts: $!\n";
22643
+ close $out or die "close checkouts: $!\n";
22644
+ (my $pattern = $common) =~ s/([\\*?\[])/\\$1/g;
22645
+ for my $gitdir ($pattern, "$pattern/worktrees/") {
22646
+ system('git', 'config', '--file', $next, '--replace-all', "includeIf.gitdir:$gitdir.path", $checkout) == 0
22647
+ or die "write checkout include\n";
22648
+ }
22649
+ replace_file($checkout, $text);
22650
+ rename $next, $includes or die "replace checkouts: $!\n";
22651
+ }
22652
+ if (defined $author_file && length $author_file) {
22653
+ # An in-flight old session cannot restore an author cleared after a failed switch.
22654
+ my $may_write = 1;
22655
+ if (!$activate_author && -e $author_file) {
22656
+ open my $in, '<', $author_file or die "read author: $!\n";
22657
+ my $first = <$in> // '';
22658
+ close $in or die "close author: $!\n";
22659
+ $may_write = 0 if $first eq $disabled;
22660
+ }
22661
+ replace_file($author_file, $author_text) if $may_write;
22662
+ }
22663
+ `;
22664
+ /** The same writer inside a hook, where the next plain commit must use the person as author. */
22665
+ const codexSignOff = [
22666
+ "ref0_common=$(git rev-parse --path-format=absolute --git-common-dir) || exit 1",
22667
+ "ref0_common=$(cd -P \"$ref0_common\" && pwd) || exit 1",
22668
+ "[ -n \"${ref0_codex-}\" ] || exit 1",
22669
+ "ref0_codex_hooks=$ref0_hooks/most",
22670
+ "for ref0_frequent in post-index-change reference-transaction; do",
22671
+ " ref0_own=$(git rev-parse --git-path \"hooks/$ref0_frequent\") || exit 1",
22672
+ " [ ! -x \"$ref0_own\" ] || ref0_codex_hooks=$ref0_hooks/all",
22673
+ "done",
22674
+ `perl -e ${shellWord(codexCheckoutWriter)} "$ref0_codex/checkouts" "$ref0_common" "$ref0_codex_hooks" 1 || exit 1`
22675
+ ];
22676
+ //#endregion
22677
+ //#region src/harness/empty-folder.ts
22678
+ /**
22679
+ * The folder one of a harness's own commands runs in: made empty for it in the system's temporary
22680
+ * folder, and removed once the command's scope ends. ref0 never runs one in the folder the person
22681
+ * runs ref0 in, and names the config folder only by `CLAUDE_CONFIG_DIR` or `CODEX_HOME`.
22682
+ *
22683
+ * Measured 24 Oct 2026. Claude Code 2.1.310's `plugin marketplace remove` also clears the name
22684
+ * from `.claude/settings.json` and `.claude/settings.local.json` in the folder it runs in, and
22685
+ * `plugin list` reads them. In the home folder, the first is `~/.claude`'s own settings: removing
22686
+ * ref0's marketplace from `~/.claude-work` there turned ref0's plugin off in `~/.claude`. Codex
22687
+ * 0.164.0 reads a trusted project's `.codex/` in each folder from its own up to the Git root: from
22688
+ * a folder under a home kept in Git, such as `~/.ref0`, it reads `~/.codex` as that project's too.
22689
+ */
22690
+ const emptyFolder = (fs) => fs.makeTempDirectoryScoped({ prefix: "ref0-harness-" });
22691
+ //#endregion
22692
+ //#region src/harness/codex-app-server.ts
22693
+ /**
22694
+ * Codex's app server, spoken as its TUI speaks it: `codex app-server` started for one exchange,
22695
+ * from an empty folder of its own, the requests written a line each, the answers read until each
22696
+ * request has one, then stopped. Each answer is matched to its request by ID, and read by that
22697
+ * request's method. Anything else it writes, such as notifications, or an answer to an ID ref0
22698
+ * didn't send, is passed over.
22699
+ */
22700
+ /** Codex's app server didn't answer a request within 15 seconds, or didn't start. */
22701
+ var CodexUnanswered = class extends Schema.TaggedError()("CodexUnanswered", { method: Schema.String }) {};
22702
+ /** Codex's app server answered a request with an error. */
22703
+ var CodexRefused = class extends Schema.TaggedError()("CodexRefused", {
22704
+ method: Schema.String,
22705
+ error: CodexError
22706
+ }) {};
22707
+ /** Codex's app server answered a request with a result its method's schema doesn't read. */
22708
+ var CodexAnswerMismatch = class extends Schema.TaggedError()("CodexAnswerMismatch", {
22709
+ method: Schema.String,
22710
+ issue: Schema.String
22711
+ }) {};
22712
+ const encodeRequest = Schema.encodeUnknownEffect(Schema.fromJsonString(CodexRequest));
22713
+ const decodeAnswer$1 = Schema.decodeUnknownOption(Schema.fromJsonString(CodexAnswer));
22714
+ /** The IDs of the exchange's two requests: the opening's, and the one asked. */
22715
+ const initializeId = "ref0-initialize";
22716
+ const askedId = "ref0-asked";
22717
+ /** The exchange's opening: who's asking, then that it's ready. */
22718
+ const opening = [{
22719
+ jsonrpc: "2.0",
22720
+ id: initializeId,
22721
+ method: codexMethods.initialize.method,
22722
+ params: { clientInfo: {
22723
+ name: "ref0",
22724
+ title: null,
22725
+ version: cliVersion
22726
+ } }
22727
+ }, {
22728
+ jsonrpc: "2.0",
22729
+ method: codexMethods.initialized.method
22730
+ }];
22731
+ /**
22732
+ * Codex's answers to `lines`, by ID, or none if it didn't answer each of `ids`. Each ID's first
22733
+ * answer is its own: a repeat, or an answer to an ID ref0 didn't send, is passed over.
22734
+ */
22735
+ const exchange = (codex, lines, ids, env) => Effect.scoped(Effect.gen(function* () {
22736
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
22737
+ const fs = yield* FileSystem.FileSystem;
22738
+ const answered = yield* (yield* spawner.spawn(ChildProcess.make(codex, ["app-server"], {
22739
+ cwd: yield* emptyFolder(fs),
22740
+ env,
22741
+ extendEnv: true,
22742
+ forceKillAfter: "2 seconds",
22743
+ stdin: {
22744
+ stream: Stream.fromIterable(lines.map((line) => `${line}\n`)).pipe(Stream.encodeText),
22745
+ endOnDone: false
22746
+ }
22747
+ }))).stdout.pipe(Stream.decodeText(), Stream.splitLines, Stream.map((line) => decodeAnswer$1(line)), Stream.filter(Option.isSome), Stream.scan(() => /* @__PURE__ */ new Map(), (answers, { value: answer }) => ids.includes(answer.id) && !answers.has(answer.id) ? new Map(answers).set(answer.id, answer) : answers), Stream.takeUntil((answers) => answers.size === ids.length), Stream.runLast);
22748
+ return Option.filter(answered, (answers) => answers.size === ids.length);
22749
+ })).pipe(Effect.timeoutOption("15 seconds"), Effect.map(Option.flatten), Effect.catchCause(() => Effect.succeed(Option.none())));
22750
+ /** `answer`'s result, read by `method`'s own schema, or why it can't be. */
22751
+ const resultOf = (method, answer) => {
22752
+ if (answer === void 0) return Effect.fail(new CodexUnanswered({ method: method.method }));
22753
+ if ("error" in answer) return Effect.fail(new CodexRefused({
22754
+ method: method.method,
22755
+ error: answer.error
22756
+ }));
22757
+ const { result } = method;
22758
+ return Schema.decodeUnknownEffect(result)(answer.result).pipe(Effect.mapError((error) => new CodexAnswerMismatch({
22759
+ method: method.method,
22760
+ issue: error.message
22761
+ })));
22762
+ };
22763
+ /**
22764
+ * One exchange with Codex's app server under the person's Codex home: the opening, then `method`
22765
+ * asked with `params`, which TypeScript holds to the method's own. Its answer is the one with
22766
+ * its request's ID, and its result is read by the method's result schema.
22767
+ */
22768
+ const askCodex = (codex, method, params, env = {}) => Effect.gen(function* () {
22769
+ const asked = {
22770
+ jsonrpc: "2.0",
22771
+ id: askedId,
22772
+ method: method.method,
22773
+ params
22774
+ };
22775
+ const lines = yield* Effect.forEach([...opening, asked], (request) => encodeRequest(request).pipe(Effect.orDie));
22776
+ const answers = yield* exchange(codex, lines, [initializeId, askedId], env);
22777
+ if (Option.isNone(answers)) return yield* new CodexUnanswered({ method: method.method });
22778
+ yield* resultOf(codexMethods.initialize, answers.value.get(initializeId));
22779
+ return yield* resultOf(method, answers.value.get(askedId));
22780
+ });
22781
+ //#endregion
22782
+ //#region src/harness/commit-hooks.ts
22783
+ /**
22784
+ * ref0's part of Git's `prepare-commit-msg` and `commit-msg` hooks, which their forwarders in
22785
+ * ref0's hooks folder run once the repo's own hook succeeds, and only in an agent's commands,
22786
+ * where `REF0_HARNESS` is set. A commit anywhere else is unchanged.
22787
+ *
22788
+ * The trailer, `Assisted-by: <harness>:<model>`, goes on once with `git interpret-trailers`:
22789
+ * `prepare-commit-msg` adds it to a message the command or Git wrote, and `commit-msg` to one
22790
+ * written in an editor, or again if an editor removed it, since Git runs `prepare-commit-msg`
22791
+ * before the editor and `commit-msg` after. Neither adds it to a message Git refuses as empty, or
22792
+ * to `commit.template` left as it was, which Git would otherwise commit. Two commits made in an
22793
+ * editor are beyond them: one with `--no-verify`, which skips `commit-msg`, gets no trailer, and
22794
+ * a template from `-t`, which `commit-msg` can't see, gets it even when left as it was. The model
22795
+ * is the last one the session's transcript names, read from its end in `sh`, with no Node
22796
+ * process, or else the one the session started with. Both are as the harness reports them, never
22797
+ * proven.
22798
+ *
22799
+ * The refusal: a commit whose author is an agent's commit email and whose message has a
22800
+ * `Signed-off-by`. A sign-off must match its commit's author, and an agent never signs off.
22801
+ * `prepare-commit-msg` catches `-s` and `-m`, which `--no-verify` doesn't skip, and `commit-msg`
22802
+ * a sign-off written in an editor, unless `--no-verify` skips it.
22803
+ *
22804
+ * Only the refusal stops a commit: when Git can't say what ref0's part needs, such as a Git older
22805
+ * than 2.20 without `--no-divider`, the commit goes on without the trailer.
22806
+ *
22807
+ * Neither part runs while Git replays commits, in a rebase or a cherry-pick: Git makes those
22808
+ * commits itself with their own authors, and these hooks see the command's author instead (Git
22809
+ * 2.53, 23 Oct 2026). Nor does the trailer go on an amend that keeps another's commit: `git
22810
+ * commit` gives its hooks the author it commits with, and an amend keeps HEAD's, to the second.
22811
+ * So a new commit by HEAD's author in HEAD's second gets none either.
22812
+ */
22813
+ /** The hooks ref0 adds its part to, after the repo's own. */
22814
+ const commitHooks = ["prepare-commit-msg", "commit-msg"];
22815
+ /** Whether a hook is one ref0 adds its part to. */
22816
+ const isCommitHook = (name) => commitHooks.some((hook) => hook === name);
22817
+ /** The trailer's key, as the Linux kernel writes it. */
22818
+ const assistedBy = "Assisted-by";
22819
+ /** How much of the transcript's end the hook reads for its last model: well past its last turn. */
22820
+ const transcriptTail = 1048576;
22821
+ /**
22822
+ * Git's trailer tool, reading and writing `:` alone whatever the repo's `trailer.separators`
22823
+ * says, since Git writes a sign-off with `:`.
22824
+ */
22825
+ const trailers = "git -c trailer.separators=: interpret-trailers --no-divider";
22826
+ /**
22827
+ * Whether its input says anything, as Git decides whether a message is empty: with a line that's
22828
+ * neither blank nor a sign-off.
22829
+ */
22830
+ const saysSomething = "grep -qv -e '^[[:space:]]*$' -e '^Signed-off-by: '";
22831
+ /** How the agent commits again as the person, which the refusal prints. */
22832
+ const asThePerson = "env -u GIT_AUTHOR_NAME -u GIT_AUTHOR_EMAIL git commit -s …";
22833
+ /** What the refusal prints, for a commit by `author` that the person `person` should author. */
22834
+ const signOffRefusal = (author, person) => `ref0: this commit has a Signed-off-by, and its author is ${author}, an agent. A sign-off must match its commit's author, and an agent never signs off, so commit again with ${person} as the author, adding --reset-author to an amend:\n ${asThePerson}\n`;
22835
+ /** ref0's part of `hook`, run once the repo's own hook has succeeded. */
22836
+ const commitPart = (hook) => {
22837
+ const { harness, transcript, model } = gitHookVariables;
22838
+ return [
22839
+ "ref0_nl='",
22840
+ "'",
22841
+ `[ -n "\${${harness}-}" ] || exit 0`,
22842
+ `case $${harness} in *[!a-z0-9-]*) exit 0 ;; esac`,
22843
+ "ref0_git=$(git rev-parse --git-dir) || exit 0",
22844
+ "[ -d \"$ref0_git/rebase-merge\" ] || [ -d \"$ref0_git/rebase-apply\" ] || [ -f \"$ref0_git/CHERRY_PICK_HEAD\" ] && exit 0",
22845
+ `ref0_trailers=$(${trailers} --parse "$1") || exit 0`,
22846
+ "case ${GIT_AUTHOR_EMAIL-} in",
22847
+ `*@${agentCommitDomain})`,
22848
+ " if printf '%s\\n' \"$ref0_trailers\" | grep -qi '^signed-off-by:'; then",
22849
+ " ref0_person=$(git var GIT_COMMITTER_IDENT)",
22850
+ ` if [ "$${harness}" = codex ]; then`,
22851
+ ...codexSignOff.map((line) => ` ${line}`),
22852
+ " printf '%s\\n' 'ref0: this checkout now uses the person as author. Run the same git commit -s again; add --reset-author to an amend.' >&2",
22853
+ " else",
22854
+ ` printf ${shellWord(signOffRefusal("%s", "%s"))} "\${GIT_AUTHOR_NAME-} <$GIT_AUTHOR_EMAIL>" "\${ref0_person% * *}" >&2`,
22855
+ " fi",
22856
+ " exit 1",
22857
+ " fi",
22858
+ " ;;",
22859
+ "*)",
22860
+ " if [ -n \"${GIT_AUTHOR_DATE-}\" ]; then",
22861
+ ` ref0_head=$(git log -1 --no-show-signature --date=raw --format='%(trailers:key=${assistedBy},valueonly)%n%an <%ae> %ad' HEAD 2>/dev/null)`,
22862
+ " [ \"${ref0_head##*\"$ref0_nl\"}\" = \"${GIT_AUTHOR_NAME-} <${GIT_AUTHOR_EMAIL-}> ${GIT_AUTHOR_DATE#@}\" ] && [ -z \"${ref0_head%\"$ref0_nl\"*}\" ] && exit 0",
22863
+ " fi",
22864
+ " ;;",
22865
+ "esac",
22866
+ ...hook === "prepare-commit-msg" ? ["case ${2-} in ''|template) exit 0 ;; esac"] : [],
22867
+ `case "$ref0_nl$ref0_trailers$ref0_nl" in *"\${ref0_nl}${assistedBy}: $${harness}:"*|*"\${ref0_nl}${assistedBy}: $${harness}$ref0_nl"*) exit 0 ;; esac`,
22868
+ ...hook === "prepare-commit-msg" ? [`${saysSomething} "$1" || exit 0`] : [
22869
+ "ref0_message=$(git stripspace --strip-comments < \"$1\")",
22870
+ `printf '%s\\n' "$ref0_message" | ${saysSomething} || exit 0`,
22871
+ `ref0_template=$(git config --path commit.template) && ref0_template=$(git stripspace --strip-comments < "$ref0_template" 2>/dev/null) && case $ref0_message in "$ref0_template"*) printf '%s\\n' "\${ref0_message#"$ref0_template"}" | ${saysSomething} || exit 0 ;; esac`
22872
+ ],
22873
+ "ref0_model=",
22874
+ `if [ "$${harness}" = codex ]; then`,
22875
+ " case ${CODEX_THREAD_ID-} in \"\"|*[!a-zA-Z0-9-]*) ;; *)",
22876
+ " ref0_rollout=$(find \"${CODEX_HOME:-\"$HOME/.codex\"}/sessions\" -type f -name \"rollout-*-${CODEX_THREAD_ID}.jsonl\" 2>/dev/null | head -n 1)",
22877
+ ` [ -f "$ref0_rollout" ] && ref0_model=$(tail -c ${transcriptTail} "$ref0_rollout" | LC_ALL=C grep '"type"[[:space:]]*:[[:space:]]*"turn_context"' | grep -o '"model"[[:space:]]*:[[:space:]]*"[A-Za-z0-9][A-Za-z0-9._:@/-]*"' | tail -n 1 | sed 's/^[^:]*:[[:space:]]*"//; s/"$//')`,
22878
+ " ;; esac",
22879
+ "else",
22880
+ `[ -f "\${${transcript}-}" ] && ref0_model=$(tail -c ${transcriptTail} "$${transcript}" | LC_ALL=C grep -o '"message":{"model":"[A-Za-z0-9][A-Za-z0-9._:@/-]*"')`,
22881
+ "ref0_model=${ref0_model##*\\\"model\\\":\\\"}",
22882
+ "ref0_model=${ref0_model%\\\"}",
22883
+ `[ -n "$ref0_model" ] || ref0_model=\${${model}-}`,
22884
+ "fi",
22885
+ "case $ref0_model in *[!A-Za-z0-9._:@/-]*) ref0_model= ;; esac",
22886
+ `${trailers} --in-place --if-exists addIfDifferent --trailer "${assistedBy}: $${harness}\${ref0_model:+:$ref0_model}" "$1"`,
22887
+ "exit 0"
22888
+ ];
22889
+ };
22890
+ //#endregion
22891
+ //#region src/harness/git-hooks.ts
22892
+ /**
22893
+ * ref0's Git hooks: folders of forwarders in `~/.ref0/origins/<origin>/git-hooks`, which ref0 names as
22894
+ * `core.hooksPath` in its agents' commands alone, never in a repo, its config or its hooks.
22895
+ * `core.hooksPath` replaces a repo's whole hooks folder, so each forwarder runs the repo's own
22896
+ * hook of its name, with the same arguments, stdin and exit status, wherever the repo keeps it:
22897
+ * husky's `.husky/_`, `.git/hooks/` or another `core.hooksPath`. It finds and runs that hook
22898
+ * with ref0's entry left out of its environment, so a hook that looks up another, as
22899
+ * `pre-applypatch.sample` runs `pre-commit`, finds the repo's own. A forwarder runs nothing
22900
+ * when the repo's hooks path is one of ref0's folders, or when a forwarder started it, so none
22901
+ * calls itself: a first version that left ref0's entry in started about 1,000 processes. Only
22902
+ * `prepare-commit-msg` and `commit-msg` do more: once the repo's own hook succeeds, they add
22903
+ * ref0's part, in `commit-hooks.ts`.
22904
+ *
22905
+ * Git runs a hook that exists even where the repo has none, so the forwarders cost each hook one
22906
+ * `sh` and one `git rev-parse`, about 1.5 ms. Two hooks run on every index write and ref update,
22907
+ * hundreds of times in a long rebase, so only `all` forwards them, for a checkout that has its
22908
+ * own; `most` forwards every other (measured 23 Oct 2026, in the piece's pull request).
22909
+ *
22910
+ * The folders are the CLI's: a session's start writes them again when a forwarder differs, as
22911
+ * after an update. Each is read-only, so a tool that installs hooks wherever `core.hooksPath`
22912
+ * points, run in an agent's command, fails there rather than replacing a forwarder for every
22913
+ * repo.
22914
+ */
22915
+ /** The hooks Git runs on every index write and ref update. */
22916
+ const frequentHooks = ["post-index-change", "reference-transaction"];
22917
+ /**
22918
+ * Every other hook Git looks for in its hooks path. Not `push-to-checkout`, which replaces Git's
22919
+ * own update of a checkout pushed to merely by existing, nor `fsmonitor-watchman`, which Git runs
22920
+ * from `core.fsmonitor`, never from the hooks path.
22921
+ */
22922
+ const forwardedHooks = [
22923
+ "applypatch-msg",
22924
+ "pre-applypatch",
22925
+ "post-applypatch",
22926
+ "pre-commit",
22927
+ "pre-merge-commit",
22928
+ "prepare-commit-msg",
22929
+ "commit-msg",
22930
+ "post-commit",
22931
+ "pre-rebase",
22932
+ "post-checkout",
22933
+ "post-merge",
22934
+ "pre-push",
22935
+ "pre-receive",
22936
+ "update",
22937
+ "proc-receive",
22938
+ "post-receive",
22939
+ "post-update",
22940
+ "pre-auto-gc",
22941
+ "post-rewrite",
22942
+ "sendemail-validate",
22943
+ "p4-changelist",
22944
+ "p4-prepare-changelist",
22945
+ "p4-post-changelist",
22946
+ "p4-pre-submit"
22947
+ ];
22948
+ /**
22949
+ * What finds and runs the repo's own hook `name`, with ref0's entry left out: an entry naming one
22950
+ * of ref0's folders, in `$ref0_hooks`, is ref0's, and the rest keep their order, renumbered.
22951
+ * `done` ends it with nothing to run, `failed` when Git can't say where the hook is, and `run`
22952
+ * runs the hook it found.
22953
+ */
22954
+ const forwarding = (name, end) => [
22955
+ `[ -n "\${${gitHookVariables.forwarded}-}" ] && ${end.done}`,
22956
+ "case ${GIT_CONFIG_PARAMETERS-} in *'/@git/agents/'*)",
22957
+ ` ref0_codex=$(perl -e ${shellWord(withoutCodexInclude)} root) || ${end.failed}`,
22958
+ ` GIT_CONFIG_PARAMETERS=$(perl -e ${shellWord(withoutCodexInclude)}) || ${end.failed}`,
22959
+ " if [ -z \"$GIT_CONFIG_PARAMETERS\" ]; then unset GIT_CONFIG_PARAMETERS; else export GIT_CONFIG_PARAMETERS; fi",
22960
+ ";; esac",
22961
+ "ref0_n=${GIT_CONFIG_COUNT:-0}",
22962
+ "ref0_i=0",
22963
+ "ref0_j=0",
22964
+ "while [ \"$ref0_i\" -lt \"$ref0_n\" ]; do",
22965
+ " eval \"ref0_key=\\${GIT_CONFIG_KEY_$ref0_i-} ref0_value=\\${GIT_CONFIG_VALUE_$ref0_i-}\"",
22966
+ " unset \"GIT_CONFIG_KEY_$ref0_i\" \"GIT_CONFIG_VALUE_$ref0_i\"",
22967
+ " if [ \"$ref0_key\" != core.hooksPath ] || [ \"${ref0_value%/*}\" != \"$ref0_hooks\" ]; then",
22968
+ " export \"GIT_CONFIG_KEY_$ref0_j=$ref0_key\" \"GIT_CONFIG_VALUE_$ref0_j=$ref0_value\"",
22969
+ " ref0_j=$((ref0_j + 1))",
22970
+ " fi",
22971
+ " ref0_i=$((ref0_i + 1))",
22972
+ "done",
22973
+ "if [ \"$ref0_j\" -eq 0 ]; then unset GIT_CONFIG_COUNT; else export GIT_CONFIG_COUNT=\"$ref0_j\"; fi",
22974
+ `ref0_hook=$(git rev-parse --git-path hooks/${name}) || ${end.failed}`,
22975
+ `[ -f "$ref0_hook" ] && [ -x "$ref0_hook" ] || ${end.done}`,
22976
+ `ref0_dir=$(cd -P "\${ref0_hook%/*}" && pwd) || ${end.failed}`,
22977
+ `[ "\${ref0_dir%/*}" = "$ref0_hooks" ] && ${end.done}`,
22978
+ ...end.run
22979
+ ];
22980
+ /**
22981
+ * The forwarder for one hook, in ref0's folders in `hooks`. Each `exec`s the repo's own hook,
22982
+ * but `prepare-commit-msg` and `commit-msg`, which run it and add ref0's part once it succeeds,
22983
+ * even when a forwarder started them and they forward nothing.
22984
+ */
22985
+ const forwarder = (name, hooks) => [
22986
+ "#!/bin/sh",
22987
+ `# ref0 ${cliVersion}'s forwarder for Git's ${name} hook. ref0 sets core.hooksPath to this`,
22988
+ "# folder only in its agents' commands. This runs the repo's own hook, found and run with",
22989
+ "# ref0's entry left out, with the same arguments, stdin and exit status.",
22990
+ `ref0_hooks=${shellWord(hooks)}`,
22991
+ ...isCommitHook(name) ? [
22992
+ "# Then, in an agent's commands, it adds the Assisted-by trailer and refuses a sign-off on an",
22993
+ "# agent's commit.",
22994
+ "ref0_forward() {",
22995
+ ...forwarding(name, {
22996
+ done: "return 0",
22997
+ failed: "return",
22998
+ run: [`${gitHookVariables.forwarded}=${name} "$ref0_hook" "$@"`]
22999
+ }).map((line) => ` ${line}`),
23000
+ "}",
23001
+ "ref0_forward \"$@\" || exit",
23002
+ ...commitPart(name)
23003
+ ] : forwarding(name, {
23004
+ done: "exit 0",
23005
+ failed: "exit",
23006
+ run: [`export ${gitHookVariables.forwarded}=${name}`, "exec \"$ref0_hook\" \"$@\""]
23007
+ }),
23008
+ ""
23009
+ ].join("\n");
23010
+ /** The hooks each folder forwards. */
23011
+ const hooksIn = (folder) => folder === "all" ? [...forwardedHooks, ...frequentHooks] : forwardedHooks;
23012
+ /** The folder holding ref0's hooks folders, made if it isn't yet: its path, through any links. */
23013
+ const gitHooksHome = Effect.fnUntraced(function* (home, api) {
23014
+ const fs = yield* FileSystem.FileSystem;
23015
+ const given = (yield* Path.Path).join(home, ".ref0", "origins", originKey(api), "git-hooks");
23016
+ yield* fs.makeDirectory(given, {
23017
+ recursive: true,
23018
+ mode: 448
23019
+ });
23020
+ return yield* fs.realPath(given);
23021
+ });
23022
+ /**
23023
+ * One of ref0's hooks folders, in `hooks`, written first unless every forwarder in it is already
23024
+ * as this CLI writes it: its path, as the agent's `core.hooksPath` names it.
23025
+ */
23026
+ const hooksFolder = Effect.fnUntraced(function* (hooks, folder) {
23027
+ const fs = yield* FileSystem.FileSystem;
23028
+ const path = yield* Path.Path;
23029
+ const crypto = yield* Crypto.Crypto;
23030
+ const written = path.join(hooks, folder);
23031
+ const names = hooksIn(folder);
23032
+ const current = Effect.forEach(names, (name) => fs.readFileString(path.join(written, name)).pipe(Effect.map((text) => text === forwarder(name, hooks)), Effect.orElseSucceed(() => false))).pipe(Effect.map((each) => each.every(Boolean)));
23033
+ if (yield* current) return written;
23034
+ yield* Effect.gen(function* () {
23035
+ yield* fs.makeDirectory(written, { recursive: true });
23036
+ yield* fs.chmod(written, 493);
23037
+ for (const name of names) {
23038
+ const next = path.join(written, `${name}.${yield* crypto.randomUUIDv4}.next`);
23039
+ yield* fs.writeFileString(next, forwarder(name, hooks), { mode: 365 });
23040
+ yield* fs.rename(next, path.join(written, name));
23041
+ }
23042
+ yield* fs.chmod(written, 365);
23043
+ }).pipe(Effect.catch((error) => Effect.flatMap(current, (done) => done ? Effect.void : Effect.fail(error))));
23044
+ return written;
23045
+ });
23046
+ //#endregion
23047
+ //#region src/harness/codex-commits.ts
23048
+ /**
23049
+ * `@git` isn't a valid Codex plugin version. Version selection and upgrade skip it, while
23050
+ * `plugin remove` deletes it with the plugin's cache root (Codex 0.160–0.164). A same-version
23051
+ * reinstall replaces the root; session start restores these files before the first command.
23052
+ * Only `checkouts` is writable in ref0's permission profile, never the agents' author files.
23053
+ */
23054
+ /** A Git config value, including paths with quotes, backslashes or line breaks. */
23055
+ const gitValue = (value) => `"${value.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"").replaceAll("\n", "\\n").replaceAll(" ", "\\t").replaceAll("\b", "\\b")}"`;
23056
+ var CodexCommitFailed = class extends Schema.TaggedError()("CodexCommitFailed", { detail: Schema.String }) {};
23057
+ /** Writes a checkout's overrides and, at session start, the agent's include under the same lock. */
23058
+ const writeCodexCheckout = Effect.fnUntraced(function* (input) {
23059
+ if ((yield* (yield* ChildProcessSpawner.ChildProcessSpawner).exitCode(ChildProcess.make("perl", [
23060
+ "-e",
23061
+ codexCheckoutWriter,
23062
+ codexCheckoutsFolder(input.gitFolder),
23063
+ input.common,
23064
+ input.folder,
23065
+ input.signsOff ? "1" : "0",
23066
+ input.author?.file ?? "",
23067
+ input.author?.text ?? "",
23068
+ input.clearAuthor ?? "",
23069
+ input.activateAuthor ? "1" : ""
23070
+ ]))) !== ChildProcessSpawner.ExitCode(0)) return yield* new CodexCommitFailed({ detail: "Could not write Codex’s Git config files." });
23071
+ });
23072
+ /** The include installed into `set`, and restored by each session's start. */
23073
+ const codexAgentConfig = Effect.fnUntraced(function* (agent, root, home) {
23074
+ const fs = yield* FileSystem.FileSystem;
23075
+ const directory = `${root}/agents`;
23076
+ yield* fs.makeDirectory(directory, {
23077
+ recursive: true,
23078
+ mode: 448
23079
+ });
23080
+ const folder = yield* hooksFolder(yield* gitHooksHome(home, (yield* Ref0Config).api), "most");
23081
+ yield* hooksFolder(yield* gitHooksHome(home, (yield* Ref0Config).api), "all");
23082
+ const author = `[author]\n\tname = ${gitValue(agentCommitName(agent.owner, agent.agent))}\n\temail = ${gitValue(agentCommitEmail(agent.agentId))}\n`;
23083
+ const file = `${directory}/${agent.agentId}.gitconfig`;
23084
+ return {
23085
+ file,
23086
+ text: `${author}[core]\n\thooksPath = ${gitValue(folder)}\n[include]\n\tpath = ${gitValue(`${codexCheckoutsFolder(root)}/checkouts.gitconfig`)}\n`,
23087
+ folder,
23088
+ parameters: shellWord(`include.path=${file}`)
23089
+ };
23090
+ });
23091
+ /** Codex has no environment file: its install selects a file; session start fills its checkout. */
23092
+ const setCodexCommitEnvironment = Effect.fnUntraced(function* (cwd, checkout) {
23093
+ const folders = yield* ConfigFolders;
23094
+ const fs = yield* FileSystem.FileSystem;
23095
+ const path = yield* Path.Path;
23096
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
23097
+ const agent = yield* (yield* HostGrant).get(yield* folders.folder("codex"));
23098
+ if (Option.isNone(agent)) return false;
23099
+ const pluginRoot = yield* Config.String("CLAUDE_PLUGIN_ROOT").pipe(Config.withDefault(""));
23100
+ if (pluginRoot === "") return false;
23101
+ const gitFolder = path.join(path.dirname(pluginRoot), "@git");
23102
+ const home = yield* Config.String("HOME");
23103
+ const author = yield* codexAgentConfig(agent.value, gitFolder, home);
23104
+ const common = (yield* spawner.string(ChildProcess.make("git", [
23105
+ "-C",
23106
+ cwd,
23107
+ "rev-parse",
23108
+ "--path-format=absolute",
23109
+ "--git-common-dir"
23110
+ ])).pipe(Effect.orElseSucceed(() => ""))).trim();
23111
+ const folder = yield* hooksFolder(yield* gitHooksHome(yield* Config.String("HOME"), (yield* Ref0Config).api), checkout.folder);
23112
+ yield* writeCodexCheckout({
23113
+ gitFolder,
23114
+ common: common === "" ? "" : yield* fs.realPath(path.resolve(common)),
23115
+ folder,
23116
+ signsOff: checkout.signsOff,
23117
+ author
23118
+ });
23119
+ return true;
23120
+ });
23121
+ //#endregion
23122
+ //#region src/harness/hook-fields.ts
23123
+ /**
23124
+ * The same for Codex 0.160.0, which skips a hook outright if its template names a field the event
23125
+ * lacks, so each list holds only fields every hook of its event carried in the prototypes, and
23126
+ * only events they saw, each a `HookEvent`. Codex puts the model in every hook but `SessionEnd`.
23127
+ * Its `UserPromptSubmit` carries the prompt (`spikes/harness/results/codex/catalog.json`), which
23128
+ * no list names, as for Claude Code.
21626
23129
  * Every Codex hook carries `transcript_path`, the session's rollout, `null` for a session kept
21627
23130
  * nowhere (Codex's hook input schemas, 0.160.0 to 0.163.0): its turns' tokens are read there.
21628
23131
  * A subagent's are read from its own rollout, which its `SubagentStop` names, with the
@@ -21828,6 +23331,7 @@ const marketplaceFor = Effect.fnUntraced(function* (harness, options) {
21828
23331
  const failed = (reason, detail = "") => into === void 0 ? unavailable : new InstallFailed({ says: `${name} ${reason}.${detail === "" ? "" : ` ${detail}`}` });
21829
23332
  return Effect.scoped(Effect.gen(function* () {
21830
23333
  const handle = yield* spawner.spawn(ChildProcess.make(options.command, args, {
23334
+ cwd: yield* emptyFolder(fs),
21831
23335
  env: into === void 0 ? fetchEnv : envFor(into),
21832
23336
  extendEnv: true
21833
23337
  }));
@@ -21919,119 +23423,241 @@ const marketplaceFor = Effect.fnUntraced(function* (harness, options) {
21919
23423
  }
21920
23424
  return root;
21921
23425
  }).pipe(Effect.catchTags({
21922
- PlatformError: () => Effect.fail(unavailable),
21923
- SchemaError: () => Effect.fail(unavailable)
21924
- }));
21925
- const scoped = yield* keepOriginPlugin(folder, options.home).pipe(Effect.mapError(() => unavailable));
21926
- const into = Effect.fnUntraced(function* (configFolder) {
21927
- if (harness === "codex") yield* prepare(configFolder);
21928
- const runIn = (args) => run(args, configFolder);
21929
- yield* runIn([
21930
- "plugin",
21931
- "marketplace",
21932
- "remove",
21933
- scoped.name
21934
- ]).pipe(Effect.ignore);
21935
- yield* runIn([
21936
- "plugin",
21937
- "marketplace",
21938
- "add",
21939
- scoped.folder
21940
- ]);
21941
- return runIn;
21942
- });
21943
- return {
21944
- folder: scoped.folder,
21945
- name: scoped.name,
21946
- id: scoped.id,
21947
- into
21948
- };
21949
- });
21950
- //#endregion
21951
- //#region src/harness/codex-app-server.ts
21952
- /**
21953
- * Codex's app server, spoken as its TUI speaks it: `codex app-server` started for one exchange,
21954
- * the requests written a line each, the answers read until each request has one, then stopped.
21955
- * Each answer is matched to its request by ID, and read by that request's method. Anything else
21956
- * it writes, such as notifications, or an answer to an ID ref0 didn't send, is passed over.
21957
- */
21958
- /** Codex's app server didn't answer a request within 15 seconds, or didn't start. */
21959
- var CodexUnanswered = class extends Schema.TaggedError()("CodexUnanswered", { method: Schema.String }) {};
21960
- /** Codex's app server answered a request with an error. */
21961
- var CodexRefused = class extends Schema.TaggedError()("CodexRefused", {
21962
- method: Schema.String,
21963
- error: CodexError
21964
- }) {};
21965
- /** Codex's app server answered a request with a result its method's schema doesn't read. */
21966
- var CodexAnswerMismatch = class extends Schema.TaggedError()("CodexAnswerMismatch", {
21967
- method: Schema.String,
21968
- issue: Schema.String
21969
- }) {};
21970
- const encodeRequest = Schema.encodeUnknownEffect(Schema.fromJsonString(CodexRequest));
21971
- const decodeAnswer$1 = Schema.decodeUnknownOption(Schema.fromJsonString(CodexAnswer));
21972
- /** The IDs of the exchange's two requests: the opening's, and the one asked. */
21973
- const initializeId = "ref0-initialize";
21974
- const askedId = "ref0-asked";
21975
- /** The exchange's opening: who's asking, then that it's ready. */
21976
- const opening = [{
21977
- jsonrpc: "2.0",
21978
- id: initializeId,
21979
- method: codexMethods.initialize.method,
21980
- params: { clientInfo: {
21981
- name: "ref0",
21982
- title: null,
21983
- version: cliVersion
21984
- } }
21985
- }, {
21986
- jsonrpc: "2.0",
21987
- method: codexMethods.initialized.method
21988
- }];
21989
- /**
21990
- * Codex's answers to `lines`, by ID, or none if it didn't answer each of `ids`. Each ID's first
21991
- * answer is its own: a repeat, or an answer to an ID ref0 didn't send, is passed over.
21992
- */
21993
- const exchange = (codex, lines, ids, configFolder) => Effect.scoped(Effect.gen(function* () {
21994
- const answered = yield* (yield* (yield* ChildProcessSpawner.ChildProcessSpawner).spawn(ChildProcess.make(codex, ["app-server"], {
21995
- env: configFolder === void 0 ? void 0 : { CODEX_HOME: configFolder },
21996
- extendEnv: true,
21997
- forceKillAfter: "2 seconds",
21998
- stdin: {
21999
- stream: Stream.fromIterable(lines.map((line) => `${line}\n`)).pipe(Stream.encodeText),
22000
- endOnDone: false
22001
- }
22002
- }))).stdout.pipe(Stream.decodeText(), Stream.splitLines, Stream.map((line) => decodeAnswer$1(line)), Stream.filter(Option.isSome), Stream.scan(() => /* @__PURE__ */ new Map(), (answers, { value: answer }) => ids.includes(answer.id) && !answers.has(answer.id) ? new Map(answers).set(answer.id, answer) : answers), Stream.takeUntil((answers) => answers.size === ids.length), Stream.runLast);
22003
- return Option.filter(answered, (answers) => answers.size === ids.length);
22004
- })).pipe(Effect.timeoutOption("15 seconds"), Effect.map(Option.flatten), Effect.catchCause(() => Effect.succeed(Option.none())));
22005
- /** `answer`'s result, read by `method`'s own schema, or why it can't be. */
22006
- const resultOf = (method, answer) => {
22007
- if (answer === void 0) return Effect.fail(new CodexUnanswered({ method: method.method }));
22008
- if ("error" in answer) return Effect.fail(new CodexRefused({
22009
- method: method.method,
22010
- error: answer.error
22011
- }));
22012
- const { result } = method;
22013
- return Schema.decodeUnknownEffect(result)(answer.result).pipe(Effect.mapError((error) => new CodexAnswerMismatch({
22014
- method: method.method,
22015
- issue: error.message
22016
- })));
22017
- };
22018
- /**
22019
- * One exchange with Codex's app server under the person's Codex home: the opening, then `method`
22020
- * asked with `params`, which TypeScript holds to the method's own. Its answer is the one with
22021
- * its request's ID, and its result is read by the method's result schema.
22022
- */
22023
- const askCodex = (codex, method, params, configFolder) => Effect.gen(function* () {
22024
- const asked = {
22025
- jsonrpc: "2.0",
22026
- id: askedId,
22027
- method: method.method,
22028
- params
23426
+ PlatformError: () => Effect.fail(unavailable),
23427
+ SchemaError: () => Effect.fail(unavailable)
23428
+ }));
23429
+ const scoped = yield* keepOriginPlugin(folder, options.home).pipe(Effect.mapError(() => unavailable));
23430
+ const into = Effect.fnUntraced(function* (configFolder) {
23431
+ if (harness === "codex") yield* prepare(configFolder);
23432
+ const runIn = (args) => run(args, configFolder);
23433
+ yield* runIn([
23434
+ "plugin",
23435
+ "marketplace",
23436
+ "remove",
23437
+ scoped.name
23438
+ ]).pipe(Effect.ignore);
23439
+ yield* runIn([
23440
+ "plugin",
23441
+ "marketplace",
23442
+ "add",
23443
+ scoped.folder
23444
+ ]);
23445
+ return runIn;
23446
+ });
23447
+ return {
23448
+ folder: scoped.folder,
23449
+ name: scoped.name,
23450
+ id: scoped.id,
23451
+ into
22029
23452
  };
22030
- const lines = yield* Effect.forEach([...opening, asked], (request) => encodeRequest(request).pipe(Effect.orDie));
22031
- const answers = yield* exchange(codex, lines, [initializeId, askedId], configFolder);
22032
- if (Option.isNone(answers)) return yield* new CodexUnanswered({ method: method.method });
22033
- yield* resultOf(codexMethods.initialize, answers.value.get(initializeId));
22034
- return yield* resultOf(method, answers.value.get(askedId));
23453
+ });
23454
+ //#endregion
23455
+ //#region src/harness/codex-install-commits.ts
23456
+ const decodeProfile = Schema.decodeUnknownEffect(CodexConfigRead);
23457
+ const sameJson = Schema.toEquivalence(Schema.Json);
23458
+ /** Only the single ref0 include installed by this CLI belongs to ref0. */
23459
+ const selectedInclude = (set) => {
23460
+ const parameters = set["GIT_CONFIG_PARAMETERS"] ?? "";
23461
+ const included = parameters.slice(1, -1).replaceAll("'\\''", "'");
23462
+ return set["REF0_HARNESS"] === "codex" && shellWord(included) === parameters && /^include\.path=[\s\S]*\/plugins\/cache\/[^/]+\/[^/]+\/@git\/agents\/[a-z0-9_-]+\.gitconfig$/.test(included) ? included.slice(13) : void 0;
23463
+ };
23464
+ /** A changed grant must never leave the previous agent as author after a failed install. */
23465
+ const clearOtherCodexAuthor = Effect.fnUntraced(function* (agent, options) {
23466
+ const fs = yield* FileSystem.FileSystem;
23467
+ const configFile = `${options.configFolder}/config.toml`;
23468
+ if (!(yield* fs.exists(configFile))) return;
23469
+ const text = yield* fs.readFileString(configFile);
23470
+ const document = yield* Effect.try(() => parse(text, { integersAsBigInt: "asNeeded" }));
23471
+ const read = yield* decodeProfile({ config: document });
23472
+ const file = selectedInclude(read.config.shell_environment_policy?.set ?? {});
23473
+ if (file === void 0) return;
23474
+ const name = yield* originPluginName((yield* Ref0Config).api);
23475
+ if (file === `${codexGitFolder(options.configFolder, `${name}@${name}`)}/agents/${agent.agentId}.gitconfig`) return;
23476
+ const oldRoot = file.slice(0, file.lastIndexOf("/agents/"));
23477
+ const fallback = yield* codexAgentConfig(agent, oldRoot, options.home ?? (yield* Config.String("HOME").pipe(Config.withDefault("."))));
23478
+ yield* writeCodexCheckout({
23479
+ gitFolder: oldRoot,
23480
+ common: "",
23481
+ folder: "",
23482
+ signsOff: false,
23483
+ clearAuthor: file,
23484
+ author: fallback
23485
+ });
23486
+ yield* Console.error(`ref0: Commits now name you instead of the previously connected agent, including its running sessions; ref0’s Git hooks still run. Fix the shell_environment_policy conflict in Codex’s config or profile named above: remove conflicting set values or allowlists, and keep GIT_AUTHOR_NAME and GIT_AUTHOR_EMAIL excluded. Then run: ${installLine(options.configFolder)}`);
23487
+ });
23488
+ /** Install the connected agent's include and shell policy. */
23489
+ const installCodexCommits = Effect.fnUntraced(function* (agent, options) {
23490
+ const env = {
23491
+ HOME: options.home,
23492
+ CODEX_HOME: options.configFolder
23493
+ };
23494
+ const failed = (what) => new InstallFailed({ says: `Couldn’t ${what}. Check that Codex is installed.` });
23495
+ return yield* Effect.gen(function* () {
23496
+ const fs = yield* FileSystem.FileSystem;
23497
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
23498
+ yield* fs.makeDirectory(`${options.home}/.ref0`, {
23499
+ recursive: true,
23500
+ mode: 448
23501
+ });
23502
+ if ((yield* (yield* spawner.spawn(ChildProcess.make("perl", [
23503
+ "-e",
23504
+ "use Fcntl qw(LOCK_EX); open my $lock, \">>\", $ARGV[0] or die $!; alarm 5; flock($lock, LOCK_EX) or die $!; alarm 0; $|=1; print \"locked\\n\"; <STDIN>;",
23505
+ `${options.home}/.ref0/codex-install.lock`
23506
+ ], { stdin: "pipe" }))).stdout.pipe(Stream.decodeText(), Stream.splitLines, Stream.take(1), Stream.mkString)) !== "locked") return yield* failed("lock Codex’s commit setup");
23507
+ const read = yield* askCodex(options.codex, codexMethods.configRead, {
23508
+ includeLayers: true,
23509
+ cwd: null
23510
+ }, env);
23511
+ const policy = read.config.shell_environment_policy;
23512
+ const set = policy?.set ?? {};
23513
+ const own = selectedInclude(set) !== void 0;
23514
+ const collision = [
23515
+ "GIT_CONFIG_PARAMETERS",
23516
+ "REF0_HARNESS",
23517
+ "GIT_AUTHOR_NAME",
23518
+ "GIT_AUTHOR_EMAIL"
23519
+ ].find((key) => Object.hasOwn(set, key) && !(own && (key === "GIT_CONFIG_PARAMETERS" || key === "REF0_HARNESS")));
23520
+ const allowlist = policy?.include_only != null || Object.values(policy?.filters ?? {}).includes("include");
23521
+ if (collision !== void 0 || allowlist) {
23522
+ yield* Console.error(`ref0: Codex’s shell_environment_policy ${collision === void 0 ? "has an allowlist" : `already sets ${collision}`}, so ref0 left its commit environment as it was.`);
23523
+ if (own) return yield* failed("update the existing commit environment; fix the conflicting Codex config or profile, then reinstall");
23524
+ return;
23525
+ }
23526
+ const profiles = (yield* fs.readDirectory(options.configFolder)).filter((file) => file.endsWith(".config.toml"));
23527
+ for (const file of profiles) {
23528
+ const text = yield* fs.readFileString(`${options.configFolder}/${file}`);
23529
+ const document = yield* Effect.try({
23530
+ try: () => parse(text, { integersAsBigInt: "asNeeded" }),
23531
+ catch: () => failed(`read Codex’s profile ${printable(file)}`)
23532
+ });
23533
+ const profile = yield* decodeProfile({ config: document });
23534
+ const overrides = profile.config.shell_environment_policy?.set ?? {};
23535
+ const key = [
23536
+ "GIT_CONFIG_PARAMETERS",
23537
+ "REF0_HARNESS",
23538
+ "GIT_AUTHOR_NAME",
23539
+ "GIT_AUTHOR_EMAIL"
23540
+ ].find((key) => Object.hasOwn(overrides, key));
23541
+ const profilePolicy = profile.config.shell_environment_policy;
23542
+ const filters = profilePolicy?.filters;
23543
+ const replaced = filters != null && policy?.filters == null;
23544
+ const excluded = filters != null ? [...replaced ? [] : ["GIT_AUTHOR_NAME", "GIT_AUTHOR_EMAIL"], ...Object.keys(filters)] : profilePolicy?.exclude;
23545
+ const losesAuthors = excluded !== void 0 && excluded !== null && ["GIT_AUTHOR_NAME", "GIT_AUTHOR_EMAIL"].some((name) => !excluded.some((pattern) => pathMatchesWithin(pattern.toLowerCase(), name.toLowerCase(), { left: 1e4 })));
23546
+ const allowlist = profilePolicy?.include_only != null || Object.values(filters ?? {}).includes("include");
23547
+ if (key === void 0 && !losesAuthors && !allowlist) continue;
23548
+ yield* Console.error(`ref0: Codex’s profile ${printable(file)} ${key === void 0 ? "overrides the author exclusions or has an allowlist" : `already sets ${key}`}, so ref0 left its commit environment as it was.`);
23549
+ if (own) return yield* failed("update the existing commit environment; fix the conflicting Codex config or profile, then reinstall");
23550
+ return;
23551
+ }
23552
+ if (!own && (options.approveCommits === void 0 || !(yield* options.approveCommits()))) return;
23553
+ const crypto = yield* Crypto.Crypto;
23554
+ const author = yield* codexAgentConfig(agent, options.gitFolder, options.home);
23555
+ const configFile = `${options.configFolder}/config.toml`;
23556
+ const before = (file) => Effect.flatMap(fs.exists(file), (exists) => exists ? Effect.map(fs.readFileString(file), Option.some) : Effect.succeed(Option.none()));
23557
+ const previousAuthor = yield* before(author.file);
23558
+ const restore = (file, before) => Effect.gen(function* () {
23559
+ if (Option.isNone(before)) return yield* fs.remove(file, { force: true });
23560
+ const next = `${file}.${yield* crypto.randomUUIDv4}.next`;
23561
+ yield* fs.writeFileString(next, before.value, { mode: 384 });
23562
+ yield* fs.rename(next, file);
23563
+ });
23564
+ const user = read.layers?.find((layer) => layer.name.type === "user" && layer.name.file === configFile);
23565
+ const previousPolicy = user?.config === void 0 ? policy ?? null : user.config.shell_environment_policy ?? null;
23566
+ const edits = [{
23567
+ keyPath: "shell_environment_policy.set",
23568
+ value: {
23569
+ ...set,
23570
+ GIT_CONFIG_PARAMETERS: author.parameters,
23571
+ REF0_HARNESS: "codex"
23572
+ },
23573
+ mergeStrategy: "replace"
23574
+ }, ...policy?.filters == null ? [{
23575
+ keyPath: "shell_environment_policy.exclude",
23576
+ value: [.../* @__PURE__ */ new Set([
23577
+ ...policy?.exclude ?? [],
23578
+ "GIT_AUTHOR_NAME",
23579
+ "GIT_AUTHOR_EMAIL"
23580
+ ])],
23581
+ mergeStrategy: "replace"
23582
+ }] : [{
23583
+ keyPath: "shell_environment_policy.filters",
23584
+ value: {
23585
+ ...policy.filters,
23586
+ ...Object.fromEntries(["GIT_AUTHOR_NAME", "GIT_AUTHOR_EMAIL"].flatMap((key) => Object.keys(policy.filters ?? {}).some((pattern) => pattern.toUpperCase() === key) ? [] : [[key, "exclude"]]))
23587
+ },
23588
+ mergeStrategy: "replace"
23589
+ }]];
23590
+ const installedPolicy = {
23591
+ ...previousPolicy,
23592
+ ...Object.fromEntries(edits.map((edit) => [edit.keyPath.slice(25), edit.value]))
23593
+ };
23594
+ const readCurrent = askCodex(options.codex, codexMethods.configRead, {
23595
+ includeLayers: true,
23596
+ cwd: null
23597
+ }, env);
23598
+ const recover = Effect.gen(function* () {
23599
+ const layer = (yield* readCurrent).layers?.find((layer) => layer.name.type === "user" && layer.name.file === configFile);
23600
+ if (layer?.config === void 0) return false;
23601
+ const currentPolicy = layer.config.shell_environment_policy ?? null;
23602
+ if (sameJson(currentPolicy, previousPolicy)) return true;
23603
+ if (!sameJson(currentPolicy, installedPolicy)) return false;
23604
+ const restored = yield* askCodex(options.codex, codexMethods.configBatchWrite, {
23605
+ edits: [{
23606
+ keyPath: "shell_environment_policy",
23607
+ value: previousPolicy,
23608
+ mergeStrategy: "replace"
23609
+ }],
23610
+ filePath: configFile,
23611
+ expectedVersion: layer.version,
23612
+ reloadUserConfig: false
23613
+ }, env).pipe(Effect.option);
23614
+ if (Option.isSome(restored)) return true;
23615
+ const checkedLayer = (yield* readCurrent).layers?.find((layer) => layer.name.type === "user" && layer.name.file === configFile);
23616
+ return checkedLayer?.config !== void 0 && sameJson(checkedLayer.config.shell_environment_policy ?? null, previousPolicy);
23617
+ }).pipe(Effect.orElseSucceed(() => false));
23618
+ let attemptedConfig = false;
23619
+ yield* Effect.gen(function* () {
23620
+ yield* writeCodexCheckout({
23621
+ gitFolder: options.gitFolder,
23622
+ common: "",
23623
+ folder: author.folder,
23624
+ signsOff: false,
23625
+ author,
23626
+ activateAuthor: true
23627
+ });
23628
+ attemptedConfig = true;
23629
+ if ((yield* askCodex(options.codex, codexMethods.configBatchWrite, {
23630
+ edits,
23631
+ filePath: configFile,
23632
+ expectedVersion: user?.version ?? null,
23633
+ reloadUserConfig: false
23634
+ }, env)).status !== "ok") return yield* failed("set Codex’s commit environment because another config layer overrides it");
23635
+ }).pipe(Effect.onError(() => Effect.gen(function* () {
23636
+ if (!attemptedConfig || (yield* recover)) yield* restore(author.file, previousAuthor);
23637
+ else yield* Console.error("ref0: Could not safely restore Codex’s commit policy after the failed setup. Its author file was kept so the include remains valid. Check your Codex config, then run ref0 install again.");
23638
+ }).pipe(Effect.orDie)));
23639
+ }).pipe(Effect.scoped, Effect.mapError(() => failed("set Codex’s commit environment")));
23640
+ }, Effect.scoped);
23641
+ //#endregion
23642
+ //#region src/harness/install-claude-code.ts
23643
+ /** Install into one config folder, from the release Claude Code fetched and ref0 checked. */
23644
+ const installClaudeCode = Effect.fnUntraced(function* (release, configFolder) {
23645
+ const run = yield* release.into(configFolder);
23646
+ yield* run([
23647
+ "plugin",
23648
+ "install",
23649
+ release.id,
23650
+ "--scope",
23651
+ "user"
23652
+ ]);
23653
+ yield* run([
23654
+ "plugin",
23655
+ "update",
23656
+ release.id,
23657
+ "--scope",
23658
+ "user"
23659
+ ]);
23660
+ return release.folder;
22035
23661
  });
22036
23662
  //#endregion
22037
23663
  //#region src/harness/install-codex.ts
@@ -22076,95 +23702,120 @@ const trustRef0Hooks = (reinstall, id) => `In a terminal, run: ${reinstall}, and
22076
23702
  * `CODEX_HOME`: neither half alone lets a commit and HTTPS through. A linked worktree's own git
22077
23703
  * directory isn't covered; there Codex needs full access, which whatever launches it sets.
22078
23704
  */
22079
- const ref0Profile = {
23705
+ const ref0Profile = (checkouts) => ({
22080
23706
  extends: ":workspace",
22081
- filesystem: { ":workspace_roots": { ".git": "write" } },
23707
+ filesystem: {
23708
+ ":workspace_roots": { ".git": "write" },
23709
+ [checkouts]: "write"
23710
+ },
22082
23711
  network: { enabled: true }
22083
- };
23712
+ });
22084
23713
  const isPathModes = Schema.is(Schema.Record(Schema.String, Schema.String));
22085
23714
  /** A default that already lets a command write `.git` and reach the network, so needs nothing. */
22086
23715
  const enough = (read) => read.config.default_permissions === ":danger-full-access";
22087
23716
  /** Whether a setup's config has ref0's profile, as its default. */
22088
- const profileSet = (read) => {
23717
+ const profileSet = (read, checkouts) => {
22089
23718
  const profile = read.config.permissions?.["ref0"];
22090
23719
  const roots = profile?.filesystem?.[":workspace_roots"];
22091
- return read.config.default_permissions === "ref0" && profile?.extends === ref0Profile.extends && isPathModes(roots) && roots[".git"] === "write" && profile.network?.enabled === true;
23720
+ return read.config.default_permissions === "ref0" && profile?.extends === ref0Profile(checkouts).extends && isPathModes(roots) && roots[".git"] === "write" && profile.filesystem?.[checkouts] === "write" && profile.network?.enabled === true;
22092
23721
  };
22093
- /** Install into one config folder, from the release Codex fetched and ref0 checked. */
22094
- const installCodex = Effect.fnUntraced(function* (release, options) {
22095
- const failed = (what) => new InstallFailed({ says: `Couldn’t ${what}. Check that Codex is installed.` });
22096
- const { folder, id, name } = release;
22097
- yield* (yield* release.into(options.configFolder))([
22098
- "plugin",
22099
- "add",
22100
- id
22101
- ]);
22102
- const hooks = (yield* askCodex(options.codex, codexMethods.hooksList, { cwds: [options.home] }, options.configFolder).pipe(Effect.mapError(() => failed("read the plugin’s hooks from Codex")))).data.flatMap((entry) => entry.hooks).filter((hook) => pluginHook(hook, id));
22103
- if (hooks.length === 0) return yield* failed("read the plugin’s hooks from Codex");
22104
- if (!hooks.every((hook) => ownHook(hook, name))) return yield* new InstallFailed({ says: `Codex’s ref0 plugin holds hooks ref0 didn’t write, so none was trusted. Remove it with: codex plugin remove ${id}.` });
22105
- const trust = Effect.fnUntraced(function* (waiting) {
22106
- if (!(yield* options.approve(waiting))) return false;
22107
- yield* askCodex(options.codex, codexMethods.configBatchWrite, {
22108
- edits: [{
22109
- keyPath: "hooks.state",
22110
- value: Object.fromEntries(waiting.map((hook) => [hook.key, { trusted_hash: hook.currentHash }])),
22111
- mergeStrategy: "upsert"
22112
- }],
22113
- filePath: null,
22114
- expectedVersion: null,
22115
- reloadUserConfig: false
22116
- }, options.configFolder).pipe(Effect.mapError(() => failed("record the hooks as trusted in Codex")));
22117
- return true;
22118
- });
22119
- const offerProfile = Effect.fnUntraced(function* () {
22120
- const config = yield* askCodex(options.codex, codexMethods.configRead, {
22121
- includeLayers: false,
22122
- cwd: null
22123
- }, options.configFolder).pipe(Effect.option);
22124
- if (Option.isSome(config) && (profileSet(config.value) || enough(config.value))) return true;
22125
- const replacing = Option.match(config, {
22126
- onNone: () => ({
22127
- default: void 0,
22128
- ownRef0: false
22129
- }),
22130
- onSome: (read) => ({
22131
- default: read.config.default_permissions ?? void 0,
22132
- ownRef0: read.config.permissions?.["ref0"] !== void 0
22133
- })
23722
+ /**
23723
+ * Install into one config folder, from the release Codex fetched and ref0 checked, for the agent
23724
+ * the folder's host grant names.
23725
+ */
23726
+ const installCodex = Effect.fnUntraced(function* (release, agent, options) {
23727
+ return yield* Effect.gen(function* () {
23728
+ const failed = (what) => new InstallFailed({ says: `Couldn’t ${what}. Check that Codex is installed.` });
23729
+ const { folder, id, name } = release;
23730
+ yield* (yield* release.into(options.configFolder))([
23731
+ "plugin",
23732
+ "add",
23733
+ id
23734
+ ]);
23735
+ const gitFolder = codexGitFolder(options.configFolder, id);
23736
+ const checkouts = codexCheckoutsFolder(gitFolder);
23737
+ const env = {
23738
+ HOME: options.home,
23739
+ CODEX_HOME: options.configFolder
23740
+ };
23741
+ const hooks = (yield* askCodex(options.codex, codexMethods.hooksList, { cwds: [] }, env).pipe(Effect.mapError(() => failed("read the plugin’s hooks from Codex")))).data.flatMap((entry) => entry.hooks).filter((hook) => pluginHook(hook, id));
23742
+ if (hooks.length === 0) return yield* failed("read the plugin’s hooks from Codex");
23743
+ if (!hooks.every((hook) => ownHook(hook, name))) return yield* new InstallFailed({ says: `Codex’s ref0 plugin holds hooks ref0 didn’t write, so none was trusted. Remove it with: codex plugin remove ${id}.` });
23744
+ const trust = Effect.fnUntraced(function* (waiting) {
23745
+ if (!(yield* options.approve(waiting))) return false;
23746
+ yield* askCodex(options.codex, codexMethods.configBatchWrite, {
23747
+ edits: [{
23748
+ keyPath: "hooks.state",
23749
+ value: Object.fromEntries(waiting.map((hook) => [hook.key, { trusted_hash: hook.currentHash }])),
23750
+ mergeStrategy: "upsert"
23751
+ }],
23752
+ filePath: null,
23753
+ expectedVersion: null,
23754
+ reloadUserConfig: false
23755
+ }, env).pipe(Effect.mapError(() => failed("record the hooks as trusted in Codex")));
23756
+ return true;
22134
23757
  });
22135
- if (!(yield* options.approveProfile(replacing))) return false;
22136
- yield* askCodex(options.codex, codexMethods.configBatchWrite, {
22137
- edits: [{
22138
- keyPath: "permissions.ref0",
22139
- value: ref0Profile,
22140
- mergeStrategy: "replace"
22141
- }, {
22142
- keyPath: "default_permissions",
22143
- value: "ref0",
22144
- mergeStrategy: "replace"
22145
- }],
22146
- filePath: null,
22147
- expectedVersion: null,
22148
- reloadUserConfig: false
22149
- }, options.configFolder).pipe(Effect.mapError(() => failed("set ref0’s profile in Codex")));
22150
- return true;
22151
- });
22152
- const waiting = hooks.filter(waitingForTrust);
22153
- return {
22154
- folder,
22155
- trusted: waiting.length === 0 || (yield* trust(waiting)),
22156
- profile: yield* offerProfile()
22157
- };
23758
+ const offerProfile = Effect.fnUntraced(function* () {
23759
+ const config = yield* askCodex(options.codex, codexMethods.configRead, {
23760
+ includeLayers: false,
23761
+ cwd: null
23762
+ }, env).pipe(Effect.option);
23763
+ if (Option.isSome(config) && (profileSet(config.value, checkouts) || enough(config.value))) return true;
23764
+ const replacing = Option.match(config, {
23765
+ onNone: () => ({
23766
+ default: void 0,
23767
+ ownRef0: false
23768
+ }),
23769
+ onSome: (read) => ({
23770
+ default: read.config.default_permissions ?? void 0,
23771
+ ownRef0: read.config.permissions?.["ref0"] !== void 0
23772
+ })
23773
+ });
23774
+ if (!(yield* options.approveProfile(replacing, checkouts))) return false;
23775
+ yield* askCodex(options.codex, codexMethods.configBatchWrite, {
23776
+ edits: [{
23777
+ keyPath: "permissions.ref0",
23778
+ value: ref0Profile(checkouts),
23779
+ mergeStrategy: "replace"
23780
+ }, {
23781
+ keyPath: "default_permissions",
23782
+ value: "ref0",
23783
+ mergeStrategy: "replace"
23784
+ }],
23785
+ filePath: null,
23786
+ expectedVersion: null,
23787
+ reloadUserConfig: false
23788
+ }, env).pipe(Effect.mapError(() => failed("set ref0’s profile in Codex")));
23789
+ return true;
23790
+ });
23791
+ const waiting = hooks.filter(waitingForTrust);
23792
+ const trusted = waiting.length === 0 || (yield* trust(waiting));
23793
+ const profile = yield* offerProfile();
23794
+ yield* installCodexCommits(agent, {
23795
+ ...options,
23796
+ gitFolder
23797
+ });
23798
+ return {
23799
+ folder,
23800
+ trusted,
23801
+ profile
23802
+ };
23803
+ }).pipe(Effect.onError(() => clearOtherCodexAuthor(agent, options).pipe(Effect.orDie)));
22158
23804
  });
22159
23805
  //#endregion
22160
23806
  //#region src/harness/install.ts
22161
- /** A command on this machine's `PATH`, by absolute path. */
23807
+ /**
23808
+ * A command on this machine's `PATH`, by absolute path: a folder `PATH` names relative to this
23809
+ * one is read from here, since a harness's commands run from an empty folder of their own. Each
23810
+ * folder is kept as written, `..` and all, so the system follows a symlink before the `..` after it.
23811
+ */
22162
23812
  const onPath = Effect.fnUntraced(function* (name) {
22163
23813
  const fs = yield* FileSystem.FileSystem;
23814
+ const paths = yield* Path.Path;
22164
23815
  const path = yield* Config.String("PATH").pipe(Config.withDefault(""));
22165
23816
  for (const folder of path.split(":")) {
22166
23817
  if (folder === "") continue;
22167
- const candidate = `${folder}/${name}`;
23818
+ const candidate = `${paths.isAbsolute(folder) ? folder : `${process.cwd()}/${folder}`}/${name}`;
22168
23819
  if (yield* fs.exists(candidate).pipe(Effect.orElseSucceed(() => false))) return Option.some(candidate);
22169
23820
  }
22170
23821
  return Option.none();
@@ -22182,15 +23833,18 @@ const askToTrust = (hooks, shown, yesOrNo) => yesOrNo([`Codex runs a plugin's ho
22182
23833
  * Codex's default. A change to their Codex's security, so it's shown in full and asked the first
22183
23834
  * time (decided 9 Oct, 5).
22184
23835
  */
22185
- const askForProfile = (replacing, shown, yesOrNo) => yesOrNo([
23836
+ const askForProfile = (replacing, checkouts, shown, yesOrNo) => yesOrNo([
22186
23837
  "Codex's sandbox keeps commands from writing .git or reaching the network, so a Codex agent can't commit or push to ref0.",
22187
- "ref0's profile, \"ref0\", is Codex's workspace profile with two changes:",
23838
+ "ref0's profile, \"ref0\", is Codex's workspace profile with these changes:",
22188
23839
  "- each workspace root's .git is writable, so an agent can commit",
23840
+ `- ${printable(checkouts)} is writable, so a signed-off checkout can keep you as author; the agent’s author files stay outside it`,
22189
23841
  "- the network is on, so an agent can push; any command can then reach the internet, services and sockets on this machine, and your login Keychain",
22190
23842
  "In a linked worktree (git worktree add), Codex still needs full access there.",
22191
23843
  ...replacing.default === void 0 ? [] : [`It replaces your default now, ${printable(replacing.default)}.`],
22192
23844
  ...replacing.ownRef0 ? ["It replaces the profile you have named \"ref0\", and every rule in it."] : []
22193
23845
  ], `Make "ref0" the default profile for ${printable(shown)}?`);
23846
+ /** Shows the person what naming the agent as author changes in Codex, and asks whether it may. */
23847
+ const askForCommits = (shown, yesOrNo) => yesOrNo(["ref0 will set GIT_CONFIG_PARAMETERS and REF0_HARNESS in this Codex setup’s shell_environment_policy, and exclude inherited GIT_AUTHOR_NAME and GIT_AUTHOR_EMAIL. Its agents will author their commits, with you as committer. Signed-off checkouts keep you as author. Your own terminal is unchanged."], `Name the agent as author of its Codex commits, for ${printable(shown)}?`);
22194
23848
  /** What a Codex folder without ref0's profile, or full access, can't do. */
22195
23849
  const sandboxWords = "Codex’s sandbox is as it was, so its agents commit and push to ref0 only where it has full access; to change that, run it again in a terminal.";
22196
23850
  /**
@@ -22246,38 +23900,47 @@ const installInto = Effect.fnUntraced(function* (folders, options) {
22246
23900
  const failAll = (into, failed) => {
22247
23901
  for (const folder of into) answers.set(folder, Result.fail(failed));
22248
23902
  };
23903
+ const started = /* @__PURE__ */ new Set();
23904
+ const clearUnstarted = (into) => Effect.forEach(into.filter((folder) => folder.harness === "codex" && !started.has(folder)), (folder) => codexAgentOf(folder.configFolder).pipe(Effect.flatMap((agent) => clearOtherCodexAuthor(agent, {
23905
+ configFolder: folder.configFolder,
23906
+ home
23907
+ }).pipe(Effect.orDie)), Effect.catchTag("InstallFailed", () => Effect.void)), { discard: true });
22249
23908
  const launcher = yield* Effect.cached(Effect.gen(function* () {
22250
23909
  const git = yield* onPath("git");
22251
23910
  if (Option.isNone(git)) return yield* new InstallFailed({ says: "Git isn’t on this machine’s PATH. Install it." });
22252
23911
  yield* writeRef0Launcher(home, git.value).pipe(Effect.mapError(() => new InstallFailed({ says: "Couldn’t write ~/.ref0. Check that it’s writable." })));
22253
23912
  }));
22254
- for (const harness of ConnectHarness.literals) {
22255
- const into = folders.filter((folder) => folder.harness === harness);
22256
- if (into.length === 0) continue;
22257
- const command = yield* onPath(harness === "codex" ? "codex" : "claude");
22258
- if (Option.isNone(command)) {
22259
- failAll(into, new InstallFailed({ says: `${configFolders[harness].name} isn’t on this machine’s PATH. Install it.` }));
22260
- continue;
22261
- }
22262
- yield* Effect.scoped(Effect.gen(function* () {
22263
- yield* launcher;
22264
- const release = yield* marketplaceFor(harness, {
22265
- command: command.value,
22266
- home,
22267
- configFolder: into[0]?.configFolder ?? home
22268
- });
22269
- for (const folder of into) {
22270
- const installed = yield* (harness === "claude-code" ? installedInClaudeCode(release, folder.configFolder) : installedInCodex(release, {
22271
- codex: command.value,
22272
- home,
22273
- configFolder: folder.configFolder,
22274
- name,
22275
- yesOrNo
22276
- })).pipe(Effect.result);
22277
- answers.set(folder, installed);
23913
+ yield* Effect.gen(function* () {
23914
+ for (const harness of ConnectHarness.literals) {
23915
+ const into = folders.filter((folder) => folder.harness === harness);
23916
+ if (into.length === 0) continue;
23917
+ const command = yield* onPath(harness === "codex" ? "codex" : "claude");
23918
+ if (Option.isNone(command)) {
23919
+ failAll(into, new InstallFailed({ says: `${configFolders[harness].name} isn’t on this machine’s PATH. Install it.` }));
23920
+ yield* clearUnstarted(into);
23921
+ continue;
22278
23922
  }
22279
- }).pipe(Effect.catchTag("InstallFailed", (failed) => Effect.sync(() => failAll(into, failed)))));
22280
- }
23923
+ yield* Effect.scoped(Effect.gen(function* () {
23924
+ yield* launcher;
23925
+ const release = yield* marketplaceFor(harness, {
23926
+ command: command.value,
23927
+ home,
23928
+ configFolder: into[0]?.configFolder ?? home
23929
+ });
23930
+ for (const folder of into) {
23931
+ started.add(folder);
23932
+ const installed = yield* (harness === "claude-code" ? installedInClaudeCode(release, folder.configFolder) : installedInCodex(release, {
23933
+ codex: command.value,
23934
+ home,
23935
+ configFolder: folder.configFolder,
23936
+ name,
23937
+ yesOrNo
23938
+ })).pipe(Effect.result);
23939
+ answers.set(folder, installed);
23940
+ }
23941
+ }).pipe(Effect.catchTag("InstallFailed", (failed) => Effect.sync(() => failAll(into, failed)).pipe(Effect.andThen(clearUnstarted(into))))));
23942
+ }
23943
+ }).pipe(Effect.onInterrupt(() => clearUnstarted(folders)));
22281
23944
  return folders.map((folder) => ({
22282
23945
  ...folder,
22283
23946
  installed: answers.get(folder) ?? Result.fail(new InstallFailed({ says: "ref0 didn’t install into this folder." }))
@@ -22292,14 +23955,27 @@ const installedInClaudeCode = Effect.fnUntraced(function* (release, configFolder
22292
23955
  words: `Installed in Claude Code, from ${folder}.`
22293
23956
  };
22294
23957
  });
23958
+ /** The agent a Codex folder's host grant names, whose ID its commits' author file is named by. */
23959
+ const codexAgentOf = (configFolder) => HostGrant.use((grants) => grants.get(configFolder)).pipe(Effect.catchTag("KeychainError", (failed) => Effect.fail(failed.unreadable === true ? new InstallFailed({
23960
+ says: "Its host grant can’t be read.",
23961
+ run: "ref0 connect"
23962
+ }) : new InstallFailed({ says: failed.nextStep }))), Effect.flatMap(Option.match({
23963
+ onNone: () => Effect.fail(new InstallFailed({
23964
+ says: "It isn’t connected.",
23965
+ run: "ref0 connect"
23966
+ })),
23967
+ onSome: (grant) => Effect.succeed(grant)
23968
+ })));
22295
23969
  const installedInCodex = Effect.fnUntraced(function* (release, options) {
22296
23970
  const shown = shownConfigFolder(options.home, options.configFolder);
22297
- const done = yield* installCodex(release, {
23971
+ const agent = yield* codexAgentOf(options.configFolder);
23972
+ const done = yield* installCodex(release, agent, {
22298
23973
  codex: options.codex,
22299
23974
  home: options.home,
22300
23975
  configFolder: options.configFolder,
22301
23976
  approve: (hooks) => askToTrust(hooks, shown, options.yesOrNo),
22302
- approveProfile: (replacing) => askForProfile(replacing, shown, options.yesOrNo)
23977
+ approveProfile: (replacing, checkouts) => askForProfile(replacing, checkouts, shown, options.yesOrNo),
23978
+ approveCommits: () => askForCommits(shown, options.yesOrNo)
22303
23979
  });
22304
23980
  const sandbox = done.profile ? "" : ` ${sandboxWords}`;
22305
23981
  const trust = trustRef0Hooks(installLine(options.configFolder), `${options.name}@${options.name}`);
@@ -30727,6 +32403,7 @@ var Waker = class Waker extends Context.Service()("ref0/cli/Waker") {
30727
32403
  const socket = yield* Config.String("CLAUDE_CODE_MESSAGING_SOCKET").pipe(Config.option);
30728
32404
  const token = yield* Config.Redacted("CLAUDE_CODE_MESSAGING_TOKEN").pipe(Config.option);
30729
32405
  const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
32406
+ const fs = yield* FileSystem.FileSystem;
30730
32407
  const post = Option.zipWith(socket, token, (path, secret) => Effect.callback((resume) => {
30731
32408
  const connection = connect(path);
30732
32409
  connection.on("error", () => resume(Effect.succeed(false)));
@@ -30753,6 +32430,7 @@ var Waker = class Waker extends Context.Service()("ref0/cli/Waker") {
30753
32430
  "--message",
30754
32431
  wakeLine
30755
32432
  ], {
32433
+ cwd: yield* emptyFolder(fs),
30756
32434
  env: { CODEX_HOME: setup.home },
30757
32435
  extendEnv: true
30758
32436
  }));
@@ -31962,139 +33640,17 @@ const endHarnessSession = Effect.gen(function* () {
31962
33640
  }).pipe(Effect.ignore);
31963
33641
  const sessionEnd = Command.make(harnessCommandNames.sessionEnd, { protocol: protocolFlag }, (flags) => flags.protocol === "1" ? endHarnessSession : Effect.void).pipe(Command.withDescription("Ends the ref0 session of a harness session that's closing"), Command.withShortDescription("End the harness’s ref0 session"));
31964
33642
  //#endregion
31965
- //#region src/harness/git-hooks.ts
31966
- /**
31967
- * ref0's Git hooks: folders of forwarders in `~/.ref0/origins/<origin>/git-hooks`, which ref0 names as
31968
- * `core.hooksPath` in its agents' commands alone, never in a repo, its config or its hooks.
31969
- * `core.hooksPath` replaces a repo's whole hooks folder, so each forwarder runs the repo's own
31970
- * hook of its name, with the same arguments, stdin and exit status, wherever the repo keeps it:
31971
- * husky's `.husky/_`, `.git/hooks/` or another `core.hooksPath`. It finds and runs that hook
31972
- * with ref0's entry left out of its environment, so a hook that looks up another, as
31973
- * `pre-applypatch.sample` runs `pre-commit`, finds the repo's own. A forwarder runs nothing
31974
- * when the repo's hooks path is one of ref0's folders, or when a forwarder started it, so none
31975
- * calls itself: a first version that left ref0's entry in started about 1,000 processes.
31976
- *
31977
- * Git runs a hook that exists even where the repo has none, so the forwarders cost each hook one
31978
- * `sh` and one `git rev-parse`, about 1.5 ms. Two hooks run on every index write and ref update,
31979
- * hundreds of times in a long rebase, so only `all` forwards them, for a checkout that has its
31980
- * own; `most` forwards every other (measured 23 Oct 2026, in the piece's pull request).
31981
- *
31982
- * The folders are the CLI's: a session's start writes them again when a forwarder differs, as
31983
- * after an update. Each is read-only, so a tool that installs hooks wherever `core.hooksPath`
31984
- * points, run in an agent's command, fails there rather than replacing a forwarder for every
31985
- * repo.
31986
- */
31987
- /** The hooks Git runs on every index write and ref update. */
31988
- const frequentHooks = ["post-index-change", "reference-transaction"];
31989
- /**
31990
- * Every other hook Git looks for in its hooks path. Not `push-to-checkout`, which replaces Git's
31991
- * own update of a checkout pushed to merely by existing, nor `fsmonitor-watchman`, which Git runs
31992
- * from `core.fsmonitor`, never from the hooks path.
31993
- */
31994
- const forwardedHooks = [
31995
- "applypatch-msg",
31996
- "pre-applypatch",
31997
- "post-applypatch",
31998
- "pre-commit",
31999
- "pre-merge-commit",
32000
- "prepare-commit-msg",
32001
- "commit-msg",
32002
- "post-commit",
32003
- "pre-rebase",
32004
- "post-checkout",
32005
- "post-merge",
32006
- "pre-push",
32007
- "pre-receive",
32008
- "update",
32009
- "proc-receive",
32010
- "post-receive",
32011
- "post-update",
32012
- "pre-auto-gc",
32013
- "post-rewrite",
32014
- "sendemail-validate",
32015
- "p4-changelist",
32016
- "p4-prepare-changelist",
32017
- "p4-post-changelist",
32018
- "p4-pre-submit"
32019
- ];
32020
- /**
32021
- * The forwarder for one hook. `hooks` is the folder holding ref0's folders: an entry naming one
32022
- * of them is ref0's, and is left out. The rest keep their order, renumbered.
32023
- */
32024
- const forwarder = (name, hooks) => [
32025
- "#!/bin/sh",
32026
- `# ref0 ${cliVersion}'s forwarder for Git's ${name} hook. ref0 sets core.hooksPath to this`,
32027
- "# folder only in its agents' commands. This runs the repo's own hook, found and run with",
32028
- "# ref0's entry left out, with the same arguments, stdin and exit status.",
32029
- `[ -n "\${${gitHookVariables.forwarded}-}" ] && exit 0`,
32030
- `ref0_hooks=${shellWord(hooks)}`,
32031
- "ref0_n=${GIT_CONFIG_COUNT:-0}",
32032
- "ref0_i=0",
32033
- "ref0_j=0",
32034
- "while [ \"$ref0_i\" -lt \"$ref0_n\" ]; do",
32035
- " eval \"ref0_key=\\${GIT_CONFIG_KEY_$ref0_i-} ref0_value=\\${GIT_CONFIG_VALUE_$ref0_i-}\"",
32036
- " unset \"GIT_CONFIG_KEY_$ref0_i\" \"GIT_CONFIG_VALUE_$ref0_i\"",
32037
- " if [ \"$ref0_key\" != core.hooksPath ] || [ \"${ref0_value%/*}\" != \"$ref0_hooks\" ]; then",
32038
- " export \"GIT_CONFIG_KEY_$ref0_j=$ref0_key\" \"GIT_CONFIG_VALUE_$ref0_j=$ref0_value\"",
32039
- " ref0_j=$((ref0_j + 1))",
32040
- " fi",
32041
- " ref0_i=$((ref0_i + 1))",
32042
- "done",
32043
- "if [ \"$ref0_j\" -eq 0 ]; then unset GIT_CONFIG_COUNT; else export GIT_CONFIG_COUNT=\"$ref0_j\"; fi",
32044
- `ref0_hook=$(git rev-parse --git-path hooks/${name}) || exit`,
32045
- "[ -f \"$ref0_hook\" ] && [ -x \"$ref0_hook\" ] || exit 0",
32046
- "ref0_dir=$(cd -P \"${ref0_hook%/*}\" && pwd) || exit",
32047
- "[ \"${ref0_dir%/*}\" = \"$ref0_hooks\" ] && exit 0",
32048
- `export ${gitHookVariables.forwarded}=${name}`,
32049
- "exec \"$ref0_hook\" \"$@\"",
32050
- ""
32051
- ].join("\n");
32052
- /** The hooks each folder forwards. */
32053
- const hooksIn = (folder) => folder === "all" ? [...forwardedHooks, ...frequentHooks] : forwardedHooks;
32054
- /** The folder holding ref0's hooks folders, made if it isn't yet: its path, through any links. */
32055
- const gitHooksHome = Effect.fnUntraced(function* (home, api) {
32056
- const fs = yield* FileSystem.FileSystem;
32057
- const given = (yield* Path.Path).join(home, ".ref0", "origins", originKey(api), "git-hooks");
32058
- yield* fs.makeDirectory(given, {
32059
- recursive: true,
32060
- mode: 448
32061
- });
32062
- return yield* fs.realPath(given);
32063
- });
32064
- /**
32065
- * One of ref0's hooks folders, in `hooks`, written first unless every forwarder in it is already
32066
- * as this CLI writes it: its path, as the agent's `core.hooksPath` names it.
32067
- */
32068
- const hooksFolder = Effect.fnUntraced(function* (hooks, folder) {
32069
- const fs = yield* FileSystem.FileSystem;
32070
- const path = yield* Path.Path;
32071
- const crypto = yield* Crypto.Crypto;
32072
- const written = path.join(hooks, folder);
32073
- const names = hooksIn(folder);
32074
- const current = Effect.forEach(names, (name) => fs.readFileString(path.join(written, name)).pipe(Effect.map((text) => text === forwarder(name, hooks)), Effect.orElseSucceed(() => false))).pipe(Effect.map((each) => each.every(Boolean)));
32075
- if (yield* current) return written;
32076
- yield* Effect.gen(function* () {
32077
- yield* fs.makeDirectory(written, { recursive: true });
32078
- yield* fs.chmod(written, 493);
32079
- for (const name of names) {
32080
- const next = path.join(written, `${name}.${yield* crypto.randomUUIDv4}.next`);
32081
- yield* fs.writeFileString(next, forwarder(name, hooks), { mode: 365 });
32082
- yield* fs.rename(next, path.join(written, name));
32083
- }
32084
- yield* fs.chmod(written, 365);
32085
- }).pipe(Effect.catch((error) => Effect.flatMap(current, (done) => done ? Effect.void : Effect.fail(error))));
32086
- return written;
32087
- });
32088
- //#endregion
32089
33643
  //#region src/harness/commit-environment.ts
32090
33644
  /**
32091
33645
  * The agent as the author of its commits, set at a Claude Code session's start in the
32092
33646
  * environment of the agent's own commands, and nowhere else: the file `CLAUDE_ENV_FILE` names,
32093
33647
  * which Claude Code runs ahead of each of them. It sets Git's author to the agent, by its owner
32094
33648
  * and handle and its commit email; one `GIT_CONFIG_*` entry naming ref0's hooks folder as
32095
- * `core.hooksPath`, after any the command already has; and the harness, for the trailer. Git
32096
- * takes the committer from the person's own config, with their signature, and a commit the
32097
- * person makes in their own terminal has none of it.
33649
+ * `core.hooksPath`, after any the command already has; and, for the trailer, the harness, the
33650
+ * session's transcript and the model it started with, both from Claude Code's `SessionStart`.
33651
+ * Its other hooks carry no model, so the trailer reads the transcript's last. Git takes the
33652
+ * committer from the person's own config, with their signature, and a commit the person makes in
33653
+ * their own terminal has none of it.
32098
33654
  *
32099
33655
  * The agent is the one the session's config folder acts as, from that folder's host grant, which
32100
33656
  * `ref0 connect` keeps with the agent's ID. It sets no author when the grant can't be read, or
@@ -32104,6 +33660,18 @@ const hooksFolder = Effect.fnUntraced(function* (hooks, folder) {
32104
33660
  */
32105
33661
  /** How many commits on the default branch a session's start reads for a sign-off. */
32106
33662
  const signedOffWithin = 20;
33663
+ /** A model as the trailer names it, which the hook also checks: one word, without a newline. */
33664
+ const modelName = /^[A-Za-z0-9][A-Za-z0-9._:@/-]*$/;
33665
+ /**
33666
+ * The model a session started with, as the trailer names it: none for a value it can't name.
33667
+ * Claude Code may follow the model with its context window, as `[1m]`, which isn't the model.
33668
+ */
33669
+ const startingModel = (model) => Option.filter(Option.fromNullishOr(model?.replace(/\[[^\]]*\]$/, "")), (name) => modelName.test(name));
33670
+ /** A variable the file exports, or unsets when the session has no value for it. */
33671
+ const exported = (name, value) => Option.match(value, {
33672
+ onNone: () => `unset ${name}`,
33673
+ onSome: (word) => `export ${name}=${shellWord(word)}`
33674
+ });
32107
33675
  /**
32108
33676
  * What a session's start adds to the file Claude Code runs ahead of the agent's commands. The
32109
33677
  * hooks path goes after the entries a command already has, once however often the file runs.
@@ -32113,10 +33681,12 @@ const environmentScript = (set) => [
32113
33681
  "",
32114
33682
  "# ref0: the agent as the author of its commits, and ref0's Git hooks, in its commands",
32115
33683
  ...Option.match(set.author, {
32116
- onNone: () => ["case ${GIT_AUTHOR_EMAIL-} in *@ref0.work) unset GIT_AUTHOR_NAME GIT_AUTHOR_EMAIL ;; esac"],
33684
+ onNone: () => [`case \${GIT_AUTHOR_EMAIL-} in *@${agentCommitDomain}) unset GIT_AUTHOR_NAME GIT_AUTHOR_EMAIL ;; esac`],
32117
33685
  onSome: (author) => [`export GIT_AUTHOR_NAME=${shellWord(author.name)}`, `export GIT_AUTHOR_EMAIL=${shellWord(author.email)}`]
32118
33686
  }),
32119
33687
  `export ${gitHookVariables.harness}=${set.harness}`,
33688
+ exported(gitHookVariables.transcript, set.transcript),
33689
+ exported(gitHookVariables.model, set.model),
32120
33690
  "if [ -z \"${ref0_hooks_set-}\" ]; then",
32121
33691
  " ref0_n=${GIT_CONFIG_COUNT:-0}",
32122
33692
  ` export "GIT_CONFIG_KEY_$ref0_n=core.hooksPath" "GIT_CONFIG_VALUE_$ref0_n="${shellWord(set.folder)} "GIT_CONFIG_COUNT=$((ref0_n + 1))"`,
@@ -32125,12 +33695,16 @@ const environmentScript = (set) => [
32125
33695
  "fi",
32126
33696
  ""
32127
33697
  ].join("\n");
33698
+ /** How the agent runs a tool that installs hooks where `core.hooksPath` points. */
33699
+ const hookInstallerPrefix = "env -u GIT_CONFIG_COUNT";
32128
33700
  /**
32129
33701
  * What the agent reads at its session's start once its commands name ref0's hooks folder: an
32130
33702
  * installer run there fails on that read-only folder, and without ref0's entry it installs into
32131
33703
  * the repo's own.
32132
33704
  */
32133
- const hookInstallerWords = `ref0: Git in your commands runs hooks through ref0's read-only folder, so run a hook installer, such as git lfs install, pre-commit install, lefthook install or simple-git-hooks, as env -u GIT_CONFIG_COUNT <command>, to install into the repo's own hooks folder.`;
33705
+ const hookInstallerWords = `ref0: Git in your commands runs hooks through ref0's read-only folder, so run a hook installer, such as git lfs install, pre-commit install, lefthook install or simple-git-hooks, as ${hookInstallerPrefix} <command>, to install into the repo's own hooks folder.`;
33706
+ /** Codex uses an include in `GIT_CONFIG_PARAMETERS`, leaving a launcher's count alone. */
33707
+ const codexHookInstallerWords = hookInstallerWords.replace(hookInstallerPrefix, "env -u GIT_CONFIG_PARAMETERS");
32134
33708
  /**
32135
33709
  * What the session's checkout says: which of ref0's hooks folders it needs, `all` when it has
32136
33710
  * its own frequent hook, and whether it signs off its commits. Outside a checkout, neither.
@@ -32176,28 +33750,35 @@ const checkoutAt = Effect.fnUntraced(function* (cwd) {
32176
33750
  };
32177
33751
  });
32178
33752
  /**
32179
- * Adds the agent's commit environment to the file Claude Code names, for the session opening in
32180
- * `cwd`: only in Claude Code, and only for a setup with an agent. ref0's hooks folder is written
32181
- * first, so the file never names a folder that isn't there. Whether it set ref0's hooks path.
33753
+ * Adds the agent's commit environment to the file Claude Code names, for the session its
33754
+ * `SessionStart` opens: only in Claude Code, and only for a setup with an agent. ref0's hooks
33755
+ * folder is written first, so the file never names a folder that isn't there. Whether it set
33756
+ * ref0's hooks path.
32182
33757
  */
32183
- const setCommitEnvironment = Effect.fnUntraced(function* (cwd) {
33758
+ const setCommitEnvironment = Effect.fnUntraced(function* (session) {
32184
33759
  const fs = yield* FileSystem.FileSystem;
32185
33760
  const folders = yield* ConfigFolders;
32186
33761
  const file = yield* Config.String("CLAUDE_ENV_FILE").pipe(Config.withDefault(""));
32187
- if (file === "") return false;
33762
+ if (file === "") {
33763
+ if ((yield* Config.String("CODEX_HOME").pipe(Config.withDefault(""))) === "") return false;
33764
+ return yield* setCodexCommitEnvironment(session.cwd ?? ".", yield* checkoutAt(session.cwd ?? "."));
33765
+ }
32188
33766
  const connected = yield* (yield* HostGrant).get(yield* folders.folder("claude-code")).pipe(Effect.map(Option.map(Option.some)), Effect.catchIf((failed) => failed.unreadable === true, () => Effect.succeedSome(Option.none())));
32189
33767
  if (Option.isNone(connected)) return false;
32190
- const checkout = yield* checkoutAt(cwd);
33768
+ const checkout = yield* checkoutAt(session.cwd ?? ".");
32191
33769
  const folder = yield* hooksFolder(yield* gitHooksHome(yield* Config.String("HOME"), (yield* Ref0Config).api), checkout.folder);
32192
33770
  const author = checkout.signsOff ? Option.none() : Option.map(connected.value, (grant) => ({
32193
33771
  name: agentCommitName(grant.owner, grant.agent),
32194
33772
  email: agentCommitEmail(grant.agentId)
32195
33773
  }));
32196
- yield* fs.writeFileString(file, environmentScript({
33774
+ const script = environmentScript({
32197
33775
  author,
32198
33776
  folder,
32199
- harness: "claude-code"
32200
- }), { flag: "a" });
33777
+ harness: "claude-code",
33778
+ transcript: Option.filter(Option.fromNullishOr(session.transcript_path), (path) => path !== ""),
33779
+ model: startingModel(session.model)
33780
+ });
33781
+ yield* fs.writeFileString(file, script, { flag: "a" });
32201
33782
  return true;
32202
33783
  });
32203
33784
  //#endregion
@@ -32250,7 +33831,10 @@ const startOnHook = (protocol) => Effect.gen(function* () {
32250
33831
  const input = yield* stdio.stdin.pipe(Stream.decodeText(), Stream.mkString);
32251
33832
  const hook = decodeHook(input);
32252
33833
  if (Option.isNone(hook)) return;
32253
- if (yield* setCommitEnvironment(hook.value.cwd ?? ".").pipe(Effect.orElseSucceed(() => false))) yield* Console.log(hookInstallerWords);
33834
+ if (yield* setCommitEnvironment(hook.value).pipe(Effect.orElseSucceed(() => false))) {
33835
+ const claude = yield* Config.String("CLAUDE_ENV_FILE").pipe(Config.withDefault(""));
33836
+ yield* Console.log(claude === "" ? codexHookInstallerWords : hookInstallerWords);
33837
+ }
32254
33838
  const binding = yield* bindingHere(hook.value.session_id);
32255
33839
  if (Option.isNone(binding)) return;
32256
33840
  const harnessName = binding.value.harness === "codex" ? "codex" : "claude-code";
@@ -32367,24 +33951,26 @@ const claudeCodeChecks = Effect.fnUntraced(function* (configFolder) {
32367
33951
  const claude = yield* onPath("claude");
32368
33952
  if (Option.isNone(claude)) return [checkOf("plugin", "unknown", "Claude Code isn’t on PATH", reinstall)];
32369
33953
  const home = yield* Config.String("HOME").pipe(Config.withDefault("."));
32370
- const listed = yield* (yield* ChildProcessSpawner.ChildProcessSpawner).string(ChildProcess.make(claude.value, [
33954
+ const fs = yield* FileSystem.FileSystem;
33955
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
33956
+ const listed = yield* Effect.scoped(Effect.flatMap(emptyFolder(fs), (cwd) => spawner.string(ChildProcess.make(claude.value, [
32371
33957
  "plugin",
32372
33958
  "list",
32373
33959
  "--json"
32374
33960
  ], {
33961
+ cwd,
32375
33962
  env: {
32376
33963
  HOME: home,
32377
33964
  CLAUDE_CONFIG_DIR: configFolder
32378
33965
  },
32379
33966
  extendEnv: true
32380
- })).pipe(Effect.flatMap(decodePlugins), Effect.timeout("15 seconds"), Effect.option);
33967
+ })))).pipe(Effect.flatMap(decodePlugins), Effect.timeout("15 seconds"), Effect.option);
32381
33968
  if (Option.isNone(listed)) return [checkOf("plugin", "unknown", "Claude Code didn’t list its plugins", reinstall)];
32382
33969
  const name = yield* originPluginName((yield* Ref0Config).api);
32383
33970
  const id = `${name}@${name}`;
32384
33971
  const plugin = listed.value.find((each) => each.id === id);
32385
33972
  if (plugin === void 0) return [checkOf("plugin", "no", "not installed in this folder", reinstall)];
32386
33973
  if (!plugin.enabled) return [checkOf("plugin", "no", "turned off in this folder", `CLAUDE_CONFIG_DIR=${shellWord(configFolder)} claude plugin enable ${id}`)];
32387
- const fs = yield* FileSystem.FileSystem;
32388
33974
  const folder = plugin.readFromFolder ?? plugin.installPath;
32389
33975
  const hooks = folder === void 0 ? Option.none() : yield* fs.readFileString(`${folder}/hooks/hooks.json`).pipe(Effect.flatMap(decodeHooks), Effect.option);
32390
33976
  return [speaksCheck(Option.match(hooks, {
@@ -32404,8 +33990,7 @@ const codexChecks = Effect.fnUntraced(function* (configFolder) {
32404
33990
  const reinstall = installLine(configFolder);
32405
33991
  const codex = yield* onPath("codex");
32406
33992
  if (Option.isNone(codex)) return [checkOf("plugin", "unknown", "Codex isn’t on PATH", reinstall)];
32407
- const home = yield* Config.String("HOME").pipe(Config.withDefault("."));
32408
- const listed = yield* askCodex(codex.value, codexMethods.hooksList, { cwds: [home] }, configFolder).pipe(Effect.option);
33993
+ const listed = yield* askCodex(codex.value, codexMethods.hooksList, { cwds: [] }, { CODEX_HOME: configFolder }).pipe(Effect.option);
32409
33994
  if (Option.isNone(listed)) return [checkOf("plugin", "unknown", "Codex didn’t list its hooks", reinstall)];
32410
33995
  const name = yield* originPluginName((yield* Ref0Config).api);
32411
33996
  const hooks = listed.value.data.flatMap((entry) => entry.hooks).filter((hook) => pluginHook(hook, `${name}@${name}`));