diff --git a/.devcontainer/agents.yml b/.devcontainer/agents.yml new file mode 100644 index 0000000..77dc4b4 --- /dev/null +++ b/.devcontainer/agents.yml @@ -0,0 +1,11 @@ +# The agents of this repository (the agents Feature): task agents -- inside, +# task devcontainer:agent -- from the workstation; task agents:list shows them. +agents: + # The host session, driven from the workstation and from the phone. On-demand with + # --remote-control until a background session is shown to be reachable through Remote + # Control (the agents proposal, decision D2 and fact 5); then it becomes resident. + - name: host + cli: claude + kind: on-demand + folder: . + args: [--remote-control] diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index ec6a2e5..c82b891 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -48,6 +48,9 @@ }, "./features/gh": {}, "./features/claude-code": {}, + // The agent list is .devcontainer/agents.yml: `task agents -- host` inside, + // `task devcontainer:agent -- host` from the workstation. + "./features/agents": {}, "./features/devhost": {} }, // Host side, before every start: stage the Features, and make sure diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 8f25b19..9749625 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -23,6 +23,7 @@ jobs: - go-task - devcontainer-cli - claude-code + - agents - coder-cli - openbao - velero @@ -63,6 +64,7 @@ jobs: - go-task - devcontainer-cli - claude-code + - agents - coder-cli - openbao - velero diff --git a/LEXICON.md b/LEXICON.md index ff47376..14c3735 100644 --- a/LEXICON.md +++ b/LEXICON.md @@ -57,6 +57,14 @@ The domain words of the project itself are not here; they live with the project' the **workstation**, never "the host". - **Inter** (interface agent): an agent that works with an engineer, directly or through another inter; the host is the main one. +- **Resident agent**: an agent kind in the agent list: it runs continuously in the background, and + starting it attaches to it (starting it first if it is not running). The host becomes one once + Remote Control reaches a background session; until then it is on-demand. Not to be confused with + a back, which no person attaches to. +- **On-demand agent**: an agent kind in the agent list: starting it opens a new session, and it + can resume an earlier one when asked. A designer or a general Claude instance is one. +- **Agent list**: the file a repository keeps naming its agents, each with its kind and CLI; + `.devcontainer/agents.yml` by default, with a configurable path relative to the repository root. - **Back** (background agent): an agent that never works with a person. After each turn, and after each con's job, it reads what was done and keeps one record current, so no inter spends a turn on upkeep. It reports only through a quiet notice. Each runs on its own model, inside the diff --git a/README.md b/README.md index df86142..611eb3c 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,8 @@ installing Node.js, and a one-line bootstrap that wires both into any repo. | --- | --- | | [`go-task`](src/go-task) | [Task](https://taskfile.dev) (`task`), sha256-verified from GitHub releases, with bash/zsh completions. | | [`devcontainer-cli`](src/devcontainer-cli) | `@devcontainers/cli` as `devcontainer`, on a private Node.js under `/opt/devcontainer-cli` that is never put on PATH. | -| [`claude-code`](src/claude-code) | [Claude Code](https://code.claude.com/docs): the native release binary (no Node.js, version pinnable) unless a `claude` is already there (the official Anthropic Feature), plus the `claude-host` launcher (a running session of the folder is never started twice), `claude-host-pin` (opt-in SessionStart hook keeping the pin current), `claude-fleet` (several sessions in one terminal) and the state (`CLAUDE_CONFIG_DIR`) in a volume it owns. | +| [`claude-code`](src/claude-code) | [Claude Code](https://code.claude.com/docs): the native release binary (no Node.js, version pinnable) unless a `claude` is already there (the official Anthropic Feature), plus `agent-adapter-claude` (Claude Code's adapter for the `agents` Feature) and the state (`CLAUDE_CONFIG_DIR`) in a volume it owns; the `claude-host` launcher, `claude-host-pin` hook and `claude-fleet` still work in 1.7.0 but are deprecated (claude-code 2.0 removes them). | +| [`agents`](src/agents) | One list of a repository's agents (`.devcontainer/agents.yml`: name, CLI, folder, args, kind) and one verb for every agent CLI: `task agents -- ` makes a `resident` agent run in the background and attaches, or opens a new session of an `on-demand` one; `agents:up` starts the residents with the container; each CLI Feature supplies the adapter. | | [`git`](src/git) | git augmented for the container: a worktree-aware wrapper that scopes `GIT_DIR` to the checkout and maps a `.git` pointer under the host's `.git-dirs` onto one shared mount, `safe.directory` and `core.hooksPath` at post-create, the global config in a volume it owns. | | [`gh`](src/gh) | [GitHub CLI](https://cli.github.com) from GitHub releases, checksum-verified, unless the official Feature installed it; its login (`GH_CONFIG_DIR`) in a volume it owns, git's GitHub credentials taken from it. | | [`ssh-agent`](src/ssh-agent) | `SSH_AUTH_SOCK` at a fixed path in every process, so git commits can be signed with an SSH key: the launcher mounts the host's agent socket (linux, macOS, a WSL distro), the private key never entering the container; the Windows engines get a plain explanation and an opt-in, temporary key mode; `ssh-agent:*` tasks for keys, dedicated-key warnings, rotation and retirement (`key:rotate`, `key:retire`, `key:age`) and git signing setup. | @@ -75,14 +76,17 @@ gap to close here (see [Contributing from a consuming repo](#contributing-from-a worktrees stay out of the checkout too (`PYTHONPYCACHEPREFIX`, a volume over `.claude/worktrees`), or a synced checkout carries every build artefact. -- **One session per role, found by UUID.** `claude-host` resumes the session - in `HOST_SESSION` (from `.devcontainer/.env`) wherever it runs; - `task devcontainer:claude` does it from the host, `task claude-code:host` - inside, `DIR=` for a session per sub-folder. A session that is - running in the folder (`claude agents --json`, an entry with a pid) is never - started a second time (claude-host refuses and names it; `/clear` gives a - session a new id, which the opt-in `claude-host-pin` SessionStart hook writes back to `.env`); several agents in one terminal are - `task devcontainer:claude:fleet` (below), never a second copy of one. +- **One list of agents, one verb.** A repository names its agents in + `.devcontainer/agents.yml` (the `agents` Feature): a name, a CLI, a folder, + extra args and a kind. `task agents -- ` inside, `task + devcontainer:agent -- ` from the host, does the right thing for the + kind: a `resident` agent is made to run in the background and attached, an + `on-demand` one opens a new session (`--resume` for its latest). Each CLI + Feature supplies an adapter (`agent-adapter-claude` from `claude-code`); the + adapter finds a session by its name and folder and acts on it by id, so no + session id is pinned anywhere and nothing starts a second copy of a running + agent. `claude-host`, `claude-fleet`, `HOST_SESSION` and `SESSIONS` are + deprecated and go with claude-code 2.0. - **Settings are plain variables, three ways.** Command-line var, then the `.env` files, then the exported environment, with the same names in the tasks, the PowerShell scripts and their bash twins. @@ -268,8 +272,10 @@ Each Feature stores its persistent state (logins, config, transcripts) in a name | `devcontainer:ssh-key-import FILE=` | Opt-in key mode: stream a private key file of the host (and its `.pub`) into the running container's `ssh-agent` key volume through `devcontainer exec` on stdin (never an argument), mode 0600, then load it; prints the fingerprint, never the key; warns (never blocks) when FILE has a default name or its public key also opens this host. Refuses when FILE is missing, no container runs, or key mode is off there. Host only. | | `devcontainer:host` | Print the host profile (`HOST_TYPE` and why, engine, `DCF_HOST_*`) and the host mounts `up` would add or skip; starts nothing. | | `devcontainer:exec CMD='...'` / `shell` / `pwsh` | Run a command (base64-safe), an interactive bash, or PowerShell in the container. | -| `devcontainer:claude [DIR=] [SESSION=] [-- ]` | Start or resume the Claude Code host session inside (needs the `claude-code` Feature): `HOST_SESSION` from `.devcontainer/.env`, `HOST_SESSION_` for a sub-folder. Runs directly when already inside the container. | -| `devcontainer:claude:fleet [-- --plan\|--no-view\|--no-wait\|--stop-duplicates]` | Several Claude Code sessions in one terminal (`claude-fleet` from the `claude-code` Feature): `SESSIONS` (include `vars:`; one line per session, `name\|session-variable\|dir\|extra claude args`) is read against `claude agents --json`; a session counts as running when its id (from `.devcontainer/.env`, then the container's environment) or its folder is live, the missing ones start in the background with `claude-host --bg` in their folder, then `claude agents` opens. It first waits for sessions restored after a rebuild to settle (`-- --no-wait` skips that); several live sessions for one agent are reported, and `-- --stop-duplicates` stops the extras with `claude stop` (never rm). `-- --plan` prints what it would do and starts nothing; `-- --no-view` skips the view. | +| `devcontainer:agent [AGENTS_FILE=] -- [--resume\|--new] []` | Start an agent of the repository's agent list inside (needs the `agents` Feature and the adapter of the agent's CLI): a `resident` runs in the background and is attached, an `on-demand` agent opens a new session in this terminal. Runs directly when already inside the container. | +| `devcontainer:agent:list [-- --json]` | The agents with their kind, CLI, state and session id. | +| `devcontainer:claude [DIR=] [SESSION=] [-- ]` | Deprecated (prints one line; goes with claude-code 2.0): use `devcontainer:agent`. Start or resume the Claude Code host session inside (needs the `claude-code` Feature): `HOST_SESSION` from `.devcontainer/.env`, `HOST_SESSION_` for a sub-folder. Runs directly when already inside the container. | +| `devcontainer:claude:fleet [-- --plan\|--no-view\|--no-wait\|--stop-duplicates]` | Deprecated (prints one line; goes with claude-code 2.0): list the agents in `agents.yml` instead. Several Claude Code sessions in one terminal (`claude-fleet` from the `claude-code` Feature): `SESSIONS` (include `vars:`; one line per session, `name\|session-variable\|dir\|extra claude args`) is read against `claude agents --json`; a session counts as running when its id (from `.devcontainer/.env`, then the container's environment) or its folder is live, the missing ones start in the background with `claude-host --bg` in their folder, then `claude agents` opens. It first waits for sessions restored after a rebuild to settle (`-- --no-wait` skips that); several live sessions for one agent are reported, and `-- --stop-duplicates` stops the extras with `claude stop` (never rm). `-- --plan` prints what it would do and starts nothing; `-- --no-view` skips the view. | | `devcontainer:config` | Resolved configuration. | | `devcontainer:` | Passthrough to that CLI subcommand, args after `--`. | | `devcontainer:reset-storage` | wslc.exe only: reset WSL Containers' storage after a read-only BuildKit error. | @@ -306,6 +312,14 @@ Where there is no host folder (a cluster), `cloud-folder` 1.1.0 syncs it with rc `cloud-folder-setup shutdown-check` to your `shutdown:check` so a stop waits for a running sync (see the Feature's NOTES). +**One verb for every agent (`agents` 1.0.0, claude-code 1.7.0, devcontainer-cli 1.13.0):** add +`ghcr.io/nabil-becker/devcontainer-features/agents:1` to the `features`, write `.devcontainer/agents.yml` (one entry per +agent: `name`, `cli`, `kind` resident or on-demand, `folder`, `args`) and start them with `task devcontainer:agent -- +` (inside: `task agents -- `). That replaces `devcontainer:claude`, `devcontainer:claude:fleet` with +`SESSIONS`, the `HOST_SESSION...` pins in `.devcontainer/.env`, the `claude-host-pin` hook and any join script of your +own; they keep working, with a deprecation line, until claude-code 2.0. Step by step: +[docs/migrations/agents.md](docs/migrations/agents.md). + All of them also answer to `dc:` (the include's alias in the generated Taskfile). Each task dispatches per platform (Task's per-command `platforms`): diff --git a/TODO.md b/TODO.md index 3f54c63..e97e781 100644 --- a/TODO.md +++ b/TODO.md @@ -57,7 +57,7 @@ stays. Ids are short descriptive slugs (two to four kebab-case words naming the - [ ] **gh-ssh-key-format-check Check the real output of gh ssh-key list** (engineers). `REVOKE=1` finds the old key's id by parsing `gh ssh-key list`, which only the stubs have produced; run one real rotation on a throwaway key, fix the parse if the format differs, and file what it shows. Added 2026-10-24. - [x] **fleet-reconcile-live-sessions The fleet waits for restored sessions, reports and stops duplicates** (host). Requested by a consuming repository running four agents in one container (2026-10-24): `claude-fleet` waits until the restored session list is stable before reconciling (`--no-wait` skips it), reads ids from `.devcontainer/.env` before the environment, reports every agent with more than one live session and, only with `--stop-duplicates`, stops the extras with `claude stop` (never rm); `claude-host`'s refusal names `claude attach `. claude-code 1.6.0, devcontainer-cli 1.11.0. Added 2026-10-24. Done 2026-10-24 (branch `feature/fleet-reconcile-live-sessions/1`). - [x] **release-claude-code-1-6-1 Release claude-code 1.6.1 after the pin-hook and attach fixes merge** (the Operator). 1.6.1 carries the pin-hook and attach-advice fixes found on the workstation; the host reviews the con's changes, the Operator says "commit" and opens the pull request, and merges it once validate, lint, tests and DCO pass. The host does not start the release; it says when one is due. Added 2026-10-24. Done 2026-10-24. -- [ ] **cloud-folder-build Build the cloud-folder Feature** (host). A central SharePoint folder in the container through rclone, decided 2026-10-27 (`docs/proposals/cloud-folder.md`, section 7): slice 1 (`host` mode, `status`) is in progress; slice 2 (`sync`, sign-in, the shutdown check) and slice 3 (`mount`, FUSE granted per machine) remain. The office can adopt after slice 1. Added 2026-10-27. +- [ ] **cloud-folder-build Build the cloud-folder Feature** (host). A central SharePoint folder in the container through rclone, decided 2026-10-27 (`docs/proposals/cloud-folder.md`, section 7): slices 1 (`host`, #82) and 2 (`sync`, #83) are released as cloud-folder 1.1.0 (devcontainer-cli 1.12.0); slice 3 (`mount`, `auto` with FUSE, the launcher granting FUSE on request) remains. The office can adopt now. Added 2026-10-27. ### Devhost and the way of working @@ -271,8 +271,15 @@ stays. Ids are short descriptive slugs (two to four kebab-case words naming the - [ ] **psscriptanalyzer-tracked-files The PowerShell lint checks tracked files only** (host). `task lint` fails on a workstation checkout because the PSScriptAnalyzer step scans the git-ignored `material.ignore/` folder and reports 32 problems in files git never sees; the earlier `lint-ignore-local-folders` fix covered yamllint only. Build the analyzer's file list from tracked and non-ignored files, as the YAML check does, with a test. Added 2026-10-27. - [ ] **cloud-folder-ci-check Read the first CI run of the cloud-folder tests** (engineers). The Feature's container tests (`test/cloud-folder`) and the collection-wide test have not run with Docker anywhere but CI; read the first CI run and file what it shows. Added 2026-10-27. - [ ] **cloud-folder-workstation-up Run cloud-folder once on the workstation** (the Operator). One real `up` with a cloud folder whose path has spaces, on Docker Desktop and on wslc, to check the launcher's mount and `cloud-folder:status` on real hosts. Added 2026-10-27. -- [ ] **release-cloud-folder Release cloud-folder 1.0.0 and devcontainer-cli 1.12.0 after the step 1 pull request merges** (the Operator). The host does not start the release. Until it runs, consumers cannot pull cloud-folder or name it in `installsAfter`. Added 2026-10-27. -- [ ] **cloud-folder-step-2-rebase Rebase cloud-folder step 2 onto main** (host). The step 2 branch `feature/cloud-folder-feature/2` was cut from step 1 before #82 was squash-merged, so it carries the old history; rebase it onto `origin/main` once its subagent has finished, before any commit on it. Added 2026-10-27. +- [x] **release-cloud-folder Release cloud-folder 1.0.0 and devcontainer-cli 1.12.0 after the step 1 pull request merges** (the Operator). The host does not start the release. Until it runs, consumers cannot pull cloud-folder or name it in `installsAfter`. Added 2026-10-27. Done 2026-10-27. +- [x] **cloud-folder-step-2-rebase Rebase cloud-folder step 2 onto main** (host). The step 2 branch `feature/cloud-folder-feature/2` was cut from step 1 before #82 was squash-merged, so it carries the old history; rebase it onto `origin/main` once its subagent has finished, before any commit on it. Added 2026-10-27. Done 2026-10-27. +- [x] **merge-cloud-folder-step-2 Review and merge the cloud-folder step 2 pull request** (the Operator). Step 2 (`sync` mode with rclone, cloud-folder 1.1.0) is committed on its rebased branch and its pull request is open; review it, and merge once validate, lint, tests and DCO pass. The host does not merge. Added 2026-10-27. Done 2026-10-27. +- [x] **release-cloud-folder-1-1-0 Release cloud-folder 1.1.0 after the step 2 pull request merges** (the Operator). Step 2 (`sync` mode) ships as cloud-folder 1.1.0 once #83 is in; consumers cannot pull it until the release runs. The host does not start the release. Added 2026-10-27. Done 2026-10-27. +- [ ] **cloud-folder-sync-live-check Check cloud-folder sync on a real sign-in** (the Operator). After the 1.1.0 release: one real sign-in, a file each way between the container and the SharePoint folder, and a stop in the middle of a sync, to see what the sync does when it is interrupted. Added 2026-10-27. +- [ ] **agents-lexicon-terms Land the agent-model terms on the agents branch** (host). The librarian's three terms (resident agent, on-demand agent, agent list) were taken out of the step 2 commit: they came ahead of the Operator's decision and their ontology entries are missing, so the guard would refuse them. The saved patch in the session scratchpad goes onto the agents branch with those entries. Added 2026-10-27. +- [ ] **agents-commit Commit and open the pull request for the agents build** (the Operator). The `agents` 1.0.0 Feature, the Claude adapter (claude-code 1.7.0), the host task (devcontainer-cli 1.13.0) and this repository's `.devcontainer/agents.yml` pass their unit tests and the repository checks in a worktree, but nothing is committed. The host commits and opens the pull request only on the Operator's word. Added 2026-10-27. +- [ ] **agents-ci-check Read the first CI run of the agents tests** (engineers). The container tests of the `agents` Feature have not run with Docker anywhere but CI; read the first CI run and file what it shows. Added 2026-10-27. +- [ ] **release-agents Release agents 1.0.0, claude-code 1.7.0 and devcontainer-cli 1.13.0 after the agents pull request merges** (the Operator). The host does not start the release. Once it runs, rebuild the devhost here so the launcher and `task agents` carry the change. Added 2026-10-27. ## Next diff --git a/Taskfile.yml b/Taskfile.yml index df68c49..9a9ed0b 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -80,7 +80,7 @@ tasks: echo "generated READMEs are up to date" lint: - desc: 'shellcheck every shell script, yamllint every YAML file (.yamllint), PSScriptAnalyzer every PowerShell file, the vendored copies of src/common/*.sh (in src/ and local-features/) identical to the canonical ones, and the Docker-free unit tests (devhost tooling, the git Feature''s .git-dirs mapping, the ssh-agent Feature''s setup script, the cloud-folder Feature''s status and sync, the claude-code Feature''s live-session rule and claude-fleet, the host launcher) (CI runs exactly this inside .devcontainer/ci; the tools must be installed - this repo''s devcontainer has them, at the pinned versions).' + desc: 'shellcheck every shell script, yamllint every YAML file (.yamllint), PSScriptAnalyzer every PowerShell file, the vendored copies of src/common/*.sh (in src/ and local-features/) identical to the canonical ones, and the Docker-free unit tests (devhost tooling, the git Feature''s .git-dirs mapping, the ssh-agent Feature''s setup script, the cloud-folder Feature''s status and sync, the agents Feature''s list and verbs, the claude-code Feature''s agent adapter, live-session rule and claude-fleet, the host launcher and its agent task) (CI runs exactly this inside .devcontainer/ci; the tools must be installed - this repo''s devcontainer has them, at the pinned versions).' cmds: - shellcheck -x src/*/*.sh local-features/*/*.sh local-features/*/share/bin/*.sh local-features/*/share/jobs/*/*.sh test/*/*.sh test/*/unit/*.sh test/bootstrap/*.sh bootstrap/*.sh bootstrap/host/*.sh .githooks/* .devcontainer/*.sh - yamllint --strict . @@ -98,13 +98,26 @@ tasks: - task: test:ssh-key-import - task: test:ssh-agent-unit - task: test:cloud-folder-unit + - task: test:agents-unit - task: test:claude-unit - task: test:claude-fleet-task + - task: test:agent-task test:claude-unit: - desc: 'Docker-free tests of the claude-code Feature''s claude-host live-session rule (a running session, one with a pid, refuses a second copy; ended sessions never count; CLAUDE_HOST_ALLOW_SECOND; the stale-pin warning and CLAUDE_HOST_UPDATE_PIN; `claude agents --json` unavailable), of the claude-host-pin SessionStart hook (the right .env line, every other byte kept, never fails) and of claude-fleet (running by id or by folder, unset variable, start with --bg in the folder, --plan, --no-view, the environment beating .env); a fake claude on PATH answers `agents --json` from a fixture and records the other calls; part of lint.' + desc: 'Docker-free tests of the claude-code Feature''s claude-host live-session rule (a running session, one with a pid, refuses a second copy; ended sessions never count; CLAUDE_HOST_ALLOW_SECOND; the stale-pin warning and CLAUDE_HOST_UPDATE_PIN; `claude agents --json` unavailable), of the claude-host-pin SessionStart hook (the right .env line, every other byte kept, never fails) of claude-fleet (running by id or by folder, unset variable, start with --bg in the folder, --plan, --no-view, the environment beating .env), the deprecation line of the three, and of agent-adapter-claude (list from `claude agents --json --all`, a session found by name and folder, every action by id, a stopped one woken with --bg --resume , never -c, never rm, resume refused while running, --extras); a fake claude on PATH answers `agents --json` and records the other calls; part of lint.' cmds: - bash test/claude-code/unit/live_session_test.sh + - bash test/claude-code/unit/adapter_test.sh + + test:agents-unit: + desc: 'Docker-free tests of the agents Feature''s agents command: the agent list (the YAML subset, required keys, unknown keys warned, errors with file and line), AGENTS_FILE from the task variable, .env, .devcontainer/.env, the environment or the default (a missing file named with its source), each verb per kind (start, --resume, --new and its question, up, list, stop, logs), the no-terminal rule, the fallbacks when the adapter answers exit 3, the once-per-container-start marker, and the Feature Taskfile; a fake adapter keeps sessions in a file and records every call; part of lint. Needs task for the Taskfile part.' + cmds: + - bash test/agents/unit/agents_test.sh + + test:agent-task: + desc: 'Docker-free test of how `task devcontainer:agent` and `devcontainer:agent:list` hand the name, the flags and AGENTS_FILE to the agents command, inside the container and through the exec task from the host (--dry), and that the deprecated devcontainer:claude and devcontainer:claude:fleet still run and print one deprecation line; fake commands record what they get; part of lint.' + cmds: + - bash test/bootstrap/agent_task_test.sh test:claude-fleet-task: desc: 'Docker-free test of how `task devcontainer:claude:fleet` hands SESSIONS (encoded) and its arguments to claude-fleet, inside the container and through the exec task from the host (--dry), and its refusal without SESSIONS; a fake claude-fleet records what it gets; part of lint.' diff --git a/bootstrap/devcontainer.yml b/bootstrap/devcontainer.yml index 3871e81..a1d5f37 100644 --- a/bootstrap/devcontainer.yml +++ b/bootstrap/devcontainer.yml @@ -80,7 +80,10 @@ # ahead). No single quote in it. The contract is in # bootstrap/SHUTDOWN.md in the collection. # FORCE=1 (task var) stop/rebuild without running that check. -# HOST_SESSION UUID of the Claude Code session that the claude +# AGENTS_FILE for `agent` and `agent:list`: the agent list, relative to the +# repository root (default .devcontainer/agents.yml; the agents +# Feature resolves it inside: task variable, .env, environment). +# HOST_SESSION (deprecated with `claude`) UUID of the Claude Code session that the claude # task resumes (HOST_SESSION_ per sub-folder). # A session running in the folder (`claude agents --json`, # an entry with a pid) is never started twice: claude-host @@ -88,7 +91,7 @@ # a stale pin (after /clear) is reported, and the opt-in # claude-host-pin SessionStart hook (claude-code Feature # NOTES) keeps the line in .devcontainer/.env current. -# SESSIONS for `claude:fleet`: one line per session, +# SESSIONS (deprecated with `claude:fleet`) one line per session, # name|session-variable|dir|extra claude args, set where the # Taskfile includes this file, e.g. # includes: @@ -342,8 +345,23 @@ tasks: SUBCOMMAND: exec ARGS: 'pwsh -NoLogo' + agent: + desc: 'Start an agent of the repository''s agent list inside the devcontainer (the agents Feature: task agents -- ), starting the container first (through exec, which runs up; idempotent): task devcontainer:agent -- [--resume|--new] [extra CLI args]. A resident agent is made to run in the background and attached; an on-demand agent opens a new session in this terminal. AGENTS_FILE= (before the --) picks another list than .devcontainer/agents.yml. Runs directly when already inside the container.' + interactive: true + cmds: + - task: run + vars: + CMD: '{{if .AGENTS_FILE}}AGENTS_FILE_TASK_VAR={{.AGENTS_FILE | shellQuote}} {{end}}agents start {{.CLI_ARGS}}' + + agent:list: + desc: 'List the agents of the repository''s agent list with their kind, CLI, state and session id (the agents Feature: task agents:list), starting the container first; -- --json for a JSON array. Runs directly when already inside the container.' + cmds: + - task: run + vars: + CMD: '{{if .AGENTS_FILE}}AGENTS_FILE_TASK_VAR={{.AGENTS_FILE | shellQuote}} {{end}}agents list {{.CLI_ARGS}}' + claude: - desc: 'Start or resume the Claude Code host session inside the devcontainer (claude-host from the claude-code Feature), starting the container first (through exec, which runs up; idempotent): the session named by HOST_SESSION in .devcontainer/.env is resumed by UUID, or created the first time; unset, a plain new session. DIR= starts there and reads HOST_SESSION_ instead (e.g. DIR=rooms/solis -> HOST_SESSION_SOLIS); SESSION= overrides both. Extra arguments after --: task devcontainer:claude -- --model sonnet. Runs directly when already inside the container.' + desc: 'Deprecated: use task devcontainer:agent -- (the agents Feature); goes with claude-code 2.0. Start or resume the Claude Code host session inside the devcontainer (claude-host from the claude-code Feature), starting the container first (through exec, which runs up; idempotent): the session named by HOST_SESSION in .devcontainer/.env is resumed by UUID, or created the first time; unset, a plain new session. DIR= starts there and reads HOST_SESSION_ instead (e.g. DIR=rooms/solis -> HOST_SESSION_SOLIS); SESSION= overrides both. Extra arguments after --: task devcontainer:claude -- --model sonnet. Runs directly when already inside the container.' interactive: true vars: DIR: '{{.DIR | default ""}}' @@ -352,17 +370,19 @@ tasks: sh: 'printf %s "${{"{"}}{{._SESSION_VAR}}:-${HOST_SESSION:-}}"' SESSION: '{{.SESSION | default ._SESSION_FROM_ENV}}' cmds: + - cmd: 'echo "devcontainer:claude is deprecated: use task devcontainer:agent -- (the agents Feature, .devcontainer/agents.yml); it goes with claude-code 2.0." >&2' - task: run vars: CMD: '{{if .DIR}}cd ''{{.DIR}}'' && {{end}}HOST_SESSION=''{{.SESSION}}'' claude-host {{.CLI_ARGS}}' claude:fleet: - desc: 'Several Claude Code sessions in one terminal, never a second copy of one (claude-fleet from the claude-code Feature, which needs the claude-code Feature''s claude-host and Claude Code with `claude agents --json`): SESSIONS, set by the including Taskfile (include vars), has one line per session, name|session-variable|dir|extra claude args. A session counts as running when its id (the variable''s value, from .devcontainer/.env first, then the container''s environment) or its folder is live in `claude agents --json`; the others are started in the background with claude-host --bg in their folder; then `claude agents` opens. It first waits until sessions restored after a rebuild are stable (-- --no-wait skips that). Several live sessions for one agent are reported; -- --stop-duplicates stops the extras with `claude stop` (never rm). -- --plan prints what it would do and starts nothing; -- --no-view starts the missing ones without opening the view. Starts the container first; runs directly when already inside it.' + desc: 'Deprecated: use the agent list and task devcontainer:agent (the agents Feature; residents start with the container); goes with claude-code 2.0. Several Claude Code sessions in one terminal, never a second copy of one (claude-fleet from the claude-code Feature, which needs the claude-code Feature''s claude-host and Claude Code with `claude agents --json`): SESSIONS, set by the including Taskfile (include vars), has one line per session, name|session-variable|dir|extra claude args. A session counts as running when its id (the variable''s value, from .devcontainer/.env first, then the container''s environment) or its folder is live in `claude agents --json`; the others are started in the background with claude-host --bg in their folder; then `claude agents` opens. It first waits until sessions restored after a rebuild are stable (-- --no-wait skips that). Several live sessions for one agent are reported; -- --stop-duplicates stops the extras with `claude stop` (never rm). -- --plan prints what it would do and starts nothing; -- --no-view starts the missing ones without opening the view. Starts the container first; runs directly when already inside it.' interactive: true preconditions: - sh: '[ -n "{{.SESSIONS | default "" | trim}}" ]' msg: 'devcontainer:claude:fleet needs SESSIONS, the list of sessions to keep running, one per line: name|session-variable|dir|extra claude args. Set it in the vars of your root Taskfile''s devcontainer include, e.g. SESSIONS: "host|HOST_SESSION|.|" (see the claude-code Feature NOTES, "Fleet"). A repository with one session uses task devcontainer:claude instead.' cmds: + - cmd: 'echo "devcontainer:claude:fleet is deprecated: list the agents in .devcontainer/agents.yml and use task devcontainer:agent (the agents Feature); it goes with claude-code 2.0." >&2' # SESSIONS travels base64-encoded: its lines hold | and spaces, and the # command is quoted again by exec and the host shell. - task: run diff --git a/docs/ledger/2026-10.md b/docs/ledger/2026-10.md index 3c0017b..12f221b 100644 --- a/docs/ledger/2026-10.md +++ b/docs/ledger/2026-10.md @@ -4895,3 +4895,78 @@ - **For the repository:** [docs] Correct the "newer wins" wording in the proposal (section 10, item 6) and ADR 0031 (Decision 6). [tooling] Exclude git-ignored folders from the PSScriptAnalyzer scan. [process] A cloud-folder 1.1.0 release is due after #82 and this branch merge; it needs a Docker run and a manual sign-in check. - **Decisions needed:** (1) Keep bisync's default conflict handling, or use --conflict-resolve newer as the proposal described. (2) Accept --resilient --recover on every sync. (3) Accept the local copy in the container filesystem rather than a second volume, which the proposal's NFR-1 row described. (4) Keep the shutdown check refusing pending local changes. (5) Keep mode host installing no rclone. (6) Keep the client-credentials sign-in only in cloud-folder:login until verified on a real tenant. - **Measures:** $14.29 (claude-opus-5-5 $14.29; 77 replies, 13,363,464 tokens in, 129,787 out); list-price estimate (this stretch; model haiku wrote the entry) + +### 2026-10-27 Step 2 committed on the branch, rebased onto main, pull request #83 opened + +- **Session:** host (57f13518-09e9-42b2-8c54-eea9caea9f5f) +- **Engineer:** operator +- **Branch:** feature/cloud-folder-feature/2 +- **Asked:** Commit step 2 on the branch, rebase it onto main after the step 1 squash merge, and open the pull request. +- **Skill:** none +- **Came out:** One commit on feature/cloud-folder-feature/2, rebased onto main on top of #82 (squash-merged as f2da03b). Pull request #83 open. ADR 0031 holds the corrected "newer wins" wording and a change-log line for the six step 2 decisions. Unit tests (83 host-mode, 136 sync-mode) and the repository checks pass after the rebase. Agent-model lexicon terms moved out of the commit to /tmp/claude-1000/-workspaces-devcontainer-features/57f13518-09e9-42b2-8c54-eea9caea9f5f/scratchpad/agents-lexicon.patch. +- **Friction:** [tooling] The librarian added three agent-model terms to LEXICON.md before the Operator's decision; the ontology guard would have refused the commit, so they were moved to a patch. [tooling] Container tests cannot run without Docker, so that checklist box is unticked. [tooling] task lint fails on the git-ignored material.ignore/ folder, so that box is unticked too. +- **Learned:** The ontology guard refuses a lexicon term that has no matching ontology entry. +- **Decisions:** none in this stretch; the six step 2 decisions are recorded in the ADR 0031 change log. +- **For the repository:** [process] Cut the cloud-folder 1.1.0 release after #83 merges; the Operator runs it, then a real sign-in, a file each way, and a stop mid-sync. [tooling] The ontology guard should not be the first place an undecided term is caught. +- **Decisions needed:** none +- **Measures:** $4.42 (claude-opus-5-5 $4.42; 42 replies, 6,870,198 tokens in, 20,008 out); cons $1.67 (general-purpose on claude-opus-5-5 $1.67); list-price estimate (this stretch; model haiku wrote the entry) + +### 2026-10-27 Sync folder handed to the user at build and every start, for PR #83 checks + +- **Session:** host (57f13518-09e9-42b2-8c54-eea9caea9f5f) +- **Engineer:** operator +- **Branch:** feature/cloud-folder-feature/2 +- **Asked:** Fix the check failures reported on pull request #83. +- **Skill:** none +- **Came out:** Commit pushed to feature/cloud-folder-feature/2. The sync folder is now handed to the user at build and at every start, and a scenario check covers it. Lint and the unit tests were rerun before the push. The CI rerun for #83 had not reported when the stretch ended. +- **Friction:** [tooling] The failing scenario log had to be read in pieces to find the sync_local assertion and its status codes. +- **Learned:** The sync folder was reported only at build; the post-start report is the second place it is now handed over. +- **Decisions:** none +- **For the repository:** none +- **Decisions needed:** none +- **Measures:** counted in the first entry of this stretch, above + +### 2026-10-27 Live background-session facts run and written into the agents proposal + +- **Session:** host (57f13518-09e9-42b2-8c54-eea9caea9f5f) +- **Engineer:** operator +- **Branch:** feature/cloud-folder-feature/2 +- **Asked:** "trust a throwaway and run them"; the Operator answered "Yes to all". +- **Skill:** none +- **Came out:** Live background-session fact checks run in one trusted throwaway folder, with a backup of the trust record. The trust entry was removed and the cleanup confirmed. Results written into the agents proposal's facts section, and the proposal's status set to decided. +- **Friction:** [tooling] Background-session checks need a trusted folder, so trust was set by hand in Claude Code's own record and removed after the run. +- **Learned:** none +- **Decisions:** "trust a throwaway and run them; yes to all" +- **For the repository:** [tooling] Keep a trusted scratch folder for background-session tests, so the checks do not need a manual trust edit. +- **Decisions needed:** Whether the agents proposal may stay marked decided. The host set that status, and the Operator's decisions on the proposal's list were not recorded in this stretch. +- **Measures:** counted in the first entry of this stretch, above + +### 2026-10-27 Step 2 pull request #83 merged to main + +- **Session:** host (57f13518-09e9-42b2-8c54-eea9caea9f5f) +- **Engineer:** operator +- **Branch:** unknown +- **Asked:** Merge pull request #83 (the Operator reported "merged 83"). +- **Skill:** none +- **Came out:** #83 is in main at 98f67b4. The merged branch was deleted locally, and the main checkout is on main with the backs' uncommitted records. +- **Friction:** not reported +- **Learned:** none +- **Decisions:** none +- **For the repository:** none +- **Decisions needed:** none +- **Measures:** $3.06 (claude-opus-5-5 $3.06; 26 replies, 5,196,131 tokens in, 11,284 out); cons $6.69 (general-purpose on claude-opus-5-5 $6.69); list-price estimate (this stretch; model haiku wrote the entry) + +### 2026-10-27 Agents Feature 1.0.0, claude-code 1.7.0 and devcontainer-cli 1.13.0 built in worktree, not committed + +- **Session:** con general-purpose (a3ae384c8d11d35d0) +- **Engineer:** operator +- **Branch:** unknown +- **Asked:** Build slices 2 and 3 of docs/proposals/agents.md on branch feature/agent-kind-standard/1 in the scratchpad worktree: new src/agents/ Feature 1.0.0, claude-code 1.7.0 with agent-adapter-claude, devcontainer-cli 1.13.0 verbs. Do not commit or push. +- **Skill:** none +- **Came out:** Uncommitted changes in the agents worktree on feature/agent-kind-standard/1. New src/agents/ (agents.sh, install.sh, taskfile.yml, NOTES.md, README) and src/claude-code/agent-adapter-claude.sh. Updated claude-code, devcontainer-cli, bootstrap/devcontainer.yml, README, Taskfile.yml, ontology and lexicon, and the proposal. New docs/migrations/agents.md and test/agents/. Full task lint exit 0 in the worktree. Docker scenarios not run (no daemon). +- **Friction:** [tooling] Feature tasks are namespaced by Feature id, so the verb is `task agents -- `, not `task agent`. [tooling] Task lets .env override the environment for template variables, so the task cannot tell where a value came from; it passes a separate task-variable marker. [docs] The proposal's example `task agent -- host AGENTS_FILE=...` does not work, because anything after `--` is a CLI argument. [plan] The section 5 contract had no way to wake a stopped resident without a terminal; a `wake` verb was added. [tooling] The shared .env parser keeps the closing quote when a quoted value is followed by a comment. Fixed in agents.sh only; claude-host, claude-fleet and claude-host-pin still have it. +- **Learned:** Namespace default tasks let `task agents -- ` work inside the container. Bash, awk and sed are present on every base image, so the agents YAML subset is parsed in awk, not python3 or jq. +- **Decisions:** none in this stretch; D1–D5 were already decided in the proposal (status 2026-10-27). +- **For the repository:** [docs] The restart-devhost and shutdown-devhost skills and the devhost Feature description still name claude-host. [docs] CONTRIBUTING step 9 says `grep -qx 16`; the count is now 18. [tooling] Apply the quoted-value .env fix to claude-host, claude-fleet and claude-host-pin. [process] Release due after this merges: agents 1.0.0, claude-code 1.7.0, devcontainer-cli 1.13.0. +- **Decisions needed:** (1) Accept `task agents -- ` as the inside verb, or rename the Feature to `agent`. (2) Accept `wake`, `--extras` and newest-first `list` into section 5 of the contract. (3) Keep `devcontainer:claude` as it is until it is removed. (4) Accept the agents Feature in this repository's devhost. +- **Measures:** $13.33 (claude-opus-5-5 $13.33; 91 replies, 17,513,601 tokens in, 119,327 out); list-price estimate (this stretch; model haiku wrote the entry) diff --git a/docs/migrations/agents.md b/docs/migrations/agents.md new file mode 100644 index 0000000..789d1d1 --- /dev/null +++ b/docs/migrations/agents.md @@ -0,0 +1,90 @@ +# Migrating to the agents Feature + +For a consuming repository's maintainer. Releases: `agents` 1.0.0, `claude-code` 1.7.0, +`devcontainer-cli` 1.13.0 (the bootstrap). The design and its reasons are in +[the agents proposal](../proposals/agents.md). + +## What changes + +Before, each repository told Claude Code which session to resume by its id: `HOST_SESSION` (and +`HOST_SESSION_`) in `.devcontainer/.env`, kept current by the `claude-host-pin` hook; several +sessions were a `SESSIONS` list in the root Taskfile for `devcontainer:claude:fleet`; some +repositories added a join script of their own. After, a repository lists its agents once, by name, +and starts any of them with one verb. The CLI finds each session again by its name and folder, so +nothing is pinned. + +| Before | After | +| --- | --- | +| `task devcontainer:claude` (`DIR=`, `SESSION=`) | `task devcontainer:agent -- ` | +| `task devcontainer:claude:fleet` with `SESSIONS` | `kind: resident` entries; they start with the container (`agents:up`), `task devcontainer:agent -- ` attaches | +| `task claude-code:host` / `claude-code:fleet` inside | `task agents -- ` / `task agents:up` | +| `HOST_SESSION`, `HOST_SESSION_` in `.devcontainer/.env` | the agent's `name` in the list | +| the `claude-host-pin` SessionStart hook | nothing (the name survives `/clear`) | +| a join script (attach to the running session, else start it) | a `resident` entry: start always attaches | + +## Steps + +1. **Take the releases.** Re-run the bootstrap (it vendors `devcontainer.yml` with + `devcontainer:agent`), and make sure `devcontainer.json` pulls `claude-code:1` (1.7.0 or later + brings `agent-adapter-claude`). +2. **Add the Feature** to `.devcontainer/devcontainer.json`: + + ```json + "ghcr.io/nabil-becker/devcontainer-features/agents:1": {} + ``` + + It needs the go-task Feature for the `agents:*` tasks (the registry include in the root + Taskfile is already there in a bootstrapped repository). +3. **Write `.devcontainer/agents.yml`**, one entry per session you start today. A `SESSIONS` line + `name|session-variable|dir|extra claude args` becomes: + + ```yaml + agents: + - name: host # the label of the SESSIONS line + cli: claude + kind: on-demand # resident: keep it running in the background, attach on start + folder: . # the dir of the line, relative to the repository root + args: [--remote-control] # the extra claude args, one list item each + ``` + + Pick the kind per agent. `resident` runs in the background from the container's start and + start attaches to it; `on-demand` opens a new interactive session each time (`--resume` for its + latest conversation). An agent you drive from your phone through Remote Control stays + `on-demand` with `--remote-control` in `args` until a background session is shown to be + reachable that way (the proposal's fact 5). Several agents may share a folder. + `task agents:check` (or `task devcontainer:agent:list`) reads the list and names any problem by + line. Keep the file elsewhere with `AGENTS_FILE=` in `.devcontainer/.env` + or `containerEnv`. +4. **Rebuild** (`task devcontainer:rebuild`), then `task devcontainer:agent:list`: each agent with + its state. The residents were started by the container start; `task devcontainer:agent -- + ` attaches. A resident in a folder Claude Code has never trusted fails with `Workspace not + trusted`: run `claude` there once and accept. +5. **Rename the task** wherever it is named: your README, skills, start-up scripts and + `DEVCONTAINER_CONTRIBUTION.md` say `task devcontainer:agent -- ` (host) and `task agents -- + ` (inside) instead of `devcontainer:claude`, `claude-host`, `claude-code:host`. +6. **Remove the old pieces** once the new verb works for you: + - `HOST_SESSION` and every `HOST_SESSION_` line from `.devcontainer/.env` (and from a root + `.env` or `.env.example`); + - the `claude-host-pin` entry under `hooks.SessionStart` in `.claude/settings.json`; + - the `SESSIONS` var of the `devcontainer` include in the root Taskfile; + - a join script of your own and its tests (the `agents` and adapter unit tests in this + collection cover the same cases with a fake `claude`). + +## What still works until claude-code 2.0 + +`claude-host`, `claude-fleet`, `claude-host-pin`, `task claude-code:host`, `task +claude-code:fleet`, `task devcontainer:claude` and `task devcontainer:claude:fleet` keep working +in claude-code 1.7.0 and devcontainer-cli 1.13.0. Each prints one line on stderr saying it is +deprecated and that claude-code 2.0 removes it. Nothing else about them changed, so you can move at +your own pace, one agent at a time; the old and the new verb see the same sessions (both read +`claude agents --json`), but only the new one finds them by name, so give a session started the old +way a name (`/rename `) or let it end before the agent's first start the new way, or +`task agents -- ` will start a second session beside it. + +## What 2.0 removes + +claude-code 2.0.0 removes `claude-host`, `claude-fleet` and `claude-host-pin` and their tasks; +the next devcontainer-cli minor after it removes `devcontainer:claude` and +`devcontainer:claude:fleet`. In this collection's own repository that release also drops +`SESSIONS` from `Taskfile.yml`, `HOST_SESSION` from `.devcontainer/.env`, the pin hook from +`.claude/settings.json` and the `test:claude-fleet-task` test. diff --git a/docs/model/ontology.toml b/docs/model/ontology.toml index 64b151f..67e82b7 100644 --- a/docs/model/ontology.toml +++ b/docs/model/ontology.toml @@ -876,3 +876,38 @@ summary = "A bind mount the launcher adds to devcontainer up from a per-host-typ subject = "devcontainer" predicate = "uses" object = "host-mount" + +[agent-list] +type = "record" +term = "Agent list" +summary = "The file a repository keeps naming its agents (name, CLI, folder, args, kind); .devcontainer/agents.yml unless AGENTS_FILE names another repo-relative path." + +[resident-agent] +type = "concept" +term = "Resident agent" +summary = "An agent kind: runs continuously in the background; starting it attaches, starting it first when it is not running." + +[on-demand-agent] +type = "concept" +term = "On-demand agent" +summary = "An agent kind: starting it opens a new session; it resumes an earlier one only when asked." + +[[relation]] +subject = "agent-list" +predicate = "contains" +object = "resident-agent" + +[[relation]] +subject = "agent-list" +predicate = "contains" +object = "on-demand-agent" + +[[relation]] +subject = "resident-agent" +predicate = "is-a" +object = "agent" + +[[relation]] +subject = "on-demand-agent" +predicate = "is-a" +object = "agent" diff --git a/docs/proposals/README.md b/docs/proposals/README.md index 4652e60..76088e6 100644 --- a/docs/proposals/README.md +++ b/docs/proposals/README.md @@ -12,3 +12,4 @@ Drafts the Operator decides on. A proposal is never in force until the Operator | [Iteration package](iteration-package.md) | accepted (2026-10-16): the flow and rule (papers carry over; the foundation is merged, never nested; it holds what the iteration finds foundational, not the OS tooling); no true-up: this repository exports the first package, and its paper records what came over | | [Host profile](host-profile.md) | decided 2026-10-23: the host is an adapter owned by devcontainer-cli (ADR 0024); `HOST_TYPE` with `HOST_TYPE_PRIORITY`, `DCF_HOST_*` variables, launcher mounts, the bootstrap in `.devcontainer/cli_bootstrap.ignore/`; build not started | | [Cloud folder](cloud-folder.md) | decided 2026-10-27: a Feature giving the container one fixed folder filled from SharePoint or OneDrive through rclone (`host`, `sync`, `mount`; `auto` in that order); `remoteType` `sharepoint`; FUSE granted by the launcher on request; three slices, build not started | +| [Agents](agents.md) | decided 2026-10-27: one agent list per repository (path set by `AGENTS_FILE`), two kinds (`resident` attaches, `on-demand` opens a new session), one `task agent` verb, CLI adapters in a new `agents` Feature; the host stays `on-demand` until Remote Control reaches a background session; claude-code 2.0 drops claude-host, claude-fleet and the pin hook; build not started | diff --git a/docs/proposals/agents.md b/docs/proposals/agents.md new file mode 100644 index 0000000..1f96097 --- /dev/null +++ b/docs/proposals/agents.md @@ -0,0 +1,292 @@ +# Proposal: one agent list, two kinds, one verb for every agent CLI + +Status: decided 2026-10-27 by the Operator: D1–D5 as recommended, and no limit on resident agents per folder. Facts 5 and 6 are still for the workstation. Slices 2 and 3 built 2026-10-27 (`agents` 1.0.0, claude-code 1.7.0, devcontainer-cli 1.13.0; "As built" below), not yet released. Written 2026-10-27 from the Operator's direction of the same day. Backlog: `agent-kind-standard` (it carries `fleet-design-rethink`'s fact list); decision `session-concept-drop`; replaces `claude-host-join-running` (paused). No Feature code yet. + +Who this serves: every consuming repository's maintainer, and the engineers who start agents in a devcontainer. They get one standard way to start any agent, whatever its CLI. + +## As built (slices 2 and 3, 2026-10-27) + +Where the build differs from the text below, the build is what ships: + +- **Task names.** A Feature's tasks live under its id, so inside the container the verb is `task agents -- ` (the + namespace's default task), `task agents:up`, `agents:list`, `agents:stop`, `agents:logs`, plus `agents:check`; from + the host `task devcontainer:agent -- ` and `devcontainer:agent:list`. The command behind them is `agents` + (`agents start|up|list|stop|logs|check`). +- **A task variable goes before the `--`:** `task agents AGENTS_FILE=ops/agents.yml -- host` (after it, it is a CLI + argument). +- **The contract has one more verb, `wake`:** bring a stopped or failed session back in the background with its + conversation (Claude Code: `claude --bg --resume `, fact 3). `start-bg` always starts a fresh session. + `wake` unsupported falls back to a fresh `start-bg`, said in one line. `list` prints the sessions newest first, and + `stop` takes `--extras` (stop the agent's other running sessions, keep the newest). `` is absolute. +- **A stopped resident** is woken with `wake` whether or not there is a terminal (then attached from one), rather than + `claude attach ` / `claude respawn `. `attach` on a stopped session still restarts it. +- **On-demand `resume`** finds the agent's newest session by name and folder in `claude agents --json --all` and runs + `claude --resume `, never `--resume `; it is refused while that session runs. +- **D5 as decided:** claude-code is **1.7.0** (not 2.0.0): the adapter is added, and `claude-host`, `claude-fleet`, + `claude-host-pin`, `devcontainer:claude` and `devcontainer:claude:fleet` keep working with one deprecation line each. + `devcontainer:claude` keeps its behaviour rather than becoming `devcontainer:agent -- `. +- **The list's YAML** is a subset read by awk (no python3 or jq on every base image); unknown keys warn, anything + outside the subset is refused with its line. +- **This repository** lists `host` as `on-demand` with `--remote-control` (D2) and adds the Feature to its devhost; + `SESSIONS`, `HOST_SESSION` and the pin hook stay until 2.0 (section 8). +- The consumer migration note is `docs/migrations/agents.md`. + +## 1. The direction (given, not reopened) + +1. Each repository declares its agents in **one list**: a name, a CLI, a folder, extra args, and a kind. +2. **Two kinds.** `resident`: runs continuously in the background and starts with the container; "start" means make sure it runs, then attach. `on-demand`: "start" opens a new session; a resume only on request. +3. **One verb** for everyone: `task agent -- ` inside, `task devcontainer:agent -- ` from the host. It does the right thing for the kind. +4. Each CLI Feature supplies an **adapter** with the same verbs. Claude Code first; Copilot CLI and others later. An adapter may say "not supported"; the verb then falls back (section 6). +5. The list's **path is configurable** (the Operator, 2026-10-27). Default `.devcontainer/agents.yml`. + +The session leaves the domain. An agent is a name, a folder and a kind. Which session runs it is the adapter's business. + +## 2. The picture + +``` + HOST DEVCONTAINER + ---- ------------ + task devcontainer:agent -- host + | (passes through, starts the + | container first) + v + task agent -- [--resume|--new] + | + v + agents list (AGENTS_FILE, else .devcontainer/agents.yml) + | host: cli=claude kind=resident folder=. + | reviewer: cli=claude kind=on-demand folder=rooms/review + | pair: cli=copilot kind=on-demand folder=. + v + kind? + +-- resident ----> adapter: list -> running? no -> start-bg + | | (stopped -> restart) + | v + | attach (no TTY: print how, exit 0) + | + +-- on-demand ---> adapter: new (default) + resume (with --resume) + v + adapter for the CLI: agent-adapter- + claude -> claude --bg / attach / --resume / agents --json / stop + copilot -> (later) verbs it lacks answer "unsupported" (exit 3) + + container start (postStartCommand): task agent:up + -> every resident: make sure it runs (no attach) +``` + +## 3. The list + +Default path `.devcontainer/agents.yml`, beside `devcontainer.json`, committed. + +```yaml +# .devcontainer/agents.yml +agents: + - name: host # unique in the list; letters, digits, - and _ + cli: claude # picks the adapter: agent-adapter-claude + kind: resident # resident | on-demand + folder: . # repo-relative; the session's working folder + args: [--model, opus] # handed to the CLI as they are (a YAML list, no shell quoting) + - name: reviewer + cli: claude + kind: on-demand + folder: rooms/review + args: [--model, sonnet] + - name: pair + cli: copilot + kind: on-demand + folder: . +``` + +Rules, kept small on purpose: + +- `name`, `cli`, `kind` are required. `folder` defaults to `.`, `args` to none. +- Nothing else is read. Unknown keys are a warning, not an error, so a repository can note things for itself. +- One file. No includes, no per-user overrides. + +**Where the list lives.** One plain variable, `AGENTS_FILE`, a path relative to the repository root (the folder holding `.devcontainer/`). It follows NFR-6: resolved from the task's CLI variable, then `.env` / `.devcontainer/.env`, then the environment. Unset: `.devcontainer/agents.yml`. The `agents` Feature may also take an option, `agentsFile`, which only sets the environment default (written at install, read when `AGENTS_FILE` is unset). Order, first wins: + +| Source | Example | When to use | +|---|---|---| +| task variable | `task agent -- host AGENTS_FILE=ops/agents.yml` | a one-off run, a test | +| `.env` / `.devcontainer/.env` | `AGENTS_FILE=ops/agents.yml` | per checkout | +| environment (incl. `containerEnv`) | `"containerEnv": {"AGENTS_FILE": "ops/agents.yml"}` | per repository, committed | +| Feature option `agentsFile` | `"agents": {"agentsFile": "ops/agents.yml"}` | per repository, committed | +| built-in default | `.devcontainer/agents.yml` | almost always | + +Recommendation: the variable only, no Feature option (decision D3). The option is a second way to say the same thing, and `containerEnv` already covers "committed with the repository". A missing file is an error naming the path it looked for and where that path came from. + +## 4. The verbs + +| Verb | `resident` | `on-demand` | +|---|---|---| +| `task agent -- ` | make sure it runs, then attach | a new session in its folder, in this terminal | +| `task agent -- --resume` | same as above | resume this agent's latest conversation (adapter `resume`) | +| `task agent -- --new` | stop it, start a fresh one, attach (asks first; decision D4) | same as plain start | +| `task agent:up` | start every resident not running (container start) | skipped | +| `task agent:list` | each agent: kind, state (running, stopped, none), id | the same, from the adapter's `list` | +| `task agent:stop -- ` | stop it; the conversation is kept | stop a running one, if the adapter can | +| `task agent:logs -- ` | recent output | recent output, if running in the background | + +Extra CLI flags pass through after the name: `task agent -- reviewer --effort high`. + +**No TTY.** `task agent` is `interactive: true`. When stdin or stdout is not a terminal (an agent or a script calls it), a resident is made to run and the verb prints the attach command and exits 0; an on-demand agent is refused (exit 2), because a new interactive session without a terminal is never what was meant. This holds whatever fact 4 turns out to be. + +**Identity is the name.** The adapter tags each session with the agent's name and finds it again by name and folder. No UUID is written anywhere in the repository. + +## 5. The adapter contract + +Each CLI Feature installs one executable, `agent-adapter-` on `PATH`. The `agents` Feature calls it; nothing else does. + +``` +agent-adapter- [-- args...] + start-bg start the agent in the background in ; print its id + attach open the running agent in this terminal + resume open the agent's latest conversation in this terminal + new open a new conversation in this terminal + list print one line per session of this CLI: nameidstatefolder + state: running | stopped | failed + stop stop the agent; keep its conversation + logs print recent output + supports print the verbs it supports, one per line +Exit: 0 done, 1 failed, 2 bad use, 3 not supported by this CLI. +``` + +**The Claude Code adapter** (`agent-adapter-claude`, from the claude-code Feature): + +| Verb | Claude Code call | Rests on | +|---|---|---| +| `start-bg` | `cd && claude --bg --name ` | fact 1 | +| `list` | `claude agents --json --all`, kept where `name` and `cwd` match | fact 2 | +| `attach` | `claude attach ` (the id from `list`, never the name: the name matches "part of a name" and only while running) | facts 1, 4 | +| `resume` | `cd && claude --resume ` | fact 3 | +| `new` | `cd && claude --name ` | | +| `stop` | `claude stop ` | fact 1 | +| `logs` | `claude logs ` | fact 1 | + +A stopped or failed resident is restarted by `claude attach ` (the docs: attaching to a stopped session restarts it from its saved conversation) or, without a terminal, `claude respawn `. + +**Two residents by the same name** (Claude numbers a collision `host (2)`): the adapter keeps the newest by `startedAt`, reports the others and stops nothing. `task agent:stop` with `--extras` stops them. That is today's fleet rule, kept. + +## 6. When an adapter says "not supported" + +| Verb unsupported | Fallback | +|---|---| +| `start-bg` | a resident runs as on-demand: plain start opens `new`, with one line saying the CLI has no background mode. `agent:up` skips it with the same line. | +| `attach` | print `logs` (if supported) and the reason; exit 1. | +| `resume` | `new`, with one line saying the CLI cannot resume. | +| `list` | the agent counts as not running. A resident is then never started twice by `agent:up` within one container start (a marker under the Feature's state folder); otherwise "make sure it runs" cannot be promised, and the verb says so. | +| `stop`, `logs` | say so; exit 3. | + +Rule: a fallback never starts a second copy of something that may be running, and always says in one line what it did instead. + +## 7. Facts from Claude Code 2.1.308, and how they shape this + +Checked 2026-10-27 in this devhost with `claude --help`, the subcommands' `--help`, the docs at code.claude.com (agent-view, sessions, remote-control), and harmless calls on a session name that does not exist. **No background session could be started here**: `claude --bg` in an untrusted folder exits with `Workspace not trusted. Run claude ... once and accept the trust prompt`, and the only trusted folder is this repository's main checkout, which another session was using. Accepting trust for a scratch folder is a change to the Operator's Claude state, so it was not done. A script for the live checks is in section 11. + +**Live run, 2026-10-27** (the Operator allowed one throwaway folder to be trusted; the trust entry was removed afterwards and both test sessions were stopped and removed): + +- **1:** `claude --bg --name zz-throwaway-agtest ...` started `d7aba308`; `claude logs ` and `claude logs ` both worked; `claude stop ` failed (`No job matching`, exit 1): **stop takes the id only, confirmed**. +- **2:** `claude agents --json` showed `"name": "zz-throwaway-agtest"`, `"kind": "background"`, a `sessionId` and `status`/`state`. **Confirmed live**; `/clear` still not driven. +- **3:** `claude --bg --resume ` woke the same id (`note: woke session d7aba308 with its saved options (--name, --model)`): **confirmed live**. `claude --bg -c` started a copy under a new id with a `note:` line: **confirmed live**. +- **4:** `claude attach ` with stdin and stdout not a terminal printed `Attaching…` and exited 0 at once: it does not hang and does not fail, but it is no use to a non-interactive caller. **The adapter attaches only from a terminal** (section 4's rule stands). +- **5, 6:** not testable from here; still for the Operator (`/remote-control` inside an attached background session; one rebuild). + +| # | Fact | Result | Evidence | +|---|---|---|---| +| 1 | A background session can be named, and `attach`, `logs`, `stop` take the name | **Partly confirmed (docs and help)**; live run untestable here | `claude --help`: `-n, --name `; `attach ` "Part of the session name works too"; `logs `; `stop\|kill ` and `rm ` take the id only. The docs: names need 2.1.290+; `attach` by name "only while its process is running". `claude attach zz-nonexistent-agtest` prints `No job matching 'zz-nonexistent-agtest'. Run 'claude agents' to list running sessions.`, exit 1 (same for `logs`, `stop`, `rm`). | +| 2 | The name shows in `claude agents --json` and survives `/clear` | **Confirmed (docs)**; `/clear` could not be driven non-interactively | The docs list `name` as a field "when set"; here the list shows `name` for every session. Sessions docs: after `/clear` "the new session keeps a name you set with `--name` or `/rename`". A `/rename` survives a supervisor restart. | +| 3 | A background session can continue a conversation | **Confirmed (docs and help)**; live run untestable here | `--bg` help: "With --resume , continues that session in the background under the same ID". The docs: `--bg` with `--continue`, a bare `--resume`, or `--resume ` "always starts a copy" and prints a `note:` line. | +| 4 | A caller without a TTY can attach; attach on a stopped session | **Partly**: the not-found path needs no TTY (exit 1); attaching to a running session without a TTY is untested | Docs: attach "takes over the terminal"; attaching to a stopped or failed session "restarts it from its saved conversation" (by id). Not stated whether attach refuses without a TTY. | +| 5 | A background session can be reached through Remote Control | **Not documented**; untestable here | Remote Control docs: auto-connect applies to "every interactive session"; "each interactive Claude Code process registers one remote session". Agent-view docs never mention Remote Control. Third-party write-ups say background sessions are not reachable from a phone. | +| 6 | Background sessions after a container rebuild | **Untestable from inside; the Operator to test on the workstation** | Docs: "Shutdown or restart stops running sessions"; within 48 hours they show as failed and attach or reply restarts them from the saved conversation; older ones show stopped. State lives in `CLAUDE_CONFIG_DIR` (`jobs/`, `daemon/roster.json`), which the claude-code Feature keeps in a volume. The Feature's NOTES (1.6.0) record a consumer seeing the supervisor restore background sessions after a rebuild. | + +How they shape the design: + +- **Identity by name, action by id** (facts 1, 2). The adapter finds the agent by `name` and `cwd` in `claude agents --json --all`, then always calls `attach`, `stop`, `logs` with the id. That avoids the "part of a name" match (`host` would match `host-review`) and the "name only while running" limit. +- **No pins** (fact 2). The name survives `/clear`, so nothing has to write a new id back. This is what lets `HOST_SESSION`, the pin hook and the stale-pin warnings go. +- **Resume by id in the background** (fact 3). A stopped resident is brought back with `attach ` or `respawn `, never with `--bg -c` or `--bg --resume `, which start copies. +- **No TTY, no attach** (fact 4). Section 4's rule does not depend on what attach does without a terminal. +- **Remote Control** (fact 5). If a background session cannot be reached from a phone, the host agent as a resident loses phone access. Fallback: keep `kind: on-demand` for the agent the Operator drives from the phone, started with `--remote-control` in `args`. Decision D2. +- **Rebuild** (fact 6). `agent:up` at container start first waits for the list to settle (today's fleet rule, from `claude-fleet`), then restarts each resident found stopped or failed by id, and starts the rest fresh. + +## 8. What goes away, and the migration + +| Goes | Replaced by | +|---|---| +| `HOST_SESSION`, `HOST_SESSION_` in `.env` | the agent's `name` in the list | +| the `claude-host-pin` SessionStart hook | nothing (the name survives `/clear`) | +| `claude-host`: its refusals, attach advice, `CLAUDE_HOST_*` variables | `task agent`, by kind | +| `claude-fleet` and the `SESSIONS` include variable | the list and `task agent:up` | +| `devcontainer:claude`, `devcontainer:claude:fleet` | `devcontainer:agent` | +| `CLAUDE_HOST_JOIN` (requested, paused) | a resident always attaches | + +Versions (D5, as built): claude-code **1.7.0** adds the adapter and prints a one-line deprecation note from the three old scripts, which keep working; a new `agents` Feature **1.0.0**; devcontainer-cli **1.13.0** adds `devcontainer:agent` and `devcontainer:agent:list`, and `devcontainer:claude` and `devcontainer:claude:fleet` keep working with a deprecation line. claude-code **2.0.0** then removes `claude-host`, `claude-fleet`, `claude-host-pin`, and the next devcontainer-cli minor removes `devcontainer:claude` and `devcontainer:claude:fleet`. + +What removes the old pieces, and when (none of them goes in the 1.7.0 build): + +| Piece | Removed by | +|---|---| +| `claude-host`, `claude-fleet`, `claude-host-pin` and `task claude-code:host` / `:fleet` | claude-code 2.0.0 | +| `devcontainer:claude`, `devcontainer:claude:fleet`, the `HOST_SESSION` / `SESSIONS` notes in `bootstrap/devcontainer.yml` | the devcontainer-cli minor that follows claude-code 2.0.0 | +| this repository's `SESSIONS` var in `Taskfile.yml`, `HOST_SESSION` in `.devcontainer/.env`, the `claude-host-pin` hook in `.claude/settings.json`, `test:claude-fleet-task` | the same change as claude-code 2.0.0 | +| a consumer's pins, hook, `SESSIONS` and join scripts | the consumer, following `docs/migrations/agents.md`, before it takes claude-code 2.x | + +Per consumer: + +- **This repository.** `SESSIONS: "host|HOST_SESSION|.|"` in `Taskfile.yml` becomes `.devcontainer/agents.yml` with `host` (`kind` per decision D2). Drop `HOST_SESSION` from `.devcontainer/.env`, the `claude-host-pin` hook from `.claude/settings.json`, `test:claude-fleet-task`. The `restart-devhost` skill and the devhost Feature's description and setup message name `task agent -- host` instead of `claude-host`. +- **The office.** It runs several agents in one container (the source of the fleet's 1.6.0 lessons). Its `SESSIONS` lines become list entries, one per agent, `kind: resident` for the ones kept running. Its `HOST_SESSION_` lines go. +- **Software project 1.** One host session today: one `resident` or `on-demand` entry; its pin goes. +- **agentic-operating-framework.** Its `scripts/join.sh` (what `CLAUDE_HOST_JOIN` was for) is replaced by a `resident` entry; its `scripts/test-join.sh` cases become the adapter's unit tests (a fake `claude`). It drops both once `agents` 1.0.0 ships. + +Each consumer's `.devcontainer/DEVCONTAINER_CONTRIBUTION.md` gets the migration note: add the `agents` Feature, write the list, remove the pins and the hook, rename the task. + +## 9. Where it lives + +Recommendation (decision D1): **a small new Feature, `agents`**, that owns the list, `task agent*`, the kind logic, the fallbacks and `agent:up` (as its `postStartCommand`). CLI Features each add only `agent-adapter-`. `agents` names no CLI Feature in `dependsOn`; it finds adapters on `PATH`, and a list entry whose adapter is missing is an error naming the Feature to add. It installs a bash script and a taskfile, nothing downloaded. + +The simpler option, weighed: put it all in claude-code now and split later. It is less to ship today, but the list, the kinds and the verbs would then belong to one CLI, and the Copilot adapter would have to depend on claude-code or copy the logic. The split is the point of the standard, so it should be there from the start. + +## 10. The backs and the devhost here + +- The backs run from the repository's `.claude/settings.json` `Stop` hooks (`devhost-run-job`, `claude -p`). Every Claude session in the folder fires them, residents included. One resident (the host) changes nothing. Several residents may share a folder (the Operator, 2026-10-27: no limit). Each fires the backs only when it takes a turn, so an idle resident costs nothing and the cost follows the work, which the ledger should record anyway. The backs already serialise on a per-job lock (`run-job.sh`, `flock`): a `Stop` that finds the lock busy is skipped and picked up from its session's offset on the next turn, so nothing is torn or written twice; a record may arrive a turn late under load (see "Still open"). An agent may still have its own folder (`dir`) when that suits it. +- `restart-devhost` and `shutdown-devhost` name `task agent:list` and `task agent -- host`. +- The shutdown handshake tabled in `fleet-design-rethink` (votes from busy agents) is not part of this proposal; `agent:stop` is enough to build it on later. + +## 11. Slices + +1. **The facts, live.** The Operator runs the script below in a trusted throwaway folder on the workstation's devcontainer, plus a rebuild with one resident running (fact 6) and `/remote-control` inside an attached background session (fact 5). One ledger entry with the results. Script: `run-facts.sh` (handed over with this proposal; not committed). +2. **`agents` 1.0.0 and the Claude adapter** (claude-code 2.0.0): the list, `AGENTS_FILE`, the verbs, the fallbacks, `agent:up`; unit tests with a fake adapter and a fake `claude`; this repository migrates. +3. **devcontainer-cli**: `devcontainer:agent` and the deprecated `devcontainer:claude` alias. +4. **Consumers**: migration notes; the office and agentic-operating-framework move. +5. **A second CLI** (Copilot CLI): its adapter, proving the contract and the fallbacks. + +## 12. Non-functional requirements + +- **NFR-1 owns-its-state:** `agents` keeps only the `list`-fallback marker, in `/var/lib/devcontainer-features/agents`. Sessions stay in each CLI's own state (claude-code's volume). +- **NFR-2, NFR-7:** `task agent*` in the Feature's taskfile, `version` first, every task with a `desc`; `agent` and its `--resume`/`--new` forms `interactive: true`; `agent:list` prints a machine-readable form with `-- --json`. +- **NFR-4 augments-not-replaces:** the adapter calls the CLI as installed; nothing is wrapped or replaced. +- **NFR-5:** `agent:up` is idempotent: a second run starts nothing. +- **NFR-6:** `AGENTS_FILE` is the one setting, resolved in the usual order. +- **NFR-8, NFR-9:** NOTES to the standard; NFR-9: Claude Code's own agent view lists sessions but has no per-repository list or kinds, and no other CLI has one. +- **NFR-10:** tests on the base images, with fake CLIs (no login, no network). +- **No process of ours on the host** (ADR 0024): the host task only passes through. The background processes are the CLIs' own, inside the container. + +## 13. Decisions for the Operator + +- **D1 Where it lives.** A new `agents` Feature with adapters in the CLI Features (recommended), or everything in claude-code for now. +- **D2 The host agent's kind here.** `resident` (recommended once fact 5 shows a background session can be reached through Remote Control), else `on-demand` with `--remote-control` in its `args` until it can. +- **D3 The list's path override.** The `AGENTS_FILE` variable only (recommended), or also a Feature option `agentsFile`. +- **D4 `--new` on a resident.** Stop the running one and start fresh after a yes/no question (recommended), or refuse and point to `agent:stop`. +- **D5 claude-code 2.0.0 removes `claude-host`, `claude-fleet`, `claude-host-pin` outright** after one 1.x release with a deprecation note (recommended), or keeps them as thin shims over `task agent`. + +## 14. Still open (unverified facts, not decisions) + +- Fact 1 live: `claude stop ` (help says id only); whether `logs` matches a prefix of the name. +- Fact 4: `claude attach ` on a running session without a TTY. +- Fact 5: Remote Control for a background session, attached or not. +- Fact 6: background sessions after a container rebuild with the claude-code volume. +- The workspace trust prompt: a resident in a folder that was never trusted fails to start (`Workspace not trusted`). Whether `agent:up` should say so and stop, or the claude-code Feature should mark the repository's folders trusted (a change to the user's Claude state; not proposed). +- Idle cost: the supervisor stops a finished, unattached session after about an hour; a resident then restarts on attach. Whether residents should be pinned (`Ctrl+T`, no CLI flag found) to stay warm. +- Worktree isolation: a background session moves into its own git worktree under `.claude/worktrees/` before editing, unless `worktree.bgIsolation` is `none`. With this repository's git wrapper (ADR 0020) that may fail as the Agent tool's isolation does (`devhost-worktree-isolation`); a resident here probably needs `bgIsolation: none`. +- Under several busy residents in one folder, how late do the backs' records arrive (a `Stop` skipped on a busy lock is caught up on that session's next turn)? A load check, not a design question. diff --git a/src/agents/NOTES.md b/src/agents/NOTES.md new file mode 100644 index 0000000..f57b24f --- /dev/null +++ b/src/agents/NOTES.md @@ -0,0 +1,228 @@ +## What it installs + +One standard way to start any agent, whatever its CLI. A repository declares its agents in one +list; the `agents` command (on PATH, `/usr/local/bin/agents`) reads it and does the right thing +for each agent's kind, through the adapter its CLI Feature supplies. The Feature installs that +bash script, the Feature Taskfile and an empty state folder; nothing is downloaded, so there is +nothing to verify. It installs no adapter: the `claude-code` Feature (1.7.0 or later) brings +`agent-adapter-claude`; other CLIs follow. + +``` + HOST DEVCONTAINER + task devcontainer:agent -- host --> task agents -- host [--resume|--new] + | + v + agent list (AGENTS_FILE, else .devcontainer/agents.yml) + | + resident <----------+----------> on-demand + running? attach : start-bg/wake, attach new (default) / resume (--resume) + | + v + agent-adapter- (claude: claude --bg / attach / --resume ...) + container start (postStartCommand): agents up --post-start -> every resident runs, none attached +``` + +## The agent list + +`.devcontainer/agents.yml`, beside `devcontainer.json`, committed: + +```yaml +agents: + - name: host # unique; letters, digits, - and _ + cli: claude # picks the adapter: agent-adapter-claude + kind: on-demand # resident | on-demand + folder: . # repo-relative, default . + args: [--remote-control] # handed to the CLI as they are (a YAML list, no shell quoting) + - name: reviewer + cli: claude + kind: resident + folder: rooms/review + args: + - --model + - sonnet +``` + +- `name`, `cli`, `kind` are required; `folder` defaults to `.` and must stay inside the + repository; `args` default to none. +- Nothing else is read. An unknown key is a warning (with its line), not an error, so a + repository can note things for itself. One file: no includes, no per-user overrides. +- A broken list is refused (exit 2) with every problem named by file and line. `task + agents:check` reads and checks it without starting anything. +- The YAML is a small subset, read by awk: comments, plain, single- and double-quoted values, an + `args` list in flow (`[a, "b c"]`) or block (`- a`) form. No anchors, no multi-line values, no + `{ }` entries. Why awk: the Feature must work on every base image, and bash, awk and sed are + what they all have; python3 and jq are not guaranteed (the CI matrix includes `debian:latest` + and `ubuntu:latest`), and the other Features of this collection read JSON with sed for the same + reason. A vendored YAML parser would be a larger, unverified download for a five-key file. + +**Kinds.** `resident`: runs continuously in the background; starting it means make sure it runs, +then attach. `on-demand`: starting it opens a new session in this terminal; it resumes its latest +conversation only when asked. + +**Where the list is: `AGENTS_FILE`.** A path relative to the repository root (the nearest folder +at or above the current one holding `.devcontainer/`; an absolute path is used as it is), +resolved like every setting here (NFR-6), first wins: + +| Source | Example | +| --- | --- | +| task variable (before the `--`) | `task agents AGENTS_FILE=ops/agents.yml -- host` | +| the root `.env`, then `.devcontainer/.env` | `AGENTS_FILE=ops/agents.yml` | +| the environment (incl. `containerEnv`) | `"containerEnv": {"AGENTS_FILE": "ops/agents.yml"}` | +| built-in default | `.devcontainer/agents.yml` | + +There is no Feature option for it (`containerEnv` already covers "committed with the +repository"). A missing file is an error naming the path it looked for and where that path came +from. Task fills its variables from `.env` and the environment too, so the task hands its +variable over as `AGENTS_FILE_TASK_VAR`, and the command counts it as a task variable only when +it differs from both. + +## The verbs + +| Task | `resident` | `on-demand` | +| --- | --- | --- | +| `task agents -- ` | make sure it runs (start it, or wake a stopped one with its conversation), then attach | a new session in its folder, in this terminal | +| `task agents -- --resume` | the same | resume its latest conversation (refused while that one runs) | +| `task agents -- --new` | stop the running session and start a fresh one, after a yes/no question (`--yes` skips it), then attach | the same as plain | +| `task agents:up` | start every resident not running, attach none (the postStartCommand) | skipped | +| `task agents:list` | name, kind, CLI, state (`running`, `stopped`, `failed`, `none`; `+N` extra running sessions), id, folder; `-- --json` for agents | the same | +| `task agents:stop -- ` | stop it; the conversation is kept. `-- --extras` stops only the extra sessions | stop a running one, if the adapter can | +| `task agents:logs -- ` | recent output | recent output, if it ran in the background | +| `task agents:check` | read and check the list, print it | the same | +| `task agents:version` | the Feature version and the list in use | | + +Extra CLI flags follow the name: `task agents -- reviewer --effort high`; after `--` everything +goes to the CLI, even `--resume`. They come after the list's `args`. + +**No terminal.** `task agents` is `interactive: true`. When stdin or stdout is not a terminal (an +agent or a script calls it), a resident is made to run and the command prints how to attach +(exit 0); an on-demand agent is refused (exit 2), because a new interactive session without a +terminal is never what was meant. `--new` on a running resident without a terminal is refused +unless `--yes`. + +**Two sessions for one agent** (a crash, a restore, Claude Code numbering a collision `host (2)`): +the newest is the agent's; the others are reported, never stopped on their own. `task +agents:stop -- --extras` stops them. + +**At container start** `agents up --post-start` first waits until the CLI's session list settles +(a CLI may restore background sessions after a rebuild; starting in that window makes copies): +two reads `AGENTS_SETTLE_INTERVAL` seconds apart (default 3) with the same running sessions, at +most `AGENTS_SETTLE_TIMEOUT` (default 30). Then each resident found stopped or failed is woken by +id and the rest are started. It never fails the container start: a missing list is one line, a +start that fails is said and the start goes on. `task agents:up -- --no-wait` skips the wait. + +Exit codes of `agents`: 0 done, 1 failed, 2 bad use (unknown agent, broken or missing list, +refused), 3 the CLI cannot do it. + +## The adapter contract + +Each CLI Feature installs one executable on PATH, `agent-adapter-`; `agents` calls it, +nothing else does. A list entry whose adapter is missing is an error naming the program and the +Feature to add. + +``` +agent-adapter- [--extras] [-- args...] + start-bg start the agent in the background in (a fresh session); print its id + wake bring the agent's stopped or failed session back in the background, same conversation + attach open the agent's running session in this terminal + resume open the agent's latest conversation in this terminal + new open a new conversation in this terminal + stop stop the agent (--extras: only its extra sessions); keep the conversation + logs print recent output +agent-adapter- list one line per session of this CLI, newest first: + nameidstatefolder (state: running | stopped | failed) +agent-adapter- supports the verbs it supports, one per line +Exit: 0 done, 1 failed, 2 bad use, 3 not supported by this CLI. +``` + +`` is absolute. A session is the agent's when its folder is the agent's and its name is +the agent's name, or that name with a collision number (`host (2)`). Identity is the name: the +adapter tags each session with it and finds it again by name and folder, so no id is written into +the repository. + +When an adapter answers exit 3, the verb falls back, says so in one line, and never starts a +second copy of something that may be running: + +| Not supported | Fallback | +| --- | --- | +| `start-bg` | the resident runs as on-demand: plain start opens `new`; `agents:up` skips it | +| `wake` | a fresh `start-bg` | +| `attach` | its `logs` and the reason; exit 1 | +| `resume` | `new` | +| `list` | the agent counts as not running; a resident is started at most once per container start (a marker under `/var/lib/devcontainer-features/agents`, keyed by the start of the container's PID 1), and the verb says that "make sure it runs" cannot be promised | +| `stop`, `logs` | say so; exit 3 | + +## State + +None that must survive a rebuild (NFR-1): the agents are the CLIs' own sessions, kept in each +CLI's state (claude-code's volume at `CLAUDE_CONFIG_DIR`). `/var/lib/devcontainer-features/agents` +(mode 1777, created at install, not a volume) holds only the once-per-container-start markers of +the `list` fallback; `AGENTS_STATE_DIR` points elsewhere. + +## Options + +| Option | Default | What it does | +| --- | --- | --- | +| `installTaskfile` | `true` | Register `taskfile.yml` with the go-task Feature's registry, so `task agents*` exist. Turn it off only if you call `agents` directly. | + +No option for the list's path: `AGENTS_FILE` is the one setting (above). + +## Minimal `devcontainer.json` + +```json +"features": { + "ghcr.io/nabil-becker/devcontainer-features/go-task:1": {}, + "ghcr.io/nabil-becker/devcontainer-features/claude-code:1": {}, + "ghcr.io/nabil-becker/devcontainer-features/agents:1": {} +} +``` + +plus `.devcontainer/agents.yml` and the go-task registry include in the root Taskfile (the go-task +Feature's notes). + +## Verify it works + +``` +$ task agents:check +agents: /workspaces/myrepo/.devcontainer/agents.yml (from the default (AGENTS_FILE is not set)): 1 agent(s) + host: cli claude, on-demand, folder ., args: [--remote-control] +$ task agents:list +NAME KIND CLI STATE ID FOLDER +host on-demand claude none - . +``` + +The post-start log shows one line per agent (`host: on-demand, not started`, `reviewer: started in +the background (...)`). + +## Pairs with + +- **An adapter per CLI**, from that CLI's Feature: `claude-code` 1.7.0 or later for `cli: claude`. + `agents` names no CLI Feature in `dependsOn`; it finds adapters on PATH. +- **go-task** for the tasks (the `agents:` namespace; `task agents` is its default task). +- **devcontainer-cli** 1.13.0 or later on the host: `task devcontainer:agent -- [flags]` + and `task devcontainer:agent:list` run the same verbs in the container, starting it first. + +## Limits + +- The YAML subset above; anything else is refused with its line. +- `agents:up` at container start waits up to `AGENTS_SETTLE_TIMEOUT` seconds when a resident's + CLI can list sessions. +- A resident whose folder the CLI has never trusted fails to start (Claude Code: `Workspace not + trusted`; run `claude` there once and accept). The Feature does not mark folders trusted. +- Whether Claude Code's Remote Control reaches a background session is not established; an agent + driven from a phone stays `on-demand` with `--remote-control` in its `args` until it is. +- Tested with fake adapters and a fake `claude` (`task test:agents-unit`, `task + test:claude-unit`); no live CLI in CI. + +## Why it exists + +Claude Code's own agent view (`claude agents`) lists sessions, but it has no per-repository list, +no kinds and no way to say "this one always runs"; other agent CLIs have nothing of the kind, and +no published Feature offers one (NFR-9). The list, the kinds and the verbs belong to no single CLI, +so they live here and each CLI Feature adds only its adapter. + +## Version and upgrade + +`ghcr.io/nabil-becker/devcontainer-features/agents:1` follows 1.x; rebuild to update. 1.0.0 is the +first release. It replaces claude-code's `claude-host`, `claude-fleet` and `claude-host-pin` and +the `SESSIONS` / `HOST_SESSION` settings, which claude-code 2.0 removes; the migration is in +[docs/migrations/agents.md](https://github.com/nabil-becker/devcontainer-features/blob/main/docs/migrations/agents.md). diff --git a/src/agents/README.md b/src/agents/README.md new file mode 100644 index 0000000..139ff5a --- /dev/null +++ b/src/agents/README.md @@ -0,0 +1,252 @@ + +# Agents (agents) + +One list of a repository's agents (.devcontainer/agents.yml, or the repo-relative path in AGENTS_FILE: name, CLI, folder, args, kind), two kinds and one verb for every agent CLI: task agents -- makes a resident agent run in the background and attaches to it, or opens a new session of an on-demand agent (--resume, --new); agents:up starts every resident at container start (its postStartCommand, never fatal), agents:list, agents:stop, agents:logs, agents:check. Each CLI Feature supplies an adapter (agent-adapter- on PATH; the claude-code Feature ships agent-adapter-claude); a verb an adapter does not support falls back and says so. Installs a bash script and the Feature Taskfile; nothing downloaded. + +## Example Usage + +```json +"features": { + "ghcr.io/nabil-becker/devcontainer-features/agents:1": {} +} +``` + +## Options + +| Options Id | Description | Type | Default Value | +|-----|-----|-----|-----| +| installTaskfile | Also install this Feature's Taskfile into /usr/local/share/go-task/includes.d so its tasks appear under the : namespace of a root Taskfile that includes /usr/local/share/go-task/features.yml (see the go-task Feature). | boolean | true | + +## What it installs + +One standard way to start any agent, whatever its CLI. A repository declares its agents in one +list; the `agents` command (on PATH, `/usr/local/bin/agents`) reads it and does the right thing +for each agent's kind, through the adapter its CLI Feature supplies. The Feature installs that +bash script, the Feature Taskfile and an empty state folder; nothing is downloaded, so there is +nothing to verify. It installs no adapter: the `claude-code` Feature (1.7.0 or later) brings +`agent-adapter-claude`; other CLIs follow. + +``` + HOST DEVCONTAINER + task devcontainer:agent -- host --> task agents -- host [--resume|--new] + | + v + agent list (AGENTS_FILE, else .devcontainer/agents.yml) + | + resident <----------+----------> on-demand + running? attach : start-bg/wake, attach new (default) / resume (--resume) + | + v + agent-adapter- (claude: claude --bg / attach / --resume ...) + container start (postStartCommand): agents up --post-start -> every resident runs, none attached +``` + +## The agent list + +`.devcontainer/agents.yml`, beside `devcontainer.json`, committed: + +```yaml +agents: + - name: host # unique; letters, digits, - and _ + cli: claude # picks the adapter: agent-adapter-claude + kind: on-demand # resident | on-demand + folder: . # repo-relative, default . + args: [--remote-control] # handed to the CLI as they are (a YAML list, no shell quoting) + - name: reviewer + cli: claude + kind: resident + folder: rooms/review + args: + - --model + - sonnet +``` + +- `name`, `cli`, `kind` are required; `folder` defaults to `.` and must stay inside the + repository; `args` default to none. +- Nothing else is read. An unknown key is a warning (with its line), not an error, so a + repository can note things for itself. One file: no includes, no per-user overrides. +- A broken list is refused (exit 2) with every problem named by file and line. `task + agents:check` reads and checks it without starting anything. +- The YAML is a small subset, read by awk: comments, plain, single- and double-quoted values, an + `args` list in flow (`[a, "b c"]`) or block (`- a`) form. No anchors, no multi-line values, no + `{ }` entries. Why awk: the Feature must work on every base image, and bash, awk and sed are + what they all have; python3 and jq are not guaranteed (the CI matrix includes `debian:latest` + and `ubuntu:latest`), and the other Features of this collection read JSON with sed for the same + reason. A vendored YAML parser would be a larger, unverified download for a five-key file. + +**Kinds.** `resident`: runs continuously in the background; starting it means make sure it runs, +then attach. `on-demand`: starting it opens a new session in this terminal; it resumes its latest +conversation only when asked. + +**Where the list is: `AGENTS_FILE`.** A path relative to the repository root (the nearest folder +at or above the current one holding `.devcontainer/`; an absolute path is used as it is), +resolved like every setting here (NFR-6), first wins: + +| Source | Example | +| --- | --- | +| task variable (before the `--`) | `task agents AGENTS_FILE=ops/agents.yml -- host` | +| the root `.env`, then `.devcontainer/.env` | `AGENTS_FILE=ops/agents.yml` | +| the environment (incl. `containerEnv`) | `"containerEnv": {"AGENTS_FILE": "ops/agents.yml"}` | +| built-in default | `.devcontainer/agents.yml` | + +There is no Feature option for it (`containerEnv` already covers "committed with the +repository"). A missing file is an error naming the path it looked for and where that path came +from. Task fills its variables from `.env` and the environment too, so the task hands its +variable over as `AGENTS_FILE_TASK_VAR`, and the command counts it as a task variable only when +it differs from both. + +## The verbs + +| Task | `resident` | `on-demand` | +| --- | --- | --- | +| `task agents -- ` | make sure it runs (start it, or wake a stopped one with its conversation), then attach | a new session in its folder, in this terminal | +| `task agents -- --resume` | the same | resume its latest conversation (refused while that one runs) | +| `task agents -- --new` | stop the running session and start a fresh one, after a yes/no question (`--yes` skips it), then attach | the same as plain | +| `task agents:up` | start every resident not running, attach none (the postStartCommand) | skipped | +| `task agents:list` | name, kind, CLI, state (`running`, `stopped`, `failed`, `none`; `+N` extra running sessions), id, folder; `-- --json` for agents | the same | +| `task agents:stop -- ` | stop it; the conversation is kept. `-- --extras` stops only the extra sessions | stop a running one, if the adapter can | +| `task agents:logs -- ` | recent output | recent output, if it ran in the background | +| `task agents:check` | read and check the list, print it | the same | +| `task agents:version` | the Feature version and the list in use | | + +Extra CLI flags follow the name: `task agents -- reviewer --effort high`; after `--` everything +goes to the CLI, even `--resume`. They come after the list's `args`. + +**No terminal.** `task agents` is `interactive: true`. When stdin or stdout is not a terminal (an +agent or a script calls it), a resident is made to run and the command prints how to attach +(exit 0); an on-demand agent is refused (exit 2), because a new interactive session without a +terminal is never what was meant. `--new` on a running resident without a terminal is refused +unless `--yes`. + +**Two sessions for one agent** (a crash, a restore, Claude Code numbering a collision `host (2)`): +the newest is the agent's; the others are reported, never stopped on their own. `task +agents:stop -- --extras` stops them. + +**At container start** `agents up --post-start` first waits until the CLI's session list settles +(a CLI may restore background sessions after a rebuild; starting in that window makes copies): +two reads `AGENTS_SETTLE_INTERVAL` seconds apart (default 3) with the same running sessions, at +most `AGENTS_SETTLE_TIMEOUT` (default 30). Then each resident found stopped or failed is woken by +id and the rest are started. It never fails the container start: a missing list is one line, a +start that fails is said and the start goes on. `task agents:up -- --no-wait` skips the wait. + +Exit codes of `agents`: 0 done, 1 failed, 2 bad use (unknown agent, broken or missing list, +refused), 3 the CLI cannot do it. + +## The adapter contract + +Each CLI Feature installs one executable on PATH, `agent-adapter-`; `agents` calls it, +nothing else does. A list entry whose adapter is missing is an error naming the program and the +Feature to add. + +``` +agent-adapter- [--extras] [-- args...] + start-bg start the agent in the background in (a fresh session); print its id + wake bring the agent's stopped or failed session back in the background, same conversation + attach open the agent's running session in this terminal + resume open the agent's latest conversation in this terminal + new open a new conversation in this terminal + stop stop the agent (--extras: only its extra sessions); keep the conversation + logs print recent output +agent-adapter- list one line per session of this CLI, newest first: + nameidstatefolder (state: running | stopped | failed) +agent-adapter- supports the verbs it supports, one per line +Exit: 0 done, 1 failed, 2 bad use, 3 not supported by this CLI. +``` + +`` is absolute. A session is the agent's when its folder is the agent's and its name is +the agent's name, or that name with a collision number (`host (2)`). Identity is the name: the +adapter tags each session with it and finds it again by name and folder, so no id is written into +the repository. + +When an adapter answers exit 3, the verb falls back, says so in one line, and never starts a +second copy of something that may be running: + +| Not supported | Fallback | +| --- | --- | +| `start-bg` | the resident runs as on-demand: plain start opens `new`; `agents:up` skips it | +| `wake` | a fresh `start-bg` | +| `attach` | its `logs` and the reason; exit 1 | +| `resume` | `new` | +| `list` | the agent counts as not running; a resident is started at most once per container start (a marker under `/var/lib/devcontainer-features/agents`, keyed by the start of the container's PID 1), and the verb says that "make sure it runs" cannot be promised | +| `stop`, `logs` | say so; exit 3 | + +## State + +None that must survive a rebuild (NFR-1): the agents are the CLIs' own sessions, kept in each +CLI's state (claude-code's volume at `CLAUDE_CONFIG_DIR`). `/var/lib/devcontainer-features/agents` +(mode 1777, created at install, not a volume) holds only the once-per-container-start markers of +the `list` fallback; `AGENTS_STATE_DIR` points elsewhere. + +## Options + +| Option | Default | What it does | +| --- | --- | --- | +| `installTaskfile` | `true` | Register `taskfile.yml` with the go-task Feature's registry, so `task agents*` exist. Turn it off only if you call `agents` directly. | + +No option for the list's path: `AGENTS_FILE` is the one setting (above). + +## Minimal `devcontainer.json` + +```json +"features": { + "ghcr.io/nabil-becker/devcontainer-features/go-task:1": {}, + "ghcr.io/nabil-becker/devcontainer-features/claude-code:1": {}, + "ghcr.io/nabil-becker/devcontainer-features/agents:1": {} +} +``` + +plus `.devcontainer/agents.yml` and the go-task registry include in the root Taskfile (the go-task +Feature's notes). + +## Verify it works + +``` +$ task agents:check +agents: /workspaces/myrepo/.devcontainer/agents.yml (from the default (AGENTS_FILE is not set)): 1 agent(s) + host: cli claude, on-demand, folder ., args: [--remote-control] +$ task agents:list +NAME KIND CLI STATE ID FOLDER +host on-demand claude none - . +``` + +The post-start log shows one line per agent (`host: on-demand, not started`, `reviewer: started in +the background (...)`). + +## Pairs with + +- **An adapter per CLI**, from that CLI's Feature: `claude-code` 1.7.0 or later for `cli: claude`. + `agents` names no CLI Feature in `dependsOn`; it finds adapters on PATH. +- **go-task** for the tasks (the `agents:` namespace; `task agents` is its default task). +- **devcontainer-cli** 1.13.0 or later on the host: `task devcontainer:agent -- [flags]` + and `task devcontainer:agent:list` run the same verbs in the container, starting it first. + +## Limits + +- The YAML subset above; anything else is refused with its line. +- `agents:up` at container start waits up to `AGENTS_SETTLE_TIMEOUT` seconds when a resident's + CLI can list sessions. +- A resident whose folder the CLI has never trusted fails to start (Claude Code: `Workspace not + trusted`; run `claude` there once and accept). The Feature does not mark folders trusted. +- Whether Claude Code's Remote Control reaches a background session is not established; an agent + driven from a phone stays `on-demand` with `--remote-control` in its `args` until it is. +- Tested with fake adapters and a fake `claude` (`task test:agents-unit`, `task + test:claude-unit`); no live CLI in CI. + +## Why it exists + +Claude Code's own agent view (`claude agents`) lists sessions, but it has no per-repository list, +no kinds and no way to say "this one always runs"; other agent CLIs have nothing of the kind, and +no published Feature offers one (NFR-9). The list, the kinds and the verbs belong to no single CLI, +so they live here and each CLI Feature adds only its adapter. + +## Version and upgrade + +`ghcr.io/nabil-becker/devcontainer-features/agents:1` follows 1.x; rebuild to update. 1.0.0 is the +first release. It replaces claude-code's `claude-host`, `claude-fleet` and `claude-host-pin` and +the `SESSIONS` / `HOST_SESSION` settings, which claude-code 2.0 removes; the migration is in +[docs/migrations/agents.md](https://github.com/nabil-becker/devcontainer-features/blob/main/docs/migrations/agents.md). + + +--- + +_Note: This file was auto-generated from the [devcontainer-feature.json](https://github.com/nabil-becker/devcontainer-features/blob/main/src/agents/devcontainer-feature.json). Add additional notes to a `NOTES.md`._ diff --git a/src/agents/agents.sh b/src/agents/agents.sh new file mode 100644 index 0000000..f519e9a --- /dev/null +++ b/src/agents/agents.sh @@ -0,0 +1,666 @@ +#!/usr/bin/env bash +# agents: one list of a repository's agents, two kinds, one verb for every agent CLI. +# Installed on PATH by the agents Feature; behind `task agents -- ` and the other +# agents:* tasks inside, and `task devcontainer:agent -- ` from the host. +# +# agents start [--resume | --new [--yes]] [--] [extra CLI args...] +# agents up [--no-wait] [--post-start] +# agents list [--json] +# agents stop [--extras] +# agents logs +# agents check +# +# The list (the agent list) is a small YAML file, .devcontainer/agents.yml unless +# AGENTS_FILE names another path, relative to the repository root (the nearest folder at +# or above the current one holding .devcontainer). AGENTS_FILE is resolved like every +# setting (NFR-6): the task variable (passed here as AGENTS_FILE_TASK_VAR), then the root +# .env, then .devcontainer/.env, then the environment, then the default. +# +# agents: +# - name: host # unique; letters, digits, - and _ +# cli: claude # picks the adapter: agent-adapter-claude on PATH +# kind: on-demand # resident | on-demand +# folder: . # repo-relative (default .) +# args: [--remote-control] # handed to the CLI as they are (default none) +# +# Only that subset of YAML is read (comments, plain and quoted scalars, a flow or block +# list for args); the parser is awk, because bash, awk and sed are what every base image +# has (python3 and jq are not). Unknown keys are a warning. +# +# Kinds. resident: runs in the background; start = make sure it runs, then attach. +# on-demand: start = a new session in this terminal; --resume opens its latest one. +# Without a terminal (stdin or stdout not a TTY) a resident is made to run and the +# attach command is printed (exit 0); an on-demand agent is refused (exit 2). +# +# Adapters (the contract, docs/proposals/agents.md section 5): each CLI Feature installs +# agent-adapter- [--extras] [-- args...] +# with the verbs start-bg, wake, attach, resume, new, list, stop, logs, supports. Exit 0 +# done, 1 failed, 2 bad use, 3 not supported by this CLI; `list` prints +# nameidstatefolder per session, newest first (state running, stopped or +# failed). An unsupported verb falls back as section 6 says and says so in one line; +# a fallback never starts a second copy of something that may be running. +# +# Exit: 0 done, 1 failed, 2 bad use (unknown agent, bad list, refused), 3 the CLI cannot. +set -uo pipefail + +STATE_DIR="${AGENTS_STATE_DIR:-/var/lib/devcontainer-features/agents}" +DEFAULT_FILE=.devcontainer/agents.yml + +warn() { printf 'agents: %s\n' "$*" >&2; } +die() { local rc="$1"; shift; printf 'agents: %s\n' "$*" >&2; exit "$rc"; } + +usage() { sed -n '2,/^set -uo/p' "$0" | sed '$d; s/^# \{0,1\}//'; } + +# ------------------------------------------------------------ the list file ---- +find_root() { # the nearest folder at or above here holding .devcontainer; else here + local d + d="$(pwd -P)" + while :; do + [ -d "$d/.devcontainer" ] && { printf '%s' "$d"; return 0; } + [ "$d" = / ] && break + d="$(dirname "$d")" + done + pwd -P +} + +# dotenv_value : the value of the last line for var in the file (quotes, +# export and a trailing comment dropped); returns 1 when the file has no line for it. +dotenv_value() { + grep -q "^[[:space:]]*\(export[[:space:]]\{1,\}\)\{0,1\}$2=" "$1" 2>/dev/null || return 1 + sed -n "s/^[[:space:]]*\(export[[:space:]]\{1,\}\)\{0,1\}$2=[[:space:]]*//p" "$1" | tail -n 1 | tr -d '\r' | + sed "s/[[:space:]]\{1,\}#.*\$//; s/[[:space:]]*\$//; s/^\"\(.*\)\"\$/\1/; s/^'\(.*\)'\$/\1/" +} + +# resolve_file: ROOT, AF (as given), AF_SOURCE (where it came from), AF_ABS. +# Task hands its variable over as AGENTS_FILE_TASK_VAR; Task fills that variable from +# .env and the environment too, so it counts as a task variable only when it differs +# from both. +resolve_file() { + local tv="${AGENTS_FILE_TASK_VAR:-}" ev="${AGENTS_FILE:-}" dv='' dsrc='' f v + ROOT="$(find_root)" + for f in "$ROOT/.env" "$ROOT/.devcontainer/.env"; do + if [ -f "$f" ] && v="$(dotenv_value "$f" AGENTS_FILE)"; then + dv="$v" dsrc="${f#"$ROOT"/}" + break + fi + done + if [ -n "$tv" ] && [ "$tv" != "$dv" ] && [ "$tv" != "$ev" ]; then + AF="$tv" AF_SOURCE='the task variable AGENTS_FILE' + elif [ -n "$dv" ]; then + AF="$dv" AF_SOURCE="AGENTS_FILE in $dsrc" + elif [ -n "$ev" ]; then + AF="$ev" AF_SOURCE='AGENTS_FILE in the environment' + else + AF="$DEFAULT_FILE" AF_SOURCE='the default (AGENTS_FILE is not set)' + fi + case "$AF" in /*) AF_ABS="$AF" ;; *) AF_ABS="$ROOT/$AF" ;; esac +} + +# The parser: the YAML subset above, one record per line out: +# I an entry starts +# K name, cli, kind or folder +# A the entry has args +# G one arg +# W a warning +# E an error (the last line) +# Written for mawk as well as gawk: no gensub, no regex intervals. +read -r -d '' PARSER <<'AWK' +function fail(msg) { printf "E\t%d\t%s\n", NR, msg; failed = 1; exit 2 } +function ind(s) { match(s, /^ */); return RLENGTH } +function plain(s, i) { + if (substr(s, 1, 1) == "#") return "" + i = index(s, " #"); if (i > 0) s = substr(s, 1, i - 1) + sub(/[ \t]+$/, "", s); return s +} +# quoted(s): s starts with a quote; sets QV (the value) and QR (the text after it) +function quoted(s, q, i, ch, r, n) { + q = substr(s, 1, 1); r = ""; i = 2; n = length(s) + while (i <= n) { + ch = substr(s, i, 1) + if (q == "'" && ch == "'") { if (substr(s, i + 1, 1) == "'") { r = r "'"; i += 2; continue } ; break } + if (q == "\"" && ch == "\\") { r = r substr(s, i + 1, 1); i += 2; continue } + if (q == "\"" && ch == "\"") break + r = r ch; i++ + } + if (i > n) fail("a quoted value is not closed") + QV = r; QR = substr(s, i + 1); sub(/^[ \t]+/, "", QR) +} +function scalar(s, what) { + if (s ~ /^["']/) { + quoted(s) + if (QR != "" && substr(QR, 1, 1) != "#") fail("text after the quoted value of " what) + return QV + } + if (s ~ /^[\[\{|>&*!%@`]/) fail(what " must be a plain or quoted value") + return plain(s) +} +function emit_arg(v) { + if (v == "") fail("an empty argument") + if (v ~ /\t/) fail("a tab in an argument") + printf "G\t%d\t%s\n", n, v +} +# flow(s): a [a, "b c", 'd'] list on one line +function flow(s, v, ch) { + s = substr(s, 2) + while (1) { + sub(/^[ \t]+/, "", s) + if (s == "") fail("a [ list must close on the same line") + ch = substr(s, 1, 1) + if (ch == "]") { s = substr(s, 2); break } + if (ch == "\"" || ch == "'") { quoted(s); v = QV; s = QR } + else { + if (ch == "[" || ch == "{") fail("nested lists are not supported in args") + v = s; sub(/[,\]].*$/, "", v); s = substr(s, length(v) + 1); sub(/[ \t]+$/, "", v) + if (v == "") fail("an empty item in the args list") + } + emit_arg(v) + sub(/^[ \t]+/, "", s) + ch = substr(s, 1, 1) + if (ch == ",") { s = substr(s, 2); continue } + if (ch == "]") { s = substr(s, 2); break } + fail("expected , or ] in the args list") + } + sub(/^[ \t]+/, "", s) + if (s != "" && substr(s, 1, 1) != "#") fail("text after the args list") +} +function keyline(t, k, v) { + if (t !~ /^[A-Za-z0-9_-]+:/) fail("expected key: value") + k = t; sub(/:.*/, "", k) + v = substr(t, length(k) + 2) + if (v != "" && v !~ /^[ \t]/) fail("expected a space after " k ":") + sub(/^[ \t]+/, "", v) + if ((n, k) in seen) fail("'" k "' appears twice in one entry") + seen[n, k] = 1 + inargs = 0; skipping = 0 + if (k == "args") { + printf "A\t%d\n", n + if (v == "" || substr(v, 1, 1) == "#") { inargs = 1; return } + if (substr(v, 1, 1) == "[") { flow(v); return } + fail("args must be a list: args: [--model, opus], or one - value per line below it") + } + if (k == "name" || k == "cli" || k == "kind" || k == "folder") { + if (v == "" || substr(v, 1, 1) == "#") fail("'" k "' has no value") + v = scalar(v, "'" k "'") + if (v ~ /\t/) fail("a tab in the value of '" k "'") + printf "K\t%d\t%s\t%s\n", n, k, v + return + } + printf "W\t%d\tunknown key '%s' (ignored)\n", NR, k + skipping = 1 +} +BEGIN { n = 0; itemind = -1; keyind = -1; inagents = 0; sawagents = 0; skiptop = 0 } +{ + line = $0; sub(/\r$/, "", line) + t = line; sub(/^ +/, "", t) + if (t == "" || substr(t, 1, 1) == "#") next + if (substr(t, 1, 1) == "\t") fail("a tab in the indentation; indent with spaces") + i = ind(line) + if (i == 0) { + if (t == "---") next + inagents = 0; skiptop = 0; inargs = 0; skipping = 0 + if (t ~ /^agents:/) { + if (sawagents) fail("'agents' appears twice") + sawagents = 1 + v = plain(substr(t, 8)); sub(/^[ \t]+/, "", v) + if (v == "") { inagents = 1; next } + if (v ~ /^\[[ ]*\]$/) next + fail("agents must be a list of entries, one '- name: ...' each") + } + if (t ~ /^[A-Za-z0-9_-]+:/) { k = t; sub(/:.*/, "", k); printf "W\t%d\tunknown top-level key '%s' (ignored)\n", NR, k; skiptop = 1; next } + fail("expected agents: at the top level") + } + if (skiptop) next + if (!inagents) fail("unexpected indentation") + if (skipping && keyind >= 0 && i > keyind) next + skipping = 0 + if (t == "-" || substr(t, 1, 2) == "- ") { + if (inargs && i > itemind) { + v = substr(t, 2); sub(/^[ \t]+/, "", v) + if (v == "" || substr(v, 1, 1) == "#") fail("an empty item in the args list") + emit_arg(scalar(v, "an argument")) + next + } + if (itemind < 0) itemind = i + if (i != itemind) fail("a list item at the wrong indentation") + n++; inargs = 0 + printf "I\t%d\t%d\n", n, NR + rest = substr(t, 2) + if (rest ~ /^ *$/) { keyind = -1; next } + if (rest ~ /^ *#/) { keyind = -1; next } + keyind = i + 1 + ind(rest) + sub(/^ +/, "", rest) + if (substr(rest, 1, 1) == "{") fail("write an entry as key: value lines, not { }") + keyline(rest) + next + } + if (n == 0) fail("expected a list item, - name: ...") + if (keyind < 0) { if (i <= itemind) fail("unexpected indentation"); keyind = i } + if (i != keyind) fail("unexpected indentation") + keyline(t) +} +END { + if (failed) exit 2 + if (!sawagents) { printf "E\t0\tno agents: list in the file\n"; exit 2 } +} +AWK + +# load_list: parse and check AF_ABS into N and the arrays NAME, CLI, KIND, FOLDER, ARGS +# (args joined by \x1f), LINE. A broken list exits 2 with every problem named. +load_list() { + local out rc rec a b c errs=0 i j + resolve_file + if [ ! -f "$AF_ABS" ]; then + die 2 "no agent list at $AF_ABS (from $AF_SOURCE); write one (see the agents Feature notes) or set AGENTS_FILE to its path relative to $ROOT." + fi + out="$(awk "$PARSER" "$AF_ABS")"; rc=$? + N=0 NAME=() CLI=() KIND=() FOLDER=() ARGS=() LINE=() HASARGS=() + while IFS=$'\t' read -r rec a b c; do + case "$rec" in + I) N="$a"; LINE[a]="$b" FOLDER[a]=. ARGS[a]='' ;; + K) case "$b" in name) NAME[a]="$c" ;; cli) CLI[a]="$c" ;; kind) KIND[a]="$c" ;; folder) FOLDER[a]="$c" ;; esac ;; + A) HASARGS[a]=1 ;; + G) if [ -n "${ARGS[a]:-}" ]; then ARGS[a]="${ARGS[a]}"$'\x1f'"$b"; else ARGS[a]="$b"; fi ;; + W) warn "$AF:$a: $b" ;; + E) die 2 "$AF:$a: $b" ;; + esac + done <<< "$out" + [ "$rc" -eq 0 ] || die 2 "$AF: could not be read" + for ((i = 1; i <= N; i++)); do + local at="$AF:${LINE[i]}: entry $i" + for a in name cli kind; do + case "$a" in name) b="${NAME[i]:-}" ;; cli) b="${CLI[i]:-}" ;; kind) b="${KIND[i]:-}" ;; esac + [ -n "$b" ] || { warn "$at has no '$a' (name, cli and kind are required)"; errs=$((errs + 1)); } + done + if [ -n "${NAME[i]:-}" ] && ! [[ "${NAME[i]}" =~ ^[A-Za-z0-9_-]+$ ]]; then + warn "$at: name '${NAME[i]}' may hold only letters, digits, - and _"; errs=$((errs + 1)) + fi + if [ -n "${CLI[i]:-}" ] && ! [[ "${CLI[i]}" =~ ^[A-Za-z0-9_-]+$ ]]; then + warn "$at: cli '${CLI[i]}' may hold only letters, digits, - and _"; errs=$((errs + 1)) + fi + case "${KIND[i]:-}" in resident | on-demand | '') ;; *) warn "$at: kind '${KIND[i]}' is neither resident nor on-demand"; errs=$((errs + 1)) ;; esac + case "${FOLDER[i]}" in + /*) warn "$at: folder '${FOLDER[i]}' must be relative to the repository root"; errs=$((errs + 1)) ;; + .. | ../* | */.. | */../*) warn "$at: folder '${FOLDER[i]}' must stay inside the repository"; errs=$((errs + 1)) ;; + esac + for ((j = 1; j < i; j++)); do + if [ -n "${NAME[i]:-}" ] && [ "${NAME[i]}" = "${NAME[j]:-}" ]; then + warn "$at: name '${NAME[i]}' is already used by entry $j"; errs=$((errs + 1)) + fi + done + done + [ "$errs" -eq 0 ] || die 2 "$AF: $errs problem(s) in the agent list (above)." +} + +# find_agent : AI, the entry's index; exits 2 naming the agents when there is none. +find_agent() { + local i names='' + for ((i = 1; i <= N; i++)); do + [ "${NAME[i]}" = "$1" ] && { AI="$i"; return 0; } + names="$names ${NAME[i]}" + done + die 2 "no agent '$1' in $AF (agents:${names:- none})." +} + +args_of() { # the entry's args into the array CLIARGS + CLIARGS=() + [ -n "${HASARGS[$1]:-}" ] || return 0 + local IFS=$'\x1f' + read -r -a CLIARGS <<< "${ARGS[$1]}" +} + +# ---------------------------------------------------------------- adapters ---- +feature_of() { + case "$1" in + claude) echo 'the claude-code Feature (1.7.0 or later)' ;; + *) echo "the Feature of that CLI that provides agent-adapter-$1" ;; + esac +} + +# adapter_of : ADAPTER and DIR (the absolute folder) for entry i; returns 1 (said) when +# the adapter or the folder is missing. +adapter_of() { + local i="$1" + ADAPTER="$(command -v "agent-adapter-${CLI[i]}" 2>/dev/null)" || ADAPTER='' + if [ -z "$ADAPTER" ]; then + warn "${NAME[i]}: no adapter for cli '${CLI[i]}' (agent-adapter-${CLI[i]} is not on PATH); add $(feature_of "${CLI[i]}") to devcontainer.json." + return 1 + fi + if ! DIR="$(cd "$ROOT/${FOLDER[i]}" 2>/dev/null && pwd -P)"; then + warn "${NAME[i]}: folder '${FOLDER[i]}' does not exist under $ROOT." + return 1 + fi +} + +declare -A LISTS=() LIST_RC=() +# sessions_of : the adapter's list, read once per run (LISTS, LIST_RC; 3 = cannot list). +sessions_of() { + local cli="$1" out rc + [ -n "${LIST_RC[$cli]:-}" ] && return 0 + out="$("$ADAPTER" list : STATE (running, stopped, failed, none, unknown), SID (the id) and EXTRAS +# (how many more running sessions the agent has) from the adapter's list; a session is +# the agent's when its folder is the agent's and its name is the agent's name, or the +# name with a collision number, "host (2)". +state_of() { + local i="$1" cli="${CLI[$1]}" nm id st fo first='' firstst='' running=0 runid='' + sessions_of "$cli" + STATE=none SID='' EXTRAS=0 + if [ "${LIST_RC[$cli]}" = 3 ]; then STATE=unknown; return 0; fi + if [ "${LIST_RC[$cli]}" != 0 ]; then STATE=unknown; return 1; fi + while IFS=$'\t' read -r nm id st fo; do + [ -n "${nm:-}" ] || continue + [ "$fo" = "$DIR" ] || continue + case "$nm" in "${NAME[i]}") ;; "${NAME[i]} ("*")") [[ "$nm" =~ \([0-9]+\)$ ]] || continue ;; *) continue ;; esac + if [ "$st" = running ]; then + running=$((running + 1)); [ -z "$runid" ] && runid="$id" + fi + [ -z "$first" ] && { first="$id"; firstst="$st"; } + done <<< "${LISTS[$cli]}" + if [ "$running" -gt 0 ]; then STATE=running SID="$runid" EXTRAS=$((running - 1)) + elif [ -n "$first" ]; then STATE="$firstst" SID="$first" + fi + return 0 +} + +call() { # call [adapter flags]: the adapter for entry i, CLI args after -- + local verb="$1" i="$2"; shift 2 + args_of "$i" + "$ADAPTER" "$verb" "${NAME[i]}" "$DIR" "$@" -- ${CLIARGS[@]+"${CLIARGS[@]}"} ${PASS[@]+"${PASS[@]}"} +} + +has_tty() { + case "${AGENTS_TTY:-}" in 1) return 0 ;; 0) return 1 ;; esac + [ -t 0 ] && [ -t 1 ] +} + +# ------------------------------------------------- once per container start ---- +start_id() { + if [ -n "${AGENTS_START_ID:-}" ]; then printf '%s' "$AGENTS_START_ID"; return; fi + # field 22 of /proc/1/stat: when PID 1 (the container's init) started; new on every start + awk '{ print $22 }' /proc/1/stat 2>/dev/null || echo unknown +} +marker() { printf '%s/%s/%s/%s' "$STATE_DIR" "$(start_id)" "$(printf '%s' "$ROOT" | sed 's/[^A-Za-z0-9]/-/g')" "$1"; } +mark_started() { + local m; m="$(marker "$1")" + if ! { mkdir -p "$(dirname "$m")" && : > "$m"; } 2>/dev/null; then + warn "$1: could not write the start marker under $STATE_DIR." + fi +} +marked() { [ -e "$(marker "$1")" ]; } + +# make_run : make the resident run in the background (no attach). Prints one line on +# what it did. Returns 0 running, 1 failed, 3 the CLI has no background mode. +make_run() { + local i="$1" nm="${NAME[$1]}" rc + state_of "$i" || { warn "$nm: the adapter could not list sessions; starting nothing (it may be running)."; return 1; } + case "$STATE" in + running) + echo "$nm: running ($SID)" + [ "$EXTRAS" -gt 0 ] && echo "$nm: $EXTRAS more running session(s) for this agent; task agents:stop -- $nm --extras stops them." + return 0 ;; + stopped | failed) + echo "$nm: $STATE ($SID); waking it with its conversation" + call wake "$i" /dev/null; rc=$? + if [ "$rc" -eq 3 ]; then + echo "$nm: ${CLI[i]} cannot wake a stopped session; starting a fresh one in the background instead." + elif [ "$rc" -eq 0 ]; then + mark_started "$nm"; return 0 + else + warn "$nm: waking failed."; return 1 + fi ;; + unknown) + if marked "$nm"; then + echo "$nm: ${CLI[i]} cannot list its sessions; started earlier in this container start, so not started again." + return 0 + fi + echo "$nm: ${CLI[i]} cannot list its sessions, so 'make sure it runs' cannot be promised; starting it once for this container start." ;; + esac + call start-bg "$i" /dev/null; rc=$? + case "$rc" in + 0) mark_started "$nm"; reread "${CLI[i]}"; state_of "$i"; echo "$nm: started in the background${SID:+ ($SID)}"; return 0 ;; + 3) return 3 ;; + *) warn "$nm: starting it in the background failed."; return 1 ;; + esac +} + +# --------------------------------------------------------------------- verbs ---- +cmd_start() { + local name='' mode=plain yes='' rc + PASS=() + [ $# -gt 0 ] || die 2 "start needs an agent name: agents start [--resume|--new] [-- extra CLI args]" + name="$1"; shift + case "$name" in -*) die 2 "start needs an agent name first, then flags: agents start [--resume|--new]" ;; esac + while [ $# -gt 0 ]; do + case "$1" in + --resume) [ "$mode" = plain ] || die 2 "--resume and --new exclude each other"; mode=resume ;; + --new) [ "$mode" = plain ] || die 2 "--resume and --new exclude each other"; mode=new ;; + --yes) yes=1 ;; + --) shift; PASS+=("$@"); break ;; + *) PASS+=("$1") ;; + esac + shift + done + load_list + find_agent "$name" + local i="$AI" + adapter_of "$i" || exit 1 + if [ "${KIND[i]}" = resident ]; then + if [ "$mode" = new ]; then + state_of "$i" || die 1 "$name: the adapter could not list sessions; starting nothing." + if [ "$STATE" = running ]; then + if [ -z "$yes" ]; then + has_tty || die 2 "$name: --new stops the running session ($SID) and starts a fresh one; it asks first, so run it from a terminal or add --yes." + printf 'Stop %s (%s) and start a fresh session? [y/N] ' "$name" "$SID" + local ans='' + read -r ans || ans='' + case "$ans" in y | Y | yes | YES | Yes) ;; *) echo "$name: nothing changed."; exit 0 ;; esac + fi + call stop "$i" /dev/null; rc=$? + if [ "$rc" -eq 3 ]; then + echo "$name: ${CLI[i]} has no background mode; opening a new session in this terminal instead." + has_tty || die 2 "$name: a new interactive session needs a terminal." + call new "$i"; exit $? + fi + [ "$rc" -eq 0 ] || die 1 "$name: starting a fresh session failed." + mark_started "$name"; reread "${CLI[i]}"; state_of "$i" + echo "$name: started a fresh session${SID:+ ($SID)}" + else + make_run "$i"; rc=$? + if [ "$rc" -eq 3 ]; then + echo "$name: ${CLI[i]} has no background mode; opening a new session in this terminal instead (it runs as on-demand)." + has_tty || die 2 "$name: a new interactive session needs a terminal." + call new "$i"; exit $? + fi + [ "$rc" -eq 0 ] || exit 1 + fi + if ! has_tty; then + echo "$name: running; attach from a terminal with: task agents -- $name (from the host: task devcontainer:agent -- $name)" + exit 0 + fi + call attach "$i"; rc=$? + if [ "$rc" -eq 3 ]; then + warn "$name: ${CLI[i]} cannot attach to a background session; its recent output instead:" + call logs "$i" : after a rebuild a CLI may restore background sessions on its own; starting +# agents in that window makes copies. Wait until two reads AGENTS_SETTLE_INTERVAL seconds +# apart (default 3) list the same running sessions, at most AGENTS_SETTLE_TIMEOUT (30). +settle() { + local cli="$1" interval="${AGENTS_SETTLE_INTERVAL:-3}" timeout="${AGENTS_SETTLE_TIMEOUT:-30}" start="$SECONDS" prev cur + sessions_of "$cli" + [ "${LIST_RC[$cli]}" = 0 ] || return 0 + prev="$(printf '%s\n' "${LISTS[$cli]}" | awk -F'\t' '$3 == "running" { print $2 }' | sort)" + echo "waiting for restored $cli sessions…" + while :; do + sleep "$interval" + reread "$cli" + [ "${LIST_RC[$cli]}" = 0 ] || return 0 + cur="$(printf '%s\n' "${LISTS[$cli]}" | awk -F'\t' '$3 == "running" { print $2 }' | sort)" + [ "$cur" = "$prev" ] && return 0 + if [ $((SECONDS - start)) -ge "$timeout" ]; then + echo "agents: the $cli session list was still changing after ${timeout}s; going on." + return 0 + fi + prev="$cur" + done +} + +json_str() { local s="$1"; s="${s//\\/\\\\}"; s="${s//\"/\\\"}"; printf '"%s"' "$s"; } + +cmd_list() { + local json='' i first=1 st a + for a in "$@"; do + case "$a" in --json) json=1 ;; *) die 2 "list: unknown argument '$a' (--json)" ;; esac + done + load_list + [ -n "$json" ] && printf '[' + [ -z "$json" ] && printf '%-16s %-10s %-8s %-9s %-38s %s\n' NAME KIND CLI STATE ID FOLDER + for ((i = 1; i <= N; i++)); do + STATE='' SID='' EXTRAS=0 + if adapter_of "$i" 2>/dev/null; then + state_of "$i" || STATE=unknown + elif [ -z "$(command -v "agent-adapter-${CLI[i]}" 2>/dev/null)" ]; then + STATE=no-adapter + else + STATE=no-folder + fi + st="$STATE"; [ "$EXTRAS" -gt 0 ] && st="$STATE+$EXTRAS" + if [ -n "$json" ]; then + [ -z "$first" ] && printf ',' + first='' + printf '{"name":%s,"kind":%s,"cli":%s,"folder":%s,"state":%s,"id":%s,"extras":%d}' \ + "$(json_str "${NAME[i]}")" "$(json_str "${KIND[i]}")" "$(json_str "${CLI[i]}")" \ + "$(json_str "${FOLDER[i]}")" "$(json_str "$STATE")" "$(json_str "$SID")" "$EXTRAS" + else + printf '%-16s %-10s %-8s %-9s %-38s %s\n' "${NAME[i]}" "${KIND[i]}" "${CLI[i]}" "$st" "${SID:--}" "${FOLDER[i]}" + fi + done + [ -n "$json" ] && printf ']\n' + return 0 +} + +cmd_stop() { + local name="${1:-}" rc a + local -a extras=() + PASS=() + [ -n "$name" ] || die 2 "stop needs an agent name: agents stop [--extras]" + shift + for a in "$@"; do + case "$a" in --extras) extras=(--extras) ;; *) die 2 "stop: unknown argument '$a' (--extras)" ;; esac + done + load_list + find_agent "$name" + adapter_of "$AI" || exit 1 + call stop "$AI" ${extras[@]+"${extras[@]}"} " + load_list + find_agent "$name" + adapter_of "$AI" || exit 1 + call logs "$AI" &2; exit 2 ;; + *) die 2 "unknown verb '$verb' (start, up, list, stop, logs, check)" ;; +esac diff --git a/src/agents/devcontainer-feature.json b/src/agents/devcontainer-feature.json new file mode 100644 index 0000000..91d6b54 --- /dev/null +++ b/src/agents/devcontainer-feature.json @@ -0,0 +1,20 @@ +{ + "id": "agents", + "version": "1.0.0", + "name": "Agents", + "description": "One list of a repository's agents (.devcontainer/agents.yml, or the repo-relative path in AGENTS_FILE: name, CLI, folder, args, kind), two kinds and one verb for every agent CLI: task agents -- makes a resident agent run in the background and attaches to it, or opens a new session of an on-demand agent (--resume, --new); agents:up starts every resident at container start (its postStartCommand, never fatal), agents:list, agents:stop, agents:logs, agents:check. Each CLI Feature supplies an adapter (agent-adapter- on PATH; the claude-code Feature ships agent-adapter-claude); a verb an adapter does not support falls back and says so. Installs a bash script and the Feature Taskfile; nothing downloaded.", + "documentationURL": "https://github.com/nabil-becker/devcontainer-features/tree/main/src/agents", + "licenseURL": "https://github.com/nabil-becker/devcontainer-features/blob/main/LICENSE", + "options": { + "installTaskfile": { + "type": "boolean", + "default": true, + "description": "Also install this Feature's Taskfile into /usr/local/share/go-task/includes.d so its tasks appear under the : namespace of a root Taskfile that includes /usr/local/share/go-task/features.yml (see the go-task Feature)." + } + }, + "postStartCommand": "agents up --post-start", + "installsAfter": [ + "ghcr.io/nabil-becker/devcontainer-features/go-task", + "ghcr.io/devcontainers/features/common-utils" + ] +} diff --git a/src/agents/install.sh b/src/agents/install.sh new file mode 100644 index 0000000..f25890b --- /dev/null +++ b/src/agents/install.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Installs the agents Feature: the `agents` command (the agent list, the kinds, the verbs and +# the fallbacks; also the postStartCommand `agents up --post-start`) and the Feature Taskfile. +# Nothing is downloaded, so there is nothing to verify. No adapter is installed here: each CLI +# Feature brings its own (agent-adapter-; the claude-code Feature has agent-adapter-claude). +# +# State: none that must survive a rebuild. The agents live in each CLI's own state (claude-code's +# volume). /var/lib/devcontainer-features/agents holds only the once-per-container-start markers +# used when an adapter cannot list its sessions; it is deliberately not a volume, and the markers +# are keyed by the container start, so a stale one never counts. +set -euo pipefail + +if [ "$(id -u)" -ne 0 ]; then + echo "agents: install.sh must run as root (the devcontainer Feature lifecycle does this)." >&2 + exit 1 +fi + +feature_dir="$(cd "$(dirname "$0")" && pwd)" +version="$(sed -n 's/^[[:space:]]*"version":[[:space:]]*"\([^"]*\)".*/\1/p' "$feature_dir/devcontainer-feature.json" | head -n 1)" + +install -m 0755 "$feature_dir/agents.sh" /usr/local/bin/agents +mkdir -p /usr/local/share/agents-feature +printf '%s\n' "${version:-unknown}" > /usr/local/share/agents-feature/version +chmod 0644 /usr/local/share/agents-feature/version +# Any user writes their markers here; the sticky bit keeps users out of each other's. +mkdir -p /var/lib/devcontainer-features/agents +chmod 1777 /var/lib/devcontainer-features/agents +echo "agents: installed agents ${version:-unknown}" + +# ---------------------------------------------------- Feature Taskfile ---- +if [ "${INSTALLTASKFILE:-true}" = "true" ]; then + includes_d=/usr/local/share/go-task/includes.d + mkdir -p "$includes_d" + install -m 0644 "$(cd "$(dirname "$0")" && pwd)/taskfile.yml" "$includes_d/agents.yml" + if command -v task-features-registry >/dev/null 2>&1; then task-features-registry; fi +fi diff --git a/src/agents/taskfile.yml b/src/agents/taskfile.yml new file mode 100644 index 0000000..087f7fa --- /dev/null +++ b/src/agents/taskfile.yml @@ -0,0 +1,49 @@ +# Tasks contributed by the agents Feature (namespace: agents). `task agents -- ` is +# the default task of the namespace. AGENTS_FILE (repo-relative path of the agent list) from +# the command line, a .env file or the environment; the agents command resolves the order. +version: '3' + +tasks: + version: + dir: '{{.USER_WORKING_DIR}}' + desc: 'Show the agents Feature version and which agent list is used (the path and where AGENTS_FILE came from).' + cmds: + - 'cat /usr/local/share/agents-feature/version 2>/dev/null || echo unknown' + - '{{if .AGENTS_FILE}}AGENTS_FILE_TASK_VAR={{shellQuote .AGENTS_FILE}} {{end}}agents check || true' + + default: + dir: '{{.USER_WORKING_DIR}}' + desc: 'Start an agent of the agent list (.devcontainer/agents.yml, or AGENTS_FILE): task agents -- . A resident is made to run in the background and attached; an on-demand agent opens a new session in this terminal. -- --resume resumes an on-demand agent''s latest conversation; -- --new gives a resident a fresh session (it asks first; --yes skips the question). Extra CLI arguments follow the name. Without a terminal a resident is started and the attach command printed (exit 0); an on-demand agent is refused (exit 2). From the host: task devcontainer:agent -- .' + interactive: true + cmds: + - '{{if .AGENTS_FILE}}AGENTS_FILE_TASK_VAR={{shellQuote .AGENTS_FILE}} {{end}}agents start {{.CLI_ARGS}}' + + up: + dir: '{{.USER_WORKING_DIR}}' + desc: 'Make every resident agent of the list run in the background, attaching to none (what the container start runs); on-demand agents are skipped. Waits first until sessions restored after a rebuild settle (-- --no-wait skips that). Idempotent: a second run starts nothing.' + cmds: + - '{{if .AGENTS_FILE}}AGENTS_FILE_TASK_VAR={{shellQuote .AGENTS_FILE}} {{end}}agents up {{.CLI_ARGS}}' + + list: + dir: '{{.USER_WORKING_DIR}}' + desc: 'List the agents: name, kind, CLI, state (running, stopped, failed, none; +N when it has extra running sessions), the session id and the folder. -- --json prints a JSON array for agents and scripts.' + cmds: + - '{{if .AGENTS_FILE}}AGENTS_FILE_TASK_VAR={{shellQuote .AGENTS_FILE}} {{end}}agents list {{.CLI_ARGS}}' + + stop: + dir: '{{.USER_WORKING_DIR}}' + desc: 'Stop an agent''s running session, keeping its conversation: task agents:stop -- . -- --extras stops only the extra running sessions of that agent and keeps the newest.' + cmds: + - '{{if .AGENTS_FILE}}AGENTS_FILE_TASK_VAR={{shellQuote .AGENTS_FILE}} {{end}}agents stop {{.CLI_ARGS}}' + + logs: + dir: '{{.USER_WORKING_DIR}}' + desc: 'Print an agent''s recent output (a background session): task agents:logs -- .' + cmds: + - '{{if .AGENTS_FILE}}AGENTS_FILE_TASK_VAR={{shellQuote .AGENTS_FILE}} {{end}}agents logs {{.CLI_ARGS}}' + + check: + dir: '{{.USER_WORKING_DIR}}' + desc: 'Read and check the agent list without starting anything: which file (and where AGENTS_FILE came from), each agent with its CLI, kind, folder and args; a broken list exits 2 naming the file, the line and the problem.' + cmds: + - '{{if .AGENTS_FILE}}AGENTS_FILE_TASK_VAR={{shellQuote .AGENTS_FILE}} {{end}}agents check' diff --git a/src/claude-code/NOTES.md b/src/claude-code/NOTES.md index 4fd6423..cfee155 100644 --- a/src/claude-code/NOTES.md +++ b/src/claude-code/NOTES.md @@ -1,3 +1,46 @@ +## Deprecated in 1.7.0: claude-host, claude-fleet, claude-host-pin + +The [`agents`](../agents) Feature replaces them: a repository lists its agents in +`.devcontainer/agents.yml` (name, CLI, kind, folder, args) and starts any of them with +`task agents -- ` inside, `task devcontainer:agent -- ` from the host. No session id +is pinned anywhere, so `HOST_SESSION`, the `claude-host-pin` hook and `SESSIONS` go. This +release keeps the three scripts working; each prints one line on stderr saying it is deprecated +and that **claude-code 2.0 removes it** (`claude-fleet` says it once, not again for the +`claude-host` it calls). `task claude-code:host` and `task claude-code:fleet` say so in their +descriptions. The migration for a consuming repository is in +[docs/migrations/agents.md](https://github.com/nabil-becker/devcontainer-features/blob/main/docs/migrations/agents.md). + +## The agents adapter (`agent-adapter-claude`, 1.7.0) + +Claude Code's adapter for the `agents` Feature, on PATH. The `agents` command calls it; nothing +else should. The contract (verbs, exit codes) is in the `agents` Feature's notes. + +| Verb | What it runs | +| --- | --- | +| `start-bg` | `cd && claude --bg --name `; waits until the session is listed and prints its id | +| `wake` | the agent's newest stopped or failed session, by its full id: `cd && claude --bg --resume ` (the same id and conversation; Claude Code 2.1.308 says `woke session ...`) | +| `attach` | `claude attach ` of its newest running session; an interactive one is refused with the pid and tty of the terminal that holds it | +| `resume` | `cd && claude --resume ` of its newest session; refused while that session runs (a second process on one transcript) | +| `new` | `cd && claude --name ` | +| `list` | `claude agents --json --all`: name, session id, state (a pid is running; failed; anything else stopped), folder, newest first | +| `stop` | `claude stop ` of its newest running session; `--extras` stops every other running one instead. Never `claude rm`. | +| `logs` | `claude logs ` | + +**Identity by name and folder, action by id.** A session is the agent's when its `cwd` is the +agent's folder and its `name` is the agent's name, or that name with Claude Code's collision +number (`host (2)`). `attach`, `stop` and `logs` take the session's short `id` from the list (the +full `sessionId` when there is none), never the name: `claude attach host` also matches part of a +name (`host-review`) and works only while the session runs, and `claude stop` takes an id only. +A stopped session is woken by its full id with `--bg --resume`; `--bg -c` and `--bg --resume +` start copies, so the adapter never uses them. The name is set with `--name` at start and +survives `/clear`, so nothing has to be written back. The JSON is read with `sed` (no jq or python +in the Feature), as `claude-host` does. Needs Claude Code 2.1.290 or later (session names). + +Not verified against a live `claude --bg` in CI: the unit tests (`task test:claude-unit`) use a +fake `claude`; the calls rest on the facts checked live on Claude Code 2.1.308 (the agents +proposal, section 7). A background session in a folder Claude Code has never trusted fails to +start (`Workspace not trusted`): run `claude` there once and accept. + ## Augment, don't duplicate With the default `install: auto` this Feature keeps a `claude` that is @@ -20,7 +63,8 @@ version and verifies nothing, which is why this installer still exists. What the Feature adds in both cases: -- `claude-host` on PATH: start or resume **one** session by its UUID. With +- `agent-adapter-claude` on PATH (1.7.0, above). +- `claude-host` on PATH (deprecated in 1.7.0, see the top): start or resume **one** session by its UUID. With `HOST_SESSION=` set it runs `claude --resume ` when that session's transcript exists for the current directory, else `claude --session-id ` to create it; unset, a plain `claude` with a @@ -59,7 +103,7 @@ What the Feature adds in both cases: python, which the Feature does not install. Deliberately not done: falling back to the newest transcript of the folder when the pin is stale. It is too implicit; the hook below makes it unnecessary. -- **`claude-host-pin`: keep the pin equal to the running id** (opt-in SessionStart hook, 1.5.0). +- **`claude-host-pin`: keep the pin equal to the running id** (opt-in SessionStart hook, 1.5.0; deprecated in 1.7.0). The Feature manages no Claude Code settings, so a repository registers the hook in its `.claude/settings.json`: @@ -84,7 +128,7 @@ What the Feature adds in both cases: required. A variable without a line is left alone (never adds one) unless `CLAUDE_HOST_PIN_CREATE=1` appends `VAR=` to `.devcontainer/.env` (no file holds the variable). It always exits 0, notes go to stderr, so a session never fails because of it. The consumer's own write-back and second-copy check can go. -- **`claude-fleet`: several sessions in one terminal, never a second copy** (1.5.0; 1.6.0 below). Bash +- **`claude-fleet`: several sessions in one terminal, never a second copy** (1.5.0; 1.6.0 below; deprecated in 1.7.0). Bash like `claude-host`, for the same reason (no jq, no python; the container always has bash), installed beside it, run where `claude agents` runs. `SESSIONS` (environment, or `SESSIONS_B64` base64-encoded) holds one line per session, diff --git a/src/claude-code/README.md b/src/claude-code/README.md index 24bd844..203bc0d 100644 --- a/src/claude-code/README.md +++ b/src/claude-code/README.md @@ -1,7 +1,7 @@ # Claude Code (claude-code) -Claude Code (https://code.claude.com/docs) as a member of this collection's ecosystem: installs the sha256-verified native release binary at /usr/local/bin/claude (no Node.js, version pinnable) unless a claude is already in the image - e.g. from the official ghcr.io/anthropics/devcontainer-features/claude-code, which is then augmented instead of replaced - and adds the claude-host launcher (resume one session by UUID via HOST_SESSION, never a second copy of a running session), the claude-fleet command (several sessions in one terminal, never a second copy), a named volume mounted at CLAUDE_CONFIG_DIR (/var/lib/devcontainer-features/claude-code) so login and sessions survive rebuilds, a post-create hook that makes that directory usable by the remote user, and the Feature Taskfile. The in-app auto-updater is off; a rebuild updates. +Claude Code (https://code.claude.com/docs) as a member of this collection's ecosystem: installs the sha256-verified native release binary at /usr/local/bin/claude (no Node.js, version pinnable) unless a claude is already in the image - e.g. from the official ghcr.io/anthropics/devcontainer-features/claude-code, which is then augmented instead of replaced - and adds agent-adapter-claude (Claude Code's adapter for the agents Feature: task agents -- finds a session by its name and folder and acts on it by id), the deprecated claude-host launcher, claude-fleet command and claude-host-pin hook (still working, each printing one deprecation line; claude-code 2.0 removes them), a named volume mounted at CLAUDE_CONFIG_DIR (/var/lib/devcontainer-features/claude-code) so login and sessions survive rebuilds, a post-create hook that makes that directory usable by the remote user, and the Feature Taskfile. The in-app auto-updater is off; a rebuild updates. ## Example Usage @@ -25,6 +25,49 @@ Claude Code (https://code.claude.com/docs) as a member of this collection's ecos - `anthropic.claude-code` +## Deprecated in 1.7.0: claude-host, claude-fleet, claude-host-pin + +The [`agents`](../agents) Feature replaces them: a repository lists its agents in +`.devcontainer/agents.yml` (name, CLI, kind, folder, args) and starts any of them with +`task agents -- ` inside, `task devcontainer:agent -- ` from the host. No session id +is pinned anywhere, so `HOST_SESSION`, the `claude-host-pin` hook and `SESSIONS` go. This +release keeps the three scripts working; each prints one line on stderr saying it is deprecated +and that **claude-code 2.0 removes it** (`claude-fleet` says it once, not again for the +`claude-host` it calls). `task claude-code:host` and `task claude-code:fleet` say so in their +descriptions. The migration for a consuming repository is in +[docs/migrations/agents.md](https://github.com/nabil-becker/devcontainer-features/blob/main/docs/migrations/agents.md). + +## The agents adapter (`agent-adapter-claude`, 1.7.0) + +Claude Code's adapter for the `agents` Feature, on PATH. The `agents` command calls it; nothing +else should. The contract (verbs, exit codes) is in the `agents` Feature's notes. + +| Verb | What it runs | +| --- | --- | +| `start-bg` | `cd && claude --bg --name `; waits until the session is listed and prints its id | +| `wake` | the agent's newest stopped or failed session, by its full id: `cd && claude --bg --resume ` (the same id and conversation; Claude Code 2.1.308 says `woke session ...`) | +| `attach` | `claude attach ` of its newest running session; an interactive one is refused with the pid and tty of the terminal that holds it | +| `resume` | `cd && claude --resume ` of its newest session; refused while that session runs (a second process on one transcript) | +| `new` | `cd && claude --name ` | +| `list` | `claude agents --json --all`: name, session id, state (a pid is running; failed; anything else stopped), folder, newest first | +| `stop` | `claude stop ` of its newest running session; `--extras` stops every other running one instead. Never `claude rm`. | +| `logs` | `claude logs ` | + +**Identity by name and folder, action by id.** A session is the agent's when its `cwd` is the +agent's folder and its `name` is the agent's name, or that name with Claude Code's collision +number (`host (2)`). `attach`, `stop` and `logs` take the session's short `id` from the list (the +full `sessionId` when there is none), never the name: `claude attach host` also matches part of a +name (`host-review`) and works only while the session runs, and `claude stop` takes an id only. +A stopped session is woken by its full id with `--bg --resume`; `--bg -c` and `--bg --resume +` start copies, so the adapter never uses them. The name is set with `--name` at start and +survives `/clear`, so nothing has to be written back. The JSON is read with `sed` (no jq or python +in the Feature), as `claude-host` does. Needs Claude Code 2.1.290 or later (session names). + +Not verified against a live `claude --bg` in CI: the unit tests (`task test:claude-unit`) use a +fake `claude`; the calls rest on the facts checked live on Claude Code 2.1.308 (the agents +proposal, section 7). A background session in a folder Claude Code has never trusted fails to +start (`Workspace not trusted`): run `claude` there once and accept. + ## Augment, don't duplicate With the default `install: auto` this Feature keeps a `claude` that is @@ -47,7 +90,8 @@ version and verifies nothing, which is why this installer still exists. What the Feature adds in both cases: -- `claude-host` on PATH: start or resume **one** session by its UUID. With +- `agent-adapter-claude` on PATH (1.7.0, above). +- `claude-host` on PATH (deprecated in 1.7.0, see the top): start or resume **one** session by its UUID. With `HOST_SESSION=` set it runs `claude --resume ` when that session's transcript exists for the current directory, else `claude --session-id ` to create it; unset, a plain `claude` with a @@ -86,7 +130,7 @@ What the Feature adds in both cases: python, which the Feature does not install. Deliberately not done: falling back to the newest transcript of the folder when the pin is stale. It is too implicit; the hook below makes it unnecessary. -- **`claude-host-pin`: keep the pin equal to the running id** (opt-in SessionStart hook, 1.5.0). +- **`claude-host-pin`: keep the pin equal to the running id** (opt-in SessionStart hook, 1.5.0; deprecated in 1.7.0). The Feature manages no Claude Code settings, so a repository registers the hook in its `.claude/settings.json`: @@ -111,7 +155,7 @@ What the Feature adds in both cases: required. A variable without a line is left alone (never adds one) unless `CLAUDE_HOST_PIN_CREATE=1` appends `VAR=` to `.devcontainer/.env` (no file holds the variable). It always exits 0, notes go to stderr, so a session never fails because of it. The consumer's own write-back and second-copy check can go. -- **`claude-fleet`: several sessions in one terminal, never a second copy** (1.5.0; 1.6.0 below). Bash +- **`claude-fleet`: several sessions in one terminal, never a second copy** (1.5.0; 1.6.0 below; deprecated in 1.7.0). Bash like `claude-host`, for the same reason (no jq, no python; the container always has bash), installed beside it, run where `claude agents` runs. `SESSIONS` (environment, or `SESSIONS_B64` base64-encoded) holds one line per session, diff --git a/src/claude-code/agent-adapter-claude.sh b/src/claude-code/agent-adapter-claude.sh new file mode 100644 index 0000000..033f562 --- /dev/null +++ b/src/claude-code/agent-adapter-claude.sh @@ -0,0 +1,185 @@ +#!/usr/bin/env bash +# agent-adapter-claude: the Claude Code adapter of the agents Feature's contract +# (docs/proposals/agents.md, section 5). Installed on PATH by the claude-code Feature; the +# agents Feature calls it, nothing else does. +# +# agent-adapter-claude [--extras] [-- claude args...] +# start-bg cd && claude --bg --name ; prints the new session's id +# wake the agent's newest stopped or failed session, by its full id: +# cd && claude --bg --resume (the same id, its conversation) +# attach claude attach of its newest running session (a terminal is needed) +# resume cd && claude --resume of its newest session; +# refused while that session runs (a second process on one transcript) +# new cd && claude --name +# stop claude stop of its newest running session; --extras: every other one +# logs claude logs of its newest session +# agent-adapter-claude list namesession idstatefolder, newest first +# agent-adapter-claude supports the verbs above, one per line +# Exit: 0 done, 1 failed, 2 bad use, 3 not supported. +# +# Identity by name and folder, action by id: a session is the agent's when its cwd is the +# folder and its name is the agent's name (or " (2)", how Claude Code numbers a +# collision), read from `claude agents --json --all`. attach, stop and logs then take the +# session's short id (the "id" field; the full sessionId when there is none), never the +# name: `claude attach host` would match part of a name ("host-review" too) and only while +# the session runs. A stopped session is woken by its full id with --bg --resume, never +# with -c or --resume , which start copies. The JSON is read with sed (no jq or +# python in the Feature), as claude-host does; entries are flat objects. +set -u + +die() { local rc="$1"; shift; printf 'agent-adapter-claude: %s\n' "$*" >&2; exit "$rc"; } +note() { printf 'agent-adapter-claude: %s\n' "$*" >&2; } + +field() { # field : a string value + printf '%s' "$1" | sed -n "s/.*\"$2\"[[:space:]]*:[[:space:]]*\"\(\([^\"\\\\]\|\\\\.\)*\)\".*/\1/p" | sed 's/\\\(.\)/\1/g' +} +number() { # number + printf '%s' "$1" | sed -n "s/.*\"$2\"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p" +} + +# sessions: one line per session, newest first: +# name sessionId state cwd shortid kind pid startedAt (tab-separated) +# state: running (a pid, or a background state that says so), failed, stopped. +sessions() { + local out entry name sid id cwd pid kind st started + out="$(timeout 30 claude agents --json --all 2>/dev/null)" || + out="$(timeout 30 claude agents --json 2>/dev/null)" || return 1 + out="$(printf '%s' "$out" | tr -d '\n\r')" + case "$out" in '['*) ;; *) return 1 ;; esac + while IFS= read -r entry || [ -n "$entry" ]; do + sid="$(field "$entry" sessionId)" + [ -n "$sid" ] || continue + name="$(field "$entry" name)" id="$(field "$entry" id)" cwd="$(field "$entry" cwd)" + kind="$(field "$entry" kind)" pid="$(number "$entry" pid)" started="$(number "$entry" startedAt)" + st="$(field "$entry" state)"; [ -n "$st" ] || st="$(field "$entry" status)" + if [ "${pid:-0}" -gt 0 ]; then st=running + else + case "${st,,}" in + running | idle | busy | working | waiting | starting) st=running ;; + fail* | error* | crash*) st=failed ;; + *) st=stopped ;; + esac + fi + cwd="$( (cd "$cwd" 2>/dev/null && pwd -P) || printf '%s' "${cwd%/}")" + printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' "${name//$'\t'/ }" "$sid" "$st" "$cwd" "${id:-$sid}" "${kind:-unknown}" "${pid:-0}" "${started:-0}" + done < <(printf '%s' "$out" | sed 's/}[[:space:]]*,[[:space:]]*{/}\n{/g') | sort -t $'\t' -k8,8nr +} + +# mine [state]: the agent's sessions (NAME in FOLDER), newest first, optionally one state. +mine() { + local want="${1:-}" nm rest st fo + [ -n "${ALL+x}" ] || ALL="$(sessions)" || return 1 + while IFS=$'\t' read -r nm rest; do + [ -n "$nm" ] || continue + st="$(printf '%s' "$rest" | cut -f2)" fo="$(printf '%s' "$rest" | cut -f3)" + [ "$fo" = "$FOLDER" ] || continue + case "$nm" in "$NAME") ;; "$NAME ("*")") [[ "$nm" =~ \([0-9]+\)$ ]] || continue ;; *) continue ;; esac + [ -z "$want" ] || [ "$st" = "$want" ] || continue + printf '%s\t%s\n' "$nm" "$rest" + done <<< "$ALL" +} +col() { printf '%s\n' "$1" | head -n 1 | cut -f"$2"; } + +# wait_running: after a --bg start, wait (AGENT_ADAPTER_WAIT seconds, default 10) until the +# session is in the list, and print its session id. +wait_running() { + local left="${AGENT_ADAPTER_WAIT:-10}" got + while :; do + unset ALL + got="$(mine running)" + [ -n "$got" ] && { col "$got" 2; return 0; } + [ "$left" -le 0 ] && { note "$NAME: started, but not in 'claude agents' yet."; return 0; } + sleep 1; left=$((left - 1)) + done +} + +extras_note() { # extras_note + local n; n="$(printf '%s\n' "$1" | grep -c .)" + [ "$n" -gt 1 ] && note "$NAME: $n running sessions; using the newest ($(col "$1" 2)). task agents:stop -- $NAME --extras stops the others." + return 0 +} + +verb="${1:-}" +[ $# -gt 0 ] && shift +case "$verb" in + supports) printf '%s\n' start-bg wake attach resume new list stop logs supports; exit 0 ;; + list) + ALL="$(sessions)" || die 1 "'claude agents --json' could not be read." + printf '%s\n' "$ALL" | awk -F'\t' 'NF { print $1 "\t" $2 "\t" $3 "\t" $4 }' + exit 0 ;; + start-bg | wake | attach | resume | new | stop | logs) ;; + '') die 2 "usage: agent-adapter-claude [-- claude args]" ;; + *) die 3 "'$verb' is not supported" ;; +esac +[ $# -ge 2 ] || die 2 "$verb needs " +NAME="$1" FOLDER="$2"; shift 2 +extras='' +while [ $# -gt 0 ]; do + case "$1" in + --extras) extras=1 ;; + --) shift; break ;; + *) die 2 "unknown flag '$1' (claude arguments go after --)" ;; + esac + shift +done +given="$FOLDER" +FOLDER="$(cd "$FOLDER" 2>/dev/null && pwd -P)" || die 1 "folder '$given' does not exist" +case "$verb" in + start-bg | new) ;; + *) ALL="$(sessions)" || die 1 "'claude agents --json' could not be read (Claude Code 2.1.290 or later is needed)." ;; +esac + +case "$verb" in + start-bg) + (cd "$FOLDER" && claude --bg --name "$NAME" "$@" &2) || die 1 "$NAME: 'claude --bg' failed (a folder never trusted fails with 'Workspace not trusted': run claude there once and accept)." + wait_running ;; + wake) + got="$(mine)" + s="$(printf '%s\n' "$got" | awk -F'\t' '$3 == "stopped" || $3 == "failed"' | head -n 1)" + [ -n "$s" ] || die 1 "$NAME: no stopped session to wake in $FOLDER." + (cd "$FOLDER" && claude --bg --resume "$(col "$s" 2)" &2) || die 1 "$NAME: 'claude --bg --resume' failed." + wait_running ;; + attach) + run="$(mine running)" + if [ -z "$run" ]; then + s="$(mine | head -n 1)" + [ -n "$s" ] || die 1 "$NAME: no session in $FOLDER to attach to." + # attaching to a stopped or failed session restarts it from its saved conversation + exec claude attach "$(col "$s" 5)" + fi + extras_note "$run" + if [ "$(col "$run" 6)" = interactive ]; then + tty="$(ps -o tty= -p "$(col "$run" 7)" 2>/dev/null | tr -d '[:space:]')" + die 1 "$NAME: its session $(col "$run" 2) (pid $(col "$run" 7)) is held by a terminal${tty:+ ($tty)}; switch to that terminal (or Remote Control). claude attach does not reach an interactive session." + fi + exec claude attach "$(col "$run" 5)" ;; + resume) + s="$(mine | head -n 1)" + [ -n "$s" ] || die 1 "$NAME: no earlier conversation in $FOLDER to resume; task agents -- $NAME starts one." + if [ "$(col "$s" 3)" = running ]; then + die 1 "$NAME: its latest session $(col "$s" 2) is running (pid $(col "$s" 7)); resuming it here would be a second copy. Switch to it, or stop it first." + fi + cd "$FOLDER" && exec claude --resume "$(col "$s" 2)" "$@" ;; + new) + cd "$FOLDER" && exec claude --name "$NAME" "$@" ;; + stop) + run="$(mine running)" + [ -n "$run" ] || { echo "$NAME: not running"; exit 0; } + if [ -n "$extras" ]; then targets="$(printf '%s\n' "$run" | tail -n +2)" + else targets="$(printf '%s\n' "$run" | head -n 1)"; extras_note "$run"; fi + [ -n "$targets" ] || { echo "$NAME: no extra sessions"; exit 0; } + rc=0 + while IFS=$'\t' read -r _ sid _ _ short kind pid _; do + if [ "$kind" = interactive ]; then + note "$NAME: session $sid (pid $pid) is held by a terminal; end it there."; rc=1; continue + fi + if claude stop "$short" /dev/null; then echo "$NAME: stopped $sid (its conversation is kept)" + else note "$NAME: 'claude stop $short' failed."; rc=1; fi + done <<< "$targets" + exit "$rc" ;; + logs) + s="$(mine running | head -n 1)"; [ -n "$s" ] || s="$(mine | head -n 1)" + [ -n "$s" ] || die 1 "$NAME: no session in $FOLDER." + [ "$(col "$s" 6)" = interactive ] && die 1 "$NAME: its session is interactive; claude logs reads background sessions only." + exec claude logs "$(col "$s" 5)" ;; +esac diff --git a/src/claude-code/claude-fleet.sh b/src/claude-code/claude-fleet.sh index 30b2928..e108399 100644 --- a/src/claude-code/claude-fleet.sh +++ b/src/claude-code/claude-fleet.sh @@ -50,7 +50,12 @@ # missing ones and leaves the view out. When the list cannot be read, nothing is started (exit 1): # guessing would start copies. Exit 1 also when a start fails, a dir is missing # or an id is not a UUID; the other sessions are still handled. +# Deprecated (claude-code 1.7.0): the agents Feature replaces it (task agents -- , the +# agent list .devcontainer/agents.yml); claude-code 2.0 removes it. It still works; one line +# on stderr says so (once per call chain: CLAUDE_CODE_DEPRECATION_SHOWN). set -u +[ -n "${CLAUDE_CODE_DEPRECATION_SHOWN:-}" ] || echo "claude-fleet: deprecated: use task agents -- (the agents Feature, .devcontainer/agents.yml); claude-code 2.0 removes claude-fleet." >&2 +export CLAUDE_CODE_DEPRECATION_SHOWN=1 # the claude-host it calls says nothing more plan='' view=1 wait=1 stopdup='' for a in "$@"; do diff --git a/src/claude-code/claude-host-pin.sh b/src/claude-code/claude-host-pin.sh index c214b31..2cc904a 100644 --- a/src/claude-code/claude-host-pin.sh +++ b/src/claude-code/claude-host-pin.sh @@ -26,7 +26,11 @@ # CLAUDE_HOST_UPDATE_PIN calls: the one HOST_SESSION... line holding gets # (CLAUDE_HOST_PIN_VAR= restricts the variable); not exactly one such # line: nothing is changed. Exit 0, or 1 when nothing was updated. +# Deprecated (claude-code 1.7.0): the agents Feature replaces it (task agents -- , the +# agent list .devcontainer/agents.yml); claude-code 2.0 removes it. It still works; one line +# on stderr says so (once per call chain: CLAUDE_CODE_DEPRECATION_SHOWN). set -u +[ -n "${CLAUDE_CODE_DEPRECATION_SHOWN:-}" ] || echo "claude-host-pin: deprecated: use task agents -- (the agents Feature, .devcontainer/agents.yml); claude-code 2.0 removes claude-host-pin." >&2 uuid='^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$' # env_files : first line the root (the folder holding .devcontainer), then the .env files diff --git a/src/claude-code/claude-host.sh b/src/claude-code/claude-host.sh index 7dbed25..244c087 100644 --- a/src/claude-code/claude-host.sh +++ b/src/claude-code/claude-host.sh @@ -46,7 +46,11 @@ # change with /rename and may collide. The transcript is looked up where # Claude Code keeps it for the current directory: # ${CLAUDE_CONFIG_DIR:-$HOME/.claude}/projects//.jsonl +# Deprecated (claude-code 1.7.0): the agents Feature replaces it (task agents -- , the +# agent list .devcontainer/agents.yml); claude-code 2.0 removes it. It still works; one line +# on stderr says so (once per call chain: CLAUDE_CODE_DEPRECATION_SHOWN). set -u +[ -n "${CLAUDE_CODE_DEPRECATION_SHOWN:-}" ] || echo "claude-host: deprecated: use task agents -- (the agents Feature, .devcontainer/agents.yml); claude-code 2.0 removes claude-host." >&2 sid="${HOST_SESSION:-}" if [ -z "$sid" ]; then echo "claude-host: HOST_SESSION is not set; set it to a UUID to resume one session by its id." >&2 diff --git a/src/claude-code/devcontainer-feature.json b/src/claude-code/devcontainer-feature.json index 3f68826..5ce8e49 100644 --- a/src/claude-code/devcontainer-feature.json +++ b/src/claude-code/devcontainer-feature.json @@ -1,8 +1,8 @@ { "id": "claude-code", - "version": "1.6.1", + "version": "1.7.0", "name": "Claude Code", - "description": "Claude Code (https://code.claude.com/docs) as a member of this collection's ecosystem: installs the sha256-verified native release binary at /usr/local/bin/claude (no Node.js, version pinnable) unless a claude is already in the image - e.g. from the official ghcr.io/anthropics/devcontainer-features/claude-code, which is then augmented instead of replaced - and adds the claude-host launcher (resume one session by UUID via HOST_SESSION, never a second copy of a running session), the claude-fleet command (several sessions in one terminal, never a second copy), a named volume mounted at CLAUDE_CONFIG_DIR (/var/lib/devcontainer-features/claude-code) so login and sessions survive rebuilds, a post-create hook that makes that directory usable by the remote user, and the Feature Taskfile. The in-app auto-updater is off; a rebuild updates.", + "description": "Claude Code (https://code.claude.com/docs) as a member of this collection's ecosystem: installs the sha256-verified native release binary at /usr/local/bin/claude (no Node.js, version pinnable) unless a claude is already in the image - e.g. from the official ghcr.io/anthropics/devcontainer-features/claude-code, which is then augmented instead of replaced - and adds agent-adapter-claude (Claude Code's adapter for the agents Feature: task agents -- finds a session by its name and folder and acts on it by id), the deprecated claude-host launcher, claude-fleet command and claude-host-pin hook (still working, each printing one deprecation line; claude-code 2.0 removes them), a named volume mounted at CLAUDE_CONFIG_DIR (/var/lib/devcontainer-features/claude-code) so login and sessions survive rebuilds, a post-create hook that makes that directory usable by the remote user, and the Feature Taskfile. The in-app auto-updater is off; a rebuild updates.", "documentationURL": "https://github.com/nabil-becker/devcontainer-features/tree/main/src/claude-code", "licenseURL": "https://github.com/nabil-becker/devcontainer-features/blob/main/LICENSE", "options": { diff --git a/src/claude-code/install.sh b/src/claude-code/install.sh index 65a81ad..5dcac96 100755 --- a/src/claude-code/install.sh +++ b/src/claude-code/install.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash # Installs Anthropic's Claude Code CLI (https://code.claude.com/docs) and the -# pieces that make it part of this collection's ecosystem: the claude-host -# launcher, the claude-fleet command, the post-create setup of CLAUDE_CONFIG_DIR, and the Feature -# Taskfile. Feature options arrive as upper-cased environment variables (see +# pieces that make it part of this collection's ecosystem: agent-adapter-claude (the agents +# Feature's adapter), the deprecated claude-host launcher and claude-fleet command, the post-create +# setup of CLAUDE_CONFIG_DIR, and the Feature Taskfile. Feature options arrive as upper-cased environment variables (see # devcontainer-feature.json): INSTALL, VERSION. # # State: devcontainer-feature.json points CLAUDE_CONFIG_DIR at @@ -144,6 +144,9 @@ install -m 0755 "$feature_dir/claude-fleet.sh" /usr/local/bin/claude-fleet # claude-host's CLAUDE_HOST_UPDATE_PIN calls). install -m 0755 "$feature_dir/claude-host-pin.sh" /usr/local/bin/claude-host-pin install -m 0755 "$feature_dir/claude-code-setup.sh" /usr/local/bin/claude-code-setup +# agent-adapter-claude is Claude Code's adapter for the agents Feature (task agents -- ), +# which replaces claude-host, claude-fleet and claude-host-pin (deprecated; gone in 2.0). +install -m 0755 "$feature_dir/agent-adapter-claude.sh" /usr/local/bin/agent-adapter-claude # dcf-persist is the shared state-directory helper the hook calls (verbatim # copy of src/common/dcf-persist.sh; every Feature of this collection # installs the same file). diff --git a/src/claude-code/taskfile.yml b/src/claude-code/taskfile.yml index 6472044..4a7b0cf 100644 --- a/src/claude-code/taskfile.yml +++ b/src/claude-code/taskfile.yml @@ -11,14 +11,14 @@ tasks: host: dir: '{{.USER_WORKING_DIR}}' - desc: 'Start or resume the host session with claude-host: the session named by HOST_SESSION (a UUID) is resumed, or created under that UUID the first time; unset, a plain new session. Extra arguments after --, e.g. task claude-code:host -- --model sonnet. From the host machine use task devcontainer:claude instead.' + desc: 'Deprecated (claude-code 2.0 removes it): use task agents -- (the agents Feature). Start or resume the host session with claude-host: the session named by HOST_SESSION (a UUID) is resumed, or created under that UUID the first time; unset, a plain new session. Extra arguments after --, e.g. task claude-code:host -- --model sonnet. From the host machine use task devcontainer:claude instead.' interactive: true cmds: - claude-host {{.CLI_ARGS}} fleet: dir: '{{.USER_WORKING_DIR}}' - desc: 'Several sessions in one terminal, never a second copy: claude-fleet reads SESSIONS from the environment (one line per session, name|session-variable|dir|extra claude args), starts the ones not running in the background (a session counts as running when its id or its folder is in `claude agents --json`) and opens `claude agents`. Before reconciling it waits until the restored sessions are stable (-- --no-wait skips that). Duplicates (several live sessions for one agent) are reported, and -- --stop-duplicates stops the extras with `claude stop` (never rm). Ids are read from .devcontainer/.env first, then the environment. -- --plan prints what it would do and starts nothing; -- --no-view leaves the view out. From the host machine use task devcontainer:claude:fleet.' + desc: 'Deprecated (claude-code 2.0 removes it): use the agent list and task agents:up (the agents Feature). Several sessions in one terminal, never a second copy: claude-fleet reads SESSIONS from the environment (one line per session, name|session-variable|dir|extra claude args), starts the ones not running in the background (a session counts as running when its id or its folder is in `claude agents --json`) and opens `claude agents`. Before reconciling it waits until the restored sessions are stable (-- --no-wait skips that). Duplicates (several live sessions for one agent) are reported, and -- --stop-duplicates stops the extras with `claude stop` (never rm). Ids are read from .devcontainer/.env first, then the environment. -- --plan prints what it would do and starts nothing; -- --no-view leaves the view out. From the host machine use task devcontainer:claude:fleet.' interactive: true cmds: - claude-fleet {{.CLI_ARGS}} diff --git a/src/devcontainer-cli/NOTES.md b/src/devcontainer-cli/NOTES.md index 752c138..f3e3010 100644 --- a/src/devcontainer-cli/NOTES.md +++ b/src/devcontainer-cli/NOTES.md @@ -154,7 +154,7 @@ installed globally on any host. Its host settings are `task devcontainer:up` (alias `start`) and `task devcontainer:stop` (alias `down`) are idempotent, so other tasks can require them. `up` starts a stopped -container and leaves a running one alone. `exec`, `shell`, `pwsh` and `claude` +container and leaves a running one alone. `exec`, `shell`, `pwsh`, `agent` and `claude` run `up` first; `rebuild` runs `stop` first (shutdown check included; `FORCE=1` and `STOP_CHECK` pass through) and then recreates the container; a stop that fails (the check refuses, or any error) stops the rebuild too, so `FORCE=1` is the @@ -311,6 +311,13 @@ the host type needs; ADR 0024 decides it, `docs/proposals/host-profile.md` is th repository line for the same target suppress it like the others. `DCF_HOST_CLOUD_FOLDER` itself is passed on to the CLI unchanged, so `containerEnv` can map it (the bootstrap's template does) and the Feature's report can name the source. Not verified on a real host: Docker Desktop and wslc taking a Windows path with spaces from `--mount`. +- **One verb for every agent (1.13.0).** `task devcontainer:agent -- [--resume|--new] [extra CLI args]` runs the + `agents` Feature's start verb in the container (`agents start`, through `exec` with a terminal, starting the container + first; directly when already inside), and `task devcontainer:agent:list [-- --json]` its list. `AGENTS_FILE=` + on the command line (before the `--`) picks another agent list; it reaches the container as `AGENTS_FILE_TASK_VAR`, + and the `agents` command resolves it against `.env` and the environment there. Taskfile tasks only: neither launcher + twin changes. `devcontainer:claude` and `devcontainer:claude:fleet` still work and print one line saying they are + deprecated and go with claude-code 2.0; the migration is in `docs/migrations/agents.md` of the collection. - **Fleet waits, reports and stops duplicates (1.11.0).** `task devcontainer:claude:fleet -- --no-wait|--stop-duplicates` reach `claude-fleet` (claude-code 1.6.0); the task description says so. - **Several Claude sessions (1.9.0).** `task devcontainer:claude:fleet` passes `SESSIONS` (set by the including Taskfile) and its arguments to the claude-code Feature's `claude-fleet` in the container (through `exec`, or directly inside); see diff --git a/src/devcontainer-cli/README.md b/src/devcontainer-cli/README.md index e98246f..15d2a79 100644 --- a/src/devcontainer-cli/README.md +++ b/src/devcontainer-cli/README.md @@ -175,7 +175,7 @@ installed globally on any host. Its host settings are `task devcontainer:up` (alias `start`) and `task devcontainer:stop` (alias `down`) are idempotent, so other tasks can require them. `up` starts a stopped -container and leaves a running one alone. `exec`, `shell`, `pwsh` and `claude` +container and leaves a running one alone. `exec`, `shell`, `pwsh`, `agent` and `claude` run `up` first; `rebuild` runs `stop` first (shutdown check included; `FORCE=1` and `STOP_CHECK` pass through) and then recreates the container; a stop that fails (the check refuses, or any error) stops the rebuild too, so `FORCE=1` is the @@ -332,6 +332,13 @@ the host type needs; ADR 0024 decides it, `docs/proposals/host-profile.md` is th repository line for the same target suppress it like the others. `DCF_HOST_CLOUD_FOLDER` itself is passed on to the CLI unchanged, so `containerEnv` can map it (the bootstrap's template does) and the Feature's report can name the source. Not verified on a real host: Docker Desktop and wslc taking a Windows path with spaces from `--mount`. +- **One verb for every agent (1.13.0).** `task devcontainer:agent -- [--resume|--new] [extra CLI args]` runs the + `agents` Feature's start verb in the container (`agents start`, through `exec` with a terminal, starting the container + first; directly when already inside), and `task devcontainer:agent:list [-- --json]` its list. `AGENTS_FILE=` + on the command line (before the `--`) picks another agent list; it reaches the container as `AGENTS_FILE_TASK_VAR`, + and the `agents` command resolves it against `.env` and the environment there. Taskfile tasks only: neither launcher + twin changes. `devcontainer:claude` and `devcontainer:claude:fleet` still work and print one line saying they are + deprecated and go with claude-code 2.0; the migration is in `docs/migrations/agents.md` of the collection. - **Fleet waits, reports and stops duplicates (1.11.0).** `task devcontainer:claude:fleet -- --no-wait|--stop-duplicates` reach `claude-fleet` (claude-code 1.6.0); the task description says so. - **Several Claude sessions (1.9.0).** `task devcontainer:claude:fleet` passes `SESSIONS` (set by the including Taskfile) and its arguments to the claude-code Feature's `claude-fleet` in the container (through `exec`, or directly inside); see diff --git a/src/devcontainer-cli/devcontainer-feature.json b/src/devcontainer-cli/devcontainer-feature.json index 6f85787..5770b06 100644 --- a/src/devcontainer-cli/devcontainer-feature.json +++ b/src/devcontainer-cli/devcontainer-feature.json @@ -1,6 +1,6 @@ { "id": "devcontainer-cli", - "version": "1.12.0", + "version": "1.13.0", "name": "Dev Container CLI", "description": "Installs @devcontainers/cli as /usr/local/bin/devcontainer, backed by a private, sha256-verified Node.js under /opt/devcontainer-cli that is NOT added to PATH - so `devcontainer up/build/exec/features test` work inside a container without a system-wide Node.js. Pair with docker-in-docker (needed for features test and nested `up` on Windows/macOS hosts) or docker-outside-of-docker (Linux hosts, same paths).", "documentationURL": "https://github.com/nabil-becker/devcontainer-features/tree/main/src/devcontainer-cli", diff --git a/test/_global/all_features.sh b/test/_global/all_features.sh index ab38ccc..c7c1334 100755 --- a/test/_global/all_features.sh +++ b/test/_global/all_features.sh @@ -9,6 +9,8 @@ source dev-container-features-test-lib check "task" task --version check "claude" claude --version check "claude-host" command -v claude-host +check "agents" command -v agents +check "agent-adapter-claude" command -v agent-adapter-claude check "devcontainer" devcontainer --version check "coder" coder version check "bao" bao version @@ -49,8 +51,8 @@ check "ssh-agent key volume present" test -d /var/lib/devcontainer-features/ssh- registry=/usr/local/share/go-task/features.yml # shellcheck disable=SC2016 # expansion is meant to happen inside the inner bash -check "registry lists every Feature" bash -c 'for f in common go-task claude-code devcontainer-cli coder-cli openbao velero kustomize kubeconform argocd dsc git gh ssh-agent cloud-folder az kube-state; do grep -q "^ $f:" /usr/local/share/go-task/features.yml || { echo "missing $f"; exit 1; }; done' -check "feature tasks resolve through the registry" bash -c "task -t $registry --list-all | grep -c -E '^\\* (dsc:get|velero:backups|kustomize:build:all|argocd:apps|openbao:status|coder-cli:workspaces|claude-code:host|devcontainer-cli:version|kubeconform:validate:kustomize|go-task:registered|git:setup|gh:status|ssh-agent:check|cloud-folder:status|az:account|kube-state:contexts|common:persist):' | grep -qx 17" +check "registry lists every Feature" bash -c 'for f in common go-task claude-code agents devcontainer-cli coder-cli openbao velero kustomize kubeconform argocd dsc git gh ssh-agent cloud-folder az kube-state; do grep -q "^ $f:" /usr/local/share/go-task/features.yml || { echo "missing $f"; exit 1; }; done' +check "feature tasks resolve through the registry" bash -c "task -t $registry --list-all | grep -c -E '^\\* (dsc:get|velero:backups|kustomize:build:all|argocd:apps|openbao:status|coder-cli:workspaces|claude-code:host|agents:list|devcontainer-cli:version|kubeconform:validate:kustomize|go-task:registered|git:setup|gh:status|ssh-agent:check|cloud-folder:status|az:account|kube-state:contexts|common:persist):' | grep -qx 18" check "feature task runs" task -t "$registry" dsc:version check "registered list task" bash -c "task -t $registry go-task:registered | grep -qx dsc" diff --git a/test/_global/scenarios.json b/test/_global/scenarios.json index 89125ed..ba7ff67 100644 --- a/test/_global/scenarios.json +++ b/test/_global/scenarios.json @@ -10,6 +10,7 @@ "go-task": {}, "devcontainer-cli": {}, "claude-code": {}, + "agents": {}, "coder-cli": {}, "openbao": {}, "velero": {}, diff --git a/test/agents/no_taskfile.sh b/test/agents/no_taskfile.sh new file mode 100644 index 0000000..a35b087 --- /dev/null +++ b/test/agents/no_taskfile.sh @@ -0,0 +1,10 @@ +#!/bin/bash +# Scenario 'no_taskfile' (see scenarios.json): installTaskfile=false leaves the Taskfile out. +set -e +# shellcheck disable=SC1091 # provided by the devcontainer CLI at test time +source dev-container-features-test-lib + +check "taskfile not registered" bash -c "! test -e /usr/local/share/go-task/includes.d/agents.yml" +check "agents still installed" command -v agents + +reportResults diff --git a/test/agents/scenarios.json b/test/agents/scenarios.json new file mode 100644 index 0000000..40c3a4a --- /dev/null +++ b/test/agents/scenarios.json @@ -0,0 +1,29 @@ +{ + "with_task": { + "image": "mcr.microsoft.com/devcontainers/base:debian", + "features": { + "go-task": {}, + "agents": {} + }, + "remoteUser": "vscode" + }, + "with_claude_code": { + "image": "mcr.microsoft.com/devcontainers/base:debian", + "features": { + "claude-code": { + "install": "none" + }, + "agents": {} + }, + "remoteUser": "vscode" + }, + "no_taskfile": { + "image": "mcr.microsoft.com/devcontainers/base:debian", + "features": { + "agents": { + "installTaskfile": false + } + }, + "remoteUser": "vscode" + } +} diff --git a/test/agents/test.sh b/test/agents/test.sh new file mode 100644 index 0000000..6b913bd --- /dev/null +++ b/test/agents/test.sh @@ -0,0 +1,30 @@ +#!/bin/bash +# Auto-generated scenario: the agents Feature with default options. The command, its state +# folder, the version and the Taskfile are installed; on a list in a temp repository with a fake +# adapter the verbs work (the logic is covered Docker-free in test/agents/unit/agents_test.sh; +# this checks the installed command runs on this base image, its awk included). Run with: +# devcontainer features test -f agents --skip-scenarios . +set -e +# shellcheck disable=SC1091 # provided by the devcontainer CLI at test time +source dev-container-features-test-lib + +check "agents on PATH" command -v agents +check "agents is root-owned" bash -c "[ \"\$(stat -c %U \"\$(command -v agents)\")\" = root ]" +check "the Feature version is baked" grep -qx '1.0.0' /usr/local/share/agents-feature/version +check "the state folder is there and sticky" bash -c "[ \"\$(stat -c %a /var/lib/devcontainer-features/agents)\" = 1777 ]" +check "taskfile registered" test -f /usr/local/share/go-task/includes.d/agents.yml +check "no adapter of its own" bash -c "! ls /usr/local/bin/agent-adapter-* 2>/dev/null" + +# shellcheck disable=SC2016 # expansion is meant to happen inside the inner bash +setup='t=$(mktemp -d); mkdir -p "$t/repo/.devcontainer" "$t/bin" +printf "agents:\n - name: host\n cli: fake\n kind: resident\n args: [--x, \"a b\"]\n - name: pair\n cli: fake\n kind: on-demand\n" > "$t/repo/.devcontainer/agents.yml" +printf "#!/bin/sh\necho \"\$*\" >> %s/calls\n[ \"\$1\" = list ] && cat %s/sessions 2>/dev/null\nexit 0\n" "$t" "$t" > "$t/bin/agent-adapter-fake"; chmod +x "$t/bin/agent-adapter-fake" +export PATH="$t/bin:$PATH" AGENTS_SETTLE_INTERVAL=0 AGENTS_SETTLE_TIMEOUT=0; cd "$t/repo"' +check "check reads the list" bash -c "$setup; agents check | grep -F 'host: cli fake, resident, folder ., args: [--x] [a b]'" +check "a missing list names the path and its source" bash -c "$setup; rm .devcontainer/agents.yml; agents check 2>&1 | grep -F 'from the default'; [ \"\${PIPESTATUS[0]}\" -eq 2 ]" +check "up starts the resident only" bash -c "$setup; agents up >/dev/null; grep -c start-bg \"\$t/calls\" | grep -qx 1 && ! grep -q 'pair' \"\$t/calls\"" +check "an on-demand agent without a terminal is refused" bash -c "$setup; agents start pair /dev/null 2>&1; [ \$? -eq 2 ]" +check "a resident without a terminal is started and not attached" bash -c "$setup; agents start host idstatefolder, newest first); +# FAKE_UNSUPPORTED (a space-separated list of verbs) answers exit 3, FAKE_FAIL exit 1; every call +# is recorded in $FAKE_CALLS as "verb|name|folder|arg|arg...". +cat > "$bin/agent-adapter-fake" <<'FAKE' +#!/usr/bin/env bash +verb="$1"; shift +case " ${FAKE_UNSUPPORTED:-} " in *" $verb "*) echo "fake: $verb not supported" >&2; exit 3 ;; esac +case " ${FAKE_FAIL:-} " in *" $verb "*) exit 1 ;; esac +if [ "$verb" = list ]; then echo "list" >> "$FAKE_CALLS"; cat "$FAKE_SESSIONS" 2>/dev/null; exit 0; fi +if [ "$verb" = supports ]; then echo start-bg; exit 0; fi +name="$1" folder="$2"; shift 2 +line="$verb|$name|$folder"; for a in "$@"; do line="$line|$a"; done +echo "$line" >> "$FAKE_CALLS" +case "$verb" in + start-bg) n=$(( $(cat "$FAKE_SESSIONS" 2>/dev/null | grep -c .) + 1 )) + { printf '%s\tid-%s-%s\trunning\t%s\n' "$name" "$name" "$n" "$folder"; cat "$FAKE_SESSIONS" 2>/dev/null; } > "$FAKE_SESSIONS.new" + mv "$FAKE_SESSIONS.new" "$FAKE_SESSIONS"; echo "id-$name-$n" ;; + wake) sed -i "s/^\($name\t[^\t]*\t\)\(stopped\|failed\)\t/\1running\t/" "$FAKE_SESSIONS" ;; + stop) sed -i "0,/^\($name\t[^\t]*\t\)running\t/s//\1stopped\t/" "$FAKE_SESSIONS" ;; + logs) echo "fake log of $name" ;; +esac +exit 0 +FAKE +chmod +x "$bin/agent-adapter-fake" + +repo="$T/repo"; mkdir -p "$repo/.devcontainer" "$repo/rooms/review" "$repo/sub" +export FAKE_SESSIONS="$T/sessions" FAKE_CALLS="$T/calls" AGENTS_STATE_DIR="$T/state" AGENTS_START_ID=start-1 +export AGENTS_SETTLE_INTERVAL=0 AGENTS_SETTLE_TIMEOUT=0 +unset AGENTS_FILE AGENTS_FILE_TASK_VAR AGENTS_TTY +write_list() { cat > "$repo/.devcontainer/agents.yml"; } +reset() { : > "$T/sessions"; : > "$T/calls"; rm -rf "$T/state"; unset FAKE_UNSUPPORTED FAKE_FAIL; } +run() { (cd "${DIRIN:-$repo}" && PATH="$bin:$PATH" agents "$@" 2>&1); } +calls() { grep -v '^list$' "$T/calls" | tr '\n' ';'; } + +# ------------------------------------------------------------------- parsing ---- +write_list <<'YAML' +# the agents of this repository +--- +agents: + - name: host # the main one + cli: fake + kind: resident + args: [--model, "opus 4", 'it''s', --flag=a:b] # a flow list + - name: reviewer + cli: fake + kind: on-demand + folder: rooms/review + args: + - --effort + - "high" + note: kept for us + owner: + team: x + - + name: "pair" + kind: 'on-demand' + cli: fake +other: thing +YAML +out="$(run check)"; rc=$? +check 'check: a valid list exits 0' 0 "$rc" +contains 'check: names the file and its source' "$out" "$repo/.devcontainer/agents.yml (from the default" +contains 'check: counts the agents' "$out" '3 agent(s)' +contains 'check: a flow list with quotes and a colon' "$out" 'host: cli fake, resident, folder ., args: [--model] [opus 4] [it'"'"'s] [--flag=a:b]' +contains 'check: a block list of args' "$out" 'reviewer: cli fake, on-demand, folder rooms/review, args: [--effort] [high]' +contains 'check: folder defaults to ., no args' "$out" 'pair: cli fake, on-demand, folder ., args: none' +contains 'check: an unknown key is a warning with its line' "$out" "agents.yml:15: unknown key 'note' (ignored)" +contains 'check: an unknown top-level key is a warning' "$out" "unknown top-level key 'other'" +lacks 'check: a nested unknown key is skipped, not read' "$out" "'team'" + +bad() { # : the list on stdin must be refused with exit 2 + write_list + local o r; o="$(run check)"; r=$? + check "refused: $1 (exit 2)" 2 "$r" + contains "refused: $1 (message)" "$o" "$2" +} +printf 'agents:\n - name: a\n kind: resident\n' | bad 'no cli' "agents.yml:2: entry 1 has no 'cli'" +printf 'agents:\n - cli: fake\n kind: resident\n' | bad 'no name' "has no 'name'" +printf 'agents:\n - name: a\n cli: fake\n' | bad 'no kind' "has no 'kind'" +printf 'agents:\n - name: a\n cli: fake\n kind: daemon\n' | bad 'an unknown kind' "kind 'daemon' is neither resident nor on-demand" +printf 'agents:\n - name: a b\n cli: fake\n kind: resident\n' | bad 'a name with a space' "may hold only letters" +printf 'agents:\n - name: a\n cli: fake\n kind: resident\n - name: a\n cli: fake\n kind: on-demand\n' | bad 'a duplicate name' "name 'a' is already used by entry 1" +printf 'agents:\n - name: a\n cli: fake\n kind: resident\n folder: /etc\n' | bad 'an absolute folder' "must be relative" +printf 'agents:\n - name: a\n cli: fake\n kind: resident\n folder: ../x\n' | bad 'a folder outside' "must stay inside" +printf 'agents:\n - name: a\n cli: fake\n kind: resident\n args: --model opus\n' | bad 'args as a string' "agents.yml:5: args must be a list" +printf 'agents:\n - name: "a\n cli: fake\n' | bad 'an open quote' "agents.yml:2: a quoted value is not closed" +printf 'agents:\n - name: a\n cli: fake\n kind: resident\n' | bad 'bad indentation' "agents.yml:4: unexpected indentation" +printf 'agents:\n - name: a\n name: b\n' | bad 'a key twice' "'name' appears twice" +printf 'agents:\n\t- name: a\n' | bad 'a tab' "a tab in the indentation" +printf 'agents:\n - {name: a, cli: fake}\n' | bad 'a flow mapping' "not { }" +printf 'agents:\n - name: a\n cli: fake\n kind: resident\n args: [a, [b]]\n' | bad 'a nested list' "nested lists" +printf 'agents:\n - name: a\n cli: fake\n kind: resident\n args: [a,\n' | bad 'an open flow list' "must close on the same line" +printf 'name: a\n' | bad 'no agents key' "no agents: list" +printf 'agents:\n - name: a\n cli: fake\n kind: resident\n args: [""]\n' | bad 'an empty argument' "an empty argument" +write_list <<'YAML' +agents: [] +YAML +out="$(run check)"; rc=$? +check 'agents: [] is an empty list' 0 "$rc" +contains 'agents: [] counts none' "$out" '0 agent(s)' + +# ------------------------------------------------------ where the list is ---- +rm -f "$repo/.devcontainer/agents.yml" +mkdir -p "$repo/ops" "$repo/env" "$repo/dc" +for f in ops env dc; do printf 'agents:\n - name: from-%s\n cli: fake\n kind: on-demand\n' "$f" > "$repo/$f/agents.yml"; done +printf 'agents:\n - name: from-default\n cli: fake\n kind: on-demand\n' > "$T/default.yml" + +out="$(run check)"; rc=$? +check 'missing default list: exit 2' 2 "$rc" +contains 'missing default list: names the path' "$out" "no agent list at $repo/.devcontainer/agents.yml" +contains 'missing default list: names the source' "$out" 'from the default (AGENTS_FILE is not set)' +out="$(AGENTS_FILE=env/agents.yml run check)" +contains 'environment: used when nothing else sets it' "$out" 'from AGENTS_FILE in the environment' +contains 'environment: its list is read' "$out" 'from-env' +printf 'AGENTS_FILE=dc/agents.yml\n' > "$repo/.devcontainer/.env" +out="$(AGENTS_FILE=env/agents.yml run check)" +contains '.devcontainer/.env beats the environment' "$out" 'from-dc' +contains '.devcontainer/.env named as the source' "$out" 'from AGENTS_FILE in .devcontainer/.env' +printf 'OTHER=1\nexport AGENTS_FILE="ops/agents.yml" # the ops list\n' > "$repo/.env" +out="$(AGENTS_FILE=env/agents.yml run check)" +contains 'the root .env beats .devcontainer/.env' "$out" 'from-ops' +contains 'the root .env: export, quotes and a comment dropped' "$out" "$repo/ops/agents.yml (from AGENTS_FILE in .env)" +out="$(AGENTS_FILE=env/agents.yml AGENTS_FILE_TASK_VAR=dc/agents.yml run check)" +contains 'a task variable different from .env and the environment wins' "$out" 'from-dc' +contains 'a task variable is named as such' "$out" 'from the task variable AGENTS_FILE' +out="$(AGENTS_FILE=env/agents.yml AGENTS_FILE_TASK_VAR=env/agents.yml run check)" +contains 'a task variable equal to the environment is the environment (Task fills it from there): .env wins' "$out" 'from-ops' +out="$(AGENTS_FILE_TASK_VAR=ops/agents.yml run check)" +contains 'a task variable equal to .env is named as .env' "$out" 'from AGENTS_FILE in .env' +out="$(AGENTS_FILE_TASK_VAR=nope.yml run check)"; rc=$? +check 'a missing file from the task variable: exit 2' 2 "$rc" +contains 'a missing file from the task variable: path and source' "$out" "no agent list at $repo/nope.yml (from the task variable AGENTS_FILE)" +out="$(AGENTS_FILE_TASK_VAR="$T/default.yml" run check)" +contains 'an absolute path is used as it is' "$out" 'from-default' +out="$(DIRIN="$repo/rooms/review" run check)" +contains 'from a sub-folder the root is the folder holding .devcontainer' "$out" "$repo/ops/agents.yml" +rm -f "$repo/.env" "$repo/.devcontainer/.env" + +# ------------------------------------------------------------- the verbs ---- +write_list <<'YAML' +agents: + - name: host + cli: fake + kind: resident + args: [--remote-control] + - name: reviewer + cli: fake + kind: on-demand + folder: rooms/review + args: [--model, sonnet] + - name: ghost + cli: nosuch + kind: on-demand + - name: lost + cli: fake + kind: resident + folder: not-there +YAML + +reset +out="$(AGENTS_TTY=1 run start host)"; rc=$? +check 'resident, none running, terminal: exit 0' 0 "$rc" +check 'resident, none running, terminal: start-bg then attach, args from the list' "start-bg|host|$repo|--|--remote-control;attach|host|$repo|--|--remote-control;" "$(calls)" +contains 'resident: says it started' "$out" 'host: started in the background (id-host-1)' + +reset +out="$(AGENTS_TTY=0 run start host)"; rc=$? +check 'resident, no terminal: exit 0' 0 "$rc" +check 'resident, no terminal: started, never attached' "start-bg|host|$repo|--|--remote-control;" "$(calls)" +contains 'resident, no terminal: prints how to attach' "$out" 'attach from a terminal with: task agents -- host' + +reset; printf 'host\tid-old\trunning\t%s\n' "$repo" > "$T/sessions" +out="$(AGENTS_TTY=1 run start host -- --effort high)"; rc=$? +check 'resident running: attach only, extra args after the list args' "attach|host|$repo|--|--remote-control|--effort|high;" "$(calls)" +contains 'resident running: says so' "$out" 'host: running (id-old)' + +reset; printf 'host\tid-old\trunning\t%s\n' "$repo" > "$T/sessions" +out="$(AGENTS_TTY=1 run start host --resume)" +check 'resident --resume: the same as plain' "attach|host|$repo|--|--remote-control;" "$(calls)" + +reset; printf 'host (2)\tid-new\trunning\t%s\nhost\tid-old\trunning\t%s\nhost-review\tid-x\trunning\t%s\nhost\tid-y\trunning\t%s/sub\n' "$repo" "$repo" "$repo" "$repo" > "$T/sessions" +out="$(AGENTS_TTY=0 run start host)" +contains 'a collision "host (2)" counts, the newest is kept' "$out" 'host: running (id-new)' +contains 'the other running session is reported as an extra' "$out" '1 more running session(s)' +lacks 'host-review (part of the name) does not count' "$out" 'id-x' +check 'nothing started when running' "" "$(calls)" + +reset; printf 'host\tid-old\tstopped\t%s\n' "$repo" > "$T/sessions" +out="$(AGENTS_TTY=1 run start host)" +check 'resident stopped: wake (same conversation), then attach' "wake|host|$repo|--|--remote-control;attach|host|$repo|--|--remote-control;" "$(calls)" +contains 'resident stopped: says so' "$out" 'host: stopped (id-old); waking it' + +reset; printf 'host\tid-old\tfailed\t%s\n' "$repo" > "$T/sessions"; export FAKE_UNSUPPORTED=wake +out="$(AGENTS_TTY=0 run start host)" +check 'wake unsupported: a fresh start in the background' "start-bg|host|$repo|--|--remote-control;" "$(calls)" +contains 'wake unsupported: one line says so' "$out" 'cannot wake a stopped session; starting a fresh one' + +reset; export FAKE_UNSUPPORTED=start-bg +out="$(AGENTS_TTY=1 run start host)"; rc=$? +check 'start-bg unsupported: the resident runs as on-demand (new)' "new|host|$repo|--|--remote-control;" "$(calls)" +contains 'start-bg unsupported: one line says so' "$out" 'has no background mode; opening a new session' +reset; export FAKE_UNSUPPORTED=start-bg +out="$(AGENTS_TTY=0 run start host)"; rc=$? +check 'start-bg unsupported, no terminal: refused (exit 2)' 2 "$rc" +check 'start-bg unsupported, no terminal: nothing opened' "" "$(calls)" + +reset; printf 'host\tid-old\trunning\t%s\n' "$repo" > "$T/sessions"; export FAKE_UNSUPPORTED=attach +out="$(AGENTS_TTY=1 run start host)"; rc=$? +check 'attach unsupported: exit 1' 1 "$rc" +contains 'attach unsupported: the reason' "$out" 'cannot attach to a background session' +contains 'attach unsupported: its logs instead' "$out" 'fake log of host' + +reset; export FAKE_UNSUPPORTED=list +out="$(AGENTS_TTY=0 run start host)"; rc=$? +check 'list unsupported: started once' "start-bg|host|$repo|--|--remote-control;" "$(calls)" +contains 'list unsupported: says it cannot promise' "$out" "cannot be promised" +: > "$T/calls" +out="$(AGENTS_TTY=0 run start host)" +check 'list unsupported: never twice in one container start (marker)' "" "$(calls)" +contains 'list unsupported: the second run says why' "$out" 'started earlier in this container start' +: > "$T/calls" +out="$(AGENTS_TTY=0 AGENTS_START_ID=start-2 run start host)" +check 'list unsupported: a new container start starts it again' "start-bg|host|$repo|--|--remote-control;" "$(calls)" + +reset; printf 'host\tid-old\trunning\t%s\n' "$repo" > "$T/sessions" +out="$(printf 'y\n' | AGENTS_TTY=1 run start host --new)"; rc=$? +contains '--new asks first' "$out" 'Stop host (id-old) and start a fresh session? [y/N]' +check '--new, yes: stop, start-bg, attach' "stop|host|$repo|--|--remote-control;start-bg|host|$repo|--|--remote-control;attach|host|$repo|--|--remote-control;" "$(calls)" +reset; printf 'host\tid-old\trunning\t%s\n' "$repo" > "$T/sessions" +out="$(printf 'n\n' | AGENTS_TTY=1 run start host --new)"; rc=$? +check '--new, no: exit 0' 0 "$rc" +check '--new, no: nothing done' "" "$(calls)" +contains '--new, no: says so' "$out" 'nothing changed' +reset; printf 'host\tid-old\trunning\t%s\n' "$repo" > "$T/sessions" +out="$(AGENTS_TTY=0 run start host --new)"; rc=$? +check '--new without a terminal: refused (exit 2)' 2 "$rc" +check '--new without a terminal: nothing stopped' "" "$(calls)" +out="$(AGENTS_TTY=0 run start host --new --yes)"; rc=$? +check '--new --yes without a terminal: stop and start, no attach' "stop|host|$repo|--|--remote-control;start-bg|host|$repo|--|--remote-control;" "$(calls)" +out="$(run start host --new --resume)"; rc=$? +check '--new and --resume together: exit 2' 2 "$rc" + +reset +out="$(AGENTS_TTY=1 run start reviewer --effort high)"; rc=$? +check 'on-demand: new in its folder, list args then extra args' "new|reviewer|$repo/rooms/review|--|--model|sonnet|--effort|high;" "$(calls)" +reset +out="$(AGENTS_TTY=1 run start reviewer --new)" +check 'on-demand --new: the same as plain' "new|reviewer|$repo/rooms/review|--|--model|sonnet;" "$(calls)" +reset +out="$(AGENTS_TTY=1 run start reviewer --resume -- --resume)" +check 'on-demand --resume: resume; after -- a flag goes to the CLI' "resume|reviewer|$repo/rooms/review|--|--model|sonnet|--resume;" "$(calls)" +reset; export FAKE_UNSUPPORTED=resume +out="$(AGENTS_TTY=1 run start reviewer --resume)" +check 'resume unsupported: new instead' "new|reviewer|$repo/rooms/review|--|--model|sonnet;" "$(calls)" +contains 'resume unsupported: one line says so' "$out" 'cannot resume a conversation; opening a new one' +reset +out="$(AGENTS_TTY=0 run start reviewer)"; rc=$? +check 'on-demand without a terminal: refused (exit 2)' 2 "$rc" +contains 'on-demand without a terminal: says why' "$out" 'needs a terminal' +check 'on-demand without a terminal: nothing called' "" "$(calls)" +out="$(run start reviewer "$T/calls" +out="$(run up --no-wait)" +check 'up twice: starts nothing (idempotent)' "" "$(calls)" +lacks 'up --no-wait: no wait' "$out" 'waiting for restored' +reset; printf 'host\tid-old\tfailed\t%s\n' "$repo" > "$T/sessions" +out="$(run up --no-wait)" +contains 'up: a failed resident is woken' "$(calls)" "wake|host|$repo" +reset; export FAKE_UNSUPPORTED=start-bg +out="$(run up --no-wait)"; rc=$? +contains 'up: start-bg unsupported is skipped with a line' "$out" 'host: skipped, fake has no background mode' +check 'up: start-bg unsupported is not a failure' 0 "$rc" +reset; export FAKE_FAIL=start-bg +out="$(run up --no-wait)"; rc=$? +check 'up: a failed start is exit 1' 1 "$rc" +out="$(run up --post-start)"; rc=$? +check 'up --post-start: never fails the container start' 0 "$rc" +contains 'up --post-start: says what did not start' "$out" 'did not start' +reset +mv "$repo/.devcontainer/agents.yml" "$T/saved.yml" +out="$(run up --post-start)"; rc=$? +check 'up --post-start without a list: exit 0' 0 "$rc" +contains 'up --post-start without a list: one line' "$out" 'no agent list at' +out="$(run up)"; rc=$? +check 'up without a list: exit 2' 2 "$rc" +mv "$T/saved.yml" "$repo/.devcontainer/agents.yml" +rmdir "$repo/not-there" + +# list, stop, logs +reset; printf 'host\tid-old\trunning\t%s\nreviewer\tid-r\tstopped\t%s/rooms/review\n' "$repo" "$repo" > "$T/sessions" +out="$(run list)"; rc=$? +check 'list: exit 0' 0 "$rc" +contains 'list: a header' "$out" 'NAME' +contains 'list: a running resident with its id' "$out" "$(printf '%-16s %-10s %-8s %-9s %-38s %s' host resident fake running id-old .)" +contains 'list: a stopped on-demand agent' "$out" "$(printf '%-16s %-10s %-8s %-9s %-38s %s' reviewer on-demand fake stopped id-r rooms/review)" +contains 'list: no adapter' "$out" 'no-adapter' +contains 'list: no folder' "$out" 'no-folder' +out="$(run list --json)" +contains 'list --json: an array of objects' "$out" '[{"name":"host","kind":"resident","cli":"fake","folder":".","state":"running","id":"id-old","extras":0},' +export FAKE_UNSUPPORTED=list +out="$(run list --json)" +contains 'list: unknown when the adapter cannot list' "$out" '"state":"unknown"' +reset; printf 'host\tid-old\trunning\t%s\n' "$repo" > "$T/sessions" +out="$(run stop host)"; rc=$? +check 'stop: the adapter stop' "stop|host|$repo|--|--remote-control;" "$(calls)" +: > "$T/calls" +out="$(run stop host --extras)" +check 'stop --extras: handed to the adapter' "stop|host|$repo|--extras|--|--remote-control;" "$(calls)" +export FAKE_UNSUPPORTED=stop +out="$(run stop host)"; rc=$? +check 'stop unsupported: exit 3' 3 "$rc" +contains 'stop unsupported: says so' "$out" 'cannot stop' +reset +out="$(run logs host)"; rc=$? +contains 'logs: the adapter logs' "$out" 'fake log of host' +export FAKE_UNSUPPORTED=logs +out="$(run logs host)"; rc=$? +check 'logs unsupported: exit 3' 3 "$rc" +out="$(run frobnicate)"; rc=$? +check 'an unknown verb: exit 2' 2 "$rc" + +# ------------------------------------------------- the Feature Taskfile ---- +if command -v task >/dev/null 2>&1; then + reset + mkdir -p "$T/inc" + cp "$root/src/agents/taskfile.yml" "$T/inc/agents.yml" + printf "version: '3'\nincludes:\n agents:\n taskfile: %s/inc/agents.yml\n" "$T" > "$T/features.yml" + printf "version: '3'\ndotenv: ['.env', '.devcontainer/.env']\nincludes:\n features:\n taskfile: %s/features.yml\n flatten: true\n" "$T" > "$repo/Taskfile.yml" + out="$(cd "$repo" && AGENTS_TTY=1 PATH="$bin:$PATH" task agents -- reviewer --resume 2>&1)" + check 'task agents -- --resume: the start verb with the flags' "resume|reviewer|$repo/rooms/review|--|--model|sonnet;" "$(calls)" + out="$(cd "$repo" && PATH="$bin:$PATH" task agents:check AGENTS_FILE=ops/agents.yml 2>&1)" + contains 'task agents:check AGENTS_FILE=...: the task variable reaches the command' "$out" 'from the task variable AGENTS_FILE' + printf 'AGENTS_FILE=dc/agents.yml\n' > "$repo/.env" + out="$(cd "$repo" && AGENTS_FILE=env/agents.yml PATH="$bin:$PATH" task agents:check 2>&1)" + contains 'task agents:check: .env beats the environment through Task' "$out" 'from AGENTS_FILE in .env' + rm -f "$repo/.env" + out="$(cd "$repo" && PATH="$bin:$PATH" task agents:list -- --json 2>&1)" + contains 'task agents:list -- --json: CLI_ARGS reach the command' "$out" '"name":"host"' +else + echo "skip - the Feature Taskfile (no task on PATH)" +fi + +echo "$pass passed, $fail failed" +[ "$fail" -eq 0 ] diff --git a/test/agents/with_claude_code.sh b/test/agents/with_claude_code.sh new file mode 100644 index 0000000..71ff0fb --- /dev/null +++ b/test/agents/with_claude_code.sh @@ -0,0 +1,15 @@ +#!/bin/bash +# Scenario 'with_claude_code' (see scenarios.json): the claude-code Feature's adapter is found on +# PATH, so a cli: claude entry has an adapter (no claude binary: install none). +set -e +# shellcheck disable=SC1091 # provided by the devcontainer CLI at test time +source dev-container-features-test-lib + +check "agent-adapter-claude on PATH" command -v agent-adapter-claude +# shellcheck disable=SC2016 # expansion is meant to happen inside the inner bash +check "a claude agent is listed (no claude binary: state unknown)" bash -c ' + t=$(mktemp -d); mkdir -p "$t/.devcontainer" + printf "agents:\n - name: host\n cli: claude\n kind: on-demand\n args: [--remote-control]\n" > "$t/.devcontainer/agents.yml" + cd "$t" && agents list | grep -E "^host +on-demand +claude +unknown"' + +reportResults diff --git a/test/agents/with_task.sh b/test/agents/with_task.sh new file mode 100644 index 0000000..8e833ba --- /dev/null +++ b/test/agents/with_task.sh @@ -0,0 +1,22 @@ +#!/bin/bash +# Scenario 'with_task' (see scenarios.json): the agents:* tasks through the go-task registry, in a +# consuming repository whose root Taskfile includes it flattened; `task agents` is the default task. +set -e +# shellcheck disable=SC1091 # provided by the devcontainer CLI at test time +source dev-container-features-test-lib + +# shellcheck disable=SC2016 # expansion is meant to happen inside the inner bash +setup='t=$(mktemp -d); mkdir -p "$t/repo/.devcontainer" "$t/repo/ops" "$t/bin" +printf "agents:\n - name: pair\n cli: fake\n kind: on-demand\n" > "$t/repo/.devcontainer/agents.yml" +printf "agents:\n - name: ops-agent\n cli: fake\n kind: on-demand\n" > "$t/repo/ops/agents.yml" +printf "#!/bin/sh\necho \"\$*\" >> %s/calls\nexit 0\n" "$t" > "$t/bin/agent-adapter-fake"; chmod +x "$t/bin/agent-adapter-fake" +printf "version: '"'"'3'"'"'\nincludes:\n features:\n taskfile: /usr/local/share/go-task/features.yml\n flatten: true\n" > "$t/repo/Taskfile.yml" +export PATH="$t/bin:$PATH"; cd "$t/repo"' +check "the registry includes agents" grep -q '^ agents:' /usr/local/share/go-task/features.yml +check "task agents:check" bash -c "$setup; task agents:check | grep -F 'pair: cli fake, on-demand'" +check "task agents:check AGENTS_FILE=... (a task variable)" bash -c "$setup; task agents:check AGENTS_FILE=ops/agents.yml | grep -F 'from the task variable AGENTS_FILE'" +check "task agents -- runs the start verb (refused without a terminal)" bash -c "$setup; ! task agents -- pair "$REC_OUT/%s"\necho "file=${AGENTS_FILE_TASK_VAR:-}" >> "$REC_OUT/%s"\n' "$c" "$c" "$c" > "$T/bin/$c" + chmod +x "$T/bin/$c" +done +cat > "$T/repo/Taskfile.yml" <&1); } +fromhost() { (cd "$T/repo" && DEVCONTAINER_IN_CONTAINER=0 PATH="$T/bin:$PATH" task "$@" 2>&1); } + +out="$(inside devcontainer:agent -- host --resume --effort high)"; rc=$? +check 'inside: exits 0' 0 "$rc" +check 'inside: agents start with the name, the flags and the CLI args' 'agents start host --resume --effort high' "$(head -n 1 "$T/agents")" +check 'inside: no AGENTS_FILE, none passed' 'file=' "$(tail -n 1 "$T/agents")" +out="$(inside devcontainer:agent AGENTS_FILE='ops/my agents.yml' -- host)" +check 'inside: AGENTS_FILE reaches the command as AGENTS_FILE_TASK_VAR (spaces kept)' 'file=ops/my agents.yml' "$(tail -n 1 "$T/agents")" +out="$(inside devcontainer:agent:list -- --json)" +check 'inside: agent:list runs agents list with its arguments' 'agents list --json' "$(head -n 1 "$T/agents")" + +out="$(fromhost devcontainer:agent --dry -- host --new)" +contains 'host: goes through the exec task' "$out" 'devcontainer:exec CMD=' +contains 'host: the command is agents start with the arguments' "$out" 'agents start host --new' +out="$(fromhost devcontainer:agent:list --dry)" +contains 'host: agent:list through the exec task' "$out" 'agents list' + +out="$(inside devcontainer:claude)"; rc=$? +check 'devcontainer:claude still runs claude-host' 0 "$rc" +contains 'devcontainer:claude still runs claude-host (called)' "$(cat "$T/claude-host" 2>/dev/null)" 'claude-host' +contains 'devcontainer:claude prints the deprecation line' "$out" 'devcontainer:claude is deprecated: use task devcontainer:agent -- ' +out="$(inside devcontainer:claude:fleet -- --plan)"; rc=$? +check 'devcontainer:claude:fleet still runs claude-fleet' 'claude-fleet --plan' "$(head -n 1 "$T/claude-fleet" 2>/dev/null)" +contains 'devcontainer:claude:fleet prints the deprecation line' "$out" 'devcontainer:claude:fleet is deprecated' + +echo "$pass passed, $fail failed" +[ "$fail" -eq 0 ] diff --git a/test/claude-code/install_none.sh b/test/claude-code/install_none.sh index e76ba78..ec6fc61 100644 --- a/test/claude-code/install_none.sh +++ b/test/claude-code/install_none.sh @@ -7,6 +7,7 @@ source dev-container-features-test-lib check "no claude binary" bash -c "! command -v claude" check "claude-host on PATH" command -v claude-host +check "agent-adapter-claude on PATH" command -v agent-adapter-claude check "claude-code-setup on PATH" command -v claude-code-setup check "dcf-persist on PATH" command -v dcf-persist check "CLAUDE_CONFIG_DIR points at the Feature's state dir" bash -c "[ \"\$CLAUDE_CONFIG_DIR\" = /var/lib/devcontainer-features/claude-code ]" diff --git a/test/claude-code/test.sh b/test/claude-code/test.sh index 4d8b0cd..c3b1b7c 100755 --- a/test/claude-code/test.sh +++ b/test/claude-code/test.sh @@ -46,6 +46,18 @@ check "claude-host print flag: a non-UUID is still exit 2" bash -c 'HOST_SESSION # --plan names what it would start and starts nothing (the logic is covered Docker-free in # test/claude-code/unit/live_session_test.sh; this checks the installed command works here). check "claude-fleet on PATH" command -v claude-fleet +check "agent-adapter-claude on PATH" command -v agent-adapter-claude +check "agent-adapter-claude is root-owned" bash -c "[ \"\$(stat -c %U \"\$(command -v agent-adapter-claude)\")\" = root ]" +check "agent-adapter-claude supports wake" bash -c "agent-adapter-claude supports | grep -qx wake" +# shellcheck disable=SC2016 +check "claude-host prints the deprecation line" bash -c 'HOST_SESSION= claude-host --version 2>&1 >/dev/null | grep -q "claude-host: deprecated"' +# shellcheck disable=SC2016 +check "agent-adapter-claude list reads claude agents --json --all" bash -c ' + t=$(mktemp -d); mkdir -p "$t/bin" + echo "[{\"id\":\"b1\",\"cwd\":\"/tmp\",\"kind\":\"background\",\"startedAt\":1,\"sessionId\":\"s1\",\"name\":\"host\",\"pid\":5}]" > "$t/agents.json" + printf "#!/bin/sh\n[ \"\$1\" = agents ] && exec cat %s/agents.json\nexit 1\n" "$t" > "$t/bin/claude"; chmod +x "$t/bin/claude" + [ "$(PATH="$t/bin:$PATH" agent-adapter-claude list)" = "$(printf "host\ts1\trunning\t/tmp")" ]' + check "claude-host-pin on PATH" command -v claude-host-pin # shellcheck disable=SC2016 check "claude-host-pin never fails on bad input" bash -c 'echo garbage | claude-host-pin 2>/dev/null' diff --git a/test/claude-code/unit/adapter_test.sh b/test/claude-code/unit/adapter_test.sh new file mode 100644 index 0000000..0e2fc37 --- /dev/null +++ b/test/claude-code/unit/adapter_test.sh @@ -0,0 +1,176 @@ +#!/usr/bin/env bash +# Docker-free tests of the claude-code Feature's agent-adapter-claude (the agents Feature's +# adapter contract for Claude Code): `list` from `claude agents --json --all`, newest first, +# with the state (a pid is running; failed; everything else stopped) and the resolved folder; +# a session is the agent's by name AND folder (another folder, or a name that only starts +# with the agent's, never counts; "host (2)" does); every action takes the session's id, +# never the name; a stopped session is woken with --bg --resume , never -c; +# resume refuses a running session; stop keeps the newest unless --extras, never rm; +# start-bg and new tag the session with --name. A fake `claude` keeps sessions in a table, +# renders them as `claude agents --json` does and records every other call with its folder. +# Part of `task lint` (`task test:claude-unit`). ADAPTER_SH points the test at another copy. +set -u + +here="$(cd "$(dirname "$0")" && pwd)" +root="$(cd "$here/../../.." && pwd)" +adapter_sh="${ADAPTER_SH:-$root/src/claude-code/agent-adapter-claude.sh}" + +T="$(mktemp -d)" +trap 'rm -rf "$T"' EXIT +T="$(cd "$T" && pwd -P)" + +pass=0 fail=0 +check() { if [ "$2" = "$3" ]; then pass=$((pass + 1)); echo "ok - $1"; else fail=$((fail + 1)); echo "FAIL - $1 (expected '$2', got '$3')"; fi; } +contains() { case "$2" in *"$3"*) pass=$((pass + 1)); echo "ok - $1" ;; *) fail=$((fail + 1)); echo "FAIL - $1 (no '$3' in '$2')" ;; esac; } +lacks() { case "$2" in *"$3"*) fail=$((fail + 1)); echo "FAIL - $1 ('$3' found in '$2')" ;; *) pass=$((pass + 1)); echo "ok - $1" ;; esac; } + +bin="$T/bin"; mkdir -p "$bin" +cp "$adapter_sh" "$bin/agent-adapter-claude"; chmod +x "$bin/agent-adapter-claude" +# The fake claude. $FAKE_TABLE: short id, sessionId, name, cwd, kind, pid (0 = none), state, +# startedAt, tab-separated. `agents --json [--all]` renders it (pid and state only when set, +# as Claude Code does; FAKE_NO_ALL=1 refuses --all). `--bg --name N` adds a running background +# session in the current folder; `--bg --resume ` gives that session a pid again; +# `stop ` clears it. Every call but `agents` is recorded in $FAKE_CALLS as "folder|arg|arg". +cat > "$bin/claude" <<'FAKE' +#!/usr/bin/env bash +if [ "$1" = agents ]; then + [ -n "${FAKE_NO_ALL:-}" ] && [ "${3:-}" = --all ] && { echo "error: unknown option '--all'" >&2; exit 1; } + [ -n "${FAKE_AGENTS_FAIL:-}" ] && exit 1 + printf '['; first=1 + while IFS=$'\t' read -r id sid name cwd kind pid state started; do + [ -n "$sid" ] || continue + [ -z "$first" ] && printf ',\n'; first='' + printf '{"id":"%s","cwd":"%s","kind":"%s","startedAt":%s,"sessionId":"%s","name":"%s"' "$id" "$cwd" "$kind" "$started" "$sid" "$name" + [ "$pid" != 0 ] && printf ',"pid":%s' "$pid" + [ "$state" != - ] && printf ',"state":"%s"' "$state" + printf '}' + done < "$FAKE_TABLE" + printf ']\n'; exit 0 +fi +line="$(pwd -P)"; for a in "$@"; do line="$line|$a"; done +echo "$line" >> "$FAKE_CALLS" +n=$(( $(grep -c . "$FAKE_TABLE") + 1 )) +if [ "$1" = --bg ] && [ "$2" = --name ]; then + printf 'b%07d\tsess-%s-0000-0000-0000-000000000000\t%s\t%s\tbackground\t%s\trunning\t%s\n' "$n" "$n" "$3" "$(pwd -P)" "$((100 + n))" "$((2000 + n))" >> "$FAKE_TABLE" + echo "started b$n" +elif [ "$1" = --bg ] && [ "$2" = --resume ]; then + awk -F'\t' -v OFS='\t' -v s="$3" '$2 == s { $6 = 999; $7 = "running" } { print }' "$FAKE_TABLE" > "$FAKE_TABLE.n" && mv "$FAKE_TABLE.n" "$FAKE_TABLE" +elif [ "$1" = stop ]; then + awk -F'\t' -v OFS='\t' -v s="$2" '$1 == s { $6 = 0; $7 = "done" } { print }' "$FAKE_TABLE" > "$FAKE_TABLE.n" && mv "$FAKE_TABLE.n" "$FAKE_TABLE" +fi +exit 0 +FAKE +chmod +x "$bin/claude" + +repo="$T/repo"; mkdir -p "$repo/rooms/review" +export FAKE_TABLE="$T/table" FAKE_CALLS="$T/calls" AGENT_ADAPTER_WAIT=0 +table() { # rows: id sid name cwd kind pid state started + : > "$FAKE_TABLE"; : > "$FAKE_CALLS" + local r; for r in "$@"; do printf '%s\n' "$r" | tr '|' '\t' >> "$FAKE_TABLE"; done +} +ad() { (cd "$T" && PATH="$bin:$PATH" agent-adapter-claude "$@" 2>&1); } +calls() { tr '\n' ';' < "$FAKE_CALLS"; cat "$FAKE_CALLS" >> "$T/all-calls"; } + +table \ + "a1|sess-a1|host|$repo|background|0|done|1000" \ + "a2|sess-a2|host|$repo/|background|201|-|3000" \ + "a3|sess-a3|host-review|$repo|background|202|-|4000" \ + "a4|sess-a4|host|$repo/rooms/review|background|203|-|5000" \ + "a5|sess-a5|reviewer|$repo/rooms/review|interactive|204|busy|2500" \ + "a6|sess-a6|old|$repo|background|0|failed|500" +out="$(ad list)"; rc=$? +check 'list: exit 0' 0 "$rc" +check 'list: name, session id, state, folder; newest first; a pid is running, failed kept, done is stopped' \ + "host sess-a4 running $repo/rooms/review;host-review sess-a3 running $repo;host sess-a2 running $repo;reviewer sess-a5 running $repo/rooms/review;host sess-a1 stopped $repo;old sess-a6 failed $repo;" \ + "$(printf '%s\n' "$out" | tr '\n' ';')" +out="$(FAKE_NO_ALL=1 ad list)" +contains 'list: without --all (older Claude Code) the plain list' "$out" 'sess-a2' +out="$(FAKE_AGENTS_FAIL=1 ad list)"; rc=$? +check 'list: an unreadable list is exit 1' 1 "$rc" +out="$(ad supports)" +contains 'supports: names the verbs' "$out" 'wake' + +: > "$FAKE_CALLS" +out="$(ad attach host "$repo")"; rc=$? +check 'attach: by the short id of the session with that name in that folder (not host-review, not another folder)' "$T|attach|a2;" "$(calls)" + +table "a1|sess-a1|host|$repo|background|0|done|1000" +out="$(ad attach host "$repo")" +check 'attach a stopped session: claude attach (restarts it)' "$T|attach|a1;" "$(calls)" + +table "a5|sess-a5|reviewer|$repo|interactive|204|busy|2500" +out="$(ad attach reviewer "$repo")"; rc=$? +check 'attach an interactive session: refused (exit 1)' 1 "$rc" +contains 'attach an interactive session: says a terminal holds it' "$out" 'held by a terminal' +check 'attach an interactive session: nothing called' "" "$(calls)" + +table "a1|sess-a1|host|$repo|background|201|-|1000" "a7|sess-a7|host (2)|$repo|background|207|-|7000" +out="$(ad attach host "$repo")" +check 'two sessions by one name ("host (2)"): the newest' "$T|attach|a7;" "$(calls)" +contains 'two sessions by one name: the others are reported' "$out" '2 running sessions; using the newest (sess-a7)' + +table +out="$(cd "$T" && PATH="$bin:$PATH" agent-adapter-claude start-bg host "$repo" -- --remote-control --model opus 2>/dev/null)"; rc=$? +check 'start-bg: exit 0' 0 "$rc" +check 'start-bg: claude --bg --name in the folder' "$repo|--bg|--name|host|--remote-control|--model|opus;" "$(calls)" +check 'start-bg: prints the session id on stdout (claude'"'"'s own output goes to stderr)' 'sess-1-0000-0000-0000-000000000000' "$out" + +table "a1|sess-a1|host|$repo|background|0|done|1000" "a8|sess-a8|host|$repo|background|0|failed|1500" "a9|sess-a9|host|$repo/rooms/review|background|0|done|9000" +out="$(ad wake host "$repo" -- --remote-control)"; rc=$? +check 'wake: the newest stopped or failed session of the agent, by its full id, in the background' "$repo|--bg|--resume|sess-a8;" "$(calls)" +lacks 'wake: never -c' "$(calls)" '|-c' +table +out="$(ad wake host "$repo")"; rc=$? +check 'wake with nothing to wake: exit 1' 1 "$rc" + +table "a1|sess-a1|reviewer|$repo/rooms/review|interactive|0|done|1000" +out="$(ad resume reviewer "$repo/rooms/review" -- --model sonnet)"; rc=$? +check 'resume: claude --resume in the folder' "$repo/rooms/review|--resume|sess-a1|--model|sonnet;" "$(calls)" +table "a1|sess-a1|reviewer|$repo/rooms/review|interactive|301|busy|1000" +out="$(ad resume reviewer "$repo/rooms/review")"; rc=$? +check 'resume a running session: refused (exit 1, a second copy)' 1 "$rc" +check 'resume a running session: nothing called' "" "$(calls)" +table +out="$(ad resume reviewer "$repo/rooms/review")"; rc=$? +check 'resume with nothing to resume: exit 1' 1 "$rc" +contains 'resume with nothing to resume: says how to start one' "$out" 'task agents -- reviewer' + +table +out="$(ad new reviewer "$repo/rooms/review" -- --model sonnet)" +check 'new: claude --name in the folder' "$repo/rooms/review|--name|reviewer|--model|sonnet;" "$(calls)" + +table "a1|sess-a1|host|$repo|background|201|-|1000" "a7|sess-a7|host (2)|$repo|background|207|-|7000" +out="$(ad stop host "$repo")" +check 'stop: the newest by its id' "$T|stop|a7;" "$(calls)" +table "a1|sess-a1|host|$repo|background|201|-|1000" "a7|sess-a7|host (2)|$repo|background|207|-|7000" +out="$(ad stop host "$repo" --extras)" +check 'stop --extras: the others, never the newest' "$T|stop|a1;" "$(calls)" +table "a1|sess-a1|host|$repo|background|0|done|1000" +out="$(ad stop host "$repo")"; rc=$? +check 'stop when not running: exit 0, nothing called' "0:" "$rc:$(calls)" + +table "a1|sess-a1|host|$repo|background|0|done|1000" "a2|sess-a2|host|$repo|background|201|-|500" +out="$(ad logs host "$repo")" +check 'logs: the running session first, by its id' "$T|logs|a2;" "$(calls)" + +out="$(ad frob host "$repo")"; rc=$? +check 'an unknown verb: exit 3 (not supported)' 3 "$rc" +out="$(ad attach)"; rc=$? +check 'a verb without name and folder: exit 2' 2 "$rc" +out="$(ad attach host "$T/nope")"; rc=$? +check 'a missing folder: exit 1' 1 "$rc" + +# Across every call above: never rm, never -c or --continue; in the code, never an action by name. +all="$(cat "$T/all-calls" 2>/dev/null)" +lacks 'never claude rm' "$all" '|rm|' +lacks 'never -c' "$all" '|-c' +lacks 'never --continue' "$all" '|--continue' +# shellcheck disable=SC2016 # a literal $NAME is what is looked for +if grep -v '^[[:space:]]*#' "$adapter_sh" | grep -E -- 'claude (attach|stop|logs) "\$NAME"|claude [^|]*[[:space:]]-c( |$)|--continue' >/dev/null; then + fail=$((fail + 1)); echo "FAIL - the adapter never acts by name and never uses -c/--continue" +else + pass=$((pass + 1)); echo "ok - the adapter never acts by name and never uses -c/--continue" +fi + +echo "$pass passed, $fail failed" +[ "$fail" -eq 0 ] diff --git a/test/claude-code/unit/live_session_test.sh b/test/claude-code/unit/live_session_test.sh index 2986487..2e62e6e 100644 --- a/test/claude-code/unit/live_session_test.sh +++ b/test/claude-code/unit/live_session_test.sh @@ -553,5 +553,17 @@ lacks 'never rm: the fake saw no rm' "$log" 'RM' check 'claude-fleet never invokes rm on a session (guard on the code)' 0 "$(grep -v '^[[:space:]]*#' "$fleet_sh" | grep -cE 'claude[[:space:]]+rm|\brm[[:space:]]+"?\$')" rm -f "$T"/agents.json.* "$T/agents.dup" "$T/count" +echo "# deprecated (claude-code 1.7.0): one line, the scripts still work" +echo '[]' > "$T/agents.json" +err="$(cd "$T" && PATH="$bin:$PATH" CLAUDE_CONFIG_DIR="$T/cfg" FAKE_AGENTS="$T/agents.json" FAKE_LOG="$T/log" claude-host 2>&1 >/dev/null)" +contains 'claude-host prints the deprecation line' "$err" 'claude-host: deprecated: use task agents -- ' +contains 'the deprecation line names the claude-code 2.0 removal' "$err" 'claude-code 2.0 removes claude-host' +err="$(cd "$T" && printf '{}' | PATH="$bin:$PATH" claude-host-pin 2>&1 >/dev/null)"; rc=$? +contains 'claude-host-pin prints the deprecation line' "$err" 'claude-host-pin: deprecated' +check 'claude-host-pin still exits 0' 0 "$rc" +err="$(cd "$T" && PATH="$bin:$PATH" CLAUDE_CONFIG_DIR="$T/cfg" FAKE_AGENTS="$T/agents.json" FAKE_LOG="$T/log" \ + SESSIONS='a|HOST_SESSION_A|.|' HOST_SESSION_A=11111111-1111-1111-1111-111111111111 claude-fleet --no-wait --no-view 2>&1 >/dev/null)" +check 'claude-fleet: one deprecation line, none from the claude-host it calls' 1 "$(printf '%s\n' "$err" | grep -c 'deprecated')" + echo "$pass passed, $fail failed" [ "$fail" -eq 0 ]