@ref0/cli 0.9.0 → 0.10.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 +562 -234
  2. package/package.json +2 -2
package/dist/main.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { NodeRuntime, NodeServices } from "@effect/platform-node";
3
- import { Cause, Clock, Config, ConfigProvider, Console, Context, Cron, Crypto, Data, DateTime, Deferred, Duration, Effect, Exit, FiberSet, FileSystem, Layer, Match, Option, Path, Predicate, Queue, Random, Redacted, Ref, Result, Runtime, Schedule, Schema, SchemaIssue, SchemaTransformation, Scope, Semaphore, Stdio, Stream, Struct, Terminal, Tuple } from "effect";
3
+ import { Cause, Clock, Config, ConfigProvider, Console, Context, Cron, Crypto, Data, DateTime, Deferred, Duration, Effect, Exit, Fiber, FiberSet, FileSystem, Layer, Match, Option, Path, Predicate, Queue, Random, Redacted, Ref, Result, Runtime, Schedule, Schema, SchemaIssue, SchemaTransformation, Scope, Semaphore, Stdio, Stream, Struct, Terminal, Tuple } from "effect";
4
4
  import { Argument, CliOutput, Command, Flag, Prompt } from "effect/cli";
5
5
  import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/http";
6
6
  import { HttpApi, HttpApiClient, HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, HttpApiSecurity } from "effect/http-api";
@@ -11,7 +11,7 @@ import { access, constants } from "node:fs/promises";
11
11
  import { hostname } from "node:os";
12
12
  import { connect, createServer as createServer$1 } from "node:net";
13
13
  import { join } from "node:path";
14
- import { McpProtocol, McpServer, Tool, Toolkit } from "effect/ai";
14
+ import { McpProtocol, McpSchema, McpServer, Tool, Toolkit } from "effect/ai";
15
15
  //#region ../contracts/src/typeid.ts
16
16
  /**
17
17
  * TypeIDs: a prefix, an underscore, then a UUID in 26 characters of Crockford's base32,
@@ -18464,7 +18464,7 @@ const HooksFile = Schema.Struct({ hooks: Schema.Record(Schema.String, Schema.Arr
18464
18464
  const McpServersFile = Schema.Struct({ mcpServers: Schema.Record(Schema.String, Schema.Struct({
18465
18465
  command: Schema.String,
18466
18466
  args: Schema.Array(Schema.String),
18467
- /** The variables Codex passes the server beside `HOME`, `PATH` and `SHELL`. */
18467
+ /** The variables Codex passes the server beside the few it always passes, such as `HOME`. */
18468
18468
  env_vars: Schema.optionalKey(Schema.Array(Schema.String))
18469
18469
  })) });
