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