@ref0/cli 0.7.0 → 0.8.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 +476 -92
  2. package/package.json +2 -2
package/dist/main.mjs CHANGED
@@ -6510,8 +6510,17 @@ var PasskeyRefused = class extends Schema.TaggedError()("PasskeyRefused", { next
6510
6510
  //#endregion
6511
6511
  //#region ../contracts/src/attachments.ts
6512
6512
  /** A machine asks, a signed-in person claims and approves, and the answer returns to its terminal. */
6513
+ /**
6514
+ * What could reorder or hide the text around it, such as U+202E: a control, format, surrogate,
6515
+ * private-use or unassigned character, or a line or paragraph separator.
6516
+ */
6517
+ const hiddenCharacters = String.raw`\p{C}\p{Zl}\p{Zp}`;
6518
+ /** Text with none of them, which reads as it's written wherever it's shown. */
6519
+ const shownAsWritten = Schema.isPattern(new RegExp(`^[^${hiddenCharacters}]+$`, "u"));
6520
+ /** Text with each character that could reorder or hide what's around it taken out. */
6521
+ const withoutHiddenCharacters = (text) => text.replaceAll(new RegExp(`[${hiddenCharacters}]`, "gu"), "");
6513
6522
  /** A machine's own word for itself: shown to the person as what it says, never as fact. */
6514
- const MachineName = Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(64));
6523
+ const MachineName = Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(64), shownAsWritten);
6515
6524
  /** The harness a machine says it connects for, never proof of what it runs. */
6516
6525
  const ConnectHarness = Schema.Literals(["claude-code", "codex"]);
6517
6526
  /** The machine's loopback listener; no caller may choose its host. */
@@ -6523,7 +6532,7 @@ const AttachmentCallback = Schema.Struct({
6523
6532
  state: Schema.String.check(Schema.isPattern(/^[A-Za-z0-9_-]{22}$/))
6524
6533
  });
6525
6534
  /** A config folder's path, as the machine shows it, never proof of its location. */
6526
- const ConfigFolderPath = Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(200), Schema.isPattern(/^[^\p{C}\p{Zl}\p{Zp}]+$/u));
6535
+ const ConfigFolderPath = Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(200), shownAsWritten);
6527
6536
  const uniqueFolderPaths = Schema.makeFilter((folders) => new Set(folders.map((folder) => folder.path)).size === folders.length, { expected: "unique config folder paths" });
