Skip to content

Commit 481cfac

Browse files
committed
Merge branch 'serve-mode' into main
Generated by construct
2 parents 82f1e09 + 9cb2895 commit 481cfac

12 files changed

Lines changed: 1085 additions & 37 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@
33
## [Unreleased]
44

55
### Added
6+
- **Serve mode**`construct run` now starts `opencode serve` headlessly inside the container (`docker run -d`) and connects a local client from the host. The local client is `opencode attach <url>` when `opencode` is on `$PATH`, or the system default browser as a fallback. This eliminates TUI-in-container rendering issues and lets users interact through their own local opencode setup.
7+
- **Headless mode** — when passthrough args are provided (`construct [path] -- "message"`), `opencode run --attach <url> <args...>` is run locally instead of launching an interactive TUI.
8+
- **`--serve-port` flag** — sets the port for the opencode HTTP server inside the container (default `4096`). Distinct from `--port` (application ports). Saved to `last-used.json` and replayed by `construct qs`.
69
- **Pass-through args (`--`)** — both `construct [flags] [path] -- <tool-args>` and `construct qs [path] -- <tool-args>` now forward everything after the bare `--` separator verbatim to the tool inside the container (e.g. `construct qs -- continue-session <session-id>`). Pass-through args are not persisted to last-used settings. Debug mode (`--debug`) ignores them.
10+
- **`--client` flag** — explicitly choose the local client that connects to the opencode server: `tui` (always `opencode attach`; errors if opencode not on PATH), `web` (always opens browser directly), or omit for auto-detect (default: `opencode attach` if on PATH, browser otherwise). `--client web` is incompatible with passthrough args (headless mode requires opencode). Saved to `last-used.json` and replayed by `construct qs`.
711

812
### Fixed
913
- **Container startup "Permission denied" errors** — the entrypoint script's heredoc that writes `~/.config/opencode/AGENTS.md` used an unquoted delimiter, causing the shell to treat backtick-wrapped paths (`` `/workspace` ``, `` `/home/agent` ``) as command substitutions. The delimiter is now quoted (`<< 'AGENTSEOF'`), preventing the errors `/workspace: Permission denied` and `/home/agent: Permission denied` on startup.

README.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ go build -o construct ./cmd/construct
5151
## Usage
5252

5353
```
54-
construct [--stack <stack>] [--docker <mode>] [--rebuild] [--reset] [--debug] [--mcp] [--port <port>] [path] [-- <tool-args>]
54+
construct [--stack <stack>] [--docker <mode>] [--rebuild] [--reset] [--debug] [--mcp] [--port <port>] [--serve-port <port>] [--client <tui|web>] [path] [-- <tool-args>]
5555
construct config <set|unset|list> [--local] [KEY [VALUE]]
5656
construct qs [path] [-- <tool-args>]
5757
```
@@ -63,6 +63,8 @@ construct --stack dotnet /path/to/repo
6363
construct --stack go ~/projects/myapp
6464
construct --stack ui --mcp --port 3000 .
6565
construct --stack go --docker dind .
66+
construct --client web .
67+
construct --client tui .
6668
```
6769

6870
### Flags
@@ -76,6 +78,8 @@ construct --stack go --docker dind .
7678
| `--debug` | `false` | Start an interactive shell instead of the agent (for troubleshooting) |
7779
| `--mcp` | `false` | Activate MCP servers (e.g. `@playwright/mcp`); requires `--stack ui`, `--stack dotnet-ui`, `--stack dotnet-big-ui`, or `--stack ruby-ui` for browser automation |
7880
| `--port` | *(none)* | Publish a container port to the host (repeatable). Accepts any format `docker run -p` supports: `3000`, `9000:3000`, `127.0.0.1:3000:3000`. |
81+
| `--serve-port` | `4096` | Port for the opencode HTTP server inside the container. |
82+
| `--client` | *(auto)* | Local client to connect to the opencode server: `tui` (always use `opencode attach`; error if not on PATH), `web` (always open browser), or omit for auto-detect. |
7983
| `--version` || Print the construct version and exit. |
8084

8185
## Quickstart (`qs`)
@@ -86,7 +90,7 @@ After running `construct` at least once in a repo, replay the exact same invocat
8690
construct qs [path]
8791
```
8892

89-
`qs` restores the last `--stack`, `--docker`, `--mcp`, and all `--port` values used for that repo. Settings are stored in `~/.construct/last-used.json`.
93+
`qs` restores the last `--stack`, `--docker`, `--mcp`, `--port`, `--serve-port`, and `--client` values used for that repo. Settings are stored in `~/.construct/last-used.json`.
9094

