Skip to content

Commit 4bc731e

Browse files
sanchitrkclaude
andcommitted
feat(cli): background server for codework tui
`codework tui` now finds or starts a background server and attaches the Rust TUI to it: - Discovery reads <home>/service.json and trusts it only when the endpoint answers `server.info` with the registered pid. A server from another CLI version is replaced. Otherwise a detached `codework serve --service` is started, logging to <home>/log/server.log, and startup fails fast with the reason from that log. - Concurrent starts converge on one server: the loser adopts the winner, and a contender that is not adopted is stopped with the caller's scope. - A registered server rewrites a missing service.json and shuts down when another server takes it over, so no server runs undiscoverable. - While the TUI runs, the CLI restarts a server that dies on the same port so the TUI's reconnect lands. - The TUI is spawned through Effect's ChildProcessSpawner in the CLI's scope, so SIGINT/SIGTERM to `codework tui` now stop the UI instead of orphaning it. Adds `codework service start|restart|status|stop`, moves the RPC route to /rpc/v1, and shows the cause of a failed `serve` (e.g. EADDRINUSE) instead of a bare tag. The TUI launcher package now only resolves the binary, Unix only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent c33d610 commit 4bc731e

28 files changed

Lines changed: 816 additions & 358 deletions

‎packages/codework/README.md‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ codework run --sandbox-driver daytona --sandbox-provider-id <remote-id> "Continu
4949

5050
#### Flags:
5151

52-
- `--server <url>` — Connect to a running RPC server, e.g. `ws://127.0.0.1:7433/rpc`.
52+
- `--server <url>` — Connect to a running RPC server, e.g. `ws://127.0.0.1:7433/rpc/v1`.
5353
- `-s, --session <id>` — Continue an existing session.
5454
- `-C, --cwd <path>` — Working directory for a new session.
5555
- `--sandbox-driver <driver>` — Sandbox driver (`local`, `daytona`, `vercel`, `memory`, `sqldb`). Default: `local`.
@@ -69,9 +69,9 @@ Start a persistent server that owns sessions and sandboxes:
6969

7070
```bash
7171
codework serve --host 127.0.0.1 --port 7433
72-
codework run --server ws://127.0.0.1:7433/rpc "Inspect the failing tests"
73-
codework run --server ws://127.0.0.1:7433/rpc --session <id> "Now fix them"
74-
codework run --server ws://127.0.0.1:7433/rpc --sandbox-id <sandbox-id> "Start another session in this sandbox"
72+
codework run --server ws://127.0.0.1:7433/rpc/v1 "Inspect the failing tests"
73+
codework run --server ws://127.0.0.1:7433/rpc/v1 --session <id> "Now fix them"
74+
codework run --server ws://127.0.0.1:7433/rpc/v1 --sandbox-id <sandbox-id> "Start another session in this sandbox"
7575
```
7676

7777
The default address is `127.0.0.1:7433`; `--port 0` picks an available port and prints the actual URL. Set `--home`, `--database`, and `--user-config-dir` on the server. Model credentials, plugins, and working directories are resolved by the server. A remote run does not boot a local harness.
@@ -105,7 +105,7 @@ codework auth refresh --openai-codex
105105
codework auth logout --github-copilot
106106