6528
6537
  const RequestFolder = Schema.Struct({
6529
6538
  path: ConfigFolderPath,
@@ -11766,10 +11775,12 @@ Schema.Union([
11766
11775
  NotPermitted
11767
11776
  ]);
11768
11777
  /**
11769
- * A session's fork's path on ref0's host. Its thread's, `/{owner}/{project}/threads/{n}/{repo}.git`
11770
- * on a project's thread and `/{owner}/threads/{n}/{repo}.git` on an org thread, reaches the fork
11771
- * of the session whose token asks, and takes its pushes. A session's,
11772
- * `…/threads/{n}/sessions/{m}/{repo}.git`, reads that session's fork, and takes no push.
11778
+ * A fork's path on ref0's host. Its thread's, `/{owner}/{project}/threads/{n}/{repo}.git` on a
11779
+ * project's thread and `/{owner}/threads/{n}/{repo}.git` on an org thread, reaches the fork of the
11780
+ * session whose token asks, and takes its pushes. A session's,
11781
+ * `…/threads/{n}/sessions/{m}/{repo}.git`, reads that session's current fork, and a fork's,
11782
+ * `…/threads/{n}/forks/{fork}/{repo}.git`, reads that fork when it holds thread `n`'s work, a
11783
+ * session's on it or a run's whose pull request is on it. Neither takes a push.
11773
11784
  */
11774
11785
  const ForkPath = Schema.String.check(Schema.isPattern(/^\/[^\s]+\.git$/), Schema.isMaxLength(512)).pipe(Schema.brand("ForkPath"));
11775
11786
  /** A session's fork's URL on the app's one origin: the one a notice names, and a clone pushes to. */
@@ -11793,17 +11804,21 @@ const ForkView = Schema.Struct({
11793
11804
  /** On the app's one origin, whichever host was asked. */
11794
11805
  url: ForkUrl
11795
11806
  });
11796
- Schema.Struct({
11807
+ /**
11808
+ * For a reader that sees only threads it holds a grant on, those threads: a fork of a thread
11809
+ * stacked, at any depth, on any other is refused, since it holds that thread's work. Unset for one
11810
+ * that reads the project as a whole.
11811
+ */
11812
+ const readerSees = { only: Schema.optionalKey(Schema.Array(ThreadId)) };
11813
+ Schema.Union([Schema.Struct({
11797
11814
  session: SessionId,
11798
- fork: Schema.optionalKey(ForkId),
11799
- /**
11800
- * For a reader that sees only threads it holds a grant on, those threads: a fork of a thread
11801
- * stacked, at any depth, on any other is refused, since it holds that thread's work. Unset for
11802
- * one that reads the project as a whole.
11803
- */
11804
- only: Schema.optionalKey(Schema.Array(ThreadId))
11805
- });
11806
- /** A session's fork in Artifacts, as Git reaches it: where it is, and which Git repository. */
11815
+ ...readerSees
11816
+ }), Schema.Struct({
11817
+ fork: ForkId,
11818
+ thread: ThreadId,
11819
+ ...readerSees
11820
+ })]);
11821
+ /** A fork in Artifacts, as Git reaches it: where it is, and which Git repository. */
11807
11822
  const ForkPlace = Schema.Struct({
11808
11823
  ...CurrentBaseline.fields,
11809
11824
  gitRepository: GitRepositoryId
@@ -11834,19 +11849,16 @@ const SessionBase = Schema.Struct({
11834
11849
  commit: CommitOid,
11835
11850
  /**
11836
11851
  * Where to fetch it from, on ref0's origin: the repo's own path, as `ref0 clone` cloned it, for
11837
- * `main`; for a stack, the path of the fork that holds the pin.
11852
+ * `main`; for a stack, the path of the fork that holds the pin, by its ID under the thread below.
11838
11853
  */
11839
11854
  path: Schema.String.check(Schema.isPattern(/^\/[^\s]+\.git$/), Schema.isMaxLength(512)),
11840
11855
  /** `path` on the app's one origin. A client checks it's a fork of its own repo there. */
11841
11856
  url: Schema.String,
11842
11857
  ref: Schema.Literal("refs/heads/main")
11843
11858
  });
11844
- /**
11845
- * No base to fetch: `main` has no commit yet, or the pin of the thread below is in a workflow
11846
- * run's fork, which has no session's path to fetch from.
11847
- */
11859
+ /** No base to fetch: `main` has no commit yet. */
11848
11860
  var NoBase = class extends Schema.TaggedError()("NoBase", {
11849
- why: Schema.Literals(["main empty", "held by a run"]),
11861
+ why: Schema.Literal("main empty"),
11850
11862
  nextStep: Schema.String
11851
11863
  }, { httpApiStatus: 404 }) {};
11852
11864
  Schema.Struct({
@@ -11864,15 +11876,14 @@ const BaseOnMain = Schema.Struct({
11864
11876
  commit: CommitOid
11865
11877
  });
11866
11878
  /**
11867
- * The base on the thread below, `lower`: its pin's commit, the fork that holds it, and that
11868
- * fork's session, or none for a workflow run's fork.
11879
+ * The base on the thread below, `lower`: its pin's commit, and the fork that holds it, a session's
11880
+ * on that thread or a run's whose pull request is on it.
11869
11881
  */
11870
11882
  const BaseOnStack = Schema.Struct({
11871
11883
  on: Schema.Literal("stack"),
11872
11884
  lower: ThreadId,
11873
11885
  commit: CommitOid,
11874
- fork: ForkId,
11875
- session: Schema.NullOr(SessionId)
11886
+ fork: ForkId
11876
11887
  });
11877
11888
  Schema.Union([
11878
11889
  BaseOnMain,
@@ -14978,6 +14989,11 @@ var ApprovalSecret = class extends HttpApiMiddleware.Service()("ref0/contracts/A
14978
14989
  security: { bearer: HttpApiSecurity.bearer },
14979
14990
  error: [Unauthenticated]
14980
14991
  }) {};
14992
+ /**
14993
+ * A machine's request whose name its contract refuses, answered as `CommandRefused`, which says
14994
+ * what to change. Any other part that fails its contract is answered with Effect's empty 400.
14995
+ */
14996
+ var MachineNameRefusal = class extends HttpApiMiddleware.Service()("ref0/contracts/MachineNameRefusal", { error: CommandRefused }) {};
14981
14997
  const idempotency = { "idempotency-key": RequestId };
14982
14998
  const inProject$1 = {
14983
14999
  owner: OwnerRef,
@@ -15101,7 +15117,7 @@ var MachineAttachmentsGroup = class extends HttpApiGroup.make("machineAttachment
15101
15117
  SlowDown,
15102
15118
  AuthorityUnavailable
15103
15119
  ]
15104
- }), HttpApiEndpoint.post("collect", "/v1/attachments/requests/:approval/result", {
15120
+ }).middleware(MachineNameRefusal), HttpApiEndpoint.post("collect", "/v1/attachments/requests/:approval/result", {
15105
15121
  params: { approval: ApprovalId },
15106
15122
  payload: Schema.Struct({ answerCode: Schema.optionalKey(Schema.String.check(Schema.isMaxLength(64))) }),
15107
15123
  success: RequestState,
@@ -19074,6 +19090,14 @@ const renderBlocks = (blocks, stream) => {
19074
19090
  };
19075
19091
  /** Words go to stderr by default; a command's answer goes to stdout. */
19076
19092
  const printBlocks = (blocks, stream = "stderr") => Effect.sync(() => renderBlocks(blocks, stream)).pipe(Effect.flatMap((text) => stream === "stdout" ? Console.log(text) : Console.error(text)));
19093
+ /**
19094
+ * A yes-or-no question as the terminal shows it, bold, then `(y/N)`: no unless the person says
19095
+ * yes. The cursor stays after it, for their answer.
19096
+ */
19097
+ const questionLine = (question) => `${renderBlocks([[{
19098
+ label: question,
19099
+ text: "(y/N)"
19100
+ }]], "stdout")} `;
19077
19101
  /** Keep explicit line breaks and wrap between words within the available columns. */
19078
19102
  const wrapWords = (text, width) => text.split("\n").flatMap((line) => {
19079
19103
  const wrapped = [];
@@ -19142,6 +19166,23 @@ const accountCommands = [Command.make("whoami", { json }, ({ json: asJson }) =>
19142
19166
  yield* printAnswer(answer.json, asJson, me.agent === void 0 ? `You, as ${me.actor.id}.` : `Agent ${me.agent.id}, in session ${me.session?.session ?? "none"}.`);
19143
19167
  }).pipe(Effect.catchTag("CallFailed", (failed) => sayFailed(failed.nextStep)))).pipe(Command.withDescription("Who’s acting, and what they hold"), Command.withShortDescription("Show who’s acting and what they hold"))];
19144
19168
  //#endregion
19169
+ //#region ../model/src/agent-commits.ts
19170
+ /**
19171
+ * How an agent's commits name it as their author: `pip/claude <agent_…@ref0.work>`. The person
19172
+ * stays the committer, with their own signature. Git keeps every commit for good, and anyone
19173
+ * who reads the repo reads its author, so these name the agent and nothing about its sessions.
19174
+ */
19175
+ /**
19176
+ * An agent's commit email: built from its agent ID alone, so it never changes, and never passes
19177
+ * to another agent. Nobody reads mail there.
19178
+ */
19179
+ const agentCommitEmail = (agent) => `${agent}@ref0.work`;
19180
+ /**
19181
+ * An agent's name as its commits' author: its owner, then its handle, as commands write it. A
19182
+ * handle renamed later stays as it was in the commits made before.
19183
+ */
19184
+ const agentCommitName = (owner, handle) => `${owner}/${handle}`;
19185
+ //#endregion
19145
19186
  //#region ../model/src/cut-text.ts
19146
19187
  /**
19147
19188
  * `text` within `max` as a `Schema`'s length counts it, in UTF-16 code units: whole code points
@@ -19422,13 +19463,13 @@ function freeTextProblem(text, maxCharacters) {
19422
19463
  //#endregion
19423
19464
  //#region ../model/src/git-paths.ts
19424
19465
  /**
19425
- * A session's fork's path: an owner, a project for a project's thread and none for an org
19426
- * thread's, a thread, a session when it names one and the source fork when that names one too, a
19427
- * repo, and what Git asks for. The two thread forms never read alike: after `threads` comes a
19428
- * number on an org thread's, and the word `threads` on a project's, so a project named `threads`
19429
- * keeps its forks. Nor do the two fork forms: a repo's name holds no `/`.
19466
+ * A fork's path: an owner, a project for a project's thread and none for an org thread's, a
19467
+ * thread, then a session or a fork by its ID when it names one, a repo, and what Git asks for.
19468
+ * The two thread forms never read alike: after `threads` comes a number on an org thread's, and
19469
+ * the word `threads` on a project's, so a project named `threads` keeps its forks. Nor do the three
19470
+ * fork forms: a repo's name holds no `/`.
19430
19471
  */
19431
- 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)$/;
19472
+ 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)$/;
19432
19473
  //#endregion
19433
19474
  //#region ../model/src/thread-workflows.ts
19434
19475
  const minuteMillis = 6e4;
@@ -20340,6 +20381,15 @@ const watchVariables = {
20340
20381
  event: "REF0_EVENT",
20341
20382
  title: "REF0_TITLE"
20342
20383
  };
20384
+ /**
20385
+ * 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.
20388
+ */
20389
+ const gitHookVariables = {
20390
+ harness: "REF0_HARNESS",
20391
+ forwarded: "REF0_GIT_HOOK"
20392
+ };
20343
20393
  //#endregion
20344
20394
  //#region src/harness/plugin-files.ts
20345
20395
  /**
@@ -20814,11 +20864,16 @@ const connectLineFor = Effect.fnUntraced(function* (harness, configFolder) {
20814
20864
  });
20815
20865
  //#endregion
20816
20866
  //#region src/host-grant.ts
20817
- /** All lasting authority for one config folder, stored as one keychain value. */
20867
+ /**
20868
+ * All lasting authority for one config folder, stored as one keychain value: the agent the folder
20869
+ * acts as, by its owner, handle and agent ID, and its token. The ID is kept in the same write as
20870
+ * the token, so a folder with a grant always knows which agent its commits name.
20871
+ */
20818
20872
  const KeptHostGrant = Schema.Struct({
20819
20873
  harness: ConnectHarness,
20820
20874
  owner: OwnerName,
20821
20875
  agent: AgentHandle,
20876
+ agentId: AgentId,
20822
20877
  token: Schema.RedactedFromValue(Schema.String)
20823
20878
  });
20824
20879
  const decodeGrant = Schema.decodeUnknownOption(Schema.fromJsonString(KeptHostGrant));
@@ -20865,7 +20920,7 @@ const grantForAgent = Effect.fnUntraced(function* (owner, agent) {
20865
20920
  * an update installs anew. Node, run from source, loads JSON only with the `type` attribute,
20866
20921
  * and only as a default export.
20867
20922
  */
20868
- const cliVersion = "0.7.0";
20923
+ const cliVersion = "0.8.0";
20869
20924
  //#endregion
20870
20925
  //#region src/harness/hook-protocol.ts
20871
20926
  /**
@@ -20909,8 +20964,9 @@ const updateLine = (plugin, reinstall) => Option.map(protocolMismatch(plugin, re
20909
20964
  //#region src/machine.ts
20910
20965
  /**
20911
20966
  * This machine, as ref0 knows it: an ID made once and kept in ref0's config, `machine` under
20912
- * `$XDG_CONFIG_HOME/ref0` or `~/.config/ref0`, and its own name for itself, which ref0 shows
20913
- * the person as what it says. Neither is a secret, and neither is proof of anything.
20967
+ * `$XDG_CONFIG_HOME/ref0` or `~/.config/ref0`, and its host name, from which `ref0 connect` makes
20968
+ * the name ref0 shows the person as what it says. Neither is a secret, and neither is proof of
20969
+ * anything.
20914
20970
  */
20915
20971
  var MachineError = class extends Schema.TaggedError()("MachineError", { nextStep: Schema.String }) {};
20916
20972
  const decodeMachineId = Schema.decodeUnknownOption(MachineId);
@@ -20938,10 +20994,10 @@ var Machine = class Machine extends Context.Service()("ref0/cli/Machine") {
20938
20994
  onSome: Effect.succeed
20939
20995
  }));
20940
20996
  });
20941
- const name = (yield* Effect.sync(() => hostname())).slice(0, 64) || "this machine";
20997
+ const hostName = yield* Effect.sync(() => hostname());
20942
20998
  return Machine.of({
20943
20999
  id,
20944
- name
21000
+ hostName
20945
21001
  });
20946
21002
  }));
20947
21003
  };
