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
172 changes: 52 additions & 120 deletions .github/linters/package-lock.json

Large diffs are not rendered by default.

3 changes: 1 addition & 2 deletions integrations/isolation/acq-kits/paseo/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,8 +131,7 @@ This kit does the best achievable thing: it pins the one global root to a
subdirectory of the **first mounted project directory**. When you run the
`opencode` wrapper on the `acq run` path (whose working directory is that primary
workspace), the kit shim writes `worktrees.root = <your-project>/.paseo-worktrees`
into `config.json` and **restarts the daemon** so the change takes effect (Paseo
reads `worktrees.root` only at daemon startup — there is no signal to reread it).
into `config.json` and **restarts the daemon worker** so the change takes effect.
Before you run it once, worktrees default to `$PASEO_HOME/worktrees`.

To override the location, pre-set `worktrees.root` to an absolute path in
Expand Down
25 changes: 9 additions & 16 deletions integrations/isolation/acq-kits/paseo/TROUBLESHOOTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -306,15 +306,11 @@ acq exec <sandbox> -- sh -c 'cat "${PASEO_HOME:-$HOME/.paseo}/config.json"'
`acq run opencode <project>` once.
- `worktrees.root` points at `<project>/.paseo-worktrees` but new worktrees still
land elsewhere → the daemon may not have restarted to pick it up. The wrapper
bounces the daemon only when the value **changes**; force a bounce by killing
the daemon child (the supervisor respawns it):
restarts the daemon only when the value **changes**; force a restart with the
supported CLI command:

```bash
acq exec <sandbox> -- sh -c '
for pid in $(pgrep -u "$(id -u)" -f "paseo daemon start"); do
tr "\0" " " </proc/"$pid"/cmdline | grep -q "supervisor:paseo-daemon" && continue
kill "$pid"
done'
acq exec <sandbox> -- paseo daemon restart --home /home/agent/.paseo
```

- **The root must be absolute.** A relative `worktrees.root` resolves against
Expand Down Expand Up @@ -353,18 +349,15 @@ serves the API + WebSocket + UI locally; `curl http://127.0.0.1:6767/api/health`
returns 200 throughout. If the **host** browser sees a refused connection, that is
a port-mapping issue (see "The browser UI never loads" above), not the relay.

**Fix.** The kit now sets `PASEO_RELAY_ENABLED=false` in `spec.yaml` (read at
daemon config load, precedence over persisted config, survives restarts/self-
updates), so the relay is off by default. If you are running an older sandbox that
predates this, disable it live and bounce the daemon:
**Fix.** The kit sets `PASEO_RELAY_ENABLED=false` in `spec.yaml` and persists
`daemon.relay.enabled=false` in config.json for Paseo 0.9 managed/config tooling,
so the relay is off by default. If you are running an older sandbox that predates
this, disable it live and restart the daemon:

```bash
acq exec <sandbox> -- sh -c '
node -e "const f=process.env.HOME+\"/.paseo/config.json\";const c=require(f);(c.daemon??={}).relay={enabled:false};require(\"fs\").writeFileSync(f,JSON.stringify(c,null,2)+\"\n\",{mode:0o600})"
for pid in $(pgrep -u "$(id -u)" -f "paseo daemon start"); do
tr "\0" " " </proc/"$pid"/cmdline | grep -q "supervisor:paseo-daemon" && continue
kill "$pid"
done'
paseo daemon config set --home "${PASEO_HOME:-$HOME/.paseo}" daemon.relay.enabled false
paseo daemon restart --home "${PASEO_HOME:-$HOME/.paseo}"'
```

## The daemon keeps restarting in the logs
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Decision: launch Paseo 0.9 with persisted config and `daemon run`

**Status:** accepted

## Context

Paseo 0.9 removed the launch flags this kit used with `paseo daemon start`:
`--listen`, `--web-ui`, and `--foreground`. Passing any of those flags now fails
before the daemon starts.

Paseo 0.9 also separates two local launch modes:

- `paseo daemon start` is managed mode. It launches a detached supervisor and
strips daemon-setting environment variables such as `PASEO_LISTEN`,
`PASEO_WEB_UI_ENABLED`, and `PASEO_RELAY_ENABLED` before the worker starts.
Managed mode therefore depends on persisted `config.json` for those settings.
- `paseo daemon run` is foreground deployment mode. It keeps daemon-setting
environment overrides and still runs Paseo's own supervisor-entrypoint, which
manages the daemon worker with IPC restart and crash restart.

