From 4dbffab5b883d95693d8cce5f160fd3eb919798e Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 21 Jul 2026 07:40:56 +0000 Subject: [PATCH 1/2] fix(sessions): derive Remote Control names from a real, public host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auto-derived Remote Control names are "-@". Two things made unhelpful: - When remoteControlHost/fqdnOrHostName was empty the supervisor fell straight back to the live kernel hostname, which on a cloud box is the internal, non-routable fqdn (ip-10-x-x-x..compute.internal) — useless for identifying or reaching the box in the Claude apps. - The AWS image named the seeded "main" session after the CloudFormation stack, so it never reflected the box's public address either. Add services.agent-box.remoteControlHost and, when it is empty, fall back to the box's public web.domain (sslip.io on AWS, custom DNS on bare metal) before the kernel hostname. The AWS template now sets remoteControlHost to the public sslip.io host, so every session — the seeded "main" and any added at runtime — is named after the address the box is actually reached at. Also drop the "main" special case (review feedback): the derived name is now uniformly "-@", so "main" becomes "-main@" rather than a bare "@". Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01GfaHqHJWLGiS78pvGzoyjP --- README.md | 3 +- aws/README.md | 10 +++--- aws/template.yaml | 30 +++++++++++----- modules/agent-box.nix | 81 +++++++++++++++++++++++++++++-------------- tests/sessions.nix | 17 +++++++++ 5 files changed, 101 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index f8a7142..b97293a 100644 --- a/README.md +++ b/README.md @@ -317,11 +317,12 @@ All under `services.agent-box`: | `agent` | `"claude"` | Default agent CLI: `"claude"` or `"codex"`. | | `package` | selected agent default | Override package to run for every agent user. | | `installAgents` | all supported | Agent CLIs installed on the box (independent of what sessions run). | +| `remoteControlHost` | `fqdnOrHostName` | Host label for the `@` suffix of auto-derived Remote Control names. Empty -> falls back to the public `web.domain`, then the live kernel hostname. The AWS image sets it to the box's public sslip.io host. | | `users..sessions..*` | `{}` | Seed sessions (first boot only): per session `agent`, `skipPermissions`, `remoteControl`, `remoteControlName`, `workingDirectory`, `extraArgs`. Empty = the legacy per-user options below seed a session named `main`. | | `users..agent` | `null` | Agent for the default `main` session; null uses `services.agent-box.agent`. | | `users..skipPermissions` | `true` | Pass the selected agent's autonomy flag. | | `users..remoteControl` | `true` | Pass Claude's `--remote-control` when `agent = "claude"`; ignored for Codex. | -| `users..remoteControlName` | `@` | Claude Remote Control session name (null -> `@` for `main`, `-@` for other sessions). Ignored for Codex. | +| `users..remoteControlName` | `-main@` | Claude Remote Control session name (null -> `-@`, where `` is `remoteControlHost`). Ignored for Codex. | | `users..workingDirectory` | `/home/` | Agent startup directory. | | `users..extraGroups` | `[]` | Extra groups for the user. | | `users..extraArgs` | `[]` | Extra args appended to the selected agent CLI. | diff --git a/aws/README.md b/aws/README.md index 04ddff6..5608582 100644 --- a/aws/README.md +++ b/aws/README.md @@ -31,10 +31,12 @@ choose Claude Code or Codex. `user@` userinfo: Chrome answers the auth challenge with URL userinfo plus an empty password, and credentials typed into the prompt cannot override the URL-embedded identity (issue 56). -- `@` becomes the Claude Remote Control session name - (default user: `agent`). Rename the stack before launch if you want a - friendlier label in the Claude apps; post-deploy, this can still be changed - in the NixOS config. +- `-main@.sslip.io` becomes the Claude Remote Control session + name (default user: `agent`), where `.sslip.io` is the box's public + address — so the box is identifiable and reachable in the Claude apps. + Sessions added at runtime derive the same way (`-@...`). + Override per user via `remoteControlName`, or box-wide via + `services.agent-box.remoteControlHost`, in the NixOS config post-deploy. - The hostname `.sslip.io` is derived at CFN time via `Fn::Split ':' + Fn::Join '-'` on the NetworkInterface's PrimaryIpv6Address (IPv6 mode) or the EIP address (IPv4 mode). Consecutive `::` becomes an empty split diff --git a/aws/template.yaml b/aws/template.yaml index 6111456..d9d2119 100644 --- a/aws/template.yaml +++ b/aws/template.yaml @@ -84,7 +84,7 @@ Parameters: Linux user the agent CLI runs as. Doubles as the browser-terminal sign-in username (the basic-auth username picks the terminal), the // path in the WebURL, the home directory (/home/), and - the @ Remote Control session name. Lowercase letters, + the -main@ Remote Control session name. Lowercase letters, digits, "_" and "-", starting with a letter or "_", max 31 chars. # The picker annotates each value with vCPU/RAM because the CloudFormation @@ -625,9 +625,15 @@ Resources: services.agent-box = { enable = true; agent = "${Agent}"; + # Host suffix for auto-derived Remote Control names. Use the + # box's PUBLIC address (the sslip.io hostname the box is + # actually reached at) rather than networking.hostName (unset + # on the AWS image) or the internal EC2 fqdn. Every session — + # the seeded "main" and any added at runtime — is then + # identifiable in the Claude apps as "-@". + remoteControlHost = hostname; users."${UserName}" = { environment = { TERM = "xterm-256color"; }; - remoteControlName = "${UserName}@${StackName}"; web.passwordHashFile = "/var/lib/agent-box-web/password-hash"; # The AGENTS.md ADDENDUM from the CFN AgentsMd parameter, # spliced as-is into a Nix indented string; the module @@ -727,7 +733,6 @@ Resources: - !Split [':', !GetAtt NetworkInterface.PrimaryIpv6Address] WebPasswordBase64: Fn::Base64: !Ref WebPassword - StackName: !Ref AWS::StackName # First-boot health signal (issue 106): tell CloudFormation the # user-data config actually APPLIED. This unit only exists once # the rebuild switched into this config, which is exactly the @@ -793,12 +798,19 @@ Outputs: RemoteControlSession: Description: >- - Claude Remote Control session name (@). After - finishing `claude login` once in the browser terminal, the Claude - desktop / mobile apps can drive this session directly (sign in with the - same Claude account). Only meaningful when Agent=claude; Codex has no - Remote Control channel. - Value: !Sub ${UserName}@${AWS::StackName} + Claude Remote Control session name (-main@.sslip.io, the + box's public address). After finishing `claude login` once in the browser + terminal, the Claude desktop / mobile apps can drive this session directly + (sign in with the same Claude account). Only meaningful when Agent=claude; + Codex has no Remote Control channel. + Value: !Sub + - "${UserName}-main@${Host}.sslip.io" + - Host: !If + - UseIpv4 + - !Ref EIP + - !Join + - '-' + - !Split [':', !GetAtt NetworkInterface.PrimaryIpv6Address] PublicAddress: Description: Elastic IP (IPv4 mode) or primary IPv6 (IPv6 mode). diff --git a/modules/agent-box.nix b/modules/agent-box.nix index 52ada24..a1f3bc1 100644 --- a/modules/agent-box.nix +++ b/modules/agent-box.nix @@ -231,8 +231,8 @@ let sessions = lib.mapAttrs (sname: s: { agent = if s.agent != null then s.agent else cfg.agent; inherit (s) skipPermissions remoteControl extraArgs; - # null → the supervisor derives "@" (main) or - # "-@" at start time. + # null → the supervisor derives "-@" at + # start time (see remoteControlHost). remoteControlName = s.remoteControlName; workingDirectory = if s.workingDirectory != null then s.workingDirectory @@ -407,11 +407,12 @@ let default = null; description = '' Remote Control session name. When null, defaults to - "@" for the session named "main" and - "-@" otherwise, where is - networking.fqdnOrHostName, or the live kernel hostname when that - is empty at build time. When no hostname is resolvable the "@" - suffix is omitted (just "" / "-"). + "-@", where is + services.agent-box.remoteControlHost (fqdnOrHostName by default, + the public sslip.io host on the AWS image), else the public + services.agent-box.web.domain, else the live kernel hostname when + both are empty at build time. When no host is resolvable the + "@" suffix is omitted (just "-"). ''; }; workingDirectory = lib.mkOption { @@ -498,10 +499,12 @@ let default = null; description = '' Remote Control session name, used to correlate the session to this box - from the Claude apps. Keep it shell-safe (no spaces/quotes). When null, - defaults to "@", where is networking.fqdnOrHostName, - or the live kernel hostname when that is empty at build time; the - "@" suffix is omitted entirely when no hostname is resolvable. + from the Claude apps. Keep it shell-safe (no spaces/quotes). This seeds + the "main" session; when null it defaults to "-main@", + where is services.agent-box.remoteControlHost (fqdnOrHostName by + default, the public sslip.io host on the AWS image), else the public + web.domain, else the live kernel hostname when both are empty at build + time; the "@" suffix is omitted when no host is resolvable. ''; }; workingDirectory = lib.mkOption { @@ -604,7 +607,17 @@ let mkStart = name: u: let home = "/home/${name}"; - fqdn = config.networking.fqdnOrHostName; + # Host suffix for auto-derived Remote Control names. Prefer an explicit + # remoteControlHost; otherwise the box's PUBLIC web domain — the name + # you actually reach the box at (sslip.io on AWS, custom DNS on bare + # metal) — which is far more useful in the Claude apps than the internal + # kernel hostname the supervisor would otherwise fall back to (an EC2 + # box's is ip-10-x-x-x..compute.internal). Guarded on web.enable + # because web.domain has no default and errors if read while unset. + hostLabel = + if cfg.remoteControlHost != "" then cfg.remoteControlHost + else if cfg.web.enable && cfg.web.domain != "" then cfg.web.domain + else ""; agentBinCases = lib.concatMapStrings (a: " ${a}) printf '%s\\n' ${lib.escapeShellArg (lib.getExe (agentPackage a))} ;;\n" ) cfg.installAgents @@ -723,24 +736,22 @@ ${agentBinCases} *) return 1 ;; fi if [ "$agent" = claude ] && [ "$rc" = true ]; then if [ -z "$rcname" ]; then - # Host suffix for the derived "[-]@" name. - # ${fqdn} is config.networking.fqdnOrHostName, fixed at build - # time — but it is "" when networking.hostName is unset, which - # used to leave a dangling trailing "@" (e.g. "agent-devs@"). - # Fall back to the live kernel hostname, and drop "@" - # entirely when even that is empty rather than emitting a bare + # Host suffix for the derived "-@" name. + # ${hostLabel} is baked at build time: remoteControlHost if set + # (the CloudFormation stack name on the AWS image), else the + # public web.domain — the address the box is actually reachable + # at. Fall back to the live kernel hostname only if both are + # empty; that kernel name is the INTERNAL fqdn on a cloud box + # (ip-10-x-x-x..compute.internal), which is why the public + # web.domain is preferred above it. Drop "@" entirely when + # even the kernel name is empty rather than emitting a dangling # "@". read is a bash builtin, so this needs nothing on PATH. - host=${fqdn} + host=${hostLabel} if [ -z "$host" ] && [ -r /proc/sys/kernel/hostname ]; then read -r host < /proc/sys/kernel/hostname || host= fi - rcbase=${name} - [ "$sname" = main ] || rcbase=${name}-$sname - if [ -n "$host" ]; then - rcname="$rcbase@$host" - else - rcname="$rcbase" - fi + rcname=${name}-$sname + [ -z "$host" ] || rcname="$rcname@$host" fi cmd="$cmd --remote-control $(printf '%q' "$rcname")" fi @@ -824,6 +835,24 @@ in ''; }; + remoteControlHost = lib.mkOption { + type = lib.types.str; + default = config.networking.fqdnOrHostName; + defaultText = lib.literalExpression "config.networking.fqdnOrHostName"; + example = "my-agent-box"; + description = '' + Host label used as the "@" suffix of auto-derived Remote + Control session names (see users..sessions..remoteControlName). + Defaults to the box's fqdnOrHostName; the AWS image sets it to the + box's public sslip.io host so a box is identifiable AND reachable in + the Claude apps even when networking.hostName is unset. When empty, + the name falls back to the public web.domain (the address the box is + reachable at), and only if that is also unset to the live kernel + hostname at start time — which on a cloud box is the internal, + non-routable fqdn. + ''; + }; + users = lib.mkOption { type = lib.types.attrsOf (lib.types.submodule userOpts); default = { }; diff --git a/tests/sessions.nix b/tests/sessions.nix index bdb9d13..d74f685 100644 --- a/tests/sessions.nix +++ b/tests/sessions.nix @@ -38,6 +38,10 @@ services.agent-box = { enable = true; agent = "claude"; + # Leave the host label unset so auto-derived Remote Control names fall + # back to the public web.domain rather than the internal kernel + # hostname (issue: derived names showed the internal EC2 fqdn). + remoteControlHost = ""; users.agent = { web.passwordHashFile = "/var/lib/agent-box-web/password-hash"; }; @@ -123,6 +127,19 @@ "/home/agent/.config/agent-box/sessions.json" ) + # With remoteControlHost unset, the auto-derived "-@" + # Remote Control name takes its host suffix from the public web.domain, + # NOT the internal kernel hostname — and every session (including "main") + # gets the "-" suffix (no "main" special case). The supervisor + # bakes both into its start script, so assert those literals. + start_script = machine.succeed( + "systemctl show agent-box-agent --property=ExecStart --value " + "| grep -o '/nix/store/[^ ;]*-agent-box-agent-start'" + ).strip() + script_body = machine.succeed(f"cat {start_script}") + assert "host=box.test" in script_body, script_body + assert "rcname=agent-$sname" in script_body, script_body + # Both agent CLIs are installed even though no session uses codex yet # (installAgents defaults to all supported agents). machine.succeed("test -x /run/current-system/sw/bin/claude") From 79cadc663105d5e085108b0c122132c3285164d2 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 22 Jul 2026 13:41:41 +0000 Subject: [PATCH 2/2] feat(sessions): kickoff prompts, resume-on-respawn, and full CLI parity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Let a main/interactive session spin up work in sibling sessions and reach everything the settings UI can do, without the web UI or a rebuild. Sessions (agent-box-session / settings page / seed) gain: - initialPrompt: a kickoff task handed to the harness on the FIRST spawn only (its positional prompt arg). The supervisor consumes it (mark_started sets hasRun, records boxSessionId, clears initialPrompt) so a respawn never redoes the task. - Resume-on-respawn: every later spawn (crash, reboot, Spot stop->restart — all of which keep the on-disk transcript, since /home is the persistent root EBS volume) resumes the prior transcript instead. boxSessionId is the stable id we own across respawns: * Claude consumes it directly as --session-id (first spawn) / --resume (respawn, when a transcript exists; else a fresh --session-id) — exact, so concurrent sessions never cross. * Codex mints its own id, so we stamp "[agent-box session ]" into the kickoff prompt and locate the exact rollout by that marker (grep -> newest -> trailing-36-char UUID) -> codex resume . No match -> start fresh; never `resume --last`, which could grab a sibling session's transcript in a shared cwd. resumePrompt overrides the built-in steer (continue unfinished work, or stop if already done). CLI (agent-box-session) reaches UI parity: - add --prompt / --resume-prompt - env ls | set KEY VALUE | rm KEY (writes the same ~/.config/agent-box/env the settings page manages and the env-exec wrapper reads; ls shows keys only, never values) - restart --all Settings page: add-session forms gain an optional kickoff-prompt field. Seeded AGENTS.md documents --prompt, resume semantics, env, restart --all so every harness discovers them. Adds sessions.nix regression coverage: kickoff deliver-once + resume state, env set/ls/rm, restart --all, and the web-form prompt. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Wx9PVLmDbzS35JCN5N85i6 --- modules/agent-box.nix | 451 +++++++++++++++++++++++++++++++++++------- tests/sessions.nix | 65 ++++++ 2 files changed, 449 insertions(+), 67 deletions(-) diff --git a/modules/agent-box.nix b/modules/agent-box.nix index a1f3bc1..15ac5b4 100644 --- a/modules/agent-box.nix +++ b/modules/agent-box.nix @@ -64,14 +64,18 @@ let - Install extra tools with nix, e.g. `nix profile add nixpkgs#awscli2` (no sudo needed; tools land in ~/.nix-profile/bin, already on PATH). - - Secrets go in ~/.config/agent-box/env (KEY=value, one per line) or the - settings page, and load on the next session (re)start, eg. GH_TOKEN is read - automatically, so `git clone https://github.com/...` just works. + - Secrets go in ~/.config/agent-box/env (KEY=value, one per line). Set them + with `agent-box-session env set KEY VALUE` (or `env ls` / `env rm KEY`, or + the settings page); they load on the next session (re)start, eg. GH_TOKEN + is read automatically, so `git clone https://github.com/...` just works. - Manage your own sessions without a rebuild: `agent-box-session ls|add|rm|restart`. `add` takes an optional name plus - `--agent claude|codex|shell` and `--cwd DIR` — handy for fanning out - work, spinning up a second reviewer agent, or opening a plain shell for - investigation. Listed sessions start within ~2s. + `--agent claude|codex|shell`, `--cwd DIR`, and `--prompt "TASK"` to kick + the session off on a task — handy for fanning out work, spinning up a + second reviewer agent, or opening a plain shell. The kickoff prompt fires + once; if that session is later respawned (crash, reboot, Spot restart) the + supervisor resumes its prior transcript instead of redoing the work. + `restart --all` bounces every session. Listed sessions start within ~2s. ## Handing a file to the user @@ -109,6 +113,24 @@ let `sudo systemctl start agent-box-update.service` (kills the running tmux session — save context first). ''; + # The canonical guide is published READ-ONLY under /etc (see + # environment.etc below) rather than seeded into $HOME. environment.etc + # symlinks are relinked by `nixos-rebuild switch` — which the self-update + # service runs — so this path always reflects the CURRENT module, while the + # editable ~/AGENTS.md the agent owns is never clobbered. The full content + # (built-in default + per-user addendum) lives here so both the etc entry + # and any tooling share one definition; per-user because the addendum is + # per-user and environment.etc is global. null (agentsMd = null) opts out. + canonicalAgentsMd = name: u: + if u.agentsMd == null then null + else pkgs.writeText "agent-box-${name}-agents.md" + (defaultAgentsMd + + lib.optionalString (builtins.match "[[:space:]]*" u.agentsMd == null) + "\n${u.agentsMd}"); + # Stable read-only path the seeded ~/AGENTS.md @imports. Refreshed on every + # `nixos-rebuild switch` (i.e. every box update); readable but not writable + # under ProtectSystem=strict. + canonicalAgentsPath = name: "/etc/agent-box/AGENTS.${name}.md"; tmuxSocketName = "agent-box"; runtimeDirectory = name: "agent-box-${name}"; # ttyd port base; ports are assigned in sorted user-name order (see @@ -223,6 +245,10 @@ let main = { inherit (u) agent skipPermissions remoteControl remoteControlName extraArgs; inherit (u) workingDirectory; + # No legacy top-level equivalents — the single "main" session just + # starts interactively and resumes on respawn like any other. + initialPrompt = null; + resumePrompt = null; }; }; sessionsSeedFile = name: u: @@ -237,6 +263,12 @@ let workingDirectory = if s.workingDirectory != null then s.workingDirectory else "/home/${name}"; + # Runtime state the supervisor manages; seeded so a declared kickoff + # prompt fires once and hasRun exists from the start. + initialPrompt = s.initialPrompt; + resumePrompt = s.resumePrompt; + boxSessionId = null; + hasRun = false; }) (seedSessions name u); }); userSessionsFile = name: "/home/${name}/.config/agent-box/sessions.json"; @@ -256,16 +288,25 @@ let t() { ${pkgs.tmux}/bin/tmux -L ${tmuxSocketName} "$@"; } usage() { echo "usage: agent-box-session ls" - echo " agent-box-session add [NAME] [--agent AGENT] [--cwd DIR] [-- EXTRA_ARGS...]" + echo " agent-box-session add [NAME] [--agent AGENT] [--cwd DIR]" + echo " [--prompt TEXT] [--resume-prompt TEXT] [-- EXTRA_ARGS...]" echo " agent-box-session rm NAME" - echo " agent-box-session restart NAME" + echo " agent-box-session restart NAME | --all" + echo " agent-box-session env ls | set KEY VALUE | rm KEY" echo "agents: $AGENTS (default: $DEFAULT_AGENT)" + echo "--prompt kicks the session off with a task (first spawn only); a later" + echo "respawn resumes the prior transcript instead of redoing it." echo "Listed sessions are (re)started by the per-user supervisor within ~2s." echo "Attach: tmux -L ${tmuxSocketName} attach -t NAME, or the browser terminal //?arg=NAME" } valid_name() { case "$1" in (*[!A-Za-z0-9_-]*|"") return 1 ;; esac } + valid_key() { + # env var name charset — mirrors the settings daemon's KEY_RE and the + # env-exec wrapper: letters/digits/underscore, not starting with a digit. + case "$1" in (*[!A-Za-z0-9_]*|""|[0-9]*) return 1 ;; esac + } ensure_file() { mkdir -p "$(dirname "$FILE")" [ -s "$FILE" ] || printf '{"version":1,"sessions":{}}\n' > "$FILE" @@ -322,11 +363,13 @@ let ""|-*) ;; *) name="$1"; shift; valid_name "$name" || { usage >&2; exit 2; } ;; esac - agent="$DEFAULT_AGENT"; cwd="" + agent="$DEFAULT_AGENT"; cwd=""; prompt=""; rprompt=""; has_prompt=0; has_rprompt=0 while [ $# -gt 0 ]; do case "$1" in --agent) agent="''${2:?--agent needs a value}"; shift 2 ;; --cwd) cwd="''${2:?--cwd needs a value}"; shift 2 ;; + --prompt) prompt="''${2?--prompt needs a value}"; has_prompt=1; shift 2 ;; + --resume-prompt) rprompt="''${2?--resume-prompt needs a value}"; has_rprompt=1; shift 2 ;; --) shift; break ;; *) echo "unknown option: $1" >&2; usage >&2; exit 2 ;; esac @@ -341,15 +384,30 @@ let echo "session '$name' already exists — 'agent-box-session rm $name' first, or 'restart $name' to bounce it" >&2 exit 2 fi + # The stable box-session id we own across respawns (Claude --session-id / + # --resume; Codex transcript marker). Minted here so it's set before the + # first spawn; the supervisor mints one too for legacy sessions. + bid="" + [ -r /proc/sys/kernel/random/uuid ] && read -r bid < /proc/sys/kernel/random/uuid || true # `--` after --args: jq otherwise still option-parses positional # args, so a dashed extra arg like --model would error out. jq_edit --arg n "$name" --arg a "$agent" --arg c "$cwd" \ + --arg p "$prompt" --arg pp "$has_prompt" \ + --arg rp "$rprompt" --arg rpp "$has_rprompt" --arg bid "$bid" \ '.sessions[$n] = {agent: $a, skipPermissions: true, remoteControl: true, remoteControlName: null, workingDirectory: (if $c == "" then null else $c end), - extraArgs: $ARGS.positional}' \ + extraArgs: $ARGS.positional, + initialPrompt: (if $pp == "1" then $p else null end), + resumePrompt: (if $rpp == "1" then $rp else null end), + boxSessionId: (if $bid == "" then null else $bid end), + hasRun: false}' \ --args -- "$@" - echo "session '$name' ($agent) added — the supervisor starts it within ~2s" + if [ "$has_prompt" = 1 ]; then + echo "session '$name' ($agent) added with a kickoff prompt — the supervisor starts it within ~2s" + else + echo "session '$name' ($agent) added — the supervisor starts it within ~2s" + fi ;; rm) name="''${1:-}" @@ -360,10 +418,76 @@ let echo "session '$name' removed" ;; restart) - name="''${1:-}" - valid_name "$name" || { usage >&2; exit 2; } - t kill-session -t "=$name" - echo "session '$name' killed — the supervisor restarts it within ~2s if still listed" + if [ "''${1:-}" = "--all" ]; then + ensure_file + "$JQ" -r '.sessions | keys[]' "$FILE" | while IFS= read -r n; do + [ -n "$n" ] && t kill-session -t "=$n" 2>/dev/null || true + done + echo "all sessions killed — the supervisor restarts each within ~2s (re-reading env)" + else + name="''${1:-}" + valid_name "$name" || { usage >&2; exit 2; } + t kill-session -t "=$name" + echo "session '$name' killed — the supervisor restarts it within ~2s if still listed" + fi + ;; + env) + # Manages the same ~/.config/agent-box/env the settings page writes and + # the env-exec wrapper reads at every session spawn. Applies on the next + # (re)start — see 'restart'. ls shows KEYS only, never values (matching + # the settings page, which never surfaces a stored secret). + ENV_FILE="$HOME/.config/agent-box/env" + env_header() { + printf '# Managed by agent-box settings page. KEY=value, one per line.\n' + printf '# Do not add secrets by hand here unless you know what you are doing.\n' + } + env_rewrite() { + # env_rewrite DROP_KEY [APPEND_KEY APPEND_VALUE] — atomically rewrite + # ENV_FILE dropping DROP_KEY, keeping every other valid KEY=value, then + # optionally appending a fresh pair. + mkdir -p "$(dirname "$ENV_FILE")" + tmp="$(mktemp "$ENV_FILE.XXXXXX")" + { env_header + if [ -f "$ENV_FILE" ]; then + while IFS= read -r line; do + case "$line" in ('#'*|"") continue ;; (*=*) ;; (*) continue ;; esac + ek="''${line%%=*}" + valid_key "$ek" || continue + [ "$ek" = "$1" ] && continue + printf '%s\n' "$line" + done < "$ENV_FILE" + fi + if [ $# -ge 3 ]; then printf '%s=%s\n' "$2" "$3"; fi + } > "$tmp" + chmod 600 "$tmp"; mv "$tmp" "$ENV_FILE" + } + sub="''${1:-}"; shift || true + case "$sub" in + ls) + [ -f "$ENV_FILE" ] || exit 0 + while IFS= read -r line; do + case "$line" in ('#'*|"") continue ;; (*=*) ;; (*) continue ;; esac + k="''${line%%=*}" + valid_key "$k" && printf '%s\n' "$k" + done < "$ENV_FILE" | sort -u + ;; + set) + k="''${1:-}"; v="''${2-}" + valid_key "$k" || { echo "invalid key '$k' (use letters, digits, underscore; not starting with a digit)" >&2; exit 2; } + case "$v" in (*" +"*) echo "value may not contain a newline" >&2; exit 2 ;; esac + env_rewrite "$k" "$k" "$v" + echo "env '$k' set — applies on the next session (re)start ('agent-box-session restart --all')" + ;; + rm) + k="''${1:-}" + valid_key "$k" || { usage >&2; exit 2; } + [ -f "$ENV_FILE" ] || exit 0 + env_rewrite "$k" + echo "env '$k' removed — applies on the next session (re)start ('agent-box-session restart --all')" + ;; + *) usage >&2; exit 2 ;; + esac ;; *) usage >&2 @@ -425,6 +549,26 @@ let default = [ ]; description = "Extra arguments appended to this session's agent invocation."; }; + initialPrompt = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = '' + Kickoff prompt handed to the agent on the FIRST spawn only (passed + as the harness's positional prompt argument). The supervisor clears + it after that first launch, so a respawn resumes the prior + transcript instead of redoing the task. Ignored for shell sessions. + ''; + }; + resumePrompt = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = '' + Prompt used when the supervisor RESUMES this session after any + respawn (crash, reboot, Spot stop→restart). Null uses a built-in + steer that continues unfinished work or stops if it was already + done. Ignored for shell sessions. + ''; + }; }; }; @@ -625,26 +769,39 @@ let # always resolvable, independent of installAgents. + " shell) printf '%s\\n' ${lib.escapeShellArg (utils.toShellPath config.users.users.${name}.shell)} ;;\n"; # AGENTS.md — cross-vendor agent-instructions file (codex, opencode - # native; claude-code as CLAUDE.md fallback). Content lives in the Nix - # store so no in-shell quoting; $AGENT_BOX_URL and other $refs in the - # content stay literal for the agent to expand at read time. Seeded - # per session in start_session below. - # null opts out entirely; otherwise seed the built-in default with the - # per-user addendum appended. A whitespace-only addendum (incl. the "" - # default and the lone "\n" that aws/template.yaml's block-scalar splice - # yields for an empty AgentsMd) appends nothing — the base is seeded as-is. - agentsMdFile = + # native; claude-code as CLAUDE.md fallback). What we SEED into $HOME is + # deliberately minimal and EDITABLE: a short notes header plus a claude + # `@import` of the read-only canonical guide at canonicalAgentsPath, so + # the up-to-date guidance is pulled in at load time without ever + # overwriting the agent's own edits. The canonical file itself is + # published via environment.etc and refreshed on every box update. + # null (agentsMd = null) opts out of seeding entirely. + # NOTE: claude-code expands `@path` imports; codex does not, so on a + # codex session the import line shows literally — the guide then has to + # be read explicitly from canonicalAgentsPath. + agentsMdPointer = if u.agentsMd == null then null - else pkgs.writeText "agent-box-${name}-agents.md" - (defaultAgentsMd - + lib.optionalString (builtins.match "[[:space:]]*" u.agentsMd == null) - "\n${u.agentsMd}"); + else pkgs.writeText "agent-box-${name}-agents-pointer.md" '' + # agent-box — your notes + + This file is yours to edit; anything you add here persists across + restarts. The canonical agent-box guide (environment, secrets, + serving files, self-update) is read-only and auto-updated on every + box update — it is imported below and also readable directly at + ${canonicalAgentsPath name}. + + @${canonicalAgentsPath name} + ''; in pkgs.writeShellScript "agent-box-${name}-start" '' set -u JQ=${pkgs.jq}/bin/jq TMUX="${pkgs.tmux}/bin/tmux -L ${tmuxSocketName}" SESSIONS_FILE=${lib.escapeShellArg (userSessionsFile name)} + # grep/find are NOT on the unit PATH (see the service's `path`); the + # transcript lookups below reference them by store path, as with jq/tmux. + GREP=${pkgs.gnugrep}/bin/grep + FIND=${pkgs.findutils}/bin/find # First boot only: seed the Nix-declared sessions. The file is RUNTIME # data afterwards — a rebuild must never clobber sessions the user @@ -710,6 +867,58 @@ ${agentBinCases} *) return 1 ;; esac } + # Deliver-once + resume bookkeeping. A session's kickoff prompt + # (initialPrompt) must fire on the FIRST spawn only; every later respawn + # (crash, clean exit, reboot, Spot stop→restart — all of which keep the + # on-disk transcript because /home is the persistent root EBS volume) + # must RESUME the prior transcript instead of redoing the task. We fold + # that state into sessions.json rather than a side file: after the first + # spawn mark_started sets hasRun, records the (possibly freshly minted) + # boxSessionId, and clears initialPrompt. boxSessionId is the STABLE id + # WE own across respawns — Claude consumes it directly as --session-id / + # --resume; for Codex (which mints its own id) we stamp it into the + # kickoff prompt so the transcript is findable by content (codex_rollout_uuid). + mark_started() { + # mark_started SESSION BOXID — best-effort; a failed rewrite just means + # the prompt re-fires next spawn, never a crash. + _tmp="$(mktemp "$SESSIONS_FILE.XXXXXX")" || return 0 + if $JQ --arg s "$1" --arg b "$2" \ + '(.sessions[$s]) |= (.hasRun = true | .boxSessionId = $b | .initialPrompt = null)' \ + "$SESSIONS_FILE" > "$_tmp" 2>/dev/null; then + mv "$_tmp" "$SESSIONS_FILE" + else + rm -f "$_tmp" + fi + } + + claude_has_transcript() { + # True when Claude has a saved conversation for this session id, so we + # can safely --resume it; else the caller starts fresh with --session-id + # (same id) instead of erroring on an unknown --resume target. + [ -n "$1" ] || return 1 + $FIND ${home}/.claude/projects -maxdepth 3 -name "$1.jsonl" 2>/dev/null \ + | $GREP -q . + } + + codex_rollout_uuid() { + # Echo the Codex session UUID whose rollout transcript carries our + # "[agent-box session ]" marker (newest wins if a prior resume + # forked the rollout). Empty when none match — the caller then starts a + # FRESH codex session rather than risk `resume --last` grabbing a + # sibling session's transcript in a shared working directory. + [ -n "$1" ] || return 0 + d=${home}/.codex/sessions + [ -d "$d" ] || return 0 + f="$($GREP -rlF "agent-box session $1" "$d" 2>/dev/null \ + | while IFS= read -r p; do printf '%s\t%s\n' "$(stat -c %Y "$p" 2>/dev/null)" "$p"; done \ + | sort -rn | head -n1 | cut -f2-)" + [ -n "$f" ] || return 0 + b="''${f##*/}"; b="''${b%.jsonl}" + # rollout-T