@@ -21052,18 +21108,31 @@ const keepBudget = Duration.minutes(5);
21052
21108
  /** A request that couldn't finish: no answer in time, no connection, or ref0 briefly unable. */
21053
21109
  var Unanswered = class extends Schema.TaggedError()("Unanswered", {}) {};
21054
21110
  const unanswered = () => Effect.fail(new Unanswered());
21111
+ /**
21112
+ * The name the machine sends for itself: its host name without any character that could reorder
21113
+ * or hide what's around it, cut to 64 characters without splitting one in two, or `this machine`
21114
+ * when that leaves none.
21115
+ */
21116
+ const machineNameOf = (hostName) => {
21117
+ let name = "";
21118
+ for (const character of withoutHiddenCharacters(hostName).trim()) {
21119
+ if (name.length + character.length > 64) break;
21120
+ name += character;
21121
+ }
21122
+ return name || "this machine";
21123
+ };
21055
21124
  /** A refusal ref0 gave, said as it gave it. */
21056
21125
  const said = (refusal) => sayFailed(refusal.nextStep);
21057
21126
  const isEnded = (state) => state.state !== "waiting";
21058
21127
  /** The line that asks again. */
21059
- const again$1 = "ref0 connect";
21128
+ const again$2 = "ref0 connect";
21060
21129
  /** What each way a request can end without connecting says, with the line that asks again. */
