@opencode/client 0.0.0-beta-19275
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -0
- package/dist/contract.d.ts +1 -0
- package/dist/contract.js +1 -0
- package/dist/effect/api/api.d.ts +2659 -0
- package/dist/effect/api/api.js +0 -0
- package/dist/effect/api.d.ts +8 -0
- package/dist/effect/api.js +0 -0
- package/dist/effect/client.d.ts +2535 -0
- package/dist/effect/client.js +29 -0
- package/dist/effect/generated/client-error.d.ts +7 -0
- package/dist/effect/generated/client-error.js +5 -0
- package/dist/effect/generated/client.d.ts +2534 -0
- package/dist/effect/generated/client.js +602 -0
- package/dist/effect/generated/index.d.ts +2 -0
- package/dist/effect/generated/index.js +2 -0
- package/dist/effect/index.d.ts +35 -0
- package/dist/effect/index.js +30 -0
- package/dist/effect/rpc.d.ts +20 -0
- package/dist/effect/rpc.js +49 -0
- package/dist/effect/service.d.ts +78 -0
- package/dist/effect/service.js +259 -0
- package/dist/promise/api.d.ts +24 -0
- package/dist/promise/api.js +0 -0
- package/dist/promise/client.d.ts +257 -0
- package/dist/promise/client.js +13 -0
- package/dist/promise/generated/client-error.d.ts +6 -0
- package/dist/promise/generated/client-error.js +8 -0
- package/dist/promise/generated/client.d.ts +264 -0
- package/dist/promise/generated/client.js +1425 -0
- package/dist/promise/generated/index.d.ts +3 -0
- package/dist/promise/generated/index.js +3 -0
- package/dist/promise/generated/types.d.ts +8717 -0
- package/dist/promise/generated/types.js +30 -0
- package/dist/promise/index.d.ts +6 -0
- package/dist/promise/index.js +2 -0
- package/dist/promise/rpc.d.ts +32 -0
- package/dist/promise/rpc.js +86 -0
- package/dist/promise/service.d.ts +26 -0
- package/dist/promise/service.js +247 -0
- package/dist/pty-handoff.d.ts +9 -0
- package/dist/pty-handoff.js +121 -0
- package/dist/rpc-runtime.d.ts +21 -0
- package/dist/rpc-runtime.js +32 -0
- package/dist/service-contender.d.ts +11 -0
- package/dist/service-contender.js +56 -0
- package/dist/service-timing.d.ts +13 -0
- package/dist/service-timing.js +19 -0
- package/dist/service-version.d.ts +2 -0
- package/dist/service-version.js +9 -0
- package/dist/service.d.ts +52 -0
- package/dist/service.js +0 -0
- package/dist/shared-events.d.ts +11 -0
- package/dist/shared-events.js +137 -0
- package/dist/solid/connection.d.ts +37 -0
- package/dist/solid/connection.js +246 -0
- package/dist/solid/data.d.ts +199 -0
- package/dist/solid/data.js +1724 -0
- package/dist/solid/index.d.ts +3 -0
- package/dist/solid/index.js +3 -0
- package/dist/solid/pty.d.ts +22 -0
- package/dist/solid/pty.js +43 -0
- package/package.json +85 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export const isInvalidRequestError = (value) => typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "InvalidRequestError";
|
|
2
|
+
export const isUnauthorizedError = (value) => typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "UnauthorizedError";
|
|
3
|
+
export const isAgentNotFoundError = (value) => typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "AgentNotFoundError";
|
|
4
|
+
export const isServiceUnavailableError = (value) => typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ServiceUnavailableError";
|
|
5
|
+
export const isInvalidCursorError = (value) => typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "InvalidCursorError";
|
|
6
|
+
export const isSessionNotFoundError = (value) => typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "SessionNotFoundError";
|
|
7
|
+
export const isConflictError = (value) => typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ConflictError";
|
|
8
|
+
export const isUnknownError = (value) => typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "UnknownError";
|
|
9
|
+
export const isMessageNotFoundError = (value) => typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "MessageNotFoundError";
|
|
10
|
+
export const isCommandNotFoundError = (value) => typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "CommandNotFoundError";
|
|
11
|
+
export const isCommandExecutionError = (value) => typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "CommandExecutionError";
|
|
12
|
+
export const isSkillNotFoundError = (value) => typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "SkillNotFoundError";
|
|
13
|
+
export const isSessionBusyError = (value) => typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "SessionBusyError";
|
|
14
|
+
export const isInstructionEntryValueTooLargeError = (value) => typeof value === "object" &&
|
|
15
|
+
value !== null &&
|
|
16
|
+
"_tag" in value &&
|
|
17
|
+
value["_tag"] === "InstructionEntryValueTooLargeError";
|
|
18
|
+
export const isProviderNotFoundError = (value) => typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ProviderNotFoundError";
|
|
19
|
+
export const isMcpServerNotFoundError = (value) => typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "McpServerNotFoundError";
|
|
20
|
+
export const isProjectNotFoundError = (value) => typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ProjectNotFoundError";
|
|
21
|
+
export const isFormNotFoundError = (value) => typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "FormNotFoundError";
|
|
22
|
+
export const isFormInvalidAnswerError = (value) => typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "FormInvalidAnswerError";
|
|
23
|
+
export const isFormAlreadySettledError = (value) => typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "FormAlreadySettledError";
|
|
24
|
+
export const isPermissionNotFoundError = (value) => typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "PermissionNotFoundError";
|
|
25
|
+
export const isRpcError = (value) => typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "RpcError";
|
|
26
|
+
export const isRpcInternalError = (value) => typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "RpcInternalError";
|
|
27
|
+
export const isPtyNotFoundError = (value) => typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "PtyNotFoundError";
|
|
28
|
+
export const isForbiddenError = (value) => typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ForbiddenError";
|
|
29
|
+
export const isShellNotFoundError = (value) => typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ShellNotFoundError";
|
|
30
|
+
export const isWorktreeError = (value) => typeof value === "object" && value !== null && "name" in value && value["name"] === "WorktreeError";
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { OpenCode } from "./client.js";
|
|
2
|
+
export * from "./generated/index.js";
|
|
3
|
+
export { OpenCode } from "./client.js";
|
|
4
|
+
export type { AgentApi, CatalogApi, CommandApi, ConfigApi, EventApi, IntegrationApi, ModelApi, PluginApi, ProviderApi, ReferenceApi, RpcApi, RpcCallOptions, RpcClient, RpcEventPayload, WebSearchApi, SessionApi, SkillApi, } from "./api.js";
|
|
5
|
+
export type { EventSubscribeOutput as OpenCodeEvent } from "./generated/types.js";
|
|
6
|
+
export type OpenCodeClient = ReturnType<typeof OpenCode.make>;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { Rpc } from "@opencode/schema/rpc";
|
|
2
|
+
import type { make, RequestOptions } from "./generated/client.js";
|
|
3
|
+
import type { EventSubscribeOutput, LocationGetInput } from "./generated/types.js";
|
|
4
|
+
type RpcEvent = Extract<EventSubscribeOutput, {
|
|
5
|
+
type: `rpc.${string}`;
|
|
6
|
+
}>;
|
|
7
|
+
export interface RpcCallOptions extends RequestOptions {
|
|
8
|
+
readonly location?: LocationGetInput["location"];
|
|
9
|
+
}
|
|
10
|
+
export type RpcArguments<Input, Options> = unknown extends Input ? [input: Input, options?: Options] : undefined extends Input ? [input?: Input, options?: Options] : [input: Input, options?: Options];
|
|
11
|
+
export type RpcClient<D extends Rpc.PortableDefinition, Options = RpcCallOptions> = {
|
|
12
|
+
readonly [Name in keyof D["methods"]]: (...args: RpcArguments<Rpc.Input<D["methods"][Name]["input"]>, Options>) => Promise<Rpc.Output<D["methods"][Name]["output"]>>;
|
|
13
|
+
} & {
|
|
14
|
+
readonly events: {
|
|
15
|
+
readonly subscribe: <Name extends keyof D["events"] & string>(name: Name, options?: Pick<RequestOptions, "signal">) => AsyncIterable<RpcEventPayload<D, Name>>;
|
|
16
|
+
readonly on: <Name extends keyof D["events"] & string>(name: Name, handler: (event: RpcEventPayload<D, Name>) => Promise<void> | void, options?: Pick<RequestOptions, "signal">) => () => void;
|
|
17
|
+
};
|
|
18
|
+
};
|
|
19
|
+
type RpcEventPayloadFor<D extends Rpc.PortableDefinition, Name extends keyof D["events"] & string> = Omit<RpcEvent, "type" | "data"> & {
|
|
20
|
+
type: `rpc.${D["id"]}.${Name}`;
|
|
21
|
+
data: Rpc.EventData<D["events"][Name]["schema"]>;
|
|
22
|
+
};
|
|
23
|
+
export type RpcEventPayload<D extends Rpc.PortableDefinition, Name extends keyof D["events"] & string = keyof D["events"] & string> = {
|
|
24
|
+
[K in Name]: RpcEventPayloadFor<D, K>;
|
|
25
|
+
}[Name];
|
|
26
|
+
export interface RpcApi<Options = RpcCallOptions> {
|
|
27
|
+
<D extends Rpc.PortableDefinition>(definition: D): RpcClient<D, Options>;
|
|
28
|
+
}
|
|
29
|
+
export declare function makeRpc(raw: ReturnType<typeof make>, events: {
|
|
30
|
+
subscribe(options?: Pick<RequestOptions, "signal">): AsyncIterable<EventSubscribeOutput>;
|
|
31
|
+
}): RpcApi;
|
|
32
|
+
export {};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { isRpcError, isRpcInternalError } from "./generated/types.js";
|
|
2
|
+
export function makeRpc(raw, events) {
|
|
3
|
+
return (definition) => {
|
|
4
|
+
const subscribe = (name, options) => {
|
|
5
|
+
if (!Object.hasOwn(definition.events, name))
|
|
6
|
+
throw new Error(`Unknown RPC event: ${definition.id}.${name}`);
|
|
7
|
+
const type = eventType(definition, name);
|
|
8
|
+
return {
|
|
9
|
+
[Symbol.asyncIterator]() {
|
|
10
|
+
const controller = new AbortController();
|
|
11
|
+
const signal = options?.signal ? AbortSignal.any([controller.signal, options.signal]) : controller.signal;
|
|
12
|
+
const iterator = (async function* () {
|
|
13
|
+
try {
|
|
14
|
+
for await (const published of events.subscribe({ signal })) {
|
|
15
|
+
if (signal.aborted)
|
|
16
|
+
return;
|
|
17
|
+
if (published.type !== type)
|
|
18
|
+
continue;
|
|
19
|
+
// SAFETY: The exact RPC type was selected above; Promise contracts require no client-side transform.
|
|
20
|
+
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
|
|
21
|
+
yield published;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
if (!signal.aborted)
|
|
26
|
+
throw error;
|
|
27
|
+
}
|
|
28
|
+
finally {
|
|
29
|
+
controller.abort();
|
|
30
|
+
}
|
|
31
|
+
})();
|
|
32
|
+
return {
|
|
33
|
+
next: () => iterator.next(),
|
|
34
|
+
return: () => {
|
|
35
|
+
// Interrupt a pending source read before closing the generator.
|
|
36
|
+
controller.abort();
|
|
37
|
+
return iterator.return();
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
// SAFETY: Every runtime key comes from this definition's method and event maps, which define RpcClient's mapped keys.
|
|
44
|
+
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
|
|
45
|
+
return Object.assign(Object.fromEntries(Object.keys(definition.methods).map((name) => [
|
|
46
|
+
name,
|
|
47
|
+
async (input, options) => {
|
|
48
|
+
try {
|
|
49
|
+
const result = await raw.rpc.call({
|
|
50
|
+
rpcID: definition.id,
|
|
51
|
+
method: name,
|
|
52
|
+
// SAFETY: The method schema defines the accepted input; this assertion bridges it to the generic JSON transport.
|
|
53
|
+
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
|
|
54
|
+
input: input,
|
|
55
|
+
location: options?.location,
|
|
56
|
+
}, { signal: options?.signal, headers: options?.headers });
|
|
57
|
+
return result.output;
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
if (!isRpcError(error) && !isRpcInternalError(error))
|
|
61
|
+
throw error;
|
|
62
|
+
throw error.data === undefined
|
|
63
|
+
? { type: error.type, message: error.message }
|
|
64
|
+
: { type: error.type, message: error.message, data: error.data };
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
])), {
|
|
68
|
+
events: {
|
|
69
|
+
subscribe,
|
|
70
|
+
on: (name, handler, options) => {
|
|
71
|
+
const controller = new AbortController();
|
|
72
|
+
const signal = options?.signal ? AbortSignal.any([controller.signal, options.signal]) : controller.signal;
|
|
73
|
+
const source = subscribe(name, { signal });
|
|
74
|
+
void (async () => {
|
|
75
|
+
for await (const event of source)
|
|
76
|
+
await handler(event);
|
|
77
|
+
})().catch((error) => console.error(error));
|
|
78
|
+
return () => controller.abort();
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
function eventType(definition, name) {
|
|
85
|
+
return `rpc.${definition.id}.${name}`;
|
|
86
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { DiscoverOptions, Endpoint, EnsureOptions, StopOptions } from "../service.js";
|
|
2
|
+
export * from "../service.js";
|
|
3
|
+
/** Discover a healthy, compatible local service without starting one. */
|
|
4
|
+
export declare function discover(options?: DiscoverOptions): Promise<{
|
|
5
|
+
url: string;
|
|
6
|
+
auth: {
|
|
7
|
+
type: "basic";
|
|
8
|
+
username: string;
|
|
9
|
+
password: string;
|
|
10
|
+
} | undefined;
|
|
11
|
+
} | undefined>;
|
|
12
|
+
/** Ensure a healthy, compatible local service is running. */
|
|
13
|
+
export declare function ensure(options?: EnsureOptions): Promise<Endpoint>;
|
|
14
|
+
/** Stop the registered local service. */
|
|
15
|
+
export declare function stop(options?: StopOptions): Promise<void>;
|
|
16
|
+
/** Create HTTP authentication headers for a service endpoint. */
|
|
17
|
+
export declare function headers(endpoint: Endpoint): {
|
|
18
|
+
authorization: string;
|
|
19
|
+
} | undefined;
|
|
20
|
+
/** Promise-based local service lifecycle operations. */
|
|
21
|
+
export declare const Service: {
|
|
22
|
+
discover: typeof discover;
|
|
23
|
+
ensure: typeof ensure;
|
|
24
|
+
stop: typeof stop;
|
|
25
|
+
headers: typeof headers;
|
|
26
|
+
};
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { readFile, rm } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { contenderFailure, contenderFinished, spawnServiceContender, } from "../service-contender.js";
|
|
5
|
+
import { defaultEnsureTiming, ensureTiming } from "../service-timing.js";
|
|
6
|
+
import { matchesVersion } from "../service-version.js";
|
|
7
|
+
import { PtyHandoff } from "../pty-handoff.js";
|
|
8
|
+
export * from "../service.js";
|
|
9
|
+
// Find, start, and stop the local opencode background service.
|
|
10
|
+
//
|
|
11
|
+
// The registration file is the complete discovery contract. This module is
|
|
12
|
+
// intentionally implemented with Node APIs so Promise clients do not need
|
|
13
|
+
// Effect or @effect/platform-node at runtime.
|
|
14
|
+
/** Discover a healthy, compatible local service without starting one. */
|
|
15
|
+
export async function discover(options = {}) {
|
|
16
|
+
const found = (await registered(options.file)).service;
|
|
17
|
+
if (found?.state !== "ready")
|
|
18
|
+
return undefined;
|
|
19
|
+
if (!matchesVersion(found.version, options))
|
|
20
|
+
return undefined;
|
|
21
|
+
return found.endpoint;
|
|
22
|
+
}
|
|
23
|
+
/** Ensure a healthy, compatible local service is running. */
|
|
24
|
+
export async function ensure(options = {}) {
|
|
25
|
+
const timing = ensureTiming(options);
|
|
26
|
+
const deadline = Date.now() + timing.promiseTimeout;
|
|
27
|
+
const contenders = new Set();
|
|
28
|
+
let timeouts;
|
|
29
|
+
let announced = false;
|
|
30
|
+
let lastSpawn = 0;
|
|
31
|
+
let spawnDelay = timing.spawnDelay;
|
|
32
|
+
const announce = (reason, previousVersion) => {
|
|
33
|
+
if (announced)
|
|
34
|
+
return;
|
|
35
|
+
announced = true;
|
|
36
|
+
options.onStart?.(reason, previousVersion);
|
|
37
|
+
};
|
|
38
|
+
const spawnContender = async () => {
|
|
39
|
+
const [command, ...args] = options.command ?? ["opencode", "serve", "--service"];
|
|
40
|
+
if (command === undefined)
|
|
41
|
+
throw new Error("Missing service command");
|
|
42
|
+
try {
|
|
43
|
+
return spawnServiceContender(command, args, await PtyHandoff.environment(options.file ?? fallback(), options.env));
|
|
44
|
+
}
|
|
45
|
+
catch (cause) {
|
|
46
|
+
throw new Error("Failed to start server", { cause });
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
try {
|
|
50
|
+
while (true) {
|
|
51
|
+
if (Date.now() >= deadline)
|
|
52
|
+
throw new Error("Timed out waiting for the background service to start");
|
|
53
|
+
const registration = await registered(options.file, true, timing.requestTimeout);
|
|
54
|
+
if (registration.timedOut && registration.info !== undefined) {
|
|
55
|
+
timeouts = {
|
|
56
|
+
info: registration.info,
|
|
57
|
+
count: timeouts !== undefined && same(timeouts.info, registration.info) ? timeouts.count + 1 : 1,
|
|
58
|
+
};
|
|
59
|
+
if (timeouts.count >= 3) {
|
|
60
|
+
announce("missing");
|
|
61
|
+
console.warn("Background service is unresponsive; recovery cannot preserve persistent terminals");
|
|
62
|
+
await PtyHandoff.clear(options.file ?? fallback());
|
|
63
|
+
await terminate(registration.info, options, timing);
|
|
64
|
+
timeouts = undefined;
|
|
65
|
+
lastSpawn = Date.now() - spawnDelay;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
else
|
|
69
|
+
timeouts = undefined;
|
|
70
|
+
if (registration.service !== undefined) {
|
|
71
|
+
spawnDelay = timing.spawnDelay;
|
|
72
|
+
const service = registration.service;
|
|
73
|
+
const compatible = !service.legacy && matchesVersion(service.version, options);
|
|
74
|
+
if (compatible && service.state === "ready") {
|
|
75
|
+
await PtyHandoff.complete(options.file ?? fallback(), service.info);
|
|
76
|
+
return service.endpoint;
|
|
77
|
+
}
|
|
78
|
+
if (compatible && service.state === "failed")
|
|
79
|
+
throw new Error("Background service failed to start");
|
|
80
|
+
if (!compatible) {
|
|
81
|
+
announce("version-mismatch", service.version);
|
|
82
|
+
if (!service.legacy && service.state !== "ready")
|
|
83
|
+
console.warn("Background service is not ready; replacement cannot preserve persistent terminals");
|
|
84
|
+
await stop({
|
|
85
|
+
file: options.file,
|
|
86
|
+
pty: !service.legacy && service.state === "ready" ? "handoff" : "clear",
|
|
87
|
+
}).catch(() => undefined);
|
|
88
|
+
lastSpawn = 0;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
if (lastSpawn === 0 && registration.info !== undefined)
|
|
93
|
+
lastSpawn = Date.now();
|
|
94
|
+
const finished = [...contenders].filter(contenderFinished);
|
|
95
|
+
const failure = finished.map(contenderFailure).find((error) => error !== undefined);
|
|
96
|
+
if (finished.some((item) => item.child.exitCode === 0)) {
|
|
97
|
+
spawnDelay = Math.min(spawnDelay * 2, timing.maxSpawnDelay);
|
|
98
|
+
}
|
|
99
|
+
finished.forEach((item) => contenders.delete(item));
|
|
100
|
+
if (failure !== undefined && contenders.size === 0)
|
|
101
|
+
throw failure;
|
|
102
|
+
// Keep one candidate plus one lock probe so a pre-lock stall cannot block recovery.
|
|
103
|
+
if (contenders.size < 2 && Date.now() - lastSpawn >= spawnDelay) {
|
|
104
|
+
announce("missing");
|
|
105
|
+
contenders.add(await spawnContender());
|
|
106
|
+
lastSpawn = Date.now();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
await delay(timing.pollInterval);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
finally {
|
|
113
|
+
contenders.forEach((contender) => contender.release());
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/** Stop the registered local service. */
|
|
117
|
+
export async function stop(options = {}) {
|
|
118
|
+
const info = await read(options.file);
|
|
119
|
+
if (options.pty === "handoff" && info !== undefined)
|
|
120
|
+
await PtyHandoff.prepare(options.file ?? fallback(), info, defaultEnsureTiming.requestTimeout);
|
|
121
|
+
else
|
|
122
|
+
await PtyHandoff.clear(options.file ?? fallback());
|
|
123
|
+
if (info !== undefined)
|
|
124
|
+
await terminate(info, options, defaultEnsureTiming);
|
|
125
|
+
}
|
|
126
|
+
function fallback() {
|
|
127
|
+
return join(process.env["XDG_STATE_HOME"] ?? join(homedir(), ".local", "state"), "opencode", "service.json");
|
|
128
|
+
}
|
|
129
|
+
/** Create HTTP authentication headers for a service endpoint. */
|
|
130
|
+
export function headers(endpoint) {
|
|
131
|
+
if (endpoint.auth === undefined)
|
|
132
|
+
return undefined;
|
|
133
|
+
return {
|
|
134
|
+
authorization: "Basic " + Buffer.from(endpoint.auth.username + ":" + endpoint.auth.password).toString("base64"),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
async function read(file) {
|
|
138
|
+
const text = await readFile(file ?? fallback(), "utf8").catch(() => undefined);
|
|
139
|
+
if (text === undefined)
|
|
140
|
+
return undefined;
|
|
141
|
+
try {
|
|
142
|
+
return JSON.parse(text);
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
return undefined;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
async function probeResult(info, allowLegacy = false, timeout = defaultEnsureTiming.requestTimeout) {
|
|
149
|
+
const endpoint = {
|
|
150
|
+
url: info.url,
|
|
151
|
+
auth: info.password === undefined
|
|
152
|
+
? undefined
|
|
153
|
+
: { type: "basic", username: "opencode", password: info.password },
|
|
154
|
+
};
|
|
155
|
+
const signal = AbortSignal.timeout(timeout);
|
|
156
|
+
const result = await fetch(new URL("/api/health", info.url), {
|
|
157
|
+
headers: headers(endpoint),
|
|
158
|
+
signal,
|
|
159
|
+
})
|
|
160
|
+
.then(async (response) => ({
|
|
161
|
+
response,
|
|
162
|
+
body: (await response.json()),
|
|
163
|
+
}))
|
|
164
|
+
.then((value) => ({ value }), (cause) => ({ cause }));
|
|
165
|
+
if ("cause" in result)
|
|
166
|
+
return { service: undefined, timedOut: signal.aborted };
|
|
167
|
+
const response = result.value.response;
|
|
168
|
+
const body = result.value.body;
|
|
169
|
+
if (body !== undefined && "version" in body && "pid" in body) {
|
|
170
|
+
if (body.pid !== info.pid)
|
|
171
|
+
return { service: undefined, timedOut: false };
|
|
172
|
+
if (info.version !== undefined && body.version !== info.version)
|
|
173
|
+
return { service: undefined, timedOut: false };
|
|
174
|
+
return {
|
|
175
|
+
service: {
|
|
176
|
+
info,
|
|
177
|
+
endpoint,
|
|
178
|
+
version: body.version,
|
|
179
|
+
state: response.ok ? "ready" : response.status === 500 ? "failed" : "waiting",
|
|
180
|
+
legacy: false,
|
|
181
|
+
},
|
|
182
|
+
timedOut: false,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
if (!allowLegacy || body?.healthy !== true)
|
|
186
|
+
return { service: undefined, timedOut: false };
|
|
187
|
+
return {
|
|
188
|
+
service: { info, endpoint, state: "ready", legacy: true },
|
|
189
|
+
timedOut: false,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
async function registered(file, allowLegacy = false, timeout) {
|
|
193
|
+
const info = await read(file);
|
|
194
|
+
if (info === undefined)
|
|
195
|
+
return { info: undefined, service: undefined, timedOut: false };
|
|
196
|
+
return { info, ...(await probeResult(info, allowLegacy, timeout)) };
|
|
197
|
+
}
|
|
198
|
+
function signal(pid, name) {
|
|
199
|
+
try {
|
|
200
|
+
process.kill(pid, name);
|
|
201
|
+
}
|
|
202
|
+
catch { }
|
|
203
|
+
}
|
|
204
|
+
function stopped(pid) {
|
|
205
|
+
try {
|
|
206
|
+
process.kill(pid, 0);
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
async function waitUntilStopped(pid, timing) {
|
|
214
|
+
for (let attempt = 0; attempt <= timing.stopPollAttempts; attempt++) {
|
|
215
|
+
if (stopped(pid))
|
|
216
|
+
return true;
|
|
217
|
+
if (attempt < timing.stopPollAttempts)
|
|
218
|
+
await delay(timing.stopPollInterval);
|
|
219
|
+
}
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
function same(left, right) {
|
|
223
|
+
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid;
|
|
224
|
+
}
|
|
225
|
+
async function terminate(info, options, timing) {
|
|
226
|
+
const current = await read(options.file);
|
|
227
|
+
if (current === undefined || !same(current, info))
|
|
228
|
+
return;
|
|
229
|
+
signal(info.pid, "SIGTERM");
|
|
230
|
+
if (!(await waitUntilStopped(info.pid, timing))) {
|
|
231
|
+
const latest = await read(options.file);
|
|
232
|
+
if (latest === undefined || !same(latest, info))
|
|
233
|
+
return;
|
|
234
|
+
signal(info.pid, "SIGKILL");
|
|
235
|
+
if (!(await waitUntilStopped(info.pid, timing)))
|
|
236
|
+
throw new Error(`Server process ${info.pid} is still running`);
|
|
237
|
+
}
|
|
238
|
+
const latest = await read(options.file);
|
|
239
|
+
if (latest === undefined || !same(latest, info))
|
|
240
|
+
return;
|
|
241
|
+
await rm(options.file ?? fallback(), { force: true });
|
|
242
|
+
}
|
|
243
|
+
function delay(milliseconds) {
|
|
244
|
+
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
245
|
+
}
|
|
246
|
+
/** Promise-based local service lifecycle operations. */
|
|
247
|
+
export const Service = { discover, ensure, stop, headers };
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export * as PtyHandoff from "./pty-handoff.js";
|
|
2
|
+
import type { Info } from "./service.js";
|
|
3
|
+
/** Publish the ticket before stopping its owner so every replacement contender can adopt it. */
|
|
4
|
+
export declare function prepare(file: string, info: Info, timeout: number): Promise<void>;
|
|
5
|
+
export declare function environment(file: string, env?: Readonly<Record<string, string>>): Promise<{
|
|
6
|
+
OPENCODE_PTY_HANDOFF: string | undefined;
|
|
7
|
+
}>;
|
|
8
|
+
export declare function complete(file: string, info: Info): Promise<void>;
|
|
9
|
+
export declare function clear(file: string): Promise<void>;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
export * as PtyHandoff from "./pty-handoff.js";
|
|
2
|
+
import { readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
/** Publish the ticket before stopping its owner so every replacement contender can adopt it. */
|
|
4
|
+
export async function prepare(file, info, timeout) {
|
|
5
|
+
const existing = await read(file);
|
|
6
|
+
if (existing !== undefined && existing.expiresAt > Date.now() && same(existing.source, info))
|
|
7
|
+
return;
|
|
8
|
+
const { ClientError, OpenCode } = await import("./promise/index.js");
|
|
9
|
+
const client = OpenCode.make({
|
|
10
|
+
baseUrl: info.url,
|
|
11
|
+
headers: info.password === undefined
|
|
12
|
+
? undefined
|
|
13
|
+
: { authorization: "Basic " + Buffer.from(`opencode:${info.password}`).toString("base64") },
|
|
14
|
+
});
|
|
15
|
+
const missing = (error) => error instanceof ClientError &&
|
|
16
|
+
error.reason === "UnexpectedStatus" &&
|
|
17
|
+
typeof error.cause === "object" &&
|
|
18
|
+
error.cause !== null &&
|
|
19
|
+
"status" in error.cause &&
|
|
20
|
+
error.cause.status === 404;
|
|
21
|
+
const result = await client.experimental.persistentPty.handoff({ signal: AbortSignal.timeout(timeout) }).then((value) => ({ value }), (cause) => ({ cause }));
|
|
22
|
+
if ("cause" in result) {
|
|
23
|
+
// Another caller may already have prepared and stopped this server.
|
|
24
|
+
const concurrent = await read(file);
|
|
25
|
+
if (concurrent !== undefined && concurrent.expiresAt > Date.now() && same(concurrent.source, info))
|
|
26
|
+
return;
|
|
27
|
+
if (!missing(result.cause))
|
|
28
|
+
throw new Error("Failed to prepare persistent terminals for service replacement", { cause: result.cause });
|
|
29
|
+
console.warn("Background service cannot hand off persistent terminals; shutting them down before replacement");
|
|
30
|
+
await client.experimental.persistentPty
|
|
31
|
+
.shutdown({ signal: AbortSignal.timeout(timeout) })
|
|
32
|
+
.catch((cause) => {
|
|
33
|
+
if (missing(cause))
|
|
34
|
+
return;
|
|
35
|
+
throw new Error("Failed to shut down persistent terminals before service replacement", { cause });
|
|
36
|
+
});
|
|
37
|
+
await publish(file, info, null);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const body = result.value;
|
|
41
|
+
if (typeof body !== "object" || body === null || !("handoff" in body))
|
|
42
|
+
throw new Error("Invalid persistent terminal handoff response");
|
|
43
|
+
if (body.handoff === null) {
|
|
44
|
+
await publish(file, info, null);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (!isHandoff(body.handoff) || body.handoff.expiresAt <= Date.now())
|
|
48
|
+
throw new Error("Invalid or expired persistent terminal handoff");
|
|
49
|
+
await publish(file, info, body.handoff);
|
|
50
|
+
}
|
|
51
|
+
async function publish(file, info, handoff) {
|
|
52
|
+
const temporary = `${file}.pty-handoff.${crypto.randomUUID()}.tmp`;
|
|
53
|
+
await writeFile(temporary, JSON.stringify({
|
|
54
|
+
source: { id: info.id, pid: info.pid, url: info.url },
|
|
55
|
+
handoff,
|
|
56
|
+
expiresAt: handoff?.expiresAt ?? Date.now() + 30_000,
|
|
57
|
+
}), { mode: 0o600, flag: "wx" });
|
|
58
|
+
await rename(temporary, file + ".pty-handoff").finally(() => rm(temporary, { force: true }));
|
|
59
|
+
}
|
|
60
|
+
export async function environment(file, env) {
|
|
61
|
+
const record = await read(file);
|
|
62
|
+
const current = await readFile(file, "utf8")
|
|
63
|
+
.then((text) => JSON.parse(text))
|
|
64
|
+
.catch(() => undefined);
|
|
65
|
+
const handoff = record !== undefined && record.expiresAt > Date.now() && (current === undefined || same(record.source, current))
|
|
66
|
+
? record.handoff
|
|
67
|
+
: undefined;
|
|
68
|
+
return { ...env, OPENCODE_PTY_HANDOFF: handoff == null ? undefined : JSON.stringify(handoff) };
|
|
69
|
+
}
|
|
70
|
+
export async function complete(file, info) {
|
|
71
|
+
const record = await read(file);
|
|
72
|
+
if (record !== undefined && !same(record.source, info))
|
|
73
|
+
await clear(file);
|
|
74
|
+
}
|
|
75
|
+
export async function clear(file) {
|
|
76
|
+
await rm(file + ".pty-handoff", { force: true });
|
|
77
|
+
}
|
|
78
|
+
async function read(file) {
|
|
79
|
+
const value = await readFile(file + ".pty-handoff", "utf8")
|
|
80
|
+
.then((text) => JSON.parse(text))
|
|
81
|
+
.catch(() => undefined);
|
|
82
|
+
if (typeof value !== "object" || value === null || !("source" in value) || !("handoff" in value))
|
|
83
|
+
return;
|
|
84
|
+
if (typeof value.source !== "object" || value.source === null)
|
|
85
|
+
return;
|
|
86
|
+
if (!("pid" in value.source) || typeof value.source.pid !== "number")
|
|
87
|
+
return;
|
|
88
|
+
if (!("url" in value.source) || typeof value.source.url !== "string")
|
|
89
|
+
return;
|
|
90
|
+
if ("id" in value.source && typeof value.source.id !== "string")
|
|
91
|
+
return;
|
|
92
|
+
if (value.handoff !== null && !isHandoff(value.handoff))
|
|
93
|
+
return;
|
|
94
|
+
if (!("expiresAt" in value) || typeof value.expiresAt !== "number" || !Number.isFinite(value.expiresAt))
|
|
95
|
+
return;
|
|
96
|
+
return {
|
|
97
|
+
source: {
|
|
98
|
+
id: "id" in value.source && typeof value.source.id === "string" ? value.source.id : undefined,
|
|
99
|
+
pid: value.source.pid,
|
|
100
|
+
url: value.source.url,
|
|
101
|
+
},
|
|
102
|
+
handoff: value.handoff,
|
|
103
|
+
expiresAt: value.expiresAt,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
function same(left, right) {
|
|
107
|
+
return left.id === right.id && left.pid === right.pid && left.url === right.url;
|
|
108
|
+
}
|
|
109
|
+
function isHandoff(value) {
|
|
110
|
+
return (typeof value === "object" &&
|
|
111
|
+
value !== null &&
|
|
112
|
+
"directory" in value &&
|
|
113
|
+
typeof value.directory === "string" &&
|
|
114
|
+
"instanceID" in value &&
|
|
115
|
+
typeof value.instanceID === "string" &&
|
|
116
|
+
"ticket" in value &&
|
|
117
|
+
typeof value.ticket === "string" &&
|
|
118
|
+
"expiresAt" in value &&
|
|
119
|
+
typeof value.expiresAt === "number" &&
|
|
120
|
+
Number.isFinite(value.expiresAt));
|
|
121
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export * as RpcRuntime from "./rpc-runtime.js";
|
|
2
|
+
import type { Rpc } from "@opencode/schema/rpc";
|
|
3
|
+
import { Effect, Schema } from "effect";
|
|
4
|
+
export declare function read(schema: Rpc.Method["output"], value: unknown): Effect.Effect<unknown, Schema.SchemaError, never>;
|
|
5
|
+
export declare function readError(method: Rpc.Method, error: unknown): Effect.Effect<never, unknown>;
|
|
6
|
+
export declare const event: <D extends Rpc.Definition, Name extends keyof D["events"] & string>(definition: D, name: Name, schema: Rpc.EventDefinition, event: {
|
|
7
|
+
readonly id: string & import("effect/Brand").Brand<"Event.ID">;
|
|
8
|
+
readonly location: {
|
|
9
|
+
readonly directory: string & import("effect/Brand").Brand<"AbsolutePath">;
|
|
10
|
+
readonly workspaceID?: (string & import("effect/Brand").Brand<"Workspace.ID">) | undefined;
|
|
11
|
+
};
|
|
12
|
+
readonly data: {
|
|
13
|
+
readonly [x: string]: unknown;
|
|
14
|
+
};
|
|
15
|
+
readonly type: `rpc.${string}`;
|
|
16
|
+
readonly created: number;
|
|
17
|
+
readonly metadata?: {
|
|
18
|
+
readonly [x: string]: unknown;
|
|
19
|
+
} | undefined;
|
|
20
|
+
}) => Effect.Effect<Rpc.EventPayload<D, Name>, unknown, never>;
|
|
21
|
+
export declare function eventType<const D extends Rpc.Definition, const Name extends keyof D["events"] & string>(definition: D, name: Name): `rpc.${D["id"]}.${Name}`;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export * as RpcRuntime from "./rpc-runtime.js";
|
|
2
|
+
import { RpcError, RpcInternalError } from "@opencode/protocol/errors";
|
|
3
|
+
import { Effect, Schema } from "effect";
|
|
4
|
+
export function read(schema, value) {
|
|
5
|
+
// Standard Schema results have already been parsed by the server.
|
|
6
|
+
return Schema.isSchema(schema) ? Schema.decodeUnknownEffect(schema)(value) : Effect.succeed(value);
|
|
7
|
+
}
|
|
8
|
+
export function readError(method, error) {
|
|
9
|
+
if (!(error instanceof RpcError) && !(error instanceof RpcInternalError))
|
|
10
|
+
return Effect.fail(error);
|
|
11
|
+
if (!method.errors || !Object.hasOwn(method.errors, error.type)) {
|
|
12
|
+
return Effect.fail(error.data === undefined
|
|
13
|
+
? { type: error.type, message: error.message }
|
|
14
|
+
: { type: error.type, message: error.message, data: error.data });
|
|
15
|
+
}
|
|
16
|
+
return read(method.errors[error.type], error.data).pipe(Effect.catch((cause) => Effect.die(cause)), Effect.flatMap((data) => Effect.fail(data === undefined
|
|
17
|
+
? { type: error.type, message: error.message }
|
|
18
|
+
: { type: error.type, message: error.message, data })));
|
|
19
|
+
}
|
|
20
|
+
export const event = Effect.fn("Client.Rpc.event")(function* (definition, name, schema, event) {
|
|
21
|
+
const data = yield* read(schema.schema, event.data);
|
|
22
|
+
// SAFETY: The event type was selected by the caller and data was decoded with this event's schema.
|
|
23
|
+
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
|
|
24
|
+
return {
|
|
25
|
+
...event,
|
|
26
|
+
type: eventType(definition, name),
|
|
27
|
+
data,
|
|
28
|
+
};
|
|
29
|
+
});
|
|
30
|
+
export function eventType(definition, name) {
|
|
31
|
+
return `rpc.${definition.id}.${name}`;
|
|
32
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type ChildProcess } from "node:child_process";
|
|
2
|
+
export type ServiceContender = {
|
|
3
|
+
readonly child: ChildProcess;
|
|
4
|
+
readonly error: () => Error | undefined;
|
|
5
|
+
readonly closed: () => boolean;
|
|
6
|
+
readonly stderr: () => string;
|
|
7
|
+
readonly release: () => void;
|
|
8
|
+
};
|
|
9
|
+
export declare function spawnServiceContender(command: string, args: ReadonlyArray<string>, env?: Readonly<Record<string, string | undefined>>): ServiceContender;
|
|
10
|
+
export declare function contenderFailure(contender: ServiceContender): Error | undefined;
|
|
11
|
+
export declare function contenderFinished(contender: ServiceContender): boolean;
|