18470
18470
  const PluginManifest = Schema.Struct({
@@ -18669,6 +18669,7 @@ const StatusCheck = Schema.Struct({
18669
18669
  "accepted",
18670
18670
  "plugin",
18671
18671
  "trusted",
18672
+ "server",
18672
18673
  "session"
18673
18674
  ]),
18674
18675
  answer: StatusAnswer,
@@ -18823,6 +18824,12 @@ const freshLinkSecret = Effect.suspend(() => hashed(Buffer.from(crypto.getRandom
18823
18824
  //#endregion
18824
18825
  //#region src/session.ts
18825
18826
  /**
18827
+ * An agent's command in a harness session that has no ref0 session, though an agent is connected
18828
+ * for its config folder: why, as far as ref0 can tell, and what to do. It never runs as its
18829
+ * person instead.
18830
+ */
18831
+ var NoSession = class extends Schema.TaggedError()("NoSession", { nextStep: Schema.String }) {};
18832
+ /**
18826
18833
  * The token of the session this command runs in, if it runs in one: an agent's, which its
18827
18834
  * harness bound when the session started. Where it's found is the harness's to say. Outside a
18828
18835
  * session, there's none, and every command is the person's, asked by approval.
@@ -20454,6 +20461,18 @@ const harnessFlagNames = { protocol: "protocol" };
20454
20461
  * release, which no hook's declaration carries.
20455
20462
  */
20456
20463
  const pluginVersionVariable = "REF0_PLUGIN_VERSION";
20464
+ /**
20465
+ * What the launcher puts in `ref0 mcp`'s environment in a checkout of another origin: that
20466
+ * origin. The server runs all the same, answering every hook with nothing, since a harness
20467
+ * takes a server that exits before `initialize` as broken.
20468
+ */
20469
+ const otherOriginVariable = "REF0_OTHER_ORIGIN";
20470
+ /**
20471
+ * What `ref0 status` puts in the environment of a folder's ref0 server, which it starts to check
20472
+ * it starts: the harness it plays. No harness sets it, so a harness's server finds its harness up
20473
+ * its parent chain.
20474
+ */
20475
+ const serverCheckVariable = "REF0_SERVER_CHECK";
20457
20476
  /** A flag as a command line writes it. */
20458
20477
  const flagWord = (name) => `--${name}`;
20459
20478
  /**
@@ -20561,10 +20580,19 @@ const sessionStartHook = ref0Hook(harnessCommandNames.sessionStart);
20561
20580
  /** Ends the session, as a command too. */
20562
20581
  const sessionEndHook = ref0Hook(harnessCommandNames.sessionEnd);
20563
20582
  /**
20583
+ * What Linux's keychain needs to be reached: `secret-tool` finds the session bus by its address,
20584
+ * or else in the runtime folder, and with neither it can't read a grant. Codex 0.164.0 passes a
20585
+ * server neither unless asked. macOS's keychain needs neither, and Codex passes an unset one as
20586
+ * unset.
20587
+ */
20588
+ const keychainVariables = ["DBUS_SESSION_BUS_ADDRESS", "XDG_RUNTIME_DIR"];
20589
+ /**
20564
20590
  * The plugin's server, `ref0 mcp`, started by M0's inline launcher, the lookup in one line: a
20565
20591
  * script path in a server's arguments stays literal in Codex. It has no `cwd`, so the server
20566
- * keeps the project's folder in both harnesses. Codex starts its servers with only `HOME`, `PATH`
20567
- * and `SHELL`, so it's asked for `CODEX_HOME`, which an unset one leaves unset.
20592
+ * keeps the project's folder in both harnesses. Codex starts its servers with only a few of its
20593
+ * variables, such as `HOME`, `PATH` and `SHELL` (`server-check.ts` lists them), so it's asked for
20594
+ * `CODEX_HOME` and the keychain's, each passed through as Codex has it and never written down: a
20595
+ * bus address changes with each login. Claude Code passes its whole environment.
20568
20596
  */
20569
20597
  const mcpFile = (version, api, server = "ref0") => ({
20570
20598
  path: "ref0/.mcp.json",
@@ -20579,133 +20607,14 @@ const mcpFile = (version, api, server = "ref0") => ({
20579
20607
  env_vars: [
20580
20608
  "CODEX_HOME",
20581
20609
  "CLAUDE_CONFIG_DIR",
20582
- "REF0_API"
20610
+ "REF0_API",
20611
+ ...keychainVariables
20583
20612
  ]
20584
20613
  } } })
20585
20614
  });
20586
20615
  /** Each event a harness's hooks send `ref0 mcp`, with the fields it reads, as an `mcp_tool` hook. */
20587
20616
  const mcpToolHooks = (server, hookFields) => Object.fromEntries(Object.entries(hookFields).map(([event, fields]) => [event, [mcpToolHook(server, event, fields)]]));
20588
20617
  //#endregion
20589
- //#region src/keychain.ts
20590
- /**
20591
- * The machine's keychain, where the CLI keeps what lets it act: the host grant `ref0 connect`
20592
- * makes, and a session's token. Never a file. On macOS through `security`, on Linux through
20593
- * `secret-tool`, under an origin-specific `ref0/<origin>` service, by account. A secret goes in through stdin, never as
20594
- * an argument, since any process on the machine can read another's arguments.
20595
- */
20596
- var KeychainError = class extends Schema.TaggedError()("KeychainError", {
20597
- nextStep: Schema.String,
20598
- /** The keychain answered, with an item ref0 can't read, rather than not answering. */
20599
- unreadable: Schema.optionalKey(Schema.Literal(true))
20600
- }) {};
20601
- /**
20602
- * The keychain under one service. Each API origin has its own, `ref0/<origin>`; 0.5.0 kept its
20603
- * grants under `ref0`, which only `ref0 connect` reads, to replace and delete them.
20604
- */
20605
- const keychainIn = Effect.fnUntraced(function* (service) {
20606
- const crypto = yield* Crypto.Crypto;
20607
- const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
20608
- const macOS = (yield* spawner.string(ChildProcess.make("uname", ["-s"])).pipe(Effect.orElseSucceed(() => ""))).trim() === "Darwin";
20609
- const unavailable = new KeychainError({ nextStep: macOS ? "The keychain didn’t answer. Unlock it, and try again." : "The keychain didn’t answer. Unlock the keychain or start a secret service, and try again." });
20610
- const missing = new KeychainError({ nextStep: "The keychain didn’t answer: ref0 needs `secret-tool`, from libsecret. Install `secret-tool` with `sudo apt install libsecret-tools` on Debian or Ubuntu, `sudo dnf install libsecret` on Fedora or `sudo pacman -S libsecret` on Arch." });
20611
- const run = (command, args, stdin) => Effect.scoped(Effect.gen(function* () {
20612
- const handle = yield* spawner.spawn(ChildProcess.make(command, args, { stdin: Stream.make(new TextEncoder().encode(stdin)) })).pipe(Effect.mapError((error) => !macOS && Predicate.isTagged(error.reason, "NotFound") ? missing : unavailable));
20613
- const [output, complaint] = yield* Effect.all([handle.stdout.pipe(Stream.decodeText(), Stream.mkString), handle.stderr.pipe(Stream.decodeText(), Stream.mkString)], { concurrency: "unbounded" });
20614
- const code = yield* handle.exitCode;
20615
- return {
20616
- output: output.trim(),
20617
- complaint: complaint.trim(),
20618
- code,
20619
- succeeded: code === ChildProcessSpawner.ExitCode(0)
20620
- };
20621
- })).pipe(Effect.catchTag("PlatformError", () => Effect.fail(unavailable)));
20622
- const get = Effect.fn("Keychain.get")(function* (account) {
20623
- const found = macOS ? yield* run("security", [
20624
- "find-generic-password",
20625
- "-s",
20626
- service,
20627
- "-a",
20628
- account,
20629
- "-w"
20630
- ], "") : yield* run("secret-tool", [
20631
- "lookup",
20632
- "service",
20633
- service,
20634
- "account",
20635
- account
20636
- ], "");
20637
- if (found.succeeded) return found.output.length > 0 ? Option.some(Redacted.make(found.output)) : Option.none();
20638
- if (macOS) {
20639
- if (found.code === ChildProcessSpawner.ExitCode(44)) return Option.none();
20640
- return yield* unavailable;
20641
- }
20642
- if (found.code !== ChildProcessSpawner.ExitCode(1) || found.complaint.length > 0) return yield* unavailable;
20643
- const listed = yield* run("secret-tool", [
20644
- "search",
20645
- "--all",
20646
- "service",
20647
- service,
20648
- "account",
20649
- account
20650
- ], "");
20651
- if (listed.output.length > 0 || listed.complaint.length > 0) return yield* unavailable;
20652
- return Option.none();
20653
- });
20654
- const set = Effect.fn("Keychain.set")(function* (account, secret) {
20655
- const quote = (text) => `"${text.replace(/[\\"]/g, "\\$&")}"`;
20656
- const value = Redacted.value(secret);
20657
- if (macOS && /[\r\n\0]/.test(account + value)) return yield* unavailable;
20658
- const input = macOS ? `add-generic-password -U -s ${quote(service)} -a ${quote(account)} -w ${quote(value)}\n` : value;
20659
- if (macOS && new TextEncoder().encode(input).length >= 4096) return yield* unavailable;
20660
- if (!(macOS ? yield* run("security", ["-i"], input) : yield* run("secret-tool", [
20661
- "store",
20662
- "--label",
20663
- `ref0 ${account}`,
20664
- "service",
20665
- service,
20666
- "account",
20667
- account
20668
- ], Redacted.value(secret))).succeeded) return yield* unavailable;
20669
- });
20670
- const remove = Effect.fn("Keychain.remove")(function* (account) {
20671
- if (!(yield* macOS ? run("security", [
20672
- "delete-generic-password",
20673
- "-s",
20674
- service,
20675
- "-a",
20676
- account
20677
- ], "") : run("secret-tool", [
20678
- "clear",
20679
- "service",
20680
- service,
20681
- "account",
20682
- account
20683
- ], "")).succeeded) return yield* unavailable;
20684
- });
20685
- const random = crypto.randomUUIDv4.pipe(Effect.mapError(() => unavailable));
20686
- return {
20687
- probe: Effect.gen(function* () {
20688
- const account = `connect-probe/${yield* random}`;
20689
- const value = Redacted.make(yield* random);
20690
- yield* Effect.gen(function* () {
20691
- yield* set(account, value);
20692
- const kept = yield* get(account);
20693
- if (Option.isNone(kept) || Redacted.value(kept.value) !== Redacted.value(value)) return yield* unavailable;
20694
- yield* remove(account);
20695
- }).pipe(Effect.catch((error) => remove(account).pipe(Effect.ignore, Effect.andThen(Effect.fail(error)))));
20696
- }).pipe(Effect.timeoutOrElse({
20697
- duration: Duration.minutes(1),
20698
- orElse: () => Effect.fail(unavailable)
20699
- })),
20700
- get,
20701
- set,
20702
- remove
20703
- };
20704
- });
20705
- var Keychain = class Keychain extends Context.Service()("ref0/cli/Keychain") {
20706
- static layer = Layer.effect(Keychain, Effect.flatMap(Ref0Config, (config) => Effect.map(keychainIn(`ref0/${originKey(config.api)}`), Keychain.of)));
20707
- };
20708
- //#endregion
20709
20618
  //#region src/harness/binding.ts
20710
20619
  /**
20711
20620
  * Where a session's token is kept: the machine's keychain, under the API origin’s keychain service, by an
@@ -20743,9 +20652,8 @@ function bindingFromEnvironment(environment, found, hookSession) {
20743
20652
  //#endregion
20744
20653
  //#region src/harness/session-token.ts
20745
20654
  /**
20746
- * The session token of the harness session a command runs in, from the keychain, under the
20747
- * account its binding names. Outside a harness, or in one whose session ref0 never started,
20748
- * there's none, and a command is its person's, asked by approval.
20655
+ * The harness session a command runs in: the harness process up its parent chain, and the
20656
+ * binding its session's token is kept under, which `command-token.ts` reads.
20749
20657
  */
20750
20658
  const optional$1 = (name) => Config.String(name).pipe(Config.option, Config.map(Option.getOrUndefined));
20751
20659
  /** What the harness put in this process's environment, as far as a binding needs. */
@@ -20753,7 +20661,6 @@ const harnessEnvironment = Config.all({
20753
20661
  CLAUDE_CODE_SESSION_ID: optional$1("CLAUDE_CODE_SESSION_ID"),
20754
20662
  CLAUDE_PID: optional$1("CLAUDE_PID"),
20755
20663
  CODEX_SESSION_ID: optional$1("CODEX_SESSION_ID"),
20756
- CODEX_HOME: optional$1("CODEX_HOME"),
20757
20664
  CODEX_SANDBOX: optional$1("CODEX_SANDBOX")
20758
20665
  });
20759
20666
  /**
@@ -20828,17 +20735,6 @@ const bindingForServer = Effect.fnUntraced(function* (harnessSession) {
20828
20735
  process: found.value.process
20829
20736
  });
20830
20737
  });
20831
- /** `SessionToken` from the keychain, by the binding of the harness session this runs in. */
20832
- const harnessSessionToken = Layer.effect(SessionToken, Effect.gen(function* () {
20833
- const keychain = yield* Keychain;
20834
- const harness = yield* HarnessProcess;
20835
- const current = Effect.gen(function* () {
20836
- const binding = yield* bindingHere().pipe(Effect.provideService(HarnessProcess, harness), Effect.orElseSucceed(() => Option.none()));
20837
- if (Option.isNone(binding)) return Option.none();
20838
- return yield* keychain.get(sessionAccount(binding.value));
20839
- });
20840
- return SessionToken.of({ current });
20841
- }));
20842
20738
  //#endregion
20843
20739
  //#region src/harness/config-folders.ts
20844
20740
  const Folders = Schema.Array(Schema.String);
@@ -20951,6 +20847,126 @@ const connectLineFor = Effect.fnUntraced(function* (harness, configFolder) {
20951
20847
  return `${configFolders[harness].variable}=${shellWord(configFolder)} ref0 connect`;
20952
20848
  });
20953
20849
  //#endregion
20850
+ //#region src/keychain.ts
20851
+ /**
20852
+ * The machine's keychain, where the CLI keeps what lets it act: the host grant `ref0 connect`
20853
+ * makes, and a session's token. Never a file. On macOS through `security`, on Linux through
20854
+ * `secret-tool`, under an origin-specific `ref0/<origin>` service, by account. A secret goes in through stdin, never as
20855
+ * an argument, since any process on the machine can read another's arguments.
20856
+ */
20857
+ var KeychainError = class extends Schema.TaggedError()("KeychainError", {
20858
+ nextStep: Schema.String,
20859
+ /** The keychain answered, with an item ref0 can't read, rather than not answering. */
20860
+ unreadable: Schema.optionalKey(Schema.Literal(true))
20861
+ }) {};
20862
+ /**
20863
+ * The keychain under one service. Each API origin has its own, `ref0/<origin>`; 0.5.0 kept its
20864
+ * grants under `ref0`, which only `ref0 connect` reads, to replace and delete them.
20865
+ */
20866
+ const keychainIn = Effect.fnUntraced(function* (service) {
20867
+ const crypto = yield* Crypto.Crypto;
20868
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
20869
+ const macOS = (yield* spawner.string(ChildProcess.make("uname", ["-s"])).pipe(Effect.orElseSucceed(() => ""))).trim() === "Darwin";
20870
+ const unavailable = new KeychainError({ nextStep: macOS ? "The keychain didn’t answer. Unlock it, and try again." : "The keychain didn’t answer. Unlock the keychain or start a secret service, and try again." });
20871
+ const missing = new KeychainError({ nextStep: "The keychain didn’t answer: ref0 needs `secret-tool`, from libsecret. Install `secret-tool` with `sudo apt install libsecret-tools` on Debian or Ubuntu, `sudo dnf install libsecret` on Fedora or `sudo pacman -S libsecret` on Arch." });
20872
+ const run = (command, args, stdin) => Effect.scoped(Effect.gen(function* () {
20873
+ const handle = yield* spawner.spawn(ChildProcess.make(command, args, { stdin: Stream.make(new TextEncoder().encode(stdin)) })).pipe(Effect.mapError((error) => !macOS && Predicate.isTagged(error.reason, "NotFound") ? missing : unavailable));
20874
+ const [output, complaint] = yield* Effect.all([handle.stdout.pipe(Stream.decodeText(), Stream.mkString), handle.stderr.pipe(Stream.decodeText(), Stream.mkString)], { concurrency: "unbounded" });
20875
+ const code = yield* handle.exitCode;
20876
+ return {
20877
+ output: output.trim(),
20878
+ complaint: complaint.trim(),
20879
+ code,
20880
+ succeeded: code === ChildProcessSpawner.ExitCode(0)
20881
+ };
20882
+ })).pipe(Effect.catchTag("PlatformError", () => Effect.fail(unavailable)));
20883
+ const get = Effect.fn("Keychain.get")(function* (account) {
20884
+ const found = macOS ? yield* run("security", [
20885
+ "find-generic-password",
20886
+ "-s",
20887
+ service,
20888
+ "-a",
20889
+ account,
20890
+ "-w"
20891
+ ], "") : yield* run("secret-tool", [
20892
+ "lookup",
20893
+ "service",
20894
+ service,
20895
+ "account",
20896
+ account
20897
+ ], "");
20898
+ if (found.succeeded) return found.output.length > 0 ? Option.some(Redacted.make(found.output)) : Option.none();
20899
+ if (macOS) {
20900
+ if (found.code === ChildProcessSpawner.ExitCode(44)) return Option.none();
20901
+ return yield* unavailable;
20902
+ }
20903
+ if (found.code !== ChildProcessSpawner.ExitCode(1) || found.complaint.length > 0) return yield* unavailable;
20904
+ const listed = yield* run("secret-tool", [
20905
+ "search",
20906
+ "--all",
20907
+ "service",
20908
+ service,
20909
+ "account",
20910
+ account
20911
+ ], "");
20912
+ if (listed.output.length > 0 || listed.complaint.length > 0) return yield* unavailable;
20913
+ return Option.none();
20914
+ });
20915
+ const set = Effect.fn("Keychain.set")(function* (account, secret) {
20916
+ const quote = (text) => `"${text.replace(/[\\"]/g, "\\$&")}"`;
20917
+ const value = Redacted.value(secret);
20918
+ if (macOS && /[\r\n\0]/.test(account + value)) return yield* unavailable;
20919
+ const input = macOS ? `add-generic-password -U -s ${quote(service)} -a ${quote(account)} -w ${quote(value)}\n` : value;
20920
+ if (macOS && new TextEncoder().encode(input).length >= 4096) return yield* unavailable;
20921
+ if (!(macOS ? yield* run("security", ["-i"], input) : yield* run("secret-tool", [
20922
+ "store",
20923
+ "--label",
20924
+ `ref0 ${account}`,
20925
+ "service",
20926
+ service,
20927
+ "account",
20928
+ account
20929
+ ], Redacted.value(secret))).succeeded) return yield* unavailable;
20930
+ });
20931
+ const remove = Effect.fn("Keychain.remove")(function* (account) {
20932
+ if (!(yield* macOS ? run("security", [
20933
+ "delete-generic-password",
20934
+ "-s",
20935
+ service,
20936
+ "-a",
20937
+ account
20938
+ ], "") : run("secret-tool", [
20939
+ "clear",
20940
+ "service",
20941
+ service,
20942
+ "account",
20943
+ account
20944
+ ], "")).succeeded) return yield* unavailable;
20945
+ });
20946
+ const random = crypto.randomUUIDv4.pipe(Effect.mapError(() => unavailable));
20947
+ return {
20948
+ probe: Effect.gen(function* () {
20949
+ const account = `connect-probe/${yield* random}`;
20950
+ const value = Redacted.make(yield* random);
20951
+ yield* Effect.gen(function* () {
20952
+ yield* set(account, value);
20953
+ const kept = yield* get(account);
20954
+ if (Option.isNone(kept) || Redacted.value(kept.value) !== Redacted.value(value)) return yield* unavailable;
20955
+ yield* remove(account);
20956
+ }).pipe(Effect.catch((error) => remove(account).pipe(Effect.ignore, Effect.andThen(Effect.fail(error)))));
20957
+ }).pipe(Effect.timeoutOrElse({
20958
+ duration: Duration.minutes(1),
20959
+ orElse: () => Effect.fail(unavailable)
20960
+ })),
20961
+ get,
20962
+ set,
20963
+ remove
20964
+ };
20965
+ });
20966
+ var Keychain = class Keychain extends Context.Service()("ref0/cli/Keychain") {
20967
+ static layer = Layer.effect(Keychain, Effect.flatMap(Ref0Config, (config) => Effect.map(keychainIn(`ref0/${originKey(config.api)}`), Keychain.of)));
20968
+ };
20969
+ //#endregion
20954
20970
  //#region src/host-grant.ts
20955
20971
  /**
20956
20972
  * All lasting authority for one config folder, stored as one keychain value: the agent the folder
@@ -20966,6 +20982,11 @@ const KeptHostGrant = Schema.Struct({
20966
20982
  });
20967
20983
  const decodeGrant = Schema.decodeUnknownOption(Schema.fromJsonString(KeptHostGrant));
20968
20984
  const encodeGrant = Schema.encodeSync(Schema.fromJsonString(KeptHostGrant));
20985
+ /**
20986
+ * The token alone, from any record that's JSON with a string `token`, so a record this ref0 can't
20987
+ * read as a grant, such as one an earlier ref0 kept, still names the grant it held.
20988
+ */
20989
+ const decodeHeldToken = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Struct({ token: KeptHostGrant.fields.token })));
20969
20990
  /** Grants in the origin’s keychain service, account host-grant/<resolved config folder>. */
20970
20991
  var HostGrant = class HostGrant extends Context.Service()("ref0/cli/HostGrant") {
20971
20992
  static layer = Layer.effect(HostGrant, Effect.gen(function* () {
@@ -20981,6 +21002,7 @@ var HostGrant = class HostGrant extends Context.Service()("ref0/cli/HostGrant")
20981
21002
  unreadable: true
20982
21003
  }));
20983
21004
  })),