9195
You can pass extra arguments to the tool after `--`. They are forwarded verbatim and are not saved to last-used:
9296

cmd/construct/main.go

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,11 +74,13 @@ func runAgent(args []string) {
7474
reset := fs.Bool("reset", false, "Wipe and re-seed the agent home volume before starting")
7575
mcp := fs.Bool("mcp", false, "Activate MCP servers (e.g. @playwright/mcp); requires --stack ui for browser automation")
7676
dockerMode := fs.String("docker", "none", "Docker access mode: none (default, no Docker), dood (Docker-outside-of-Docker via host socket), dind (Docker-in-Docker sidecar)")
77+
servePort := fs.Int("serve-port", 0, "Port for the opencode HTTP server inside the container (default 4096)")
78+
client := fs.String("client", "", "Local client to connect to the opencode server: tui (opencode attach), web (browser), or empty for auto-detect")
7779
var ports portFlag
7880
fs.Var(&ports, "port", "Publish a container port to the host (repeatable): --port 3000 --port 8080:8080")
7981

8082
fs.Usage = func() {
81-
fmt.Fprintf(os.Stderr, "Usage: construct [--stack <stack>] [--docker <mode>] [--rebuild] [--reset] [--debug] [--mcp] [--port <port>] [path] [-- <tool-args>]\n\n")
83+
fmt.Fprintf(os.Stderr, "Usage: construct [--stack <stack>] [--docker <mode>] [--rebuild] [--reset] [--debug] [--mcp] [--port <port>] [--serve-port <port>] [--client <tui|web>] [path] [-- <tool-args>]\n\n")
8284
fmt.Fprintf(os.Stderr, "Subcommands:\n")
8385
fmt.Fprintf(os.Stderr, " config Manage credential environment variables\n")
8486
fmt.Fprintf(os.Stderr, " qs Re-run the last stack used in the current repo\n\n")
@@ -95,12 +97,17 @@ func runAgent(args []string) {
9597
fmt.Fprintf(os.Stderr, " none No Docker access inside the agent container (default)\n")
9698
fmt.Fprintf(os.Stderr, " dood Docker-outside-of-Docker: bind-mounts the host socket (/var/run/docker.sock)\n")
9799
fmt.Fprintf(os.Stderr, " dind Docker-in-Docker: starts a privileged dind sidecar container\n")
100+
fmt.Fprintf(os.Stderr, "\nClient modes (--client):\n")
101+
fmt.Fprintf(os.Stderr, " <empty> Auto-detect: opencode attach if opencode on PATH, else browser (default)\n")
102+
fmt.Fprintf(os.Stderr, " tui Always use opencode attach; error if opencode not on PATH\n")
103+
fmt.Fprintf(os.Stderr, " web Always open browser directly\n")
98104
fmt.Fprintf(os.Stderr, "\nExamples:\n")
99105
fmt.Fprintf(os.Stderr, " construct --stack dotnet /path/to/repo\n")
100106
fmt.Fprintf(os.Stderr, " construct --stack go ~/projects/myapp\n")
101107
fmt.Fprintf(os.Stderr, " construct --stack ui --mcp --port 3000 --port 8080 .\n")
102108
fmt.Fprintf(os.Stderr, " construct --docker dood .\n")
103-
fmt.Fprintf(os.Stderr, " construct --docker dind .\n\n")
109+
fmt.Fprintf(os.Stderr, " construct --docker dind .\n")
110+
fmt.Fprintf(os.Stderr, " construct --client web .\n\n")
104111
fmt.Fprintf(os.Stderr, "Flags:\n")
105112
fs.PrintDefaults()
106113
}
@@ -122,6 +129,13 @@ func runAgent(args []string) {
122129
log.Fatalf("unknown docker mode %q; supported modes: none, dood, dind", *dockerMode)
123130
}
124131

132+
switch *client {
133+
case "", "tui", "web":
134+
// valid
135+
default:
136+
log.Fatalf("unknown client %q; supported values: tui, web", *client)
137+
}
138+
125139
repoPath := "."
126140
if fs.NArg() > 0 {
127141
repoPath = fs.Arg(0)
@@ -136,7 +150,7 @@ func runAgent(args []string) {
136150

137151
// Persist so `construct qs` can replay this invocation.
138152
// Pass-through args (after --) are not persisted.
139-
if err := config.SaveLastUsed(absRepoPath, *stackName, *mcp, []string(ports), *dockerMode); err != nil {
153+
if err := config.SaveLastUsed(absRepoPath, *stackName, *mcp, []string(ports), *dockerMode, *servePort, *client); err != nil {
140154
log.Printf("warning: could not save last-used settings: %v", err)
141155
}
142156

@@ -151,6 +165,8 @@ func runAgent(args []string) {
151165
Ports: []string(ports),
152166
DockerMode: *dockerMode,
153167
ExtraArgs: passthroughArgs,
168+
ServePort: *servePort,
169+
Client: *client,
154170
}); err != nil {
155171
log.Fatal(err)
156172
}
@@ -297,6 +313,12 @@ func runQuickstart(args []string) {
297313
for _, p := range last.Ports {
298314
statusLine += " --port " + p
299315
}
316+
if last.ServePort != 0 {
317+
statusLine += fmt.Sprintf(" --serve-port %d", last.ServePort)
318+
}
319+
if last.Client != "" {
320+
statusLine += " --client " + last.Client
321+
}
300322
fmt.Fprintln(os.Stderr, statusLine)
301323

302324
agentArgs := []string{"--stack", last.Stack, "--docker", dockerMode}
@@ -306,6 +328,12 @@ func runQuickstart(args []string) {
306328
for _, p := range last.Ports {
307329
agentArgs = append(agentArgs, "--port", p)
308330
}
331+
if last.ServePort != 0 {
332+
agentArgs = append(agentArgs, "--serve-port", fmt.Sprintf("%d", last.ServePort))
333+
}
334+
if last.Client != "" {
335+
agentArgs = append(agentArgs, "--client", last.Client)
336+
}
309337
agentArgs = append(agentArgs, absRepoPath)
310338
// Pass-through args are appended after -- so runAgent's splitPassthrough
311339
// correctly routes them to runner.Config.ExtraArgs (and they are not saved

docs/spec/client-flag.md

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
# Spec: `--client` flag
2+
3+
## Problem
4+
5+
`construct` currently uses a hardcoded heuristic to decide how to connect the user to the running `opencode serve` server:
6+
7+
1. If `opencode` is on `$PATH` → run `opencode attach <url>` (TUI).
8+
2. Otherwise → open the URL in a browser.
9+
10+
This is a reasonable default but gives users no control. A user who has `opencode` installed but prefers the web UI must work around the auto-detection. Conversely, a user who knows `opencode` is not on their `$PATH` gets a silent browser fallback with no error. Web and TUI should be equal citizens that the user can explicitly select.
11+
12+
## Solution
13+
14+
Add a `--client` flag to `construct` (and replayed by `construct qs`) with three valid values:
15+
16+
| Value | Meaning |
17+
|---|---|
18+
| `""` (empty, default) | Auto-detect: try `opencode attach`; fall back to browser if not found. |
19+
| `"tui"` | Always use `opencode attach <url>`. Error if `opencode` not on `$PATH`. |
20+
| `"web"` | Always open the browser directly; skip `opencode` check entirely. |
21+
22+
The flag is saved to `last-used.json` and replayed by `construct qs`. An empty string is omitted from JSON (`omitempty`), meaning old entries behave as auto (the previous default behaviour).
23+
24+
## Behaviour table
25+
26+
| `--client` | `opencode` on `$PATH`? | Passthrough args? | Result |
27+
|---|---|---|---|
28+
| `""` (auto) | yes | any | `opencode attach <url>` |
29+
| `""` (auto) | no | any | browser (`xdg-open`/`open`) |
30+
| `"tui"` | yes | any | `opencode attach <url>` |
31+
| `"tui"` | no | any | **error**: "opencode not found on PATH; install opencode or use --client web" |
32+
| `"web"` | either | none | browser directly |
33+
| `"web"` | either | present | **fatal error**: "--client web is incompatible with passthrough args (headless requires opencode)" |
34+
35+
Any value other than `""`, `"tui"`, or `"web"` is a fatal validation error at startup.
36+
37+
## Persistence
38+
39+
`Client string \`json:"client,omitempty"\`` is added to `config.LastUsed`. An empty string is absent from JSON, so existing entries continue to behave as auto.
40+
41+
`SaveLastUsed` gains a `client string` parameter. All call sites are updated.
42+
43+
`construct qs` replays `--client <value>` in the `agentArgs` slice only when `last.Client != ""`.
44+
45+
## Error messages
46+
47+
| Condition | Message |
48+
|---|---|
49+
| `--client tui` and `opencode` not on PATH | `opencode not found on PATH; install opencode or use --client web` |
50+
| `--client web` with passthrough args | `--client web is incompatible with passthrough args (headless requires opencode)` |
51+
| Unknown `--client` value | `unknown client %q; supported values: tui, web` |
52+
53+
## Files changed
54+
55+
| File | Change |
56+
|---|---|
57+
| `docs/spec/client-flag.md` | This file — spec |
58+
| `internal/config/lastused.go` | Add `Client string` to `LastUsed`; add `client string` param to `SaveLastUsed` |
59+
| `internal/config/lastused_test.go` | Update `SaveLastUsed` call sites; add `TestSaveAndLoadLastUsed_Client` and `TestSaveAndLoadLastUsed_ClientOmittedWhenEmpty` |
60+
| `internal/runner/runner.go` | Add `Client string` to `Config`; refactor `runLocalAttach` to accept `client string`; add validation in `Run` |
61+
| `internal/runner/runner_test.go` | Tests for `runLocalAttach` client modes and `Run` validation |
62+
| `cmd/construct/main.go` | Add `--client` flag; validate; wire into `SaveLastUsed` and `runner.Config`; update `runQuickstart` |
63+
| `CHANGELOG.md` | Entry under `[Unreleased]` |

docs/spec/passthrough-args.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ construct qs [path] -- [tool-args...]
1919
- The `[path]` positional and all `construct` flags are parsed from the portion **before** `--`.
2020
- If `--` is absent the behaviour is identical to today (no change in the default case).
2121
- `--debug` mode (`/bin/bash`) still ignores pass-through args — there is nothing useful to forward to a shell.
22+
- `--client web` is incompatible with pass-through args: headless mode requires `opencode run --attach`, so a browser-only client cannot be used. Passing both results in a fatal error:
23+
```
24+
--client web is incompatible with passthrough args (headless requires opencode)
25+
```
2226

2327
### Examples
2428

docs/spec/quickstart-qs.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ Running `construct` requires remembering the `--stack` flag used last time for a
66

77
## Solution
88

9-
Introduce a `qs` subcommand that replays the last `--stack`, `--docker`, `--mcp`, and `--port` flags used in a repository without requiring the user to re-type them.
9+
Introduce a `qs` subcommand that replays the last `--stack`, `--docker`, `--mcp`, `--port`, `--serve-port`, and `--client` flags used in a repository without requiring the user to re-type them.
1010

1111
## Behaviour
1212

@@ -16,10 +16,11 @@ construct qs [path] [-- <tool-args>]
1616

1717
- `path` defaults to the current working directory.
1818
- Prints all replayed flags to stderr before launching, e.g.:
19-
`construct qs: reusing --stack go --docker dind --mcp --port 3000`
19+
`construct qs: reusing --stack go --docker dind --mcp --port 3000 --client web`
2020
- Errors with a clear message if no previous run has been recorded for the given path.
21-
- Replays `--docker`, `--mcp`, and all `--port` values that were used in the last recorded invocation.
21+
- Replays `--docker`, `--mcp`, all `--port` values, `--serve-port`, and `--client` that were used in the last recorded invocation.
2222
- For entries recorded before `--docker` was introduced (no `"docker"` key), defaults to `--docker none`.
23+
- `--client` is only replayed when it was explicitly set (non-empty); absent/empty means auto-detect and is not added to the args.
2324
- Anything after a bare `--` separator is forwarded verbatim to the tool inside the container and is **not** saved to last-used (see `docs/spec/passthrough-args.md`).
2425

2526
## Persistence
@@ -30,11 +31,12 @@ Last-used settings are stored in `~/.construct/last-used.json` as a JSON object
3031
{
3132
"/home/alice/projects/myapp": { "stack": "base" },
3233
"/home/alice/projects/api": { "stack": "go", "docker": "dind" },
33-
"/home/alice/projects/web": { "stack": "ui", "mcp": true, "ports": ["3000", "8080:8080"], "docker": "dood" }
34+
"/home/alice/projects/web": { "stack": "ui", "mcp": true, "ports": ["3000", "8080:8080"], "docker": "dood" },
35+
"/home/alice/projects/srv": { "stack": "go", "serve_port": 4096, "client": "web" }
3436
}
3537
```
3638

37-
The `mcp` key is omitted when `false`; the `ports` key is omitted when empty; the `docker` key is omitted when empty (legacy entries without a docker mode default to `none` at replay time).
39+
The `mcp` key is omitted when `false`; the `ports` key is omitted when empty; the `docker` key is omitted when empty (legacy entries without a docker mode default to `none` at replay time); `serve_port` is omitted when zero (defaults to `4096`); `client` is omitted when empty (defaults to auto-detect).
3840

3941
The file is written atomically (write to `.tmp`, then rename) with mode `0600`. The directory is created with mode `0700` if it does not exist.
4042

0 commit comments

Comments
 (0)