@ref0/cli 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/main.mjs +1436 -677
- package/package.json +2 -2
package/dist/main.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { NodeRuntime, NodeServices } from "@effect/platform-node";
|
|
3
|
-
import { Cause, Clock, Config, ConfigProvider, Console, Context, Cron, Crypto, Data, DateTime, Deferred, Duration, Effect, Exit, FiberSet, FileSystem, Layer, Match, Option, Path, Predicate, Queue, Random, Redacted, Ref, Result, Runtime, Schedule, Schema, SchemaIssue, SchemaTransformation, Scope, Semaphore, Stdio, Stream, Struct, Terminal, Tuple } from "effect";
|
|
3
|
+
import { Cause, Clock, Config, ConfigProvider, Console, Context, Cron, Crypto, Data, DateTime, Deferred, Duration, Effect, Exit, Fiber, FiberSet, FileSystem, Layer, Match, Option, Path, Predicate, Queue, Random, Redacted, Ref, Result, Runtime, Schedule, Schema, SchemaIssue, SchemaTransformation, Scope, Semaphore, Stdio, Stream, Struct, Terminal, Tuple } from "effect";
|
|
4
4
|
import { Argument, CliOutput, Command, Flag, Prompt } from "effect/cli";
|
|
5
5
|
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/http";
|
|
6
6
|
import { HttpApi, HttpApiClient, HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, HttpApiSecurity } from "effect/http-api";
|
|
@@ -11,7 +11,7 @@ import { access, constants } from "node:fs/promises";
|
|
|
11
11
|
import { hostname } from "node:os";
|
|
12
12
|
import { connect, createServer as createServer$1 } from "node:net";
|
|
13
13
|
import { join } from "node:path";
|
|
14
|
-
import { McpProtocol, McpServer, Tool, Toolkit } from "effect/ai";
|
|
14
|
+
import { McpProtocol, McpSchema, McpServer, Tool, Toolkit } from "effect/ai";
|
|
15
15
|
//#region ../contracts/src/typeid.ts
|
|
16
16
|
/**
|
|
17
17
|
* TypeIDs: a prefix, an underscore, then a UUID in 26 characters of Crockford's base32,
|
|
@@ -5486,12 +5486,15 @@ const name$1 = ownerNamePattern.source.replace(/^\^|\$$/g, "");
|
|
|
5486
5486
|
const projectPattern = new RegExp(`^(${name$1})/(${name$1})$`);
|
|
5487
5487
|
const threadPattern = new RegExp(`^(${name$1})(?:/(${name$1}))?#([1-9][0-9]*)$`);
|
|
5488
5488
|
const repoPattern = new RegExp(`^(${name$1})/(${repoNamePattern.source.replace(/^\^|\$$/g, "")})$`);
|
|
5489
|
+
const pullRequestPattern = new RegExp(`${repoPattern.source.replace(/\$$/, "")}#[1-9][0-9]*$`);
|
|
5489
5490
|
/** A project, `owner/project`. */
|
|
5490
5491
|
const WrittenProject = Schema.String.check(Schema.isPattern(projectPattern)).pipe(Schema.brand("WrittenProject"));
|
|
5491
5492
|
/** A thread, `owner/project#number`, or an org thread, `owner#number`. */
|
|
5492
5493
|
const WrittenThread = Schema.String.check(Schema.isPattern(threadPattern)).pipe(Schema.brand("WrittenThread"));
|
|
5493
5494
|
/** A repo, `owner/repo`. */
|
|
5494
5495
|
const WrittenRepo = Schema.String.check(Schema.isPattern(repoPattern)).pipe(Schema.brand("WrittenRepo"));
|
|
5496
|
+
/** A pull request, `owner/repo#number`. */
|
|
5497
|
+
const WrittenPullRequest = Schema.String.check(Schema.isPattern(pullRequestPattern)).pipe(Schema.brand("WrittenPullRequest"));
|
|
5495
5498
|
/** A written repo's parts: its owner, as an endpoint's path takes it, and its name. */
|
|
5496
5499
|
const repoParts = (written) => {
|
|
5497
5500
|
const [, owner = "", repo = ""] = repoPattern.exec(written) ?? [];
|
|
@@ -6767,383 +6770,6 @@ Schema.Union([Schema.Struct({ found: Schema.Literal(false) }), Schema.Struct({
|
|
|
6767
6770
|
attachGrant: Schema.optionalKey(EventId)
|
|
6768
6771
|
})]);
|
|
6769
6772
|
//#endregion
|
|
6770
|
-
//#region ../contracts/src/approvals.ts
|
|
6771
|
-
/**
|
|
6772
|
-
* Approvals, as their commands travel: what a terminal, a page or an agent asks its person to
|
|
6773
|
-
* say yes to. A command is one endpoint, by its name in the surface table, and its request, as
|
|
6774
|
-
* that endpoint encodes it. ref0 decodes each part by the endpoint's own `Schema` before it
|
|
6775
|
-
* keeps or shows anything, so a command is exactly what the endpoint would take.
|
|
6776
|
-
*/
|
|
6777
|
-
/**
|
|
6778
|
-
* An endpoint by its name in the surface table, `group.endpoint`. Checked against the table
|
|
6779
|
-
* where the command is made canonical, since the table is typed from the API this is part of.
|
|
6780
|
-
*/
|
|
6781
|
-
const CommandEndpoint = Schema.String.check(Schema.isPattern(/^[a-zA-Z]+\.[a-zA-Z]+$/));
|
|
6782
|
-
/** One command, as asked: its endpoint, and its request's path, query and payload, encoded. */
|
|
6783
|
-
const ApprovalCommand = Schema.Struct({
|
|
6784
|
-
endpoint: CommandEndpoint,
|
|
6785
|
-
params: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)),
|
|
6786
|
-
query: Schema.optionalKey(Schema.JsonObject),
|
|
6787
|
-
payload: Schema.optionalKey(Schema.Json)
|
|
6788
|
-
});
|
|
6789
|
-
/** A command ref0 won't ask anyone about: not an endpoint's request, or not one an approval runs. */
|
|
6790
|
-
var CommandRefused = class extends Schema.TaggedError()("CommandRefused", { nextStep: Schema.String }, { httpApiStatus: 400 }) {};
|
|
6791
|
-
/** A terminal asking: its command, and the hash of the secret it collects the result with. */
|
|
6792
|
-
const StartApproval = Schema.Struct({
|
|
6793
|
-
command: ApprovalCommand,
|
|
6794
|
-
secretHash: SecretHash
|
|
6795
|
-
});
|
|
6796
|
-
/** A terminal's approval, started: what to show its person, and until when. */
|
|
6797
|
-
const ApprovalStarted = Schema.Struct({
|
|
6798
|
-
approval: ApprovalId,
|
|
6799
|
-
code: ApprovalCode,
|
|
6800
|
-
expiresAt: Timestamp
|
|
6801
|
-
});
|
|
6802
|
-
/**
|
|
6803
|
-
* An answer that waits for the person: the call only the person may make, or that agents ask
|
|
6804
|
-
* for, became an approval, which the person answers on their own page. A `202`, never a failure
|
|
6805
|
-
* of the call: whoever asked collects the result with `GET /v1/approvals/{approval}`.
|
|
6806
|
-
*/
|
|
6807
|
-
var ApprovalNeeded = class extends Schema.TaggedError()("ApprovalNeeded", {
|
|
6808
|
-
approval: ApprovalId,
|
|
6809
|
-
says: Schema.String,
|
|
6810
|
-
expiresAt: Timestamp
|
|
6811
|
-
}, { httpApiStatus: 202 }) {};
|
|
6812
|
-
const terminalAsker = Schema.Struct({ kind: Schema.Literal("terminal") });
|
|
6813
|
-
const pageAsker = Schema.Struct({ kind: Schema.Literal("page") });
|
|
6814
|
-
const agentAsker = Schema.Struct({
|
|
6815
|
-
kind: Schema.Literal("agent"),
|
|
6816
|
-
agent: AgentId,
|
|
6817
|
-
handle: AgentHandle,
|
|
6818
|
-
session: SessionId,
|
|
6819
|
-
/** The attachment its credential was made under, for a person's agent in an organization. */
|
|
6820
|
-
attachment: Schema.optionalKey(CredentialRef)
|
|
6821
|
-
});
|
|
6822
|
-
/**
|
|
6823
|
-
* Who asked, as a receipt keeps it: a terminal, one of ref0's pages, an agent, or a machine
|
|
6824
|
-
* asking to connect, by its ID and its own word for itself, and from where.
|
|
6825
|
-
*/
|
|
6826
|
-
const ApprovalAsker = Schema.Union([
|
|
6827
|
-
terminalAsker,
|
|
6828
|
-
pageAsker,
|
|
6829
|
-
Schema.Struct({
|
|
6830
|
-
kind: Schema.Literal("machine"),
|
|
6831
|
-
machine: MachineId,
|
|
6832
|
-
machineName: MachineName,
|
|
6833
|
-
askedFrom: Schema.optionalKey(Schema.String)
|
|
6834
|
-
}),
|
|
6835
|
-
agentAsker
|
|
6836
|
-
]);
|
|
6837
|
-
/**
|
|
6838
|
-
* Who asked, as the person reads a request before they answer: a terminal, a page or an agent.
|
|
6839
|
-
* A machine's request is read as an `AttachmentRequestView`, never as a command.
|
|
6840
|
-
*/
|
|
6841
|
-
const ApprovalViewAsker = Schema.Union([
|
|
6842
|
-
terminalAsker,
|
|
6843
|
-
pageAsker,
|
|
6844
|
-
agentAsker
|
|
6845
|
-
]);
|
|
6846
|
-
/** A request, as the person reads it before they answer. */
|
|
6847
|
-
const ApprovalView = Schema.Struct({
|
|
6848
|
-
approval: ApprovalId,
|
|
6849
|
-
/** The command as its CLI line. */
|
|
6850
|
-
says: Schema.String,
|
|
6851
|
-
/** Everything the command carries, each named, in order: nothing it would run is left out. */
|
|
6852
|
-
fields: Schema.Array(Schema.Struct({
|
|
6853
|
-
name: Schema.String,
|
|
6854
|
-
value: Schema.String,
|
|
6855
|
-
part: Schema.Literals([
|
|
6856
|
-
"path",
|
|
6857
|
-
"query",
|
|
6858
|
-
"payload"
|
|
6859
|
-
])
|
|
6860
|
-
})),
|
|
6861
|
-
command: ApprovalCommand,
|
|
6862
|
-
asker: ApprovalViewAsker,
|
|
6863
|
-
askedFrom: Schema.optionalKey(Schema.String),
|
|
6864
|
-
askedAt: Timestamp,
|
|
6865
|
-
expiresAt: Timestamp,
|
|
6866
|
-
/**
|
|
6867
|
-
* An agent's ask: the kind of action its answer may set agents' permission for, and whether a
|
|
6868
|
-
* yes may let agents take it without asking. A no with the passkey may always stop them asking.
|
|
6869
|
-
*/
|
|
6870
|
-
agentPermission: Schema.optionalKey(Schema.Struct({
|
|
6871
|
-
action: ScopeAction,
|
|
6872
|
-
mayAllow: Schema.Boolean
|
|
6873
|
-
})),
|
|
6874
|
-
/**
|
|
6875
|
-
* What the command acts on, drawn as ref0 resolves it for the person reading, where its
|
|
6876
|
-
* endpoint names a preview. Without one the page can draw, it offers only no.
|
|
6877
|
-
*/
|
|
6878
|
-
preview: Schema.optionalKey(ApprovalPreview)
|
|
6879
|
-
});
|
|
6880
|
-
/** A statement's hash: the SHA-256 of its words and quoted values as JSON, as lowercase hex. */
|
|
6881
|
-
const StatementHash = Schema.String.check(Schema.isPattern(/^[0-9a-f]{64}$/));
|
|
6882
|
-
/**
|
|
6883
|
-
* Who asks for a yes to a statement: a workflow's run, `passkey.run`'s, by the workflow and the
|
|
6884
|
-
* run; or a gate's check, `passkey.check`'s, by the check, the pull request's version it gates and
|
|
6885
|
-
* the check run its statement is over.
|
|
6886
|
-
*/
|
|
6887
|
-
const StatementAsker = Schema.Union([Schema.Struct({
|
|
6888
|
-
kind: Schema.Literal("workflow"),
|
|
6889
|
-
workflow: WorkflowName,
|
|
6890
|
-
run: WorkflowRunId
|
|
6891
|
-
}), Schema.Struct({
|
|
6892
|
-
kind: Schema.Literal("gate"),
|
|
6893
|
-
check: CheckKey,
|
|
6894
|
-
pullRequest: PullRequestNumber,
|
|
6895
|
-
version: VersionNumber,
|
|
6896
|
-
run: CheckRunId
|
|
6897
|
-
})]);
|
|
6898
|
-
/**
|
|
6899
|
-
* A passkey request, as the person reads it before they answer: the statement, its words as its
|
|
6900
|
-
* asker wrote them and each value it quotes, and its hash; who asks; where, a workflow's
|
|
6901
|
-
* `owner/repo` or a thread's `owner/project#number`, or a gate's pull request, `owner/repo#number`;
|
|
6902
|
-
* whom it asks, the run's accountable person, or for a gate the person behind its run, a person by
|
|
6903
|
-
* name, or a permission's holders; and until when.
|
|
6904
|
-
*/
|
|
6905
|
-
const StatementView = Schema.Struct({
|
|
6906
|
-
approval: ApprovalId,
|
|
6907
|
-
statement: WorkflowText,
|
|
6908
|
-
hash: StatementHash,
|
|
6909
|
-
asker: StatementAsker,
|
|
6910
|
-
home: Schema.String,
|
|
6911
|
-
from: Schema.Union([Schema.Literals(["accountable", "person"]), Schema.Struct({ permission: RepoPermissionName })]),
|
|
6912
|
-
askedAt: Timestamp,
|
|
6913
|
-
expiresAt: Timestamp
|
|
6914
|
-
});
|
|
6915
|
-
/**
|
|
6916
|
-
* The person's own requests, waiting for them; the passkey requests workflows' runs and gates asked
|
|
6917
|
-
* them; and each machine's request this browser session is the answerer of, so a reload finds it
|
|
6918
|
-
* again without its code.
|
|
6919
|
-
*/
|
|
6920
|
-
const ApprovalsWaiting = Schema.Struct({
|
|
6921
|
-
approvals: Schema.Array(ApprovalView),
|
|
6922
|
-
statements: Schema.Array(StatementView),
|
|
6923
|
-
requests: Schema.Array(AttachmentRequestView)
|
|
6924
|
-
});
|
|
6925
|
-
/** Finding a terminal's request by the code it showed. */
|
|
6926
|
-
const FindApproval = Schema.Union([Schema.Struct({ code: TypedApprovalCode }), Schema.Struct({ link: Schema.String.check(Schema.isPattern(/^[A-Za-z0-9_-]{22}$/)) })]);
|
|
6927
|
-
/**
|
|
6928
|
-
* Asking for the passkey's options to answer a request: a terminal's request needs its code
|
|
6929
|
-
* again, so its ID alone, which travels where its code doesn't, never answers it. A machine's
|
|
6930
|
-
* request needs its answerer, and the agent it chooses to connect, before a yes has options.
|
|
6931
|
-
*/
|
|
6932
|
-
const ApprovalOptionsRequest = Schema.Struct({
|
|
6933
|
-
code: Schema.optionalKey(TypedApprovalCode),
|
|
6934
|
-
folders: Schema.optionalKey(ConnectFolderChoices)
|
|
6935
|
-
});
|
|
6936
|
-
/**
|
|
6937
|
-
* An answer: yes, with the passkey, or no. For an agent's ask, each answer can set the owner's
|
|
6938
|
-
* permission its own way, so a pairing that can't happen can't be sent: yes, and let agents do
|
|
6939
|
-
* it without asking; no, and stop agents asking, with the passkey too.
|
|
6940
|
-
*/
|
|
6941
|
-
const AnswerApproval = Schema.Union([
|
|
6942
|
-
Schema.Struct({
|
|
6943
|
-
answer: Schema.Literal("yes"),
|
|
6944
|
-
permission: Schema.optionalKey(Schema.Literal("allow")),
|
|
6945
|
-
assertion: PasskeyAsserted,
|
|
6946
|
-
code: Schema.optionalKey(TypedApprovalCode),
|
|
6947
|
-
/** The preview the page drew, where the command's endpoint names one. */
|
|
6948
|
-
preview: Schema.optionalKey(PreviewEcho),
|
|
6949
|
-
/** The statement the page showed, by its hash, for a workflow run's or a gate's passkey request. */
|
|
6950
|
-
statement: Schema.optionalKey(Schema.Struct({ hash: StatementHash }))
|
|
6951
|
-
}),
|
|
6952
|
-
Schema.Struct({
|
|
6953
|
-
answer: Schema.Literal("no"),
|
|
6954
|
-
permission: Schema.Literal("deny"),
|
|
6955
|
-
assertion: PasskeyAsserted,
|
|
6956
|
-
code: Schema.optionalKey(TypedApprovalCode)
|
|
6957
|
-
}),
|
|
6958
|
-
Schema.Struct({
|
|
6959
|
-
answer: Schema.Literal("no"),
|
|
6960
|
-
code: Schema.optionalKey(TypedApprovalCode)
|
|
6961
|
-
})
|
|
6962
|
-
]);
|
|
6963
|
-
/**
|
|
6964
|
-
* What running an approved command came to: its status, and its endpoint's answer or refusal,
|
|
6965
|
-
* as JSON. ref0 reads it by the command's endpoint before anyone sees it, and whoever asked reads
|
|
6966
|
-
* it the same way, with `ranOf`: this schema is part of the API the endpoints' types come from,
|
|
6967
|
-
* so it can't be typed by them itself.
|
|
6968
|
-
*/
|
|
6969
|
-
const Ran = Schema.Struct({
|
|
6970
|
-
status: Schema.Int,
|
|
6971
|
-
result: Schema.Json
|
|
6972
|
-
});
|
|
6973
|
-
/**
|
|
6974
|
-
* A ran command whose answer its endpoint's schemas no longer read, such as one kept from
|
|
6975
|
-
* before they changed: it ran, and what it came to can't be said.
|
|
6976
|
-
*/
|
|
6977
|
-
var ResultUnreadable = class extends Schema.TaggedError()("ResultUnreadable", { nextStep: Schema.String }, { httpApiStatus: 500 }) {};
|
|
6978
|
-
/**
|
|
6979
|
-
* Where a request stands, for whoever asked: still waiting; approved, and running as soon as
|
|
6980
|
-
* ref0 can; refused; expired; or what it ran to, with the endpoint it ran, whose schemas read it.
|
|
6981
|
-
*/
|
|
6982
|
-
const ApprovalState = Schema.Union([
|
|
6983
|
-
Schema.Struct({
|
|
6984
|
-
state: Schema.Literal("waiting"),
|
|
6985
|
-
expiresAt: Timestamp
|
|
6986
|
-
}),
|
|
6987
|
-
Schema.Struct({
|
|
6988
|
-
state: Schema.Literal("approved"),
|
|
6989
|
-
/** A machine's answering browser may forget its saved answer once the terminal kept it. */
|
|
6990
|
-
collected: Schema.optionalKey(Schema.Literal(true))
|
|
6991
|
-
}),
|
|
6992
|
-
Schema.Struct({ state: Schema.Literal("refused") }),
|
|
6993
|
-
Schema.Struct({ state: Schema.Literal("expired") }),
|
|
6994
|
-
Schema.Struct({
|
|
6995
|
-
state: Schema.Literal("ran"),
|
|
6996
|
-
endpoint: CommandEndpoint,
|
|
6997
|
-
...Ran.fields
|
|
6998
|
-
})
|
|
6999
|
-
]);
|
|
7000
|
-
/** Only the answering browser receives a machine's answer code; state reads never carry it. */
|
|
7001
|
-
const ApprovalAnswer = Schema.Union([
|
|
7002
|
-
Schema.Struct({
|
|
7003
|
-
...ApprovalState.members[1].fields,
|
|
7004
|
-
answerCode: ApprovalCode
|
|
7005
|
-
}),
|
|
7006
|
-
Schema.Struct({
|
|
7007
|
-
...ApprovalState.members[4].fields,
|
|
7008
|
-
answerCode: ApprovalCode
|
|
7009
|
-
}),
|
|
7010
|
-
ApprovalState
|
|
7011
|
-
]);
|
|
7012
|
-
/** A request that isn't there, or isn't the caller's to see, answers the same. */
|
|
7013
|
-
var ApprovalNotFound = class extends Schema.TaggedError()("ApprovalNotFound", { nextStep: Schema.String }, { httpApiStatus: 404 }) {};
|
|
7014
|
-
/** Too many asked, or too many codes typed, from here in the last hour. */
|
|
7015
|
-
var SlowDown = class extends Schema.TaggedError()("SlowDown", { nextStep: Schema.String }, { httpApiStatus: 429 }) {};
|
|
7016
|
-
/** A command's hash: the SHA-256 of its canonical JSON, as lowercase hex. */
|
|
7017
|
-
const CommandHash = Schema.String.check(Schema.isPattern(/^[0-9a-f]{64}$/));
|
|
7018
|
-
Schema.Struct({
|
|
7019
|
-
approval: ApprovalId,
|
|
7020
|
-
endpoint: CommandEndpoint,
|
|
7021
|
-
/** The command's canonical JSON. */
|
|
7022
|
-
command: Schema.String,
|
|
7023
|
-
commandHash: CommandHash,
|
|
7024
|
-
asker: ApprovalAsker,
|
|
7025
|
-
/** The browser session the person answered in, for their log. */
|
|
7026
|
-
credential: CredentialRef,
|
|
7027
|
-
generation: Generation,
|
|
7028
|
-
/** When the request expires, in epoch milliseconds. */
|
|
7029
|
-
deadline: Schema.Int,
|
|
7030
|
-
challenge: Schema.String,
|
|
7031
|
-
/** A machine answer retries only from its answering session, with this key. */
|
|
7032
|
-
answerKey: Schema.optionalKey(RequestId),
|
|
7033
|
-
/** An agent's ask: the credential it asked with, checked again before the answer counts. */
|
|
7034
|
-
asking: Schema.optionalKey(TokenId),
|
|
7035
|
-
/** The kind of action the person, answering, let agents take without asking from now on. */
|
|
7036
|
-
allow: Schema.optionalKey(ScopeAction)
|
|
7037
|
-
});
|
|
7038
|
-
Schema.Struct({
|
|
7039
|
-
approval: ApprovalId,
|
|
7040
|
-
action: ScopeAction,
|
|
7041
|
-
credential: CredentialRef,
|
|
7042
|
-
generation: Generation
|
|
7043
|
-
});
|
|
7044
|
-
Schema.Union([Schema.Struct({ denied: Schema.Literal(true) }), Schema.Struct({
|
|
7045
|
-
denied: Schema.Literal(false),
|
|
7046
|
-
reason: Schema.Literals(["generation moved", "not set up"])
|
|
7047
|
-
})]);
|
|
7048
|
-
Schema.Union([Schema.Struct({
|
|
7049
|
-
admitted: Schema.Literal(true),
|
|
7050
|
-
admittedAt: Schema.Int,
|
|
7051
|
-
answerCode: Schema.optionalKey(ApprovalCode)
|
|
7052
|
-
}), Schema.Struct({
|
|
7053
|
-
admitted: Schema.Literal(false),
|
|
7054
|
-
reason: Schema.Literals([
|
|
7055
|
-
"expired",
|
|
7056
|
-
"generation moved",
|
|
7057
|
-
"challenge used",
|
|
7058
|
-
"not set up",
|
|
7059
|
-
"asker gone"
|
|
7060
|
-
])
|
|
7061
|
-
})]);
|
|
7062
|
-
Schema.Union([Schema.Struct({ found: Schema.Literal(false) }), Schema.Struct({
|
|
7063
|
-
found: Schema.Literal(true),
|
|
7064
|
-
endpoint: CommandEndpoint,
|
|
7065
|
-
command: Schema.String,
|
|
7066
|
-
commandHash: CommandHash,
|
|
7067
|
-
generation: Generation,
|
|
7068
|
-
generationNow: Generation,
|
|
7069
|
-
/** The `approval.answered` its admission recorded in the person's log. */
|
|
7070
|
-
answeredEvent: EventId,
|
|
7071
|
-
/** Who asked, as that `approval.answered` says. */
|
|
7072
|
-
asker: ApprovalAsker,
|
|
7073
|
-
/** What running it came to, once it ran, as `KeptRan` keeps it. */
|
|
7074
|
-
result: Schema.optionalKey(Schema.String)
|
|
7075
|
-
})]);
|
|
7076
|
-
/**
|
|
7077
|
-
* A `Ran` as it's kept, on its receipt and on its approval: its JSON, as text. The Owner DO
|
|
7078
|
-
* keeps it as it comes and runs no endpoint's schemas, so it's read only by its endpoint, with
|
|
7079
|
-
* `keptRan`, which refuses one they don't take.
|
|
7080
|
-
*/
|
|
7081
|
-
const KeptRan = Schema.fromJsonString(Ran);
|
|
7082
|
-
Schema.Struct({
|
|
7083
|
-
approval: ApprovalId,
|
|
7084
|
-
result: Schema.String
|
|
7085
|
-
});
|
|
7086
|
-
Schema.Struct({ result: Schema.String });
|
|
7087
|
-
Schema.Struct({ approval: ApprovalId });
|
|
7088
|
-
Schema.Struct({ approval: ApprovalId });
|
|
7089
|
-
Schema.Union([Schema.Struct({ expired: Schema.Literal(true) }), Schema.Struct({ expired: Schema.Literal(false) })]);
|
|
7090
|
-
Schema.Struct({
|
|
7091
|
-
person: PersonId,
|
|
7092
|
-
approval: ApprovalId
|
|
7093
|
-
});
|
|
7094
|
-
//#endregion
|
|
7095
|
-
//#region ../contracts/src/browser-sessions.ts
|
|
7096
|
-
/**
|
|
7097
|
-
* A browser session's cookie: Better Auth's, named for ref0, `Secure` and host-only on the
|
|
7098
|
-
* app's one origin. It holds the session's token, signed; the directory and the person's Owner DO hold
|
|
7099
|
-
* only the token's SHA-256.
|
|
7100
|
-
*/
|
|
7101
|
-
const browserSessionCookie = "__Secure-ref0.session_token";
|
|
7102
|
-
Schema.Struct({
|
|
7103
|
-
token: TokenId,
|
|
7104
|
-
secretHash: SecretHash,
|
|
7105
|
-
expiresAt: Schema.DateTimeUtcFromMillis
|
|
7106
|
-
});
|
|
7107
|
-
Schema.Struct({ registered: Schema.Boolean });
|
|
7108
|
-
/**
|
|
7109
|
-
* Where a signed-in person stands: naming, when the name their GitHub login gave was taken or
|
|
7110
|
-
* kept back, with that login as a suggestion; setting up, once their owner is made and before
|
|
7111
|
-
* it's ready with this session registered, which naming again finishes; or ready.
|
|
7112
|
-
*/
|
|
7113
|
-
const Welcome = Schema.Union([Schema.Struct({
|
|
7114
|
-
state: Schema.Literals(["ready", "setting up"]),
|
|
7115
|
-
person: PersonId,
|
|
7116
|
-
owner: OwnerId,
|
|
7117
|
-
name: OwnerName
|
|
7118
|
-
}), Schema.Struct({
|
|
7119
|
-
state: Schema.Literal("naming"),
|
|
7120
|
-
person: PersonId,
|
|
7121
|
-
suggestion: Schema.optionalKey(Schema.String)
|
|
7122
|
-
})]);
|
|
7123
|
-
/**
|
|
7124
|
-
* A browser session renewed: its expiry, in its person's Owner DO and in the directory, and on its cookie.
|
|
7125
|
-
* Renewed at most once a day, so one asked sooner answers its expiry unchanged.
|
|
7126
|
-
*/
|
|
7127
|
-
const SessionRenewed = Schema.Struct({ expiresAt: Schema.DateTimeUtcFromMillis });
|
|
7128
|
-
/**
|
|
7129
|
-
* What starting a GitHub sign-in at `POST /auth/sign-in/social` answers: GitHub's address to go
|
|
7130
|
-
* to, with the sign-in's state in a cookie set alongside. Better Auth's answer, which ref0 serves.
|
|
7131
|
-
*/
|
|
7132
|
-
const NavigationUrl = Schema.String.check(Schema.isPattern(/^https?:\/\/[^\s/]+(?:\/[^\s]*)?$/), Schema.makeFilter((url) => URL.canParse(url) || "an HTTP(S) URL the platform can parse")).pipe(Schema.brand("NavigationUrl"));
|
|
7133
|
-
Schema.Struct({ url: NavigationUrl });
|
|
7134
|
-
/**
|
|
7135
|
-
* Where signing in sends the browser back to, as `POST /auth/sign-in/social` takes it: a path on
|
|
7136
|
-
* ref0's own origin, such as `/welcome`, or a whole URL. Never a socket's.
|
|
7137
|
-
*/
|
|
7138
|
-
const SignInDestination = Schema.String.pipe(Schema.brand("SignInDestination"));
|
|
7139
|
-
Schema.Struct({
|
|
7140
|
-
provider: Schema.Literal("github"),
|
|
7141
|
-
callbackURL: SignInDestination,
|
|
7142
|
-
errorCallbackURL: SignInDestination
|
|
7143
|
-
});
|
|
7144
|
-
/** The name a naming person chose for their owner. */
|
|
7145
|
-
const NameOwner = Schema.Struct({ name: OwnerName });
|
|
7146
|
-
//#endregion
|
|
7147
6773
|
//#region ../contracts/src/plan-events.ts
|
|
7148
6774
|
/**
|
|
7149
6775
|
* A plan's events, in its thread's project's log, written by the Project DO. A version's
|
|
@@ -7282,6 +6908,21 @@ const DecisionView = Schema.Struct({
|
|
|
7282
6908
|
closedWhy: Schema.optionalKey(DecisionClosedWhy)
|
|
7283
6909
|
});
|
|
7284
6910
|
/**
|
|
6911
|
+
* Where a decision is, as people write it: its thread, `pip/default#3`, or an org thread's,
|
|
6912
|
+
* `acme#7`; and a gate's pull request, `pip/notes#1`. Its Project DO writes them as it answers,
|
|
6913
|
+
* from its own threads and repos and the owner's name the Worker gives it, so each reads as it's
|
|
6914
|
+
* called now.
|
|
6915
|
+
*/
|
|
6916
|
+
const DecisionNames = Schema.Struct({
|
|
6917
|
+
thread: WrittenThread,
|
|
6918
|
+
pullRequest: Schema.optionalKey(WrittenPullRequest)
|
|
6919
|
+
});
|
|
6920
|
+
/** A decision as `/v1` answers it: as it stands, and where it is, in words. */
|
|
6921
|
+
const NamedDecision = Schema.Struct({
|
|
6922
|
+
...DecisionView.fields,
|
|
6923
|
+
names: DecisionNames
|
|
6924
|
+
});
|
|
6925
|
+
/**
|
|
7285
6926
|
* A version as it stands: its content, its state, and, once proposed, where its approval is: its
|
|
7286
6927
|
* repos still being checked, or who accepted it.
|
|
7287
6928
|
*/
|
|
@@ -7435,7 +7076,7 @@ const GateAnswerRoute = Schema.TaggedStruct("GateAnswerRoute", {
|
|
|
7435
7076
|
reason: Schema.optionalKey(TextId)
|
|
7436
7077
|
});
|
|
7437
7078
|
Schema.Union([
|
|
7438
|
-
|
|
7079
|
+
NamedDecision,
|
|
7439
7080
|
GateAnswerRoute,
|
|
7440
7081
|
DecisionNotFound,
|
|
7441
7082
|
AnswerRefused,
|
|
@@ -7467,14 +7108,29 @@ Schema.Union([
|
|
|
7467
7108
|
]) }),
|
|
7468
7109
|
DeliveryOutsideWindow
|
|
7469
7110
|
]);
|
|
7470
|
-
|
|
7111
|
+
/** Someone decisions wait on: a person, or an agent, and the roles the Worker found they hold. */
|
|
7112
|
+
const Waiter = Schema.Struct({
|
|
7471
7113
|
decider: DeciderRef,
|
|
7472
|
-
roles: Schema.
|
|
7114
|
+
roles: Schema.Array(Schema.Struct({
|
|
7473
7115
|
role: Schema.Literal("admin"),
|
|
7474
7116
|
owner: OwnerId
|
|
7475
|
-
}))
|
|
7117
|
+
}))
|
|
7476
7118
|
});
|
|
7477
|
-
|
|
7119
|
+
Schema.Struct({
|
|
7120
|
+
ownerName: OwnerName,
|
|
7121
|
+
you: Waiter,
|
|
7122
|
+
person: Schema.optionalKey(Waiter),
|
|
7123
|
+
decisions: Schema.optionalKey(Schema.Array(DecisionId).check(Schema.isMaxLength(100)))
|
|
7124
|
+
});
|
|
7125
|
+
/**
|
|
7126
|
+
* An open decision that waits on the caller: on them, or, for an agent, on its person, who
|
|
7127
|
+
* answers it. A gate never takes an agent's answer, so an agent's gate always waits on its person.
|
|
7128
|
+
*/
|
|
7129
|
+
const WaitingDecision = Schema.Struct({
|
|
7130
|
+
...NamedDecision.fields,
|
|
7131
|
+
waitsOn: Schema.Literals(["you", "your person"])
|
|
7132
|
+
});
|
|
7133
|
+
const WaitingPage = Schema.Struct({ decisions: Schema.Array(WaitingDecision) });
|
|
7478
7134
|
Schema.Struct({
|
|
7479
7135
|
ownerName: OwnerName,
|
|
7480
7136
|
number: ThreadNumber
|
|
@@ -7492,7 +7148,7 @@ Schema.Struct({
|
|
|
7492
7148
|
decision: DecisionId,
|
|
7493
7149
|
wait: Schema.optionalKey(DecisionWait)
|
|
7494
7150
|
});
|
|
7495
|
-
Schema.Union([
|
|
7151
|
+
Schema.Union([NamedDecision, DecisionNotFound]);
|
|
7496
7152
|
/**
|
|
7497
7153
|
* `POST …/plans`: a version, as a draft unless it's proposed at once, and why it was written.
|
|
7498
7154
|
* A first version needs no reason, and a later one may leave it out.
|
|
@@ -7541,6 +7197,385 @@ const ChildStageView = Schema.Struct({
|
|
|
7541
7197
|
});
|
|
7542
7198
|
Schema.Union([ChildStageView, ThreadNotFound]);
|
|
7543
7199
|
//#endregion
|
|
7200
|
+
//#region ../contracts/src/approvals.ts
|
|
7201
|
+
/**
|
|
7202
|
+
* Approvals, as their commands travel: what a terminal, a page or an agent asks its person to
|
|
7203
|
+
* say yes to. A command is one endpoint, by its name in the surface table, and its request, as
|
|
7204
|
+
* that endpoint encodes it. ref0 decodes each part by the endpoint's own `Schema` before it
|
|
7205
|
+
* keeps or shows anything, so a command is exactly what the endpoint would take.
|
|
7206
|
+
*/
|
|
7207
|
+
/**
|
|
7208
|
+
* An endpoint by its name in the surface table, `group.endpoint`. Checked against the table
|
|
7209
|
+
* where the command is made canonical, since the table is typed from the API this is part of.
|
|
7210
|
+
*/
|
|
7211
|
+
const CommandEndpoint = Schema.String.check(Schema.isPattern(/^[a-zA-Z]+\.[a-zA-Z]+$/));
|
|
7212
|
+
/** One command, as asked: its endpoint, and its request's path, query and payload, encoded. */
|
|
7213
|
+
const ApprovalCommand = Schema.Struct({
|
|
7214
|
+
endpoint: CommandEndpoint,
|
|
7215
|
+
params: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)),
|
|
7216
|
+
query: Schema.optionalKey(Schema.JsonObject),
|
|
7217
|
+
payload: Schema.optionalKey(Schema.Json)
|
|
7218
|
+
});
|
|
7219
|
+
/** A command ref0 won't ask anyone about: not an endpoint's request, or not one an approval runs. */
|
|
7220
|
+
var CommandRefused = class extends Schema.TaggedError()("CommandRefused", { nextStep: Schema.String }, { httpApiStatus: 400 }) {};
|
|
7221
|
+
/** A terminal asking: its command, and the hash of the secret it collects the result with. */
|
|
7222
|
+
const StartApproval = Schema.Struct({
|
|
7223
|
+
command: ApprovalCommand,
|
|
7224
|
+
secretHash: SecretHash
|
|
7225
|
+
});
|
|
7226
|
+
/** A terminal's approval, started: what to show its person, and until when. */
|
|
7227
|
+
const ApprovalStarted = Schema.Struct({
|
|
7228
|
+
approval: ApprovalId,
|
|
7229
|
+
code: ApprovalCode,
|
|
7230
|
+
expiresAt: Timestamp
|
|
7231
|
+
});
|
|
7232
|
+
/**
|
|
7233
|
+
* An answer that waits for the person: the call only the person may make, or that agents ask
|
|
7234
|
+
* for, became an approval, which the person answers on their own page. A `202`, never a failure
|
|
7235
|
+
* of the call: whoever asked collects the result with `GET /v1/approvals/{approval}`.
|
|
7236
|
+
*/
|
|
7237
|
+
var ApprovalNeeded = class extends Schema.TaggedError()("ApprovalNeeded", {
|
|
7238
|
+
approval: ApprovalId,
|
|
7239
|
+
says: Schema.String,
|
|
7240
|
+
expiresAt: Timestamp
|
|
7241
|
+
}, { httpApiStatus: 202 }) {};
|
|
7242
|
+
const terminalAsker = Schema.Struct({ kind: Schema.Literal("terminal") });
|
|
7243
|
+
const pageAsker = Schema.Struct({ kind: Schema.Literal("page") });
|
|
7244
|
+
const agentAsker = Schema.Struct({
|
|
7245
|
+
kind: Schema.Literal("agent"),
|
|
7246
|
+
agent: AgentId,
|
|
7247
|
+
handle: AgentHandle,
|
|
7248
|
+
session: SessionId,
|
|
7249
|
+
/** The attachment its credential was made under, for a person's agent in an organization. */
|
|
7250
|
+
attachment: Schema.optionalKey(CredentialRef)
|
|
7251
|
+
});
|
|
7252
|
+
/**
|
|
7253
|
+
* Who asked, as a receipt keeps it: a terminal, one of ref0's pages, an agent, or a machine
|
|
7254
|
+
* asking to connect, by its ID and its own word for itself, and from where.
|
|
7255
|
+
*/
|
|
7256
|
+
const ApprovalAsker = Schema.Union([
|
|
7257
|
+
terminalAsker,
|
|
7258
|
+
pageAsker,
|
|
7259
|
+
Schema.Struct({
|
|
7260
|
+
kind: Schema.Literal("machine"),
|
|
7261
|
+
machine: MachineId,
|
|
7262
|
+
machineName: MachineName,
|
|
7263
|
+
askedFrom: Schema.optionalKey(Schema.String)
|
|
7264
|
+
}),
|
|
7265
|
+
agentAsker
|
|
7266
|
+
]);
|
|
7267
|
+
/**
|
|
7268
|
+
* Who asked, as the person reads a request before they answer: a terminal, a page or an agent.
|
|
7269
|
+
* A machine's request is read as an `AttachmentRequestView`, never as a command.
|
|
7270
|
+
*/
|
|
7271
|
+
const ApprovalViewAsker = Schema.Union([
|
|
7272
|
+
terminalAsker,
|
|
7273
|
+
pageAsker,
|
|
7274
|
+
agentAsker
|
|
7275
|
+
]);
|
|
7276
|
+
/** A request, as the person reads it before they answer. */
|
|
7277
|
+
const ApprovalView = Schema.Struct({
|
|
7278
|
+
approval: ApprovalId,
|
|
7279
|
+
/** The command as its CLI line. */
|
|
7280
|
+
says: Schema.String,
|
|
7281
|
+
/** Everything the command carries, each named, in order: nothing it would run is left out. */
|
|
7282
|
+
fields: Schema.Array(Schema.Struct({
|
|
7283
|
+
name: Schema.String,
|
|
7284
|
+
value: Schema.String,
|
|
7285
|
+
part: Schema.Literals([
|
|
7286
|
+
"path",
|
|
7287
|
+
"query",
|
|
7288
|
+
"payload"
|
|
7289
|
+
])
|
|
7290
|
+
})),
|
|
7291
|
+
command: ApprovalCommand,
|
|
7292
|
+
asker: ApprovalViewAsker,
|
|
7293
|
+
askedFrom: Schema.optionalKey(Schema.String),
|
|
7294
|
+
askedAt: Timestamp,
|
|
7295
|
+
expiresAt: Timestamp,
|
|
7296
|
+
/**
|
|
7297
|
+
* An agent's ask: the kind of action its answer may set agents' permission for, and whether a
|
|
7298
|
+
* yes may let agents take it without asking. A no with the passkey may always stop them asking.
|
|
7299
|
+
*/
|
|
7300
|
+
agentPermission: Schema.optionalKey(Schema.Struct({
|
|
7301
|
+
action: ScopeAction,
|
|
7302
|
+
mayAllow: Schema.Boolean
|
|
7303
|
+
})),
|
|
7304
|
+
/**
|
|
7305
|
+
* What the command acts on, drawn as ref0 resolves it for the person reading, where its
|
|
7306
|
+
* endpoint names a preview. Without one the page can draw, it offers only no.
|
|
7307
|
+
*/
|
|
7308
|
+
preview: Schema.optionalKey(ApprovalPreview)
|
|
7309
|
+
});
|
|
7310
|
+
/** A statement's hash: the SHA-256 of its words and quoted values as JSON, as lowercase hex. */
|
|
7311
|
+
const StatementHash = Schema.String.check(Schema.isPattern(/^[0-9a-f]{64}$/));
|
|
7312
|
+
/**
|
|
7313
|
+
* Who asks for a yes to a statement: a workflow's run, `passkey.run`'s, by the workflow and the
|
|
7314
|
+
* run; or a gate's check, `passkey.check`'s, by the check, the pull request's version it gates and
|
|
7315
|
+
* the check run its statement is over.
|
|
7316
|
+
*/
|
|
7317
|
+
const StatementAsker = Schema.Union([Schema.Struct({
|
|
7318
|
+
kind: Schema.Literal("workflow"),
|
|
7319
|
+
workflow: WorkflowName,
|
|
7320
|
+
run: WorkflowRunId
|
|
7321
|
+
}), Schema.Struct({
|
|
7322
|
+
kind: Schema.Literal("gate"),
|
|
7323
|
+
check: CheckKey,
|
|
7324
|
+
pullRequest: PullRequestNumber,
|
|
7325
|
+
version: VersionNumber,
|
|
7326
|
+
run: CheckRunId
|
|
7327
|
+
})]);
|
|
7328
|
+
/**
|
|
7329
|
+
* A passkey request, as the person reads it before they answer: the statement, its words as its
|
|
7330
|
+
* asker wrote them and each value it quotes, and its hash; who asks; where, a workflow's
|
|
7331
|
+
* `owner/repo` or a thread's `owner/project#number`, or a gate's pull request, `owner/repo#number`;
|
|
7332
|
+
* whom it asks, the run's accountable person, or for a gate the person behind its run, a person by
|
|
7333
|
+
* name, or a permission's holders; and until when.
|
|
7334
|
+
*/
|
|
7335
|
+
const StatementView = Schema.Struct({
|
|
7336
|
+
approval: ApprovalId,
|
|
7337
|
+
statement: WorkflowText,
|
|
7338
|
+
hash: StatementHash,
|
|
7339
|
+
asker: StatementAsker,
|
|
7340
|
+
home: Schema.String,
|
|
7341
|
+
from: Schema.Union([Schema.Literals(["accountable", "person"]), Schema.Struct({ permission: RepoPermissionName })]),
|
|
7342
|
+
askedAt: Timestamp,
|
|
7343
|
+
expiresAt: Timestamp
|
|
7344
|
+
});
|
|
7345
|
+
/**
|
|
7346
|
+
* The person's own requests, waiting for them; the passkey requests workflows' runs and gates asked
|
|
7347
|
+
* them; each machine's request this browser session is the answerer of, so a reload finds it
|
|
7348
|
+
* again without its code; and the gates' decisions that wait on their yes or no, in every project
|
|
7349
|
+
* they see, oldest first.
|
|
7350
|
+
*/
|
|
7351
|
+
const ApprovalsWaiting = Schema.Struct({
|
|
7352
|
+
approvals: Schema.Array(ApprovalView),
|
|
7353
|
+
statements: Schema.Array(StatementView),
|
|
7354
|
+
requests: Schema.Array(AttachmentRequestView),
|
|
7355
|
+
decisions: Schema.Array(NamedDecision)
|
|
7356
|
+
});
|
|
7357
|
+
/** Finding a terminal's request by the code it showed. */
|
|
7358
|
+
const FindApproval = Schema.Union([Schema.Struct({ code: TypedApprovalCode }), Schema.Struct({ link: Schema.String.check(Schema.isPattern(/^[A-Za-z0-9_-]{22}$/)) })]);
|
|
7359
|
+
/**
|
|
7360
|
+
* Asking for the passkey's options to answer a request: a terminal's request needs its code
|
|
7361
|
+
* again, so its ID alone, which travels where its code doesn't, never answers it. A machine's
|
|
7362
|
+
* request needs its answerer, and the agent it chooses to connect, before a yes has options.
|
|
7363
|
+
*/
|
|
7364
|
+
const ApprovalOptionsRequest = Schema.Struct({
|
|
7365
|
+
code: Schema.optionalKey(TypedApprovalCode),
|
|
7366
|
+
folders: Schema.optionalKey(ConnectFolderChoices)
|
|
7367
|
+
});
|
|
7368
|
+
/**
|
|
7369
|
+
* An answer: yes, with the passkey, or no. For an agent's ask, each answer can set the owner's
|
|
7370
|
+
* permission its own way, so a pairing that can't happen can't be sent: yes, and let agents do
|
|
7371
|
+
* it without asking; no, and stop agents asking, with the passkey too.
|
|
7372
|
+
*/
|
|
7373
|
+
const AnswerApproval = Schema.Union([
|
|
7374
|
+
Schema.Struct({
|
|
7375
|
+
answer: Schema.Literal("yes"),
|
|
7376
|
+
permission: Schema.optionalKey(Schema.Literal("allow")),
|
|
7377
|
+
assertion: PasskeyAsserted,
|
|
7378
|
+
code: Schema.optionalKey(TypedApprovalCode),
|
|
7379
|
+
/** The preview the page drew, where the command's endpoint names one. */
|
|
7380
|
+
preview: Schema.optionalKey(PreviewEcho),
|
|
7381
|
+
/** The statement the page showed, by its hash, for a workflow run's or a gate's passkey request. */
|
|
7382
|
+
statement: Schema.optionalKey(Schema.Struct({ hash: StatementHash }))
|
|
7383
|
+
}),
|
|
7384
|
+
Schema.Struct({
|
|
7385
|
+
answer: Schema.Literal("no"),
|
|
7386
|
+
permission: Schema.Literal("deny"),
|
|
7387
|
+
assertion: PasskeyAsserted,
|
|
7388
|
+
code: Schema.optionalKey(TypedApprovalCode)
|
|
7389
|
+
}),
|
|
7390
|
+
Schema.Struct({
|
|
7391
|
+
answer: Schema.Literal("no"),
|
|
7392
|
+
code: Schema.optionalKey(TypedApprovalCode)
|
|
7393
|
+
})
|
|
7394
|
+
]);
|
|
7395
|
+
/**
|
|
7396
|
+
* What running an approved command came to: its status, and its endpoint's answer or refusal,
|
|
7397
|
+
* as JSON. ref0 reads it by the command's endpoint before anyone sees it, and whoever asked reads
|
|
7398
|
+
* it the same way, with `ranOf`: this schema is part of the API the endpoints' types come from,
|
|
7399
|
+
* so it can't be typed by them itself.
|
|
7400
|
+
*/
|
|
7401
|
+
const Ran = Schema.Struct({
|
|
7402
|
+
status: Schema.Int,
|
|
7403
|
+
result: Schema.Json
|
|
7404
|
+
});
|
|
7405
|
+
/**
|
|
7406
|
+
* A ran command whose answer its endpoint's schemas no longer read, such as one kept from
|
|
7407
|
+
* before they changed: it ran, and what it came to can't be said.
|
|
7408
|
+
*/
|
|
7409
|
+
var ResultUnreadable = class extends Schema.TaggedError()("ResultUnreadable", { nextStep: Schema.String }, { httpApiStatus: 500 }) {};
|
|
7410
|
+
/**
|
|
7411
|
+
* Where a request stands, for whoever asked: still waiting; approved, and running as soon as
|
|
7412
|
+
* ref0 can; refused; expired; or what it ran to, with the endpoint it ran, whose schemas read it.
|
|
7413
|
+
*/
|
|
7414
|
+
const ApprovalState = Schema.Union([
|
|
7415
|
+
Schema.Struct({
|
|
7416
|
+
state: Schema.Literal("waiting"),
|
|
7417
|
+
expiresAt: Timestamp
|
|
7418
|
+
}),
|
|
7419
|
+
Schema.Struct({
|
|
7420
|
+
state: Schema.Literal("approved"),
|
|
7421
|
+
/** A machine's answering browser may forget its saved answer once the terminal kept it. */
|
|
7422
|
+
collected: Schema.optionalKey(Schema.Literal(true))
|
|
7423
|
+
}),
|
|
7424
|
+
Schema.Struct({ state: Schema.Literal("refused") }),
|
|
7425
|
+
Schema.Struct({ state: Schema.Literal("expired") }),
|
|
7426
|
+
Schema.Struct({
|
|
7427
|
+
state: Schema.Literal("ran"),
|
|
7428
|
+
endpoint: CommandEndpoint,
|
|
7429
|
+
...Ran.fields
|
|
7430
|
+
})
|
|
7431
|
+
]);
|
|
7432
|
+
/** Only the answering browser receives a machine's answer code; state reads never carry it. */
|
|
7433
|
+
const ApprovalAnswer = Schema.Union([
|
|
7434
|
+
Schema.Struct({
|
|
7435
|
+
...ApprovalState.members[1].fields,
|
|
7436
|
+
answerCode: ApprovalCode
|
|
7437
|
+
}),
|
|
7438
|
+
Schema.Struct({
|
|
7439
|
+
...ApprovalState.members[4].fields,
|
|
7440
|
+
answerCode: ApprovalCode
|
|
7441
|
+
}),
|
|
7442
|
+
ApprovalState
|
|
7443
|
+
]);
|
|
7444
|
+
/** A request that isn't there, or isn't the caller's to see, answers the same. */
|
|
7445
|
+
var ApprovalNotFound = class extends Schema.TaggedError()("ApprovalNotFound", { nextStep: Schema.String }, { httpApiStatus: 404 }) {};
|
|
7446
|
+
/** Too many asked, or too many codes typed, from here in the last hour. */
|
|
7447
|
+
var SlowDown = class extends Schema.TaggedError()("SlowDown", { nextStep: Schema.String }, { httpApiStatus: 429 }) {};
|
|
7448
|
+
/** A command's hash: the SHA-256 of its canonical JSON, as lowercase hex. */
|
|
7449
|
+
const CommandHash = Schema.String.check(Schema.isPattern(/^[0-9a-f]{64}$/));
|
|
7450
|
+
Schema.Struct({
|
|
7451
|
+
approval: ApprovalId,
|
|
7452
|
+
endpoint: CommandEndpoint,
|
|
7453
|
+
/** The command's canonical JSON. */
|
|
7454
|
+
command: Schema.String,
|
|
7455
|
+
commandHash: CommandHash,
|
|
7456
|
+
asker: ApprovalAsker,
|
|
7457
|
+
/** The browser session the person answered in, for their log. */
|
|
7458
|
+
credential: CredentialRef,
|
|
7459
|
+
generation: Generation,
|
|
7460
|
+
/** When the request expires, in epoch milliseconds. */
|
|
7461
|
+
deadline: Schema.Int,
|
|
7462
|
+
challenge: Schema.String,
|
|
7463
|
+
/** A machine answer retries only from its answering session, with this key. */
|
|
7464
|
+
answerKey: Schema.optionalKey(RequestId),
|
|
7465
|
+
/** An agent's ask: the credential it asked with, checked again before the answer counts. */
|
|
7466
|
+
asking: Schema.optionalKey(TokenId),
|
|
7467
|
+
/** The kind of action the person, answering, let agents take without asking from now on. */
|
|
7468
|
+
allow: Schema.optionalKey(ScopeAction)
|
|
7469
|
+
});
|
|
7470
|
+
Schema.Struct({
|
|
7471
|
+
approval: ApprovalId,
|
|
7472
|
+
action: ScopeAction,
|
|
7473
|
+
credential: CredentialRef,
|
|
7474
|
+
generation: Generation
|
|
7475
|
+
});
|
|
7476
|
+
Schema.Union([Schema.Struct({ denied: Schema.Literal(true) }), Schema.Struct({
|
|
7477
|
+
denied: Schema.Literal(false),
|
|
7478
|
+
reason: Schema.Literals(["generation moved", "not set up"])
|
|
7479
|
+
})]);
|
|
7480
|
+
Schema.Union([Schema.Struct({
|
|
7481
|
+
admitted: Schema.Literal(true),
|
|
7482
|
+
admittedAt: Schema.Int,
|
|
7483
|
+
answerCode: Schema.optionalKey(ApprovalCode)
|
|
7484
|
+
}), Schema.Struct({
|
|
7485
|
+
admitted: Schema.Literal(false),
|
|
7486
|
+
reason: Schema.Literals([
|
|
7487
|
+
"expired",
|
|
7488
|
+
"generation moved",
|
|
7489
|
+
"challenge used",
|
|
7490
|
+
"not set up",
|
|
7491
|
+
"asker gone"
|
|
7492
|
+
])
|
|
7493
|
+
})]);
|
|
7494
|
+
Schema.Union([Schema.Struct({ found: Schema.Literal(false) }), Schema.Struct({
|
|
7495
|
+
found: Schema.Literal(true),
|
|
7496
|
+
endpoint: CommandEndpoint,
|
|
7497
|
+
command: Schema.String,
|
|
7498
|
+
commandHash: CommandHash,
|
|
7499
|
+
generation: Generation,
|
|
7500
|
+
generationNow: Generation,
|
|
7501
|
+
/** The `approval.answered` its admission recorded in the person's log. */
|
|
7502
|
+
answeredEvent: EventId,
|
|
7503
|
+
/** Who asked, as that `approval.answered` says. */
|
|
7504
|
+
asker: ApprovalAsker,
|
|
7505
|
+
/** What running it came to, once it ran, as `KeptRan` keeps it. */
|
|
7506
|
+
result: Schema.optionalKey(Schema.String)
|
|
7507
|
+
})]);
|
|
7508
|
+
/**
|
|
7509
|
+
* A `Ran` as it's kept, on its receipt and on its approval: its JSON, as text. The Owner DO
|
|
7510
|
+
* keeps it as it comes and runs no endpoint's schemas, so it's read only by its endpoint, with
|
|
7511
|
+
* `keptRan`, which refuses one they don't take.
|
|
7512
|
+
*/
|
|
7513
|
+
const KeptRan = Schema.fromJsonString(Ran);
|
|
7514
|
+
Schema.Struct({
|
|
7515
|
+
approval: ApprovalId,
|
|
7516
|
+
result: Schema.String
|
|
7517
|
+
});
|
|
7518
|
+
Schema.Struct({ result: Schema.String });
|
|
7519
|
+
Schema.Struct({ approval: ApprovalId });
|
|
7520
|
+
Schema.Struct({ approval: ApprovalId });
|
|
7521
|
+
Schema.Union([Schema.Struct({ expired: Schema.Literal(true) }), Schema.Struct({ expired: Schema.Literal(false) })]);
|
|
7522
|
+
Schema.Struct({
|
|
7523
|
+
person: PersonId,
|
|
7524
|
+
approval: ApprovalId
|
|
7525
|
+
});
|
|
7526
|
+
//#endregion
|
|
7527
|
+
//#region ../contracts/src/browser-sessions.ts
|
|
7528
|
+
/**
|
|
7529
|
+
* A browser session's cookie: Better Auth's, named for ref0, `Secure` and host-only on the
|
|
7530
|
+
* app's one origin. It holds the session's token, signed; the directory and the person's Owner DO hold
|
|
7531
|
+
* only the token's SHA-256.
|
|
7532
|
+
*/
|
|
7533
|
+
const browserSessionCookie = "__Secure-ref0.session_token";
|
|
7534
|
+
Schema.Struct({
|
|
7535
|
+
token: TokenId,
|
|
7536
|
+
secretHash: SecretHash,
|
|
7537
|
+
expiresAt: Schema.DateTimeUtcFromMillis
|
|
7538
|
+
});
|
|
7539
|
+
Schema.Struct({ registered: Schema.Boolean });
|
|
7540
|
+
/**
|
|
7541
|
+
* Where a signed-in person stands: naming, when the name their GitHub login gave was taken or
|
|
7542
|
+
* kept back, with that login as a suggestion; setting up, once their owner is made and before
|
|
7543
|
+
* it's ready with this session registered, which naming again finishes; or ready.
|
|
7544
|
+
*/
|
|
7545
|
+
const Welcome = Schema.Union([Schema.Struct({
|
|
7546
|
+
state: Schema.Literals(["ready", "setting up"]),
|
|
7547
|
+
person: PersonId,
|
|
7548
|
+
owner: OwnerId,
|
|
7549
|
+
name: OwnerName
|
|
7550
|
+
}), Schema.Struct({
|
|
7551
|
+
state: Schema.Literal("naming"),
|
|
7552
|
+
person: PersonId,
|
|
7553
|
+
suggestion: Schema.optionalKey(Schema.String)
|
|
7554
|
+
})]);
|
|
7555
|
+
/**
|
|
7556
|
+
* A browser session renewed: its expiry, in its person's Owner DO and in the directory, and on its cookie.
|
|
7557
|
+
* Renewed at most once a day, so one asked sooner answers its expiry unchanged.
|
|
7558
|
+
*/
|
|
7559
|
+
const SessionRenewed = Schema.Struct({ expiresAt: Schema.DateTimeUtcFromMillis });
|
|
7560
|
+
/**
|
|
7561
|
+
* What starting a GitHub sign-in at `POST /auth/sign-in/social` answers: GitHub's address to go
|
|
7562
|
+
* to, with the sign-in's state in a cookie set alongside. Better Auth's answer, which ref0 serves.
|
|
7563
|
+
*/
|
|
7564
|
+
const NavigationUrl = Schema.String.check(Schema.isPattern(/^https?:\/\/[^\s/]+(?:\/[^\s]*)?$/), Schema.makeFilter((url) => URL.canParse(url) || "an HTTP(S) URL the platform can parse")).pipe(Schema.brand("NavigationUrl"));
|
|
7565
|
+
Schema.Struct({ url: NavigationUrl });
|
|
7566
|
+
/**
|
|
7567
|
+
* Where signing in sends the browser back to, as `POST /auth/sign-in/social` takes it: a path on
|
|
7568
|
+
* ref0's own origin, such as `/welcome`, or a whole URL. Never a socket's.
|
|
7569
|
+
*/
|
|
7570
|
+
const SignInDestination = Schema.String.pipe(Schema.brand("SignInDestination"));
|
|
7571
|
+
Schema.Struct({
|
|
7572
|
+
provider: Schema.Literal("github"),
|
|
7573
|
+
callbackURL: SignInDestination,
|
|
7574
|
+
errorCallbackURL: SignInDestination
|
|
7575
|
+
});
|
|
7576
|
+
/** The name a naming person chose for their owner. */
|
|
7577
|
+
const NameOwner = Schema.Struct({ name: OwnerName });
|
|
7578
|
+
//#endregion
|
|
7544
7579
|
//#region ../contracts/src/ask-calls.ts
|
|
7545
7580
|
/**
|
|
7546
7581
|
* Asks: a question that waits on an answer, from an agent's session or a person, on a thread,
|
|
@@ -11659,6 +11694,151 @@ Schema.Struct({
|
|
|
11659
11694
|
name: RepoName
|
|
11660
11695
|
}))
|
|
11661
11696
|
});
|
|
11697
|
+
/** Where an agent's session asks for an identity token. */
|
|
11698
|
+
const identityTokenPath = "/v1/me/identity-token";
|
|
11699
|
+
/** One DNS label, or one part of an IPv4 address: letters, digits and inner hyphens. */
|
|
11700
|
+
const label = "[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?";
|
|
11701
|
+
/** `https://`, a host name, then maybe `:` and a port with no leading zero. */
|
|
11702
|
+
const audiencePattern = new RegExp(`^https://(${label}(?:\\.${label})*)(?::([1-9][0-9]{0,4}))?$`);
|
|
11703
|
+
/**
|
|
11704
|
+
* Whether the platform's URL parser reads the audience as written: an `https` URL whose host is
|
|
11705
|
+
* the audience's own, lowercased, and whose port is its own, or none for 443. That refuses a host
|
|
11706
|
+
* the parser rewrites, such as `https://1.2.3` read as `1.2.0.3`, and a port past 65535.
|
|
11707
|
+
*/
|
|
11708
|
+
const parsesAsWritten = (audience) => {
|
|
11709
|
+
const parts = audiencePattern.exec(audience);
|
|
11710
|
+
if (parts === null || !URL.canParse(audience)) return false;
|
|
11711
|
+
const url = new URL(audience);
|
|
11712
|
+
const port = parts[2] ?? "";
|
|
11713
|
+
return url.protocol === "https:" && url.hostname === (parts[1] ?? "").toLowerCase() && url.port === (port === "443" ? "" : port);
|
|
11714
|
+
};
|
|
11715
|
+
/**
|
|
11716
|
+
* Who an identity token is for: an outside service's HTTPS origin, in ASCII, at most 255 bytes,
|
|
11717
|
+
* with no credentials, path, query, fragment, wildcard or space. It's kept exactly as asked, its
|
|
11718
|
+
* case and any port too, so `https://verifier.example:443` is another audience than
|
|
11719
|
+
* `https://verifier.example`. ref0 never fetches or resolves it.
|
|
11720
|
+
*/
|
|
11721
|
+
const IdentityAudience = Schema.String.check(Schema.isMaxLength(255), Schema.isPattern(audiencePattern), Schema.makeFilter((audience) => parsesAsWritten(audience) || "an origin the platform reads as written")).pipe(Schema.brand("IdentityAudience"));
|
|
11722
|
+
/** `POST /v1/me/identity-token`'s body: the one audience, and nothing else. */
|
|
11723
|
+
const IdentityTokenRequest = Schema.Struct({ audience: IdentityAudience });
|
|
11724
|
+
/** A compact JWT: its header, payload and signature, each in base64url, at most 4 KiB. */
|
|
11725
|
+
const IdentityToken = Schema.String.check(Schema.isMaxLength(4096), Schema.isPattern(/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/)).pipe(Schema.brand("IdentityToken"));
|
|
11726
|
+
/** What asking for an identity token answers: the token, and when it expires, its `exp`. */
|
|
11727
|
+
const IssuedIdentityToken = Schema.Struct({
|
|
11728
|
+
token: IdentityToken,
|
|
11729
|
+
expiresAt: Schema.DateTimeUtcFromMillis
|
|
11730
|
+
});
|
|
11731
|
+
/**
|
|
11732
|
+
* An identity token's `jti`: a fresh UUIDv4 from the platform's cryptographic source for each
|
|
11733
|
+
* token, naming its issuance in ref0's log without naming any row or secret.
|
|
11734
|
+
*/
|
|
11735
|
+
const IdentityTokenId = Schema.String.check(Schema.isPattern(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/)).pipe(Schema.brand("IdentityTokenId"));
|
|
11736
|
+
/**
|
|
11737
|
+
* A person, as one audience knows them: `pperson_`, then the first 128 bits of an HMAC of the
|
|
11738
|
+
* person's ref0 ID for the token's issuer and audience, as a TypeID suffix. Stable at one
|
|
11739
|
+
* audience, different at every other, and carrying no time.
|
|
11740
|
+
*/
|
|
11741
|
+
const PairwisePersonId = TypeId("pperson").pipe(Schema.brand("PairwisePersonId"));
|
|
11742
|
+
/** A session, as one audience knows it, made as a person's is. */
|
|
11743
|
+
const PairwiseSessionId = TypeId("psession").pipe(Schema.brand("PairwiseSessionId"));
|
|
11744
|
+
/** The accountable person, last in a token's chain: only their pairwise ID. */
|
|
11745
|
+
const PersonActor = Schema.Struct({ sub: PairwisePersonId });
|
|
11746
|
+
/** A session in a token's chain, then whoever it acts for: its parent session, or the person. */
|
|
11747
|
+
const sessionActing = (act) => Schema.Struct({
|
|
11748
|
+
sub: PairwiseSessionId,
|
|
11749
|
+
act
|
|
11750
|
+
});
|
|
11751
|
+
const oneSession = sessionActing(PersonActor);
|
|
11752
|
+
const twoSessions = sessionActing(oneSession);
|
|
11753
|
+
const threeSessions = sessionActing(twoSessions);
|
|
11754
|
+
/**
|
|
11755
|
+
* A token's `act`, as RFC 8693 nests it: the current session, then up to three parent sessions,
|
|
11756
|
+
* then the accountable person, each by its pairwise ID. It's history, not authority: a verifier
|
|
11757
|
+
* allows the agent in `sub`, never an actor.
|
|
11758
|
+
*/
|
|
11759
|
+
const IdentityActor = Schema.Union([
|
|
11760
|
+
oneSession,
|
|
11761
|
+
twoSessions,
|
|
11762
|
+
threeSessions,
|
|
11763
|
+
sessionActing(threeSessions)
|
|
11764
|
+
]);
|
|
11765
|
+
/** A time in a token, in whole UTC epoch seconds. */
|
|
11766
|
+
const EpochSeconds = Schema.Int.check(Schema.isGreaterThan(0));
|
|
11767
|
+
Schema.Struct({
|
|
11768
|
+
iss: ApiOrigin,
|
|
11769
|
+
sub: AgentId,
|
|
11770
|
+
aud: IdentityAudience,
|
|
11771
|
+
iat: EpochSeconds,
|
|
11772
|
+
exp: EpochSeconds,
|
|
11773
|
+
jti: IdentityTokenId,
|
|
11774
|
+
act: IdentityActor
|
|
11775
|
+
}).check(Schema.makeFilter(({ iat, exp }) => exp > iat && exp - iat <= 600 || `an expiry after its issue, at most 600 seconds later`));
|
|
11776
|
+
/**
|
|
11777
|
+
* A signing key's ID, its `kid`: the key's RFC 7638 thumbprint, SHA-256 in base64url, so one ID
|
|
11778
|
+
* never names two keys.
|
|
11779
|
+
*/
|
|
11780
|
+
const SigningKeyId = Schema.String.check(Schema.isPattern(/^[A-Za-z0-9_-]{43}$/)).pipe(Schema.brand("SigningKeyId"));
|
|
11781
|
+
Schema.Struct({
|
|
11782
|
+
alg: Schema.Literal("ES256"),
|
|
11783
|
+
kid: SigningKeyId,
|
|
11784
|
+
typ: Schema.Literal("JWT")
|
|
11785
|
+
});
|
|
11786
|
+
/** A P-256 coordinate: 32 bytes in base64url, unpadded, in its one canonical spelling. */
|
|
11787
|
+
const Coordinate = Schema.String.check(Schema.isPattern(/^[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$/));
|
|
11788
|
+
/** One public key in the JWKS: exactly these fields, and never a private one. */
|
|
11789
|
+
const PublicSigningKey = Schema.Struct({
|
|
11790
|
+
kty: Schema.Literal("EC"),
|
|
11791
|
+
crv: Schema.Literal("P-256"),
|
|
11792
|
+
x: Coordinate,
|
|
11793
|
+
y: Coordinate,
|
|
11794
|
+
use: Schema.Literal("sig"),
|
|
11795
|
+
alg: Schema.Literal("ES256"),
|
|
11796
|
+
kid: SigningKeyId
|
|
11797
|
+
});
|
|
11798
|
+
Schema.Struct({ keys: Schema.Array(PublicSigningKey).check(Schema.isMaxLength(3)) });
|
|
11799
|
+
Schema.Struct({
|
|
11800
|
+
issuer: ApiOrigin,
|
|
11801
|
+
jwks_uri: Schema.String,
|
|
11802
|
+
subject_types_supported: Schema.Tuple([Schema.Literal("public")]),
|
|
11803
|
+
id_token_signing_alg_values_supported: Schema.Tuple([Schema.Literal("ES256")]),
|
|
11804
|
+
claims_supported: Schema.Tuple([
|
|
11805
|
+
Schema.Literal("iss"),
|
|
11806
|
+
Schema.Literal("sub"),
|
|
11807
|
+
Schema.Literal("aud"),
|
|
11808
|
+
Schema.Literal("exp"),
|
|
11809
|
+
Schema.Literal("iat"),
|
|
11810
|
+
Schema.Literal("jti"),
|
|
11811
|
+
Schema.Literal("act")
|
|
11812
|
+
])
|
|
11813
|
+
});
|
|
11814
|
+
/**
|
|
11815
|
+
* The audience isn't one ref0 signs for: missing, given twice, or not an HTTPS origin as
|
|
11816
|
+
* `IdentityAudience` says. The refused text is never echoed.
|
|
11817
|
+
*/
|
|
11818
|
+
var IdentityAudienceRefused = class extends Schema.TaggedError()("IdentityAudienceRefused", { nextStep: Schema.String }, { httpApiStatus: 400 }) {};
|
|
11819
|
+
/**
|
|
11820
|
+
* Less than a whole second is left before the session, a parent or the credential expires, or
|
|
11821
|
+
* the token would expire before it's recorded: the next prompt renews or replaces the session.
|
|
11822
|
+
*/
|
|
11823
|
+
var IdentityTokenNotReady = class extends Schema.TaggedError()("IdentityTokenNotReady", { nextStep: Schema.String }, { httpApiStatus: 409 }) {};
|
|
11824
|
+
/**
|
|
11825
|
+
* ref0 signed an identity token for one of a project's sessions: in that project's log, on the
|
|
11826
|
+
* session's thread, with ref0's own IDs for the session, its agent and its accountable person,
|
|
11827
|
+
* and the token's `jti`, never the token. It proves ref0 made the token, not that any service
|
|
11828
|
+
* received or used it. A failed append discards the token, so every token has its event.
|
|
11829
|
+
*/
|
|
11830
|
+
const IdentityTokenIssued = eventKind("identity_token.issued", Schema.Struct({
|
|
11831
|
+
session: SessionId,
|
|
11832
|
+
agent: AgentRef,
|
|
11833
|
+
/** The person whose attachment authorized the session's chain, last in the token's `act`. */
|
|
11834
|
+
accountable: PersonId,
|
|
11835
|
+
jti: IdentityTokenId,
|
|
11836
|
+
audience: IdentityAudience,
|
|
11837
|
+
issuer: ApiOrigin,
|
|
11838
|
+
keyId: SigningKeyId,
|
|
11839
|
+
issuedAt: Schema.DateTimeUtcFromMillis,
|
|
11840
|
+
expiresAt: Schema.DateTimeUtcFromMillis
|
|
11841
|
+
}));
|
|
11662
11842
|
Schema.Literals([
|
|
11663
11843
|
"info/refs",
|
|
11664
11844
|
"git-upload-pack",
|
|
@@ -11911,6 +12091,71 @@ const GateAddition = Schema.Struct({
|
|
|
11911
12091
|
from: NeedFrom
|
|
11912
12092
|
});
|
|
11913
12093
|
/**
|
|
12094
|
+
* A yes that met a need: its decider's, given by the person `by` when one gave it, such as an
|
|
12095
|
+
* admin answering as the owner's admins; or, `overridden`, a check ref0 said no to, met by the
|
|
12096
|
+
* yes of each of its overrides.
|
|
12097
|
+
*/
|
|
12098
|
+
const NeedYes = Schema.Struct({
|
|
12099
|
+
decider: DeciderRef,
|
|
12100
|
+
by: Schema.optionalKey(PersonId),
|
|
12101
|
+
overridden: Schema.optionalKey(Schema.Literal(true))
|
|
12102
|
+
});
|
|
12103
|
+
/**
|
|
12104
|
+
* An override left of a check ref0 said no to: of a part's failed run, or of the check not running,
|
|
12105
|
+
* and who answers it.
|
|
12106
|
+
*/
|
|
12107
|
+
const OverrideLeft = Schema.Struct({
|
|
12108
|
+
of: Schema.Literals(["failed run", "not run"]),
|
|
12109
|
+
by: OverrideAnswerer
|
|
12110
|
+
});
|
|
12111
|
+
/**
|
|
12112
|
+
* A decider a need waits on. For a check ref0 said no to, `overrides` lists each of its overrides
|
|
12113
|
+
* left, every one of whose yeses meets it in a pass's place, none when it has none: one yes to a
|
|
12114
|
+
* gate answers every decider its person may answer as, each override among them.
|
|
12115
|
+
*/
|
|
12116
|
+
const NeedWait = Schema.Struct({
|
|
12117
|
+
decider: DeciderRef,
|
|
12118
|
+
overrides: Schema.optionalKey(Schema.Array(OverrideLeft).check(Schema.isMaxLength(maxGateDeciders)))
|
|
12119
|
+
});
|
|
12120
|
+
/**
|
|
12121
|
+
* How one of a version's needs stands under its gate's answers now, as the gate is decided from
|
|
12122
|
+
* them: `met` by each yes in `by`; `met` with nothing to wait for, `as` `no checks`, for `checks`
|
|
12123
|
+
* when none of the repo's checks runs on the change, or `on the tip`, for a check only the merge
|
|
12124
|
+
* queue's tip runs, which judges it before `main` moves; `waiting` on each decider in `on`, when
|
|
12125
|
+
* `all`, or else on a yes from any one of them; `waiting` for ref0 to read which of `main`'s
|
|
12126
|
+
* checks run on the change; or `refused` by a person's no to one of its deciders, or to an
|
|
12127
|
+
* override of one, which decides the whole gate no.
|
|
12128
|
+
*/
|
|
12129
|
+
const NeedStanding = Schema.Union([
|
|
12130
|
+
Schema.Struct({
|
|
12131
|
+
state: Schema.Literal("met"),
|
|
12132
|
+
by: Schema.Array(NeedYes).check(Schema.isMaxLength(maxGateDeciders))
|
|
12133
|
+
}),
|
|
12134
|
+
Schema.Struct({
|
|
12135
|
+
state: Schema.Literal("met"),
|
|
12136
|
+
as: Schema.Literals(["no checks", "on the tip"])
|
|
12137
|
+
}),
|
|
12138
|
+
Schema.Struct({
|
|
12139
|
+
state: Schema.Literal("waiting"),
|
|
12140
|
+
on: Schema.Array(NeedWait).check(Schema.isMaxLength(maxGateDeciders)),
|
|
12141
|
+
all: Schema.Boolean
|
|
12142
|
+
}),
|
|
12143
|
+
Schema.Struct({
|
|
12144
|
+
state: Schema.Literal("waiting"),
|
|
12145
|
+
for: Schema.Literal("checks read")
|
|
12146
|
+
}),
|
|
12147
|
+
Schema.Struct({
|
|
12148
|
+
state: Schema.Literal("refused"),
|
|
12149
|
+
decider: DeciderRef,
|
|
12150
|
+
by: PersonId
|
|
12151
|
+
})
|
|
12152
|
+
]);
|
|
12153
|
+
/** A version's need, with every place it came from, and how it stands once its gate is open. */
|
|
12154
|
+
const GateNeedView = Schema.Struct({
|
|
12155
|
+
...GateNeed.fields,
|
|
12156
|
+
standing: Schema.optionalKey(NeedStanding)
|
|
12157
|
+
});
|
|
12158
|
+
/**
|
|
11914
12159
|
* The version's gate decided again in place under `main`'s `.ref0/` now (Q7c): on which `main`
|
|
11915
12160
|
* and under its tree, `pending` until `main`'s gate answers, then `held` or `grew`; the needs
|
|
11916
12161
|
* it answered, joined to ref0's floor, absent while pending; what `main`'s gate read of the
|
|
@@ -12012,8 +12257,21 @@ const GateView = Schema.Struct({
|
|
|
12012
12257
|
state: GateState,
|
|
12013
12258
|
/** Why the gate failed; null otherwise. */
|
|
12014
12259
|
why: Schema.NullOr(Schema.String),
|
|
12015
|
-
/**
|
|
12016
|
-
|
|
12260
|
+
/**
|
|
12261
|
+
* None while pending; once answered, the floor's too, sorted as they're spelled, each with how
|
|
12262
|
+
* it stands once the version's gate is open.
|
|
12263
|
+
*/
|
|
12264
|
+
needs: Schema.Array(GateNeedView),
|
|
12265
|
+
/** The repo's owner, whose admins answer its admins' needs: at home, its person alone. */
|
|
12266
|
+
owner: OwnerId,
|
|
12267
|
+
/**
|
|
12268
|
+
* The names of the owner and of the people its needs' standings name, where the directory has
|
|
12269
|
+
* them.
|
|
12270
|
+
*/
|
|
12271
|
+
names: Schema.optionalKey(Schema.Array(Schema.Struct({
|
|
12272
|
+
owner: OwnerId,
|
|
12273
|
+
name: OwnerName
|
|
12274
|
+
})).check(Schema.isMaxLength(maxGateDeciders + 1))),
|
|
12017
12275
|
/** What the gate read of the change's trees, each read once, in order; none while pending. */
|
|
12018
12276
|
reads: GateReads,
|
|
12019
12277
|
/** The project gate revision the version was decided under; none for 0. */
|
|
@@ -14133,6 +14391,7 @@ const KnownEvent = Schema.Union([
|
|
|
14133
14391
|
ThreadListingChanged,
|
|
14134
14392
|
SessionStarted,
|
|
14135
14393
|
SessionEnded,
|
|
14394
|
+
IdentityTokenIssued,
|
|
14136
14395
|
MemberRoleChanged,
|
|
14137
14396
|
MemberRemoved,
|
|
14138
14397
|
InvitationSent,
|
|
@@ -14994,6 +15253,11 @@ var ApprovalSecret = class extends HttpApiMiddleware.Service()("ref0/contracts/A
|
|
|
14994
15253
|
* what to change. Any other part that fails its contract is answered with Effect's empty 400.
|
|
14995
15254
|
*/
|
|
14996
15255
|
var MachineNameRefusal = class extends HttpApiMiddleware.Service()("ref0/contracts/MachineNameRefusal", { error: CommandRefused }) {};
|
|
15256
|
+
/**
|
|
15257
|
+
* An identity token's request whose audience its contract refuses, missing or not an HTTPS
|
|
15258
|
+
* origin, answered as `IdentityAudienceRefused`, which never echoes what was sent.
|
|
15259
|
+
*/
|
|
15260
|
+
var IdentityAudienceRefusal = class extends HttpApiMiddleware.Service()("ref0/contracts/IdentityAudienceRefusal", { error: IdentityAudienceRefused }) {};
|
|
14997
15261
|
const idempotency = { "idempotency-key": RequestId };
|
|
14998
15262
|
const inProject$1 = {
|
|
14999
15263
|
owner: OwnerRef,
|
|
@@ -15216,7 +15480,18 @@ var MeGroup = class extends HttpApiGroup.make("me").add(HttpApiEndpoint.get("get
|
|
|
15216
15480
|
UsageRangeRefused,
|
|
15217
15481
|
AuthorityUnavailable
|
|
15218
15482
|
]
|
|
15219
|
-
})
|
|
15483
|
+
}), HttpApiEndpoint.post("identityToken", identityTokenPath, {
|
|
15484
|
+
payload: IdentityTokenRequest,
|
|
15485
|
+
success: IssuedIdentityToken,
|
|
15486
|
+
error: [
|
|
15487
|
+
IdentityAudienceRefused,
|
|
15488
|
+
IdentityTokenNotReady,
|
|
15489
|
+
NotPermitted,
|
|
15490
|
+
SessionHasEnded,
|
|
15491
|
+
SlowDown,
|
|
15492
|
+
AuthorityUnavailable
|
|
15493
|
+
]
|
|
15494
|
+
}).middleware(IdentityAudienceRefusal)).middleware(Authorization) {};
|
|
15220
15495
|
/**
|
|
15221
15496
|
* A thread's endpoints, as one of a project's threads and an org thread take them alike, by
|
|
15222
15497
|
* where they are: `at` is a project's path parameters, or the owner's alone for its own scope.
|
|
@@ -16152,15 +16427,18 @@ const decisionEndpoints = (at) => {
|
|
|
16152
16427
|
HttpApiEndpoint.get("get", "/:decision", {
|
|
16153
16428
|
params: onDecision,
|
|
16154
16429
|
query: { wait: Schema.optionalKey(DecisionWait) },
|
|
16155
|
-
success:
|
|
16430
|
+
success: NamedDecision,
|
|
16156
16431
|
error: [...projectErrors, DecisionNotFound]
|
|
16157
16432
|
}),
|
|
16158
16433
|
HttpApiEndpoint.post("answer", "/:decision/answers", {
|
|
16159
16434
|
params: onDecision,
|
|
16160
16435
|
headers: idempotency,
|
|
16161
16436
|
payload: AnswerRequest,
|
|
16162
|
-
success:
|
|
16437
|
+
success: NamedDecision,
|
|
16163
16438
|
error: [
|
|
16439
|
+
ApprovalNeeded,
|
|
16440
|
+
CommandRefused,
|
|
16441
|
+
SlowDown,
|
|
16164
16442
|
...projectErrors,
|
|
16165
16443
|
DecisionNotFound,
|
|
16166
16444
|
AnswerRefused,
|
|
@@ -17007,6 +17285,15 @@ const ownSurfaces = {
|
|
|
17007
17285
|
cli: "meters",
|
|
17008
17286
|
mcp: null
|
|
17009
17287
|
},
|
|
17288
|
+
"me.identityToken": {
|
|
17289
|
+
action: "session:change",
|
|
17290
|
+
actors: "agent",
|
|
17291
|
+
target: "session",
|
|
17292
|
+
stepUp: "never",
|
|
17293
|
+
asks: "never",
|
|
17294
|
+
cli: "identity-token",
|
|
17295
|
+
mcp: null
|
|
17296
|
+
},
|
|
17010
17297
|
"threads.list": {
|
|
17011
17298
|
action: "project:read",
|
|
17012
17299
|
actors: "both",
|
|
@@ -18464,7 +18751,7 @@ const HooksFile = Schema.Struct({ hooks: Schema.Record(Schema.String, Schema.Arr
|
|
|
18464
18751
|
const McpServersFile = Schema.Struct({ mcpServers: Schema.Record(Schema.String, Schema.Struct({
|
|
18465
18752
|
command: Schema.String,
|
|
18466
18753
|
args: Schema.Array(Schema.String),
|
|
18467
|
-
/** The variables Codex passes the server beside
|
|
18754
|
+
/** The variables Codex passes the server beside the few it always passes, such as `HOME`. */
|
|
18468
18755
|
env_vars: Schema.optionalKey(Schema.Array(Schema.String))
|
|
18469
18756
|
})) });
|
|
18470
18757
|
const PluginManifest = Schema.Struct({
|
|
@@ -18669,6 +18956,7 @@ const StatusCheck = Schema.Struct({
|
|
|
18669
18956
|
"accepted",
|
|
18670
18957
|
"plugin",
|
|
18671
18958
|
"trusted",
|
|
18959
|
+
"server",
|
|
18672
18960
|
"session"
|
|
18673
18961
|
]),
|
|
18674
18962
|
answer: StatusAnswer,
|
|
@@ -18823,6 +19111,12 @@ const freshLinkSecret = Effect.suspend(() => hashed(Buffer.from(crypto.getRandom
|
|
|
18823
19111
|
//#endregion
|
|
18824
19112
|
//#region src/session.ts
|
|
18825
19113
|
/**
|
|
19114
|
+
* An agent's command in a harness session that has no ref0 session, though an agent is connected
|
|
19115
|
+
* for its config folder: why, as far as ref0 can tell, and what to do. It never runs as its
|
|
19116
|
+
* person instead.
|
|
19117
|
+
*/
|
|
19118
|
+
var NoSession = class extends Schema.TaggedError()("NoSession", { nextStep: Schema.String }) {};
|
|
19119
|
+
/**
|
|
18826
19120
|
* The token of the session this command runs in, if it runs in one: an agent's, which its
|
|
18827
19121
|
* harness bound when the session started. Where it's found is the harness's to say. Outside a
|
|
18828
19122
|
* session, there's none, and every command is the person's, asked by approval.
|
|
@@ -20173,10 +20467,10 @@ const blockerLines = (list, address) => {
|
|
|
20173
20467
|
].join("\n");
|
|
20174
20468
|
};
|
|
20175
20469
|
/** How many blockers stand on the thread now, in words. */
|
|
20176
|
-
const standingWords = (changed) => changed.standing === 0 ? "None stands now, so ref0 merges it once it’s ready." : `${changed.standing} ${changed.standing === 1 ? "stands" : "stand"}, so ref0 won’t merge it yet.`;
|
|
20470
|
+
const standingWords$1 = (changed) => changed.standing === 0 ? "None stands now, so ref0 merges it once it’s ready." : `${changed.standing} ${changed.standing === 1 ? "stands" : "stand"}, so ref0 won’t merge it yet.`;
|
|
20177
20471
|
/** A blocker added, in words: which, its ask, how many stand, and a publish it didn't stop. */
|
|
20178
20472
|
const addedWords = (changed, address) => [
|
|
20179
|
-
`Blocked ${address} by ${changed.blocker}. ${standingWords(changed)}`,
|
|
20473
|
+
`Blocked ${address} by ${changed.blocker}. ${standingWords$1(changed)}`,
|
|
20180
20474
|
...changed.decision === void 0 ? [] : [`It waits on a person’s answer to the ask ${changed.decision}.`],
|
|
20181
20475
|
...changed.admitted === void 0 ? [] : [`A merge carrying it was already under way in ${changed.admitted.join(", ")}; the blocker doesn’t stop that one.`]
|
|
20182
20476
|
].join("\n");
|
|
@@ -20323,7 +20617,7 @@ const clearing = (name, description) => Command.make(name, {
|
|
|
20323
20617
|
payload
|
|
20324
20618
|
}));
|
|
20325
20619
|
const done = name === "remove" ? "Removed" : "Marked met";
|
|
20326
|
-
yield* printAnswer(answer.json, asJson, `${done} ${answer.value.blocker} on ${address}. ${standingWords(answer.value)}`);
|
|
20620
|
+
yield* printAnswer(answer.json, asJson, `${done} ${answer.value.blocker} on ${address}. ${standingWords$1(answer.value)}`);
|
|
20327
20621
|
}).pipe(Effect.catchTag("CallFailed", (failed) => sayFailed(failed.nextStep)))).pipe(Command.withDescription(description));
|
|
20328
20622
|
/** `ref0 blocked` and its subcommands. */
|
|
20329
20623
|
const blockedCommands = [Command.make("blocked").pipe(Command.withDescription("What a thread is blocked by, which stops ref0 merging it: list, add, hold, remove, and mark met"), Command.withShortDescription("List and manage a thread’s blockers"), Command.withSubcommands([
|
|
@@ -20454,6 +20748,18 @@ const harnessFlagNames = { protocol: "protocol" };
|
|
|
20454
20748
|
* release, which no hook's declaration carries.
|
|
20455
20749
|
*/
|
|
20456
20750
|
const pluginVersionVariable = "REF0_PLUGIN_VERSION";
|
|
20751
|
+
/**
|
|
20752
|
+
* What the launcher puts in `ref0 mcp`'s environment in a checkout of another origin: that
|
|
20753
|
+
* origin. The server runs all the same, answering every hook with nothing, since a harness
|
|
20754
|
+
* takes a server that exits before `initialize` as broken.
|
|
20755
|
+
*/
|
|
20756
|
+
const otherOriginVariable = "REF0_OTHER_ORIGIN";
|
|
20757
|
+
/**
|
|
20758
|
+
* What `ref0 status` puts in the environment of a folder's ref0 server, which it starts to check
|
|
20759
|
+
* it starts: the harness it plays. No harness sets it, so a harness's server finds its harness up
|
|
20760
|
+
* its parent chain.
|
|
20761
|
+
*/
|
|
20762
|
+
const serverCheckVariable = "REF0_SERVER_CHECK";
|
|
20457
20763
|
/** A flag as a command line writes it. */
|
|
20458
20764
|
const flagWord = (name) => `--${name}`;
|
|
20459
20765
|
/**
|
|
@@ -20561,10 +20867,19 @@ const sessionStartHook = ref0Hook(harnessCommandNames.sessionStart);
|
|
|
20561
20867
|
/** Ends the session, as a command too. */
|
|
20562
20868
|
const sessionEndHook = ref0Hook(harnessCommandNames.sessionEnd);
|
|
20563
20869
|
/**
|
|
20870
|
+
* What Linux's keychain needs to be reached: `secret-tool` finds the session bus by its address,
|
|
20871
|
+
* or else in the runtime folder, and with neither it can't read a grant. Codex 0.164.0 passes a
|
|
20872
|
+
* server neither unless asked. macOS's keychain needs neither, and Codex passes an unset one as
|
|
20873
|
+
* unset.
|
|
20874
|
+
*/
|
|
20875
|
+
const keychainVariables = ["DBUS_SESSION_BUS_ADDRESS", "XDG_RUNTIME_DIR"];
|
|
20876
|
+
/**
|
|
20564
20877
|
* The plugin's server, `ref0 mcp`, started by M0's inline launcher, the lookup in one line: a
|
|
20565
20878
|
* script path in a server's arguments stays literal in Codex. It has no `cwd`, so the server
|
|
20566
|
-
* keeps the project's folder in both harnesses. Codex starts its servers with only
|
|
20567
|
-
* and `SHELL
|
|
20879
|
+
* keeps the project's folder in both harnesses. Codex starts its servers with only a few of its
|
|
20880
|
+
* variables, such as `HOME`, `PATH` and `SHELL` (`server-check.ts` lists them), so it's asked for
|
|
20881
|
+
* `CODEX_HOME` and the keychain's, each passed through as Codex has it and never written down: a
|
|
20882
|
+
* bus address changes with each login. Claude Code passes its whole environment.
|
|
20568
20883
|
*/
|
|
20569
20884
|
const mcpFile = (version, api, server = "ref0") => ({
|
|
20570
20885
|
path: "ref0/.mcp.json",
|
|
@@ -20579,133 +20894,14 @@ const mcpFile = (version, api, server = "ref0") => ({
|
|
|
20579
20894
|
env_vars: [
|
|
20580
20895
|
"CODEX_HOME",
|
|
20581
20896
|
"CLAUDE_CONFIG_DIR",
|
|
20582
|
-
"REF0_API"
|
|
20897
|
+
"REF0_API",
|
|
20898
|
+
...keychainVariables
|
|
20583
20899
|
]
|
|
20584
20900
|
} } })
|
|
20585
20901
|
});
|
|
20586
20902
|
/** Each event a harness's hooks send `ref0 mcp`, with the fields it reads, as an `mcp_tool` hook. */
|
|
20587
20903
|
const mcpToolHooks = (server, hookFields) => Object.fromEntries(Object.entries(hookFields).map(([event, fields]) => [event, [mcpToolHook(server, event, fields)]]));
|
|
20588
20904
|
//#endregion
|
|
20589
|
-
//#region src/keychain.ts
|
|
20590
|
-
/**
|
|
20591
|
-
* The machine's keychain, where the CLI keeps what lets it act: the host grant `ref0 connect`
|
|
20592
|
-
* makes, and a session's token. Never a file. On macOS through `security`, on Linux through
|
|
20593
|
-
* `secret-tool`, under an origin-specific `ref0/<origin>` service, by account. A secret goes in through stdin, never as
|
|
20594
|
-
* an argument, since any process on the machine can read another's arguments.
|
|
20595
|
-
*/
|
|
20596
|
-
var KeychainError = class extends Schema.TaggedError()("KeychainError", {
|
|
20597
|
-
nextStep: Schema.String,
|
|
20598
|
-
/** The keychain answered, with an item ref0 can't read, rather than not answering. */
|
|
20599
|
-
unreadable: Schema.optionalKey(Schema.Literal(true))
|
|
20600
|
-
}) {};
|
|
20601
|
-
/**
|
|
20602
|
-
* The keychain under one service. Each API origin has its own, `ref0/<origin>`; 0.5.0 kept its
|
|
20603
|
-
* grants under `ref0`, which only `ref0 connect` reads, to replace and delete them.
|
|
20604
|
-
*/
|
|
20605
|
-
const keychainIn = Effect.fnUntraced(function* (service) {
|
|
20606
|
-
const crypto = yield* Crypto.Crypto;
|
|
20607
|
-
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
|
|
20608
|
-
const macOS = (yield* spawner.string(ChildProcess.make("uname", ["-s"])).pipe(Effect.orElseSucceed(() => ""))).trim() === "Darwin";
|
|
20609
|
-
const unavailable = new KeychainError({ nextStep: macOS ? "The keychain didn’t answer. Unlock it, and try again." : "The keychain didn’t answer. Unlock the keychain or start a secret service, and try again." });
|
|
20610
|
-
const missing = new KeychainError({ nextStep: "The keychain didn’t answer: ref0 needs `secret-tool`, from libsecret. Install `secret-tool` with `sudo apt install libsecret-tools` on Debian or Ubuntu, `sudo dnf install libsecret` on Fedora or `sudo pacman -S libsecret` on Arch." });
|
|
20611
|
-
const run = (command, args, stdin) => Effect.scoped(Effect.gen(function* () {
|
|
20612
|
-
const handle = yield* spawner.spawn(ChildProcess.make(command, args, { stdin: Stream.make(new TextEncoder().encode(stdin)) })).pipe(Effect.mapError((error) => !macOS && Predicate.isTagged(error.reason, "NotFound") ? missing : unavailable));
|
|
20613
|
-
const [output, complaint] = yield* Effect.all([handle.stdout.pipe(Stream.decodeText(), Stream.mkString), handle.stderr.pipe(Stream.decodeText(), Stream.mkString)], { concurrency: "unbounded" });
|
|
20614
|
-
const code = yield* handle.exitCode;
|
|
20615
|
-
return {
|
|
20616
|
-
output: output.trim(),
|
|
20617
|
-
complaint: complaint.trim(),
|
|
20618
|
-
code,
|
|
20619
|
-
succeeded: code === ChildProcessSpawner.ExitCode(0)
|
|
20620
|
-
};
|
|
20621
|
-
})).pipe(Effect.catchTag("PlatformError", () => Effect.fail(unavailable)));
|
|
20622
|
-
const get = Effect.fn("Keychain.get")(function* (account) {
|
|
20623
|
-
const found = macOS ? yield* run("security", [
|
|
20624
|
-
"find-generic-password",
|
|
20625
|
-
"-s",
|
|
20626
|
-
service,
|
|
20627
|
-
"-a",
|
|
20628
|
-
account,
|
|
20629
|
-
"-w"
|
|
20630
|
-
], "") : yield* run("secret-tool", [
|
|
20631
|
-
"lookup",
|
|
20632
|
-
"service",
|
|
20633
|
-
service,
|
|
20634
|
-
"account",
|
|
20635
|
-
account
|
|
20636
|
-
], "");
|
|
20637
|
-
if (found.succeeded) return found.output.length > 0 ? Option.some(Redacted.make(found.output)) : Option.none();
|
|
20638
|
-
if (macOS) {
|
|
20639
|
-
if (found.code === ChildProcessSpawner.ExitCode(44)) return Option.none();
|
|
20640
|
-
return yield* unavailable;
|
|
20641
|
-
}
|
|
20642
|
-
if (found.code !== ChildProcessSpawner.ExitCode(1) || found.complaint.length > 0) return yield* unavailable;
|
|
20643
|
-
const listed = yield* run("secret-tool", [
|
|
20644
|
-
"search",
|
|
20645
|
-
"--all",
|
|
20646
|
-
"service",
|
|
20647
|
-
service,
|
|
20648
|
-
"account",
|
|
20649
|
-
account
|
|
20650
|
-
], "");
|
|
20651
|
-
if (listed.output.length > 0 || listed.complaint.length > 0) return yield* unavailable;
|
|
20652
|
-
return Option.none();
|
|
20653
|
-
});
|
|
20654
|
-
const set = Effect.fn("Keychain.set")(function* (account, secret) {
|
|
20655
|
-
const quote = (text) => `"${text.replace(/[\\"]/g, "\\$&")}"`;
|
|
20656
|
-
const value = Redacted.value(secret);
|
|
20657
|
-
if (macOS && /[\r\n\0]/.test(account + value)) return yield* unavailable;
|
|
20658
|
-
const input = macOS ? `add-generic-password -U -s ${quote(service)} -a ${quote(account)} -w ${quote(value)}\n` : value;
|
|
20659
|
-
if (macOS && new TextEncoder().encode(input).length >= 4096) return yield* unavailable;
|
|
20660
|
-
if (!(macOS ? yield* run("security", ["-i"], input) : yield* run("secret-tool", [
|
|
20661
|
-
"store",
|
|
20662
|
-
"--label",
|
|
20663
|
-
`ref0 ${account}`,
|
|
20664
|
-
"service",
|
|
20665
|
-
service,
|
|
20666
|
-
"account",
|
|
20667
|
-
account
|
|
20668
|
-
], Redacted.value(secret))).succeeded) return yield* unavailable;
|
|
20669
|
-
});
|
|
20670
|
-
const remove = Effect.fn("Keychain.remove")(function* (account) {
|
|
20671
|
-
if (!(yield* macOS ? run("security", [
|
|
20672
|
-
"delete-generic-password",
|
|
20673
|
-
"-s",
|
|
20674
|
-
service,
|
|
20675
|
-
"-a",
|
|
20676
|
-
account
|
|
20677
|
-
], "") : run("secret-tool", [
|
|
20678
|
-
"clear",
|
|
20679
|
-
"service",
|
|
20680
|
-
service,
|
|
20681
|
-
"account",
|
|
20682
|
-
account
|
|
20683
|
-
], "")).succeeded) return yield* unavailable;
|
|
20684
|
-
});
|
|
20685
|
-
const random = crypto.randomUUIDv4.pipe(Effect.mapError(() => unavailable));
|
|
20686
|
-
return {
|
|
20687
|
-
probe: Effect.gen(function* () {
|
|
20688
|
-
const account = `connect-probe/${yield* random}`;
|
|
20689
|
-
const value = Redacted.make(yield* random);
|
|
20690
|
-
yield* Effect.gen(function* () {
|
|
20691
|
-
yield* set(account, value);
|
|
20692
|
-
const kept = yield* get(account);
|
|
20693
|
-
if (Option.isNone(kept) || Redacted.value(kept.value) !== Redacted.value(value)) return yield* unavailable;
|
|
20694
|
-
yield* remove(account);
|
|
20695
|
-
}).pipe(Effect.catch((error) => remove(account).pipe(Effect.ignore, Effect.andThen(Effect.fail(error)))));
|
|
20696
|
-
}).pipe(Effect.timeoutOrElse({
|
|
20697
|
-
duration: Duration.minutes(1),
|
|
20698
|
-
orElse: () => Effect.fail(unavailable)
|
|
20699
|
-
})),
|
|
20700
|
-
get,
|
|
20701
|
-
set,
|
|
20702
|
-
remove
|
|
20703
|
-
};
|
|
20704
|
-
});
|
|
20705
|
-
var Keychain = class Keychain extends Context.Service()("ref0/cli/Keychain") {
|
|
20706
|
-
static layer = Layer.effect(Keychain, Effect.flatMap(Ref0Config, (config) => Effect.map(keychainIn(`ref0/${originKey(config.api)}`), Keychain.of)));
|
|
20707
|
-
};
|
|
20708
|
-
//#endregion
|
|
20709
20905
|
//#region src/harness/binding.ts
|
|
20710
20906
|
/**
|
|
20711
20907
|
* Where a session's token is kept: the machine's keychain, under the API origin’s keychain service, by an
|
|
@@ -20743,9 +20939,8 @@ function bindingFromEnvironment(environment, found, hookSession) {
|
|
|
20743
20939
|
//#endregion
|
|
20744
20940
|
//#region src/harness/session-token.ts
|
|
20745
20941
|
/**
|
|
20746
|
-
* The
|
|
20747
|
-
*
|
|
20748
|
-
* there's none, and a command is its person's, asked by approval.
|
|
20942
|
+
* The harness session a command runs in: the harness process up its parent chain, and the
|
|
20943
|
+
* binding its session's token is kept under, which `command-token.ts` reads.
|
|
20749
20944
|
*/
|
|
20750
20945
|
const optional$1 = (name) => Config.String(name).pipe(Config.option, Config.map(Option.getOrUndefined));
|
|
20751
20946
|
/** What the harness put in this process's environment, as far as a binding needs. */
|
|
@@ -20753,7 +20948,6 @@ const harnessEnvironment = Config.all({
|
|
|
20753
20948
|
CLAUDE_CODE_SESSION_ID: optional$1("CLAUDE_CODE_SESSION_ID"),
|
|
20754
20949
|
CLAUDE_PID: optional$1("CLAUDE_PID"),
|
|
20755
20950
|
CODEX_SESSION_ID: optional$1("CODEX_SESSION_ID"),
|
|
20756
|
-
CODEX_HOME: optional$1("CODEX_HOME"),
|
|
20757
20951
|
CODEX_SANDBOX: optional$1("CODEX_SANDBOX")
|
|
20758
20952
|
});
|
|
20759
20953
|
/**
|
|
@@ -20828,17 +21022,6 @@ const bindingForServer = Effect.fnUntraced(function* (harnessSession) {
|
|
|
20828
21022
|
process: found.value.process
|
|
20829
21023
|
});
|
|
20830
21024
|
});
|
|
20831
|
-
/** `SessionToken` from the keychain, by the binding of the harness session this runs in. */
|
|
20832
|
-
const harnessSessionToken = Layer.effect(SessionToken, Effect.gen(function* () {
|
|
20833
|
-
const keychain = yield* Keychain;
|
|
20834
|
-
const harness = yield* HarnessProcess;
|
|
20835
|
-
const current = Effect.gen(function* () {
|
|
20836
|
-
const binding = yield* bindingHere().pipe(Effect.provideService(HarnessProcess, harness), Effect.orElseSucceed(() => Option.none()));
|
|
20837
|
-
if (Option.isNone(binding)) return Option.none();
|
|
20838
|
-
return yield* keychain.get(sessionAccount(binding.value));
|
|
20839
|
-
});
|
|
20840
|
-
return SessionToken.of({ current });
|
|
20841
|
-
}));
|
|
20842
21025
|
//#endregion
|
|
20843
21026
|
//#region src/harness/config-folders.ts
|
|
20844
21027
|
const Folders = Schema.Array(Schema.String);
|
|
@@ -20951,6 +21134,126 @@ const connectLineFor = Effect.fnUntraced(function* (harness, configFolder) {
|
|
|
20951
21134
|
return `${configFolders[harness].variable}=${shellWord(configFolder)} ref0 connect`;
|
|
20952
21135
|
});
|
|
20953
21136
|
//#endregion
|
|
21137
|
+
//#region src/keychain.ts
|
|
21138
|
+
/**
|
|
21139
|
+
* The machine's keychain, where the CLI keeps what lets it act: the host grant `ref0 connect`
|
|
21140
|
+
* makes, and a session's token. Never a file. On macOS through `security`, on Linux through
|
|
21141
|
+
* `secret-tool`, under an origin-specific `ref0/<origin>` service, by account. A secret goes in through stdin, never as
|
|
21142
|
+
* an argument, since any process on the machine can read another's arguments.
|
|
21143
|
+
*/
|
|
21144
|
+
var KeychainError = class extends Schema.TaggedError()("KeychainError", {
|
|
21145
|
+
nextStep: Schema.String,
|
|
21146
|
+
/** The keychain answered, with an item ref0 can't read, rather than not answering. */
|
|
21147
|
+
unreadable: Schema.optionalKey(Schema.Literal(true))
|
|
21148
|
+
}) {};
|
|
21149
|
+
/**
|
|
21150
|
+
* The keychain under one service. Each API origin has its own, `ref0/<origin>`; 0.5.0 kept its
|
|
21151
|
+
* grants under `ref0`, which only `ref0 connect` reads, to replace and delete them.
|
|
21152
|
+
*/
|
|
21153
|
+
const keychainIn = Effect.fnUntraced(function* (service) {
|
|
21154
|
+
const crypto = yield* Crypto.Crypto;
|
|
21155
|
+
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
|
|
21156
|
+
const macOS = (yield* spawner.string(ChildProcess.make("uname", ["-s"])).pipe(Effect.orElseSucceed(() => ""))).trim() === "Darwin";
|
|
21157
|
+
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." });
|
|
21158
|
+
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." });
|
|
21159
|
+
const run = (command, args, stdin) => Effect.scoped(Effect.gen(function* () {
|
|
21160
|
+
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));
|
|
21161
|
+
const [output, complaint] = yield* Effect.all([handle.stdout.pipe(Stream.decodeText(), Stream.mkString), handle.stderr.pipe(Stream.decodeText(), Stream.mkString)], { concurrency: "unbounded" });
|
|
21162
|
+
const code = yield* handle.exitCode;
|
|
21163
|
+
return {
|
|
21164
|
+
output: output.trim(),
|
|
21165
|
+
complaint: complaint.trim(),
|
|
21166
|
+
code,
|
|
21167
|
+
succeeded: code === ChildProcessSpawner.ExitCode(0)
|
|
21168
|
+
};
|
|
21169
|
+
})).pipe(Effect.catchTag("PlatformError", () => Effect.fail(unavailable)));
|
|
21170
|
+
const get = Effect.fn("Keychain.get")(function* (account) {
|
|
21171
|
+
const found = macOS ? yield* run("security", [
|
|
21172
|
+
"find-generic-password",
|
|
21173
|
+
"-s",
|
|
21174
|
+
service,
|
|
21175
|
+
"-a",
|
|
21176
|
+
account,
|
|
21177
|
+
"-w"
|
|
21178
|
+
], "") : yield* run("secret-tool", [
|
|
21179
|
+
"lookup",
|
|
21180
|
+
"service",
|
|
21181
|
+
service,
|
|
21182
|
+
"account",
|
|
21183
|
+
account
|
|
21184
|
+
], "");
|
|
21185
|
+
if (found.succeeded) return found.output.length > 0 ? Option.some(Redacted.make(found.output)) : Option.none();
|
|
21186
|
+
if (macOS) {
|
|
21187
|
+
if (found.code === ChildProcessSpawner.ExitCode(44)) return Option.none();
|
|
21188
|
+
return yield* unavailable;
|
|
21189
|
+
}
|
|
21190
|
+
if (found.code !== ChildProcessSpawner.ExitCode(1) || found.complaint.length > 0) return yield* unavailable;
|
|
21191
|
+
const listed = yield* run("secret-tool", [
|
|
21192
|
+
"search",
|
|
21193
|
+
"--all",
|
|
21194
|
+
"service",
|
|
21195
|
+
service,
|
|
21196
|
+
"account",
|
|
21197
|
+
account
|
|
21198
|
+
], "");
|
|
21199
|
+
if (listed.output.length > 0 || listed.complaint.length > 0) return yield* unavailable;
|
|
21200
|
+
return Option.none();
|
|
21201
|
+
});
|
|
21202
|
+
const set = Effect.fn("Keychain.set")(function* (account, secret) {
|
|
21203
|
+
const quote = (text) => `"${text.replace(/[\\"]/g, "\\$&")}"`;
|
|
21204
|
+
const value = Redacted.value(secret);
|
|
21205
|
+
if (macOS && /[\r\n\0]/.test(account + value)) return yield* unavailable;
|
|
21206
|
+
const input = macOS ? `add-generic-password -U -s ${quote(service)} -a ${quote(account)} -w ${quote(value)}\n` : value;
|
|
21207
|
+
if (macOS && new TextEncoder().encode(input).length >= 4096) return yield* unavailable;
|
|
21208
|
+
if (!(macOS ? yield* run("security", ["-i"], input) : yield* run("secret-tool", [
|
|
21209
|
+
"store",
|
|
21210
|
+
"--label",
|
|
21211
|
+
`ref0 ${account}`,
|
|
21212
|
+
"service",
|
|
21213
|
+
service,
|
|
21214
|
+
"account",
|
|
21215
|
+
account
|
|
21216
|
+
], Redacted.value(secret))).succeeded) return yield* unavailable;
|
|
21217
|
+
});
|
|
21218
|
+
const remove = Effect.fn("Keychain.remove")(function* (account) {
|
|
21219
|
+
if (!(yield* macOS ? run("security", [
|
|
21220
|
+
"delete-generic-password",
|
|
21221
|
+
"-s",
|
|
21222
|
+
service,
|
|
21223
|
+
"-a",
|
|
21224
|
+
account
|
|
21225
|
+
], "") : run("secret-tool", [
|
|
21226
|
+
"clear",
|
|
21227
|
+
"service",
|
|
21228
|
+
service,
|
|
21229
|
+
"account",
|
|
21230
|
+
account
|
|
21231
|
+
], "")).succeeded) return yield* unavailable;
|
|
21232
|
+
});
|
|
21233
|
+
const random = crypto.randomUUIDv4.pipe(Effect.mapError(() => unavailable));
|
|
21234
|
+
return {
|
|
21235
|
+
probe: Effect.gen(function* () {
|
|
21236
|
+
const account = `connect-probe/${yield* random}`;
|
|
21237
|
+
const value = Redacted.make(yield* random);
|
|
21238
|
+
yield* Effect.gen(function* () {
|
|
21239
|
+
yield* set(account, value);
|
|
21240
|
+
const kept = yield* get(account);
|
|
21241
|
+
if (Option.isNone(kept) || Redacted.value(kept.value) !== Redacted.value(value)) return yield* unavailable;
|
|
21242
|
+
yield* remove(account);
|
|
21243
|
+
}).pipe(Effect.catch((error) => remove(account).pipe(Effect.ignore, Effect.andThen(Effect.fail(error)))));
|
|
21244
|
+
}).pipe(Effect.timeoutOrElse({
|
|
21245
|
+
duration: Duration.minutes(1),
|
|
21246
|
+
orElse: () => Effect.fail(unavailable)
|
|
21247
|
+
})),
|
|
21248
|
+
get,
|
|
21249
|
+
set,
|
|
21250
|
+
remove
|
|
21251
|
+
};
|
|
21252
|
+
});
|
|
21253
|
+
var Keychain = class Keychain extends Context.Service()("ref0/cli/Keychain") {
|
|
21254
|
+
static layer = Layer.effect(Keychain, Effect.flatMap(Ref0Config, (config) => Effect.map(keychainIn(`ref0/${originKey(config.api)}`), Keychain.of)));
|
|
21255
|
+
};
|
|
21256
|
+
//#endregion
|
|
20954
21257
|
//#region src/host-grant.ts
|
|
20955
21258
|
/**
|
|
20956
21259
|
* All lasting authority for one config folder, stored as one keychain value: the agent the folder
|
|
@@ -20966,6 +21269,11 @@ const KeptHostGrant = Schema.Struct({
|
|
|
20966
21269
|
});
|
|
20967
21270
|
const decodeGrant = Schema.decodeUnknownOption(Schema.fromJsonString(KeptHostGrant));
|
|
20968
21271
|
const encodeGrant = Schema.encodeSync(Schema.fromJsonString(KeptHostGrant));
|
|
21272
|
+
/**
|
|
21273
|
+
* The token alone, from any record that's JSON with a string `token`, so a record this ref0 can't
|
|
21274
|
+
* read as a grant, such as one an earlier ref0 kept, still names the grant it held.
|
|
21275
|
+
*/
|
|
21276
|
+
const decodeHeldToken = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Struct({ token: KeptHostGrant.fields.token })));
|
|
20969
21277
|
/** Grants in the origin’s keychain service, account host-grant/<resolved config folder>. */
|
|
20970
21278
|
var HostGrant = class HostGrant extends Context.Service()("ref0/cli/HostGrant") {
|
|
20971
21279
|
static layer = Layer.effect(HostGrant, Effect.gen(function* () {
|
|
@@ -20981,6 +21289,7 @@ var HostGrant = class HostGrant extends Context.Service()("ref0/cli/HostGrant")
|
|
|
20981
21289
|
unreadable: true
|
|
20982
21290
|
}));
|
|
20983
21291
|
})),
|
|
21292
|
+
heldToken: (folder) => account(folder).pipe(Effect.flatMap(keychain.get), Effect.map(Option.flatMap((kept) => Option.map(decodeHeldToken(Redacted.value(kept)), (held) => held.token)))),
|
|
20984
21293
|
keep: (folder, grant) => Effect.gen(function* () {
|
|
20985
21294
|
yield* keychain.set(yield* account(folder), Redacted.make(encodeGrant(grant)));
|
|
20986
21295
|
yield* folders.keep(folder).pipe(Effect.mapError(() => new KeychainError({ nextStep: "Couldn’t record this connected config folder in ~/.ref0. Check that it’s writable, then run ref0 connect." })));
|
|
@@ -21008,7 +21317,7 @@ const grantForAgent = Effect.fnUntraced(function* (owner, agent) {
|
|
|
21008
21317
|
* an update installs anew. Node, run from source, loads JSON only with the `type` attribute,
|
|
21009
21318
|
* and only as a default export.
|
|
21010
21319
|
*/
|
|
21011
|
-
const cliVersion = "0.
|
|
21320
|
+
const cliVersion = "0.11.0";
|
|
21012
21321
|
//#endregion
|
|
21013
21322
|
//#region src/harness/hook-protocol.ts
|
|
21014
21323
|
/**
|
|
@@ -21332,12 +21641,12 @@ const requestAttachment = Effect.fnUntraced(function* (asked) {
|
|
|
21332
21641
|
const unreachable = () => sayFailed(`Couldn’t reach ref0 at ${config.api}. Try again.`);
|
|
21333
21642
|
const requestId = yield* newRequestId;
|
|
21334
21643
|
const asking = yield* Effect.forEach(asked, Effect.fnUntraced(function* (folder) {
|
|
21335
|
-
const
|
|
21644
|
+
const held = yield* hostGrant.heldToken(folder.configFolder).pipe(Effect.catchTag("KeychainError", said));
|
|
21336
21645
|
return {
|
|
21337
21646
|
...folder,
|
|
21338
21647
|
shown: shownConfigFolder(home, folder.configFolder),
|
|
21339
21648
|
grant: yield* freshTokenSecret,
|
|
21340
|
-
replaces: Option.isSome(
|
|
21649
|
+
replaces: Option.isSome(held) ? grantOf(held.value) : yield* oldGrants.replaces(folder).pipe(Effect.catchTag("KeychainError", said))
|
|
21341
21650
|
};
|
|
21342
21651
|
}));
|
|
21343
21652
|
const folders = yield* Effect.forEach(asking, Effect.fnUntraced(function* (folder) {
|
|
@@ -21386,9 +21695,7 @@ const requestAttachment = Effect.fnUntraced(function* (asked) {
|
|
|
21386
21695
|
"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.",
|
|
21387
21696
|
"Waiting for the answer. Ctrl-C stops it."
|
|
21388
21697
|
]]);
|
|
21389
|
-
|
|
21390
|
-
const show = (text) => Effect.ignore(terminal.display(text));
|
|
21391
|
-
const endWait = yield* Effect.cached(Effect.suspend(() => typed === "" ? Effect.void : show("\n")).pipe(Effect.andThen(Console.error(""))));
|
|
21698
|
+
const endWait = yield* Effect.cached(Console.error(""));
|
|
21392
21699
|
const saidAfterWait = (refusal) => Effect.andThen(endWait, said(refusal));
|
|
21393
21700
|
if (opener !== void 0) yield* openBrowser(opener, `${config.api}/connect#${link.secret}`);
|
|
21394
21701
|
const withSecret = HttpApiMiddleware.layerClient(ApprovalSecret, ({ request, next }) => next(HttpClientRequest.bearerToken(request, collecting.secret)));
|
|
@@ -21547,25 +21854,21 @@ const requestAttachment = Effect.fnUntraced(function* (asked) {
|
|
|
21547
21854
|
});
|
|
21548
21855
|
const typedCode = Effect.gen(function* () {
|
|
21549
21856
|
const keys = yield* terminal.readInput;
|
|
21857
|
+
let typed = "";
|
|
21550
21858
|
while (true) {
|
|
21551
21859
|
const pressed = yield* Effect.option(Queue.take(keys));
|
|
21552
21860
|
if (Option.isNone(pressed) || quits(pressed.value.key)) return yield* Effect.interrupt;
|
|
21553
21861
|
const { input, key } = pressed.value;
|
|
21554
21862
|
if (key.name === "backspace") {
|
|
21555
|
-
if (typed === "") continue;
|
|
21556
21863
|
typed = takeBack(typed);
|
|
21557
|
-
yield* show("\b \b");
|
|
21558
21864
|
continue;
|
|
21559
21865
|
}
|
|
21560
21866
|
if (key.name !== "return" && key.name !== "enter") {
|
|
21561
21867
|
const text = Option.getOrElse(input, () => "");
|
|
21562
|
-
if (
|
|
21563
|
-
typed += text;
|
|
21564
|
-
yield* show(text);
|
|
21868
|
+
if (typesText(text)) typed += text;
|
|
21565
21869
|
continue;
|
|
21566
21870
|
}
|
|
21567
21871
|
const line = typed.trim();
|
|
21568
|
-
if (typed !== "") yield* show("\n");
|
|
21569
21872
|
typed = "";
|
|
21570
21873
|
if (line === "") continue;
|
|
21571
21874
|
if (isAnswerCode(line)) return line;
|
|
@@ -21573,7 +21876,7 @@ const requestAttachment = Effect.fnUntraced(function* (asked) {
|
|
|
21573
21876
|
}
|
|
21574
21877
|
}).pipe(Effect.scoped);
|
|
21575
21878
|
const readAnswer = Effect.gen(function* () {
|
|
21576
|
-
yield* printBlocks([[`Paste the code from your own page on ${config.api}.`]]);
|
|
21879
|
+
if (listener === void 0) yield* printBlocks([[`Paste the code from your own page on ${config.api}.`]]);
|
|
21577
21880
|
const code = yield* typedCode;
|
|
21578
21881
|
yield* Ref.update(pasted, (first) => first ?? code);
|
|
21579
21882
|
if (listener !== void 0) yield* listener.stop;
|
|
@@ -22710,7 +23013,7 @@ var CodexAnswerMismatch = class extends Schema.TaggedError()("CodexAnswerMismatc
|
|
|
22710
23013
|
issue: Schema.String
|
|
22711
23014
|
}) {};
|
|
22712
23015
|
const encodeRequest = Schema.encodeUnknownEffect(Schema.fromJsonString(CodexRequest));
|
|
22713
|
-
const decodeAnswer$
|
|
23016
|
+
const decodeAnswer$2 = Schema.decodeUnknownOption(Schema.fromJsonString(CodexAnswer));
|
|
22714
23017
|
/** The IDs of the exchange's two requests: the opening's, and the one asked. */
|
|
22715
23018
|
const initializeId = "ref0-initialize";
|
|
22716
23019
|
const askedId = "ref0-asked";
|
|
@@ -22744,7 +23047,7 @@ const exchange = (codex, lines, ids, env) => Effect.scoped(Effect.gen(function*
|
|
|
22744
23047
|
stream: Stream.fromIterable(lines.map((line) => `${line}\n`)).pipe(Stream.encodeText),
|
|
22745
23048
|
endOnDone: false
|
|
22746
23049
|
}
|
|
22747
|
-
}))).stdout.pipe(Stream.decodeText(), Stream.splitLines, Stream.map((line) => decodeAnswer$
|
|
23050
|
+
}))).stdout.pipe(Stream.decodeText(), Stream.splitLines, Stream.map((line) => decodeAnswer$2(line)), Stream.filter(Option.isSome), Stream.scan(() => /* @__PURE__ */ new Map(), (answers, { value: answer }) => ids.includes(answer.id) && !answers.has(answer.id) ? new Map(answers).set(answer.id, answer) : answers), Stream.takeUntil((answers) => answers.size === ids.length), Stream.runLast);
|
|
22748
23051
|
return Option.filter(answered, (answers) => answers.size === ids.length);
|
|
22749
23052
|
})).pipe(Effect.timeoutOption("15 seconds"), Effect.map(Option.flatten), Effect.catchCause(() => Effect.succeed(Option.none())));
|
|
22750
23053
|
/** `answer`'s result, read by `method`'s own schema, or why it can't be. */
|
|
@@ -23088,16 +23391,19 @@ const codexAgentConfig = Effect.fnUntraced(function* (agent, root, home) {
|
|
|
23088
23391
|
parameters: shellWord(`include.path=${file}`)
|
|
23089
23392
|
};
|
|
23090
23393
|
});
|
|
23091
|
-
/**
|
|
23394
|
+
/**
|
|
23395
|
+
* Codex has no environment file: its install selects a file; session start fills its checkout,
|
|
23396
|
+
* for the agent of the config folder `CODEX_HOME` names, or the default `~/.codex`.
|
|
23397
|
+
*/
|
|
23092
23398
|
const setCodexCommitEnvironment = Effect.fnUntraced(function* (cwd, checkout) {
|
|
23093
23399
|
const folders = yield* ConfigFolders;
|
|
23094
23400
|
const fs = yield* FileSystem.FileSystem;
|
|
23095
23401
|
const path = yield* Path.Path;
|
|
23096
23402
|
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
|
|
23097
|
-
const agent = yield* (yield* HostGrant).get(yield* folders.folder("codex"));
|
|
23403
|
+
const agent = yield* (yield* HostGrant).get(yield* folders.folder("codex")).pipe(Effect.catchIf((failed) => failed.unreadable === true, () => Effect.succeedNone));
|
|
23098
23404
|
if (Option.isNone(agent)) return false;
|
|
23099
23405
|
const pluginRoot = yield* Config.String("CLAUDE_PLUGIN_ROOT").pipe(Config.withDefault(""));
|
|
23100
|
-
if (pluginRoot === "") return
|
|
23406
|
+
if (pluginRoot === "") return yield* new CodexCommitFailed({ detail: "Codex gave the hook no plugin root." });
|
|
23101
23407
|
const gitFolder = path.join(path.dirname(pluginRoot), "@git");
|
|
23102
23408
|
const home = yield* Config.String("HOME");
|
|
23103
23409
|
const author = yield* codexAgentConfig(agent.value, gitFolder, home);
|
|
@@ -23867,6 +24173,7 @@ const writeRef0Launcher = Effect.fnUntraced(function* (home, git) {
|
|
|
23867
24173
|
const gitPath = yield* fs.realPath(git);
|
|
23868
24174
|
yield* fs.writeFileString(next, [
|
|
23869
24175
|
"#!/bin/sh",
|
|
24176
|
+
`unset ${otherOriginVariable}`,
|
|
23870
24177
|
"if [ \"$REF0_SELECT_ORIGIN\" = 1 ]; then",
|
|
23871
24178
|
` checkout=$(${shellWord(gitPath)} config --get ref0.api 2>/dev/null)`,
|
|
23872
24179
|
" result=$?",
|
|
@@ -23876,7 +24183,11 @@ const writeRef0Launcher = Effect.fnUntraced(function* (home, git) {
|
|
|
23876
24183
|
` *) printf '%s\\n' ${shellWord("ref0: could not read the checkout origin with Git. Restore Git, then run ref0 install again.")} >&2; exit 1 ;;`,
|
|
23877
24184
|
" esac",
|
|
23878
24185
|
" export REF0_SELECTED_API",
|
|
23879
|
-
` ${shellWord(node)} -e ${shellWord("try { process.exit(new URL(process.env.REF0_SELECTED_API).origin === process.argv[1] ? 0 : 1) } catch { process.exit(1) }")} ${shellWord(config.api)}
|
|
24186
|
+
` if ! ${shellWord(node)} -e ${shellWord("try { process.exit(new URL(process.env.REF0_SELECTED_API).origin === process.argv[1] ? 0 : 1) } catch { process.exit(1) }")} ${shellWord(config.api)}; then`,
|
|
24187
|
+
` [ "$1" = ${harnessCommandNames.mcp} ] || exit 0`,
|
|
24188
|
+
` ${otherOriginVariable}=$REF0_SELECTED_API`,
|
|
24189
|
+
` export ${otherOriginVariable}`,
|
|
24190
|
+
" fi",
|
|
23880
24191
|
"fi",
|
|
23881
24192
|
"unset REF0_SELECT_ORIGIN REF0_SELECTED_API",
|
|
23882
24193
|
`export REF0_API=${shellWord(config.api)}`,
|
|
@@ -24032,8 +24343,16 @@ const mostFolders = 8;
|
|
|
24032
24343
|
const isConfigFolderPath = Schema.is(ConfigFolderPath);
|
|
24033
24344
|
/** The line that asks again. */
|
|
24034
24345
|
const again = "ref0 connect";
|
|
24035
|
-
/**
|
|
24346
|
+
/** The widest a line of ref0's own words runs, as its help does, in a terminal at least as wide. */
|
|
24347
|
+
const mostColumns = 100;
|
|
24348
|
+
/**
|
|
24349
|
+
* Every folder found, each ticked but one ref0 can't be shown; Enter keeps the ticked ones. The
|
|
24350
|
+
* question is said first, wrapped between words to the terminal, and the chooser shows only the
|
|
24351
|
+
* folders: once Enter chooses, it leaves the ticked ones on one line after any question it was
|
|
24352
|
+
* given, which the terminal would break mid-word.
|
|
24353
|
+
*/
|
|
24036
24354
|
const choose = Effect.fnUntraced(function* (found, home) {
|
|
24355
|
+
const columns = yield* (yield* Terminal.Terminal).columns;
|
|
24037
24356
|
const choices = found.map((folder) => {
|
|
24038
24357
|
const shown = shownConfigFolder(home, folder.configFolder);
|
|
24039
24358
|
const sendable = isConfigFolderPath(shown);
|
|
@@ -24049,8 +24368,9 @@ const choose = Effect.fnUntraced(function* (found, home) {
|
|
|
24049
24368
|
};
|
|
24050
24369
|
});
|
|
24051
24370
|
const ticked = choices.filter((choice) => choice.selected).length;
|
|
24371
|
+
const keys = "Space ticks or unticks one; Enter connects those ticked.";
|
|
24372
|
+
yield* printBlocks([[{ label: "Connect which config folders?" }, ...wrapWords(ticked > mostFolders ? `One request connects at most ${mostFolders}, so untick ${ticked - mostFolders} or more. ${keys}` : keys, columns > 0 ? Math.min(columns, mostColumns) : mostColumns)]]);
|
|
24052
24373
|
return yield* Prompt.run(Prompt.MultiSelect({
|
|
24053
|
-
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.",
|
|
24054
24374
|
choices,
|
|
24055
24375
|
max: mostFolders
|
|
24056
24376
|
}));
|
|
@@ -24158,7 +24478,7 @@ const answerOf = (connected, installed, others) => ({
|
|
|
24158
24478
|
const connectCommands = [Command.make("connect", { json }, ({ json: asJson }) => Effect.gen(function* () {
|
|
24159
24479
|
const session = yield* SessionToken;
|
|
24160
24480
|
const tty = yield* Tty;
|
|
24161
|
-
if (
|
|
24481
|
+
if (yield* session.current.pipe(Effect.map(Option.isSome), Effect.catchTag("NoSession", () => Effect.succeed(true)))) return yield* sayFailed("ref0 connect runs only in a person’s own terminal, never in an agent’s session: it shows a code, and a yes to that code connects this machine to an agent. If this machine needs connecting, tell your person to run ref0 connect in their own terminal. Never ask them to approve a code for you.");
|
|
24162
24482
|
if (!tty.attached) return yield* sayFailed("ref0 connect runs only in a terminal you type into, and this isn’t one. Run it yourself, in your own terminal: not through a script, a pipe or an agent.");
|
|
24163
24483
|
yield* (yield* ConnectLock).hold;
|
|
24164
24484
|
yield* (yield* Keychain).probe;
|
|
@@ -24167,6 +24487,7 @@ const connectCommands = [Command.make("connect", { json }, ({ json: asJson }) =>
|
|
|
24167
24487
|
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.`);
|
|
24168
24488
|
const chosen = yield* choose(found, home).pipe(Effect.catchTag("QuitError", () => sayFailed("Stopped, and nothing was asked.")));
|
|
24169
24489
|
if (chosen.length === 0) return yield* sayFailed(`No config folder was ticked, so nothing was asked. To connect one, run ${again} again.`);
|
|
24490
|
+
yield* Console.error("");
|
|
24170
24491
|
const connected = yield* requestAttachment(chosen);
|
|
24171
24492
|
const installed = yield* installInto(connected.kept, { asJson });
|
|
24172
24493
|
const others = yield* otherOwners(connected);
|
|
@@ -24552,7 +24873,7 @@ const paramValues = (params) => {
|
|
|
24552
24873
|
* its params, and when it ends; under it, each workflow's run, by its title and its file, and
|
|
24553
24874
|
* how it stands; and under each, its newest asks and passkeys.
|
|
24554
24875
|
*/
|
|
24555
|
-
const waitingLines = (wait, now) => [`${wait.check}${paramValues(wait.params)} · waiting, until ${minute(wait.endsAt)} · ${wait.start}`, ...wait.runs.flatMap((run, index) => {
|
|
24876
|
+
const waitingLines$1 = (wait, now) => [`${wait.check}${paramValues(wait.params)} · waiting, until ${minute(wait.endsAt)} · ${wait.start}`, ...wait.runs.flatMap((run, index) => {
|
|
24556
24877
|
const last = index === wait.runs.length - 1;
|
|
24557
24878
|
const under = last ? " " : "│ ";
|
|
24558
24879
|
return [`${last ? "└─" : "├─"} ${run.title ?? run.workflow} · ${waitedText[run.state]} · ${run.workflow}.ts`, ...run.leaves.map((leaf, at) => `${under}${at === run.leaves.length - 1 ? "└─" : "├─"} ${leafText(leaf, now)}`)];
|
|
@@ -25026,7 +25347,7 @@ const isPlainJson = (value, depth = 0) => {
|
|
|
25026
25347
|
const proto = Object.getPrototypeOf(value);
|
|
25027
25348
|
return (proto === Object.prototype || proto === null) && Object.values(value).every(inner);
|
|
25028
25349
|
};
|
|
25029
|
-
const decodeAnswer = Schema.decodeUnknownOption(CheckRunAnswer);
|
|
25350
|
+
const decodeAnswer$1 = Schema.decodeUnknownOption(CheckRunAnswer);
|
|
25030
25351
|
const encodeJson$1 = Schema.encodeSync(Schema.fromJsonString(Schema.Json));
|
|
25031
25352
|
/**
|
|
25032
25353
|
* A check's bundle, written for `import()` under a name of its code's and this process's, in the
|
|
@@ -25167,7 +25488,7 @@ const runPart = Effect.fnUntraced(function* (own, part) {
|
|
|
25167
25488
|
error: "timeout",
|
|
25168
25489
|
why: `It ran past its timeout, ${part.timeoutSeconds} seconds`
|
|
25169
25490
|
}),
|
|
25170
|
-
onSome: (answer) => isPlainJson(answer, 0) ? Option.getOrElse(decodeAnswer(answer), () => ({
|
|
25491
|
+
onSome: (answer) => isPlainJson(answer, 0) ? Option.getOrElse(decodeAnswer$1(answer), () => ({
|
|
25171
25492
|
error: "invalid output",
|
|
25172
25493
|
why: "Its answer isn't one a check gives"
|
|
25173
25494
|
})) : {
|
|
@@ -25364,7 +25685,7 @@ const runLocally = Effect.fnUntraced(function* (folder, check, named, asJson) {
|
|
|
25364
25685
|
if (Option.isNone(thread)) return yield* sayFailed("Name the thread with --thread owner/project#number.");
|
|
25365
25686
|
const root = yield* gitIn(folder, ["rev-parse", "--show-toplevel"]);
|
|
25366
25687
|
if (Option.isNone(root) || root.value === "") return yield* sayFailed("Git couldn’t find this checkout’s root.");
|
|
25367
|
-
const session = yield* (yield* SessionToken).current.pipe(Effect.catchTag("KeychainError", (failed) => sayFailed(failed.nextStep)));
|
|
25688
|
+
const session = yield* (yield* SessionToken).current.pipe(Effect.catchTag(["KeychainError", "NoSession"], (failed) => sayFailed(failed.nextStep)));
|
|
25368
25689
|
if (Option.isSome(session) && Option.isSome(named.agent)) return yield* sayFailed("A session’s local run is signed by the session: leave out --agent.");
|
|
25369
25690
|
if (Option.isNone(session) && Option.isNone(named.agent)) return yield* sayFailed("Outside a session, name the agent this machine is attached as, with --agent owner/handle: a local run is signed by the attachment it ran on.");
|
|
25370
25691
|
const attachment = Option.isSome(named.agent) ? { attachment: yield* namedAttachment(named.agent.value, Option.none(), true) } : {};
|
|
@@ -25462,7 +25783,7 @@ const nextStepsFor = (place, wait) => `Wait on it with ref0 runs waiting ${wait.
|
|
|
25462
25783
|
/** Says a waiting start's tree on stderr, as it stands now. */
|
|
25463
25784
|
const sayTree = Effect.fnUntraced(function* (wait) {
|
|
25464
25785
|
const now = DateTime.makeUnsafe(yield* Clock.currentTimeMillis);
|
|
25465
|
-
yield* Console.error(printable(waitingLines(wait, now)));
|
|
25786
|
+
yield* Console.error(printable(waitingLines$1(wait, now)));
|
|
25466
25787
|
});
|
|
25467
25788
|
/**
|
|
25468
25789
|
* Waits on a start that waits until it goes on or ends, or 9 minutes pass: what it got, printed
|
|
@@ -25609,7 +25930,7 @@ const run = Command.make("run", {
|
|
|
25609
25930
|
value
|
|
25610
25931
|
}, asJson, rerunCommand);
|
|
25611
25932
|
const now = DateTime.makeUnsafe(yield* Clock.currentTimeMillis);
|
|
25612
|
-
yield* printAnswer(answer.json, asJson, `${waitingLines(value.waiting, now)}\n${nextStepsFor(place, value.waiting)}`);
|
|
25933
|
+
yield* printAnswer(answer.json, asJson, `${waitingLines$1(value.waiting, now)}\n${nextStepsFor(place, value.waiting)}`);
|
|
25613
25934
|
}).pipe(Effect.catchTag("CallFailed", failedWith$13))).pipe(Command.withDescription("Run a check on main, or on a thread’s head: a run it ran already is answered, not run again, unless the check says reuse: false. A start its check’s start rule makes wait shows what it waits on, and waits up to 9 minutes for it, unless --no-wait. With --local, run a check of the thread’s open version on this machine, and report it"), Command.withShortDescription("Run a check on main or a thread’s head"));
|
|
25614
25935
|
const trace = Command.make("trace", {
|
|
25615
25936
|
run: Argument.String("run").pipe(Argument.withDescription("The run, as checkrun_… or workflowrun_…")),
|
|
@@ -25687,7 +26008,7 @@ const waiting$1 = Command.make("waiting", {
|
|
|
25687
26008
|
value
|
|
25688
26009
|
}, asJson, rerunCommand);
|
|
25689
26010
|
const now = DateTime.makeUnsafe(yield* Clock.currentTimeMillis);
|
|
25690
|
-
yield* printAnswer(answer.json, asJson, `${waitingLines(value.waiting, now)}\n${nextStepsFor(place, value.waiting)}`);
|
|
26011
|
+
yield* printAnswer(answer.json, asJson, `${waitingLines$1(value.waiting, now)}\n${nextStepsFor(place, value.waiting)}`);
|
|
25691
26012
|
}).pipe(Effect.catchTag("CallFailed", failedWith$13))).pipe(Command.withDescription("A start of yours its check’s start rule made wait: what it waits on, as a tree, waiting up to 9 minutes for it to go on or end, unless --no-wait"), Command.withShortDescription("Show what a waiting start waits on"));
|
|
25692
26013
|
/** What a cancel did, in words. */
|
|
25693
26014
|
const cancelledText = {
|
|
@@ -25806,19 +26127,21 @@ const checksCommands = [Command.make("checks").pipe(Command.withDescription("A r
|
|
|
25806
26127
|
//#endregion
|
|
25807
26128
|
//#region src/gate-lines.ts
|
|
25808
26129
|
/**
|
|
25809
|
-
* A pull request's gate as `ref0 gate` says it: its current version, whether its
|
|
25810
|
-
*
|
|
25811
|
-
*
|
|
25812
|
-
*
|
|
25813
|
-
*
|
|
25814
|
-
*
|
|
25815
|
-
*
|
|
25816
|
-
*
|
|
25817
|
-
*
|
|
25818
|
-
*
|
|
25819
|
-
*
|
|
25820
|
-
*
|
|
25821
|
-
*
|
|
26130
|
+
* A pull request's gate as `ref0 gate` says it: its current version, whether its gate returned its
|
|
26131
|
+
* needs, and once it's open, how many still wait and whose one yes would meet them all; the project
|
|
26132
|
+
* gate's revision and whether it failed; and each need on its own line with where it came from,
|
|
26133
|
+
* ref0's floor or the line of `.ref0/gate.ts`, or of the project gate's, that returned it, with
|
|
26134
|
+
* that line's code, and under it whether it's met, by whose yes, or on whom or what it waits; in an
|
|
26135
|
+
* organization, what one yes gives its giver. A need for one of the project's checks says the
|
|
26136
|
+
* project gate revision whose checks run for it, the version's own. Then what each gate read of the
|
|
26137
|
+
* change, each read's tree, path and the object it found, short. Then its gate decided again in
|
|
26138
|
+
* place under `main`'s `.ref0/`, with `main`'s commit: the needs it held, or those that grew, and
|
|
26139
|
+
* why. Then the additions standing on the pull request now, each with the ID `ref0 pr needs remove`
|
|
26140
|
+
* takes: the version's own say who added its needs when it was made, and one may have gone since.
|
|
26141
|
+
* Then the local runs of its parts' identities, each with its signer; each part that takes local or
|
|
26142
|
+
* needs a yes, with its wrappers, the run and the yeses that meet it or what it waits for; and
|
|
26143
|
+
* last, each signer revoked from answering one of their checks, an attachment or a person by name,
|
|
26144
|
+
* with how an admin restores them.
|
|
25822
26145
|
*/
|
|
25823
26146
|
const fromWords = (from) => {
|
|
25824
26147
|
switch (from.kind) {
|
|
@@ -25849,11 +26172,116 @@ const columns = (lines) => {
|
|
|
25849
26172
|
* version was decided under, whose checks run for it, never `main`'s.
|
|
25850
26173
|
*/
|
|
25851
26174
|
const noteOf = (need, view) => need.kind === "check" && need.source === "project" && view.projectRevision !== void 0 ? ` at gate revision ${view.projectRevision}` : "";
|
|
25852
|
-
const
|
|
26175
|
+
const isPerson = Schema.is(PersonId);
|
|
26176
|
+
/** An owner or a person, by name where it's known. */
|
|
26177
|
+
const nameOf = (id, named) => named.names.get(id) ?? id;
|
|
26178
|
+
/** The admins of an owner, in words: at home, its person alone, by name. */
|
|
26179
|
+
const adminsOf = (owner, named) => isPerson(owner) ? nameOf(owner, named) : `an admin of ${nameOf(owner, named)}`;
|
|
26180
|
+
/** Who answers as a decider, in words: a person by their name, a check by its key. */
|
|
26181
|
+
const whoWords = (decider, named) => {
|
|
26182
|
+
switch (decider.kind) {
|
|
26183
|
+
case "person": return nameOf(decider.id, named);
|
|
26184
|
+
case "role": return adminsOf(decider.owner, named);
|
|
26185
|
+
case "permission": return adminsOf(named.owner, named);
|
|
26186
|
+
case "check": return checkKey(decider);
|
|
26187
|
+
case "agent": return decider.id;
|
|
26188
|
+
case "override": return whoWords(decider.by, named);
|
|
26189
|
+
}
|
|
26190
|
+
};
|
|
26191
|
+
/** Alternatives in words, each once: `pip or an admin of acme`. */
|
|
26192
|
+
const anyOf = (deciders, named) => [...new Set(deciders.map((each) => whoWords(each, named)))].join(" or ");
|
|
26193
|
+
/** A yes that met a need, in words: whose, or the check that passed or was overridden. */
|
|
26194
|
+
const needYesWords = (yes, named) => {
|
|
26195
|
+
if (yes.decider.kind === "check") return `${checkKey(yes.decider)} ${yes.overridden === true ? "overridden" : "passed"}`;
|
|
26196
|
+
if (yes.by === void 0) return `${whoWords(yes.decider, named)}’s yes`;
|
|
26197
|
+
const by = nameOf(yes.by, named);
|
|
26198
|
+
return yes.decider.kind === "person" ? `${by}’s yes` : `${by}’s yes, as an admin`;
|
|
26199
|
+
};
|
|
26200
|
+
/** An override left of a check, in words: whose yes overrides which of its noes. */
|
|
26201
|
+
const overrideWords = (check, left, named) => `a yes from ${whoWords(left.by, named)} to override ${left.of === "failed run" ? `${check}’s failed run` : `${check} not running`}`;
|
|
26202
|
+
/**
|
|
26203
|
+
* One decider a need waits on, in words: a check to pass, or its parts when the need wraps it,
|
|
26204
|
+
* or, once ref0 said no to it, every override left.
|
|
26205
|
+
*/
|
|
26206
|
+
const waitWords = (wait, need, named) => {
|
|
26207
|
+
if (wait.decider.kind !== "check") return whoWords(wait.decider, named);
|
|
26208
|
+
const check = checkKey(wait.decider);
|
|
26209
|
+
if (wait.overrides === void 0) return need.kind === "check" && (need.local === true || need.passkey !== void 0) ? `${check}’s parts, as below` : `${check} to pass`;
|
|
26210
|
+
if (wait.overrides.length === 0) return `${check}, which ref0 said no to`;
|
|
26211
|
+
return [...new Set(wait.overrides.map((left) => overrideWords(check, left, named)))].join(", and ");
|
|
26212
|
+
};
|
|
26213
|
+
/** The decider keys whose answers meet a need, for telling two needs met by the same yes. */
|
|
26214
|
+
const yesKey = (standing) => {
|
|
26215
|
+
if (standing.state === "met" && "by" in standing && standing.by.every(({ decider }) => decider.kind !== "check")) return standing.by.map(({ decider }) => deciderKey(decider)).join(" ");
|
|
26216
|
+
if (standing.state === "waiting" && "on" in standing && !standing.all) return standing.on.map(({ decider }) => deciderKey(decider)).join(" ");
|
|
26217
|
+
};
|
|
26218
|
+
/** How a need stands, in words. */
|
|
26219
|
+
const standingWords = (standing, need, named) => {
|
|
26220
|
+
switch (standing.state) {
|
|
26221
|
+
case "refused": return `refused: ${nameOf(standing.by, named)} said no, which decides the gate no`;
|
|
26222
|
+
case "met": {
|
|
26223
|
+
if ("as" in standing) return standing.as === "no checks" ? "met: no check runs on this change" : "met: the merge queue’s tip runs it, and main moves only once it passes there";
|
|
26224
|
+
const people = /* @__PURE__ */ new Map();
|
|
26225
|
+
for (const yes of standing.by) {
|
|
26226
|
+
const who = yes.by ?? deciderKey(yes.decider);
|
|
26227
|
+
if (!people.has(who)) people.set(who, yes);
|
|
26228
|
+
}
|
|
26229
|
+
const yeses = [...new Set([...people.values()].map((yes) => needYesWords(yes, named)))];
|
|
26230
|
+
return standing.by.some(({ decider }) => decider.kind === "check") ? `met: ${yeses.join(", ")}` : `met by ${yeses.join(" and ")}`;
|
|
26231
|
+
}
|
|
26232
|
+
case "waiting": {
|
|
26233
|
+
if ("for" in standing) return "waiting for ref0 to read which of main’s checks run on this change";
|
|
26234
|
+
if (standing.all) return `waiting for ${standing.on.map((wait) => waitWords(wait, need, named)).join(", and ")}`;
|
|
26235
|
+
const asked = `a yes from ${anyOf(standing.on.map(({ decider }) => decider), named)}`;
|
|
26236
|
+
return need.kind === "check" || need.kind === "checks" ? `waiting on ${asked}: no run can meet it before main moves` : `waiting on ${asked}`;
|
|
26237
|
+
}
|
|
26238
|
+
}
|
|
26239
|
+
};
|
|
26240
|
+
/**
|
|
26241
|
+
* Who one yes would meet a waiting need with, in words, each alone: anyone it asks a yes of, or
|
|
26242
|
+
* the one answerer of every override it waits on; none for a check to pass.
|
|
26243
|
+
*/
|
|
26244
|
+
const meetersOf = (standing, named) => {
|
|
26245
|
+
if (standing.state !== "waiting" || !("on" in standing)) return [];
|
|
26246
|
+
if (!standing.all) return standing.on.map(({ decider }) => whoWords(decider, named));
|
|
26247
|
+
const answerers = new Set(standing.on.flatMap(({ overrides }) => overrides === void 0 || overrides.length === 0 ? [""] : overrides.map((left) => whoWords(left.by, named))));
|
|
26248
|
+
return answerers.size === 1 && !answerers.has("") ? [...answerers] : [];
|
|
26249
|
+
};
|
|
26250
|
+
/** An open gate's standings, or none until every need has one. */
|
|
26251
|
+
const standingsOf = (needs) => {
|
|
26252
|
+
const standings = needs.flatMap(({ standing }) => standing === void 0 ? [] : [standing]);
|
|
26253
|
+
return standings.length === 0 || standings.length < needs.length ? void 0 : standings;
|
|
26254
|
+
};
|
|
26255
|
+
/**
|
|
26256
|
+
* Where an open gate's needs stand, after its state: each met; refused, by whom; or how many
|
|
26257
|
+
* still wait, and whose one yes would meet them all, since one yes answers every decider its
|
|
26258
|
+
* person may answer as.
|
|
26259
|
+
*/
|
|
26260
|
+
const standsWords = (needs, named) => {
|
|
26261
|
+
const standings = standingsOf(needs);
|
|
26262
|
+
if (standings === void 0) return "";
|
|
26263
|
+
const refused = standings.find((each) => each.state === "refused");
|
|
26264
|
+
if (refused !== void 0 && refused.state === "refused") return `; ${nameOf(refused.by, named)} said no, which decides it no`;
|
|
26265
|
+
const waiting = standings.filter((each) => each.state === "waiting");
|
|
26266
|
+
if (waiting.length === 0) return "; each need is met";
|
|
26267
|
+
const [first, ...rest] = waiting.map((each) => meetersOf(each, named));
|
|
26268
|
+
const common = (first ?? []).filter((who) => rest.every((each) => each.includes(who)));
|
|
26269
|
+
const still = waiting.length === 1 ? "1 still waits" : `${waiting.length} still wait`;
|
|
26270
|
+
if (common.length === 0) return `; ${still}`;
|
|
26271
|
+
return `; ${still}, and one yes from ${[...new Set(common)].join(" or ")} meets ${waiting.length === 1 ? "it" : "them all"}`;
|
|
26272
|
+
};
|
|
26273
|
+
/**
|
|
26274
|
+
* In an organization, what one yes gives, under the needs, while one waits on a yes: every
|
|
26275
|
+
* approval and override its giver may give, so an admin's meets every need an admin may meet.
|
|
26276
|
+
*/
|
|
26277
|
+
const oneYesLines = (view, named) => {
|
|
26278
|
+
return (standingsOf(view.needs) ?? []).some((each) => each.state === "waiting" && "on" in each && each.on.some(({ decider, overrides }) => decider.kind !== "check" || overrides !== void 0)) && !isPerson(view.owner) ? [` one yes to this gate gives every approval and override above its giver may give, as themselves and as ${adminsOf(view.owner, named)}`] : [];
|
|
26279
|
+
};
|
|
26280
|
+
const stateWords = (view, named) => {
|
|
25853
26281
|
switch (view.state) {
|
|
25854
|
-
case "pending": return "its gate hasn’t
|
|
25855
|
-
case "answered": return
|
|
25856
|
-
case "failed": return `its gate failed: ${view.why ?? "ref0 couldn’t run it"}`;
|
|
26282
|
+
case "pending": return "its gate hasn’t returned its needs yet";
|
|
26283
|
+
case "answered": return `its gate returned its needs${standsWords(view.needs, named)}`;
|
|
26284
|
+
case "failed": return `its gate failed: ${view.why ?? "ref0 couldn’t run it"}${standsWords(view.needs, named)}`;
|
|
25857
26285
|
}
|
|
25858
26286
|
};
|
|
25859
26287
|
/** The project gate's line, under the head: its revision, and why it failed. */
|
|
@@ -25861,7 +26289,7 @@ const projectWords$1 = (view) => {
|
|
|
25861
26289
|
const project = view.projectGate;
|
|
25862
26290
|
if (project === void 0) return [];
|
|
25863
26291
|
const at = ` project gate, revision ${project.revision}`;
|
|
25864
|
-
return [project.state === "failed" ? `${at}: failed: ${project.why ?? "ref0 couldn’t run it"}, so an admin answers it` : `${at}: ${project.state}`];
|
|
26292
|
+
return [project.state === "failed" ? `${at}: failed: ${project.why ?? "ref0 couldn’t run it"}, so an admin answers it` : `${at}: ${project.state === "pending" ? "hasn’t returned its needs yet" : "returned its needs"}`];
|
|
25865
26293
|
};
|
|
25866
26294
|
/**
|
|
25867
26295
|
* What a gate read of the change, under `head`: each read's tree, its path and the object it
|
|
@@ -25876,6 +26304,23 @@ const needLines = (needs, view) => columns(needs.map(({ need, from }) => ({
|
|
|
25876
26304
|
need: spellNeed(need),
|
|
25877
26305
|
from: `${from.map(fromWords).join("; ")}${noteOf(need, view)}`
|
|
25878
26306
|
})));
|
|
26307
|
+
/**
|
|
26308
|
+
* The version's needs, each with how it stands on the line under it, once its gate is open: a
|
|
26309
|
+
* need met by, or waiting on, the same yes as one above says so.
|
|
26310
|
+
*/
|
|
26311
|
+
const standingLines = (view, named) => {
|
|
26312
|
+
const lines = needLines(view.needs, view);
|
|
26313
|
+
const seen = /* @__PURE__ */ new Map();
|
|
26314
|
+
return view.needs.flatMap(({ need, standing }, i) => {
|
|
26315
|
+
const line = lines[i] ?? "";
|
|
26316
|
+
if (standing === void 0) return [line];
|
|
26317
|
+
const key = yesKey(standing);
|
|
26318
|
+
const same = key === void 0 ? void 0 : seen.get(key);
|
|
26319
|
+
if (key !== void 0 && same === void 0) seen.set(key, need);
|
|
26320
|
+
const also = same === void 0 ? "" : `, the same yes as ${spellNeed(same)}`;
|
|
26321
|
+
return [line, ` ${standingWords(standing, need, named)}${also}`];
|
|
26322
|
+
});
|
|
26323
|
+
};
|
|
25879
26324
|
const grewWords = (grew) => {
|
|
25880
26325
|
const why = grew.why ?? "ref0 couldn’t say why";
|
|
25881
26326
|
switch (grew.kind) {
|
|
@@ -25887,7 +26332,7 @@ const grewWords = (grew) => {
|
|
|
25887
26332
|
};
|
|
25888
26333
|
const inPlaceLines = (inPlace, view) => {
|
|
25889
26334
|
const head = `under main’s .ref0/, at ${inPlace.main.slice(0, 7)}:`;
|
|
25890
|
-
if (inPlace.state === "pending") return [`${head} its gate there hasn’t
|
|
26335
|
+
if (inPlace.state === "pending") return [`${head} its gate there hasn’t returned its needs yet`];
|
|
25891
26336
|
const reads = readLines("its gate there read:", inPlace.reads ?? []);
|
|
25892
26337
|
if (inPlace.state === "held" || inPlace.grew === void 0) return [
|
|
25893
26338
|
`${head} ${inPlace.state}`,
|
|
@@ -25961,7 +26406,11 @@ const wrappedPartLines = (parts) => parts.length === 0 ? [] : ["parts that take
|
|
|
25961
26406
|
const revokedSignerLines = (one) => one.kind === "attachment" ? [` ${one.check} attachment ${one.attachment.id} of ${one.attachment.person}, since ${DateTime.formatIso(one.at)}: ref0 failed local run ${one.signed}'s identity twice, the second in run ${one.failed}`, ` an admin restores it: ref0 checks signers restore ${one.check} --attachment ${one.attachment.id}`] : [` ${one.check} ${one.person}, since ${DateTime.formatIso(one.at)}: ref0 failed the identity of run ${one.signed}, which they said yes over, twice, the second in run ${one.failed}`, ` an admin restores them: ref0 checks signers restore ${one.check} --person ${one.person}`];
|
|
25962
26407
|
const revokedLines = (revoked) => revoked.length === 0 ? [] : ["signers revoked:", ...revoked.flatMap(revokedSignerLines)];
|
|
25963
26408
|
const gateLines = (view, repo) => {
|
|
25964
|
-
const
|
|
26409
|
+
const named = {
|
|
26410
|
+
names: new Map((view.names ?? []).map(({ owner, name }) => [owner, name])),
|
|
26411
|
+
owner: view.owner
|
|
26412
|
+
};
|
|
26413
|
+
const head = `${repo}#${view.pullRequest}, version ${view.version}: ${stateWords(view, named)}`;
|
|
25965
26414
|
const inPlace = view.inPlace === void 0 ? [] : inPlaceLines(view.inPlace, view);
|
|
25966
26415
|
const added = view.added.map(({ need, from }) => ({
|
|
25967
26416
|
need: spellNeed(need),
|
|
@@ -25973,7 +26422,8 @@ const gateLines = (view, repo) => {
|
|
|
25973
26422
|
return [
|
|
25974
26423
|
head,
|
|
25975
26424
|
...projectWords$1(view),
|
|
25976
|
-
...
|
|
26425
|
+
...standingLines(view, named),
|
|
26426
|
+
...oneYesLines(view, named),
|
|
25977
26427
|
...readLines("its gate read:", view.reads),
|
|
25978
26428
|
...readLines("its project gate read:", view.projectGate?.reads ?? []),
|
|
25979
26429
|
...inPlace,
|
|
@@ -26028,7 +26478,7 @@ const gateCommands = [Command.make("gate", {
|
|
|
26028
26478
|
}
|
|
26029
26479
|
});
|
|
26030
26480
|
yield* printAnswer(answer.json, asJson, gateLines(answer.value, repo));
|
|
26031
|
-
}).pipe(Effect.catchTag("CallFailed", failedWith$11))).pipe(Command.withDescription("A pull request’s needs, each with where it came from
|
|
26481
|
+
}).pipe(Effect.catchTag("CallFailed", failedWith$11))).pipe(Command.withDescription("A pull request’s needs, each with where it came from, ref0’s floor or a line of .ref0/gate.ts, and whether it’s met or on whom it waits; with --project, the project gate"), Command.withShortDescription("Show a pull request’s or project’s gate"))];
|
|
26032
26482
|
//#endregion
|
|
26033
26483
|
//#region src/clone.ts
|
|
26034
26484
|
/** The remote a clone pushes to: its thread's fork path, named as the thread is. */
|
|
@@ -26143,6 +26593,44 @@ const answerGit = Effect.fnUntraced(function* (operation, input) {
|
|
|
26143
26593
|
return Option.map(token, credentialLines);
|
|
26144
26594
|
});
|
|
26145
26595
|
//#endregion
|
|
26596
|
+
//#region src/own-session.ts
|
|
26597
|
+
/**
|
|
26598
|
+
* A command only an agent's session runs, on a repo of the session's project. Its endpoints take
|
|
26599
|
+
* the repo's name alone and find it in that project, so the command takes the name alone too: an
|
|
26600
|
+
* owner it couldn't check could name another project's repo, and the session's of that name
|
|
26601
|
+
* would be changed in its place.
|
|
26602
|
+
*/
|
|
26603
|
+
const decodeRepoName$1 = Schema.decodeUnknownOption(RepoName);
|
|
26604
|
+
/**
|
|
26605
|
+
* The token of the agent's session a command runs in. Outside one it says `outside`, what the
|
|
26606
|
+
* command is for; in a harness session whose ref0 session never started, why and what to do.
|
|
26607
|
+
* Either way it fails before anything's sent.
|
|
26608
|
+
*/
|
|
26609
|
+
const ownSession = Effect.fnUntraced(function* (outside) {
|
|
26610
|
+
const token = yield* (yield* SessionToken).current.pipe(Effect.catchTag("NoSession", (none) => sayFailed(none.nextStep)));
|
|
26611
|
+
if (Option.isNone(token)) return yield* sayFailed(outside);
|
|
26612
|
+
return token.value;
|
|
26613
|
+
});
|
|
26614
|
+
/** The repo of a session's command, by its name alone. */
|
|
26615
|
+
const sessionRepoFlag = Flag.String("repo").pipe(Flag.withDescription("A repo of your session’s project, by its name alone, as shop; if not named, the one this checkout is of"), Flag.optional);
|
|
26616
|
+
/**
|
|
26617
|
+
* The session a command runs as, and the name of the repo it acts on: the one named, or this
|
|
26618
|
+
* checkout's. Outside a session it says `outside`, what the command is for, and fails before
|
|
26619
|
+
* anything's sent.
|
|
26620
|
+
*/
|
|
26621
|
+
const sessionRepo = Effect.fnUntraced(function* (named, outside) {
|
|
26622
|
+
yield* ownSession(outside);
|
|
26623
|
+
const checkout = yield* Checkout;
|
|
26624
|
+
const repo = Option.isSome(named) ? decodeRepoName$1(named.value) : yield* checkout.repoAt(".");
|
|
26625
|
+
if (Option.isNone(repo)) return yield* sayFailed(Option.isSome(named) ? "Name a repo of your session’s project by its name alone, as --repo shop." : "Name the repo with --repo and its name, as --repo shop, or run this in its checkout.");
|
|
26626
|
+
const session = (yield* callEndpoint({ endpoint: "me.get" })).value.session?.session;
|
|
26627
|
+
if (session === void 0) return yield* sayFailed("This token isn’t a session’s.");
|
|
26628
|
+
return {
|
|
26629
|
+
session,
|
|
26630
|
+
repo: repo.value
|
|
26631
|
+
};
|
|
26632
|
+
});
|
|
26633
|
+
//#endregion
|
|
26146
26634
|
//#region src/sync-history.ts
|
|
26147
26635
|
const decodePlace = Schema.decodeUnknownEffect(Schema.Struct({
|
|
26148
26636
|
owner: OwnerRef,
|
|
@@ -26198,7 +26686,7 @@ const trimShallow = Effect.fnUntraced(function* (gitDir) {
|
|
|
26198
26686
|
*/
|
|
26199
26687
|
const loadSyncHistory = Effect.fnUntraced(function* (gitDir, repo) {
|
|
26200
26688
|
const config = yield* Ref0Config;
|
|
26201
|
-
const token = yield* (yield* SessionToken).current;
|
|
26689
|
+
const token = yield* (yield* SessionToken).current.pipe(Effect.catchTag("NoSession", () => Effect.succeedNone));
|
|
26202
26690
|
if (Option.isNone(token)) return yield* new HistoryNotLoaded({ why: "this terminal has no session to load it as" });
|
|
26203
26691
|
const fs = yield* FileSystem.FileSystem;
|
|
26204
26692
|
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
|
|
@@ -26371,9 +26859,8 @@ const gitCommands = [
|
|
|
26371
26859
|
json
|
|
26372
26860
|
}, ({ repo: written, directory, json: asJson }) => Effect.gen(function* () {
|
|
26373
26861
|
const { owner, repo: name } = repoParts(written);
|
|
26374
|
-
const session = yield* SessionToken;
|
|
26375
26862
|
const config = yield* Ref0Config;
|
|
26376
|
-
|
|
26863
|
+
yield* ownSession("ref0 clone runs only in an agent’s ref0 session, which pushes to a fork of its own, and none runs here. In Claude Code or Codex, one starts at each prompt once ref0 connect has connected its config folder.");
|
|
26377
26864
|
const ours = (yield* callEndpoint({ endpoint: "me.get" })).value.session?.session;
|
|
26378
26865
|
if (ours === void 0) return yield* sayFailed("This token isn’t a session’s.");
|
|
26379
26866
|
const fork = yield* callEndpoint({
|
|
@@ -26405,8 +26892,7 @@ const gitCommands = [
|
|
|
26405
26892
|
yield* printAnswer(fork.json, asJson, `Cloned ${owner}/${name} into ${into}. origin is read-only to agents: git push goes to ${forkRemote(fork.value)}, the fork of whichever session on the thread pushes.`);
|
|
26406
26893
|
}).pipe(Effect.catchTag("CallFailed", failedWith$10))).pipe(Command.withDescription("Clone a repo of the session’s project, to push to its fork"), Command.withShortDescription("Clone a repo to work in your session’s fork")),
|
|
26407
26894
|
Command.make("sync", { json }, ({ json: asJson }) => Effect.gen(function* () {
|
|
26408
|
-
|
|
26409
|
-
if (Option.isNone(yield* session.current)) return yield* sayFailed("ref0 sync runs in an agent’s session, in a clone ref0 clone made.");
|
|
26895
|
+
yield* ownSession("ref0 sync runs in an agent’s session, in a clone ref0 clone made.");
|
|
26410
26896
|
const synced = yield* syncBase(".");
|
|
26411
26897
|
if (synced.kind === "refused") return yield* sayFailed(synced.why);
|
|
26412
26898
|
const { on, commit } = synced.base;
|
|
@@ -27442,18 +27928,28 @@ const repoCommands = [Command.make("repos", {
|
|
|
27442
27928
|
//#endregion
|
|
27443
27929
|
//#region src/decision-lines.ts
|
|
27444
27930
|
/** A decision in words, as `ref0 waiting`, `ref0 decision` and `ref0 answer` print it. */
|
|
27445
|
-
/** What a decision is about, in words. */
|
|
27446
|
-
const subjectWords = (subject) => {
|
|
27447
|
-
switch (subject.kind) {
|
|
27448
|
-
case "thread": return subject.thread;
|
|
27449
|
-
case "version": return `version ${subject.version} of pull request ${subject.pullRequest} in ${subject.repo}, from ${subject.thread}`;
|
|
27450
|
-
}
|
|
27451
|
-
};
|
|
27931
|
+
/** What a decision is about, in words: its thread, and a gate's version of its pull request. */
|
|
27932
|
+
const subjectWords = ({ subject, names }) => subject.kind === "version" && names.pullRequest !== void 0 ? `version ${subject.version} of ${names.pullRequest}, from ${names.thread}` : names.thread;
|
|
27452
27933
|
/** A decision in a line. */
|
|
27453
|
-
const decisionLines = (
|
|
27454
|
-
const on = subjectWords(
|
|
27455
|
-
const answers =
|
|
27456
|
-
return
|
|
27934
|
+
const decisionLines = (decision) => {
|
|
27935
|
+
const on = subjectWords(decision);
|
|
27936
|
+
const answers = decision.options.map((option) => option.key).join(" or ");
|
|
27937
|
+
return decision.state === "open" ? `${decision.decision} on ${on}: ${answers}` : `${decision.decision} on ${on}: ${decision.result ?? "no answer"}, ${decision.closedWhy ?? "closed"}`;
|
|
27938
|
+
};
|
|
27939
|
+
const nothing = "Nothing waits on you here.";
|
|
27940
|
+
/**
|
|
27941
|
+
* What waits, as `ref0 waiting` prints it: what waits on the caller, then, for an agent, what
|
|
27942
|
+
* waits on its person, which they answer on their own page.
|
|
27943
|
+
*/
|
|
27944
|
+
const waitingLines = (decisions) => {
|
|
27945
|
+
const yours = decisions.flatMap((decision) => decision.waitsOn === "you" ? [decisionLines(decision)] : []);
|
|
27946
|
+
const theirs = decisions.flatMap((decision) => decision.waitsOn === "your person" ? [` ${decisionLines(decision)}`] : []);
|
|
27947
|
+
if (theirs.length === 0) return yours.length === 0 ? nothing : yours.join("\n");
|
|
27948
|
+
return [
|
|
27949
|
+
...yours,
|
|
27950
|
+
"Waits on your person, under Waiting on you on their page:",
|
|
27951
|
+
...theirs
|
|
27952
|
+
].join("\n");
|
|
27457
27953
|
};
|
|
27458
27954
|
//#endregion
|
|
27459
27955
|
//#region src/commands/plans.ts
|
|
@@ -27647,9 +28143,7 @@ const planCommands = [
|
|
|
27647
28143
|
endpoint: "decisions.waiting",
|
|
27648
28144
|
params: scopeParams(address)
|
|
27649
28145
|
}));
|
|
27650
|
-
|
|
27651
|
-
const words = decisions.length === 0 ? "Nothing waits on you here." : decisions.map((each) => decisionLines(each)).join("\n");
|
|
27652
|
-
yield* printAnswer(answer.json, asJson, words);
|
|
28146
|
+
yield* printAnswer(answer.json, asJson, waitingLines(answer.value.decisions));
|
|
27653
28147
|
}).pipe(Effect.catchTag("CallFailed", (failed) => sayFailed(failed.nextStep)))).pipe(Command.withDescription("What in a project, or in an organization’s org threads, waits on you"), Command.withShortDescription("Show what waits on you")),
|
|
27654
28148
|
Command.make("decision", {
|
|
27655
28149
|
project: project$2,
|
|
@@ -27690,35 +28184,6 @@ const planCommands = [
|
|
|
27690
28184
|
}).pipe(Effect.catchTag("CallFailed", (failed) => sayFailed(failed.nextStep)))).pipe(Command.withDescription("Answer a decision that waits on you"))
|
|
27691
28185
|
];
|
|
27692
28186
|
//#endregion
|
|
27693
|
-
//#region src/own-session.ts
|
|
27694
|
-
/**
|
|
27695
|
-
* A command only an agent's session runs, on a repo of the session's project. Its endpoints take
|
|
27696
|
-
* the repo's name alone and find it in that project, so the command takes the name alone too: an
|
|
27697
|
-
* owner it couldn't check could name another project's repo, and the session's of that name
|
|
27698
|
-
* would be changed in its place.
|
|
27699
|
-
*/
|
|
27700
|
-
const decodeRepoName$1 = Schema.decodeUnknownOption(RepoName);
|
|
27701
|
-
/** The repo of a session's command, by its name alone. */
|
|
27702
|
-
const sessionRepoFlag = Flag.String("repo").pipe(Flag.withDescription("A repo of your session’s project, by its name alone, as shop; if not named, the one this checkout is of"), Flag.optional);
|
|
27703
|
-
/**
|
|
27704
|
-
* The session a command runs as, and the name of the repo it acts on: the one named, or this
|
|
27705
|
-
* checkout's. Outside a session it says `outside`, what the command is for, and fails before
|
|
27706
|
-
* anything's sent.
|
|
27707
|
-
*/
|
|
27708
|
-
const sessionRepo = Effect.fnUntraced(function* (named, outside) {
|
|
27709
|
-
const token = yield* SessionToken;
|
|
27710
|
-
if (Option.isNone(yield* token.current)) return yield* sayFailed(outside);
|
|
27711
|
-
const checkout = yield* Checkout;
|
|
27712
|
-
const repo = Option.isSome(named) ? decodeRepoName$1(named.value) : yield* checkout.repoAt(".");
|
|
27713
|
-
if (Option.isNone(repo)) return yield* sayFailed(Option.isSome(named) ? "Name a repo of your session’s project by its name alone, as --repo shop." : "Name the repo with --repo and its name, as --repo shop, or run this in its checkout.");
|
|
27714
|
-
const session = (yield* callEndpoint({ endpoint: "me.get" })).value.session?.session;
|
|
27715
|
-
if (session === void 0) return yield* sayFailed("This token isn’t a session’s.");
|
|
27716
|
-
return {
|
|
27717
|
-
session,
|
|
27718
|
-
repo: repo.value
|
|
27719
|
-
};
|
|
27720
|
-
});
|
|
27721
|
-
//#endregion
|
|
27722
28187
|
//#region src/pull-request-lines.ts
|
|
27723
28188
|
/**
|
|
27724
28189
|
* Pull requests as `ref0 pr` says them: each one's number, thread, state and current version,
|
|
@@ -28179,8 +28644,7 @@ const taskCommands = [Command.make("tasks", {
|
|
|
28179
28644
|
file: Flag.FileText("file").pipe(Flag.withDescription("The whole list, as JSON: each task’s text, up to 200 characters, and its state, \"to do\", \"in progress\" or \"done\"; at most 50")),
|
|
28180
28645
|
json
|
|
28181
28646
|
}, ({ file, json: asJson }) => Effect.gen(function* () {
|
|
28182
|
-
|
|
28183
|
-
if (Option.isNone(yield* session.current)) return yield* sayFailed("ref0 tasks runs in an agent’s session, which sets its own list.");
|
|
28647
|
+
yield* ownSession("ref0 tasks runs in an agent’s session, which sets its own list.");
|
|
28184
28648
|
const parsed = yield* Effect.result(decodeJsonText(file));
|
|
28185
28649
|
if (Result.isFailure(parsed)) return yield* sayFailed("That list isn’t JSON.");
|
|
28186
28650
|
const checked = yield* Effect.result(checkTasks(parsed.success));
|
|
@@ -28783,9 +29247,8 @@ const waitCommands = [Command.make("wait", {
|
|
|
28783
29247
|
blockers: Flag.Boolean("blockers").pipe(Flag.withDescription("Wait until nothing blocks your session’s thread from merging"), Flag.withDefault(false)),
|
|
28784
29248
|
json
|
|
28785
29249
|
}, ({ blockers, json: asJson }) => Effect.gen(function* () {
|
|
28786
|
-
const session = yield* SessionToken;
|
|
28787
29250
|
if (!blockers) return yield* sayFailed("Say what to wait for: ref0 wait --blockers waits until nothing blocks your thread.");
|
|
28788
|
-
|
|
29251
|
+
yield* ownSession("ref0 wait --blockers runs in an agent’s session: it waits on its own thread.");
|
|
28789
29252
|
yield* waitOnBlockers(asJson);
|
|
28790
29253
|
}).pipe(Effect.catchTag("CallFailed", (failed) => sayFailed(failed.nextStep)))).pipe(Command.withDescription(`Wait until nothing blocks your session’s thread from merging, for 9 minutes at most`), Command.withShortDescription("Wait for your session’s thread to be ready to merge"))];
|
|
28791
29254
|
//#endregion
|
|
@@ -32545,6 +33008,8 @@ function readHook(args) {
|
|
|
32545
33008
|
*/
|
|
32546
33009
|
/** The keychain account a binding's thread is kept under, beside its session token's. */
|
|
32547
33010
|
const threadAccount = (binding) => `${sessionAccount(binding)}/thread`;
|
|
33011
|
+
/** The keychain account a start not yet landed is kept under, beside its session token's. */
|
|
33012
|
+
const pendingAccount = (binding) => `${sessionAccount(binding)}/pending`;
|
|
32548
33013
|
const decodeRequest = Schema.decodeUnknownEffect(StartSessionRequest);
|
|
32549
33014
|
const decodeRequestId = Schema.decodeUnknownOption(RequestId);
|
|
32550
33015
|
/**
|
|
@@ -32584,14 +33049,14 @@ const startHarnessSession = Effect.fnUntraced(function* (input) {
|
|
|
32584
33049
|
const folder = yield* (yield* ConfigFolders).folder(harness);
|
|
32585
33050
|
const grant = yield* hostGrant.get(folder);
|
|
32586
33051
|
if (Option.isNone(grant) || grant.value.harness !== harness) return Option.none();
|
|
32587
|
-
const
|
|
33052
|
+
const pending = pendingAccount(input.binding);
|
|
32588
33053
|
const held = Option.flatMap(yield* keychain.get(sessionAccount(input.binding)), (kept) => parseToken(Redacted.value(kept)));
|
|
32589
|
-
const
|
|
32590
|
-
const { requestId, secret } = Option.isSome(
|
|
33054
|
+
const resent = Option.filter(Option.flatMap(yield* keychain.get(pending), (kept) => decodePending(Redacted.value(kept))), (one) => !Option.exists(held, (parts) => parts.secret === one.secret));
|
|
33055
|
+
const { requestId, secret } = Option.isSome(resent) ? resent.value : {
|
|
32591
33056
|
requestId: yield* newRequestId,
|
|
32592
33057
|
secret: encodeSecret(crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(32)))
|
|
32593
33058
|
};
|
|
32594
|
-
if (Option.isNone(
|
|
33059
|
+
if (Option.isNone(resent)) yield* keychain.set(pending, Redacted.make(`${requestId} ${secret}`));
|
|
32595
33060
|
const hash = yield* hashOf(secret);
|
|
32596
33061
|
const where = input.on.kind === "thread" ? threadParts(input.on.written) : {
|
|
32597
33062
|
owner: grant.value.owner,
|
|
@@ -32621,7 +33086,7 @@ const startHarnessSession = Effect.fnUntraced(function* (input) {
|
|
|
32621
33086
|
const started = yield* (yield* ref0Client(config.api).pipe(Effect.provide(bearerCredential(grant.value.token)))).sessions.start({
|
|
32622
33087
|
headers: { "idempotency-key": requestId },
|
|
32623
33088
|
payload
|
|
32624
|
-
}).pipe(Effect.tapError((error) => Match.value(error).pipe(Match.tag("ProjectNotFound", "ProjectNotReady", "ThreadNotFound", "ThreadIsClosed", "TextRefused", "NotPermitted", "DeliveryOutsideWindow", () => keychain.remove(
|
|
33089
|
+
}).pipe(Effect.tapError((error) => Match.value(error).pipe(Match.tag("ProjectNotFound", "ProjectNotReady", "ThreadNotFound", "ThreadIsClosed", "TextRefused", "NotPermitted", "DeliveryOutsideWindow", () => keychain.remove(pending)), Match.orElse(() => Effect.void))));
|
|
32625
33090
|
const thread = input.on.kind === "thread" ? input.on.written : writtenThread(grant.value.owner, {
|
|
32626
33091
|
kind: "project",
|
|
32627
33092
|
name: newThreadProject
|
|
@@ -32629,7 +33094,7 @@ const startHarnessSession = Effect.fnUntraced(function* (input) {
|
|
|
32629
33094
|
yield* keychain.remove(gitTokenAccount(input.binding)).pipe(Effect.ignore);
|
|
32630
33095
|
yield* keychain.set(sessionAccount(input.binding), Redacted.make(formatToken(started.credential.person, started.credential.id, secret)));
|
|
32631
33096
|
yield* keychain.set(threadAccount(input.binding), Redacted.make(thread)).pipe(Effect.ignore);
|
|
32632
|
-
yield* keychain.remove(
|
|
33097
|
+
yield* keychain.remove(pending).pipe(Effect.ignore);
|
|
32633
33098
|
return Option.some(started);
|
|
32634
33099
|
});
|
|
32635
33100
|
//#endregion
|
|
@@ -32691,6 +33156,11 @@ const ownerOf = (bound) => ({
|
|
|
32691
33156
|
const decodeWrittenThread = Schema.decodeUnknownOption(WrittenThread);
|
|
32692
33157
|
/** How long a harness session with no token is left before the keychain is asked again. */
|
|
32693
33158
|
const unboundMillis = 3e4;
|
|
33159
|
+
/**
|
|
33160
|
+
* How long a prompt's start may run on past its hook's deadline before it's given up, and a later
|
|
33161
|
+
* prompt sends it again, as itself: well past the slowest start seen on staging, 4.5 seconds.
|
|
33162
|
+
*/
|
|
33163
|
+
const startBound = "20 seconds";
|
|
32694
33164
|
/** How often `ref0 mcp` renews its session's 30-minute lease while its harness process lives. */
|
|
32695
33165
|
const renewEvery = "10 minutes";
|
|
32696
33166
|
/** How soon a renewal that failed is tried again, well inside the lease. */
|
|
@@ -32706,6 +33176,7 @@ const sessionBinding = Effect.fnUntraced(function* (edges) {
|
|
|
32706
33176
|
const hints = yield* LiveHints;
|
|
32707
33177
|
const context = yield* Effect.context();
|
|
32708
33178
|
const owned = yield* Ref.make(Option.none());
|
|
33179
|
+
const onTheWay = yield* Ref.make(Option.none());
|
|
32709
33180
|
const listen = Effect.fnUntraced(function* (bound) {
|
|
32710
33181
|
const client = yield* clientOf(bound.token);
|
|
32711
33182
|
let pause = 1;
|
|
@@ -32801,17 +33272,31 @@ const sessionBinding = Effect.fnUntraced(function* (edges) {
|
|
|
32801
33272
|
if (Option.isNone(started)) return Option.none();
|
|
32802
33273
|
return yield* keychain.get(sessionAccount(binding)).pipe(Effect.orElseSucceed(() => Option.none()));
|
|
32803
33274
|
});
|
|
33275
|
+
const startedFor = Effect.fnUntraced(function* (binding, hook, starting) {
|
|
33276
|
+
const sent = yield* Ref.get(onTheWay);
|
|
33277
|
+
if (Option.isSome(sent) && sent.value.harnessSession === binding.harnessSession) return yield* Deferred.await(sent.value.done);
|
|
33278
|
+
if (!starting) return Option.none();
|
|
33279
|
+
const done = yield* Deferred.make();
|
|
33280
|
+
const mine = Option.some({
|
|
33281
|
+
harnessSession: binding.harnessSession,
|
|
33282
|
+
done
|
|
33283
|
+
});
|
|
33284
|
+
yield* Ref.set(onTheWay, mine);
|
|
33285
|
+
yield* Effect.forkIn(startAtPrompt(binding, hook).pipe(Effect.timeoutOption(startBound), Effect.map(Option.flatten), Effect.orElseSucceed(() => Option.none()), Effect.ensuring(Ref.update(onTheWay, (now) => now === mine ? Option.none() : now)), Effect.onExit((exit) => Deferred.done(done, exit))), scope);
|
|
33286
|
+
return yield* Deferred.await(done);
|
|
33287
|
+
});
|
|
32804
33288
|
const bind = Effect.fnUntraced(function* (hook) {
|
|
32805
33289
|
const now = yield* Clock.currentTimeMillis;
|
|
32806
33290
|
const current = yield* Ref.get(state);
|
|
32807
33291
|
if (current.kind === "bound" && current.bound.binding.harnessSession === hook.harnessSession) return Option.some(current.bound);
|
|
32808
33292
|
const starting = hook.event === "UserPromptSubmit" && Option.isSome(agent);
|
|
33293
|
+
const sent = Option.exists(yield* Ref.get(onTheWay), (one) => one.harnessSession === hook.harnessSession);
|
|
32809
33294
|
const over = current.kind === "ended" && current.harnessSession === hook.harnessSession;
|
|
32810
33295
|
const waiting = current.kind === "unbound" && now < current.until && !starting;
|
|
32811
|
-
if (over && !starting || waiting) return Option.none();
|
|
33296
|
+
if ((over && !starting || waiting) && !sent) return Option.none();
|
|
32812
33297
|
const binding = yield* bindingForServer(hook.harnessSession);
|
|
32813
33298
|
const held = Option.isNone(binding) ? Option.none() : yield* heldFor(binding.value, over);
|
|
32814
|
-
const token = Option.isSome(held) || Option.isNone(binding)
|
|
33299
|
+
const token = Option.isSome(held) || Option.isNone(binding) ? held : yield* startedFor(binding.value, hook, starting);
|
|
32815
33300
|
if (Option.isNone(binding) || Option.isNone(token)) {
|
|
32816
33301
|
if (!over) yield* Ref.set(state, {
|
|
32817
33302
|
kind: "unbound",
|
|
@@ -33101,7 +33586,7 @@ const toRead = (hook, ownHook) => {
|
|
|
33101
33586
|
const sessionTurns = Effect.fnUntraced(function* (edges) {
|
|
33102
33587
|
const transcripts = yield* Transcripts;
|
|
33103
33588
|
const turn = yield* Ref.make(Option.none());
|
|
33104
|
-
const trackTurn = Effect.fnUntraced(function* (client, bound, hook, deadline) {
|
|
33589
|
+
const trackTurn = Effect.fnUntraced(function* (client, bound, hook, deadline, late = false) {
|
|
33105
33590
|
const checkout = yield* Checkout;
|
|
33106
33591
|
const session = bound.session;
|
|
33107
33592
|
const owner = ownerOf(bound);
|
|
@@ -33115,6 +33600,10 @@ const sessionTurns = Effect.fnUntraced(function* (edges) {
|
|
|
33115
33600
|
const prompted = isNew ? hook.transcript : void 0;
|
|
33116
33601
|
if (prompted !== void 0) yield* transcripts.begin(owner, harness, prompted, harnessTurn);
|
|
33117
33602
|
const ending = prompted === void 0 ? void 0 : yield* transcripts.read(owner, harness, [prompted], { until: harnessTurn });
|
|
33603
|
+
const fresh = late && harness === "claude-code" ? {
|
|
33604
|
+
...nothingSeen,
|
|
33605
|
+
tokens: readFailed(nothingSeen.tokens)
|
|
33606
|
+
} : nothingSeen;
|
|
33118
33607
|
const tracked = isNew ? Option.some({
|
|
33119
33608
|
kind: "starting",
|
|
33120
33609
|
harnessTurn,
|
|
@@ -33125,7 +33614,7 @@ const sessionTurns = Effect.fnUntraced(function* (edges) {
|
|
|
33125
33614
|
tokens: addRead(now.seen.tokens, ending, true)
|
|
33126
33615
|
}
|
|
33127
33616
|
}) : now.previous),
|
|
33128
|
-
seen:
|
|
33617
|
+
seen: fresh
|
|
33129
33618
|
}) : before;
|
|
33130
33619
|
if (Option.isNone(tracked)) return;
|
|
33131
33620
|
const ownHook = hook.harnessTurn === tracked.value.harnessTurn;
|
|
@@ -33201,6 +33690,7 @@ const sessionTurns = Effect.fnUntraced(function* (edges) {
|
|
|
33201
33690
|
return {
|
|
33202
33691
|
now: Ref.get(turn),
|
|
33203
33692
|
track: trackTurn,
|
|
33693
|
+
trackLate: (client, bound, prompt, deadline) => trackTurn(client, bound, prompt, deadline, true),
|
|
33204
33694
|
fresh: Ref.set(turn, Option.none())
|
|
33205
33695
|
};
|
|
33206
33696
|
});
|
|
@@ -33337,6 +33827,7 @@ var HarnessSession = class HarnessSession extends Context.Service()("ref0/cli/Ha
|
|
|
33337
33827
|
const scope = yield* Scope.Scope;
|
|
33338
33828
|
const tasks = yield* sessionTasks;
|
|
33339
33829
|
const turns = yield* sessionTurns({ tasksLanded: tasks.landed });
|
|
33830
|
+
const prompted = yield* Ref.make(Option.none());
|
|
33340
33831
|
const edits = yield* sessionEdits({ turn: turns.now });
|
|
33341
33832
|
const delivery = yield* sessionDelivery;
|
|
33342
33833
|
const wake = yield* sessionWake({
|
|
@@ -33394,9 +33885,13 @@ var HarnessSession = class HarnessSession extends Context.Service()("ref0/cli/Ha
|
|
|
33394
33885
|
lastLook
|
|
33395
33886
|
});
|
|
33396
33887
|
const deliver = Effect.fnUntraced(function* (hook, deadline) {
|
|
33888
|
+
if (hook.event === "UserPromptSubmit") yield* Ref.set(prompted, Option.some(hook));
|
|
33397
33889
|
const bound = yield* binding.bind(hook);
|
|
33398
33890
|
if (Option.isNone(bound)) return Option.none();
|
|
33399
33891
|
const client = yield* clientOf(bound.value.token);
|
|
33892
|
+
const prompt = Option.filter(yield* Ref.get(prompted), (one) => one.harnessSession === hook.harnessSession && one.harnessTurn === hook.harnessTurn);
|
|
33893
|
+
if (Option.isSome(prompt)) yield* Ref.set(prompted, Option.none());
|
|
33894
|
+
if (Option.isSome(prompt) && prompt.value !== hook) yield* turns.trackLate(client, bound.value, prompt.value, deadline);
|
|
33400
33895
|
const unattributed = yield* edits.noteEdits(bound.value, hook);
|
|
33401
33896
|
const within = yield* binding.within;
|
|
33402
33897
|
if (yield* tasks.note(bound.value, hook)) yield* Effect.forkIn(tasks.send(client, bound.value).pipe(Effect.ignore), within);
|
|
@@ -33512,6 +34007,18 @@ const mcpServer = (version, started) => McpServer.toolkit(HarnessTools).pipe(Lay
|
|
|
33512
34007
|
protocols
|
|
33513
34008
|
})));
|
|
33514
34009
|
/**
|
|
34010
|
+
* `ref0 mcp` in a checkout of another origin, which the launcher names: the plugin's server all
|
|
34011
|
+
* the same, answering every hook with nothing, since that origin's own plugin serves the
|
|
34012
|
+
* checkout. A harness takes a server that closes before `initialize` as broken, and Claude Code
|
|
34013
|
+
* then skips it for 15 minutes, in every folder. It reads nothing of this machine's, so it
|
|
34014
|
+
* answers at once.
|
|
34015
|
+
*/
|
|
34016
|
+
const otherOriginServer = (version) => McpServer.toolkit(HarnessTools).pipe(Layer.provide(HarnessTools.toLayer({ hook: () => Effect.succeed({}) })), Layer.provide(McpServer.layerStdio({
|
|
34017
|
+
name: "ref0",
|
|
34018
|
+
version,
|
|
34019
|
+
protocols
|
|
34020
|
+
})));
|
|
34021
|
+
/**
|
|
33515
34022
|
* The `codex` that started this server, by absolute path: its executable, which Linux names, or
|
|
33516
34023
|
* its command's first word when that's a path. Codex isn't sure to be on `PATH` (M0).
|
|
33517
34024
|
*/
|
|
@@ -33542,11 +34049,18 @@ const codexSetupHere = Effect.fnUntraced(function* (process) {
|
|
|
33542
34049
|
});
|
|
33543
34050
|
});
|
|
33544
34051
|
const mcp = Command.make(harnessCommandNames.mcp, {}, () => Effect.gen(function* () {
|
|
34052
|
+
const checked = yield* Config.schema(ConnectHarness, serverCheckVariable).pipe(Config.option);
|
|
34053
|
+
const other = yield* Config.option(Config.String(otherOriginVariable));
|
|
34054
|
+
if (Option.isSome(other) && Option.isNone(checked)) {
|
|
34055
|
+
yield* printBlocks([[`ref0: this folder is for ${other.value}, so ref0’s plugin for ${(yield* Ref0Config).api} does nothing in it.`]]);
|
|
34056
|
+
return yield* Layer.launch(otherOriginServer(cliVersion));
|
|
34057
|
+
}
|
|
33545
34058
|
const agents = yield* ConfigFolders;
|
|
33546
|
-
const found = yield* (yield* HarnessProcess).innermost;
|
|
33547
|
-
const
|
|
34059
|
+
const found = Option.isSome(checked) ? Option.none() : yield* (yield* HarnessProcess).innermost;
|
|
34060
|
+
const harness = Option.orElse(checked, () => Option.map(found, (each) => each.harness));
|
|
34061
|
+
const agent = Option.isSome(harness) ? yield* (yield* HostGrant).get(yield* agents.folder(harness.value)).pipe(Effect.catchTag("KeychainError", (failed) => failed.unreadable === true ? Effect.succeedNone : sayFailed(failed.nextStep))) : Option.none();
|
|
33548
34062
|
const codex = Option.isSome(found) && found.value.harness === "codex" ? yield* codexSetupHere(found.value.process) : Option.none();
|
|
33549
|
-
const reinstall = Option.isSome(
|
|
34063
|
+
const reinstall = Option.isSome(harness) && Option.isSome(agent) ? installLine(yield* agents.folder(harness.value)) : "ref0 connect";
|
|
33550
34064
|
return yield* Layer.launch(mcpServer(cliVersion, {
|
|
33551
34065
|
plugin: {
|
|
33552
34066
|
version: yield* pluginVersion,
|
|
@@ -33750,21 +34264,44 @@ const checkoutAt = Effect.fnUntraced(function* (cwd) {
|
|
|
33750
34264
|
};
|
|
33751
34265
|
});
|
|
33752
34266
|
/**
|
|
33753
|
-
*
|
|
33754
|
-
*
|
|
33755
|
-
*
|
|
33756
|
-
*
|
|
34267
|
+
* The harness whose `SessionStart` runs this: Claude Code, which alone gives its hook the file it
|
|
34268
|
+
* runs ahead of the agent's commands, and never its agent's commands; else Codex, when the
|
|
34269
|
+
* innermost harness process above it is Codex's, as the session's binding reads it. Codex's hook
|
|
34270
|
+
* input says nothing Claude Code's doesn't, and Codex sets no `CODEX_HOME` for the default
|
|
34271
|
+
* `~/.codex`. Neither otherwise.
|
|
34272
|
+
*/
|
|
34273
|
+
const startingHarness = Effect.gen(function* () {
|
|
34274
|
+
const file = yield* Config.String("CLAUDE_ENV_FILE").pipe(Config.withDefault(""));
|
|
34275
|
+
if (file !== "") return Option.some({
|
|
34276
|
+
harness: "claude-code",
|
|
34277
|
+
file
|
|
34278
|
+
});
|
|
34279
|
+
const found = yield* (yield* HarnessProcess).innermost;
|
|
34280
|
+
return Option.isSome(found) && found.value.harness === "codex" ? Option.some({ harness: "codex" }) : Option.none();
|
|
34281
|
+
});
|
|
34282
|
+
/**
|
|
34283
|
+
* What the agent reads when its commits couldn't be set up at its session's start, in one line,
|
|
34284
|
+
* whatever failed: the session still starts. Its commits may then name the person, skip ref0's
|
|
34285
|
+
* hooks, or meet a sign-off's refusal once.
|
|
34286
|
+
*/
|
|
34287
|
+
const commitSetupFailedWords = (error) => {
|
|
34288
|
+
return `ref0: ref0 couldn't set up Git for your commands, so your commits may not name you as their author or run ref0's hooks: ${("nextStep" in error ? error.nextStep : "detail" in error ? error.detail : error.message).replaceAll(/\s+/g, " ").trim()}`;
|
|
34289
|
+
};
|
|
34290
|
+
/**
|
|
34291
|
+
* Adds the agent's commit environment for the session a `SessionStart` opens, in Claude Code to
|
|
34292
|
+
* the file it names and in Codex to its checkout's section, only for a setup with an agent. ref0's
|
|
34293
|
+
* hooks folder is written first, so nothing names a folder that isn't there. The harness whose
|
|
34294
|
+
* hooks path it set, if it set one.
|
|
33757
34295
|
*/
|
|
33758
34296
|
const setCommitEnvironment = Effect.fnUntraced(function* (session) {
|
|
33759
34297
|
const fs = yield* FileSystem.FileSystem;
|
|
33760
34298
|
const folders = yield* ConfigFolders;
|
|
33761
|
-
const
|
|
33762
|
-
if (
|
|
33763
|
-
|
|
33764
|
-
|
|
33765
|
-
}
|
|
34299
|
+
const starting = yield* startingHarness;
|
|
34300
|
+
if (Option.isNone(starting)) return Option.none();
|
|
34301
|
+
const hook = starting.value;
|
|
34302
|
+
if (hook.harness === "codex") return (yield* setCodexCommitEnvironment(session.cwd ?? ".", yield* checkoutAt(session.cwd ?? "."))) ? Option.some("codex") : Option.none();
|
|
33766
34303
|
const connected = yield* (yield* HostGrant).get(yield* folders.folder("claude-code")).pipe(Effect.map(Option.map(Option.some)), Effect.catchIf((failed) => failed.unreadable === true, () => Effect.succeedSome(Option.none())));
|
|
33767
|
-
if (Option.isNone(connected)) return
|
|
34304
|
+
if (Option.isNone(connected)) return Option.none();
|
|
33768
34305
|
const checkout = yield* checkoutAt(session.cwd ?? ".");
|
|
33769
34306
|
const folder = yield* hooksFolder(yield* gitHooksHome(yield* Config.String("HOME"), (yield* Ref0Config).api), checkout.folder);
|
|
33770
34307
|
const author = checkout.signsOff ? Option.none() : Option.map(connected.value, (grant) => ({
|
|
@@ -33778,8 +34315,8 @@ const setCommitEnvironment = Effect.fnUntraced(function* (session) {
|
|
|
33778
34315
|
transcript: Option.filter(Option.fromNullishOr(session.transcript_path), (path) => path !== ""),
|
|
33779
34316
|
model: startingModel(session.model)
|
|
33780
34317
|
});
|
|
33781
|
-
yield* fs.writeFileString(file, script, { flag: "a" });
|
|
33782
|
-
return
|
|
34318
|
+
yield* fs.writeFileString(hook.file, script, { flag: "a" });
|
|
34319
|
+
return Option.some("claude-code");
|
|
33783
34320
|
});
|
|
33784
34321
|
//#endregion
|
|
33785
34322
|
//#region src/harness/session-start.ts
|
|
@@ -33831,10 +34368,8 @@ const startOnHook = (protocol) => Effect.gen(function* () {
|
|
|
33831
34368
|
const input = yield* stdio.stdin.pipe(Stream.decodeText(), Stream.mkString);
|
|
33832
34369
|
const hook = decodeHook(input);
|
|
33833
34370
|
if (Option.isNone(hook)) return;
|
|
33834
|
-
|
|
33835
|
-
|
|
33836
|
-
yield* Console.log(claude === "" ? codexHookInstallerWords : hookInstallerWords);
|
|
33837
|
-
}
|
|
34371
|
+
const hooksSet = yield* setCommitEnvironment(hook.value).pipe(Effect.catch((error) => Console.log(commitSetupFailedWords(error)).pipe(Effect.as(Option.none()))));
|
|
34372
|
+
if (Option.isSome(hooksSet)) yield* Console.log(hooksSet.value === "codex" ? codexHookInstallerWords : hookInstallerWords);
|
|
33838
34373
|
const binding = yield* bindingHere(hook.value.session_id);
|
|
33839
34374
|
if (Option.isNone(binding)) return;
|
|
33840
34375
|
const harnessName = binding.value.harness === "codex" ? "codex" : "claude-code";
|
|
@@ -33888,6 +34423,205 @@ const sessionStart = Command.make(harnessCommandNames.sessionStart, { protocol:
|
|
|
33888
34423
|
/** The commands a harness runs: `ref0 mcp`, and its command hooks. */
|
|
33889
34424
|
const harnessCommands = [mcp, Command.make(harnessCommandNames.hook).pipe(Command.withDescription("The harness's hooks that run as commands"), Command.withShortDescription("Run the harness’s session hooks"), Command.withSubcommands([sessionStart, sessionEnd]))];
|
|
33890
34425
|
//#endregion
|
|
34426
|
+
//#region src/harness/command-token.ts
|
|
34427
|
+
/**
|
|
34428
|
+
* The session an agent's command acts as: its harness session's, by the token kept under its
|
|
34429
|
+
* binding. Outside a harness there's none, and a command is its person's, asked by approval; so
|
|
34430
|
+
* too in a harness whose config folder has no agent connected. Where one is connected, a harness
|
|
34431
|
+
* session with no token has lost its start, and the command says why, as far as ref0 can tell,
|
|
34432
|
+
* and what to do, rather than run as its person.
|
|
34433
|
+
*/
|
|
34434
|
+
/**
|
|
34435
|
+
* Whether ref0's server, `ref0 mcp`, runs right under a harness process, as the harness starts
|
|
34436
|
+
* it: none when the process table can't be read.
|
|
34437
|
+
*/
|
|
34438
|
+
var HarnessServers = class HarnessServers extends Context.Service()("ref0/cli/HarnessServers") {
|
|
34439
|
+
static layer = Layer.effect(HarnessServers, Effect.gen(function* () {
|
|
34440
|
+
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
|
|
34441
|
+
const runsUnder = (process) => spawner.string(ChildProcess.make("ps", [
|
|
34442
|
+
"-A",
|
|
34443
|
+
"-o",
|
|
34444
|
+
"ppid=,args="
|
|
34445
|
+
])).pipe(Effect.map((table) => table.trim() === "" ? Option.none() : Option.some(table.split("\n").some((line) => {
|
|
34446
|
+
const [parent = "", ...words] = line.trim().split(/\s+/);
|
|
34447
|
+
return Number(parent) === process && words.at(-1) === harnessCommandNames.mcp && words.some((word) => word.includes("ref0"));
|
|
34448
|
+
}))), Effect.orElseSucceed(() => Option.none()));
|
|
34449
|
+
return HarnessServers.of({ runsUnder });
|
|
34450
|
+
}));
|
|
34451
|
+
};
|
|
34452
|
+
/**
|
|
34453
|
+
* What an agent's command in a harness session with no ref0 session says to do: run again, while
|
|
34454
|
+
* the prompt's start is on its way; reconnect or restart, when the server isn't running; else
|
|
34455
|
+
* send the prompt again, which starts one.
|
|
34456
|
+
*/
|
|
34457
|
+
const noSessionStep = (harness, server, seen) => {
|
|
34458
|
+
const name = configFolders[harness].name;
|
|
34459
|
+
const restart = harness === "codex" ? "restart Codex" : `run /mcp, choose ${server} and Reconnect, or restart Claude Code`;
|
|
34460
|
+
if (Option.contains(seen.serverRuns, false)) return `This ${name} session has no ref0 session: ref0’s server isn’t running in it, so none started at the last prompt. Tell your person to ${restart}, then send the prompt again.`;
|
|
34461
|
+
if (seen.pending) return `This ${name} session has no ref0 session yet: its start at the last prompt hasn’t landed. Run this again in a few seconds. If it says this again, tell your person to send the prompt again.`;
|
|
34462
|
+
return `This ${name} session has no ref0 session: none started at the last prompt. Tell your person to send the prompt again. If it says this again, tell them to ${restart}.`;
|
|
34463
|
+
};
|
|
34464
|
+
/** `SessionToken` from the keychain, by the binding of the harness session this runs in. */
|
|
34465
|
+
const harnessSessionToken = Layer.effect(SessionToken, Effect.gen(function* () {
|
|
34466
|
+
const keychain = yield* Keychain;
|
|
34467
|
+
const harness = yield* HarnessProcess;
|
|
34468
|
+
const servers = yield* HarnessServers;
|
|
34469
|
+
const hostGrant = yield* HostGrant;
|
|
34470
|
+
const folders = yield* ConfigFolders;
|
|
34471
|
+
const config = yield* Ref0Config;
|
|
34472
|
+
const plugin = yield* originPluginName(config.api);
|
|
34473
|
+
const current = Effect.gen(function* () {
|
|
34474
|
+
const binding = yield* bindingHere().pipe(Effect.provideService(HarnessProcess, harness), Effect.orElseSucceed(() => Option.none()));
|
|
34475
|
+
if (Option.isNone(binding)) return Option.none();
|
|
34476
|
+
const kept = yield* keychain.get(sessionAccount(binding.value));
|
|
34477
|
+
if (Option.isSome(kept)) return kept;
|
|
34478
|
+
const name = binding.value.harness === "codex" ? "codex" : "claude-code";
|
|
34479
|
+
const grant = yield* hostGrant.get(yield* folders.folder(name)).pipe(Effect.catchIf((failed) => failed.unreadable === true, () => Effect.succeedNone));
|
|
34480
|
+
if (Option.isNone(grant) || grant.value.harness !== name) return Option.none();
|
|
34481
|
+
const pending = yield* keychain.get(pendingAccount(binding.value));
|
|
34482
|
+
const serverRuns = yield* servers.runsUnder(binding.value.process);
|
|
34483
|
+
return yield* new NoSession({ nextStep: noSessionStep(name, `plugin:${plugin}:${plugin}`, {
|
|
34484
|
+
serverRuns,
|
|
34485
|
+
pending: Option.isSome(pending)
|
|
34486
|
+
}) });
|
|
34487
|
+
});
|
|
34488
|
+
return SessionToken.of({ current });
|
|
34489
|
+
}));
|
|
34490
|
+
//#endregion
|
|
34491
|
+
//#region src/harness/server-check.ts
|
|
34492
|
+
/**
|
|
34493
|
+
* Whether a config folder's ref0 server starts, asked as its harness asks: the command its
|
|
34494
|
+
* installed plugin's `.mcp.json` names, started from an empty folder of ref0's own with the
|
|
34495
|
+
* environment that harness would give it from this terminal's, and sent `initialize`. Told by
|
|
34496
|
+
* `serverCheckVariable` which harness it's started as, the server reads that folder's grant from
|
|
34497
|
+
* the keychain as it does at a session's start, and starts no session. Its whole process group is
|
|
34498
|
+
* stopped once it answers, stops, or the limit passes, so nothing it started outlasts the check.
|
|
34499
|
+
*/
|
|
34500
|
+
/**
|
|
34501
|
+
* The variables Codex 0.164.0 passes a stdio server of its own, beside those its entry's
|
|
34502
|
+
* `env_vars` names that are set. Nothing else reaches the server (measured on Linux, 24 Oct 2026).
|
|
34503
|
+
*/
|
|
34504
|
+
const codexPasses = [
|
|
34505
|
+
"HOME",
|
|
34506
|
+
"LOGNAME",
|
|
34507
|
+
"PATH",
|
|
34508
|
+
"SHELL",
|
|
34509
|
+
"USER",
|
|
34510
|
+
"LANG",
|
|
34511
|
+
"LC_ALL",
|
|
34512
|
+
"TERM",
|
|
34513
|
+
"TMPDIR",
|
|
34514
|
+
"TZ",
|
|
34515
|
+
"__CF_USER_TEXT_ENCODING",
|
|
34516
|
+
"SSL_CERT_FILE",
|
|
34517
|
+
"NODE_EXTRA_CA_CERTS"
|
|
34518
|
+
];
|
|
34519
|
+
/**
|
|
34520
|
+
* How long a server has to answer `initialize`: ten times its slowest start measured on Linux,
|
|
34521
|
+
* 0.5 s (24 Oct 2026), so status stays quick. A harness may wait longer (Codex 0.164.0 waited 14 s
|
|
34522
|
+
* for a slow one), so a server that doesn't answer in it is unknown, not one that can't start.
|
|
34523
|
+
*/
|
|
34524
|
+
const serverLimit = Duration.seconds(5);
|
|
34525
|
+
const checkId = "ref0-status";
|
|
34526
|
+
/**
|
|
34527
|
+
* `initialize` and its answer, as JSON-RPC carries them, in MCP's own schemas: those `ref0 mcp`
|
|
34528
|
+
* serves through Effect's MCP server.
|
|
34529
|
+
*/
|
|
34530
|
+
const InitializeRequest = Schema.Struct({
|
|
34531
|
+
jsonrpc: Schema.Literal("2.0"),
|
|
34532
|
+
id: Schema.Literal(checkId),
|
|
34533
|
+
method: Schema.Literal(McpSchema.Initialize._tag),
|
|
34534
|
+
params: McpSchema.Initialize.payloadSchema
|
|
34535
|
+
});
|
|
34536
|
+
const InitializeAnswer = Schema.Struct({
|
|
34537
|
+
id: Schema.Literal(checkId),
|
|
34538
|
+
result: McpSchema.InitializeResult
|
|
34539
|
+
});
|
|
34540
|
+
/** `initialize`, at a version every ref0 server speaks, as a harness's first line. */
|
|
34541
|
+
const initialize = `${Schema.encodeSync(Schema.fromJsonString(InitializeRequest))({
|
|
34542
|
+
jsonrpc: "2.0",
|
|
34543
|
+
id: checkId,
|
|
34544
|
+
method: McpSchema.Initialize._tag,
|
|
34545
|
+
params: {
|
|
34546
|
+
protocolVersion: "2025-06-18",
|
|
34547
|
+
capabilities: new McpSchema.ClientCapabilities({}),
|
|
34548
|
+
clientInfo: {
|
|
34549
|
+
name: "ref0 status",
|
|
34550
|
+
version: cliVersion
|
|
34551
|
+
}
|
|
34552
|
+
}
|
|
34553
|
+
})}\n`;
|
|
34554
|
+
const decodeServers = Schema.decodeUnknownEffect(Schema.fromJsonString(McpServersFile));
|
|
34555
|
+
const decodeAnswer = Schema.decodeUnknownOption(Schema.fromJsonString(InitializeAnswer));
|
|
34556
|
+
/**
|
|
34557
|
+
* `names` as this terminal sets them, which a harness started here would pass on. A relative
|
|
34558
|
+
* `PATH` folder is read from here, as `onPath` reads it, since the server starts elsewhere.
|
|
34559
|
+
*/
|
|
34560
|
+
const fromHere = Effect.fnUntraced(function* (names) {
|
|
34561
|
+
const paths = yield* Path.Path;
|
|
34562
|
+
const set = [];
|
|
34563
|
+
for (const name of new Set(names)) {
|
|
34564
|
+
const value = yield* Config.String(name).pipe(Config.option);
|
|
34565
|
+
if (Option.isNone(value)) continue;
|
|
34566
|
+
set.push([name, name === "PATH" ? value.value.split(":").map((folder) => folder === "" || paths.isAbsolute(folder) ? folder : `${process.cwd()}/${folder}`).join(":") : value.value]);
|
|
34567
|
+
}
|
|
34568
|
+
return Object.fromEntries(set);
|
|
34569
|
+
});
|
|
34570
|
+
/** The first line a stopped server said, made printable. */
|
|
34571
|
+
const firstLine = (said) => printable(said.split("\n").map((line) => line.trim()).find((line) => line !== "") ?? "");
|
|
34572
|
+
/**
|
|
34573
|
+
* Starts the server named `name` in the plugin installed at `plugin`, as `harness` would for
|
|
34574
|
+
* `configFolder`, and answers how its start went.
|
|
34575
|
+
*/
|
|
34576
|
+
const startServer = Effect.fnUntraced(function* (harness, configFolder, plugin, name) {
|
|
34577
|
+
const fs = yield* FileSystem.FileSystem;
|
|
34578
|
+
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
|
|
34579
|
+
const server = yield* fs.readFileString(`${plugin}/.mcp.json`).pipe(Effect.flatMap(decodeServers), Effect.map((file) => Option.fromNullishOr(file.mcpServers[name])), Effect.orElseSucceed(Option.none));
|
|
34580
|
+
if (Option.isNone(server)) return { kind: "unnamed" };
|
|
34581
|
+
const asked = server.value.env_vars ?? [];
|
|
34582
|
+
const checked = {
|
|
34583
|
+
REF0_API: (yield* Ref0Config).api,
|
|
34584
|
+
[serverCheckVariable]: harness
|
|
34585
|
+
};
|
|
34586
|
+
const env = harness === "codex" ? {
|
|
34587
|
+
...yield* fromHere([...codexPasses, ...asked]),
|
|
34588
|
+
CODEX_HOME: configFolder,
|
|
34589
|
+
...checked
|
|
34590
|
+
} : {
|
|
34591
|
+
...yield* fromHere([
|
|
34592
|
+
...codexPasses,
|
|
34593
|
+
...keychainVariables,
|
|
34594
|
+
...asked
|
|
34595
|
+
]),
|
|
34596
|
+
CLAUDE_CONFIG_DIR: configFolder,
|
|
34597
|
+
...checked
|
|
34598
|
+
};
|
|
34599
|
+
const withheld = Object.keys(yield* fromHere(keychainVariables)).some((variable) => env[variable] === void 0);
|
|
34600
|
+
return yield* Effect.scoped(Effect.gen(function* () {
|
|
34601
|
+
const handle = yield* spawner.spawn(ChildProcess.make(server.value.command, server.value.args, {
|
|
34602
|
+
cwd: yield* emptyFolder(fs),
|
|
34603
|
+
env,
|
|
34604
|
+
extendEnv: harness === "claude-code",
|
|
34605
|
+
forceKillAfter: "1 second",
|
|
34606
|
+
stdin: {
|
|
34607
|
+
stream: Stream.make(initialize).pipe(Stream.encodeText),
|
|
34608
|
+
endOnDone: false
|
|
34609
|
+
}
|
|
34610
|
+
}));
|
|
34611
|
+
const said = yield* handle.stderr.pipe(Stream.decodeText(), Stream.mkString, Effect.forkScoped);
|
|
34612
|
+
const lines = yield* handle.stdout.pipe(Stream.decodeText(), Stream.splitLines, Stream.takeUntil((line) => Option.isSome(decodeAnswer(line))), Stream.runCollect);
|
|
34613
|
+
if (lines.some((line) => Option.isSome(decodeAnswer(line)))) return { kind: "answered" };
|
|
34614
|
+
if (withheld) return { kind: "withoutKeychain" };
|
|
34615
|
+
return {
|
|
34616
|
+
kind: "stopped",
|
|
34617
|
+
said: firstLine(yield* Fiber.join(said)) || firstLine(lines.join("\n"))
|
|
34618
|
+
};
|
|
34619
|
+
})).pipe(Effect.catchTag("PlatformError", (error) => Effect.succeed({
|
|
34620
|
+
kind: "stopped",
|
|
34621
|
+
said: firstLine(error.message)
|
|
34622
|
+
})), Effect.timeoutOption(serverLimit), Effect.map(Option.getOrElse(() => ({ kind: "unanswered" }))));
|
|
34623
|
+
});
|
|
34624
|
+
//#endregion
|
|
33891
34625
|
//#region src/harness/status.ts
|
|
33892
34626
|
/** Each connected or initialized folder has its own report; secrets are used only to ask ref0. */
|
|
33893
34627
|
const encodeReports = Schema.encodeEffect(Schema.Array(StatusReport));
|
|
@@ -33943,6 +34677,16 @@ const speaksCheck = (protocols, version, reinstall) => {
|
|
|
33943
34677
|
if (Option.isSome(mismatch)) return checkOf("plugin", "no", mismatch.value.says, mismatch.value.run);
|
|
33944
34678
|
return checkOf("plugin", "yes", "installed and speaks this ref0");
|
|
33945
34679
|
};
|
|
34680
|
+
/** A server's start, as its line says it, with this folder's reinstall for one that can't start. */
|
|
34681
|
+
const serverCheckOf = (started, configFolder) => {
|
|
34682
|
+
switch (started.kind) {
|
|
34683
|
+
case "answered": return checkOf("server", "yes", "starts");
|
|
34684
|
+
case "stopped": return checkOf("server", "no", started.said === "" ? "can’t start, and said nothing" : `can’t start: ${started.said}`, installLine(configFolder));
|
|
34685
|
+
case "withoutKeychain": return checkOf("server", "no", "can’t reach the keychain, since an older ref0 installed it", installLine(configFolder));
|
|
34686
|
+
case "unnamed": return checkOf("server", "no", "the plugin names no ref0 server", installLine(configFolder));
|
|
34687
|
+
case "unanswered": return checkOf("server", "unknown", `didn’t answer in ${Duration.toSeconds(serverLimit)} s`, "ref0 status");
|
|
34688
|
+
}
|
|
34689
|
+
};
|
|
33946
34690
|
const decodePlugins = Schema.decodeUnknownEffect(Schema.fromJsonString(ClaudePluginsListed));
|
|
33947
34691
|
const decodeHooks = Schema.decodeUnknownEffect(Schema.fromJsonString(HooksFile));
|
|
33948
34692
|
/** Ask Claude Code about this folder, even when another folder is current in the terminal. */
|
|
@@ -33976,7 +34720,7 @@ const claudeCodeChecks = Effect.fnUntraced(function* (configFolder) {
|
|
|
33976
34720
|
return [speaksCheck(Option.match(hooks, {
|
|
33977
34721
|
onNone: () => [],
|
|
33978
34722
|
onSome: protocolsOf
|
|
33979
|
-
}), Option.fromUndefinedOr(plugin.version), reinstall)];
|
|
34723
|
+
}), Option.fromUndefinedOr(plugin.version), reinstall), folder === void 0 ? checkOf("server", "unknown", "Claude Code didn’t say where ref0’s plugin is", reinstall) : serverCheckOf(yield* startServer("claude-code", configFolder, folder, name), configFolder)];
|
|
33980
34724
|
});
|
|
33981
34725
|
/** Trust only ref0's hooks; removal of an unexpected plugin targets this folder too. */
|
|
33982
34726
|
const trustCheck = (hooks, configFolder, name) => {
|
|
@@ -33996,7 +34740,13 @@ const codexChecks = Effect.fnUntraced(function* (configFolder) {
|
|
|
33996
34740
|
const hooks = listed.value.data.flatMap((entry) => entry.hooks).filter((hook) => pluginHook(hook, `${name}@${name}`));
|
|
33997
34741
|
if (hooks.length === 0) return [checkOf("plugin", "no", "not installed or turned off in this folder", reinstall)];
|
|
33998
34742
|
const protocols = hooks.map((hook) => protocolFlagged(hook.command ?? "")).filter(Predicate.isString);
|
|
33999
|
-
|
|
34743
|
+
const path = yield* Path.Path;
|
|
34744
|
+
const source = hooks.find((hook) => Predicate.isString(hook.sourcePath))?.sourcePath;
|
|
34745
|
+
return [
|
|
34746
|
+
speaksCheck(protocols, Option.none(), reinstall),
|
|
34747
|
+
trustCheck(hooks, configFolder, name),
|
|
34748
|
+
Predicate.isString(source) ? serverCheckOf(yield* startServer("codex", configFolder, path.dirname(path.dirname(source)), name), configFolder) : checkOf("server", "unknown", "Codex didn’t say where ref0’s plugin is", reinstall)
|
|
34749
|
+
];
|
|
34000
34750
|
});
|
|
34001
34751
|
/** A session belongs only to the current folder, even when several folders share its agent. */
|
|
34002
34752
|
const sessionCheck = Effect.fnUntraced(function* (harness, configFolder) {
|
|
@@ -34025,8 +34775,7 @@ const statusCommands = [Command.make(harnessCommandNames.status, { json }, ({ js
|
|
|
34025
34775
|
}
|
|
34026
34776
|
for (const { configFolder, harness } of found) candidates.set(configFolder, harness);
|
|
34027
34777
|
const paths = [...candidates.keys()].sort((a, b) => a === current ? -1 : b === current ? 1 : 0);
|
|
34028
|
-
const
|
|
34029
|
-
for (const configFolder of paths) {
|
|
34778
|
+
const reportOf = Effect.fnUntraced(function* (configFolder) {
|
|
34030
34779
|
const kept = yield* Effect.result(grants.get(configFolder));
|
|
34031
34780
|
const unreadable = Result.isFailure(kept) && kept.failure.unreadable === true;
|
|
34032
34781
|
const grant = Result.isSuccess(kept) ? kept.success : Option.none();
|
|
@@ -34036,15 +34785,19 @@ const statusCommands = [Command.make(harnessCommandNames.status, { json }, ({ js
|
|
|
34036
34785
|
configFolder,
|
|
34037
34786
|
checks: [Result.isFailure(kept) && !unreadable ? checkOf("connected", "unknown", "The keychain didn’t say whether this config folder holds a host grant.", kept.failure.nextStep) : checkOf("connected", "no", unreadable ? "its host grant can’t be read" : "not connected", harness === void 0 ? "ref0 connect" : yield* connectLineFor(harness, configFolder))]
|
|
34038
34787
|
};
|
|
34039
|
-
|
|
34788
|
+
return harness === void 0 ? report : {
|
|
34040
34789
|
...report,
|
|
34041
34790
|
harness
|
|
34042
|
-
}
|
|
34043
|
-
continue;
|
|
34791
|
+
};
|
|
34044
34792
|
}
|
|
34045
|
-
const
|
|
34046
|
-
|
|
34047
|
-
|
|
34793
|
+
const [granted, installed] = yield* Effect.all([grantChecks(grant.value, configFolder), grant.value.harness === "codex" ? codexChecks(configFolder) : claudeCodeChecks(configFolder)], { concurrency: "unbounded" });
|
|
34794
|
+
const checks = [...granted, ...installed];
|
|
34795
|
+
if (configFolder === current) {
|
|
34796
|
+
const session = yield* sessionCheck(grant.value.harness, configFolder);
|
|
34797
|
+
const server = checks.find((check) => check.check === "server");
|
|
34798
|
+
checks.push(server !== void 0 && server.answer !== "yes" && session.answer === "no" ? checkOf("session", "no", `none, since ref0’s server ${server.answer === "no" ? "can’t start" : "didn’t answer"}`, server.next) : session);
|
|
34799
|
+
}
|
|
34800
|
+
return {
|
|
34048
34801
|
harness: grant.value.harness,
|
|
34049
34802
|
configFolder,
|
|
34050
34803
|
agent: {
|
|
@@ -34052,8 +34805,9 @@ const statusCommands = [Command.make(harnessCommandNames.status, { json }, ({ js
|
|
|
34052
34805
|
agent: grant.value.agent
|
|
34053
34806
|
},
|
|
34054
34807
|
checks
|
|
34055
|
-
}
|
|
34056
|
-
}
|
|
34808
|
+
};
|
|
34809
|
+
});
|
|
34810
|
+
const reports = yield* Effect.forEach(paths, reportOf, { concurrency: "unbounded" });
|
|
34057
34811
|
const home = yield* Config.String("HOME").pipe(Config.withDefault("."));
|
|
34058
34812
|
yield* printAnswer(yield* Effect.orDie(encodeReports(reports)), asJson, reports.length === 0 ? [["No initialized harness folders found.", { command: "ref0 connect" }]] : reports.map((report) => wordsOf(report, home)));
|
|
34059
34813
|
if (reports.some((report) => report.checks.some((check) => check.answer === "no"))) return yield* new Said();
|
|
@@ -34098,7 +34852,12 @@ const ref0 = Command.make("ref0").pipe(Command.withDescription("ref0 from the te
|
|
|
34098
34852
|
]));
|
|
34099
34853
|
const keychain = Keychain.layer.pipe(Layer.provide([NodeServices.layer, Ref0Config.layer]));
|
|
34100
34854
|
const harnessProcess = HarnessProcess.layer.pipe(Layer.provide(NodeServices.layer));
|
|
34101
|
-
const sessionToken = harnessSessionToken.pipe(Layer.provide([
|
|
34855
|
+
const sessionToken = harnessSessionToken.pipe(Layer.provide([
|
|
34856
|
+
keychain,
|
|
34857
|
+
harnessProcess,
|
|
34858
|
+
HarnessServers.layer.pipe(Layer.provide(NodeServices.layer)),
|
|
34859
|
+
HostGrant.layer.pipe(Layer.provide(keychain))
|
|
34860
|
+
]));
|
|
34102
34861
|
/** Every service `ref0`'s commands use, on this machine. */
|
|
34103
34862
|
const services = Layer.mergeAll(cliOutput, Ref0.layer.pipe(Layer.provide([
|
|
34104
34863
|
Ref0Config.layer,
|