The acq kit needs a foreground process so its outer startup loop can observe an
exit and relaunch it, and it needs a `0.0.0.0:6767` in-guest bind so msb
create-time publishing can reach the daemon from the host.

## Decision

Use `paseo daemon run` inside the kit's existing `supervisor:paseo-daemon` loop.
Before launching it, persist the settings the sandbox depends on into
`$PASEO_HOME/config.json`:

- `daemon.listen = 0.0.0.0:6767`
- `features.webUi.enabled = true`
- `daemon.relay.enabled = false`

Keep the matching environment variables on the `daemon run` invocation as a
belt-and-suspenders deployment override, but treat persisted config as the
source of truth for Paseo 0.9 managed/config tooling. Startup takes the shared
`$PASEO_HOME/.config-json.lock`, writes the settings, and reads them back before
launching the daemon; if the required settings cannot be verified, it does not
start an unauthenticated daemon with unknown relay/listen defaults.

When the entrypoint shim changes `worktrees.root`, prefer the supported
`paseo daemon restart` command so Paseo restarts the worker and re-reads
`config.json`. Keep the old marker-scoped kill path only as a fallback if the
CLI restart cannot run.

## Consequences

- The kit no longer passes removed Paseo 0.9 flags.
- The daemon still comes up automatically on sandbox start and remains supervised.
- `paseo daemon status` and config tooling report the same listen/web UI/relay
settings that the sandbox uses at runtime.
- The `0.0.0.0:6767` bind remains explicit, preserving msb create-time port
reachability.
- Future Paseo version bumps must verify both the version pin and daemon command
surface, not only the package version.

## Links

- `paseo-single-port-daemon.md`
- `supervisor-owns-daemon-wrapper-pins-worktrees.md`
- `worktrees-root-global-only.md`
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@ Paseo is architected differently:
- A **single daemon process** (`@getpaseo/server`, launched by `@getpaseo/cli`)
serves the daemon API, the WebSocket, **and** the bundled browser web UI, all
on **one** port (default `6767`). Verified against the Paseo Docker image and
the published npm tarball: `PASEO_WEB_UI_ENABLED=true` (or `--web-ui`) serves
the UI from `dist/server/web-ui/` inside the `@getpaseo/server` package on the
same origin as the API.
the published npm tarball: `PASEO_WEB_UI_ENABLED=true` serves the UI from
`dist/server/web-ui/` inside the `@getpaseo/server` package on the same origin
as the API.
- Paseo is **not** an OpenCode server. It orchestrates agent CLIs (Claude Code,
Codex, Copilot, OpenCode, Pi) by **launching them as child processes**. There
is no attachable "raw agent server" that a terminal TUI could join.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,15 @@ entrypoint runs in the primary workspace on `acq run`).
**Split the responsibility, and reconcile with a daemon bounce:**