107107
# Authenticate the home owned by a running RPC server
108-
codework auth login --openai-codex --server ws://127.0.0.1:7433/rpc
108+
codework auth login --openai-codex --server ws://127.0.0.1:7433/rpc/v1
109109
```
110110

111111
`--home` keeps authentication alongside the rest of that Codework installation; `--auth-file` overrides the credential

‎packages/codework/src/cli/cmd/cmd.ts‎

Lines changed: 35 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,26 @@ const AuthParams = {
2323
Flag.withDescription("Use GitHub Copilot OAuth (device flow)"),
2424
Flag.withDefault(false),
2525
),
26-
server: Flag.String("server").pipe(Flag.withDescription("RPC server URL (ws://host:port/rpc)"), Flag.optional),
26+
server: Flag.String("server").pipe(Flag.withDescription("RPC server URL (ws://host:port/rpc/v1)"), Flag.optional),
2727
authFile: Flag.String("auth-file").pipe(
2828
Flag.withDescription("Credential file (defaults to <home>/aikit/auth.json)"),
2929
Flag.optional,
3030
),
3131
};
3232

33+
/** Where the background server binds when `tui` or `service` has to start one. */
34+
const ServerAddress = {
35+
host: Flag.String("host").pipe(
36+
Flag.withDescription("Host interface the background server binds"),
37+
Flag.withDefault("127.0.0.1"),
38+
),
39+
port: Flag.Int("port").pipe(
40+
Flag.withSchema(Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 65535 }))),
41+
Flag.withDescription("Port the background server listens on"),
42+
Flag.withDefault(7433),
43+
),
44+
};
45+
3346
const JsonFlag = Flag.Boolean("json").pipe(
3447
Flag.withDescription("Print machine-readable output"),
3548
Flag.withDefault(false),
@@ -82,7 +95,7 @@ export const Cmd = Spec.make("codework", {
8295
description: "Sign in with a device code, no local port",
8396
},
8497
{
85-
command: "codework auth login --openai-codex --server ws://127.0.0.1:7433/rpc",
98+
command: "codework auth login --openai-codex --server ws://127.0.0.1:7433/rpc/v1",
8699
description: "Sign in the running server",
87100
},
88101
],
@@ -108,7 +121,7 @@ export const Cmd = Spec.make("codework", {
108121
description: "Start or continue an agent session",
109122
params: {
110123
server: Flag.String("server").pipe(
111-
Flag.withDescription("RPC server URL (ws://host:port/rpc)"),
124+
Flag.withDescription("RPC server URL (ws://host:port/rpc/v1)"),
112125
Flag.optional,
113126
),
114127
prompt: Argument.String("prompt").pipe(Argument.withDescription("Prompt for the agent")),
@@ -162,29 +175,21 @@ export const Cmd = Spec.make("codework", {
162175
],
163176
}),
164177
Spec.make("tui", {
165-
description: "Launch the CodeWork terminal UI against a managed server",
178+
description: "Launch the CodeWork terminal UI against the background server",
166179
params: {
167180
server: Flag.String("server").pipe(
168-
Flag.withDescription("RPC server URL to use instead of a managed one (ws://host:port/rpc)"),
181+
Flag.withDescription("RPC server URL to use instead of the background server (ws://host:port/rpc/v1)"),
169182
Flag.optional,
170183
),
171-
host: Flag.String("host").pipe(
172-
Flag.withDescription("Host interface a managed server binds"),
173-
Flag.withDefault("127.0.0.1"),
174-
),
175-
port: Flag.Int("port").pipe(
176-
Flag.withSchema(Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 65535 }))),
177-
Flag.withDescription("Port a managed server listens on"),
178-
Flag.withDefault(7433),
179-
),
184+
...ServerAddress,
180185
args: Argument.variadic(
181186
Argument.String("args").pipe(Argument.withDescription("Arguments forwarded to the terminal UI")),
182187
),
183188
},
184189
examples: [
185190
{ command: "codework tui", description: "Start or reuse the local server and open the terminal UI" },
186191
{
187-
command: "codework tui --server ws://127.0.0.1:7433/rpc",
192+
command: "codework tui --server ws://127.0.0.1:7433/rpc/v1",
188193
description: "Attach the terminal UI to a server you started yourself",
189194
},
190195
],
@@ -201,12 +206,27 @@ export const Cmd = Spec.make("codework", {
201206
Flag.withDescription("Port to listen on (0 for an ephemeral port)"),
202207
Flag.withDefault(7433),
203208
),
209+
service: Flag.Boolean("service").pipe(
210+
Flag.withDescription(
211+
"Run as the background server `codework tui` manages, logging to <home>/log/server.log",
212+
),
213+
Flag.withDefault(false),
214+
),
204215
},
205216
examples: [
206217
{ command: "codework serve", description: "Start the RPC server on 127.0.0.1:7433" },
207218
{ command: "codework serve --port 0", description: "Choose an available local port" },
208219
],
209220
}),
221+
Spec.make("service", {
222+
description: "Manage the background server",
223+
commands: [
224+
Spec.make("start", { description: "Start the background server", params: ServerAddress }),
225+
Spec.make("restart", { description: "Restart the background server", params: ServerAddress }),
226+
Spec.make("status", { description: "Show background server status" }),
227+
Spec.make("stop", { description: "Stop the background server" }),
228+
],
229+
}),
210230
Spec.make("session", {
211231
description: "Manage sessions",
212232
commands: [
Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,39 @@
1-
import { Effect, Layer } from "effect";
1+
import { Global } from "@codeworksh/harness/effect";
2+
import { Effect, FileSystem, Layer, Logger } from "effect";
23
import { Runtime } from "../../../framework/runtime.ts";
4+
import { Registration } from "../../../server/registration.ts";
35
import { Server } from "../../../server/server.ts";
46
import { renderError } from "../../error.ts";
57
import { harnessOptions } from "../../harness.ts";
68
import { writeError } from "../../output.ts";
79
import { Cmd } from "../cmd.ts";
810

11+
const failed = Effect.sync(() => {
12+
process.exitCode = 1;
13+
});
14+
15+
const report = (error: unknown) => writeError(renderError(error)).pipe(Effect.andThen(failed));
16+
917
export default Runtime.handler(
1018
Cmd.commands.serve,
11-
Effect.fn("CLI.serve")(function* ({ host, port }) {
19+
Effect.fn("CLI.serve")(function* ({ host, port, service }) {
1220
const shared = yield* Cmd.spec;
13-
yield* Layer.launch(Server.layer({ host, port, harness: { ...harnessOptions(shared), watchModels: true } })).pipe(
14-
Effect.catch((error) =>
15-
writeError(renderError(error)).pipe(
16-
Effect.andThen(
17-
Effect.sync(() => {
18-
process.exitCode = 1;
19-
}),
20-
),
21-
),
22-
),
23-
);
21+
const harness = { ...harnessOptions(shared), watchModels: true };
22+
const serve = Layer.launch(Server.layer({ host, port, harness }));
23+
if (!service) return yield* serve.pipe(Effect.catch(report));
24+
25+
// A background server has no terminal: its log, and the reason it stopped, go to a
26+
// file the CLI that started it reads back when startup fails.
27+
const background = Effect.gen(function* () {
28+
const paths = yield* Global.resolve(harness.home === undefined ? {} : { home: harness.home });
29+
const fs = yield* FileSystem.FileSystem;
30+
yield* fs.makeDirectory(paths.log, { recursive: true });
31+
const logger = Logger.formatSimple.pipe(Logger.toFile(Registration.log(paths.log), { flag: "w" }));
32+
yield* serve.pipe(
33+
Effect.catch((error) => Effect.logError(renderError(error).trim()).pipe(Effect.andThen(failed))),
34+
Effect.provide(Logger.layer([logger])),
35+
);
36+
});
37+
yield* background.pipe(Effect.catch(report));
2438
}),
2539
);
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { Effect } from "effect";
2+
import { Runtime } from "../../../../framework/runtime.ts";
3+
import { Discovery } from "../../../../server/discovery.ts";
4+
import { reportFailure } from "../../../error.ts";
5+
import { writeOut } from "../../../output.ts";
6+
import { Service } from "../../../service.ts";
7+
import { Cmd } from "../../cmd.ts";
8+
9+
export default Runtime.handler(
10+
Cmd.commands.service.commands.restart,
11+
Effect.fn("CLI.service.restart")(function* ({ host, port }) {
12+
const options = yield* Service.options(yield* Cmd.spec, { host, port });
13+
yield* Discovery.stop(options.home);
14+
const server = yield* Discovery.ensure({ ...options, replace: true });
15+
yield* writeOut(`${server.url}\n`);
16+
}, Effect.catch(reportFailure)),
17+
);
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { Effect } from "effect";
2+
import { Runtime } from "../../../../framework/runtime.ts";
3+
import { Discovery } from "../../../../server/discovery.ts";
4+
import { reportFailure } from "../../../error.ts";
5+
import { writeOut } from "../../../output.ts";
6+
import { Service } from "../../../service.ts";
7+
import { Cmd } from "../../cmd.ts";
8+
9+
export default Runtime.handler(
10+
Cmd.commands.service.commands.start,
11+
Effect.fn("CLI.service.start")(function* ({ host, port }) {
12+
const options = yield* Service.options(yield* Cmd.spec, { host, port });
13+
const server = yield* Discovery.ensure({ ...options, replace: true });
14+
yield* writeOut(`${server.url}\n`);
15+
}, Effect.catch(reportFailure)),
16+
);
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { Effect } from "effect";
2+
import { Runtime } from "../../../../framework/runtime.ts";
3+
import { Discovery } from "../../../../server/discovery.ts";
4+
import { reportFailure } from "../../../error.ts";
5+
import { writeOut } from "../../../output.ts";
6+
import { Service } from "../../../service.ts";
7+
import { Cmd } from "../../cmd.ts";
8+
9+
export default Runtime.handler(
10+
Cmd.commands.service.commands.status,
11+
Effect.fn("CLI.service.status")(function* () {
12+
const paths = yield* Service.home(yield* Cmd.spec);
13+
const server = yield* Discovery.discover(paths.home);
14+
yield* writeOut(`${server?.url ?? "stopped"}\n`);
15+
}, Effect.catch(reportFailure)),
16+
);
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { Effect } from "effect";
2+
import { Runtime } from "../../../../framework/runtime.ts";
3+
import { Discovery } from "../../../../server/discovery.ts";
4+
import { reportFailure } from "../../../error.ts";
5+
import { Service } from "../../../service.ts";
6+
import { Cmd } from "../../cmd.ts";
7+
8+
export default Runtime.handler(
9+
Cmd.commands.service.commands.stop,
10+
Effect.fn("CLI.service.stop")(function* () {
11+
const paths = yield* Service.home(yield* Cmd.spec);
12+
yield* Discovery.stop(paths.home);
13+
}, Effect.catch(reportFailure)),
14+
);

0 commit comments

Comments
 (0)