21005
+ heldToken: (folder) => account(folder).pipe(Effect.flatMap(keychain.get), Effect.map(Option.flatMap((kept) => Option.map(decodeHeldToken(Redacted.value(kept)), (held) => held.token)))),
20984
21006
  keep: (folder, grant) => Effect.gen(function* () {
20985
21007
  yield* keychain.set(yield* account(folder), Redacted.make(encodeGrant(grant)));
20986
21008
  yield* folders.keep(folder).pipe(Effect.mapError(() => new KeychainError({ nextStep: "Couldn’t record this connected config folder in ~/.ref0. Check that it’s writable, then run ref0 connect." })));
@@ -21008,7 +21030,7 @@ const grantForAgent = Effect.fnUntraced(function* (owner, agent) {
21008
21030
  * an update installs anew. Node, run from source, loads JSON only with the `type` attribute,
21009
21031
  * and only as a default export.
21010
21032
  */
21011
- const cliVersion = "0.9.0";
21033
+ const cliVersion = "0.10.0";
21012
21034
  //#endregion
21013
21035
  //#region src/harness/hook-protocol.ts
21014
21036
  /**
@@ -21332,12 +21354,12 @@ const requestAttachment = Effect.fnUntraced(function* (asked) {
21332
21354
  const unreachable = () => sayFailed(`Couldn’t reach ref0 at ${config.api}. Try again.`);
21333
21355
  const requestId = yield* newRequestId;
21334
21356
  const asking = yield* Effect.forEach(asked, Effect.fnUntraced(function* (folder) {
21335
- const previous = yield* hostGrant.get(folder.configFolder).pipe(Effect.catchIf((failed) => failed.unreadable === true, () => Effect.succeedNone), Effect.catchTag("KeychainError", said));
21357
+ const held = yield* hostGrant.heldToken(folder.configFolder).pipe(Effect.catchTag("KeychainError", said));
21336
21358
  return {
21337
21359
  ...folder,
21338
21360
  shown: shownConfigFolder(home, folder.configFolder),
21339
21361
  grant: yield* freshTokenSecret,
21340
- replaces: Option.isSome(previous) ? grantOf(previous.value.token) : yield* oldGrants.replaces(folder).pipe(Effect.catchTag("KeychainError", said))
21362
+ replaces: Option.isSome(held) ? grantOf(held.value) : yield* oldGrants.replaces(folder).pipe(Effect.catchTag("KeychainError", said))
21341
21363
  };
21342
21364
  }));
21343
21365
  const folders = yield* Effect.forEach(asking, Effect.fnUntraced(function* (folder) {
@@ -22710,7 +22732,7 @@ var CodexAnswerMismatch = class extends Schema.TaggedError()("CodexAnswerMismatc
22710
22732
  issue: Schema.String
22711
22733
  }) {};
22712
22734
  const encodeRequest = Schema.encodeUnknownEffect(Schema.fromJsonString(CodexRequest));
22713
- const decodeAnswer$1 = Schema.decodeUnknownOption(Schema.fromJsonString(CodexAnswer));
22735
+ const decodeAnswer$2 = Schema.decodeUnknownOption(Schema.fromJsonString(CodexAnswer));
22714
22736
  /** The IDs of the exchange's two requests: the opening's, and the one asked. */
22715
22737
  const initializeId = "ref0-initialize";
22716
22738
  const askedId = "ref0-asked";
@@ -22744,7 +22766,7 @@ const exchange = (codex, lines, ids, env) => Effect.scoped(Effect.gen(function*
22744
22766
  stream: Stream.fromIterable(lines.map((line) => `${line}\n`)).pipe(Stream.encodeText),
22745
22767
  endOnDone: false
22746
22768
  }
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);
22769
+ }))).stdout.pipe(Stream.decodeText(), Stream.splitLines, Stream.map((line) => decodeAnswer$2(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
22770
  return Option.filter(answered, (answers) => answers.size === ids.length);
22749
22771
  })).pipe(Effect.timeoutOption("15 seconds"), Effect.map(Option.flatten), Effect.catchCause(() => Effect.succeed(Option.none())));
22750
22772
  /** `answer`'s result, read by `method`'s own schema, or why it can't be. */
@@ -23088,16 +23110,19 @@ const codexAgentConfig = Effect.fnUntraced(function* (agent, root, home) {
23088
23110
  parameters: shellWord(`include.path=${file}`)
23089
23111
  };
23090
23112
  });
