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
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "opencode-pty"
version = "0.1.13"
version = "0.2.0"
edition = "2024"
rust-version = "1.90"
description = "Persistent PTY service for OpenCode"
Expand Down
24 changes: 18 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ that connection until exit; exiting the playground stops the daemon and all its
terminals. Observer commands connect to an existing daemon without taking ownership.
The service uses a private authenticated Unix socket and atomic registration file.

Integrations launch `opencode-pty daemon` (protocol 7).
Integrations launch `opencode-pty daemon --name NAME [--runtime-dir DIR]` (protocol 7).
The server must claim the daemon within 5 seconds by sending the authenticated
framed envelope `{"token":"...","request":{"op":"own","instance_id":"..."}}`.
The response is `{"type":"owned"}`; that connection stays open as the sole
Expand All @@ -33,6 +33,17 @@ cancels the handoff. An ordinary authenticated `shutdown` request, or one from t
current owner, stops the daemon even during handoff. No ownership or handoff state
is persisted.

Every command requires `--name NAME`, which selects the runtime directory
`DIR/NAME`. `--runtime-dir DIR` is optional and defaults to OpenCode's state
directory, `${XDG_STATE_HOME:-~/.local/state}/opencode/pty`. Names are a single
path component of letters, digits, `.`, `_`, or `-`. The registration
(`service.json`) and lock (`service.lock`) live in that directory. They only
default to a temporary directory when no home directory exists, because macOS deletes unaccessed regular files
there after three days. The socket stays under `/tmp/opencode-pty-<uid>/` to fit
socket path limits; temporary cleaners skip sockets. A stopping daemon removes
its own files and runtime directory, and a starting daemon removes abandoned
sibling runtime directories older than ten minutes.

## Architecture

```text
Expand Down Expand Up @@ -67,15 +78,16 @@ user input without blocking inside the callback.
## Playground

```sh
cargo run -- play
cargo run -- play --name play
```

Other service commands:

```sh
cargo run -- status
cargo run -- list
cargo run -- stop # destructive: terminates every terminal
cargo run -- status --name play
cargo run -- list --name play
cargo run -- watch 1 --name play
cargo run -- stop --name play # destructive: terminates every terminal
```

`play` starts and owns a new daemon; it cannot adopt an already running daemon.
Expand Down Expand Up @@ -146,7 +158,7 @@ libclang and do not depend on a third-party Ghostty Rust crate.
cargo fmt --check
cargo clippy --all-targets --all-features -- -D warnings
cargo test
printf 'demo\nlist\nquit\n' | cargo run -- play
printf 'demo\nlist\nquit\n' | cargo run -- play --name play
```

### Direct Ghostty bindings
Expand Down
18 changes: 16 additions & 2 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,8 +100,9 @@ terminals may die; live replacement of the daemon is not supported.
Every daemon requires one authenticated owner connection within five seconds
of startup. Owner loss stops the daemon unless a handoff was prepared on that
connection. Handoff tickets expire 120 seconds after preparation and are
consumed by successful replacement ownership. A connected owner cannot be
displaced. The playground holds ownership until it exits; other CLI commands
consumed by successful replacement ownership. A valid ticket replaces even a
connected owner; the superseded connection can no longer prepare handoffs or
stop the daemon, and its disconnect does not affect the new owner. The playground holds ownership until it exits; other CLI commands
only observe or operate an existing daemon and never start one.

Protocol v7 uses four-byte big-endian framing with bounded UTF-8 JSON control
Expand All @@ -116,6 +117,19 @@ service lock elects one process and protects stale socket cleanup. On Unix, the
socket uses a fixed-length hash of the canonical runtime path
under a private per-user `/tmp` directory to stay below platform path limits.

Every command requires `--name NAME`; the runtime directory is `DIR/NAME`,
where `--runtime-dir DIR` defaults to OpenCode's state directory,
`${XDG_STATE_HOME:-~/.local/state}/opencode/pty`. A name is one path component
of letters, digits, `.`, `_`, or `-`. Registration defaults to a temporary directory only when no home
directory can be found: macOS deletes regular files there that are unaccessed for three
days, even while the daemon runs. Temporary cleaners skip sockets, so the
socket stays in `/tmp`. On exit the daemon removes its registration and socket
only if they are still its own, then its empty runtime directory. At startup
it removes sibling runtime directories in `DIR` that are over ten
minutes old, contain only registration files, and have either a lock it can
acquire or, without a lock file, a valid registration whose PID no longer
exists. Directories without that evidence, including empty ones, are kept.