21061
21130
  const endedWords$1 = (state) => {
21062
21131
  switch (state.state) {
21063
- case "refused": return `It was refused, and nothing was connected. To ask again, run: ${again$1}`;
21064
- case "aborted": return `It was aborted, and nothing was connected. To ask again, run: ${again$1}`;
21065
- case "expired": return `Nobody approved it in time, and nothing was connected. For a new code, run: ${again$1}`;
21066
- case "failed": return `${state.nextStep} To connect this machine, run: ${again$1}`;
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}`;
21067
21136
  }
21068
21137
  };
21069
21138
  /** The grant a kept record holds, by its ID. */
@@ -21111,7 +21180,7 @@ const requestAttachment = Effect.fnUntraced(function* (asked) {
21111
21180
  }));
21112
21181
  const payload = {
21113
21182
  machine: machineId,
21114
- machineName: machine.name,
21183
+ machineName: machineNameOf(machine.hostName),
21115
21184
  folders,
21116
21185
  collectHash: yield* Effect.orDie(decodeSecretHash$1(collecting.hash)),
21117
21186
  linkHash: yield* Effect.orDie(decodeSecretHash$1(link.hash))
@@ -21162,7 +21231,7 @@ const requestAttachment = Effect.fnUntraced(function* (asked) {
21162
21231
  while (true) {
21163
21232
  const state = yield* collect;
21164
21233
  if (Option.isSome(state) && isEnded(state.value)) return state.value;
21165
- 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$1}`);
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}`);
21166
21235
  yield* Effect.sleep(pollMillis);
21167
21236
  }
21168
21237
  });
@@ -21174,6 +21243,7 @@ const requestAttachment = Effect.fnUntraced(function* (asked) {
21174
21243
  harness: folder.harness,
21175
21244
  owner: connection.owner,
21176
21245
  agent: connection.handle,
21246
+ agentId: connection.agent.id,
21177
21247
  token: Redacted.make(token)
21178
21248
  }).pipe(Effect.timeoutOrElse({
21179
21249
  duration: Duration.min(keepTimeout, Duration.millis(left)),
@@ -21225,12 +21295,12 @@ const requestAttachment = Effect.fnUntraced(function* (asked) {
21225
21295
  "It connected, but this machine couldn’t keep what it needs to act:",
21226
21296
  ...notKept.map((each) => `${shownConfigFolder(home, each.configFolder)}: ${each.nextStep}`),
21227
21297
  "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:",
21228
- { command: again$1 }
21298
+ { command: again$2 }
21229
21299
  ]]);
21230
- 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}`);
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}`);
21231
21301
  const confirmId = yield* newRequestId;
21232
21302
  const answerCode = yield* Ref.get(pasted);
21233
- if (answerCode === void 0) return yield* sayFailed(`The answer code is missing. Run ${again$1} again.`);
21303
+ if (answerCode === void 0) return yield* sayFailed(`The answer code is missing. Run ${again$2} again.`);
21234
21304
  if (!(yield* collector.machineAttachments.confirm({
21235
21305
  params,
21236
21306
  headers: { "idempotency-key": confirmId },
@@ -21253,7 +21323,7 @@ const requestAttachment = Effect.fnUntraced(function* (asked) {
21253
21323
  schedule: Schedule.spaced("1 second")
21254
21324
  }), Effect.catchTag("Unanswered", unconfirmed))).confirmed) {
21255
21325
  for (const folder of kept) yield* hostGrant.remove(folder.configFolder).pipe(Effect.ignore);
21256
- 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}`);
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}`);
21257
21327
  }
21258
21328
  return {
21259
21329
  owner: state.owner,
@@ -21442,6 +21512,89 @@ var ConnectLock = class ConnectLock extends Context.Service()("ref0/cli/ConnectL
21442
21512
  }));
21443
21513
  };
21444
21514
  //#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);
21555
+ });
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;
21565
+ }
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;
21575
+ }
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;
21582
+ }
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;
21590
+ }
21591
+ typed = "";
21592
+ yield* terminal.display(`${again$1}\n${questionLine(question)}`);
21593
+ }
21594
+ }).pipe(Effect.scoped, Effect.orElseSucceed(() => false));
21595
+ return ask;
21596
+ });
21597
+ //#endregion
21445
21598
  //#region src/harness/install-claude-code.ts
21446
21599
  /** Install into one config folder, from the release Claude Code fetched and ref0 checked. */
21447
21600
  const installClaudeCode = Effect.fnUntraced(function* (release, configFolder) {
@@ -22020,41 +22173,24 @@ const onPath = Effect.fnUntraced(function* (name) {
22020
22173
  const runs$2 = (hook) => hook.handlerType === "command" ? printable(hook.command ?? "") : `the ${printable(hook.tool ?? "")} tool on MCP server ${printable(hook.server ?? "")}`;
22021
22174
  /**
22022
22175
  * Shows the person ref0's hooks for Codex, on stderr so a JSON answer stays alone on stdout,
22023
- * and asks whether Codex may run them. Only in a terminal, and never for `--json`: anywhere
22024
- * else the answer is no, and the hooks stay off until the person runs it again or trusts them in
22025
- * Codex itself.
22026
- */
22027
- const askToTrust = (hooks, shown, asked) => Effect.gen(function* () {
22028
- const terminal = process.stdin.isTTY === true && process.stdout.isTTY === true && process.stderr.isTTY === true;
22029
- if (!asked || !terminal) return false;
22030
- yield* Console.error([`Codex runs a plugin's hooks only once you trust them. ref0's, for ${printable(shown)}, are:`, ...hooks.map((hook) => `- ${hook.eventName}: ${runs$2(hook)}`)].join("\n"));
22031
- return yield* Prompt.run(Prompt.Confirm({
22032
- message: "Trust them, so Codex runs them?",
22033
- initial: false
22034
- }));
22035
- }).pipe(Effect.orElseSucceed(() => false));
22176
+ * and asks whether Codex may run them. On a no, or with no terminal to ask in, the hooks stay
22177
+ * off until the person runs it again or trusts them in Codex itself.
22178
+ */
22179
+ const askToTrust = (hooks, shown, yesOrNo) => yesOrNo([`Codex runs a plugin's hooks only once you trust them. ref0's, for ${printable(shown)}, are:`, ...hooks.map((hook) => `- ${hook.eventName}: ${runs$2(hook)}`)], "Trust them, so Codex runs them?");
22036
22180
  /**
22037
22181
  * Shows the person ref0's sandbox profile for Codex, on stderr, and asks whether it may be
22038
- * Codex's default: only in a terminal, and never for `--json`, as with the hooks. A change to
22039
- * their Codex's security, so it's shown in full and asked the first time (decided 9 Oct, 5).
22040
- */
22041
- const askForProfile = (replacing, shown, asked) => Effect.gen(function* () {
22042
- const terminal = process.stdin.isTTY === true && process.stdout.isTTY === true && process.stderr.isTTY === true;
22043
- if (!asked || !terminal) return false;
22044
- yield* Console.error([
22045
- "Codex's sandbox keeps commands from writing .git or reaching the network, so a Codex agent can't commit or push to ref0.",
22046
- "ref0's profile, \"ref0\", is Codex's workspace profile with two changes:",
22047
- "- each workspace root's .git is writable, so an agent can commit",
22048
- "- 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",
22049
- "In a linked worktree (git worktree add), Codex still needs full access there.",
22050
- ...replacing.default === void 0 ? [] : [`It replaces your default now, ${printable(replacing.default)}.`],
22051
- ...replacing.ownRef0 ? ["It replaces the profile you have named \"ref0\", and every rule in it."] : []
22052
- ].join("\n"));
22053
- return yield* Prompt.run(Prompt.Confirm({
22054
- message: `Make "ref0" the default profile for ${printable(shown)}?`,
22055
- initial: false
22056
- }));
22057
- }).pipe(Effect.orElseSucceed(() => false));
22182
+ * Codex's default. A change to their Codex's security, so it's shown in full and asked the first
22183
+ * time (decided 9 Oct, 5).
22184
+ */
22185
+ const askForProfile = (replacing, shown, yesOrNo) => yesOrNo([
22186
+ "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:",
22188
+ "- each workspace root's .git is writable, so an agent can commit",
22189
+ "- 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
+ "In a linked worktree (git worktree add), Codex still needs full access there.",
22191
+ ...replacing.default === void 0 ? [] : [`It replaces your default now, ${printable(replacing.default)}.`],
22192
+ ...replacing.ownRef0 ? ["It replaces the profile you have named \"ref0\", and every rule in it."] : []
22193
+ ], `Make "ref0" the default profile for ${printable(shown)}?`);
22058
22194
  /** What a Codex folder without ref0's profile, or full access, can't do. */
22059
22195
  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.";
22060
22196
  /**
@@ -22105,7 +22241,7 @@ const writeRef0Launcher = Effect.fnUntraced(function* (home, git) {
22105
22241
  const installInto = Effect.fnUntraced(function* (folders, options) {
22106
22242
  const home = yield* Config.String("HOME");
22107
22243
  const name = yield* originPluginName((yield* Ref0Config).api);
22108
- const terminal = yield* Effect.context();
22244
+ const yesOrNo = options.asJson ? () => Effect.succeed(false) : yield* yesOrNoQuestions;
22109
22245
  const answers = /* @__PURE__ */ new Map();
22110
22246
  const failAll = (into, failed) => {
22111
22247
  for (const folder of into) answers.set(folder, Result.fail(failed));
@@ -22136,8 +22272,7 @@ const installInto = Effect.fnUntraced(function* (folders, options) {
22136
22272
  home,
22137
22273
  configFolder: folder.configFolder,
22138
22274
  name,
22139
- asked: !options.asJson,
22140
- terminal
22275
+ yesOrNo
22141
22276
  })).pipe(Effect.result);
22142
22277
  answers.set(folder, installed);
22143
22278
  }
@@ -22163,8 +22298,8 @@ const installedInCodex = Effect.fnUntraced(function* (release, options) {
22163
22298
  codex: options.codex,
22164
22299
  home: options.home,
22165
22300
  configFolder: options.configFolder,
22166
- approve: (hooks) => askToTrust(hooks, shown, options.asked).pipe(Effect.provide(options.terminal)),
22167
- approveProfile: (replacing) => askForProfile(replacing, shown, options.asked).pipe(Effect.provide(options.terminal))
22301
+ approve: (hooks) => askToTrust(hooks, shown, options.yesOrNo),
22302
+ approveProfile: (replacing) => askForProfile(replacing, shown, options.yesOrNo)
22168
22303
  });
22169
22304
  const sandbox = done.profile ? "" : ` ${sandboxWords}`;
22170
22305
  const trust = trustRef0Hooks(installLine(options.configFolder), `${options.name}@${options.name}`);
@@ -31731,7 +31866,7 @@ const codexSetupHere = Effect.fnUntraced(function* (process) {
31731
31866
  const mcp = Command.make(harnessCommandNames.mcp, {}, () => Effect.gen(function* () {
31732
31867
  const agents = yield* ConfigFolders;
31733
31868
  const found = yield* (yield* HarnessProcess).innermost;
31734
- const agent = Option.isSome(found) ? yield* (yield* HostGrant).get(yield* agents.folder(found.value.harness)) : Option.none();
31869
+ const agent = Option.isSome(found) ? yield* (yield* HostGrant).get(yield* agents.folder(found.value.harness)).pipe(Effect.catchIf((failed) => failed.unreadable === true, () => Effect.succeedNone)) : Option.none();
31735
31870
  const codex = Option.isSome(found) && found.value.harness === "codex" ? yield* codexSetupHere(found.value.process) : Option.none();
31736
31871
  const reinstall = Option.isSome(found) && Option.isSome(agent) ? installLine(yield* agents.folder(found.value.harness)) : "ref0 connect";
31737
31872
  return yield* Layer.launch(mcpServer(cliVersion, {
@@ -31827,6 +31962,245 @@ const endHarnessSession = Effect.gen(function* () {
31827
31962
  }).pipe(Effect.ignore);
31828
31963
  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"));
31829
31964
  //#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
+ //#region src/harness/commit-environment.ts
32090
+ /**
32091
+ * The agent as the author of its commits, set at a Claude Code session's start in the
32092
+ * environment of the agent's own commands, and nowhere else: the file `CLAUDE_ENV_FILE` names,
32093
+ * which Claude Code runs ahead of each of them. It sets Git's author to the agent, by its owner
32094
+ * 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.
32098
+ *
32099
+ * The agent is the one the session's config folder acts as, from that folder's host grant, which
32100
+ * `ref0 connect` keeps with the agent's ID. It sets no author when the grant can't be read, or
32101
+ * when the session's checkout signs off its commits: any of the last 20 on its default branch
32102
+ * has a `Signed-off-by`, or it has `.github/dco.yml`. A DCO check wants each sign-off to match
32103
+ * its commit's author, and an agent never signs off. The hooks path is still set.
32104
+ */
32105
+ /** How many commits on the default branch a session's start reads for a sign-off. */
32106
+ const signedOffWithin = 20;
32107
+ /**
32108
+ * What a session's start adds to the file Claude Code runs ahead of the agent's commands. The
32109
+ * hooks path goes after the entries a command already has, once however often the file runs.
32110
+ * Without an author, it drops one an outer agent's session set.
32111
+ */
32112
+ const environmentScript = (set) => [
32113
+ "",
32114
+ "# ref0: the agent as the author of its commits, and ref0's Git hooks, in its commands",
32115
+ ...Option.match(set.author, {
32116
+ onNone: () => ["case ${GIT_AUTHOR_EMAIL-} in *@ref0.work) unset GIT_AUTHOR_NAME GIT_AUTHOR_EMAIL ;; esac"],
32117
+ onSome: (author) => [`export GIT_AUTHOR_NAME=${shellWord(author.name)}`, `export GIT_AUTHOR_EMAIL=${shellWord(author.email)}`]
32118
+ }),
32119
+ `export ${gitHookVariables.harness}=${set.harness}`,
32120
+ "if [ -z \"${ref0_hooks_set-}\" ]; then",
32121
+ " ref0_n=${GIT_CONFIG_COUNT:-0}",
32122
+ ` export "GIT_CONFIG_KEY_$ref0_n=core.hooksPath" "GIT_CONFIG_VALUE_$ref0_n="${shellWord(set.folder)} "GIT_CONFIG_COUNT=$((ref0_n + 1))"`,
32123
+ " ref0_hooks_set=1",
32124
+ " unset ref0_n",
32125
+ "fi",
32126
+ ""
32127
+ ].join("\n");
32128
+ /**
32129
+ * What the agent reads at its session's start once its commands name ref0's hooks folder: an
32130
+ * installer run there fails on that read-only folder, and without ref0's entry it installs into
32131
+ * the repo's own.
32132
+ */
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.`;
32134
+ /**
32135
+ * What the session's checkout says: which of ref0's hooks folders it needs, `all` when it has
32136
+ * its own frequent hook, and whether it signs off its commits. Outside a checkout, neither.
32137
+ */
32138
+ const checkoutAt = Effect.fnUntraced(function* (cwd) {
32139
+ const fs = yield* FileSystem.FileSystem;
32140
+ const path = yield* Path.Path;
32141
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
32142
+ const git = (folder, args) => spawner.string(ChildProcess.make("git", [
32143
+ "-C",
32144
+ folder,
32145
+ ...args
32146
+ ])).pipe(Effect.catchCause(() => Effect.succeed("")));
32147
+ const executable = (file) => fs.stat(file).pipe(Effect.map((info) => info.type === "File" && (info.mode & 73) !== 0), Effect.orElseSucceed(() => false));
32148
+ const top = (yield* git(cwd, ["rev-parse", "--show-toplevel"])).trim();
32149
+ const outside = {
32150
+ folder: "most",
32151
+ signsOff: false
32152
+ };
32153
+ if (top === "") return outside;
32154
+ const hooks = (yield* git(top, ["rev-parse", ...frequentHooks.flatMap((name) => ["--git-path", `hooks/${name}`])])).split("\n").filter(Boolean);
32155
+ const folder = (yield* Effect.forEach(hooks, (hook) => executable(path.resolve(top, hook)))).some(Boolean) ? "all" : "most";
32156
+ if (yield* fs.exists(path.join(top, ".github", "dco.yml")).pipe(Effect.orElseSucceed(() => false))) return {
32157
+ folder,
32158
+ signsOff: true
32159
+ };
32160
+ const defaultBranch = (yield* git(top, [
32161
+ "symbolic-ref",
32162
+ "-q",
32163
+ "refs/remotes/origin/HEAD"
32164
+ ])).trim();
32165
+ return {
32166
+ folder,
32167
+ signsOff: (yield* git(top, [
32168
+ "log",
32169
+ "--no-show-signature",
32170
+ "-n",
32171
+ `${signedOffWithin}`,
32172
+ "--format=%(trailers:key=Signed-off-by,valueonly)",
32173
+ defaultBranch === "" ? "HEAD" : defaultBranch,
32174
+ "--"
32175
+ ])).trim() !== ""
32176
+ };
32177
+ });
32178
+ /**
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.
32182
+ */
32183
+ const setCommitEnvironment = Effect.fnUntraced(function* (cwd) {
32184
+ const fs = yield* FileSystem.FileSystem;
32185
+ const folders = yield* ConfigFolders;
32186
+ const file = yield* Config.String("CLAUDE_ENV_FILE").pipe(Config.withDefault(""));
32187
+ if (file === "") return false;
32188
+ 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
+ if (Option.isNone(connected)) return false;
32190
+ const checkout = yield* checkoutAt(cwd);
32191
+ const folder = yield* hooksFolder(yield* gitHooksHome(yield* Config.String("HOME"), (yield* Ref0Config).api), checkout.folder);
32192
+ const author = checkout.signsOff ? Option.none() : Option.map(connected.value, (grant) => ({
32193
+ name: agentCommitName(grant.owner, grant.agent),
32194
+ email: agentCommitEmail(grant.agentId)
32195
+ }));
32196
+ yield* fs.writeFileString(file, environmentScript({
32197
+ author,
32198
+ folder,
32199
+ harness: "claude-code"
32200
+ }), { flag: "a" });
32201
+ return true;
32202
+ });
32203
+ //#endregion
31830
32204
  //#region src/harness/session-start.ts