- **The startup script (`paseo-start.sh`) owns the daemon lifecycle.** It installs
the Paseo CLI on first boot and supervises
`paseo daemon start --foreground --listen 0.0.0.0:6767 --web-ui` in a respawn
loop marked `supervisor:paseo-daemon`. The daemon comes up immediately with
whatever `config.json` currently says (default root on first boot).
the Paseo CLI on first boot, persists the required daemon settings in
`config.json`, and supervises `paseo daemon run` in a respawn loop marked
`supervisor:paseo-daemon`. The daemon comes up immediately with whatever
`config.json` currently says (default root on first boot).
- **The entrypoint pins the worktree root.** On `acq run` the thin wrapper
(`~/.local/bin/opencode`) execs the generic shim (`~/paseo-agent-shim`), which
runs in the primary-workspace cwd, computes `<PWD>/.paseo-worktrees`, and writes
it to `config.json`. **If the value changed**, it restarts the daemon so the new
root takes effect. (The pin/bounce lives in the shim; see
it to `config.json`. **If the value changed**, it restarts the daemon worker so
the new root takes effect. (The pin/restart lives in the shim; see
`paseo-mixin-generic-wrapper.md` for the wrapper/shim split.)

### The bounce mechanism (why it's safe)
Expand All @@ -44,21 +44,23 @@ The process tree is:

```
sh -c (marker: supervisor:paseo-daemon) ← the startup script's respawn loop
└─ paseo daemon start --foreground ← node CLI
└─ paseo daemon run ← node CLI
└─ node supervisor-entrypoint.js ← "Paseo Supervisor"; holds $PASEO_HOME/paseo.pid
└─ node daemon-worker.js ← the listener on :6767 (SIGTERM = graceful)
```

The wrapper kills the PIDs whose argv matches `paseo daemon start` **but do NOT
carry the `supervisor:paseo-daemon` marker** — i.e. the Paseo-owned tree, never
our shell respawn loop. `kill` (SIGTERM) lets `daemon-worker` shut down
gracefully, free `:6767`, and release the PID lock. Our shell loop then respawns
`paseo daemon start`, which re-reads `config.json` and picks up the new
`worktrees.root`.
The wrapper first uses `paseo daemon restart`, which asks Paseo's own supervisor
to restart the worker and re-read `config.json`. If that command cannot run, the
fallback kills PIDs whose argv matches `paseo daemon run` **but do NOT carry the
`supervisor:paseo-daemon` marker** — i.e. the Paseo-owned tree, never our shell
respawn loop. `kill` (SIGTERM) lets `daemon-worker` shut down gracefully and free
`:6767`. The fallback clears `paseo.pid` only after it actually kills a daemon
process, so it does not delete a live worker's lock during ordinary respawn.

The PID lock is stale-tolerant (`acquirePidLock` reclaims a dead owner's lock and
treats a >5-min-old lock as stale), so even an ungraceful death does not deadlock
the relaunch.
The normal `paseo daemon restart` path keeps the outer supervisor and PID lock
intact; the marker-scoped kill is only a recovery fallback. Both startup config
writes and wrapper worktree-root writes use the same coarse config lock before
modifying `$PASEO_HOME/config.json`.

## Alternatives considered

Expand All @@ -79,8 +81,8 @@ the relaunch.
daemon bounce (idempotent; no bounce if unchanged).
- Two independent "supervisors" exist conceptually — our shell respawn loop and
Paseo's own `supervisor-entrypoint.js` — but they nest cleanly (ours respawns
the whole `paseo daemon start` tree; Paseo's manages the worker within one
start). The kill is marker-scoped so our loop is never the target.
the whole `paseo daemon run` tree; Paseo's manages the worker within one run).
The fallback kill is marker-scoped so our loop is never the target.

## Links

Expand Down
47 changes: 27 additions & 20 deletions integrations/isolation/acq-kits/paseo/files/home/paseo-agent-shim
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,10 @@
# relative root resolves against PASEO_HOME. On `acq run <path>` the entrypoint
# runs with cwd = the primary (first mounted) workspace, so the shim computes
# <PWD>/.paseo-worktrees and writes it to $PASEO_HOME/config.json (via
# paseo-set-worktrees-root.mjs). The daemon reads worktrees.root ONLY at boot
# (no SIGHUP / no runtime reread), so when the value CHANGES the shim bounces
# the daemon — it kills the supervised daemon child (never the supervisor),
# and the startup script's supervisor:paseo-daemon loop respawns it, re-reading
# the new root.
# paseo-set-worktrees-root.mjs). The daemon reads worktrees.root at worker
# start, so when the value CHANGES the shim restarts the daemon worker via
# `paseo daemon restart` (falling back to a marker-scoped process kill only
# if the CLI restart cannot run).
# 2. PRINT HOST-CONNECT INSTRUCTIONS and, when it IS PID 1 (the `acq run`
# entrypoint), HOLD the foreground so that session's sandbox — and thus the
# daemon/UI — stays up.
Expand Down Expand Up @@ -79,13 +78,12 @@ if [ -f "$SET_ROOT_SCRIPT" ] && command -v node >/dev/null 2>&1; then
esac
fi