23091
- /** Codex has no environment file: its install selects a file; session start fills its checkout. */
23113
+ /**
23114
+ * Codex has no environment file: its install selects a file; session start fills its checkout,
23115
+ * for the agent of the config folder `CODEX_HOME` names, or the default `~/.codex`.
23116
+ */
23092
23117
  const setCodexCommitEnvironment = Effect.fnUntraced(function* (cwd, checkout) {
23093
23118
  const folders = yield* ConfigFolders;
23094
23119
  const fs = yield* FileSystem.FileSystem;
23095
23120
  const path = yield* Path.Path;
23096
23121
  const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
23097
- const agent = yield* (yield* HostGrant).get(yield* folders.folder("codex"));
23122
+ const agent = yield* (yield* HostGrant).get(yield* folders.folder("codex")).pipe(Effect.catchIf((failed) => failed.unreadable === true, () => Effect.succeedNone));
23098
23123
  if (Option.isNone(agent)) return false;
23099
23124
  const pluginRoot = yield* Config.String("CLAUDE_PLUGIN_ROOT").pipe(Config.withDefault(""));
23100
- if (pluginRoot === "") return false;
23125
+ if (pluginRoot === "") return yield* new CodexCommitFailed({ detail: "Codex gave the hook no plugin root." });
23101
23126
  const gitFolder = path.join(path.dirname(pluginRoot), "@git");
23102
23127
  const home = yield* Config.String("HOME");
23103
23128
  const author = yield* codexAgentConfig(agent.value, gitFolder, home);
@@ -23867,6 +23892,7 @@ const writeRef0Launcher = Effect.fnUntraced(function* (home, git) {
23867
23892
  const gitPath = yield* fs.realPath(git);
23868
23893
  yield* fs.writeFileString(next, [
23869
23894
  "#!/bin/sh",
23895
+ `unset ${otherOriginVariable}`,
23870
23896
  "if [ \"$REF0_SELECT_ORIGIN\" = 1 ]; then",
23871
23897
  ` checkout=$(${shellWord(gitPath)} config --get ref0.api 2>/dev/null)`,
23872
23898
  " result=$?",
@@ -23876,7 +23902,11 @@ const writeRef0Launcher = Effect.fnUntraced(function* (home, git) {
23876
23902
  ` *) printf '%s\\n' ${shellWord("ref0: could not read the checkout origin with Git. Restore Git, then run ref0 install again.")} >&2; exit 1 ;;`,
23877
23903
  " esac",
23878
23904
  " export REF0_SELECTED_API",
23879
- ` ${shellWord(node)} -e ${shellWord("try { process.exit(new URL(process.env.REF0_SELECTED_API).origin === process.argv[1] ? 0 : 1) } catch { process.exit(1) }")} ${shellWord(config.api)} || exit 0`,
23905
+ ` if ! ${shellWord(node)} -e ${shellWord("try { process.exit(new URL(process.env.REF0_SELECTED_API).origin === process.argv[1] ? 0 : 1) } catch { process.exit(1) }")} ${shellWord(config.api)}; then`,
23906
+ ` [ "$1" = ${harnessCommandNames.mcp} ] || exit 0`,
23907
+ ` ${otherOriginVariable}=$REF0_SELECTED_API`,
23908
+ ` export ${otherOriginVariable}`,
23909
+ " fi",
23880
23910
  "fi",
23881
23911
  "unset REF0_SELECT_ORIGIN REF0_SELECTED_API",
23882
23912
  `export REF0_API=${shellWord(config.api)}`,
@@ -24158,7 +24188,7 @@ const answerOf = (connected, installed, others) => ({
24158
24188
  const connectCommands = [Command.make("connect", { json }, ({ json: asJson }) => Effect.gen(function* () {
24159
24189
  const session = yield* SessionToken;
24160
24190
  const tty = yield* Tty;
24161
- if (Option.isSome(yield* session.current)) return yield* sayFailed("ref0 connect runs only in a person’s own terminal, never in an agent’s session: it shows a code, and a yes to that code connects this machine to an agent. If this machine needs connecting, tell your person to run ref0 connect in their own terminal. Never ask them to approve a code for you.");
24191
+ if (yield* session.current.pipe(Effect.map(Option.isSome), Effect.catchTag("NoSession", () => Effect.succeed(true)))) return yield* sayFailed("ref0 connect runs only in a person’s own terminal, never in an agent’s session: it shows a code, and a yes to that code connects this machine to an agent. If this machine needs connecting, tell your person to run ref0 connect in their own terminal. Never ask them to approve a code for you.");
24162
24192
  if (!tty.attached) return yield* sayFailed("ref0 connect runs only in a terminal you type into, and this isn’t one. Run it yourself, in your own terminal: not through a script, a pipe or an agent.");
24163
24193
  yield* (yield* ConnectLock).hold;
24164
24194
  yield* (yield* Keychain).probe;
@@ -25026,7 +25056,7 @@ const isPlainJson = (value, depth = 0) => {
25026
25056
  const proto = Object.getPrototypeOf(value);
25027
25057
  return (proto === Object.prototype || proto === null) && Object.values(value).every(inner);
25028
25058
  };
25029
- const decodeAnswer = Schema.decodeUnknownOption(CheckRunAnswer);
25059
+ const decodeAnswer$1 = Schema.decodeUnknownOption(CheckRunAnswer);
25030
25060
  const encodeJson$1 = Schema.encodeSync(Schema.fromJsonString(Schema.Json));
25031
25061
  /**
25032
25062
  * A check's bundle, written for `import()` under a name of its code's and this process's, in the
@@ -25167,7 +25197,7 @@ const runPart = Effect.fnUntraced(function* (own, part) {
25167
25197
  error: "timeout",
25168
25198
  why: `It ran past its timeout, ${part.timeoutSeconds} seconds`
25169
25199
  }),
25170
- onSome: (answer) => isPlainJson(answer, 0) ? Option.getOrElse(decodeAnswer(answer), () => ({
25200
+ onSome: (answer) => isPlainJson(answer, 0) ? Option.getOrElse(decodeAnswer$1(answer), () => ({
25171
25201
  error: "invalid output",
25172
25202
  why: "Its answer isn't one a check gives"
25173
25203
  })) : {
@@ -25364,7 +25394,7 @@ const runLocally = Effect.fnUntraced(function* (folder, check, named, asJson) {
25364
25394
  if (Option.isNone(thread)) return yield* sayFailed("Name the thread with --thread owner/project#number.");
25365
25395
  const root = yield* gitIn(folder, ["rev-parse", "--show-toplevel"]);
25366
25396
  if (Option.isNone(root) || root.value === "") return yield* sayFailed("Git couldn’t find this checkout’s root.");
25367
- const session = yield* (yield* SessionToken).current.pipe(Effect.catchTag("KeychainError", (failed) => sayFailed(failed.nextStep)));
25397
+ const session = yield* (yield* SessionToken).current.pipe(Effect.catchTag(["KeychainError", "NoSession"], (failed) => sayFailed(failed.nextStep)));
25368
25398
  if (Option.isSome(session) && Option.isSome(named.agent)) return yield* sayFailed("A session’s local run is signed by the session: leave out --agent.");
25369
25399
  if (Option.isNone(session) && Option.isNone(named.agent)) return yield* sayFailed("Outside a session, name the agent this machine is attached as, with --agent owner/handle: a local run is signed by the attachment it ran on.");
25370
25400
  const attachment = Option.isSome(named.agent) ? { attachment: yield* namedAttachment(named.agent.value, Option.none(), true) } : {};
@@ -26143,6 +26173,44 @@ const answerGit = Effect.fnUntraced(function* (operation, input) {
26143
26173
  return Option.map(token, credentialLines);
26144
26174
  });
26145
26175
  //#endregion
26176
+ //#region src/own-session.ts
26177
+ /**
26178
+ * A command only an agent's session runs, on a repo of the session's project. Its endpoints take
26179
+ * the repo's name alone and find it in that project, so the command takes the name alone too: an
26180
+ * owner it couldn't check could name another project's repo, and the session's of that name
26181
+ * would be changed in its place.
26182
+ */
26183
+ const decodeRepoName$1 = Schema.decodeUnknownOption(RepoName);
26184
+ /**
26185
+ * The token of the agent's session a command runs in. Outside one it says `outside`, what the
26186
+ * command is for; in a harness session whose ref0 session never started, why and what to do.
26187
+ * Either way it fails before anything's sent.
26188
+ */
26189
+ const ownSession = Effect.fnUntraced(function* (outside) {
26190
+ const token = yield* (yield* SessionToken).current.pipe(Effect.catchTag("NoSession", (none) => sayFailed(none.nextStep)));
26191
+ if (Option.isNone(token)) return yield* sayFailed(outside);
26192
+ return token.value;
26193
+ });
26194
+ /** The repo of a session's command, by its name alone. */
26195
+ const sessionRepoFlag = Flag.String("repo").pipe(Flag.withDescription("A repo of your session’s project, by its name alone, as shop; if not named, the one this checkout is of"), Flag.optional);
26196
+ /**
26197
+ * The session a command runs as, and the name of the repo it acts on: the one named, or this
26198
+ * checkout's. Outside a session it says `outside`, what the command is for, and fails before
26199
+ * anything's sent.
26200
+ */
26201
+ const sessionRepo = Effect.fnUntraced(function* (named, outside) {
26202
+ yield* ownSession(outside);
26203
+ const checkout = yield* Checkout;
26204
+ const repo = Option.isSome(named) ? decodeRepoName$1(named.value) : yield* checkout.repoAt(".");
26205
+ if (Option.isNone(repo)) return yield* sayFailed(Option.isSome(named) ? "Name a repo of your session’s project by its name alone, as --repo shop." : "Name the repo with --repo and its name, as --repo shop, or run this in its checkout.");
26206
+ const session = (yield* callEndpoint({ endpoint: "me.get" })).value.session?.session;
26207
+ if (session === void 0) return yield* sayFailed("This token isn’t a session’s.");
26208
+ return {
26209
+ session,
26210
+ repo: repo.value
26211
+ };
26212
+ });
26213
+ //#endregion
26146
26214
  //#region src/sync-history.ts
26147
26215
  const decodePlace = Schema.decodeUnknownEffect(Schema.Struct({
26148
26216
  owner: OwnerRef,
@@ -26198,7 +26266,7 @@ const trimShallow = Effect.fnUntraced(function* (gitDir) {
26198
26266
  */
26199
26267
  const loadSyncHistory = Effect.fnUntraced(function* (gitDir, repo) {
26200
26268
  const config = yield* Ref0Config;
26201
- const token = yield* (yield* SessionToken).current;
26269
+ const token = yield* (yield* SessionToken).current.pipe(Effect.catchTag("NoSession", () => Effect.succeedNone));
26202
26270
  if (Option.isNone(token)) return yield* new HistoryNotLoaded({ why: "this terminal has no session to load it as" });
26203
26271
  const fs = yield* FileSystem.FileSystem;
26204
26272
  const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
@@ -26371,9 +26439,8 @@ const gitCommands = [
26371
26439
  json
26372
26440
  }, ({ repo: written, directory, json: asJson }) => Effect.gen(function* () {
26373
26441
  const { owner, repo: name } = repoParts(written);
26374
- const session = yield* SessionToken;
26375
26442
  const config = yield* Ref0Config;
26376
- if (Option.isNone(yield* session.current)) return yield* sayFailed("ref0 clone runs in an agent’s session, which pushes to a fork of its own. A person’s clone, by approval, isn’t built yet.");
26443
+ yield* ownSession("ref0 clone runs only in an agent’s ref0 session, which pushes to a fork of its own, and none runs here. In Claude Code or Codex, one starts at each prompt once ref0 connect has connected its config folder.");
26377
26444
  const ours = (yield* callEndpoint({ endpoint: "me.get" })).value.session?.session;
26378
26445
  if (ours === void 0) return yield* sayFailed("This token isn’t a session’s.");
26379
26446
  const fork = yield* callEndpoint({
@@ -26405,8 +26472,7 @@ const gitCommands = [
26405
26472
  yield* printAnswer(fork.json, asJson, `Cloned ${owner}/${name} into ${into}. origin is read-only to agents: git push goes to ${forkRemote(fork.value)}, the fork of whichever session on the thread pushes.`);
26406
26473
  }).pipe(Effect.catchTag("CallFailed", failedWith$10))).pipe(Command.withDescription("Clone a repo of the session’s project, to push to its fork"), Command.withShortDescription("Clone a repo to work in your session’s fork")),
26407
26474
  Command.make("sync", { json }, ({ json: asJson }) => Effect.gen(function* () {
26408
- const session = yield* SessionToken;
26409
- if (Option.isNone(yield* session.current)) return yield* sayFailed("ref0 sync runs in an agent’s session, in a clone ref0 clone made.");
26475
+ yield* ownSession("ref0 sync runs in an agent’s session, in a clone ref0 clone made.");
26410
26476
  const synced = yield* syncBase(".");
26411
26477
  if (synced.kind === "refused") return yield* sayFailed(synced.why);
26412
26478
  const { on, commit } = synced.base;
@@ -27690,35 +27756,6 @@ const planCommands = [
27690
27756
  }).pipe(Effect.catchTag("CallFailed", (failed) => sayFailed(failed.nextStep)))).pipe(Command.withDescription("Answer a decision that waits on you"))
27691
27757
  ];
27692
27758
  //#endregion
27693
- //#region src/own-session.ts
27694
- /**
27695
- * A command only an agent's session runs, on a repo of the session's project. Its endpoints take
27696
- * the repo's name alone and find it in that project, so the command takes the name alone too: an
27697
- * owner it couldn't check could name another project's repo, and the session's of that name
27698
- * would be changed in its place.
27699
- */
27700
- const decodeRepoName$1 = Schema.decodeUnknownOption(RepoName);
27701
- /** The repo of a session's command, by its name alone. */
27702
- const sessionRepoFlag = Flag.String("repo").pipe(Flag.withDescription("A repo of your session’s project, by its name alone, as shop; if not named, the one this checkout is of"), Flag.optional);
27703
- /**
27704
- * The session a command runs as, and the name of the repo it acts on: the one named, or this
27705
- * checkout's. Outside a session it says `outside`, what the command is for, and fails before
27706
- * anything's sent.
27707
- */
27708
- const sessionRepo = Effect.fnUntraced(function* (named, outside) {
27709
- const token = yield* SessionToken;
27710
- if (Option.isNone(yield* token.current)) return yield* sayFailed(outside);
27711
- const checkout = yield* Checkout;
27712
- const repo = Option.isSome(named) ? decodeRepoName$1(named.value) : yield* checkout.repoAt(".");
27713
- if (Option.isNone(repo)) return yield* sayFailed(Option.isSome(named) ? "Name a repo of your session’s project by its name alone, as --repo shop." : "Name the repo with --repo and its name, as --repo shop, or run this in its checkout.");
27714
- const session = (yield* callEndpoint({ endpoint: "me.get" })).value.session?.session;
27715
- if (session === void 0) return yield* sayFailed("This token isn’t a session’s.");
27716
- return {
27717
- session,
27718
- repo: repo.value
27719
- };
27720
- });
27721
- //#endregion
27722
27759
  //#region src/pull-request-lines.ts
27723
27760
  /**
27724
27761
  * Pull requests as `ref0 pr` says them: each one's number, thread, state and current version,
@@ -28179,8 +28216,7 @@ const taskCommands = [Command.make("tasks", {
28179
28216
  file: Flag.FileText("file").pipe(Flag.withDescription("The whole list, as JSON: each task’s text, up to 200 characters, and its state, \"to do\", \"in progress\" or \"done\"; at most 50")),
28180
28217
  json
28181
28218
  }, ({ file, json: asJson }) => Effect.gen(function* () {
28182
- const session = yield* SessionToken;
28183
- if (Option.isNone(yield* session.current)) return yield* sayFailed("ref0 tasks runs in an agent’s session, which sets its own list.");
28219
+ yield* ownSession("ref0 tasks runs in an agent’s session, which sets its own list.");
28184
28220
  const parsed = yield* Effect.result(decodeJsonText(file));
28185
28221
  if (Result.isFailure(parsed)) return yield* sayFailed("That list isn’t JSON.");
28186
28222
  const checked = yield* Effect.result(checkTasks(parsed.success));
@@ -28783,9 +28819,8 @@ const waitCommands = [Command.make("wait", {
28783
28819
  blockers: Flag.Boolean("blockers").pipe(Flag.withDescription("Wait until nothing blocks your session’s thread from merging"), Flag.withDefault(false)),
28784
28820
  json
28785
28821
  }, ({ blockers, json: asJson }) => Effect.gen(function* () {
28786
- const session = yield* SessionToken;
28787
28822
  if (!blockers) return yield* sayFailed("Say what to wait for: ref0 wait --blockers waits until nothing blocks your thread.");
28788
- if (Option.isNone(yield* session.current)) return yield* sayFailed("ref0 wait --blockers runs in an agent’s session: it waits on its own thread.");
28823
+ yield* ownSession("ref0 wait --blockers runs in an agent’s session: it waits on its own thread.");
28789
28824
  yield* waitOnBlockers(asJson);
28790
28825
  }).pipe(Effect.catchTag("CallFailed", (failed) => sayFailed(failed.nextStep)))).pipe(Command.withDescription(`Wait until nothing blocks your session’s thread from merging, for 9 minutes at most`), Command.withShortDescription("Wait for your session’s thread to be ready to merge"))];
28791
28826
  //#endregion
@@ -32545,6 +32580,8 @@ function readHook(args) {
32545
32580
  */
32546
32581
  /** The keychain account a binding's thread is kept under, beside its session token's. */
32547
32582
  const threadAccount = (binding) => `${sessionAccount(binding)}/thread`;
32583
+ /** The keychain account a start not yet landed is kept under, beside its session token's. */
32584
+ const pendingAccount = (binding) => `${sessionAccount(binding)}/pending`;
32548
32585
  const decodeRequest = Schema.decodeUnknownEffect(StartSessionRequest);
32549
32586
  const decodeRequestId = Schema.decodeUnknownOption(RequestId);
32550
32587
  /**
@@ -32584,14 +32621,14 @@ const startHarnessSession = Effect.fnUntraced(function* (input) {
32584
32621
  const folder = yield* (yield* ConfigFolders).folder(harness);
32585
32622
  const grant = yield* hostGrant.get(folder);
32586
32623
  if (Option.isNone(grant) || grant.value.harness !== harness) return Option.none();
32587
- const pendingAccount = `${sessionAccount(input.binding)}/pending`;
32624
+ const pending = pendingAccount(input.binding);
32588
32625
  const held = Option.flatMap(yield* keychain.get(sessionAccount(input.binding)), (kept) => parseToken(Redacted.value(kept)));
32589
- const pending = Option.filter(Option.flatMap(yield* keychain.get(pendingAccount), (kept) => decodePending(Redacted.value(kept))), (one) => !Option.exists(held, (parts) => parts.secret === one.secret));
32590
- const { requestId, secret } = Option.isSome(pending) ? pending.value : {
32626
+ const resent = Option.filter(Option.flatMap(yield* keychain.get(pending), (kept) => decodePending(Redacted.value(kept))), (one) => !Option.exists(held, (parts) => parts.secret === one.secret));
32627
+ const { requestId, secret } = Option.isSome(resent) ? resent.value : {
32591
32628
  requestId: yield* newRequestId,
32592
32629
  secret: encodeSecret(crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(32)))
32593
32630
  };
32594
- if (Option.isNone(pending)) yield* keychain.set(pendingAccount, Redacted.make(`${requestId} ${secret}`));
32631
+ if (Option.isNone(resent)) yield* keychain.set(pending, Redacted.make(`${requestId} ${secret}`));
32595
32632
  const hash = yield* hashOf(secret);
32596
32633
  const where = input.on.kind === "thread" ? threadParts(input.on.written) : {
32597
32634
  owner: grant.value.owner,
@@ -32621,7 +32658,7 @@ const startHarnessSession = Effect.fnUntraced(function* (input) {
32621
32658
  const started = yield* (yield* ref0Client(config.api).pipe(Effect.provide(bearerCredential(grant.value.token)))).sessions.start({
32622
32659
  headers: { "idempotency-key": requestId },
32623
32660
  payload
32624
- }).pipe(Effect.tapError((error) => Match.value(error).pipe(Match.tag("ProjectNotFound", "ProjectNotReady", "ThreadNotFound", "ThreadIsClosed", "TextRefused", "NotPermitted", "DeliveryOutsideWindow", () => keychain.remove(pendingAccount)), Match.orElse(() => Effect.void))));
32661
+ }).pipe(Effect.tapError((error) => Match.value(error).pipe(Match.tag("ProjectNotFound", "ProjectNotReady", "ThreadNotFound", "ThreadIsClosed", "TextRefused", "NotPermitted", "DeliveryOutsideWindow", () => keychain.remove(pending)), Match.orElse(() => Effect.void))));
32625
32662
  const thread = input.on.kind === "thread" ? input.on.written : writtenThread(grant.value.owner, {
32626
32663
  kind: "project",
32627
32664
  name: newThreadProject
@@ -32629,7 +32666,7 @@ const startHarnessSession = Effect.fnUntraced(function* (input) {
32629
32666
  yield* keychain.remove(gitTokenAccount(input.binding)).pipe(Effect.ignore);
32630
32667
  yield* keychain.set(sessionAccount(input.binding), Redacted.make(formatToken(started.credential.person, started.credential.id, secret)));
32631
32668
  yield* keychain.set(threadAccount(input.binding), Redacted.make(thread)).pipe(Effect.ignore);
32632
- yield* keychain.remove(pendingAccount).pipe(Effect.ignore);
32669
+ yield* keychain.remove(pending).pipe(Effect.ignore);
32633
32670
  return Option.some(started);
32634
32671
  });
32635
32672
  //#endregion
@@ -32691,6 +32728,11 @@ const ownerOf = (bound) => ({
32691
32728
  const decodeWrittenThread = Schema.decodeUnknownOption(WrittenThread);
32692
32729
  /** How long a harness session with no token is left before the keychain is asked again. */
32693
32730
  const unboundMillis = 3e4;
32731
+ /**
32732
+ * How long a prompt's start may run on past its hook's deadline before it's given up, and a later
32733
+ * prompt sends it again, as itself: well past the slowest start seen on staging, 4.5 seconds.
32734
+ */
32735
+ const startBound = "20 seconds";
32694
32736
  /** How often `ref0 mcp` renews its session's 30-minute lease while its harness process lives. */
32695
32737
  const renewEvery = "10 minutes";
32696
32738
  /** How soon a renewal that failed is tried again, well inside the lease. */
@@ -32706,6 +32748,7 @@ const sessionBinding = Effect.fnUntraced(function* (edges) {
32706
32748
  const hints = yield* LiveHints;
32707
32749
  const context = yield* Effect.context();
32708
32750
  const owned = yield* Ref.make(Option.none());
32751
+ const onTheWay = yield* Ref.make(Option.none());
32709
32752
  const listen = Effect.fnUntraced(function* (bound) {
32710
32753
  const client = yield* clientOf(bound.token);
32711
32754
  let pause = 1;
@@ -32801,17 +32844,31 @@ const sessionBinding = Effect.fnUntraced(function* (edges) {
32801
32844
  if (Option.isNone(started)) return Option.none();
32802
32845
  return yield* keychain.get(sessionAccount(binding)).pipe(Effect.orElseSucceed(() => Option.none()));
32803
32846
  });
32847
+ const startedFor = Effect.fnUntraced(function* (binding, hook, starting) {
32848
+ const sent = yield* Ref.get(onTheWay);
32849
+ if (Option.isSome(sent) && sent.value.harnessSession === binding.harnessSession) return yield* Deferred.await(sent.value.done);
32850
+ if (!starting) return Option.none();
32851
+ const done = yield* Deferred.make();
32852
+ const mine = Option.some({
32853
+ harnessSession: binding.harnessSession,
32854
+ done
32855
+ });
32856
+ yield* Ref.set(onTheWay, mine);
32857
+ yield* Effect.forkIn(startAtPrompt(binding, hook).pipe(Effect.timeoutOption(startBound), Effect.map(Option.flatten), Effect.orElseSucceed(() => Option.none()), Effect.ensuring(Ref.update(onTheWay, (now) => now === mine ? Option.none() : now)), Effect.onExit((exit) => Deferred.done(done, exit))), scope);
32858
+ return yield* Deferred.await(done);
32859
+ });
32804
32860
  const bind = Effect.fnUntraced(function* (hook) {
32805
32861
  const now = yield* Clock.currentTimeMillis;
32806
32862
  const current = yield* Ref.get(state);
32807
32863
  if (current.kind === "bound" && current.bound.binding.harnessSession === hook.harnessSession) return Option.some(current.bound);
32808
32864
  const starting = hook.event === "UserPromptSubmit" && Option.isSome(agent);
32865
+ const sent = Option.exists(yield* Ref.get(onTheWay), (one) => one.harnessSession === hook.harnessSession);
32809
32866
  const over = current.kind === "ended" && current.harnessSession === hook.harnessSession;
32810
32867
  const waiting = current.kind === "unbound" && now < current.until && !starting;
32811
- if (over && !starting || waiting) return Option.none();
32868
+ if ((over && !starting || waiting) && !sent) return Option.none();
32812
32869
  const binding = yield* bindingForServer(hook.harnessSession);
32813
32870
  const held = Option.isNone(binding) ? Option.none() : yield* heldFor(binding.value, over);
32814
- const token = Option.isSome(held) || Option.isNone(binding) || !starting ? held : yield* startAtPrompt(binding.value, hook);
32871
+ const token = Option.isSome(held) || Option.isNone(binding) ? held : yield* startedFor(binding.value, hook, starting);
32815
32872
  if (Option.isNone(binding) || Option.isNone(token)) {
32816
32873
  if (!over) yield* Ref.set(state, {
32817
32874
  kind: "unbound",
@@ -33101,7 +33158,7 @@ const toRead = (hook, ownHook) => {
33101
33158
  const sessionTurns = Effect.fnUntraced(function* (edges) {
33102
33159
  const transcripts = yield* Transcripts;
33103
33160
  const turn = yield* Ref.make(Option.none());
33104
- const trackTurn = Effect.fnUntraced(function* (client, bound, hook, deadline) {
33161
+ const trackTurn = Effect.fnUntraced(function* (client, bound, hook, deadline, late = false) {
33105
33162
  const checkout = yield* Checkout;
33106
33163
  const session = bound.session;
33107
33164
  const owner = ownerOf(bound);
@@ -33115,6 +33172,10 @@ const sessionTurns = Effect.fnUntraced(function* (edges) {
33115
33172
  const prompted = isNew ? hook.transcript : void 0;
33116
33173
  if (prompted !== void 0) yield* transcripts.begin(owner, harness, prompted, harnessTurn);
33117
33174
  const ending = prompted === void 0 ? void 0 : yield* transcripts.read(owner, harness, [prompted], { until: harnessTurn });
33175
+ const fresh = late && harness === "claude-code" ? {
33176
+ ...nothingSeen,
33177
+ tokens: readFailed(nothingSeen.tokens)
33178
+ } : nothingSeen;
33118
33179
  const tracked = isNew ? Option.some({
33119
33180
  kind: "starting",
33120
33181
  harnessTurn,
@@ -33125,7 +33186,7 @@ const sessionTurns = Effect.fnUntraced(function* (edges) {
33125
33186
  tokens: addRead(now.seen.tokens, ending, true)
33126
33187
  }
33127
33188
  }) : now.previous),
33128
- seen: nothingSeen
33189
+ seen: fresh
33129
33190
  }) : before;
33130
33191
  if (Option.isNone(tracked)) return;
33131
33192
  const ownHook = hook.harnessTurn === tracked.value.harnessTurn;
@@ -33201,6 +33262,7 @@ const sessionTurns = Effect.fnUntraced(function* (edges) {
33201
33262
  return {
33202
33263
  now: Ref.get(turn),
33203
33264
  track: trackTurn,
33265
+ trackLate: (client, bound, prompt, deadline) => trackTurn(client, bound, prompt, deadline, true),
33204
33266
  fresh: Ref.set(turn, Option.none())
33205
33267
  };
33206
33268
  });
@@ -33337,6 +33399,7 @@ var HarnessSession = class HarnessSession extends Context.Service()("ref0/cli/Ha
33337
33399
  const scope = yield* Scope.Scope;
33338
33400
  const tasks = yield* sessionTasks;
33339
33401
  const turns = yield* sessionTurns({ tasksLanded: tasks.landed });
33402
+ const prompted = yield* Ref.make(Option.none());
33340
33403
  const edits = yield* sessionEdits({ turn: turns.now });
33341
33404
  const delivery = yield* sessionDelivery;
33342
33405
  const wake = yield* sessionWake({
@@ -33394,9 +33457,13 @@ var HarnessSession = class HarnessSession extends Context.Service()("ref0/cli/Ha
33394
33457
  lastLook
33395
33458
  });
33396
33459
  const deliver = Effect.fnUntraced(function* (hook, deadline) {
33460
+ if (hook.event === "UserPromptSubmit") yield* Ref.set(prompted, Option.some(hook));
33397
33461
  const bound = yield* binding.bind(hook);
33398
33462
  if (Option.isNone(bound)) return Option.none();
33399
33463
  const client = yield* clientOf(bound.value.token);
33464
+ const prompt = Option.filter(yield* Ref.get(prompted), (one) => one.harnessSession === hook.harnessSession && one.harnessTurn === hook.harnessTurn);
33465
+ if (Option.isSome(prompt)) yield* Ref.set(prompted, Option.none());
33466
+ if (Option.isSome(prompt) && prompt.value !== hook) yield* turns.trackLate(client, bound.value, prompt.value, deadline);
33400
33467
  const unattributed = yield* edits.noteEdits(bound.value, hook);
33401
33468
  const within = yield* binding.within;
33402
33469
  if (yield* tasks.note(bound.value, hook)) yield* Effect.forkIn(tasks.send(client, bound.value).pipe(Effect.ignore), within);
@@ -33512,6 +33579,18 @@ const mcpServer = (version, started) => McpServer.toolkit(HarnessTools).pipe(Lay
33512
33579
  protocols
33513
33580
  })));
33514
33581
  /**
33582
+ * `ref0 mcp` in a checkout of another origin, which the launcher names: the plugin's server all
33583
+ * the same, answering every hook with nothing, since that origin's own plugin serves the
33584
+ * checkout. A harness takes a server that closes before `initialize` as broken, and Claude Code
33585
+ * then skips it for 15 minutes, in every folder. It reads nothing of this machine's, so it
33586
+ * answers at once.
33587
+ */
33588
+ const otherOriginServer = (version) => McpServer.toolkit(HarnessTools).pipe(Layer.provide(HarnessTools.toLayer({ hook: () => Effect.succeed({}) })), Layer.provide(McpServer.layerStdio({
33589
+ name: "ref0",
33590
+ version,
33591
+ protocols
33592
+ })));
33593
+ /**
33515
33594
  * The `codex` that started this server, by absolute path: its executable, which Linux names, or
33516
33595
  * its command's first word when that's a path. Codex isn't sure to be on `PATH` (M0).
33517
33596
  */
@@ -33542,11 +33621,18 @@ const codexSetupHere = Effect.fnUntraced(function* (process) {
33542
33621
  });
33543
33622
  });
33544
33623
  const mcp = Command.make(harnessCommandNames.mcp, {}, () => Effect.gen(function* () {
33624
+ const checked = yield* Config.schema(ConnectHarness, serverCheckVariable).pipe(Config.option);
33625
+ const other = yield* Config.option(Config.String(otherOriginVariable));
33626
+ if (Option.isSome(other) && Option.isNone(checked)) {
33627
+ yield* printBlocks([[`ref0: this folder is for ${other.value}, so ref0’s plugin for ${(yield* Ref0Config).api} does nothing in it.`]]);
33628
+ return yield* Layer.launch(otherOriginServer(cliVersion));
33629
+ }
33545
33630
  const agents = yield* ConfigFolders;
33546
- const found = yield* (yield* HarnessProcess).innermost;
33547
- 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();
33631
+ const found = Option.isSome(checked) ? Option.none() : yield* (yield* HarnessProcess).innermost;
33632
+ const harness = Option.orElse(checked, () => Option.map(found, (each) => each.harness));
33633
+ const agent = Option.isSome(harness) ? yield* (yield* HostGrant).get(yield* agents.folder(harness.value)).pipe(Effect.catchTag("KeychainError", (failed) => failed.unreadable === true ? Effect.succeedNone : sayFailed(failed.nextStep))) : Option.none();
33548
33634
  const codex = Option.isSome(found) && found.value.harness === "codex" ? yield* codexSetupHere(found.value.process) : Option.none();
33549
- const reinstall = Option.isSome(found) && Option.isSome(agent) ? installLine(yield* agents.folder(found.value.harness)) : "ref0 connect";
33635
+ const reinstall = Option.isSome(harness) && Option.isSome(agent) ? installLine(yield* agents.folder(harness.value)) : "ref0 connect";
33550
33636
  return yield* Layer.launch(mcpServer(cliVersion, {
33551
33637
  plugin: {
33552
33638
  version: yield* pluginVersion,
@@ -33750,21 +33836,44 @@ const checkoutAt = Effect.fnUntraced(function* (cwd) {
33750
33836
  };
33751
33837
  });
33752
33838
  /**
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.
33839
+ * The harness whose `SessionStart` runs this: Claude Code, which alone gives its hook the file it
33840
+ * runs ahead of the agent's commands, and never its agent's commands; else Codex, when the
33841
+ * innermost harness process above it is Codex's, as the session's binding reads it. Codex's hook
33842
+ * input says nothing Claude Code's doesn't, and Codex sets no `CODEX_HOME` for the default
33843
+ * `~/.codex`. Neither otherwise.
33844
+ */
33845
+ const startingHarness = Effect.gen(function* () {
33846
+ const file = yield* Config.String("CLAUDE_ENV_FILE").pipe(Config.withDefault(""));
33847
+ if (file !== "") return Option.some({
33848
+ harness: "claude-code",
33849
+ file
33850
+ });
33851
+ const found = yield* (yield* HarnessProcess).innermost;
33852
+ return Option.isSome(found) && found.value.harness === "codex" ? Option.some({ harness: "codex" }) : Option.none();
33853
+ });
33854
+ /**
33855
+ * What the agent reads when its commits couldn't be set up at its session's start, in one line,
33856
+ * whatever failed: the session still starts. Its commits may then name the person, skip ref0's
33857
+ * hooks, or meet a sign-off's refusal once.
33858
+ */
33859
+ const commitSetupFailedWords = (error) => {
33860
+ return `ref0: ref0 couldn't set up Git for your commands, so your commits may not name you as their author or run ref0's hooks: ${("nextStep" in error ? error.nextStep : "detail" in error ? error.detail : error.message).replaceAll(/\s+/g, " ").trim()}`;
33861
+ };
33862
+ /**
33863
+ * Adds the agent's commit environment for the session a `SessionStart` opens, in Claude Code to
33864
+ * the file it names and in Codex to its checkout's section, only for a setup with an agent. ref0's
33865
+ * hooks folder is written first, so nothing names a folder that isn't there. The harness whose
33866
+ * hooks path it set, if it set one.
33757
33867
  */
33758
33868
  const setCommitEnvironment = Effect.fnUntraced(function* (session) {
33759
33869
  const fs = yield* FileSystem.FileSystem;
33760
33870
  const folders = yield* ConfigFolders;
33761
- const file = yield* Config.String("CLAUDE_ENV_FILE").pipe(Config.withDefault(""));
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
- }
33871
+ const starting = yield* startingHarness;
33872
+ if (Option.isNone(starting)) return Option.none();
33873
+ const hook = starting.value;
33874
+ if (hook.harness === "codex") return (yield* setCodexCommitEnvironment(session.cwd ?? ".", yield* checkoutAt(session.cwd ?? "."))) ? Option.some("codex") : Option.none();
33766
33875
  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())));
33767
- if (Option.isNone(connected)) return false;
33876
+ if (Option.isNone(connected)) return Option.none();
33768
33877
  const checkout = yield* checkoutAt(session.cwd ?? ".");
33769
33878
  const folder = yield* hooksFolder(yield* gitHooksHome(yield* Config.String("HOME"), (yield* Ref0Config).api), checkout.folder);
33770
33879
  const author = checkout.signsOff ? Option.none() : Option.map(connected.value, (grant) => ({
@@ -33778,8 +33887,8 @@ const setCommitEnvironment = Effect.fnUntraced(function* (session) {
33778
33887
  transcript: Option.filter(Option.fromNullishOr(session.transcript_path), (path) => path !== ""),
33779
33888
  model: startingModel(session.model)
33780
33889
  });
33781
- yield* fs.writeFileString(file, script, { flag: "a" });
33782
- return true;
33890
+ yield* fs.writeFileString(hook.file, script, { flag: "a" });
33891
+ return Option.some("claude-code");
33783
33892
  });
33784
33893
  //#endregion
33785
33894
  //#region src/harness/session-start.ts
@@ -33831,10 +33940,8 @@ const startOnHook = (protocol) => Effect.gen(function* () {
33831
33940
  const input = yield* stdio.stdin.pipe(Stream.decodeText(), Stream.mkString);
33832
33941
  const hook = decodeHook(input);
33833
33942
  if (Option.isNone(hook)) return;
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
- }
33943
+ const hooksSet = yield* setCommitEnvironment(hook.value).pipe(Effect.catch((error) => Console.log(commitSetupFailedWords(error)).pipe(Effect.as(Option.none()))));
33944
+ if (Option.isSome(hooksSet)) yield* Console.log(hooksSet.value === "codex" ? codexHookInstallerWords : hookInstallerWords);
33838
33945
  const binding = yield* bindingHere(hook.value.session_id);
33839
33946
  if (Option.isNone(binding)) return;
33840
33947
  const harnessName = binding.value.harness === "codex" ? "codex" : "claude-code";
@@ -33888,6 +33995,203 @@ const sessionStart = Command.make(harnessCommandNames.sessionStart, { protocol:
33888
33995
  /** The commands a harness runs: `ref0 mcp`, and its command hooks. */
33889
33996
  const harnessCommands = [mcp, Command.make(harnessCommandNames.hook).pipe(Command.withDescription("The harness's hooks that run as commands"), Command.withShortDescription("Run the harness’s session hooks"), Command.withSubcommands([sessionStart, sessionEnd]))];
33890
33997
  //#endregion
33998
+ //#region src/harness/command-token.ts
33999
+ /**
34000
+ * The session an agent's command acts as: its harness session's, by the token kept under its
34001
+ * binding. Outside a harness there's none, and a command is its person's, asked by approval; so
34002
+ * too in a harness whose config folder has no agent connected. Where one is connected, a harness
34003
+ * session with no token has lost its start, and the command says why, as far as ref0 can tell,
34004
+ * and what to do, rather than run as its person.
34005
+ */
34006
+ /**
34007
+ * Whether ref0's server, `ref0 mcp`, runs right under a harness process, as the harness starts
34008
+ * it: none when the process table can't be read.
34009
+ */
34010
+ var HarnessServers = class HarnessServers extends Context.Service()("ref0/cli/HarnessServers") {
34011
+ static layer = Layer.effect(HarnessServers, Effect.gen(function* () {
34012
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
34013
+ const runsUnder = (process) => spawner.string(ChildProcess.make("ps", [
34014
+ "-A",
34015
+ "-o",
34016
+ "ppid=,args="
34017
+ ])).pipe(Effect.map((table) => table.trim() === "" ? Option.none() : Option.some(table.split("\n").some((line) => {
34018
+ const [parent = "", ...words] = line.trim().split(/\s+/);
34019
+ return Number(parent) === process && words.at(-1) === harnessCommandNames.mcp && words.some((word) => word.includes("ref0"));
34020
+ }))), Effect.orElseSucceed(() => Option.none()));
34021
+ return HarnessServers.of({ runsUnder });
34022
+ }));
34023
+ };
34024
+ /**
34025
+ * What an agent's command in a harness session with no ref0 session says to do: run again, while
34026
+ * the prompt's start is on its way; reconnect or restart, when the server isn't running; else
34027
+ * send the prompt again, which starts one.
34028
+ */
34029
+ const noSessionStep = (harness, server, seen) => {
34030
+ const name = configFolders[harness].name;
34031
+ const restart = harness === "codex" ? "restart Codex" : `run /mcp, choose ${server} and Reconnect, or restart Claude Code`;
34032
+ if (Option.contains(seen.serverRuns, false)) return `This ${name} session has no ref0 session: ref0’s server isn’t running in it, so none started at the last prompt. Tell your person to ${restart}, then send the prompt again.`;
34033
+ if (seen.pending) return `This ${name} session has no ref0 session yet: its start at the last prompt hasn’t landed. Run this again in a few seconds. If it says this again, tell your person to send the prompt again.`;
34034
+ return `This ${name} session has no ref0 session: none started at the last prompt. Tell your person to send the prompt again. If it says this again, tell them to ${restart}.`;
34035
+ };
34036
+ /** `SessionToken` from the keychain, by the binding of the harness session this runs in. */
34037
+ const harnessSessionToken = Layer.effect(SessionToken, Effect.gen(function* () {
34038
+ const keychain = yield* Keychain;
34039
+ const harness = yield* HarnessProcess;
34040
+ const servers = yield* HarnessServers;
34041
+ const hostGrant = yield* HostGrant;
34042
+ const folders = yield* ConfigFolders;
34043
+ const config = yield* Ref0Config;
34044
+ const plugin = yield* originPluginName(config.api);
34045
+ const current = Effect.gen(function* () {
34046
+ const binding = yield* bindingHere().pipe(Effect.provideService(HarnessProcess, harness), Effect.orElseSucceed(() => Option.none()));
34047
+ if (Option.isNone(binding)) return Option.none();
34048
+ const kept = yield* keychain.get(sessionAccount(binding.value));
34049
+ if (Option.isSome(kept)) return kept;
34050
+ const name = binding.value.harness === "codex" ? "codex" : "claude-code";
34051
+ const grant = yield* hostGrant.get(yield* folders.folder(name)).pipe(Effect.catchIf((failed) => failed.unreadable === true, () => Effect.succeedNone));
34052
+ if (Option.isNone(grant) || grant.value.harness !== name) return Option.none();
34053
+ const pending = yield* keychain.get(pendingAccount(binding.value));
34054
+ const serverRuns = yield* servers.runsUnder(binding.value.process);
34055
+ return yield* new NoSession({ nextStep: noSessionStep(name, `plugin:${plugin}:${plugin}`, {
34056
+ serverRuns,
34057
+ pending: Option.isSome(pending)
34058
+ }) });
34059
+ });
34060
+ return SessionToken.of({ current });
34061
+ }));
34062
+ //#endregion
34063
+ //#region src/harness/server-check.ts
34064
+ /**
34065
+ * Whether a config folder's ref0 server starts, asked as its harness asks: the command its
34066
+ * installed plugin's `.mcp.json` names, started from an empty folder of ref0's own with the
34067
+ * environment that harness would give it from this terminal's, and sent `initialize`. Told by
34068
+ * `serverCheckVariable` which harness it's started as, the server reads that folder's grant from
34069
+ * the keychain as it does at a session's start, and starts no session. Its whole process group is
34070
+ * stopped once it answers, stops, or the limit passes, so nothing it started outlasts the check.
34071
+ */
34072
+ /**
34073
+ * The variables Codex 0.164.0 passes a stdio server of its own, beside those its entry's
34074
+ * `env_vars` names that are set. Nothing else reaches the server (measured on Linux, 24 Oct 2026).
34075
+ */
34076
+ const codexPasses = [
34077
+ "HOME",
34078
+ "LOGNAME",
34079
+ "PATH",
34080
+ "SHELL",
34081
+ "USER",
34082
+ "LANG",
34083
+ "LC_ALL",
34084
+ "TERM",
34085
+ "TMPDIR",
34086
+ "TZ",
34087
+ "__CF_USER_TEXT_ENCODING",
34088
+ "SSL_CERT_FILE",
34089
+ "NODE_EXTRA_CA_CERTS"
34090
+ ];
34091
+ /**
34092
+ * How long a server has to answer `initialize`: ten times its slowest start measured on Linux,
34093
+ * 0.5 s (24 Oct 2026), so status stays quick. A harness may wait longer (Codex 0.164.0 waited 14 s
34094
+ * for a slow one), so a server that doesn't answer in it is unknown, not one that can't start.
34095
+ */
34096
+ const serverLimit = Duration.seconds(5);
34097
+ const checkId = "ref0-status";
34098
+ /**
34099
+ * `initialize` and its answer, as JSON-RPC carries them, in MCP's own schemas: those `ref0 mcp`
34100
+ * serves through Effect's MCP server.
34101
+ */
34102
+ const InitializeRequest = Schema.Struct({
34103
+ jsonrpc: Schema.Literal("2.0"),
34104
+ id: Schema.Literal(checkId),
34105
+ method: Schema.Literal(McpSchema.Initialize._tag),
34106
+ params: McpSchema.Initialize.payloadSchema
34107
+ });
34108
+ const InitializeAnswer = Schema.Struct({
34109
+ id: Schema.Literal(checkId),
34110
+ result: McpSchema.InitializeResult
34111
+ });
34112
+ /** `initialize`, at a version every ref0 server speaks, as a harness's first line. */
34113
+ const initialize = `${Schema.encodeSync(Schema.fromJsonString(InitializeRequest))({
34114
+ jsonrpc: "2.0",
34115
+ id: checkId,
34116
+ method: McpSchema.Initialize._tag,
34117
+ params: {
34118
+ protocolVersion: "2025-06-18",
34119
+ capabilities: new McpSchema.ClientCapabilities({}),
34120
+ clientInfo: {
34121
+ name: "ref0 status",
34122
+ version: cliVersion
34123
+ }
34124
+ }
34125
+ })}\n`;
34126
+ const decodeServers = Schema.decodeUnknownEffect(Schema.fromJsonString(McpServersFile));
34127
+ const decodeAnswer = Schema.decodeUnknownOption(Schema.fromJsonString(InitializeAnswer));
34128
+ /**
34129
+ * `names` as this terminal sets them, which a harness started here would pass on. A relative
34130
+ * `PATH` folder is read from here, as `onPath` reads it, since the server starts elsewhere.
34131
+ */
34132
+ const fromHere = Effect.fnUntraced(function* (names) {
34133
+ const paths = yield* Path.Path;
34134
+ const set = [];
34135
+ for (const name of new Set(names)) {
34136
+ const value = yield* Config.String(name).pipe(Config.option);
34137
+ if (Option.isNone(value)) continue;
34138
+ set.push([name, name === "PATH" ? value.value.split(":").map((folder) => folder === "" || paths.isAbsolute(folder) ? folder : `${process.cwd()}/${folder}`).join(":") : value.value]);
34139
+ }
34140
+ return Object.fromEntries(set);
34141
+ });
34142
+ /** The first line a stopped server said, made printable. */
34143
+ const firstLine = (said) => printable(said.split("\n").map((line) => line.trim()).find((line) => line !== "") ?? "");
34144
+ /**
34145
+ * Starts the server named `name` in the plugin installed at `plugin`, as `harness` would for
34146
+ * `configFolder`, and answers how its start went.
34147
+ */
34148
+ const startServer = Effect.fnUntraced(function* (harness, configFolder, plugin, name) {
34149
+ const fs = yield* FileSystem.FileSystem;
34150
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
34151
+ const server = yield* fs.readFileString(`${plugin}/.mcp.json`).pipe(Effect.flatMap(decodeServers), Effect.map((file) => Option.fromNullishOr(file.mcpServers[name])), Effect.orElseSucceed(Option.none));
34152
+ if (Option.isNone(server)) return { kind: "unnamed" };
34153
+ const asked = server.value.env_vars ?? [];
34154
+ const checked = {
34155
+ REF0_API: (yield* Ref0Config).api,
34156
+ [serverCheckVariable]: harness
34157
+ };
34158
+ const env = harness === "codex" ? {
34159
+ ...yield* fromHere([...codexPasses, ...asked]),
34160
+ CODEX_HOME: configFolder,
34161
+ ...checked
34162
+ } : {
34163
+ ...yield* fromHere([
34164
+ ...codexPasses,
34165
+ ...keychainVariables,
34166
+ ...asked
34167
+ ]),
34168
+ CLAUDE_CONFIG_DIR: configFolder,
34169
+ ...checked
34170
+ };
34171
+ return yield* Effect.scoped(Effect.gen(function* () {
34172
+ const handle = yield* spawner.spawn(ChildProcess.make(server.value.command, server.value.args, {
34173
+ cwd: yield* emptyFolder(fs),
34174
+ env,
34175
+ extendEnv: harness === "claude-code",
34176
+ forceKillAfter: "1 second",
34177
+ stdin: {
34178
+ stream: Stream.make(initialize).pipe(Stream.encodeText),
34179
+ endOnDone: false
34180
+ }
34181
+ }));
34182
+ const said = yield* handle.stderr.pipe(Stream.decodeText(), Stream.mkString, Effect.forkScoped);
34183
+ const lines = yield* handle.stdout.pipe(Stream.decodeText(), Stream.splitLines, Stream.takeUntil((line) => Option.isSome(decodeAnswer(line))), Stream.runCollect);
34184
+ if (lines.some((line) => Option.isSome(decodeAnswer(line)))) return { kind: "answered" };
34185
+ return {
34186
+ kind: "stopped",
34187
+ said: firstLine(yield* Fiber.join(said)) || firstLine(lines.join("\n"))
34188
+ };
34189
+ })).pipe(Effect.catchTag("PlatformError", (error) => Effect.succeed({
34190
+ kind: "stopped",
34191
+ said: firstLine(error.message)
34192
+ })), Effect.timeoutOption(serverLimit), Effect.map(Option.getOrElse(() => ({ kind: "unanswered" }))));
34193
+ });
34194
+ //#endregion
33891
34195
  //#region src/harness/status.ts
33892
34196
  /** Each connected or initialized folder has its own report; secrets are used only to ask ref0. */
33893
34197
  const encodeReports = Schema.encodeEffect(Schema.Array(StatusReport));
@@ -33943,6 +34247,15 @@ const speaksCheck = (protocols, version, reinstall) => {
33943
34247
  if (Option.isSome(mismatch)) return checkOf("plugin", "no", mismatch.value.says, mismatch.value.run);
33944
34248
  return checkOf("plugin", "yes", "installed and speaks this ref0");
33945
34249
  };
34250
+ /** A server's start, as its line says it, with this folder's reinstall for one that can't start. */
34251
+ const serverCheckOf = (started, configFolder) => {
34252
+ switch (started.kind) {
34253
+ case "answered": return checkOf("server", "yes", "starts");
34254
+ case "stopped": return checkOf("server", "no", started.said === "" ? "can’t start, and said nothing" : `can’t start: ${started.said}`, installLine(configFolder));
34255
+ case "unnamed": return checkOf("server", "no", "the plugin names no ref0 server", installLine(configFolder));
34256
+ case "unanswered": return checkOf("server", "unknown", `didn’t answer in ${Duration.toSeconds(serverLimit)} s`, "ref0 status");
34257
+ }
34258
+ };
33946
34259
  const decodePlugins = Schema.decodeUnknownEffect(Schema.fromJsonString(ClaudePluginsListed));
33947
34260
  const decodeHooks = Schema.decodeUnknownEffect(Schema.fromJsonString(HooksFile));
33948
34261
  /** Ask Claude Code about this folder, even when another folder is current in the terminal. */
@@ -33976,7 +34289,7 @@ const claudeCodeChecks = Effect.fnUntraced(function* (configFolder) {
33976
34289
  return [speaksCheck(Option.match(hooks, {
33977
34290
  onNone: () => [],
33978
34291
  onSome: protocolsOf
33979
- }), Option.fromUndefinedOr(plugin.version), reinstall)];
34292
+ }), Option.fromUndefinedOr(plugin.version), reinstall), folder === void 0 ? checkOf("server", "unknown", "Claude Code didn’t say where ref0’s plugin is", reinstall) : serverCheckOf(yield* startServer("claude-code", configFolder, folder, name), configFolder)];
33980
34293
  });
33981
34294
  /** Trust only ref0's hooks; removal of an unexpected plugin targets this folder too. */
33982
34295
  const trustCheck = (hooks, configFolder, name) => {
@@ -33996,7 +34309,13 @@ const codexChecks = Effect.fnUntraced(function* (configFolder) {
33996
34309
  const hooks = listed.value.data.flatMap((entry) => entry.hooks).filter((hook) => pluginHook(hook, `${name}@${name}`));
33997
34310
  if (hooks.length === 0) return [checkOf("plugin", "no", "not installed or turned off in this folder", reinstall)];
33998
34311
  const protocols = hooks.map((hook) => protocolFlagged(hook.command ?? "")).filter(Predicate.isString);
33999
- return [speaksCheck(protocols, Option.none(), reinstall), trustCheck(hooks, configFolder, name)];
34312
+ const path = yield* Path.Path;
34313
+ const source = hooks.find((hook) => Predicate.isString(hook.sourcePath))?.sourcePath;
34314
+ return [
34315
+ speaksCheck(protocols, Option.none(), reinstall),
34316
+ trustCheck(hooks, configFolder, name),
34317
+ Predicate.isString(source) ? serverCheckOf(yield* startServer("codex", configFolder, path.dirname(path.dirname(source)), name), configFolder) : checkOf("server", "unknown", "Codex didn’t say where ref0’s plugin is", reinstall)
34318
+ ];
34000
34319
  });
34001
34320
  /** A session belongs only to the current folder, even when several folders share its agent. */
34002
34321
  const sessionCheck = Effect.fnUntraced(function* (harness, configFolder) {
@@ -34025,8 +34344,7 @@ const statusCommands = [Command.make(harnessCommandNames.status, { json }, ({ js
34025
34344
  }
34026
34345
  for (const { configFolder, harness } of found) candidates.set(configFolder, harness);
34027
34346
  const paths = [...candidates.keys()].sort((a, b) => a === current ? -1 : b === current ? 1 : 0);
34028
- const reports = [];
34029
- for (const configFolder of paths) {
34347
+ const reportOf = Effect.fnUntraced(function* (configFolder) {
34030
34348
  const kept = yield* Effect.result(grants.get(configFolder));
34031
34349
  const unreadable = Result.isFailure(kept) && kept.failure.unreadable === true;
34032
34350
  const grant = Result.isSuccess(kept) ? kept.success : Option.none();
@@ -34036,15 +34354,19 @@ const statusCommands = [Command.make(harnessCommandNames.status, { json }, ({ js
34036
34354
  configFolder,
34037
34355
  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))]
34038
34356
  };
34039
- reports.push(harness === void 0 ? report : {
34357
+ return harness === void 0 ? report : {
34040
34358
  ...report,
34041
34359
  harness
34042
- });
34043
- continue;
34360
+ };
34361
+ }
34362
+ const [granted, installed] = yield* Effect.all([grantChecks(grant.value, configFolder), grant.value.harness === "codex" ? codexChecks(configFolder) : claudeCodeChecks(configFolder)], { concurrency: "unbounded" });
34363
+ const checks = [...granted, ...installed];
34364
+ if (configFolder === current) {
34365
+ const session = yield* sessionCheck(grant.value.harness, configFolder);
34366
+ const server = checks.find((check) => check.check === "server");
34367
+ checks.push(server !== void 0 && server.answer !== "yes" && session.answer === "no" ? checkOf("session", "no", `none, since ref0’s server ${server.answer === "no" ? "can’t start" : "didn’t answer"}`, server.next) : session);
34044
34368
  }
34045
- const checks = [...yield* grantChecks(grant.value, configFolder), ...yield* grant.value.harness === "codex" ? codexChecks(configFolder) : claudeCodeChecks(configFolder)];
34046
- if (configFolder === current) checks.push(yield* sessionCheck(grant.value.harness, configFolder));
34047
- reports.push({
34369
+ return {
34048
34370
  harness: grant.value.harness,
34049
34371
  configFolder,
34050
34372
  agent: {
@@ -34052,8 +34374,9 @@ const statusCommands = [Command.make(harnessCommandNames.status, { json }, ({ js
34052
34374
  agent: grant.value.agent
34053
34375
  },
34054
34376
  checks
34055
- });
34056
- }
34377
+ };
34378
+ });
34379
+ const reports = yield* Effect.forEach(paths, reportOf, { concurrency: "unbounded" });
34057
34380
  const home = yield* Config.String("HOME").pipe(Config.withDefault("."));
34058
34381
  yield* printAnswer(yield* Effect.orDie(encodeReports(reports)), asJson, reports.length === 0 ? [["No initialized harness folders found.", { command: "ref0 connect" }]] : reports.map((report) => wordsOf(report, home)));
34059
34382
  if (reports.some((report) => report.checks.some((check) => check.answer === "no"))) return yield* new Said();
@@ -34098,7 +34421,12 @@ const ref0 = Command.make("ref0").pipe(Command.withDescription("ref0 from the te
34098
34421
  ]));
34099
34422
  const keychain = Keychain.layer.pipe(Layer.provide([NodeServices.layer, Ref0Config.layer]));
34100
34423
  const harnessProcess = HarnessProcess.layer.pipe(Layer.provide(NodeServices.layer));
34101
- const sessionToken = harnessSessionToken.pipe(Layer.provide([keychain, harnessProcess]));
34424
+ const sessionToken = harnessSessionToken.pipe(Layer.provide([
34425
+ keychain,
34426
+ harnessProcess,
34427
+ HarnessServers.layer.pipe(Layer.provide(NodeServices.layer)),
34428
+ HostGrant.layer.pipe(Layer.provide(keychain))
34429
+ ]));
34102
34430
  /** Every service `ref0`'s commands use, on this machine. */
34103
34431
  const services = Layer.mergeAll(cliOutput, Ref0.layer.pipe(Layer.provide([
34104
34432
  Ref0Config.layer,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ref0/cli",
3
- "version": "0.9.0",
3
+ "version": "0.10.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.9.0",
32
+ "@ref0/sdk": "0.10.0",
33
33
  "@types/node": "24.19.2",
34
34
  "smol-toml": "1.9.1",
35
35
  "typescript": "7.0.2",