@ref0/cli 0.6.0 → 0.7.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 (3) hide show
  1. package/README.md +36 -5
  2. package/dist/main.mjs +1618 -822
  3. package/package.json +2 -2
package/dist/main.mjs CHANGED
@@ -1,13 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
  import { NodeRuntime, NodeServices } from "@effect/platform-node";
3
- import { Cause, Clock, Config, 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, 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";
7
7
  import { ChildProcess, ChildProcessSpawner } from "effect/process";
8
- import { hostname } from "node:os";
9
8
  import { execFile, spawn } from "node:child_process";
10
- import { connect, createServer } from "node:net";
9
+ import { createServer } from "node:http";
10
+ import { access, constants } from "node:fs/promises";
11
+ import { hostname } from "node:os";
12
+ import { connect, createServer as createServer$1 } from "node:net";
11
13
  import { join } from "node:path";
12
14
  import { McpProtocol, McpServer, Tool, Toolkit } from "effect/ai";
13
15
  //#region ../contracts/src/typeid.ts
@@ -1299,7 +1301,9 @@ const FindingRef = Schema.Struct({
1299
1301
  * Who a passkey request asks: the accountable person; a person, by their name; or anyone holding
1300
1302
  * one of the repo's permissions, the first of whom to answer decides. Until a permission's holders
1301
1303
  * are kept, they're its owner's admins. A workflow run's `passkey.run` asks its run's accountable
1302
- * person; a gate's `passkey.check` asks the person behind the run that meets its need.
1304
+ * person; a gate's `passkey.check` asks the person behind the run that meets its need. A person or
1305
+ * a permission is someone else, a second pair of eyes, so the person `'accountable'` would ask
1306
+ * never answers it: a request with nobody else to ask is refused.
1303
1307
  */
1304
1308
  const PasskeyFrom = Schema.Union([
1305
1309
  Schema.Literal("accountable"),
@@ -6510,35 +6514,46 @@ var PasskeyRefused = class extends Schema.TaggedError()("PasskeyRefused", { next
6510
6514
  const MachineName = Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(64));
6511
6515
  /** The harness a machine says it connects for, never proof of what it runs. */
6512
6516
  const ConnectHarness = Schema.Literals(["claude-code", "codex"]);
6513
- /**
6514
- * A machine asks with no owner and sends hashes of its grant, collecting and link secrets.
6515
- * The optional callback names only a loopback port and a random state segment, never a host.
6516
- */
6517
+ /** The machine's loopback listener; no caller may choose its host. */
6518
+ const AttachmentCallback = Schema.Struct({
6519
+ port: Schema.Int.check(Schema.isBetween({
6520
+ minimum: 1024,
6521
+ maximum: 65535
6522
+ })),
6523
+ state: Schema.String.check(Schema.isPattern(/^[A-Za-z0-9_-]{22}$/))
6524
+ });
6525
+ /** 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));
6527
+ const uniqueFolderPaths = Schema.makeFilter((folders) => new Set(folders.map((folder) => folder.path)).size === folders.length, { expected: "unique config folder paths" });
6528
+ const RequestFolder = Schema.Struct({
6529
+ path: ConfigFolderPath,
6530
+ harness: ConnectHarness,
6531
+ secretHash: SecretHash,
6532
+ replaces: Schema.optionalKey(TokenId)
6533
+ });
6534
+ /** One to eight config folders, each with its own grant secret. */
6517
6535
  const RequestAttachment = Schema.Struct({
6518
6536
  machine: MachineId,
6519
6537
  machineName: MachineName,
6520
- harness: Schema.optionalKey(ConnectHarness),
6521
- secretHash: SecretHash,
6538
+ folders: Schema.Array(RequestFolder).check(Schema.isMinLength(1), Schema.isMaxLength(8), uniqueFolderPaths),
6522
6539
  collectHash: SecretHash,
6523
6540
  linkHash: SecretHash,
6524
- callback: Schema.optionalKey(Schema.Struct({
6525
- port: Schema.Int.check(Schema.isBetween({
6526
- minimum: 1024,
6527
- maximum: 65535
6528
- })),
6529
- state: Schema.String.check(Schema.isPattern(/^[A-Za-z0-9_-]{22}$/))
6530
- }))
6541
+ callback: Schema.optionalKey(AttachmentCallback)
6531
6542
  });
6532
6543
  /** An attachment request, as its person reads it once they type its code. */
6533
6544
  const AttachmentRequestView = Schema.Struct({
6534
6545
  approval: ApprovalId,
6535
6546
  /** The machine's own word for itself, never fact. */
6536
6547
  machineName: MachineName,
6537
- /** The harness the machine says it uses, never fact. */
6538
- harness: Schema.optionalKey(ConnectHarness),
6548
+ folders: Schema.Array(Schema.Struct({
6549
+ path: ConfigFolderPath,
6550
+ harness: ConnectHarness,
6551
+ replaces: Schema.optionalKey(TokenId)
6552
+ })),
6539
6553
  askedFrom: Schema.optionalKey(Schema.String),
6540
6554
  askedAt: Timestamp,
6541
- expiresAt: Timestamp
6555
+ expiresAt: Timestamp,
6556
+ callback: Schema.optionalKey(AttachmentCallback)
6542
6557
  });
6543
6558
  /**
6544
6559
  * What its answerer chooses to connect a machine's request to: one of the person's own agents,
@@ -6562,6 +6577,16 @@ const ConnectOrganization = Schema.Struct({
6562
6577
  organization: OrgId,
6563
6578
  name: OwnerName
6564
6579
  });
6580
+ /** Whether admission replaced a live host grant, or skipped one that no longer belongs here. */
6581
+ const GrantReplacement = Schema.Struct({
6582
+ grant: TokenId,
6583
+ state: Schema.Literals([
6584
+ "replaces",
6585
+ "revoked",
6586
+ "skipped"
6587
+ ]),
6588
+ attachedAt: Schema.optionalKey(Timestamp)
6589
+ });
6565
6590
  /**
6566
6591
  * What a yes would connect the machine to, as options resolved the choice: the agent, or none
6567
6592
  * yet, to make with the handle; and each organization its attachment is authorized in.
@@ -6569,22 +6594,32 @@ const ConnectOrganization = Schema.Struct({
6569
6594
  const ConnectChosen = Schema.Struct({
6570
6595
  agent: Schema.optionalKey(AgentRef),
6571
6596
  handle: AgentHandle,
6572
- organizations: Schema.Array(ConnectOrganization)
6597
+ organizations: Schema.Array(ConnectOrganization),
6598
+ replacement: Schema.optionalKey(GrantReplacement)
6573
6599
  });
6574
- /**
6575
- * A machine's request, as its answerer chooses what it connects to. Without a choice: the
6576
- * request and the person's agents that are on, and nothing chosen for them. With one: the
6577
- * request, what the choice resolved to, and the passkey's options, whose challenge signs over
6578
- * the connect it would run.
6579
- */
6580
- const ConnectOptions = Schema.Union([Schema.Struct({
6581
- request: AttachmentRequestView,
6582
- agents: Schema.Array(ConnectableAgent)
6600
+ /** A complete choice: name every folder once, leaving at least one in. */
6601
+ const ConnectFolderChoice = Schema.Union([Schema.Struct({
6602
+ path: ConfigFolderPath,
6603
+ excluded: Schema.Literal(true)
6604
+ }), Schema.Struct({
6605
+ path: ConfigFolderPath,
6606
+ agent: ConnectAgent
6607
+ })]);
6608
+ const ConnectFolderChoices = Schema.Array(ConnectFolderChoice).check(Schema.isMinLength(1), Schema.isMaxLength(8), uniqueFolderPaths);
6609
+ const ConnectFolderOption = Schema.Union([Schema.Struct({
6610
+ path: ConfigFolderPath,
6611
+ excluded: Schema.Literal(true)
6583
6612
  }), Schema.Struct({
6613
+ path: ConfigFolderPath,
6614
+ connect: ConnectChosen
6615
+ })]);
6616
+ /** Defaults and explicit choices both resolve the entire request and sign one challenge. */
6617
+ const ConnectOptions = Schema.Struct({
6584
6618
  request: AttachmentRequestView,
6585
- connect: ConnectChosen,
6619
+ agents: Schema.Array(ConnectableAgent),
6620
+ folders: Schema.Array(ConnectFolderOption),
6586
6621
  passkey: PasskeyRequestOptions
6587
- })]);
6622
+ });
6588
6623
  /** Why connecting connected nothing, said for the person. */
6589
6624
  const AttachmentFailure = Schema.Struct({
6590
6625
  reason: Schema.Literals([
@@ -6612,11 +6647,9 @@ const RequestOrganization = Schema.Struct({
6612
6647
  ])
6613
6648
  });
6614
6649
  /**
6615
- * Where a machine's request stands, for the machine that asked, by its collecting secret: waiting,
6616
- * or how it ended. Every answer but waiting is final, but a connect nobody confirmed 10 minutes
6617
- * after it connected fails `not confirmed` once ref0 takes its grant to revoke it. Connected
6618
- * names the owner by its name, and the host grant, whose token the machine makes from its ID and
6619
- * the grant's secret.
6650
+ * The result collected with the machine's two proofs: each included folder's identity and grant,
6651
+ * its agent's owner name and organizations, and excluded paths. Confirmation checks each saved
6652
+ * grant's live deadline in the Owner DO.
6620
6653
  */
6621
6654
  const RequestState = Schema.Union([
6622
6655
  Schema.Struct({
@@ -6626,20 +6659,30 @@ const RequestState = Schema.Union([
6626
6659
  Schema.Struct({
6627
6660
  state: Schema.Literal("connected"),
6628
6661
  owner: OwnerName,
6629
- agent: AgentRef,
6630
- handle: AgentHandle,
6631
- credential: CredentialRef,
6632
- organizations: Schema.Array(RequestOrganization)
6662
+ folders: Schema.Array(Schema.Struct({
6663
+ path: ConfigFolderPath,
6664
+ owner: OwnerName,
6665
+ ...MachineConnected.fields,
6666
+ organizations: Schema.Array(RequestOrganization),
6667
+ replacement: Schema.optionalKey(GrantReplacement)
6668
+ })),
6669
+ excluded: Schema.Array(ConfigFolderPath)
6633
6670
  }),
6634
6671
  Schema.Struct({ state: Schema.Literal("refused") }),
6635
6672
  Schema.Struct({ state: Schema.Literal("aborted") }),
6636
6673
  Schema.Struct({ state: Schema.Literal("expired") }),
6637
6674
  Schema.Struct({
6638
6675
  state: Schema.Literal("failed"),
6639
- reason: Schema.Literals([...AttachmentFailure.fields.reason.literals, "not confirmed"]),
6676
+ reason: AttachmentFailure.fields.reason,
6640
6677
  nextStep: Schema.String
6641
6678
  })
6642
6679
  ]);
6680
+ /** The grant IDs saved by this terminal, each belonging to the same admission. */
6681
+ const SavedMachineGrants = Schema.Array(TokenId).check(Schema.isMinLength(1), Schema.isMaxLength(8), Schema.isUnique());
6682
+ Schema.Struct({
6683
+ approval: ApprovalId,
6684
+ grants: SavedMachineGrants
6685
+ });
6643
6686
  /**
6644
6687
  * The machine saying it saved the grant it collected: confirmed, the same again, or not, once ref0
6645
6688
  * has taken the grant to revoke it, and the machine deletes what it saved.
@@ -6670,21 +6713,34 @@ const AuthorizeIn = Schema.Struct({
6670
6713
  organization: OrgId,
6671
6714
  targetGeneration: TargetGeneration
6672
6715
  });
6673
- /** What every connect names: the agent, the machine and its host grant, and what it was bound to. */
6674
- const connectFields = {
6716
+ /** One folder admitted under the request's machine and approving browser. */
6717
+ const ConnectFolderCommand = Schema.Struct({
6718
+ path: ConfigFolderPath,
6675
6719
  agent: AttachmentAgent,
6676
- machine: MachineId,
6677
- machineName: MachineName,
6678
6720
  token: TokenId,
6679
6721
  secretHash: SecretHash,
6680
- browserSession: TokenId,
6681
- personGeneration: Generation,
6722
+ replaces: Schema.optionalKey(TokenId),
6682
6723
  authorizeIn: Schema.Array(AuthorizeIn)
6683
- };
6724
+ });
6684
6725
  Schema.Struct({
6685
6726
  request: ApprovalId,
6686
- ...connectFields
6727
+ machine: MachineId,
6728
+ machineName: MachineName,
6729
+ browserSession: TokenId,
6730
+ personGeneration: Generation,
6731
+ folders: Schema.Array(ConnectFolderCommand).check(Schema.isMinLength(1), Schema.isMaxLength(8), uniqueFolderPaths),
6732
+ excluded: Schema.Array(ConfigFolderPath).check(Schema.isMaxLength(7), Schema.isUnique())
6733
+ });
6734
+ const FoldersConnected = Schema.Struct({
6735
+ folders: Schema.Array(Schema.Struct({
6736
+ path: ConfigFolderPath,
6737
+ ...MachineConnected.fields,
6738
+ replacement: Schema.optionalKey(GrantReplacement)
6739
+ })),
6740
+ excluded: Schema.Array(ConfigFolderPath)
6687
6741
  });
6742
+ Schema.Array(GrantReplacement);
6743
+ Schema.Array(TokenId).check(Schema.isMaxLength(8));
6688
6744
  /** An attachment that isn't there, or isn't the caller's, answers the same. */
6689
6745
  var AttachmentNotFound = class extends Schema.TaggedError()("AttachmentNotFound", { nextStep: Schema.String }, { httpApiStatus: 404 }) {};
6690
6746
  /** Connecting connected nothing, and why: what an attachment's approval came to instead. */
@@ -6866,7 +6922,7 @@ const FindApproval = Schema.Union([Schema.Struct({ code: TypedApprovalCode }), S
6866
6922
  */
6867
6923
  const ApprovalOptionsRequest = Schema.Struct({
6868
6924
  code: Schema.optionalKey(TypedApprovalCode),
6869
- agent: Schema.optionalKey(ConnectAgent)
6925
+ folders: Schema.optionalKey(ConnectFolderChoices)
6870
6926
  });
6871
6927
  /**
6872
6928
  * An answer: yes, with the passkey, or no. For an agent's ask, each answer can set the owner's
@@ -6919,7 +6975,11 @@ const ApprovalState = Schema.Union([
6919
6975
  state: Schema.Literal("waiting"),
6920
6976
  expiresAt: Timestamp
6921
6977
  }),
6922
- Schema.Struct({ state: Schema.Literal("approved") }),
6978
+ Schema.Struct({
6979
+ state: Schema.Literal("approved"),
6980
+ /** A machine's answering browser may forget its saved answer once the terminal kept it. */
6981
+ collected: Schema.optionalKey(Schema.Literal(true))
6982
+ }),
6923
6983
  Schema.Struct({ state: Schema.Literal("refused") }),
6924
6984
  Schema.Struct({ state: Schema.Literal("expired") }),
6925
6985
  Schema.Struct({
@@ -11860,10 +11920,10 @@ const GateInPlaceView = Schema.Struct({
11860
11920
  /**
11861
11921
  * One `from`'s yes over the run that counts for a part that needs one (L2), as `passkey.check`
11862
11922
  * asks: `asked`, waiting, of `asked` when it's one person; `yes`, by whom and when; `no`;
11863
- * `expired`, its day passed unanswered; `refused`, the person it names can't be asked; or
11864
- * `revoked`, a yes whose answerer a miss revoked since, until an admin restores them. A local
11865
- * run's no or day passing waits for a newer local run; a ref0 run's, or a refusal, leaves the
11866
- * part with no yes, until an admin overrides it.
11923
+ * `expired`, its day passed unanswered; `refused`, asking nobody, `why`; or `revoked`, a yes whose
11924
+ * answerer a miss revoked since, until an admin restores them. A local run's no or day passing
11925
+ * waits for a newer local run; a ref0 run's, or a refusal, leaves the part with no yes, until an
11926
+ * admin overrides it.
11867
11927
  */
11868
11928
  const GatePartYes = Schema.Struct({
11869
11929
  from: PasskeyFrom,
@@ -11875,6 +11935,12 @@ const GatePartYes = Schema.Struct({
11875
11935
  "refused",
11876
11936
  "revoked"
11877
11937
  ]),
11938
+ /**
11939
+ * Why a request was refused: `target`, nobody it names can be asked, a person who isn't there
11940
+ * or can't see the pull request's thread; or `self`, it names the person behind the run, or a
11941
+ * permission only they hold, who can't be the second pair of eyes over their own run.
11942
+ */
11943
+ why: Schema.optionalKey(Schema.Literals(["target", "self"])),
11878
11944
  asked: Schema.optionalKey(PersonId),
11879
11945
  by: Schema.optionalKey(OwnerName),
11880
11946
  at: Schema.optionalKey(Schema.DateTimeUtcFromMillis)
@@ -15042,7 +15108,10 @@ var MachineAttachmentsGroup = class extends HttpApiGroup.make("machineAttachment
15042
15108
  error: [AttachmentNotFound, AuthorityUnavailable]
15043
15109
  }).middleware(ApprovalSecret), HttpApiEndpoint.post("confirm", "/v1/attachments/requests/:approval/collected", {
15044
15110
  params: { approval: ApprovalId },
15045
- payload: Schema.Struct({ answerCode: Schema.optionalKey(Schema.String.check(Schema.isMaxLength(64))) }),
15111
+ payload: Schema.Struct({
15112
+ grants: SavedMachineGrants,
15113
+ answerCode: Schema.optionalKey(Schema.String.check(Schema.isMaxLength(64)))
15114
+ }),
15046
15115
  headers: idempotency,
15047
15116
  success: RequestConfirmed,
15048
15117
  error: [AttachmentNotFound, AuthorityUnavailable]
@@ -18086,7 +18155,7 @@ const schemasOf = (name) => Option.fromNullishOr(reflected.get(name));
18086
18155
  * keeps what it came to as `/v1` would answer it.
18087
18156
  */
18088
18157
  const connectAnswers = {
18089
- successes: [Schema.toCodecJson(MachineConnected)],
18158
+ successes: [Schema.toCodecJson(FoldersConnected)],
18090
18159
  refusals: /* @__PURE__ */ new Map([[409, [Schema.toCodecJson(AttachmentFailed)]]])
18091
18160
  };
18092
18161
  const isEndpointName = (name) => name in surfaces;
@@ -18548,7 +18617,7 @@ const HarnessBinding = Schema.Struct({
18548
18617
  //#region ../contracts/src/harness-status.ts
18549
18618
  /**
18550
18619
  * What `ref0 status --json` prints, for a script or an agent: whether ref0 follows the sessions
18551
- * of the harness it ran under, one check for each thing that has to hold, as its lines say them.
18620
+ * in every initialized or connected folder, as an array of folder reports.
18552
18621
  */
18553
18622
  /** A check's answer: unknown when ref0 couldn't ask, never a guess. */
18554
18623
  const StatusAnswer = Schema.Literals([
@@ -18556,7 +18625,7 @@ const StatusAnswer = Schema.Literals([
18556
18625
  "no",
18557
18626
  "unknown"
18558
18627
  ]);
18559
- /** One line of the report: what's checked, its answer and why, and for a no, what to do next. */
18628
+ /** One line of the report: what's checked, its answer and why, and for any non-yes answer, the command to run next. */
18560
18629
  const StatusCheck = Schema.Struct({
18561
18630
  check: Schema.Literals([
18562
18631
  "connected",
@@ -18569,9 +18638,9 @@ const StatusCheck = Schema.Struct({
18569
18638
  says: Schema.String,
18570
18639
  next: Schema.optionalKey(Schema.String)
18571
18640
  });
18572
- /** The report: the harness and its config folder, the agent it starts sessions as, and checks. */
18641
+ /** One folder: the harness, if known, and its config folder, the agent it starts sessions as, and checks. */
18573
18642
  const StatusReport = Schema.Struct({
18574
- harness: Schema.Literals(["claude-code", "codex"]),
18643
+ harness: Schema.optionalKey(Schema.Literals(["claude-code", "codex"])),
18575
18644
  configFolder: Schema.String,
18576
18645
  agent: Schema.optionalKey(Schema.Struct({
18577
18646
  owner: OwnerName,
@@ -18624,24 +18693,72 @@ const newRequestId = Effect.gen(function* () {
18624
18693
  return yield* Effect.orDie(decodeRequestId$1(`request_${encodeSuffix(uuidV7(millis, random))}`));
18625
18694
  });
18626
18695
  //#endregion
18696
+ //#region src/sha256.ts
18697
+ /** Bytes as lowercase hex. */
18698
+ const hex = (bytes) => Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
18699
+ const encoder$1 = new TextEncoder();
18700
+ /** The SHA-256 of a text's UTF-8 bytes, as lowercase hex. */
18701
+ const sha256 = (text) => Effect.flatMap(Crypto.Crypto, (crypto) => Effect.map(Effect.orDie(crypto.digest("SHA-256", encoder$1.encode(text))), hex));
18702
+ //#endregion
18703
+ //#region src/origin.ts
18704
+ /**
18705
+ * ref0's own API origin: the one the CLI and the plugin's launcher use when neither `REF0_API`
18706
+ * nor a clone's `ref0.api` names another.
18707
+ */
18708
+ const defaultOrigin = ApiOrigin.make("https://ref0.dev");
18709
+ /** A normalized origin as one reversible, path-safe storage key. */
18710
+ const originKey = (api) => encodeURIComponent(new URL(api).origin);
18711
+ /**
18712
+ * The name an origin's plugin, marketplace and MCP server install under. ref0's own keeps the
18713
+ * public plugin's, `ref0`, so its status command is `/ref0:status` in Claude Code and
18714
+ * `$ref0:status` in Codex. Every other is `ref0-` and 128 bits of its SHA-256, which keep plugin
18715
+ * IDs and qualified hook-tool names within harness limits, so no two origins share plugins,
18716
+ * hooks or servers.
18717
+ */
18718
+ const originPluginName = (api) => {
18719
+ const origin = new URL(api).origin;
18720
+ if (origin === defaultOrigin) return Effect.succeed("ref0");
18721
+ return Effect.map(sha256(origin), (digest) => `ref0-${digest.slice(0, 32)}`);
18722
+ };
18723
+ //#endregion
18627
18724
  //#region src/config.ts
18628
18725
  /**
18629
18726
  * `REF0_API` as a person sets it, any trailing `/` left off, read as ref0's origin. One that isn't
18630
18727
  * an origin, such as a bare host or one with a path, fails the CLI at start, naming `REF0_API`.
18631
18728
  */
18632
18729
  const ApiFromEnv = Schema.String.pipe(Schema.decodeTo(ApiOrigin, SchemaTransformation.transform({
18633
- decode: (api) => api.replace(/\/+$/, ""),
18730
+ decode: (api) => api.replace(/^https?:/i, (scheme) => scheme.toLowerCase()).replace(/\/+$/, ""),
18634
18731
  encode: (api) => api
18635
18732
  })));
18636
18733
  /**
18637
- * Where the CLI finds ref0: the API's origin, from `REF0_API`, or ref0's own. One value, as the
18734
+ * Where the CLI finds ref0: the API's origin, from `REF0_API`, the checkout's `ref0.api`, or ref0's own. One value, as the
18638
18735
  * domain is one value everywhere.
18639
18736
  */
18640
18737
  var Ref0Config = class Ref0Config extends Context.Service()("ref0/cli/Ref0Config") {
18641
18738
  static layer = Layer.effect(Ref0Config, Effect.gen(function* () {
18642
- const api = yield* Config.schema(ApiFromEnv, "REF0_API").pipe(Config.withDefault(ApiOrigin.make("https://ref0.dev")));
18643
- return Ref0Config.of({ api });
18644
- }));
18739
+ const explicit = yield* Config.schema(ApiFromEnv, "REF0_API").pipe(Config.option);
18740
+ let api = Option.getOrUndefined(explicit);
18741
+ if (api === void 0) {
18742
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
18743
+ const unavailable = new Config.ConfigError(new ConfigProvider.SourceError({ message: "Could not read ref0.api with Git. Set REF0_API explicitly, or make Git available and check the checkout config." }));
18744
+ api = yield* Effect.scoped(Effect.gen(function* () {
18745
+ const handle = yield* spawner.spawn(ChildProcess.make("git", [
18746
+ "config",
18747
+ "--get",
18748
+ "ref0.api"
18749
+ ]));
18750
+ const [checkout, , code] = yield* Effect.all([
18751
+ handle.stdout.pipe(Stream.decodeText(), Stream.mkString),
18752
+ handle.stderr.pipe(Stream.runDrain),
18753
+ handle.exitCode
18754
+ ], { concurrency: "unbounded" });
18755
+ if (code === ChildProcessSpawner.ExitCode(1)) return defaultOrigin;
18756
+ if (code !== ChildProcessSpawner.ExitCode(0)) return yield* Effect.fail(unavailable);
18757
+ return yield* Config.schema(ApiFromEnv, "ref0.api").parse(ConfigProvider.fromUnknown({ "ref0.api": checkout.trim() }));
18758
+ })).pipe(Effect.catchTag("PlatformError", () => Effect.fail(unavailable)));
18759
+ }
18760
+ return Ref0Config.of({ api: ApiOrigin.make(new URL(api).origin) });
18761
+ })).pipe(Layer.provide(NodeServices.layer));
18645
18762
  };
18646
18763
  //#endregion
18647
18764
  //#region src/secret.ts
@@ -18952,7 +19069,7 @@ const renderLine = (line, terminal) => {
18952
19069
  return `${line.status === "yes" ? styled("✓", 32, terminal) : styled("✗", 31, terminal)} ${line.status} ${printable(line.text)}`;
18953
19070
  };
18954
19071
  const renderBlocks = (blocks, stream) => {
18955
- const terminal = process[stream].isTTY === true && !process.env["NO_COLOR"];
19072
+ const terminal = process[stream].isTTY === true && process.env["NO_COLOR"] === void 0;
18956
19073
  return blocks.flatMap((block) => block.length === 0 ? [] : [block.map((line) => renderLine(line, terminal)).join("\n")]).join("\n\n");
18957
19074
  };
18958
19075
  /** Words go to stderr by default; a command's answer goes to stdout. */
@@ -20092,97 +20209,344 @@ const blockedCommands = [Command.make("blocked").pipe(Command.withDescription("W
20092
20209
  clearing("met", "Mark an outside blocker met, or lift a hold")
20093
20210
  ]))];
20094
20211
  //#endregion
20212
+ //#region src/connect-browser.ts
20213
+ /** The desktop opener, checked before a listener is started. SSH gets only the paste. */
20214
+ const browserOpener = Effect.gen(function* () {
20215
+ const system = yield* (yield* ChildProcessSpawner.ChildProcessSpawner).string(ChildProcess.make("uname", ["-s"])).pipe(Effect.orElseSucceed(() => ""));
20216
+ if (system.trim() === "Darwin") return "/usr/bin/open";
20217
+ if (system.trim() !== "Linux") return void 0;
20218
+ const display = yield* Config.String("DISPLAY").pipe(Config.withDefault(""));
20219
+ const wayland = yield* Config.String("WAYLAND_DISPLAY").pipe(Config.withDefault(""));
20220
+ if (display === "" && wayland === "") return void 0;
20221
+ const search = yield* Config.String("PATH").pipe(Config.withDefault(""));
20222
+ for (const folder of search.split(":")) {
20223
+ const program = `${folder || "."}/xdg-open`;
20224
+ const executable = yield* Effect.tryPromise(() => access(program, constants.X_OK)).pipe(Effect.option);
20225
+ if (Option.isSome(executable)) return program;
20226
+ }
20227
+ });
20228
+ /** Starts the browser independently; a missing or failing desktop never stops the paste. */
20229
+ const openBrowser = (program, link) => Effect.sync(() => {
20230
+ const failed = () => Effect.runFork(Console.error("Couldn’t open the browser. Open the printed link yourself."));
20231
+ try {
20232
+ const child = spawn(program, [link], {
20233
+ detached: true,
20234
+ stdio: "ignore",
20235
+ shell: false
20236
+ });
20237
+ child.once("error", failed);
20238
+ child.once("exit", (code) => {
20239
+ if (code !== 0) failed();
20240
+ });
20241
+ child.unref();
20242
+ } catch {
20243
+ failed();
20244
+ }
20245
+ });
20246
+ const isAnswer = Schema.is(TypedApprovalCode);
20247
+ /** One form answer from ref0's own page, on IPv4 loopback only, owned by the waiting scope. */
20248
+ const listenForAnswer = Effect.fnUntraced(function* (origin) {
20249
+ const state = (yield* freshLinkSecret).secret;
20250
+ const answer = yield* Deferred.make();
20251
+ let taken = false;
20252
+ const server = createServer((request, response) => {
20253
+ if (taken || request.method !== "POST" || request.url !== `/${state}` || request.headers.origin !== origin || request.headers["content-type"]?.split(";")[0] !== "application/x-www-form-urlencoded") {
20254
+ response.writeHead(404).end();
20255
+ request.resume();
20256
+ return;
20257
+ }
20258
+ let body = "";
20259
+ request.setTimeout(5e3, () => request.destroy());
20260
+ request.on("error", () => void 0);
20261
+ request.on("data", (chunk) => {
20262
+ body += chunk.toString("utf8");
20263
+ if (body.length > 1024) request.destroy();
20264
+ });
20265
+ request.on("end", () => {
20266
+ const fields = new URLSearchParams(body);
20267
+ const code = fields.get("answer");
20268
+ if (taken || code === null || fields.getAll("answer").length !== 1 || !isAnswer(code)) {
20269
+ response.writeHead(404).end();
20270
+ return;
20271
+ }
20272
+ taken = true;
20273
+ response.writeHead(200, {
20274
+ "content-type": "text/plain; charset=utf-8",
20275
+ "cache-control": "no-store"
20276
+ });
20277
+ response.end("Connected. Go back to your terminal.");
20278
+ server.close();
20279
+ Deferred.doneUnsafe(answer, Exit.succeed(code));
20280
+ });
20281
+ });
20282
+ server.maxConnections = 16;
20283
+ server.headersTimeout = 5e3;
20284
+ server.requestTimeout = 5e3;
20285
+ const stop = Effect.sync(() => {
20286
+ taken = true;
20287
+ server.close();
20288
+ server.closeAllConnections();
20289
+ });
20290
+ yield* Effect.acquireRelease(Effect.callback((resume) => {
20291
+ server.once("error", () => resume(Effect.fail(new ListenerUnavailable())));
20292
+ server.listen(0, "127.0.0.1", () => resume(Effect.void));
20293
+ }), () => stop);
20294
+ const address = server.address();
20295
+ if (address === null || Predicate.isString(address)) return yield* Effect.die("No loopback port");
20296
+ return {
20297
+ callback: {
20298
+ port: address.port,
20299
+ state
20300
+ },
20301
+ answer: Deferred.await(answer),
20302
+ stop
20303
+ };
20304
+ });
20305
+ var ListenerUnavailable = class extends Schema.TaggedError()("ListenerUnavailable", {}) {};
20306
+ //#endregion
20307
+ //#region src/names.ts
20308
+ /**
20309
+ * The names one part of the CLI writes and another reads, each defined here once: the commands,
20310
+ * flags and variable a harness's plugin runs `ref0` with, which their definitions take too, and
20311
+ * the variables `ref0 watch` sets for the command it runs, which a harness that command starts
20312
+ * reads.
20313
+ */
20314
+ /**
20315
+ * The commands a harness runs: `ref0 mcp`, `ref0 hook` with each of its command hooks, and
20316
+ * `ref0 status` for the plugin's own command.
20317
+ */
20318
+ const harnessCommandNames = {
20319
+ mcp: "mcp",
20320
+ hook: "hook",
20321
+ sessionStart: "session-start",
20322
+ sessionEnd: "session-end",
20323
+ status: "status"
20324
+ };
20325
+ /** The flag a plugin passes its command hooks: the hook protocol their declarations speak. */
20326
+ const harnessFlagNames = { protocol: "protocol" };
20327
+ /**
20328
+ * What the plugin's lookup script and server launcher put in `ref0`'s environment: the plugin's
20329
+ * release, which no hook's declaration carries.
20330
+ */
20331
+ const pluginVersionVariable = "REF0_PLUGIN_VERSION";
20332
+ /** A flag as a command line writes it. */
20333
+ const flagWord = (name) => `--${name}`;
20334
+ /**
20335
+ * What `ref0 watch` puts in the environment of the command it runs: the thread, the event that
20336
+ * started it, and its title.
20337
+ */
20338
+ const watchVariables = {
20339
+ thread: "REF0_THREAD",
20340
+ event: "REF0_EVENT",
20341
+ title: "REF0_TITLE"
20342
+ };
20343
+ //#endregion
20344
+ //#region src/harness/plugin-files.ts
20345
+ /**
20346
+ * What both harnesses' parts of ref0's plugin folder are built from: hooks, each an `mcp_tool`
20347
+ * hook to `ref0 mcp` or a command, the hooks file they're written into, and the two ways the
20348
+ * plugin finds `ref0`: a script for its command hooks, and a launcher for its server. The public plugin
20349
+ * names no machine's paths, owner or agent. Installation pins its lookup to one API origin.
20350
+ * Only its release, in its manifests, script and launcher, changes from one release to the next;
20351
+ * never a hook's declaration, which carries the hook protocol instead.
20352
+ */
20353
+ const encodeHooks = Schema.encodeSync(Schema.fromJsonString(HooksFile));
20354
+ const encodeMcp = Schema.encodeSync(Schema.fromJsonString(McpServersFile));
20355
+ /** A path as one word for `sh`, which runs command hooks. */
20356
+ const shellWord = (word) => `'${word.replaceAll("'", `'\\''`)}'`;
20357
+ /** An event's hook asking `ref0 mcp`, with a template naming each field it reads. */
20358
+ const mcpToolHook = (server, event, fields) => ({ hooks: [{
20359
+ type: "mcp_tool",
20360
+ server,
20361
+ tool: "hook",
20362
+ input: Object.fromEntries([
20363
+ ["protocol", "1"],
20364
+ ["hook_event_name", event],
20365
+ ...fields.map((field) => [field, `\${${field}}`])
20366
+ ])
20367
+ }] });
20368
+ const commandHook = (command) => ({ hooks: [{
20369
+ type: "command",
20370
+ command
20371
+ }] });
20372
+ /** The plugin's lookup script, by its path inside the plugin. */
20373
+ const lookupScript = "bin/ref0";
20374
+ /** The plugin's root, as a command hook names it for the harness to fill in. */
20375
+ const pluginRoot = "${CLAUDE_PLUGIN_ROOT}";
20376
+ /** What the lookup script and the launcher say when neither finds `ref0`. */
20377
+ const missingRef0 = "run npm i -g @ref0/cli, then ref0 connect";
20378
+ /**
20379
+ * How the plugin finds `ref0`, as `sh` statements: on `PATH` in the public template, or at the
20380
+ * origin-specific launcher after installation, telling it the plugin's release. M0 measured both lookups in each
20381
+ * harness. The script isn't executable, so the plugin's `bin/` never puts it on an agent's `PATH`
20382
+ * as `ref0`, where it would find itself.
20383
+ */
20384
+ const lookup = (version, api) => [
20385
+ `${pluginVersionVariable}=${shellWord(version)}`,
20386
+ `export ${pluginVersionVariable}`,
20387
+ ...api === void 0 ? ["candidate=$(command -v ref0 2>/dev/null || :)", "if [ -n \"$candidate\" ]; then exec \"$candidate\" \"$@\"; fi"] : [
20388
+ "REF0_SELECT_ORIGIN=1",
20389
+ "export REF0_SELECT_ORIGIN",
20390
+ `if [ -x "$HOME/.ref0/origins/${originKey(api)}/bin/ref0" ]; then exec "$HOME/.ref0/origins/${originKey(api)}/bin/ref0" "$@"; fi`
20391
+ ],
20392
+ `printf '%s\\n' ${shellWord(missingRef0)} >&2`,
20393
+ "exit 127"
20394
+ ];
20395
+ /** The lookup script, which a command hook or the plugin's command runs with `sh`. */
20396
+ const lookupScriptFile = (version, api) => ({
20397
+ path: `ref0/${lookupScript}`,
20398
+ text: [
20399
+ "#!/bin/sh",
20400
+ "# Runs ref0 for the hooks and commands of its plugin.",
20401
+ ...lookup(version, api),
20402
+ ""
20403
+ ].join("\n")
20404
+ });
20405
+ /**
20406
+ * Runs `ref0 <words>` through the lookup script. Claude Code fills in the plugin's root before
20407
+ * it runs the command, and Codex sets it in the hook's environment, where `sh` reads it.
20408
+ */
20409
+ const throughLookup = (words) => [
20410
+ "/bin/sh",
20411
+ `"${pluginRoot}/${lookupScript}"`,
20412
+ ...words
20413
+ ].join(" ");
20414
+ /** Runs `ref0 hook <event>`, saying the hook protocol its declaration speaks. */
20415
+ const ref0Hook = (event) => commandHook(throughLookup([
20416
+ harnessCommandNames.hook,
20417
+ event,
20418
+ flagWord(harnessFlagNames.protocol),
20419
+ "1"
20420
+ ]));
20421
+ /** Starts the agent's session: a command, since it runs before the harness connects its servers. */
20422
+ const sessionStartHook = ref0Hook(harnessCommandNames.sessionStart);
20423
+ /** Ends the session, as a command too. */
20424
+ const sessionEndHook = ref0Hook(harnessCommandNames.sessionEnd);
20425
+ /**
20426
+ * The plugin's server, `ref0 mcp`, started by M0's inline launcher, the lookup in one line: a
20427
+ * script path in a server's arguments stays literal in Codex. It has no `cwd`, so the server
20428
+ * keeps the project's folder in both harnesses. Codex starts its servers with only `HOME`, `PATH`
20429
+ * and `SHELL`, so it's asked for `CODEX_HOME`, which an unset one leaves unset.
20430
+ */
20431
+ const mcpFile = (version, api, server = "ref0") => ({
20432
+ path: "ref0/.mcp.json",
20433
+ text: encodeMcp({ mcpServers: { [server]: {
20434
+ command: "/bin/sh",
20435
+ args: [
20436
+ "-c",
20437
+ lookup(version, api).join("; "),
20438
+ "ref0",
20439
+ harnessCommandNames.mcp
20440
+ ],
20441
+ env_vars: [
20442
+ "CODEX_HOME",
20443
+ "CLAUDE_CONFIG_DIR",
20444
+ "REF0_API"
20445
+ ]
20446
+ } } })
20447
+ });
20448
+ /** Each event a harness's hooks send `ref0 mcp`, with the fields it reads, as an `mcp_tool` hook. */
20449
+ const mcpToolHooks = (server, hookFields) => Object.fromEntries(Object.entries(hookFields).map(([event, fields]) => [event, [mcpToolHook(server, event, fields)]]));
20450
+ //#endregion
20095
20451
  //#region src/keychain.ts
20096
20452
  /**
20097
20453
  * The machine's keychain, where the CLI keeps what lets it act: the host grant `ref0 connect`
20098
20454
  * makes, and a session's token. Never a file. On macOS through `security`, on Linux through
20099
- * `secret-tool`, under the service `ref0`, by account. A secret goes in through stdin, never as
20455
+ * `secret-tool`, under an origin-specific `ref0/<origin>` service, by account. A secret goes in through stdin, never as
20100
20456
  * an argument, since any process on the machine can read another's arguments.
20101
20457
  */
20102
- var KeychainError = class extends Schema.TaggedError()("KeychainError", { nextStep: Schema.String }) {};
20103
- var Keychain = class Keychain extends Context.Service()("ref0/cli/Keychain") {
20104
- static layer = Layer.effect(Keychain, Effect.gen(function* () {
20105
- const crypto = yield* Crypto.Crypto;
20106
- const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
20107
- const macOS = (yield* spawner.string(ChildProcess.make("uname", ["-s"])).pipe(Effect.orElseSucceed(() => ""))).trim() === "Darwin";
20108
- 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." });
20109
- 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." });
20110
- const run = (command, args, stdin) => Effect.scoped(Effect.gen(function* () {
20111
- 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));
20112
- const [output, complaint] = yield* Effect.all([handle.stdout.pipe(Stream.decodeText(), Stream.mkString), handle.stderr.pipe(Stream.decodeText(), Stream.mkString)], { concurrency: "unbounded" });
20113
- const code = yield* handle.exitCode;
20114
- return {
20115
- output: output.trim(),
20116
- complaint: complaint.trim(),
20117
- code,
20118
- succeeded: code === ChildProcessSpawner.ExitCode(0)
20119
- };
20120
- })).pipe(Effect.catchTag("PlatformError", () => Effect.fail(unavailable)));
20121
- const get = Effect.fn("Keychain.get")(function* (account) {
20122
- const found = macOS ? yield* run("security", [
20123
- "find-generic-password",
20124
- "-s",
20125
- "ref0",
20126
- "-a",
20127
- account,
20128
- "-w"
20129
- ], "") : yield* run("secret-tool", [
20130
- "lookup",
20131
- "service",
20132
- "ref0",
20133
- "account",
20134
- account
20135
- ], "");
20136
- if (found.succeeded) return found.output.length > 0 ? Option.some(Redacted.make(found.output)) : Option.none();
20137
- if (macOS) {
20138
- if (found.code === ChildProcessSpawner.ExitCode(44)) return Option.none();
20139
- return yield* unavailable;
20140
- }
20141
- if (found.code !== ChildProcessSpawner.ExitCode(1) || found.complaint.length > 0) return yield* unavailable;
20142
- const listed = yield* run("secret-tool", [
20143
- "search",
20144
- "--all",
20145
- "service",
20146
- "ref0",
20147
- "account",
20148
- account
20149
- ], "");
20150
- if (listed.output.length > 0 || listed.complaint.length > 0) return yield* unavailable;
20151
- return Option.none();
20152
- });
20153
- const set = Effect.fn("Keychain.set")(function* (account, secret) {
20154
- const quote = (text) => `"${text.replace(/[\\"]/g, "\\$&")}"`;
20155
- const value = Redacted.value(secret);
20156
- if (macOS && /[\r\n\0]/.test(account + value)) return yield* unavailable;
20157
- const input = macOS ? `add-generic-password -U -s ref0 -a ${quote(account)} -w ${quote(value)}\n` : value;
20158
- if (macOS && new TextEncoder().encode(input).length >= 4096) return yield* unavailable;
20159
- if (!(macOS ? yield* run("security", ["-i"], input) : yield* run("secret-tool", [
20160
- "store",
20161
- "--label",
20162
- `ref0 ${account}`,
20163
- "service",
20164
- "ref0",
20165
- "account",
20166
- account
20167
- ], Redacted.value(secret))).succeeded) return yield* unavailable;
20168
- });
20169
- const remove = Effect.fn("Keychain.remove")(function* (account) {
20170
- if (!(yield* macOS ? run("security", [
20171
- "delete-generic-password",
20172
- "-s",
20173
- "ref0",
20174
- "-a",
20175
- account
20176
- ], "") : run("secret-tool", [
20177
- "clear",
20178
- "service",
20179
- "ref0",
20180
- "account",
20181
- account
20182
- ], "")).succeeded) return yield* unavailable;
20183
- });
20184
- const random = crypto.randomUUIDv4.pipe(Effect.mapError(() => unavailable));
20185
- const probe = Effect.gen(function* () {
20458
+ var KeychainError = class extends Schema.TaggedError()("KeychainError", {
20459
+ nextStep: Schema.String,
20460
+ /** The keychain answered, with an item ref0 can't read, rather than not answering. */
20461
+ unreadable: Schema.optionalKey(Schema.Literal(true))
20462
+ }) {};
20463
+ /**
20464
+ * The keychain under one service. Each API origin has its own, `ref0/<origin>`; 0.5.0 kept its
20465
+ * grants under `ref0`, which only `ref0 connect` reads, to replace and delete them.
20466
+ */
20467
+ const keychainIn = Effect.fnUntraced(function* (service) {
20468
+ const crypto = yield* Crypto.Crypto;
20469
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
20470
+ const macOS = (yield* spawner.string(ChildProcess.make("uname", ["-s"])).pipe(Effect.orElseSucceed(() => ""))).trim() === "Darwin";
20471
+ 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." });
20472
+ 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." });
20473
+ const run = (command, args, stdin) => Effect.scoped(Effect.gen(function* () {
20474
+ 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));
20475
+ const [output, complaint] = yield* Effect.all([handle.stdout.pipe(Stream.decodeText(), Stream.mkString), handle.stderr.pipe(Stream.decodeText(), Stream.mkString)], { concurrency: "unbounded" });
20476
+ const code = yield* handle.exitCode;
20477
+ return {
20478
+ output: output.trim(),
20479
+ complaint: complaint.trim(),
20480
+ code,
20481
+ succeeded: code === ChildProcessSpawner.ExitCode(0)
20482
+ };
20483
+ })).pipe(Effect.catchTag("PlatformError", () => Effect.fail(unavailable)));
20484
+ const get = Effect.fn("Keychain.get")(function* (account) {
20485
+ const found = macOS ? yield* run("security", [
20486
+ "find-generic-password",
20487
+ "-s",
20488
+ service,
20489
+ "-a",
20490
+ account,
20491
+ "-w"
20492
+ ], "") : yield* run("secret-tool", [
20493
+ "lookup",
20494
+ "service",
20495
+ service,
20496
+ "account",
20497
+ account
20498
+ ], "");
20499
+ if (found.succeeded) return found.output.length > 0 ? Option.some(Redacted.make(found.output)) : Option.none();
20500
+ if (macOS) {
20501
+ if (found.code === ChildProcessSpawner.ExitCode(44)) return Option.none();
20502
+ return yield* unavailable;
20503
+ }
20504
+ if (found.code !== ChildProcessSpawner.ExitCode(1) || found.complaint.length > 0) return yield* unavailable;
20505
+ const listed = yield* run("secret-tool", [
20506
+ "search",
20507
+ "--all",
20508
+ "service",
20509
+ service,
20510
+ "account",
20511
+ account
20512
+ ], "");
20513
+ if (listed.output.length > 0 || listed.complaint.length > 0) return yield* unavailable;
20514
+ return Option.none();
20515
+ });
20516
+ const set = Effect.fn("Keychain.set")(function* (account, secret) {
20517
+ const quote = (text) => `"${text.replace(/[\\"]/g, "\\$&")}"`;
20518
+ const value = Redacted.value(secret);
20519
+ if (macOS && /[\r\n\0]/.test(account + value)) return yield* unavailable;
20520
+ const input = macOS ? `add-generic-password -U -s ${quote(service)} -a ${quote(account)} -w ${quote(value)}\n` : value;
20521
+ if (macOS && new TextEncoder().encode(input).length >= 4096) return yield* unavailable;
20522
+ if (!(macOS ? yield* run("security", ["-i"], input) : yield* run("secret-tool", [
20523
+ "store",
20524
+ "--label",
20525
+ `ref0 ${account}`,
20526
+ "service",
20527
+ service,
20528
+ "account",
20529
+ account
20530
+ ], Redacted.value(secret))).succeeded) return yield* unavailable;
20531
+ });
20532
+ const remove = Effect.fn("Keychain.remove")(function* (account) {
20533
+ if (!(yield* macOS ? run("security", [
20534
+ "delete-generic-password",
20535
+ "-s",
20536
+ service,
20537
+ "-a",
20538
+ account
20539
+ ], "") : run("secret-tool", [
20540
+ "clear",
20541
+ "service",
20542
+ service,
20543
+ "account",
20544
+ account
20545
+ ], "")).succeeded) return yield* unavailable;
20546
+ });
20547
+ const random = crypto.randomUUIDv4.pipe(Effect.mapError(() => unavailable));
20548
+ return {
20549
+ probe: Effect.gen(function* () {
20186
20550
  const account = `connect-probe/${yield* random}`;
20187
20551
  const value = Redacted.make(yield* random);
20188
20552
  yield* Effect.gen(function* () {
@@ -20194,19 +20558,19 @@ var Keychain = class Keychain extends Context.Service()("ref0/cli/Keychain") {
20194
20558
  }).pipe(Effect.timeoutOrElse({
20195
20559
  duration: Duration.minutes(1),
20196
20560
  orElse: () => Effect.fail(unavailable)
20197
- }));
20198
- return Keychain.of({
20199
- probe,
20200
- get,
20201
- set,
20202
- remove
20203
- });
20204
- }));
20561
+ })),
20562
+ get,
20563
+ set,
20564
+ remove
20565
+ };
20566
+ });
20567
+ var Keychain = class Keychain extends Context.Service()("ref0/cli/Keychain") {
20568
+ static layer = Layer.effect(Keychain, Effect.flatMap(Ref0Config, (config) => Effect.map(keychainIn(`ref0/${originKey(config.api)}`), Keychain.of)));
20205
20569
  };
20206
20570
  //#endregion
20207
20571
  //#region src/harness/binding.ts
20208
20572
  /**
20209
- * Where a session's token is kept: the machine's keychain, under the service `ref0`, by an
20573
+ * Where a session's token is kept: the machine's keychain, under the API origin’s keychain service, by an
20210
20574
  * account naming its binding. One per harness process, so a resume in another terminal while
20211
20575
  * the first still runs keeps its own token.
20212
20576
  */
@@ -20342,13 +20706,15 @@ const harnessSessionToken = Layer.effect(SessionToken, Effect.gen(function* () {
20342
20706
  const Folders = Schema.Array(Schema.String);
20343
20707
  const decodeFolders = Schema.decodeUnknownOption(Schema.fromJsonString(Folders));
20344
20708
  const encodeFolders = Schema.encodeSync(Schema.fromJsonString(Folders));
20345
- /** The variable naming each harness's config folder, and its default under HOME. */
20709
+ /** Each harness's name, the variable naming its config folder, and its default under HOME. */
20346
20710
  const configFolders = {
20347
20711
  "claude-code": {
20712
+ name: "Claude Code",
20348
20713
  variable: "CLAUDE_CONFIG_DIR",
20349
20714
  inHome: ".claude"
20350
20715
  },
20351
20716
  codex: {
20717
+ name: "Codex",
20352
20718
  variable: "CODEX_HOME",
20353
20719
  inHome: ".codex"
20354
20720
  }
@@ -20363,7 +20729,8 @@ var ConfigFolders = class ConfigFolders extends Context.Service()("ref0/cli/Conf
20363
20729
  "claude-code": yield* Config.String(configFolders["claude-code"].variable).pipe(Config.withDefault("")),
20364
20730
  codex: yield* Config.String(configFolders.codex.variable).pipe(Config.withDefault(""))
20365
20731
  };
20366
- const directory = path.join(home, ".ref0");
20732
+ const config = yield* Ref0Config;
20733
+ const directory = path.join(home, ".ref0", "origins", originKey(config.api));
20367
20734
  const file = path.join(directory, "config-folders.json");
20368
20735
  const resolved = (given) => fs.realPath(given).pipe(Effect.catch(() => {
20369
20736
  const parent = path.dirname(given);
@@ -20400,6 +20767,51 @@ const currentHarness = Effect.gen(function* () {
20400
20767
  return Option.isSome(found) ? found.value.harness : Option.isSome(sandbox) ? "codex" : claude !== "" ? "claude-code" : codex !== "" ? "codex" : "claude-code";
20401
20768
  });
20402
20769
  const currentConfigFolder = Effect.flatMap(currentHarness, (harness) => ConfigFolders.use((folders) => folders.folder(harness)));
20770
+ /** A folder as the person reads it, and ref0 shows it: under the home as `~/…`, else whole. */
20771
+ const shownConfigFolder = (home, folder) => folder.startsWith(`${home}/`) ? `~/${folder.slice(home.length + 1)}` : folder;
20772
+ /** Initialized folders in the home, and the folder each variable names, when it's asked to. */
20773
+ const searchConfigFolders = Effect.fnUntraced(function* (variables) {
20774
+ const fs = yield* FileSystem.FileSystem;
20775
+ const path = yield* Path.Path;
20776
+ const folders = yield* ConfigFolders;
20777
+ const home = yield* Config.String("HOME").pipe(Config.withDefault("."));
20778
+ const names = yield* fs.readDirectory(home).pipe(Effect.orElseSucceed(() => []));
20779
+ const found = [];
20780
+ const seen = /* @__PURE__ */ new Set();
20781
+ const has = (file, kind = "File") => fs.stat(file).pipe(Effect.map((info) => info.type === kind), Effect.orElseSucceed(() => false));
20782
+ for (const harness of ["claude-code", "codex"]) {
20783
+ const { variable, inHome } = configFolders[harness];
20784
+ const named = variables ? yield* Config.String(variable).pipe(Config.withDefault("")) : "";
20785
+ const candidates = [
20786
+ path.join(home, inHome),
20787
+ ...names.filter((name) => name.startsWith(`${inHome}-`)).sort().map((name) => path.join(home, name)),
20788
+ ...named === "" ? [] : [named]
20789
+ ];
20790
+ for (const candidate of candidates) {
20791
+ const configFolder = yield* folders.resolve(candidate);
20792
+ if (seen.has(configFolder) || !(yield* has(configFolder, "Directory"))) continue;
20793
+ if (!(harness === "claude-code" ? (yield* has(path.join(configFolder, ".claude.json"))) || candidate === path.join(home, ".claude") && (yield* has(path.join(home, ".claude.json"))) || (yield* has(path.join(configFolder, "projects"), "Directory")) && (yield* has(path.join(configFolder, "sessions"), "Directory")) : (yield* has(path.join(configFolder, ".sandbox_migration"))) || (yield* has(path.join(configFolder, "skills/.system/.codex-system-skills.marker"))) || (yield* has(path.join(configFolder, "installation_id"))) && (yield* has(path.join(configFolder, "sessions"), "Directory")) || (yield* has(path.join(configFolder, "config.toml"))))) continue;
20794
+ seen.add(configFolder);
20795
+ found.push({
20796
+ harness,
20797
+ configFolder
20798
+ });
20799
+ }
20800
+ }
20801
+ return found;
20802
+ });
20803
+ /** Find initialized harness folders without reading credentials or config file contents. */
20804
+ const findConfigFolders = searchConfigFolders(true);
20805
+ /**
20806
+ * The line that connects a folder, from the person's own terminal: `ref0 connect` finds it
20807
+ * there unless only its variable names it, which this session's environment may have set.
20808
+ */
20809
+ const connectLineFor = Effect.fnUntraced(function* (harness, configFolder) {
20810
+ const found = yield* searchConfigFolders(false);
20811
+ const real = yield* (yield* ConfigFolders).resolve(configFolder);
20812
+ if (found.some((each) => each.harness === harness && each.configFolder === real)) return "ref0 connect";
20813
+ return `${configFolders[harness].variable}=${shellWord(configFolder)} ref0 connect`;
20814
+ });
20403
20815
  //#endregion
20404
20816
  //#region src/host-grant.ts
20405
20817
  /** All lasting authority for one config folder, stored as one keychain value. */
@@ -20411,7 +20823,7 @@ const KeptHostGrant = Schema.Struct({
20411
20823
  });
20412
20824
  const decodeGrant = Schema.decodeUnknownOption(Schema.fromJsonString(KeptHostGrant));
20413
20825
  const encodeGrant = Schema.encodeSync(Schema.fromJsonString(KeptHostGrant));
20414
- /** Grants under service ref0, account host-grant/<resolved config folder>. */
20826
+ /** Grants in the origin’s keychain service, account host-grant/<resolved config folder>. */
20415
20827
  var HostGrant = class HostGrant extends Context.Service()("ref0/cli/HostGrant") {
20416
20828
  static layer = Layer.effect(HostGrant, Effect.gen(function* () {
20417
20829
  const keychain = yield* Keychain;
@@ -20421,7 +20833,10 @@ var HostGrant = class HostGrant extends Context.Service()("ref0/cli/HostGrant")
20421
20833
  get: (folder) => account(folder).pipe(Effect.flatMap(keychain.get), Effect.flatMap((kept) => {
20422
20834
  if (Option.isNone(kept)) return Effect.succeedNone;
20423
20835
  const grant = decodeGrant(Redacted.value(kept.value));
20424
- return Option.isSome(grant) ? Effect.succeed(grant) : Effect.fail(new KeychainError({ nextStep: "This config folder’s host grant is unreadable. Run ref0 connect again." }));
20836
+ return Option.isSome(grant) ? Effect.succeed(grant) : Effect.fail(new KeychainError({
20837
+ nextStep: "This config folder’s host grant is unreadable. Run ref0 connect again.",
20838
+ unreadable: true
20839
+ }));
20425
20840
  })),
20426
20841
  keep: (folder, grant) => Effect.gen(function* () {
20427
20842
  yield* keychain.set(yield* account(folder), Redacted.make(encodeGrant(grant)));
@@ -20443,103 +20858,21 @@ const grantForAgent = Effect.fnUntraced(function* (owner, agent) {
20443
20858
  return Option.none();
20444
20859
  });
20445
20860
  //#endregion
20446
- //#region src/names.ts
20447
- /**
20448
- * The names one part of the CLI writes and another reads, each defined here once: the commands,
20449
- * flags and variable a harness's plugin runs `ref0` with, which their definitions take too, and
20450
- * the variables `ref0 watch` sets for the command it runs, which a harness that command starts
20451
- * reads.
20452
- */
20861
+ //#region src/version.ts
20453
20862
  /**
20454
- * The commands a harness runs: `ref0 mcp`, `ref0 hook` with each of its command hooks, and
20455
- * `ref0 status` for the plugin's own command.
20863
+ * The CLI's version, from its `package.json` as the build reads it: what `ref0 --version`
20864
+ * prints, what its MCP server and Codex client say they are, and what the plugin carries, so
20865
+ * an update installs anew. Node, run from source, loads JSON only with the `type` attribute,
20866
+ * and only as a default export.
20456
20867
  */
20457
- const harnessCommandNames = {
20458
- mcp: "mcp",
20459
- hook: "hook",
20460
- sessionStart: "session-start",
20461
- sessionEnd: "session-end",
20462
- status: "status"
20463
- };
20464
- /** The flag a plugin passes its command hooks: the hook protocol their declarations speak. */
20465
- const harnessFlagNames = { protocol: "protocol" };
20868
+ const cliVersion = "0.7.0";
20869
+ //#endregion
20870
+ //#region src/harness/hook-protocol.ts
20466
20871
  /**
20467
- * What the plugin's lookup script and server launcher put in `ref0`'s environment: the plugin's
20468
- * release, which no hook's declaration carries.
20469
- */
20470
- const pluginVersionVariable = "REF0_PLUGIN_VERSION";
20471
- /** A flag as a command line writes it. */
20472
- const flagWord = (name) => `--${name}`;
20473
- /**
20474
- * What `ref0 watch` puts in the environment of the command it runs: the thread, the event that
20475
- * started it, and its title.
20476
- */
20477
- const watchVariables = {
20478
- thread: "REF0_THREAD",
20479
- event: "REF0_EVENT",
20480
- title: "REF0_TITLE"
20481
- };
20482
- //#endregion
20483
- //#region src/version.ts
20484
- /**
20485
- * The CLI's version, from its `package.json` as the build reads it: what `ref0 --version`
20486
- * prints, what its MCP server and Codex client say they are, and what the plugin carries, so
20487
- * an update installs anew. Node, run from source, loads JSON only with the `type` attribute,
20488
- * and only as a default export.
20489
- */
20490
- const cliVersion = "0.6.0";
20491
- Schema.encodeSync(Schema.fromJsonString(HooksFile));
20492
- Schema.encodeSync(Schema.fromJsonString(McpServersFile));
20493
- /** A path as one word for `sh`, which runs command hooks. */
20494
- const shellWord = (word) => `'${word.replaceAll("'", `'\\''`)}'`;
20495
- /** An event's hook asking `ref0 mcp`, with a template naming each field it reads. */
20496
- const mcpToolHook = (server, event, fields) => ({ hooks: [{
20497
- type: "mcp_tool",
20498
- server,
20499
- tool: "hook",
20500
- input: Object.fromEntries([
20501
- ["protocol", "1"],
20502
- ["hook_event_name", event],
20503
- ...fields.map((field) => [field, `\${${field}}`])
20504
- ])
20505
- }] });
20506
- const commandHook = (command) => ({ hooks: [{
20507
- type: "command",
20508
- command
20509
- }] });
20510
- /** The plugin's lookup script, by its path inside the plugin. */
20511
- const lookupScript = "bin/ref0";
20512
- /** The plugin's root, as a command hook names it for the harness to fill in. */
20513
- const pluginRoot = "${CLAUDE_PLUGIN_ROOT}";
20514
- /**
20515
- * Runs `ref0 <words>` through the lookup script. Claude Code fills in the plugin's root before
20516
- * it runs the command, and Codex sets it in the hook's environment, where `sh` reads it.
20517
- */
20518
- const throughLookup = (words) => [
20519
- "/bin/sh",
20520
- `"${pluginRoot}/${lookupScript}"`,
20521
- ...words
20522
- ].join(" ");
20523
- /** Runs `ref0 hook <event>`, saying the hook protocol its declaration speaks. */
20524
- const ref0Hook = (event) => commandHook(throughLookup([
20525
- harnessCommandNames.hook,
20526
- event,
20527
- flagWord(harnessFlagNames.protocol),
20528
- "1"
20529
- ]));
20530
- /** Starts the agent's session: a command, since it runs before the harness connects its servers. */
20531
- const sessionStartHook = ref0Hook(harnessCommandNames.sessionStart);
20532
- /** Ends the session, as a command too. */
20533
- const sessionEndHook = ref0Hook(harnessCommandNames.sessionEnd);
20534
- /** Each event a harness's hooks send `ref0 mcp`, with the fields it reads, as an `mcp_tool` hook. */
20535
- const mcpToolHooks = (server, hookFields) => Object.fromEntries(Object.entries(hookFields).map(([event, fields]) => [event, [mcpToolHook(server, event, fields)]]));
20536
- //#endregion
20537
- //#region src/harness/hook-protocol.ts
20538
- /**
20539
- * Whether this `ref0` speaks the hook protocol of the plugin that ran it, and what to say when
20540
- * it doesn't. The plugin and the CLI update apart, so each hook's declaration carries the
20541
- * protocol it speaks, and a `ref0` that speaks another answers with the update line: the session
20542
- * start says it to the agent, and `ref0 mcp` refuses each hook with it.
20872
+ * Whether this `ref0` speaks the hook protocol of the plugin that ran it, and what to say when
20873
+ * it doesn't. The plugin and the CLI update apart, so each hook's declaration carries the
20874
+ * protocol it speaks, and a `ref0` that speaks another answers with the update line: the session
20875
+ * start says it to the agent, and `ref0 mcp` refuses each hook with it.
20543
20876
  */
20544
20877
  /** The plugin's release, from its lookup script or launcher; none if something else ran `ref0`. */
20545
20878
  const pluginVersion = Config.String(pluginVersionVariable).pipe(Config.option);
@@ -20549,8 +20882,8 @@ const protocolFlagged = (command) => /--protocol (\S+)/.exec(command)?.[1];
20549
20882
  const protocolsOf = (file) => Object.values(file.hooks).flatMap((groups) => groups.flatMap((group) => group.hooks)).map((hook) => hook.type === "mcp_tool" ? hook.input["protocol"] : protocolFlagged(hook.command)).filter(Predicate.isString);
20550
20883
  /** `--protocol`, which each command hook passes: the hook protocol its declaration speaks. */
20551
20884
  const protocolFlag = Flag.String(harnessFlagNames.protocol).pipe(Flag.withDescription("The hook protocol the plugin speaks"));
20552
- /** The command that installs the plugin again for an agent, or connects one first. */
20553
- const installLine = (folder, connected = true, harness) => connected ? `ref0 install ${shellWord(folder)}` : harness === void 0 ? "ref0 connect" : `ref0 connect --harness ${harness}`;
20885
+ /** The command that installs the plugin again into a connected folder. */
20886
+ const installLine = (folder) => `ref0 install ${shellWord(folder)}`;
20554
20887
  /**
20555
20888
  * Why this `ref0` doesn't speak the plugin's hook protocol, and what to run: a newer plugin needs
20556
20889
  * a newer `ref0`, and an older one installing again. None when `ref0` speaks it.
@@ -20613,12 +20946,89 @@ var Machine = class Machine extends Context.Service()("ref0/cli/Machine") {
20613
20946
  }));
20614
20947
  };
20615
20948
  //#endregion
20949
+ //#region src/old-host-grants.ts
20950
+ /**
20951
+ * 0.5.0 kept each host grant under `host-grant/<owner>/<agent>` in the keychain service `ref0`,
20952
+ * and which agent each config folder acted as in `~/.ref0/plugin-agents.json`. Nothing reads
20953
+ * them since but `ref0 connect`: it names the grant each folder replaces, then forgets each
20954
+ * folder once its new grant is kept, and deletes each grant no folder still names. Two folders
20955
+ * acting as one agent shared its grant, so one kept and one not leaves it for the next run.
20956
+ * 0.5.0 kept no origin, and spoke to ref0.dev unless `REF0_API` said otherwise, so only a
20957
+ * connect to ref0.dev reads them.
20958
+ */
20959
+ const ByFolder = Schema.Record(Schema.String, Schema.Struct({
20960
+ owner: OwnerName,
20961
+ agent: AgentHandle
20962
+ }));
20963
+ const PluginAgents = Schema.Struct({
20964
+ "claude-code": ByFolder,
20965
+ codex: ByFolder
20966
+ });
20967
+ const decodeAgents = Schema.decodeUnknownOption(Schema.fromJsonString(PluginAgents));
20968
+ const encodeAgents = Schema.encodeSync(Schema.fromJsonString(PluginAgents));
20969
+ const decodeTokenId$2 = Schema.decodeUnknownOption(TokenId);
20970
+ var OldHostGrants = class OldHostGrants extends Context.Service()("ref0/cli/OldHostGrants") {
20971
+ static layer = Layer.effect(OldHostGrants, Effect.gen(function* () {
20972
+ const fs = yield* FileSystem.FileSystem;
20973
+ const path = yield* Path.Path;
20974
+ const config = yield* Ref0Config;
20975
+ const keychain = yield* keychainIn("ref0");
20976
+ const home = yield* Config.String("HOME").pipe(Config.withDefault("."));
20977
+ const file = path.join(home, ".ref0", "plugin-agents.json");
20978
+ const account = (named) => `host-grant/${named.owner}/${named.agent}`;
20979
+ const agents = config.api === defaultOrigin ? fs.readFileString(file).pipe(Effect.option, Effect.map(Option.flatMap(decodeAgents))) : Effect.succeedNone;
20980
+ const replaces = Effect.fnUntraced(function* ({ harness, configFolder }) {
20981
+ const kept = yield* agents;
20982
+ if (Option.isNone(kept)) return Option.none();
20983
+ const named = kept.value[harness][configFolder];
20984
+ if (named === void 0) return Option.none();
20985
+ const token = yield* keychain.get(account(named));
20986
+ return Option.flatMap(token, (held) => Option.flatMap(parseToken(Redacted.value(held)), (parts) => decodeTokenId$2(parts.token)));
20987
+ });
20988
+ const retire = Effect.fnUntraced(function* (kept) {
20989
+ const before = yield* agents;
20990
+ if (Option.isNone(before)) return;
20991
+ const left = {
20992
+ "claude-code": { ...before.value["claude-code"] },
20993
+ codex: { ...before.value.codex }
20994
+ };
20995
+ const forgotten = /* @__PURE__ */ new Map();
20996
+ for (const folder of kept) {
20997
+ const named = left[folder.harness][folder.configFolder];
20998
+ if (named === void 0) continue;
20999
+ delete left[folder.harness][folder.configFolder];
21000
+ forgotten.set(account(named), [...forgotten.get(account(named)) ?? [], {
21001
+ ...folder,
21002
+ named
21003
+ }]);
21004
+ }
21005
+ const stillNamed = new Set([...Object.values(left["claude-code"]), ...Object.values(left.codex)].map(account));
21006
+ for (const [name, folders] of forgotten) {
21007
+ if (stillNamed.has(name)) continue;
21008
+ if (yield* keychain.remove(name).pipe(Effect.as(true), Effect.catch(() => Effect.map(keychain.get(name).pipe(Effect.orElseSucceed(() => Option.some(Redacted.make("")))), Option.isNone)))) continue;
21009
+ for (const folder of folders) left[folder.harness][folder.configFolder] = folder.named;
21010
+ }
21011
+ if (forgotten.size === 0) return;
21012
+ const empty = Object.keys(left["claude-code"]).length === 0 && Object.keys(left.codex).length === 0;
21013
+ const next = `${file}.next`;
21014
+ yield* (empty ? fs.remove(file) : fs.writeFileString(next, encodeAgents(left), { mode: 384 }).pipe(Effect.andThen(fs.rename(next, file)))).pipe(Effect.ignore);
21015
+ });
21016
+ return OldHostGrants.of({
21017
+ replaces,
21018
+ retire
21019
+ });
21020
+ }));
21021
+ };
21022
+ //#endregion
20616
21023
  //#region src/attachment-request.ts
20617
21024
  /**
20618
21025
  * The machine prints its link and Find code, then collects with its own secret and the answer
20619
- * pasted from the approving browser. It keeps and confirms the grant before installing.
21026
+ * from the approving browser. It keeps and confirms each folder's grant before installing.
20620
21027
  */
20621
21028
  const isAnswerCode = Schema.is(TypedApprovalCode);
21029
+ /** A key's escape code, such as an arrow's, as a terminal sends it. */
21030
+ const escapeCode = /\u001b\[[\d;]*[A-Za-z~]/gu;
21031
+ const decodeTokenId$1 = Schema.decodeUnknownOption(TokenId);
20622
21032
  const decodeSecretHash$1 = Schema.decodeUnknownEffect(SecretHash);
20623
21033
  /** How often the machine asks whether its person has answered. */
20624
21034
  const pollMillis = 2e3;
@@ -20634,62 +21044,79 @@ const giveUpAfterMillis = 12e4;
20634
21044
  * it, before the machine stops it. Ctrl-C can't: the grant is kept even after an interrupt.
20635
21045
  */
20636
21046
  const keepTimeout = Duration.minutes(1);
21047
+ /**
21048
+ * How long keeping every folder's grant may take, so the machine confirms what it kept well
21049
+ * before ref0 revokes what it hasn't, 10 minutes after it connected.
21050
+ */
21051
+ const keepBudget = Duration.minutes(5);
20637
21052
  /** A request that couldn't finish: no answer in time, no connection, or ref0 briefly unable. */
20638
21053
  var Unanswered = class extends Schema.TaggedError()("Unanswered", {}) {};
20639
21054
  const unanswered = () => Effect.fail(new Unanswered());
20640
21055
  /** A refusal ref0 gave, said as it gave it. */
20641
21056
  const said = (refusal) => sayFailed(refusal.nextStep);
20642
21057
  const isEnded = (state) => state.state !== "waiting";
20643
- /** The line that asks again, as the person ran it. */
20644
- const connectLine = (harness) => Option.match(harness, {
20645
- onNone: () => "ref0 connect",
20646
- onSome: (into) => `ref0 connect --harness ${into}`
20647
- });
20648
- /** What connecting connected, in words: the agent, and each organization it doesn't work in yet. */
20649
- const connectedWords = (connected) => {
20650
- const named = (state) => connected.organizations.filter((place) => place.state === state).map((place) => place.name);
20651
- const pending = named("pending");
20652
- const refused = named("refused");
20653
- return [
20654
- `Connected: ${connected.handle}, ${connected.owner}’s agent, on this machine.`,
20655
- ...pending.length === 0 ? [] : [`ref0 is still authorizing it in ${pending.join(", ")}: it works there once that’s done.`],
20656
- ...refused.length === 0 ? [] : [`It doesn’t work in ${refused.join(", ")}, which refused to authorize it.`]
20657
- ].join(" ");
20658
- };
21058
+ /** The line that asks again. */
21059
+ const again$1 = "ref0 connect";
20659
21060
  /** What each way a request can end without connecting says, with the line that asks again. */
20660
- const endedWords$1 = (state, again) => {
21061
+ const endedWords$1 = (state) => {
20661
21062
  switch (state.state) {
20662
- case "refused": return `It was refused, and nothing was connected. To ask again, run: ${again}`;
20663
- case "aborted": return `It was aborted, and nothing was connected. To ask again, run: ${again}`;
20664
- case "expired": return `Nobody approved it in time, and nothing was connected. For a new code, run: ${again}`;
20665
- case "failed": return `${state.nextStep} To connect this machine, run: ${again}`;
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}`;
20666
21067
  }
20667
21068
  };
21069
+ /** The grant a kept record holds, by its ID. */
21070
+ const grantOf = (token) => Option.flatMap(parseToken(Redacted.value(token)), (parts) => decodeTokenId$1(parts.token));
20668
21071
  /**
20669
- * Asks with no owner, shows the link, and takes the answer pasted from the person’s own page.
21072
+ * Asks with no owner for every folder given, each with a grant secret of its own, shows the
21073
+ * link, and takes the answer from the person's own page. Then it keeps each folder ref0
21074
+ * connected, confirms exactly the grants it kept, and forgets 0.5.0's grants they replace.
20670
21075
  */
20671
- const requestAttachment = Effect.fnUntraced(function* (harness) {
21076
+ const requestAttachment = Effect.fnUntraced(function* (asked) {
20672
21077
  const config = yield* Ref0Config;
20673
- const again = connectLine(harness);
20674
21078
  const machine = yield* Machine;
20675
21079
  const hostGrant = yield* HostGrant;
21080
+ const oldGrants = yield* OldHostGrants;
20676
21081
  const http = yield* HttpClient.HttpClient;
20677
- const grant = yield* freshTokenSecret;
21082
+ const home = yield* Config.String("HOME").pipe(Config.withDefault("."));
20678
21083
  const collecting = yield* freshSecret;
20679
21084
  const link = yield* freshLinkSecret;
20680
21085
  const pasted = yield* Ref.make(void 0);
21086
+ const opener = yield* browserOpener;
21087
+ const listener = opener === void 0 ? void 0 : yield* listenForAnswer(new URL(config.api).origin).pipe(Effect.catchTag("ListenerUnavailable", () => Effect.as(Console.error("Couldn’t listen for the browser. Paste the code instead."), void 0)));
20681
21088
  const machineId = yield* machine.id.pipe(Effect.catchTag("MachineError", said));
20682
21089
  const client = yield* ref0Client(config.api).pipe(Effect.provide(anonymousCredential), Effect.provideService(HttpClient.HttpClient, http));
20683
21090
  const unreachable = () => sayFailed(`Couldn’t reach ref0 at ${config.api}. Try again.`);
20684
21091
  const requestId = yield* newRequestId;
21092
+ const asking = yield* Effect.forEach(asked, Effect.fnUntraced(function* (folder) {
21093
+ const previous = yield* hostGrant.get(folder.configFolder).pipe(Effect.catchIf((failed) => failed.unreadable === true, () => Effect.succeedNone), Effect.catchTag("KeychainError", said));
21094
+ return {
21095
+ ...folder,
21096
+ shown: shownConfigFolder(home, folder.configFolder),
21097
+ grant: yield* freshTokenSecret,
21098
+ replaces: Option.isSome(previous) ? grantOf(previous.value.token) : yield* oldGrants.replaces(folder).pipe(Effect.catchTag("KeychainError", said))
21099
+ };
21100
+ }));
21101
+ const folders = yield* Effect.forEach(asking, Effect.fnUntraced(function* (folder) {
21102
+ const requested = {
21103
+ path: folder.shown,
21104
+ harness: folder.harness,
21105
+ secretHash: yield* Effect.orDie(decodeSecretHash$1(folder.grant.hash))
21106
+ };
21107
+ return Option.isSome(folder.replaces) ? {
21108
+ ...requested,
21109
+ replaces: folder.replaces.value
21110
+ } : requested;
21111
+ }));
20685
21112
  const payload = {
20686
21113
  machine: machineId,
20687
21114
  machineName: machine.name,
20688
- secretHash: yield* Effect.orDie(decodeSecretHash$1(grant.hash)),
21115
+ folders,
20689
21116
  collectHash: yield* Effect.orDie(decodeSecretHash$1(collecting.hash)),
20690
21117
  linkHash: yield* Effect.orDie(decodeSecretHash$1(link.hash))
20691
21118
  };
20692
- if (Option.isSome(harness)) payload.harness = harness.value;
21119
+ if (listener !== void 0) payload.callback = listener.callback;
20693
21120
  const started = yield* client.machineAttachments.request({
20694
21121
  headers: { "idempotency-key": requestId },
20695
21122
  payload
@@ -20710,9 +21137,10 @@ const requestAttachment = Effect.fnUntraced(function* (harness) {
20710
21137
  yield* Console.error([
20711
21138
  `Open ${config.api}/connect#${link.secret}`,
20712
21139
  `Or type ${started.code} in Find under Waiting on you, by ${minute(started.expiresAt)}.`,
20713
- "Approve it only if you ran this ref0 connect yourself, just now: a yes gives this machine a lasting credential for that agent.",
21140
+ "Approve it only if you ran this ref0 connect yourself, just now: a yes gives this machine a lasting credential for each agent it connects.",
20714
21141
  "Waiting for the answer. Ctrl-C stops it."
20715
21142
  ].join("\n"));
21143
+ if (opener !== void 0) yield* openBrowser(opener, `${config.api}/connect#${link.secret}`);
20716
21144
  const withSecret = HttpApiMiddleware.layerClient(ApprovalSecret, ({ request, next }) => next(HttpClientRequest.bearerToken(request, collecting.secret)));
20717
21145
  const collector = yield* ref0Client(config.api).pipe(Effect.provide(Layer.merge(anonymousCredential, withSecret)), Effect.provideService(HttpClient.HttpClient, http));
20718
21146
  const params = { approval: started.approval };
@@ -20734,35 +21162,82 @@ const requestAttachment = Effect.fnUntraced(function* (harness) {
20734
21162
  while (true) {
20735
21163
  const state = yield* collect;
20736
21164
  if (Option.isSome(state) && isEnded(state.value)) return state.value;
20737
- 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 grant 10 minutes after it connected. To connect this machine, run: ${again}`);
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}`);
20738
21166
  yield* Effect.sleep(pollMillis);
20739
21167
  }
20740
21168
  });
20741
- const finish = Effect.fnUntraced(function* (state) {
20742
- if (state.state !== "connected") return yield* sayFailed(endedWords$1(state, again));
20743
- const token = formatToken(state.credential.person, state.credential.id, grant.secret);
20744
- const into = Option.isSome(harness) ? harness.value : yield* currentHarness;
20745
- const folder = yield* (yield* ConfigFolders).folder(into);
20746
- yield* hostGrant.keep(folder, {
20747
- harness: into,
20748
- owner: state.owner,
20749
- agent: state.handle,
21169
+ const keepOne = Effect.fnUntraced(function* (folder, connection, keepBy) {
21170
+ const left = keepBy - (yield* Clock.currentTimeMillis);
21171
+ if (left <= 0) return Option.some("The keychain took too long with the folders before it, so this machine didn’t keep this one’s grant.");
21172
+ const token = formatToken(connection.credential.person, connection.credential.id, folder.grant.secret);
21173
+ const kept = yield* hostGrant.keep(folder.configFolder, {
21174
+ harness: folder.harness,
21175
+ owner: connection.owner,
21176
+ agent: connection.handle,
20750
21177
  token: Redacted.make(token)
20751
21178
  }).pipe(Effect.timeoutOrElse({
20752
- duration: keepTimeout,
21179
+ duration: Duration.min(keepTimeout, Duration.millis(left)),
20753
21180
  orElse: () => Effect.fail(new KeychainError({ nextStep: "The keychain didn’t answer within a minute. Check that it’s unlocked." }))
20754
21181
  }), Effect.retry({
20755
21182
  times: 2,
20756
21183
  schedule: Schedule.spaced("1 second")
20757
- }), Effect.catchTag("KeychainError", (failed) => sayFailed(`It connected, but this machine couldn’t keep what it needs to act: ${failed.nextStep} This machine never confirmed the grant, so ref0 revokes it 10 minutes after it connected, even if the keychain kept it after all. To connect this machine, run: ${again}`)));
20758
- const unconfirmed = () => sayFailed(`It connected, and this machine kept its grant, but ref0 didn’t answer when it said so. If ref0 never heard, it revokes the grant 10 minutes after it connected: then run ${again}`);
21184
+ }), Effect.result);
21185
+ return Result.isSuccess(kept) ? Option.none() : Option.some(kept.failure.nextStep);
21186
+ });
21187
+ const finish = Effect.fnUntraced(function* (state) {
21188
+ if (state.state !== "connected") return yield* sayFailed(endedWords$1(state));
21189
+ const keepBy = (yield* Clock.currentTimeMillis) + Duration.toMillis(keepBudget);
21190
+ const kept = [];
21191
+ const grants = [];
21192
+ const notKept = [];
21193
+ for (const folder of asking) {
21194
+ const connection = state.folders.find((each) => each.path === folder.shown);
21195
+ if (connection === void 0) {
21196
+ if (!state.excluded.includes(folder.shown)) notKept.push({
21197
+ harness: folder.harness,
21198
+ configFolder: folder.configFolder,
21199
+ nextStep: "ref0 didn’t connect this folder."
21200
+ });
21201
+ continue;
21202
+ }
21203
+ const failed = yield* keepOne(folder, connection, keepBy);
21204
+ if (Option.isSome(failed)) {
21205
+ notKept.push({
21206
+ harness: folder.harness,
21207
+ configFolder: folder.configFolder,
21208
+ nextStep: failed.value
21209
+ });
21210
+ continue;
21211
+ }
21212
+ kept.push({
21213
+ harness: folder.harness,
21214
+ configFolder: folder.configFolder,
21215
+ owner: connection.owner,
21216
+ agent: connection.agent.id,
21217
+ handle: connection.handle,
21218
+ organizations: connection.organizations
21219
+ });
21220
+ grants.push(connection.credential.id);
21221
+ }
21222
+ yield* oldGrants.retire(kept);
21223
+ const [first, ...rest] = grants;
21224
+ if (first === void 0) return yield* sayFailed([[
21225
+ "It connected, but this machine couldn’t keep what it needs to act:",
21226
+ ...notKept.map((each) => `${shownConfigFolder(home, each.configFolder)}: ${each.nextStep}`),
21227
+ "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 }
21229
+ ]]);
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}`);
20759
21231
  const confirmId = yield* newRequestId;
20760
21232
  const answerCode = yield* Ref.get(pasted);
20761
- if (answerCode === void 0) return yield* sayFailed("The answer code is missing. Run ref0 connect again.");
21233
+ if (answerCode === void 0) return yield* sayFailed(`The answer code is missing. Run ${again$1} again.`);
20762
21234
  if (!(yield* collector.machineAttachments.confirm({
20763
21235
  params,
20764
21236
  headers: { "idempotency-key": confirmId },
20765
- payload: { answerCode }
21237
+ payload: {
21238
+ answerCode,
21239
+ grants: [first, ...rest]
21240
+ }
20766
21241
  }).pipe(Effect.timeoutOrElse({
20767
21242
  duration: requestTimeout,
20768
21243
  orElse: unanswered
@@ -20777,14 +21252,17 @@ const requestAttachment = Effect.fnUntraced(function* (harness) {
20777
21252
  times: 2,
20778
21253
  schedule: Schedule.spaced("1 second")
20779
21254
  }), Effect.catchTag("Unanswered", unconfirmed))).confirmed) {
20780
- yield* hostGrant.remove(folder).pipe(Effect.ignore);
20781
- return yield* sayFailed(`ref0 revoked the grant, since this machine didn’t confirm in time that it kept it. It’s deleted here, and nothing is connected. To connect this machine, run: ${again}`);
21255
+ 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}`);
20782
21257
  }
20783
21258
  return {
20784
21259
  owner: state.owner,
20785
- agent: state.agent.id,
20786
- handle: state.handle,
20787
- organizations: state.organizations
21260
+ kept,
21261
+ notKept,
21262
+ excluded: asking.flatMap(({ harness, configFolder, shown }) => state.excluded.includes(shown) ? [{
21263
+ harness,
21264
+ configFolder
21265
+ }] : [])
20788
21266
  };
20789
21267
  });
20790
21268
  const stopped = Effect.gen(function* () {
@@ -20805,29 +21283,351 @@ const requestAttachment = Effect.fnUntraced(function* (harness) {
20805
21283
  }), Effect.retry({
20806
21284
  while: Predicate.isTagged("Unanswered"),
20807
21285
  times: 2
20808
- }), Effect.catchTag("Unanswered", () => sayFailed("Couldn’t reach ref0 to abort it. If it’s approved anyway, ref0 revokes its grant 10 minutes after it connects, since this machine never kept it.")));
21286
+ }), Effect.catchTag("Unanswered", () => sayFailed("Couldn’t reach ref0 to abort it. If it’s approved anyway, ref0 revokes its grants 10 minutes after they connect, since this machine never kept them.")));
20809
21287
  if (!isEnded(state)) {
20810
- if ((yield* Ref.get(pasted)) === void 0) return yield* sayFailed("It was already answered, but no code was pasted. Nothing was kept here; its unconfirmed grant expires.");
21288
+ if ((yield* Ref.get(pasted)) === void 0) return yield* sayFailed("It was already answered, but no code was pasted. Nothing was kept here; its unconfirmed grants expire.");
20811
21289
  yield* Console.error("It was already answered, so this machine waits to hear how.");
20812
21290
  }
20813
21291
  const connected = yield* finish(isEnded(state) ? state : yield* answered);
20814
- const install = Option.isSome(harness) ? ` Nothing was installed: to install ref0’s plugin for it, run ${installLine(yield* (yield* ConfigFolders).folder(harness.value))}` : "";
20815
- yield* Console.error(`${connectedWords(connected)}${install}`);
21292
+ yield* printBlocks([[{ label: `Connected as ${connected.owner}’s agents` }, ...connected.kept.map((folder) => `${shownConfigFolder(home, folder.configFolder)} ${folder.handle}`)], ["Stopped, so nothing was installed. To install ref0’s plugin into each, run:", ...connected.kept.map((folder) => ({ command: installLine(folder.configFolder) }))]]);
20816
21293
  });
20817
21294
  const terminal = yield* Terminal.Terminal;
20818
21295
  const readAnswer = Effect.gen(function* () {
20819
- yield* Console.error("Paste the code from your own page on ref0.dev.");
21296
+ yield* Console.error(`Paste the code from your own page on ${config.api}.`);
20820
21297
  while (true) {
20821
- const typed = (yield* terminal.readLine).trim();
21298
+ const typed = (yield* terminal.readLine).replaceAll(escapeCode, "").trim();
21299
+ if (typed === "") continue;
20822
21300
  if (!isAnswerCode(typed)) {
20823
- yield* Console.error("Paste the 8-character code from your own page on ref0.dev.");
21301
+ yield* Console.error(`Paste the 8-character code from your own page on ${config.api}.`);
20824
21302
  continue;
20825
21303
  }
20826
- yield* Ref.set(pasted, typed);
21304
+ yield* Ref.update(pasted, (first) => first ?? typed);
21305
+ if (listener !== void 0) yield* listener.stop;
20827
21306
  return yield* Effect.never;
20828
21307
  }
20829
21308
  }).pipe(Effect.catchTag("QuitError", () => Effect.interrupt));
20830
- return yield* Effect.uninterruptibleMask((restore) => restore(Effect.raceFirst(answered, readAnswer)).pipe(Effect.onInterrupt(() => Effect.ignore(stopped)), Effect.flatMap(finish)));
21309
+ const receiveAnswer = listener === void 0 ? readAnswer : Effect.raceFirst(readAnswer, listener.answer.pipe(Effect.flatMap((code) => Ref.update(pasted, (first) => first ?? code)), Effect.andThen(Effect.never)));
21310
+ return yield* Effect.uninterruptibleMask((restore) => restore(Effect.raceFirst(answered, receiveAnswer)).pipe(Effect.ensuring(listener === void 0 ? Effect.void : listener.stop), Effect.onInterrupt(() => Effect.ignore(stopped)), Effect.flatMap(finish)));
21311
+ }, Effect.scoped);
21312
+ //#endregion
21313
+ //#region src/connect-lock.ts
21314
+ /**
21315
+ * One `ref0 connect` at a time on this machine, for every API origin, from finding its config
21316
+ * folders to installing into them: two at once could each keep a grant for one folder, and the
21317
+ * one written last would leave the other live with nobody holding it.
21318
+ *
21319
+ * The lock is `~/.ref0/connect.lock`. Each entry names its process, when that process started,
21320
+ * and a nonce, and is written whole, then linked into place, so nobody reads one half written.
21321
+ * An entry whose process has ended is passed over to `connect.lock-<its nonce>`, which only one
21322
+ * process can link: a crash leaves the lock to the next run, and two runs that find the same
21323
+ * ended entry can't both take it. A run that linked further along reads the chain again from
21324
+ * the head before it holds, and lets go if any of it changed. The holder unlinks from the head,
21325
+ * so a crash part way through leaves only entries no chain reaches.
21326
+ */
21327
+ /** Another run holds the lock, or a file in it can't be read. */
21328
+ var ConnectBusy = class extends Schema.TaggedError()("ConnectBusy", { nextStep: Schema.String }) {};
21329
+ const Entry = Schema.Struct({
21330
+ pid: Schema.Int.check(Schema.isGreaterThan(0)),
21331
+ started: Schema.String,
21332
+ nonce: Schema.String.check(Schema.isPattern(/^[0-9a-f-]{36}$/))
21333
+ });
21334
+ const decodeEntry = Schema.decodeUnknownOption(Schema.fromJsonString(Entry));
21335
+ const encodeEntry = Schema.encodeSync(Schema.fromJsonString(Entry));
21336
+ /** A process, as far as the lock goes: which one this is, and whether one is still running. */
21337
+ var Processes = class Processes extends Context.Service()("ref0/cli/Processes") {
21338
+ static layer = Layer.effect(Processes, Effect.gen(function* () {
21339
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
21340
+ const startOf = (pid) => spawner.string(ChildProcess.make("ps", [
21341
+ "-o",
21342
+ "lstart=",
21343
+ "-p",
21344
+ String(pid)
21345
+ ], {
21346
+ env: {
21347
+ TZ: "UTC",
21348
+ LC_ALL: "C"
21349
+ },
21350
+ extendEnv: true
21351
+ })).pipe(Effect.map((text) => text.trim()), Effect.timeout("5 seconds"), Effect.orElseSucceed(() => ""));
21352
+ const signalled = (pid) => Effect.try(() => process.kill(pid, 0)).pipe(Effect.as(true), Effect.catch((error) => Effect.succeed(Predicate.hasProperty(error.cause, "code") && error.cause.code === "EPERM")));
21353
+ return Processes.of({
21354
+ own: Effect.map(startOf(process.pid), (started) => ({
21355
+ pid: process.pid,
21356
+ started
21357
+ })),
21358
+ running: Effect.fnUntraced(function* ({ pid, started }) {
21359
+ if (!(yield* signalled(pid))) return false;
21360
+ if (started === "") return true;
21361
+ const now = yield* startOf(pid);
21362
+ return now === "" || now === started;
21363
+ })
21364
+ });
21365
+ }));
21366
+ };
21367
+ var ConnectLock = class ConnectLock extends Context.Service()("ref0/cli/ConnectLock") {
21368
+ static layer = Layer.effect(ConnectLock, Effect.gen(function* () {
21369
+ const fs = yield* FileSystem.FileSystem;
21370
+ const path = yield* Path.Path;
21371
+ const crypto = yield* Crypto.Crypto;
21372
+ const processes = yield* Processes;
21373
+ const home = yield* Config.String("HOME").pipe(Config.withDefault("."));
21374
+ const directory = path.join(home, ".ref0");
21375
+ const head = path.join(directory, "connect.lock");
21376
+ const shown = "~/.ref0/connect.lock";
21377
+ const unwritable = new ConnectBusy({ nextStep: `Couldn’t write ${shown}. Check that ~/.ref0 is writable, then run ref0 connect again.` });
21378
+ const unreadable = new ConnectBusy({ nextStep: `${shown} holds a file ref0 can’t read. If no ref0 connect is running, delete ${shown}*, then run ref0 connect again.` });
21379
+ const busy = (pid) => new ConnectBusy({ nextStep: `Another ref0 connect is running on this machine, as process ${pid}. Let it finish, then run ref0 connect again.` });
21380
+ const raced = new ConnectBusy({ nextStep: "Another ref0 connect started at the same time. Let it finish, then run ref0 connect again." });
21381
+ const after = (entry) => `${head}-${entry.nonce}`;
21382
+ const read = Effect.fnUntraced(function* (name) {
21383
+ const text = yield* fs.readFileString(name).pipe(Effect.map(Option.some), Effect.catchTag("PlatformError", (error) => Predicate.isTagged(error.reason, "NotFound") ? Effect.succeedNone : Effect.fail(unreadable)));
21384
+ if (Option.isNone(text)) return Option.none();
21385
+ const entry = decodeEntry(text.value);
21386
+ if (Option.isNone(entry)) return yield* unreadable;
21387
+ return entry;
21388
+ });
21389
+ const intact = Effect.fnUntraced(function* (chain, own) {
21390
+ for (const [index, name] of chain.entries()) {
21391
+ const entry = yield* read(name);
21392
+ if (Option.isNone(entry)) return false;
21393
+ const next = chain[index + 1];
21394
+ if (next === void 0) return entry.value.nonce === own.nonce;
21395
+ if (after(entry.value) !== next) return false;
21396
+ }
21397
+ return false;
21398
+ });
21399
+ const take = Effect.fnUntraced(function* (draft, own) {
21400
+ for (let attempt = 0; attempt < 3; attempt += 1) {
21401
+ const chain = [];
21402
+ let name = head;
21403
+ for (let step = 0; step < 16; step += 1) {
21404
+ if (yield* fs.link(draft, name).pipe(Effect.as(true), Effect.catchTag("PlatformError", (error) => Predicate.isTagged(error.reason, "AlreadyExists") ? Effect.succeed(false) : Effect.fail(unwritable)))) {
21405
+ chain.push(name);
21406
+ if (yield* intact(chain, own)) return chain;
21407
+ yield* fs.remove(name).pipe(Effect.ignore);
21408
+ break;
21409
+ }
21410
+ const entry = yield* read(name);
21411
+ if (Option.isNone(entry)) continue;
21412
+ if (yield* processes.running(entry.value)) return yield* busy(entry.value.pid);
21413
+ chain.push(name);
21414
+ name = after(entry.value);
21415
+ }
21416
+ }
21417
+ return yield* raced;
21418
+ });
21419
+ const release = Effect.fnUntraced(function* (chain, own) {
21420
+ if (!(yield* intact(chain, own).pipe(Effect.orElseSucceed(() => false)))) return;
21421
+ for (const name of chain) yield* fs.remove(name).pipe(Effect.ignore);
21422
+ });
21423
+ const hold = Effect.gen(function* () {
21424
+ const own = {
21425
+ ...yield* processes.own,
21426
+ nonce: yield* crypto.randomUUIDv4.pipe(Effect.mapError(() => unwritable))
21427
+ };
21428
+ const draft = path.join(directory, `connect.lock.${own.nonce}.draft`);
21429
+ yield* Effect.acquireRelease(Effect.gen(function* () {
21430
+ yield* fs.makeDirectory(directory, {
21431
+ recursive: true,
21432
+ mode: 448
21433
+ });
21434
+ yield* fs.writeFileString(draft, encodeEntry(own), {
21435
+ flag: "wx",
21436
+ mode: 384
21437
+ });
21438
+ return yield* take(draft, own);
21439
+ }).pipe(Effect.catchTag("PlatformError", () => Effect.fail(unwritable)), Effect.ensuring(fs.remove(draft).pipe(Effect.ignore))), (chain) => release(chain, own));
21440
+ });
21441
+ return ConnectLock.of({ hold });
21442
+ }));
21443
+ };
21444
+ //#endregion
21445
+ //#region src/harness/install-claude-code.ts
21446
+ /** Install into one config folder, from the release Claude Code fetched and ref0 checked. */
21447
+ const installClaudeCode = Effect.fnUntraced(function* (release, configFolder) {
21448
+ const run = yield* release.into(configFolder);
21449
+ yield* run([
21450
+ "plugin",
21451
+ "install",
21452
+ release.id,
21453
+ "--scope",
21454
+ "user"
21455
+ ]);
21456
+ yield* run([
21457
+ "plugin",
21458
+ "update",
21459
+ release.id,
21460
+ "--scope",
21461
+ "user"
21462
+ ]);
21463
+ return release.folder;
21464
+ });
21465
+ //#endregion
21466
+ //#region src/harness/hook-fields.ts
21467
+ /**
21468
+ * The same for Codex 0.160.0, which skips a hook outright if its template names a field the event
21469
+ * lacks, so each list holds only fields every hook of its event carried in the prototypes, and
21470
+ * only events they saw, each a `HookEvent`. Codex puts the model in every hook but `SessionEnd`.
21471
+ * Its `UserPromptSubmit` carries the prompt (`spikes/harness/results/codex/catalog.json`), which
21472
+ * no list names, as for Claude Code.
21473
+ * Every Codex hook carries `transcript_path`, the session's rollout, `null` for a session kept
21474
+ * nowhere (Codex's hook input schemas, 0.160.0 to 0.163.0): its turns' tokens are read there.
21475
+ * A subagent's are read from its own rollout, which its `SubagentStop` names, with the
21476
+ * subagent's own turn as its `turn_id` (Codex 0.163.0's subagents, 20 Oct 2026).
21477
+ */
21478
+ const codexHookFields = {
21479
+ UserPromptSubmit: [
21480
+ "session_id",
21481
+ "turn_id",
21482
+ "cwd",
21483
+ "model",
21484
+ "transcript_path"
21485
+ ],
21486
+ PreToolUse: [
21487
+ "session_id",
21488
+ "turn_id",
21489
+ "cwd",
21490
+ "model",
21491
+ "tool_name",
21492
+ "tool_use_id",
21493
+ "tool_input"
21494
+ ],
21495
+ PostToolUse: [
21496
+ "session_id",
21497
+ "turn_id",
21498
+ "cwd",
21499
+ "model",
21500
+ "tool_name",
21501
+ "tool_use_id",
21502
+ "tool_input",
21503
+ "tool_response"
21504
+ ],
21505
+ Stop: [
21506
+ "session_id",
21507
+ "turn_id",
21508
+ "model",
21509
+ "stop_hook_active",
21510
+ "transcript_path"
21511
+ ],
21512
+ SubagentStop: [
21513
+ "session_id",
21514
+ "turn_id",
21515
+ "agent_id",
21516
+ "agent_type",
21517
+ "stop_hook_active",
21518
+ "agent_transcript_path"
21519
+ ]
21520
+ };
21521
+ Schema.encodeSync(Schema.fromJsonString(CodexPluginManifest));
21522
+ /** How Codex names a plugin's MCP server in an `mcp_tool` hook. */
21523
+ const codexServer = "ref0";
21524
+ /** Codex's hooks file, by its path inside the plugin. */
21525
+ const codexHooksPath = "hooks/codex-hooks.json";
21526
+ ({ ...mcpToolHooks(codexServer, codexHookFields) });
21527
+ /**
21528
+ * The folder of Codex's skills, by its path inside the plugin. Not `skills`, which Claude Code
21529
+ * reads too, where a `status` skill would be a second status command. Codex 0.163.0 skips
21530
+ * Claude Code's `commands/status.md`, since its `!` line is a template it can't run.
21531
+ */
21532
+ const codexSkillsPath = "codex-skills";
21533
+ /** The status skill, by its path inside the plugin. */
21534
+ const codexStatusSkillPath = `${codexSkillsPath}/status/SKILL.md`;
21535
+ /**
21536
+ * How a person runs the status skill of a plugin installed as `name`: Codex 0.163.0 names a
21537
+ * plugin's skill by the plugin, then the skill.
21538
+ */
21539
+ const codexStatusShortcut = (name) => `$${name}:status`;
21540
+ /** The skill's placeholder for the plugin's folder, which Codex's agent fills in. */
21541
+ const pluginFolder = "<plugin>";
21542
+ /** What the status skill runs: `ref0 status`, through the plugin's lookup script. */
21543
+ const codexStatusLine = throughLookup([harnessCommandNames.status]).replaceAll(pluginRoot, pluginFolder);
21544
+ [
21545
+ "---",
21546
+ "name: status",
21547
+ `description: Shows whether ref0 follows this Codex session, and what to do next if it doesn’t. Use when the person runs ${codexStatusShortcut("ref0")} or asks whether ref0 is working.`,
21548
+ "---",
21549
+ "",
21550
+ "Run ref0’s check and show the person what it says.",
21551
+ "",
21552
+ `1. This file is \`${pluginFolder}/${codexSkillsPath}/status/SKILL.md\`. Run this command, with \`${pluginFolder}\` replaced by that folder’s absolute path:`,
21553
+ "",
21554
+ " ```sh",
21555
+ ` ${codexStatusLine}`,
21556
+ " ```",
21557
+ "",
21558
+ " Run it outside Codex’s sandbox, with `sandbox_permissions: \"require_escalated\"`, since in the sandbox it can’t ask Codex, this machine’s keychain or ref0. If that isn’t allowed, run it in the sandbox.",
21559
+ "2. Show every line it prints as it is, and add nothing. It exits 1 when a line says no: that’s its answer, not an error to fix.",
21560
+ ""
21561
+ ].join("\n");
21562
+ //#endregion
21563
+ //#region src/harness/origin-plugin.ts
21564
+ const manifestJson = Schema.fromJsonString(PluginManifest);
21565
+ const codexJson = Schema.fromJsonString(CodexPluginManifest);
21566
+ const marketJson = Schema.fromJsonString(PluginMarketplace);
21567
+ const hooksJson = Schema.fromJsonString(HooksFile);
21568
+ /**
21569
+ * Keep the checked release under this origin's own marketplace and plugin identity, with its
21570
+ * hooks, server and Codex status skill named for it.
21571
+ */
21572
+ const keepOriginPlugin = Effect.fnUntraced(function* (source, home) {
21573
+ const fs = yield* FileSystem.FileSystem;
21574
+ const path = yield* Path.Path;
21575
+ const { api } = yield* Ref0Config;
21576
+ const name = yield* originPluginName(api);
21577
+ const directory = path.join(home, ".ref0", "origins", originKey(api), "plugins");
21578
+ yield* fs.makeDirectory(directory, {
21579
+ recursive: true,
21580
+ mode: 448
21581
+ });
21582
+ const folder = yield* fs.makeTempDirectory({
21583
+ directory,
21584
+ prefix: "release-"
21585
+ });
21586
+ yield* fs.copy(source, folder, { overwrite: true });
21587
+ const manifestFile = path.join(folder, "ref0/.claude-plugin/plugin.json");
21588
+ const manifest = yield* fs.readFileString(manifestFile).pipe(Effect.flatMap(Schema.decodeUnknownEffect(manifestJson)));
21589
+ yield* fs.writeFileString(manifestFile, yield* Schema.encodeEffect(manifestJson)({
21590
+ ...manifest,
21591
+ name
21592
+ }));
21593
+ const codexFile = path.join(folder, "ref0/.codex-plugin/plugin.json");
21594
+ const codex = yield* fs.readFileString(codexFile).pipe(Effect.flatMap(Schema.decodeUnknownEffect(codexJson)));
21595
+ yield* fs.writeFileString(codexFile, yield* Schema.encodeEffect(codexJson)({
21596
+ ...codex,
21597
+ name
21598
+ }));
21599
+ yield* fs.writeFileString(path.join(folder, ".claude-plugin/marketplace.json"), yield* Schema.encodeEffect(marketJson)({
21600
+ name,
21601
+ owner: { name: "ref0" },
21602
+ plugins: [{
21603
+ name,
21604
+ source: "./ref0",
21605
+ description: manifest.description
21606
+ }]
21607
+ }));
21608
+ for (const [file, server] of [["hooks/hooks.json", `plugin:${name}:${name}`], ["hooks/codex-hooks.json", name]]) {
21609
+ const target = path.join(folder, "ref0", file);
21610
+ const hooks = yield* fs.readFileString(target).pipe(Effect.flatMap(Schema.decodeUnknownEffect(hooksJson)));
21611
+ const scoped = Object.fromEntries(Object.entries(hooks.hooks).map(([event, groups]) => [event, groups.map((group) => ({
21612
+ ...group,
21613
+ hooks: group.hooks.map((hook) => hook.type === "mcp_tool" ? {
21614
+ ...hook,
21615
+ server
21616
+ } : hook)
21617
+ }))]));
21618
+ yield* fs.writeFileString(target, encodeHooks({ hooks: scoped }));
21619
+ }
21620
+ for (const file of [lookupScriptFile(manifest.version, api), mcpFile(manifest.version, api, name)]) yield* fs.writeFileString(path.join(folder, file.path), file.text);
21621
+ const skillFile = path.join(folder, "ref0", codexStatusSkillPath);
21622
+ if (yield* fs.exists(skillFile)) {
21623
+ const skill = yield* fs.readFileString(skillFile);
21624
+ yield* fs.writeFileString(skillFile, skill.replaceAll(codexStatusShortcut("ref0"), codexStatusShortcut(name)));
21625
+ }
21626
+ return {
21627
+ folder,
21628
+ name,
21629
+ id: `${name}@${name}`
21630
+ };
20831
21631
  });
20832
21632
  //#endregion
20833
21633
  //#region src/harness/marketplace.ts
@@ -20837,49 +21637,45 @@ const decodeCodexPluginManifest = Schema.decodeUnknownEffect(Schema.fromJsonStri
20837
21637
  const decodePluginManifest = Schema.decodeUnknownEffect(Schema.fromJsonString(PluginManifest));
20838
21638
  const decodePluginMarketplace = Schema.decodeUnknownEffect(Schema.fromJsonString(PluginMarketplace));
20839
21639
  const decodeHooksFile = Schema.decodeUnknownEffect(Schema.fromJsonString(HooksFile));
20840
- var InstallFailed = class extends Schema.TaggedError()("InstallFailed", { nextStep: Schema.String }) {};
20841
- /** Fetch and inspect the public marketplace with the harness, before installing its plugin. */
20842
- const marketplaceFor = Effect.fnUntraced(function* (harness, options, again) {
21640
+ /**
21641
+ * Why ref0's plugin isn't installed in a config folder, and anything to fix first. Then the
21642
+ * folder's install runs again, or, for a plugin newer than this `ref0`, `run` updates `ref0`.
21643
+ */
21644
+ var InstallFailed = class extends Schema.TaggedError()("InstallFailed", {
21645
+ says: Schema.String,
21646
+ run: Schema.optionalKey(Schema.String)
21647
+ }) {};
21648
+ /** The one command that repairs a folder's install. */
21649
+ const repairOf = (failed, configFolder) => failed.run ?? installLine(configFolder);
21650
+ /**
21651
+ * Fetch and inspect the public marketplace with the harness, before installing its plugin. The
21652
+ * fetch runs in a config folder of its own: the public marketplace's name, `ref0`, is ref0.dev's
21653
+ * own installed marketplace's too, and Codex's fetch would upgrade an installed plugin. Every
21654
+ * folder then installs from the release ref0 checked.
21655
+ */
21656
+ const marketplaceFor = Effect.fnUntraced(function* (harness, options) {
20843
21657
  const fs = yield* FileSystem.FileSystem;
20844
21658
  const path = yield* Path.Path;
20845
21659
  const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
20846
- const unavailable = new InstallFailed({ nextStep: `Couldn’t read ref0’s plugin from ref-0/plugins. Check your network; if the marketplace has no plugin yet, wait for ref0’s next release. Then run: ${again}` });
20847
- const binary = harness === "codex" ? "codex" : "claude";
20848
- const foreign = new InstallFailed({ nextStep: `This setup has a marketplace named ref0 from another source. Check it with: ${binary} plugin marketplace list. Remove it with: ${binary} plugin marketplace remove ref0. Then run: ${again}` });
20849
- const installEnv = {};
20850
- let checkedRevision;
20851
- const env = {
21660
+ const unavailable = new InstallFailed({ says: "Couldn’t read ref0’s plugin from ref-0/plugins. Check your network; if the marketplace has no plugin yet, wait for ref0’s next release." });
21661
+ const variable = harness === "codex" ? "CODEX_HOME" : "CLAUDE_CONFIG_DIR";
21662
+ const envFor = (configFolder) => ({
20852
21663
  HOME: options.home,
20853
- [harness === "codex" ? "CODEX_HOME" : "CLAUDE_CONFIG_DIR"]: options.configFolder
21664
+ [variable]: configFolder
21665
+ });
21666
+ const prepare = (configFolder) => fs.makeDirectory(configFolder, { recursive: true }).pipe(Effect.mapError(() => new InstallFailed({ says: "Couldn’t prepare Codex’s config folder. Check that it’s writable." })));
21667
+ const probe = yield* fs.makeTempDirectoryScoped().pipe(Effect.mapError(() => unavailable));
21668
+ const fetchEnv = {
21669
+ HOME: probe,
21670
+ [variable]: path.join(probe, "config")
20854
21671
  };
20855
- let fetchEnv = env;
20856
- if (harness === "codex") {
20857
- yield* fs.makeDirectory(options.configFolder, { recursive: true }).pipe(Effect.mapError(() => new InstallFailed({ nextStep: `Couldn’t prepare Codex’s config folder. Check that it’s writable, then run: ${again}` })));
20858
- const probe = yield* fs.makeTempDirectoryScoped().pipe(Effect.mapError(() => unavailable));
20859
- fetchEnv = {
20860
- HOME: probe,
20861
- CODEX_HOME: path.join(probe, "config")
20862
- };
20863
- yield* fs.makeDirectory(fetchEnv["CODEX_HOME"] ?? probe).pipe(Effect.mapError(() => unavailable));
20864
- }
20865
- const run = (args, stage = "fetch") => {
21672
+ yield* fs.makeDirectory(path.join(probe, "config")).pipe(Effect.mapError(() => unavailable));
21673
+ const run = (args, into) => {
20866
21674
  const name = harness === "codex" ? "Codex" : "Claude Code";
20867
- const operation = stage === "install" ? "install ref0’s plugin" : "read its marketplaces";
20868
- const failed = (reason, detail = "") => stage === "fetch" ? unavailable : new InstallFailed({ nextStep: `${name} ${reason}.${detail === "" ? "" : ` ${detail}`} Then run: ${again}` });
21675
+ const failed = (reason, detail = "") => into === void 0 ? unavailable : new InstallFailed({ says: `${name} ${reason}.${detail === "" ? "" : ` ${detail}`}` });
20869
21676
  return Effect.scoped(Effect.gen(function* () {
20870
- const handle = yield* spawner.spawn(ChildProcess.make(options.command, harness === "codex" && stage === "install" && checkedRevision !== void 0 ? [
20871
- "--config",
20872
- `marketplaces.ref0.ref="${checkedRevision}"`,
20873
- ...args
20874
- ] : args, {
20875
- env: {
20876
- fetch: fetchEnv,
20877
- inspect: env,
20878
- install: {
20879
- ...env,
20880
- ...installEnv
20881
- }
20882
- }[stage],
21677
+ const handle = yield* spawner.spawn(ChildProcess.make(options.command, args, {
21678
+ env: into === void 0 ? fetchEnv : envFor(into),
20883
21679
  extendEnv: true
20884
21680
  }));
20885
21681
  const [stdout, stderr, exit] = yield* Effect.all([
@@ -20887,47 +21683,38 @@ const marketplaceFor = Effect.fnUntraced(function* (harness, options, again) {
20887
21683
  handle.stderr.pipe(Stream.decodeText(), Stream.splitLines, Stream.runFold(() => Option.none(), (first, line) => Option.isSome(first) ? first : Option.some(line))),
20888
21684
  handle.exitCode
20889
21685
  ], { concurrency: "unbounded" });
20890
- if (exit !== ChildProcessSpawner.ExitCode(0)) return yield* failed(`refused to ${operation}`, Option.getOrElse(stderr, () => ""));
21686
+ if (exit !== ChildProcessSpawner.ExitCode(0)) return yield* failed("refused to install ref0’s plugin", Option.getOrElse(stderr, () => ""));
20891
21687
  return stdout;
20892
21688
  })).pipe(Effect.timeout("2 minutes"), Effect.catchTags({
20893
- PlatformError: () => Effect.fail(failed(`couldn’t ${operation}`)),
20894
- TimeoutError: () => Effect.fail(failed(`timed out trying to ${operation}`))
21689
+ PlatformError: () => Effect.fail(failed("couldn’t install ref0’s plugin")),
21690
+ TimeoutError: () => Effect.fail(failed("timed out trying to install ref0’s plugin"))
20895
21691
  }));
20896
21692
  };
20897
- const list = Effect.fnUntraced(function* (stage = "fetch") {
21693
+ const fetchedRoot = Effect.fnUntraced(function* () {
20898
21694
  const text = yield* run([
20899
21695
  "plugin",
20900
21696
  "marketplace",
20901
21697
  "list",
20902
21698
  "--json"
20903
- ], stage);
21699
+ ]);
20904
21700
  if (harness === "claude-code") {
20905
21701
  const found = (yield* decodeClaudeMarketplacesListed(text).pipe(Effect.mapError(() => unavailable))).find((entry) => entry.name === "ref0");
20906
- if (found === void 0) return Option.none();
20907
- if (found.source !== "github" || found.repo !== "ref-0/plugins") return yield* foreign;
20908
- return Option.some(found.installLocation);
21702
+ if (found?.source !== "github" || found.repo !== "ref-0/plugins") return yield* unavailable;
21703
+ return found.installLocation;
20909
21704
  }
20910
21705
  const found = (yield* decodeCodexMarketplacesListed(text).pipe(Effect.mapError(() => unavailable))).marketplaces.find((entry) => entry.name === "ref0");
20911
- if (found === void 0) return Option.none();
20912
- if (found.marketplaceSource?.sourceType !== "git" || found.marketplaceSource.source !== "https://github.com/ref-0/plugins.git") return yield* foreign;
20913
- return Option.some(found.root);
21706
+ if (found?.marketplaceSource?.sourceType !== "git" || found.marketplaceSource.source !== "https://github.com/ref-0/plugins.git") return yield* unavailable;
21707
+ return found.root;
20914
21708
  });
20915
- const existing = yield* list("inspect");
20916
- yield* run(harness === "codex" || Option.isNone(existing) ? [
21709
+ yield* run([
20917
21710
  "plugin",
20918
21711
  "marketplace",
20919
21712
  "add",
20920
21713
  "ref-0/plugins"
20921
- ] : [
20922
- "plugin",
20923
- "marketplace",
20924
- "update",
20925
- "ref0"
20926
21714
  ]);
20927
- const fetched = yield* list();
20928
- if (Option.isNone(fetched)) return yield* unavailable;
20929
- let folder = yield* Effect.gen(function* () {
20930
- let root = yield* fs.realPath(fetched.value);
21715
+ const fetched = yield* fetchedRoot();
21716
+ const folder = yield* Effect.gen(function* () {
21717
+ let root = yield* fs.realPath(fetched);
20931
21718
  {
20932
21719
  const temp = yield* fs.makeTempDirectoryScoped();
20933
21720
  const snapshot = path.join(temp, "marketplace");
@@ -20949,30 +21736,6 @@ const marketplaceFor = Effect.fnUntraced(function* (harness, options, again) {
20949
21736
  extendEnv: true
20950
21737
  })).pipe(Effect.timeout("2 minutes"), Effect.catchCause(() => Effect.fail(unavailable)))) !== ChildProcessSpawner.ExitCode(0)) return yield* unavailable;
20951
21738
  root = yield* fs.realPath(snapshot);
20952
- if (harness === "codex") {
20953
- const revision = (yield* spawner.string(ChildProcess.make("git", [
20954
- "-C",
20955
- snapshot,
20956
- "rev-parse",
20957
- "HEAD"
20958
- ]))).trim();
20959
- if (!/^[a-f0-9]{40,64}$/.test(revision)) return yield* unavailable;
20960
- checkedRevision = revision;
20961
- }
20962
- const url = new URL("file:///");
20963
- url.pathname = snapshot;
20964
- Object.assign(installEnv, {
20965
- GIT_CONFIG_GLOBAL: "/dev/null",
20966
- GIT_CONFIG_NOSYSTEM: "1",
20967
- GIT_CONFIG_PARAMETERS: "",
20968
- GIT_CONFIG_COUNT: "3",
20969
- GIT_CONFIG_KEY_0: `url.${url.href}.insteadOf`,
20970
- GIT_CONFIG_VALUE_0: "https://github.com/ref-0/plugins.git",
20971
- GIT_CONFIG_KEY_1: `url.${url.href}.insteadOf`,
20972
- GIT_CONFIG_VALUE_1: "git@github.com:ref-0/plugins.git",
20973
- GIT_CONFIG_KEY_2: `url.${url.href}.insteadOf`,
20974
- GIT_CONFIG_VALUE_2: "ssh://git@github.com/ref-0/plugins.git"
20975
- });
20976
21739
  }
20977
21740
  const market = yield* fs.readFileString(path.join(root, ".claude-plugin/marketplace.json")).pipe(Effect.flatMap(decodePluginMarketplace));
20978
21741
  const plugin = market.plugins.find((entry) => entry.name === "ref0");
@@ -20990,63 +21753,48 @@ const marketplaceFor = Effect.fnUntraced(function* (harness, options, again) {
20990
21753
  const declarations = Object.values(hooks.hooks).flatMap((groups) => groups.flatMap((group) => group.hooks));
20991
21754
  if (protocols.length === 0 || protocols.length !== declarations.length || protocols.some((protocol) => !/^\d+$/.test(protocol))) return yield* unavailable;
20992
21755
  for (const protocol of protocols) {
20993
- const mismatch = updateLine({
21756
+ const mismatch = protocolMismatch({
20994
21757
  protocol,
20995
21758
  version: Option.some(manifest.version)
20996
- }, again);
20997
- if (Option.isSome(mismatch)) return yield* new InstallFailed({ nextStep: mismatch.value });
21759
+ }, installLine(options.configFolder));
21760
+ if (Option.isNone(mismatch)) continue;
21761
+ const { says, run } = mismatch.value;
21762
+ return yield* Number(protocol) > 1 ? new InstallFailed({
21763
+ says: `${says}.`,
21764
+ run
21765
+ }) : new InstallFailed({ says: `${says}.` });
20998
21766
  }
20999
- return fetched.value;
21767
+ return root;
21000
21768
  }).pipe(Effect.catchTags({
21001
21769
  PlatformError: () => Effect.fail(unavailable),
21002
21770
  SchemaError: () => Effect.fail(unavailable)
21003
21771
  }));
21004
- if (harness === "codex") {
21005
- yield* run(Option.isNone(existing) ? [
21772
+ const scoped = yield* keepOriginPlugin(folder, options.home).pipe(Effect.mapError(() => unavailable));
21773
+ const into = Effect.fnUntraced(function* (configFolder) {
21774
+ if (harness === "codex") yield* prepare(configFolder);
21775
+ const runIn = (args) => run(args, configFolder);
21776
+ yield* runIn([
21006
21777
  "plugin",
21007
21778
  "marketplace",
21008
- "add",
21009
- "ref-0/plugins"
21010
- ] : [
21779
+ "remove",
21780
+ scoped.name
21781
+ ]).pipe(Effect.ignore);
21782
+ yield* runIn([
21011
21783
  "plugin",
21012
21784
  "marketplace",
21013
- "upgrade",
21014
- "ref0"
21015
- ], "install");
21016
- const selected = yield* list("install");
21017
- if (Option.isNone(selected)) return yield* unavailable;
21018
- folder = selected.value;
21019
- }
21785
+ "add",
21786
+ scoped.folder
21787
+ ]);
21788
+ return runIn;
21789
+ });
21020
21790
  return {
21021
- folder,
21022
- run: (args) => run(args, "install")
21791
+ folder: scoped.folder,
21792
+ name: scoped.name,
21793
+ id: scoped.id,
21794
+ into
21023
21795
  };
21024
21796
  });
21025
21797
  //#endregion
21026
- //#region src/harness/install-claude-code.ts
21027
- /** Install from the public marketplace fetched and checked by Claude Code itself. */
21028
- const installClaudeCode = Effect.fnUntraced(function* (options) {
21029
- const { folder, run } = yield* marketplaceFor("claude-code", {
21030
- ...options,
21031
- command: options.claude
21032
- }, installLine(options.configFolder));
21033
- yield* run([
21034
- "plugin",
21035
- "install",
21036
- "ref0@ref0",
21037
- "--scope",
21038
- "user"
21039
- ]);
21040
- yield* run([
21041
- "plugin",
21042
- "update",
21043
- "ref0@ref0",
21044
- "--scope",
21045
- "user"
21046
- ]);
21047
- return folder;
21048
- }, Effect.scoped);
21049
- //#endregion
21050
21798
  //#region src/harness/codex-app-server.ts
21051
21799
  /**
21052
21800
  * Codex's app server, spoken as its TUI speaks it: `codex app-server` started for one exchange,
@@ -21089,8 +21837,10 @@ const opening = [{
21089
21837
  * Codex's answers to `lines`, by ID, or none if it didn't answer each of `ids`. Each ID's first
21090
21838
  * answer is its own: a repeat, or an answer to an ID ref0 didn't send, is passed over.
21091
21839
  */
21092
- const exchange = (codex, lines, ids) => Effect.scoped(Effect.gen(function* () {
21840
+ const exchange = (codex, lines, ids, configFolder) => Effect.scoped(Effect.gen(function* () {
21093
21841
  const answered = yield* (yield* (yield* ChildProcessSpawner.ChildProcessSpawner).spawn(ChildProcess.make(codex, ["app-server"], {
21842
+ env: configFolder === void 0 ? void 0 : { CODEX_HOME: configFolder },
21843
+ extendEnv: true,
21094
21844
  forceKillAfter: "2 seconds",
21095
21845
  stdin: {
21096
21846
  stream: Stream.fromIterable(lines.map((line) => `${line}\n`)).pipe(Stream.encodeText),
@@ -21117,7 +21867,7 @@ const resultOf = (method, answer) => {
21117
21867
  * asked with `params`, which TypeScript holds to the method's own. Its answer is the one with
21118
21868
  * its request's ID, and its result is read by the method's result schema.
21119
21869
  */
21120
- const askCodex = (codex, method, params) => Effect.gen(function* () {
21870
+ const askCodex = (codex, method, params, configFolder) => Effect.gen(function* () {
21121
21871
  const asked = {
21122
21872
  jsonrpc: "2.0",
21123
21873
  id: askedId,
@@ -21125,102 +21875,12 @@ const askCodex = (codex, method, params) => Effect.gen(function* () {
21125
21875
  params
21126
21876
  };
21127
21877
  const lines = yield* Effect.forEach([...opening, asked], (request) => encodeRequest(request).pipe(Effect.orDie));
21128
- const answers = yield* exchange(codex, lines, [initializeId, askedId]);
21878
+ const answers = yield* exchange(codex, lines, [initializeId, askedId], configFolder);
21129
21879
  if (Option.isNone(answers)) return yield* new CodexUnanswered({ method: method.method });
21130
21880
  yield* resultOf(codexMethods.initialize, answers.value.get(initializeId));
21131
21881
  return yield* resultOf(method, answers.value.get(askedId));
21132
21882
  });
21133
21883
  //#endregion
21134
- //#region src/harness/hook-fields.ts
21135
- /**
21136
- * The same for Codex 0.160.0, which skips a hook outright if its template names a field the event
21137
- * lacks, so each list holds only fields every hook of its event carried in the prototypes, and
21138
- * only events they saw, each a `HookEvent`. Codex puts the model in every hook but `SessionEnd`.
21139
- * Its `UserPromptSubmit` carries the prompt (`spikes/harness/results/codex/catalog.json`), which
21140
- * no list names, as for Claude Code.
21141
- * Every Codex hook carries `transcript_path`, the session's rollout, `null` for a session kept
21142
- * nowhere (Codex's hook input schemas, 0.160.0 to 0.163.0): its turns' tokens are read there.
21143
- * A subagent's are read from its own rollout, which its `SubagentStop` names, with the
21144
- * subagent's own turn as its `turn_id` (Codex 0.163.0's subagents, 20 Oct 2026).
21145
- */
21146
- const codexHookFields = {
21147
- UserPromptSubmit: [
21148
- "session_id",
21149
- "turn_id",
21150
- "cwd",
21151
- "model",
21152
- "transcript_path"
21153
- ],
21154
- PreToolUse: [
21155
- "session_id",
21156
- "turn_id",
21157
- "cwd",
21158
- "model",
21159
- "tool_name",
21160
- "tool_use_id",
21161
- "tool_input"
21162
- ],
21163
- PostToolUse: [
21164
- "session_id",
21165
- "turn_id",
21166
- "cwd",
21167
- "model",
21168
- "tool_name",
21169
- "tool_use_id",
21170
- "tool_input",
21171
- "tool_response"
21172
- ],
21173
- Stop: [
21174
- "session_id",
21175
- "turn_id",
21176
- "model",
21177
- "stop_hook_active",
21178
- "transcript_path"
21179
- ],
21180
- SubagentStop: [
21181
- "session_id",
21182
- "turn_id",
21183
- "agent_id",
21184
- "agent_type",
21185
- "stop_hook_active",
21186
- "agent_transcript_path"
21187
- ]
21188
- };
21189
- Schema.encodeSync(Schema.fromJsonString(CodexPluginManifest));
21190
- /** How Codex names a plugin's MCP server in an `mcp_tool` hook. */
21191
- const codexServer = "ref0";
21192
- /** Codex's hooks file, by its path inside the plugin. */
21193
- const codexHooksPath = "hooks/codex-hooks.json";
21194
- ({ ...mcpToolHooks(codexServer, codexHookFields) });
21195
- /**
21196
- * The folder of Codex's skills, by its path inside the plugin. Not `skills`, which Claude Code
21197
- * reads too, where a `status` skill would be a second `/ref0:status`. Codex 0.163.0 skips
21198
- * Claude Code's `commands/status.md`, since its `!` line is a template it can't run.
21199
- */
21200
- const codexSkillsPath = "codex-skills";
21201
- /** The skill's placeholder for the plugin's folder, which Codex's agent fills in. */
21202
- const pluginFolder = "<plugin>";
21203
- /** What `$ref0:status` runs: `ref0 status`, through the plugin's lookup script. */
21204
- const codexStatusLine = throughLookup([harnessCommandNames.status]).replaceAll(pluginRoot, pluginFolder);
21205
- [
21206
- "---",
21207
- "name: status",
21208
- "description: Shows whether ref0 follows this Codex session, and what to do next if it doesn’t. Use when the person runs $ref0:status or asks whether ref0 is working.",
21209
- "---",
21210
- "",
21211
- "Run ref0’s check and show the person what it says.",
21212
- "",
21213
- `1. This file is \`${pluginFolder}/${codexSkillsPath}/status/SKILL.md\`. Run this command, with \`${pluginFolder}\` replaced by that folder’s absolute path:`,
21214
- "",
21215
- " ```sh",
21216
- ` ${codexStatusLine}`,
21217
- " ```",
21218
- "",
21219
- " Run it outside Codex’s sandbox, with `sandbox_permissions: \"require_escalated\"`, since in the sandbox it can’t ask Codex, this machine’s keychain or ref0. If that isn’t allowed, run it in the sandbox.",
21220
- "2. Show every line it prints as it is, and add nothing. It exits 1 when a line says no: that’s its answer, not an error to fix.",
21221
- ""
21222
- ].join("\n");
21223
- //#endregion
21224
21884
  //#region src/harness/install-codex.ts
21225
21885
  /**
21226
21886
  * Install from ref-0/plugins after Codex fetches it and ref0 checks its protocol. Codex runs
@@ -21229,7 +21889,7 @@ const codexStatusLine = throughLookup([harnessCommandNames.status]).replaceAll(p
21229
21889
  * asks again. Marketplace commands were measured with Codex 0.163.0 on 23 Oct 2026.
21230
21890
  */
21231
21891
  /** Codex names a plugin's hooks by the plugin, then the file, event and place. */
21232
- const pluginHook = (hook) => hook.pluginId === "ref0@ref0" && hook.key.startsWith("ref0@ref0:");
21892
+ const pluginHook = (hook, id) => hook.pluginId === id && hook.key.startsWith(`${id}:`);
21233
21893
  /** The plugin's two command hooks, as declared. */
21234
21894
  const ownCommands = [sessionStartHook, sessionEndHook].flatMap((group) => group.hooks.map((hook) => hook.command));
21235
21895
  /**
@@ -21245,7 +21905,7 @@ const ownCommand = (hook) => {
21245
21905
  * Whether a hook is one ref0 wrote: one of its two commands, or ref0's `hook` tool on its own
21246
21906
  * server. Anything else under ref0's name isn't trusted.
21247
21907
  */
21248
- const ownHook = (hook) => hook.handlerType === "command" && ownCommand(hook) || hook.handlerType === "mcpTool" && hook.server === "ref0" && hook.tool === "hook";
21908
+ const ownHook = (hook, server) => hook.handlerType === "command" && ownCommand(hook) || hook.handlerType === "mcpTool" && hook.server === server && hook.tool === "hook";
21249
21909
  /** Whether Codex waits for the person to trust a hook before it runs it: new, or changed. */
21250
21910
  const waitingForTrust = (hook) => hook.trustStatus === "untrusted" || hook.trustStatus === "modified";
21251
21911
  /**
@@ -21255,7 +21915,7 @@ const waitingForTrust = (hook) => hook.trustStatus === "untrusted" || hook.trust
21255
21915
  * so "Trust all and continue", like `t` on `/hooks`' list, would trust those too. Its review names
21256
21916
  * each hook's source, which tells ref0's apart from a repo's made to look like them.
21257
21917
  */
21258
- const trustRef0Hooks = (reinstall) => `In a terminal, run: ${reinstall}, and say yes. Or, where a new Codex session says "Hooks need review", choose "Review hooks" and trust each hook whose source is "Plugin - ref0@ref0", not "Trust all and continue", which trusts every hook waiting.`;
21918
+ const trustRef0Hooks = (reinstall, id) => `In a terminal, run: ${reinstall}, and say yes. Or, where a new Codex session says "Hooks need review", choose "Review hooks" and trust each hook whose source is "Plugin - ${id}", not "Trust all and continue", which trusts every hook waiting.`;
21259
21919
  /**
21260
21920
  * The "ref0" permission profile, decided 9 Oct (5): Codex's workspace profile, with each
21261
21921
  * workspace root's `.git` writable and the network on, so a Codex agent can commit, push, and
@@ -21277,21 +21937,18 @@ const profileSet = (read) => {
21277
21937
  const roots = profile?.filesystem?.[":workspace_roots"];
21278
21938
  return read.config.default_permissions === "ref0" && profile?.extends === ref0Profile.extends && isPathModes(roots) && roots[".git"] === "write" && profile.network?.enabled === true;
21279
21939
  };
21280
- const installCodex = Effect.fnUntraced(function* (options) {
21281
- const again = installLine(options.configFolder);
21282
- const failed = (what) => new InstallFailed({ nextStep: `Couldn’t ${what}. Check that Codex is installed, then run: ${again}` });
21283
- const { folder, run } = yield* marketplaceFor("codex", {
21284
- ...options,
21285
- command: options.codex
21286
- }, again);
21287
- yield* run([
21940
+ /** Install into one config folder, from the release Codex fetched and ref0 checked. */
21941
+ const installCodex = Effect.fnUntraced(function* (release, options) {
21942
+ const failed = (what) => new InstallFailed({ says: `Couldn’t ${what}. Check that Codex is installed.` });
21943
+ const { folder, id, name } = release;
21944
+ yield* (yield* release.into(options.configFolder))([
21288
21945
  "plugin",
21289
21946
  "add",
21290
- "ref0@ref0"
21947
+ id
21291
21948
  ]);
21292
- const hooks = (yield* askCodex(options.codex, codexMethods.hooksList, { cwds: [options.home] }).pipe(Effect.mapError(() => failed("read the plugin’s hooks from Codex")))).data.flatMap((entry) => entry.hooks).filter(pluginHook);
21949
+ const hooks = (yield* askCodex(options.codex, codexMethods.hooksList, { cwds: [options.home] }, options.configFolder).pipe(Effect.mapError(() => failed("read the plugin’s hooks from Codex")))).data.flatMap((entry) => entry.hooks).filter((hook) => pluginHook(hook, id));
21293
21950
  if (hooks.length === 0) return yield* failed("read the plugin’s hooks from Codex");
21294
- if (!hooks.every(ownHook)) return yield* new InstallFailed({ nextStep: `Codex’s ref0 plugin holds hooks ref0 didn’t write, so none was trusted. Remove it with: codex plugin remove ref0@ref0, then run: ${again}` });
21951
+ if (!hooks.every((hook) => ownHook(hook, name))) return yield* new InstallFailed({ says: `Codex’s ref0 plugin holds hooks ref0 didn’t write, so none was trusted. Remove it with: codex plugin remove ${id}.` });
21295
21952
  const trust = Effect.fnUntraced(function* (waiting) {
21296
21953
  if (!(yield* options.approve(waiting))) return false;
21297
21954
  yield* askCodex(options.codex, codexMethods.configBatchWrite, {
@@ -21303,14 +21960,14 @@ const installCodex = Effect.fnUntraced(function* (options) {
21303
21960
  filePath: null,
21304
21961
  expectedVersion: null,
21305
21962
  reloadUserConfig: false
21306
- }).pipe(Effect.mapError(() => failed("record the hooks as trusted in Codex")));
21963
+ }, options.configFolder).pipe(Effect.mapError(() => failed("record the hooks as trusted in Codex")));
21307
21964
  return true;
21308
21965
  });
21309
21966
  const offerProfile = Effect.fnUntraced(function* () {
21310
21967
  const config = yield* askCodex(options.codex, codexMethods.configRead, {
21311
21968
  includeLayers: false,
21312
21969
  cwd: null
21313
- }).pipe(Effect.option);
21970
+ }, options.configFolder).pipe(Effect.option);
21314
21971
  if (Option.isSome(config) && (profileSet(config.value) || enough(config.value))) return true;
21315
21972
  const replacing = Option.match(config, {
21316
21973
  onNone: () => ({
@@ -21336,7 +21993,7 @@ const installCodex = Effect.fnUntraced(function* (options) {
21336
21993
  filePath: null,
21337
21994
  expectedVersion: null,
21338
21995
  reloadUserConfig: false
21339
- }).pipe(Effect.mapError(() => failed("set ref0’s profile in Codex")));
21996
+ }, options.configFolder).pipe(Effect.mapError(() => failed("set ref0’s profile in Codex")));
21340
21997
  return true;
21341
21998
  });
21342
21999
  const waiting = hooks.filter(waitingForTrust);
@@ -21345,19 +22002,9 @@ const installCodex = Effect.fnUntraced(function* (options) {
21345
22002
  trusted: waiting.length === 0 || (yield* trust(waiting)),
21346
22003
  profile: yield* offerProfile()
21347
22004
  };
21348
- }, Effect.scoped);
22005
+ });
21349
22006
  //#endregion
21350
22007
  //#region src/harness/install.ts
21351
- /**
21352
- * Installing ref0's plugin into a harness for an agent this machine is connected to: what
21353
- * `ref0 connect --harness claude-code` does once its host grant is kept, and what
21354
- * `ref0 install` does again from that grant, so an install that failed never needs a new code.
21355
- * The keychain record supplies the identity to the plugin's hooks and server. Installation
21356
- * writes `~/.ref0/bin/ref0` with the absolute Node and CLI paths, where the plugin
21357
- * looks when neither is on PATH.
21358
- */
21359
- /** The harnesses a plugin installs into, as a flag names them. */
21360
- const harnessFlag = Flag.Literals("harness", ConnectHarness.literals);
21361
22008
  /** A command on this machine's `PATH`, by absolute path. */
21362
22009
  const onPath = Effect.fnUntraced(function* (name) {
21363
22010
  const fs = yield* FileSystem.FileSystem;
@@ -21377,10 +22024,10 @@ const runs$2 = (hook) => hook.handlerType === "command" ? printable(hook.command
21377
22024
  * else the answer is no, and the hooks stay off until the person runs it again or trusts them in
21378
22025
  * Codex itself.
21379
22026
  */
21380
- const askToTrust = (hooks, asked) => Effect.gen(function* () {
22027
+ const askToTrust = (hooks, shown, asked) => Effect.gen(function* () {
21381
22028
  const terminal = process.stdin.isTTY === true && process.stdout.isTTY === true && process.stderr.isTTY === true;
21382
22029
  if (!asked || !terminal) return false;
21383
- yield* Console.error(["Codex runs a plugin's hooks only once you trust them. ref0's are:", ...hooks.map((hook) => `- ${hook.eventName}: ${runs$2(hook)}`)].join("\n"));
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"));
21384
22031
  return yield* Prompt.run(Prompt.Confirm({
21385
22032
  message: "Trust them, so Codex runs them?",
21386
22033
  initial: false
@@ -21391,7 +22038,7 @@ const askToTrust = (hooks, asked) => Effect.gen(function* () {
21391
22038
  * Codex's default: only in a terminal, and never for `--json`, as with the hooks. A change to
21392
22039
  * their Codex's security, so it's shown in full and asked the first time (decided 9 Oct, 5).
21393
22040
  */
21394
- const askForProfile = (replacing, asked) => Effect.gen(function* () {
22041
+ const askForProfile = (replacing, shown, asked) => Effect.gen(function* () {
21395
22042
  const terminal = process.stdin.isTTY === true && process.stdout.isTTY === true && process.stderr.isTTY === true;
21396
22043
  if (!asked || !terminal) return false;
21397
22044
  yield* Console.error([
@@ -21404,70 +22051,132 @@ const askForProfile = (replacing, asked) => Effect.gen(function* () {
21404
22051
  ...replacing.ownRef0 ? ["It replaces the profile you have named \"ref0\", and every rule in it."] : []
21405
22052
  ].join("\n"));
21406
22053
  return yield* Prompt.run(Prompt.Confirm({
21407
- message: "Make \"ref0\" your Codex's default profile?",
22054
+ message: `Make "ref0" the default profile for ${printable(shown)}?`,
21408
22055
  initial: false
21409
22056
  }));
21410
22057
  }).pipe(Effect.orElseSucceed(() => false));
22058
+ /** What a Codex folder without ref0's profile, or full access, can't do. */
22059
+ 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.";
21411
22060
  /**
21412
22061
  * Writes the plugin's fallback launcher with absolute Node and CLI paths, so it runs even
21413
22062
  * when neither is on the harness's PATH. Another connect replaces the launcher atomically.
21414
22063
  */
21415
- const writeRef0Launcher = Effect.fnUntraced(function* (home) {
22064
+ const writeRef0Launcher = Effect.fnUntraced(function* (home, git) {
21416
22065
  const fs = yield* FileSystem.FileSystem;
21417
22066
  const path = yield* Path.Path;
21418
22067
  const ref0 = process.argv[1];
21419
22068
  if (ref0 === void 0) return;
21420
22069
  const target = yield* fs.realPath(ref0);
21421
- const folder = path.join(home, ".ref0", "bin");
22070
+ const config = yield* Ref0Config;
22071
+ const folder = path.join(home, ".ref0", "origins", originKey(config.api), "bin");
21422
22072
  const link = path.join(folder, "ref0");
21423
22073
  const next = `${link}.next`;
21424
22074
  yield* fs.makeDirectory(folder, { recursive: true });
21425
22075
  yield* fs.remove(next, { force: true });
21426
22076
  const node = yield* fs.realPath(process.execPath);
21427
- yield* fs.writeFileString(next, `#!/bin/sh\nexec ${shellWord(node)} ${shellWord(target)} "$@"\n`);
22077
+ const gitPath = yield* fs.realPath(git);
22078
+ yield* fs.writeFileString(next, [
22079
+ "#!/bin/sh",
22080
+ "if [ \"$REF0_SELECT_ORIGIN\" = 1 ]; then",
22081
+ ` checkout=$(${shellWord(gitPath)} config --get ref0.api 2>/dev/null)`,
22082
+ " result=$?",
22083
+ " case \"$result\" in",
22084
+ " 0) REF0_SELECTED_API=$checkout ;;",
22085
+ ` 1) REF0_SELECTED_API=\${REF0_API:-${defaultOrigin}} ;;`,
22086
+ ` *) printf '%s\\n' ${shellWord("ref0: could not read the checkout origin with Git. Restore Git, then run ref0 install again.")} >&2; exit 1 ;;`,
22087
+ " esac",
22088
+ " export REF0_SELECTED_API",
22089
+ ` ${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`,
22090
+ "fi",
22091
+ "unset REF0_SELECT_ORIGIN REF0_SELECTED_API",
22092
+ `export REF0_API=${shellWord(config.api)}`,
22093
+ `exec ${shellWord(node)} ${shellWord(target)} "$@"`,
22094
+ ""
22095
+ ].join("\n"));
21428
22096
  yield* fs.chmod(next, 493);
21429
22097
  yield* fs.rename(next, link);
21430
22098
  });
21431
- /** Installs into a connected folder. The plugin reads its identity from the keychain. */
21432
- const installFor = Effect.fnUntraced(function* (_agent, harness, options) {
22099
+ /**
22100
+ * Installs into each connected folder: the launcher once, and the plugin fetched and checked once
22101
+ * for each harness, then added to each of that harness's folders. A folder whose install fails
22102
+ * leaves the rest installing, and answers its failure in its own place. The plugin reads each
22103
+ * folder's identity from the keychain.
22104
+ */
22105
+ const installInto = Effect.fnUntraced(function* (folders, options) {
21433
22106
  const home = yield* Config.String("HOME");
21434
- const agents = yield* ConfigFolders;
21435
- const configFolder = options.configFolder ?? (yield* agents.folder(harness));
21436
- const again = installLine(configFolder);
21437
- yield* writeRef0Launcher(home).pipe(Effect.mapError(() => new InstallFailed({ nextStep: `Couldn’t write ~/.ref0. Check that it’s writable, then run: ${again}` })));
21438
- if (harness === "claude-code") {
21439
- const claude = yield* onPath("claude");
21440
- if (Option.isNone(claude)) return yield* new InstallFailed({ nextStep: `Claude Code isn’t on this machine’s PATH. Install it, then run: ${again}` });
21441
- const folder = yield* installClaudeCode({
21442
- claude: claude.value,
21443
- home,
21444
- configFolder
21445
- });
21446
- return {
21447
- folder,
21448
- trusted: true,
21449
- words: `Installed in Claude Code, from ${folder}.`
21450
- };
21451
- }
21452
- const codex = yield* onPath("codex");
21453
- if (Option.isNone(codex)) return yield* new InstallFailed({ nextStep: `Codex isn’t on this machine’s PATH. Install it, then run: ${again}` });
22107
+ const name = yield* originPluginName((yield* Ref0Config).api);
21454
22108
  const terminal = yield* Effect.context();
21455
- const done = yield* installCodex({
21456
- codex: codex.value,
21457
- home,
21458
- configFolder,
21459
- approve: (hooks) => askToTrust(hooks, !options.asJson).pipe(Effect.provide(terminal)),
21460
- approveProfile: (replacing) => askForProfile(replacing, !options.asJson).pipe(Effect.provide(terminal))
22109
+ const answers = /* @__PURE__ */ new Map();
22110
+ const failAll = (into, failed) => {
22111
+ for (const folder of into) answers.set(folder, Result.fail(failed));
22112
+ };
22113
+ const launcher = yield* Effect.cached(Effect.gen(function* () {
22114
+ const git = yield* onPath("git");
22115
+ if (Option.isNone(git)) return yield* new InstallFailed({ says: "Git isn’t on this machine’s PATH. Install it." });
22116
+ yield* writeRef0Launcher(home, git.value).pipe(Effect.mapError(() => new InstallFailed({ says: "Couldn’t write ~/.ref0. Check that it’s writable." })));
22117
+ }));
22118
+ for (const harness of ConnectHarness.literals) {
22119
+ const into = folders.filter((folder) => folder.harness === harness);
22120
+ if (into.length === 0) continue;
22121
+ const command = yield* onPath(harness === "codex" ? "codex" : "claude");
22122
+ if (Option.isNone(command)) {
22123
+ failAll(into, new InstallFailed({ says: `${configFolders[harness].name} isn’t on this machine’s PATH. Install it.` }));
22124
+ continue;
22125
+ }
22126
+ yield* Effect.scoped(Effect.gen(function* () {
22127
+ yield* launcher;
22128
+ const release = yield* marketplaceFor(harness, {
22129
+ command: command.value,
22130
+ home,
22131
+ configFolder: into[0]?.configFolder ?? home
22132
+ });
22133
+ for (const folder of into) {
22134
+ const installed = yield* (harness === "claude-code" ? installedInClaudeCode(release, folder.configFolder) : installedInCodex(release, {
22135
+ codex: command.value,
22136
+ home,
22137
+ configFolder: folder.configFolder,
22138
+ name,
22139
+ asked: !options.asJson,
22140
+ terminal
22141
+ })).pipe(Effect.result);
22142
+ answers.set(folder, installed);
22143
+ }
22144
+ }).pipe(Effect.catchTag("InstallFailed", (failed) => Effect.sync(() => failAll(into, failed)))));
22145
+ }
22146
+ return folders.map((folder) => ({
22147
+ ...folder,
22148
+ installed: answers.get(folder) ?? Result.fail(new InstallFailed({ says: "ref0 didn’t install into this folder." }))
22149
+ }));
22150
+ });
22151
+ const installedInClaudeCode = Effect.fnUntraced(function* (release, configFolder) {
22152
+ const folder = yield* installClaudeCode(release, configFolder);
22153
+ return {
22154
+ folder,
22155
+ trusted: true,
22156
+ profile: true,
22157
+ words: `Installed in Claude Code, from ${folder}.`
22158
+ };
22159
+ });
22160
+ const installedInCodex = Effect.fnUntraced(function* (release, options) {
22161
+ const shown = shownConfigFolder(options.home, options.configFolder);
22162
+ const done = yield* installCodex(release, {
22163
+ codex: options.codex,
22164
+ home: options.home,
22165
+ 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))
21461
22168
  });
21462
- const sandbox = done.profile ? "" : " 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.";
21463
- const trust = trustRef0Hooks(again);
22169
+ const sandbox = done.profile ? "" : ` ${sandboxWords}`;
22170
+ const trust = trustRef0Hooks(installLine(options.configFolder), `${options.name}@${options.name}`);
21464
22171
  return done.trusted ? {
21465
22172
  folder: done.folder,
21466
22173
  trusted: true,
22174
+ profile: done.profile,
21467
22175
  words: `Installed in Codex, from ${done.folder}.${sandbox}`
21468
22176
  } : {
21469
22177
  folder: done.folder,
21470
22178
  trusted: false,
22179
+ profile: done.profile,
21471
22180
  words: `Installed in Codex, from ${done.folder}, with ref0’s hooks off until you trust them. ${trust}${sandbox}`,
21472
22181
  nextStep: trust
21473
22182
  };
@@ -21477,13 +22186,15 @@ const installCommands = [Command.make("install", { folder: Argument.String("fold
21477
22186
  const folder = Option.isSome(given) ? yield* folders.resolve(given.value) : yield* currentConfigFolder;
21478
22187
  const grant = yield* (yield* HostGrant).get(folder);
21479
22188
  if (Option.isNone(grant)) return yield* sayFailed(`This config folder, ${folder}, isn’t connected. Run ref0 connect.`);
21480
- const installed = yield* installFor(grant.value, grant.value.harness, {
21481
- asJson: false,
22189
+ const answers = yield* installInto([{
22190
+ harness: grant.value.harness,
21482
22191
  configFolder: folder
21483
- });
21484
- yield* printAnswer({ installed: installed.folder }, false, installed.words);
22192
+ }], { asJson: false });
22193
+ for (const { installed } of answers) {
22194
+ if (Result.isFailure(installed)) return yield* sayFailed([[`${installed.failure.says} Then run:`, { command: repairOf(installed.failure, folder) }]]);
22195
+ yield* printAnswer({ installed: installed.success.folder }, false, installed.success.words);
22196
+ }
21485
22197
  }).pipe(Effect.catchTags({
21486
- InstallFailed: (failed) => sayFailed(failed.nextStep),
21487
22198
  KeychainError: (failed) => sayFailed(failed.nextStep),
21488
22199
  ConfigError: () => sayFailed("HOME isn’t set, so ref0 has nowhere to keep the plugin.")
21489
22200
  }))).pipe(Command.withDescription("Installs ref0’s plugin into a connected config folder"), Command.withShortDescription("Install ref0’s plugin into a config folder"))];
@@ -21499,42 +22210,162 @@ var Tty = class Tty extends Context.Service()("ref0/cli/Tty") {
21499
22210
  //#endregion
21500
22211
  //#region src/commands/connect.ts
21501
22212
  /**
21502
- * A person connects from their own terminal, opens the printed link, and pastes their page's
21503
- * answer code. An agent's session and a process without a terminal cannot start it.
21504
- */
21505
- const harness = harnessFlag.pipe(Flag.optional, Flag.withDescription("Then install ref0’s plugin into this harness, for the agent"));
22213
+ * A person connects from their own terminal: ref0 finds every Claude Code and Codex config
22214
+ * folder in their home, and they keep the ones to connect. One request asks for all of them;
22215
+ * they open its link, approve on their page, and its answer code reaches the terminal. Then
22216
+ * each folder's grant is kept and confirmed, and ref0's plugin installed into it. An agent's
22217
+ * session and a process without a terminal cannot start it, and only one runs at a time.
22218
+ */
22219
+ /** One request connects at most this many config folders. */
22220
+ const mostFolders = 8;
22221
+ const isConfigFolderPath = Schema.is(ConfigFolderPath);
22222
+ /** The line that asks again. */
22223
+ const again = "ref0 connect";
22224
+ /** Every folder found, each ticked but one ref0 can't be shown; Enter keeps the ticked ones. */
22225
+ const choose = Effect.fnUntraced(function* (found, home) {
22226
+ const choices = found.map((folder) => {
22227
+ const shown = shownConfigFolder(home, folder.configFolder);
22228
+ const sendable = isConfigFolderPath(shown);
22229
+ const choice = {
22230
+ title: `${printable(shown)} ${configFolders[folder.harness].name}`,
22231
+ value: folder,
22232
+ selected: sendable,
22233
+ disabled: !sendable
22234
+ };
22235
+ return sendable ? choice : {
22236
+ ...choice,
22237
+ description: "Its path is too long, or holds characters ref0 can’t show."
22238
+ };
22239
+ });
22240
+ const ticked = choices.filter((choice) => choice.selected).length;
22241
+ return yield* Prompt.run(Prompt.MultiSelect({
22242
+ message: ticked > mostFolders ? `Connect which config folders? One request connects at most ${mostFolders}, so untick ${ticked - mostFolders} or more. Space ticks or unticks one; Enter connects those ticked.` : "Connect which config folders? Space ticks or unticks one; Enter connects those ticked.",
22243
+ choices,
22244
+ max: mostFolders
22245
+ }));
22246
+ });
22247
+ /** Each other owner whose agents connected folders this run didn't touch act as. */
22248
+ const otherOwners = Effect.fnUntraced(function* (connected) {
22249
+ const folders = yield* ConfigFolders;
22250
+ const grants = yield* HostGrant;
22251
+ const touched = new Set([
22252
+ ...connected.kept,
22253
+ ...connected.notKept,
22254
+ ...connected.excluded
22255
+ ].map((folder) => folder.configFolder));
22256
+ const others = /* @__PURE__ */ new Map();
22257
+ for (const folder of yield* folders.all) {
22258
+ if (touched.has(folder)) continue;
22259
+ const grant = yield* grants.get(folder).pipe(Effect.orElseSucceed(Option.none));
22260
+ if (Option.isNone(grant) || grant.value.owner === connected.owner) continue;
22261
+ others.set(grant.value.owner, [...others.get(grant.value.owner) ?? [], folder]);
22262
+ }
22263
+ return [...others].map(([owner, configFolders]) => ({
22264
+ owner,
22265
+ configFolders
22266
+ }));
22267
+ });
22268
+ /** The organizations an agent is in this state in, by name, once each. */
22269
+ const organizationsIn = (kept, state) => {
22270
+ const byAgent = /* @__PURE__ */ new Map();
22271
+ for (const folder of kept) for (const place of folder.organizations) {
22272
+ if (place.state !== state) continue;
22273
+ byAgent.set(folder.handle, (byAgent.get(folder.handle) ?? /* @__PURE__ */ new Set()).add(place.name));
22274
+ }
22275
+ return [...byAgent].map(([handle, names]) => ({
22276
+ handle,
22277
+ names: [...names].join(", ")
22278
+ }));
22279
+ };
22280
+ /** What the terminal shows once each grant is kept and confirmed, and each install tried. */
22281
+ const reportOf = (connected, installed, others, home) => {
22282
+ const shown = (folder) => shownConfigFolder(home, folder.configFolder);
22283
+ const connectedBlock = [
22284
+ { label: `Connected as ${connected.owner}’s agents` },
22285
+ ...installed.map((folder) => `${shown(folder)} ${folder.owner === connected.owner ? folder.handle : `${folder.owner}/${folder.handle}`}`),
22286
+ ...connected.excluded.map((folder) => `${shown(folder)} taken out on the page, not connected`),
22287
+ ...others.map((other) => {
22288
+ const one = other.configFolders.length === 1;
22289
+ return `${other.configFolders.map((folder) => shownConfigFolder(home, folder)).join(", ")} ${one ? "is" : "are"} still connected as ${other.owner}’s ${one ? "agent" : "agents"}.`;
22290
+ })
22291
+ ];
22292
+ const organizationsBlock = [...organizationsIn(connected.kept, "pending").map(({ handle, names }) => `ref0 is still authorizing ${handle} in ${names}: it works there once that’s done.`), ...organizationsIn(connected.kept, "refused").map(({ handle, names }) => `${handle} doesn’t work in ${names}, which refused to authorize it.`)];
22293
+ const failedBlocks = [...connected.notKept.map((folder) => [`${shown(folder)} isn’t connected: ${folder.nextStep} To connect it, run:`, { command: again }]), ...installed.flatMap((folder) => {
22294
+ if (Result.isFailure(folder.installed)) return [[`${shown(folder)} is connected, but ref0’s plugin isn’t installed: ${folder.installed.failure.says} Then run:`, { command: repairOf(folder.installed.failure, folder.configFolder) }]];
22295
+ const done = folder.installed.success;
22296
+ const notes = [...done.nextStep === void 0 ? [] : [done.nextStep], ...done.profile ? [] : [sandboxWords]];
22297
+ return notes.length === 0 ? [] : [[`${shown(folder)}: ${notes.join(" ")}`]];
22298
+ })];
22299
+ const anyInstalled = installed.some((folder) => Result.isSuccess(folder.installed));
22300
+ return [
22301
+ connectedBlock,
22302
+ ...organizationsBlock.length === 0 ? [] : [organizationsBlock],
22303
+ ...failedBlocks,
22304
+ ...anyInstalled ? [["Start a new session in each."]] : []
22305
+ ];
22306
+ };
22307
+ /** The same, for `--json`: each folder asked for, and how it ended. */
22308
+ const answerOf = (connected, installed, others) => ({
22309
+ owner: connected.owner,
22310
+ folders: [
22311
+ ...installed.map(({ configFolder, harness, owner, handle, organizations, installed: done }) => Result.isSuccess(done) ? {
22312
+ configFolder,
22313
+ harness,
22314
+ state: "installed",
22315
+ owner,
22316
+ agent: handle,
22317
+ organizations,
22318
+ plugin: done.success.folder,
22319
+ trusted: done.success.trusted,
22320
+ nextStep: done.success.nextStep ?? null
22321
+ } : {
22322
+ configFolder,
22323
+ harness,
22324
+ state: "notInstalled",
22325
+ owner,
22326
+ agent: handle,
22327
+ organizations,
22328
+ says: done.failure.says,
22329
+ nextStep: repairOf(done.failure, configFolder)
22330
+ }),
22331
+ ...connected.notKept.map(({ configFolder, harness, nextStep }) => ({
22332
+ configFolder,
22333
+ harness,
22334
+ state: "notKept",
22335
+ says: nextStep,
22336
+ nextStep: again
22337
+ })),
22338
+ ...connected.excluded.map(({ configFolder, harness }) => ({
22339
+ configFolder,
22340
+ harness,
22341
+ state: "excluded"
22342
+ }))
22343
+ ],
22344
+ others
22345
+ });
21506
22346
  /** Connecting this machine. */
21507
- const connectCommands = [Command.make("connect", {
21508
- json,
21509
- harness
21510
- }, ({ json: asJson, harness: into }) => Effect.gen(function* () {
22347
+ const connectCommands = [Command.make("connect", { json }, ({ json: asJson }) => Effect.gen(function* () {
21511
22348
  const session = yield* SessionToken;
21512
22349
  const tty = yield* Tty;
21513
22350
  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.");
21514
22351
  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.");
22352
+ yield* (yield* ConnectLock).hold;
21515
22353
  yield* (yield* Keychain).probe;
21516
- const connected = yield* requestAttachment(into);
21517
- const words = connectedWords(connected);
21518
- if (Option.isNone(into)) return yield* printAnswer(connected, asJson, words);
21519
- const installed = yield* installFor({
21520
- owner: connected.owner,
21521
- agent: connected.handle
21522
- }, into.value, { asJson }).pipe(Effect.catchTags({
21523
- InstallFailed: (failed) => sayFailed(`${words} Only the install failed. ${failed.nextStep}`),
21524
- ConfigError: () => sayFailed(`${words} Only the install failed: HOME isn’t set, so ref0 has nowhere to keep the plugin.`)
21525
- }));
21526
- const answer = installed.nextStep === void 0 ? {
21527
- ...connected,
21528
- installed: installed.folder,
21529
- trusted: installed.trusted
21530
- } : {
21531
- ...connected,
21532
- installed: installed.folder,
21533
- trusted: installed.trusted,
21534
- nextStep: installed.nextStep
21535
- };
21536
- yield* printAnswer(answer, asJson, `${words} ${installed.words}`);
21537
- }).pipe(Effect.catchTag("KeychainError", (failed) => sayFailed(failed.nextStep)))).pipe(Command.withDescription("Connect this machine to one of your agents, in your terminal"))];
22354
+ const home = yield* Config.String("HOME").pipe(Config.withDefault("."));
22355
+ const found = yield* findConfigFolders;
22356
+ if (found.length === 0) return yield* sayFailed(`ref0 found no Claude Code or Codex config folder in your home. Start Claude Code or Codex once, then run ${again} again.`);
22357
+ const chosen = yield* choose(found, home).pipe(Effect.catchTag("QuitError", () => sayFailed("Stopped, and nothing was asked.")));
22358
+ if (chosen.length === 0) return yield* sayFailed(`No config folder was ticked, so nothing was asked. To connect one, run ${again} again.`);
22359
+ const connected = yield* requestAttachment(chosen);
22360
+ const installed = yield* installInto(connected.kept, { asJson });
22361
+ const others = yield* otherOwners(connected);
22362
+ yield* printAnswer(answerOf(connected, installed, others), asJson, reportOf(connected, installed, others, home));
22363
+ if (connected.notKept.length > 0 || installed.some((folder) => Result.isFailure(folder.installed))) return yield* new Said();
22364
+ }).pipe(Effect.scoped, Effect.catchTags({
22365
+ KeychainError: (failed) => sayFailed(failed.nextStep),
22366
+ ConnectBusy: (failed) => sayFailed(failed.nextStep),
22367
+ ConfigError: () => sayFailed("HOME isn’t set, so ref0 has nowhere to keep the plugin. Set it, then run ref0 install.")
22368
+ }))).pipe(Command.withDescription("Connect this machine’s config folders to your agents, in your terminal"))];
21538
22369
  //#endregion
21539
22370
  //#region src/connection-lines.ts
21540
22371
  /**
@@ -22067,13 +22898,6 @@ const traceLines = (page, nextCommand) => {
22067
22898
  ].join("\n");
22068
22899
  };
22069
22900
  //#endregion
22070
- //#region src/sha256.ts
22071
- /** Bytes as lowercase hex. */
22072
- const hex = (bytes) => Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
22073
- const encoder = new TextEncoder();
22074
- /** The SHA-256 of a text's UTF-8 bytes, as lowercase hex. */
22075
- const sha256 = (text) => Effect.flatMap(Crypto.Crypto, (crypto) => Effect.map(Effect.orDie(crypto.digest("SHA-256", encoder.encode(text))), hex));
22076
- //#endregion
22077
22901
  //#region src/local/sandboxes.ts
22078
22902
  /**
22079
22903
  * A local run's sandboxes: the check's own, by name, opened on this machine, in the checkout.
@@ -22248,7 +23072,7 @@ const execIn = Effect.fnUntraced(function* (root, held, command, options = {}, l
22248
23072
  });
22249
23073
  /** Why a port can't be served on here, if it can't: something on this machine holds it. */
22250
23074
  const portHeld = (port) => Effect.callback((resume) => {
22251
- const probe = createServer();
23075
+ const probe = createServer$1();
22252
23076
  probe.once("error", (error) => resume(Effect.succeedSome(error.code === "EADDRINUSE" ? `Port ${port} is in use on this machine: serve on another` : `Port ${port} can't be served on here: ${error.message}`)));
22253
23077
  probe.listen(port, "127.0.0.1", () => probe.close(() => resume(Effect.succeedNone)));
22254
23078
  });
@@ -23272,6 +24096,11 @@ const askedWords = (from) => {
23272
24096
  if (from === "accountable") return "the person behind the run";
23273
24097
  return "person" in from ? from.person : `anyone who holds ${from.permission}`;
23274
24098
  };
24099
+ /**
24100
+ * A request refused since it asks someone else and nobody is left but the person behind the run,
24101
+ * in words.
24102
+ */
24103
+ const selfWords = (from) => from !== "accountable" && "permission" in from ? `nobody who holds ${from.permission} but the person behind the run, who can’t vouch for it` : `${askedWords(from)} is the person behind the run, who can’t vouch for it`;
23275
24104
  /** One yes a part needs, in words, as it stands over the run named. */
23276
24105
  const yesWords = (yes) => {
23277
24106
  switch (yes.state) {
@@ -23279,7 +24108,7 @@ const yesWords = (yes) => {
23279
24108
  case "asked": return `asked ${askedWords(yes.from)}${yes.asked === void 0 ? "" : `, ${yes.asked}`}`;
23280
24109
  case "no": return `no from ${yes.by ?? askedWords(yes.from)}`;
23281
24110
  case "expired": return `${askedWords(yes.from)} didn’t answer in a day`;
23282
- case "refused": return `${askedWords(yes.from)} can’t be asked`;
24111
+ case "refused": return yes.why === "self" ? selfWords(yes.from) : `${askedWords(yes.from)} can’t be asked`;
23283
24112
  case "revoked": return `yes from ${yes.by ?? askedWords(yes.from)}, revoked since`;
23284
24113
  }
23285
24114
  };
@@ -23394,7 +24223,7 @@ const gateCommands = [Command.make("gate", {
23394
24223
  /** The remote a clone pushes to: its thread's fork path, named as the thread is. */
23395
24224
  const forkRemote = (fork) => `thread-${threadParts(fork.thread).number}`;
23396
24225
  /** Git's credential helper for ref0, as Git runs it: this `ref0`, quoted for the shell. */
23397
- const helperCommand = (ref0) => `!${shellWord(ref0)} git-credential`;
24226
+ const helperCommand = (ref0, api) => `!REF0_API=${shellWord(api)} ${shellWord(ref0)} git-credential`;
23398
24227
  /**
23399
24228
  * The config Git takes while it clones, and keeps in the clone: for ref0's origin, the helpers
23400
24229
  * Git already has cleared, as macOS's keychain helper would keep one session's token for the
@@ -23402,7 +24231,7 @@ const helperCommand = (ref0) => `!${shellWord(ref0)} git-credential`;
23402
24231
  */
23403
24232
  const credentialConfig = (api, ref0) => [
23404
24233
  [`credential.${api}.helper`, ""],
23405
- [`credential.${api}.helper`, helperCommand(ref0)],
24234
+ [`credential.${api}.helper`, helperCommand(ref0, api)],
23406
24235
  [`credential.${api}.useHttpPath`, "true"]
23407
24236
  ];
23408
24237
  /**
@@ -23413,6 +24242,7 @@ const forkConfig = (api, fork, repo) => [
23413
24242
  [`remote.${forkRemote(fork)}.url`, forkUrlOf(api, fork.path)],
23414
24243
  [`remote.${forkRemote(fork)}.fetch`, `+refs/heads/*:refs/remotes/${forkRemote(fork)}/*`],
23415
24244
  ["remote.pushDefault", forkRemote(fork)],
24245
+ ["ref0.api", api],
23416
24246
  ["ref0.repo", repo],
23417
24247
  ["ref0.thread", fork.thread]
23418
24248
  ];
@@ -24550,7 +25380,7 @@ const pathOf$1 = (written) => agentParts(written) ?? {
24550
25380
  agent: ""
24551
25381
  };
24552
25382
  /** An agent, as a person reads it in a list: `acme/triage on`. */
24553
- const lineOf$3 = (named, view) => `${named}/${view.handle} ${view.turnedOff ? "off" : "on"}`;
25383
+ const lineOf$2 = (named, view) => `${named}/${view.handle} ${view.turnedOff ? "off" : "on"}`;
24554
25384
  const agents = Command.make("agents", {
24555
25385
  owner: owner$1,
24556
25386
  cursor: cursor$1,
@@ -24564,7 +25394,7 @@ const agents = Command.make("agents", {
24564
25394
  onSome: (after) => ({ after })
24565
25395
  })
24566
25396
  });
24567
- const lines = answer.value.agents.map((each) => lineOf$3(named, each));
25397
+ const lines = answer.value.agents.map((each) => lineOf$2(named, each));
24568
25398
  const next = answer.value.nextAfter;
24569
25399
  yield* printAnswer(answer.json, asJson, lines.length === 0 ? `${named} has no agents.` : [...lines, ...next === void 0 ? [] : [`More: --cursor ${next}`]].join("\n"));
24570
25400
  }).pipe(Effect.catchTag("CallFailed", failedWith$7))).pipe(Command.withDescription("An owner’s agents: yours, or an organization’s you’re in"), Command.withShortDescription("List an owner’s agents"));
@@ -24661,7 +25491,7 @@ const agentCommands = [agents, Command.make("agent", {
24661
25491
  params
24662
25492
  });
24663
25493
  const added = DateTime.formatIso(answer.value.addedAt);
24664
- yield* printAnswer(answer.json, asJson, `${lineOf$3(params.owner, answer.value)} generation ${answer.value.generation} added ${added}`);
25494
+ yield* printAnswer(answer.json, asJson, `${lineOf$2(params.owner, answer.value)} generation ${answer.value.generation} added ${added}`);
24665
25495
  }).pipe(Effect.catchTag("CallFailed", failedWith$7))).pipe(Command.withDescription("Read one agent; add one, turn one off or on, or authorize an attachment of your own"), Command.withShortDescription("Read and manage an agent"), Command.withSubcommands([
24666
25496
  add$1,
24667
25497
  disable$1,
@@ -24687,7 +25517,7 @@ const pathOf = (written) => agentParts(written) ?? {
24687
25517
  agent: ""
24688
25518
  };
24689
25519
  /** An attachment, as a person reads it in a list: `pip token_… machine_… attached …`. */
24690
- const lineOf$2 = (view) => [
25520
+ const lineOf$1 = (view) => [
24691
25521
  view.person,
24692
25522
  view.credential.id,
24693
25523
  view.machine ?? "an app",
@@ -24707,7 +25537,7 @@ const attachmentCommands = [Command.make("attachments", {
24707
25537
  onSome: (after) => ({ after })
24708
25538
  })
24709
25539
  });
24710
- const lines = answer.value.attachments.map(lineOf$2);
25540
+ const lines = answer.value.attachments.map(lineOf$1);
24711
25541
  const next = answer.value.nextAfter;
24712
25542
  yield* printAnswer(answer.json, asJson, lines.length === 0 ? `Nothing of yours is attached to ${written}.` : [...lines, ...next === void 0 ? [] : [`More: --cursor ${next}`]].join("\n"));
24713
25543
  }).pipe(Effect.catchTag("CallFailed", failedWith$6))).pipe(Command.withDescription("An agent’s machines and apps: all to an admin, yours to you"), Command.withShortDescription("List an agent’s machines and apps")), Command.make("disconnect", {
@@ -25745,7 +26575,7 @@ const failedWith$2 = (failed) => sayFailed(failed.nextStep);
25745
26575
  * A thread in a line: its number, its title, where it stands, what started it if a workflow
25746
26576
  * did, and how long it has waited if nobody has picked it up.
25747
26577
  */
25748
- const lineOf$1 = (view, now) => {
26578
+ const lineOf = (view, now) => {
25749
26579
  const byWorkflow = view.origin?.workflow === void 0 ? "" : ` by workflow ${view.origin.workflow.workflow}`;
25750
26580
  const waiting = nobodyOnWords(view, now);
25751
26581
  return `#${view.number} ${view.title ?? "(untitled)"} ${view.stage}${byWorkflow}${waiting === void 0 ? "" : ` ${waiting}`}`;
@@ -25802,7 +26632,7 @@ const projectList = (address, from, waiting, asJson) => Effect.gen(function* ()
25802
26632
  const page = answer.value;
25803
26633
  const now = yield* Clock.currentTimeMillis;
25804
26634
  const none = waiting ? "No child is waiting for someone: every open one a plan or a workflow started over an hour ago has had a session." : "No threads yet.";
25805
- const lines = page.threads.length === 0 ? [none] : page.threads.map((view) => lineOf$1(view, now));
26635
+ const lines = page.threads.length === 0 ? [none] : page.threads.map((view) => lineOf(view, now));
25806
26636
  const more = page.nextAfter === void 0 ? [] : [`More: ref0 threads ${address}${waiting ? " --nobody-on" : ""} --after ${page.nextAfter}`];
25807
26637
  yield* printAnswer(answer.json, asJson, [...lines, ...more].join("\n"));
25808
26638
  });
@@ -25870,7 +26700,7 @@ const readThread = ({ thread: address, json: asJson }) => Effect.gen(function* (
25870
26700
  const view = answer.value;
25871
26701
  const now = yield* Clock.currentTimeMillis;
25872
26702
  yield* printAnswer(answer.json, asJson, [
25873
- lineOf$1(view, now),
26703
+ lineOf(view, now),
25874
26704
  ...workflowsLines(view, DateTime.makeUnsafe(now)),
25875
26705
  ...view.origin === void 0 ? [] : [originLine(view.origin)],
25876
26706
  `Why: ${whyOf(view, address)}`,
@@ -27557,7 +28387,8 @@ var Attribution = class Attribution extends Context.Service()("ref0/cli/Attribut
27557
28387
  const crypto = yield* Crypto.Crypto;
27558
28388
  const home = yield* Config.String("HOME").pipe(Config.withDefault("."));
27559
28389
  const stateHome = yield* Config.String("XDG_STATE_HOME").pipe(Config.withDefault(path.join(home, ".local", "state")));
27560
- const directory = path.join(stateHome, "ref0", "attribution");
28390
+ const config = yield* Ref0Config;
28391
+ const directory = path.join(stateHome, "ref0", "origins", originKey(config.api), "attribution");
27561
28392
  const held = yield* Ref.make(/* @__PURE__ */ new Map());
27562
28393
  const checking = yield* Semaphore.make(1);
27563
28394
  const keeping = yield* Semaphore.make(1);
@@ -28967,7 +29798,8 @@ var HarnessTasks = class HarnessTasks extends Context.Service()("ref0/cli/Harnes
28967
29798
  const path = yield* Path.Path;
28968
29799
  const home = yield* Config.String("HOME").pipe(Config.withDefault("."));
28969
29800
  const stateHome = yield* Config.String("XDG_STATE_HOME").pipe(Config.withDefault(path.join(home, ".local", "state")));
28970
- const directory = path.join(stateHome, "ref0", "tasks");
29801
+ const config = yield* Ref0Config;
29802
+ const directory = path.join(stateHome, "ref0", "origins", originKey(config.api), "tasks");
28971
29803
  const held = yield* Ref.make(/* @__PURE__ */ new Map());
28972
29804
  const keeping = yield* Semaphore.make(1);
28973
29805
  const fileOf = (session, generation) => path.join(directory, `${session}.${generation}.json`);
@@ -29474,7 +30306,8 @@ var Transcripts = class Transcripts extends Context.Service()("ref0/cli/Transcri
29474
30306
  const crypto = yield* Crypto.Crypto;
29475
30307
  const home = yield* Config.String("HOME").pipe(Config.withDefault("."));
29476
30308
  const stateHome = yield* Config.String("XDG_STATE_HOME").pipe(Config.withDefault(path.join(home, ".local", "state")));
29477
- const directory = path.join(stateHome, "ref0", "transcripts");
30309
+ const config = yield* Ref0Config;
30310
+ const directory = path.join(stateHome, "ref0", "origins", originKey(config.api), "transcripts");
29478
30311
  const held = yield* Ref.make(/* @__PURE__ */ new Map());
29479
30312
  const fileOf = (session, generation) => path.join(directory, `${session}.${generation}.json`);
29480
30313
  const keptGenerations = (session) => Effect.map(fs.readDirectory(directory).pipe(Effect.orElseSucceed(() => [])), (names) => names.flatMap((name) => {
@@ -29805,9 +30638,9 @@ var Waker = class Waker extends Context.Service()("ref0/cli/Waker") {
29805
30638
  //#endregion
29806
30639
  //#region src/harness/hook-answer.ts
29807
30640
  /** How Claude Code names a tool of the plugin's server. */
29808
- const claudeCodeToolName = (tool) => `mcp__plugin_ref0_ref0__${tool}`;
30641
+ const claudeCodeToolName = (tool, server) => `mcp__plugin_${server}_${server}__${tool}`;
29809
30642
  /** How Codex names a tool of the plugin's server, once its tool search found it. */
29810
- const codexToolName = (tool) => `mcp__ref0__${tool}`;
30643
+ const codexToolName = (tool, server) => `mcp__${server}__${tool}`;
29811
30644
  /** The tool the harness calls for its hooks. The agent sees it too, but may never call it. */
29812
30645
  const hookTool = "hook";
29813
30646
  const preToolUse = (permissionDecision, permissionDecisionReason) => ({ hookSpecificOutput: {
@@ -29823,11 +30656,11 @@ const preToolUse = (permissionDecision, permissionDecisionReason) => ({ hookSpec
29823
30656
  * serves the agent, named in `agentTools`, are allowed, since a plugin can't add permission
29824
30657
  * rules. Codex approves them through its config instead; its hooks' `allow` grants nothing.
29825
30658
  */
29826
- function answerHook(hook, agentTools) {
30659
+ function answerHook(hook, agentTools, server) {
29827
30660
  if (hook.event !== "PreToolUse" || hook.tool === void 0) return {};
29828
30661
  const name = hook.tool.name;
29829
- if (name === claudeCodeToolName("hook") || name === codexToolName("hook")) return preToolUse("deny", "ref0's hook tool is the harness's, not the agent's");
29830
- for (const tool of agentTools) if (name === claudeCodeToolName(tool)) return preToolUse("allow", "ref0's own tool");
30662
+ if (name === claudeCodeToolName("hook", server) || name === codexToolName("hook", server)) return preToolUse("deny", "ref0's hook tool is the harness's, not the agent's");
30663
+ for (const tool of agentTools) if (name === claudeCodeToolName(tool, server)) return preToolUse("allow", "ref0's own tool");
29831
30664
  return {};
29832
30665
  }
29833
30666
  /**
@@ -30840,6 +31673,7 @@ const agentTools = /* @__PURE__ */ new Set();
30840
31673
  const decodeArguments = Schema.decodeUnknownEffect(HookArguments);
30841
31674
  const hookHandlers = (plugin) => HarnessTools.toLayer(Effect.gen(function* () {
30842
31675
  const session = yield* HarnessSession;
31676
+ const server = yield* originPluginName((yield* Ref0Config).api);
30843
31677
  return { hook: (call) => Effect.gen(function* () {
30844
31678
  const update = updateLine({
30845
31679
  protocol: call.protocol,
@@ -30848,7 +31682,7 @@ const hookHandlers = (plugin) => HarnessTools.toLayer(Effect.gen(function* () {
30848
31682
  if (Option.isSome(update)) return yield* new HookRefused({ message: update.value });
30849
31683
  const hook = readHook(yield* decodeArguments(call).pipe(Effect.orDie));
30850
31684
  const delivered = yield* session.deliver(hook);
30851
- return withContext(answerHook(hook, agentTools), hook.event, delivered);
31685
+ return withContext(answerHook(hook, agentTools, server), hook.event, delivered);
30852
31686
  }) };
30853
31687
  }));
30854
31688
  /** Every MCP version Effect speaks; the harness picks. */
@@ -30899,7 +31733,7 @@ const mcp = Command.make(harnessCommandNames.mcp, {}, () => Effect.gen(function*
30899
31733
  const found = yield* (yield* HarnessProcess).innermost;
30900
31734
  const agent = Option.isSome(found) ? yield* (yield* HostGrant).get(yield* agents.folder(found.value.harness)) : Option.none();
30901
31735
  const codex = Option.isSome(found) && found.value.harness === "codex" ? yield* codexSetupHere(found.value.process) : Option.none();
30902
- const reinstall = Option.isSome(found) ? installLine(yield* agents.folder(found.value.harness), Option.isSome(agent), found.value.harness) : "ref0 connect";
31736
+ const reinstall = Option.isSome(found) && Option.isSome(agent) ? installLine(yield* agents.folder(found.value.harness)) : "ref0 connect";
30903
31737
  return yield* Layer.launch(mcpServer(cliVersion, {
30904
31738
  plugin: {
30905
31739
  version: yield* pluginVersion,
@@ -31047,20 +31881,15 @@ const startOnHook = (protocol) => Effect.gen(function* () {
31047
31881
  const harnessName = binding.value.harness === "codex" ? "codex" : "claude-code";
31048
31882
  const folder = yield* agents.folder(harnessName);
31049
31883
  const agent = yield* (yield* HostGrant).get(folder);
31884
+ const repair = Option.isSome(agent) ? installLine(folder) : yield* connectLineFor(harnessName, folder);
31050
31885
  const update = updateLine({
31051
31886
  protocol,
31052
31887
  version: yield* pluginVersion
31053
- }, installLine(folder, Option.isSome(agent), harnessName));
31888
+ }, repair);
31054
31889
  if (Option.isSome(update)) return yield* Console.log(update.value);
31055
31890
  if (Option.isNone(agent)) {
31056
- const fs = yield* FileSystem.FileSystem;
31057
- const path = yield* Path.Path;
31058
- const home = yield* Config.String("HOME");
31059
- const setup = configFolders[harnessName];
31060
- const defaultPath = path.join(home, setup.inHome);
31061
- const prefix = folder === (yield* fs.realPath(defaultPath).pipe(Effect.orElseSucceed(() => defaultPath))) ? "" : `${setup.variable}=${shellWord(folder)} `;
31062
31891
  const name = harnessName === "codex" ? "Codex" : "Claude Code";
31063
- return yield* Console.log(`ref0: no agent is connected for this ${name} setup, ${folder}, so no session starts. Run: ${prefix}${installLine(folder, Option.isSome(agent), harnessName)}`);
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}`);
31064
31893
  }
31065
31894
  const account = sessionAccount(binding.value);
31066
31895
  const held = yield* keychain.get(account);
@@ -31093,15 +31922,8 @@ const sessionStart = Command.make(harnessCommandNames.sessionStart, { protocol:
31093
31922
  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]))];
31094
31923
  //#endregion
31095
31924
  //#region src/harness/status.ts
31096
- /**
31097
- * `ref0 status`: whether ref0 follows the sessions of the harness it runs under, a line for each
31098
- * thing that has to hold. This machine holds a host grant for the agent the harness's config
31099
- * folder starts sessions as, and ref0 accepts it; ref0's plugin is installed and on, and speaks
31100
- * this `ref0`'s hook protocol; Codex trusts its hooks, where Codex can say; and ref0 follows this
31101
- * session. Each "no" ends with the one thing to do next, and the command exits 1. It never prints
31102
- * a secret: a token only asks ref0 who it is. The plugin's `/ref0:status` runs it.
31103
- */
31104
- const encodeReport = Schema.encodeEffect(StatusReport);
31925
+ /** Each connected or initialized folder has its own report; secrets are used only to ask ref0. */
31926
+ const encodeReports = Schema.encodeEffect(Schema.Array(StatusReport));
31105
31927
  const checkOf = (check, answer, says, next) => next === void 0 ? {
31106
31928
  check,
31107
31929
  answer,
@@ -31112,24 +31934,16 @@ const checkOf = (check, answer, says, next) => next === void 0 ? {
31112
31934
  says,
31113
31935
  next
31114
31936
  };
31115
- const harnessWords = {
31116
- "claude-code": "Claude Code",
31117
- codex: "Codex"
31118
- };
31119
- const labels = {
31120
- connected: "Connected",
31121
- accepted: "Accepted",
31122
- plugin: "Plugin",
31123
- trusted: "Hooks trusted",
31124
- session: "Session"
31125
- };
31126
- const lineOf = (check) => [
31127
- `${labels[check.check]}: ${check.answer}.`,
31128
- check.says,
31129
- check.next
31130
- ].filter(Predicate.isString).join(" ");
31131
- /** The report as a person reads it: the harness and its folder, then a line for each check. */
31132
- const wordsOf = (report) => [`ref0 status, for ${harnessWords[report.harness]} in ${report.configFolder}:`, ...report.checks.map(lineOf)].join("\n");
31937
+ /** Each non-yes line puts its repair command on the next line, through the shared printer. */
31938
+ const wordsOf = (report, home) => {
31939
+ return [{
31940
+ label: shownConfigFolder(home, report.configFolder),
31941
+ text: ` ${report.agent === void 0 ? report.harness === void 0 ? "unknown harness" : configFolders[report.harness].name : `${report.agent.agent}, ${report.agent.owner}'s agent`}`
31942
+ }, ...report.checks.flatMap((check) => [{
31943
+ status: check.answer,
31944
+ text: `${check.check}: ${check.says}`
31945
+ }, ...check.next === void 0 ? [] : [{ command: check.next }]])];
31946
+ };
31133
31947
  const refused = () => Effect.succeed({ kind: "refused" });
31134
31948
  const whoIs = Effect.fnUntraced(function* (token) {
31135
31949
  const config = yield* Ref0Config;
@@ -31142,157 +31956,139 @@ const whoIs = Effect.fnUntraced(function* (token) {
31142
31956
  }), Effect.orElseSucceed(() => ({ kind: "unanswered" })));
31143
31957
  });
31144
31958
  const unansweredWords = (api) => `ref0 didn’t answer at ${api}.`;
31145
- const tryAgain = "Run ref0 status again in a minute.";
31146
- /** Whether this machine holds a host grant for the agent kept for the harness, and ref0 accepts it. */
31147
- const grantChecks = Effect.fnUntraced(function* (harness, grant) {
31148
- const words = harnessWords[harness];
31149
- const notAsked = checkOf("accepted", "unknown", "Not asked, with no host grant to ask with.");
31150
- if (Result.isFailure(grant)) return [checkOf("connected", "unknown", "The keychain didn’t say whether this config folder holds a host grant.", grant.failure.nextStep), notAsked];
31151
- const agent = grant.success;
31152
- if (Option.isNone(agent)) return [checkOf("connected", "no", `This machine’s ${words} isn’t connected to an agent.`, `Run: ${installLine("", false, harness)}`), notAsked];
31153
- const { owner, agent: handle } = agent.value;
31154
- const who = `${owner}’s ${handle}`;
31155
- const connect = `Run: ref0 connect --harness ${harness}`;
31156
- const connected = checkOf("connected", "yes", `This machine holds a host grant for ${who}.`);
31157
- switch ((yield* whoIs(agent.value.token)).kind) {
31158
- case "accepted": return [connected, checkOf("accepted", "yes", `ref0 accepts it: ${handle} is on, and the grant isn’t revoked.`)];
31159
- case "refused": return [checkOf("connected", "no", `This machine keeps a host grant for ${who} that ref0 no longer accepts.`, connect), checkOf("accepted", "no", `ref0 refuses it: the grant was revoked or not confirmed, or ${handle} was turned off.`, connect)];
31160
- case "unanswered": return [connected, checkOf("accepted", "unknown", unansweredWords((yield* Ref0Config).api), tryAgain)];
31959
+ /** Check the grant kept for this folder, without borrowing another folder's authority. */
31960
+ const grantChecks = Effect.fnUntraced(function* (grant, configFolder) {
31961
+ const asked = yield* whoIs(grant.token);
31962
+ const connected = checkOf("connected", "yes", "this machine holds its grant");
31963
+ switch (asked.kind) {
31964
+ case "accepted": return [connected, checkOf("accepted", "yes", "ref0 accepts its grant")];
31965
+ case "refused": return [connected, checkOf("accepted", "no", "ref0 refuses its grant", yield* connectLineFor(grant.harness, configFolder))];
31966
+ case "unanswered": return [connected, checkOf("accepted", "unknown", unansweredWords((yield* Ref0Config).api), "ref0 status")];
31161
31967
  }
31162
31968
  });
31163
- /**
31164
- * The plugin check once its hooks are read: whether each speaks this `ref0`'s hook protocol, and
31165
- * what to run if one doesn't.
31166
- */
31167
- const speaksCheck = (harness, protocols, version, reinstall) => {
31168
- const words = harnessWords[harness];
31169
- if (protocols.length === 0) return checkOf("plugin", "no", `ref0’s plugin in ${words} has no hooks ref0 can read.`, `Run: ${reinstall}`);
31170
- const speaking = {
31969
+ /** Protocol mismatches either update this CLI or reinstall this folder's plugin. */
31970
+ const speaksCheck = (protocols, version, reinstall) => {
31971
+ if (protocols.length === 0) return checkOf("plugin", "no", "no readable hooks in this folder", reinstall);
31972
+ const mismatch = protocolMismatch({
31171
31973
  protocol: protocols.find((protocol) => protocol !== "1") ?? "1",
31172
31974
  version
31173
- };
31174
- const mismatch = protocolMismatch(speaking, reinstall);
31175
- if (Option.isSome(mismatch)) return checkOf("plugin", "no", `${mismatch.value.says}.`, `Run: ${mismatch.value.run}`);
31176
- const which = Option.match(version, {
31177
- onNone: () => "ref0’s plugin",
31178
- onSome: (release) => `ref0’s plugin ${release}`
31179
- });
31180
- return checkOf("plugin", "yes", `${which} is on in ${words}, and speaks hook protocol 1, as this ref0 does.`);
31975
+ }, reinstall);
31976
+ if (Option.isSome(mismatch)) return checkOf("plugin", "no", mismatch.value.says, mismatch.value.run);
31977
+ return checkOf("plugin", "yes", "installed and speaks this ref0");
31181
31978
  };
31182
31979
  const decodePlugins = Schema.decodeUnknownEffect(Schema.fromJsonString(ClaudePluginsListed));
31183
31980
  const decodeHooks = Schema.decodeUnknownEffect(Schema.fromJsonString(HooksFile));
31184
- /**
31185
- * Whether Claude Code has ref0's plugin on, and its hooks speak this `ref0`'s protocol, from
31186
- * `claude plugin list` and the hooks file in the folder Claude Code reads it from. Claude Code
31187
- * doesn't say whether it trusts a plugin's hooks, so there's no line for that.
31188
- */
31189
- const claudeCodeChecks = Effect.fnUntraced(function* (agent) {
31190
- const reinstall = installLine(yield* (yield* ConfigFolders).folder("claude-code"), Option.isSome(agent), "claude-code");
31981
+ /** Ask Claude Code about this folder, even when another folder is current in the terminal. */
31982
+ const claudeCodeChecks = Effect.fnUntraced(function* (configFolder) {
31983
+ const reinstall = installLine(configFolder);
31191
31984
  const claude = yield* onPath("claude");
31192
- if (Option.isNone(claude)) return [checkOf("plugin", "unknown", "Claude Code isn’t on this machine’s PATH, so ref0 can’t ask it.")];
31985
+ if (Option.isNone(claude)) return [checkOf("plugin", "unknown", "Claude Code isn’t on PATH", reinstall)];
31986
+ const home = yield* Config.String("HOME").pipe(Config.withDefault("."));
31193
31987
  const listed = yield* (yield* ChildProcessSpawner.ChildProcessSpawner).string(ChildProcess.make(claude.value, [
31194
31988
  "plugin",
31195
31989
  "list",
31196
31990
  "--json"
31197
- ])).pipe(Effect.flatMap(decodePlugins), Effect.timeout("15 seconds"), Effect.option);
31198
- if (Option.isNone(listed)) return [checkOf("plugin", "unknown", "Claude Code didn’t list its plugins, so ref0 can’t tell.", "Run: claude plugin list")];
31199
- const plugin = listed.value.find((each) => each.id === "ref0@ref0");
31200
- if (plugin === void 0) return [checkOf("plugin", "no", "ref0’s plugin isn’t installed in Claude Code.", `Run: ${reinstall}`)];
31201
- if (!plugin.enabled) return [checkOf("plugin", "no", "ref0’s plugin is installed in Claude Code, but turned off.", "Run: claude plugin enable ref0@ref0")];
31991
+ ], {
31992
+ env: {
31993
+ HOME: home,
31994
+ CLAUDE_CONFIG_DIR: configFolder
31995
+ },
31996
+ extendEnv: true
31997
+ })).pipe(Effect.flatMap(decodePlugins), Effect.timeout("15 seconds"), Effect.option);
31998
+ if (Option.isNone(listed)) return [checkOf("plugin", "unknown", "Claude Code didn’t list its plugins", reinstall)];
31999
+ const name = yield* originPluginName((yield* Ref0Config).api);
32000
+ const id = `${name}@${name}`;
32001
+ const plugin = listed.value.find((each) => each.id === id);
32002
+ if (plugin === void 0) return [checkOf("plugin", "no", "not installed in this folder", reinstall)];
32003
+ if (!plugin.enabled) return [checkOf("plugin", "no", "turned off in this folder", `CLAUDE_CONFIG_DIR=${shellWord(configFolder)} claude plugin enable ${id}`)];
31202
32004
  const fs = yield* FileSystem.FileSystem;
31203
32005
  const folder = plugin.readFromFolder ?? plugin.installPath;
31204
32006
  const hooks = folder === void 0 ? Option.none() : yield* fs.readFileString(`${folder}/hooks/hooks.json`).pipe(Effect.flatMap(decodeHooks), Effect.option);
31205
- return [speaksCheck("claude-code", Option.match(hooks, {
32007
+ return [speaksCheck(Option.match(hooks, {
31206
32008
  onNone: () => [],
31207
32009
  onSome: protocolsOf
31208
32010
  }), Option.fromUndefinedOr(plugin.version), reinstall)];
31209
32011
  });
31210
- /** Whether Codex trusts ref0's hooks, which it runs only once the person has. */
31211
- const trustCheck = (hooks, reinstall) => {
31212
- if (!hooks.every(ownHook)) return checkOf("trusted", "no", "Codex’s ref0 plugin holds hooks ref0 didn’t write.", `Remove it with: codex plugin remove ref0@ref0, then run: ${reinstall}`);
31213
- const waiting = hooks.filter(waitingForTrust).length;
31214
- if (waiting > 0) return checkOf("trusted", "no", `Codex runs ref0’s hooks only once you trust them, and ${waiting} of ${hooks.length} ${waiting === 1 ? "isn’t" : "aren’t"}.`, trustRef0Hooks(reinstall));
31215
- return checkOf("trusted", "yes", "Codex trusts each of ref0’s hooks.");
32012
+ /** Trust only ref0's hooks; removal of an unexpected plugin targets this folder too. */
32013
+ const trustCheck = (hooks, configFolder, name) => {
32014
+ const reinstall = installLine(configFolder);
32015
+ if (!hooks.every((hook) => ownHook(hook, name))) return checkOf("trusted", "no", "the plugin holds hooks ref0 didn’t write", `CODEX_HOME=${shellWord(configFolder)} codex plugin remove ${name}@${name} && ${reinstall}`);
32016
+ if (hooks.some(waitingForTrust)) return checkOf("trusted", "no", "ref0’s hooks need your trust in a terminal", reinstall);
32017
+ return checkOf("trusted", "yes", "Codex trusts each ref0 hook");
31216
32018
  };
31217
- /**
31218
- * Whether Codex runs ref0's plugin's hooks, each speaking this `ref0`'s protocol, and trusts
31219
- * them, from its app server's `hooks/list`, as `ref0 install` reads them.
31220
- */
31221
- const codexChecks = Effect.fnUntraced(function* (agent) {
31222
- const reinstall = installLine(yield* (yield* ConfigFolders).folder("codex"), Option.isSome(agent), "codex");
31223
- const notAsked = (why) => checkOf("trusted", "unknown", `Not asked, ${why}.`);
32019
+ /** Ask Codex's app server under this folder's CODEX_HOME. */
32020
+ const codexChecks = Effect.fnUntraced(function* (configFolder) {
32021
+ const reinstall = installLine(configFolder);
31224
32022
  const codex = yield* onPath("codex");
31225
- if (Option.isNone(codex)) return [checkOf("plugin", "unknown", "Codex isn’t on this machine’s PATH, so ref0 can’t ask it."), notAsked("with no Codex to ask")];
32023
+ if (Option.isNone(codex)) return [checkOf("plugin", "unknown", "Codex isn’t on PATH", reinstall)];
31226
32024
  const home = yield* Config.String("HOME").pipe(Config.withDefault("."));
31227
- const listed = yield* askCodex(codex.value, codexMethods.hooksList, { cwds: [home] }).pipe(Effect.option);
31228
- if (Option.isNone(listed)) return [checkOf("plugin", "unknown", "Codex didn’t list its hooks, so ref0 can’t tell."), notAsked("with no hooks listed")];
31229
- const hooks = listed.value.data.flatMap((entry) => entry.hooks).filter(pluginHook);
31230
- if (hooks.length === 0) return [checkOf("plugin", "no", "ref0’s plugin isn’t installed in Codex, or is turned off.", `Run: ${reinstall}`), notAsked("with no hooks of ref0’s in Codex")];
32025
+ const listed = yield* askCodex(codex.value, codexMethods.hooksList, { cwds: [home] }, configFolder).pipe(Effect.option);
32026
+ if (Option.isNone(listed)) return [checkOf("plugin", "unknown", "Codex didn’t list its hooks", reinstall)];
32027
+ const name = yield* originPluginName((yield* Ref0Config).api);
32028
+ const hooks = listed.value.data.flatMap((entry) => entry.hooks).filter((hook) => pluginHook(hook, `${name}@${name}`));
32029
+ if (hooks.length === 0) return [checkOf("plugin", "no", "not installed or turned off in this folder", reinstall)];
31231
32030
  const protocols = hooks.map((hook) => protocolFlagged(hook.command ?? "")).filter(Predicate.isString);
31232
- return [speaksCheck("codex", protocols, Option.none(), reinstall), trustCheck(hooks, reinstall)];
32031
+ return [speaksCheck(protocols, Option.none(), reinstall), trustCheck(hooks, configFolder, name)];
31233
32032
  });
31234
- /**
31235
- * Whether ref0 follows the harness session this runs in: its token, kept when the session
31236
- * started, and whether ref0 still accepts it. A session started before its agent was connected
31237
- * was never followed, and a new one is.
31238
- */
31239
- const sessionCheck = Effect.fnUntraced(function* (harness, earlierYes) {
32033
+ /** A session belongs only to the current folder, even when several folders share its agent. */
32034
+ const sessionCheck = Effect.fnUntraced(function* (harness, configFolder) {
32035
+ const launch = `${configFolders[harness].variable}=${shellWord(configFolder)} ${harness === "codex" ? "codex" : "claude"}`;
31240
32036
  const binding = yield* bindingHere().pipe(Effect.map(Option.filter((found) => found.harness === harness)), Effect.orElseSucceed(() => Option.none()));
31241
- if (Option.isNone(binding)) return checkOf("session", "unknown", (yield* harnessEnvironment).CODEX_SANDBOX === void 0 ? `This isn’t a ${harnessWords[harness]} session, so there’s none to follow.` : "Codex’s sandbox hides which Codex session this is, so ref0 can’t say whether it follows it.");
31242
- const words = harnessWords[harness];
31243
- const startNew = `Start a new ${words} session${earlierYes ? "" : " once the lines above say yes"}.`;
32037
+ if (Option.isNone(binding)) return checkOf("session", "unknown", (yield* harnessEnvironment).CODEX_SANDBOX === void 0 ? "no current session to check" : "Codex’s sandbox hides its session", launch);
31244
32038
  const token = yield* Effect.result((yield* Keychain).get(sessionAccount(binding.value)));
31245
- if (Result.isFailure(token)) return checkOf("session", "unknown", `The keychain didn’t say whether ref0 follows this ${words} session.`, token.failure.nextStep);
31246
- const notFollowed = checkOf("session", "no", `ref0 doesn’t follow this ${words} session.`, startNew);
31247
- if (Option.isNone(token.success)) return notFollowed;
32039
+ if (Result.isFailure(token)) return checkOf("session", "unknown", `The keychain didn’t say whether ref0 follows this ${configFolders[harness].name} session.`, token.failure.nextStep);
32040
+ if (Option.isNone(token.success)) return checkOf("session", "no", "start a new session in this folder", launch);
31248
32041
  const asked = yield* whoIs(token.success.value);
31249
32042
  switch (asked.kind) {
31250
- case "accepted": return asked.me.session === void 0 ? notFollowed : checkOf("session", "yes", `ref0 follows this ${words} session, as ${asked.me.session.session}.`);
31251
- case "refused": return checkOf("session", "no", `ref0 no longer follows this ${words} session: it ended, or its agent was turned off.`, startNew);
31252
- case "unanswered": return checkOf("session", "unknown", unansweredWords((yield* Ref0Config).api), tryAgain);
32043
+ case "accepted": return asked.me.session === void 0 ? checkOf("session", "no", "start a new session in this folder", launch) : checkOf("session", "yes", `ref0 follows this session as ${asked.me.session.session}`);
32044
+ case "refused": return checkOf("session", "no", "ref0 no longer follows this session", launch);
32045
+ case "unanswered": return checkOf("session", "unknown", unansweredWords((yield* Ref0Config).api), "ref0 status");
31253
32046
  }
31254
32047
  });
31255
- /**
31256
- * The harness asked about: the flag's, else the harness process this runs under, else Codex in
31257
- * Codex's sandbox, which hides that process and says so, else Claude Code. A harness found
31258
- * without its session still names the setup.
31259
- */
31260
- const harnessHere = Effect.fnUntraced(function* (flagged) {
31261
- if (Option.isSome(flagged)) return flagged.value;
31262
- return yield* currentHarness;
31263
- });
31264
- const statusCommands = [Command.make(harnessCommandNames.status, {
31265
- harness: harnessFlag.pipe(Flag.optional, Flag.withDescription("The harness to check; the session’s, or claude-code, without it")),
31266
- json
31267
- }, ({ harness: flagged, json: asJson }) => Effect.gen(function* () {
31268
- const harness = yield* harnessHere(flagged);
31269
- const configFolder = yield* (yield* ConfigFolders).folder(harness);
31270
- const kept = yield* Effect.result((yield* HostGrant).get(configFolder));
31271
- const agent = Result.isSuccess(kept) ? Option.map(kept.success, ({ owner, agent }) => ({
31272
- owner,
31273
- agent
31274
- })) : Option.none();
31275
- const grant = yield* grantChecks(harness, kept);
31276
- const plugin = yield* harness === "codex" ? codexChecks(agent) : claudeCodeChecks(agent);
31277
- const before = [...grant, ...plugin];
31278
- const session = yield* sessionCheck(harness, before.every((check) => check.answer === "yes"));
31279
- const checks = [...before, session];
31280
- const report = Option.match(agent, {
31281
- onNone: () => ({
31282
- harness,
31283
- configFolder,
31284
- checks
31285
- }),
31286
- onSome: (kept) => ({
31287
- harness,
32048
+ const statusCommands = [Command.make(harnessCommandNames.status, { json }, ({ json: asJson }) => Effect.gen(function* () {
32049
+ const folders = yield* ConfigFolders;
32050
+ const grants = yield* HostGrant;
32051
+ const current = yield* currentConfigFolder;
32052
+ const found = yield* findConfigFolders;
32053
+ const candidates = /* @__PURE__ */ new Map();
32054
+ for (const given of yield* folders.all) {
32055
+ const folder = yield* folders.resolve(given);
32056
+ if (!candidates.has(folder)) candidates.set(folder, void 0);
32057
+ }
32058
+ for (const { configFolder, harness } of found) candidates.set(configFolder, harness);
32059
+ const paths = [...candidates.keys()].sort((a, b) => a === current ? -1 : b === current ? 1 : 0);
32060
+ const reports = [];
32061
+ for (const configFolder of paths) {
32062
+ const kept = yield* Effect.result(grants.get(configFolder));
32063
+ const grant = Result.isSuccess(kept) ? kept.success : Option.none();
32064
+ const harness = Option.isSome(grant) ? grant.value.harness : candidates.get(configFolder);
32065
+ if (Result.isFailure(kept) || Option.isNone(grant)) {
32066
+ const report = {
32067
+ 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))]
32069
+ };
32070
+ reports.push(harness === void 0 ? report : {
32071
+ ...report,
32072
+ harness
32073
+ });
32074
+ continue;
32075
+ }
32076
+ const checks = [...yield* grantChecks(grant.value, configFolder), ...yield* grant.value.harness === "codex" ? codexChecks(configFolder) : claudeCodeChecks(configFolder)];
32077
+ if (configFolder === current) checks.push(yield* sessionCheck(grant.value.harness, configFolder));
32078
+ reports.push({
32079
+ harness: grant.value.harness,
31288
32080
  configFolder,
31289
- agent: kept,
32081
+ agent: {
32082
+ owner: grant.value.owner,
32083
+ agent: grant.value.agent
32084
+ },
31290
32085
  checks
31291
- })
31292
- });
31293
- yield* printAnswer(yield* Effect.orDie(encodeReport(report)), asJson, wordsOf(report));
31294
- if (checks.some((check) => check.answer === "no")) return yield* new Said();
31295
- })).pipe(Command.withDescription("Says whether ref0 follows this machine’s sessions, and what to do next if it doesn’t"), Command.withShortDescription("Show whether ref0 works here"))];
32086
+ });
32087
+ }
32088
+ const home = yield* Config.String("HOME").pipe(Config.withDefault("."));
32089
+ 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)));
32090
+ if (reports.some((report) => report.checks.some((check) => check.answer === "no"))) return yield* new Said();
32091
+ })).pipe(Command.withDescription("Shows every config folder’s connection and plugin, with this session’s folder first"), Command.withShortDescription("Check every harness config folder"))];
31296
32092
  //#endregion
31297
32093
  //#region src/ref0.ts
31298
32094
  /**
@@ -31331,7 +32127,7 @@ const ref0 = Command.make("ref0").pipe(Command.withDescription("ref0 from the te
31331
32127
  ...agentCommands,
31332
32128
  ...attachmentCommands
31333
32129
  ]));
31334
- const keychain = Keychain.layer.pipe(Layer.provide(NodeServices.layer));
32130
+ const keychain = Keychain.layer.pipe(Layer.provide([NodeServices.layer, Ref0Config.layer]));
31335
32131
  const harnessProcess = HarnessProcess.layer.pipe(Layer.provide(NodeServices.layer));
31336
32132
  const sessionToken = harnessSessionToken.pipe(Layer.provide([keychain, harnessProcess]));
31337
32133
  /** Every service `ref0`'s commands use, on this machine. */
@@ -31339,7 +32135,7 @@ const services = Layer.mergeAll(cliOutput, Ref0.layer.pipe(Layer.provide([
31339
32135
  Ref0Config.layer,
31340
32136
  sessionToken,
31341
32137
  FetchHttpClient.layer
31342
- ])), Ref0Config.layer, sessionToken, FetchHttpClient.layer, keychain, HostGrant.layer.pipe(Layer.provide(keychain)), Machine.layer, Tty.layer, harnessProcess, Checkout.layer.pipe(Layer.provide(NodeServices.layer)), BaseChecks.layer.pipe(Layer.provide([Ref0Config.layer, FetchHttpClient.layer])), Attribution.layer.pipe(Layer.provide([Commits.layer, Checkout.layer])), HarnessTasks.layer, Transcripts.layer, Waker.layer, LiveHints.layer).pipe(Layer.provideMerge(ConfigFolders.layer), Layer.provideMerge(NodeServices.layer));
32138
+ ])), sessionToken, FetchHttpClient.layer, keychain, HostGrant.layer.pipe(Layer.provide(keychain)), OldHostGrants.layer, ConnectLock.layer.pipe(Layer.provide(Processes.layer)), Machine.layer, Tty.layer, harnessProcess, Checkout.layer.pipe(Layer.provide(NodeServices.layer)), BaseChecks.layer.pipe(Layer.provide([Ref0Config.layer, FetchHttpClient.layer])), Attribution.layer.pipe(Layer.provide([Commits.layer, Checkout.layer])), HarnessTasks.layer, Transcripts.layer, Waker.layer, LiveHints.layer).pipe(Layer.provideMerge(ConfigFolders.layer), Layer.provideMerge(Ref0Config.layer), Layer.provideMerge(NodeServices.layer));
31343
32139
  //#endregion
31344
32140
  //#region src/main.ts
31345
32141
  ref0.pipe(Command.run({ version: cliVersion }), Effect.provide(services), NodeRuntime.runMain);