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