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