OpenCode chooses a fresh UUID runtime directory for each server, independent of
the database. It starts the daemon only when the first terminal is created.
Only an explicit restart handoff descriptor lets a replacement server reuse
Expand Down
14 changes: 7 additions & 7 deletions npm/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/pty",
"version": "0.1.13",
"version": "0.2.0",
"description": "Persistent PTY service for OpenCode",
"type": "module",
"license": "MIT",
Expand All @@ -24,12 +24,12 @@
"index.js"
],
"optionalDependencies": {
"@opencode-ai/pty-darwin-arm64": "0.1.13",
"@opencode-ai/pty-darwin-x64": "0.1.13",
"@opencode-ai/pty-linux-arm64-gnu": "0.1.13",
"@opencode-ai/pty-linux-arm64-musl": "0.1.13",
"@opencode-ai/pty-linux-x64-gnu": "0.1.13",
"@opencode-ai/pty-linux-x64-musl": "0.1.13"
"@opencode-ai/pty-darwin-arm64": "0.2.0",
"@opencode-ai/pty-darwin-x64": "0.2.0",
"@opencode-ai/pty-linux-arm64-gnu": "0.2.0",
"@opencode-ai/pty-linux-arm64-musl": "0.2.0",
"@opencode-ai/pty-linux-x64-gnu": "0.2.0",
"@opencode-ai/pty-linux-x64-musl": "0.2.0"
},
"publishConfig": {
"access": "public"
Expand Down
106 changes: 75 additions & 31 deletions src/client.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
use std::env;
use std::io::{self, BufRead, Read, Write};
use std::path::{Path, PathBuf};
use std::thread;
use std::time::{Duration, Instant};

use anyhow::{Context, Result, anyhow, bail};
use base64::Engine;

use crate::daemon::{Registration, read_registration};
use crate::daemon::{Registration, read_registration, runtime_dir};
use crate::protocol::{
AttachmentRole, Envelope, PROTOCOL_VERSION, Request, Response, SubscriptionEvent, read_frame,
read_subscription_event, write_frame,
Expand Down Expand Up @@ -57,14 +58,24 @@ impl TerminalSubscription {

impl TerminalClient {
#[cfg(unix)]
pub fn start() -> Result<Self> {
pub fn start(directory: &Path) -> Result<Self> {
use std::os::unix::net::UnixStream;
use std::os::unix::process::CommandExt;
use std::process::{Command, Stdio};

let invalid = || anyhow!("invalid runtime directory {}", directory.display());
let name = directory
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(invalid)?;
let root = directory.parent().ok_or_else(invalid)?;
let mut command = Command::new(env::current_exe()?);
command
.arg("daemon")
.arg("--name")
.arg(name)
.arg("--runtime-dir")
.arg(root)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
Expand All @@ -79,7 +90,7 @@ impl TerminalClient {
if let Some(status) = child.try_wait()? {
bail!("opencode-pty daemon exited before ownership acquisition: {status}");
}
if let Ok(registration) = read_registration()
if let Ok(registration) = read_registration(directory)
&& registration.pid == child.id()
{
let mut stream = UnixStream::connect(&registration.socket)?;
Expand Down Expand Up @@ -109,12 +120,12 @@ impl TerminalClient {
}

#[cfg(not(unix))]
pub fn start() -> Result<Self> {
pub fn start(_directory: &Path) -> Result<Self> {
bail!("opencode-pty client transport is not implemented on this platform")
}

pub fn discover() -> Result<Self> {
let registration = read_registration()?;
pub fn discover(directory: &Path) -> Result<Self> {
let registration = read_registration(directory)?;
if registration.protocol != PROTOCOL_VERSION {
bail!(
"opencode-pty protocol mismatch: service={}, client={PROTOCOL_VERSION}",
Expand Down Expand Up @@ -404,25 +415,52 @@ impl Drop for TerminalClient {

pub fn run_cli() -> Result<()> {
let mut args = env::args().skip(1);
match args.next().as_deref() {
Some("daemon") => {
if args.next().is_some() {
bail!("usage: opencode-pty daemon");
let command = args.next();
let mut name = None;
let mut root = None;
let mut positional = Vec::new();
while let Some(arg) = args.next() {
match arg.as_str() {
"--name" => name = Some(args.next().ok_or_else(|| anyhow!("--name needs a value"))?),
"--runtime-dir" => {
root = Some(PathBuf::from(
args.next()
.ok_or_else(|| anyhow!("--runtime-dir needs a value"))?,
))
}
crate::daemon::run()
_ => positional.push(arg),
}
}
let directory = || -> Result<PathBuf> {
let name = name
.as_deref()
.ok_or_else(|| anyhow!("--name is required; use `opencode-pty help`"))?;
runtime_dir(root.as_deref(), name)
};
let no_arguments = |usage: &str| {
if positional.is_empty() {
Ok(())
} else {
Err(anyhow!("usage: opencode-pty {usage}"))
}
};
match command.as_deref() {
Some("daemon") => {
no_arguments("daemon --name NAME [--runtime-dir DIR]")?;
crate::daemon::run(&directory()?)
}
Some("fixture") => run_fixture(),
None | Some("play") => play(),
Some("status") => status(),
Some("stop") => stop(),
Some("list") => print_terminals(&TerminalClient::discover()?),
Some("play") => play(&directory()?),
Some("status") => status(&directory()?),
Some("stop") => stop(&directory()?),
Some("list") => print_terminals(&TerminalClient::discover(&directory()?)?),
Some("watch") => {
let id = args
.next()
.ok_or_else(|| anyhow!("usage: opencode-pty watch TERMINAL_ID"))?
let id = positional
.first()
.ok_or_else(|| anyhow!("usage: opencode-pty watch TERMINAL_ID --name NAME"))?
.parse()
.context("expected terminal ID")?;
watch(id)
watch(&directory()?, id)
}
Some("version" | "--version" | "-V") => {
println!(
Expand All @@ -432,16 +470,16 @@ pub fn run_cli() -> Result<()> {
);
Ok(())
}
Some("help" | "--help" | "-h") => {
None | Some("help" | "--help" | "-h") => {
print_usage();
Ok(())
}
Some(command) => bail!("unknown command {command:?}; use `opencode-pty help`"),
}
}

fn status() -> Result<()> {
let client = TerminalClient::discover()?;
fn status(directory: &Path) -> Result<()> {
let client = TerminalClient::discover(directory)?;
println!(
"opencode-pty running: pid={} instance={} terminals={}",
client.registration.pid,
Expand All @@ -451,13 +489,13 @@ fn status() -> Result<()> {
Ok(())
}

fn stop() -> Result<()> {
let client = TerminalClient::discover()?;
fn stop(directory: &Path) -> Result<()> {
let client = TerminalClient::discover(directory)?;
let pid = client.registration.pid;
client.shutdown()?;
let deadline = Instant::now() + START_TIMEOUT;
while Instant::now() < deadline {
if TerminalClient::discover().is_err() {
if TerminalClient::discover(directory).is_err() {
println!("stopped opencode-pty pid={pid} (all terminals exited)");
return Ok(());
}
Expand All @@ -467,8 +505,8 @@ fn stop() -> Result<()> {
}

#[cfg(unix)]
fn watch(id: TerminalId) -> Result<()> {
let client = TerminalClient::discover()?;
fn watch(directory: &Path, id: TerminalId) -> Result<()> {
let client = TerminalClient::discover(directory)?;
let attachment_id = format!(
"watch-{}-{:016x}",
std::process::id(),
Expand Down Expand Up @@ -503,12 +541,12 @@ fn watch(id: TerminalId) -> Result<()> {
}

#[cfg(not(unix))]
fn watch(_id: TerminalId) -> Result<()> {
fn watch(_directory: &Path, _id: TerminalId) -> Result<()> {
bail!("streaming transport is not implemented on this platform")
}

fn play() -> Result<()> {
let client = TerminalClient::start()?;
fn play(directory: &Path) -> Result<()> {
let client = TerminalClient::start(directory)?;
let mut active = client.list()?.first().map(|terminal| terminal.id);
println!("\n opencode-pty playground");
println!(
Expand Down Expand Up @@ -682,7 +720,13 @@ fn set_stdin_raw() -> Result<()> {
}

fn print_usage() {
println!("usage: opencode-pty [play|status|list|watch ID|stop|daemon|--version]");
println!(
"usage: opencode-pty [play|status|list|watch ID|stop|daemon] --name NAME [--runtime-dir DIR]"
);
println!(" opencode-pty --version");
println!(
" NAME selects DIR/NAME; DIR defaults to ${{XDG_STATE_HOME:-~/.local/state}}/opencode/pty"
);
}

fn print_help() {
Expand Down
Loading
Loading