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
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -432,14 +432,15 @@ development database **schema-only** into a template (`pgx copy`), writes a
committed `.pgx.toml`, installs lifecycle hooks (`pgx agent setup`) and
proves a branch. From then on each Claude Code session gets its own branch
with `DATABASE_URL` already set, extended while the agent works and removed
after it stops; two agents never see each other's changes and your
PostgreSQL is never written. Installing the Skill executes nothing by
itself: the agent does the setup when the Skill is activated and your
permissions allow it.
after it stops, and every subagent it launches gets a branch of its own,
with its shell commands routed there; two agents never see each other's
changes and your PostgreSQL is never written. Installing the Skill executes
nothing by itself: the agent does the setup when the Skill is activated and
your permissions allow it.

| Host | Automatic isolation | Status |
|---|---|---|
| Claude Code | yes, through hooks | verified live |
| Claude Code | yes, through hooks: one branch per session and one per subagent | verified live |
| Cursor | hooks installed; `pgx agent exec --` when `PGX_ISOLATION` is unset | env propagation pending live verification |
| Codex | `pgx agent exec -- codex` (Codex hooks cannot set environment) | wrapper verified |

Expand Down
9 changes: 6 additions & 3 deletions crates/bin/pgx/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -393,10 +393,13 @@ Agents (one isolated database per coding-agent session)
one-time project setup: writes .pgx.toml, installs lifecycle
hooks for the detected hosts, verifies them
pgx agent doctor check hooks, .pgx.toml, the source template, the runtime
pgx agent exec [--source <db>] [--ttl <dur>] [--keep] -- <command> [args…]
pgx agent exec [--id <session>] [--agent <id>] [--source <db>] [--ttl <dur>] [--keep] -- <command> [args…]
run any command (codex, tests, a script) with its own branch
in DATABASE_URL; retire the branch when it exits
pgx agent ls agent branches: host, session, source, expires-in
in DATABASE_URL; drop the branch when it exits, or with
--keep leave it active for its idle TTL (the next exec with
the same --id attaches to it). Inside a Claude Code or Codex
session it attaches to that session's branch instead.
pgx agent ls agent branches: host, session, agent, source, expires-in
pgx agent up|touch|down|env|hook|install|uninstall
lifecycle primitives (see docs/pgx/local-dev/agents.md)

