@ref0/cli 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +36 -5
  2. package/dist/main.mjs +2097 -917
  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"),
@@ -6506,39 +6510,59 @@ var PasskeyRefused = class extends Schema.TaggedError()("PasskeyRefused", { next
6506
6510
  //#endregion
6507
6511
  //#region ../contracts/src/attachments.ts
6508
6512
  /** A machine asks, a signed-in person claims and approves, and the answer returns to its terminal. */
6513
+ /**
6514
+ * What could reorder or hide the text around it, such as U+202E: a control, format, surrogate,
6515
+ * private-use or unassigned character, or a line or paragraph separator.
6516
+ */
6517
+ const hiddenCharacters = String.raw`\p{C}\p{Zl}\p{Zp}`;
6518
+ /** Text with none of them, which reads as it's written wherever it's shown. */
6519
+ const shownAsWritten = Schema.isPattern(new RegExp(`^[^${hiddenCharacters}]+$`, "u"));
6520
+ /** Text with each character that could reorder or hide what's around it taken out. */
6521
+ const withoutHiddenCharacters = (text) => text.replaceAll(new RegExp(`[${hiddenCharacters}]`, "gu"), "");
6509
6522
  /** A machine's own word for itself: shown to the person as what it says, never as fact. */
6510
- const MachineName = Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(64));
6523
+ const MachineName = Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(64), shownAsWritten);
6511
6524
  /** The harness a machine says it connects for, never proof of what it runs. */
6512
6525
  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
- */
6526
+ /** The machine's loopback listener; no caller may choose its host. */
6527
+ const AttachmentCallback = Schema.Struct({
6528
+ port: Schema.Int.check(Schema.isBetween({
6529
+ minimum: 1024,
6530
+ maximum: 65535
6531
+ })),
6532
+ state: Schema.String.check(Schema.isPattern(/^[A-Za-z0-9_-]{22}$/))
6533
+ });
6534
+ /** A config folder's path, as the machine shows it, never proof of its location. */
6535
+ const ConfigFolderPath = Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(200), shownAsWritten);
6536
+ const uniqueFolderPaths = Schema.makeFilter((folders) => new Set(folders.map((folder) => folder.path)).size === folders.length, { expected: "unique config folder paths" });
6537
+ const RequestFolder = Schema.Struct({
6538
+ path: ConfigFolderPath,
6539
+ harness: ConnectHarness,
6540
+ secretHash: SecretHash,
6541
+ replaces: Schema.optionalKey(TokenId)
6542
+ });
6543
+ /** One to eight config folders, each with its own grant secret. */
6517
6544
  const RequestAttachment = Schema.Struct({
6518
6545
  machine: MachineId,
6519
6546
  machineName: MachineName,
6520
- harness: Schema.optionalKey(ConnectHarness),
6521
- secretHash: SecretHash,
6547
+ folders: Schema.Array(RequestFolder).check(Schema.isMinLength(1), Schema.isMaxLength(8), uniqueFolderPaths),
6522
6548
  collectHash: SecretHash,
6523
6549
  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
- }))
6550
+ callback: Schema.optionalKey(AttachmentCallback)
6531
6551
  });
6532
6552
  /** An attachment request, as its person reads it once they type its code. */
6533
6553
  const AttachmentRequestView = Schema.Struct({
6534
6554
  approval: ApprovalId,
6535
6555
  /** The machine's own word for itself, never fact. */
6536
6556
  machineName: MachineName,
6537
- /** The harness the machine says it uses, never fact. */
6538
- harness: Schema.optionalKey(ConnectHarness),
6557
+ folders: Schema.Array(Schema.Struct({
6558
+ path: ConfigFolderPath,
6559
+ harness: ConnectHarness,
6560
+ replaces: Schema.optionalKey(TokenId)
6561
+ })),
6539
6562
  askedFrom: Schema.optionalKey(Schema.String),
6540
6563
  askedAt: Timestamp,
6541
- expiresAt: Timestamp
6564
+ expiresAt: Timestamp,
6565
+ callback: Schema.optionalKey(AttachmentCallback)
6542
6566
  });
6543
6567
  /**
6544
6568
  * What its answerer chooses to connect a machine's request to: one of the person's own agents,
@@ -6562,6 +6586,16 @@ const ConnectOrganization = Schema.Struct({
6562
6586
  organization: OrgId,
6563
6587
  name: OwnerName
6564
6588
  });
6589
+ /** Whether admission replaced a live host grant, or skipped one that no longer belongs here. */
6590
+ const GrantReplacement = Schema.Struct({
6591
+ grant: TokenId,
6592
+ state: Schema.Literals([
6593
+ "replaces",
6594
+ "revoked",
6595
+ "skipped"
6596
+ ]),
6597
+ attachedAt: Schema.optionalKey(Timestamp)
6598
+ });
6565
6599
  /**
6566
6600
  * What a yes would connect the machine to, as options resolved the choice: the agent, or none
6567
6601
  * yet, to make with the handle; and each organization its attachment is authorized in.
@@ -6569,22 +6603,32 @@ const ConnectOrganization = Schema.Struct({
6569
6603
  const ConnectChosen = Schema.Struct({
6570
6604
  agent: Schema.optionalKey(AgentRef),
6571
6605
  handle: AgentHandle,
6572
- organizations: Schema.Array(ConnectOrganization)
6606
+ organizations: Schema.Array(ConnectOrganization),
6607
+ replacement: Schema.optionalKey(GrantReplacement)
6573
6608
  });
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)
6609
+ /** A complete choice: name every folder once, leaving at least one in. */
6610
+ const ConnectFolderChoice = Schema.Union([Schema.Struct({
6611
+ path: ConfigFolderPath,
6612
+ excluded: Schema.Literal(true)
6583
6613
  }), Schema.Struct({
6614
+ path: ConfigFolderPath,
6615
+ agent: ConnectAgent
6616
+ })]);
6617
+ const ConnectFolderChoices = Schema.Array(ConnectFolderChoice).check(Schema.isMinLength(1), Schema.isMaxLength(8), uniqueFolderPaths);
6618
+ const ConnectFolderOption = Schema.Union([Schema.Struct({
6619
+ path: ConfigFolderPath,
6620
+ excluded: Schema.Literal(true)
6621
+ }), Schema.Struct({
6622
+ path: ConfigFolderPath,
6623
+ connect: ConnectChosen
6624
+ })]);
6625
+ /** Defaults and explicit choices both resolve the entire request and sign one challenge. */
6626
+ const ConnectOptions = Schema.Struct({
6584
6627
  request: AttachmentRequestView,
6585
- connect: ConnectChosen,
6628
+ agents: Schema.Array(ConnectableAgent),
6629
+ folders: Schema.Array(ConnectFolderOption),
6586
6630
  passkey: PasskeyRequestOptions
6587
- })]);
6631
+ });
6588
6632
  /** Why connecting connected nothing, said for the person. */
