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