Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/server/src/bin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ const makeCliTestServerConfig = (baseDir: string) =>
mode: "web",
port: 0,
host: "127.0.0.1",
advertisedHost: undefined,
cwd: process.cwd(),
baseDir,
...derivedPaths,
Expand Down
8 changes: 8 additions & 0 deletions apps/server/src/cli/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,13 @@ const EnvServerConfig = Config.all({
Config.string("BADCODE_HOST"),
Config.string("T3CODE_HOST"),
),
// The hostname startup URLs advertise, when the bind address is not the
// reachable one -- a container binds 0.0.0.0 and its interfaces are
// internal, so only the operator knows the address clients can reach.
advertisedHost: Config.string("THREADLINES_ADVERTISED_HOST").pipe(
Config.option,
Config.map(Option.getOrUndefined),
),
threadlinesHome: aliasedConfigOption(
Config.string("THREADLINES_HOME"),
Config.string("BADCODE_HOME"),
Expand Down Expand Up @@ -485,6 +492,7 @@ export const resolveServerConfig = (
...derivedPaths,
serverTracePath,
host,
advertisedHost: env.advertisedHost,
staticDir,
devUrl,
noBrowser,
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/cli/marketingStudioSeed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ const makeServerConfig = (input: MarketingStudioSeedInput): ServerConfigShape =>
mode: "desktop",
port: 0,
host: undefined,
advertisedHost: undefined,
cwd: input.cwd,
baseDir: input.baseDir,
staticDir: undefined,
Expand Down
7 changes: 7 additions & 0 deletions apps/server/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,12 @@ export interface ServerConfigShape extends ServerDerivedPaths {
readonly mode: RuntimeMode;
readonly port: number;
readonly host: string | undefined;
/**
* The hostname startup URLs advertise when the bind address is not the
* reachable one. A container binds 0.0.0.0 and its interfaces are internal,
* so only the operator knows what clients can actually reach.
*/
readonly advertisedHost: string | undefined;
readonly cwd: string;
readonly baseDir: string;
readonly staticDir: string | undefined;
Expand Down Expand Up @@ -194,6 +200,7 @@ export class ServerConfig extends Context.Service<ServerConfig, ServerConfigShap
tailscaleServePort: 443,
port: 0,
host: undefined,
advertisedHost: undefined,
desktopBootstrapToken: undefined,
staticDir: undefined,
devUrl,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ const makeServerConfig = Effect.fn(function* (baseDir: string) {
tailscaleServePort: 443,
port: 0,
host: undefined,
advertisedHost: undefined,
desktopBootstrapToken: undefined,
staticDir: undefined,
devUrl: undefined,
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,7 @@ const buildAppUnderTest = (options?: {
mode: "desktop",
port: 0,
host: "127.0.0.1",
advertisedHost: undefined,
cwd: process.cwd(),
baseDir,
...derivedPaths,
Expand Down
4 changes: 3 additions & 1 deletion apps/server/src/serverRuntimeStartup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,10 +254,12 @@ const resolveStartupBrowserTarget = Effect.gen(function* () {
const serverAuth = yield* ServerAuth;
// Dev servers keep the Vite dev URL: the client is only served there, and
// that harness retargets by hand anyway.
// An operator-set advertised host wins outright: inside a container the
// resolved interfaces are internal addresses no client can reach.
const baseTarget =
serverConfig.devUrl?.toString() ??
resolveAdvertisedServerUrl({
host: serverConfig.host,
host: serverConfig.advertisedHost ?? serverConfig.host,
port: serverConfig.port,
mode: serverConfig.mode,
});
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/usage/UsageService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,7 @@ const makeFixture = Effect.fn("makeFixture")(function* () {
mode: "web",
port: 0,
host: "127.0.0.1",
advertisedHost: undefined,
cwd: root,
baseDir: NodePath.join(root, "state"),
...derivedPaths,
Expand Down
67 changes: 67 additions & 0 deletions docker/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Threadlines self-host image: the published npm server plus the provider CLIs
# it drives, ready to run as a long-lived service on a VPS or homelab box.
#
# docker run -d --name threadlines --restart unless-stopped \
# -p 3773:3773 \
# -v threadlines-home:/home/threadlines \
# -v /path/to/your/repos:/workspace \
# ghcr.io/threadlines/threadlines:latest
#
# First run: `docker logs threadlines` prints the pairing link that unlocks
# the web app and mints phone pairing QR codes (Settings -> Devices).

ARG THREADLINES_VERSION=latest

# node-pty compiles natively on install, so the install happens in a stage
# with a toolchain and only the result ships.
FROM node:22-bookworm AS build
ARG THREADLINES_VERSION
RUN npm install -g --prefix /opt/threadlines "@threadlines/server@${THREADLINES_VERSION}"

FROM node:22-bookworm-slim

RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
curl \
git \
openssh-client \
tini \
&& rm -rf /var/lib/apt/lists/*

# The coding agent CLIs the server orchestrates. Their credentials land in
# the home volume, so sign-ins survive container replacement.
RUN npm install -g @anthropic-ai/claude-code @openai/codex

COPY --from=build /opt/threadlines /opt/threadlines
RUN ln -s /opt/threadlines/bin/threadlines /usr/local/bin/threadlines

# Reuse uid/gid 1000 (the image's `node` user) so named volumes and typical
# bind mounts agree on ownership.
RUN userdel -r node \
&& useradd --create-home --uid 1000 --user-group threadlines \
&& mkdir -p /workspace \
&& chown threadlines:threadlines /workspace

USER threadlines
WORKDIR /workspace

# Bind-mounted repos rarely share the container user's uid; without this git
# refuses them as "dubious ownership". /workspace is the operator's own code.
RUN git config --global safe.directory "*"

ENV THREADLINES_HOME=/home/threadlines/.threadlines \
THREADLINES_HOST=0.0.0.0 \
THREADLINES_PORT=3773 \
THREADLINES_NO_BROWSER=1 \
# A service must not mint a project from its own working directory the way
# a `npx` launch does; operators add projects from /workspace deliberately.
THREADLINES_AUTO_BOOTSTRAP_PROJECT_FROM_CWD=false

EXPOSE 3773

# Agent sessions spawn deep process trees; tini reaps what they leave behind.
ENTRYPOINT ["tini", "--", "threadlines"]

HEALTHCHECK --interval=30s --timeout=5s --start-period=30s \
CMD curl -fsS "http://127.0.0.1:${THREADLINES_PORT}/api/auth/session" || exit 1
91 changes: 91 additions & 0 deletions docker/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# Threadlines on a server

Run Threadlines on a machine that is always on: a VPS, a home server, or a
spare computer. Your coding agents keep working when your laptop is closed,
and your phone or any browser connects to them from anywhere.

## Quick start

```bash
docker run -d --name threadlines --restart unless-stopped \
-p 3773:3773 \
-v threadlines-home:/home/threadlines \
-v /path/to/your/repos:/workspace \
ghcr.io/threadlines/threadlines:latest
```

Then:

```bash
docker logs threadlines
```

The log prints a one-time link. Open it in a browser to unlock the web app.
From there, Settings -> Devices mints QR codes for pairing your phone.

That is the whole install.

## What the pieces mean

- `--restart unless-stopped` makes Docker bring Threadlines back after a
crash or a server reboot. Nothing to babysit.
- `threadlines-home` is a named volume holding everything that must survive:
your threads, settings, device pairings, and the Claude/Codex sign-ins.
Containers are disposable; this volume is not.
- `/workspace` is where your repositories live. Mount the folder that holds
them and add projects from `/workspace/...` inside the app.
- Port `3773` serves both the web app and the phone connection.

If clients will reach the server at a known address, tell startup links about
it with `-e THREADLINES_ADVERTISED_HOST=your-server.example.com` so the URL
in `docker logs` is directly clickable.

## Signing in to the agents

The Claude Code and Codex CLIs ship inside the image. Sign in once from the
web app (Settings -> Providers); the credentials land in the home volume and
survive container updates. For Claude on a headless server, the
"Advanced: headless chat token" flow in provider settings avoids needing a
browser on the server itself.

## Updating

```bash
docker pull ghcr.io/threadlines/threadlines:latest
docker rm -f threadlines
# re-run the same docker run command
```

Your data lives in the volume, so this is safe. Pin a version tag
(for example `ghcr.io/threadlines/threadlines:0.3.3`) if you prefer
updates on your own schedule.

## Reaching it from outside your network

The safest defaults, in order of effort:

- **Tailscale or a VPN**: install it on the server and your devices, then use
the server's private address. Nothing is exposed to the internet.
- **A reverse proxy with HTTPS** (Caddy, nginx, Traefik) in front of port
3773, if you want a public address. Threadlines requires pairing before any
data is served, but public endpoints deserve TLS.

Avoid exposing port 3773 to the open internet without one of the above.

## docker-compose

```yaml
services:
threadlines:
image: ghcr.io/threadlines/threadlines:latest
container_name: threadlines
restart: unless-stopped
ports:
- "3773:3773"
volumes:
- threadlines-home:/home/threadlines
- /path/to/your/repos:/workspace

volumes:
threadlines-home:
```
16 changes: 16 additions & 0 deletions patches/effect@4.0.0-beta.98.patch
Original file line number Diff line number Diff line change
@@ -1,3 +1,19 @@
diff --git a/dist/PubSub.js b/dist/PubSub.js
index 5f294e781fcedc57902bf53d20cab83184fe358b..72d3b1f36b84e54a22321ab756401ad144e7c62b 100644
--- a/dist/PubSub.js
+++ b/dist/PubSub.js
@@ -2049,6 +2049,11 @@ const strategyCompletePollersUnsafe = (strategy, pubsub, subscribers, subscripti
let keepPolling = true;
while (keepPolling && !subscription.isEmpty()) {
const poller = MutableList.take(pollers);
+ // An interrupted poller leaves an undefined hole in the list; completing
+ // it would dereference undefined. Skip holes and keep draining.
+ if (poller === undefined || poller === null) {
+ continue;
+ }
if (poller === MutableList.Empty) {
removeSubscribers(subscribers, subscription, pollers);
if (pollers.length === 0) {
diff --git a/dist/unstable/rpc/RpcClient.d.ts b/dist/unstable/rpc/RpcClient.d.ts
index a70061a638faabdd663eca25cb79585fec06e03c..1af40759e2d51d1c1d2da86bd00a43fc71cfff3a 100644
--- a/dist/unstable/rpc/RpcClient.d.ts
Expand Down
Loading
Loading