6589
6633
  const AttachmentFailure = Schema.Struct({
6590
6634
  reason: Schema.Literals([
@@ -6612,11 +6656,9 @@ const RequestOrganization = Schema.Struct({
6612
6656
  ])
6613
6657
  });
6614
6658
  /**
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.
6659
+ * The result collected with the machine's two proofs: each included folder's identity and grant,
6660
+ * its agent's owner name and organizations, and excluded paths. Confirmation checks each saved
6661
+ * grant's live deadline in the Owner DO.
6620
6662
  */
6621
6663
  const RequestState = Schema.Union([
6622
6664
  Schema.Struct({
@@ -6626,20 +6668,30 @@ const RequestState = Schema.Union([
6626
6668
  Schema.Struct({
6627
6669
  state: Schema.Literal("connected"),
6628
6670
  owner: OwnerName,
6629
- agent: AgentRef,
6630
- handle: AgentHandle,
6631
- credential: CredentialRef,
6632
- organizations: Schema.Array(RequestOrganization)
6671
+ folders: Schema.Array(Schema.Struct({
6672
+ path: ConfigFolderPath,
6673
+ owner: OwnerName,
6674
+ ...MachineConnected.fields,
6675
+ organizations: Schema.Array(RequestOrganization),
6676
+ replacement: Schema.optionalKey(GrantReplacement)
6677
+ })),
6678
+ excluded: Schema.Array(ConfigFolderPath)
6633
6679
  }),
6634
6680
  Schema.Struct({ state: Schema.Literal("refused") }),
6635
6681
  Schema.Struct({ state: Schema.Literal("aborted") }),
6636
6682
  Schema.Struct({ state: Schema.Literal("expired") }),
6637
6683
  Schema.Struct({
6638
6684
  state: Schema.Literal("failed"),
6639
- reason: Schema.Literals([...AttachmentFailure.fields.reason.literals, "not confirmed"]),
6685
+ reason: AttachmentFailure.fields.reason,
6640
6686
  nextStep: Schema.String
6641
6687
  })
6642
6688
  ]);
6689
+ /** The grant IDs saved by this terminal, each belonging to the same admission. */
6690
+ const SavedMachineGrants = Schema.Array(TokenId).check(Schema.isMinLength(1), Schema.isMaxLength(8), Schema.isUnique());
6691
+ Schema.Struct({
6692
+ approval: ApprovalId,
6693
+ grants: SavedMachineGrants
6694
+ });
6643
6695
  /**
6644
6696
  * The machine saying it saved the grant it collected: confirmed, the same again, or not, once ref0
6645
6697
  * has taken the grant to revoke it, and the machine deletes what it saved.
@@ -6670,21 +6722,34 @@ const AuthorizeIn = Schema.Struct({
6670
6722
  organization: OrgId,
6671
6723
  targetGeneration: TargetGeneration
6672
6724
  });
6673
- /** What every connect names: the agent, the machine and its host grant, and what it was bound to. */
6674
- const connectFields = {
6725
+ /** One folder admitted under the request's machine and approving browser. */
6726
+ const ConnectFolderCommand = Schema.Struct({
6727
+ path: ConfigFolderPath,
6675
6728
  agent: AttachmentAgent,
6676
- machine: MachineId,
6677
- machineName: MachineName,
6678
6729
  token: TokenId,
6679
6730
  secretHash: SecretHash,
6680
- browserSession: TokenId,
6681
- personGeneration: Generation,
6731
+ replaces: Schema.optionalKey(TokenId),
6682
6732
  authorizeIn: Schema.Array(AuthorizeIn)
6683
- };
6733
+ });
6684
6734
  Schema.Struct({
6685
6735
  request: ApprovalId,
6686
- ...connectFields
6736
+ machine: MachineId,
6737
+ machineName: MachineName,
6738
+ browserSession: TokenId,
6739
+ personGeneration: Generation,
6740
+ folders: Schema.Array(ConnectFolderCommand).check(Schema.isMinLength(1), Schema.isMaxLength(8), uniqueFolderPaths),
6741
+ excluded: Schema.Array(ConfigFolderPath).check(Schema.isMaxLength(7), Schema.isUnique())
6742
+ });
6743
+ const FoldersConnected = Schema.Struct({
6744
+ folders: Schema.Array(Schema.Struct({
6745
+ path: ConfigFolderPath,
6746
+ ...MachineConnected.fields,
6747
+ replacement: Schema.optionalKey(GrantReplacement)
6748
+ })),
6749
+ excluded: Schema.Array(ConfigFolderPath)
6687
6750
  });
6751
+ Schema.Array(GrantReplacement);
6752
+ Schema.Array(TokenId).check(Schema.isMaxLength(8));
6688
6753
  /** An attachment that isn't there, or isn't the caller's, answers the same. */
6689
6754
  var AttachmentNotFound = class extends Schema.TaggedError()("AttachmentNotFound", { nextStep: Schema.String }, { httpApiStatus: 404 }) {};
6690
6755
  /** Connecting connected nothing, and why: what an attachment's approval came to instead. */
@@ -6866,7 +6931,7 @@ const FindApproval = Schema.Union([Schema.Struct({ code: TypedApprovalCode }), S
6866
6931
  */
6867
6932
  const ApprovalOptionsRequest = Schema.Struct({
6868
6933
  code: Schema.optionalKey(TypedApprovalCode),
6869
- agent: Schema.optionalKey(ConnectAgent)
6934
+ folders: Schema.optionalKey(ConnectFolderChoices)
6870
6935
  });
6871
6936
  /**
6872
6937
  * An answer: yes, with the passkey, or no. For an agent's ask, each answer can set the owner's
@@ -6919,7 +6984,11 @@ const ApprovalState = Schema.Union([
6919
6984
  state: Schema.Literal("waiting"),
6920
6985
  expiresAt: Timestamp
6921
6986
  }),
6922
- Schema.Struct({ state: Schema.Literal("approved") }),
6987
+ Schema.Struct({
6988
+ state: Schema.Literal("approved"),
6989
+ /** A machine's answering browser may forget its saved answer once the terminal kept it. */
6990
+ collected: Schema.optionalKey(Schema.Literal(true))
6991
+ }),
6923
6992
  Schema.Struct({ state: Schema.Literal("refused") }),
6924
6993
  Schema.Struct({ state: Schema.Literal("expired") }),
6925
6994
  Schema.Struct({
@@ -11706,10 +11775,12 @@ Schema.Union([
11706
11775
  NotPermitted
11707
11776
  ]);
11708
11777
  /**
11709
- * A session's fork's path on ref0's host. Its thread's, `/{owner}/{project}/threads/{n}/{repo}.git`
11710
- * on a project's thread and `/{owner}/threads/{n}/{repo}.git` on an org thread, reaches the fork
11711
- * of the session whose token asks, and takes its pushes. A session's,
11712
- * `…/threads/{n}/sessions/{m}/{repo}.git`, reads that session's fork, and takes no push.
11778
+ * A fork's path on ref0's host. Its thread's, `/{owner}/{project}/threads/{n}/{repo}.git` on a
11779
+ * project's thread and `/{owner}/threads/{n}/{repo}.git` on an org thread, reaches the fork of the
11780
+ * session whose token asks, and takes its pushes. A session's,
11781
+ * `…/threads/{n}/sessions/{m}/{repo}.git`, reads that session's current fork, and a fork's,
11782
+ * `…/threads/{n}/forks/{fork}/{repo}.git`, reads that fork when it holds thread `n`'s work, a
11783
+ * session's on it or a run's whose pull request is on it. Neither takes a push.
11713
11784
  */
11714
11785
  const ForkPath = Schema.String.check(Schema.isPattern(/^\/[^\s]+\.git$/), Schema.isMaxLength(512)).pipe(Schema.brand("ForkPath"));
11715
11786
  /** A session's fork's URL on the app's one origin: the one a notice names, and a clone pushes to. */
@@ -11733,17 +11804,21 @@ const ForkView = Schema.Struct({
11733
11804
  /** On the app's one origin, whichever host was asked. */
11734
11805
  url: ForkUrl
11735
11806
  });
11736
- Schema.Struct({
11807
+ /**
11808
+ * For a reader that sees only threads it holds a grant on, those threads: a fork of a thread
11809
+ * stacked, at any depth, on any other is refused, since it holds that thread's work. Unset for one
11810
+ * that reads the project as a whole.
11811
+ */
11812
+ const readerSees = { only: Schema.optionalKey(Schema.Array(ThreadId)) };
11813
+ Schema.Union([Schema.Struct({
11737
11814
  session: SessionId,
11738
- fork: Schema.optionalKey(ForkId),
11739
- /**
11740
- * For a reader that sees only threads it holds a grant on, those threads: a fork of a thread
11741
- * stacked, at any depth, on any other is refused, since it holds that thread's work. Unset for
11742
- * one that reads the project as a whole.
11743
- */
11744
- only: Schema.optionalKey(Schema.Array(ThreadId))
11745
- });
11746
- /** A session's fork in Artifacts, as Git reaches it: where it is, and which Git repository. */
11815
+ ...readerSees
11816
+ }), Schema.Struct({
11817
+ fork: ForkId,
11818
+ thread: ThreadId,
11819
+ ...readerSees
11820
+ })]);
11821
+ /** A fork in Artifacts, as Git reaches it: where it is, and which Git repository. */
11747
11822
  const ForkPlace = Schema.Struct({
11748
11823
  ...CurrentBaseline.fields,
11749
11824
  gitRepository: GitRepositoryId
@@ -11774,19 +11849,16 @@ const SessionBase = Schema.Struct({
11774
11849
  commit: CommitOid,
11775
11850
  /**
11776
11851
  * Where to fetch it from, on ref0's origin: the repo's own path, as `ref0 clone` cloned it, for
11777
- * `main`; for a stack, the path of the fork that holds the pin.
11852
+ * `main`; for a stack, the path of the fork that holds the pin, by its ID under the thread below.
11778
11853
  */
11779
11854
  path: Schema.String.check(Schema.isPattern(/^\/[^\s]+\.git$/), Schema.isMaxLength(512)),
11780
11855
  /** `path` on the app's one origin. A client checks it's a fork of its own repo there. */
11781
11856
  url: Schema.String,
11782
11857
  ref: Schema.Literal("refs/heads/main")
11783
11858
  });
11784
- /**
11785
- * No base to fetch: `main` has no commit yet, or the pin of the thread below is in a workflow
11786
- * run's fork, which has no session's path to fetch from.
11787
- */
11859
+ /** No base to fetch: `main` has no commit yet. */
11788
11860
  var NoBase = class extends Schema.TaggedError()("NoBase", {
11789
- why: Schema.Literals(["main empty", "held by a run"]),
11861
+ why: Schema.Literal("main empty"),
11790
11862
  nextStep: Schema.String
11791
11863
  }, { httpApiStatus: 404 }) {};
11792
11864
  Schema.Struct({
@@ -11804,15 +11876,14 @@ const BaseOnMain = Schema.Struct({
11804
11876
  commit: CommitOid
11805
11877
  });
11806
11878
  /**
11807
- * The base on the thread below, `lower`: its pin's commit, the fork that holds it, and that
11808
- * fork's session, or none for a workflow run's fork.
11879
+ * The base on the thread below, `lower`: its pin's commit, and the fork that holds it, a session's
11880
+ * on that thread or a run's whose pull request is on it.
11809
11881
  */
11810
11882
  const BaseOnStack = Schema.Struct({
11811
11883
  on: Schema.Literal("stack"),
11812
11884
  lower: ThreadId,
11813
11885
  commit: CommitOid,
11814
- fork: ForkId,
11815
- session: Schema.NullOr(SessionId)
11886
+ fork: ForkId
11816
11887
  });
11817
11888
  Schema.Union([
11818
11889
  BaseOnMain,
@@ -11860,10 +11931,10 @@ const GateInPlaceView = Schema.Struct({
11860
11931
  /**
11861
11932
  * One `from`'s yes over the run that counts for a part that needs one (L2), as `passkey.check`
11862
11933
  * 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.
11934
+ * `expired`, its day passed unanswered; `refused`, asking nobody, `why`; or `revoked`, a yes whose
11935
+ * answerer a miss revoked since, until an admin restores them. A local run's no or day passing
11936
+ * waits for a newer local run; a ref0 run's, or a refusal, leaves the part with no yes, until an
11937
+ * admin overrides it.
11867
11938
  */
11868
11939
  const GatePartYes = Schema.Struct({
11869
11940
  from: PasskeyFrom,
@@ -11875,6 +11946,12 @@ const GatePartYes = Schema.Struct({
11875
11946
  "refused",
11876
11947
  "revoked"
11877
11948
  ]),
11949
+ /**
11950
+ * Why a request was refused: `target`, nobody it names can be asked, a person who isn't there
11951
+ * or can't see the pull request's thread; or `self`, it names the person behind the run, or a
11952
+ * permission only they hold, who can't be the second pair of eyes over their own run.
11953
+ */
11954
+ why: Schema.optionalKey(Schema.Literals(["target", "self"])),
11878
11955
  asked: Schema.optionalKey(PersonId),
11879
11956
  by: Schema.optionalKey(OwnerName),
11880
11957
  at: Schema.optionalKey(Schema.DateTimeUtcFromMillis)
@@ -14912,6 +14989,11 @@ var ApprovalSecret = class extends HttpApiMiddleware.Service()("ref0/contracts/A
14912
14989
  security: { bearer: HttpApiSecurity.bearer },
14913
14990
  error: [Unauthenticated]
14914
14991
  }) {};
14992
+ /**
14993
+ * A machine's request whose name its contract refuses, answered as `CommandRefused`, which says
14994
+ * what to change. Any other part that fails its contract is answered with Effect's empty 400.
14995
+ */
14996
+ var MachineNameRefusal = class extends HttpApiMiddleware.Service()("ref0/contracts/MachineNameRefusal", { error: CommandRefused }) {};
14915
14997
  const idempotency = { "idempotency-key": RequestId };
14916
14998
  const inProject$1 = {
14917
14999
  owner: OwnerRef,
@@ -15035,14 +15117,17 @@ var MachineAttachmentsGroup = class extends HttpApiGroup.make("machineAttachment
15035
15117
  SlowDown,
15036
15118
  AuthorityUnavailable
15037
15119
  ]
15038
- }), HttpApiEndpoint.post("collect", "/v1/attachments/requests/:approval/result", {
15120
+ }).middleware(MachineNameRefusal), HttpApiEndpoint.post("collect", "/v1/attachments/requests/:approval/result", {
15039
15121
  params: { approval: ApprovalId },
15040
15122
  payload: Schema.Struct({ answerCode: Schema.optionalKey(Schema.String.check(Schema.isMaxLength(64))) }),
15041
15123
  success: RequestState,
15042
15124
  error: [AttachmentNotFound, AuthorityUnavailable]
15043
15125
  }).middleware(ApprovalSecret), HttpApiEndpoint.post("confirm", "/v1/attachments/requests/:approval/collected", {
15044
15126
  params: { approval: ApprovalId },
15045
- payload: Schema.Struct({ answerCode: Schema.optionalKey(Schema.String.check(Schema.isMaxLength(64))) }),
15127
+ payload: Schema.Struct({
15128
+ grants: SavedMachineGrants,
15129
+ answerCode: Schema.optionalKey(Schema.String.check(Schema.isMaxLength(64)))
15130
+ }),
15046
15131
  headers: idempotency,
15047
15132
  success: RequestConfirmed,
15048
15133
  error: [AttachmentNotFound, AuthorityUnavailable]
@@ -18086,7 +18171,7 @@ const schemasOf = (name) => Option.fromNullishOr(reflected.get(name));
18086
18171
  * keeps what it came to as `/v1` would answer it.
18087
18172
  */
18088
18173
  const connectAnswers = {
18089
- successes: [Schema.toCodecJson(MachineConnected)],
18174
+ successes: [Schema.toCodecJson(FoldersConnected)],
18090
18175
  refusals: /* @__PURE__ */ new Map([[409, [Schema.toCodecJson(AttachmentFailed)]]])
18091
18176
  };
18092
18177
  const isEndpointName = (name) => name in surfaces;
@@ -18548,7 +18633,7 @@ const HarnessBinding = Schema.Struct({
18548
18633
  //#region ../contracts/src/harness-status.ts
18549
18634
  /**
18550
18635
  * 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.
18636
+ * in every initialized or connected folder, as an array of folder reports.
18552
18637
  */
18553
18638
  /** A check's answer: unknown when ref0 couldn't ask, never a guess. */
18554
18639
  const StatusAnswer = Schema.Literals([
@@ -18556,7 +18641,7 @@ const StatusAnswer = Schema.Literals([
18556
18641
  "no",
18557
18642
  "unknown"
18558
18643
  ]);
18559
- /** One line of the report: what's checked, its answer and why, and for a no, what to do next. */
18644
+ /** One line of the report: what's checked, its answer and why, and for any non-yes answer, the command to run next. */
18560
18645
  const StatusCheck = Schema.Struct({
18561
18646
  check: Schema.Literals([
18562
18647
  "connected",
@@ -18569,9 +18654,9 @@ const StatusCheck = Schema.Struct({
18569
18654
  says: Schema.String,
18570
18655
  next: Schema.optionalKey(Schema.String)
18571
18656
  });
18572
- /** The report: the harness and its config folder, the agent it starts sessions as, and checks. */
18657
+ /** One folder: the harness, if known, and its config folder, the agent it starts sessions as, and checks. */
18573
18658
  const StatusReport = Schema.Struct({
18574
- harness: Schema.Literals(["claude-code", "codex"]),
18659
+ harness: Schema.optionalKey(Schema.Literals(["claude-code", "codex"])),
18575
18660
  configFolder: Schema.String,
18576
18661
  agent: Schema.optionalKey(Schema.Struct({
18577
18662
  owner: OwnerName,
@@ -18624,24 +18709,72 @@ const newRequestId = Effect.gen(function* () {
18624
18709
  return yield* Effect.orDie(decodeRequestId$1(`request_${encodeSuffix(uuidV7(millis, random))}`));
18625
18710
  });
18626
18711
  //#endregion
18712
+ //#region src/sha256.ts
18713
+ /** Bytes as lowercase hex. */
18714
+ const hex = (bytes) => Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
18715
+ const encoder$1 = new TextEncoder();
18716
+ /** The SHA-256 of a text's UTF-8 bytes, as lowercase hex. */
18717
+ const sha256 = (text) => Effect.flatMap(Crypto.Crypto, (crypto) => Effect.map(Effect.orDie(crypto.digest("SHA-256", encoder$1.encode(text))), hex));
18718
+ //#endregion
18719
+ //#region src/origin.ts
18720
+ /**
18721
+ * ref0's own API origin: the one the CLI and the plugin's launcher use when neither `REF0_API`
18722
+ * nor a clone's `ref0.api` names another.
18723
+ */
18724
+ const defaultOrigin = ApiOrigin.make("https://ref0.dev");
18725
+ /** A normalized origin as one reversible, path-safe storage key. */
18726
+ const originKey = (api) => encodeURIComponent(new URL(api).origin);
18727
+ /**
18728
+ * The name an origin's plugin, marketplace and MCP server install under. ref0's own keeps the
18729
+ * public plugin's, `ref0`, so its status command is `/ref0:status` in Claude Code and
18730
+ * `$ref0:status` in Codex. Every other is `ref0-` and 128 bits of its SHA-256, which keep plugin
18731
+ * IDs and qualified hook-tool names within harness limits, so no two origins share plugins,
18732
+ * hooks or servers.
18733
+ */
18734
+ const originPluginName = (api) => {
18735
+ const origin = new URL(api).origin;
18736
+ if (origin === defaultOrigin) return Effect.succeed("ref0");
18737
+ return Effect.map(sha256(origin), (digest) => `ref0-${digest.slice(0, 32)}`);
18738
+ };
18739
+ //#endregion
18627
18740
  //#region src/config.ts
18628
18741
  /**
18629
18742
  * `REF0_API` as a person sets it, any trailing `/` left off, read as ref0's origin. One that isn't
18630
18743
  * an origin, such as a bare host or one with a path, fails the CLI at start, naming `REF0_API`.
18631
18744
  */
18632
18745
  const ApiFromEnv = Schema.String.pipe(Schema.decodeTo(ApiOrigin, SchemaTransformation.transform({
18633
- decode: (api) => api.replace(/\/+$/, ""),
18746
+ decode: (api) => api.replace(/^https?:/i, (scheme) => scheme.toLowerCase()).replace(/\/+$/, ""),
18634
18747
  encode: (api) => api
18635
18748
  })));
18636
18749
  /**
18637
- * Where the CLI finds ref0: the API's origin, from `REF0_API`, or ref0's own. One value, as the
18750
+ * 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
18751
  * domain is one value everywhere.
18639
18752
  */
18640
18753
  var Ref0Config = class Ref0Config extends Context.Service()("ref0/cli/Ref0Config") {
18641
18754
  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
- }));
18755
+ const explicit = yield* Config.schema(ApiFromEnv, "REF0_API").pipe(Config.option);
18756
+ let api = Option.getOrUndefined(explicit);
18757
+ if (api === void 0) {
18758
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
18759
+ 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." }));
18760
+ api = yield* Effect.scoped(Effect.gen(function* () {
18761
+ const handle = yield* spawner.spawn(ChildProcess.make("git", [
18762
+ "config",
18763
+ "--get",
18764
+ "ref0.api"
18765
+ ]));
18766
+ const [checkout, , code] = yield* Effect.all([
18767
+ handle.stdout.pipe(Stream.decodeText(), Stream.mkString),
18768
+ handle.stderr.pipe(Stream.runDrain),
18769
+ handle.exitCode
18770
+ ], { concurrency: "unbounded" });
18771
+ if (code === ChildProcessSpawner.ExitCode(1)) return defaultOrigin;
18772
+ if (code !== ChildProcessSpawner.ExitCode(0)) return yield* Effect.fail(unavailable);
18773
+ return yield* Config.schema(ApiFromEnv, "ref0.api").parse(ConfigProvider.fromUnknown({ "ref0.api": checkout.trim() }));
18774
+ })).pipe(Effect.catchTag("PlatformError", () => Effect.fail(unavailable)));
18775
+ }
18776
+ return Ref0Config.of({ api: ApiOrigin.make(new URL(api).origin) });
18777
+ })).pipe(Layer.provide(NodeServices.layer));
18645
18778
  };
18646
18779
  //#endregion
18647
18780
  //#region src/secret.ts
@@ -18952,11 +19085,19 @@ const renderLine = (line, terminal) => {
18952
19085
  return `${line.status === "yes" ? styled("✓", 32, terminal) : styled("✗", 31, terminal)} ${line.status} ${printable(line.text)}`;
18953
19086
  };
18954
19087
  const renderBlocks = (blocks, stream) => {
18955
- const terminal = process[stream].isTTY === true && !process.env["NO_COLOR"];
19088
+ const terminal = process[stream].isTTY === true && process.env["NO_COLOR"] === void 0;
18956
19089
  return blocks.flatMap((block) => block.length === 0 ? [] : [block.map((line) => renderLine(line, terminal)).join("\n")]).join("\n\n");
18957
19090
  };
18958
19091
  /** Words go to stderr by default; a command's answer goes to stdout. */
18959
19092
  const printBlocks = (blocks, stream = "stderr") => Effect.sync(() => renderBlocks(blocks, stream)).pipe(Effect.flatMap((text) => stream === "stdout" ? Console.log(text) : Console.error(text)));
19093
+ /**
19094
+ * A yes-or-no question as the terminal shows it, bold, then `(y/N)`: no unless the person says
19095
+ * yes. The cursor stays after it, for their answer.
19096
+ */
19097
+ const questionLine = (question) => `${renderBlocks([[{
19098
+ label: question,
19099
+ text: "(y/N)"
19100
+ }]], "stdout")} `;
18960
19101
  /** Keep explicit line breaks and wrap between words within the available columns. */
18961
19102
  const wrapWords = (text, width) => text.split("\n").flatMap((line) => {
18962
19103
  const wrapped = [];
@@ -19025,6 +19166,23 @@ const accountCommands = [Command.make("whoami", { json }, ({ json: asJson }) =>
19025
19166
  yield* printAnswer(answer.json, asJson, me.agent === void 0 ? `You, as ${me.actor.id}.` : `Agent ${me.agent.id}, in session ${me.session?.session ?? "none"}.`);
19026
19167
  }).pipe(Effect.catchTag("CallFailed", (failed) => sayFailed(failed.nextStep)))).pipe(Command.withDescription("Who’s acting, and what they hold"), Command.withShortDescription("Show who’s acting and what they hold"))];
19027
19168
  //#endregion
19169
+ //#region ../model/src/agent-commits.ts
19170
+ /**
19171
+ * How an agent's commits name it as their author: `pip/claude <agent_…@ref0.work>`. The person
19172
+ * stays the committer, with their own signature. Git keeps every commit for good, and anyone
19173
+ * who reads the repo reads its author, so these name the agent and nothing about its sessions.
19174
+ */
19175
+ /**
19176
+ * An agent's commit email: built from its agent ID alone, so it never changes, and never passes
19177
+ * to another agent. Nobody reads mail there.
19178
+ */
19179
+ const agentCommitEmail = (agent) => `${agent}@ref0.work`;
19180
+ /**
19181
+ * An agent's name as its commits' author: its owner, then its handle, as commands write it. A
19182
+ * handle renamed later stays as it was in the commits made before.
19183
+ */
19184
+ const agentCommitName = (owner, handle) => `${owner}/${handle}`;
19185
+ //#endregion
19028
19186
  //#region ../model/src/cut-text.ts
19029
19187
  /**
19030
19188
  * `text` within `max` as a `Schema`'s length counts it, in UTF-16 code units: whole code points
@@ -19305,13 +19463,13 @@ function freeTextProblem(text, maxCharacters) {
19305
19463
  //#endregion
19306
19464
  //#region ../model/src/git-paths.ts
19307
19465
  /**
19308
- * A session's fork's path: an owner, a project for a project's thread and none for an org
19309
- * thread's, a thread, a session when it names one and the source fork when that names one too, a
19310
- * repo, and what Git asks for. The two thread forms never read alike: after `threads` comes a
19311
- * number on an org thread's, and the word `threads` on a project's, so a project named `threads`
19312
- * keeps its forks. Nor do the two fork forms: a repo's name holds no `/`.
19466
+ * A fork's path: an owner, a project for a project's thread and none for an org thread's, a
19467
+ * thread, then a session or a fork by its ID when it names one, a repo, and what Git asks for.
19468
+ * The two thread forms never read alike: after `threads` comes a number on an org thread's, and
19469
+ * the word `threads` on a project's, so a project named `threads` keeps its forks. Nor do the three
19470
+ * fork forms: a repo's name holds no `/`.
19313
19471
  */
19314
- const forkPath = /^\/([^/]+)(?:\/([^/]+))?\/threads\/([1-9]\d{0,9})(?:\/sessions\/([1-9]\d{0,9})(?:\/forks\/(fork_[a-z0-9]+))?)?\/([^/]+)\.git\/(info\/refs|git-upload-pack|git-receive-pack)$/;
19472
+ const forkPath = /^\/([^/]+)(?:\/([^/]+))?\/threads\/([1-9]\d{0,9})(?:\/sessions\/([1-9]\d{0,9})|\/forks\/(fork_[a-z0-9]+))?\/([^/]+)\.git\/(info\/refs|git-upload-pack|git-receive-pack)$/;
19315
19473
  //#endregion
19316
19474
  //#region ../model/src/thread-workflows.ts
19317
19475
  const minuteMillis = 6e4;
@@ -20092,97 +20250,353 @@ const blockedCommands = [Command.make("blocked").pipe(Command.withDescription("W
20092
20250
  clearing("met", "Mark an outside blocker met, or lift a hold")
20093
20251
  ]))];
20094
20252
  //#endregion
20253
+ //#region src/connect-browser.ts
20254
+ /** The desktop opener, checked before a listener is started. SSH gets only the paste. */
20255
+ const browserOpener = Effect.gen(function* () {
20256
+ const system = yield* (yield* ChildProcessSpawner.ChildProcessSpawner).string(ChildProcess.make("uname", ["-s"])).pipe(Effect.orElseSucceed(() => ""));
20257
+ if (system.trim() === "Darwin") return "/usr/bin/open";
20258
+ if (system.trim() !== "Linux") return void 0;
20259
+ const display = yield* Config.String("DISPLAY").pipe(Config.withDefault(""));
20260
+ const wayland = yield* Config.String("WAYLAND_DISPLAY").pipe(Config.withDefault(""));
20261
+ if (display === "" && wayland === "") return void 0;
20262
+ const search = yield* Config.String("PATH").pipe(Config.withDefault(""));
20263
+ for (const folder of search.split(":")) {
20264
+ const program = `${folder || "."}/xdg-open`;
20265
+ const executable = yield* Effect.tryPromise(() => access(program, constants.X_OK)).pipe(Effect.option);
20266
+ if (Option.isSome(executable)) return program;
20267
+ }
20268
+ });
20269
+ /** Starts the browser independently; a missing or failing desktop never stops the paste. */
20270
+ const openBrowser = (program, link) => Effect.sync(() => {
20271
+ const failed = () => Effect.runFork(Console.error("Couldn’t open the browser. Open the printed link yourself."));
20272
+ try {
20273
+ const child = spawn(program, [link], {
20274
+ detached: true,
20275
+ stdio: "ignore",
20276
+ shell: false
20277
+ });
20278
+ child.once("error", failed);
20279
+ child.once("exit", (code) => {
20280
+ if (code !== 0) failed();
20281
+ });
20282
+ child.unref();
20283
+ } catch {
20284
+ failed();
20285
+ }
20286
+ });
20287
+ const isAnswer = Schema.is(TypedApprovalCode);
20288
+ /** One form answer from ref0's own page, on IPv4 loopback only, owned by the waiting scope. */
20289
+ const listenForAnswer = Effect.fnUntraced(function* (origin) {
20290
+ const state = (yield* freshLinkSecret).secret;
20291
+ const answer = yield* Deferred.make();
20292
+ let taken = false;
20293
+ const server = createServer((request, response) => {
20294
+ if (taken || request.method !== "POST" || request.url !== `/${state}` || request.headers.origin !== origin || request.headers["content-type"]?.split(";")[0] !== "application/x-www-form-urlencoded") {
20295
+ response.writeHead(404).end();
20296
+ request.resume();
20297
+ return;
20298
+ }
20299
+ let body = "";
20300
+ request.setTimeout(5e3, () => request.destroy());
20301
+ request.on("error", () => void 0);
20302
+ request.on("data", (chunk) => {
20303
+ body += chunk.toString("utf8");
20304
+ if (body.length > 1024) request.destroy();
20305
+ });
20306
+ request.on("end", () => {
20307
+ const fields = new URLSearchParams(body);
20308
+ const code = fields.get("answer");
20309
+ if (taken || code === null || fields.getAll("answer").length !== 1 || !isAnswer(code)) {
20310
+ response.writeHead(404).end();
20311
+ return;
20312
+ }
20313
+ taken = true;
20314
+ response.writeHead(200, {
20315
+ "content-type": "text/plain; charset=utf-8",
20316
+ "cache-control": "no-store"
20317
+ });
20318
+ response.end("Connected. Go back to your terminal.");
20319
+ server.close();
20320
+ Deferred.doneUnsafe(answer, Exit.succeed(code));
20321
+ });
20322
+ });
20323
+ server.maxConnections = 16;
20324
+ server.headersTimeout = 5e3;
20325
+ server.requestTimeout = 5e3;
20326
+ const stop = Effect.sync(() => {
20327
+ taken = true;
20328
+ server.close();
20329
+ server.closeAllConnections();
20330
+ });
20331
+ yield* Effect.acquireRelease(Effect.callback((resume) => {
20332
+ server.once("error", () => resume(Effect.fail(new ListenerUnavailable())));
20333
+ server.listen(0, "127.0.0.1", () => resume(Effect.void));
20334
+ }), () => stop);
20335
+ const address = server.address();
20336
+ if (address === null || Predicate.isString(address)) return yield* Effect.die("No loopback port");
20337
+ return {
20338
+ callback: {
20339
+ port: address.port,
20340
+ state
20341
+ },
20342
+ answer: Deferred.await(answer),
20343
+ stop
20344
+ };
20345
+ });
20346
+ var ListenerUnavailable = class extends Schema.TaggedError()("ListenerUnavailable", {}) {};
20347
+ //#endregion
20348
+ //#region src/names.ts
20349
+ /**
20350
+ * The names one part of the CLI writes and another reads, each defined here once: the commands,
20351
+ * flags and variable a harness's plugin runs `ref0` with, which their definitions take too, and
20352
+ * the variables `ref0 watch` sets for the command it runs, which a harness that command starts
20353
+ * reads.
20354
+ */
20355
+ /**
20356
+ * The commands a harness runs: `ref0 mcp`, `ref0 hook` with each of its command hooks, and
20357
+ * `ref0 status` for the plugin's own command.
20358
+ */
20359
+ const harnessCommandNames = {
20360
+ mcp: "mcp",
20361
+ hook: "hook",
20362
+ sessionStart: "session-start",
20363
+ sessionEnd: "session-end",
20364
+ status: "status"
20365
+ };
20366
+ /** The flag a plugin passes its command hooks: the hook protocol their declarations speak. */
20367
+ const harnessFlagNames = { protocol: "protocol" };
20368
+ /**
20369
+ * What the plugin's lookup script and server launcher put in `ref0`'s environment: the plugin's
20370
+ * release, which no hook's declaration carries.
20371
+ */
20372
+ const pluginVersionVariable = "REF0_PLUGIN_VERSION";
20373
+ /** A flag as a command line writes it. */
20374
+ const flagWord = (name) => `--${name}`;
20375
+ /**
20376
+ * What `ref0 watch` puts in the environment of the command it runs: the thread, the event that
20377
+ * started it, and its title.
20378
+ */
20379
+ const watchVariables = {
20380
+ thread: "REF0_THREAD",
20381
+ event: "REF0_EVENT",
20382
+ title: "REF0_TITLE"
20383
+ };
20384
+ /**
20385
+ * What ref0 puts in the environment of an agent's commands for its commits, beside Git's own
20386
+ * author variables: the harness, for the trailer, and what a forwarder in ref0's hooks folder
20387
+ * sets for the repo's own hook it runs, so a forwarder that hook reaches runs nothing.
20388
+ */
20389
+ const gitHookVariables = {
20390
+ harness: "REF0_HARNESS",
20391
+ forwarded: "REF0_GIT_HOOK"
20392
+ };
20393
+ //#endregion
20394
+ //#region src/harness/plugin-files.ts
20395
+ /**
20396
+ * What both harnesses' parts of ref0's plugin folder are built from: hooks, each an `mcp_tool`
20397
+ * hook to `ref0 mcp` or a command, the hooks file they're written into, and the two ways the
20398
+ * plugin finds `ref0`: a script for its command hooks, and a launcher for its server. The public plugin
20399
+ * names no machine's paths, owner or agent. Installation pins its lookup to one API origin.
20400
+ * Only its release, in its manifests, script and launcher, changes from one release to the next;
20401
+ * never a hook's declaration, which carries the hook protocol instead.
20402
+ */
20403
+ const encodeHooks = Schema.encodeSync(Schema.fromJsonString(HooksFile));
20404
+ const encodeMcp = Schema.encodeSync(Schema.fromJsonString(McpServersFile));
20405
+ /** A path as one word for `sh`, which runs command hooks. */
20406
+ const shellWord = (word) => `'${word.replaceAll("'", `'\\''`)}'`;
20407
+ /** An event's hook asking `ref0 mcp`, with a template naming each field it reads. */
20408
+ const mcpToolHook = (server, event, fields) => ({ hooks: [{
20409
+ type: "mcp_tool",
20410
+ server,
20411
+ tool: "hook",
20412
+ input: Object.fromEntries([
20413
+ ["protocol", "1"],
20414
+ ["hook_event_name", event],
20415
+ ...fields.map((field) => [field, `\${${field}}`])
20416
+ ])
20417
+ }] });
20418
+ const commandHook = (command) => ({ hooks: [{
20419
+ type: "command",
20420
+ command
20421
+ }] });
20422
+ /** The plugin's lookup script, by its path inside the plugin. */
20423
+ const lookupScript = "bin/ref0";
20424
+ /** The plugin's root, as a command hook names it for the harness to fill in. */
20425
+ const pluginRoot = "${CLAUDE_PLUGIN_ROOT}";
20426
+ /** What the lookup script and the launcher say when neither finds `ref0`. */
20427
+ const missingRef0 = "run npm i -g @ref0/cli, then ref0 connect";
20428
+ /**
20429
+ * How the plugin finds `ref0`, as `sh` statements: on `PATH` in the public template, or at the
20430
+ * origin-specific launcher after installation, telling it the plugin's release. M0 measured both lookups in each
20431
+ * harness. The script isn't executable, so the plugin's `bin/` never puts it on an agent's `PATH`
20432
+ * as `ref0`, where it would find itself.
20433
+ */
20434
+ const lookup = (version, api) => [
20435
+ `${pluginVersionVariable}=${shellWord(version)}`,
20436
+ `export ${pluginVersionVariable}`,
20437
+ ...api === void 0 ? ["candidate=$(command -v ref0 2>/dev/null || :)", "if [ -n \"$candidate\" ]; then exec \"$candidate\" \"$@\"; fi"] : [
20438
+ "REF0_SELECT_ORIGIN=1",
20439
+ "export REF0_SELECT_ORIGIN",
20440
+ `if [ -x "$HOME/.ref0/origins/${originKey(api)}/bin/ref0" ]; then exec "$HOME/.ref0/origins/${originKey(api)}/bin/ref0" "$@"; fi`
20441
+ ],
20442
+ `printf '%s\\n' ${shellWord(missingRef0)} >&2`,
20443
+ "exit 127"
20444
+ ];
20445
+ /** The lookup script, which a command hook or the plugin's command runs with `sh`. */
20446
+ const lookupScriptFile = (version, api) => ({
20447
+ path: `ref0/${lookupScript}`,
20448
+ text: [
20449
+ "#!/bin/sh",
20450
+ "# Runs ref0 for the hooks and commands of its plugin.",
20451
+ ...lookup(version, api),
20452
+ ""
20453
+ ].join("\n")
20454
+ });
20455
+ /**
20456
+ * Runs `ref0 <words>` through the lookup script. Claude Code fills in the plugin's root before
20457
+ * it runs the command, and Codex sets it in the hook's environment, where `sh` reads it.
20458
+ */
20459
+ const throughLookup = (words) => [
20460
+ "/bin/sh",
20461
+ `"${pluginRoot}/${lookupScript}"`,
20462
+ ...words
20463
+ ].join(" ");
20464
+ /** Runs `ref0 hook <event>`, saying the hook protocol its declaration speaks. */
20465
+ const ref0Hook = (event) => commandHook(throughLookup([
20466
+ harnessCommandNames.hook,
20467
+ event,
20468
+ flagWord(harnessFlagNames.protocol),
20469
+ "1"
20470
+ ]));
20471
+ /** Starts the agent's session: a command, since it runs before the harness connects its servers. */
20472
+ const sessionStartHook = ref0Hook(harnessCommandNames.sessionStart);
20473
+ /** Ends the session, as a command too. */
20474
+ const sessionEndHook = ref0Hook(harnessCommandNames.sessionEnd);
20475
+ /**
20476
+ * The plugin's server, `ref0 mcp`, started by M0's inline launcher, the lookup in one line: a
20477
+ * script path in a server's arguments stays literal in Codex. It has no `cwd`, so the server
20478
+ * keeps the project's folder in both harnesses. Codex starts its servers with only `HOME`, `PATH`
20479
+ * and `SHELL`, so it's asked for `CODEX_HOME`, which an unset one leaves unset.
20480
+ */
20481
+ const mcpFile = (version, api, server = "ref0") => ({
20482
+ path: "ref0/.mcp.json",
20483
+ text: encodeMcp({ mcpServers: { [server]: {
20484
+ command: "/bin/sh",
20485
+ args: [
20486
+ "-c",
20487
+ lookup(version, api).join("; "),
20488
+ "ref0",
20489
+ harnessCommandNames.mcp
20490
+ ],
20491
+ env_vars: [
20492
+ "CODEX_HOME",
20493
+ "CLAUDE_CONFIG_DIR",
20494
+ "REF0_API"
20495
+ ]
20496
+ } } })
20497
+ });
20498
+ /** Each event a harness's hooks send `ref0 mcp`, with the fields it reads, as an `mcp_tool` hook. */
20499
+ const mcpToolHooks = (server, hookFields) => Object.fromEntries(Object.entries(hookFields).map(([event, fields]) => [event, [mcpToolHook(server, event, fields)]]));
20500
+ //#endregion
20095
20501
  //#region src/keychain.ts
20096
20502
  /**
20097
20503
  * The machine's keychain, where the CLI keeps what lets it act: the host grant `ref0 connect`
20098
20504
  * 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
20505
+ * `secret-tool`, under an origin-specific `ref0/<origin>` service, by account. A secret goes in through stdin, never as
20100
20506
  * an argument, since any process on the machine can read another's arguments.
20101
20507
  */
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* () {
20508
+ var KeychainError = class extends Schema.TaggedError()("KeychainError", {
20509
+ nextStep: Schema.String,
20510
+ /** The keychain answered, with an item ref0 can't read, rather than not answering. */
20511
+ unreadable: Schema.optionalKey(Schema.Literal(true))
20512
+ }) {};
20513
+ /**
20514
+ * The keychain under one service. Each API origin has its own, `ref0/<origin>`; 0.5.0 kept its
20515
+ * grants under `ref0`, which only `ref0 connect` reads, to replace and delete them.
20516
+ */
20517
+ const keychainIn = Effect.fnUntraced(function* (service) {
20518
+ const crypto = yield* Crypto.Crypto;
20519
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
20520
+ const macOS = (yield* spawner.string(ChildProcess.make("uname", ["-s"])).pipe(Effect.orElseSucceed(() => ""))).trim() === "Darwin";
20521
+ 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." });
20522
+ 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." });
20523
+ const run = (command, args, stdin) => Effect.scoped(Effect.gen(function* () {
20524
+ 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));
20525
+ const [output, complaint] = yield* Effect.all([handle.stdout.pipe(Stream.decodeText(), Stream.mkString), handle.stderr.pipe(Stream.decodeText(), Stream.mkString)], { concurrency: "unbounded" });
20526
+ const code = yield* handle.exitCode;
20527
+ return {
20528
+ output: output.trim(),
20529
+ complaint: complaint.trim(),
20530
+ code,
20531
+ succeeded: code === ChildProcessSpawner.ExitCode(0)
20532
+ };
20533
+ })).pipe(Effect.catchTag("PlatformError", () => Effect.fail(unavailable)));
20534
+ const get = Effect.fn("Keychain.get")(function* (account) {
20535
+ const found = macOS ? yield* run("security", [
20536
+ "find-generic-password",
20537
+ "-s",
20538
+ service,
20539
+ "-a",
20540
+ account,
20541
+ "-w"
20542
+ ], "") : yield* run("secret-tool", [
20543
+ "lookup",
20544
+ "service",
20545
+ service,
20546
+ "account",
20547
+ account
20548
+ ], "");
20549
+ if (found.succeeded) return found.output.length > 0 ? Option.some(Redacted.make(found.output)) : Option.none();
20550
+ if (macOS) {
20551
+ if (found.code === ChildProcessSpawner.ExitCode(44)) return Option.none();
20552
+ return yield* unavailable;
20553
+ }
20554
+ if (found.code !== ChildProcessSpawner.ExitCode(1) || found.complaint.length > 0) return yield* unavailable;
20555
+ const listed = yield* run("secret-tool", [
20556
+ "search",
20557
+ "--all",
20558
+ "service",
20559
+ service,
20560
+ "account",
20561
+ account
20562
+ ], "");
20563
+ if (listed.output.length > 0 || listed.complaint.length > 0) return yield* unavailable;
20564
+ return Option.none();
20565
+ });
20566
+ const set = Effect.fn("Keychain.set")(function* (account, secret) {
20567
+ const quote = (text) => `"${text.replace(/[\\"]/g, "\\$&")}"`;
20568
+ const value = Redacted.value(secret);
20569
+ if (macOS && /[\r\n\0]/.test(account + value)) return yield* unavailable;
20570
+ const input = macOS ? `add-generic-password -U -s ${quote(service)} -a ${quote(account)} -w ${quote(value)}\n` : value;
20571
+ if (macOS && new TextEncoder().encode(input).length >= 4096) return yield* unavailable;
20572
+ if (!(macOS ? yield* run("security", ["-i"], input) : yield* run("secret-tool", [
20573
+ "store",
20574
+ "--label",
20575
+ `ref0 ${account}`,
20576
+ "service",
20577
+ service,
20578
+ "account",
20579
+ account
20580
+ ], Redacted.value(secret))).succeeded) return yield* unavailable;
20581
+ });
20582
+ const remove = Effect.fn("Keychain.remove")(function* (account) {
20583
+ if (!(yield* macOS ? run("security", [
20584
+ "delete-generic-password",
20585
+ "-s",
20586
+ service,
20587
+ "-a",
20588
+ account
20589
+ ], "") : run("secret-tool", [
20590
+ "clear",
20591
+ "service",
20592
+ service,
20593
+ "account",
20594
+ account
20595
+ ], "")).succeeded) return yield* unavailable;
20596
+ });
20597
+ const random = crypto.randomUUIDv4.pipe(Effect.mapError(() => unavailable));
20598
+ return {
20599
+ probe: Effect.gen(function* () {
20186
20600
  const account = `connect-probe/${yield* random}`;
20187
20601
  const value = Redacted.make(yield* random);
20188
20602
  yield* Effect.gen(function* () {
@@ -20194,19 +20608,19 @@ var Keychain = class Keychain extends Context.Service()("ref0/cli/Keychain") {
20194
20608
  }).pipe(Effect.timeoutOrElse({
20195
20609
  duration: Duration.minutes(1),
20196
20610
  orElse: () => Effect.fail(unavailable)
20197
- }));
20198
- return Keychain.of({
20199
- probe,
20200
- get,
20201
- set,
20202
- remove
20203
- });
20204
- }));
20611
+ })),
20612
+ get,
20613
+ set,
20614
+ remove
20615
+ };
20616
+ });
20617
+ var Keychain = class Keychain extends Context.Service()("ref0/cli/Keychain") {
20618
+ static layer = Layer.effect(Keychain, Effect.flatMap(Ref0Config, (config) => Effect.map(keychainIn(`ref0/${originKey(config.api)}`), Keychain.of)));
20205
20619
  };
20206
20620
  //#endregion
20207
20621
  //#region src/harness/binding.ts
20208
20622
  /**
20209
- * Where a session's token is kept: the machine's keychain, under the service `ref0`, by an
20623
+ * Where a session's token is kept: the machine's keychain, under the API origin’s keychain service, by an
20210
20624
  * account naming its binding. One per harness process, so a resume in another terminal while
20211
20625
  * the first still runs keeps its own token.
20212
20626
  */
@@ -20342,13 +20756,15 @@ const harnessSessionToken = Layer.effect(SessionToken, Effect.gen(function* () {
20342
20756
  const Folders = Schema.Array(Schema.String);
20343
20757
  const decodeFolders = Schema.decodeUnknownOption(Schema.fromJsonString(Folders));
20344
20758
  const encodeFolders = Schema.encodeSync(Schema.fromJsonString(Folders));
20345
- /** The variable naming each harness's config folder, and its default under HOME. */
20759
+ /** Each harness's name, the variable naming its config folder, and its default under HOME. */
20346
20760
  const configFolders = {
20347
20761
  "claude-code": {
20762
+ name: "Claude Code",
20348
20763
  variable: "CLAUDE_CONFIG_DIR",
20349
20764
  inHome: ".claude"
20350
20765
  },
20351
20766
  codex: {
20767
+ name: "Codex",
20352
20768
  variable: "CODEX_HOME",
20353
20769
  inHome: ".codex"
20354
20770
  }
@@ -20363,7 +20779,8 @@ var ConfigFolders = class ConfigFolders extends Context.Service()("ref0/cli/Conf
20363
20779
  "claude-code": yield* Config.String(configFolders["claude-code"].variable).pipe(Config.withDefault("")),
20364
20780
  codex: yield* Config.String(configFolders.codex.variable).pipe(Config.withDefault(""))
20365
20781
  };
20366
- const directory = path.join(home, ".ref0");
20782
+ const config = yield* Ref0Config;
20783
+ const directory = path.join(home, ".ref0", "origins", originKey(config.api));
20367
20784
  const file = path.join(directory, "config-folders.json");
20368
20785
  const resolved = (given) => fs.realPath(given).pipe(Effect.catch(() => {
20369
20786
  const parent = path.dirname(given);
@@ -20400,18 +20817,68 @@ const currentHarness = Effect.gen(function* () {
20400
20817
  return Option.isSome(found) ? found.value.harness : Option.isSome(sandbox) ? "codex" : claude !== "" ? "claude-code" : codex !== "" ? "codex" : "claude-code";
20401
20818
  });
20402
20819
  const currentConfigFolder = Effect.flatMap(currentHarness, (harness) => ConfigFolders.use((folders) => folders.folder(harness)));
20820
+ /** A folder as the person reads it, and ref0 shows it: under the home as `~/…`, else whole. */
20821
+ const shownConfigFolder = (home, folder) => folder.startsWith(`${home}/`) ? `~/${folder.slice(home.length + 1)}` : folder;
20822
+ /** Initialized folders in the home, and the folder each variable names, when it's asked to. */
20823
+ const searchConfigFolders = Effect.fnUntraced(function* (variables) {
20824
+ const fs = yield* FileSystem.FileSystem;
20825
+ const path = yield* Path.Path;
20826
+ const folders = yield* ConfigFolders;
20827
+ const home = yield* Config.String("HOME").pipe(Config.withDefault("."));
20828
+ const names = yield* fs.readDirectory(home).pipe(Effect.orElseSucceed(() => []));
20829
+ const found = [];
20830
+ const seen = /* @__PURE__ */ new Set();
20831
+ const has = (file, kind = "File") => fs.stat(file).pipe(Effect.map((info) => info.type === kind), Effect.orElseSucceed(() => false));
20832
+ for (const harness of ["claude-code", "codex"]) {
20833
+ const { variable, inHome } = configFolders[harness];
20834
+ const named = variables ? yield* Config.String(variable).pipe(Config.withDefault("")) : "";
20835
+ const candidates = [
20836
+ path.join(home, inHome),
20837
+ ...names.filter((name) => name.startsWith(`${inHome}-`)).sort().map((name) => path.join(home, name)),
20838
+ ...named === "" ? [] : [named]
20839
+ ];
20840
+ for (const candidate of candidates) {
20841
+ const configFolder = yield* folders.resolve(candidate);
20842
+ if (seen.has(configFolder) || !(yield* has(configFolder, "Directory"))) continue;
20843
+ 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;
20844
+ seen.add(configFolder);
20845
+ found.push({
20846
+ harness,
20847
+ configFolder
20848
+ });
20849
+ }
20850
+ }
20851
+ return found;
20852
+ });
20853
+ /** Find initialized harness folders without reading credentials or config file contents. */
20854
+ const findConfigFolders = searchConfigFolders(true);
20855
+ /**
20856
+ * The line that connects a folder, from the person's own terminal: `ref0 connect` finds it
20857
+ * there unless only its variable names it, which this session's environment may have set.
20858
+ */
20859
+ const connectLineFor = Effect.fnUntraced(function* (harness, configFolder) {
20860
+ const found = yield* searchConfigFolders(false);
20861
+ const real = yield* (yield* ConfigFolders).resolve(configFolder);
20862
+ if (found.some((each) => each.harness === harness && each.configFolder === real)) return "ref0 connect";
20863
+ return `${configFolders[harness].variable}=${shellWord(configFolder)} ref0 connect`;
20864
+ });
20403
20865
  //#endregion
20404
20866
  //#region src/host-grant.ts
20405
- /** All lasting authority for one config folder, stored as one keychain value. */
20406
- const KeptHostGrant = Schema.Struct({
20867
+ /**
20868
+ * All lasting authority for one config folder, stored as one keychain value: the agent the folder
20869
+ * acts as, by its owner, handle and agent ID, and its token. The ID is kept in the same write as
20870
+ * the token, so a folder with a grant always knows which agent its commits name.
20871
+ */
20872
+ const KeptHostGrant = Schema.Struct({
20407
20873
  harness: ConnectHarness,
20408
20874
  owner: OwnerName,
20409
20875
  agent: AgentHandle,
20876
+ agentId: AgentId,
20410
20877
  token: Schema.RedactedFromValue(Schema.String)
20411
20878
  });
20412
20879
  const decodeGrant = Schema.decodeUnknownOption(Schema.fromJsonString(KeptHostGrant));
20413
20880
  const encodeGrant = Schema.encodeSync(Schema.fromJsonString(KeptHostGrant));
20414
- /** Grants under service ref0, account host-grant/<resolved config folder>. */
20881
+ /** Grants in the origin’s keychain service, account host-grant/<resolved config folder>. */
20415
20882
  var HostGrant = class HostGrant extends Context.Service()("ref0/cli/HostGrant") {
20416
20883
  static layer = Layer.effect(HostGrant, Effect.gen(function* () {
20417
20884
  const keychain = yield* Keychain;
@@ -20421,7 +20888,10 @@ var HostGrant = class HostGrant extends Context.Service()("ref0/cli/HostGrant")
20421
20888
  get: (folder) => account(folder).pipe(Effect.flatMap(keychain.get), Effect.flatMap((kept) => {
20422
20889
  if (Option.isNone(kept)) return Effect.succeedNone;
20423
20890
  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." }));
20891
+ return Option.isSome(grant) ? Effect.succeed(grant) : Effect.fail(new KeychainError({
20892
+ nextStep: "This config folder’s host grant is unreadable. Run ref0 connect again.",
20893
+ unreadable: true
20894
+ }));
20425
20895
  })),
20426
20896
  keep: (folder, grant) => Effect.gen(function* () {
20427
20897
  yield* keychain.set(yield* account(folder), Redacted.make(encodeGrant(grant)));
@@ -20443,43 +20913,6 @@ const grantForAgent = Effect.fnUntraced(function* (owner, agent) {
20443
20913
  return Option.none();
20444
20914
  });
20445
20915
  //#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
- */
20453
- /**
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.
20456
- */
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" };
20466
- /**
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
20916
  //#region src/version.ts
20484
20917
  /**
20485
20918
  * The CLI's version, from its `package.json` as the build reads it: what `ref0 --version`
@@ -20487,52 +20920,7 @@ const watchVariables = {
20487
20920
  * an update installs anew. Node, run from source, loads JSON only with the `type` attribute,
20488
20921
  * and only as a default export.
20489
20922
  */
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)]]));
20923
+ const cliVersion = "0.8.0";
20536
20924
  //#endregion
20537
20925
  //#region src/harness/hook-protocol.ts
20538
20926
  /**
@@ -20549,8 +20937,8 @@ const protocolFlagged = (command) => /--protocol (\S+)/.exec(command)?.[1];
20549
20937
  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
20938
  /** `--protocol`, which each command hook passes: the hook protocol its declaration speaks. */
20551
20939
  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}`;
20940
+ /** The command that installs the plugin again into a connected folder. */
20941
+ const installLine = (folder) => `ref0 install ${shellWord(folder)}`;
20554
20942
  /**
20555
20943
  * Why this `ref0` doesn't speak the plugin's hook protocol, and what to run: a newer plugin needs
20556
20944
  * a newer `ref0`, and an older one installing again. None when `ref0` speaks it.
@@ -20576,8 +20964,9 @@ const updateLine = (plugin, reinstall) => Option.map(protocolMismatch(plugin, re
20576
20964
  //#region src/machine.ts
20577
20965
  /**
20578
20966
  * This machine, as ref0 knows it: an ID made once and kept in ref0's config, `machine` under
20579
- * `$XDG_CONFIG_HOME/ref0` or `~/.config/ref0`, and its own name for itself, which ref0 shows
20580
- * the person as what it says. Neither is a secret, and neither is proof of anything.
20967
+ * `$XDG_CONFIG_HOME/ref0` or `~/.config/ref0`, and its host name, from which `ref0 connect` makes
20968
+ * the name ref0 shows the person as what it says. Neither is a secret, and neither is proof of
20969
+ * anything.
20581
20970
  */
20582
20971
  var MachineError = class extends Schema.TaggedError()("MachineError", { nextStep: Schema.String }) {};
20583
20972
  const decodeMachineId = Schema.decodeUnknownOption(MachineId);
@@ -20605,10 +20994,84 @@ var Machine = class Machine extends Context.Service()("ref0/cli/Machine") {
20605
20994
  onSome: Effect.succeed
20606
20995
  }));
20607
20996
  });
20608
- const name = (yield* Effect.sync(() => hostname())).slice(0, 64) || "this machine";
20997
+ const hostName = yield* Effect.sync(() => hostname());
20609
20998
  return Machine.of({
20610
20999
  id,
20611
- name
21000
+ hostName
21001
+ });
21002
+ }));
21003
+ };
21004
+ //#endregion
21005
+ //#region src/old-host-grants.ts
21006
+ /**
21007
+ * 0.5.0 kept each host grant under `host-grant/<owner>/<agent>` in the keychain service `ref0`,
21008
+ * and which agent each config folder acted as in `~/.ref0/plugin-agents.json`. Nothing reads
21009
+ * them since but `ref0 connect`: it names the grant each folder replaces, then forgets each
21010
+ * folder once its new grant is kept, and deletes each grant no folder still names. Two folders
21011
+ * acting as one agent shared its grant, so one kept and one not leaves it for the next run.
21012
+ * 0.5.0 kept no origin, and spoke to ref0.dev unless `REF0_API` said otherwise, so only a
21013
+ * connect to ref0.dev reads them.
21014
+ */
21015
+ const ByFolder = Schema.Record(Schema.String, Schema.Struct({
21016
+ owner: OwnerName,
21017
+ agent: AgentHandle
21018
+ }));
21019
+ const PluginAgents = Schema.Struct({
21020
+ "claude-code": ByFolder,
21021
+ codex: ByFolder
21022
+ });
21023
+ const decodeAgents = Schema.decodeUnknownOption(Schema.fromJsonString(PluginAgents));
21024
+ const encodeAgents = Schema.encodeSync(Schema.fromJsonString(PluginAgents));
21025
+ const decodeTokenId$2 = Schema.decodeUnknownOption(TokenId);
21026
+ var OldHostGrants = class OldHostGrants extends Context.Service()("ref0/cli/OldHostGrants") {
21027
+ static layer = Layer.effect(OldHostGrants, Effect.gen(function* () {
21028
+ const fs = yield* FileSystem.FileSystem;
21029
+ const path = yield* Path.Path;
21030
+ const config = yield* Ref0Config;
21031
+ const keychain = yield* keychainIn("ref0");
21032
+ const home = yield* Config.String("HOME").pipe(Config.withDefault("."));
21033
+ const file = path.join(home, ".ref0", "plugin-agents.json");
21034
+ const account = (named) => `host-grant/${named.owner}/${named.agent}`;
21035
+ const agents = config.api === defaultOrigin ? fs.readFileString(file).pipe(Effect.option, Effect.map(Option.flatMap(decodeAgents))) : Effect.succeedNone;
21036
+ const replaces = Effect.fnUntraced(function* ({ harness, configFolder }) {
21037
+ const kept = yield* agents;
21038
+ if (Option.isNone(kept)) return Option.none();
21039
+ const named = kept.value[harness][configFolder];
21040
+ if (named === void 0) return Option.none();
21041
+ const token = yield* keychain.get(account(named));
21042
+ return Option.flatMap(token, (held) => Option.flatMap(parseToken(Redacted.value(held)), (parts) => decodeTokenId$2(parts.token)));
21043
+ });
21044
+ const retire = Effect.fnUntraced(function* (kept) {
21045
+ const before = yield* agents;
21046
+ if (Option.isNone(before)) return;
21047
+ const left = {
21048
+ "claude-code": { ...before.value["claude-code"] },
21049
+ codex: { ...before.value.codex }
21050
+ };
21051
+ const forgotten = /* @__PURE__ */ new Map();
21052
+ for (const folder of kept) {
21053
+ const named = left[folder.harness][folder.configFolder];
21054
+ if (named === void 0) continue;
21055
+ delete left[folder.harness][folder.configFolder];
21056
+ forgotten.set(account(named), [...forgotten.get(account(named)) ?? [], {
21057
+ ...folder,
21058
+ named
21059
+ }]);
21060
+ }
21061
+ const stillNamed = new Set([...Object.values(left["claude-code"]), ...Object.values(left.codex)].map(account));
21062
+ for (const [name, folders] of forgotten) {
21063
+ if (stillNamed.has(name)) continue;
21064
+ 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;
21065
+ for (const folder of folders) left[folder.harness][folder.configFolder] = folder.named;
21066
+ }
21067
+ if (forgotten.size === 0) return;
21068
+ const empty = Object.keys(left["claude-code"]).length === 0 && Object.keys(left.codex).length === 0;
21069
+ const next = `${file}.next`;
21070
+ yield* (empty ? fs.remove(file) : fs.writeFileString(next, encodeAgents(left), { mode: 384 }).pipe(Effect.andThen(fs.rename(next, file)))).pipe(Effect.ignore);
21071
+ });
21072
+ return OldHostGrants.of({
21073
+ replaces,
21074
+ retire
20612
21075
  });
20613
21076
  }));
20614
21077
  };
@@ -20616,9 +21079,12 @@ var Machine = class Machine extends Context.Service()("ref0/cli/Machine") {
20616
21079
  //#region src/attachment-request.ts
20617
21080
  /**
20618
21081
  * 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.
21082
+ * from the approving browser. It keeps and confirms each folder's grant before installing.
20620
21083
  */
20621
21084
  const isAnswerCode = Schema.is(TypedApprovalCode);
21085
+ /** A key's escape code, such as an arrow's, as a terminal sends it. */
21086
+ const escapeCode = /\u001b\[[\d;]*[A-Za-z~]/gu;
21087
+ const decodeTokenId$1 = Schema.decodeUnknownOption(TokenId);
20622
21088
  const decodeSecretHash$1 = Schema.decodeUnknownEffect(SecretHash);
20623
21089
  /** How often the machine asks whether its person has answered. */
20624
21090
  const pollMillis = 2e3;
@@ -20634,62 +21100,92 @@ const giveUpAfterMillis = 12e4;
20634
21100
  * it, before the machine stops it. Ctrl-C can't: the grant is kept even after an interrupt.
20635
21101
  */
20636
21102
  const keepTimeout = Duration.minutes(1);
21103
+ /**
21104
+ * How long keeping every folder's grant may take, so the machine confirms what it kept well
21105
+ * before ref0 revokes what it hasn't, 10 minutes after it connected.
21106
+ */
21107
+ const keepBudget = Duration.minutes(5);
20637
21108
  /** A request that couldn't finish: no answer in time, no connection, or ref0 briefly unable. */
20638
21109
  var Unanswered = class extends Schema.TaggedError()("Unanswered", {}) {};
20639
21110
  const unanswered = () => Effect.fail(new Unanswered());
21111
+ /**
21112
+ * The name the machine sends for itself: its host name without any character that could reorder
21113
+ * or hide what's around it, cut to 64 characters without splitting one in two, or `this machine`
21114
+ * when that leaves none.
21115
+ */
21116
+ const machineNameOf = (hostName) => {
21117
+ let name = "";
21118
+ for (const character of withoutHiddenCharacters(hostName).trim()) {
21119
+ if (name.length + character.length > 64) break;
21120
+ name += character;
21121
+ }
21122
+ return name || "this machine";
21123
+ };
20640
21124
  /** A refusal ref0 gave, said as it gave it. */
20641
21125
  const said = (refusal) => sayFailed(refusal.nextStep);
20642
21126
  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
- };
21127
+ /** The line that asks again. */
21128
+ const again$2 = "ref0 connect";
20659
21129
  /** What each way a request can end without connecting says, with the line that asks again. */
20660
- const endedWords$1 = (state, again) => {
21130
+ const endedWords$1 = (state) => {
20661
21131
  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}`;
21132
+ case "refused": return `It was refused, and nothing was connected. To ask again, run: ${again$2}`;
21133
+ case "aborted": return `It was aborted, and nothing was connected. To ask again, run: ${again$2}`;
21134
+ case "expired": return `Nobody approved it in time, and nothing was connected. For a new code, run: ${again$2}`;
21135
+ case "failed": return `${state.nextStep} To connect this machine, run: ${again$2}`;
20666
21136
  }
20667
21137
  };
21138
+ /** The grant a kept record holds, by its ID. */
21139
+ const grantOf = (token) => Option.flatMap(parseToken(Redacted.value(token)), (parts) => decodeTokenId$1(parts.token));
20668
21140
  /**
20669
- * Asks with no owner, shows the link, and takes the answer pasted from the person’s own page.
21141
+ * Asks with no owner for every folder given, each with a grant secret of its own, shows the
21142
+ * link, and takes the answer from the person's own page. Then it keeps each folder ref0
21143
+ * connected, confirms exactly the grants it kept, and forgets 0.5.0's grants they replace.
20670
21144
  */
20671
- const requestAttachment = Effect.fnUntraced(function* (harness) {
21145
+ const requestAttachment = Effect.fnUntraced(function* (asked) {
20672
21146
  const config = yield* Ref0Config;
20673
- const again = connectLine(harness);
20674
21147
  const machine = yield* Machine;
20675
21148
  const hostGrant = yield* HostGrant;
21149
+ const oldGrants = yield* OldHostGrants;
20676
21150
  const http = yield* HttpClient.HttpClient;
20677
- const grant = yield* freshTokenSecret;
21151
+ const home = yield* Config.String("HOME").pipe(Config.withDefault("."));
20678
21152
  const collecting = yield* freshSecret;
20679
21153
  const link = yield* freshLinkSecret;
20680
21154
  const pasted = yield* Ref.make(void 0);
21155
+ const opener = yield* browserOpener;
21156
+ 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
21157
  const machineId = yield* machine.id.pipe(Effect.catchTag("MachineError", said));
20682
21158
  const client = yield* ref0Client(config.api).pipe(Effect.provide(anonymousCredential), Effect.provideService(HttpClient.HttpClient, http));
20683
21159
  const unreachable = () => sayFailed(`Couldn’t reach ref0 at ${config.api}. Try again.`);
20684
21160
  const requestId = yield* newRequestId;
21161
+ const asking = yield* Effect.forEach(asked, Effect.fnUntraced(function* (folder) {
21162
+ const previous = yield* hostGrant.get(folder.configFolder).pipe(Effect.catchIf((failed) => failed.unreadable === true, () => Effect.succeedNone), Effect.catchTag("KeychainError", said));
21163
+ return {
21164
+ ...folder,
21165
+ shown: shownConfigFolder(home, folder.configFolder),
21166
+ grant: yield* freshTokenSecret,
21167
+ replaces: Option.isSome(previous) ? grantOf(previous.value.token) : yield* oldGrants.replaces(folder).pipe(Effect.catchTag("KeychainError", said))
21168
+ };
21169
+ }));
21170
+ const folders = yield* Effect.forEach(asking, Effect.fnUntraced(function* (folder) {
21171
+ const requested = {
21172
+ path: folder.shown,
21173
+ harness: folder.harness,
21174
+ secretHash: yield* Effect.orDie(decodeSecretHash$1(folder.grant.hash))
21175
+ };
21176
+ return Option.isSome(folder.replaces) ? {
21177
+ ...requested,
21178
+ replaces: folder.replaces.value
21179
+ } : requested;
21180
+ }));
20685
21181
  const payload = {
20686
21182
  machine: machineId,
20687
- machineName: machine.name,
20688
- secretHash: yield* Effect.orDie(decodeSecretHash$1(grant.hash)),
21183
+ machineName: machineNameOf(machine.hostName),
21184
+ folders,
20689
21185
  collectHash: yield* Effect.orDie(decodeSecretHash$1(collecting.hash)),
20690
21186
  linkHash: yield* Effect.orDie(decodeSecretHash$1(link.hash))
20691
21187
  };
20692
- if (Option.isSome(harness)) payload.harness = harness.value;
21188
+ if (listener !== void 0) payload.callback = listener.callback;
20693
21189
  const started = yield* client.machineAttachments.request({
20694
21190
  headers: { "idempotency-key": requestId },
20695
21191
  payload
@@ -20710,9 +21206,10 @@ const requestAttachment = Effect.fnUntraced(function* (harness) {
20710
21206
  yield* Console.error([
20711
21207
  `Open ${config.api}/connect#${link.secret}`,
20712
21208
  `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.",
21209
+ "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
21210
  "Waiting for the answer. Ctrl-C stops it."
20715
21211
  ].join("\n"));
21212
+ if (opener !== void 0) yield* openBrowser(opener, `${config.api}/connect#${link.secret}`);
20716
21213
  const withSecret = HttpApiMiddleware.layerClient(ApprovalSecret, ({ request, next }) => next(HttpClientRequest.bearerToken(request, collecting.secret)));
20717
21214
  const collector = yield* ref0Client(config.api).pipe(Effect.provide(Layer.merge(anonymousCredential, withSecret)), Effect.provideService(HttpClient.HttpClient, http));
20718
21215
  const params = { approval: started.approval };
@@ -20734,35 +21231,83 @@ const requestAttachment = Effect.fnUntraced(function* (harness) {
20734
21231
  while (true) {
20735
21232
  const state = yield* collect;
20736
21233
  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}`);
21234
+ if ((yield* Clock.currentTimeMillis) > giveUpAt) return yield* sayFailed(`ref0 hasn’t said how it ended, so this machine kept nothing. If it was approved, ref0 revokes its grants 10 minutes after it connected. To connect this machine, run: ${again$2}`);
20738
21235
  yield* Effect.sleep(pollMillis);
20739
21236
  }
20740
21237
  });
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,
21238
+ const keepOne = Effect.fnUntraced(function* (folder, connection, keepBy) {
21239
+ const left = keepBy - (yield* Clock.currentTimeMillis);
21240
+ 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.");
21241
+ const token = formatToken(connection.credential.person, connection.credential.id, folder.grant.secret);
21242
+ const kept = yield* hostGrant.keep(folder.configFolder, {
21243
+ harness: folder.harness,
21244
+ owner: connection.owner,
21245
+ agent: connection.handle,
21246
+ agentId: connection.agent.id,
20750
21247
  token: Redacted.make(token)
20751
21248
  }).pipe(Effect.timeoutOrElse({
20752
- duration: keepTimeout,
21249
+ duration: Duration.min(keepTimeout, Duration.millis(left)),
20753
21250
  orElse: () => Effect.fail(new KeychainError({ nextStep: "The keychain didn’t answer within a minute. Check that it’s unlocked." }))
20754
21251
  }), Effect.retry({
20755
21252
  times: 2,
20756
21253
  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}`);
21254
+ }), Effect.result);
21255
+ return Result.isSuccess(kept) ? Option.none() : Option.some(kept.failure.nextStep);
21256
+ });
21257
+ const finish = Effect.fnUntraced(function* (state) {
21258
+ if (state.state !== "connected") return yield* sayFailed(endedWords$1(state));
21259
+ const keepBy = (yield* Clock.currentTimeMillis) + Duration.toMillis(keepBudget);
21260
+ const kept = [];
21261
+ const grants = [];
21262
+ const notKept = [];
21263
+ for (const folder of asking) {
21264
+ const connection = state.folders.find((each) => each.path === folder.shown);
21265
+ if (connection === void 0) {
21266
+ if (!state.excluded.includes(folder.shown)) notKept.push({
21267
+ harness: folder.harness,
21268
+ configFolder: folder.configFolder,
21269
+ nextStep: "ref0 didn’t connect this folder."
21270
+ });
21271
+ continue;
21272
+ }
21273
+ const failed = yield* keepOne(folder, connection, keepBy);
21274
+ if (Option.isSome(failed)) {
21275
+ notKept.push({
21276
+ harness: folder.harness,
21277
+ configFolder: folder.configFolder,
21278
+ nextStep: failed.value
21279
+ });
21280
+ continue;
21281
+ }
21282
+ kept.push({
21283
+ harness: folder.harness,
21284
+ configFolder: folder.configFolder,
21285
+ owner: connection.owner,
21286
+ agent: connection.agent.id,
21287
+ handle: connection.handle,
21288
+ organizations: connection.organizations
21289
+ });
21290
+ grants.push(connection.credential.id);
21291
+ }
21292
+ yield* oldGrants.retire(kept);
21293
+ const [first, ...rest] = grants;
21294
+ if (first === void 0) return yield* sayFailed([[
21295
+ "It connected, but this machine couldn’t keep what it needs to act:",
21296
+ ...notKept.map((each) => `${shownConfigFolder(home, each.configFolder)}: ${each.nextStep}`),
21297
+ "This machine never confirmed the grants, so ref0 revokes them 10 minutes after they connected, even if the keychain kept them after all. To connect this machine, run:",
21298
+ { command: again$2 }
21299
+ ]]);
21300
+ const unconfirmed = () => sayFailed(`It connected, and this machine kept its grants, but ref0 didn’t answer when it said so. If ref0 never heard, it revokes them 10 minutes after they connected: then run ${again$2}`);
20759
21301
  const confirmId = yield* newRequestId;
20760
21302
  const answerCode = yield* Ref.get(pasted);
20761
- if (answerCode === void 0) return yield* sayFailed("The answer code is missing. Run ref0 connect again.");
21303
+ if (answerCode === void 0) return yield* sayFailed(`The answer code is missing. Run ${again$2} again.`);
20762
21304
  if (!(yield* collector.machineAttachments.confirm({
20763
21305
  params,
20764
21306
  headers: { "idempotency-key": confirmId },
20765
- payload: { answerCode }
21307
+ payload: {
21308
+ answerCode,
21309
+ grants: [first, ...rest]
21310
+ }
20766
21311
  }).pipe(Effect.timeoutOrElse({
20767
21312
  duration: requestTimeout,
20768
21313
  orElse: unanswered
@@ -20777,14 +21322,17 @@ const requestAttachment = Effect.fnUntraced(function* (harness) {
20777
21322
  times: 2,
20778
21323
  schedule: Schedule.spaced("1 second")
20779
21324
  }), 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}`);
21325
+ for (const folder of kept) yield* hostGrant.remove(folder.configFolder).pipe(Effect.ignore);
21326
+ return yield* sayFailed(`ref0 revoked the grants, since this machine didn’t confirm in time that it kept them. They’re deleted here, and nothing is connected. To connect this machine, run: ${again$2}`);
20782
21327
  }
20783
21328
  return {
20784
21329
  owner: state.owner,
20785
- agent: state.agent.id,
20786
- handle: state.handle,
20787
- organizations: state.organizations
21330
+ kept,
21331
+ notKept,
21332
+ excluded: asking.flatMap(({ harness, configFolder, shown }) => state.excluded.includes(shown) ? [{
21333
+ harness,
21334
+ configFolder
21335
+ }] : [])
20788
21336
  };
20789
21337
  });
20790
21338
  const stopped = Effect.gen(function* () {
@@ -20805,29 +21353,434 @@ const requestAttachment = Effect.fnUntraced(function* (harness) {
20805
21353
  }), Effect.retry({
20806
21354
  while: Predicate.isTagged("Unanswered"),
20807
21355
  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.")));
21356
+ }), 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
21357
  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.");
21358
+ 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
21359
  yield* Console.error("It was already answered, so this machine waits to hear how.");
20812
21360
  }
20813
21361
  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}`);
21362
+ 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
21363
  });
20817
21364
  const terminal = yield* Terminal.Terminal;
20818
21365
  const readAnswer = Effect.gen(function* () {
20819
- yield* Console.error("Paste the code from your own page on ref0.dev.");
21366
+ yield* Console.error(`Paste the code from your own page on ${config.api}.`);
20820
21367
  while (true) {
20821
- const typed = (yield* terminal.readLine).trim();
21368
+ const typed = (yield* terminal.readLine).replaceAll(escapeCode, "").trim();
21369
+ if (typed === "") continue;
20822
21370
  if (!isAnswerCode(typed)) {
20823
- yield* Console.error("Paste the 8-character code from your own page on ref0.dev.");
21371
+ yield* Console.error(`Paste the 8-character code from your own page on ${config.api}.`);
20824
21372
  continue;
20825
21373
  }
20826
- yield* Ref.set(pasted, typed);
21374
+ yield* Ref.update(pasted, (first) => first ?? typed);
21375
+ if (listener !== void 0) yield* listener.stop;
20827
21376
  return yield* Effect.never;
20828
21377
  }
20829
21378
  }).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)));
21379
+ const receiveAnswer = listener === void 0 ? readAnswer : Effect.raceFirst(readAnswer, listener.answer.pipe(Effect.flatMap((code) => Ref.update(pasted, (first) => first ?? code)), Effect.andThen(Effect.never)));
21380
+ return yield* Effect.uninterruptibleMask((restore) => restore(Effect.raceFirst(answered, receiveAnswer)).pipe(Effect.ensuring(listener === void 0 ? Effect.void : listener.stop), Effect.onInterrupt(() => Effect.ignore(stopped)), Effect.flatMap(finish)));
21381
+ }, Effect.scoped);
21382
+ //#endregion
21383
+ //#region src/connect-lock.ts
21384
+ /**
21385
+ * One `ref0 connect` at a time on this machine, for every API origin, from finding its config
21386
+ * folders to installing into them: two at once could each keep a grant for one folder, and the
21387
+ * one written last would leave the other live with nobody holding it.
21388
+ *
21389
+ * The lock is `~/.ref0/connect.lock`. Each entry names its process, when that process started,
21390
+ * and a nonce, and is written whole, then linked into place, so nobody reads one half written.
21391
+ * An entry whose process has ended is passed over to `connect.lock-<its nonce>`, which only one
21392
+ * process can link: a crash leaves the lock to the next run, and two runs that find the same
21393
+ * ended entry can't both take it. A run that linked further along reads the chain again from
21394
+ * the head before it holds, and lets go if any of it changed. The holder unlinks from the head,
21395
+ * so a crash part way through leaves only entries no chain reaches.
21396
+ */
21397
+ /** Another run holds the lock, or a file in it can't be read. */
21398
+ var ConnectBusy = class extends Schema.TaggedError()("ConnectBusy", { nextStep: Schema.String }) {};
21399
+ const Entry = Schema.Struct({
21400
+ pid: Schema.Int.check(Schema.isGreaterThan(0)),
21401
+ started: Schema.String,
21402
+ nonce: Schema.String.check(Schema.isPattern(/^[0-9a-f-]{36}$/))
21403
+ });
21404
+ const decodeEntry = Schema.decodeUnknownOption(Schema.fromJsonString(Entry));
21405
+ const encodeEntry = Schema.encodeSync(Schema.fromJsonString(Entry));
21406
+ /** A process, as far as the lock goes: which one this is, and whether one is still running. */
21407
+ var Processes = class Processes extends Context.Service()("ref0/cli/Processes") {
21408
+ static layer = Layer.effect(Processes, Effect.gen(function* () {
21409
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
21410
+ const startOf = (pid) => spawner.string(ChildProcess.make("ps", [
21411
+ "-o",
21412
+ "lstart=",
21413
+ "-p",
21414
+ String(pid)
21415
+ ], {
21416
+ env: {
21417
+ TZ: "UTC",
21418
+ LC_ALL: "C"
21419
+ },
21420
+ extendEnv: true
21421
+ })).pipe(Effect.map((text) => text.trim()), Effect.timeout("5 seconds"), Effect.orElseSucceed(() => ""));
21422
+ 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")));
21423
+ return Processes.of({
21424
+ own: Effect.map(startOf(process.pid), (started) => ({
21425
+ pid: process.pid,
21426
+ started
21427
+ })),
21428
+ running: Effect.fnUntraced(function* ({ pid, started }) {
21429
+ if (!(yield* signalled(pid))) return false;
21430
+ if (started === "") return true;
21431
+ const now = yield* startOf(pid);
21432
+ return now === "" || now === started;
21433
+ })
21434
+ });
21435
+ }));
21436
+ };
21437
+ var ConnectLock = class ConnectLock extends Context.Service()("ref0/cli/ConnectLock") {
21438
+ static layer = Layer.effect(ConnectLock, Effect.gen(function* () {
21439
+ const fs = yield* FileSystem.FileSystem;
21440
+ const path = yield* Path.Path;
21441
+ const crypto = yield* Crypto.Crypto;
21442
+ const processes = yield* Processes;
21443
+ const home = yield* Config.String("HOME").pipe(Config.withDefault("."));
21444
+ const directory = path.join(home, ".ref0");
21445
+ const head = path.join(directory, "connect.lock");
21446
+ const shown = "~/.ref0/connect.lock";
21447
+ const unwritable = new ConnectBusy({ nextStep: `Couldn’t write ${shown}. Check that ~/.ref0 is writable, then run ref0 connect again.` });
21448
+ 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.` });
21449
+ 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.` });
21450
+ const raced = new ConnectBusy({ nextStep: "Another ref0 connect started at the same time. Let it finish, then run ref0 connect again." });
21451
+ const after = (entry) => `${head}-${entry.nonce}`;
21452
+ const read = Effect.fnUntraced(function* (name) {
21453
+ 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)));
21454
+ if (Option.isNone(text)) return Option.none();
21455
+ const entry = decodeEntry(text.value);
21456
+ if (Option.isNone(entry)) return yield* unreadable;
21457
+ return entry;
21458
+ });
21459
+ const intact = Effect.fnUntraced(function* (chain, own) {
21460
+ for (const [index, name] of chain.entries()) {
21461
+ const entry = yield* read(name);
21462
+ if (Option.isNone(entry)) return false;
21463
+ const next = chain[index + 1];
21464
+ if (next === void 0) return entry.value.nonce === own.nonce;
21465
+ if (after(entry.value) !== next) return false;
21466
+ }
21467
+ return false;
21468
+ });
21469
+ const take = Effect.fnUntraced(function* (draft, own) {
21470
+ for (let attempt = 0; attempt < 3; attempt += 1) {
21471
+ const chain = [];
21472
+ let name = head;
21473
+ for (let step = 0; step < 16; step += 1) {
21474
+ 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)))) {
21475
+ chain.push(name);
21476
+ if (yield* intact(chain, own)) return chain;
21477
+ yield* fs.remove(name).pipe(Effect.ignore);
21478
+ break;
21479
+ }
21480
+ const entry = yield* read(name);
21481
+ if (Option.isNone(entry)) continue;
21482
+ if (yield* processes.running(entry.value)) return yield* busy(entry.value.pid);
21483
+ chain.push(name);
21484
+ name = after(entry.value);
21485
+ }
21486
+ }
21487
+ return yield* raced;
21488
+ });
21489
+ const release = Effect.fnUntraced(function* (chain, own) {
21490
+ if (!(yield* intact(chain, own).pipe(Effect.orElseSucceed(() => false)))) return;
21491
+ for (const name of chain) yield* fs.remove(name).pipe(Effect.ignore);
21492
+ });
21493
+ const hold = Effect.gen(function* () {
21494
+ const own = {
21495
+ ...yield* processes.own,
21496
+ nonce: yield* crypto.randomUUIDv4.pipe(Effect.mapError(() => unwritable))
21497
+ };
21498
+ const draft = path.join(directory, `connect.lock.${own.nonce}.draft`);
21499
+ yield* Effect.acquireRelease(Effect.gen(function* () {
21500
+ yield* fs.makeDirectory(directory, {
21501
+ recursive: true,
21502
+ mode: 448
21503
+ });
21504
+ yield* fs.writeFileString(draft, encodeEntry(own), {
21505
+ flag: "wx",
21506
+ mode: 384
21507
+ });
21508
+ return yield* take(draft, own);
21509
+ }).pipe(Effect.catchTag("PlatformError", () => Effect.fail(unwritable)), Effect.ensuring(fs.remove(draft).pipe(Effect.ignore))), (chain) => release(chain, own));
21510
+ });
21511
+ return ConnectLock.of({ hold });
21512
+ }));
21513
+ };
21514
+ //#endregion
21515
+ //#region src/yes-or-no.ts
21516
+ /**
21517
+ * How long what a terminal held while nobody read it takes to arrive once a question reads: one
21518
+ * read, well within this.
21519
+ */
21520
+ const settle = Duration.millis(50);
21521
+ const again$1 = "Type y or yes, or n or no. Enter alone is no.";
21522
+ /** A line's answer, or none when it's neither yes nor no. */
21523
+ const answerOf$1 = (line) => {
21524
+ const word = line.trim().toLowerCase();
21525
+ if (word === "y" || word === "yes") return Option.some(true);
21526
+ if (word === "" || word === "n" || word === "no") return Option.some(false);
21527
+ return Option.none();
21528
+ };
21529
+ /** What Backspace takes back: one character as the person sees it, an emoji's parts included. */
21530
+ const characters = new Intl.Segmenter();
21531
+ /** Whether a key types text: none of its characters moves the cursor or controls the terminal. */
21532
+ const typesText = (text) => text !== "" && !/\p{Cc}/u.test(text);
21533
+ const quits = (key) => key.ctrl && (key.name === "c" || key.name === "d");
21534
+ /** The explanation goes to stderr, the question to stdout and its answer comes from stdin. */
21535
+ const allTerminals = () => process.stdin.isTTY === true && process.stdout.isTTY === true && process.stderr.isTTY === true;
21536
+ /**
21537
+ * Asks yes-or-no questions in this terminal, one after another: each shows `explained` on stderr,
21538
+ * then the question, and waits for a line. What was typed before a question showed doesn't answer
21539
+ * it, unless another question was asked when it arrived: a line typed after the one that answered
21540
+ * a question answers the next. The answer is no, unasked, unless stdin, stdout and stderr are each
21541
+ * a terminal, and on Ctrl-C or Ctrl-D.
21542
+ */
21543
+ const yesOrNoQuestions = Effect.gen(function* () {
21544
+ const terminal = yield* Terminal.Terminal;
21545
+ const ahead = yield* Ref.make([]);
21546
+ const ask = (explained, question) => Effect.gen(function* () {
21547
+ if (!allTerminals()) return false;
21548
+ const keys = yield* terminal.readInput;
21549
+ yield* Effect.sleep(settle);
21550
+ yield* Queue.clear(keys);
21551
+ const pending = [...yield* Ref.getAndSet(ahead, [])];
21552
+ const next = Effect.suspend(() => {
21553
+ const key = pending.shift();
21554
+ return key === void 0 ? Queue.take(keys) : Effect.succeed(key);
21555
+ });
21556
+ yield* printBlocks([explained]);
21557
+ yield* terminal.display(questionLine(question));
21558
+ let typed = "";
21559
+ let previous = "";
21560
+ while (true) {
21561
+ const pressed = yield* Effect.option(next);
21562
+ if (Option.isNone(pressed) || quits(pressed.value.key)) {
21563
+ yield* terminal.display("\n");
21564
+ return false;
21565
+ }
21566
+ const { input, key } = pressed.value;
21567
+ const ended = key.name === "enter" && previous === "return";
21568
+ previous = key.name;
21569
+ if (ended) continue;
21570
+ if (key.name === "backspace") {
21571
+ if (typed === "") continue;
21572
+ typed = typed.slice(0, [...characters.segment(typed)].at(-1)?.index);
21573
+ yield* terminal.display("\b \b");
21574
+ continue;
21575
+ }
21576
+ if (key.name !== "return" && key.name !== "enter") {
21577
+ const text = Option.getOrElse(input, () => "");
21578
+ if (!typesText(text)) continue;
21579
+ typed += text;
21580
+ yield* terminal.display(text);
21581
+ continue;
21582
+ }
21583
+ yield* terminal.display("\n");
21584
+ const answer = answerOf$1(typed);
21585
+ if (Option.isSome(answer)) {
21586
+ const rest = [...pending, ...yield* Queue.clear(keys)];
21587
+ const ended = key.name === "return" && rest[0]?.key.name === "enter";
21588
+ yield* Ref.set(ahead, ended ? rest.slice(1) : rest);
21589
+ return answer.value;
21590
+ }
21591
+ typed = "";
21592
+ yield* terminal.display(`${again$1}\n${questionLine(question)}`);
21593
+ }
21594
+ }).pipe(Effect.scoped, Effect.orElseSucceed(() => false));
21595
+ return ask;
21596
+ });
21597
+ //#endregion
21598
+ //#region src/harness/install-claude-code.ts
21599
+ /** Install into one config folder, from the release Claude Code fetched and ref0 checked. */
21600
+ const installClaudeCode = Effect.fnUntraced(function* (release, configFolder) {
21601
+ const run = yield* release.into(configFolder);
21602
+ yield* run([
21603
+ "plugin",
21604
+ "install",
21605
+ release.id,
21606
+ "--scope",
21607
+ "user"
21608
+ ]);
21609
+ yield* run([
21610
+ "plugin",
21611
+ "update",
21612
+ release.id,
21613
+ "--scope",
21614
+ "user"
21615
+ ]);
21616
+ return release.folder;
21617
+ });
21618
+ //#endregion
21619
+ //#region src/harness/hook-fields.ts
21620
+ /**
21621
+ * The same for Codex 0.160.0, which skips a hook outright if its template names a field the event
21622
+ * lacks, so each list holds only fields every hook of its event carried in the prototypes, and
21623
+ * only events they saw, each a `HookEvent`. Codex puts the model in every hook but `SessionEnd`.
21624
+ * Its `UserPromptSubmit` carries the prompt (`spikes/harness/results/codex/catalog.json`), which
21625
+ * no list names, as for Claude Code.
21626
+ * Every Codex hook carries `transcript_path`, the session's rollout, `null` for a session kept
21627
+ * nowhere (Codex's hook input schemas, 0.160.0 to 0.163.0): its turns' tokens are read there.
21628
+ * A subagent's are read from its own rollout, which its `SubagentStop` names, with the
21629
+ * subagent's own turn as its `turn_id` (Codex 0.163.0's subagents, 20 Oct 2026).
21630
+ */
21631
+ const codexHookFields = {
21632
+ UserPromptSubmit: [
21633
+ "session_id",
21634
+ "turn_id",
21635
+ "cwd",
21636
+ "model",
21637
+ "transcript_path"
21638
+ ],
21639
+ PreToolUse: [
21640
+ "session_id",
21641
+ "turn_id",
21642
+ "cwd",
21643
+ "model",
21644
+ "tool_name",
21645
+ "tool_use_id",
21646
+ "tool_input"
21647
+ ],
21648
+ PostToolUse: [
21649
+ "session_id",
21650
+ "turn_id",
21651
+ "cwd",
21652
+ "model",
21653
+ "tool_name",
21654
+ "tool_use_id",
21655
+ "tool_input",
21656
+ "tool_response"
21657
+ ],
21658
+ Stop: [
21659
+ "session_id",
21660
+ "turn_id",
21661
+ "model",
21662
+ "stop_hook_active",
21663
+ "transcript_path"
21664
+ ],
21665
+ SubagentStop: [
21666
+ "session_id",
21667
+ "turn_id",
21668
+ "agent_id",
21669
+ "agent_type",
21670
+ "stop_hook_active",
21671
+ "agent_transcript_path"
21672
+ ]
21673
+ };
21674
+ Schema.encodeSync(Schema.fromJsonString(CodexPluginManifest));
21675
+ /** How Codex names a plugin's MCP server in an `mcp_tool` hook. */
21676
+ const codexServer = "ref0";
21677
+ /** Codex's hooks file, by its path inside the plugin. */
21678
+ const codexHooksPath = "hooks/codex-hooks.json";
21679
+ ({ ...mcpToolHooks(codexServer, codexHookFields) });
21680
+ /**
21681
+ * The folder of Codex's skills, by its path inside the plugin. Not `skills`, which Claude Code
21682
+ * reads too, where a `status` skill would be a second status command. Codex 0.163.0 skips
21683
+ * Claude Code's `commands/status.md`, since its `!` line is a template it can't run.
21684
+ */
21685
+ const codexSkillsPath = "codex-skills";
21686
+ /** The status skill, by its path inside the plugin. */
21687
+ const codexStatusSkillPath = `${codexSkillsPath}/status/SKILL.md`;
21688
+ /**
21689
+ * How a person runs the status skill of a plugin installed as `name`: Codex 0.163.0 names a
21690
+ * plugin's skill by the plugin, then the skill.
21691
+ */
21692
+ const codexStatusShortcut = (name) => `$${name}:status`;
21693
+ /** The skill's placeholder for the plugin's folder, which Codex's agent fills in. */
21694
+ const pluginFolder = "<plugin>";
21695
+ /** What the status skill runs: `ref0 status`, through the plugin's lookup script. */
21696
+ const codexStatusLine = throughLookup([harnessCommandNames.status]).replaceAll(pluginRoot, pluginFolder);
21697
+ [
21698
+ "---",
21699
+ "name: status",
21700
+ `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.`,
21701
+ "---",
21702
+ "",
21703
+ "Run ref0’s check and show the person what it says.",
21704
+ "",
21705
+ `1. This file is \`${pluginFolder}/${codexSkillsPath}/status/SKILL.md\`. Run this command, with \`${pluginFolder}\` replaced by that folder’s absolute path:`,
21706
+ "",
21707
+ " ```sh",
21708
+ ` ${codexStatusLine}`,
21709
+ " ```",
21710
+ "",
21711
+ " 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.",
21712
+ "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.",
21713
+ ""
21714
+ ].join("\n");
21715
+ //#endregion
21716
+ //#region src/harness/origin-plugin.ts
21717
+ const manifestJson = Schema.fromJsonString(PluginManifest);
21718
+ const codexJson = Schema.fromJsonString(CodexPluginManifest);
21719
+ const marketJson = Schema.fromJsonString(PluginMarketplace);
21720
+ const hooksJson = Schema.fromJsonString(HooksFile);
21721
+ /**
21722
+ * Keep the checked release under this origin's own marketplace and plugin identity, with its
21723
+ * hooks, server and Codex status skill named for it.
21724
+ */
21725
+ const keepOriginPlugin = Effect.fnUntraced(function* (source, home) {
21726
+ const fs = yield* FileSystem.FileSystem;
21727
+ const path = yield* Path.Path;
21728
+ const { api } = yield* Ref0Config;
21729
+ const name = yield* originPluginName(api);
21730
+ const directory = path.join(home, ".ref0", "origins", originKey(api), "plugins");
21731
+ yield* fs.makeDirectory(directory, {
21732
+ recursive: true,
21733
+ mode: 448
21734
+ });
21735
+ const folder = yield* fs.makeTempDirectory({
21736
+ directory,
21737
+ prefix: "release-"
21738
+ });
21739
+ yield* fs.copy(source, folder, { overwrite: true });
21740
+ const manifestFile = path.join(folder, "ref0/.claude-plugin/plugin.json");
21741
+ const manifest = yield* fs.readFileString(manifestFile).pipe(Effect.flatMap(Schema.decodeUnknownEffect(manifestJson)));
21742
+ yield* fs.writeFileString(manifestFile, yield* Schema.encodeEffect(manifestJson)({
21743
+ ...manifest,
21744
+ name
21745
+ }));
21746
+ const codexFile = path.join(folder, "ref0/.codex-plugin/plugin.json");
21747
+ const codex = yield* fs.readFileString(codexFile).pipe(Effect.flatMap(Schema.decodeUnknownEffect(codexJson)));
21748
+ yield* fs.writeFileString(codexFile, yield* Schema.encodeEffect(codexJson)({
21749
+ ...codex,
21750
+ name
21751
+ }));
21752
+ yield* fs.writeFileString(path.join(folder, ".claude-plugin/marketplace.json"), yield* Schema.encodeEffect(marketJson)({
21753
+ name,
21754
+ owner: { name: "ref0" },
21755
+ plugins: [{
21756
+ name,
21757
+ source: "./ref0",
21758
+ description: manifest.description
21759
+ }]
21760
+ }));
21761
+ for (const [file, server] of [["hooks/hooks.json", `plugin:${name}:${name}`], ["hooks/codex-hooks.json", name]]) {
21762
+ const target = path.join(folder, "ref0", file);
21763
+ const hooks = yield* fs.readFileString(target).pipe(Effect.flatMap(Schema.decodeUnknownEffect(hooksJson)));
21764
+ const scoped = Object.fromEntries(Object.entries(hooks.hooks).map(([event, groups]) => [event, groups.map((group) => ({
21765
+ ...group,
21766
+ hooks: group.hooks.map((hook) => hook.type === "mcp_tool" ? {
21767
+ ...hook,
21768
+ server
21769
+ } : hook)
21770
+ }))]));
21771
+ yield* fs.writeFileString(target, encodeHooks({ hooks: scoped }));
21772
+ }
21773
+ for (const file of [lookupScriptFile(manifest.version, api), mcpFile(manifest.version, api, name)]) yield* fs.writeFileString(path.join(folder, file.path), file.text);
21774
+ const skillFile = path.join(folder, "ref0", codexStatusSkillPath);
21775
+ if (yield* fs.exists(skillFile)) {
21776
+ const skill = yield* fs.readFileString(skillFile);
21777
+ yield* fs.writeFileString(skillFile, skill.replaceAll(codexStatusShortcut("ref0"), codexStatusShortcut(name)));
21778
+ }
21779
+ return {
21780
+ folder,
21781
+ name,
21782
+ id: `${name}@${name}`
21783
+ };
20831
21784
  });
20832
21785
  //#endregion
20833
21786
  //#region src/harness/marketplace.ts
@@ -20837,49 +21790,45 @@ const decodeCodexPluginManifest = Schema.decodeUnknownEffect(Schema.fromJsonStri
20837
21790
  const decodePluginManifest = Schema.decodeUnknownEffect(Schema.fromJsonString(PluginManifest));
20838
21791
  const decodePluginMarketplace = Schema.decodeUnknownEffect(Schema.fromJsonString(PluginMarketplace));
20839
21792
  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) {
21793
+ /**
21794
+ * Why ref0's plugin isn't installed in a config folder, and anything to fix first. Then the
21795
+ * folder's install runs again, or, for a plugin newer than this `ref0`, `run` updates `ref0`.
21796
+ */
21797
+ var InstallFailed = class extends Schema.TaggedError()("InstallFailed", {
21798
+ says: Schema.String,
21799
+ run: Schema.optionalKey(Schema.String)
21800
+ }) {};
21801
+ /** The one command that repairs a folder's install. */
21802
+ const repairOf = (failed, configFolder) => failed.run ?? installLine(configFolder);
21803
+ /**
21804
+ * Fetch and inspect the public marketplace with the harness, before installing its plugin. The
21805
+ * fetch runs in a config folder of its own: the public marketplace's name, `ref0`, is ref0.dev's
21806
+ * own installed marketplace's too, and Codex's fetch would upgrade an installed plugin. Every
21807
+ * folder then installs from the release ref0 checked.
21808
+ */
21809
+ const marketplaceFor = Effect.fnUntraced(function* (harness, options) {
20843
21810
  const fs = yield* FileSystem.FileSystem;
20844
21811
  const path = yield* Path.Path;
20845
21812
  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 = {
21813
+ 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." });
21814
+ const variable = harness === "codex" ? "CODEX_HOME" : "CLAUDE_CONFIG_DIR";
21815
+ const envFor = (configFolder) => ({
20852
21816
  HOME: options.home,
20853
- [harness === "codex" ? "CODEX_HOME" : "CLAUDE_CONFIG_DIR"]: options.configFolder
21817
+ [variable]: configFolder
21818
+ });
21819
+ 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." })));
21820
+ const probe = yield* fs.makeTempDirectoryScoped().pipe(Effect.mapError(() => unavailable));
21821
+ const fetchEnv = {
21822
+ HOME: probe,
21823
+ [variable]: path.join(probe, "config")
20854
21824
  };
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") => {
21825
+ yield* fs.makeDirectory(path.join(probe, "config")).pipe(Effect.mapError(() => unavailable));
21826
+ const run = (args, into) => {
20866
21827
  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}` });
21828
+ const failed = (reason, detail = "") => into === void 0 ? unavailable : new InstallFailed({ says: `${name} ${reason}.${detail === "" ? "" : ` ${detail}`}` });
20869
21829
  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],
21830
+ const handle = yield* spawner.spawn(ChildProcess.make(options.command, args, {
21831
+ env: into === void 0 ? fetchEnv : envFor(into),
20883
21832
  extendEnv: true
20884
21833
  }));
20885
21834
  const [stdout, stderr, exit] = yield* Effect.all([
@@ -20887,47 +21836,38 @@ const marketplaceFor = Effect.fnUntraced(function* (harness, options, again) {
20887
21836
  handle.stderr.pipe(Stream.decodeText(), Stream.splitLines, Stream.runFold(() => Option.none(), (first, line) => Option.isSome(first) ? first : Option.some(line))),
20888
21837
  handle.exitCode
20889
21838
  ], { concurrency: "unbounded" });
20890
- if (exit !== ChildProcessSpawner.ExitCode(0)) return yield* failed(`refused to ${operation}`, Option.getOrElse(stderr, () => ""));
21839
+ if (exit !== ChildProcessSpawner.ExitCode(0)) return yield* failed("refused to install ref0’s plugin", Option.getOrElse(stderr, () => ""));
20891
21840
  return stdout;
20892
21841
  })).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}`))
21842
+ PlatformError: () => Effect.fail(failed("couldn’t install ref0’s plugin")),
21843
+ TimeoutError: () => Effect.fail(failed("timed out trying to install ref0’s plugin"))
20895
21844
  }));
20896
21845
  };
20897
- const list = Effect.fnUntraced(function* (stage = "fetch") {
21846
+ const fetchedRoot = Effect.fnUntraced(function* () {
20898
21847
  const text = yield* run([
20899
21848
  "plugin",
20900
21849
  "marketplace",
20901
21850
  "list",
20902
21851
  "--json"
20903
- ], stage);
21852
+ ]);
20904
21853
  if (harness === "claude-code") {
20905
21854
  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);
21855
+ if (found?.source !== "github" || found.repo !== "ref-0/plugins") return yield* unavailable;
21856
+ return found.installLocation;
20909
21857
  }
20910
21858
  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);
21859
+ if (found?.marketplaceSource?.sourceType !== "git" || found.marketplaceSource.source !== "https://github.com/ref-0/plugins.git") return yield* unavailable;
21860
+ return found.root;
20914
21861
  });
20915
- const existing = yield* list("inspect");
20916
- yield* run(harness === "codex" || Option.isNone(existing) ? [
21862
+ yield* run([
20917
21863
  "plugin",
20918
21864
  "marketplace",
20919
21865
  "add",
20920
21866
  "ref-0/plugins"
20921
- ] : [
20922
- "plugin",
20923
- "marketplace",
20924
- "update",
20925
- "ref0"
20926
21867
  ]);
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);
21868
+ const fetched = yield* fetchedRoot();
21869
+ const folder = yield* Effect.gen(function* () {
21870
+ let root = yield* fs.realPath(fetched);
20931
21871
  {
20932
21872
  const temp = yield* fs.makeTempDirectoryScoped();
20933
21873
  const snapshot = path.join(temp, "marketplace");
@@ -20949,30 +21889,6 @@ const marketplaceFor = Effect.fnUntraced(function* (harness, options, again) {
20949
21889
  extendEnv: true
20950
21890
  })).pipe(Effect.timeout("2 minutes"), Effect.catchCause(() => Effect.fail(unavailable)))) !== ChildProcessSpawner.ExitCode(0)) return yield* unavailable;
20951
21891
  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
21892
  }
20977
21893
  const market = yield* fs.readFileString(path.join(root, ".claude-plugin/marketplace.json")).pipe(Effect.flatMap(decodePluginMarketplace));
20978
21894
  const plugin = market.plugins.find((entry) => entry.name === "ref0");
@@ -20990,63 +21906,48 @@ const marketplaceFor = Effect.fnUntraced(function* (harness, options, again) {
20990
21906
  const declarations = Object.values(hooks.hooks).flatMap((groups) => groups.flatMap((group) => group.hooks));
20991
21907
  if (protocols.length === 0 || protocols.length !== declarations.length || protocols.some((protocol) => !/^\d+$/.test(protocol))) return yield* unavailable;
20992
21908
  for (const protocol of protocols) {
20993
- const mismatch = updateLine({
21909
+ const mismatch = protocolMismatch({
20994
21910
  protocol,
20995
21911
  version: Option.some(manifest.version)
20996
- }, again);
20997
- if (Option.isSome(mismatch)) return yield* new InstallFailed({ nextStep: mismatch.value });
21912
+ }, installLine(options.configFolder));
21913
+ if (Option.isNone(mismatch)) continue;
21914
+ const { says, run } = mismatch.value;
21915
+ return yield* Number(protocol) > 1 ? new InstallFailed({
21916
+ says: `${says}.`,
21917
+ run
21918
+ }) : new InstallFailed({ says: `${says}.` });
20998
21919
  }
20999
- return fetched.value;
21920
+ return root;
21000
21921
  }).pipe(Effect.catchTags({
21001
21922
  PlatformError: () => Effect.fail(unavailable),
21002
21923
  SchemaError: () => Effect.fail(unavailable)
21003
21924
  }));
21004
- if (harness === "codex") {
21005
- yield* run(Option.isNone(existing) ? [
21925
+ const scoped = yield* keepOriginPlugin(folder, options.home).pipe(Effect.mapError(() => unavailable));
21926
+ const into = Effect.fnUntraced(function* (configFolder) {
21927
+ if (harness === "codex") yield* prepare(configFolder);
21928
+ const runIn = (args) => run(args, configFolder);
21929
+ yield* runIn([
21006
21930
  "plugin",
21007
21931
  "marketplace",
21008
- "add",
21009
- "ref-0/plugins"
21010
- ] : [
21932
+ "remove",
21933
+ scoped.name
21934
+ ]).pipe(Effect.ignore);
21935
+ yield* runIn([
21011
21936
  "plugin",
21012
21937
  "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
- }
21938
+ "add",
21939
+ scoped.folder
21940
+ ]);
21941
+ return runIn;
21942
+ });
21020
21943
  return {
21021
- folder,
21022
- run: (args) => run(args, "install")
21944
+ folder: scoped.folder,
21945
+ name: scoped.name,
21946
+ id: scoped.id,
21947
+ into
21023
21948
  };
21024
21949
  });
21025
21950
  //#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
21951
  //#region src/harness/codex-app-server.ts
21051
21952
  /**
21052
21953
  * Codex's app server, spoken as its TUI speaks it: `codex app-server` started for one exchange,
@@ -21088,138 +21989,50 @@ const opening = [{
21088
21989
  /**
21089
21990
  * Codex's answers to `lines`, by ID, or none if it didn't answer each of `ids`. Each ID's first
21090
21991
  * answer is its own: a repeat, or an answer to an ID ref0 didn't send, is passed over.
21091
- */
21092
- const exchange = (codex, lines, ids) => Effect.scoped(Effect.gen(function* () {
21093
- const answered = yield* (yield* (yield* ChildProcessSpawner.ChildProcessSpawner).spawn(ChildProcess.make(codex, ["app-server"], {
21094
- forceKillAfter: "2 seconds",
21095
- stdin: {
21096
- stream: Stream.fromIterable(lines.map((line) => `${line}\n`)).pipe(Stream.encodeText),
21097
- endOnDone: false
21098
- }
21099
- }))).stdout.pipe(Stream.decodeText(), Stream.splitLines, Stream.map((line) => decodeAnswer$1(line)), Stream.filter(Option.isSome), Stream.scan(() => /* @__PURE__ */ new Map(), (answers, { value: answer }) => ids.includes(answer.id) && !answers.has(answer.id) ? new Map(answers).set(answer.id, answer) : answers), Stream.takeUntil((answers) => answers.size === ids.length), Stream.runLast);
21100
- return Option.filter(answered, (answers) => answers.size === ids.length);
21101
- })).pipe(Effect.timeoutOption("15 seconds"), Effect.map(Option.flatten), Effect.catchCause(() => Effect.succeed(Option.none())));
21102
- /** `answer`'s result, read by `method`'s own schema, or why it can't be. */
21103
- const resultOf = (method, answer) => {
21104
- if (answer === void 0) return Effect.fail(new CodexUnanswered({ method: method.method }));
21105
- if ("error" in answer) return Effect.fail(new CodexRefused({
21106
- method: method.method,
21107
- error: answer.error
21108
- }));
21109
- const { result } = method;
21110
- return Schema.decodeUnknownEffect(result)(answer.result).pipe(Effect.mapError((error) => new CodexAnswerMismatch({
21111
- method: method.method,
21112
- issue: error.message
21113
- })));
21114
- };
21115
- /**
21116
- * One exchange with Codex's app server under the person's Codex home: the opening, then `method`
21117
- * asked with `params`, which TypeScript holds to the method's own. Its answer is the one with
21118
- * its request's ID, and its result is read by the method's result schema.
21119
- */
21120
- const askCodex = (codex, method, params) => Effect.gen(function* () {
21121
- const asked = {
21122
- jsonrpc: "2.0",
21123
- id: askedId,
21124
- method: method.method,
21125
- params
21126
- };
21127
- const lines = yield* Effect.forEach([...opening, asked], (request) => encodeRequest(request).pipe(Effect.orDie));
21128
- const answers = yield* exchange(codex, lines, [initializeId, askedId]);
21129
- if (Option.isNone(answers)) return yield* new CodexUnanswered({ method: method.method });
21130
- yield* resultOf(codexMethods.initialize, answers.value.get(initializeId));
21131
- return yield* resultOf(method, answers.value.get(askedId));
21132
- });
21133
- //#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
- ]
21992
+ */
21993
+ const exchange = (codex, lines, ids, configFolder) => Effect.scoped(Effect.gen(function* () {
21994
+ const answered = yield* (yield* (yield* ChildProcessSpawner.ChildProcessSpawner).spawn(ChildProcess.make(codex, ["app-server"], {
21995
+ env: configFolder === void 0 ? void 0 : { CODEX_HOME: configFolder },
21996
+ extendEnv: true,
21997
+ forceKillAfter: "2 seconds",
21998
+ stdin: {
21999
+ stream: Stream.fromIterable(lines.map((line) => `${line}\n`)).pipe(Stream.encodeText),
22000
+ endOnDone: false
22001
+ }
22002
+ }))).stdout.pipe(Stream.decodeText(), Stream.splitLines, Stream.map((line) => decodeAnswer$1(line)), Stream.filter(Option.isSome), Stream.scan(() => /* @__PURE__ */ new Map(), (answers, { value: answer }) => ids.includes(answer.id) && !answers.has(answer.id) ? new Map(answers).set(answer.id, answer) : answers), Stream.takeUntil((answers) => answers.size === ids.length), Stream.runLast);
22003
+ return Option.filter(answered, (answers) => answers.size === ids.length);
22004
+ })).pipe(Effect.timeoutOption("15 seconds"), Effect.map(Option.flatten), Effect.catchCause(() => Effect.succeed(Option.none())));
22005
+ /** `answer`'s result, read by `method`'s own schema, or why it can't be. */
22006
+ const resultOf = (method, answer) => {
22007
+ if (answer === void 0) return Effect.fail(new CodexUnanswered({ method: method.method }));
22008
+ if ("error" in answer) return Effect.fail(new CodexRefused({
22009
+ method: method.method,
22010
+ error: answer.error
22011
+ }));
22012
+ const { result } = method;
22013
+ return Schema.decodeUnknownEffect(result)(answer.result).pipe(Effect.mapError((error) => new CodexAnswerMismatch({
22014
+ method: method.method,
22015
+ issue: error.message
22016
+ })));
21188
22017
  };
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
22018
  /**
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.
22019
+ * One exchange with Codex's app server under the person's Codex home: the opening, then `method`
22020
+ * asked with `params`, which TypeScript holds to the method's own. Its answer is the one with
22021
+ * its request's ID, and its result is read by the method's result schema.
21199
22022
  */
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");
22023
+ const askCodex = (codex, method, params, configFolder) => Effect.gen(function* () {
22024
+ const asked = {
22025
+ jsonrpc: "2.0",
22026
+ id: askedId,
22027
+ method: method.method,
22028
+ params
22029
+ };
22030
+ const lines = yield* Effect.forEach([...opening, asked], (request) => encodeRequest(request).pipe(Effect.orDie));
22031
+ const answers = yield* exchange(codex, lines, [initializeId, askedId], configFolder);
22032
+ if (Option.isNone(answers)) return yield* new CodexUnanswered({ method: method.method });
22033
+ yield* resultOf(codexMethods.initialize, answers.value.get(initializeId));
22034
+ return yield* resultOf(method, answers.value.get(askedId));
22035
+ });
21223
22036
  //#endregion
21224
22037
  //#region src/harness/install-codex.ts
21225
22038
  /**
@@ -21229,7 +22042,7 @@ const codexStatusLine = throughLookup([harnessCommandNames.status]).replaceAll(p
21229
22042
  * asks again. Marketplace commands were measured with Codex 0.163.0 on 23 Oct 2026.
21230
22043
  */
21231
22044
  /** 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:");
22045
+ const pluginHook = (hook, id) => hook.pluginId === id && hook.key.startsWith(`${id}:`);
21233
22046
  /** The plugin's two command hooks, as declared. */
21234
22047
  const ownCommands = [sessionStartHook, sessionEndHook].flatMap((group) => group.hooks.map((hook) => hook.command));
21235
22048
  /**
@@ -21245,7 +22058,7 @@ const ownCommand = (hook) => {
21245
22058
  * Whether a hook is one ref0 wrote: one of its two commands, or ref0's `hook` tool on its own
21246
22059
  * server. Anything else under ref0's name isn't trusted.
21247
22060
  */
21248
- const ownHook = (hook) => hook.handlerType === "command" && ownCommand(hook) || hook.handlerType === "mcpTool" && hook.server === "ref0" && hook.tool === "hook";
22061
+ const ownHook = (hook, server) => hook.handlerType === "command" && ownCommand(hook) || hook.handlerType === "mcpTool" && hook.server === server && hook.tool === "hook";
21249
22062
  /** Whether Codex waits for the person to trust a hook before it runs it: new, or changed. */
21250
22063
  const waitingForTrust = (hook) => hook.trustStatus === "untrusted" || hook.trustStatus === "modified";
21251
22064
  /**
@@ -21255,7 +22068,7 @@ const waitingForTrust = (hook) => hook.trustStatus === "untrusted" || hook.trust
21255
22068
  * so "Trust all and continue", like `t` on `/hooks`' list, would trust those too. Its review names
21256
22069
  * each hook's source, which tells ref0's apart from a repo's made to look like them.
21257
22070
  */
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.`;
22071
+ 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
22072
  /**
21260
22073
  * The "ref0" permission profile, decided 9 Oct (5): Codex's workspace profile, with each
21261
22074
  * workspace root's `.git` writable and the network on, so a Codex agent can commit, push, and
@@ -21277,21 +22090,18 @@ const profileSet = (read) => {
21277
22090
  const roots = profile?.filesystem?.[":workspace_roots"];
21278
22091
  return read.config.default_permissions === "ref0" && profile?.extends === ref0Profile.extends && isPathModes(roots) && roots[".git"] === "write" && profile.network?.enabled === true;
21279
22092
  };
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([
22093
+ /** Install into one config folder, from the release Codex fetched and ref0 checked. */
22094
+ const installCodex = Effect.fnUntraced(function* (release, options) {
22095
+ const failed = (what) => new InstallFailed({ says: `Couldn’t ${what}. Check that Codex is installed.` });
22096
+ const { folder, id, name } = release;
22097
+ yield* (yield* release.into(options.configFolder))([
21288
22098
  "plugin",
21289
22099
  "add",
21290
- "ref0@ref0"
22100
+ id
21291
22101
  ]);
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);
22102
+ const hooks = (yield* askCodex(options.codex, codexMethods.hooksList, { cwds: [options.home] }, options.configFolder).pipe(Effect.mapError(() => failed("read the plugin’s hooks from Codex")))).data.flatMap((entry) => entry.hooks).filter((hook) => pluginHook(hook, id));
21293
22103
  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}` });
22104
+ if (!hooks.every((hook) => ownHook(hook, name))) return yield* new InstallFailed({ says: `Codex’s ref0 plugin holds hooks ref0 didn’t write, so none was trusted. Remove it with: codex plugin remove ${id}.` });
21295
22105
  const trust = Effect.fnUntraced(function* (waiting) {
21296
22106
  if (!(yield* options.approve(waiting))) return false;
21297
22107
  yield* askCodex(options.codex, codexMethods.configBatchWrite, {
@@ -21303,14 +22113,14 @@ const installCodex = Effect.fnUntraced(function* (options) {
21303
22113
  filePath: null,
21304
22114
  expectedVersion: null,
21305
22115
  reloadUserConfig: false
21306
- }).pipe(Effect.mapError(() => failed("record the hooks as trusted in Codex")));
22116
+ }, options.configFolder).pipe(Effect.mapError(() => failed("record the hooks as trusted in Codex")));
21307
22117
  return true;
21308
22118
  });
21309
22119
  const offerProfile = Effect.fnUntraced(function* () {
21310
22120
  const config = yield* askCodex(options.codex, codexMethods.configRead, {
21311
22121
  includeLayers: false,
21312
22122
  cwd: null
21313
- }).pipe(Effect.option);
22123
+ }, options.configFolder).pipe(Effect.option);
21314
22124
  if (Option.isSome(config) && (profileSet(config.value) || enough(config.value))) return true;
21315
22125
  const replacing = Option.match(config, {
21316
22126
  onNone: () => ({
@@ -21336,7 +22146,7 @@ const installCodex = Effect.fnUntraced(function* (options) {
21336
22146
  filePath: null,
21337
22147
  expectedVersion: null,
21338
22148
  reloadUserConfig: false
21339
- }).pipe(Effect.mapError(() => failed("set ref0’s profile in Codex")));
22149
+ }, options.configFolder).pipe(Effect.mapError(() => failed("set ref0’s profile in Codex")));
21340
22150
  return true;
21341
22151
  });
21342
22152
  const waiting = hooks.filter(waitingForTrust);
@@ -21345,19 +22155,9 @@ const installCodex = Effect.fnUntraced(function* (options) {
21345
22155
  trusted: waiting.length === 0 || (yield* trust(waiting)),
21346
22156
  profile: yield* offerProfile()
21347
22157
  };
21348
- }, Effect.scoped);
22158
+ });
21349
22159
  //#endregion
21350
22160
  //#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
22161
  /** A command on this machine's `PATH`, by absolute path. */
21362
22162
  const onPath = Effect.fnUntraced(function* (name) {
21363
22163
  const fs = yield* FileSystem.FileSystem;
@@ -21373,101 +22173,145 @@ const onPath = Effect.fnUntraced(function* (name) {
21373
22173
  const runs$2 = (hook) => hook.handlerType === "command" ? printable(hook.command ?? "") : `the ${printable(hook.tool ?? "")} tool on MCP server ${printable(hook.server ?? "")}`;
21374
22174
  /**
21375
22175
  * Shows the person ref0's hooks for Codex, on stderr so a JSON answer stays alone on stdout,
21376
- * and asks whether Codex may run them. Only in a terminal, and never for `--json`: anywhere
21377
- * else the answer is no, and the hooks stay off until the person runs it again or trusts them in
21378
- * Codex itself.
21379
- */
21380
- const askToTrust = (hooks, asked) => Effect.gen(function* () {
21381
- const terminal = process.stdin.isTTY === true && process.stdout.isTTY === true && process.stderr.isTTY === true;
21382
- 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"));
21384
- return yield* Prompt.run(Prompt.Confirm({
21385
- message: "Trust them, so Codex runs them?",
21386
- initial: false
21387
- }));
21388
- }).pipe(Effect.orElseSucceed(() => false));
22176
+ * and asks whether Codex may run them. On a no, or with no terminal to ask in, the hooks stay
22177
+ * off until the person runs it again or trusts them in Codex itself.
22178
+ */
22179
+ const askToTrust = (hooks, shown, yesOrNo) => yesOrNo([`Codex runs a plugin's hooks only once you trust them. ref0's, for ${printable(shown)}, are:`, ...hooks.map((hook) => `- ${hook.eventName}: ${runs$2(hook)}`)], "Trust them, so Codex runs them?");
21389
22180
  /**
21390
22181
  * Shows the person ref0's sandbox profile for Codex, on stderr, and asks whether it may be
21391
- * Codex's default: only in a terminal, and never for `--json`, as with the hooks. A change to
21392
- * their Codex's security, so it's shown in full and asked the first time (decided 9 Oct, 5).
21393
- */
21394
- const askForProfile = (replacing, asked) => Effect.gen(function* () {
21395
- const terminal = process.stdin.isTTY === true && process.stdout.isTTY === true && process.stderr.isTTY === true;
21396
- if (!asked || !terminal) return false;
21397
- yield* Console.error([
21398
- "Codex's sandbox keeps commands from writing .git or reaching the network, so a Codex agent can't commit or push to ref0.",
21399
- "ref0's profile, \"ref0\", is Codex's workspace profile with two changes:",
21400
- "- each workspace root's .git is writable, so an agent can commit",
21401
- "- the network is on, so an agent can push; any command can then reach the internet, services and sockets on this machine, and your login Keychain",
21402
- "In a linked worktree (git worktree add), Codex still needs full access there.",
21403
- ...replacing.default === void 0 ? [] : [`It replaces your default now, ${printable(replacing.default)}.`],
21404
- ...replacing.ownRef0 ? ["It replaces the profile you have named \"ref0\", and every rule in it."] : []
21405
- ].join("\n"));
21406
- return yield* Prompt.run(Prompt.Confirm({
21407
- message: "Make \"ref0\" your Codex's default profile?",
21408
- initial: false
21409
- }));
21410
- }).pipe(Effect.orElseSucceed(() => false));
22182
+ * Codex's default. A change to their Codex's security, so it's shown in full and asked the first
22183
+ * time (decided 9 Oct, 5).
22184
+ */
22185
+ const askForProfile = (replacing, shown, yesOrNo) => yesOrNo([
22186
+ "Codex's sandbox keeps commands from writing .git or reaching the network, so a Codex agent can't commit or push to ref0.",
22187
+ "ref0's profile, \"ref0\", is Codex's workspace profile with two changes:",
22188
+ "- each workspace root's .git is writable, so an agent can commit",
22189
+ "- the network is on, so an agent can push; any command can then reach the internet, services and sockets on this machine, and your login Keychain",
22190
+ "In a linked worktree (git worktree add), Codex still needs full access there.",
22191
+ ...replacing.default === void 0 ? [] : [`It replaces your default now, ${printable(replacing.default)}.`],
22192
+ ...replacing.ownRef0 ? ["It replaces the profile you have named \"ref0\", and every rule in it."] : []
22193
+ ], `Make "ref0" the default profile for ${printable(shown)}?`);
22194
+ /** What a Codex folder without ref0's profile, or full access, can't do. */
22195
+ const sandboxWords = "Codex’s sandbox is as it was, so its agents commit and push to ref0 only where it has full access; to change that, run it again in a terminal.";
21411
22196
  /**
21412
22197
  * Writes the plugin's fallback launcher with absolute Node and CLI paths, so it runs even
21413
22198
  * when neither is on the harness's PATH. Another connect replaces the launcher atomically.
21414
22199
  */
21415
- const writeRef0Launcher = Effect.fnUntraced(function* (home) {
22200
+ const writeRef0Launcher = Effect.fnUntraced(function* (home, git) {
21416
22201
  const fs = yield* FileSystem.FileSystem;
21417
22202
  const path = yield* Path.Path;
21418
22203
  const ref0 = process.argv[1];
21419
22204
  if (ref0 === void 0) return;
21420
22205
  const target = yield* fs.realPath(ref0);
21421
- const folder = path.join(home, ".ref0", "bin");
22206
+ const config = yield* Ref0Config;
22207
+ const folder = path.join(home, ".ref0", "origins", originKey(config.api), "bin");
21422
22208
  const link = path.join(folder, "ref0");
21423
22209
  const next = `${link}.next`;
21424
22210
  yield* fs.makeDirectory(folder, { recursive: true });
21425
22211
  yield* fs.remove(next, { force: true });
21426
22212
  const node = yield* fs.realPath(process.execPath);
21427
- yield* fs.writeFileString(next, `#!/bin/sh\nexec ${shellWord(node)} ${shellWord(target)} "$@"\n`);
22213
+ const gitPath = yield* fs.realPath(git);
22214
+ yield* fs.writeFileString(next, [
22215
+ "#!/bin/sh",
22216
+ "if [ \"$REF0_SELECT_ORIGIN\" = 1 ]; then",
22217
+ ` checkout=$(${shellWord(gitPath)} config --get ref0.api 2>/dev/null)`,
22218
+ " result=$?",
22219
+ " case \"$result\" in",
22220
+ " 0) REF0_SELECTED_API=$checkout ;;",
22221
+ ` 1) REF0_SELECTED_API=\${REF0_API:-${defaultOrigin}} ;;`,
22222
+ ` *) printf '%s\\n' ${shellWord("ref0: could not read the checkout origin with Git. Restore Git, then run ref0 install again.")} >&2; exit 1 ;;`,
22223
+ " esac",
22224
+ " export REF0_SELECTED_API",
22225
+ ` ${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`,
22226
+ "fi",
22227
+ "unset REF0_SELECT_ORIGIN REF0_SELECTED_API",
22228
+ `export REF0_API=${shellWord(config.api)}`,
22229
+ `exec ${shellWord(node)} ${shellWord(target)} "$@"`,
22230
+ ""
22231
+ ].join("\n"));
21428
22232
  yield* fs.chmod(next, 493);
21429
22233
  yield* fs.rename(next, link);
21430
22234
  });
21431
- /** Installs into a connected folder. The plugin reads its identity from the keychain. */
21432
- const installFor = Effect.fnUntraced(function* (_agent, harness, options) {
22235
+ /**
22236
+ * Installs into each connected folder: the launcher once, and the plugin fetched and checked once
22237
+ * for each harness, then added to each of that harness's folders. A folder whose install fails
22238
+ * leaves the rest installing, and answers its failure in its own place. The plugin reads each
22239
+ * folder's identity from the keychain.
22240
+ */
22241
+ const installInto = Effect.fnUntraced(function* (folders, options) {
21433
22242
  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
- };
22243
+ const name = yield* originPluginName((yield* Ref0Config).api);
22244
+ const yesOrNo = options.asJson ? () => Effect.succeed(false) : yield* yesOrNoQuestions;
22245
+ const answers = /* @__PURE__ */ new Map();
22246
+ const failAll = (into, failed) => {
22247
+ for (const folder of into) answers.set(folder, Result.fail(failed));
22248
+ };
22249
+ const launcher = yield* Effect.cached(Effect.gen(function* () {
22250
+ const git = yield* onPath("git");
22251
+ if (Option.isNone(git)) return yield* new InstallFailed({ says: "Git isn’t on this machine’s PATH. Install it." });
22252
+ yield* writeRef0Launcher(home, git.value).pipe(Effect.mapError(() => new InstallFailed({ says: "Couldn’t write ~/.ref0. Check that it’s writable." })));
22253
+ }));
22254
+ for (const harness of ConnectHarness.literals) {
22255
+ const into = folders.filter((folder) => folder.harness === harness);
22256
+ if (into.length === 0) continue;
22257
+ const command = yield* onPath(harness === "codex" ? "codex" : "claude");
22258
+ if (Option.isNone(command)) {
22259
+ failAll(into, new InstallFailed({ says: `${configFolders[harness].name} isn’t on this machine’s PATH. Install it.` }));
22260
+ continue;
22261
+ }
22262
+ yield* Effect.scoped(Effect.gen(function* () {
22263
+ yield* launcher;
22264
+ const release = yield* marketplaceFor(harness, {
22265
+ command: command.value,
22266
+ home,
22267
+ configFolder: into[0]?.configFolder ?? home
22268
+ });
22269
+ for (const folder of into) {
22270
+ const installed = yield* (harness === "claude-code" ? installedInClaudeCode(release, folder.configFolder) : installedInCodex(release, {
22271
+ codex: command.value,
22272
+ home,
22273
+ configFolder: folder.configFolder,
22274
+ name,
22275
+ yesOrNo
22276
+ })).pipe(Effect.result);
22277
+ answers.set(folder, installed);
22278
+ }
22279
+ }).pipe(Effect.catchTag("InstallFailed", (failed) => Effect.sync(() => failAll(into, failed)))));
21451
22280
  }
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}` });
21454
- 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))
22281
+ return folders.map((folder) => ({
22282
+ ...folder,
22283
+ installed: answers.get(folder) ?? Result.fail(new InstallFailed({ says: "ref0 didn’t install into this folder." }))
22284
+ }));
22285
+ });
22286
+ const installedInClaudeCode = Effect.fnUntraced(function* (release, configFolder) {
22287
+ const folder = yield* installClaudeCode(release, configFolder);
22288
+ return {
22289
+ folder,
22290
+ trusted: true,
22291
+ profile: true,
22292
+ words: `Installed in Claude Code, from ${folder}.`
22293
+ };
22294
+ });
22295
+ const installedInCodex = Effect.fnUntraced(function* (release, options) {
22296
+ const shown = shownConfigFolder(options.home, options.configFolder);
22297
+ const done = yield* installCodex(release, {
22298
+ codex: options.codex,
22299
+ home: options.home,
22300
+ configFolder: options.configFolder,
22301
+ approve: (hooks) => askToTrust(hooks, shown, options.yesOrNo),
22302
+ approveProfile: (replacing) => askForProfile(replacing, shown, options.yesOrNo)
21461
22303
  });
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);
22304
+ const sandbox = done.profile ? "" : ` ${sandboxWords}`;
22305
+ const trust = trustRef0Hooks(installLine(options.configFolder), `${options.name}@${options.name}`);
21464
22306
  return done.trusted ? {
21465
22307
  folder: done.folder,
21466
22308
  trusted: true,
22309
+ profile: done.profile,
21467
22310
  words: `Installed in Codex, from ${done.folder}.${sandbox}`
21468
22311
  } : {
21469
22312
  folder: done.folder,
21470
22313
  trusted: false,
22314
+ profile: done.profile,
21471
22315
  words: `Installed in Codex, from ${done.folder}, with ref0’s hooks off until you trust them. ${trust}${sandbox}`,
21472
22316
  nextStep: trust
21473
22317
  };
@@ -21477,13 +22321,15 @@ const installCommands = [Command.make("install", { folder: Argument.String("fold
21477
22321
  const folder = Option.isSome(given) ? yield* folders.resolve(given.value) : yield* currentConfigFolder;
21478
22322
  const grant = yield* (yield* HostGrant).get(folder);
21479
22323
  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,
22324
+ const answers = yield* installInto([{
22325
+ harness: grant.value.harness,
21482
22326
  configFolder: folder
21483
- });
21484
- yield* printAnswer({ installed: installed.folder }, false, installed.words);
22327
+ }], { asJson: false });
22328
+ for (const { installed } of answers) {
22329
+ if (Result.isFailure(installed)) return yield* sayFailed([[`${installed.failure.says} Then run:`, { command: repairOf(installed.failure, folder) }]]);
22330
+ yield* printAnswer({ installed: installed.success.folder }, false, installed.success.words);
22331
+ }
21485
22332
  }).pipe(Effect.catchTags({
21486
- InstallFailed: (failed) => sayFailed(failed.nextStep),
21487
22333
  KeychainError: (failed) => sayFailed(failed.nextStep),
21488
22334
  ConfigError: () => sayFailed("HOME isn’t set, so ref0 has nowhere to keep the plugin.")
21489
22335
  }))).pipe(Command.withDescription("Installs ref0’s plugin into a connected config folder"), Command.withShortDescription("Install ref0’s plugin into a config folder"))];
@@ -21499,42 +22345,162 @@ var Tty = class Tty extends Context.Service()("ref0/cli/Tty") {
21499
22345
  //#endregion
21500
22346
  //#region src/commands/connect.ts
21501
22347
  /**
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"));
22348
+ * A person connects from their own terminal: ref0 finds every Claude Code and Codex config
22349
+ * folder in their home, and they keep the ones to connect. One request asks for all of them;
22350
+ * they open its link, approve on their page, and its answer code reaches the terminal. Then
22351
+ * each folder's grant is kept and confirmed, and ref0's plugin installed into it. An agent's
22352
+ * session and a process without a terminal cannot start it, and only one runs at a time.
22353
+ */
22354
+ /** One request connects at most this many config folders. */
22355
+ const mostFolders = 8;
22356
+ const isConfigFolderPath = Schema.is(ConfigFolderPath);
22357
+ /** The line that asks again. */
22358
+ const again = "ref0 connect";
22359
+ /** Every folder found, each ticked but one ref0 can't be shown; Enter keeps the ticked ones. */
22360
+ const choose = Effect.fnUntraced(function* (found, home) {
22361
+ const choices = found.map((folder) => {
22362
+ const shown = shownConfigFolder(home, folder.configFolder);
22363
+ const sendable = isConfigFolderPath(shown);
22364
+ const choice = {
22365
+ title: `${printable(shown)} ${configFolders[folder.harness].name}`,
22366
+ value: folder,
22367
+ selected: sendable,
22368
+ disabled: !sendable
22369
+ };
22370
+ return sendable ? choice : {
22371
+ ...choice,
22372
+ description: "Its path is too long, or holds characters ref0 can’t show."
22373
+ };
22374
+ });
22375
+ const ticked = choices.filter((choice) => choice.selected).length;
22376
+ return yield* Prompt.run(Prompt.MultiSelect({
22377
+ 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.",
22378
+ choices,
22379
+ max: mostFolders
22380
+ }));
22381
+ });
22382
+ /** Each other owner whose agents connected folders this run didn't touch act as. */
22383
+ const otherOwners = Effect.fnUntraced(function* (connected) {
22384
+ const folders = yield* ConfigFolders;
22385
+ const grants = yield* HostGrant;
22386
+ const touched = new Set([
22387
+ ...connected.kept,
22388
+ ...connected.notKept,
22389
+ ...connected.excluded
22390
+ ].map((folder) => folder.configFolder));
22391
+ const others = /* @__PURE__ */ new Map();
22392
+ for (const folder of yield* folders.all) {
22393
+ if (touched.has(folder)) continue;
22394
+ const grant = yield* grants.get(folder).pipe(Effect.orElseSucceed(Option.none));
22395
+ if (Option.isNone(grant) || grant.value.owner === connected.owner) continue;
22396
+ others.set(grant.value.owner, [...others.get(grant.value.owner) ?? [], folder]);
22397
+ }
22398
+ return [...others].map(([owner, configFolders]) => ({
22399
+ owner,
22400
+ configFolders
22401
+ }));
22402
+ });
22403
+ /** The organizations an agent is in this state in, by name, once each. */
22404
+ const organizationsIn = (kept, state) => {
22405
+ const byAgent = /* @__PURE__ */ new Map();
22406
+ for (const folder of kept) for (const place of folder.organizations) {
22407
+ if (place.state !== state) continue;
22408
+ byAgent.set(folder.handle, (byAgent.get(folder.handle) ?? /* @__PURE__ */ new Set()).add(place.name));
22409
+ }
22410
+ return [...byAgent].map(([handle, names]) => ({
22411
+ handle,
22412
+ names: [...names].join(", ")
22413
+ }));
22414
+ };
22415
+ /** What the terminal shows once each grant is kept and confirmed, and each install tried. */
22416
+ const reportOf = (connected, installed, others, home) => {
22417
+ const shown = (folder) => shownConfigFolder(home, folder.configFolder);
22418
+ const connectedBlock = [
22419
+ { label: `Connected as ${connected.owner}’s agents` },
22420
+ ...installed.map((folder) => `${shown(folder)} ${folder.owner === connected.owner ? folder.handle : `${folder.owner}/${folder.handle}`}`),
22421
+ ...connected.excluded.map((folder) => `${shown(folder)} taken out on the page, not connected`),
22422
+ ...others.map((other) => {
22423
+ const one = other.configFolders.length === 1;
22424
+ return `${other.configFolders.map((folder) => shownConfigFolder(home, folder)).join(", ")} ${one ? "is" : "are"} still connected as ${other.owner}’s ${one ? "agent" : "agents"}.`;
22425
+ })
22426
+ ];
22427
+ 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.`)];
22428
+ const failedBlocks = [...connected.notKept.map((folder) => [`${shown(folder)} isn’t connected: ${folder.nextStep} To connect it, run:`, { command: again }]), ...installed.flatMap((folder) => {
22429
+ 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) }]];
22430
+ const done = folder.installed.success;
22431
+ const notes = [...done.nextStep === void 0 ? [] : [done.nextStep], ...done.profile ? [] : [sandboxWords]];
22432
+ return notes.length === 0 ? [] : [[`${shown(folder)}: ${notes.join(" ")}`]];
22433
+ })];
22434
+ const anyInstalled = installed.some((folder) => Result.isSuccess(folder.installed));
22435
+ return [
22436
+ connectedBlock,
22437
+ ...organizationsBlock.length === 0 ? [] : [organizationsBlock],
22438
+ ...failedBlocks,
22439
+ ...anyInstalled ? [["Start a new session in each."]] : []
22440
+ ];
22441
+ };
22442
+ /** The same, for `--json`: each folder asked for, and how it ended. */
22443
+ const answerOf = (connected, installed, others) => ({
22444
+ owner: connected.owner,
22445
+ folders: [
22446
+ ...installed.map(({ configFolder, harness, owner, handle, organizations, installed: done }) => Result.isSuccess(done) ? {
22447
+ configFolder,
22448
+ harness,
22449
+ state: "installed",
22450
+ owner,
22451
+ agent: handle,
22452
+ organizations,
22453
+ plugin: done.success.folder,
22454
+ trusted: done.success.trusted,
22455
+ nextStep: done.success.nextStep ?? null
22456
+ } : {
22457
+ configFolder,
22458
+ harness,
22459
+ state: "notInstalled",
22460
+ owner,
22461
+ agent: handle,
22462
+ organizations,
22463
+ says: done.failure.says,
22464
+ nextStep: repairOf(done.failure, configFolder)
22465
+ }),
22466
+ ...connected.notKept.map(({ configFolder, harness, nextStep }) => ({
22467
+ configFolder,
22468
+ harness,
22469
+ state: "notKept",
22470
+ says: nextStep,
22471
+ nextStep: again
22472
+ })),
22473
+ ...connected.excluded.map(({ configFolder, harness }) => ({
22474
+ configFolder,
22475
+ harness,
22476
+ state: "excluded"
22477
+ }))
22478
+ ],
22479
+ others
22480
+ });
21506
22481
  /** Connecting this machine. */
21507
- const connectCommands = [Command.make("connect", {
21508
- json,
21509
- harness
21510
- }, ({ json: asJson, harness: into }) => Effect.gen(function* () {
22482
+ const connectCommands = [Command.make("connect", { json }, ({ json: asJson }) => Effect.gen(function* () {
21511
22483
  const session = yield* SessionToken;
21512
22484
  const tty = yield* Tty;
21513
22485
  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
22486
  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.");
22487
+ yield* (yield* ConnectLock).hold;
21515
22488
  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"))];
22489
+ const home = yield* Config.String("HOME").pipe(Config.withDefault("."));
22490
+ const found = yield* findConfigFolders;
22491
+ 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.`);
22492
+ const chosen = yield* choose(found, home).pipe(Effect.catchTag("QuitError", () => sayFailed("Stopped, and nothing was asked.")));
22493
+ if (chosen.length === 0) return yield* sayFailed(`No config folder was ticked, so nothing was asked. To connect one, run ${again} again.`);
22494
+ const connected = yield* requestAttachment(chosen);
22495
+ const installed = yield* installInto(connected.kept, { asJson });
22496
+ const others = yield* otherOwners(connected);
22497
+ yield* printAnswer(answerOf(connected, installed, others), asJson, reportOf(connected, installed, others, home));
22498
+ if (connected.notKept.length > 0 || installed.some((folder) => Result.isFailure(folder.installed))) return yield* new Said();
22499
+ }).pipe(Effect.scoped, Effect.catchTags({
22500
+ KeychainError: (failed) => sayFailed(failed.nextStep),
22501
+ ConnectBusy: (failed) => sayFailed(failed.nextStep),
22502
+ ConfigError: () => sayFailed("HOME isn’t set, so ref0 has nowhere to keep the plugin. Set it, then run ref0 install.")
22503
+ }))).pipe(Command.withDescription("Connect this machine’s config folders to your agents, in your terminal"))];
21538
22504
  //#endregion
21539
22505
  //#region src/connection-lines.ts
21540
22506
  /**
@@ -22067,13 +23033,6 @@ const traceLines = (page, nextCommand) => {
22067
23033
  ].join("\n");
22068
23034
  };
22069
23035
  //#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
23036
  //#region src/local/sandboxes.ts
22078
23037
  /**
22079
23038
  * A local run's sandboxes: the check's own, by name, opened on this machine, in the checkout.
@@ -22248,7 +23207,7 @@ const execIn = Effect.fnUntraced(function* (root, held, command, options = {}, l
22248
23207
  });
22249
23208
  /** Why a port can't be served on here, if it can't: something on this machine holds it. */
22250
23209
  const portHeld = (port) => Effect.callback((resume) => {
22251
- const probe = createServer();
23210
+ const probe = createServer$1();
22252
23211
  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
23212
  probe.listen(port, "127.0.0.1", () => probe.close(() => resume(Effect.succeedNone)));
22254
23213
  });
@@ -23272,6 +24231,11 @@ const askedWords = (from) => {
23272
24231
  if (from === "accountable") return "the person behind the run";
23273
24232
  return "person" in from ? from.person : `anyone who holds ${from.permission}`;
23274
24233
  };
24234
+ /**
24235
+ * A request refused since it asks someone else and nobody is left but the person behind the run,
24236
+ * in words.
24237
+ */
24238
+ 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
24239
  /** One yes a part needs, in words, as it stands over the run named. */
23276
24240
  const yesWords = (yes) => {
23277
24241
  switch (yes.state) {
@@ -23279,7 +24243,7 @@ const yesWords = (yes) => {
23279
24243
  case "asked": return `asked ${askedWords(yes.from)}${yes.asked === void 0 ? "" : `, ${yes.asked}`}`;
23280
24244
  case "no": return `no from ${yes.by ?? askedWords(yes.from)}`;
23281
24245
  case "expired": return `${askedWords(yes.from)} didn’t answer in a day`;
23282
- case "refused": return `${askedWords(yes.from)} can’t be asked`;
24246
+ case "refused": return yes.why === "self" ? selfWords(yes.from) : `${askedWords(yes.from)} can’t be asked`;
23283
24247
  case "revoked": return `yes from ${yes.by ?? askedWords(yes.from)}, revoked since`;
23284
24248
  }
23285
24249
  };
@@ -23394,7 +24358,7 @@ const gateCommands = [Command.make("gate", {
23394
24358
  /** The remote a clone pushes to: its thread's fork path, named as the thread is. */
23395
24359
  const forkRemote = (fork) => `thread-${threadParts(fork.thread).number}`;
23396
24360
  /** Git's credential helper for ref0, as Git runs it: this `ref0`, quoted for the shell. */
23397
- const helperCommand = (ref0) => `!${shellWord(ref0)} git-credential`;
24361
+ const helperCommand = (ref0, api) => `!REF0_API=${shellWord(api)} ${shellWord(ref0)} git-credential`;
23398
24362
  /**
23399
24363
  * The config Git takes while it clones, and keeps in the clone: for ref0's origin, the helpers
23400
24364
  * Git already has cleared, as macOS's keychain helper would keep one session's token for the
@@ -23402,7 +24366,7 @@ const helperCommand = (ref0) => `!${shellWord(ref0)} git-credential`;
23402
24366
  */
23403
24367
  const credentialConfig = (api, ref0) => [
23404
24368
  [`credential.${api}.helper`, ""],
23405
- [`credential.${api}.helper`, helperCommand(ref0)],
24369
+ [`credential.${api}.helper`, helperCommand(ref0, api)],
23406
24370
  [`credential.${api}.useHttpPath`, "true"]
23407
24371
  ];
23408
24372
  /**
@@ -23413,6 +24377,7 @@ const forkConfig = (api, fork, repo) => [
23413
24377
  [`remote.${forkRemote(fork)}.url`, forkUrlOf(api, fork.path)],
23414
24378
  [`remote.${forkRemote(fork)}.fetch`, `+refs/heads/*:refs/remotes/${forkRemote(fork)}/*`],
23415
24379
  ["remote.pushDefault", forkRemote(fork)],
24380
+ ["ref0.api", api],
23416
24381
  ["ref0.repo", repo],
23417
24382
  ["ref0.thread", fork.thread]
23418
24383
  ];
@@ -24550,7 +25515,7 @@ const pathOf$1 = (written) => agentParts(written) ?? {
24550
25515
  agent: ""
24551
25516
  };
24552
25517
  /** 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"}`;
25518
+ const lineOf$2 = (named, view) => `${named}/${view.handle} ${view.turnedOff ? "off" : "on"}`;
24554
25519
  const agents = Command.make("agents", {
24555
25520
  owner: owner$1,
24556
25521
  cursor: cursor$1,
@@ -24564,7 +25529,7 @@ const agents = Command.make("agents", {
24564
25529
  onSome: (after) => ({ after })
24565
25530
  })
24566
25531
  });
24567
- const lines = answer.value.agents.map((each) => lineOf$3(named, each));
25532
+ const lines = answer.value.agents.map((each) => lineOf$2(named, each));
24568
25533
  const next = answer.value.nextAfter;
24569
25534
  yield* printAnswer(answer.json, asJson, lines.length === 0 ? `${named} has no agents.` : [...lines, ...next === void 0 ? [] : [`More: --cursor ${next}`]].join("\n"));
24570
25535
  }).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 +25626,7 @@ const agentCommands = [agents, Command.make("agent", {
24661
25626
  params
24662
25627
  });
24663
25628
  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}`);
25629
+ yield* printAnswer(answer.json, asJson, `${lineOf$2(params.owner, answer.value)} generation ${answer.value.generation} added ${added}`);
24665
25630
  }).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
25631
  add$1,
24667
25632
  disable$1,
@@ -24687,7 +25652,7 @@ const pathOf = (written) => agentParts(written) ?? {
24687
25652
  agent: ""
24688
25653
  };
24689
25654
  /** An attachment, as a person reads it in a list: `pip token_… machine_… attached …`. */
24690
- const lineOf$2 = (view) => [
25655
+ const lineOf$1 = (view) => [
24691
25656
  view.person,
24692
25657
  view.credential.id,
24693
25658
  view.machine ?? "an app",
@@ -24707,7 +25672,7 @@ const attachmentCommands = [Command.make("attachments", {
24707
25672
  onSome: (after) => ({ after })
24708
25673
  })
24709
25674
  });
24710
- const lines = answer.value.attachments.map(lineOf$2);
25675
+ const lines = answer.value.attachments.map(lineOf$1);
24711
25676
  const next = answer.value.nextAfter;
24712
25677
  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
25678
  }).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 +26710,7 @@ const failedWith$2 = (failed) => sayFailed(failed.nextStep);
25745
26710
  * A thread in a line: its number, its title, where it stands, what started it if a workflow
25746
26711
  * did, and how long it has waited if nobody has picked it up.
25747
26712
  */
25748
- const lineOf$1 = (view, now) => {
26713
+ const lineOf = (view, now) => {
25749
26714
  const byWorkflow = view.origin?.workflow === void 0 ? "" : ` by workflow ${view.origin.workflow.workflow}`;
25750
26715
  const waiting = nobodyOnWords(view, now);
25751
26716
  return `#${view.number} ${view.title ?? "(untitled)"} ${view.stage}${byWorkflow}${waiting === void 0 ? "" : ` ${waiting}`}`;
@@ -25802,7 +26767,7 @@ const projectList = (address, from, waiting, asJson) => Effect.gen(function* ()
25802
26767
  const page = answer.value;
25803
26768
  const now = yield* Clock.currentTimeMillis;
25804
26769
  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));
26770
+ const lines = page.threads.length === 0 ? [none] : page.threads.map((view) => lineOf(view, now));
25806
26771
  const more = page.nextAfter === void 0 ? [] : [`More: ref0 threads ${address}${waiting ? " --nobody-on" : ""} --after ${page.nextAfter}`];
25807
26772
  yield* printAnswer(answer.json, asJson, [...lines, ...more].join("\n"));
25808
26773
  });
@@ -25870,7 +26835,7 @@ const readThread = ({ thread: address, json: asJson }) => Effect.gen(function* (
25870
26835
  const view = answer.value;
25871
26836
  const now = yield* Clock.currentTimeMillis;
25872
26837
  yield* printAnswer(answer.json, asJson, [
25873
- lineOf$1(view, now),
26838
+ lineOf(view, now),
25874
26839
  ...workflowsLines(view, DateTime.makeUnsafe(now)),
25875
26840
  ...view.origin === void 0 ? [] : [originLine(view.origin)],
25876
26841
  `Why: ${whyOf(view, address)}`,
@@ -27557,7 +28522,8 @@ var Attribution = class Attribution extends Context.Service()("ref0/cli/Attribut
27557
28522
  const crypto = yield* Crypto.Crypto;
27558
28523
  const home = yield* Config.String("HOME").pipe(Config.withDefault("."));
27559
28524
  const stateHome = yield* Config.String("XDG_STATE_HOME").pipe(Config.withDefault(path.join(home, ".local", "state")));
27560
- const directory = path.join(stateHome, "ref0", "attribution");
28525
+ const config = yield* Ref0Config;
28526
+ const directory = path.join(stateHome, "ref0", "origins", originKey(config.api), "attribution");
27561
28527
  const held = yield* Ref.make(/* @__PURE__ */ new Map());
27562
28528
  const checking = yield* Semaphore.make(1);
27563
28529
  const keeping = yield* Semaphore.make(1);
@@ -28967,7 +29933,8 @@ var HarnessTasks = class HarnessTasks extends Context.Service()("ref0/cli/Harnes
28967
29933
  const path = yield* Path.Path;
28968
29934
  const home = yield* Config.String("HOME").pipe(Config.withDefault("."));
28969
29935
  const stateHome = yield* Config.String("XDG_STATE_HOME").pipe(Config.withDefault(path.join(home, ".local", "state")));
28970
- const directory = path.join(stateHome, "ref0", "tasks");
29936
+ const config = yield* Ref0Config;
29937
+ const directory = path.join(stateHome, "ref0", "origins", originKey(config.api), "tasks");
28971
29938
  const held = yield* Ref.make(/* @__PURE__ */ new Map());
28972
29939
  const keeping = yield* Semaphore.make(1);
28973
29940
  const fileOf = (session, generation) => path.join(directory, `${session}.${generation}.json`);
@@ -29474,7 +30441,8 @@ var Transcripts = class Transcripts extends Context.Service()("ref0/cli/Transcri
29474
30441
  const crypto = yield* Crypto.Crypto;
29475
30442
  const home = yield* Config.String("HOME").pipe(Config.withDefault("."));
29476
30443
  const stateHome = yield* Config.String("XDG_STATE_HOME").pipe(Config.withDefault(path.join(home, ".local", "state")));
29477
- const directory = path.join(stateHome, "ref0", "transcripts");
30444
+ const config = yield* Ref0Config;
30445
+ const directory = path.join(stateHome, "ref0", "origins", originKey(config.api), "transcripts");
29478
30446
  const held = yield* Ref.make(/* @__PURE__ */ new Map());
29479
30447
  const fileOf = (session, generation) => path.join(directory, `${session}.${generation}.json`);
29480
30448
  const keptGenerations = (session) => Effect.map(fs.readDirectory(directory).pipe(Effect.orElseSucceed(() => [])), (names) => names.flatMap((name) => {
@@ -29805,9 +30773,9 @@ var Waker = class Waker extends Context.Service()("ref0/cli/Waker") {
29805
30773
  //#endregion
29806
30774
  //#region src/harness/hook-answer.ts
29807
30775
  /** How Claude Code names a tool of the plugin's server. */
29808
- const claudeCodeToolName = (tool) => `mcp__plugin_ref0_ref0__${tool}`;
30776
+ const claudeCodeToolName = (tool, server) => `mcp__plugin_${server}_${server}__${tool}`;
29809
30777
  /** How Codex names a tool of the plugin's server, once its tool search found it. */
29810
- const codexToolName = (tool) => `mcp__ref0__${tool}`;
30778
+ const codexToolName = (tool, server) => `mcp__${server}__${tool}`;
29811
30779
  /** The tool the harness calls for its hooks. The agent sees it too, but may never call it. */
29812
30780
  const hookTool = "hook";
29813
30781
  const preToolUse = (permissionDecision, permissionDecisionReason) => ({ hookSpecificOutput: {
@@ -29823,11 +30791,11 @@ const preToolUse = (permissionDecision, permissionDecisionReason) => ({ hookSpec
29823
30791
  * serves the agent, named in `agentTools`, are allowed, since a plugin can't add permission
29824
30792
  * rules. Codex approves them through its config instead; its hooks' `allow` grants nothing.
29825
30793
  */
29826
- function answerHook(hook, agentTools) {
30794
+ function answerHook(hook, agentTools, server) {
29827
30795
  if (hook.event !== "PreToolUse" || hook.tool === void 0) return {};
29828
30796
  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");
30797
+ if (name === claudeCodeToolName("hook", server) || name === codexToolName("hook", server)) return preToolUse("deny", "ref0's hook tool is the harness's, not the agent's");
30798
+ for (const tool of agentTools) if (name === claudeCodeToolName(tool, server)) return preToolUse("allow", "ref0's own tool");
29831
30799
  return {};
29832
30800
  }
29833
30801
  /**
@@ -30840,6 +31808,7 @@ const agentTools = /* @__PURE__ */ new Set();
30840
31808
  const decodeArguments = Schema.decodeUnknownEffect(HookArguments);
30841
31809
  const hookHandlers = (plugin) => HarnessTools.toLayer(Effect.gen(function* () {
30842
31810
  const session = yield* HarnessSession;
31811
+ const server = yield* originPluginName((yield* Ref0Config).api);
30843
31812
  return { hook: (call) => Effect.gen(function* () {
30844
31813
  const update = updateLine({
30845
31814
  protocol: call.protocol,
@@ -30848,7 +31817,7 @@ const hookHandlers = (plugin) => HarnessTools.toLayer(Effect.gen(function* () {
30848
31817
  if (Option.isSome(update)) return yield* new HookRefused({ message: update.value });
30849
31818
  const hook = readHook(yield* decodeArguments(call).pipe(Effect.orDie));
30850
31819
  const delivered = yield* session.deliver(hook);
30851
- return withContext(answerHook(hook, agentTools), hook.event, delivered);
31820
+ return withContext(answerHook(hook, agentTools, server), hook.event, delivered);
30852
31821
  }) };
30853
31822
  }));
30854
31823
  /** Every MCP version Effect speaks; the harness picks. */
@@ -30897,9 +31866,9 @@ const codexSetupHere = Effect.fnUntraced(function* (process) {
30897
31866
  const mcp = Command.make(harnessCommandNames.mcp, {}, () => Effect.gen(function* () {
30898
31867
  const agents = yield* ConfigFolders;
30899
31868
  const found = yield* (yield* HarnessProcess).innermost;
30900
- const agent = Option.isSome(found) ? yield* (yield* HostGrant).get(yield* agents.folder(found.value.harness)) : Option.none();
31869
+ const agent = Option.isSome(found) ? yield* (yield* HostGrant).get(yield* agents.folder(found.value.harness)).pipe(Effect.catchIf((failed) => failed.unreadable === true, () => Effect.succeedNone)) : Option.none();
30901
31870
  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";
31871
+ const reinstall = Option.isSome(found) && Option.isSome(agent) ? installLine(yield* agents.folder(found.value.harness)) : "ref0 connect";
30903
31872
  return yield* Layer.launch(mcpServer(cliVersion, {
30904
31873
  plugin: {
30905
31874
  version: yield* pluginVersion,
@@ -30993,6 +31962,245 @@ const endHarnessSession = Effect.gen(function* () {
30993
31962
  }).pipe(Effect.ignore);
30994
31963
  const sessionEnd = Command.make(harnessCommandNames.sessionEnd, { protocol: protocolFlag }, (flags) => flags.protocol === "1" ? endHarnessSession : Effect.void).pipe(Command.withDescription("Ends the ref0 session of a harness session that's closing"), Command.withShortDescription("End the harness’s ref0 session"));
30995
31964
  //#endregion
31965
+ //#region src/harness/git-hooks.ts
31966
+ /**
31967
+ * ref0's Git hooks: folders of forwarders in `~/.ref0/origins/<origin>/git-hooks`, which ref0 names as
31968
+ * `core.hooksPath` in its agents' commands alone, never in a repo, its config or its hooks.
31969
+ * `core.hooksPath` replaces a repo's whole hooks folder, so each forwarder runs the repo's own
31970
+ * hook of its name, with the same arguments, stdin and exit status, wherever the repo keeps it:
31971
+ * husky's `.husky/_`, `.git/hooks/` or another `core.hooksPath`. It finds and runs that hook
31972
+ * with ref0's entry left out of its environment, so a hook that looks up another, as
31973
+ * `pre-applypatch.sample` runs `pre-commit`, finds the repo's own. A forwarder runs nothing
31974
+ * when the repo's hooks path is one of ref0's folders, or when a forwarder started it, so none
31975
+ * calls itself: a first version that left ref0's entry in started about 1,000 processes.
31976
+ *
31977
+ * Git runs a hook that exists even where the repo has none, so the forwarders cost each hook one
31978
+ * `sh` and one `git rev-parse`, about 1.5 ms. Two hooks run on every index write and ref update,
31979
+ * hundreds of times in a long rebase, so only `all` forwards them, for a checkout that has its
31980
+ * own; `most` forwards every other (measured 23 Oct 2026, in the piece's pull request).
31981
+ *
31982
+ * The folders are the CLI's: a session's start writes them again when a forwarder differs, as
31983
+ * after an update. Each is read-only, so a tool that installs hooks wherever `core.hooksPath`
31984
+ * points, run in an agent's command, fails there rather than replacing a forwarder for every
31985
+ * repo.
31986
+ */
31987
+ /** The hooks Git runs on every index write and ref update. */
31988
+ const frequentHooks = ["post-index-change", "reference-transaction"];
31989
+ /**
31990
+ * Every other hook Git looks for in its hooks path. Not `push-to-checkout`, which replaces Git's
31991
+ * own update of a checkout pushed to merely by existing, nor `fsmonitor-watchman`, which Git runs
31992
+ * from `core.fsmonitor`, never from the hooks path.
31993
+ */
31994
+ const forwardedHooks = [
31995
+ "applypatch-msg",
31996
+ "pre-applypatch",
31997
+ "post-applypatch",
31998
+ "pre-commit",
31999
+ "pre-merge-commit",
32000
+ "prepare-commit-msg",
32001
+ "commit-msg",
32002
+ "post-commit",
32003
+ "pre-rebase",
32004
+ "post-checkout",
32005
+ "post-merge",
32006
+ "pre-push",
32007
+ "pre-receive",
32008
+ "update",
32009
+ "proc-receive",
32010
+ "post-receive",
32011
+ "post-update",
32012
+ "pre-auto-gc",
32013
+ "post-rewrite",
32014
+ "sendemail-validate",
32015
+ "p4-changelist",
32016
+ "p4-prepare-changelist",
32017
+ "p4-post-changelist",
32018
+ "p4-pre-submit"
32019
+ ];
32020
+ /**
32021
+ * The forwarder for one hook. `hooks` is the folder holding ref0's folders: an entry naming one
32022
+ * of them is ref0's, and is left out. The rest keep their order, renumbered.
32023
+ */
32024
+ const forwarder = (name, hooks) => [
32025
+ "#!/bin/sh",
32026
+ `# ref0 ${cliVersion}'s forwarder for Git's ${name} hook. ref0 sets core.hooksPath to this`,
32027
+ "# folder only in its agents' commands. This runs the repo's own hook, found and run with",
32028
+ "# ref0's entry left out, with the same arguments, stdin and exit status.",
32029
+ `[ -n "\${${gitHookVariables.forwarded}-}" ] && exit 0`,
32030
+ `ref0_hooks=${shellWord(hooks)}`,
32031
+ "ref0_n=${GIT_CONFIG_COUNT:-0}",
32032
+ "ref0_i=0",
32033
+ "ref0_j=0",
32034
+ "while [ \"$ref0_i\" -lt \"$ref0_n\" ]; do",
32035
+ " eval \"ref0_key=\\${GIT_CONFIG_KEY_$ref0_i-} ref0_value=\\${GIT_CONFIG_VALUE_$ref0_i-}\"",
32036
+ " unset \"GIT_CONFIG_KEY_$ref0_i\" \"GIT_CONFIG_VALUE_$ref0_i\"",
32037
+ " if [ \"$ref0_key\" != core.hooksPath ] || [ \"${ref0_value%/*}\" != \"$ref0_hooks\" ]; then",
32038
+ " export \"GIT_CONFIG_KEY_$ref0_j=$ref0_key\" \"GIT_CONFIG_VALUE_$ref0_j=$ref0_value\"",
32039
+ " ref0_j=$((ref0_j + 1))",
32040
+ " fi",
32041
+ " ref0_i=$((ref0_i + 1))",
32042
+ "done",
32043
+ "if [ \"$ref0_j\" -eq 0 ]; then unset GIT_CONFIG_COUNT; else export GIT_CONFIG_COUNT=\"$ref0_j\"; fi",
32044
+ `ref0_hook=$(git rev-parse --git-path hooks/${name}) || exit`,
32045
+ "[ -f \"$ref0_hook\" ] && [ -x \"$ref0_hook\" ] || exit 0",
32046
+ "ref0_dir=$(cd -P \"${ref0_hook%/*}\" && pwd) || exit",
32047
+ "[ \"${ref0_dir%/*}\" = \"$ref0_hooks\" ] && exit 0",
32048
+ `export ${gitHookVariables.forwarded}=${name}`,
32049
+ "exec \"$ref0_hook\" \"$@\"",
32050
+ ""
32051
+ ].join("\n");
32052
+ /** The hooks each folder forwards. */
32053
+ const hooksIn = (folder) => folder === "all" ? [...forwardedHooks, ...frequentHooks] : forwardedHooks;
32054
+ /** The folder holding ref0's hooks folders, made if it isn't yet: its path, through any links. */
32055
+ const gitHooksHome = Effect.fnUntraced(function* (home, api) {
32056
+ const fs = yield* FileSystem.FileSystem;
32057
+ const given = (yield* Path.Path).join(home, ".ref0", "origins", originKey(api), "git-hooks");
32058
+ yield* fs.makeDirectory(given, {
32059
+ recursive: true,
32060
+ mode: 448
32061
+ });
32062
+ return yield* fs.realPath(given);
32063
+ });
32064
+ /**
32065
+ * One of ref0's hooks folders, in `hooks`, written first unless every forwarder in it is already
32066
+ * as this CLI writes it: its path, as the agent's `core.hooksPath` names it.
32067
+ */
32068
+ const hooksFolder = Effect.fnUntraced(function* (hooks, folder) {
32069
+ const fs = yield* FileSystem.FileSystem;
32070
+ const path = yield* Path.Path;
32071
+ const crypto = yield* Crypto.Crypto;
32072
+ const written = path.join(hooks, folder);
32073
+ const names = hooksIn(folder);
32074
+ const current = Effect.forEach(names, (name) => fs.readFileString(path.join(written, name)).pipe(Effect.map((text) => text === forwarder(name, hooks)), Effect.orElseSucceed(() => false))).pipe(Effect.map((each) => each.every(Boolean)));
32075
+ if (yield* current) return written;
32076
+ yield* Effect.gen(function* () {
32077
+ yield* fs.makeDirectory(written, { recursive: true });
32078
+ yield* fs.chmod(written, 493);
32079
+ for (const name of names) {
32080
+ const next = path.join(written, `${name}.${yield* crypto.randomUUIDv4}.next`);
32081
+ yield* fs.writeFileString(next, forwarder(name, hooks), { mode: 365 });
32082
+ yield* fs.rename(next, path.join(written, name));
32083
+ }
32084
+ yield* fs.chmod(written, 365);
32085
+ }).pipe(Effect.catch((error) => Effect.flatMap(current, (done) => done ? Effect.void : Effect.fail(error))));
32086
+ return written;
32087
+ });
32088
+ //#endregion
32089
+ //#region src/harness/commit-environment.ts
32090
+ /**
32091
+ * The agent as the author of its commits, set at a Claude Code session's start in the
32092
+ * environment of the agent's own commands, and nowhere else: the file `CLAUDE_ENV_FILE` names,
32093
+ * which Claude Code runs ahead of each of them. It sets Git's author to the agent, by its owner
32094
+ * and handle and its commit email; one `GIT_CONFIG_*` entry naming ref0's hooks folder as
32095
+ * `core.hooksPath`, after any the command already has; and the harness, for the trailer. Git
32096
+ * takes the committer from the person's own config, with their signature, and a commit the
32097
+ * person makes in their own terminal has none of it.
32098
+ *
32099
+ * The agent is the one the session's config folder acts as, from that folder's host grant, which
32100
+ * `ref0 connect` keeps with the agent's ID. It sets no author when the grant can't be read, or
32101
+ * when the session's checkout signs off its commits: any of the last 20 on its default branch
32102
+ * has a `Signed-off-by`, or it has `.github/dco.yml`. A DCO check wants each sign-off to match
32103
+ * its commit's author, and an agent never signs off. The hooks path is still set.
32104
+ */
32105
+ /** How many commits on the default branch a session's start reads for a sign-off. */
32106
+ const signedOffWithin = 20;
32107
+ /**
32108
+ * What a session's start adds to the file Claude Code runs ahead of the agent's commands. The
32109
+ * hooks path goes after the entries a command already has, once however often the file runs.
32110
+ * Without an author, it drops one an outer agent's session set.
32111
+ */
32112
+ const environmentScript = (set) => [
32113
+ "",
32114
+ "# ref0: the agent as the author of its commits, and ref0's Git hooks, in its commands",
32115
+ ...Option.match(set.author, {
32116
+ onNone: () => ["case ${GIT_AUTHOR_EMAIL-} in *@ref0.work) unset GIT_AUTHOR_NAME GIT_AUTHOR_EMAIL ;; esac"],
32117
+ onSome: (author) => [`export GIT_AUTHOR_NAME=${shellWord(author.name)}`, `export GIT_AUTHOR_EMAIL=${shellWord(author.email)}`]
32118
+ }),
32119
+ `export ${gitHookVariables.harness}=${set.harness}`,
32120
+ "if [ -z \"${ref0_hooks_set-}\" ]; then",
32121
+ " ref0_n=${GIT_CONFIG_COUNT:-0}",
32122
+ ` export "GIT_CONFIG_KEY_$ref0_n=core.hooksPath" "GIT_CONFIG_VALUE_$ref0_n="${shellWord(set.folder)} "GIT_CONFIG_COUNT=$((ref0_n + 1))"`,
32123
+ " ref0_hooks_set=1",
32124
+ " unset ref0_n",
32125
+ "fi",
32126
+ ""
32127
+ ].join("\n");
32128
+ /**
32129
+ * What the agent reads at its session's start once its commands name ref0's hooks folder: an
32130
+ * installer run there fails on that read-only folder, and without ref0's entry it installs into
32131
+ * the repo's own.
32132
+ */
32133
+ const hookInstallerWords = `ref0: Git in your commands runs hooks through ref0's read-only folder, so run a hook installer, such as git lfs install, pre-commit install, lefthook install or simple-git-hooks, as env -u GIT_CONFIG_COUNT <command>, to install into the repo's own hooks folder.`;
32134
+ /**
32135
+ * What the session's checkout says: which of ref0's hooks folders it needs, `all` when it has
32136
+ * its own frequent hook, and whether it signs off its commits. Outside a checkout, neither.
32137
+ */
32138
+ const checkoutAt = Effect.fnUntraced(function* (cwd) {
32139
+ const fs = yield* FileSystem.FileSystem;
32140
+ const path = yield* Path.Path;
32141
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
32142
+ const git = (folder, args) => spawner.string(ChildProcess.make("git", [
32143
+ "-C",
32144
+ folder,
32145
+ ...args
32146
+ ])).pipe(Effect.catchCause(() => Effect.succeed("")));
32147
+ const executable = (file) => fs.stat(file).pipe(Effect.map((info) => info.type === "File" && (info.mode & 73) !== 0), Effect.orElseSucceed(() => false));
32148
+ const top = (yield* git(cwd, ["rev-parse", "--show-toplevel"])).trim();
32149
+ const outside = {
32150
+ folder: "most",
32151
+ signsOff: false
32152
+ };
32153
+ if (top === "") return outside;
32154
+ const hooks = (yield* git(top, ["rev-parse", ...frequentHooks.flatMap((name) => ["--git-path", `hooks/${name}`])])).split("\n").filter(Boolean);
32155
+ const folder = (yield* Effect.forEach(hooks, (hook) => executable(path.resolve(top, hook)))).some(Boolean) ? "all" : "most";
32156
+ if (yield* fs.exists(path.join(top, ".github", "dco.yml")).pipe(Effect.orElseSucceed(() => false))) return {
32157
+ folder,
32158
+ signsOff: true
32159
+ };
32160
+ const defaultBranch = (yield* git(top, [
32161
+ "symbolic-ref",
32162
+ "-q",
32163
+ "refs/remotes/origin/HEAD"
32164
+ ])).trim();
32165
+ return {
32166
+ folder,
32167
+ signsOff: (yield* git(top, [
32168
+ "log",
32169
+ "--no-show-signature",
32170
+ "-n",
32171
+ `${signedOffWithin}`,
32172
+ "--format=%(trailers:key=Signed-off-by,valueonly)",
32173
+ defaultBranch === "" ? "HEAD" : defaultBranch,
32174
+ "--"
32175
+ ])).trim() !== ""
32176
+ };
32177
+ });
32178
+ /**
32179
+ * Adds the agent's commit environment to the file Claude Code names, for the session opening in
32180
+ * `cwd`: only in Claude Code, and only for a setup with an agent. ref0's hooks folder is written
32181
+ * first, so the file never names a folder that isn't there. Whether it set ref0's hooks path.
32182
+ */
32183
+ const setCommitEnvironment = Effect.fnUntraced(function* (cwd) {
32184
+ const fs = yield* FileSystem.FileSystem;
32185
+ const folders = yield* ConfigFolders;
32186
+ const file = yield* Config.String("CLAUDE_ENV_FILE").pipe(Config.withDefault(""));
32187
+ if (file === "") return false;
32188
+ const connected = yield* (yield* HostGrant).get(yield* folders.folder("claude-code")).pipe(Effect.map(Option.map(Option.some)), Effect.catchIf((failed) => failed.unreadable === true, () => Effect.succeedSome(Option.none())));
32189
+ if (Option.isNone(connected)) return false;
32190
+ const checkout = yield* checkoutAt(cwd);
32191
+ const folder = yield* hooksFolder(yield* gitHooksHome(yield* Config.String("HOME"), (yield* Ref0Config).api), checkout.folder);
32192
+ const author = checkout.signsOff ? Option.none() : Option.map(connected.value, (grant) => ({
32193
+ name: agentCommitName(grant.owner, grant.agent),
32194
+ email: agentCommitEmail(grant.agentId)
32195
+ }));
32196
+ yield* fs.writeFileString(file, environmentScript({
32197
+ author,
32198
+ folder,
32199
+ harness: "claude-code"
32200
+ }), { flag: "a" });
32201
+ return true;
32202
+ });
32203
+ //#endregion
30996
32204
  //#region src/harness/session-start.ts
30997
32205
  /**
30998
32206
  * `ref0 hook session-start`: the harness's `SessionStart` hook, a command, before `ref0 mcp`
@@ -31042,25 +32250,29 @@ const startOnHook = (protocol) => Effect.gen(function* () {
31042
32250
  const input = yield* stdio.stdin.pipe(Stream.decodeText(), Stream.mkString);
31043
32251
  const hook = decodeHook(input);
31044
32252
  if (Option.isNone(hook)) return;
32253
+ if (yield* setCommitEnvironment(hook.value.cwd ?? ".").pipe(Effect.orElseSucceed(() => false))) yield* Console.log(hookInstallerWords);
31045
32254
  const binding = yield* bindingHere(hook.value.session_id);
31046
32255
  if (Option.isNone(binding)) return;
31047
32256
  const harnessName = binding.value.harness === "codex" ? "codex" : "claude-code";
31048
32257
  const folder = yield* agents.folder(harnessName);
31049
- const agent = yield* (yield* HostGrant).get(folder);
32258
+ const kept = yield* (yield* HostGrant).get(folder).pipe(Effect.map((agent) => ({
32259
+ agent,
32260
+ readable: true
32261
+ })), Effect.catchIf((failed) => failed.unreadable === true, () => Effect.succeed({
32262
+ agent: Option.none(),
32263
+ readable: false
32264
+ })));
32265
+ const agent = kept.agent;
32266
+ const repair = Option.isSome(agent) ? installLine(folder) : yield* connectLineFor(harnessName, folder);
31050
32267
  const update = updateLine({
31051
32268
  protocol,
31052
32269
  version: yield* pluginVersion
31053
- }, installLine(folder, Option.isSome(agent), harnessName));
32270
+ }, repair);
31054
32271
  if (Option.isSome(update)) return yield* Console.log(update.value);
31055
32272
  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
32273
  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)}`);
32274
+ const why = kept.readable ? `no agent is connected for this ${name} config folder, ${folder}` : `the host grant kept for this ${name} config folder, ${folder}, can't be read`;
32275
+ return yield* Console.log(`ref0: ${why}, so no session starts. In your own terminal, run: ${repair}`);
31064
32276
  }
31065
32277
  const account = sessionAccount(binding.value);
31066
32278
  const held = yield* keychain.get(account);
@@ -31093,15 +32305,8 @@ const sessionStart = Command.make(harnessCommandNames.sessionStart, { protocol:
31093
32305
  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
32306
  //#endregion
31095
32307
  //#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);
32308
+ /** Each connected or initialized folder has its own report; secrets are used only to ask ref0. */
32309
+ const encodeReports = Schema.encodeEffect(Schema.Array(StatusReport));
31105
32310
  const checkOf = (check, answer, says, next) => next === void 0 ? {
31106
32311
  check,
31107
32312
  answer,
@@ -31112,24 +32317,16 @@ const checkOf = (check, answer, says, next) => next === void 0 ? {
31112
32317
  says,
31113
32318
  next
31114
32319
  };
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");
32320
+ /** Each non-yes line puts its repair command on the next line, through the shared printer. */
32321
+ const wordsOf = (report, home) => {
32322
+ return [{
32323
+ label: shownConfigFolder(home, report.configFolder),
32324
+ text: ` ${report.agent === void 0 ? report.harness === void 0 ? "unknown harness" : configFolders[report.harness].name : `${report.agent.agent}, ${report.agent.owner}'s agent`}`
32325
+ }, ...report.checks.flatMap((check) => [{
32326
+ status: check.answer,
32327
+ text: `${check.check}: ${check.says}`
32328
+ }, ...check.next === void 0 ? [] : [{ command: check.next }]])];
32329
+ };
31133
32330
  const refused = () => Effect.succeed({ kind: "refused" });
31134
32331
  const whoIs = Effect.fnUntraced(function* (token) {
31135
32332
  const config = yield* Ref0Config;
@@ -31142,157 +32339,140 @@ const whoIs = Effect.fnUntraced(function* (token) {
31142
32339
  }), Effect.orElseSucceed(() => ({ kind: "unanswered" })));
31143
32340
  });
31144
32341
  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)];
32342
+ /** Check the grant kept for this folder, without borrowing another folder's authority. */
32343
+ const grantChecks = Effect.fnUntraced(function* (grant, configFolder) {
32344
+ const asked = yield* whoIs(grant.token);
32345
+ const connected = checkOf("connected", "yes", "this machine holds its grant");
32346
+ switch (asked.kind) {
32347
+ case "accepted": return [connected, checkOf("accepted", "yes", "ref0 accepts its grant")];
32348
+ case "refused": return [connected, checkOf("accepted", "no", "ref0 refuses its grant", yield* connectLineFor(grant.harness, configFolder))];
32349
+ case "unanswered": return [connected, checkOf("accepted", "unknown", unansweredWords((yield* Ref0Config).api), "ref0 status")];
31161
32350
  }
31162
32351
  });
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 = {
32352
+ /** Protocol mismatches either update this CLI or reinstall this folder's plugin. */
32353
+ const speaksCheck = (protocols, version, reinstall) => {
32354
+ if (protocols.length === 0) return checkOf("plugin", "no", "no readable hooks in this folder", reinstall);
32355
+ const mismatch = protocolMismatch({
31171
32356
  protocol: protocols.find((protocol) => protocol !== "1") ?? "1",
31172
32357
  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.`);
32358
+ }, reinstall);
32359
+ if (Option.isSome(mismatch)) return checkOf("plugin", "no", mismatch.value.says, mismatch.value.run);
32360
+ return checkOf("plugin", "yes", "installed and speaks this ref0");
31181
32361
  };
31182
32362
  const decodePlugins = Schema.decodeUnknownEffect(Schema.fromJsonString(ClaudePluginsListed));
31183
32363
  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");
32364
+ /** Ask Claude Code about this folder, even when another folder is current in the terminal. */
32365
+ const claudeCodeChecks = Effect.fnUntraced(function* (configFolder) {
32366
+ const reinstall = installLine(configFolder);
31191
32367
  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.")];
32368
+ if (Option.isNone(claude)) return [checkOf("plugin", "unknown", "Claude Code isn’t on PATH", reinstall)];
32369
+ const home = yield* Config.String("HOME").pipe(Config.withDefault("."));
31193
32370
  const listed = yield* (yield* ChildProcessSpawner.ChildProcessSpawner).string(ChildProcess.make(claude.value, [
31194
32371
  "plugin",
31195
32372
  "list",
31196
32373
  "--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")];
32374
+ ], {
32375
+ env: {
32376
+ HOME: home,
32377
+ CLAUDE_CONFIG_DIR: configFolder
32378
+ },
32379
+ extendEnv: true
32380
+ })).pipe(Effect.flatMap(decodePlugins), Effect.timeout("15 seconds"), Effect.option);
32381
+ if (Option.isNone(listed)) return [checkOf("plugin", "unknown", "Claude Code didn’t list its plugins", reinstall)];
32382
+ const name = yield* originPluginName((yield* Ref0Config).api);
32383
+ const id = `${name}@${name}`;
32384
+ const plugin = listed.value.find((each) => each.id === id);
32385
+ if (plugin === void 0) return [checkOf("plugin", "no", "not installed in this folder", reinstall)];
32386
+ if (!plugin.enabled) return [checkOf("plugin", "no", "turned off in this folder", `CLAUDE_CONFIG_DIR=${shellWord(configFolder)} claude plugin enable ${id}`)];
31202
32387
  const fs = yield* FileSystem.FileSystem;
31203
32388
  const folder = plugin.readFromFolder ?? plugin.installPath;
31204
32389
  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, {
32390
+ return [speaksCheck(Option.match(hooks, {
31206
32391
  onNone: () => [],
31207
32392
  onSome: protocolsOf
31208
32393
  }), Option.fromUndefinedOr(plugin.version), reinstall)];
31209
32394
  });
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.");
32395
+ /** Trust only ref0's hooks; removal of an unexpected plugin targets this folder too. */
32396
+ const trustCheck = (hooks, configFolder, name) => {
32397
+ const reinstall = installLine(configFolder);
32398
+ 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}`);
32399
+ if (hooks.some(waitingForTrust)) return checkOf("trusted", "no", "ref0’s hooks need your trust in a terminal", reinstall);
32400
+ return checkOf("trusted", "yes", "Codex trusts each ref0 hook");
31216
32401
  };
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}.`);
32402
+ /** Ask Codex's app server under this folder's CODEX_HOME. */
32403
+ const codexChecks = Effect.fnUntraced(function* (configFolder) {
32404
+ const reinstall = installLine(configFolder);
31224
32405
  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")];
32406
+ if (Option.isNone(codex)) return [checkOf("plugin", "unknown", "Codex isn’t on PATH", reinstall)];
31226
32407
  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")];
32408
+ const listed = yield* askCodex(codex.value, codexMethods.hooksList, { cwds: [home] }, configFolder).pipe(Effect.option);
32409
+ if (Option.isNone(listed)) return [checkOf("plugin", "unknown", "Codex didn’t list its hooks", reinstall)];
32410
+ const name = yield* originPluginName((yield* Ref0Config).api);
32411
+ const hooks = listed.value.data.flatMap((entry) => entry.hooks).filter((hook) => pluginHook(hook, `${name}@${name}`));
32412
+ if (hooks.length === 0) return [checkOf("plugin", "no", "not installed or turned off in this folder", reinstall)];
31231
32413
  const protocols = hooks.map((hook) => protocolFlagged(hook.command ?? "")).filter(Predicate.isString);
31232
- return [speaksCheck("codex", protocols, Option.none(), reinstall), trustCheck(hooks, reinstall)];
32414
+ return [speaksCheck(protocols, Option.none(), reinstall), trustCheck(hooks, configFolder, name)];
31233
32415
  });
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) {
32416
+ /** A session belongs only to the current folder, even when several folders share its agent. */
32417
+ const sessionCheck = Effect.fnUntraced(function* (harness, configFolder) {
32418
+ const launch = `${configFolders[harness].variable}=${shellWord(configFolder)} ${harness === "codex" ? "codex" : "claude"}`;
31240
32419
  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"}.`;
32420
+ 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
32421
  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;
32422
+ if (Result.isFailure(token)) return checkOf("session", "unknown", `The keychain didn’t say whether ref0 follows this ${configFolders[harness].name} session.`, token.failure.nextStep);
32423
+ if (Option.isNone(token.success)) return checkOf("session", "no", "start a new session in this folder", launch);
31248
32424
  const asked = yield* whoIs(token.success.value);
31249
32425
  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);
32426
+ 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}`);
32427
+ case "refused": return checkOf("session", "no", "ref0 no longer follows this session", launch);
32428
+ case "unanswered": return checkOf("session", "unknown", unansweredWords((yield* Ref0Config).api), "ref0 status");
31253
32429
  }
31254
32430
  });
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,
32431
+ const statusCommands = [Command.make(harnessCommandNames.status, { json }, ({ json: asJson }) => Effect.gen(function* () {
32432
+ const folders = yield* ConfigFolders;
32433
+ const grants = yield* HostGrant;
32434
+ const current = yield* currentConfigFolder;
32435
+ const found = yield* findConfigFolders;
32436
+ const candidates = /* @__PURE__ */ new Map();
32437
+ for (const given of yield* folders.all) {
32438
+ const folder = yield* folders.resolve(given);
32439
+ if (!candidates.has(folder)) candidates.set(folder, void 0);
32440
+ }
32441
+ for (const { configFolder, harness } of found) candidates.set(configFolder, harness);
32442
+ const paths = [...candidates.keys()].sort((a, b) => a === current ? -1 : b === current ? 1 : 0);
32443
+ const reports = [];
32444
+ for (const configFolder of paths) {
32445
+ const kept = yield* Effect.result(grants.get(configFolder));
32446
+ const unreadable = Result.isFailure(kept) && kept.failure.unreadable === true;
32447
+ const grant = Result.isSuccess(kept) ? kept.success : Option.none();
32448
+ const harness = Option.isSome(grant) ? grant.value.harness : candidates.get(configFolder);
32449
+ if (Result.isFailure(kept) || Option.isNone(grant)) {
32450
+ const report = {
32451
+ configFolder,
32452
+ checks: [Result.isFailure(kept) && !unreadable ? checkOf("connected", "unknown", "The keychain didn’t say whether this config folder holds a host grant.", kept.failure.nextStep) : checkOf("connected", "no", unreadable ? "its host grant can’t be read" : "not connected", harness === void 0 ? "ref0 connect" : yield* connectLineFor(harness, configFolder))]
32453
+ };
32454
+ reports.push(harness === void 0 ? report : {
32455
+ ...report,
32456
+ harness
32457
+ });
32458
+ continue;
32459
+ }
32460
+ const checks = [...yield* grantChecks(grant.value, configFolder), ...yield* grant.value.harness === "codex" ? codexChecks(configFolder) : claudeCodeChecks(configFolder)];
32461
+ if (configFolder === current) checks.push(yield* sessionCheck(grant.value.harness, configFolder));
32462
+ reports.push({
32463
+ harness: grant.value.harness,
31288
32464
  configFolder,
31289
- agent: kept,
32465
+ agent: {
32466
+ owner: grant.value.owner,
32467
+ agent: grant.value.agent
32468
+ },
31290
32469
  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"))];
32470
+ });
32471
+ }
32472
+ const home = yield* Config.String("HOME").pipe(Config.withDefault("."));
32473
+ 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)));
32474
+ if (reports.some((report) => report.checks.some((check) => check.answer === "no"))) return yield* new Said();
32475
+ })).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
32476
  //#endregion
31297
32477
  //#region src/ref0.ts
31298
32478
  /**
@@ -31331,7 +32511,7 @@ const ref0 = Command.make("ref0").pipe(Command.withDescription("ref0 from the te
31331
32511
  ...agentCommands,
31332
32512
  ...attachmentCommands
31333
32513
  ]));
31334
- const keychain = Keychain.layer.pipe(Layer.provide(NodeServices.layer));
32514
+ const keychain = Keychain.layer.pipe(Layer.provide([NodeServices.layer, Ref0Config.layer]));
31335
32515
  const harnessProcess = HarnessProcess.layer.pipe(Layer.provide(NodeServices.layer));
31336
32516
  const sessionToken = harnessSessionToken.pipe(Layer.provide([keychain, harnessProcess]));
31337
32517
  /** Every service `ref0`'s commands use, on this machine. */
@@ -31339,7 +32519,7 @@ const services = Layer.mergeAll(cliOutput, Ref0.layer.pipe(Layer.provide([
31339
32519
  Ref0Config.layer,
31340
32520
  sessionToken,
31341
32521
  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));
32522
+ ])), 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
32523
  //#endregion
31344
32524
  //#region src/main.ts
31345
32525
  ref0.pipe(Command.run({ version: cliVersion }), Effect.provide(services), NodeRuntime.runMain);