# 2. If the root changed, bounce the daemon so it re-reads config.json. We kill
# ONLY the daemon process tree, never the startup script's supervisor loop, so
# the supervisor relaunches the daemon with the new worktrees.root. The
# supervisor loop's argv carries the marker "supervisor:paseo-daemon"; the
# actual daemon is a `paseo daemon start --foreground ...` CHILD of that loop.
# We select PIDs whose argv matches the daemon-start pattern but EXCLUDE the
# supervisor marker, and kill just those.
# 2. If the root changed, restart the daemon worker so it re-reads config.json.
# Paseo 0.9 exposes `paseo daemon restart` for this. If the daemon is wedged or
# the CLI surface changes again, fall back to killing ONLY the supervised Paseo
# tree, never the startup script's supervisor loop. The supervisor loop's argv
# carries the marker "supervisor:paseo-daemon"; the actual daemon child is a
# `paseo daemon run` tree beneath that loop.
daemon_up() {
# /api/health is unauthenticated and returns 200 when the daemon is listening.
curl -fsS "http://127.0.0.1:$PASEO_PORT/api/health" >/dev/null 2>&1
Expand Down Expand Up @@ -130,14 +128,23 @@ ui_ready() {
}
if [ -n "$worktrees_changed" ]; then
echo "paseo: pinned Paseo worktree root to $WORKTREES_ROOT; restarting the daemon to apply it ..."
for _pid in $(pgrep -u "$(id -u)" -f "paseo daemon start" 2>/dev/null); do
# Skip the supervisor loop (its cmdline carries the marker).
if tr '\0' ' ' < "/proc/$_pid/cmdline" 2>/dev/null | grep -q "supervisor:paseo-daemon"; then
continue
fi
kill "$_pid" 2>/dev/null || true
done
# Wait for the supervisor to bring it back on the new root (bounded).
if command -v paseo >/dev/null 2>&1 && paseo daemon restart --home "$PASEO_HOME_DIR" --timeout 30 >/dev/null 2>&1; then
:
else
echo "paseo: daemon restart command failed; falling back to supervisor respawn" >&2
killed_daemon=""
for _pid in $(pgrep -u "$(id -u)" -f "paseo daemon run" 2>/dev/null); do
# Skip the supervisor loop (its cmdline carries the marker).
if tr '\0' ' ' < "/proc/$_pid/cmdline" 2>/dev/null | grep -q "supervisor:paseo-daemon"; then
continue
fi
if kill "$_pid" 2>/dev/null; then
killed_daemon=yes
fi
done
[ -n "$killed_daemon" ] && rm -f "$PASEO_HOME_DIR/paseo.pid" 2>/dev/null || true
fi
# Wait for the daemon to return on the new root (bounded).
_i=0
while [ "$_i" -lt 30 ]; do
daemon_up && break
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
#!/usr/bin/env node
// paseo-assert-daemon-config.mjs — fail closed unless config.json contains the
// daemon settings this kit requires before launching an unauthenticated daemon.

import { readFileSync } from "node:fs";
import { homedir } from "node:os";
import path from "node:path";

function resolvePaseoHome(explicit) {
if (explicit && explicit.trim()) return path.resolve(explicit.trim());
const env = process.env.PASEO_HOME;
if (env && env.trim()) {
const raw = env.trim();
if (raw === "~") return homedir();
if (raw.startsWith("~/")) return path.join(homedir(), raw.slice(2));
return path.resolve(raw);
}
return path.join(homedir(), ".paseo");
}

function parseArgs(argv) {
const args = { paseoHome: undefined, listen: undefined };
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === "--paseo-home") {
args.paseoHome = argv[++i];
} else if (arg === "--listen") {
args.listen = argv[++i];
} else {
throw new Error(`unknown argument: ${arg}`);
}
}
if (!args.listen) throw new Error("missing required --listen <host:port>");
return args;
}

export function assertDaemonConfig({ paseoHome, listen }) {
const configPath = path.join(resolvePaseoHome(paseoHome), "config.json");
let config;
try {
config = JSON.parse(readFileSync(configPath, "utf8"));
} catch (err) {
throw new Error(`cannot read required daemon config ${configPath}: ${err.message}`);
}

const failures = [];
if (config?.daemon?.listen !== listen) {
failures.push(`daemon.listen=${JSON.stringify(config?.daemon?.listen)}`);
}
if (config?.features?.webUi?.enabled !== true) {
failures.push(`features.webUi.enabled=${JSON.stringify(config?.features?.webUi?.enabled)}`);
}
if (config?.daemon?.relay?.enabled !== false) {
failures.push(`daemon.relay.enabled=${JSON.stringify(config?.daemon?.relay?.enabled)}`);
}
if (failures.length > 0) {
throw new Error(`config.json missing required daemon settings: ${failures.join(", ")}`);
}
}

if (import.meta.url === `file://${process.argv[1]}`) {
try {
assertDaemonConfig(parseArgs(process.argv.slice(2)));
} catch (err) {
process.stderr.write(`error: ${err.message}\n`);
process.exit(1);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,12 @@ async function main() {

let client;
try {
client = await connectToDaemon({});
client = await connectToDaemon({
target: {
kind: "instance",
home: process.env.PASEO_HOME || `${process.env.HOME}/.paseo`,
},
});
} catch (err) {
log(`could not connect to the Paseo daemon: ${err.message}`);
return; // fail-open (daemon may still be coming up on a very early boot)
Expand Down
Loading
Loading