Expand Down
41 changes: 38 additions & 3 deletions crates/bin/pgx/src/cmd/agent/doctor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,8 @@ fn count_ours(path: &Path) -> Option<(usize, usize, bool)> {

fn expected_events(host: Host) -> usize {
match host {
Host::Claude | Host::Cursor => 4,
Host::Claude => 6,
Host::Cursor => 4,
Host::Codex => 3,
}
}
Expand Down Expand Up @@ -136,7 +137,11 @@ fn hooks_check(host: Host, root: &Path) -> Check {
return warn(
name,
format!("partial installation in {p}"),
"run `pgx agent install` to complete it",
if host == Host::Claude {
"run `pgx agent install claude` to complete it (installations before 0.1.2 lack the SubagentStart/SubagentStop hooks that give subagents their own branches)"
} else {
"run `pgx agent install` to complete it"
},
);
}
warn(
Expand Down Expand Up @@ -376,15 +381,45 @@ pub fn run(out: &Out, cwd: &Path) -> Result<ExitCode> {
.filter(|r| r.agent.as_ref().unwrap().retired)
.count();
let expired = agents.iter().filter(|r| r.is_expired(now)).count();
let subagents = agents
.iter()
.filter(|r| r.agent.as_ref().unwrap().agent_id.is_some())
.count();
checks.push(ok(
"branches",
format!(
"{} agent branch(es): {} retired, {} expired awaiting cleanup",
"{} agent branch(es) ({} for subagents): {} retired, {} expired awaiting cleanup",
agents.len(),
subagents,
retired,
expired
),
));
// The hook audit log: the last subagent events, so "did the hooks fire?"
// has an answer without a debugger.
let log = super::hook::audit_log_path(&home);
if let Ok(text) = std::fs::read_to_string(&log) {
let sub: Vec<&str> = text
.lines()
.filter(|l| {
l.contains(" Subagent") || l.contains("-> routed") || l.contains("-> denied")
})
.collect();
let denied = sub.iter().filter(|l| l.contains("-> denied")).count();
if let Some(last) = sub.last() {
let detail = format!(
"{} subagent event(s) logged, {} denied; last: {}",
sub.len(),
denied,
last.split_once(' ').map(|(_, rest)| rest).unwrap_or(last)
);
checks.push(if denied > 0 {
warn("subagents", detail, format!("see {}", log.display()))
} else {
ok("subagents", detail)
});
}
}

let any_fail = checks.iter().any(|c| c.status == "fail");
let mut text = String::new();
Expand Down
92 changes: 61 additions & 31 deletions crates/bin/pgx/src/cmd/agent/exec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,22 +27,11 @@ fn install_handlers() {
}
}

/// A host label from the command's basename: `codex`, `agent`, `sh`, …
fn host_from_command(cmd: &str) -> String {
let base = std::path::Path::new(cmd)
.file_name()
.map(|s| s.to_string_lossy().to_ascii_lowercase())
.unwrap_or_default();
let cleaned: String = base
.chars()
.filter(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
.take(16)
.collect();
match super::identity::validate_host(&cleaned) {
Ok(h) => h,
Err(_) => "exec".to_string(),
}
}
/// The host label of a branch `exec` makes for itself. Always `exec` unless
/// `--host` says otherwise: the label is part of the branch's identity, and
/// deriving it from the command name made `pgx agent down --id X` unable to
/// find a branch made by `pgx agent exec --id X -- sh -c …`.
pub const EXEC_HOST: &str = "exec";

fn random_id() -> String {
let mut buf = [0u8; 8];
Expand All @@ -58,8 +47,9 @@ fn random_id() -> String {

pub fn run(out: &Out, opts: ExecOpts) -> Result<ExitCode> {
let program = opts.command[0].clone();
// Attach to the surrounding agent session when one is visible, otherwise
// this run is its own session.
// Attach to the surrounding agent session (or subagent: a routed command
// carries PGX_AGENT_ID) when one is visible, otherwise this run is its
// own session.
let attached = opts.id.is_none()
&& ["CLAUDE_CODE_SESSION_ID", "CODEX_SESSION_ID"]
.iter()
Expand All @@ -69,28 +59,39 @@ pub fn run(out: &Out, opts: ExecOpts) -> Result<ExitCode> {
} else {
(
Some(opts.id.clone().unwrap_or_else(random_id)),
Some(
opts.host
.clone()
.unwrap_or_else(|| host_from_command(&program)),
),
Some(opts.host.clone().unwrap_or_else(|| EXEC_HOST.to_string())),
)
};
let r = session::resolve(&ResolveInput {
id,
host,
agent_id: opts.agent.clone(),
source: opts.source.clone(),
ttl: opts.ttl.clone(),
cwd: opts.cwd.clone(),
..Default::default()
})?;
let started = Instant::now();
let res = session::up(&out.quiet(), &r)?;
let provision_ms = started.elapsed().as_secs_f64() * 1000.0;
for w in &res.guard_warnings {
eprintln!("pgx agent exec: guard not applied on {}: {w}", res.branch);
}

let mut cmd = std::process::Command::new(&program);
cmd.args(&opts.command[1..]);
// The password travels in the environment, never in an argument. An
// inherited PGSERVICE could redirect libpq elsewhere: cleared.
for (k, v) in session::env_pairs(&res) {
cmd.env(k, v);
}
for k in ["PGSERVICE", "PGSERVICEFILE"] {
cmd.env_remove(k);
}
cmd.env("PGX_AGENT_SESSION", &r.key.session_id);
if r.key.agent_id.is_some() {
cmd.env("PGX_ROUTED", "1");
}
install_handlers();
let mut child = match cmd.spawn() {
Ok(c) => c,
Expand Down Expand Up @@ -146,11 +147,22 @@ pub fn run(out: &Out, opts: ExecOpts) -> Result<ExitCode> {
json!({
"branch": res.branch.as_str(),
"session_id": r.key.session_id,
"agent_id": r.key.agent_id,
"host": r.key.host,
"source": res.source,
// `created`: this run cloned the branch; otherwise it attached
// to one that existed (a session's, or an earlier --keep run's).
"created": res.created,
"fresh": res.fresh,
"method": res.method,
// Wall time of ensuring the branch, clone included; only
// meaningful when it was created, so null otherwise.
"elapsed_ms": if res.created { Some((provision_ms * 10.0).round() / 10.0) } else { None },
"exit_code": code,
"attached": attached,
"dropped": outcome == Outcome::Dropped,
"retired": outcome == Outcome::Retired,
"retired": false,
"status": outcome.status(attached),
})
);
}
Expand All @@ -160,23 +172,41 @@ pub fn run(out: &Out, opts: ExecOpts) -> Result<ExitCode> {

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Outcome {
/// Still active, on its idle TTL: an attached session's branch, or `--keep`.
Kept,
Retired,
Dropped,
}

/// After the command: leave an attached session alone, retire with `--keep`,
/// drop otherwise. Failures are reported, never fatal (the command's exit
/// status is what the caller is waiting for; the TTL is the backstop).
impl Outcome {
fn status(self, attached: bool) -> &'static str {
match (self, attached) {
(Outcome::Dropped, _) => "dropped",
(Outcome::Kept, true) => "attached",
(Outcome::Kept, false) => "active",
}
}
}

/// After the command: leave an attached session's branch alone; with
/// `--keep`, leave the branch active for its idle TTL (the next `exec` with
/// the same id attaches to it, data intact); drop it otherwise. Failures are
/// reported, never fatal (the command's exit status is what the caller is
/// waiting for; the TTL is the backstop).
fn cleanup(r: &session::Resolved, attached: bool, keep: bool) -> Outcome {
if attached {
return Outcome::Kept;
}
match session::down(r, !keep) {
if keep {
if let Err(e) = session::touch(r) {
eprintln!("pgx agent exec: could not extend the branch: {e}");
}
return Outcome::Kept;
}
match session::down(r, true) {
Ok(d) if d.dropped => Outcome::Dropped,
Ok(_) => Outcome::Retired,
Ok(_) => Outcome::Kept,
Err(e) => {
eprintln!("pgx agent exec: could not retire the branch: {e}");
eprintln!("pgx agent exec: could not drop the branch: {e}");
Outcome::Kept
}
}
Expand Down
Loading
Loading