31831
32205
  /**
31832
32206
  * `ref0 hook session-start`: the harness's `SessionStart` hook, a command, before `ref0 mcp`
@@ -31876,11 +32250,19 @@ const startOnHook = (protocol) => Effect.gen(function* () {
31876
32250
  const input = yield* stdio.stdin.pipe(Stream.decodeText(), Stream.mkString);
31877
32251
  const hook = decodeHook(input);
31878
32252
  if (Option.isNone(hook)) return;
32253
+ if (yield* setCommitEnvironment(hook.value.cwd ?? ".").pipe(Effect.orElseSucceed(() => false))) yield* Console.log(hookInstallerWords);
31879
32254
  const binding = yield* bindingHere(hook.value.session_id);
31880
32255
  if (Option.isNone(binding)) return;
31881
32256
  const harnessName = binding.value.harness === "codex" ? "codex" : "claude-code";
31882
32257
  const folder = yield* agents.folder(harnessName);
31883
- const agent = yield* (yield* HostGrant).get(folder);
32258
+ const kept = yield* (yield* HostGrant).get(folder).pipe(Effect.map((agent) => ({
32259
+ agent,
32260
+ readable: true
32261
+ })), Effect.catchIf((failed) => failed.unreadable === true, () => Effect.succeed({
32262
+ agent: Option.none(),
32263
+ readable: false
32264
+ })));
32265
+ const agent = kept.agent;
31884
32266
  const repair = Option.isSome(agent) ? installLine(folder) : yield* connectLineFor(harnessName, folder);
31885
32267
  const update = updateLine({
31886
32268
  protocol,
@@ -31889,7 +32271,8 @@ const startOnHook = (protocol) => Effect.gen(function* () {
31889
32271
  if (Option.isSome(update)) return yield* Console.log(update.value);
31890
32272
  if (Option.isNone(agent)) {
31891
32273
  const name = harnessName === "codex" ? "Codex" : "Claude Code";
31892
- return yield* Console.log(`ref0: no agent is connected for this ${name} config folder, ${folder}, so no session starts. In your own terminal, run: ${repair}`);
32274
+ const why = kept.readable ? `no agent is connected for this ${name} config folder, ${folder}` : `the host grant kept for this ${name} config folder, ${folder}, can't be read`;
32275
+ return yield* Console.log(`ref0: ${why}, so no session starts. In your own terminal, run: ${repair}`);
31893
32276
  }
31894
32277
  const account = sessionAccount(binding.value);
31895
32278
  const held = yield* keychain.get(account);
@@ -32060,12 +32443,13 @@ const statusCommands = [Command.make(harnessCommandNames.status, { json }, ({ js
32060
32443
  const reports = [];
32061
32444
  for (const configFolder of paths) {
32062
32445
  const kept = yield* Effect.result(grants.get(configFolder));
32446
+ const unreadable = Result.isFailure(kept) && kept.failure.unreadable === true;
32063
32447
  const grant = Result.isSuccess(kept) ? kept.success : Option.none();
32064
32448
  const harness = Option.isSome(grant) ? grant.value.harness : candidates.get(configFolder);
32065
32449
  if (Result.isFailure(kept) || Option.isNone(grant)) {
32066
32450
  const report = {
32067
32451
  configFolder,
32068
- checks: [checkOf("connected", Result.isFailure(kept) ? "unknown" : "no", Result.isFailure(kept) ? "The keychain didn’t say whether this config folder holds a host grant." : "not connected", Result.isFailure(kept) ? kept.failure.nextStep : harness === void 0 ? "ref0 connect" : yield* connectLineFor(harness, configFolder))]
32452
+ checks: [Result.isFailure(kept) && !unreadable ? checkOf("connected", "unknown", "The keychain didn’t say whether this config folder holds a host grant.", kept.failure.nextStep) : checkOf("connected", "no", unreadable ? "its host grant can’t be read" : "not connected", harness === void 0 ? "ref0 connect" : yield* connectLineFor(harness, configFolder))]
32069
32453
  };
32070
32454
  reports.push(harness === void 0 ? report : {
32071
32455
  ...report,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ref0/cli",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "ref0's command line, which connects an agent's machine to ref0",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -29,7 +29,7 @@
29
29
  "@effect/vitest": "4.0.0",
30
30
  "@ref0/contracts": "0.0.0",
31
31
  "@ref0/model": "0.0.0",
32
- "@ref0/sdk": "0.7.0",
32
+ "@ref0/sdk": "0.8.0",
33
33
  "@types/node": "24.19.2",
34
34
  "typescript": "7.0.2",
35
35
  "vite": "npm:@voidzero-dev/vite-plus-core@1.3.0",