Skip to content
Source
Sign inSign up

OpenCode2Service.ts

118 lines4.8 KB

1import { Service } from "@opencode/client/effect/service";
2import { AgentScope } from "@t3tools/shared/AgentScope";
3import * as HostProcess from "@t3tools/shared/HostProcess";
4import { resolveCommandPath, resolveSpawnCommand } from "@t3tools/shared/shell";
5import * as Effect from "effect/Effect";
6import * as FileSystem from "effect/FileSystem";
7import * as Option from "effect/Option";
8import * as Path from "effect/Path";
9import * as Schema from "effect/Schema";
10​
11import { probeOpenCodeBinary } from "../versionProbe.ts";
12​
13const decodeServiceConfig = Schema.decodeUnknownOption(
14 Schema.fromJsonString(
15 Schema.Struct({ env: Schema.optional(Schema.Record(Schema.String, Schema.String)) }),
16 ),
17);
18​
19function isRegisteredService(registeredUrl: string, serverUrl: string): boolean {
20 const registered = URL.parse(registeredUrl);
21 const configured = URL.parse(serverUrl);
22 if (
23 registered === null ||
24 configured === null ||
25 registered.protocol !== configured.protocol ||
26 registered.port !== configured.port
27 ) {
28 return false;
29 }
30 // A wildcard listener covers only its own address family's loopback; `localhost` could be either.
31 return (
32 registered.hostname === configured.hostname ||
33 (registered.hostname === "0.0.0.0" && configured.hostname === "127.0.0.1") ||
34 (registered.hostname === "[::]" && configured.hostname === "[::1]")
35 );
37​
38/**
39 * Replaces the way the TUI's restart does, which hands persistent terminals to the new process.
40 * `opencode service restart` shuts them down instead.
41 */
42export const replaceOutdatedService = Effect.fn("OpenCode2Service.replaceOutdatedService")(
43 function* (input: {
44 readonly binaryPath: string;
45 readonly serverUrl: string;
46 readonly environment: NodeJS.ProcessEnv;
47 }) {
48 const { generation, version } = yield* probeOpenCodeBinary(input.binaryPath, input.environment);
49 if (generation !== "v2") return;
50​
51 const path = yield* Path.Path;
52 const fileSystem = yield* FileSystem.FileSystem;
53 const home = input.environment.HOME ?? (yield* HostProcess.HomeDirectory);
54 const file = path.join(
55 input.environment.XDG_STATE_HOME ?? path.join(home, ".local", "state"),
56 "opencode",
57 "service.json",
58 );
59 // `Service.incumbent` would send the local service's password to the configured URL, which may
60 // be another machine. `discover` only contacts the address in the local registration.
61 const service = yield* Service.discover({ file });
62 if (service === undefined || !isRegisteredService(service.url, input.serverUrl)) return;
63​
64 // Configured variables below can change PATH, which a bare name would then resolve against.
65 const executable = yield* resolveCommandPath(input.binaryPath, { env: input.environment });
66 if (executable === null) return;
67 // A scope of its own keeps the service alive when T3 Code's systemd unit stops.
68 const launch = yield* (yield* AgentScope).wrap({
69 command: executable,
70 args: ["serve", "--service"],
71 name: "opencode-service",
72 env: input.environment,
73 shared: true,
74 survivesRestart: true,
75 });
76 const command = yield* resolveSpawnCommand(launch.command, launch.args, {
77 env: input.environment,
78 extendEnv: true,
79 bypassNpmShim: true,
80 });
81 // The service spawns without a shell; stopping it first would leave nothing running.
82 if (command.shell) {
83 return yield* Effect.logWarning("OpenCode service cannot be replaced through a shell shim");
84 }
85​
86 const config = yield* fileSystem
87 .readFileString(
88 path.join(
89 input.environment.OPENCODE_CONFIG_DIR ??
90 path.join(input.environment.XDG_CONFIG_HOME ?? path.join(home, ".config"), "opencode"),
91 "service.json",
92 ),
93 )
94 .pipe(Effect.option, Effect.map(Option.flatMap(decodeServiceConfig)));
95 // The CLI passes configured variables but never lets them move the registration file.
96 const { XDG_STATE_HOME: _state, ...configured } = Option.getOrUndefined(config)?.env ?? {};
97 const env: Record<string, string> = {};
98 for (const [name, value] of Object.entries({ ...input.environment, ...configured })) {
99 if (value !== undefined) env[name] = value;
100 }
101​
102 if ((yield* Service.discover({ file, version })) !== undefined) return;
103​
104 // `ensure` with a version would kill every new service whose version never matches the probe,
105 // and being interrupted between the stop and the start would leave no service running.
106 yield* Service.stop({ file, pty: "handoff" }).pipe(
107 Effect.andThen(
108 Service.ensure({
109 file,
110 command: [command.command, ...command.args],
111 // T3 Desktop's backend sets this; undefined overrides the inherited value at spawn.
112 env: { ...env, ELECTRON_RUN_AS_NODE: undefined } as unknown as Record<string, string>,
113 }),
114 ),
115 Effect.uninterruptible,
116 );
117 },
118);