diff --git a/CHANGELOG.md b/CHANGELOG.md index e673fd7..f034222 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,8 +5,44 @@ compatibility surface is defined in [docs/COMPATIBILITY.md](docs/COMPATIBILITY.m ## Unreleased +## [1.3.0] - 2026-07-28 + ### Added +- Project-qualified human addressing now uses the profile metadata seats + already register: `PROJECT.SEAT` deliberately crosses projects, while a bare + canonical name, display name, handle, or role resolves only among profiles + sharing the sender's project aliases. There is no global fallback. + `identities`, `profiles`, and `status` accept `--project`; mailbox inspection + accepts qualified addresses or a project filter; directory output includes + predictable qualified aliases. Canonical mailbox keys and project aliases + reserve dot as the single qualification boundary. +- `agentpost wipe agent [NAME]`, `wipe project PROJECT`, and `wipe all` provide + clean AgentPost identity resets without touching source or bridge + repositories. Self-wipe is direct; every broader scope first returns the + exact sorted affected mailbox list and requires the same list through + `--confirm`. Wipe removes complete target mailboxes, bindings, workspace + references, adapter state, and group membership, refuses other active + consumers, and states that successful deletion is irreversible. +- `agentpost attach MAILBOX` gives an already-running Codex thread an explicit, + expiring session identity without restarting, changing the workspace + default, mutating its parent environment, or touching the global plugin. + Compatible stable hooks and AgentPost CLI subprocesses select the attachment + at subsequent lifecycle boundaries. Output distinguishes `boundary-only` + catch-up from an existing managed `live-bridge`; attachment alone publishes + no presence and cannot wake an already-idle thread. Owner-only atomic state, + exact thread/hook evidence, workspace-seat reachability, ABI validation, + consumer-lease checks, expiry, and explicit-identity precedence all fail + closed. +- Codex doctor reports an exact `codex-session-attachment` check separately + from aggregate mailbox hook-generation recovery. A working attached thread + is now visible as boundary-compatible even when historical session-start, + prompt, or stop observations still correctly require reload for full + generation parity. +- Codex install negotiation now preserves a compatible newer stable-dispatch + plugin generation when an older runtime runs `join`, preventing an in-thread + replacement or downgrade. Ambiguous and incompatible changes retain the + terminal-only, all-sessions-closed replacement contract. - `agentpost upgrade` refreshes every bound adapter in one command and reports each binding as `current`, `upgraded`, `skipped`, or `failed`, naming which CLIs need a restart. Upgrading the Python package and refreshing plugin @@ -26,6 +62,31 @@ compatibility surface is defined in [docs/COMPATIBILITY.md](docs/COMPATIBILITY.m ### Fixed +- Codex doctor and `agentpost upgrade --dry-run` now compare the installed + plugin generation with the running package's expected generation. A fully + observed older cache no longer reports `CURRENT` merely because its own + historical hooks agree with it; the preview names the required + all-sessions-closed replacement before the real upgrade is attempted. +- Safe wipe now opens the attachment directory without following symlinks, + requires owner-private directory and file state, and unlinks relative to a + held directory descriptor. A malicious `runtime/codex-sessions` link can no + longer make agent or all-scope cleanup remove JSON outside AgentPost. +- Wipe liveness now fences the authoritative mailbox consumer locks through + detachment instead of trusting heartbeat-derived presence. This closes both + lease-before-heartbeat deletion and consumer-start races; even self-wipe + refuses an unmatched live lease. +- Mailbox registration and the complete wipe transaction now share a root-level + namespace lock: target discovery, confirmation validation, consumer fencing, + and commit all observe one namespace state. Wipe also revalidates target + absence before commit. A same-name replacement cannot acquire a new consumer + inode while the original is staged. If an unsupported direct filesystem + collision prevents rollback, AgentPost preserves the original recovery stage + and reports its exact path instead of deleting it. +- Ordinary Codex attachment lookup now revalidates the directory, hook event + and stable ABI, finite coherent 30-day lifetime, initialized mailbox, and + recorded-project reachability before selecting the attached seat. +- Source distributions now include the public v1.3 specifications linked by + the README. - `agentpost reply MESSAGE_ID` with an inferred sender now answers from the seat that actually holds the letter, instead of always acting as the single workspace default. A runtime notified as an alternate seat sharing the @@ -149,6 +210,7 @@ First stable release. - Published bootstrap commands and the default installer source are pinned to the versioned `v1.0.0` release tag. +[1.3.0]: https://github.com/5000Stadia/agentpost/compare/v1.2.0...v1.3.0 [1.2.0]: https://github.com/5000Stadia/agentpost/compare/v1.1.0...v1.2.0 [1.1.0]: https://github.com/5000Stadia/agentpost/compare/v1.0.0...v1.1.0 [1.0.0]: https://github.com/5000Stadia/agentpost/releases/tag/v1.0.0 diff --git a/IMPLEMENTATION_STATUS.md b/IMPLEMENTATION_STATUS.md index e77d3ef..69cd37b 100644 --- a/IMPLEMENTATION_STATUS.md +++ b/IMPLEMENTATION_STATUS.md @@ -1,18 +1,18 @@ # AgentPost Implementation Status -Last updated: 2026-07-12 +Last updated: 2026-07-28 ## Current phase The prior-art gate selected an independent literal-filesystem semantic core. The measured agmsg comparison and live Claude/Codex evaluation are in -`PRIOR_ART_EVALUATION.md`. AgentPost 1.2.0 is the current stable release, with its +`PRIOR_ART_EVALUATION.md`. AgentPost 1.3.0 is the current stable release, with its compatibility, security, and release contracts recorded in the repository. -The local four-agent deployment now uses AgentPost as its sole actionable -development-agent channel. Claude projects K/PB/C have the project-scoped -`agentpost@agentpost-local` plugin generation 0.0.7; Cx uses Codex plugin -generation `0.0.5+codex.20260712082137`, and the Python package is at 1.2.0. +The local four-agent deployment uses AgentPost as its sole actionable +development-agent channel. The 1.3.0 release packages Claude plugin generation +0.0.7, Codex plugin generation `0.0.6+codex.20260728214546`, and Python package +version 1.3.0. Plugin generations identify independently deployed adapter artifacts and do not replace the Python package's semantic version. Codex hook commands are stable across upgrades; a process that predates the @@ -63,6 +63,10 @@ or the original data directories. executed-generation markers that never imply presence, `3/3` hook-trust verification, stale/unobserved/ambiguous generation diagnostics, and explicit approval, reinstall, and reload recovery. +- No-restart Codex session attachment with explicit identity precedence, + owner-only expiring thread maps, stable-dispatch ABI negotiation, alternate + workspace-seat validation, boundary-only capability reporting, and + compatible-newer plugin preservation without downgrade. - Claude/Codex install, deterministic doctor, and mailbox-preserving uninstall commands. - Transactional adapter binding: failed setup records no false workspace @@ -93,8 +97,13 @@ or the original data directories. delivery, ordered callback retry with visible exhaustion, async consumption, unread reconciliation, and non-claiming restart catch-up. - Human-facing channel addressing with an inspectable identity directory, - ambiguity-safe name/display/project/responsibility resolution, inferred - senders, natural named groups, and live-versus-queued delivery receipts. + project-filtered complete seat rosters, ambiguity-safe + name/display/project/responsibility resolution, inferred senders, natural + named groups, explicit `PROJECT.SEAT` cross-project references with no + global bare fallback, and live-versus-queued delivery receipts. +- Guarded clean-start deletion for one mailbox, a complete registered project, + or all mailboxes. Broader scopes require an exact user-confirmed affected-box + list and remove only AgentPost-owned mailbox/routing state. - Sender-bound `AgentChannel` Python API with the same directory, resolve/message/question vocabulary as the CLI and direct availability as `AgentRuntime.channel`. @@ -120,7 +129,7 @@ PYTHONPATH=src python3 -m unittest discover -s tests -v python3 -m compileall -q src tests ``` -The current suite contains 167 passing tests. Twenty consecutive pre-Antigravity +The current suite contains 235 passing tests. Twenty consecutive pre-Antigravity full-suite runs passed after the concurrency and UTF-8 fixes. A clean Python virtual environment editable install and executable smoke test also passed. diff --git a/MANIFEST.in b/MANIFEST.in index 94f3a33..ce399fb 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -5,3 +5,4 @@ include SECURITY.md recursive-include docs *.md recursive-include integrations *.json *.md recursive-include scripts *.py *.sh +recursive-include specs *.md diff --git a/README.md b/README.md index 6cf582d..d2294b7 100644 --- a/README.md +++ b/README.md @@ -13,15 +13,16 @@ weaken durable delivery. Installed CLI agents treat it as a named communication channel. A human can say "send it to PB" or "ask the registered reviewers group"; the integration -resolves the registered identity or group, resolves the active sender, sends the message, -and reports its durable Message-ID and live/queued state. An identity may own a -project, represent a cross-project role such as code review or marketing, serve -as a specialist, or combine those shapes. +resolves the registered identity or group within the active sender's project, +sends the message, and reports its durable Message-ID and live/queued state. +Cross-project seats use an explicit `PROJECT.SEAT` address such as +`pbe.codereview`. An identity may own a project, represent a role such as code +review or marketing, serve as a specialist, or combine those shapes. ## Quick Start ```sh -curl -fsSL https://raw.githubusercontent.com/5000Stadia/agentpost/v1.2.0/scripts/install.sh | sh +curl -fsSL https://raw.githubusercontent.com/5000Stadia/agentpost/v1.3.0/scripts/install.sh | sh ``` ### Get two agents talking To the first agent: @@ -29,16 +30,20 @@ To the first agent: Add yourself to AgentPost as Agent One. ``` -To the second agent: +To the second agent: ```text Add yourself to AgentPost as Agent Two. ``` -Follow any restart prompt, then tell one agent to talk to the other: +Follow any restart prompt. Ask Agent Two to report the qualified address printed +by `agentpost identities --project PROJECT`, then tell Agent One to use it: + ```text -Ask Agent Two to produce a couplet for a poem, append a couplet after it's return. Repeat until you have 4 couplets. +Ask PROJECT.SEAT to produce a couplet for a poem, append a couplet after its return. Repeat until you have 4 couplets. ``` +If both seats share the same registered project, bare `Agent Two` also resolves. + Other Examples: - Deliberate with Spec Reviewer until Green. Implement, then after implementation review with Code Reviewer. @@ -62,7 +67,8 @@ Other Examples: - One mailbox-wide consumer lease prevents two live CLI or Python adapters from surfacing the same inbound work; compatible runtimes wait and take over. - Normal discovery shows only live `idle` or `working` agents. Offline boxes and - all of their mail remain available through exact addressing and `--all`. + all of their mail remain available through project-qualified addressing and + `--all`. ### Assigning agents to a group (Optional) @@ -100,7 +106,7 @@ AgentPost needs Python 3.11+. The Codex real-time adapter also needs Node.js 22+ ### Installer behavior The one-line installer is idempotent. The published script and its default -source are pinned to `v1.2.0`; `AGENTPOST_SOURCE` is the explicit development +source are pinned to `v1.3.0`; `AGENTPOST_SOURCE` is the explicit development or mirror override. It upgrades the dedicated environment under `~/.local/share/agentpost`, preserves `~/.agentpost`, links the command into `~/.local/bin`, and migrates unambiguous v1 identity metadata. @@ -146,21 +152,24 @@ should state durable ownership, while roles, projects, specialties, handles, and exclusions supply the terms other agents will search. Run `agentpost profile-register --help` for the authoring checklist. -A role-only identity omits project ownership and can operate across workspaces: +A review identity can be addressable in more than one project without claiming +ownership of either runtime workspace: ```sh agentpost profile-register reviewer \ --display-name 'Code Review' --kind role \ --summary 'Provides cross-project code review focused on correctness and regression risk.' \ - --roles 'code review' --specialties 'correctness,regression analysis' \ - --handles 'pull request reviews,implementation risk reviews' + --roles 'code review' --projects 'application,docs' \ + --specialties 'correctness,regression analysis' \ + --handles 'codereview,pull request reviews,implementation risk reviews' agentpost join reviewer --cli codex --project "$PWD" agentpost codex --agent reviewer ``` -The workspace above is a runtime connection, not project ownership on the -reviewer's directory profile. +The workspace above is a runtime connection, not project ownership. The +`projects` entries are the namespaces in which coworkers may address this seat +as `application.codereview` or `docs.codereview`. Mailbox access and live connection are different states. An agent asked to set up or reconnect must run `join`, then verify the requested adapter with @@ -173,11 +182,19 @@ agentpost armed reviewer ``` Only `ARMED` establishes live receipt. `QUEUED` means delivery remains durable -but the current notifier is not live. A process opened under the workspace -default cannot silently adopt an alternate role: close or leave that process, -launch `agentpost codex --agent reviewer` externally, and verify again. Adapters -that support lifecycle catch-up but not idle wake remain honestly `QUEUED` -between lifecycle boundaries. +but the current notifier is not live. An ordinary active Codex thread can +explicitly select an alternate known workspace seat without restart: + +```sh +agentpost attach reviewer +``` + +This session-scoped operation never installs or replaces the global plugin. It +provides exact-ID delivery at the next Codex prompt/stop boundary and remains +honestly `QUEUED`; true already-idle wake still requires an external managed +resume with `agentpost codex --agent reviewer resume THREAD_ID`. Other adapters +that support lifecycle catch-up but not idle wake also remain `QUEUED` between +lifecycle boundaries. If a named launcher finds that another process already consumes the mailbox, it reports the owner and suggests the first unused numbered identity, such as @@ -230,6 +247,23 @@ automatic enumeration. A same-generation install leaves the live cache intact. `agentpost codex --agent engineer resume --last` passes resume arguments through while retaining the native mailbox bridge. +If an ordinary active Codex thread started as the wrong default seat but +already has a compatible AgentPost hook loaded, attach it without reinstalling +or restarting: + +```sh +agentpost attach reviewer +``` + +The command verifies the current `CODEX_THREAD_ID`, reachable workspace seat, +hook event and ABI, finite coherent expiry, owner-private attachment directory, +and mailbox consumer ownership. Invalid pre-existing attachment state fails +closed during ordinary identity lookup. It reports `boundary-only` unless the +thread already has a managed bridge. The session selection outranks workspace +defaults for hooks and AgentPost CLI subprocesses, but explicit `--from`, +`--agent`, and `AGENTPOST_AGENT` still outrank it. See +[Codex session attach](specs/CODEX-SESSION-ATTACH-V1.md). + If an existing unread letter needs another native notification, its original sender can re-fire attention without resending content: @@ -263,7 +297,9 @@ no model calls. ```sh # Inspect or resolve the address book, including durable offline identities. agentpost identities # attention means notifier state -agentpost resolve 'Pattern Buffer' +agentpost identities --project pattern-buffer +agentpost resolve pb --project pattern-buffer +agentpost resolve pattern-buffer.pb # Find the right coworker instead of guessing a name. agentpost agents-find 'database migration' @@ -271,9 +307,11 @@ agentpost agents-find --role marketing agentpost status agentpost profiles --offline -# The sender is inferred from the current project. Display names, project names, -# responsibility handles, canonical names, and bare group names resolve. +# The sender is inferred from the current project. Bare names resolve only +# inside it; another project's seat must be qualified. agentpost message engineer 'Please review the storage notes.' --notify idle +agentpost message pattern-buffer.pb 'Please review the storage notes.' \ + --notify idle # Urgent questions surface during an active turn. agentpost question writer 'Does this wording change the contract?' \ @@ -297,6 +335,14 @@ agentpost list writer agentpost read writer '' agentpost next writer --message-id '' agentpost reply '' 'Reviewed; one ambiguity remains.' + +# Irreversibly wipe only this inferred mailbox. +agentpost wipe agent + +# Broader wipes first print the exact affected boxes and require that exact +# user-confirmed list on the second invocation. +agentpost wipe project pattern-buffer +agentpost wipe all ``` `message` and `question` are the normal general-purpose channel commands. @@ -309,6 +355,53 @@ The lower-level `send` and `ask` forms remain for scripts that already hold canonical sender and mailbox keys. Passing `-` (or omitting the body) reads a multi-line body from standard input. +### Project-qualified addressing + +Every project seat registers a dot-free canonical mailbox key, at least one +dot-free `projects` name or alias, and preferably a short handle first. The +directory then exposes predictable addresses: + +```text +canonical: pbe-r +projects: pattern-buffer-evolution,pbe +handles: codereview,implementation review +qualified: pattern-buffer-evolution.codereview,pbe.codereview +``` + +Bare `codereview` resolves only among profiles sharing a project entry with the +sender. It never retries against the global directory, even when another +project's matching seat is globally unique. Cross-project communication uses +`pbe.codereview`; `agentpost identities --project pbe` lists every registered +seat in that project, including offline seats. A qualified address contains +exactly one dot, which is why mailbox keys and project aliases cannot contain +dots. See [Project-qualified identities](specs/PROJECT-QUALIFIED-IDENTITIES-V1.md). + +Named groups remain deliberate global fan-out addresses. Prefer explicit +`@group` where a group could resemble a local seat. Low-level `send` and `ask` +retain canonical mailbox-key semantics for scripts. + +### Clean starts + +`agentpost wipe agent` removes the current mailbox, mail, bindings, adapter +state, workspace references, and group membership. It needs no affected-box +confirmation, but still refuses a held consumer lease; close the seat and run +its final self-wipe from a terminal with `AGENTPOST_AGENT=NAME`. Wiping a +different agent, a project, or all agents never proceeds on the first call: +AgentPost prints the sorted affected mailbox list and the exact `--confirm` +value. Show that list to the user and obtain explicit confirmation before +rerunning it. A changed list invalidates the confirmation, and every live +consumer must be stopped. Wipe holds each authoritative lease fence through +mailbox detachment. Profile registration shares a namespace lock with wipe, so +project target discovery, confirmation validation, consumer fencing, and commit +observe one namespace state, and same-name recreation cannot switch to a new +consumer-lock inode mid-transaction. If an unsupported filesystem collision +prevents rollback, the original mailbox stage is preserved and its recovery +path is reported. + +Wipe never touches source or AgentBridge repositories. It is irreversible +inside AgentPost; copies held by unaffected mailboxes remain their history. See +[Safe wipe workflow](specs/SAFE-WIPE-V1.md). + Replies inherit urgency by message kind: answers to questions default to `immediate`; replies to ordinary letters default to `idle`. `--notify` remains an explicit override. A successful reply claims an unread original as part of @@ -336,6 +429,10 @@ idle. startup, before every user-requested turn, and at turn completion. Hook checks are deterministic and token-free; the managed bridge still supplies true already-idle wake and active-turn steering. +- `agentpost attach NAME` gives an already-running ordinary Codex thread a + private, expiring mailbox selection without mutating its environment or + plugin. It affects subsequent lifecycle boundaries and AgentPost CLI + subprocesses, publishes no presence, and does not create an idle-wake bridge. Each Codex hook records the exact plugin generation that executed without claiming mail or advertising presence. `doctor` compares that observation with @@ -366,11 +463,12 @@ filesystem stalls. The `identities` header labels this column `attention`: `offline` means the notifier is not currently armed, not that the durable identity or mailbox is -gone. Exact addressing is unaffected. +gone. Its `qualified` column lists stable cross-project addresses. Offline profiles are hidden by `profiles` and `agents-find` unless `--all` or -`profiles --offline` is requested. Exact addresses and named groups still -deliver to offline mailboxes, so queued specs and review requests are not lost. +`profiles --offline` is requested. Project-qualified addresses and named groups +still deliver to offline mailboxes, so queued specs and review requests are not +lost. ## Security boundary @@ -390,12 +488,14 @@ CLI-specific plugin. It provides a token-free watcher thread, heartbeat-derived presence, working/idle boundaries, and Message-ID callbacks or a queue. Its callback handoff retries in order and expects Message-ID idempotency. Its sender-bound `AgentChannel` exposes the same identity resolution and -`message`/`question` operations directly to Python. Neither calls a model or -claims mail; the host scheduler remains responsible for turn creation and work -admission. A second runtime for the same mailbox waits as standby and takes over -without surfacing duplicate mail. Async hosts can await `runtime.get_async()` -directly. Start with the [Python agent quick start](docs/PYTHON_AGENT_QUICKSTART.md), -then use [Python integration](docs/PYTHON.md) for the complete host contract. +`message`/`question` operations directly to Python, including +`identities(project=...)` and fail-closed `PROJECT.SEAT` routing. Neither calls +a model or claims mail; the host scheduler remains responsible for turn +creation and work admission. A second runtime for the same mailbox waits as +standby and takes over without surfacing duplicate mail. Async hosts can await +`runtime.get_async()` directly. Start with the +[Python agent quick start](docs/PYTHON_AGENT_QUICKSTART.md), then use +[Python integration](docs/PYTHON.md) for the complete host contract. ## Adapter capabilities diff --git a/ROADMAP.md b/ROADMAP.md index d3995f3..660ba20 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,6 +1,6 @@ # AgentPost Roadmap -Last reviewed: 2026-07-12 +Last reviewed: 2026-07-28 AgentPost v1.0.0 freezes the trusted-local filesystem core, Claude Code, Codex, Antigravity lifecycle, and CLI-neutral Python integration contracts. This file @@ -36,6 +36,12 @@ AgentPost 1.1.0 adds fail-closed immutable repository-review envelopes and closes the answered-but-unread reply lifecycle defect without changing the trusted-local architecture. +AgentPost 1.3.0 adds no-restart Codex seat attachment, project-qualified +cross-project addressing with fail-closed bare-name scope, project-filtered +directory views, guarded agent/project/all cleanup, and adapter-wide upgrade +orchestration. These remain within the trusted-local 1.x compatibility +boundary. + ## Antigravity CLI integration Google ended consumer-account access to Gemini CLI on June 18, 2026 and directs @@ -92,15 +98,14 @@ but it is not scheduled. must be durable before this can work across process restarts. - **Machine-readable capability report:** expose adapter support and degraded behavior in structured output, especially for catch-up-only integrations. -- **Adapter attach and generation truth:** the Codex slice is done: all three - lifecycle hooks stamp the exact executing generation before injection - suppression, doctor compares it with the enabled cache and Codex's current - hook trust, and stable dispatcher commands preserve first-install approval - across upgrades while naming the reload boundary. Sweep Claude and - Antigravity next, using the same running-versus-installed vocabulary and - distinguishing failed `immediate` interruption from routine queued delivery. - Python already exposes live runtime-instance ownership rather than a plugin - generation. +- **Generation truth beyond Codex:** all three Codex lifecycle hooks stamp the + exact executing generation before injection suppression, doctor compares it + with the enabled cache and current hook trust, and stable dispatcher commands + preserve first-install approval across upgrades while naming the reload + boundary. Sweep Claude and Antigravity next, using the same + running-versus-installed vocabulary and distinguishing failed `immediate` + interruption from routine queued delivery. Python already exposes live + runtime-instance ownership rather than a plugin generation. - **Typed experience evidence:** optionally distinguish repository, document, and AgentPost thread references instead of treating every evidence item as an arbitrary path string. diff --git a/SECURITY.md b/SECURITY.md index 08ecc78..9e11207 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -40,7 +40,7 @@ operation are outside the 1.x security boundary. ## Installation trust Published installation examples fetch `scripts/install.sh` from the versioned -`v1.2.0` tag, and that script installs the same tagged source by default. Review +`v1.3.0` tag, and that script installs the same tagged source by default. Review the script and release tag before piping it to a shell when your environment requires a stronger supply-chain policy. `AGENTPOST_SOURCE` intentionally overrides the source pin for development or controlled mirrors; the caller is diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md index 2fd975c..53e62de 100644 --- a/docs/COMPATIBILITY.md +++ b/docs/COMPATIBILITY.md @@ -8,8 +8,15 @@ documented migration and deprecation path. - The documented CLI commands and option meanings in the README and installation guide. +- `attach` remains a session-local Codex identity operation. It must not mutate + durable workspace defaults or global plugin artifacts, and boundary-only + success must not be reported as live wake or presence. - Public Python names exported through `agentpost.__all__`, including `PostOffice`, `AgentChannel`, and `AgentRuntime`. +- `PROJECT.SEAT` remains the explicit cross-project human address. Sender-bound + bare identity resolution must stay inside shared registered project entries + and must never fall back to another project's globally unique seat. Dot + remains reserved as the single qualification boundary. - Mailbox protocol version 1, profile version 2, binding/workspace metadata, and forward migration of durable unread, read, sent, profile, group, and binding state. @@ -28,7 +35,14 @@ documented migration and deprecation path. Security corrections may reject input that an earlier build accepted when retaining that behavior would violate the documented trusted-local boundary or -durable mailbox integrity. +durable mailbox integrity. Rejecting formerly global cross-project bare +delivery is such a routing-safety correction; canonical mailbox-key behavior +remains available through the documented low-level `send` and `ask` forms. + +`agentpost wipe` is intentionally destructive rather than a compatibility +migration. Broader-than-self scopes must enumerate the current target mailboxes +and require an exact confirmation list before mutation. Wipe never expands to +source or bridge repositories. ## Not stable diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 71cf97b..e202c01 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -17,13 +17,13 @@ scheduled. See [the roadmap](../ROADMAP.md) for remaining live-wake work. The shortest install or upgrade is one line: ```sh -curl -fsSL https://raw.githubusercontent.com/5000Stadia/agentpost/v1.2.0/scripts/install.sh | sh +curl -fsSL https://raw.githubusercontent.com/5000Stadia/agentpost/v1.3.0/scripts/install.sh | sh ``` The script creates a dedicated virtual environment under `~/.local/share/agentpost`, links `agentpost` into `~/.local/bin`, and runs idempotent initialization. The published script and default package source are -pinned to `v1.2.0`; set `AGENTPOST_SOURCE` only when deliberately installing a +pinned to `v1.3.0`; set `AGENTPOST_SOURCE` only when deliberately installing a development checkout or controlled mirror. Installation does not delete or replace `~/.agentpost`, and migration tightens existing AgentPost runtime permissions without changing mailbox contents. @@ -55,7 +55,7 @@ The equivalent manual commands are: ```sh python3 -m venv ~/.local/share/agentpost/venv ~/.local/share/agentpost/venv/bin/pip install \ - git+https://github.com/5000Stadia/agentpost.git@v1.2.0 + git+https://github.com/5000Stadia/agentpost.git@v1.3.0 mkdir -p ~/.local/bin ln -sf ~/.local/share/agentpost/venv/bin/agentpost ~/.local/bin/agentpost agentpost init @@ -77,8 +77,9 @@ For a tested end-to-end example with two identities, two project bindings, and a correlated request/reply, start with [Two-agent quick start](TWO_AGENT_QUICKSTART.md). Mailbox declaration and process connection are separate operations. A mailbox -may describe a project, role, specialist, or hybrid identity; only project and -hybrid identities need to claim project ownership in their profile. +may describe a project, role, specialist, or hybrid identity. Project and +hybrid identities may claim ownership; roles and specialists list the projects +where they are addressable without thereby owning those workspaces. `profile-register` creates or atomically updates the durable mailbox nameplate: ```sh @@ -92,9 +93,11 @@ agentpost profile-register app \ ``` Canonical name, display name, project entries, and responsibility handles are -the address-book labels accepted by `resolve`, `message`, and `question`. -Choose handles that are specific enough to avoid collisions; AgentPost rejects -tied labels instead of guessing. +directory labels. Human channel commands resolve bare labels only among seats +sharing the sender's project; use `PROJECT.SEAT` across projects. Choose a +dot-free project alias and put a simple seat handle such as `nav`, `build`, or +`codereview` first. AgentPost rejects tied labels instead of guessing, and +reserves dot as the one qualifier boundary. ### Nameplate quality @@ -104,7 +107,7 @@ Write a profile for the coworker trying to route work, not as an agent biography | --- | --- | | `summary` | One concise sentence naming durable ownership and the decisions, systems, or outputs the agent handles. | | `roles` | Broad workplace functions such as release engineering or marketing. | -| `projects` | Stable names and aliases that users actually call the projects. | +| `projects` | Stable dot-free names and aliases that users actually call the projects and use in `PROJECT.SEAT`. | | `specialties` | Specific reusable technical or domain expertise. | | `handles` | Two to five concrete request categories that should arrive here. | | `does-not-handle` | Nearby responsibilities that belong to another agent. | @@ -121,10 +124,17 @@ queries: ```sh agentpost identities +agentpost identities --project application agentpost agents-find 'temporal provenance' --all agentpost profile-register --help ``` +For example, a profile registered with projects `application,app` and first +handle `codereview` appears as `application.codereview` and +`app.codereview`. Bare `codereview` is local to a sender sharing one of those +project entries. It never falls back to another project's globally unique +seat. + For the usual first connection, change to the project and run one command. The agent supplies its current CLI, records the adapter binding and CLI-neutral workspace identity, and installs the native integration: @@ -159,6 +169,51 @@ manifest while its three stable dispatcher commands preserve existing trust; Antigravity validates and reinstalls its plugin. Mail and workspace identity remain untouched. Reload any Codex process that predates a newly added hook. +Before adopting project-qualified rollout instructions on a machine that may +already have AgentPost, verify the capability rather than guessing from the +presence of the binary: + +```sh +agentpost identities --help | grep -q -- --project +``` + +If that check fails, upgrade the package before registering or messaging the +new seats. Do not fall back to global bare handles. + +## Removing identities for a clean start + +AgentPost owns a guarded destructive workflow: + +```sh +agentpost wipe agent +agentpost wipe agent other-project.nav +agentpost wipe project other-project +agentpost wipe all +``` + +The first form wipes only the currently resolved mailbox and needs no +affected-box confirmation. It still refuses a held inbound consumer lease, +including that seat's live adapter. Close the adapter, then run the final +command from a terminal with `AGENTPOST_AGENT=NAME`; the deleted identity must +not be used afterwards. + +Every broader form performs a preview first. It prints the exact sorted +mailboxes affected and an exact `--confirm 'BOX1,BOX2'` rerun. The agent must +show that list to the user and receive explicit confirmation before executing +the rerun. A changed mailbox set invalidates stale confirmation. Other live +mailbox consumers must be stopped first. The command acquires the authoritative +consumer leases and holds them through mailbox detachment. Profile registration +is serialized by the same mailbox-namespace fence; target discovery, +confirmation validation, consumer fencing, and commit observe one namespace +state, and wipe revalidates target absence before commit. If a direct filesystem +collision blocks rollback, the original mailbox stage is retained and its +recovery path is reported. + +Wipe removes target profiles, mail, bindings, adapter state, workspace marker +references, and group membership. It never removes a source or bridge +repository. The operation is irreversible inside AgentPost; message copies +held by unaffected mailboxes remain their history. + ## Upgrading every adapter at once Upgrading the Python package and refreshing plugin artifacts are two different @@ -276,9 +331,11 @@ a turn. agentpost install codex --agent app --project /work/application ``` -Installation removes and re-adds the local plugin and structurally merges one -AgentPost handler into `~/.codex/hooks.json`, preserving unrelated hooks. On -first installation, open `/hooks` and trust all three stable AgentPost hooks: +Installation structurally merges one AgentPost handler into +`~/.codex/hooks.json`, preserving unrelated hooks. It removes and re-adds the +local plugin only when a generation replacement is required; a current or +compatible newer stable-dispatch generation is preserved. On first +installation, open `/hooks` and trust all three stable AgentPost hooks: `SessionStart`, `UserPromptSubmit`, and `Stop`. Later generation upgrades keep the same commands and therefore retain that trust. Reload a process that predates the prompt hook. Then: @@ -330,6 +387,42 @@ prompt, and at turn completion without creating a polling conversation or claiming mail. Real-time already-idle wake, immediate active-turn steering, and idle deferral still require `agentpost codex`. +If an already-running ordinary Codex thread resolves to the workspace default +but should act as another known seat at that root, do not run `join` merely to +change the live identity. Attach the current thread: + +```sh +agentpost attach reviewer +``` + +`attach` requires `CODEX_THREAD_ID` and a compatible hook observation for that +exact thread. It changes neither the workspace default nor +`AGENTPOST_AGENT`, and it never installs, replaces, or downgrades the global +plugin. Subsequent AgentPost CLI commands and lifecycle hooks select the +attached mailbox. The result reports `boundary-only`: mail can surface on the +next prompt/stop boundary, but an already-idle thread cannot wake. For full +live wake, leave the thread and resume it through: + +```sh +agentpost codex --agent reviewer resume THREAD_ID +``` + +Attachments are owner-only atomic files keyed by a hash of the thread ID, +expire after 30 days, and do not publish presence. Every lookup revalidates the +owner-private directory and file, known hook event, stable ABI, finite coherent +30-day lifetime, initialized mailbox, and current workspace reachability. +Explicit command or environment identities outrank them. A mailbox lease +conflict, unreachable seat, incompatible hook ABI, or insecure mapping fails +before identity mutation. The complete contract is in +`specs/CODEX-SESSION-ATTACH-V1.md`. + +Inside an attached thread, `doctor` adds `codex-session-attachment` as an +exact-thread check. It may pass while the separate aggregate +`codex-generation` check remains stale for older session-start, prompt, or stop +observations. Read the two lines independently: the former proves this thread's +boundary-only identity path; the latter says a reload is still needed for +complete mailbox hook-generation recovery. + The launcher binds only to `127.0.0.1`, creates a fresh app-server for the TUI, and removes its active marker and child processes on exit. Its diagnostic trace is stored at: diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md index 0ce7e4c..4c846b4 100644 --- a/docs/MIGRATION.md +++ b/docs/MIGRATION.md @@ -7,8 +7,9 @@ even when another agent has migrated. For each agent: -1. Install AgentPost, register its project identity, and run static doctor from - that project. +1. Install AgentPost, verify `identities --project` support, register its + dot-free project aliases and seat handle, inspect the qualified address, and + run static doctor from that project. 2. Send one uniquely identified acceptance request through AgentPost only. 3. Verify that agent receives and claims the exact Message-ID, exercises identity/directory resolution, and returns an outbound AgentPost report that diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 8d07a9c..cab8297 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -135,8 +135,9 @@ profile's own `cli` field into its connected adapters and reports one even when nothing is bound. Directory search and role/project/specialty selectors exclude offline agents by -default. Exact mailbox names and explicit named groups may target offline -agents. Delivery still commits to `unread/`; only notification is unavailable. +default. Project-qualified identities and explicit named groups may target +offline agents. Low-level delivery may use exact canonical mailbox keys. +Delivery still commits to `unread/`; only notification is unavailable. An original sender may append an ephemeral attention request under `agents/RECIPIENT/adapter/notifications/` for an existing unread Message-ID. @@ -148,11 +149,23 @@ to defer the rest. Human-facing channel resolution is a separate operation from active-agent discovery. `resolve` and the `message`/`question` commands match registered -canonical names, display names, project identities, and responsibility handles; -bare named groups also expand. They include offline profiles because addressing -a known coworker is durable delivery, not availability discovery. Tied matches -are rejected instead of guessed. The sender is inferred from explicit process -identity or the deepest current workspace identity source. +canonical names, display names, roles, and responsibility handles within an +exact project scope. Bare identities are restricted to profiles sharing a +registered project entry with the sender and never fall back to the global +directory. `PROJECT.SEAT` selects another project's scope explicitly and +contains exactly one reserved dot. Bare named groups also expand; `@GROUP` is +the explicit global group form. Addressing includes offline profiles because a +known coworker remains a durable delivery target. Tied matches are rejected +instead of guessed. The sender is inferred from explicit process identity or +the deepest current workspace identity source. + +Clean-start deletion detaches complete target mailbox directories through a +same-filesystem staging rename, commits binding, workspace-marker, Codex +session-attachment, and group cleanup, then irreversibly removes the stage. +Failure before final stage removal restores mailbox and metadata snapshots. +Broader-than-self CLI scopes enumerate the current sorted targets and require +that exact list as confirmation; source repositories are outside the protocol +boundary. A reply by Message-ID narrows an inferred sender further. Among the mailboxes reachable from the current directory — the workspace default and its known diff --git a/docs/PYTHON.md b/docs/PYTHON.md index 6872abe..50ea357 100644 --- a/docs/PYTHON.md +++ b/docs/PYTHON.md @@ -87,7 +87,7 @@ responsibility, and group names: ```python result = runtime.channel.message( - "Pattern Buffer", + "pattern-buffer.pb", "Please review the world-state contract.", ) @@ -100,8 +100,11 @@ question = runtime.channel.question( For a send-only process that does not own presence or watch a mailbox, construct `AgentChannel("kernos-runtime")` directly. `identities()` returns the durable address book with current presence, and `resolve(address)` returns the concrete -profiles without guessing tied matches. Both APIs only perform local filesystem -operations and never invoke a model. +profiles without guessing tied matches. `identities(project="pattern-buffer")` +filters the directory, including offline seats. Bare addresses are restricted +to profiles sharing the bound sender's projects; another project uses +`PROJECT.SEAT` and never receives a global bare-name fallback. Both APIs only +perform local filesystem operations and never invoke a model. `runtime.turn()` reference-counts concurrent work, marks the mailbox `working` while at least one scope remains active, and returns it to `idle` in the final diff --git a/docs/PYTHON_AGENT_QUICKSTART.md b/docs/PYTHON_AGENT_QUICKSTART.md index 489afb7..afb618c 100644 --- a/docs/PYTHON_AGENT_QUICKSTART.md +++ b/docs/PYTHON_AGENT_QUICKSTART.md @@ -10,7 +10,7 @@ AgentPost requires Python 3.11 or newer. Install it, register one durable identity, and bind that identity to the project: ```sh -curl -fsSL https://raw.githubusercontent.com/5000Stadia/agentpost/v1.2.0/scripts/install.sh | sh +curl -fsSL https://raw.githubusercontent.com/5000Stadia/agentpost/v1.3.0/scripts/install.sh | sh agentpost profile-register my-agent \ --display-name 'My Agent' --kind project \ @@ -88,6 +88,10 @@ channel.question( ) ``` +This bare `reviewer` form assumes both profiles share a registered project +entry. Use `other-project.reviewer` across projects; AgentPost never falls back +from a missing local bare seat to a globally unique seat elsewhere. + Neither `AgentRuntime` nor `AgentChannel` calls a model or starts a turn. ## 4. Production checklist diff --git a/docs/TWO_AGENT_QUICKSTART.md b/docs/TWO_AGENT_QUICKSTART.md index 7f69609..764f7b7 100644 --- a/docs/TWO_AGENT_QUICKSTART.md +++ b/docs/TWO_AGENT_QUICKSTART.md @@ -1,9 +1,10 @@ # Two-agent quick start This walkthrough proves the complete local exchange before involving a model or -native CLI notification. It creates one project identity and one cross-project -code-review role, connects each to a runtime workspace, sends one request by -display name, claims it, replies, and verifies correlation. +native CLI notification. It creates one project identity and one +project-addressable code-review role, connects each to a runtime workspace, +sends one same-project request by display name, claims it, replies, and verifies +correlation. ## Run the tested example @@ -66,7 +67,7 @@ Then register the durable identity: agentpost profile-register agent-one \ --display-name 'Agent One' --kind project \ --summary 'Owns planning and turns requirements into implementation briefs.' \ - --projects agent-one-project --project-roots "$AGENT_ONE_ROOT" \ + --projects agent-demo --project-roots "$AGENT_ONE_ROOT" \ --specialties 'planning,requirements' \ --handles 'implementation briefs,requirements questions' ``` @@ -86,7 +87,7 @@ AGENT_TWO_CLI=codex # Codex agentpost profile-register agent-two \ --display-name 'Agent Two' --kind role \ --summary 'Provides cross-project implementation review and engineering risk analysis.' \ - --roles 'code review' \ + --roles 'code review' --projects agent-demo \ --specialties 'code review,engineering risk' \ --handles 'implementation reviews,risk analysis' ``` @@ -96,7 +97,9 @@ identity every time the corresponding process opens, and it does not bind the mailbox to one CLI type. Agent Two's workspace is where its CLI runs; the role profile does not claim -ownership of that project. +ownership of that workspace. `agent-demo` is the address namespace both seats +share, so bare `Agent Two` is local and the predictable qualified form is +`agent-demo.agent-two`. ## 4. Connect each agent @@ -302,7 +305,7 @@ identity. ```sh agentpost groups -agentpost identities +agentpost identities --project agent-demo ``` Example group output: @@ -311,8 +314,10 @@ Example group output: engineering agent-one,agent-two,release-reviewer ``` -`identities` includes the same address as `@engineering` alongside individual -agent nameplates. +Unfiltered `identities` includes the same address as `@engineering` alongside +individual nameplates. The project-filtered form returns the complete +`agent-demo` seat roster, including offline seats, and prints each qualified +address. ### Send to the department diff --git a/integrations/antigravity/skills/agentpost/SKILL.md b/integrations/antigravity/skills/agentpost/SKILL.md index 577bad8..1646a7e 100644 --- a/integrations/antigravity/skills/agentpost/SKILL.md +++ b/integrations/antigravity/skills/agentpost/SKILL.md @@ -31,14 +31,18 @@ idle notification; questions default to immediate, but the user's urgency or non-interruption request overrides the default. ```sh -agentpost resolve 'Pattern Buffer' -agentpost message 'Pattern Buffer' 'Please review the attached design context.' +agentpost resolve nav +agentpost message nav 'Please review the attached design context.' +agentpost question pbe.codereview 'Does this contract cover retry behavior?' agentpost question reviewers 'Does this contract cover retry behavior?' ``` Both commands accept `-` or an omitted body to read a multi-line message from standard input. Bare registered group names are accepted; `@group` remains the -explicit form. Exact named identities remain addressable while offline. +explicit form. Bare identity names resolve only among seats sharing the +sender's registered projects. Cross-project seats must use `PROJECT.SEAT`; +never retry a missing local bare name against the global directory. Qualified +identities remain addressable while offline. If this project has a declared mailbox but is not connected, run bare `agentpost join --cli CURRENT_CLI` from the project root. It resolves the unique @@ -55,8 +59,10 @@ that the mailbox can be addressed: 1. Resolve `NAME` and run `agentpost identify --cli CURRENT_CLI --cwd "$PWD"` to compare the requested identity with this process. -2. Run the applicable idempotent `agentpost join --cli CURRENT_CLI`, using the - explicit `NAME` form only for an alternate identity or genuine ambiguity. +2. If an ordinary live Codex thread needs a different known seat at this + workspace, run `agentpost attach NAME`. Otherwise run the applicable + idempotent `agentpost join --cli CURRENT_CLI`, using the explicit `NAME` form + only for an alternate identity or genuine ambiguity. 3. Run `agentpost doctor NAME --project "$PWD" --cli CURRENT_CLI` and follow any safe adapter instruction it reports. 4. Run `agentpost armed NAME` and report its actual result. @@ -65,6 +71,12 @@ Mailbox existence and successful `resolve`, `list`, `read`, `message`, or `reply` commands prove durable access only. Never claim that an agent is ready, connected, receiving, or reconnected while `armed` reports `QUEUED`, or while `doctor` reports a failure that prevents the requested notification mode. +An `attach` result may be reported specifically as a boundary-only identity +selection while `armed` remains `QUEUED`; do not call that live wake. +After attachment, `doctor` reports exact `codex-session-attachment` health +separately from aggregate `codex-generation` recovery. A stale aggregate line +does not negate an exact-thread attachment pass, and neither result upgrades +boundary-only delivery to live wake. If a connection attempt reports that `NAME` already has an inbound consumer, do not steal its lease or silently treat mailbox-level `ARMED` as proof that @@ -77,12 +89,16 @@ numbered identity is a separate durable mailbox: it does not inherit, claim, or move mail already addressed to `NAME`. Do not rewrite integration state ad hoc or launch a nested copy of the current -CLI. A running process cannot retroactively become an alternate identity that -requires an explicit named launcher. If repair requires replacing or reloading -the current process, mark reconnection as pending and give the exact external -launcher, such as `agentpost codex --agent NAME`, `agentpost claude --agent -NAME`, or `agentpost antigravity --agent NAME`. The replacement session must -repeat `doctor` and `armed` before reporting success. +CLI. An ordinary running Codex thread with compatible AgentPost hooks may select +an alternate known workspace seat through `agentpost attach NAME`. Report its +returned capability precisely: `boundary-only` changes sender identity and +next-boundary catch-up without publishing presence or providing already-idle +wake. Do not use `join` or replace a plugin merely to change that live session +identity. If full wake or another adapter requires replacing or reloading the +current process, mark reconnection as pending and give the exact external +launcher, such as `agentpost codex --agent NAME resume THREAD_ID`, `agentpost +claude --agent NAME`, or `agentpost antigravity --agent NAME`. The replacement +session must repeat `doctor` and `armed` before reporting success. Some adapters honestly provide lifecycle catch-up without already-idle wake. For those adapters, describe `QUEUED` as durable mail that will surface at the @@ -95,7 +111,8 @@ current workspace or role documentation. Describe demonstrated, durable ownership so a coworker can answer both "who is this?" and "who should handle this work?" -- `name`: short, stable mailbox address; do not encode a session or task. +- `name`: short, stable dot-free mailbox address; do not encode a session or + task. Dot is reserved for `PROJECT.SEAT`. - `display-name`: recognizable project, team, or role name. - The profile is CLI-neutral. Do not make CLI type part of the identity; `join --cli` records each runtime adapter separately. @@ -105,9 +122,13 @@ this work?" - `summary`: one concise sentence naming the owned domain plus the decisions, systems, or outputs this agent can help with. Include terms coworkers search. - `roles`: broad workplace functions. -- `projects`: stable project names and aliases people will mention. +- `projects`: stable dot-free project names and aliases people will mention. + This is also the seat's address namespace, not a claim that a role owns the + runtime workspace. Include a short alias when coworkers need a compact + cross-project address. - `specialties`: specific reusable technical or domain expertise. -- `handles`: two to five concrete request categories that should route here. +- `handles`: put a simple seat address such as `nav`, `build`, or `codereview` + first, then two to five concrete request categories that should route here. - `does-not-handle`: close neighboring responsibilities owned elsewhere. Prefer "Owns Pattern Buffer temporal world-state semantics, ingestion fidelity, @@ -122,7 +143,7 @@ agentpost profile-register pb \ --summary 'Owns temporal world-state semantics, ingestion fidelity, and deterministic retrieval contracts.' \ --roles 'world-model engineering' --projects 'pattern-buffer' \ --project-roots "$PWD" --specialties 'temporal state,identity,provenance' \ - --handles 'Pattern Buffer API reviews,world-state schema questions' \ + --handles 'pb,Pattern Buffer API reviews,world-state schema questions' \ --does-not-handle 'Construct narrative orchestration,Kernos member policy' ``` @@ -130,6 +151,13 @@ agentpost profile-register pb \ registration, verify the nameplate with `agentpost identities` and test its likely search terms with `agentpost agents-find QUERY --all`. +Also verify the project-qualified address and complete seat roster: + +```sh +agentpost identities --project pattern-buffer +agentpost resolve pattern-buffer.pb +``` + ## Identity and discovery At the start of AgentPost work, resolve this session's identity from its project @@ -140,16 +168,21 @@ agentpost identify --cwd "$PWD" ``` Explicit `--agent`/`AGENTPOST_AGENT` is authoritative. Otherwise AgentPost uses -the deepest workspace marker, adapter binding, or declared project root, with -that priority for equal paths. A workspace has one default; alternate role or -review mailboxes in the same directory require an explicit named launcher. +an active Codex session attachment, then the deepest workspace marker, adapter +binding, or declared project root, with that priority for equal paths. A +workspace has one default; alternate role or review mailboxes in the same +directory require `agentpost attach NAME` in a compatible active Codex thread +or an explicit named launcher. Never guess a recipient from conversation memory. Inspect the current directory: ```sh agentpost identities -agentpost resolve 'known name, display name, or project identity' +agentpost identities --project construct +agentpost resolve nav +agentpost resolve other-project.nav agentpost profiles +agentpost profiles --project construct --all agentpost status agentpost agents-find "topic or responsibility" agentpost agents-find --role marketing @@ -159,8 +192,9 @@ agentpost agents-find --specialty "temporal identity" Normal profile and responsibility discovery returns active agents only. `agentpost profiles --all` and `agentpost agents-find --all` expose offline -history when explicitly needed. An exact mailbox name may still receive durable -mail while offline; do not infer that offline means deleted. +history when explicitly needed. `identities --project PROJECT` returns the +complete project roster including offline seats. A qualified identity may still +receive durable mail while offline; do not infer that offline means deleted. Select the smallest relevant recipient set and retain the printed match reasons. Familiarity does not outrank a better responsibility or evidence match. @@ -243,6 +277,41 @@ Named groups, comma-separated recipients, and selectors such as `@role:marketing`, `@project:construct`, and `@specialty:temporal-identity` are resolved to concrete recipients before delivery. +## Clean-start deletion + +Never manually remove AgentPost directories when a clean start is requested. +Use the guarded wipe workflow, which never touches source or AgentBridge +repositories: + +```sh +agentpost wipe agent +agentpost wipe agent other-project.nav +agentpost wipe project other-project +agentpost wipe all +``` + +`wipe agent` with no name removes only this session's resolved mailbox and +needs no affected-box confirmation. It still refuses any held inbound consumer +lease, including a live adapter for the same seat: close that adapter and run +the final wipe from a terminal with `AGENTPOST_AGENT=NAME`. Treat successful +self-wipe as this seat's final action and do not continue using the deleted +identity. + +Anything broader than this seat requires explicit user confirmation. Run the +requested command once without `--confirm`; it performs no deletion and returns +the exact sorted affected mailbox list plus the exact confirmation argument. +Show that list to the user and ask them to confirm that those boxes will be +deleted. Only after the user confirms, rerun with the printed +`--confirm 'BOX1,BOX2'`. Never infer confirmation from the original deletion +request when the affected list had not yet been shown. If the list changes, +stop and confirm the new list. Stop every live mailbox consumer first; the wipe +holds the authoritative lease fence through mailbox detachment. + +Successful wipe is irreversible within AgentPost. Report which boxes were +removed, that target profiles/mail/bindings/adapter state/group membership were +deleted, that source repositories were untouched, and that copies held by +unaffected mailboxes remain. + ## Consultation posture When work crosses ownership boundaries or resembles a problem another agent has diff --git a/integrations/claude/agentpost/skills/agentpost/SKILL.md b/integrations/claude/agentpost/skills/agentpost/SKILL.md index 577bad8..1646a7e 100644 --- a/integrations/claude/agentpost/skills/agentpost/SKILL.md +++ b/integrations/claude/agentpost/skills/agentpost/SKILL.md @@ -31,14 +31,18 @@ idle notification; questions default to immediate, but the user's urgency or non-interruption request overrides the default. ```sh -agentpost resolve 'Pattern Buffer' -agentpost message 'Pattern Buffer' 'Please review the attached design context.' +agentpost resolve nav +agentpost message nav 'Please review the attached design context.' +agentpost question pbe.codereview 'Does this contract cover retry behavior?' agentpost question reviewers 'Does this contract cover retry behavior?' ``` Both commands accept `-` or an omitted body to read a multi-line message from standard input. Bare registered group names are accepted; `@group` remains the -explicit form. Exact named identities remain addressable while offline. +explicit form. Bare identity names resolve only among seats sharing the +sender's registered projects. Cross-project seats must use `PROJECT.SEAT`; +never retry a missing local bare name against the global directory. Qualified +identities remain addressable while offline. If this project has a declared mailbox but is not connected, run bare `agentpost join --cli CURRENT_CLI` from the project root. It resolves the unique @@ -55,8 +59,10 @@ that the mailbox can be addressed: 1. Resolve `NAME` and run `agentpost identify --cli CURRENT_CLI --cwd "$PWD"` to compare the requested identity with this process. -2. Run the applicable idempotent `agentpost join --cli CURRENT_CLI`, using the - explicit `NAME` form only for an alternate identity or genuine ambiguity. +2. If an ordinary live Codex thread needs a different known seat at this + workspace, run `agentpost attach NAME`. Otherwise run the applicable + idempotent `agentpost join --cli CURRENT_CLI`, using the explicit `NAME` form + only for an alternate identity or genuine ambiguity. 3. Run `agentpost doctor NAME --project "$PWD" --cli CURRENT_CLI` and follow any safe adapter instruction it reports. 4. Run `agentpost armed NAME` and report its actual result. @@ -65,6 +71,12 @@ Mailbox existence and successful `resolve`, `list`, `read`, `message`, or `reply` commands prove durable access only. Never claim that an agent is ready, connected, receiving, or reconnected while `armed` reports `QUEUED`, or while `doctor` reports a failure that prevents the requested notification mode. +An `attach` result may be reported specifically as a boundary-only identity +selection while `armed` remains `QUEUED`; do not call that live wake. +After attachment, `doctor` reports exact `codex-session-attachment` health +separately from aggregate `codex-generation` recovery. A stale aggregate line +does not negate an exact-thread attachment pass, and neither result upgrades +boundary-only delivery to live wake. If a connection attempt reports that `NAME` already has an inbound consumer, do not steal its lease or silently treat mailbox-level `ARMED` as proof that @@ -77,12 +89,16 @@ numbered identity is a separate durable mailbox: it does not inherit, claim, or move mail already addressed to `NAME`. Do not rewrite integration state ad hoc or launch a nested copy of the current -CLI. A running process cannot retroactively become an alternate identity that -requires an explicit named launcher. If repair requires replacing or reloading -the current process, mark reconnection as pending and give the exact external -launcher, such as `agentpost codex --agent NAME`, `agentpost claude --agent -NAME`, or `agentpost antigravity --agent NAME`. The replacement session must -repeat `doctor` and `armed` before reporting success. +CLI. An ordinary running Codex thread with compatible AgentPost hooks may select +an alternate known workspace seat through `agentpost attach NAME`. Report its +returned capability precisely: `boundary-only` changes sender identity and +next-boundary catch-up without publishing presence or providing already-idle +wake. Do not use `join` or replace a plugin merely to change that live session +identity. If full wake or another adapter requires replacing or reloading the +current process, mark reconnection as pending and give the exact external +launcher, such as `agentpost codex --agent NAME resume THREAD_ID`, `agentpost +claude --agent NAME`, or `agentpost antigravity --agent NAME`. The replacement +session must repeat `doctor` and `armed` before reporting success. Some adapters honestly provide lifecycle catch-up without already-idle wake. For those adapters, describe `QUEUED` as durable mail that will surface at the @@ -95,7 +111,8 @@ current workspace or role documentation. Describe demonstrated, durable ownership so a coworker can answer both "who is this?" and "who should handle this work?" -- `name`: short, stable mailbox address; do not encode a session or task. +- `name`: short, stable dot-free mailbox address; do not encode a session or + task. Dot is reserved for `PROJECT.SEAT`. - `display-name`: recognizable project, team, or role name. - The profile is CLI-neutral. Do not make CLI type part of the identity; `join --cli` records each runtime adapter separately. @@ -105,9 +122,13 @@ this work?" - `summary`: one concise sentence naming the owned domain plus the decisions, systems, or outputs this agent can help with. Include terms coworkers search. - `roles`: broad workplace functions. -- `projects`: stable project names and aliases people will mention. +- `projects`: stable dot-free project names and aliases people will mention. + This is also the seat's address namespace, not a claim that a role owns the + runtime workspace. Include a short alias when coworkers need a compact + cross-project address. - `specialties`: specific reusable technical or domain expertise. -- `handles`: two to five concrete request categories that should route here. +- `handles`: put a simple seat address such as `nav`, `build`, or `codereview` + first, then two to five concrete request categories that should route here. - `does-not-handle`: close neighboring responsibilities owned elsewhere. Prefer "Owns Pattern Buffer temporal world-state semantics, ingestion fidelity, @@ -122,7 +143,7 @@ agentpost profile-register pb \ --summary 'Owns temporal world-state semantics, ingestion fidelity, and deterministic retrieval contracts.' \ --roles 'world-model engineering' --projects 'pattern-buffer' \ --project-roots "$PWD" --specialties 'temporal state,identity,provenance' \ - --handles 'Pattern Buffer API reviews,world-state schema questions' \ + --handles 'pb,Pattern Buffer API reviews,world-state schema questions' \ --does-not-handle 'Construct narrative orchestration,Kernos member policy' ``` @@ -130,6 +151,13 @@ agentpost profile-register pb \ registration, verify the nameplate with `agentpost identities` and test its likely search terms with `agentpost agents-find QUERY --all`. +Also verify the project-qualified address and complete seat roster: + +```sh +agentpost identities --project pattern-buffer +agentpost resolve pattern-buffer.pb +``` + ## Identity and discovery At the start of AgentPost work, resolve this session's identity from its project @@ -140,16 +168,21 @@ agentpost identify --cwd "$PWD" ``` Explicit `--agent`/`AGENTPOST_AGENT` is authoritative. Otherwise AgentPost uses -the deepest workspace marker, adapter binding, or declared project root, with -that priority for equal paths. A workspace has one default; alternate role or -review mailboxes in the same directory require an explicit named launcher. +an active Codex session attachment, then the deepest workspace marker, adapter +binding, or declared project root, with that priority for equal paths. A +workspace has one default; alternate role or review mailboxes in the same +directory require `agentpost attach NAME` in a compatible active Codex thread +or an explicit named launcher. Never guess a recipient from conversation memory. Inspect the current directory: ```sh agentpost identities -agentpost resolve 'known name, display name, or project identity' +agentpost identities --project construct +agentpost resolve nav +agentpost resolve other-project.nav agentpost profiles +agentpost profiles --project construct --all agentpost status agentpost agents-find "topic or responsibility" agentpost agents-find --role marketing @@ -159,8 +192,9 @@ agentpost agents-find --specialty "temporal identity" Normal profile and responsibility discovery returns active agents only. `agentpost profiles --all` and `agentpost agents-find --all` expose offline -history when explicitly needed. An exact mailbox name may still receive durable -mail while offline; do not infer that offline means deleted. +history when explicitly needed. `identities --project PROJECT` returns the +complete project roster including offline seats. A qualified identity may still +receive durable mail while offline; do not infer that offline means deleted. Select the smallest relevant recipient set and retain the printed match reasons. Familiarity does not outrank a better responsibility or evidence match. @@ -243,6 +277,41 @@ Named groups, comma-separated recipients, and selectors such as `@role:marketing`, `@project:construct`, and `@specialty:temporal-identity` are resolved to concrete recipients before delivery. +## Clean-start deletion + +Never manually remove AgentPost directories when a clean start is requested. +Use the guarded wipe workflow, which never touches source or AgentBridge +repositories: + +```sh +agentpost wipe agent +agentpost wipe agent other-project.nav +agentpost wipe project other-project +agentpost wipe all +``` + +`wipe agent` with no name removes only this session's resolved mailbox and +needs no affected-box confirmation. It still refuses any held inbound consumer +lease, including a live adapter for the same seat: close that adapter and run +the final wipe from a terminal with `AGENTPOST_AGENT=NAME`. Treat successful +self-wipe as this seat's final action and do not continue using the deleted +identity. + +Anything broader than this seat requires explicit user confirmation. Run the +requested command once without `--confirm`; it performs no deletion and returns +the exact sorted affected mailbox list plus the exact confirmation argument. +Show that list to the user and ask them to confirm that those boxes will be +deleted. Only after the user confirms, rerun with the printed +`--confirm 'BOX1,BOX2'`. Never infer confirmation from the original deletion +request when the affected list had not yet been shown. If the list changes, +stop and confirm the new list. Stop every live mailbox consumer first; the wipe +holds the authoritative lease fence through mailbox detachment. + +Successful wipe is irreversible within AgentPost. Report which boxes were +removed, that target profiles/mail/bindings/adapter state/group membership were +deleted, that source repositories were untouched, and that copies held by +unaffected mailboxes remain. + ## Consultation posture When work crosses ownership boundaries or resembles a problem another agent has diff --git a/integrations/codex/plugins/agentpost/.codex-plugin/plugin.json b/integrations/codex/plugins/agentpost/.codex-plugin/plugin.json index bffdc8c..e4c6a0b 100644 --- a/integrations/codex/plugins/agentpost/.codex-plugin/plugin.json +++ b/integrations/codex/plugins/agentpost/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "agentpost", - "version": "0.0.5+codex.20260712082137", + "version": "0.0.6+codex.20260728214546", "description": "A named local communication channel for CLI agents", "author": { "name": "AgentPost contributors" diff --git a/integrations/codex/plugins/agentpost/skills/agentpost/SKILL.md b/integrations/codex/plugins/agentpost/skills/agentpost/SKILL.md index 577bad8..1646a7e 100644 --- a/integrations/codex/plugins/agentpost/skills/agentpost/SKILL.md +++ b/integrations/codex/plugins/agentpost/skills/agentpost/SKILL.md @@ -31,14 +31,18 @@ idle notification; questions default to immediate, but the user's urgency or non-interruption request overrides the default. ```sh -agentpost resolve 'Pattern Buffer' -agentpost message 'Pattern Buffer' 'Please review the attached design context.' +agentpost resolve nav +agentpost message nav 'Please review the attached design context.' +agentpost question pbe.codereview 'Does this contract cover retry behavior?' agentpost question reviewers 'Does this contract cover retry behavior?' ``` Both commands accept `-` or an omitted body to read a multi-line message from standard input. Bare registered group names are accepted; `@group` remains the -explicit form. Exact named identities remain addressable while offline. +explicit form. Bare identity names resolve only among seats sharing the +sender's registered projects. Cross-project seats must use `PROJECT.SEAT`; +never retry a missing local bare name against the global directory. Qualified +identities remain addressable while offline. If this project has a declared mailbox but is not connected, run bare `agentpost join --cli CURRENT_CLI` from the project root. It resolves the unique @@ -55,8 +59,10 @@ that the mailbox can be addressed: 1. Resolve `NAME` and run `agentpost identify --cli CURRENT_CLI --cwd "$PWD"` to compare the requested identity with this process. -2. Run the applicable idempotent `agentpost join --cli CURRENT_CLI`, using the - explicit `NAME` form only for an alternate identity or genuine ambiguity. +2. If an ordinary live Codex thread needs a different known seat at this + workspace, run `agentpost attach NAME`. Otherwise run the applicable + idempotent `agentpost join --cli CURRENT_CLI`, using the explicit `NAME` form + only for an alternate identity or genuine ambiguity. 3. Run `agentpost doctor NAME --project "$PWD" --cli CURRENT_CLI` and follow any safe adapter instruction it reports. 4. Run `agentpost armed NAME` and report its actual result. @@ -65,6 +71,12 @@ Mailbox existence and successful `resolve`, `list`, `read`, `message`, or `reply` commands prove durable access only. Never claim that an agent is ready, connected, receiving, or reconnected while `armed` reports `QUEUED`, or while `doctor` reports a failure that prevents the requested notification mode. +An `attach` result may be reported specifically as a boundary-only identity +selection while `armed` remains `QUEUED`; do not call that live wake. +After attachment, `doctor` reports exact `codex-session-attachment` health +separately from aggregate `codex-generation` recovery. A stale aggregate line +does not negate an exact-thread attachment pass, and neither result upgrades +boundary-only delivery to live wake. If a connection attempt reports that `NAME` already has an inbound consumer, do not steal its lease or silently treat mailbox-level `ARMED` as proof that @@ -77,12 +89,16 @@ numbered identity is a separate durable mailbox: it does not inherit, claim, or move mail already addressed to `NAME`. Do not rewrite integration state ad hoc or launch a nested copy of the current -CLI. A running process cannot retroactively become an alternate identity that -requires an explicit named launcher. If repair requires replacing or reloading -the current process, mark reconnection as pending and give the exact external -launcher, such as `agentpost codex --agent NAME`, `agentpost claude --agent -NAME`, or `agentpost antigravity --agent NAME`. The replacement session must -repeat `doctor` and `armed` before reporting success. +CLI. An ordinary running Codex thread with compatible AgentPost hooks may select +an alternate known workspace seat through `agentpost attach NAME`. Report its +returned capability precisely: `boundary-only` changes sender identity and +next-boundary catch-up without publishing presence or providing already-idle +wake. Do not use `join` or replace a plugin merely to change that live session +identity. If full wake or another adapter requires replacing or reloading the +current process, mark reconnection as pending and give the exact external +launcher, such as `agentpost codex --agent NAME resume THREAD_ID`, `agentpost +claude --agent NAME`, or `agentpost antigravity --agent NAME`. The replacement +session must repeat `doctor` and `armed` before reporting success. Some adapters honestly provide lifecycle catch-up without already-idle wake. For those adapters, describe `QUEUED` as durable mail that will surface at the @@ -95,7 +111,8 @@ current workspace or role documentation. Describe demonstrated, durable ownership so a coworker can answer both "who is this?" and "who should handle this work?" -- `name`: short, stable mailbox address; do not encode a session or task. +- `name`: short, stable dot-free mailbox address; do not encode a session or + task. Dot is reserved for `PROJECT.SEAT`. - `display-name`: recognizable project, team, or role name. - The profile is CLI-neutral. Do not make CLI type part of the identity; `join --cli` records each runtime adapter separately. @@ -105,9 +122,13 @@ this work?" - `summary`: one concise sentence naming the owned domain plus the decisions, systems, or outputs this agent can help with. Include terms coworkers search. - `roles`: broad workplace functions. -- `projects`: stable project names and aliases people will mention. +- `projects`: stable dot-free project names and aliases people will mention. + This is also the seat's address namespace, not a claim that a role owns the + runtime workspace. Include a short alias when coworkers need a compact + cross-project address. - `specialties`: specific reusable technical or domain expertise. -- `handles`: two to five concrete request categories that should route here. +- `handles`: put a simple seat address such as `nav`, `build`, or `codereview` + first, then two to five concrete request categories that should route here. - `does-not-handle`: close neighboring responsibilities owned elsewhere. Prefer "Owns Pattern Buffer temporal world-state semantics, ingestion fidelity, @@ -122,7 +143,7 @@ agentpost profile-register pb \ --summary 'Owns temporal world-state semantics, ingestion fidelity, and deterministic retrieval contracts.' \ --roles 'world-model engineering' --projects 'pattern-buffer' \ --project-roots "$PWD" --specialties 'temporal state,identity,provenance' \ - --handles 'Pattern Buffer API reviews,world-state schema questions' \ + --handles 'pb,Pattern Buffer API reviews,world-state schema questions' \ --does-not-handle 'Construct narrative orchestration,Kernos member policy' ``` @@ -130,6 +151,13 @@ agentpost profile-register pb \ registration, verify the nameplate with `agentpost identities` and test its likely search terms with `agentpost agents-find QUERY --all`. +Also verify the project-qualified address and complete seat roster: + +```sh +agentpost identities --project pattern-buffer +agentpost resolve pattern-buffer.pb +``` + ## Identity and discovery At the start of AgentPost work, resolve this session's identity from its project @@ -140,16 +168,21 @@ agentpost identify --cwd "$PWD" ``` Explicit `--agent`/`AGENTPOST_AGENT` is authoritative. Otherwise AgentPost uses -the deepest workspace marker, adapter binding, or declared project root, with -that priority for equal paths. A workspace has one default; alternate role or -review mailboxes in the same directory require an explicit named launcher. +an active Codex session attachment, then the deepest workspace marker, adapter +binding, or declared project root, with that priority for equal paths. A +workspace has one default; alternate role or review mailboxes in the same +directory require `agentpost attach NAME` in a compatible active Codex thread +or an explicit named launcher. Never guess a recipient from conversation memory. Inspect the current directory: ```sh agentpost identities -agentpost resolve 'known name, display name, or project identity' +agentpost identities --project construct +agentpost resolve nav +agentpost resolve other-project.nav agentpost profiles +agentpost profiles --project construct --all agentpost status agentpost agents-find "topic or responsibility" agentpost agents-find --role marketing @@ -159,8 +192,9 @@ agentpost agents-find --specialty "temporal identity" Normal profile and responsibility discovery returns active agents only. `agentpost profiles --all` and `agentpost agents-find --all` expose offline -history when explicitly needed. An exact mailbox name may still receive durable -mail while offline; do not infer that offline means deleted. +history when explicitly needed. `identities --project PROJECT` returns the +complete project roster including offline seats. A qualified identity may still +receive durable mail while offline; do not infer that offline means deleted. Select the smallest relevant recipient set and retain the printed match reasons. Familiarity does not outrank a better responsibility or evidence match. @@ -243,6 +277,41 @@ Named groups, comma-separated recipients, and selectors such as `@role:marketing`, `@project:construct`, and `@specialty:temporal-identity` are resolved to concrete recipients before delivery. +## Clean-start deletion + +Never manually remove AgentPost directories when a clean start is requested. +Use the guarded wipe workflow, which never touches source or AgentBridge +repositories: + +```sh +agentpost wipe agent +agentpost wipe agent other-project.nav +agentpost wipe project other-project +agentpost wipe all +``` + +`wipe agent` with no name removes only this session's resolved mailbox and +needs no affected-box confirmation. It still refuses any held inbound consumer +lease, including a live adapter for the same seat: close that adapter and run +the final wipe from a terminal with `AGENTPOST_AGENT=NAME`. Treat successful +self-wipe as this seat's final action and do not continue using the deleted +identity. + +Anything broader than this seat requires explicit user confirmation. Run the +requested command once without `--confirm`; it performs no deletion and returns +the exact sorted affected mailbox list plus the exact confirmation argument. +Show that list to the user and ask them to confirm that those boxes will be +deleted. Only after the user confirms, rerun with the printed +`--confirm 'BOX1,BOX2'`. Never infer confirmation from the original deletion +request when the affected list had not yet been shown. If the list changes, +stop and confirm the new list. Stop every live mailbox consumer first; the wipe +holds the authoritative lease fence through mailbox detachment. + +Successful wipe is irreversible within AgentPost. Report which boxes were +removed, that target profiles/mail/bindings/adapter state/group membership were +deleted, that source repositories were untouched, and that copies held by +unaffected mailboxes remain. + ## Consultation posture When work crosses ownership boundaries or resembles a problem another agent has diff --git a/integrations/shared/agentpost/SKILL.md b/integrations/shared/agentpost/SKILL.md index 577bad8..1646a7e 100644 --- a/integrations/shared/agentpost/SKILL.md +++ b/integrations/shared/agentpost/SKILL.md @@ -31,14 +31,18 @@ idle notification; questions default to immediate, but the user's urgency or non-interruption request overrides the default. ```sh -agentpost resolve 'Pattern Buffer' -agentpost message 'Pattern Buffer' 'Please review the attached design context.' +agentpost resolve nav +agentpost message nav 'Please review the attached design context.' +agentpost question pbe.codereview 'Does this contract cover retry behavior?' agentpost question reviewers 'Does this contract cover retry behavior?' ``` Both commands accept `-` or an omitted body to read a multi-line message from standard input. Bare registered group names are accepted; `@group` remains the -explicit form. Exact named identities remain addressable while offline. +explicit form. Bare identity names resolve only among seats sharing the +sender's registered projects. Cross-project seats must use `PROJECT.SEAT`; +never retry a missing local bare name against the global directory. Qualified +identities remain addressable while offline. If this project has a declared mailbox but is not connected, run bare `agentpost join --cli CURRENT_CLI` from the project root. It resolves the unique @@ -55,8 +59,10 @@ that the mailbox can be addressed: 1. Resolve `NAME` and run `agentpost identify --cli CURRENT_CLI --cwd "$PWD"` to compare the requested identity with this process. -2. Run the applicable idempotent `agentpost join --cli CURRENT_CLI`, using the - explicit `NAME` form only for an alternate identity or genuine ambiguity. +2. If an ordinary live Codex thread needs a different known seat at this + workspace, run `agentpost attach NAME`. Otherwise run the applicable + idempotent `agentpost join --cli CURRENT_CLI`, using the explicit `NAME` form + only for an alternate identity or genuine ambiguity. 3. Run `agentpost doctor NAME --project "$PWD" --cli CURRENT_CLI` and follow any safe adapter instruction it reports. 4. Run `agentpost armed NAME` and report its actual result. @@ -65,6 +71,12 @@ Mailbox existence and successful `resolve`, `list`, `read`, `message`, or `reply` commands prove durable access only. Never claim that an agent is ready, connected, receiving, or reconnected while `armed` reports `QUEUED`, or while `doctor` reports a failure that prevents the requested notification mode. +An `attach` result may be reported specifically as a boundary-only identity +selection while `armed` remains `QUEUED`; do not call that live wake. +After attachment, `doctor` reports exact `codex-session-attachment` health +separately from aggregate `codex-generation` recovery. A stale aggregate line +does not negate an exact-thread attachment pass, and neither result upgrades +boundary-only delivery to live wake. If a connection attempt reports that `NAME` already has an inbound consumer, do not steal its lease or silently treat mailbox-level `ARMED` as proof that @@ -77,12 +89,16 @@ numbered identity is a separate durable mailbox: it does not inherit, claim, or move mail already addressed to `NAME`. Do not rewrite integration state ad hoc or launch a nested copy of the current -CLI. A running process cannot retroactively become an alternate identity that -requires an explicit named launcher. If repair requires replacing or reloading -the current process, mark reconnection as pending and give the exact external -launcher, such as `agentpost codex --agent NAME`, `agentpost claude --agent -NAME`, or `agentpost antigravity --agent NAME`. The replacement session must -repeat `doctor` and `armed` before reporting success. +CLI. An ordinary running Codex thread with compatible AgentPost hooks may select +an alternate known workspace seat through `agentpost attach NAME`. Report its +returned capability precisely: `boundary-only` changes sender identity and +next-boundary catch-up without publishing presence or providing already-idle +wake. Do not use `join` or replace a plugin merely to change that live session +identity. If full wake or another adapter requires replacing or reloading the +current process, mark reconnection as pending and give the exact external +launcher, such as `agentpost codex --agent NAME resume THREAD_ID`, `agentpost +claude --agent NAME`, or `agentpost antigravity --agent NAME`. The replacement +session must repeat `doctor` and `armed` before reporting success. Some adapters honestly provide lifecycle catch-up without already-idle wake. For those adapters, describe `QUEUED` as durable mail that will surface at the @@ -95,7 +111,8 @@ current workspace or role documentation. Describe demonstrated, durable ownership so a coworker can answer both "who is this?" and "who should handle this work?" -- `name`: short, stable mailbox address; do not encode a session or task. +- `name`: short, stable dot-free mailbox address; do not encode a session or + task. Dot is reserved for `PROJECT.SEAT`. - `display-name`: recognizable project, team, or role name. - The profile is CLI-neutral. Do not make CLI type part of the identity; `join --cli` records each runtime adapter separately. @@ -105,9 +122,13 @@ this work?" - `summary`: one concise sentence naming the owned domain plus the decisions, systems, or outputs this agent can help with. Include terms coworkers search. - `roles`: broad workplace functions. -- `projects`: stable project names and aliases people will mention. +- `projects`: stable dot-free project names and aliases people will mention. + This is also the seat's address namespace, not a claim that a role owns the + runtime workspace. Include a short alias when coworkers need a compact + cross-project address. - `specialties`: specific reusable technical or domain expertise. -- `handles`: two to five concrete request categories that should route here. +- `handles`: put a simple seat address such as `nav`, `build`, or `codereview` + first, then two to five concrete request categories that should route here. - `does-not-handle`: close neighboring responsibilities owned elsewhere. Prefer "Owns Pattern Buffer temporal world-state semantics, ingestion fidelity, @@ -122,7 +143,7 @@ agentpost profile-register pb \ --summary 'Owns temporal world-state semantics, ingestion fidelity, and deterministic retrieval contracts.' \ --roles 'world-model engineering' --projects 'pattern-buffer' \ --project-roots "$PWD" --specialties 'temporal state,identity,provenance' \ - --handles 'Pattern Buffer API reviews,world-state schema questions' \ + --handles 'pb,Pattern Buffer API reviews,world-state schema questions' \ --does-not-handle 'Construct narrative orchestration,Kernos member policy' ``` @@ -130,6 +151,13 @@ agentpost profile-register pb \ registration, verify the nameplate with `agentpost identities` and test its likely search terms with `agentpost agents-find QUERY --all`. +Also verify the project-qualified address and complete seat roster: + +```sh +agentpost identities --project pattern-buffer +agentpost resolve pattern-buffer.pb +``` + ## Identity and discovery At the start of AgentPost work, resolve this session's identity from its project @@ -140,16 +168,21 @@ agentpost identify --cwd "$PWD" ``` Explicit `--agent`/`AGENTPOST_AGENT` is authoritative. Otherwise AgentPost uses -the deepest workspace marker, adapter binding, or declared project root, with -that priority for equal paths. A workspace has one default; alternate role or -review mailboxes in the same directory require an explicit named launcher. +an active Codex session attachment, then the deepest workspace marker, adapter +binding, or declared project root, with that priority for equal paths. A +workspace has one default; alternate role or review mailboxes in the same +directory require `agentpost attach NAME` in a compatible active Codex thread +or an explicit named launcher. Never guess a recipient from conversation memory. Inspect the current directory: ```sh agentpost identities -agentpost resolve 'known name, display name, or project identity' +agentpost identities --project construct +agentpost resolve nav +agentpost resolve other-project.nav agentpost profiles +agentpost profiles --project construct --all agentpost status agentpost agents-find "topic or responsibility" agentpost agents-find --role marketing @@ -159,8 +192,9 @@ agentpost agents-find --specialty "temporal identity" Normal profile and responsibility discovery returns active agents only. `agentpost profiles --all` and `agentpost agents-find --all` expose offline -history when explicitly needed. An exact mailbox name may still receive durable -mail while offline; do not infer that offline means deleted. +history when explicitly needed. `identities --project PROJECT` returns the +complete project roster including offline seats. A qualified identity may still +receive durable mail while offline; do not infer that offline means deleted. Select the smallest relevant recipient set and retain the printed match reasons. Familiarity does not outrank a better responsibility or evidence match. @@ -243,6 +277,41 @@ Named groups, comma-separated recipients, and selectors such as `@role:marketing`, `@project:construct`, and `@specialty:temporal-identity` are resolved to concrete recipients before delivery. +## Clean-start deletion + +Never manually remove AgentPost directories when a clean start is requested. +Use the guarded wipe workflow, which never touches source or AgentBridge +repositories: + +```sh +agentpost wipe agent +agentpost wipe agent other-project.nav +agentpost wipe project other-project +agentpost wipe all +``` + +`wipe agent` with no name removes only this session's resolved mailbox and +needs no affected-box confirmation. It still refuses any held inbound consumer +lease, including a live adapter for the same seat: close that adapter and run +the final wipe from a terminal with `AGENTPOST_AGENT=NAME`. Treat successful +self-wipe as this seat's final action and do not continue using the deleted +identity. + +Anything broader than this seat requires explicit user confirmation. Run the +requested command once without `--confirm`; it performs no deletion and returns +the exact sorted affected mailbox list plus the exact confirmation argument. +Show that list to the user and ask them to confirm that those boxes will be +deleted. Only after the user confirms, rerun with the printed +`--confirm 'BOX1,BOX2'`. Never infer confirmation from the original deletion +request when the affected list had not yet been shown. If the list changes, +stop and confirm the new list. Stop every live mailbox consumer first; the wipe +holds the authoritative lease fence through mailbox detachment. + +Successful wipe is irreversible within AgentPost. Report which boxes were +removed, that target profiles/mail/bindings/adapter state/group membership were +deleted, that source repositories were untouched, and that copies held by +unaffected mailboxes remain. + ## Consultation posture When work crosses ownership boundaries or resembles a problem another agent has diff --git a/pyproject.toml b/pyproject.toml index b51e9a0..e477f45 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "agentpost" -version = "1.2.0" +version = "1.3.0" description = "Durable trusted-local messaging and token-free notifications for CLI and Python agents" readme = "README.md" requires-python = ">=3.11" diff --git a/scripts/install.sh b/scripts/install.sh index bad4f4c..69ba83a 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -4,7 +4,7 @@ set -eu python=${PYTHON:-python3} install_dir=${AGENTPOST_INSTALL_DIR:-"$HOME/.local/share/agentpost/venv"} bin_dir=${AGENTPOST_BIN_DIR:-"$HOME/.local/bin"} -source=${AGENTPOST_SOURCE:-"git+https://github.com/5000Stadia/agentpost.git@v1.2.0"} +source=${AGENTPOST_SOURCE:-"git+https://github.com/5000Stadia/agentpost.git@v1.3.0"} connection_mode=${AGENTPOST_CONNECTION_MODE:-auto} if ! command -v "$python" >/dev/null 2>&1; then diff --git a/scripts/smoke_two_agents.sh b/scripts/smoke_two_agents.sh index 07e7583..55b42dc 100755 --- a/scripts/smoke_two_agents.sh +++ b/scripts/smoke_two_agents.sh @@ -18,13 +18,13 @@ AP=("$AGENTPOST_BIN" --root "$POST_ROOT") "${AP[@]}" profile-register agent-one \ --display-name "Agent One" --cli python --kind project \ --summary "Owns planning and turns requirements into implementation briefs." \ - --projects agent-one-project --project-roots "$AGENT_ONE_ROOT" \ + --projects agent-demo --project-roots "$AGENT_ONE_ROOT" \ --specialties "planning,requirements" \ --handles "implementation briefs,requirements questions" >/dev/null "${AP[@]}" profile-register agent-two \ --display-name "Agent Two" --cli python --kind role \ --summary "Provides cross-project implementation review and engineering risk analysis." \ - --roles "code review" \ + --roles "code review" --projects agent-demo \ --specialties "code review,engineering risk" \ --handles "implementation reviews,risk analysis" >/dev/null diff --git a/specs/CODEX-SESSION-ATTACH-V1.md b/specs/CODEX-SESSION-ATTACH-V1.md new file mode 100644 index 0000000..b5efb36 --- /dev/null +++ b/specs/CODEX-SESSION-ATTACH-V1.md @@ -0,0 +1,197 @@ +# CODEX-SESSION-ATTACH-V1 + +**Status:** Implemented +**Scope:** No-restart mailbox identity attachment for an active Codex thread +**Origin:** Pattern Buffer Evolution multi-seat identity failure, 2026-07-28 + +## Problem + +An ordinary Codex thread may start from a workspace whose default mailbox is +`pbeo` even though the intended seat is the alternate `pbeocx`. The process +environment and already-loaded hook commands cannot be rewritten safely after +startup. Running `agentpost join pbeocx --cli codex` is the wrong recovery: +`join` owns durable adapter installation and may need global plugin replacement, +which remains forbidden inside a live Codex thread. + +Three concerns must remain independent: + +1. project and adapter binding, owned by `join` and `install`; +2. the mailbox selected for one active Codex thread; +3. global Codex plugin installation or replacement. + +## Command + +From the active Codex thread: + +```sh +agentpost attach pbeocx +``` + +`attach` resolves the explicit mailbox, requires `CODEX_THREAD_ID`, verifies +that the mailbox is a known seat at the current project root, and finds a hook +observation for that exact thread ID. A recognized stable dispatcher ABI is +required. The operation then atomically records an owner-only, session-scoped +identity selection without changing: + +- `AGENTPOST_AGENT` or any parent-process environment; +- the workspace default or adapter binding; +- Codex configuration, user hooks, marketplace state, plugin cache, or trust; +- mailbox presence or a consumer lease. + +`attach` is the chosen verb. It describes adding session-local routing while +avoiding the durable/global implications of `join`, `connect`, or `install`. + +## Identity precedence + +Codex identity resolution is: + +1. an explicit command identity, `--from`, `--agent`, or + `AGENTPOST_AGENT`; +2. a valid attachment for the exact Codex thread; +3. the existing workspace-default, adapter-binding, and declared-root rules. + +The hook uses the thread ID in its event payload. AgentPost CLI subprocesses +inside the thread use `CODEX_THREAD_ID`. Both therefore select the same +mailbox after attachment. An explicit environment identity that conflicts with +the requested mailbox is rejected because the attachment could not take +effect. + +## Storage and lifecycle + +The attachment is stored under: + +```text +~/.agentpost/runtime/codex-sessions/.json +``` + +The raw thread ID is not used as a filename or stored in the document. The +document records schema version, mailbox, project, attachment and expiry times, +and the lifecycle event and hook generation that proved compatibility. + +- The directory is mode `0700`; documents are mode `0600`. +- Reads securely traverse owner-private runtime directories without following + symlinks and reject non-regular files, wrong ownership, permissive directory + or file modes, malformed JSON, schema mismatch, digest mismatch, unknown + hook events, incompatible dispatcher generations, non-finite or incoherent + timestamps, uninitialized mailboxes, and seats no longer reachable from the + recorded project. +- Writes use a same-directory temporary file, `fsync`, and atomic replacement. +- Attachments expire after 30 days. The next lookup removes an expired entry + and resumes normal workspace identity resolution. +- Repeating the same attachment is idempotent. Naming another reachable seat + explicitly rebinds the thread. +- Ordinary Codex resume keeps the attachment until expiry because the thread + ID is stable. Managed resume remains authoritative through its explicit + `AGENTPOST_AGENT`. + +## Hook and plugin compatibility + +The attachment path depends only on the stable dispatcher command and event +thread ID, not on plugin cache equality. Hook generations from +`0.0.3+codex...` onward have the required dispatcher ABI. A thread observed on +`0.0.4` may therefore attach safely while `0.0.5` is installed. Unknown, +malformed, or older observed generations fail before writing session state. + +`attach` never calls the installer. Separately, `join` preserves an installed +newer generation when its release line shares the stable dispatcher ABI; an +older AgentPost runtime must not replace that generation with its own older +artifact. Incompatible or ambiguous generation state retains the existing +terminal-only replacement contract: + +1. close all Codex sessions; +2. run from a terminal; +3. acknowledge with `--confirm-codex-sessions-closed`; +4. replace under the exclusive plugin lock. + +Global Codex plugin replacement or removal remains forbidden from inside a +live Codex thread. + +## Delivery and presence + +For an ordinary already-running thread, success reports: + +```text +ATTACHED pbeocx codex-session +DELIVERY boundary-only observed-hook=0.0.4+... installed-plugin=0.0.5+... +PRESENCE boundary-only; attach publishes no presence and cannot wake an already-idle thread +NEXT for already-idle wake, relaunch with `agentpost codex --agent pbeocx resume THREAD_ID` +``` + +At the next `UserPromptSubmit` or `Stop` boundary, the compatible stable hook +selects the attachment and surfaces exact unread Message-IDs for that mailbox. +This is lifecycle-boundary catch-up, not continuous wake. The hook attempts the +existing ephemeral mailbox consumer lease and never claims mail. + +True already-idle wake and active-turn steering remain the managed-launcher +contract: + +```sh +agentpost codex --agent pbeocx resume THREAD_ID +``` + +That launcher creates the app-server/WebSocket bridge before the client starts. +`attach` does not claim it can manufacture such a bridge in an ordinary +already-running Codex process. It reports `live-bridge` only when the current +managed session already owns that bridge. + +Because a boundary-only attachment has no heartbeat and holds no lease between +events, `status` remains `offline` and `armed` remains `QUEUED`. Those results +are honest and do not invalidate the session identity selection. + +## Doctor diagnostics + +When `doctor` runs inside a thread with a valid attachment, it reports a +separate `codex-session-attachment` check. That check names the exact thread +digest, selected mailbox, boundary-only capability, observed dispatcher +generation, and installed plugin generation. + +The existing `codex-generation` check remains a separate aggregate recovery +signal for the mailbox's `SessionStart`, `UserPromptSubmit`, and `Stop` +observations. It may remain `FAIL` after a successful attachment when older +session boundaries have not re-executed. That failure means a reload is still +needed for complete aggregate generation parity; it does not negate a `PASS` +for the exact attached thread or upgrade boundary-only delivery into live wake. + +## Safety and failure behavior + +- The target must be a registered, initialized mailbox reachable as a seat + from the selected project root. +- The current thread must have an exact hook observation with a compatible ABI. +- A conflicting `AGENTPOST_AGENT` fails before mutation. +- A live inbound consumer for the target mailbox fails before mutation, except + for the current managed session's already-established bridge. +- A hook boundary that later loses the consumer-lease race emits no duplicate + notification and leaves mail unread. +- Corrupt, insecure, expired, unreachable, or incompatible attachment state + never silently selects another attached mailbox. +- No attachment operation changes or downgrades plugin artifacts. + +## Acceptance tests + +The required regression fixture is: + +- workspace default `pbeo`; +- alternate seat `pbeocx`; +- ordinary active Codex thread; +- unset `AGENTPOST_AGENT`; +- active thread observed on hook generation `0.0.4`; +- installed plugin generation `0.0.5`. + +It must attach `pbeocx` without plugin mutation, use `pbeocx` for subsequent +CLI sends, and inject `pbeocx` unread IDs at the next hook boundary without +claiming them. + +Coverage also pins idempotent attach, explicit rebind, wrong or unreachable +mailbox, explicit-environment conflict, stale mapping expiry, insecure state, +unknown/old hook ABI, consumer-lease conflict, managed-resume continuity, +missing live thread identity, and preservation of a compatible newer plugin by +an older `join`. Doctor coverage requires an exact-thread attachment `PASS` to +remain visible beside a stale aggregate generation `FAIL`. + +## Open decision + +No host-supported API for attaching an app-server/WebSocket bridge to an +arbitrary already-running Codex process has been verified. If Codex exposes +one, a later spec may add an explicit capability negotiation that upgrades an +attachment from `boundary-only` to live wake. V1 must not infer or emulate that +capability. diff --git a/specs/PROJECT-QUALIFIED-IDENTITIES-V1.md b/specs/PROJECT-QUALIFIED-IDENTITIES-V1.md new file mode 100644 index 0000000..69aeb0a --- /dev/null +++ b/specs/PROJECT-QUALIFIED-IDENTITIES-V1.md @@ -0,0 +1,97 @@ +# Project-qualified identities v1 + +## Purpose + +AgentPost profiles already record `projects`, but human-facing delivery used to +search every registered profile. A globally unique bare handle such as `nav` +could therefore resolve to a seat in an unrelated project. This contract makes +the existing project metadata an addressing namespace and reserves +`PROJECT.SEAT` for deliberate cross-project communication. + +## Address grammar + +- A qualified human address has exactly one dot: `PROJECT.SEAT`. +- `PROJECT` and `SEAT` use letters, digits, hyphens, or underscores. +- Canonical mailbox keys and registered project names or aliases cannot contain + a dot. A dot in a human address is always interpreted as the qualifier + boundary. +- The project segment matches one exact normalized entry in the profile's + `projects` field. +- Within that project, the seat segment may exactly match a canonical mailbox + key, display name, handle, or role. Ties fail rather than being guessed. + +`agentpost identities` derives a convenient qualified address from each +addressable project alias and the profile's first simple handle, falling back to +the canonical mailbox key. For example: + +```text +mailbox: projecto-n +projects: projecto +handles: nav,roadmap questions +qualified: projecto.nav +``` + +## Resolution rules + +Human channel operations (`message`, `question`, `review`, `notify`, and +sender-bound `AgentChannel`) resolve an unqualified seat only among profiles +that share at least one project entry with the sender. Failure in that scope is +terminal: AgentPost never retries the label against the global directory. + +Cross-project operations qualify the destination: + +```sh +agentpost message nav 'Same-project request.' +agentpost question project-two.codereview 'Cross-project review request.' +``` + +The same rule applies even when the bare canonical mailbox key is globally +unique. This prevents a typo or missing local seat from silently becoming a +delivery to another project. + +A projectless role or specialist has no implicit human-address scope. Add the +projects in which it is an addressable seat, place it in an explicit global +group, or use the low-level canonical-key API. + +Named groups remain global deliberate fan-out objects. `@GROUP` is always the +explicit group form. A bare group name remains accepted when it does not +collide with a same-project identity. + +Low-level `send` and `ask` continue to accept canonical mailbox keys for scripts +that already resolved both endpoints. The exported unscoped +`resolve_identity()` lookup also remains available for compatible programmatic +directory use; sender-bound and CLI human-address surfaces supply the +fail-closed scope. + +## Directory and mailbox inspection + +Project filters include offline seats: + +```sh +agentpost identities --project project-two +agentpost profiles --project project-two --all +agentpost status --project project-two +``` + +`resolve`, `status`, `list`, `read`, and `next` accept either +`PROJECT.SEAT` or `--project PROJECT`. This makes another project's complete +seat roster inspectable without making its bare names locally addressable. + +## Registration and migration + +Each seat should register: + +1. a stable dot-free canonical mailbox key; +2. a stable dot-free project name plus any short dot-free project aliases; +3. a short simple seat handle first, such as `nav`, `build`, or `codereview`; +4. any longer discovery handles after the address handle. + +Existing dot-free profiles require no migration. A previously accepted project +entry containing a dot must be renamed before the profile can be updated. +Previously accepted cross-project bare human delivery is intentionally rejected +as a routing-safety correction; qualify the address instead. + +Rollout automation must verify that the installed command exposes +`agentpost identities --project PROJECT` before registering or messaging +project seats. If it does not, upgrade AgentPost and do not fall back to global +bare-handle routing. diff --git a/specs/SAFE-WIPE-V1.md b/specs/SAFE-WIPE-V1.md new file mode 100644 index 0000000..2ade13e --- /dev/null +++ b/specs/SAFE-WIPE-V1.md @@ -0,0 +1,85 @@ +# Safe wipe workflow v1 + +## Scope + +`agentpost wipe` provides an intentional clean start for AgentPost identity +state. It never deletes a source repository, an AgentBridge repository, or +files outside AgentPost-owned mailbox metadata and workspace markers. + +The scopes are: + +```sh +agentpost wipe agent [NAME] +agentpost wipe project PROJECT +agentpost wipe all +``` + +An agent wipe removes the complete mailbox directory, including its profile, +unread/read/sent mail, adapter state, notification pointers, and consumer +ownership. It also removes the mailbox's adapter bindings, updates or removes +affected `.agentpost.toml` workspace markers, removes it from named groups, and +deletes a group if no members remain. + +A project wipe applies that operation to every profile whose `projects` field +exactly matches the named project or alias. A profile that lists the project is +an affected mailbox even if it also lists other project entries. + +An all-agent wipe removes every registered mailbox while retaining the +post-office root and its connection-mode configuration for a clean +re-registration. + +Copies stored in an unaffected mailbox remain that mailbox's durable history. +For example, wiping a sender does not erase the recipient's received copy. + +## Confirmation contract + +An agent may wipe its currently resolved mailbox without an additional +confirmation flag: + +```sh +agentpost wipe agent +``` + +This is intended as the final action of that seat. The running session must not +continue using the deleted identity. + +Wiping another mailbox, a project, or all mailboxes is broader authority. The +first invocation performs no deletion and returns a deterministic line such as: + +```text +confirmation required before wiping project pbe. Affected mailboxes: pbe-i,pbe-n,pbe-r. Ask the user to confirm this exact list, then rerun with `--confirm 'pbe-i,pbe-n,pbe-r'`. +``` + +The agent must show that list to the user and receive explicit confirmation +before rerunning the exact command. The comma-separated confirmation value must +match the current sorted mailbox set byte-for-byte. If a mailbox is added or +removed between preview and execution, the stale confirmation fails and a new +list must be confirmed. + +Any held mailbox consumer lease must be stopped before its mailbox can be +wiped, including a live adapter for a nominal self-wipe. Self-wipe is exempt +from the affected-box confirmation only; after closing the adapter it can be +run from a terminal with `AGENTPOST_AGENT=NAME`. The command acquires the +authoritative consumer leases for every target and holds those fences through +mailbox detachment, so consumer startup cannot race a confirmed wipe. +Profile registration and wipe also share a root-level mailbox-namespace lock. +Supported same-name creation therefore cannot replace the staged mailbox and +acquire a different consumer-lock inode during the transaction. Wipe +derives the current target set, validates confirmation, acquires consumer +fences, and commits while holding that namespace lock, then revalidates every +target's absence before commit. + +Codex attachment cleanup securely opens each owner-private runtime directory +without following symlinks, validates selected files, and unlinks relative to +the held directory descriptor. A symlinked, non-owned, or permissive +attachment path fails before any mailbox mutation. + +## Recovery + +Successful wipe is irreversible within AgentPost. The command reports the +removed mailbox list and states that there is no AgentPost recovery. Recovery +requires an independent filesystem backup. Hidden staging is used only to keep +metadata cleanup coherent if a pre-commit filesystem operation fails. +If an unsupported direct filesystem change recreates a target and prevents +rollback, AgentPost must not discard the staged original. It fails closed, +retains the recovery stage, and reports that exact path for manual recovery. diff --git a/src/agentpost/__init__.py b/src/agentpost/__init__.py index 6f10cd4..0864764 100644 --- a/src/agentpost/__init__.py +++ b/src/agentpost/__init__.py @@ -1,4 +1,4 @@ -__version__ = "1.2.0" +__version__ = "1.3.0" from .core import ( AgentPostError, @@ -21,7 +21,9 @@ AgentMatch, find_agents, identify_agent, + project_profiles, project_candidates, + qualified_addresses, resolve_channel_recipients, resolve_group, resolve_identity, @@ -74,6 +76,8 @@ "ask", "panel_status", "project_candidates", + "project_profiles", + "qualified_addresses", "resolve_channel_recipients", "resolve_group", "resolve_recipients", diff --git a/src/agentpost/channel.py b/src/agentpost/channel.py index bcef228..09871b6 100644 --- a/src/agentpost/channel.py +++ b/src/agentpost/channel.py @@ -6,7 +6,12 @@ from .core import FanoutResult, PostOffice, Profile from .panels import ask from .presence import agent_presence -from .routing import resolve_channel_recipients, resolve_identity +from .routing import ( + project_profiles, + qualified_addresses, + resolve_channel_recipients, + resolve_identity, +) from .review import prepare_review, render_review_request @@ -16,6 +21,7 @@ class Identity: display_name: str presence: str summary: str + qualified: tuple[str, ...] = () class AgentChannel: @@ -31,15 +37,21 @@ def __init__( self.office = office or PostOffice(root) self.sender = resolve_identity(self.office, sender).name - def identities(self) -> tuple[Identity, ...]: + def identities(self, *, project: str | None = None) -> tuple[Identity, ...]: + profiles = ( + project_profiles(self.office, project) + if project is not None + else self.office.list_profiles() + ) return tuple( Identity( name=profile.name, display_name=profile.display_name, presence=agent_presence(self.office, profile.name).state, summary=profile.summary, + qualified=qualified_addresses(profile), ) - for profile in self.office.list_profiles() + for profile in profiles ) def resolve(self, address: str) -> tuple[Profile, ...]: diff --git a/src/agentpost/cli.py b/src/agentpost/cli.py index 3ecdc08..f71dd07 100644 --- a/src/agentpost/cli.py +++ b/src/agentpost/cli.py @@ -6,12 +6,20 @@ import sys from pathlib import Path -from .core import AgentPostError, MessageNotFoundError, PostOffice, Profile +from .core import ( + AgentPostError, + MessageNotFoundError, + PostOffice, + Profile, + UnknownAgentError, +) from .routing import ( find_agents, identify_agent, identify_agent_source, + project_profiles, project_candidates, + qualified_addresses, resolve_channel_recipients, resolve_group, resolve_identity, @@ -20,7 +28,9 @@ ) from .panels import ask, panel_status, wait_for_panel from .adapters import MailboxWatcher +from .codex_session import attach_codex_session from .installer import armed, doctor, install, uninstall, upgrade +from .ownership import ConsumerLease from .presence import agent_presence from .review import prepare_review, render_review_request from .native import ( @@ -41,10 +51,11 @@ decisions or outputs it can help with. Use terms a coworker would actually search. roles Broad workplace functions, such as release engineering. - projects Stable project names and aliases users will mention. + projects Stable dot-free project names and aliases users will use + in PROJECT.SEAT addresses. specialties Specific reusable technical or domain expertise. - handles Two to five concrete request categories this agent - should receive, such as schema reviews or launch copy. + handles Two to five concrete request categories. Put a simple + local seat handle first (nav, build, codereview). does-not-handle Nearby responsibilities owned elsewhere. Prefer: "Owns Pattern Buffer temporal world-state semantics, ingestion @@ -91,7 +102,10 @@ def build_parser() -> argparse.ArgumentParser: epilog=_PROFILE_GUIDANCE, formatter_class=argparse.RawDescriptionHelpFormatter, ) - profile.add_argument("name", help="stable short mailbox address") + profile.add_argument( + "name", + help="stable short dot-free mailbox address; dot is reserved", + ) profile.add_argument( "--display-name", required=True, help="recognizable human-facing name" ) @@ -110,7 +124,11 @@ def build_parser() -> argparse.ArgumentParser: ) profile.add_argument("--organization", help="optional stable organization or team") profile.add_argument("--roles", default="", help="comma-separated workplace functions") - profile.add_argument("--projects", default="", help="comma-separated project names or aliases") + profile.add_argument( + "--projects", + default="", + help="comma-separated dot-free project names or aliases", + ) profile.add_argument( "--project-roots", default="", help="comma-separated absolute project roots" ) @@ -128,6 +146,7 @@ def build_parser() -> argparse.ArgumentParser: profiles_mode = profiles.add_mutually_exclusive_group() profiles_mode.add_argument("--all", action="store_true") profiles_mode.add_argument("--offline", action="store_true") + profiles.add_argument("--project") find = commands.add_parser("agents-find") find.add_argument("query", nargs="?") @@ -136,9 +155,11 @@ def build_parser() -> argparse.ArgumentParser: find.add_argument("--specialty") find.add_argument("--all", action="store_true") - commands.add_parser("identities") + identities = commands.add_parser("identities") + identities.add_argument("--project") resolve = commands.add_parser("resolve") resolve.add_argument("label") + resolve.add_argument("--project") group = commands.add_parser("group-set") group.add_argument("name") @@ -160,6 +181,15 @@ def build_parser() -> argparse.ArgumentParser: join.add_argument("--cli", choices=("antigravity", "claude", "codex", "python")) join.add_argument("--project", type=Path, default=Path.cwd()) join.add_argument("--confirm-codex-sessions-closed", action="store_true") + attach = commands.add_parser( + "attach", + description=( + "Bind this already-running Codex thread to a reachable mailbox " + "without changing the workspace default or global plugin." + ), + ) + attach.add_argument("agent") + attach.add_argument("--project", type=Path, default=Path.cwd()) disconnect = commands.add_parser("disconnect") disconnect.add_argument( "--cli", choices=("antigravity", "claude", "codex"), required=True @@ -168,6 +198,24 @@ def build_parser() -> argparse.ArgumentParser: commands.add_parser("bindings") status = commands.add_parser("status") status.add_argument("agent", nargs="?") + status.add_argument("--project") + + wipe = commands.add_parser( + "wipe", + description=( + "Irreversibly remove AgentPost mailbox state. This never deletes " + "source or bridge repositories." + ), + ) + wipe_targets = wipe.add_subparsers(dest="wipe_scope", required=True) + wipe_agent = wipe_targets.add_parser("agent") + wipe_agent.add_argument("agent", nargs="?") + wipe_agent.add_argument("--confirm") + wipe_project = wipe_targets.add_parser("project") + wipe_project.add_argument("project") + wipe_project.add_argument("--confirm") + wipe_all = wipe_targets.add_parser("all") + wipe_all.add_argument("--confirm") send = commands.add_parser("send") send.add_argument("sender") @@ -235,14 +283,17 @@ def build_parser() -> argparse.ArgumentParser: listing = commands.add_parser("list") listing.add_argument("agent") listing.add_argument("--state", choices=("unread", "read", "sent"), default="unread") + listing.add_argument("--project") read = commands.add_parser("read") read.add_argument("agent") read.add_argument("message_id") + read.add_argument("--project") claim = commands.add_parser("next") claim.add_argument("agent") claim.add_argument("--message-id") + claim.add_argument("--project") watch = commands.add_parser( "watch", @@ -375,7 +426,12 @@ def main(argv: list[str] | None = None) -> int: ) print(office.register_profile(profile)) elif args.command == "profiles": - for profile in office.list_profiles(): + profiles = ( + project_profiles(office, args.project) + if args.project + else office.list_profiles() + ) + for profile in profiles: presence = agent_presence(office, profile.name) if not args.all and not args.offline and not presence.active: continue @@ -402,18 +458,32 @@ def main(argv: list[str] | None = None) -> int: f"\t{evidence}" ) elif args.command == "identities": - print("type\taddress\tattention\tdisplay\tprojects\thandles\tsummary") - for profile in office.list_profiles(): + print( + "type\taddress\tattention\tqualified\tdisplay\tprojects\t" + "handles\tsummary" + ) + profiles = ( + project_profiles(office, args.project) + if args.project + else office.list_profiles() + ) + for profile in profiles: presence = agent_presence(office, profile.name) + qualified = ",".join(qualified_addresses(profile)) or "-" print( f"agent\t{profile.name}\t{presence.state}\t" + f"{qualified}\t" f"{profile.display_name}\t{','.join(profile.projects)}\t" f"{','.join(profile.handles)}\t{profile.summary}" ) - for name, members in sorted(office.list_groups().items()): - print(f"group\t@{name}\t-\t{name}\t{','.join(members)}") + if args.project is None: + for name, members in sorted(office.list_groups().items()): + print( + f"group\t@{name}\t-\t-\t{name}\t-\t-\t" + f"{','.join(members)}" + ) elif args.command == "resolve": - _print_resolution(office, args.label) + _print_resolution(office, args.label, project=args.project) elif args.command == "group-set": print(office.set_group(args.name, _csv(args.members))) elif args.command == "groups": @@ -436,6 +506,47 @@ def main(argv: list[str] | None = None) -> int: args.project, confirm_codex_sessions_closed=args.confirm_codex_sessions_closed, ) + elif args.command == "attach": + profile = office.load_profile( + _resolve_mailbox_address(office, args.agent) + ) + thread_id = os.environ.get("CODEX_THREAD_ID", "") + project = args.project.expanduser().resolve() + result = attach_codex_session( + office, + profile.name, + thread_id, + project, + allowed_agents=workspace_seats(office, project), + explicit_agent=os.environ.get("AGENTPOST_AGENT"), + bridge_active=os.environ.get("AGENTPOST_CODEX_BRIDGE") == "1", + ) + installed = result.installed_generation or ( + f"unknown ({result.installed_problem})" + ) + print( + f"{result.state.upper()}\t{profile.name}\tcodex-session\t" + f"{result.attachment.session_digest[:12]}" + ) + print( + f"DELIVERY\t{result.delivery}\t" + f"observed-hook={result.attachment.observed_generation}\t" + f"installed-plugin={installed}" + ) + print( + "PRESENCE\t" + + ( + "managed live bridge remains wake-capable" + if result.delivery == "live-bridge" + else "boundary-only; attach publishes no presence and cannot " + "wake an already-idle thread" + ) + ) + if result.delivery != "live-bridge": + print( + f"NEXT\tfor already-idle wake, relaunch with `agentpost codex " + f"--agent {profile.name} resume {thread_id}`" + ) elif args.command == "disconnect": if not office.unbind_agent(args.cli, args.project): raise ValueError( @@ -445,14 +556,25 @@ def main(argv: list[str] | None = None) -> int: for binding in office.list_bindings(): print(f"{binding.agent}\t{binding.cli}\t{binding.project}") elif args.command == "status": - names = ( - (args.agent,) - if args.agent - else tuple(profile.name for profile in office.list_profiles()) - ) + if args.agent: + names = ( + _resolve_mailbox_address( + office, + args.agent, + project=args.project, + ), + ) + elif args.project: + names = tuple( + profile.name for profile in project_profiles(office, args.project) + ) + else: + names = tuple(profile.name for profile in office.list_profiles()) for name in names: presence = agent_presence(office, name) print(f"{name}\t{presence.state}\t{presence.detail}") + elif args.command == "wipe": + _wipe(office, args) elif args.command == "send": recipients = resolve_recipients( office, @@ -584,7 +706,11 @@ def main(argv: list[str] | None = None) -> int: _warn_unarmed(office, recipients) elif args.command == "notify": sender = _channel_sender(office, args.sender) - recipient = resolve_identity(office, args.recipient).name + recipient = resolve_identity( + office, + args.recipient, + sender=sender, + ).name request = office.request_notification( sender, recipient, @@ -604,17 +730,32 @@ def main(argv: list[str] | None = None) -> int: ) ) elif args.command == "list": - for record in office.list_messages(args.agent, args.state): + agent = _resolve_mailbox_address( + office, + args.agent, + project=args.project, + ) + for record in office.list_messages(agent, args.state): letter = record.letter print( f"{letter.message_id}\t{letter.from_agent}\t" f"{letter.kind}\t{letter.subject or ''}" ) elif args.command == "read": - record = office.read(args.agent, args.message_id) + agent = _resolve_mailbox_address( + office, + args.agent, + project=args.project, + ) + record = office.read(agent, args.message_id) sys.stdout.buffer.write(record.path.read_bytes()) elif args.command == "next": - record = office.claim(args.agent, args.message_id) + agent = _resolve_mailbox_address( + office, + args.agent, + project=args.project, + ) + record = office.claim(agent, args.message_id) sys.stdout.buffer.write(record.path.read_bytes()) elif args.command == "watch": watcher = MailboxWatcher(office, args.agent, args.interval) @@ -818,7 +959,19 @@ def _mailbox_miss( def _channel_sender(office: PostOffice, requested: str | None) -> str: if requested is not None: - return resolve_identity(office, requested).name + try: + acting = identify_agent( + office, + Path.cwd(), + agent=os.environ.get("AGENTPOST_AGENT"), + ).name + except (AgentPostError, OSError, ValueError): + acting = None + return _resolve_mailbox_address( + office, + requested, + sender=acting, + ) return identify_agent( office, Path.cwd(), @@ -890,13 +1043,25 @@ def _holds_letter(office: PostOffice, agent: str, message_id: str) -> bool: return True -def _print_resolution(office: PostOffice, label: str) -> None: +def _print_resolution( + office: PostOffice, + label: str, + *, + project: str | None = None, +) -> None: groups = office.list_groups() name = resolve_group(office, label) if name is not None: + if project is not None: + raise ValueError( + "groups are global addresses and cannot be combined with --project; " + "use @GROUP explicitly" + ) if not label.startswith("@"): try: - identity = resolve_identity(office, label) + identity = office.load_profile( + _resolve_mailbox_address(office, label) + ) except AgentPostError: identity = None if identity is not None: @@ -906,9 +1071,158 @@ def _print_resolution(office: PostOffice, label: str) -> None: ) print(f"group\t@{name}\t{','.join(groups[name])}") return - profile = resolve_identity(office, label) + profile = office.load_profile( + _resolve_mailbox_address( + office, + label, + project=project, + ) + ) presence = agent_presence(office, profile.name) - print(f"agent\t{profile.name}\t{presence.state}\t{profile.display_name}") + qualified = ",".join(qualified_addresses(profile)) or "-" + print( + f"agent\t{profile.name}\t{presence.state}\t{qualified}\t" + f"{profile.display_name}" + ) + + +def _resolve_mailbox_address( + office: PostOffice, + label: str, + *, + project: str | None = None, + sender: str | None = None, +) -> str: + if project is not None or "." in label: + return resolve_identity( + office, + label, + project=project, + sender=sender, + ).name + if sender is None: + try: + sender = identify_agent( + office, + Path.cwd(), + agent=os.environ.get("AGENTPOST_AGENT"), + ).name + except (AgentPostError, OSError, ValueError): + sender = None + if sender is not None: + return resolve_identity(office, label, sender=sender).name + known = {profile.name for profile in office.list_profiles()} + if label in known: + return office.load_profile(label).name + raise UnknownAgentError( + f"unqualified AgentPost address {label!r} has no project context; " + "use PROJECT.SEAT, --project PROJECT, or a canonical mailbox key" + ) + + +def _wipe(office: PostOffice, args: argparse.Namespace) -> None: + with office._locked_mailbox_namespace(): + _wipe_locked(office, args) + + +def _wipe_locked(office: PostOffice, args: argparse.Namespace) -> None: + try: + acting = identify_agent( + office, + Path.cwd(), + agent=os.environ.get("AGENTPOST_AGENT"), + ).name + except (AgentPostError, OSError, ValueError): + acting = None + + missing_project_error = None + if args.wipe_scope == "agent": + if args.agent is None: + if acting is None: + raise UnknownAgentError( + "cannot infer this agent; name the mailbox to wipe" + ) + targets = (acting,) + else: + targets = ( + _resolve_mailbox_address( + office, + args.agent, + sender=acting, + ), + ) + label = f"agent {targets[0]}" + confirmation_required = acting != targets[0] + elif args.wipe_scope == "project": + try: + targets = tuple( + profile.name for profile in project_profiles(office, args.project) + ) + except UnknownAgentError as exc: + targets = () + missing_project_error = exc + label = f"project {args.project}" + confirmation_required = True + else: + targets = tuple(profile.name for profile in office.list_profiles()) + label = "all agents" + confirmation_required = bool(targets) + + expected = ",".join(sorted(targets)) + if args.confirm is not None and args.confirm != expected: + raise AgentPostError( + f"wipe confirmation does not match the current affected mailboxes. " + f"Affected mailboxes: {expected or '(none)'}" + ) + if missing_project_error is not None: + raise missing_project_error + if confirmation_required and args.confirm != expected: + raise AgentPostError( + f"confirmation required before wiping {label}. Affected mailboxes: " + f"{expected}. Ask the user to confirm this exact list, then rerun " + f"with `--confirm '{expected}'`." + ) + if not targets: + if args.wipe_scope == "all": + office._wipe_agents_locked((), purge_all_attachments=True) + print(f"WIPED\t{args.wipe_scope}\t-") + print("RECOVERY\tnothing was deleted") + return + + fences = [] + try: + for name in sorted(targets): + fence = ConsumerLease( + office, + name, + "wipe-fence", + cwd=args.project if args.wipe_scope == "project" else Path.cwd(), + ) + if not fence.acquire(): + owner = fence.current_owner() + detail = ( + f"{owner.get('adapter', 'unknown')} pid " + f"{owner.get('pid', '?')} instance " + f"{owner.get('instance_id', '?')}" + if owner + else "another live instance" + ) + raise AgentPostError( + f"stop the live mailbox consumer for {name} before wiping: " + f"{detail}" + ) + fences.append(fence) + removed = office._wipe_agents_locked( + targets, + purge_all_attachments=args.wipe_scope == "all", + ) + finally: + for fence in reversed(fences): + fence.release() + print(f"WIPED\t{args.wipe_scope}\t{','.join(removed)}") + print( + "RECOVERY\tirreversible; copies held by unaffected mailboxes were not removed" + ) def _print_channel_delivery(office, sender, recipients, result) -> None: diff --git a/src/agentpost/codex_generation.py b/src/agentpost/codex_generation.py index 1751317..2d72c8e 100644 --- a/src/agentpost/codex_generation.py +++ b/src/agentpost/codex_generation.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import re import tomllib from dataclasses import dataclass from pathlib import Path @@ -9,8 +10,12 @@ CODEX_PLUGIN_ID = "agentpost@agentpost-local" -CODEX_HOOK_GENERATION = "0.0.5+codex.20260712082137" +CODEX_HOOK_GENERATION = "0.0.6+codex.20260728214546" CODEX_HOOK_EVENTS = ("session-start", "user-prompt-submit", "stop") +CODEX_STABLE_DISPATCH_MIN_RELEASE = (0, 0, 3) +_CODEX_GENERATION_RE = re.compile( + r"^(\d+)\.(\d+)\.(\d+)(?:\+codex\.(\d{14}))?$" +) @dataclass(frozen=True) @@ -25,6 +30,41 @@ def current(self) -> bool: return self.state == "current" +def codex_generation_release(generation: str) -> tuple[int, int, int] | None: + match = _CODEX_GENERATION_RE.fullmatch(generation) + if match is None: + return None + return tuple(int(value) for value in match.groups()[:3]) + + +def codex_newer_generation_is_compatible( + installed: str, + expected: str, +) -> bool: + installed_release = codex_generation_release(installed) + expected_release = codex_generation_release(expected) + if installed_release is None or expected_release is None: + return False + if ( + installed_release[:2] != expected_release[:2] + or expected_release < CODEX_STABLE_DISPATCH_MIN_RELEASE + ): + return False + if installed_release > expected_release: + return True + if installed_release != expected_release: + return False + installed_match = _CODEX_GENERATION_RE.fullmatch(installed) + expected_match = _CODEX_GENERATION_RE.fullmatch(expected) + installed_stamp = installed_match.group(4) if installed_match else None + expected_stamp = expected_match.group(4) if expected_match else None + return bool( + installed_stamp + and expected_stamp + and installed_stamp > expected_stamp + ) + + def codex_hook_marker(office: PostOffice, agent: str, event: str) -> Path: return ( office.root diff --git a/src/agentpost/codex_session.py b/src/agentpost/codex_session.py new file mode 100644 index 0000000..1a8f063 --- /dev/null +++ b/src/agentpost/codex_session.py @@ -0,0 +1,446 @@ +from __future__ import annotations + +import hashlib +import json +import math +import os +import stat +import time +import uuid +from dataclasses import dataclass +from pathlib import Path + +from .codex_generation import ( + CODEX_HOOK_EVENTS, + CODEX_STABLE_DISPATCH_MIN_RELEASE, + _installed_codex_generation, + codex_generation_release, + codex_hook_marker, +) +from .core import ( + PRIVATE_FILE_MODE, + AgentPostError, + PostOffice, + _open_private_runtime_subdirectory, + _read_private_file_at, +) +from .ownership import ConsumerLease + + +CODEX_SESSION_ATTACHMENT_SCHEMA = 1 +CODEX_SESSION_ATTACHMENT_TTL_SECONDS = 30 * 24 * 60 * 60 +_MAX_ATTACHMENT_BYTES = 16 * 1024 + + +@dataclass(frozen=True) +class CodexSessionAttachment: + agent: str + session_digest: str + project: str + attached_at: float + expires_at: float + observed_event: str + observed_generation: str + + +@dataclass(frozen=True) +class CodexAttachResult: + attachment: CodexSessionAttachment + state: str + delivery: str + installed_generation: str | None + installed_problem: str + + +@dataclass(frozen=True) +class _CodexSessionObservation: + agent: str + event: str + generation: str + observed_at: float + cwd: str + + +def codex_session_attachment_path( + office: PostOffice, + session_id: str, +) -> Path: + digest = _session_digest(session_id) + return office.root / "runtime" / "codex-sessions" / f"{digest}.json" + + +def load_codex_session_attachment( + office: PostOffice, + session_id: str, + *, + now: float | None = None, +) -> CodexSessionAttachment | None: + path = codex_session_attachment_path(office, session_id) + try: + payload = _read_private_json(office, path) + except FileNotFoundError: + return None + try: + attachment = CodexSessionAttachment( + agent=str(payload["agent"]), + session_digest=str(payload["session_digest"]), + project=str(payload["project"]), + attached_at=float(payload["attached_at"]), + expires_at=float(payload["expires_at"]), + observed_event=str(payload["observed_event"]), + observed_generation=str(payload["observed_generation"]), + ) + schema = int(payload["schema"]) + except (KeyError, TypeError, ValueError) as exc: + raise AgentPostError( + f"invalid Codex session attachment at {path}: {exc}" + ) from exc + if schema != CODEX_SESSION_ATTACHMENT_SCHEMA: + raise AgentPostError( + f"unsupported Codex session attachment schema {schema} at {path}" + ) + if attachment.session_digest != _session_digest(session_id): + raise AgentPostError(f"Codex session attachment key mismatch at {path}") + current_time = time.time() if now is None else now + if not all( + math.isfinite(value) + for value in ( + attachment.attached_at, + attachment.expires_at, + current_time, + ) + ): + raise AgentPostError( + f"non-finite Codex session attachment timestamp at {path}" + ) + if ( + attachment.attached_at < 0 + or attachment.expires_at <= attachment.attached_at + or not math.isclose( + attachment.expires_at - attachment.attached_at, + CODEX_SESSION_ATTACHMENT_TTL_SECONDS, + rel_tol=0, + abs_tol=1e-6, + ) + ): + raise AgentPostError( + f"incoherent Codex session attachment timestamps at {path}" + ) + if attachment.observed_event not in CODEX_HOOK_EVENTS: + raise AgentPostError( + f"invalid Codex session attachment event " + f"{attachment.observed_event!r} at {path}" + ) + _require_compatible_generation(attachment.observed_generation) + if attachment.expires_at <= current_time: + _unlink_attachment(office, path) + return None + profile = office.load_profile(attachment.agent) + mailbox = office.root / "agents" / profile.name / "unread" + try: + mailbox_details = mailbox.lstat() + except OSError as exc: + raise AgentPostError( + f"Codex session attachment mailbox is not initialized: " + f"{profile.name}" + ) from exc + if not stat.S_ISDIR(mailbox_details.st_mode): + raise AgentPostError( + f"Codex session attachment mailbox is not initialized: " + f"{profile.name}" + ) + project = Path(attachment.project) + if ( + not project.is_absolute() + or str(project.expanduser().resolve()) != attachment.project + ): + raise AgentPostError( + f"invalid Codex session attachment project at {path}" + ) + from .routing import workspace_seats + + if profile.name not in workspace_seats(office, project): + raise AgentPostError( + f"Codex session attachment mailbox {profile.name} is no longer " + f"reachable from {project}" + ) + return attachment + + +def attach_codex_session( + office: PostOffice, + agent: str, + session_id: str, + project: Path, + *, + allowed_agents: tuple[str, ...], + explicit_agent: str | None = None, + bridge_active: bool = False, + now: float | None = None, + home: Path | None = None, +) -> CodexAttachResult: + if not session_id: + raise AgentPostError( + "`agentpost attach` requires a live Codex thread with CODEX_THREAD_ID" + ) + profile = office.load_profile(agent) + project = project.expanduser().resolve() + if profile.name not in allowed_agents: + choices = ", ".join(allowed_agents) or "none" + raise AgentPostError( + f"mailbox {profile.name} is not reachable from {project}; " + f"workspace seats: {choices}" + ) + mailbox = office.root / "agents" / profile.name / "unread" + if not mailbox.is_dir(): + raise AgentPostError(f"mailbox {profile.name} is not initialized") + if explicit_agent is not None and explicit_agent != profile.name: + raise AgentPostError( + f"AGENTPOST_AGENT={explicit_agent} outranks a session attachment; " + f"restart with that variable unset or attach {explicit_agent}" + ) + + current_time = time.time() if now is None else now + existing = load_codex_session_attachment(office, session_id, now=current_time) + if existing is None: + observation = _compatible_session_observation(office, session_id) + else: + _require_compatible_generation(existing.observed_generation) + observation = _CodexSessionObservation( + agent=existing.agent, + event=existing.observed_event, + generation=existing.observed_generation, + observed_at=existing.attached_at, + cwd=existing.project, + ) + probe = ConsumerLease(office, profile.name, "codex-attach-probe", cwd=project) + owner = probe.current_owner() + managed = _managed_bridge_matches( + office, + profile.name, + explicit_agent=explicit_agent, + bridge_active=bridge_active, + owner=owner, + ) + if not managed: + if not probe.acquire(): + owner = probe.current_owner() + detail = ( + f"{owner.get('adapter', 'unknown')} pid {owner.get('pid', '?')} " + f"instance {owner.get('instance_id', '?')}" + if owner + else "another live instance" + ) + raise AgentPostError( + f"mailbox {profile.name} already has an inbound consumer: {detail}; " + "attach did not change the session identity" + ) + probe.release() + + state = "attached" + if existing is not None and existing.agent == profile.name: + attachment = existing + state = "current" + else: + state = "rebound" if existing is not None else "attached" + attachment = CodexSessionAttachment( + agent=profile.name, + session_digest=_session_digest(session_id), + project=str(project), + attached_at=current_time, + expires_at=current_time + CODEX_SESSION_ATTACHMENT_TTL_SECONDS, + observed_event=observation.event, + observed_generation=observation.generation, + ) + _write_attachment( + office, + codex_session_attachment_path(office, session_id), + attachment, + ) + + installed, problem = _installed_codex_generation(home or Path.home()) + return CodexAttachResult( + attachment=attachment, + state=state, + delivery="live-bridge" if managed else "boundary-only", + installed_generation=installed, + installed_problem=problem, + ) + + +def _compatible_session_observation( + office: PostOffice, + session_id: str, +) -> _CodexSessionObservation: + observations = [] + for profile in office.list_profiles(): + for event in CODEX_HOOK_EVENTS: + marker = codex_hook_marker(office, profile.name, event) + try: + payload = json.loads(marker.read_text(encoding="utf-8")) + marker_session = str(payload["session_id"]) + generation = str(payload["generation"]) + observed_at = float(payload["observed_at"]) + except (OSError, KeyError, TypeError, ValueError, json.JSONDecodeError): + continue + if marker_session != session_id: + continue + if not math.isfinite(observed_at): + continue + observations.append( + _CodexSessionObservation( + agent=profile.name, + event=event, + generation=generation, + observed_at=observed_at, + cwd=str(payload.get("cwd", "")), + ) + ) + if not observations: + raise AgentPostError( + "the current CODEX_THREAD_ID has not been observed by an AgentPost " + "Codex hook; attach cannot verify a compatible lifecycle boundary. " + "Submit a prompt after trusting the stable AgentPost hooks, or use " + "`agentpost codex --agent MAILBOX resume THREAD_ID`" + ) + observation = max(observations, key=lambda item: item.observed_at) + _require_compatible_generation(observation.generation) + return observation + + +def _require_compatible_generation(generation: str) -> None: + release = codex_generation_release(generation) + if release is None or release < CODEX_STABLE_DISPATCH_MIN_RELEASE: + expected = ".".join( + str(item) for item in CODEX_STABLE_DISPATCH_MIN_RELEASE + ) + raise AgentPostError( + f"Codex hook generation {generation!r} has unknown or " + f"incompatible attach ABI; generation {expected} or newer is required. " + "No session or plugin state was changed" + ) + + +def _session_digest(session_id: str) -> str: + if not session_id or len(session_id) > 512 or "\x00" in session_id: + raise AgentPostError("invalid CODEX_THREAD_ID") + return hashlib.sha256(session_id.encode("utf-8")).hexdigest() + + +def _codex_bridge_marker(office: PostOffice, agent: str) -> Path: + return office.root / "agents" / agent / "adapter" / "codex-bridge.active" + + +def _managed_bridge_matches( + office: PostOffice, + agent: str, + *, + explicit_agent: str | None, + bridge_active: bool, + owner: dict, +) -> bool: + if not bridge_active or explicit_agent != agent or owner.get("adapter") != "codex": + return False + try: + marker = json.loads( + _codex_bridge_marker(office, agent).read_text(encoding="utf-8") + ) + except (OSError, json.JSONDecodeError): + return False + return bool( + isinstance(marker, dict) + and marker.get("instance_id") + and marker.get("instance_id") == owner.get("instance_id") + ) + + +def _read_private_json(office: PostOffice, path: Path) -> dict: + try: + directory = _open_private_runtime_subdirectory( + office.root, + ("runtime", "codex-sessions"), + ) + except FileNotFoundError: + raise + try: + contents = _read_private_file_at(directory, path.name, path) + finally: + os.close(directory) + if len(contents) > _MAX_ATTACHMENT_BYTES: + raise AgentPostError(f"Codex session attachment is too large: {path}") + try: + value = json.loads(contents) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise AgentPostError(f"invalid Codex session attachment at {path}: {exc}") from exc + if not isinstance(value, dict): + raise AgentPostError(f"invalid Codex session attachment at {path}") + return value + + +def _unlink_attachment(office: PostOffice, path: Path) -> None: + directory = _open_private_runtime_subdirectory( + office.root, + ("runtime", "codex-sessions"), + ) + try: + try: + os.unlink(path.name, dir_fd=directory) + except FileNotFoundError: + pass + os.fsync(directory) + finally: + os.close(directory) + + +def _write_attachment( + office: PostOffice, + path: Path, + attachment: CodexSessionAttachment, +) -> None: + directory = _open_private_runtime_subdirectory( + office.root, + ("runtime", "codex-sessions"), + create=True, + ) + temporary = f".{path.name}.{uuid.uuid4().hex}.tmp" + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + descriptor = os.open( + temporary, + flags, + PRIVATE_FILE_MODE, + dir_fd=directory, + ) + try: + with os.fdopen(descriptor, "w", encoding="utf-8") as handle: + json.dump( + { + "schema": CODEX_SESSION_ATTACHMENT_SCHEMA, + "agent": attachment.agent, + "session_digest": attachment.session_digest, + "project": attachment.project, + "attached_at": attachment.attached_at, + "expires_at": attachment.expires_at, + "observed_event": attachment.observed_event, + "observed_generation": attachment.observed_generation, + }, + handle, + sort_keys=True, + allow_nan=False, + ) + handle.flush() + os.fsync(handle.fileno()) + os.replace( + temporary, + path.name, + src_dir_fd=directory, + dst_dir_fd=directory, + ) + os.fsync(directory) + finally: + try: + os.unlink(temporary, dir_fd=directory) + except FileNotFoundError: + pass + os.close(directory) diff --git a/src/agentpost/core.py b/src/agentpost/core.py index c03b8c7..fa6f0d5 100644 --- a/src/agentpost/core.py +++ b/src/agentpost/core.py @@ -4,6 +4,7 @@ import json import os import re +import shutil import stat import sys import time @@ -93,6 +94,12 @@ def validate(self) -> None: raise ValueError(f"{label} must not be empty") if not (self.roles or self.projects or self.specialties): raise ValueError("profile needs at least one role, project, or specialty") + dotted_projects = [value for value in self.projects if "." in value] + if dotted_projects: + raise ValueError( + "project names and aliases must not contain '.'; dot is reserved " + "for PROJECT.SEAT addresses" + ) @dataclass(frozen=True) @@ -278,6 +285,16 @@ def agents_dir(self) -> Path: def bindings_dir(self) -> Path: return self.root / "bindings" + @property + def _mailbox_namespace_lock_path(self) -> Path: + return self.root / ".mailbox-namespace.lock" + + @contextmanager + def _locked_mailbox_namespace(self): + self.initialize() + with _exclusive_lock(self._mailbox_namespace_lock_path): + yield + def initialize(self, connection_mode: str | None = None) -> Path: _private_directory(self.root, parents=True) _private_directory(self.agents_dir) @@ -351,15 +368,15 @@ def migrate(self) -> tuple[str, ...]: def register_profile(self, profile: Profile) -> Path: profile.validate() - self.initialize() - agent_dir = self._agent_dir(profile.name) - _private_directory(agent_dir, parents=True) - for directory in MAILBOX_DIRS: - _private_directory(agent_dir / directory) - self._verify_atomic_mailbox(agent_dir) - path = agent_dir / "profile.toml" - _atomic_write(path, _profile_to_toml(profile).encode("utf-8")) - return path + with self._locked_mailbox_namespace(): + agent_dir = self._agent_dir(profile.name) + _private_directory(agent_dir, parents=True) + for directory in MAILBOX_DIRS: + _private_directory(agent_dir / directory) + self._verify_atomic_mailbox(agent_dir) + path = agent_dir / "profile.toml" + _atomic_write(path, _profile_to_toml(profile).encode("utf-8")) + return path def load_profile(self, name: str) -> Profile: path = self._require_agent(name) / "profile.toml" @@ -401,6 +418,193 @@ def list_profiles(self) -> tuple[Profile, ...]: profiles.append(self.load_profile(path.parent.name)) return tuple(profiles) + def wipe_agents( + self, + names: Iterable[str], + *, + purge_all_attachments: bool = False, + ) -> tuple[str, ...]: + """Irreversibly remove complete mailboxes and their routing metadata.""" + with self._locked_mailbox_namespace(): + return self._wipe_agents_locked( + names, + purge_all_attachments=purge_all_attachments, + ) + + def _wipe_agents_locked( + self, + names: Iterable[str], + *, + purge_all_attachments: bool, + ) -> tuple[str, ...]: + roster = tuple(sorted(dict.fromkeys(names))) + if not roster and not purge_all_attachments: + raise ValueError("at least one agent is required") + for name in roster: + self.load_profile(name) + + all_bindings = self.list_bindings() + affected_bindings = tuple( + binding for binding in all_bindings if binding.agent in roster + ) + affected_projects = tuple( + sorted({Path(binding.project) for binding in affected_bindings}) + ) + groups = self.list_groups() + remaining_groups = { + name: members + for name, roster_members in groups.items() + if ( + members := tuple( + member for member in roster_members if member not in roster + ) + ) + } + binding_paths = tuple( + self.bindings_dir + / ( + hashlib.sha256( + f"{binding.cli}\0{Path(binding.project)}".encode("utf-8") + ).hexdigest() + + ".toml" + ) + for binding in affected_bindings + ) + config = self.root / "config.toml" + marker_paths = tuple(project / ".agentpost.toml" for project in affected_projects) + snapshots = tuple( + (path, path.read_bytes() if path.exists() else None) + for path in ( + config, + *binding_paths, + *marker_paths, + ) + ) + attachments = _WipeAttachmentSet.prepare( + self.root, + roster, + purge_all=purge_all_attachments, + ) + + stage = self.root / f".wipe-{uuid.uuid4().hex}" + try: + _private_directory(stage) + staged: list[tuple[Path, Path]] = [] + try: + for name in roster: + source = self._agent_dir(name) + destination = stage / name + os.replace(source, destination) + staged.append((source, destination)) + _fsync_directory(self.agents_dir) + _fsync_directory(stage) + + for path in binding_paths: + path.unlink(missing_ok=True) + if binding_paths: + _fsync_directory(self.bindings_dir) + attachments.unlink() + + _atomic_write( + config, + _config_to_toml( + remaining_groups, + self.connection_mode(), + ).encode("utf-8"), + ) + remaining_bindings = tuple( + binding + for binding in all_bindings + if binding.agent not in roster + ) + for project in affected_projects: + self._rewrite_workspace_identity( + project, + ( + binding.agent + for binding in remaining_bindings + if Path(binding.project) == project + ), + ) + recreated = tuple( + name + for name in roster + if ( + self._agent_dir(name).exists() + or self._agent_dir(name).is_symlink() + ) + ) + if recreated: + raise AgentPostError( + "wipe targets were recreated before commit: " + + ", ".join(recreated) + ) + except Exception as exc: + rollback_errors = [] + for source, destination in reversed(staged): + try: + source_present = source.exists() or source.is_symlink() + destination_present = ( + destination.exists() or destination.is_symlink() + ) + if destination_present and source_present: + rollback_errors.append( + f"{source}: replacement collides with the original " + f"mailbox preserved at {destination}" + ) + elif destination_present: + os.replace(destination, source) + elif not source_present: + rollback_errors.append( + f"{source}: original mailbox is missing from " + f"recovery stage {destination}" + ) + except OSError as rollback_exc: + rollback_errors.append(f"{source}: {rollback_exc}") + for path, contents in reversed(snapshots): + try: + _restore_file(path, contents) + except OSError as rollback_exc: + rollback_errors.append(f"{path}: {rollback_exc}") + try: + attachments.restore() + except OSError as rollback_exc: + rollback_errors.append( + f"{attachments.path}: {rollback_exc}" + ) + if rollback_errors: + try: + _fsync_directory(stage) + _fsync_directory(self.root) + except OSError as rollback_exc: + rollback_errors.append( + f"{stage}: could not sync recovery stage: {rollback_exc}" + ) + raise AgentPostError( + "wipe failed and rollback was incomplete; recovery staging " + f"was preserved at {stage}: " + + "; ".join(rollback_errors) + ) from exc + try: + shutil.rmtree(stage) + except OSError as rollback_exc: + raise AgentPostError( + "wipe failed; rollback completed but empty staging could not " + f"be removed at {stage}: {rollback_exc}" + ) from exc + raise + + try: + shutil.rmtree(stage) + except OSError as exc: + raise AgentPostError( + f"mailboxes were detached but wipe staging remains at {stage}: {exc}" + ) from exc + _fsync_directory(self.root) + return roster + finally: + attachments.close() + def bind_agent(self, name: str, cli: str, project: str | Path) -> Path: self.load_profile(name) if not cli.strip(): @@ -1342,6 +1546,220 @@ def _git_exclude_path(project: Path) -> Path | None: return git / "info" / "exclude" +@dataclass +class _WipeAttachmentSet: + path: Path + descriptor: int | None + files: tuple[tuple[str, bytes], ...] + + @classmethod + def prepare( + cls, + root: Path, + roster: tuple[str, ...], + *, + purge_all: bool, + ) -> _WipeAttachmentSet: + path = root / "runtime" / "codex-sessions" + try: + descriptor = _open_private_runtime_subdirectory( + root, + ("runtime", "codex-sessions"), + ) + except FileNotFoundError: + return cls(path, None, ()) + selected = [] + try: + for name in sorted(os.listdir(descriptor)): + if not name.endswith(".json") or "/" in name or "\x00" in name: + continue + contents = _read_private_file_at( + descriptor, + name, + path / name, + ) + if not purge_all: + try: + attachment_agent = str( + json.loads(contents)["agent"] + ) + except ( + KeyError, + TypeError, + UnicodeDecodeError, + json.JSONDecodeError, + ) as exc: + raise AgentPostError( + "cannot safely inspect Codex session attachment " + f"{path / name}: {exc}" + ) from exc + if attachment_agent not in roster: + continue + selected.append((name, contents)) + except Exception: + os.close(descriptor) + raise + return cls(path, descriptor, tuple(selected)) + + def unlink(self) -> None: + if self.descriptor is None: + return + for name, _contents in self.files: + os.unlink(name, dir_fd=self.descriptor) + if self.files: + os.fsync(self.descriptor) + + def restore(self) -> None: + if self.descriptor is None: + return + for name, contents in self.files: + _write_private_file_at(self.descriptor, name, contents) + if self.files: + os.fsync(self.descriptor) + + def close(self) -> None: + if self.descriptor is not None: + os.close(self.descriptor) + self.descriptor = None + + +def _open_private_runtime_subdirectory( + root: Path, + parts: tuple[str, ...], + *, + create: bool = False, +) -> int: + flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + try: + descriptor = os.open(root, flags) + except OSError as exc: + raise AgentPostError( + f"cannot securely open AgentPost runtime root {root}: {exc}" + ) from exc + current = root + try: + _require_private_directory(descriptor, current) + for part in parts: + current = current / part + try: + child = os.open(part, flags, dir_fd=descriptor) + except FileNotFoundError: + if not create: + raise + try: + os.mkdir( + part, + PRIVATE_DIRECTORY_MODE, + dir_fd=descriptor, + ) + except FileExistsError: + pass + child = os.open(part, flags, dir_fd=descriptor) + except OSError as exc: + raise AgentPostError( + f"cannot securely open AgentPost runtime directory " + f"{current}: {exc}" + ) from exc + try: + _require_private_directory(child, current) + except Exception: + os.close(child) + raise + os.close(descriptor) + descriptor = child + return descriptor + except Exception: + os.close(descriptor) + raise + + +def _require_private_directory(descriptor: int, path: Path) -> None: + details = os.fstat(descriptor) + if ( + not stat.S_ISDIR(details.st_mode) + or details.st_uid != os.geteuid() + or stat.S_IMODE(details.st_mode) != PRIVATE_DIRECTORY_MODE + ): + raise AgentPostError( + f"insecure AgentPost runtime directory ownership or permissions: {path}" + ) + + +def _read_private_file_at( + directory: int, + name: str, + path: Path, +) -> bytes: + flags = os.O_RDONLY + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + try: + descriptor = os.open(name, flags, dir_fd=directory) + except FileNotFoundError: + raise + except OSError as exc: + raise AgentPostError( + f"cannot securely open AgentPost runtime file {path}: {exc}" + ) from exc + try: + details = os.fstat(descriptor) + if ( + not stat.S_ISREG(details.st_mode) + or details.st_uid != os.geteuid() + or stat.S_IMODE(details.st_mode) != PRIVATE_FILE_MODE + ): + raise AgentPostError( + f"insecure AgentPost runtime file ownership or permissions: {path}" + ) + chunks = [] + while True: + chunk = os.read(descriptor, 64 * 1024) + if not chunk: + return b"".join(chunks) + chunks.append(chunk) + finally: + os.close(descriptor) + + +def _write_private_file_at( + directory: int, + name: str, + contents: bytes, +) -> None: + temporary = f".{name}.{uuid.uuid4().hex}.tmp" + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + descriptor = os.open( + temporary, + flags, + PRIVATE_FILE_MODE, + dir_fd=directory, + ) + try: + view = memoryview(contents) + while view: + written = os.write(descriptor, view) + view = view[written:] + os.fsync(descriptor) + finally: + os.close(descriptor) + try: + os.replace( + temporary, + name, + src_dir_fd=directory, + dst_dir_fd=directory, + ) + finally: + try: + os.unlink(temporary, dir_fd=directory) + except FileNotFoundError: + pass + + def _restore_file(path: Path, contents: bytes | None) -> None: if contents is None: path.unlink(missing_ok=True) diff --git a/src/agentpost/data/codex_bridge.mjs b/src/agentpost/data/codex_bridge.mjs index ca646d0..9689cc3 100644 --- a/src/agentpost/data/codex_bridge.mjs +++ b/src/agentpost/data/codex_bridge.mjs @@ -18,7 +18,7 @@ let pollRunning = false; socket.addEventListener("open", async () => { try { await request("initialize", { - clientInfo: { name: "agentpost", title: "AgentPost", version: "1.2.0" }, + clientInfo: { name: "agentpost", title: "AgentPost", version: "1.3.0" }, capabilities: { experimentalApi: true, requestAttestation: false, diff --git a/src/agentpost/data/integrations.json b/src/agentpost/data/integrations.json index d63e73f..c899633 100644 --- a/src/agentpost/data/integrations.json +++ b/src/agentpost/data/integrations.json @@ -1,14 +1,14 @@ { "antigravity/hooks.json": "{\n \"agentpost\": {\n \"PreInvocation\": [\n {\n \"type\": \"command\",\n \"command\": \"agentpost internal-antigravity-hook pre-invocation\",\n \"timeout\": 10\n }\n ],\n \"Stop\": [\n {\n \"type\": \"command\",\n \"command\": \"agentpost internal-antigravity-hook stop\",\n \"timeout\": 10\n }\n ]\n }\n}\n", "antigravity/plugin.json": "{\n \"name\": \"agentpost\"\n}\n", - "antigravity/skills/agentpost/SKILL.md": "---\nname: agentpost\ndescription: Use AgentPost as the local agent communication channel and address book. Trigger when the user says to send, tell, ask, share, forward, confer, or review with a named local agent, project identity, specialist, or group; when setting up, connecting, reconnecting, or diagnosing an AgentPost mailbox or adapter; when another agent may own relevant context; or when processing AgentPost notifications.\n---\n\n# AgentPost\n\nAgentPost is the local post office for active CLI agents. The `agentpost`\ncommand and files under `~/.agentpost` are the durable truth; notifications are\nonly pointers.\n\n## Channel semantics\n\nTreat AgentPost as a communication channel, not merely an inbox utility. A user\ninstruction such as \"send it to PB\", \"tell Construct\", \"ask Kernos\", or \"share\nthis with the reviewers\" means:\n\n1. Resolve the named destination through the AgentPost address book.\n2. Infer the sender from the current process identity or project binding.\n3. Turn \"it\" or \"this\" into a self-contained message using the relevant current\n context; do not make the recipient reconstruct missing details.\n4. Deliver it now unless the identity or intended payload is genuinely\n ambiguous.\n5. Report the Message-ID, concrete recipients, and whether each was notified\n live or queued offline.\n\nDo not stop at suggesting an AgentPost command when the user asked to send.\nExecute the delivery. Use `message` for information, work requests, specs, and\nreviews. Use `question` when an answer is expected. Routine mail defaults to\nidle notification; questions default to immediate, but the user's urgency or\nnon-interruption request overrides the default.\n\n```sh\nagentpost resolve 'Pattern Buffer'\nagentpost message 'Pattern Buffer' 'Please review the attached design context.'\nagentpost question reviewers 'Does this contract cover retry behavior?'\n```\n\nBoth commands accept `-` or an omitted body to read a multi-line message from\nstandard input. Bare registered group names are accepted; `@group` remains the\nexplicit form. Exact named identities remain addressable while offline.\n\nIf this project has a declared mailbox but is not connected, run bare\n`agentpost join --cli CURRENT_CLI` from the project root. It resolves the unique\nregistered root and idempotently handles fresh, existing, or moved integrations;\n`connect` is an alias. Use `agentpost join NAME --cli CURRENT_CLI` only when the\ncommand reports genuine identity ambiguity. Never create a new mailbox merely\nbecause a new CLI process opened.\n\n## Connection readiness\n\nWhen the user asks this running agent to set up, connect, reconnect, or receive\nmail as `NAME`, perform the deterministic recovery rather than merely proving\nthat the mailbox can be addressed:\n\n1. Resolve `NAME` and run `agentpost identify --cli CURRENT_CLI --cwd \"$PWD\"`\n to compare the requested identity with this process.\n2. Run the applicable idempotent `agentpost join --cli CURRENT_CLI`, using the\n explicit `NAME` form only for an alternate identity or genuine ambiguity.\n3. Run `agentpost doctor NAME --project \"$PWD\" --cli CURRENT_CLI` and follow\n any safe adapter instruction it reports.\n4. Run `agentpost armed NAME` and report its actual result.\n\nMailbox existence and successful `resolve`, `list`, `read`, `message`, or\n`reply` commands prove durable access only. Never claim that an agent is ready,\nconnected, receiving, or reconnected while `armed` reports `QUEUED`, or while\n`doctor` reports a failure that prevents the requested notification mode.\n\nIf a connection attempt reports that `NAME` already has an inbound consumer,\ndo not steal its lease or silently treat mailbox-level `ARMED` as proof that\nthis process connected. Report the existing owner. If it is this managed\nsession's parent bridge, continue in the existing session. Otherwise inspect\nthe exact identities and offer the user the first unused numbered mailbox,\nstarting with `NAME2`, then `NAME3`. Create it only after explicit user approval,\nthen use the explicit named join and launcher and verify it independently. The\nnumbered identity is a separate durable mailbox: it does not inherit, claim, or\nmove mail already addressed to `NAME`.\n\nDo not rewrite integration state ad hoc or launch a nested copy of the current\nCLI. A running process cannot retroactively become an alternate identity that\nrequires an explicit named launcher. If repair requires replacing or reloading\nthe current process, mark reconnection as pending and give the exact external\nlauncher, such as `agentpost codex --agent NAME`, `agentpost claude --agent\nNAME`, or `agentpost antigravity --agent NAME`. The replacement session must\nrepeat `doctor` and `armed` before reporting success.\n\nSome adapters honestly provide lifecycle catch-up without already-idle wake.\nFor those adapters, describe `QUEUED` as durable mail that will surface at the\nnext supported lifecycle boundary; do not describe it as live readiness.\n\n## Registering a durable nameplate\n\nWhen asked to register this agent, first inspect `agentpost identities` and the\ncurrent workspace or role documentation. Describe demonstrated, durable\nownership so a coworker can answer both \"who is this?\" and \"who should handle\nthis work?\"\n\n- `name`: short, stable mailbox address; do not encode a session or task.\n- `display-name`: recognizable project, team, or role name.\n- The profile is CLI-neutral. Do not make CLI type part of the identity;\n `join --cli` records each runtime adapter separately.\n- `kind`: choose `project`, `role`, `specialist`, or `hybrid` from durable\n responsibility. A code-review or marketing role must not claim project\n ownership merely because its CLI runs from that workspace.\n- `summary`: one concise sentence naming the owned domain plus the decisions,\n systems, or outputs this agent can help with. Include terms coworkers search.\n- `roles`: broad workplace functions.\n- `projects`: stable project names and aliases people will mention.\n- `specialties`: specific reusable technical or domain expertise.\n- `handles`: two to five concrete request categories that should route here.\n- `does-not-handle`: close neighboring responsibilities owned elsewhere.\n\nPrefer \"Owns Pattern Buffer temporal world-state semantics, ingestion fidelity,\nand deterministic retrieval contracts\" over \"helpful coding agent.\" Do not put\ncurrent work, status, availability, generic personality, aspirational expertise,\nor secrets in a durable profile. Make neighboring agents distinguishable and\navoid duplicated handles; tied address labels are rejected rather than guessed.\n\n```sh\nagentpost profile-register pb \\\n --display-name 'Pattern Buffer' --kind hybrid \\\n --summary 'Owns temporal world-state semantics, ingestion fidelity, and deterministic retrieval contracts.' \\\n --roles 'world-model engineering' --projects 'pattern-buffer' \\\n --project-roots \"$PWD\" --specialties 'temporal state,identity,provenance' \\\n --handles 'Pattern Buffer API reviews,world-state schema questions' \\\n --does-not-handle 'Construct narrative orchestration,Kernos member policy'\n```\n\n`agentpost profile-register --help` carries the same field guidance. After\nregistration, verify the nameplate with `agentpost identities` and test its\nlikely search terms with `agentpost agents-find QUERY --all`.\n\n## Identity and discovery\n\nAt the start of AgentPost work, resolve this session's identity from its project\nroot:\n\n```sh\nagentpost identify --cwd \"$PWD\"\n```\n\nExplicit `--agent`/`AGENTPOST_AGENT` is authoritative. Otherwise AgentPost uses\nthe deepest workspace marker, adapter binding, or declared project root, with\nthat priority for equal paths. A workspace has one default; alternate role or\nreview mailboxes in the same directory require an explicit named launcher.\n\nNever guess a recipient from conversation memory. Inspect the current directory:\n\n```sh\nagentpost identities\nagentpost resolve 'known name, display name, or project identity'\nagentpost profiles\nagentpost status\nagentpost agents-find \"topic or responsibility\"\nagentpost agents-find --role marketing\nagentpost agents-find --project construct\nagentpost agents-find --specialty \"temporal identity\"\n```\n\nNormal profile and responsibility discovery returns active agents only.\n`agentpost profiles --all` and `agentpost agents-find --all` expose offline\nhistory when explicitly needed. An exact mailbox name may still receive durable\nmail while offline; do not infer that offline means deleted.\n\nSelect the smallest relevant recipient set and retain the printed match reasons.\nFamiliarity does not outrank a better responsibility or evidence match.\n\n## Mail workflow\n\nWhen a native notification supplies one or more Message-IDs, process exactly\nthat set. Do not run a blanket inbox listing or inspect, claim, or process other\nunread letters in that turn; another attention mode may be intentionally\ndeferring them. A notification without explicit Message-IDs may use the normal\noldest-first inbox workflow below.\n\nInspecting mail does not claim it:\n\n```sh\nagentpost list AGENT\nagentpost read AGENT MESSAGE_ID\n```\n\nClaim exactly one letter only when beginning its work:\n\n```sh\nagentpost next AGENT --message-id MESSAGE_ID\n```\n\nAgentPost permits one inbound consumer per mailbox across all CLI and Python\nadapters. Do not bypass an existing consumer lease. Standby runtimes may wait\nfor takeover, while concurrently active workers should use distinct mailbox\nidentities. Atomic claim remains the final duplicate-work guard.\n\nSend routine or non-blocking work as idle mail:\n\n```sh\nagentpost message RECIPIENT 'message' --notify idle\n```\n\nUse immediate only for an active blocker or genuinely time-sensitive question:\n\n```sh\nagentpost question RECIPIENT 'question' --notify immediate\n```\n\n`send` and `ask` are lower-level forms for scripts that already have canonical\nsender and recipient mailbox keys. Prefer `message` and `question` for CLI\nagents acting on human instructions.\n\nReply against the original Message-ID:\n\n```sh\nagentpost reply MESSAGE_ID 'answer'\n```\n\nThe sender is inferred like `message` and `question`, with one addition: when\nthe sender is inferred rather than stated, reply answers from whichever mailbox\nreachable in this workspace actually holds that Message-ID. A workspace default\nseat and an alternate seat sharing the project root are both reachable, so a\nruntime notified as the alternate seat can answer its own mail without `--from`.\nThe workspace default still wins when it holds a copy, and two alternate seats\nholding the same letter is reported rather than guessed. The legacy\n`reply AGENT MESSAGE_ID` form remains accepted for scripts during migration.\nReplies to questions default to immediate notification because an answer is\nawaited; replies to ordinary letters default to idle. Use `--notify` to\noverride either case. Reply atomically claims the exact original when it is\nstill unread; already-read originals remain replyable for corrections. Claim\nwith `next` before substantial work, or let an immediate reply perform that\ntransition when composing the response is the work.\n\nIf an existing unread letter needs a fresh native notification, the ORIGINAL\nsender may re-notify that exact durable Message-ID without creating a duplicate:\n\n```sh\nagentpost notify RECIPIENT MESSAGE_ID --mode immediate\n```\n\nRe-notification never changes, claims, or copies the letter. Do not send a new\ncontrol envelope merely to point at old unread mail. Fresh adapter startup\ncatch-up names the full queued unread set in one notification turn.\n\nNamed groups, comma-separated recipients, and selectors such as\n`@role:marketing`, `@project:construct`, and `@specialty:temporal-identity` are\nresolved to concrete recipients before delivery.\n\n## Consultation posture\n\nWhen work crosses ownership boundaries or resembles a problem another agent has\nalready solved, consider the relevant peer alongside local inspection or web\nresearch. Peer advice is attributed evidence, not a replacement for direct\nsource verification. Do not generate mail for trivial local tasks.\n\nFor reviews, put the complete actionable request in AgentPost only. Never copy\nit into a legacy inbox. A legacy message may contain installation control or a\npointer to an existing AgentPost Message-ID after a proven notification failure,\nbut must not duplicate the work.\n\nSend repository review work through the fail-closed review channel:\n\n```sh\ncommit=$(git -C REPOSITORY rev-parse HEAD)\nparent=$(git -C REPOSITORY rev-parse HEAD^)\nagentpost review RECIPIENT - --repo REPOSITORY \\\n --commit \"$commit\" --parent \"$parent\" \\\n --path src/module.py \\\n --test tests/test_module.py::ModuleTest::test_behavior\n```\n\nUse explicit full commit-object SHAs. Never pass a literal command substitution,\nplaceholder, branch, or mutable `HEAD` as a structured value. Repeat `--path`\nand `--test` for the complete assertion set; every test must begin with its\ncommit-tree file as `RELATIVE_PATH::TEST_NODE`. `--parent` is optional but, when\npresent, must be a direct parent. AgentPost verifies the worktree, artifact,\npaths, and test files, prints the complete generated envelope, and writes no\nmail if preflight fails. Inspect the referenced diff before sending and label\nanything not directly verified as unverified. The receiver treats the immutable\nrepository diff and machine-readable review headers as authoritative rather\nthan trusting prose.\n\nLegacy cutover is per agent. Migrate a project's durable communication policy\nonly after that exact agent has proven inbound receipt/claim and outbound\ndelivery. Its migration directive must make AgentPost primary, prohibit new\nactionable legacy letters, retain the old folder as read-only history, and stop\nlegacy polling. Other unconfirmed agents keep their existing recovery path.\n\nAfter processing new mail, report a short synopsis in the active user chat:\nwhat arrived, what work was done, and what response was sent or remains pending.\n", + "antigravity/skills/agentpost/SKILL.md": "---\nname: agentpost\ndescription: Use AgentPost as the local agent communication channel and address book. Trigger when the user says to send, tell, ask, share, forward, confer, or review with a named local agent, project identity, specialist, or group; when setting up, connecting, reconnecting, or diagnosing an AgentPost mailbox or adapter; when another agent may own relevant context; or when processing AgentPost notifications.\n---\n\n# AgentPost\n\nAgentPost is the local post office for active CLI agents. The `agentpost`\ncommand and files under `~/.agentpost` are the durable truth; notifications are\nonly pointers.\n\n## Channel semantics\n\nTreat AgentPost as a communication channel, not merely an inbox utility. A user\ninstruction such as \"send it to PB\", \"tell Construct\", \"ask Kernos\", or \"share\nthis with the reviewers\" means:\n\n1. Resolve the named destination through the AgentPost address book.\n2. Infer the sender from the current process identity or project binding.\n3. Turn \"it\" or \"this\" into a self-contained message using the relevant current\n context; do not make the recipient reconstruct missing details.\n4. Deliver it now unless the identity or intended payload is genuinely\n ambiguous.\n5. Report the Message-ID, concrete recipients, and whether each was notified\n live or queued offline.\n\nDo not stop at suggesting an AgentPost command when the user asked to send.\nExecute the delivery. Use `message` for information, work requests, specs, and\nreviews. Use `question` when an answer is expected. Routine mail defaults to\nidle notification; questions default to immediate, but the user's urgency or\nnon-interruption request overrides the default.\n\n```sh\nagentpost resolve nav\nagentpost message nav 'Please review the attached design context.'\nagentpost question pbe.codereview 'Does this contract cover retry behavior?'\nagentpost question reviewers 'Does this contract cover retry behavior?'\n```\n\nBoth commands accept `-` or an omitted body to read a multi-line message from\nstandard input. Bare registered group names are accepted; `@group` remains the\nexplicit form. Bare identity names resolve only among seats sharing the\nsender's registered projects. Cross-project seats must use `PROJECT.SEAT`;\nnever retry a missing local bare name against the global directory. Qualified\nidentities remain addressable while offline.\n\nIf this project has a declared mailbox but is not connected, run bare\n`agentpost join --cli CURRENT_CLI` from the project root. It resolves the unique\nregistered root and idempotently handles fresh, existing, or moved integrations;\n`connect` is an alias. Use `agentpost join NAME --cli CURRENT_CLI` only when the\ncommand reports genuine identity ambiguity. Never create a new mailbox merely\nbecause a new CLI process opened.\n\n## Connection readiness\n\nWhen the user asks this running agent to set up, connect, reconnect, or receive\nmail as `NAME`, perform the deterministic recovery rather than merely proving\nthat the mailbox can be addressed:\n\n1. Resolve `NAME` and run `agentpost identify --cli CURRENT_CLI --cwd \"$PWD\"`\n to compare the requested identity with this process.\n2. If an ordinary live Codex thread needs a different known seat at this\n workspace, run `agentpost attach NAME`. Otherwise run the applicable\n idempotent `agentpost join --cli CURRENT_CLI`, using the explicit `NAME` form\n only for an alternate identity or genuine ambiguity.\n3. Run `agentpost doctor NAME --project \"$PWD\" --cli CURRENT_CLI` and follow\n any safe adapter instruction it reports.\n4. Run `agentpost armed NAME` and report its actual result.\n\nMailbox existence and successful `resolve`, `list`, `read`, `message`, or\n`reply` commands prove durable access only. Never claim that an agent is ready,\nconnected, receiving, or reconnected while `armed` reports `QUEUED`, or while\n`doctor` reports a failure that prevents the requested notification mode.\nAn `attach` result may be reported specifically as a boundary-only identity\nselection while `armed` remains `QUEUED`; do not call that live wake.\nAfter attachment, `doctor` reports exact `codex-session-attachment` health\nseparately from aggregate `codex-generation` recovery. A stale aggregate line\ndoes not negate an exact-thread attachment pass, and neither result upgrades\nboundary-only delivery to live wake.\n\nIf a connection attempt reports that `NAME` already has an inbound consumer,\ndo not steal its lease or silently treat mailbox-level `ARMED` as proof that\nthis process connected. Report the existing owner. If it is this managed\nsession's parent bridge, continue in the existing session. Otherwise inspect\nthe exact identities and offer the user the first unused numbered mailbox,\nstarting with `NAME2`, then `NAME3`. Create it only after explicit user approval,\nthen use the explicit named join and launcher and verify it independently. The\nnumbered identity is a separate durable mailbox: it does not inherit, claim, or\nmove mail already addressed to `NAME`.\n\nDo not rewrite integration state ad hoc or launch a nested copy of the current\nCLI. An ordinary running Codex thread with compatible AgentPost hooks may select\nan alternate known workspace seat through `agentpost attach NAME`. Report its\nreturned capability precisely: `boundary-only` changes sender identity and\nnext-boundary catch-up without publishing presence or providing already-idle\nwake. Do not use `join` or replace a plugin merely to change that live session\nidentity. If full wake or another adapter requires replacing or reloading the\ncurrent process, mark reconnection as pending and give the exact external\nlauncher, such as `agentpost codex --agent NAME resume THREAD_ID`, `agentpost\nclaude --agent NAME`, or `agentpost antigravity --agent NAME`. The replacement\nsession must repeat `doctor` and `armed` before reporting success.\n\nSome adapters honestly provide lifecycle catch-up without already-idle wake.\nFor those adapters, describe `QUEUED` as durable mail that will surface at the\nnext supported lifecycle boundary; do not describe it as live readiness.\n\n## Registering a durable nameplate\n\nWhen asked to register this agent, first inspect `agentpost identities` and the\ncurrent workspace or role documentation. Describe demonstrated, durable\nownership so a coworker can answer both \"who is this?\" and \"who should handle\nthis work?\"\n\n- `name`: short, stable dot-free mailbox address; do not encode a session or\n task. Dot is reserved for `PROJECT.SEAT`.\n- `display-name`: recognizable project, team, or role name.\n- The profile is CLI-neutral. Do not make CLI type part of the identity;\n `join --cli` records each runtime adapter separately.\n- `kind`: choose `project`, `role`, `specialist`, or `hybrid` from durable\n responsibility. A code-review or marketing role must not claim project\n ownership merely because its CLI runs from that workspace.\n- `summary`: one concise sentence naming the owned domain plus the decisions,\n systems, or outputs this agent can help with. Include terms coworkers search.\n- `roles`: broad workplace functions.\n- `projects`: stable dot-free project names and aliases people will mention.\n This is also the seat's address namespace, not a claim that a role owns the\n runtime workspace. Include a short alias when coworkers need a compact\n cross-project address.\n- `specialties`: specific reusable technical or domain expertise.\n- `handles`: put a simple seat address such as `nav`, `build`, or `codereview`\n first, then two to five concrete request categories that should route here.\n- `does-not-handle`: close neighboring responsibilities owned elsewhere.\n\nPrefer \"Owns Pattern Buffer temporal world-state semantics, ingestion fidelity,\nand deterministic retrieval contracts\" over \"helpful coding agent.\" Do not put\ncurrent work, status, availability, generic personality, aspirational expertise,\nor secrets in a durable profile. Make neighboring agents distinguishable and\navoid duplicated handles; tied address labels are rejected rather than guessed.\n\n```sh\nagentpost profile-register pb \\\n --display-name 'Pattern Buffer' --kind hybrid \\\n --summary 'Owns temporal world-state semantics, ingestion fidelity, and deterministic retrieval contracts.' \\\n --roles 'world-model engineering' --projects 'pattern-buffer' \\\n --project-roots \"$PWD\" --specialties 'temporal state,identity,provenance' \\\n --handles 'pb,Pattern Buffer API reviews,world-state schema questions' \\\n --does-not-handle 'Construct narrative orchestration,Kernos member policy'\n```\n\n`agentpost profile-register --help` carries the same field guidance. After\nregistration, verify the nameplate with `agentpost identities` and test its\nlikely search terms with `agentpost agents-find QUERY --all`.\n\nAlso verify the project-qualified address and complete seat roster:\n\n```sh\nagentpost identities --project pattern-buffer\nagentpost resolve pattern-buffer.pb\n```\n\n## Identity and discovery\n\nAt the start of AgentPost work, resolve this session's identity from its project\nroot:\n\n```sh\nagentpost identify --cwd \"$PWD\"\n```\n\nExplicit `--agent`/`AGENTPOST_AGENT` is authoritative. Otherwise AgentPost uses\nan active Codex session attachment, then the deepest workspace marker, adapter\nbinding, or declared project root, with that priority for equal paths. A\nworkspace has one default; alternate role or review mailboxes in the same\ndirectory require `agentpost attach NAME` in a compatible active Codex thread\nor an explicit named launcher.\n\nNever guess a recipient from conversation memory. Inspect the current directory:\n\n```sh\nagentpost identities\nagentpost identities --project construct\nagentpost resolve nav\nagentpost resolve other-project.nav\nagentpost profiles\nagentpost profiles --project construct --all\nagentpost status\nagentpost agents-find \"topic or responsibility\"\nagentpost agents-find --role marketing\nagentpost agents-find --project construct\nagentpost agents-find --specialty \"temporal identity\"\n```\n\nNormal profile and responsibility discovery returns active agents only.\n`agentpost profiles --all` and `agentpost agents-find --all` expose offline\nhistory when explicitly needed. `identities --project PROJECT` returns the\ncomplete project roster including offline seats. A qualified identity may still\nreceive durable mail while offline; do not infer that offline means deleted.\n\nSelect the smallest relevant recipient set and retain the printed match reasons.\nFamiliarity does not outrank a better responsibility or evidence match.\n\n## Mail workflow\n\nWhen a native notification supplies one or more Message-IDs, process exactly\nthat set. Do not run a blanket inbox listing or inspect, claim, or process other\nunread letters in that turn; another attention mode may be intentionally\ndeferring them. A notification without explicit Message-IDs may use the normal\noldest-first inbox workflow below.\n\nInspecting mail does not claim it:\n\n```sh\nagentpost list AGENT\nagentpost read AGENT MESSAGE_ID\n```\n\nClaim exactly one letter only when beginning its work:\n\n```sh\nagentpost next AGENT --message-id MESSAGE_ID\n```\n\nAgentPost permits one inbound consumer per mailbox across all CLI and Python\nadapters. Do not bypass an existing consumer lease. Standby runtimes may wait\nfor takeover, while concurrently active workers should use distinct mailbox\nidentities. Atomic claim remains the final duplicate-work guard.\n\nSend routine or non-blocking work as idle mail:\n\n```sh\nagentpost message RECIPIENT 'message' --notify idle\n```\n\nUse immediate only for an active blocker or genuinely time-sensitive question:\n\n```sh\nagentpost question RECIPIENT 'question' --notify immediate\n```\n\n`send` and `ask` are lower-level forms for scripts that already have canonical\nsender and recipient mailbox keys. Prefer `message` and `question` for CLI\nagents acting on human instructions.\n\nReply against the original Message-ID:\n\n```sh\nagentpost reply MESSAGE_ID 'answer'\n```\n\nThe sender is inferred like `message` and `question`, with one addition: when\nthe sender is inferred rather than stated, reply answers from whichever mailbox\nreachable in this workspace actually holds that Message-ID. A workspace default\nseat and an alternate seat sharing the project root are both reachable, so a\nruntime notified as the alternate seat can answer its own mail without `--from`.\nThe workspace default still wins when it holds a copy, and two alternate seats\nholding the same letter is reported rather than guessed. The legacy\n`reply AGENT MESSAGE_ID` form remains accepted for scripts during migration.\nReplies to questions default to immediate notification because an answer is\nawaited; replies to ordinary letters default to idle. Use `--notify` to\noverride either case. Reply atomically claims the exact original when it is\nstill unread; already-read originals remain replyable for corrections. Claim\nwith `next` before substantial work, or let an immediate reply perform that\ntransition when composing the response is the work.\n\nIf an existing unread letter needs a fresh native notification, the ORIGINAL\nsender may re-notify that exact durable Message-ID without creating a duplicate:\n\n```sh\nagentpost notify RECIPIENT MESSAGE_ID --mode immediate\n```\n\nRe-notification never changes, claims, or copies the letter. Do not send a new\ncontrol envelope merely to point at old unread mail. Fresh adapter startup\ncatch-up names the full queued unread set in one notification turn.\n\nNamed groups, comma-separated recipients, and selectors such as\n`@role:marketing`, `@project:construct`, and `@specialty:temporal-identity` are\nresolved to concrete recipients before delivery.\n\n## Clean-start deletion\n\nNever manually remove AgentPost directories when a clean start is requested.\nUse the guarded wipe workflow, which never touches source or AgentBridge\nrepositories:\n\n```sh\nagentpost wipe agent\nagentpost wipe agent other-project.nav\nagentpost wipe project other-project\nagentpost wipe all\n```\n\n`wipe agent` with no name removes only this session's resolved mailbox and\nneeds no affected-box confirmation. It still refuses any held inbound consumer\nlease, including a live adapter for the same seat: close that adapter and run\nthe final wipe from a terminal with `AGENTPOST_AGENT=NAME`. Treat successful\nself-wipe as this seat's final action and do not continue using the deleted\nidentity.\n\nAnything broader than this seat requires explicit user confirmation. Run the\nrequested command once without `--confirm`; it performs no deletion and returns\nthe exact sorted affected mailbox list plus the exact confirmation argument.\nShow that list to the user and ask them to confirm that those boxes will be\ndeleted. Only after the user confirms, rerun with the printed\n`--confirm 'BOX1,BOX2'`. Never infer confirmation from the original deletion\nrequest when the affected list had not yet been shown. If the list changes,\nstop and confirm the new list. Stop every live mailbox consumer first; the wipe\nholds the authoritative lease fence through mailbox detachment.\n\nSuccessful wipe is irreversible within AgentPost. Report which boxes were\nremoved, that target profiles/mail/bindings/adapter state/group membership were\ndeleted, that source repositories were untouched, and that copies held by\nunaffected mailboxes remain.\n\n## Consultation posture\n\nWhen work crosses ownership boundaries or resembles a problem another agent has\nalready solved, consider the relevant peer alongside local inspection or web\nresearch. Peer advice is attributed evidence, not a replacement for direct\nsource verification. Do not generate mail for trivial local tasks.\n\nFor reviews, put the complete actionable request in AgentPost only. Never copy\nit into a legacy inbox. A legacy message may contain installation control or a\npointer to an existing AgentPost Message-ID after a proven notification failure,\nbut must not duplicate the work.\n\nSend repository review work through the fail-closed review channel:\n\n```sh\ncommit=$(git -C REPOSITORY rev-parse HEAD)\nparent=$(git -C REPOSITORY rev-parse HEAD^)\nagentpost review RECIPIENT - --repo REPOSITORY \\\n --commit \"$commit\" --parent \"$parent\" \\\n --path src/module.py \\\n --test tests/test_module.py::ModuleTest::test_behavior\n```\n\nUse explicit full commit-object SHAs. Never pass a literal command substitution,\nplaceholder, branch, or mutable `HEAD` as a structured value. Repeat `--path`\nand `--test` for the complete assertion set; every test must begin with its\ncommit-tree file as `RELATIVE_PATH::TEST_NODE`. `--parent` is optional but, when\npresent, must be a direct parent. AgentPost verifies the worktree, artifact,\npaths, and test files, prints the complete generated envelope, and writes no\nmail if preflight fails. Inspect the referenced diff before sending and label\nanything not directly verified as unverified. The receiver treats the immutable\nrepository diff and machine-readable review headers as authoritative rather\nthan trusting prose.\n\nLegacy cutover is per agent. Migrate a project's durable communication policy\nonly after that exact agent has proven inbound receipt/claim and outbound\ndelivery. Its migration directive must make AgentPost primary, prohibit new\nactionable legacy letters, retain the old folder as read-only history, and stop\nlegacy polling. Other unconfirmed agents keep their existing recovery path.\n\nAfter processing new mail, report a short synopsis in the active user chat:\nwhat arrived, what work was done, and what response was sent or remains pending.\n", "claude/.claude-plugin/marketplace.json": "{\n \"$schema\": \"https://json.schemastore.org/claude-code-marketplace.json\",\n \"name\": \"agentpost-local\",\n \"owner\": {\n \"name\": \"AgentPost contributors\"\n },\n \"metadata\": {\n \"description\": \"Local AgentPost development marketplace\",\n \"version\": \"0.0.7\"\n },\n \"plugins\": [\n {\n \"name\": \"agentpost\",\n \"description\": \"A named local communication channel for CLI agents\",\n \"version\": \"0.0.7\",\n \"author\": {\n \"name\": \"AgentPost contributors\"\n },\n \"source\": \"./agentpost\",\n \"category\": \"productivity\"\n }\n ]\n}\n", "claude/agentpost/.claude-plugin/plugin.json": "{\n \"$schema\": \"https://json.schemastore.org/claude-code-plugin-manifest.json\",\n \"name\": \"agentpost\",\n \"displayName\": \"AgentPost\",\n \"version\": \"0.0.7\",\n \"description\": \"A named local communication channel for CLI agents\",\n \"author\": {\n \"name\": \"AgentPost contributors\"\n },\n \"license\": \"MIT\",\n \"experimental\": {\n \"monitors\": \"./monitors/monitors.json\"\n }\n}\n", "claude/agentpost/hooks/hooks.json": "{\n \"hooks\": {\n \"SessionStart\": [\n {\n \"hooks\": [\n {\n \"type\": \"command\",\n \"command\": \"agentpost internal-claude-boundary idle\"\n }\n ]\n }\n ],\n \"UserPromptSubmit\": [\n {\n \"hooks\": [\n {\n \"type\": \"command\",\n \"command\": \"agentpost internal-claude-boundary busy\"\n }\n ]\n }\n ],\n \"Stop\": [\n {\n \"hooks\": [\n {\n \"type\": \"command\",\n \"command\": \"agentpost internal-claude-boundary idle --delay 1.0\"\n }\n ]\n }\n ],\n \"StopFailure\": [\n {\n \"hooks\": [\n {\n \"type\": \"command\",\n \"command\": \"agentpost internal-claude-boundary idle --delay 1.0\"\n }\n ]\n }\n ]\n }\n}\n", "claude/agentpost/monitors/monitors.json": "[\n {\n \"name\": \"agentpost-inbox\",\n \"command\": \"agentpost internal-claude-monitor\",\n \"description\": \"AgentPost inbox notifications\",\n \"when\": \"always\"\n }\n]\n", - "claude/agentpost/skills/agentpost/SKILL.md": "---\nname: agentpost\ndescription: Use AgentPost as the local agent communication channel and address book. Trigger when the user says to send, tell, ask, share, forward, confer, or review with a named local agent, project identity, specialist, or group; when setting up, connecting, reconnecting, or diagnosing an AgentPost mailbox or adapter; when another agent may own relevant context; or when processing AgentPost notifications.\n---\n\n# AgentPost\n\nAgentPost is the local post office for active CLI agents. The `agentpost`\ncommand and files under `~/.agentpost` are the durable truth; notifications are\nonly pointers.\n\n## Channel semantics\n\nTreat AgentPost as a communication channel, not merely an inbox utility. A user\ninstruction such as \"send it to PB\", \"tell Construct\", \"ask Kernos\", or \"share\nthis with the reviewers\" means:\n\n1. Resolve the named destination through the AgentPost address book.\n2. Infer the sender from the current process identity or project binding.\n3. Turn \"it\" or \"this\" into a self-contained message using the relevant current\n context; do not make the recipient reconstruct missing details.\n4. Deliver it now unless the identity or intended payload is genuinely\n ambiguous.\n5. Report the Message-ID, concrete recipients, and whether each was notified\n live or queued offline.\n\nDo not stop at suggesting an AgentPost command when the user asked to send.\nExecute the delivery. Use `message` for information, work requests, specs, and\nreviews. Use `question` when an answer is expected. Routine mail defaults to\nidle notification; questions default to immediate, but the user's urgency or\nnon-interruption request overrides the default.\n\n```sh\nagentpost resolve 'Pattern Buffer'\nagentpost message 'Pattern Buffer' 'Please review the attached design context.'\nagentpost question reviewers 'Does this contract cover retry behavior?'\n```\n\nBoth commands accept `-` or an omitted body to read a multi-line message from\nstandard input. Bare registered group names are accepted; `@group` remains the\nexplicit form. Exact named identities remain addressable while offline.\n\nIf this project has a declared mailbox but is not connected, run bare\n`agentpost join --cli CURRENT_CLI` from the project root. It resolves the unique\nregistered root and idempotently handles fresh, existing, or moved integrations;\n`connect` is an alias. Use `agentpost join NAME --cli CURRENT_CLI` only when the\ncommand reports genuine identity ambiguity. Never create a new mailbox merely\nbecause a new CLI process opened.\n\n## Connection readiness\n\nWhen the user asks this running agent to set up, connect, reconnect, or receive\nmail as `NAME`, perform the deterministic recovery rather than merely proving\nthat the mailbox can be addressed:\n\n1. Resolve `NAME` and run `agentpost identify --cli CURRENT_CLI --cwd \"$PWD\"`\n to compare the requested identity with this process.\n2. Run the applicable idempotent `agentpost join --cli CURRENT_CLI`, using the\n explicit `NAME` form only for an alternate identity or genuine ambiguity.\n3. Run `agentpost doctor NAME --project \"$PWD\" --cli CURRENT_CLI` and follow\n any safe adapter instruction it reports.\n4. Run `agentpost armed NAME` and report its actual result.\n\nMailbox existence and successful `resolve`, `list`, `read`, `message`, or\n`reply` commands prove durable access only. Never claim that an agent is ready,\nconnected, receiving, or reconnected while `armed` reports `QUEUED`, or while\n`doctor` reports a failure that prevents the requested notification mode.\n\nIf a connection attempt reports that `NAME` already has an inbound consumer,\ndo not steal its lease or silently treat mailbox-level `ARMED` as proof that\nthis process connected. Report the existing owner. If it is this managed\nsession's parent bridge, continue in the existing session. Otherwise inspect\nthe exact identities and offer the user the first unused numbered mailbox,\nstarting with `NAME2`, then `NAME3`. Create it only after explicit user approval,\nthen use the explicit named join and launcher and verify it independently. The\nnumbered identity is a separate durable mailbox: it does not inherit, claim, or\nmove mail already addressed to `NAME`.\n\nDo not rewrite integration state ad hoc or launch a nested copy of the current\nCLI. A running process cannot retroactively become an alternate identity that\nrequires an explicit named launcher. If repair requires replacing or reloading\nthe current process, mark reconnection as pending and give the exact external\nlauncher, such as `agentpost codex --agent NAME`, `agentpost claude --agent\nNAME`, or `agentpost antigravity --agent NAME`. The replacement session must\nrepeat `doctor` and `armed` before reporting success.\n\nSome adapters honestly provide lifecycle catch-up without already-idle wake.\nFor those adapters, describe `QUEUED` as durable mail that will surface at the\nnext supported lifecycle boundary; do not describe it as live readiness.\n\n## Registering a durable nameplate\n\nWhen asked to register this agent, first inspect `agentpost identities` and the\ncurrent workspace or role documentation. Describe demonstrated, durable\nownership so a coworker can answer both \"who is this?\" and \"who should handle\nthis work?\"\n\n- `name`: short, stable mailbox address; do not encode a session or task.\n- `display-name`: recognizable project, team, or role name.\n- The profile is CLI-neutral. Do not make CLI type part of the identity;\n `join --cli` records each runtime adapter separately.\n- `kind`: choose `project`, `role`, `specialist`, or `hybrid` from durable\n responsibility. A code-review or marketing role must not claim project\n ownership merely because its CLI runs from that workspace.\n- `summary`: one concise sentence naming the owned domain plus the decisions,\n systems, or outputs this agent can help with. Include terms coworkers search.\n- `roles`: broad workplace functions.\n- `projects`: stable project names and aliases people will mention.\n- `specialties`: specific reusable technical or domain expertise.\n- `handles`: two to five concrete request categories that should route here.\n- `does-not-handle`: close neighboring responsibilities owned elsewhere.\n\nPrefer \"Owns Pattern Buffer temporal world-state semantics, ingestion fidelity,\nand deterministic retrieval contracts\" over \"helpful coding agent.\" Do not put\ncurrent work, status, availability, generic personality, aspirational expertise,\nor secrets in a durable profile. Make neighboring agents distinguishable and\navoid duplicated handles; tied address labels are rejected rather than guessed.\n\n```sh\nagentpost profile-register pb \\\n --display-name 'Pattern Buffer' --kind hybrid \\\n --summary 'Owns temporal world-state semantics, ingestion fidelity, and deterministic retrieval contracts.' \\\n --roles 'world-model engineering' --projects 'pattern-buffer' \\\n --project-roots \"$PWD\" --specialties 'temporal state,identity,provenance' \\\n --handles 'Pattern Buffer API reviews,world-state schema questions' \\\n --does-not-handle 'Construct narrative orchestration,Kernos member policy'\n```\n\n`agentpost profile-register --help` carries the same field guidance. After\nregistration, verify the nameplate with `agentpost identities` and test its\nlikely search terms with `agentpost agents-find QUERY --all`.\n\n## Identity and discovery\n\nAt the start of AgentPost work, resolve this session's identity from its project\nroot:\n\n```sh\nagentpost identify --cwd \"$PWD\"\n```\n\nExplicit `--agent`/`AGENTPOST_AGENT` is authoritative. Otherwise AgentPost uses\nthe deepest workspace marker, adapter binding, or declared project root, with\nthat priority for equal paths. A workspace has one default; alternate role or\nreview mailboxes in the same directory require an explicit named launcher.\n\nNever guess a recipient from conversation memory. Inspect the current directory:\n\n```sh\nagentpost identities\nagentpost resolve 'known name, display name, or project identity'\nagentpost profiles\nagentpost status\nagentpost agents-find \"topic or responsibility\"\nagentpost agents-find --role marketing\nagentpost agents-find --project construct\nagentpost agents-find --specialty \"temporal identity\"\n```\n\nNormal profile and responsibility discovery returns active agents only.\n`agentpost profiles --all` and `agentpost agents-find --all` expose offline\nhistory when explicitly needed. An exact mailbox name may still receive durable\nmail while offline; do not infer that offline means deleted.\n\nSelect the smallest relevant recipient set and retain the printed match reasons.\nFamiliarity does not outrank a better responsibility or evidence match.\n\n## Mail workflow\n\nWhen a native notification supplies one or more Message-IDs, process exactly\nthat set. Do not run a blanket inbox listing or inspect, claim, or process other\nunread letters in that turn; another attention mode may be intentionally\ndeferring them. A notification without explicit Message-IDs may use the normal\noldest-first inbox workflow below.\n\nInspecting mail does not claim it:\n\n```sh\nagentpost list AGENT\nagentpost read AGENT MESSAGE_ID\n```\n\nClaim exactly one letter only when beginning its work:\n\n```sh\nagentpost next AGENT --message-id MESSAGE_ID\n```\n\nAgentPost permits one inbound consumer per mailbox across all CLI and Python\nadapters. Do not bypass an existing consumer lease. Standby runtimes may wait\nfor takeover, while concurrently active workers should use distinct mailbox\nidentities. Atomic claim remains the final duplicate-work guard.\n\nSend routine or non-blocking work as idle mail:\n\n```sh\nagentpost message RECIPIENT 'message' --notify idle\n```\n\nUse immediate only for an active blocker or genuinely time-sensitive question:\n\n```sh\nagentpost question RECIPIENT 'question' --notify immediate\n```\n\n`send` and `ask` are lower-level forms for scripts that already have canonical\nsender and recipient mailbox keys. Prefer `message` and `question` for CLI\nagents acting on human instructions.\n\nReply against the original Message-ID:\n\n```sh\nagentpost reply MESSAGE_ID 'answer'\n```\n\nThe sender is inferred like `message` and `question`, with one addition: when\nthe sender is inferred rather than stated, reply answers from whichever mailbox\nreachable in this workspace actually holds that Message-ID. A workspace default\nseat and an alternate seat sharing the project root are both reachable, so a\nruntime notified as the alternate seat can answer its own mail without `--from`.\nThe workspace default still wins when it holds a copy, and two alternate seats\nholding the same letter is reported rather than guessed. The legacy\n`reply AGENT MESSAGE_ID` form remains accepted for scripts during migration.\nReplies to questions default to immediate notification because an answer is\nawaited; replies to ordinary letters default to idle. Use `--notify` to\noverride either case. Reply atomically claims the exact original when it is\nstill unread; already-read originals remain replyable for corrections. Claim\nwith `next` before substantial work, or let an immediate reply perform that\ntransition when composing the response is the work.\n\nIf an existing unread letter needs a fresh native notification, the ORIGINAL\nsender may re-notify that exact durable Message-ID without creating a duplicate:\n\n```sh\nagentpost notify RECIPIENT MESSAGE_ID --mode immediate\n```\n\nRe-notification never changes, claims, or copies the letter. Do not send a new\ncontrol envelope merely to point at old unread mail. Fresh adapter startup\ncatch-up names the full queued unread set in one notification turn.\n\nNamed groups, comma-separated recipients, and selectors such as\n`@role:marketing`, `@project:construct`, and `@specialty:temporal-identity` are\nresolved to concrete recipients before delivery.\n\n## Consultation posture\n\nWhen work crosses ownership boundaries or resembles a problem another agent has\nalready solved, consider the relevant peer alongside local inspection or web\nresearch. Peer advice is attributed evidence, not a replacement for direct\nsource verification. Do not generate mail for trivial local tasks.\n\nFor reviews, put the complete actionable request in AgentPost only. Never copy\nit into a legacy inbox. A legacy message may contain installation control or a\npointer to an existing AgentPost Message-ID after a proven notification failure,\nbut must not duplicate the work.\n\nSend repository review work through the fail-closed review channel:\n\n```sh\ncommit=$(git -C REPOSITORY rev-parse HEAD)\nparent=$(git -C REPOSITORY rev-parse HEAD^)\nagentpost review RECIPIENT - --repo REPOSITORY \\\n --commit \"$commit\" --parent \"$parent\" \\\n --path src/module.py \\\n --test tests/test_module.py::ModuleTest::test_behavior\n```\n\nUse explicit full commit-object SHAs. Never pass a literal command substitution,\nplaceholder, branch, or mutable `HEAD` as a structured value. Repeat `--path`\nand `--test` for the complete assertion set; every test must begin with its\ncommit-tree file as `RELATIVE_PATH::TEST_NODE`. `--parent` is optional but, when\npresent, must be a direct parent. AgentPost verifies the worktree, artifact,\npaths, and test files, prints the complete generated envelope, and writes no\nmail if preflight fails. Inspect the referenced diff before sending and label\nanything not directly verified as unverified. The receiver treats the immutable\nrepository diff and machine-readable review headers as authoritative rather\nthan trusting prose.\n\nLegacy cutover is per agent. Migrate a project's durable communication policy\nonly after that exact agent has proven inbound receipt/claim and outbound\ndelivery. Its migration directive must make AgentPost primary, prohibit new\nactionable legacy letters, retain the old folder as read-only history, and stop\nlegacy polling. Other unconfirmed agents keep their existing recovery path.\n\nAfter processing new mail, report a short synopsis in the active user chat:\nwhat arrived, what work was done, and what response was sent or remains pending.\n", + "claude/agentpost/skills/agentpost/SKILL.md": "---\nname: agentpost\ndescription: Use AgentPost as the local agent communication channel and address book. Trigger when the user says to send, tell, ask, share, forward, confer, or review with a named local agent, project identity, specialist, or group; when setting up, connecting, reconnecting, or diagnosing an AgentPost mailbox or adapter; when another agent may own relevant context; or when processing AgentPost notifications.\n---\n\n# AgentPost\n\nAgentPost is the local post office for active CLI agents. The `agentpost`\ncommand and files under `~/.agentpost` are the durable truth; notifications are\nonly pointers.\n\n## Channel semantics\n\nTreat AgentPost as a communication channel, not merely an inbox utility. A user\ninstruction such as \"send it to PB\", \"tell Construct\", \"ask Kernos\", or \"share\nthis with the reviewers\" means:\n\n1. Resolve the named destination through the AgentPost address book.\n2. Infer the sender from the current process identity or project binding.\n3. Turn \"it\" or \"this\" into a self-contained message using the relevant current\n context; do not make the recipient reconstruct missing details.\n4. Deliver it now unless the identity or intended payload is genuinely\n ambiguous.\n5. Report the Message-ID, concrete recipients, and whether each was notified\n live or queued offline.\n\nDo not stop at suggesting an AgentPost command when the user asked to send.\nExecute the delivery. Use `message` for information, work requests, specs, and\nreviews. Use `question` when an answer is expected. Routine mail defaults to\nidle notification; questions default to immediate, but the user's urgency or\nnon-interruption request overrides the default.\n\n```sh\nagentpost resolve nav\nagentpost message nav 'Please review the attached design context.'\nagentpost question pbe.codereview 'Does this contract cover retry behavior?'\nagentpost question reviewers 'Does this contract cover retry behavior?'\n```\n\nBoth commands accept `-` or an omitted body to read a multi-line message from\nstandard input. Bare registered group names are accepted; `@group` remains the\nexplicit form. Bare identity names resolve only among seats sharing the\nsender's registered projects. Cross-project seats must use `PROJECT.SEAT`;\nnever retry a missing local bare name against the global directory. Qualified\nidentities remain addressable while offline.\n\nIf this project has a declared mailbox but is not connected, run bare\n`agentpost join --cli CURRENT_CLI` from the project root. It resolves the unique\nregistered root and idempotently handles fresh, existing, or moved integrations;\n`connect` is an alias. Use `agentpost join NAME --cli CURRENT_CLI` only when the\ncommand reports genuine identity ambiguity. Never create a new mailbox merely\nbecause a new CLI process opened.\n\n## Connection readiness\n\nWhen the user asks this running agent to set up, connect, reconnect, or receive\nmail as `NAME`, perform the deterministic recovery rather than merely proving\nthat the mailbox can be addressed:\n\n1. Resolve `NAME` and run `agentpost identify --cli CURRENT_CLI --cwd \"$PWD\"`\n to compare the requested identity with this process.\n2. If an ordinary live Codex thread needs a different known seat at this\n workspace, run `agentpost attach NAME`. Otherwise run the applicable\n idempotent `agentpost join --cli CURRENT_CLI`, using the explicit `NAME` form\n only for an alternate identity or genuine ambiguity.\n3. Run `agentpost doctor NAME --project \"$PWD\" --cli CURRENT_CLI` and follow\n any safe adapter instruction it reports.\n4. Run `agentpost armed NAME` and report its actual result.\n\nMailbox existence and successful `resolve`, `list`, `read`, `message`, or\n`reply` commands prove durable access only. Never claim that an agent is ready,\nconnected, receiving, or reconnected while `armed` reports `QUEUED`, or while\n`doctor` reports a failure that prevents the requested notification mode.\nAn `attach` result may be reported specifically as a boundary-only identity\nselection while `armed` remains `QUEUED`; do not call that live wake.\nAfter attachment, `doctor` reports exact `codex-session-attachment` health\nseparately from aggregate `codex-generation` recovery. A stale aggregate line\ndoes not negate an exact-thread attachment pass, and neither result upgrades\nboundary-only delivery to live wake.\n\nIf a connection attempt reports that `NAME` already has an inbound consumer,\ndo not steal its lease or silently treat mailbox-level `ARMED` as proof that\nthis process connected. Report the existing owner. If it is this managed\nsession's parent bridge, continue in the existing session. Otherwise inspect\nthe exact identities and offer the user the first unused numbered mailbox,\nstarting with `NAME2`, then `NAME3`. Create it only after explicit user approval,\nthen use the explicit named join and launcher and verify it independently. The\nnumbered identity is a separate durable mailbox: it does not inherit, claim, or\nmove mail already addressed to `NAME`.\n\nDo not rewrite integration state ad hoc or launch a nested copy of the current\nCLI. An ordinary running Codex thread with compatible AgentPost hooks may select\nan alternate known workspace seat through `agentpost attach NAME`. Report its\nreturned capability precisely: `boundary-only` changes sender identity and\nnext-boundary catch-up without publishing presence or providing already-idle\nwake. Do not use `join` or replace a plugin merely to change that live session\nidentity. If full wake or another adapter requires replacing or reloading the\ncurrent process, mark reconnection as pending and give the exact external\nlauncher, such as `agentpost codex --agent NAME resume THREAD_ID`, `agentpost\nclaude --agent NAME`, or `agentpost antigravity --agent NAME`. The replacement\nsession must repeat `doctor` and `armed` before reporting success.\n\nSome adapters honestly provide lifecycle catch-up without already-idle wake.\nFor those adapters, describe `QUEUED` as durable mail that will surface at the\nnext supported lifecycle boundary; do not describe it as live readiness.\n\n## Registering a durable nameplate\n\nWhen asked to register this agent, first inspect `agentpost identities` and the\ncurrent workspace or role documentation. Describe demonstrated, durable\nownership so a coworker can answer both \"who is this?\" and \"who should handle\nthis work?\"\n\n- `name`: short, stable dot-free mailbox address; do not encode a session or\n task. Dot is reserved for `PROJECT.SEAT`.\n- `display-name`: recognizable project, team, or role name.\n- The profile is CLI-neutral. Do not make CLI type part of the identity;\n `join --cli` records each runtime adapter separately.\n- `kind`: choose `project`, `role`, `specialist`, or `hybrid` from durable\n responsibility. A code-review or marketing role must not claim project\n ownership merely because its CLI runs from that workspace.\n- `summary`: one concise sentence naming the owned domain plus the decisions,\n systems, or outputs this agent can help with. Include terms coworkers search.\n- `roles`: broad workplace functions.\n- `projects`: stable dot-free project names and aliases people will mention.\n This is also the seat's address namespace, not a claim that a role owns the\n runtime workspace. Include a short alias when coworkers need a compact\n cross-project address.\n- `specialties`: specific reusable technical or domain expertise.\n- `handles`: put a simple seat address such as `nav`, `build`, or `codereview`\n first, then two to five concrete request categories that should route here.\n- `does-not-handle`: close neighboring responsibilities owned elsewhere.\n\nPrefer \"Owns Pattern Buffer temporal world-state semantics, ingestion fidelity,\nand deterministic retrieval contracts\" over \"helpful coding agent.\" Do not put\ncurrent work, status, availability, generic personality, aspirational expertise,\nor secrets in a durable profile. Make neighboring agents distinguishable and\navoid duplicated handles; tied address labels are rejected rather than guessed.\n\n```sh\nagentpost profile-register pb \\\n --display-name 'Pattern Buffer' --kind hybrid \\\n --summary 'Owns temporal world-state semantics, ingestion fidelity, and deterministic retrieval contracts.' \\\n --roles 'world-model engineering' --projects 'pattern-buffer' \\\n --project-roots \"$PWD\" --specialties 'temporal state,identity,provenance' \\\n --handles 'pb,Pattern Buffer API reviews,world-state schema questions' \\\n --does-not-handle 'Construct narrative orchestration,Kernos member policy'\n```\n\n`agentpost profile-register --help` carries the same field guidance. After\nregistration, verify the nameplate with `agentpost identities` and test its\nlikely search terms with `agentpost agents-find QUERY --all`.\n\nAlso verify the project-qualified address and complete seat roster:\n\n```sh\nagentpost identities --project pattern-buffer\nagentpost resolve pattern-buffer.pb\n```\n\n## Identity and discovery\n\nAt the start of AgentPost work, resolve this session's identity from its project\nroot:\n\n```sh\nagentpost identify --cwd \"$PWD\"\n```\n\nExplicit `--agent`/`AGENTPOST_AGENT` is authoritative. Otherwise AgentPost uses\nan active Codex session attachment, then the deepest workspace marker, adapter\nbinding, or declared project root, with that priority for equal paths. A\nworkspace has one default; alternate role or review mailboxes in the same\ndirectory require `agentpost attach NAME` in a compatible active Codex thread\nor an explicit named launcher.\n\nNever guess a recipient from conversation memory. Inspect the current directory:\n\n```sh\nagentpost identities\nagentpost identities --project construct\nagentpost resolve nav\nagentpost resolve other-project.nav\nagentpost profiles\nagentpost profiles --project construct --all\nagentpost status\nagentpost agents-find \"topic or responsibility\"\nagentpost agents-find --role marketing\nagentpost agents-find --project construct\nagentpost agents-find --specialty \"temporal identity\"\n```\n\nNormal profile and responsibility discovery returns active agents only.\n`agentpost profiles --all` and `agentpost agents-find --all` expose offline\nhistory when explicitly needed. `identities --project PROJECT` returns the\ncomplete project roster including offline seats. A qualified identity may still\nreceive durable mail while offline; do not infer that offline means deleted.\n\nSelect the smallest relevant recipient set and retain the printed match reasons.\nFamiliarity does not outrank a better responsibility or evidence match.\n\n## Mail workflow\n\nWhen a native notification supplies one or more Message-IDs, process exactly\nthat set. Do not run a blanket inbox listing or inspect, claim, or process other\nunread letters in that turn; another attention mode may be intentionally\ndeferring them. A notification without explicit Message-IDs may use the normal\noldest-first inbox workflow below.\n\nInspecting mail does not claim it:\n\n```sh\nagentpost list AGENT\nagentpost read AGENT MESSAGE_ID\n```\n\nClaim exactly one letter only when beginning its work:\n\n```sh\nagentpost next AGENT --message-id MESSAGE_ID\n```\n\nAgentPost permits one inbound consumer per mailbox across all CLI and Python\nadapters. Do not bypass an existing consumer lease. Standby runtimes may wait\nfor takeover, while concurrently active workers should use distinct mailbox\nidentities. Atomic claim remains the final duplicate-work guard.\n\nSend routine or non-blocking work as idle mail:\n\n```sh\nagentpost message RECIPIENT 'message' --notify idle\n```\n\nUse immediate only for an active blocker or genuinely time-sensitive question:\n\n```sh\nagentpost question RECIPIENT 'question' --notify immediate\n```\n\n`send` and `ask` are lower-level forms for scripts that already have canonical\nsender and recipient mailbox keys. Prefer `message` and `question` for CLI\nagents acting on human instructions.\n\nReply against the original Message-ID:\n\n```sh\nagentpost reply MESSAGE_ID 'answer'\n```\n\nThe sender is inferred like `message` and `question`, with one addition: when\nthe sender is inferred rather than stated, reply answers from whichever mailbox\nreachable in this workspace actually holds that Message-ID. A workspace default\nseat and an alternate seat sharing the project root are both reachable, so a\nruntime notified as the alternate seat can answer its own mail without `--from`.\nThe workspace default still wins when it holds a copy, and two alternate seats\nholding the same letter is reported rather than guessed. The legacy\n`reply AGENT MESSAGE_ID` form remains accepted for scripts during migration.\nReplies to questions default to immediate notification because an answer is\nawaited; replies to ordinary letters default to idle. Use `--notify` to\noverride either case. Reply atomically claims the exact original when it is\nstill unread; already-read originals remain replyable for corrections. Claim\nwith `next` before substantial work, or let an immediate reply perform that\ntransition when composing the response is the work.\n\nIf an existing unread letter needs a fresh native notification, the ORIGINAL\nsender may re-notify that exact durable Message-ID without creating a duplicate:\n\n```sh\nagentpost notify RECIPIENT MESSAGE_ID --mode immediate\n```\n\nRe-notification never changes, claims, or copies the letter. Do not send a new\ncontrol envelope merely to point at old unread mail. Fresh adapter startup\ncatch-up names the full queued unread set in one notification turn.\n\nNamed groups, comma-separated recipients, and selectors such as\n`@role:marketing`, `@project:construct`, and `@specialty:temporal-identity` are\nresolved to concrete recipients before delivery.\n\n## Clean-start deletion\n\nNever manually remove AgentPost directories when a clean start is requested.\nUse the guarded wipe workflow, which never touches source or AgentBridge\nrepositories:\n\n```sh\nagentpost wipe agent\nagentpost wipe agent other-project.nav\nagentpost wipe project other-project\nagentpost wipe all\n```\n\n`wipe agent` with no name removes only this session's resolved mailbox and\nneeds no affected-box confirmation. It still refuses any held inbound consumer\nlease, including a live adapter for the same seat: close that adapter and run\nthe final wipe from a terminal with `AGENTPOST_AGENT=NAME`. Treat successful\nself-wipe as this seat's final action and do not continue using the deleted\nidentity.\n\nAnything broader than this seat requires explicit user confirmation. Run the\nrequested command once without `--confirm`; it performs no deletion and returns\nthe exact sorted affected mailbox list plus the exact confirmation argument.\nShow that list to the user and ask them to confirm that those boxes will be\ndeleted. Only after the user confirms, rerun with the printed\n`--confirm 'BOX1,BOX2'`. Never infer confirmation from the original deletion\nrequest when the affected list had not yet been shown. If the list changes,\nstop and confirm the new list. Stop every live mailbox consumer first; the wipe\nholds the authoritative lease fence through mailbox detachment.\n\nSuccessful wipe is irreversible within AgentPost. Report which boxes were\nremoved, that target profiles/mail/bindings/adapter state/group membership were\ndeleted, that source repositories were untouched, and that copies held by\nunaffected mailboxes remain.\n\n## Consultation posture\n\nWhen work crosses ownership boundaries or resembles a problem another agent has\nalready solved, consider the relevant peer alongside local inspection or web\nresearch. Peer advice is attributed evidence, not a replacement for direct\nsource verification. Do not generate mail for trivial local tasks.\n\nFor reviews, put the complete actionable request in AgentPost only. Never copy\nit into a legacy inbox. A legacy message may contain installation control or a\npointer to an existing AgentPost Message-ID after a proven notification failure,\nbut must not duplicate the work.\n\nSend repository review work through the fail-closed review channel:\n\n```sh\ncommit=$(git -C REPOSITORY rev-parse HEAD)\nparent=$(git -C REPOSITORY rev-parse HEAD^)\nagentpost review RECIPIENT - --repo REPOSITORY \\\n --commit \"$commit\" --parent \"$parent\" \\\n --path src/module.py \\\n --test tests/test_module.py::ModuleTest::test_behavior\n```\n\nUse explicit full commit-object SHAs. Never pass a literal command substitution,\nplaceholder, branch, or mutable `HEAD` as a structured value. Repeat `--path`\nand `--test` for the complete assertion set; every test must begin with its\ncommit-tree file as `RELATIVE_PATH::TEST_NODE`. `--parent` is optional but, when\npresent, must be a direct parent. AgentPost verifies the worktree, artifact,\npaths, and test files, prints the complete generated envelope, and writes no\nmail if preflight fails. Inspect the referenced diff before sending and label\nanything not directly verified as unverified. The receiver treats the immutable\nrepository diff and machine-readable review headers as authoritative rather\nthan trusting prose.\n\nLegacy cutover is per agent. Migrate a project's durable communication policy\nonly after that exact agent has proven inbound receipt/claim and outbound\ndelivery. Its migration directive must make AgentPost primary, prohibit new\nactionable legacy letters, retain the old folder as read-only history, and stop\nlegacy polling. Other unconfirmed agents keep their existing recovery path.\n\nAfter processing new mail, report a short synopsis in the active user chat:\nwhat arrived, what work was done, and what response was sent or remains pending.\n", "codex/.agents/plugins/marketplace.json": "{\n \"name\": \"agentpost-local\",\n \"interface\": {\n \"displayName\": \"AgentPost Local Development\"\n },\n \"plugins\": [\n {\n \"name\": \"agentpost\",\n \"source\": {\n \"source\": \"local\",\n \"path\": \"./plugins/agentpost\"\n },\n \"policy\": {\n \"installation\": \"AVAILABLE\",\n \"authentication\": \"ON_INSTALL\"\n },\n \"category\": \"Productivity\"\n }\n ]\n}\n", - "codex/plugins/agentpost/.codex-plugin/plugin.json": "{\n \"name\": \"agentpost\",\n \"version\": \"0.0.5+codex.20260712082137\",\n \"description\": \"A named local communication channel for CLI agents\",\n \"author\": {\n \"name\": \"AgentPost contributors\"\n },\n \"skills\": \"./skills/\",\n \"interface\": {\n \"displayName\": \"AgentPost\",\n \"shortDescription\": \"Named local agent communication.\",\n \"longDescription\": \"Resolve registered agent identities and groups, exchange durable local messages and questions, and surface unread work at Codex lifecycle boundaries.\",\n \"developerName\": \"AgentPost contributors\",\n \"category\": \"Productivity\",\n \"capabilities\": [\n \"skills\",\n \"hooks\"\n ],\n \"defaultPrompt\": \"Use AgentPost as this project's named agent communication channel and check unread mail.\"\n }\n}\n", + "codex/plugins/agentpost/.codex-plugin/plugin.json": "{\n \"name\": \"agentpost\",\n \"version\": \"0.0.6+codex.20260728214546\",\n \"description\": \"A named local communication channel for CLI agents\",\n \"author\": {\n \"name\": \"AgentPost contributors\"\n },\n \"skills\": \"./skills/\",\n \"interface\": {\n \"displayName\": \"AgentPost\",\n \"shortDescription\": \"Named local agent communication.\",\n \"longDescription\": \"Resolve registered agent identities and groups, exchange durable local messages and questions, and surface unread work at Codex lifecycle boundaries.\",\n \"developerName\": \"AgentPost contributors\",\n \"category\": \"Productivity\",\n \"capabilities\": [\n \"skills\",\n \"hooks\"\n ],\n \"defaultPrompt\": \"Use AgentPost as this project's named agent communication channel and check unread mail.\"\n }\n}\n", "codex/plugins/agentpost/hooks/hooks.json": "{\n \"hooks\": {\n \"SessionStart\": [\n {\n \"hooks\": [\n {\n \"type\": \"command\",\n \"command\": \"agentpost internal-codex-hook session-start\",\n \"statusMessage\": \"Checking AgentPost mail\"\n }\n ]\n }\n ],\n \"Stop\": [\n {\n \"hooks\": [\n {\n \"type\": \"command\",\n \"command\": \"agentpost internal-codex-hook stop\",\n \"statusMessage\": \"Checking AgentPost mail\"\n }\n ]\n }\n ]\n }\n}\n", - "codex/plugins/agentpost/skills/agentpost/SKILL.md": "---\nname: agentpost\ndescription: Use AgentPost as the local agent communication channel and address book. Trigger when the user says to send, tell, ask, share, forward, confer, or review with a named local agent, project identity, specialist, or group; when setting up, connecting, reconnecting, or diagnosing an AgentPost mailbox or adapter; when another agent may own relevant context; or when processing AgentPost notifications.\n---\n\n# AgentPost\n\nAgentPost is the local post office for active CLI agents. The `agentpost`\ncommand and files under `~/.agentpost` are the durable truth; notifications are\nonly pointers.\n\n## Channel semantics\n\nTreat AgentPost as a communication channel, not merely an inbox utility. A user\ninstruction such as \"send it to PB\", \"tell Construct\", \"ask Kernos\", or \"share\nthis with the reviewers\" means:\n\n1. Resolve the named destination through the AgentPost address book.\n2. Infer the sender from the current process identity or project binding.\n3. Turn \"it\" or \"this\" into a self-contained message using the relevant current\n context; do not make the recipient reconstruct missing details.\n4. Deliver it now unless the identity or intended payload is genuinely\n ambiguous.\n5. Report the Message-ID, concrete recipients, and whether each was notified\n live or queued offline.\n\nDo not stop at suggesting an AgentPost command when the user asked to send.\nExecute the delivery. Use `message` for information, work requests, specs, and\nreviews. Use `question` when an answer is expected. Routine mail defaults to\nidle notification; questions default to immediate, but the user's urgency or\nnon-interruption request overrides the default.\n\n```sh\nagentpost resolve 'Pattern Buffer'\nagentpost message 'Pattern Buffer' 'Please review the attached design context.'\nagentpost question reviewers 'Does this contract cover retry behavior?'\n```\n\nBoth commands accept `-` or an omitted body to read a multi-line message from\nstandard input. Bare registered group names are accepted; `@group` remains the\nexplicit form. Exact named identities remain addressable while offline.\n\nIf this project has a declared mailbox but is not connected, run bare\n`agentpost join --cli CURRENT_CLI` from the project root. It resolves the unique\nregistered root and idempotently handles fresh, existing, or moved integrations;\n`connect` is an alias. Use `agentpost join NAME --cli CURRENT_CLI` only when the\ncommand reports genuine identity ambiguity. Never create a new mailbox merely\nbecause a new CLI process opened.\n\n## Connection readiness\n\nWhen the user asks this running agent to set up, connect, reconnect, or receive\nmail as `NAME`, perform the deterministic recovery rather than merely proving\nthat the mailbox can be addressed:\n\n1. Resolve `NAME` and run `agentpost identify --cli CURRENT_CLI --cwd \"$PWD\"`\n to compare the requested identity with this process.\n2. Run the applicable idempotent `agentpost join --cli CURRENT_CLI`, using the\n explicit `NAME` form only for an alternate identity or genuine ambiguity.\n3. Run `agentpost doctor NAME --project \"$PWD\" --cli CURRENT_CLI` and follow\n any safe adapter instruction it reports.\n4. Run `agentpost armed NAME` and report its actual result.\n\nMailbox existence and successful `resolve`, `list`, `read`, `message`, or\n`reply` commands prove durable access only. Never claim that an agent is ready,\nconnected, receiving, or reconnected while `armed` reports `QUEUED`, or while\n`doctor` reports a failure that prevents the requested notification mode.\n\nIf a connection attempt reports that `NAME` already has an inbound consumer,\ndo not steal its lease or silently treat mailbox-level `ARMED` as proof that\nthis process connected. Report the existing owner. If it is this managed\nsession's parent bridge, continue in the existing session. Otherwise inspect\nthe exact identities and offer the user the first unused numbered mailbox,\nstarting with `NAME2`, then `NAME3`. Create it only after explicit user approval,\nthen use the explicit named join and launcher and verify it independently. The\nnumbered identity is a separate durable mailbox: it does not inherit, claim, or\nmove mail already addressed to `NAME`.\n\nDo not rewrite integration state ad hoc or launch a nested copy of the current\nCLI. A running process cannot retroactively become an alternate identity that\nrequires an explicit named launcher. If repair requires replacing or reloading\nthe current process, mark reconnection as pending and give the exact external\nlauncher, such as `agentpost codex --agent NAME`, `agentpost claude --agent\nNAME`, or `agentpost antigravity --agent NAME`. The replacement session must\nrepeat `doctor` and `armed` before reporting success.\n\nSome adapters honestly provide lifecycle catch-up without already-idle wake.\nFor those adapters, describe `QUEUED` as durable mail that will surface at the\nnext supported lifecycle boundary; do not describe it as live readiness.\n\n## Registering a durable nameplate\n\nWhen asked to register this agent, first inspect `agentpost identities` and the\ncurrent workspace or role documentation. Describe demonstrated, durable\nownership so a coworker can answer both \"who is this?\" and \"who should handle\nthis work?\"\n\n- `name`: short, stable mailbox address; do not encode a session or task.\n- `display-name`: recognizable project, team, or role name.\n- The profile is CLI-neutral. Do not make CLI type part of the identity;\n `join --cli` records each runtime adapter separately.\n- `kind`: choose `project`, `role`, `specialist`, or `hybrid` from durable\n responsibility. A code-review or marketing role must not claim project\n ownership merely because its CLI runs from that workspace.\n- `summary`: one concise sentence naming the owned domain plus the decisions,\n systems, or outputs this agent can help with. Include terms coworkers search.\n- `roles`: broad workplace functions.\n- `projects`: stable project names and aliases people will mention.\n- `specialties`: specific reusable technical or domain expertise.\n- `handles`: two to five concrete request categories that should route here.\n- `does-not-handle`: close neighboring responsibilities owned elsewhere.\n\nPrefer \"Owns Pattern Buffer temporal world-state semantics, ingestion fidelity,\nand deterministic retrieval contracts\" over \"helpful coding agent.\" Do not put\ncurrent work, status, availability, generic personality, aspirational expertise,\nor secrets in a durable profile. Make neighboring agents distinguishable and\navoid duplicated handles; tied address labels are rejected rather than guessed.\n\n```sh\nagentpost profile-register pb \\\n --display-name 'Pattern Buffer' --kind hybrid \\\n --summary 'Owns temporal world-state semantics, ingestion fidelity, and deterministic retrieval contracts.' \\\n --roles 'world-model engineering' --projects 'pattern-buffer' \\\n --project-roots \"$PWD\" --specialties 'temporal state,identity,provenance' \\\n --handles 'Pattern Buffer API reviews,world-state schema questions' \\\n --does-not-handle 'Construct narrative orchestration,Kernos member policy'\n```\n\n`agentpost profile-register --help` carries the same field guidance. After\nregistration, verify the nameplate with `agentpost identities` and test its\nlikely search terms with `agentpost agents-find QUERY --all`.\n\n## Identity and discovery\n\nAt the start of AgentPost work, resolve this session's identity from its project\nroot:\n\n```sh\nagentpost identify --cwd \"$PWD\"\n```\n\nExplicit `--agent`/`AGENTPOST_AGENT` is authoritative. Otherwise AgentPost uses\nthe deepest workspace marker, adapter binding, or declared project root, with\nthat priority for equal paths. A workspace has one default; alternate role or\nreview mailboxes in the same directory require an explicit named launcher.\n\nNever guess a recipient from conversation memory. Inspect the current directory:\n\n```sh\nagentpost identities\nagentpost resolve 'known name, display name, or project identity'\nagentpost profiles\nagentpost status\nagentpost agents-find \"topic or responsibility\"\nagentpost agents-find --role marketing\nagentpost agents-find --project construct\nagentpost agents-find --specialty \"temporal identity\"\n```\n\nNormal profile and responsibility discovery returns active agents only.\n`agentpost profiles --all` and `agentpost agents-find --all` expose offline\nhistory when explicitly needed. An exact mailbox name may still receive durable\nmail while offline; do not infer that offline means deleted.\n\nSelect the smallest relevant recipient set and retain the printed match reasons.\nFamiliarity does not outrank a better responsibility or evidence match.\n\n## Mail workflow\n\nWhen a native notification supplies one or more Message-IDs, process exactly\nthat set. Do not run a blanket inbox listing or inspect, claim, or process other\nunread letters in that turn; another attention mode may be intentionally\ndeferring them. A notification without explicit Message-IDs may use the normal\noldest-first inbox workflow below.\n\nInspecting mail does not claim it:\n\n```sh\nagentpost list AGENT\nagentpost read AGENT MESSAGE_ID\n```\n\nClaim exactly one letter only when beginning its work:\n\n```sh\nagentpost next AGENT --message-id MESSAGE_ID\n```\n\nAgentPost permits one inbound consumer per mailbox across all CLI and Python\nadapters. Do not bypass an existing consumer lease. Standby runtimes may wait\nfor takeover, while concurrently active workers should use distinct mailbox\nidentities. Atomic claim remains the final duplicate-work guard.\n\nSend routine or non-blocking work as idle mail:\n\n```sh\nagentpost message RECIPIENT 'message' --notify idle\n```\n\nUse immediate only for an active blocker or genuinely time-sensitive question:\n\n```sh\nagentpost question RECIPIENT 'question' --notify immediate\n```\n\n`send` and `ask` are lower-level forms for scripts that already have canonical\nsender and recipient mailbox keys. Prefer `message` and `question` for CLI\nagents acting on human instructions.\n\nReply against the original Message-ID:\n\n```sh\nagentpost reply MESSAGE_ID 'answer'\n```\n\nThe sender is inferred like `message` and `question`, with one addition: when\nthe sender is inferred rather than stated, reply answers from whichever mailbox\nreachable in this workspace actually holds that Message-ID. A workspace default\nseat and an alternate seat sharing the project root are both reachable, so a\nruntime notified as the alternate seat can answer its own mail without `--from`.\nThe workspace default still wins when it holds a copy, and two alternate seats\nholding the same letter is reported rather than guessed. The legacy\n`reply AGENT MESSAGE_ID` form remains accepted for scripts during migration.\nReplies to questions default to immediate notification because an answer is\nawaited; replies to ordinary letters default to idle. Use `--notify` to\noverride either case. Reply atomically claims the exact original when it is\nstill unread; already-read originals remain replyable for corrections. Claim\nwith `next` before substantial work, or let an immediate reply perform that\ntransition when composing the response is the work.\n\nIf an existing unread letter needs a fresh native notification, the ORIGINAL\nsender may re-notify that exact durable Message-ID without creating a duplicate:\n\n```sh\nagentpost notify RECIPIENT MESSAGE_ID --mode immediate\n```\n\nRe-notification never changes, claims, or copies the letter. Do not send a new\ncontrol envelope merely to point at old unread mail. Fresh adapter startup\ncatch-up names the full queued unread set in one notification turn.\n\nNamed groups, comma-separated recipients, and selectors such as\n`@role:marketing`, `@project:construct`, and `@specialty:temporal-identity` are\nresolved to concrete recipients before delivery.\n\n## Consultation posture\n\nWhen work crosses ownership boundaries or resembles a problem another agent has\nalready solved, consider the relevant peer alongside local inspection or web\nresearch. Peer advice is attributed evidence, not a replacement for direct\nsource verification. Do not generate mail for trivial local tasks.\n\nFor reviews, put the complete actionable request in AgentPost only. Never copy\nit into a legacy inbox. A legacy message may contain installation control or a\npointer to an existing AgentPost Message-ID after a proven notification failure,\nbut must not duplicate the work.\n\nSend repository review work through the fail-closed review channel:\n\n```sh\ncommit=$(git -C REPOSITORY rev-parse HEAD)\nparent=$(git -C REPOSITORY rev-parse HEAD^)\nagentpost review RECIPIENT - --repo REPOSITORY \\\n --commit \"$commit\" --parent \"$parent\" \\\n --path src/module.py \\\n --test tests/test_module.py::ModuleTest::test_behavior\n```\n\nUse explicit full commit-object SHAs. Never pass a literal command substitution,\nplaceholder, branch, or mutable `HEAD` as a structured value. Repeat `--path`\nand `--test` for the complete assertion set; every test must begin with its\ncommit-tree file as `RELATIVE_PATH::TEST_NODE`. `--parent` is optional but, when\npresent, must be a direct parent. AgentPost verifies the worktree, artifact,\npaths, and test files, prints the complete generated envelope, and writes no\nmail if preflight fails. Inspect the referenced diff before sending and label\nanything not directly verified as unverified. The receiver treats the immutable\nrepository diff and machine-readable review headers as authoritative rather\nthan trusting prose.\n\nLegacy cutover is per agent. Migrate a project's durable communication policy\nonly after that exact agent has proven inbound receipt/claim and outbound\ndelivery. Its migration directive must make AgentPost primary, prohibit new\nactionable legacy letters, retain the old folder as read-only history, and stop\nlegacy polling. Other unconfirmed agents keep their existing recovery path.\n\nAfter processing new mail, report a short synopsis in the active user chat:\nwhat arrived, what work was done, and what response was sent or remains pending.\n" + "codex/plugins/agentpost/skills/agentpost/SKILL.md": "---\nname: agentpost\ndescription: Use AgentPost as the local agent communication channel and address book. Trigger when the user says to send, tell, ask, share, forward, confer, or review with a named local agent, project identity, specialist, or group; when setting up, connecting, reconnecting, or diagnosing an AgentPost mailbox or adapter; when another agent may own relevant context; or when processing AgentPost notifications.\n---\n\n# AgentPost\n\nAgentPost is the local post office for active CLI agents. The `agentpost`\ncommand and files under `~/.agentpost` are the durable truth; notifications are\nonly pointers.\n\n## Channel semantics\n\nTreat AgentPost as a communication channel, not merely an inbox utility. A user\ninstruction such as \"send it to PB\", \"tell Construct\", \"ask Kernos\", or \"share\nthis with the reviewers\" means:\n\n1. Resolve the named destination through the AgentPost address book.\n2. Infer the sender from the current process identity or project binding.\n3. Turn \"it\" or \"this\" into a self-contained message using the relevant current\n context; do not make the recipient reconstruct missing details.\n4. Deliver it now unless the identity or intended payload is genuinely\n ambiguous.\n5. Report the Message-ID, concrete recipients, and whether each was notified\n live or queued offline.\n\nDo not stop at suggesting an AgentPost command when the user asked to send.\nExecute the delivery. Use `message` for information, work requests, specs, and\nreviews. Use `question` when an answer is expected. Routine mail defaults to\nidle notification; questions default to immediate, but the user's urgency or\nnon-interruption request overrides the default.\n\n```sh\nagentpost resolve nav\nagentpost message nav 'Please review the attached design context.'\nagentpost question pbe.codereview 'Does this contract cover retry behavior?'\nagentpost question reviewers 'Does this contract cover retry behavior?'\n```\n\nBoth commands accept `-` or an omitted body to read a multi-line message from\nstandard input. Bare registered group names are accepted; `@group` remains the\nexplicit form. Bare identity names resolve only among seats sharing the\nsender's registered projects. Cross-project seats must use `PROJECT.SEAT`;\nnever retry a missing local bare name against the global directory. Qualified\nidentities remain addressable while offline.\n\nIf this project has a declared mailbox but is not connected, run bare\n`agentpost join --cli CURRENT_CLI` from the project root. It resolves the unique\nregistered root and idempotently handles fresh, existing, or moved integrations;\n`connect` is an alias. Use `agentpost join NAME --cli CURRENT_CLI` only when the\ncommand reports genuine identity ambiguity. Never create a new mailbox merely\nbecause a new CLI process opened.\n\n## Connection readiness\n\nWhen the user asks this running agent to set up, connect, reconnect, or receive\nmail as `NAME`, perform the deterministic recovery rather than merely proving\nthat the mailbox can be addressed:\n\n1. Resolve `NAME` and run `agentpost identify --cli CURRENT_CLI --cwd \"$PWD\"`\n to compare the requested identity with this process.\n2. If an ordinary live Codex thread needs a different known seat at this\n workspace, run `agentpost attach NAME`. Otherwise run the applicable\n idempotent `agentpost join --cli CURRENT_CLI`, using the explicit `NAME` form\n only for an alternate identity or genuine ambiguity.\n3. Run `agentpost doctor NAME --project \"$PWD\" --cli CURRENT_CLI` and follow\n any safe adapter instruction it reports.\n4. Run `agentpost armed NAME` and report its actual result.\n\nMailbox existence and successful `resolve`, `list`, `read`, `message`, or\n`reply` commands prove durable access only. Never claim that an agent is ready,\nconnected, receiving, or reconnected while `armed` reports `QUEUED`, or while\n`doctor` reports a failure that prevents the requested notification mode.\nAn `attach` result may be reported specifically as a boundary-only identity\nselection while `armed` remains `QUEUED`; do not call that live wake.\nAfter attachment, `doctor` reports exact `codex-session-attachment` health\nseparately from aggregate `codex-generation` recovery. A stale aggregate line\ndoes not negate an exact-thread attachment pass, and neither result upgrades\nboundary-only delivery to live wake.\n\nIf a connection attempt reports that `NAME` already has an inbound consumer,\ndo not steal its lease or silently treat mailbox-level `ARMED` as proof that\nthis process connected. Report the existing owner. If it is this managed\nsession's parent bridge, continue in the existing session. Otherwise inspect\nthe exact identities and offer the user the first unused numbered mailbox,\nstarting with `NAME2`, then `NAME3`. Create it only after explicit user approval,\nthen use the explicit named join and launcher and verify it independently. The\nnumbered identity is a separate durable mailbox: it does not inherit, claim, or\nmove mail already addressed to `NAME`.\n\nDo not rewrite integration state ad hoc or launch a nested copy of the current\nCLI. An ordinary running Codex thread with compatible AgentPost hooks may select\nan alternate known workspace seat through `agentpost attach NAME`. Report its\nreturned capability precisely: `boundary-only` changes sender identity and\nnext-boundary catch-up without publishing presence or providing already-idle\nwake. Do not use `join` or replace a plugin merely to change that live session\nidentity. If full wake or another adapter requires replacing or reloading the\ncurrent process, mark reconnection as pending and give the exact external\nlauncher, such as `agentpost codex --agent NAME resume THREAD_ID`, `agentpost\nclaude --agent NAME`, or `agentpost antigravity --agent NAME`. The replacement\nsession must repeat `doctor` and `armed` before reporting success.\n\nSome adapters honestly provide lifecycle catch-up without already-idle wake.\nFor those adapters, describe `QUEUED` as durable mail that will surface at the\nnext supported lifecycle boundary; do not describe it as live readiness.\n\n## Registering a durable nameplate\n\nWhen asked to register this agent, first inspect `agentpost identities` and the\ncurrent workspace or role documentation. Describe demonstrated, durable\nownership so a coworker can answer both \"who is this?\" and \"who should handle\nthis work?\"\n\n- `name`: short, stable dot-free mailbox address; do not encode a session or\n task. Dot is reserved for `PROJECT.SEAT`.\n- `display-name`: recognizable project, team, or role name.\n- The profile is CLI-neutral. Do not make CLI type part of the identity;\n `join --cli` records each runtime adapter separately.\n- `kind`: choose `project`, `role`, `specialist`, or `hybrid` from durable\n responsibility. A code-review or marketing role must not claim project\n ownership merely because its CLI runs from that workspace.\n- `summary`: one concise sentence naming the owned domain plus the decisions,\n systems, or outputs this agent can help with. Include terms coworkers search.\n- `roles`: broad workplace functions.\n- `projects`: stable dot-free project names and aliases people will mention.\n This is also the seat's address namespace, not a claim that a role owns the\n runtime workspace. Include a short alias when coworkers need a compact\n cross-project address.\n- `specialties`: specific reusable technical or domain expertise.\n- `handles`: put a simple seat address such as `nav`, `build`, or `codereview`\n first, then two to five concrete request categories that should route here.\n- `does-not-handle`: close neighboring responsibilities owned elsewhere.\n\nPrefer \"Owns Pattern Buffer temporal world-state semantics, ingestion fidelity,\nand deterministic retrieval contracts\" over \"helpful coding agent.\" Do not put\ncurrent work, status, availability, generic personality, aspirational expertise,\nor secrets in a durable profile. Make neighboring agents distinguishable and\navoid duplicated handles; tied address labels are rejected rather than guessed.\n\n```sh\nagentpost profile-register pb \\\n --display-name 'Pattern Buffer' --kind hybrid \\\n --summary 'Owns temporal world-state semantics, ingestion fidelity, and deterministic retrieval contracts.' \\\n --roles 'world-model engineering' --projects 'pattern-buffer' \\\n --project-roots \"$PWD\" --specialties 'temporal state,identity,provenance' \\\n --handles 'pb,Pattern Buffer API reviews,world-state schema questions' \\\n --does-not-handle 'Construct narrative orchestration,Kernos member policy'\n```\n\n`agentpost profile-register --help` carries the same field guidance. After\nregistration, verify the nameplate with `agentpost identities` and test its\nlikely search terms with `agentpost agents-find QUERY --all`.\n\nAlso verify the project-qualified address and complete seat roster:\n\n```sh\nagentpost identities --project pattern-buffer\nagentpost resolve pattern-buffer.pb\n```\n\n## Identity and discovery\n\nAt the start of AgentPost work, resolve this session's identity from its project\nroot:\n\n```sh\nagentpost identify --cwd \"$PWD\"\n```\n\nExplicit `--agent`/`AGENTPOST_AGENT` is authoritative. Otherwise AgentPost uses\nan active Codex session attachment, then the deepest workspace marker, adapter\nbinding, or declared project root, with that priority for equal paths. A\nworkspace has one default; alternate role or review mailboxes in the same\ndirectory require `agentpost attach NAME` in a compatible active Codex thread\nor an explicit named launcher.\n\nNever guess a recipient from conversation memory. Inspect the current directory:\n\n```sh\nagentpost identities\nagentpost identities --project construct\nagentpost resolve nav\nagentpost resolve other-project.nav\nagentpost profiles\nagentpost profiles --project construct --all\nagentpost status\nagentpost agents-find \"topic or responsibility\"\nagentpost agents-find --role marketing\nagentpost agents-find --project construct\nagentpost agents-find --specialty \"temporal identity\"\n```\n\nNormal profile and responsibility discovery returns active agents only.\n`agentpost profiles --all` and `agentpost agents-find --all` expose offline\nhistory when explicitly needed. `identities --project PROJECT` returns the\ncomplete project roster including offline seats. A qualified identity may still\nreceive durable mail while offline; do not infer that offline means deleted.\n\nSelect the smallest relevant recipient set and retain the printed match reasons.\nFamiliarity does not outrank a better responsibility or evidence match.\n\n## Mail workflow\n\nWhen a native notification supplies one or more Message-IDs, process exactly\nthat set. Do not run a blanket inbox listing or inspect, claim, or process other\nunread letters in that turn; another attention mode may be intentionally\ndeferring them. A notification without explicit Message-IDs may use the normal\noldest-first inbox workflow below.\n\nInspecting mail does not claim it:\n\n```sh\nagentpost list AGENT\nagentpost read AGENT MESSAGE_ID\n```\n\nClaim exactly one letter only when beginning its work:\n\n```sh\nagentpost next AGENT --message-id MESSAGE_ID\n```\n\nAgentPost permits one inbound consumer per mailbox across all CLI and Python\nadapters. Do not bypass an existing consumer lease. Standby runtimes may wait\nfor takeover, while concurrently active workers should use distinct mailbox\nidentities. Atomic claim remains the final duplicate-work guard.\n\nSend routine or non-blocking work as idle mail:\n\n```sh\nagentpost message RECIPIENT 'message' --notify idle\n```\n\nUse immediate only for an active blocker or genuinely time-sensitive question:\n\n```sh\nagentpost question RECIPIENT 'question' --notify immediate\n```\n\n`send` and `ask` are lower-level forms for scripts that already have canonical\nsender and recipient mailbox keys. Prefer `message` and `question` for CLI\nagents acting on human instructions.\n\nReply against the original Message-ID:\n\n```sh\nagentpost reply MESSAGE_ID 'answer'\n```\n\nThe sender is inferred like `message` and `question`, with one addition: when\nthe sender is inferred rather than stated, reply answers from whichever mailbox\nreachable in this workspace actually holds that Message-ID. A workspace default\nseat and an alternate seat sharing the project root are both reachable, so a\nruntime notified as the alternate seat can answer its own mail without `--from`.\nThe workspace default still wins when it holds a copy, and two alternate seats\nholding the same letter is reported rather than guessed. The legacy\n`reply AGENT MESSAGE_ID` form remains accepted for scripts during migration.\nReplies to questions default to immediate notification because an answer is\nawaited; replies to ordinary letters default to idle. Use `--notify` to\noverride either case. Reply atomically claims the exact original when it is\nstill unread; already-read originals remain replyable for corrections. Claim\nwith `next` before substantial work, or let an immediate reply perform that\ntransition when composing the response is the work.\n\nIf an existing unread letter needs a fresh native notification, the ORIGINAL\nsender may re-notify that exact durable Message-ID without creating a duplicate:\n\n```sh\nagentpost notify RECIPIENT MESSAGE_ID --mode immediate\n```\n\nRe-notification never changes, claims, or copies the letter. Do not send a new\ncontrol envelope merely to point at old unread mail. Fresh adapter startup\ncatch-up names the full queued unread set in one notification turn.\n\nNamed groups, comma-separated recipients, and selectors such as\n`@role:marketing`, `@project:construct`, and `@specialty:temporal-identity` are\nresolved to concrete recipients before delivery.\n\n## Clean-start deletion\n\nNever manually remove AgentPost directories when a clean start is requested.\nUse the guarded wipe workflow, which never touches source or AgentBridge\nrepositories:\n\n```sh\nagentpost wipe agent\nagentpost wipe agent other-project.nav\nagentpost wipe project other-project\nagentpost wipe all\n```\n\n`wipe agent` with no name removes only this session's resolved mailbox and\nneeds no affected-box confirmation. It still refuses any held inbound consumer\nlease, including a live adapter for the same seat: close that adapter and run\nthe final wipe from a terminal with `AGENTPOST_AGENT=NAME`. Treat successful\nself-wipe as this seat's final action and do not continue using the deleted\nidentity.\n\nAnything broader than this seat requires explicit user confirmation. Run the\nrequested command once without `--confirm`; it performs no deletion and returns\nthe exact sorted affected mailbox list plus the exact confirmation argument.\nShow that list to the user and ask them to confirm that those boxes will be\ndeleted. Only after the user confirms, rerun with the printed\n`--confirm 'BOX1,BOX2'`. Never infer confirmation from the original deletion\nrequest when the affected list had not yet been shown. If the list changes,\nstop and confirm the new list. Stop every live mailbox consumer first; the wipe\nholds the authoritative lease fence through mailbox detachment.\n\nSuccessful wipe is irreversible within AgentPost. Report which boxes were\nremoved, that target profiles/mail/bindings/adapter state/group membership were\ndeleted, that source repositories were untouched, and that copies held by\nunaffected mailboxes remain.\n\n## Consultation posture\n\nWhen work crosses ownership boundaries or resembles a problem another agent has\nalready solved, consider the relevant peer alongside local inspection or web\nresearch. Peer advice is attributed evidence, not a replacement for direct\nsource verification. Do not generate mail for trivial local tasks.\n\nFor reviews, put the complete actionable request in AgentPost only. Never copy\nit into a legacy inbox. A legacy message may contain installation control or a\npointer to an existing AgentPost Message-ID after a proven notification failure,\nbut must not duplicate the work.\n\nSend repository review work through the fail-closed review channel:\n\n```sh\ncommit=$(git -C REPOSITORY rev-parse HEAD)\nparent=$(git -C REPOSITORY rev-parse HEAD^)\nagentpost review RECIPIENT - --repo REPOSITORY \\\n --commit \"$commit\" --parent \"$parent\" \\\n --path src/module.py \\\n --test tests/test_module.py::ModuleTest::test_behavior\n```\n\nUse explicit full commit-object SHAs. Never pass a literal command substitution,\nplaceholder, branch, or mutable `HEAD` as a structured value. Repeat `--path`\nand `--test` for the complete assertion set; every test must begin with its\ncommit-tree file as `RELATIVE_PATH::TEST_NODE`. `--parent` is optional but, when\npresent, must be a direct parent. AgentPost verifies the worktree, artifact,\npaths, and test files, prints the complete generated envelope, and writes no\nmail if preflight fails. Inspect the referenced diff before sending and label\nanything not directly verified as unverified. The receiver treats the immutable\nrepository diff and machine-readable review headers as authoritative rather\nthan trusting prose.\n\nLegacy cutover is per agent. Migrate a project's durable communication policy\nonly after that exact agent has proven inbound receipt/claim and outbound\ndelivery. Its migration directive must make AgentPost primary, prohibit new\nactionable legacy letters, retain the old folder as read-only history, and stop\nlegacy polling. Other unconfirmed agents keep their existing recovery path.\n\nAfter processing new mail, report a short synopsis in the active user chat:\nwhat arrived, what work was done, and what response was sent or remains pending.\n" } diff --git a/src/agentpost/installer.py b/src/agentpost/installer.py index 003894e..7f7014d 100644 --- a/src/agentpost/installer.py +++ b/src/agentpost/installer.py @@ -15,10 +15,14 @@ from .codex_generation import ( CODEX_HOOK_GENERATION, + CODEX_STABLE_DISPATCH_MIN_RELEASE, CODEX_PLUGIN_ID, _installed_codex_generation, + codex_generation_release, + codex_newer_generation_is_compatible, codex_generation_status, ) +from .codex_session import load_codex_session_attachment from .codex_lock import CodexPluginLock from .core import AgentPostError, PostOffice from .presence import agent_presence @@ -146,11 +150,16 @@ def install( codex_plan.release() if cli == "codex": try: + plugin_action = ( + "refreshed the Codex plugin" + if codex_plan is not None and codex_plan.replace_plugin + else "preserved the installed Codex plugin" + ) print( - "AgentPost refreshed the Codex plugin and user prompt hook. On first " - "install, open `/hooks` and trust all three stable AgentPost hooks. " - "Reload a process that predates the prompt hook, then complete one " - "prompt to verify the active generation." + f"AgentPost {plugin_action} and refreshed the stable user prompt " + "hook. On first install, open `/hooks` and trust all three stable " + "AgentPost hooks. Reload a process that predates the prompt hook, " + "then complete one prompt to verify the active generation." ) except BrokenPipeError: pass @@ -531,8 +540,9 @@ def _doctor_codex( trusted_events = set() trust_detail = str(exc) generation = codex_generation_status(office, agent) - return ( + checks = [ Check("codex-plugin", plugin.get("enabled") is True, "enabled" if plugin else "not installed"), + _codex_expected_generation_check(), Check( "codex-hook-trust", len(trusted_events) == 3, @@ -540,7 +550,96 @@ def _doctor_codex( + ("" if len(trusted_events) == 3 else "; open `/hooks` and trust all AgentPost hooks"), ), Check("codex-node", shutil.which("node") is not None, shutil.which("node") or "not found"), - Check("codex-generation", generation.current, generation.detail), + ] + attachment = _codex_session_attachment_check(office, agent) + if attachment is not None: + checks.append(attachment) + checks.append(Check("codex-generation", generation.current, generation.detail)) + return tuple(checks) + + +def _codex_expected_generation_check() -> Check: + installed, problem = _installed_codex_generation(Path.home()) + if installed == CODEX_HOOK_GENERATION: + return Check( + "codex-plugin-generation", + True, + f"installed expected generation {installed}", + ) + if ( + installed is not None + and codex_newer_generation_is_compatible( + installed, + CODEX_HOOK_GENERATION, + ) + ): + return Check( + "codex-plugin-generation", + True, + f"compatible newer generation {installed}; preserving it", + ) + if installed is None: + detail = ( + f"installed generation unknown ({problem}); expected " + f"{CODEX_HOOK_GENERATION}" + ) + else: + detail = ( + f"installed generation {installed}; expected " + f"{CODEX_HOOK_GENERATION}" + ) + return Check( + "codex-plugin-generation", + False, + detail + + "; close all Codex sessions and run `agentpost upgrade --cli codex " + "--confirm-codex-sessions-closed` from a terminal", + ) + + +def _codex_session_attachment_check( + office: PostOffice, + agent: str, +) -> Check | None: + session_id = os.environ.get("CODEX_THREAD_ID") + if not session_id: + return None + try: + attachment = load_codex_session_attachment(office, session_id) + except (AgentPostError, OSError, ValueError) as exc: + return Check("codex-session-attachment", False, str(exc)) + if attachment is None: + return None + release = codex_generation_release(attachment.observed_generation) + compatible = ( + release is not None + and release >= CODEX_STABLE_DISPATCH_MIN_RELEASE + ) + matches = attachment.agent == agent + installed, problem = _installed_codex_generation(Path.home()) + installed_detail = installed or f"unknown ({problem})" + detail = ( + f"thread {attachment.session_digest[:12]} attached to " + f"{attachment.agent}; boundary-only; " + f"observed {attachment.observed_generation}; " + f"installed {installed_detail}; aggregate mailbox hook recovery is " + "reported separately by codex-generation" + ) + if not matches: + detail = ( + f"current thread is attached to {attachment.agent}, not {agent}; " + + detail + ) + elif not compatible: + detail = ( + f"attachment hook generation {attachment.observed_generation} has " + "unknown or incompatible stable-dispatch ABI; " + + detail + ) + return Check( + "codex-session-attachment", + matches and compatible, + detail, ) @@ -569,7 +668,7 @@ def _list_codex_hooks(project: Path, timeout: float = 5.0) -> list[dict]: "clientInfo": { "name": "agentpost-doctor", "title": "AgentPost Doctor", - "version": "1.2.0", + "version": "1.3.0", }, "capabilities": { "experimentalApi": True, @@ -850,6 +949,18 @@ def _codex_install_state(home: Path) -> _CodexInstallState: return _CodexInstallState( "current", installed, f"installed generation {installed}" ) + if ( + installed is not None + and codex_newer_generation_is_compatible( + installed, + CODEX_HOOK_GENERATION, + ) + ): + return _CodexInstallState( + "compatible-newer", + installed, + f"compatible newer generation {installed}; preserving it", + ) if installed is not None: return _CodexInstallState( "upgrade", installed, f"installed generation {installed}" @@ -913,14 +1024,14 @@ def _codex_install_plan( ) -> _CodexInstallPlan: home = home or Path.home() state = _codex_install_state(home) - if state.kind == "current": + if state.kind in {"current", "compatible-newer"}: lock = CodexPluginLock(home) if not lock.acquire_shared(): raise AgentPostError( "Codex plugin state is changing; retry the install after it completes" ) locked_state = _codex_install_state(home) - if locked_state.kind == "current": + if locked_state.kind in {"current", "compatible-newer"}: return _CodexInstallPlan(False, lock) lock.release() state = locked_state @@ -937,7 +1048,7 @@ def _codex_install_plan( ) try: locked_state = _codex_install_state(home) - if locked_state.kind == "current": + if locked_state.kind in {"current", "compatible-newer"}: lock.release() return _codex_install_plan( confirm_sessions_closed=confirm_sessions_closed, diff --git a/src/agentpost/native.py b/src/agentpost/native.py index 7d73ce9..e652dd1 100644 --- a/src/agentpost/native.py +++ b/src/agentpost/native.py @@ -118,12 +118,14 @@ def codex_hook(event_name: str, generation: str | None = None) -> int: raise ValueError(f"invalid Codex hook event: {event_name}") event = json.load(sys.stdin) office = PostOffice(_runtime_root()) + session_id = str(event.get("session_id", event.get("sessionId", ""))) try: profile = identify_agent( office, event.get("cwd", Path.cwd()), cli="codex", agent=os.environ.get("AGENTPOST_AGENT"), + session_id=session_id, ) except (AgentPostError, OSError, ValueError): print("{}") @@ -135,7 +137,7 @@ def codex_hook(event_name: str, generation: str | None = None) -> int: "generation": generation or CODEX_HOOK_GENERATION, "event": event_name, "observed_at": time.time(), - "session_id": str(event.get("session_id", event.get("sessionId", ""))), + "session_id": session_id, "cwd": str(event.get("cwd", Path.cwd())), }, ) diff --git a/src/agentpost/ownership.py b/src/agentpost/ownership.py index 9e838fa..8330c95 100644 --- a/src/agentpost/ownership.py +++ b/src/agentpost/ownership.py @@ -41,14 +41,31 @@ def acquired(self) -> bool: def acquire(self, *, blocking: bool = False) -> bool: if self.acquired: return True - self.lock_path.parent.mkdir(parents=True, exist_ok=True) - handle = self.lock_path.open("a+b") + self.office.load_profile(self.agent) + try: + handle = self.lock_path.open("a+b") + except FileNotFoundError as exc: + raise AgentPostError( + f"mailbox {self.agent} is not initialized for consumer ownership" + ) from exc flags = fcntl.LOCK_EX | (0 if blocking else fcntl.LOCK_NB) try: fcntl.flock(handle.fileno(), flags) except BlockingIOError: handle.close() return False + try: + self.office.load_profile(self.agent) + mailbox = self.office.root / "agents" / self.agent / "unread" + if not mailbox.is_dir(): + raise AgentPostError( + f"mailbox {self.agent} was removed before the consumer " + "lease could be acquired" + ) + except Exception: + fcntl.flock(handle.fileno(), fcntl.LOCK_UN) + handle.close() + raise self._handle = handle _atomic_json( self.owner_path, diff --git a/src/agentpost/routing.py b/src/agentpost/routing.py index c7342fa..55ce7cf 100644 --- a/src/agentpost/routing.py +++ b/src/agentpost/routing.py @@ -1,10 +1,12 @@ from __future__ import annotations +import os import re from dataclasses import dataclass from pathlib import Path from typing import Iterable +from .codex_session import load_codex_session_attachment from .core import PostOffice, Profile, UnknownAgentError from .presence import agent_presence @@ -29,6 +31,7 @@ "to", "with", } +_ADDRESS_SEGMENT_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$", re.IGNORECASE) @dataclass(frozen=True) @@ -215,7 +218,11 @@ def resolve_channel_recipients( group = resolve_group(office, address) if group is not None: try: - identity = resolve_identity(office, address) + identity = resolve_identity( + office, + address, + sender=sender, + ) except UnknownAgentError: identity = None if identity is not None: @@ -227,7 +234,13 @@ def resolve_channel_recipients( expanded.extend(groups[group]) continue - expanded.append(resolve_identity(office, address).name) + expanded.append( + resolve_identity( + office, + address, + sender=sender, + ).name + ) return resolve_recipients( office, @@ -262,8 +275,15 @@ def identify_agent( *, cli: str | None = None, agent: str | None = None, + session_id: str | None = None, ) -> Profile: - return identify_agent_source(office, cwd, cli=cli, agent=agent)[0] + return identify_agent_source( + office, + cwd, + cli=cli, + agent=agent, + session_id=session_id, + )[0] def identify_agent_source( @@ -272,6 +292,7 @@ def identify_agent_source( *, cli: str | None = None, agent: str | None = None, + session_id: str | None = None, ) -> tuple[Profile, str]: """Resolve the acting mailbox and name the rule that chose it. @@ -282,6 +303,16 @@ def identify_agent_source( if agent is not None: return office.load_profile(agent), "explicit identity" + codex_session = ( + session_id + if session_id is not None + else os.environ.get("CODEX_THREAD_ID") + ) + if codex_session and cli in {None, "codex"}: + attachment = load_codex_session_attachment(office, codex_session) + if attachment is not None: + return office.load_profile(attachment.agent), "Codex session attachment" + current = Path(cwd).expanduser().resolve() candidates = [] workspace = office.workspace_identity(current) @@ -389,19 +420,144 @@ def project_candidates( ) -def resolve_identity(office: PostOffice, label: str) -> Profile: - """Resolve a human-facing AgentPost identity, including offline profiles.""" +def project_profiles( + office: PostOffice, + project: str, +) -> tuple[Profile, ...]: + """Return every registered seat carrying an exact project name or alias.""" + expected = _normalize(project) + if not expected: + raise UnknownAgentError("project label must not be empty") + matches = tuple( + profile + for profile in office.list_profiles() + if expected in {_normalize(value) for value in profile.projects} + ) + if not matches: + raise UnknownAgentError( + f"unknown AgentPost project: {project}; use `agentpost identities` " + "to inspect registered project names" + ) + return matches + + +def qualified_addresses(profile: Profile) -> tuple[str, ...]: + """Derive stable PROJECT.SEAT references without inventing aliases.""" + projects = tuple( + token + for value in profile.projects + if (token := _address_segment(value)) is not None + ) + seat = next( + ( + token + for value in profile.handles + if (token := _address_segment(value)) is not None + ), + profile.name, + ) + return tuple(f"{project}.{seat}" for project in dict.fromkeys(projects)) + + +def resolve_identity( + office: PostOffice, + label: str, + *, + project: str | None = None, + sender: str | None = None, +) -> Profile: + """Resolve a human-facing identity with optional fail-closed project scope. + + A qualified PROJECT.SEAT address supplies its own scope. An unqualified + channel address with a sender is restricted to profiles sharing at least + one registered project alias with that sender. It never falls back to a + globally unique seat in another project. + """ + qualified = _qualified_address(label) + if qualified is not None: + address_project, seat = qualified + if project is not None and _normalize(project) != _normalize(address_project): + raise ValueError( + f"qualified address {label!r} conflicts with --project {project!r}" + ) + return _resolve_identity_candidates( + seat, + project_profiles(office, address_project), + context=f"project {address_project}", + include_projects=False, + ) + + candidates: tuple[Profile, ...] + context = "global directory" + if project is not None: + candidates = project_profiles(office, project) + context = f"project {project}" + elif sender is not None: + sender_profile = office.load_profile(sender) + if label == sender_profile.name: + return sender_profile + sender_projects = { + _normalize(value) + for value in sender_profile.projects + if _normalize(value) + } + if not sender_projects: + raise UnknownAgentError( + f"sender {sender} has no registered project scope; qualify " + f"{label!r} as PROJECT.SEAT" + ) + candidates = tuple( + profile + for profile in office.list_profiles() + if sender_projects + & { + _normalize(value) + for value in profile.projects + if _normalize(value) + } + ) + context = ( + f"sender {sender} projects " + + ", ".join(sender_profile.projects) + ) + else: + candidates = office.list_profiles() + try: + return _resolve_identity_candidates( + label, + candidates, + context=context, + include_projects=True, + ) + except UnknownAgentError as exc: + if sender is not None: + raise UnknownAgentError( + f"bare AgentPost address {label!r} did not resolve within " + f"{context}; cross-project addresses must use PROJECT.SEAT" + ) from exc + raise + + +def _resolve_identity_candidates( + label: str, + candidates: Iterable[Profile], + *, + context: str, + include_projects: bool, +) -> Profile: expected = _normalize(label) if not expected: raise UnknownAgentError("identity label must not be empty") exact = [] - for profile in office.list_profiles(): - fields = ( + for profile in candidates: + fields = [ (profile.name, 400), (profile.display_name, 300), *((value, 200) for value in profile.handles), - *((value, 100) for value in profile.projects), - ) + *((value, 150) for value in profile.roles), + ] + if include_projects: + fields.extend((value, 100) for value in profile.projects) score = max( (weight for value, weight in fields if _normalize(value) == expected), default=0, @@ -417,14 +573,47 @@ def resolve_identity(office: PostOffice, label: str) -> Profile: if len(best) == 1: return best[0] names = ", ".join(profile.name for profile in best) - raise ValueError(f"ambiguous AgentPost identity {label!r}: {names}") + raise ValueError( + f"ambiguous AgentPost identity {label!r} in {context}: {names}; " + "use PROJECT.SEAT or a canonical mailbox key" + ) raise UnknownAgentError( - f"unknown AgentPost identity: {label}; use `agentpost agents-find` " + f"unknown AgentPost identity in {context}: {label}; use " + "`agentpost identities --project PROJECT` or `agentpost agents-find` " "for responsibility discovery" ) +def _qualified_address(label: str) -> tuple[str, str] | None: + value = label.strip() + if "." not in value: + return None + if value.count(".") != 1: + raise ValueError( + f"invalid qualified AgentPost address {label!r}; use exactly PROJECT.SEAT" + ) + project, seat = (part.strip() for part in value.split(".", 1)) + if ( + _ADDRESS_SEGMENT_RE.fullmatch(project) is None + or _ADDRESS_SEGMENT_RE.fullmatch(seat) is None + ): + raise ValueError( + f"invalid qualified AgentPost address {label!r}; PROJECT and SEAT " + "must use letters, digits, hyphens, or underscores" + ) + return project, seat + + +def _address_segment(value: str) -> str | None: + candidate = value.strip().lower() + return ( + candidate + if _ADDRESS_SEGMENT_RE.fullmatch(candidate) is not None + else None + ) + + def _normalize(value: str) -> str: return " ".join(re.findall(r"[a-z0-9]+", value.lower())) diff --git a/tests/test_adapters.py b/tests/test_adapters.py index 1f91759..74c0d95 100644 --- a/tests/test_adapters.py +++ b/tests/test_adapters.py @@ -19,6 +19,7 @@ sys.path.insert(0, str(Path(__file__).parents[1] / "src")) from agentpost import ( # noqa: E402 + __version__, AgentPostError, BoundaryBell, MailboxWatcher, @@ -51,6 +52,7 @@ Check, _claude_plugin_version, _doctor_claude, + _doctor_codex, _install_codex_user_hook, _integration_source, _package_check, @@ -886,6 +888,44 @@ def test_malformed_codex_cache_generation_is_unknown(self) -> None: self.assertIsNone(generation) self.assertIn("malformed", problem) + def test_codex_doctor_and_upgrade_detect_package_plugin_generation_drift( + self, + ) -> None: + office = self.office() + project = Path(self.temp.name) / "generation-drift-project" + project.mkdir() + office.bind_agent("cx", "codex", project) + home = Path(self.temp.name) / "generation-drift-home" + installed = "0.0.5+codex.20260712082137" + self._write_codex_install(home, installed) + for event in ("session-start", "user-prompt-submit", "stop"): + self._write_codex_observation(office, event, installed) + + with patch("agentpost.installer.Path.home", return_value=home): + with patch("agentpost.installer._list_codex_hooks", return_value=[]): + with patch( + "agentpost.installer._trusted_agentpost_hooks", + return_value=({"sessionStart", "userPromptSubmit", "stop"}, ()), + ): + with patch( + "agentpost.installer.shutil.which", + return_value="/usr/bin/node", + ): + checks = { + check.name: check + for check in _doctor_codex(office, "cx", project) + } + preview = upgrade(office, cli="codex", dry_run=True) + + self.assertTrue(checks["codex-generation"].ok) + expected = checks["codex-plugin-generation"] + self.assertFalse(expected.ok) + self.assertIn(f"installed generation {installed}", expected.detail) + self.assertIn(f"expected {CODEX_HOOK_GENERATION}", expected.detail) + self.assertEqual(len(preview), 1) + self.assertEqual(preview[0].state, "would-upgrade") + self.assertIn(f"expected {CODEX_HOOK_GENERATION}", preview[0].detail) + def _write_codex_install(self, home: Path, generation: str) -> None: config = home / ".codex" / "config.toml" config.parent.mkdir(parents=True, exist_ok=True) @@ -1872,7 +1912,11 @@ def test_package_check_tolerates_an_uninstalled_source_checkout(self) -> None: def _antigravity_checks(self, office, agent, project): with patch("agentpost.installer._doctor_antigravity", return_value=()): - return doctor(office, agent, project, cli="antigravity") + with patch( + "agentpost.installer.importlib.metadata.version", + return_value=__version__, + ): + return doctor(office, agent, project, cli="antigravity") def _bound_antigravity_project(self, office, agent: str) -> Path: project = Path(self.temp.name) / f"{agent}-project" diff --git a/tests/test_channel.py b/tests/test_channel.py index 07e0b08..ce6861d 100644 --- a/tests/test_channel.py +++ b/tests/test_channel.py @@ -7,7 +7,7 @@ sys.path.insert(0, str(Path(__file__).parents[1] / "src")) -from agentpost import AgentChannel, PostOffice, Profile # noqa: E402 +from agentpost import AgentChannel, PostOffice, Profile, UnknownAgentError # noqa: E402 class AgentChannelTest(unittest.TestCase): @@ -44,20 +44,36 @@ def tearDown(self) -> None: def test_directory_and_resolution_include_offline_identity(self) -> None: identities = {identity.name: identity for identity in self.channel.identities()} self.assertEqual(identities["pb"].presence, "offline") - self.assertEqual(self.channel.resolve("Pattern Buffer")[0].name, "pb") + self.assertEqual( + identities["pb"].qualified, + ("pattern-buffer.pb",), + ) + project_identities = self.channel.identities(project="pattern-buffer") + self.assertEqual(tuple(item.name for item in project_identities), ("pb",)) + self.assertEqual( + self.channel.resolve("pattern-buffer.pb")[0].name, + "pb", + ) def test_message_uses_bound_sender_and_human_address(self) -> None: - result = self.channel.message("world state storage", "Please review this.") + result = self.channel.message("pattern-buffer.pb", "Please review this.") letter = self.office.read("pb", result.message_id).letter self.assertEqual(letter.from_agent, "app") self.assertEqual(letter.kind, "letter") def test_question_uses_question_kind_and_immediate_default(self) -> None: - result = self.channel.question("PB", "Does this retain provenance?") + result = self.channel.question("pattern-buffer.pb", "Does this retain provenance?") letter = self.office.read("pb", result.message_id).letter self.assertEqual(letter.kind, "question") self.assertEqual(letter.notify, "immediate") + def test_bare_address_never_falls_back_to_another_project(self) -> None: + with self.assertRaisesRegex( + UnknownAgentError, + "cross-project addresses must use PROJECT.SEAT", + ): + self.channel.resolve("pb") + def test_named_group_expands_without_at_prefix(self) -> None: self.office.register_profile( Profile( diff --git a/tests/test_cli.py b/tests/test_cli.py index 704df53..587b19d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3,17 +3,20 @@ import os import sys import tempfile +import threading import unittest from contextlib import redirect_stderr, redirect_stdout +from dataclasses import replace from io import StringIO from pathlib import Path from unittest.mock import patch sys.path.insert(0, str(Path(__file__).parents[1] / "src")) -from agentpost import PostOffice, Profile # noqa: E402 +from agentpost import PostOffice, Profile, UnknownAgentError # noqa: E402 from agentpost.cli import _infer_join_agent, _join, main # noqa: E402 from agentpost.installer import UpgradeResult # noqa: E402 +from agentpost.ownership import ConsumerLease # noqa: E402 class JoinCommandTest(unittest.TestCase): @@ -101,7 +104,7 @@ def test_message_is_a_sender_inferred_named_channel(self) -> None: "--root", str(self.root), "message", - "Pattern Buffer", + "pattern-buffer.pb", "Please inspect the world model.", ] ) @@ -179,7 +182,7 @@ def test_sender_can_re_notify_existing_unread_mail_without_resending(self) -> No "--root", str(self.root), "notify", - "Pattern Buffer", + "pattern-buffer.pb", sent.message_id, "--mode", "immediate", @@ -211,7 +214,7 @@ def test_offline_delivery_warning_is_concise(self) -> None: "--root", str(self.root), "message", - "pb", + "pattern-buffer.pb", "Queued message.", ] ) @@ -233,7 +236,7 @@ def test_offline_delivery_warning_promises_a_start_only_when_bound(self) -> None "--root", str(self.root), "message", - "pb", + "pattern-buffer.pb", "Queued message.", ] ) @@ -246,8 +249,20 @@ def test_offline_delivery_warning_promises_a_start_only_when_bound(self) -> None def test_optional_channel_bodies_may_follow_flags(self) -> None: request = self.office.send("pb", "app", "Please reply.") commands = ( - ["message", "pb", "--notify", "immediate", "message after flag"], - ["question", "pb", "--subject", "Review", "question after flag"], + [ + "message", + "pattern-buffer.pb", + "--notify", + "immediate", + "message after flag", + ], + [ + "question", + "pattern-buffer.pb", + "--subject", + "Review", + "question after flag", + ], [ "reply", "app", @@ -341,7 +356,8 @@ def test_identities_and_resolve_expose_the_address_book(self) -> None: 0, ) self.assertIn( - "agent\tpb\toffline\tPattern Buffer\tpattern-buffer\t", + "agent\tpb\toffline\tpattern-buffer.pb\tPattern Buffer\t" + "pattern-buffer\t", identities.getvalue(), ) self.assertTrue(identities.getvalue().startswith("type\taddress\tattention")) @@ -349,10 +365,375 @@ def test_identities_and_resolve_expose_the_address_book(self) -> None: resolved = StringIO() with redirect_stdout(resolved): self.assertEqual( - main(["--root", str(self.root), "resolve", "Pattern Buffer"]), + main( + [ + "--root", + str(self.root), + "resolve", + "Pattern Buffer", + "--project", + "pattern-buffer", + ] + ), + 0, + ) + self.assertEqual( + resolved.getvalue(), + "agent\tpb\toffline\tpattern-buffer.pb\tPattern Buffer\n", + ) + + def test_project_directory_filters_and_cross_project_addresses(self) -> None: + directory = StringIO() + with redirect_stdout(directory): + self.assertEqual( + main( + [ + "--root", + str(self.root), + "identities", + "--project", + "pattern-buffer", + ] + ), + 0, + ) + self.assertIn("pattern-buffer.pb", directory.getvalue()) + self.assertNotIn("\tagent\tapp\t", f"\t{directory.getvalue()}") + + profiles = StringIO() + with redirect_stdout(profiles): + self.assertEqual( + main( + [ + "--root", + str(self.root), + "profiles", + "--project", + "pattern-buffer", + "--all", + ] + ), + 0, + ) + self.assertIn( + "pb\toffline\tclaude\tproject\tPersistent world state", + profiles.getvalue(), + ) + self.assertNotIn("app\toffline", profiles.getvalue()) + + status = StringIO() + with redirect_stdout(status): + self.assertEqual( + main( + [ + "--root", + str(self.root), + "status", + "--project", + "pattern-buffer", + ] + ), 0, ) - self.assertEqual(resolved.getvalue(), "agent\tpb\toffline\tPattern Buffer\n") + self.assertTrue(status.getvalue().startswith("pb\toffline\t")) + + errors = StringIO() + with patch.dict("os.environ", {"AGENTPOST_AGENT": "app"}, clear=False): + with redirect_stdout(StringIO()), redirect_stderr(errors): + result = main( + [ + "--root", + str(self.root), + "message", + "pb", + "This must not cross projects.", + ] + ) + self.assertEqual(result, 1) + self.assertIn( + "cross-project addresses must use PROJECT.SEAT", + errors.getvalue(), + ) + + def test_project_wipe_previews_exact_boxes_and_requires_confirmation( + self, + ) -> None: + self.office.register_profile( + Profile( + name="pbr", + display_name="Pattern Buffer Review", + cli="codex", + kind="role", + summary="Reviews Pattern Buffer", + roles=("code review",), + projects=("pattern-buffer",), + handles=("codereview",), + ) + ) + errors = StringIO() + with patch.dict("os.environ", {"AGENTPOST_AGENT": "app"}, clear=False): + with redirect_stderr(errors): + result = main( + [ + "--root", + str(self.root), + "wipe", + "project", + "pattern-buffer", + ] + ) + self.assertEqual(result, 1) + self.assertIn("Affected mailboxes: pb,pbr", errors.getvalue()) + self.assertIn("--confirm 'pb,pbr'", errors.getvalue()) + self.assertEqual(self.office.load_profile("pb").name, "pb") + + output = StringIO() + with patch.dict("os.environ", {"AGENTPOST_AGENT": "app"}, clear=False): + with redirect_stdout(output): + result = main( + [ + "--root", + str(self.root), + "wipe", + "project", + "pattern-buffer", + "--confirm", + "pb,pbr", + ] + ) + self.assertEqual(result, 0) + self.assertIn("WIPED\tproject\tpb,pbr", output.getvalue()) + self.assertIn("RECOVERY\tirreversible", output.getvalue()) + with self.assertRaises(UnknownAgentError): + self.office.load_profile("pb") + with self.assertRaises(UnknownAgentError): + self.office.load_profile("pbr") + + def test_wiping_another_agent_requires_confirmation_but_self_wipe_does_not( + self, + ) -> None: + errors = StringIO() + with patch.dict("os.environ", {"AGENTPOST_AGENT": "app"}, clear=False): + with redirect_stderr(errors): + result = main( + [ + "--root", + str(self.root), + "wipe", + "agent", + "pattern-buffer.pb", + ] + ) + self.assertEqual(result, 1) + self.assertIn("Affected mailboxes: pb", errors.getvalue()) + + with patch.dict("os.environ", {"AGENTPOST_AGENT": "app"}, clear=False): + with redirect_stdout(StringIO()): + result = main( + [ + "--root", + str(self.root), + "wipe", + "agent", + ] + ) + self.assertEqual(result, 0) + with self.assertRaises(UnknownAgentError): + self.office.load_profile("app") + + def test_all_agent_wipe_rejects_a_stale_confirmation_list(self) -> None: + errors = StringIO() + with patch.dict("os.environ", {"AGENTPOST_AGENT": "app"}, clear=False): + with redirect_stderr(errors): + result = main( + [ + "--root", + str(self.root), + "wipe", + "all", + "--confirm", + "app", + ] + ) + self.assertEqual(result, 1) + self.assertIn("Affected mailboxes: app,pb", errors.getvalue()) + self.assertEqual( + tuple(profile.name for profile in self.office.list_profiles()), + ("app", "pb"), + ) + + def test_confirmed_wipe_refuses_an_authoritative_consumer_lease(self) -> None: + owner = ConsumerLease(self.office, "pb", "python") + owner.require() + errors = StringIO() + try: + with patch.dict( + os.environ, + {"AGENTPOST_AGENT": "app"}, + clear=False, + ): + with redirect_stdout(StringIO()), redirect_stderr(errors): + result = main( + [ + "--root", + str(self.root), + "wipe", + "project", + "pattern-buffer", + "--confirm", + "pb", + ] + ) + finally: + owner.release() + + self.assertEqual(result, 1) + self.assertIn( + "stop the live mailbox consumer for pb before wiping", + errors.getvalue(), + ) + self.assertEqual(self.office.load_profile("pb").name, "pb") + + def test_self_wipe_does_not_bypass_an_unmatched_live_lease(self) -> None: + owner = ConsumerLease(self.office, "app", "unrelated-owner") + owner.require() + errors = StringIO() + try: + with patch.dict( + os.environ, + {"AGENTPOST_AGENT": "app"}, + clear=False, + ): + with redirect_stdout(StringIO()), redirect_stderr(errors): + result = main( + [ + "--root", + str(self.root), + "wipe", + "agent", + ] + ) + finally: + owner.release() + + self.assertEqual(result, 1) + self.assertIn( + "stop the live mailbox consumer for app before wiping", + errors.getvalue(), + ) + self.assertEqual(self.office.load_profile("app").name, "app") + + def test_wipe_holds_consumer_fence_through_mailbox_detach(self) -> None: + contender = ConsumerLease(self.office, "pb", "late-consumer") + original = PostOffice._wipe_agents_locked + + def guarded_wipe(office, names, **kwargs): + self.assertFalse(contender.acquire()) + return original(office, names, **kwargs) + + with patch.object( + PostOffice, + "_wipe_agents_locked", + autospec=True, + side_effect=guarded_wipe, + ) as locked_wipe: + with patch.dict( + os.environ, + {"AGENTPOST_AGENT": "app"}, + clear=False, + ): + with redirect_stdout(StringIO()): + result = main( + [ + "--root", + str(self.root), + "wipe", + "project", + "pattern-buffer", + "--confirm", + "pb", + ] + ) + + self.assertEqual(result, 0) + self.assertEqual(locked_wipe.call_count, 1) + with self.assertRaises(UnknownAgentError): + contender.acquire() + self.assertFalse((self.root / "agents" / "pb").exists()) + + def test_project_wipe_selects_targets_under_the_namespace_fence(self) -> None: + updated = replace( + self.office.load_profile("pb"), + projects=("different-project",), + ) + update_started = threading.Event() + release_update = threading.Event() + update_errors = [] + wipe_results = [] + wipe_errors = StringIO() + original_verify = self.office._verify_atomic_mailbox + + def pause_profile_update(agent_dir: Path) -> None: + update_started.set() + if not release_update.wait(3): + raise AssertionError("test did not release profile update") + original_verify(agent_dir) + + def run_update() -> None: + try: + self.office.register_profile(updated) + except BaseException as exc: + update_errors.append(exc) + + def run_wipe() -> None: + with redirect_stdout(StringIO()), redirect_stderr(wipe_errors): + wipe_results.append( + main( + [ + "--root", + str(self.root), + "wipe", + "project", + "pattern-buffer", + "--confirm", + "pb", + ] + ) + ) + + with patch.object( + self.office, + "_verify_atomic_mailbox", + side_effect=pause_profile_update, + ): + update_thread = threading.Thread(target=run_update) + update_thread.start() + self.assertTrue(update_started.wait(3)) + + wipe_thread = threading.Thread(target=run_wipe) + wipe_thread.start() + try: + wipe_thread.join(0.2) + self.assertTrue(wipe_thread.is_alive()) + finally: + release_update.set() + + update_thread.join(3) + wipe_thread.join(3) + + self.assertFalse(update_thread.is_alive()) + self.assertFalse(wipe_thread.is_alive()) + self.assertEqual(update_errors, []) + self.assertEqual(wipe_results, [1]) + self.assertIn( + "wipe confirmation does not match the current affected mailboxes", + wipe_errors.getvalue(), + ) + self.assertIn("Affected mailboxes: (none)", wipe_errors.getvalue()) + self.assertEqual( + self.office.load_profile("pb").projects, + ("different-project",), + ) def test_profile_help_teaches_searchable_durable_nameplates(self) -> None: output = StringIO() diff --git a/tests/test_codex_session.py b/tests/test_codex_session.py new file mode 100644 index 0000000..ec7b0d7 --- /dev/null +++ b/tests/test_codex_session.py @@ -0,0 +1,597 @@ +from __future__ import annotations + +import json +import os +import stat +import sys +import tempfile +import time +import unittest +from contextlib import redirect_stderr, redirect_stdout +from io import StringIO +from pathlib import Path +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).parents[1] / "src")) + +from agentpost import AgentPostError, PostOffice, Profile # noqa: E402 +from agentpost.cli import main # noqa: E402 +from agentpost.codex_generation import ( # noqa: E402 + CODEX_HOOK_GENERATION, + codex_hook_marker, + codex_newer_generation_is_compatible, +) +from agentpost.codex_session import ( # noqa: E402 + CODEX_SESSION_ATTACHMENT_TTL_SECONDS, + attach_codex_session, + codex_session_attachment_path, + load_codex_session_attachment, +) +from agentpost.installer import _doctor_codex, install # noqa: E402 +from agentpost.native import codex_hook # noqa: E402 +from agentpost.ownership import ConsumerLease # noqa: E402 +from agentpost.routing import identify_agent, identify_agent_source, workspace_seats # noqa: E402 + + +class CodexSessionAttachTest(unittest.TestCase): + def setUp(self) -> None: + self.temp = tempfile.TemporaryDirectory() + self.root = Path(self.temp.name) / "post" + self.project = Path(self.temp.name) / "pattern-buffer-evolution" + self.project.mkdir() + self.home = Path(self.temp.name) / "home" + self.office = PostOffice(self.root) + for name in ("pbeo", "pbeocx", "peer"): + roots = (str(self.project),) if name != "peer" else () + self.office.register_profile( + Profile( + name=name, + display_name=name.upper(), + cli="codex", + kind="project", + summary=f"Agent {name}", + projects=("pattern-buffer-evolution",) + if name != "peer" + else ("other-project",), + project_roots=roots, + ) + ) + # The marker preserves pbeo as the workspace default and records + # pbeocx as an alternate seat at the same root. + self.office.bind_agent("pbeo", "codex", self.project) + self.office.bind_agent("pbeocx", "codex", self.project) + self.thread_id = "019-codex-thread" + self._observe(self.thread_id, "0.0.4+codex.20260701000000") + self._install_generation(CODEX_HOOK_GENERATION) + + def tearDown(self) -> None: + self.temp.cleanup() + + def _observe( + self, + thread_id: str, + generation: str, + *, + agent: str = "pbeo", + event: str = "user-prompt-submit", + observed_at: float = 100.0, + ) -> None: + marker = codex_hook_marker(self.office, agent, event) + marker.parent.mkdir(parents=True, exist_ok=True) + marker.write_text( + json.dumps( + { + "adapter": "codex-hook", + "generation": generation, + "event": event, + "observed_at": observed_at, + "session_id": thread_id, + "cwd": str(self.project), + } + ), + encoding="utf-8", + ) + + def _install_generation(self, generation: str) -> Path: + config = self.home / ".codex" / "config.toml" + config.parent.mkdir(parents=True, exist_ok=True) + config.write_text( + '[plugins."agentpost@agentpost-local"]\nenabled = true\n', + encoding="utf-8", + ) + manifest = ( + self.home + / ".codex" + / "plugins" + / "cache" + / "agentpost-local" + / "agentpost" + / generation + / ".codex-plugin" + / "plugin.json" + ) + manifest.parent.mkdir(parents=True, exist_ok=True) + manifest.write_text( + json.dumps({"name": "agentpost", "version": generation}), + encoding="utf-8", + ) + return manifest + + def _attach(self, thread_id: str | None = None, **kwargs): + current_time = kwargs.pop("now", time.time()) + return attach_codex_session( + self.office, + "pbeocx", + thread_id or self.thread_id, + self.project, + allowed_agents=workspace_seats(self.office, self.project), + home=self.home, + now=current_time, + **kwargs, + ) + + def test_observed_older_hook_attaches_without_mutating_newer_plugin(self) -> None: + config = self.home / ".codex" / "config.toml" + before = { + path: path.read_bytes() + for path in (config, *self.home.glob(".codex/plugins/cache/**/plugin.json")) + } + + result = self._attach() + + self.assertEqual(result.state, "attached") + self.assertEqual(result.delivery, "boundary-only") + self.assertEqual( + result.attachment.observed_generation, + "0.0.4+codex.20260701000000", + ) + self.assertEqual( + result.installed_generation, + CODEX_HOOK_GENERATION, + ) + self.assertEqual( + before, + {path: path.read_bytes() for path in before}, + ) + path = codex_session_attachment_path(self.office, self.thread_id) + self.assertEqual(stat.S_IMODE(path.stat().st_mode), 0o600) + self.assertEqual(stat.S_IMODE(path.parent.stat().st_mode), 0o700) + self.assertNotIn(self.thread_id, path.name) + + def test_session_attachment_outranks_workspace_default_but_not_explicit_identity( + self, + ) -> None: + default, source = identify_agent_source( + self.office, + self.project, + cli="codex", + session_id="unattached-thread", + ) + self.assertEqual((default.name, source), ("pbeo", "workspace default")) + self._attach() + + attached, source = identify_agent_source( + self.office, + self.project, + cli="codex", + session_id=self.thread_id, + ) + self.assertEqual( + (attached.name, source), + ("pbeocx", "Codex session attachment"), + ) + explicit = identify_agent( + self.office, + self.project, + cli="codex", + agent="pbeo", + session_id=self.thread_id, + ) + self.assertEqual(explicit.name, "pbeo") + + def test_hook_uses_attachment_at_the_next_boundary_without_claiming(self) -> None: + self._attach() + sent = self.office.send("pbeo", "pbeocx", "Attached-session work") + event = StringIO( + json.dumps({"cwd": str(self.project), "session_id": self.thread_id}) + ) + output = StringIO() + environment = { + "AGENTPOST_ROOT": str(self.root), + "CODEX_THREAD_ID": self.thread_id, + } + with patch.dict(os.environ, environment, clear=True): + with patch("sys.stdin", event), redirect_stdout(output): + self.assertEqual( + codex_hook( + "user-prompt-submit", + "0.0.4+codex.20260701000000", + ), + 0, + ) + + instruction = json.loads(output.getvalue())["hookSpecificOutput"][ + "additionalContext" + ] + self.assertIn(f"for pbeocx: {sent.message_id}", instruction) + self.assertEqual(len(self.office.list_messages("pbeocx", "unread")), 1) + observed = json.loads( + codex_hook_marker( + self.office, + "pbeocx", + "user-prompt-submit", + ).read_text() + ) + self.assertEqual(observed["session_id"], self.thread_id) + + def test_cli_calls_inside_attached_thread_use_the_attached_sender(self) -> None: + self._attach() + output = StringIO() + errors = StringIO() + environment = { + "CODEX_THREAD_ID": self.thread_id, + "AGENTPOST_ROOT": str(self.root), + } + with patch.dict(os.environ, environment, clear=True): + with redirect_stdout(output), redirect_stderr(errors): + result = main( + [ + "--root", + str(self.root), + "message", + "other-project.peer", + "Sent from the attached seat.", + ] + ) + self.assertEqual(result, 0) + self.assertIn("FROM\tpbeocx", output.getvalue()) + self.assertEqual( + self.office.list_messages("peer")[0].letter.from_agent, + "pbeocx", + ) + + def test_attach_is_idempotent_and_can_rebind_explicitly(self) -> None: + first = self._attach() + path = codex_session_attachment_path(self.office, self.thread_id) + contents = path.read_bytes() + for event in ("session-start", "user-prompt-submit", "stop"): + codex_hook_marker(self.office, "pbeo", event).unlink(missing_ok=True) + second = self._attach() + self.assertEqual(second.state, "current") + self.assertEqual(path.read_bytes(), contents) + self.assertEqual(second.attachment, first.attachment) + + rebound = attach_codex_session( + self.office, + "pbeo", + self.thread_id, + self.project, + allowed_agents=workspace_seats(self.office, self.project), + home=self.home, + now=time.time(), + ) + self.assertEqual(rebound.state, "rebound") + self.assertEqual( + load_codex_session_attachment( + self.office, + self.thread_id, + now=time.time(), + ).agent, + "pbeo", + ) + + def test_attach_rejects_unreachable_mailbox_and_explicit_environment_conflict( + self, + ) -> None: + with self.assertRaisesRegex(AgentPostError, "not reachable"): + attach_codex_session( + self.office, + "peer", + self.thread_id, + self.project, + allowed_agents=workspace_seats(self.office, self.project), + home=self.home, + ) + with self.assertRaisesRegex(AgentPostError, "AGENTPOST_AGENT=pbeo outranks"): + self._attach(explicit_agent="pbeo") + self.assertFalse( + codex_session_attachment_path(self.office, self.thread_id).exists() + ) + + def test_attach_fails_closed_for_unknown_hook_abi(self) -> None: + thread_id = "old-hook-thread" + self._observe(thread_id, "0.0.2+codex.20260601000000") + with self.assertRaisesRegex(AgentPostError, "incompatible attach ABI"): + self._attach(thread_id) + self.assertFalse( + codex_session_attachment_path(self.office, thread_id).exists() + ) + + def test_stale_attachment_expires_and_falls_back_to_workspace_default(self) -> None: + self._attach() + attachment = load_codex_session_attachment(self.office, self.thread_id) + self.assertIsNone( + load_codex_session_attachment( + self.office, + self.thread_id, + now=attachment.attached_at + CODEX_SESSION_ATTACHMENT_TTL_SECONDS, + ) + ) + self.assertFalse( + codex_session_attachment_path(self.office, self.thread_id).exists() + ) + profile = identify_agent( + self.office, + self.project, + cli="codex", + session_id=self.thread_id, + ) + self.assertEqual(profile.name, "pbeo") + + def test_attach_refuses_a_mailbox_consumer_conflict(self) -> None: + owner = ConsumerLease(self.office, "pbeocx", "claude") + owner.require() + try: + with self.assertRaisesRegex( + AgentPostError, + "already has an inbound consumer: claude", + ): + self._attach() + finally: + owner.release() + self.assertFalse( + codex_session_attachment_path(self.office, self.thread_id).exists() + ) + + def test_managed_resume_remains_live_and_can_record_the_same_attachment( + self, + ) -> None: + owner = ConsumerLease(self.office, "pbeocx", "codex") + owner.require() + marker = ( + self.root + / "agents" + / "pbeocx" + / "adapter" + / "codex-bridge.active" + ) + marker.write_text( + json.dumps({"instance_id": owner.instance_id}), + encoding="utf-8", + ) + try: + result = self._attach( + explicit_agent="pbeocx", + bridge_active=True, + ) + finally: + owner.release() + self.assertEqual(result.delivery, "live-bridge") + self.assertEqual(result.attachment.agent, "pbeocx") + + def test_insecure_attachment_permissions_fail_closed(self) -> None: + self._attach() + path = codex_session_attachment_path(self.office, self.thread_id) + path.chmod(0o644) + with self.assertRaisesRegex(AgentPostError, "insecure"): + load_codex_session_attachment(self.office, self.thread_id) + + def test_preexisting_invalid_attachment_provenance_fails_closed(self) -> None: + self._attach() + path = codex_session_attachment_path(self.office, self.thread_id) + valid = json.loads(path.read_text(encoding="utf-8")) + unrelated = Path(self.temp.name) / "unrelated-project" + unrelated.mkdir() + cases = ( + ( + "event", + {"observed_event": "not-a-hook"}, + "invalid Codex session attachment event", + ), + ( + "generation", + {"observed_generation": "0.0.2+codex.20260601000000"}, + "incompatible attach ABI", + ), + ( + "non-finite", + {"expires_at": float("nan")}, + "non-finite Codex session attachment timestamp", + ), + ( + "ttl", + {"expires_at": valid["expires_at"] + 1}, + "incoherent Codex session attachment timestamps", + ), + ( + "unreachable", + {"project": str(unrelated)}, + "no longer reachable", + ), + ) + for label, change, expected in cases: + with self.subTest(label=label): + payload = {**valid, **change} + path.write_text(json.dumps(payload), encoding="utf-8") + path.chmod(0o600) + with self.assertRaisesRegex(AgentPostError, expected): + load_codex_session_attachment(self.office, self.thread_id) + with self.assertRaisesRegex(AgentPostError, expected): + identify_agent_source( + self.office, + self.project, + cli="codex", + session_id=self.thread_id, + ) + + def test_permissive_attachment_directory_fails_closed(self) -> None: + self._attach() + path = codex_session_attachment_path(self.office, self.thread_id) + path.parent.chmod(0o755) + try: + with self.assertRaisesRegex( + AgentPostError, + "insecure AgentPost runtime directory", + ): + load_codex_session_attachment(self.office, self.thread_id) + with self.assertRaisesRegex( + AgentPostError, + "insecure AgentPost runtime directory", + ): + identify_agent_source( + self.office, + self.project, + cli="codex", + session_id=self.thread_id, + ) + finally: + path.parent.chmod(0o700) + + def test_attach_cli_reports_boundary_capability_without_installing(self) -> None: + output = StringIO() + errors = StringIO() + environment = { + "CODEX_THREAD_ID": self.thread_id, + "AGENTPOST_ROOT": str(self.root), + } + with patch.dict(os.environ, environment, clear=True): + with patch("agentpost.codex_session.Path.home", return_value=self.home): + with patch("agentpost.cli.install") as install: + with redirect_stdout(output), redirect_stderr(errors): + result = main( + [ + "--root", + str(self.root), + "attach", + "pbeocx", + "--project", + str(self.project), + ] + ) + self.assertEqual(result, 0, errors.getvalue()) + install.assert_not_called() + self.assertIn("ATTACHED\tpbeocx\tcodex-session", output.getvalue()) + self.assertIn("DELIVERY\tboundary-only", output.getvalue()) + self.assertIn("PRESENCE\tboundary-only", output.getvalue()) + self.assertIn("agentpost codex --agent pbeocx resume", output.getvalue()) + + def test_attach_cli_requires_a_live_codex_thread(self) -> None: + errors = StringIO() + with patch.dict(os.environ, {}, clear=True): + with redirect_stdout(StringIO()), redirect_stderr(errors): + result = main( + [ + "--root", + str(self.root), + "attach", + "pbeocx", + "--project", + str(self.project), + ] + ) + self.assertEqual(result, 1) + self.assertIn("requires a live Codex thread", errors.getvalue()) + + def test_doctor_reports_exact_attachment_separately_from_stale_aggregate( + self, + ) -> None: + self._observe( + self.thread_id, + CODEX_HOOK_GENERATION, + observed_at=200.0, + ) + self._attach() + for event in ("session-start", "user-prompt-submit", "stop"): + self._observe( + f"historical-{event}", + "0.0.4+codex.20260701000000", + agent="pbeocx", + event=event, + observed_at=100.0, + ) + environment = {"CODEX_THREAD_ID": self.thread_id} + with patch.dict(os.environ, environment, clear=True): + with patch("agentpost.installer.Path.home", return_value=self.home): + with patch("agentpost.installer._list_codex_hooks", return_value=[]): + with patch( + "agentpost.installer._trusted_agentpost_hooks", + return_value=({"sessionStart", "userPromptSubmit", "stop"}, ()), + ): + with patch( + "agentpost.installer.shutil.which", + return_value="/usr/bin/node", + ): + checks = { + check.name: check + for check in _doctor_codex( + self.office, + "pbeocx", + self.project, + ) + } + + attached = checks["codex-session-attachment"] + self.assertTrue(attached.ok) + self.assertIn("boundary-only", attached.detail) + self.assertIn("thread ", attached.detail) + self.assertIn("observed 0.0.6+codex", attached.detail) + self.assertIn("reported separately", attached.detail) + aggregate = checks["codex-generation"] + self.assertFalse(aggregate.ok) + self.assertIn("stale", aggregate.detail) + + def test_join_from_older_runtime_preserves_a_compatible_newer_plugin( + self, + ) -> None: + manifest = next( + self.home.glob(".codex/plugins/cache/**/.codex-plugin/plugin.json") + ) + before = manifest.read_bytes() + environment = {"CODEX_THREAD_ID": self.thread_id} + with patch.dict(os.environ, environment, clear=True): + with patch("agentpost.installer.Path.home", return_value=self.home): + with patch( + "agentpost.installer.CODEX_HOOK_GENERATION", + "0.0.4+codex.20260701000000", + ): + with patch( + "agentpost.installer._integration_source", + return_value=self.project, + ): + with patch("agentpost.installer._run") as run: + with redirect_stdout(StringIO()): + install( + self.office, + "codex", + "pbeocx", + self.project, + ) + + commands = [call.args[0] for call in run.call_args_list] + self.assertNotIn( + ["codex", "plugin", "remove", "agentpost@agentpost-local"], + commands, + ) + self.assertNotIn( + ["codex", "plugin", "add", "agentpost@agentpost-local"], + commands, + ) + self.assertEqual(manifest.read_bytes(), before) + + def test_newer_build_timestamp_is_also_preserved(self) -> None: + self.assertTrue( + codex_newer_generation_is_compatible( + "0.0.5+codex.20260713000000", + "0.0.5+codex.20260712082137", + ) + ) + self.assertFalse( + codex_newer_generation_is_compatible( + "0.1.0+codex.20260713000000", + "0.0.5+codex.20260712082137", + ) + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_core.py b/tests/test_core.py index 1cee657..90c21f8 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -8,6 +8,7 @@ import unittest import uuid from contextlib import redirect_stderr +from dataclasses import replace from io import StringIO from pathlib import Path from unittest.mock import patch @@ -24,6 +25,7 @@ Profile, UnknownAgentError, ) +from agentpost.ownership import ConsumerLease # noqa: E402 def profile(name: str, cli: str = "claude") -> Profile: @@ -68,6 +70,261 @@ def test_initialize_creates_minimal_runtime(self) -> None: for directory in ("tmp", "unread", "read", "sent", "adapter"): self.assertTrue((self.root / "agents" / name / directory).is_dir()) + def test_profile_address_names_reserve_dot_for_project_qualification(self) -> None: + with self.assertRaisesRegex(ValueError, "invalid agent name"): + profile("project.nav").validate() + dotted_project = replace( + profile("valid"), + projects=("project.name",), + ) + with self.assertRaisesRegex(ValueError, "PROJECT.SEAT"): + dotted_project.validate() + + def test_wipe_agents_removes_mailboxes_bindings_markers_and_group_membership( + self, + ) -> None: + project = Path(self.temp.name) / "shared-project" + project.mkdir() + self.office.bind_agent("cx", "codex", project) + self.office.bind_agent("k", "claude", project) + self.office.set_group("team", ("cx", "k")) + self.office.send("cx", "k", "A copy retained by k.") + self.office.send("k", "cx", "A copy destroyed with cx.") + attachment = self.root / "runtime" / "codex-sessions" / "cx.json" + attachment.parent.mkdir(parents=True) + attachment.write_text('{"agent": "cx"}', encoding="utf-8") + (self.root / "runtime").chmod(0o700) + attachment.parent.chmod(0o700) + attachment.chmod(0o600) + + self.assertEqual(self.office.wipe_agents(("cx",)), ("cx",)) + self.assertFalse(attachment.exists()) + with self.assertRaises(UnknownAgentError): + self.office.load_profile("cx") + self.assertEqual(self.office.load_profile("k").name, "k") + self.assertEqual( + tuple(binding.agent for binding in self.office.list_bindings()), + ("k",), + ) + self.assertEqual(self.office.list_groups(), {"team": ("k",)}) + self.assertEqual( + self.office.workspace_identity(project)[:2], + ("k", ("k",)), + ) + self.assertEqual( + self.office.list_messages("k", "unread")[0].letter.body, + "A copy retained by k.", + ) + + self.assertEqual(self.office.wipe_agents(("k",)), ("k",)) + self.assertEqual(self.office.list_profiles(), ()) + self.assertEqual(self.office.list_bindings(), ()) + self.assertEqual(self.office.list_groups(), {}) + self.assertFalse((project / ".agentpost.toml").exists()) + stale_attachment = ( + self.root / "runtime" / "codex-sessions" / "stale.json" + ) + stale_attachment.parent.mkdir(parents=True, exist_ok=True) + stale_attachment.write_text('{"agent": "missing"}', encoding="utf-8") + stale_attachment.chmod(0o600) + self.assertEqual( + self.office.wipe_agents((), purge_all_attachments=True), + (), + ) + self.assertFalse(stale_attachment.exists()) + + def test_wipe_agents_rejects_attachment_directory_symlink_escape(self) -> None: + for scope in ("agent", "all"): + with self.subTest(scope=scope), tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) / "post" + office = PostOffice(root) + office.register_profile(profile("cx")) + external = Path(temporary) / "source-repository" + external.mkdir() + external_file = external / "release.json" + external_file.write_text('{"agent": "cx"}', encoding="utf-8") + runtime = root / "runtime" + runtime.mkdir(mode=0o700) + (runtime / "codex-sessions").symlink_to( + external, + target_is_directory=True, + ) + + with self.assertRaisesRegex( + AgentPostError, + "cannot securely open AgentPost runtime directory", + ): + office.wipe_agents( + ("cx",) if scope == "agent" else (), + purge_all_attachments=scope == "all", + ) + + self.assertEqual(office.load_profile("cx").name, "cx") + self.assertEqual( + external_file.read_text(encoding="utf-8"), + '{"agent": "cx"}', + ) + + def test_wipe_agents_rejects_permissive_attachment_directory(self) -> None: + runtime = self.root / "runtime" + attachments = runtime / "codex-sessions" + attachments.mkdir(parents=True) + runtime.chmod(0o700) + attachments.chmod(0o755) + attachment = attachments / "cx.json" + attachment.write_text('{"agent": "cx"}', encoding="utf-8") + attachment.chmod(0o600) + + with self.assertRaisesRegex( + AgentPostError, + "insecure AgentPost runtime directory", + ): + self.office.wipe_agents(("cx",)) + + self.assertTrue(attachment.exists()) + self.assertEqual(self.office.load_profile("cx").name, "cx") + + def test_wipe_agents_rolls_back_before_irreversible_stage_cleanup(self) -> None: + project = Path(self.temp.name) / "rollback-project" + project.mkdir() + self.office.bind_agent("cx", "codex", project) + self.office.set_group("team", ("cx", "k")) + sent = self.office.send("k", "cx", "Must survive rollback.") + + with patch.object( + self.office, + "_rewrite_workspace_identity", + side_effect=OSError("marker unavailable"), + ): + with self.assertRaisesRegex(OSError, "marker unavailable"): + self.office.wipe_agents(("cx",)) + + self.assertEqual(self.office.load_profile("cx").name, "cx") + self.assertEqual( + self.office.read("cx", sent.message_id).letter.body, + "Must survive rollback.", + ) + self.assertEqual( + tuple(binding.agent for binding in self.office.list_bindings()), + ("cx",), + ) + self.assertEqual(self.office.list_groups(), {"team": ("cx", "k")}) + + def test_wipe_serializes_profile_recreation_and_consumer_startup(self) -> None: + project = Path(self.temp.name) / "serialized-wipe-project" + project.mkdir() + self.office.bind_agent("cx", "codex", project) + detached = threading.Event() + continue_wipe = threading.Event() + registration_started = threading.Event() + profile_registered = threading.Event() + lease_acquired = threading.Event() + release_lease = threading.Event() + wipe_results = [] + worker_errors = [] + original_rewrite = self.office._rewrite_workspace_identity + + def pause_after_detach(*args, **kwargs): + detached.set() + if not continue_wipe.wait(3): + raise AssertionError("test did not release the paused wipe") + return original_rewrite(*args, **kwargs) + + def run_wipe() -> None: + try: + wipe_results.append(self.office.wipe_agents(("cx",))) + except BaseException as exc: + worker_errors.append(exc) + + def recreate_and_consume() -> None: + lease = None + try: + registration_started.set() + self.office.register_profile(profile("cx", "codex")) + profile_registered.set() + lease = ConsumerLease(self.office, "cx", "replacement-consumer") + if not lease.acquire(): + raise AssertionError("replacement consumer did not acquire") + lease_acquired.set() + if not release_lease.wait(3): + raise AssertionError("test did not release replacement lease") + except BaseException as exc: + worker_errors.append(exc) + finally: + if lease is not None: + lease.release() + + with patch.object( + self.office, + "_rewrite_workspace_identity", + side_effect=pause_after_detach, + ): + wipe_thread = threading.Thread(target=run_wipe) + wipe_thread.start() + self.assertTrue(detached.wait(3)) + + replacement_thread = threading.Thread(target=recreate_and_consume) + replacement_thread.start() + self.assertTrue(registration_started.wait(3)) + self.assertFalse(profile_registered.wait(0.2)) + self.assertFalse(lease_acquired.is_set()) + + continue_wipe.set() + wipe_thread.join(3) + self.assertFalse(wipe_thread.is_alive()) + + self.assertEqual(wipe_results, [("cx",)]) + self.assertTrue(profile_registered.wait(3)) + self.assertTrue(lease_acquired.wait(3)) + release_lease.set() + replacement_thread.join(3) + self.assertFalse(replacement_thread.is_alive()) + self.assertEqual(worker_errors, []) + + def test_wipe_preserves_original_stage_on_recreated_source_collision( + self, + ) -> None: + project = Path(self.temp.name) / "colliding-rollback-project" + project.mkdir() + self.office.bind_agent("cx", "codex", project) + sent = self.office.send("k", "cx", "Original mailbox must survive.") + original_letter = sent.recipient_path.read_bytes() + source = self.root / "agents" / "cx" + replacement_profile = (source / "profile.toml").read_bytes() + collision_marker = source / "replacement.txt" + original_rewrite = self.office._rewrite_workspace_identity + + def recreate_source(*args, **kwargs): + result = original_rewrite(*args, **kwargs) + source.mkdir(mode=0o700) + for directory in ("tmp", "unread", "read", "sent", "adapter"): + (source / directory).mkdir(mode=0o700) + (source / "profile.toml").write_bytes(replacement_profile) + (source / "profile.toml").chmod(0o600) + collision_marker.write_text("replacement", encoding="utf-8") + return result + + with patch.object( + self.office, + "_rewrite_workspace_identity", + side_effect=recreate_source, + ): + with self.assertRaisesRegex( + AgentPostError, + "recovery staging was preserved.*replacement collides", + ): + self.office.wipe_agents(("cx",)) + + stages = tuple(self.root.glob(".wipe-*")) + self.assertEqual(len(stages), 1) + staged_letter = stages[0] / "cx" / "unread" / sent.recipient_path.name + self.assertEqual(staged_letter.read_bytes(), original_letter) + self.assertEqual(collision_marker.read_text(encoding="utf-8"), "replacement") + self.assertEqual( + tuple(binding.agent for binding in self.office.list_bindings()), + ("cx",), + ) + def test_new_runtime_state_is_private_even_with_permissive_umask(self) -> None: root = Path(self.temp.name) / "private-post" original_umask = os.umask(0) diff --git a/tests/test_examples.py b/tests/test_examples.py index 44d9f2b..fefebb9 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -46,8 +46,15 @@ def test_release_metadata_is_synchronized(self) -> None: "SECURITY.md", "recursive-include docs *.md", "recursive-include scripts *.py *.sh", + "recursive-include specs *.md", ): self.assertIn(required, manifest) + for required_spec in ( + "CODEX-SESSION-ATTACH-V1.md", + "PROJECT-QUALIFIED-IDENTITIES-V1.md", + "SAFE-WIPE-V1.md", + ): + self.assertTrue((ROOT / "specs" / required_spec).is_file()) installer = (ROOT / "scripts/install.sh").read_text(encoding="utf-8") self.assertIn( f'git+https://github.com/5000Stadia/agentpost.git@{tag}', @@ -67,7 +74,7 @@ def test_release_metadata_is_synchronized(self) -> None: (ROOT / relative).read_text(encoding="utf-8"), ) self.assertIn( - f"## [{version}] - 2026-07-27", + f"## [{version}] - 2026-07-28", (ROOT / "CHANGELOG.md").read_text(encoding="utf-8"), ) self.assertIn( @@ -118,7 +125,7 @@ def test_python_quickstart_async_bridge_preserves_claim_boundary(self) -> None: cli="python", kind="project", summary=f"Python quick-start {name}", - projects=(f"{name}-project",), + projects=("python-quickstart",), ) ) @@ -688,7 +695,9 @@ def test_shared_skill_fails_closed_on_reconnect_readiness(self) -> None: self.assertIn("offer the user the first unused numbered mailbox", skill) self.assertIn("numbered identity is a separate durable mailbox", skill) self.assertIn("move mail already addressed to `NAME`", skill) - self.assertIn("cannot retroactively become an alternate identity", skill) + self.assertIn("agentpost attach NAME", skill) + self.assertIn("Do not use `join` or replace a plugin", skill) + self.assertIn("without publishing presence", skill) self.assertIn("do not describe it as live readiness", skill) generated_paths = ( diff --git a/tests/test_review.py b/tests/test_review.py index 4e46302..ed2a607 100644 --- a/tests/test_review.py +++ b/tests/test_review.py @@ -42,6 +42,7 @@ def setUp(self) -> None: kind="role", summary="Implementation reviewer", roles=("code review",), + projects=("application",), ) ) self._git("init", "-b", "main") diff --git a/tests/test_routing.py b/tests/test_routing.py index db51aa5..a798ce2 100644 --- a/tests/test_routing.py +++ b/tests/test_routing.py @@ -18,6 +18,8 @@ ) from agentpost.routing import ( # noqa: E402 identify_agent_source, + project_profiles, + qualified_addresses, resolve_channel_recipients, resolve_identity, resolve_recipients, @@ -194,6 +196,10 @@ def test_human_identity_resolves_names_projects_and_responsibilities(self) -> No resolve_identity(self.office, "Pattern Buffer").name, "pb", ) + self.assertEqual( + resolve_identity(self.office, "cx", sender="cx").name, + "cx", + ) def test_human_identity_does_not_fuzzy_route_partial_expertise(self) -> None: with self.assertRaisesRegex(UnknownAgentError, "agents-find"): @@ -209,7 +215,7 @@ def test_channel_addresses_named_groups_and_offline_identities(self) -> None: self.assertEqual( resolve_channel_recipients( self.office, - ("World Team, Kernos",), + ("World Team, kernos.k",), sender="cx", ), ("pb", "c", "k"), @@ -221,9 +227,85 @@ def test_channel_rejects_group_identity_collision(self) -> None: resolve_channel_recipients( self.office, ("Pattern Buffer",), - sender="cx", + sender="pb", ) + def test_project_qualified_addresses_disambiguate_repeated_seat_handles( + self, + ) -> None: + profiles = ( + Profile( + name="p1n", + display_name="Project One Navigator", + cli="codex", + kind="project", + summary="Navigates project one", + projects=("project-one", "p1"), + handles=("nav",), + ), + Profile( + name="p1r", + display_name="Project One Reviewer", + cli="codex", + kind="role", + summary="Reviews project one", + roles=("code review",), + projects=("project-one", "p1"), + handles=("codereview",), + ), + Profile( + name="p2n", + display_name="Project Two Navigator", + cli="codex", + kind="project", + summary="Navigates project two", + projects=("project-two", "p2"), + handles=("nav",), + ), + Profile( + name="p2r", + display_name="Project Two Reviewer", + cli="codex", + kind="role", + summary="Reviews project two", + roles=("code review",), + projects=("project-two", "p2"), + handles=("codereview",), + ), + ) + for profile in profiles: + self.office.register_profile(profile) + + self.assertEqual( + tuple(item.name for item in project_profiles(self.office, "p2")), + ("p2n", "p2r"), + ) + self.assertEqual( + qualified_addresses(self.office.load_profile("p1n")), + ("project-one.nav", "p1.nav"), + ) + self.assertEqual( + resolve_identity(self.office, "codereview", sender="p1n").name, + "p1r", + ) + self.assertEqual( + resolve_identity(self.office, "p2.codereview", sender="p1n").name, + "p2r", + ) + with self.assertRaisesRegex( + UnknownAgentError, + "cross-project addresses must use PROJECT.SEAT", + ): + resolve_identity(self.office, "p2r", sender="p1n") + + def test_qualified_address_has_exactly_one_reserved_dot(self) -> None: + self.assertEqual( + resolve_identity(self.office, "pattern-buffer.pb").name, + "pb", + ) + with self.assertRaisesRegex(ValueError, "exactly PROJECT.SEAT"): + resolve_identity(self.office, "pattern.buffer.pb") + def test_ambiguous_human_identity_is_not_guessed(self) -> None: self.office.register_profile( Profile(