diff --git a/IMPLEMENTATION_STATUS.md b/IMPLEMENTATION_STATUS.md index 69cd37b..d864bc0 100644 --- a/IMPLEMENTATION_STATUS.md +++ b/IMPLEMENTATION_STATUS.md @@ -11,7 +11,7 @@ compatibility, security, and release contracts recorded in the repository. 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 +0.0.8, Codex plugin generation `0.0.7+codex.20260804015728`, and Python package version 1.3.0. Plugin generations identify independently deployed adapter artifacts and do not replace the Python package's semantic version. diff --git a/README.md b/README.md index d2294b7..022059d 100644 --- a/README.md +++ b/README.md @@ -61,8 +61,10 @@ Other Examples: - Replying atomically claims an exact unread original; already-read originals remain replyable for corrections. - Notifications are pointers. The mailbox is always the durable truth. -- Fresh adapter startup batches the full queued unread set into one native - exact-ID notification turn. +- Fresh interactive-adapter startup batches the full queued unread set into one + consent gate. The agent reports the mailbox and count, then asks whether to + inspect that exact set now, reload or rebind first, or defer it. Startup + priority never authorizes reading or claiming mail. - A mailbox belongs to a durable agent identity, not to one CLI process. - One mailbox-wide consumer lease prevents two live CLI or Python adapters from surfacing the same inbound work; compatible runtimes wait and take over. @@ -125,10 +127,12 @@ and specialist queues. Antigravity CLI 1.1.1 has a validated lifecycle catch-up profile. Its plugin injects exact unread Message-IDs before an invocation and at the completed -`Stop` boundary. Antigravity's SDK documents external pushes into SDK-owned -sessions; it does not document waking an arbitrary IDE/App-owned idle -conversation. The CLI exposes no validated already-idle wake path, so AgentPost -reports CLI deliveries as queued until the next prompt or lifecycle boundary. +`Stop` boundary. Its first host-process snapshot is a consent gate; later mail +uses the ordinary exact-ID instruction. Antigravity's SDK documents external +pushes into SDK-owned sessions; it does not document waking an arbitrary +IDE/App-owned idle conversation. The CLI exposes no validated already-idle +wake path, so AgentPost reports CLI deliveries as queued until the next prompt +or lifecycle boundary. Launch it with `agentpost antigravity --agent NAME` after joining so shared project roots retain the correct sender identity. @@ -264,6 +268,26 @@ 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). +Managed consumers report held leases independently from heartbeat presence. A +suspended terminal job therefore appears as `suspended`, not misleadingly +offline. If the user intended to close that exact managed instance, AgentPost +prints a guarded recovery command shaped as: + +```sh +agentpost consumer-stop reviewer --instance FULL_INSTANCE_ID +``` + +The full instance guard prevents a stale instruction from stopping a +replacement consumer. It releases the process tree and lease without deleting +mail, identity state, bindings, or attachments. Managed Codex and Antigravity +sessions convert terminal suspension into clean shutdown because a stopped +bridge cannot provide wake while retaining exclusive mailbox ownership. + +Managed Codex owner metadata also records a digest of a resumed thread ID. A +resume under another AgentPost seat fails before startup when that thread is +already managed elsewhere and prints the existing seat and exact recovery +command. See [safe session re-entry](specs/SAFE-SESSION-REENTRY-V1.md). + If an existing unread letter needs another native notification, its original sender can re-fire attention without resending content: @@ -419,9 +443,11 @@ idle. native monitor event only when unread mail appears. Lifecycle hooks maintain a short busy/idle boundary in the mailbox's AgentPost adapter directory. A fresh Claude load starts the monitor automatically; no model call is made - until mail causes a native event. The event names the optional - `/agentpost:agentpost` skill and includes exact positional `read` and `next` - commands, so inspection remains retry-safe when the skill is unavailable. + until mail causes a native event. Its initial event announces only the queued + count and asks whether to inspect, reload, or defer. Later live events name + the optional `/agentpost:agentpost` skill and include exact positional `read` + and `next` commands, so inspection remains retry-safe when the skill is + unavailable. - `agentpost codex` owns a loopback app-server, connects the ordinary Codex TUI, and runs a small Node bridge. It uses `turn/steer` for immediate mail and `turn/start` after the idle boundary. For ordinary Codex launches, plugin @@ -513,11 +539,13 @@ load; managed Codex must attach its app-server bridge; ordinary Codex proves lifecycle-hook catch-up only; Antigravity proves hook injection at its first `PreInvocation`; and Python delegates turn creation to its host scheduler. -Every native exact-ID pointer is self-sufficient when its optional skill is not -available. It emits one retry-safe `agentpost read AGENT MESSAGE_ID` command per -letter and a separate `agentpost next AGENT --message-id MESSAGE_ID` command for -the moment work actually starts. Exact pointers never recommend a blanket inbox -listing, and neither inspection nor notification claims mail automatically. +Every post-startup native exact-ID pointer is self-sufficient when its optional +skill is not available. It emits one retry-safe `agentpost read AGENT +MESSAGE_ID` command per letter and a separate `agentpost next AGENT +--message-id MESSAGE_ID` command for the moment work actually starts. Startup +gates intentionally omit those commands until the user consents. Exact pointers +never recommend a blanket inbox listing, and neither inspection nor notification +claims mail automatically. ## Documentation diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md index 53e62de..767f54c 100644 --- a/docs/COMPATIBILITY.md +++ b/docs/COMPATIBILITY.md @@ -13,6 +13,8 @@ documented migration and deprecation path. success must not be reported as live wake or presence. - Public Python names exported through `agentpost.__all__`, including `PostOffice`, `AgentChannel`, and `AgentRuntime`. +- `Notification.origin` is additive routing metadata. Existing positional + construction remains valid because it defaults to `live`. - `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 diff --git a/docs/INSTALL.md b/docs/INSTALL.md index e202c01..26b3ba3 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -375,8 +375,11 @@ denied while `send-path` passes, the denial is in the host CLI's permission configuration, not in AgentPost. On managed startup, all queued unread Message-IDs are named together in the -first native notification turn. To re-fire attention for one existing unread -letter without duplicating it, its sender runs: +first native notification turn, but that turn is a consent gate: it reports the +mailbox and count and asks whether to inspect now, reload or rebind first, or +defer. It does not include inspection or claim commands before the user chooses. +To re-fire attention for one existing unread letter without duplicating it, its +sender runs: ```sh agentpost notify RECIPIENT MESSAGE_ID --mode immediate @@ -444,8 +447,9 @@ agentpost antigravity --agent app Restart through the AgentPost launcher after first installation. It sets the per-process mailbox identity, which matters when multiple CLI agents share one project root. The plugin uses `PreInvocation` and `Stop` hooks to inject exact -unread Message-IDs without claiming them. It supports startup/next-prompt -catch-up and completed-turn idle delivery. +unread Message-IDs without claiming them. The first host-process snapshot is a +consent gate; later mail uses the ordinary exact-ID workflow. It supports +startup/next-prompt catch-up and completed-turn idle delivery. Antigravity's SDK documents external pushes into SDK-owned sessions. Current official material does not document waking an arbitrary IDE/App-owned idle @@ -454,20 +458,24 @@ conversation, and live CLI 1.1.1 acceptance exposed no already-idle wake path. conservatively see delivery as queued. Do not compensate with terminal keystroke injection or a duplicate message channel. -Managed and ordinary Codex pointers and Antigravity hook injections remain -self-sufficient when their optional AgentPost skill is unavailable. For each -listed Message-ID they include an idempotent `agentpost read AGENT MESSAGE_ID` -command and a separate `agentpost next AGENT --message-id MESSAGE_ID` command -for claim-at-start. They never require a blanket `agentpost list`, and preserve -the exact surfaced set when other unread mail is intentionally deferred. +Post-startup managed and ordinary Codex pointers and Antigravity hook injections +remain self-sufficient when their optional AgentPost skill is unavailable. For +each listed Message-ID they include an idempotent `agentpost read AGENT +MESSAGE_ID` command and a separate `agentpost next AGENT --message-id +MESSAGE_ID` command for claim-at-start. Startup gates intentionally omit those +commands until the user consents. Neither form requires a blanket `agentpost +list`, and both preserve the exact surfaced set when other unread mail is +intentionally deferred. ## Recovery Mail delivery does not depend on an adapter being healthy. If a native bell -fails, restart the CLI integration and inspect the complete unread set: +fails, restart the CLI integration; its startup gate will report the queued +count and wait for the user's choice. After explicit approval to inspect, these +commands diagnose or recover the exact mailbox: ```sh -agentpost list AGENT +agentpost read AGENT GATED_MESSAGE_ID agentpost armed AGENT agentpost status AGENT agentpost profiles --offline @@ -475,6 +483,16 @@ agentpost doctor AGENT --project /work/project --cli claude agentpost doctor AGENT --project /work/project --cli codex ``` +If status reports a suspended managed owner that the user intended to close, +run only the exact instance-guarded command printed by AgentPost: + +```sh +agentpost consumer-stop AGENT --instance FULL_INSTANCE_ID +``` + +Then retry the original named launcher. This recovery does not delete or move +mail and is not a reason to create a numbered mailbox. + For incomplete Codex trust, approve the stable AgentPost hooks in `/hooks`. For a stale installed cache, close all Codex sessions and re-run `agentpost install codex --confirm-codex-sessions-closed` (or the same `join` command with that diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index cab8297..72cc121 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -213,3 +213,15 @@ counts; later responses remain visible as duplicates. Notification adapters receive only committed Message-IDs. Adapter failure does not roll back delivery, and adapter state is never authoritative for unread state. Adapter activation always catches up from the full current unread set. +Interactive adapters surface that startup snapshot as a consent gate: they +announce the mailbox and exact pending set but prohibit inspection, claim, +reply, or work until the user chooses to read now, reload or rebind first, or +defer. An attention-request record may be acknowledged when the gate is +delivered; the durable message remains unread and is gated again after a +runtime reload. + +Consumer ownership and heartbeat presence are independent evidence. A fresh +matching heartbeat plus a held lease is live; a stopped owner holding the lease +is `suspended`; a running owner with a stale heartbeat is `unresponsive`. +Neither recovery state is treated as an offline/free mailbox or as a reason to +create a parallel numbered identity. diff --git a/docs/PYTHON.md b/docs/PYTHON.md index 50ea357..4dc8c9f 100644 --- a/docs/PYTHON.md +++ b/docs/PYTHON.md @@ -156,6 +156,14 @@ A notification contains only routing data and the immutable spool path: - `kind` - `notify` - `path` +- `origin`: `startup`, `live`, or `snapshot` + +The first batch after a runtime acquires the mailbox lease uses +`origin="startup"`. Interactive hosts should present that batch as a user +consent gate rather than enqueue model work immediately. `AgentRuntime` remains +transport-only: it does not ask the user, call a model, inspect content, or +claim mail. Later batches use `origin="live"`; side-effect-free reconciliation +through `runtime.unread()` uses `origin="snapshot"`. The application may inspect with `PostOffice.read()` and must call `PostOffice.claim()` only when it actually starts the work. This preserves the diff --git a/docs/TWO_AGENT_QUICKSTART.md b/docs/TWO_AGENT_QUICKSTART.md index 764f7b7..1a2d833 100644 --- a/docs/TWO_AGENT_QUICKSTART.md +++ b/docs/TWO_AGENT_QUICKSTART.md @@ -117,8 +117,9 @@ agentpost doctor agent-one --project "$PWD" --cli claude Restart or reload the Claude project session after `join`. The project plugin's native monitor handles catch-up, immediate notification, idle deferral, and -presence heartbeats. A Claude role agent uses `agentpost join agent-two --cli -claude` from its chosen workspace. +presence heartbeats. If queued mail exists on that fresh load, the monitor asks +whether to inspect it now, reload or rebind first, or defer it. A Claude role +agent uses `agentpost join agent-two --cli claude` from its chosen workspace. ### Codex @@ -135,7 +136,8 @@ Codex process that predates the prompt hook, then submit and complete one prompt so `doctor` can verify the active generation. Launching with `agentpost codex` supplies live immediate steering and true idle deferral. An ordinary Codex launch checks at startup, before each prompt, and at turn completion but does -not provide already-idle wake. +not provide already-idle wake. Both paths gate queued startup mail before any +inspection command is offered. ### Embedded Python @@ -167,8 +169,9 @@ agentpost antigravity --agent agent-two ``` Restart `agy` after the first `join`. Antigravity catches unread IDs before the -next invocation and at its completed `Stop` boundary. Mail arriving after the -TUI is already idle remains queued until the next prompt. +next invocation and at its completed `Stop` boundary. Its first snapshot asks +whether to inspect, reload or rebind, or defer; mail arriving after the TUI is +already idle remains queued until the next prompt. ## 5. Tell them to talk @@ -212,11 +215,13 @@ print(result.message_id) The native monitor wakes the project session with the exact Message-ID. The installed skill inspects that letter and claims it only when starting the work. +On fresh startup, it asks for consent before that inspection workflow. ### Codex The app-server bridge starts or steers the turn with the exact Message-ID. The fallback lifecycle hook catches up on unread mail after an ordinary launch. +Queued startup mail is announced through the same consent gate before reading. ### Embedded Python @@ -228,7 +233,8 @@ admitted. The plugin injects the exact Message-ID at the next invocation or completed turn boundary. It never claims mail. Already-idle external wake is not yet -supported, so senders report the message as queued until another prompt. +supported, so senders report the message as queued until another prompt. A +fresh host process gates its initial unread snapshot before inspection. ### Portable CLI workflow diff --git a/integrations/antigravity/skills/agentpost/SKILL.md b/integrations/antigravity/skills/agentpost/SKILL.md index 1646a7e..3246b32 100644 --- a/integrations/antigravity/skills/agentpost/SKILL.md +++ b/integrations/antigravity/skills/agentpost/SKILL.md @@ -82,11 +82,16 @@ 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 this process connected. Report the existing owner. If it is this managed session's parent bridge, continue in the existing session. Otherwise inspect -the exact identities and offer the user the first unused numbered mailbox, -starting with `NAME2`, then `NAME3`. Create it only after explicit user approval, -then use the explicit named join and launcher and verify it independently. The -numbered identity is a separate durable mailbox: it does not inherit, claim, or -move mail already addressed to `NAME`. +the exact identities and owner state. A suspended or unresponsive managed +consumer is a recovery condition, not parallel work: do not offer a numbered +mailbox. If the user confirms that exact instance was intended to close, use +the native `agentpost consumer-stop NAME --instance FULL_INSTANCE_ID` command +printed by AgentPost, then retry the original launcher. For a healthy unrelated +owner and genuinely parallel work, offer the user the first unused numbered +mailbox, starting with `NAME2`, then `NAME3`. Create it only after explicit user +approval, then use the explicit named join and launcher and verify it +independently. The 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. An ordinary running Codex thread with compatible AgentPost hooks may select @@ -201,6 +206,26 @@ Familiarity does not outrank a better responsibility or evidence match. ## Mail workflow +### Startup consent gate + +When a native notification begins with `AgentPost startup notice`, it is an +attention gate, not authorization to process mail. Do not run `agentpost list`, +`read`, `next`, or `reply`, and do not begin any workflow described by the +pending mail. Tell the user only the pending count and selected mailbox, then +ask whether to inspect that exact set in the current session, reload or rebind +the intended session first, or defer it. + +- If the user chooses **read now**, inspect exactly the Message-IDs embedded in + that startup notice, then use the normal claim and reply workflow below. +- If the user chooses **reload or rebind**, leave every message untouched and + give the exact applicable launcher or attachment command. The replacement + runtime will gate the still-unread set again. +- If the user chooses **defer**, leave every message untouched for this runtime. + +Never infer startup consent from `immediate` priority, a prior conversation, a +request to connect the mailbox, or the fact that the native adapter started a +turn. Mail delivered after startup uses the ordinary exact-ID workflow below. + When a native notification supplies one or more Message-IDs, process exactly that set. Do not run a blanket inbox listing or inspect, claim, or process other unread letters in that turn; another attention mode may be intentionally diff --git a/integrations/claude/.claude-plugin/marketplace.json b/integrations/claude/.claude-plugin/marketplace.json index 33d657a..ee38ff9 100644 --- a/integrations/claude/.claude-plugin/marketplace.json +++ b/integrations/claude/.claude-plugin/marketplace.json @@ -6,13 +6,13 @@ }, "metadata": { "description": "Local AgentPost development marketplace", - "version": "0.0.7" + "version": "0.0.8" }, "plugins": [ { "name": "agentpost", "description": "A named local communication channel for CLI agents", - "version": "0.0.7", + "version": "0.0.8", "author": { "name": "AgentPost contributors" }, diff --git a/integrations/claude/agentpost/.claude-plugin/plugin.json b/integrations/claude/agentpost/.claude-plugin/plugin.json index f11888b..374d23a 100644 --- a/integrations/claude/agentpost/.claude-plugin/plugin.json +++ b/integrations/claude/agentpost/.claude-plugin/plugin.json @@ -2,7 +2,7 @@ "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "agentpost", "displayName": "AgentPost", - "version": "0.0.7", + "version": "0.0.8", "description": "A named local communication channel for CLI agents", "author": { "name": "AgentPost contributors" diff --git a/integrations/claude/agentpost/skills/agentpost/SKILL.md b/integrations/claude/agentpost/skills/agentpost/SKILL.md index 1646a7e..3246b32 100644 --- a/integrations/claude/agentpost/skills/agentpost/SKILL.md +++ b/integrations/claude/agentpost/skills/agentpost/SKILL.md @@ -82,11 +82,16 @@ 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 this process connected. Report the existing owner. If it is this managed session's parent bridge, continue in the existing session. Otherwise inspect -the exact identities and offer the user the first unused numbered mailbox, -starting with `NAME2`, then `NAME3`. Create it only after explicit user approval, -then use the explicit named join and launcher and verify it independently. The -numbered identity is a separate durable mailbox: it does not inherit, claim, or -move mail already addressed to `NAME`. +the exact identities and owner state. A suspended or unresponsive managed +consumer is a recovery condition, not parallel work: do not offer a numbered +mailbox. If the user confirms that exact instance was intended to close, use +the native `agentpost consumer-stop NAME --instance FULL_INSTANCE_ID` command +printed by AgentPost, then retry the original launcher. For a healthy unrelated +owner and genuinely parallel work, offer the user the first unused numbered +mailbox, starting with `NAME2`, then `NAME3`. Create it only after explicit user +approval, then use the explicit named join and launcher and verify it +independently. The 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. An ordinary running Codex thread with compatible AgentPost hooks may select @@ -201,6 +206,26 @@ Familiarity does not outrank a better responsibility or evidence match. ## Mail workflow +### Startup consent gate + +When a native notification begins with `AgentPost startup notice`, it is an +attention gate, not authorization to process mail. Do not run `agentpost list`, +`read`, `next`, or `reply`, and do not begin any workflow described by the +pending mail. Tell the user only the pending count and selected mailbox, then +ask whether to inspect that exact set in the current session, reload or rebind +the intended session first, or defer it. + +- If the user chooses **read now**, inspect exactly the Message-IDs embedded in + that startup notice, then use the normal claim and reply workflow below. +- If the user chooses **reload or rebind**, leave every message untouched and + give the exact applicable launcher or attachment command. The replacement + runtime will gate the still-unread set again. +- If the user chooses **defer**, leave every message untouched for this runtime. + +Never infer startup consent from `immediate` priority, a prior conversation, a +request to connect the mailbox, or the fact that the native adapter started a +turn. Mail delivered after startup uses the ordinary exact-ID workflow below. + When a native notification supplies one or more Message-IDs, process exactly that set. Do not run a blanket inbox listing or inspect, claim, or process other unread letters in that turn; another attention mode may be intentionally diff --git a/integrations/codex/plugins/agentpost/.codex-plugin/plugin.json b/integrations/codex/plugins/agentpost/.codex-plugin/plugin.json index e4c6a0b..422ba5a 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.6+codex.20260728214546", + "version": "0.0.7+codex.20260804015728", "description": "A named local communication channel for CLI agents", "author": { "name": "AgentPost contributors" @@ -16,6 +16,6 @@ "skills", "hooks" ], - "defaultPrompt": "Use AgentPost as this project's named agent communication channel and check unread mail." + "defaultPrompt": "Use AgentPost as this project's named agent communication channel and follow its startup-consent and exact-ID mail rules." } } diff --git a/integrations/codex/plugins/agentpost/skills/agentpost/SKILL.md b/integrations/codex/plugins/agentpost/skills/agentpost/SKILL.md index 1646a7e..3246b32 100644 --- a/integrations/codex/plugins/agentpost/skills/agentpost/SKILL.md +++ b/integrations/codex/plugins/agentpost/skills/agentpost/SKILL.md @@ -82,11 +82,16 @@ 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 this process connected. Report the existing owner. If it is this managed session's parent bridge, continue in the existing session. Otherwise inspect -the exact identities and offer the user the first unused numbered mailbox, -starting with `NAME2`, then `NAME3`. Create it only after explicit user approval, -then use the explicit named join and launcher and verify it independently. The -numbered identity is a separate durable mailbox: it does not inherit, claim, or -move mail already addressed to `NAME`. +the exact identities and owner state. A suspended or unresponsive managed +consumer is a recovery condition, not parallel work: do not offer a numbered +mailbox. If the user confirms that exact instance was intended to close, use +the native `agentpost consumer-stop NAME --instance FULL_INSTANCE_ID` command +printed by AgentPost, then retry the original launcher. For a healthy unrelated +owner and genuinely parallel work, offer the user the first unused numbered +mailbox, starting with `NAME2`, then `NAME3`. Create it only after explicit user +approval, then use the explicit named join and launcher and verify it +independently. The 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. An ordinary running Codex thread with compatible AgentPost hooks may select @@ -201,6 +206,26 @@ Familiarity does not outrank a better responsibility or evidence match. ## Mail workflow +### Startup consent gate + +When a native notification begins with `AgentPost startup notice`, it is an +attention gate, not authorization to process mail. Do not run `agentpost list`, +`read`, `next`, or `reply`, and do not begin any workflow described by the +pending mail. Tell the user only the pending count and selected mailbox, then +ask whether to inspect that exact set in the current session, reload or rebind +the intended session first, or defer it. + +- If the user chooses **read now**, inspect exactly the Message-IDs embedded in + that startup notice, then use the normal claim and reply workflow below. +- If the user chooses **reload or rebind**, leave every message untouched and + give the exact applicable launcher or attachment command. The replacement + runtime will gate the still-unread set again. +- If the user chooses **defer**, leave every message untouched for this runtime. + +Never infer startup consent from `immediate` priority, a prior conversation, a +request to connect the mailbox, or the fact that the native adapter started a +turn. Mail delivered after startup uses the ordinary exact-ID workflow below. + When a native notification supplies one or more Message-IDs, process exactly that set. Do not run a blanket inbox listing or inspect, claim, or process other unread letters in that turn; another attention mode may be intentionally diff --git a/integrations/shared/agentpost/SKILL.md b/integrations/shared/agentpost/SKILL.md index 1646a7e..3246b32 100644 --- a/integrations/shared/agentpost/SKILL.md +++ b/integrations/shared/agentpost/SKILL.md @@ -82,11 +82,16 @@ 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 this process connected. Report the existing owner. If it is this managed session's parent bridge, continue in the existing session. Otherwise inspect -the exact identities and offer the user the first unused numbered mailbox, -starting with `NAME2`, then `NAME3`. Create it only after explicit user approval, -then use the explicit named join and launcher and verify it independently. The -numbered identity is a separate durable mailbox: it does not inherit, claim, or -move mail already addressed to `NAME`. +the exact identities and owner state. A suspended or unresponsive managed +consumer is a recovery condition, not parallel work: do not offer a numbered +mailbox. If the user confirms that exact instance was intended to close, use +the native `agentpost consumer-stop NAME --instance FULL_INSTANCE_ID` command +printed by AgentPost, then retry the original launcher. For a healthy unrelated +owner and genuinely parallel work, offer the user the first unused numbered +mailbox, starting with `NAME2`, then `NAME3`. Create it only after explicit user +approval, then use the explicit named join and launcher and verify it +independently. The 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. An ordinary running Codex thread with compatible AgentPost hooks may select @@ -201,6 +206,26 @@ Familiarity does not outrank a better responsibility or evidence match. ## Mail workflow +### Startup consent gate + +When a native notification begins with `AgentPost startup notice`, it is an +attention gate, not authorization to process mail. Do not run `agentpost list`, +`read`, `next`, or `reply`, and do not begin any workflow described by the +pending mail. Tell the user only the pending count and selected mailbox, then +ask whether to inspect that exact set in the current session, reload or rebind +the intended session first, or defer it. + +- If the user chooses **read now**, inspect exactly the Message-IDs embedded in + that startup notice, then use the normal claim and reply workflow below. +- If the user chooses **reload or rebind**, leave every message untouched and + give the exact applicable launcher or attachment command. The replacement + runtime will gate the still-unread set again. +- If the user chooses **defer**, leave every message untouched for this runtime. + +Never infer startup consent from `immediate` priority, a prior conversation, a +request to connect the mailbox, or the fact that the native adapter started a +turn. Mail delivered after startup uses the ordinary exact-ID workflow below. + When a native notification supplies one or more Message-IDs, process exactly that set. Do not run a blanket inbox listing or inspect, claim, or process other unread letters in that turn; another attention mode may be intentionally diff --git a/specs/SAFE-SESSION-REENTRY-V1.md b/specs/SAFE-SESSION-REENTRY-V1.md new file mode 100644 index 0000000..41b9996 --- /dev/null +++ b/specs/SAFE-SESSION-REENTRY-V1.md @@ -0,0 +1,375 @@ +# Safe session re-entry and startup mail consent v1 + +**Status:** Implemented +**Scope:** Managed-consumer recovery, cross-seat Codex resume diagnostics, and +consent-gated unread catch-up for every interactive AgentPost adapter +**Origin:** A stopped managed Codex launcher retained the +`jobstratassistcx` consumer lease while its heartbeat was stale, followed by a +resume of the same Codex thread under the wrong `kreview` seat, 2026-08-04 + +## Problem + +Two independent behaviors make correcting a wrongly selected mailbox harder +than it should be. + +First, an AgentPost-managed CLI is a process tree. Suspending that foreground +tree with terminal job control stops its bridge heartbeat but does not close +the process or its file descriptors. The mailbox therefore has all three of +these properties at once: + +- the exclusive `consumer.lock` is still held; +- the owner PID is alive but stopped; +- the heartbeat is stale, so `status` and `armed` currently describe the + mailbox as offline with no live consumer. + +A later named launch correctly fails to acquire the lock, but its generic +conflict diagnostic recommends a numbered mailbox. That advice is wrong for a +suspended owner the user intended to close: a numbered mailbox would split +future mail instead of recovering the existing seat. + +Second, startup catch-up is non-destructive at the transport layer but eager at +the prompt layer. The adapters do not read or claim mail themselves. They +inject instructions telling the model to inspect the pending Message-IDs, and +the model naturally begins those workflows before the user can verify that the +right conversation, workspace, and AgentPost seat were loaded. + +Correcting identity after that point is too late: message contents and their +workflows are already in the wrong conversational context. + +## Goals + +V1 must: + +1. distinguish a healthy consumer from a suspended or unresponsive lease + owner; +2. recover an exact AgentPost-managed consumer without deleting or splitting + its mailbox; +3. prevent future terminal suspension from stranding a managed consumer lease; +4. detect an attempt to resume one Codex thread under two AgentPost seats; +5. turn every interactive adapter's startup catch-up into a user-consent gate; +6. leave startup mail unread and unclaimed across a reload; +7. preserve exact Message-ID scoping after the user authorizes inspection; +8. keep live post-startup notification semantics separate from startup policy. + +V1 does not: + +- move mail between mailbox identities; +- create numbered mailboxes as recovery for a stopped owner; +- make `attach` claim a live consumer lease or manufacture a live bridge; +- infer user consent from silence, a reload, message priority, or an agent's + enthusiasm to continue; +- allow two live consumers to process the same mailbox; +- require a new durable message state beyond `unread` and `read`. + +## Terminology + +**Startup catch-up** is the complete unread snapshot surfaced when an adapter +or host conversation becomes available. It includes mail delivered while that +runtime was absent. + +**Live delivery** is a notification first observed after startup catch-up has +completed in the current runtime. + +**Consent gate** is an adapter instruction that announces pending mail and asks +the user whether to inspect it. It explicitly prohibits reading, listing, +claiming, replying to, or beginning work from that mail before the user's +choice. + +**Managed consumer** is an AgentPost-owned process that holds the authoritative +inbound lease and for which AgentPost can prove the adapter, PID, instance ID, +and launch boundary. + +## Startup consent contract + +### Default behavior + +The default startup policy for every interactive CLI is `ask`. + +When unread mail exists at startup, the adapter starts at most one model turn +for the current catch-up batch. That turn receives an instruction shaped as: + +```text +AgentPost startup notice: N unread messages exist for mailbox NAME. They are +still unread and unclaimed. Do not list, read, claim, reply to, or begin work +from them yet. Tell the user that mail is waiting and ask whether to inspect +this exact pending set in the current session, reload or rebind the intended +session first, or defer it. Do not make the choice for the user. +``` + +The adapter context retains the immutable Message-IDs in the catch-up batch, +but the conversational response should normally expose only the mailbox and +count. Sender, subject, body, attachments, and work instructions are not read +to construct the gate. + +`immediate` priority may cause the gate to surface at the earliest supported +boundary. It does not authorize inspection and does not bypass the gate. + +### User choices + +The gate asks one concise question and accepts these outcomes: + +1. **Read now.** The agent may inspect only the Message-IDs captured by that + gate. Inspection still does not claim them. Each message is claimed only + when its work actually begins, using the existing atomic claim contract. +2. **Reload or rebind first.** The agent performs no mailbox read, list, claim, + reply, or work action. It reports the currently selected seat and the exact + launcher or attachment command that applies. The unread set is surfaced as + a fresh consent gate when the replacement runtime starts. +3. **Defer.** The adapter suppresses the same startup batch for the remainder + of that runtime unless the sender explicitly re-notifies it. The messages + remain unread and are gated again on a later runtime start. + +Consent applies only to the exact Message-IDs announced by one gate. Mail that +arrives afterward is not silently added to an already authorized batch. + +### Durable and ephemeral state + +The mailbox remains the durable truth. A gate does not add a mailbox state and +does not move files from `unread`. + +Adapter-local state may record that a Message-ID was announced during the +current runtime so lifecycle hooks do not ask repeatedly. That record is an +ephemeral attention ledger, not proof of inspection, claim, completion, or +user consent. + +The ledger is scoped to a runtime epoch, not merely to a durable conversation +ID. Resuming the same conversation in a new CLI process must gate the still +unread set again. A notification-request record may be acknowledged once the +gate was successfully surfaced because restart catch-up comes from the unread +spool, not from that request record. + +If the adapter cannot prove whether an announcement belongs to the current +runtime epoch, it fails safe by presenting another gate. Duplicate questions +are preferable to processing mail in the wrong context. + +## Adapter requirements + +| Adapter | Startup boundary | Required V1 behavior | +| --- | --- | --- | +| Managed Codex bridge | `initialCatchup()` after the loaded thread is known | Replace the current imperative `deliver()` call with one batched consent-gate turn. Populate the in-memory known set without reading mail. | +| Ordinary Codex hooks | `SessionStart`; first compatible boundary if `SessionStart` was unavailable | Inject a gate and write a thread-and-runtime-scoped announcement ledger. `UserPromptSubmit` and `Stop` must not replace that gate with an imperative read instruction for the same IDs. | +| Claude monitor | First successful lease acquisition and watcher snapshot | Batch the full initial snapshot into one gate instead of printing one imperative pointer per message. Later live notifications retain immediate/idle scheduling. | +| Antigravity hooks | First invocation for a conversation in the current host-process epoch | Inject one gate and scope its announcement ledger to both conversation and runtime epoch. If no reliable runtime epoch is available, gate again rather than auto-process. | +| Embedded Python `AgentRuntime` | First callback or queue batch after lease acquisition | Transport remains model-neutral. The batch must identify its origin as startup catch-up so an interactive host can apply the same gate. AgentPost itself still never calls a model. | + +For all adapters, successful gate delivery may update attention bookkeeping but +must not call `read`, `next`, `reply`, or application work handlers. + +## Live delivery after startup + +V1 changes startup catch-up, not the established live-delivery contract. + +After the startup gate has been surfaced, mail first delivered during that +runtime continues to use `immediate` and `idle` scheduling. A future policy may +offer `ask` for every live message, but that is a separate choice and must not +delay this safety correction. + +If live mail arrives while the startup gate's question is still outstanding, +it is surfaced as a separate exact notification batch. The user's later +authorization for the startup batch does not authorize the new batch. + +## Skill and prompt contract + +The shared AgentPost skill must recognize a native startup notice as distinct +from an ordinary Message-ID notification. + +For a startup notice, the agent must not run `agentpost list`, `read`, `next`, +or `reply`. It must: + +1. state the pending count and selected mailbox; +2. ask whether to read now, reload or rebind first, or defer; +3. wait for the user's answer; +4. on `read now`, inspect exactly the gate's Message-IDs; +5. on `reload`, give the exact recovery launcher and stop mailbox work; +6. on `defer`, leave the batch untouched. + +An ordinary live notification retains the existing exact-ID workflow. The two +instruction forms must be mechanically distinguishable so generic mail rules +cannot override the startup gate. + +## Consumer-state model + +Presence and lease ownership are separate evidence and must be reported +together. + +| State | Heartbeat | Lease | Owner process | Meaning | +| --- | --- | --- | --- | --- | +| `idle` / `working` | fresh | held | running | Healthy live consumer | +| `offline` | absent or stale | free | absent or irrelevant | No consumer owns the mailbox | +| `suspended` | stale | held | stopped (`T`/`t`) | Terminal job control stopped a managed owner without releasing its lock | +| `unresponsive` | stale | held | running | Owner still holds the lock but its adapter heartbeat failed | +| `incoherent` | any | held | missing or unverifiable | Fail-closed ownership requiring manual diagnosis | + +`status` prints the specific state and exact owner evidence. `armed` remains +nonzero for `suspended`, `unresponsive`, and `incoherent`; its detail must name +the held lease rather than saying `no live mailbox consumer`. + +The generic numbered-mailbox suggestion is allowed only for a healthy, +unrelated live owner when the user genuinely requested parallel work. It is +never offered for a suspended, unresponsive, same-thread, or otherwise +recoverable owner. + +## Guarded consumer recovery + +V1 adds an explicit administrative command: + +```sh +agentpost consumer-stop NAME --instance FULL_INSTANCE_ID +``` + +The exact instance ID is required to prevent a stale terminal instruction from +stopping a replacement consumer that won a later lease. The command: + +1. loads the named mailbox and current owner record; +2. verifies the instance ID byte-for-byte; +3. proves that the mailbox lock is still held; +4. proves that the PID belongs to an AgentPost-managed consumer for that + mailbox; +5. requests the adapter's normal shutdown path; +6. resumes only a stopped launcher when needed for that shutdown handler to + run; +7. waits for child cleanup, marker removal, and lease release; +8. reports `STOPPED` only after a nonblocking lease probe succeeds. + +It never deletes mail, profile data, bindings, attachments, or notification +requests. It does not send `SIGKILL` by default. A mismatched instance, PID +reuse, unverifiable command line, non-AgentPost process, or failed graceful +shutdown fails without pretending the mailbox is free. + +Example suspended-owner diagnostic: + +```text +mailbox jobstratassistcx has a suspended AgentPost consumer: codex pid 14971 +instance 296d3e...; it is offline but still holds the inbound lease. If the +session was intentionally closed, run `agentpost consumer-stop +jobstratassistcx --instance 296d3e508bf5431faf25b3ab572cc651` and then retry +the original launcher. Do not create jobstratassistcx2 for this condition. +``` + +## Managed terminal signals + +A managed consumer cannot safely remain suspended: its heartbeat and native +wake path are stopped while its exclusive lease remains live. + +The Codex and Antigravity launchers therefore intercept terminal suspension +while they own a mailbox. `SIGTSTP` is converted into a clean managed shutdown: +the remote client, bridge, app server, marker, consumer lease, and plugin lock +are released before the launcher exits with a signal-derived status. The +terminal explains that managed sessions are stopped rather than suspended and +prints the resume command when it can be reconstructed. + +Normal `SIGINT`, terminal hangup, child exit, startup failure, and bridge +failure use the same cleanup invariant. No exit path may leave the parent +alive and stopped while it owns `consumer.lock`. + +Existing already-suspended launchers cannot execute new signal handling until +resumed, which is why `consumer-stop` remains necessary for recovery. + +## Cross-seat Codex resume protection + +For managed Codex resumes, the owner metadata records a digest of the Codex +thread ID and the selected AgentPost seat. The raw thread ID need not be stored +in AgentPost runtime metadata. + +Before launching: + +```sh +agentpost codex --agent TARGET resume THREAD_ID +``` + +AgentPost scans verified live managed Codex owners. If the same thread digest +is already active under another seat, startup fails before acquiring the target +mailbox and reports: + +- the existing seat, PID, and instance ID; +- whether it is healthy, suspended, or unresponsive; +- the exact guarded stop command when AgentPost can safely provide one; +- that `attach` is boundary-only and cannot transfer the live bridge; +- that the supported full-wake rebind is stop, then relaunch under `TARGET`. + +AgentPost never silently transfers unread mail or a live consumer lease between +seats. A thread and a mailbox are separate durable identities even when the +user intends to pair them differently on the next launch. + +## Compatibility and rollout + +- Mailbox layout and `unread`/`read` semantics do not change. +- Existing `message`, `question`, `read`, `next`, and `reply` commands retain + their contracts. +- `armed` keeps its existing success and non-success exit codes. Diagnostic + wording and `status` state become more precise. +- Interactive startup behavior intentionally changes from eager inspection to + `ask`. +- Live post-startup delivery retains its current scheduling behavior. +- Python callbacks remain transport-only; startup-origin metadata is additive. +- Older consumer owner documents without a thread digest remain valid and may + participate in same-thread cross-seat detection when AgentPost can recover + the resume or fork thread from a verified managed launcher command line. +- Adapter-local announcement ledgers may be discarded at any time; the result + is another consent gate, never lost mail or automatic processing. + +The shared skill, generated Claude/Codex/Antigravity skill copies, packaged +integration data, CLI help, installation documentation, and compatibility +documentation must ship together so no adapter retains the old imperative +startup wording. + +## Acceptance tests + +### Consent gate + +1. One unread startup message produces one question and zero calls to `read`, + `next`, `reply`, or a work handler. +2. Multiple unread startup messages produce one batched gate. +3. `immediate` startup mail still produces a gate rather than inspection. +4. `read now` permits inspection of exactly the gated IDs; a concurrently + delivered ID is excluded. +5. `reload first` leaves every gated message unread and causes the replacement + runtime to gate the same set again. +6. `defer` suppresses the batch only for the current runtime. +7. Codex `UserPromptSubmit` and `Stop` do not replace an unresolved startup + gate with an imperative read instruction. +8. Claude batches initial watcher output but retains later immediate/idle + behavior. +9. Antigravity resumes with the same conversation ID in a new process and + gates unread mail again. +10. Python startup callbacks are marked as catch-up and still do not call a + model or claim mail. + +### Consumer lifecycle + +1. A stopped owner with a stale heartbeat and held lock reports `suspended`, + not `offline`. +2. `armed` remains nonzero and names the suspended PID and instance. +3. Lease acquisition against a suspended owner does not suggest a numbered + mailbox. +4. `consumer-stop` rejects a missing or mismatched instance ID without sending + a signal. +5. `consumer-stop` cleanly releases an exact stopped managed owner and does not + alter mailbox contents. +6. Suspending a new managed Codex or Antigravity launcher invokes cleanup and + leaves no held mailbox or plugin lock. +7. The same Codex thread cannot be managed concurrently under two seats. +8. Distinct threads and distinct mailboxes remain concurrently supported. +9. A healthy unrelated owner still retains the fail-closed parallel-identity + advice. + +## Implementation boundaries + +The primary implementation sites are: + +- `src/agentpost/ownership.py` for verified owner state, lock probing, and + guarded consumer shutdown; +- `src/agentpost/presence.py` and `src/agentpost/installer.py` for truthful + suspended/unresponsive diagnostics; +- `src/agentpost/native.py` for CLI signal cleanup, Codex hook gates, Claude + startup batching, and Antigravity runtime-epoch gates; +- `src/agentpost/data/codex_bridge.mjs` for managed Codex startup gating; +- `src/agentpost/runtime.py` for additive startup-origin metadata; +- `src/agentpost/cli.py` for `consumer-stop` and exact recovery output; +- `integrations/shared/agentpost/SKILL.md` and rendered integration artifacts + for the user-consent policy. + +The startup gate and consumer lifecycle fixes are one release unit: truthful +re-entry without a consent gate still lets the wrong context process mail, and +a consent gate without truthful lease recovery can leave the user unable to +start the correct context. diff --git a/src/agentpost/cli.py b/src/agentpost/cli.py index f71dd07..6c40143 100644 --- a/src/agentpost/cli.py +++ b/src/agentpost/cli.py @@ -30,7 +30,7 @@ from .adapters import MailboxWatcher from .codex_session import attach_codex_session from .installer import armed, doctor, install, uninstall, upgrade -from .ownership import ConsumerLease +from .ownership import ConsumerLease, stop_managed_consumer from .presence import agent_presence from .review import prepare_review, render_review_request from .native import ( @@ -199,6 +199,15 @@ def build_parser() -> argparse.ArgumentParser: status = commands.add_parser("status") status.add_argument("agent", nargs="?") status.add_argument("--project") + consumer_stop = commands.add_parser( + "consumer-stop", + description=( + "Gracefully stop one exact AgentPost-managed mailbox consumer " + "without deleting mail or identity state." + ), + ) + consumer_stop.add_argument("agent") + consumer_stop.add_argument("--instance", required=True) wipe = commands.add_parser( "wipe", @@ -573,6 +582,17 @@ def main(argv: list[str] | None = None) -> int: for name in names: presence = agent_presence(office, name) print(f"{name}\t{presence.state}\t{presence.detail}") + elif args.command == "consumer-stop": + profile = office.load_profile(args.agent) + owner = stop_managed_consumer( + office, + profile.name, + args.instance, + ) + print( + f"STOPPED\t{profile.name}\t{owner.get('adapter', 'unknown')}\t" + f"pid={owner.get('pid', '?')}\tinstance={owner.get('instance_id', '?')}" + ) elif args.command == "wipe": _wipe(office, args) elif args.command == "send": diff --git a/src/agentpost/codex_generation.py b/src/agentpost/codex_generation.py index 2d72c8e..04eaff5 100644 --- a/src/agentpost/codex_generation.py +++ b/src/agentpost/codex_generation.py @@ -10,7 +10,7 @@ CODEX_PLUGIN_ID = "agentpost@agentpost-local" -CODEX_HOOK_GENERATION = "0.0.6+codex.20260728214546" +CODEX_HOOK_GENERATION = "0.0.7+codex.20260804015728" CODEX_HOOK_EVENTS = ("session-start", "user-prompt-submit", "stop") CODEX_STABLE_DISPATCH_MIN_RELEASE = (0, 0, 3) _CODEX_GENERATION_RE = re.compile( diff --git a/src/agentpost/data/codex_bridge.mjs b/src/agentpost/data/codex_bridge.mjs index 9689cc3..a583544 100644 --- a/src/agentpost/data/codex_bridge.mjs +++ b/src/agentpost/data/codex_bridge.mjs @@ -128,7 +128,7 @@ async function initialCatchup() { messages.forEach((item) => known.add(deliveryId(item))); const groups = coalesce(messages); const mode = strongestMode(groups); - await deliver(groups, mode); + await deliver(groups, mode, { startup: true }); trace("startup-catchup", { ids: [...new Set(messages.map((item) => item.message_id))], deliveries: messages.length, @@ -150,7 +150,7 @@ async function refreshTurnId() { } } -async function deliver(items, mode) { +async function deliver(items, mode, { startup = false } = {}) { const ids = items.map((item) => item.message_id); const reads = ids.map( (id) => `agentpost read ${options.agent} ${quoteShell(id)}`, @@ -158,14 +158,22 @@ async function deliver(items, mode) { const claims = ids.map( (id) => `agentpost next ${options.agent} --message-id ${quoteShell(id)}`, ); - const text = - `AgentPost ${mode} mail is waiting for ${options.agent}: ${ids.join(", ")}. ` + - "Load the agentpost skill if available. Inspect exactly the listed " + - `Message-ID(s) with: ${reads.map(code).join("; ")}. Do not ` + - "list, read, claim, or process any other unread mail during this turn; other " + - "messages may be intentionally deferred. Claim each only when starting its " + - `work with: ${claims.map(code).join("; ")}. Reply by Message-ID when ` + - "appropriate and give the user a short synopsis."; + const text = startup + ? `AgentPost startup notice: ${ids.length} unread message(s) exist for mailbox ` + + `${options.agent}. The exact pending set is: ${ids.join(", ")}. They remain ` + + "unread and unclaimed. Do not list, read, claim, reply to, or begin work " + + "from them yet. Load the agentpost skill if available. Tell the user that " + + "mail is waiting and ask whether to inspect this exact set in the current " + + "session, reload or rebind the intended session first, or defer it. Do not " + + "make the choice for the user. If the user chooses reload or defer, leave " + + "every message untouched." + : `AgentPost ${mode} mail is waiting for ${options.agent}: ${ids.join(", ")}. ` + + "Load the agentpost skill if available. Inspect exactly the listed " + + `Message-ID(s) with: ${reads.map(code).join("; ")}. Do not ` + + "list, read, claim, or process any other unread mail during this turn; other " + + "messages may be intentionally deferred. Claim each only when starting its " + + `work with: ${claims.map(code).join("; ")}. Reply by Message-ID when ` + + "appropriate and give the user a short synopsis."; const input = [{ type: "text", text, text_elements: [] }]; if (active && turnId && mode === "immediate") { try { diff --git a/src/agentpost/data/integrations.json b/src/agentpost/data/integrations.json index c899633..1cb791c 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 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", + "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 owner state. A suspended or unresponsive managed\nconsumer is a recovery condition, not parallel work: do not offer a numbered\nmailbox. If the user confirms that exact instance was intended to close, use\nthe native `agentpost consumer-stop NAME --instance FULL_INSTANCE_ID` command\nprinted by AgentPost, then retry the original launcher. For a healthy unrelated\nowner and genuinely parallel work, offer the user the first unused numbered\nmailbox, starting with `NAME2`, then `NAME3`. Create it only after explicit user\napproval, then use the explicit named join and launcher and verify it\nindependently. The numbered identity is a separate durable mailbox: it does not\ninherit, claim, or move 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\n### Startup consent gate\n\nWhen a native notification begins with `AgentPost startup notice`, it is an\nattention gate, not authorization to process mail. Do not run `agentpost list`,\n`read`, `next`, or `reply`, and do not begin any workflow described by the\npending mail. Tell the user only the pending count and selected mailbox, then\nask whether to inspect that exact set in the current session, reload or rebind\nthe intended session first, or defer it.\n\n- If the user chooses **read now**, inspect exactly the Message-IDs embedded in\n that startup notice, then use the normal claim and reply workflow below.\n- If the user chooses **reload or rebind**, leave every message untouched and\n give the exact applicable launcher or attachment command. The replacement\n runtime will gate the still-unread set again.\n- If the user chooses **defer**, leave every message untouched for this runtime.\n\nNever infer startup consent from `immediate` priority, a prior conversation, a\nrequest to connect the mailbox, or the fact that the native adapter started a\nturn. Mail delivered after startup uses the ordinary exact-ID workflow below.\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.8\"\n },\n \"plugins\": [\n {\n \"name\": \"agentpost\",\n \"description\": \"A named local communication channel for CLI agents\",\n \"version\": \"0.0.8\",\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.8\",\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 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/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 owner state. A suspended or unresponsive managed\nconsumer is a recovery condition, not parallel work: do not offer a numbered\nmailbox. If the user confirms that exact instance was intended to close, use\nthe native `agentpost consumer-stop NAME --instance FULL_INSTANCE_ID` command\nprinted by AgentPost, then retry the original launcher. For a healthy unrelated\nowner and genuinely parallel work, offer the user the first unused numbered\nmailbox, starting with `NAME2`, then `NAME3`. Create it only after explicit user\napproval, then use the explicit named join and launcher and verify it\nindependently. The numbered identity is a separate durable mailbox: it does not\ninherit, claim, or move 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\n### Startup consent gate\n\nWhen a native notification begins with `AgentPost startup notice`, it is an\nattention gate, not authorization to process mail. Do not run `agentpost list`,\n`read`, `next`, or `reply`, and do not begin any workflow described by the\npending mail. Tell the user only the pending count and selected mailbox, then\nask whether to inspect that exact set in the current session, reload or rebind\nthe intended session first, or defer it.\n\n- If the user chooses **read now**, inspect exactly the Message-IDs embedded in\n that startup notice, then use the normal claim and reply workflow below.\n- If the user chooses **reload or rebind**, leave every message untouched and\n give the exact applicable launcher or attachment command. The replacement\n runtime will gate the still-unread set again.\n- If the user chooses **defer**, leave every message untouched for this runtime.\n\nNever infer startup consent from `immediate` priority, a prior conversation, a\nrequest to connect the mailbox, or the fact that the native adapter started a\nturn. Mail delivered after startup uses the ordinary exact-ID workflow below.\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.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/.codex-plugin/plugin.json": "{\n \"name\": \"agentpost\",\n \"version\": \"0.0.7+codex.20260804015728\",\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 follow its startup-consent and exact-ID mail rules.\"\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 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/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 owner state. A suspended or unresponsive managed\nconsumer is a recovery condition, not parallel work: do not offer a numbered\nmailbox. If the user confirms that exact instance was intended to close, use\nthe native `agentpost consumer-stop NAME --instance FULL_INSTANCE_ID` command\nprinted by AgentPost, then retry the original launcher. For a healthy unrelated\nowner and genuinely parallel work, offer the user the first unused numbered\nmailbox, starting with `NAME2`, then `NAME3`. Create it only after explicit user\napproval, then use the explicit named join and launcher and verify it\nindependently. The numbered identity is a separate durable mailbox: it does not\ninherit, claim, or move 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\n### Startup consent gate\n\nWhen a native notification begins with `AgentPost startup notice`, it is an\nattention gate, not authorization to process mail. Do not run `agentpost list`,\n`read`, `next`, or `reply`, and do not begin any workflow described by the\npending mail. Tell the user only the pending count and selected mailbox, then\nask whether to inspect that exact set in the current session, reload or rebind\nthe intended session first, or defer it.\n\n- If the user chooses **read now**, inspect exactly the Message-IDs embedded in\n that startup notice, then use the normal claim and reply workflow below.\n- If the user chooses **reload or rebind**, leave every message untouched and\n give the exact applicable launcher or attachment command. The replacement\n runtime will gate the still-unread set again.\n- If the user chooses **defer**, leave every message untouched for this runtime.\n\nNever infer startup consent from `immediate` priority, a prior conversation, a\nrequest to connect the mailbox, or the fact that the native adapter started a\nturn. Mail delivered after startup uses the ordinary exact-ID workflow below.\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/native.py b/src/agentpost/native.py index e652dd1..0e872eb 100644 --- a/src/agentpost/native.py +++ b/src/agentpost/native.py @@ -5,6 +5,7 @@ import os import select import shlex +import signal import socket import subprocess import sys @@ -17,8 +18,12 @@ from .codex_generation import CODEX_HOOK_GENERATION, codex_hook_marker from .codex_lock import CodexPluginLock from .core import AgentPostError, PostOffice -from .ownership import ConsumerLease -from .presence import HEARTBEAT_INTERVAL_SECONDS +from .ownership import ( + ConsumerLease, + codex_session_digest, + find_managed_codex_thread, +) +from .presence import HEARTBEAT_INTERVAL_SECONDS, agent_presence from .routing import identify_agent @@ -78,6 +83,7 @@ def claude_monitor() -> int: ) deferred = [] last_heartbeat = 0.0 + startup = True try: while True: if not lease.acquired: @@ -98,11 +104,17 @@ def claude_monitor() -> int: }, ) last_heartbeat = now - for record in watcher.pending(): - if record.letter.notify == "idle" and state == "busy": - deferred.append(record) - else: - _emit_claude(profile.name, record) + pending = watcher.pending() + if startup: + if pending: + _emit_claude_startup(profile.name, pending) + startup = False + else: + for record in pending: + if record.letter.notify == "idle" and state == "busy": + deferred.append(record) + else: + _emit_claude(profile.name, record) if state == "idle" and deferred: for record in deferred: _emit_claude(profile.name, record) @@ -154,14 +166,44 @@ def codex_hook(event_name: str, generation: str | None = None) -> int: return 0 unread = office.list_messages(profile.name, "unread") requests = office.notification_requests(profile.name) + ledger_path = _codex_startup_ledger(office, profile.name, session_id) if not unread: + if not ledger_path.exists(): + _write_notification_ledger( + ledger_path, + set(), + runtime_epoch=f"codex:{session_id}", + ) print("{}") return 0 - instruction = _exact_mail_instruction( - profile.name, - unread, - skill_instruction="Load the agentpost skill if available.", - ) + announced = _notification_ledger_ids(ledger_path) + startup = event_name == "session-start" or not ledger_path.exists() + if startup: + pending = unread + instruction = _startup_mail_instruction( + profile.name, + pending, + skill_instruction="Load the agentpost skill if available.", + ) + _write_notification_ledger( + ledger_path, + {record.letter.message_id for record in pending}, + runtime_epoch=f"codex:{session_id}", + ) + else: + pending = [ + record + for record in unread + if record.letter.message_id not in announced + ] + if not pending: + print("{}") + return 0 + instruction = _exact_mail_instruction( + profile.name, + pending, + skill_instruction="Load the agentpost skill if available.", + ) hook_event_names = { "session-start": "SessionStart", "user-prompt-submit": "UserPromptSubmit", @@ -185,7 +227,7 @@ def codex_hook(event_name: str, generation: str | None = None) -> int: else: print("{}") if surfaced: - unread_ids = {record.letter.message_id for record in unread} + unread_ids = {record.letter.message_id for record in pending} for request in requests: if request.message_id in unread_ids: office.acknowledge_notification(profile.name, request.request_id) @@ -275,7 +317,10 @@ def antigravity_hook(event_name: str) -> int: requests = office.notification_requests(profile.name) forced = {request.message_id for request in requests} ledger_path = _antigravity_ledger(office, profile.name, event) - notified = _antigravity_notified(ledger_path) + runtime_epoch = _host_runtime_epoch() + ledger = _notification_ledger(ledger_path) + startup = ledger.get("runtime_epoch") != runtime_epoch + notified = set() if startup else _notification_ledger_ids(ledger_path) pending = [ record for record in unread @@ -283,16 +328,30 @@ def antigravity_hook(event_name: str) -> int: or record.letter.message_id in forced ] if not pending: + if startup: + _write_notification_ledger( + ledger_path, + set(), + runtime_epoch=runtime_epoch, + ) print(_antigravity_empty_output(event_name)) return 0 - instruction = _antigravity_instruction(profile.name, pending) + instruction = _antigravity_instruction( + profile.name, + pending, + startup=startup, + ) if event_name == "pre-invocation": print(json.dumps({"injectSteps": [{"ephemeralMessage": instruction}]})) else: print(json.dumps({"decision": "continue", "reason": instruction})) notified.update(record.letter.message_id for record in pending) - _atomic_json(ledger_path, {"notified": sorted(notified)}) + _write_notification_ledger( + ledger_path, + notified, + runtime_epoch=runtime_epoch, + ) pending_ids = {record.letter.message_id for record in pending} for request in requests: if request.message_id in pending_ids: @@ -323,13 +382,40 @@ def codex_launch( "live-wake bridge; headless services should embed agentpost.AgentRuntime, " "while ordinary Codex lifecycle hooks provide next-boundary catch-up" ) + thread_id = _codex_resume_thread_id(codex_args) + thread_digest = codex_session_digest(thread_id) if thread_id else None + if thread_digest: + conflict = find_managed_codex_thread( + office, + thread_digest, + exclude_agent=profile.name, + ) + if conflict is not None: + owner_agent, owner = conflict + owner_instance = owner.get("instance_id", "?") + owner_pid = owner.get("pid", "?") + presence = agent_presence(office, owner_agent) + raise AgentPostError( + f"Codex thread {thread_id} is already managed under AgentPost seat " + f"{owner_agent}: {presence.state}, pid {owner_pid}, instance " + f"{owner_instance}. Stop that exact owner with `agentpost " + f"consumer-stop {owner_agent} --instance {owner_instance}` and " + f"then relaunch under {profile.name}. `agentpost attach` is " + "boundary-only and cannot transfer a live bridge" + ) plugin_lock = CodexPluginLock() if not plugin_lock.acquire_shared(): raise AgentPostError( "Codex cannot start while AgentPost is replacing the global Codex " "plugin; retry after the install completes" ) - lease = ConsumerLease(office, profile.name, "codex", cwd=cwd) + lease = ConsumerLease( + office, + profile.name, + "codex", + cwd=cwd, + session_digest=thread_digest, + ) try: lease.require() except Exception: @@ -338,6 +424,7 @@ def codex_launch( marker = _codex_bridge_marker(office, profile.name) server = None bridge = None + signal_handlers = _install_managed_signal_handlers() try: marker.parent.mkdir(parents=True, exist_ok=True) _atomic_json( @@ -398,10 +485,20 @@ def codex_launch( f"{bridge.returncode}" ) command = _codex_remote_command(url, codex_args) - try: - return subprocess.call(command, cwd=cwd, env=environment) - except KeyboardInterrupt: - return 130 + return subprocess.call(command, cwd=cwd, env=environment) + except _ManagedTerminalSignal as exc: + resume_command = shlex.join( + ["agentpost", "codex", "--agent", profile.name, *codex_args] + ) + print( + "agentpost: managed Codex sessions are stopped cleanly rather than " + "suspended so their mailbox lease is released; resume with " + f"`{resume_command}`", + file=sys.stderr, + ) + return 128 + exc.signum + except KeyboardInterrupt: + return 130 except FileNotFoundError as exc: raise AgentPostError(f"Codex adapter dependency not found: {exc.filename}") from exc finally: @@ -419,7 +516,10 @@ def codex_launch( try: lease.release() finally: - plugin_lock.release() + try: + plugin_lock.release() + finally: + _restore_managed_signal_handlers(signal_handlers) def claude_launch( @@ -451,12 +551,29 @@ def antigravity_launch( environment = os.environ.copy() environment["AGENTPOST_AGENT"] = profile.name environment["AGENTPOST_CONSUMER_INSTANCE"] = lease.instance_id + signal_handlers = _install_managed_signal_handlers() try: return subprocess.call(["agy", *antigravity_args], cwd=cwd, env=environment) + except _ManagedTerminalSignal as exc: + resume_command = shlex.join( + ["agentpost", "antigravity", "--agent", profile.name, *antigravity_args] + ) + print( + "agentpost: managed Antigravity sessions are stopped cleanly rather " + "than suspended so their mailbox lease is released; resume with " + f"`{resume_command}`", + file=sys.stderr, + ) + return 128 + exc.signum + except KeyboardInterrupt: + return 130 except FileNotFoundError as exc: raise AgentPostError("Antigravity CLI not found") from exc finally: - lease.release() + try: + lease.release() + finally: + _restore_managed_signal_handlers(signal_handlers) def _codex_remote_command(url: str, args: list[str]) -> list[str]: @@ -465,6 +582,46 @@ def _codex_remote_command(url: str, args: list[str]) -> list[str]: return ["codex", "--remote", url, *args] +def _codex_resume_thread_id(args: list[str]) -> str | None: + if not args or args[0] not in {"fork", "resume"}: + return None + for value in args[1:]: + if not value.startswith("-"): + return value + return None + + +class _ManagedTerminalSignal(BaseException): + def __init__(self, signum: int) -> None: + super().__init__(signum) + self.signum = signum + + +def _raise_managed_terminal_signal(signum, _frame) -> None: + raise _ManagedTerminalSignal(int(signum)) + + +def _install_managed_signal_handlers() -> dict[int, object]: + handlers = {} + for signum in (getattr(signal, "SIGHUP", None), getattr(signal, "SIGTSTP", None)): + if signum is None: + continue + try: + handlers[signum] = signal.getsignal(signum) + signal.signal(signum, _raise_managed_terminal_signal) + except (OSError, ValueError): + handlers.pop(signum, None) + return handlers + + +def _restore_managed_signal_handlers(handlers: dict[int, object]) -> None: + for signum, handler in handlers.items(): + try: + signal.signal(signum, handler) + except (OSError, ValueError): + pass + + def _codex_bridge_marker(office: PostOffice, agent: str) -> Path: return office.root / "agents" / agent / "adapter" / "codex-bridge.active" @@ -497,11 +654,7 @@ def _antigravity_ledger(office: PostOffice, agent: str, event: dict) -> Path: def _antigravity_notified(path: Path) -> set[str]: - try: - value = json.loads(path.read_text(encoding="utf-8")) - return {str(item) for item in value.get("notified", [])} - except (OSError, TypeError, AttributeError, json.JSONDecodeError): - return set() + return _notification_ledger_ids(path) def _antigravity_presence_marker(office: PostOffice, agent: str) -> Path: @@ -514,14 +667,34 @@ def _antigravity_empty_output(event_name: str) -> str: return json.dumps({"decision": "stop"}) -def _antigravity_instruction(agent: str, records: list) -> str: - return _exact_mail_instruction( +def _antigravity_instruction(agent: str, records: list, *, startup: bool = False) -> str: + renderer = _startup_mail_instruction if startup else _exact_mail_instruction + return renderer( agent, records, skill_instruction="Use the agentpost skill if available.", ) +def _startup_mail_instruction( + agent: str, + records: list, + *, + skill_instruction: str, +) -> str: + pointers = ", ".join(record.letter.message_id for record in records) + return ( + f"AgentPost startup notice: {len(records)} unread message(s) exist for " + f"mailbox {agent}. The exact pending set is: {pointers}. They remain unread " + "and unclaimed. Do not list, read, claim, reply to, or begin work from " + f"them yet. {skill_instruction} Tell the user that mail is waiting and ask " + "whether to inspect this exact set in the current session, reload or " + "rebind the intended session first, or defer it. Do not make the choice " + "for the user. If the user chooses reload or defer, leave every message " + "untouched." + ) + + def _exact_mail_instruction( agent: str, records: list, @@ -559,6 +732,53 @@ def _mail_cli_commands( return reads, claims +def _codex_startup_ledger(office: PostOffice, agent: str, session_id: str) -> Path: + token = hashlib.sha256(session_id.encode("utf-8")).hexdigest()[:20] + return office.root / "agents" / agent / "adapter" / f"codex-startup-{token}.json" + + +def _notification_ledger(path: Path) -> dict: + try: + value = json.loads(path.read_text(encoding="utf-8")) + return value if isinstance(value, dict) else {} + except (OSError, json.JSONDecodeError): + return {} + + +def _notification_ledger_ids(path: Path) -> set[str]: + value = _notification_ledger(path) + try: + return {str(item) for item in value.get("notified", [])} + except (TypeError, AttributeError): + return set() + + +def _write_notification_ledger( + path: Path, + message_ids: set[str], + *, + runtime_epoch: str, +) -> None: + _atomic_json( + path, + { + "runtime_epoch": runtime_epoch, + "notified": sorted(message_ids), + }, + ) + + +def _host_runtime_epoch() -> str: + pid = os.getppid() + try: + stat = Path(f"/proc/{pid}/stat").read_text(encoding="ascii") + fields = stat[stat.rfind(")") + 2 :].split() + start_ticks = fields[19] + except (OSError, IndexError): + start_ticks = str(time.time_ns()) + return f"{pid}:{start_ticks}" + + def _free_loopback_port() -> int: with socket.socket() as listener: listener.bind(("127.0.0.1", 0)) @@ -651,3 +871,14 @@ def _emit_claude(agent: str, record) -> None: "appropriate and give the user a short synopsis.", flush=True, ) + + +def _emit_claude_startup(agent: str, records) -> None: + print( + _startup_mail_instruction( + agent, + list(records), + skill_instruction="Load `/agentpost:agentpost` if available.", + ), + flush=True, + ) diff --git a/src/agentpost/ownership.py b/src/agentpost/ownership.py index 8330c95..9009dfc 100644 --- a/src/agentpost/ownership.py +++ b/src/agentpost/ownership.py @@ -1,8 +1,10 @@ from __future__ import annotations import fcntl +import hashlib import json import os +import signal import tempfile import time import uuid @@ -22,6 +24,7 @@ def __init__( *, instance_id: str | None = None, cwd: str | Path | None = None, + session_digest: str | None = None, ) -> None: office.load_profile(agent) self.office = office @@ -29,6 +32,7 @@ def __init__( self.adapter = adapter self.instance_id = instance_id or uuid.uuid4().hex self.cwd = str(Path(cwd or Path.cwd()).expanduser().resolve()) + self.session_digest = session_digest directory = office.root / "agents" / agent / "adapter" self.lock_path = directory / "consumer.lock" self.owner_path = directory / "consumer.json" @@ -67,17 +71,17 @@ def acquire(self, *, blocking: bool = False) -> bool: handle.close() raise self._handle = handle - _atomic_json( - self.owner_path, - { - "version": 1, - "instance_id": self.instance_id, - "adapter": self.adapter, - "pid": os.getpid(), - "cwd": self.cwd, - "acquired_at": time.time(), - }, - ) + owner = { + "version": 1, + "instance_id": self.instance_id, + "adapter": self.adapter, + "pid": os.getpid(), + "cwd": self.cwd, + "acquired_at": time.time(), + } + if self.session_digest: + owner["session_digest"] = self.session_digest + _atomic_json(self.owner_path, owner) return True def require(self) -> None: @@ -94,6 +98,17 @@ def require(self) -> None: f"{owner.get('instance_id', '?')}); do not launch or join it again; " "continue in the existing session" ) + if consumer_process_state(owner_pid) in {"T", "t"}: + instance_id = owner.get("instance_id", "?") + raise AgentPostError( + f"mailbox {self.agent} has a suspended inbound consumer: " + f"{owner.get('adapter', 'unknown')} pid {owner_pid or '?'} " + f"instance {instance_id}; it is offline but still holds the " + "inbound lease. If that session was intentionally closed, run " + f"`agentpost consumer-stop {self.agent} --instance " + f"{instance_id}` and retry the original launcher. Do not create " + f"{_next_parallel_name(self.office, self.agent)} for this condition" + ) detail = ( f"{owner.get('adapter', 'unknown')} pid {owner.get('pid', '?')} " f"instance {owner.get('instance_id', '?')}" @@ -165,6 +180,196 @@ def _next_parallel_name(office: PostOffice, name: str) -> str: number += 1 +def consumer_process_state(pid: object) -> str | None: + """Return the Linux process state used for lease diagnostics.""" + try: + parsed = int(pid) + stat = Path(f"/proc/{parsed}/stat").read_text(encoding="ascii") + fields = stat[stat.rfind(")") + 2 :].split() + return fields[0] + except (TypeError, ValueError, OSError, IndexError): + return None + + +def consumer_lock_held(lock_path: Path) -> bool: + """Probe an existing consumer lock without changing its owner document.""" + try: + handle = lock_path.open("rb") + except OSError: + return False + try: + try: + fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + return True + fcntl.flock(handle.fileno(), fcntl.LOCK_UN) + return False + finally: + handle.close() + + +def stop_managed_consumer( + office: PostOffice, + agent: str, + instance_id: str, + *, + timeout: float = 10.0, +) -> dict: + """Gracefully stop one exact AgentPost-managed CLI consumer.""" + if not instance_id: + raise AgentPostError("consumer-stop requires the full owner instance ID") + probe = ConsumerLease(office, agent, "consumer-stop") + owner = probe.current_owner() + if not owner: + raise AgentPostError(f"mailbox {agent} has no recorded inbound consumer") + current_instance = str(owner.get("instance_id", "")) + if current_instance != instance_id: + raise AgentPostError( + f"mailbox {agent} is owned by instance {current_instance or '?'}, not " + f"the requested instance {instance_id}; refusing to stop a replacement " + "consumer" + ) + if not consumer_lock_held(probe.lock_path): + raise AgentPostError( + f"mailbox {agent} has no held inbound consumer lease; the owner record " + "is stale and no process was signaled" + ) + adapter = str(owner.get("adapter", "")) + if adapter not in {"antigravity", "codex"}: + raise AgentPostError( + f"consumer-stop supports AgentPost-managed Codex and Antigravity " + f"launchers; {agent} is owned by {adapter or 'an unknown adapter'}" + ) + try: + pid = int(owner["pid"]) + except (KeyError, TypeError, ValueError) as exc: + raise AgentPostError( + f"mailbox {agent} owner has no valid PID; no process was signaled" + ) from exc + if not _is_managed_launcher(pid, agent, adapter): + raise AgentPostError( + f"refusing to signal pid {pid}: it is not a verified AgentPost-managed " + f"{adapter} launcher for {agent}" + ) + + # Revalidate immediately before signaling so a newly acquired instance + # cannot be stopped by a stale terminal instruction. + if probe.current_owner().get("instance_id") != instance_id: + raise AgentPostError( + f"mailbox {agent} changed owners during recovery; no process was signaled" + ) + stopped = consumer_process_state(pid) in {"T", "t"} + try: + os.kill(pid, signal.SIGINT) + if stopped: + os.kill(pid, signal.SIGCONT) + except ProcessLookupError as exc: + raise AgentPostError( + f"consumer pid {pid} exited before recovery; retry after checking " + f"`agentpost status {agent}`" + ) from exc + except PermissionError as exc: + raise AgentPostError(f"permission denied while stopping consumer pid {pid}") from exc + + deadline = time.monotonic() + timeout + marker = office.root / "agents" / agent / "adapter" / "codex-bridge.active" + while time.monotonic() < deadline: + marker_clear = adapter != "codex" or not marker.exists() + if marker_clear and not consumer_lock_held(probe.lock_path): + return owner + time.sleep(0.05) + raise AgentPostError( + f"managed {adapter} consumer pid {pid} did not release mailbox {agent} " + f"within {timeout:g} seconds; it was not force-killed" + ) + + +def _is_managed_launcher(pid: int, agent: str, adapter: str) -> bool: + argv = _process_argv(pid) + try: + adapter_index = argv.index(adapter) + agent_index = argv.index("--agent", adapter_index + 1) + except ValueError: + return False + if agent_index + 1 >= len(argv) or argv[agent_index + 1] != agent: + return False + prefix = argv[:adapter_index] + return any(Path(item).name == "agentpost" for item in prefix) + + +def codex_session_digest(session_id: str) -> str: + return hashlib.sha256(session_id.encode("utf-8")).hexdigest() + + +def find_managed_codex_thread( + office: PostOffice, + session_digest: str, + *, + exclude_agent: str | None = None, +) -> tuple[str, dict] | None: + """Find a verified live Codex owner for one durable thread digest.""" + if not office.agents_dir.is_dir(): + return None + for mailbox in sorted(office.agents_dir.iterdir(), key=lambda path: path.name): + if not mailbox.is_dir() or mailbox.name == exclude_agent: + continue + adapter_dir = mailbox / "adapter" + lock_path = adapter_dir / "consumer.lock" + if not consumer_lock_held(lock_path): + continue + try: + owner = json.loads( + (adapter_dir / "consumer.json").read_text(encoding="utf-8") + ) + except (OSError, json.JSONDecodeError): + continue + if not isinstance(owner, dict) or owner.get("adapter") != "codex": + continue + try: + owner_pid = int(owner["pid"]) + except (KeyError, TypeError, ValueError): + continue + if not _is_managed_launcher(owner_pid, mailbox.name, "codex"): + continue + digest = owner.get("session_digest") + if not digest: + digest = _managed_codex_digest_from_argv(owner_pid) + if digest == session_digest: + return mailbox.name, owner + return None + + +def _managed_codex_digest_from_argv(pid: int) -> str | None: + argv = _process_argv(pid) + try: + command_index = argv.index("codex") + except ValueError: + return None + tail = argv[command_index + 1 :] + try: + resume_index = next( + index for index, value in enumerate(tail) if value in {"fork", "resume"} + ) + except StopIteration: + return None + for value in tail[resume_index + 1 :]: + if not value.startswith("-"): + return codex_session_digest(value) + return None + + +def _process_argv(pid: int) -> list[str]: + try: + raw = Path(f"/proc/{pid}/cmdline").read_bytes() + except OSError: + return [] + return [ + item.decode("utf-8", errors="surrogateescape") + for item in raw.split(b"\0") + if item + ] + + def _is_process_ancestor(ancestor_pid: object, descendant_pid: int | None = None) -> bool: """Best-effort Linux ancestry check used only to improve lease diagnostics.""" try: diff --git a/src/agentpost/presence.py b/src/agentpost/presence.py index 446bdc2..0847f3a 100644 --- a/src/agentpost/presence.py +++ b/src/agentpost/presence.py @@ -6,6 +6,7 @@ from dataclasses import dataclass from .core import AgentPostError, PostOffice +from .ownership import consumer_lock_held, consumer_process_state HEARTBEAT_INTERVAL_SECONDS = 1.0 @@ -24,7 +25,7 @@ class Presence: @property def active(self) -> bool: - return self.state != "offline" + return self.state in {"idle", "working"} def agent_presence(office: PostOffice, agent: str) -> Presence: @@ -36,7 +37,8 @@ def agent_presence(office: PostOffice, agent: str) -> Presence: raise AgentPostError(f"unknown agent: {agent}") from exc adapter_dir = office.root / "agents" / agent / "adapter" - owner = _consumer_owner(adapter_dir) + lock_held = consumer_lock_held(adapter_dir / "consumer.lock") + owner = _consumer_owner(adapter_dir) if lock_held else {} probes = ( _claude_presence(adapter_dir), _codex_presence(adapter_dir), @@ -45,6 +47,8 @@ def agent_presence(office: PostOffice, agent: str) -> Presence: ) active = [item for item in probes if item.active and _matches_owner(item, owner)] if not active: + if lock_held: + return _blocked_consumer_presence(owner) connected = sorted( { binding.cli @@ -227,7 +231,37 @@ def _consumer_owner(adapter) -> dict: pid = int(value["pid"]) except (OSError, ValueError, TypeError, KeyError, json.JSONDecodeError): return {} - return value if _pid_alive(pid) else {} + return value + + +def _blocked_consumer_presence(owner: dict) -> Presence: + adapter = str(owner.get("adapter", "unknown")) + instance_id = owner.get("instance_id") + try: + pid = int(owner["pid"]) + except (KeyError, TypeError, ValueError): + pid = 0 + process_state = consumer_process_state(pid) + if process_state in {"T", "t"}: + state = "suspended" + reason = "is suspended and still holds the inbound lease" + elif process_state is not None and process_state not in {"Z", "X", "x"}: + state = "unresponsive" + reason = "holds the inbound lease but its heartbeat is stale" + else: + state = "incoherent" + reason = "holds the inbound lease but its owner process is unverifiable" + detail = f"{adapter} consumer pid {pid or '?'} {reason}" + if instance_id: + detail += f"; instance {instance_id}" + return Presence( + state, + detail, + healthy=False, + adapter=adapter, + instance_id=str(instance_id) if instance_id else None, + wake_capable=False, + ) def _matches_owner(presence: Presence, owner: dict) -> bool: diff --git a/src/agentpost/runtime.py b/src/agentpost/runtime.py index 10a9f76..584b575 100644 --- a/src/agentpost/runtime.py +++ b/src/agentpost/runtime.py @@ -33,6 +33,7 @@ class Notification: kind: str notify: str path: Path + origin: str = "live" class AgentRuntime: @@ -76,6 +77,7 @@ def __init__( self._callback_exhausted: frozenset[str] = frozenset() self._stop = threading.Event() self._wake = threading.Event() + self._startup_pending = True self._thread: threading.Thread | None = None adapter = self.office.root / "agents" / agent / "adapter" instance_id = uuid.uuid4().hex @@ -97,6 +99,7 @@ def start(self) -> AgentRuntime: return self self._stop.clear() self._wake.clear() + self._startup_pending = True if self._lease.acquire(): self._write_heartbeat() self._thread = threading.Thread( @@ -184,7 +187,7 @@ async def get_async(self, timeout: float | None = None) -> tuple[Notification, . def unread(self) -> tuple[Notification, ...]: """Return a side-effect-free snapshot for host reconciliation.""" return tuple( - _notification(record) + _notification(record, origin="snapshot") for record in self.office.list_messages(self.agent, "unread") ) @@ -216,14 +219,21 @@ def _run(self) -> None: last_heartbeat = now self._prune_callback_exhaustion() self._flush_callback() - self._surface(self._watcher.pending()) + origin = "startup" if self._startup_pending else "live" + self._surface(self._watcher.pending(), origin=origin) + self._startup_pending = False self._wake.wait(self.interval) self._wake.clear() finally: self._marker.unlink(missing_ok=True) self._lease.release() - def _surface(self, fresh: tuple[MessageRecord, ...]) -> None: + def _surface( + self, + fresh: tuple[MessageRecord, ...], + *, + origin: str = "live", + ) -> None: ready = [] state = self.state for record in fresh: @@ -236,7 +246,7 @@ def _surface(self, fresh: tuple[MessageRecord, ...]) -> None: self._deferred.clear() if not ready: return - batch = tuple(_notification(record) for record in ready) + batch = tuple(_notification(record, origin=origin) for record in ready) self._batches.put(batch) if self.on_mail is not None: self._callback_pending.append(batch) @@ -326,7 +336,7 @@ def _write_heartbeat(self) -> None: ) -def _notification(record: MessageRecord) -> Notification: +def _notification(record: MessageRecord, *, origin: str = "live") -> Notification: letter = record.letter return Notification( message_id=letter.message_id, @@ -334,6 +344,7 @@ def _notification(record: MessageRecord) -> Notification: kind=letter.kind, notify=letter.notify, path=record.path, + origin=origin, ) diff --git a/tests/test_adapters.py b/tests/test_adapters.py index 74c0d95..525f56a 100644 --- a/tests/test_adapters.py +++ b/tests/test_adapters.py @@ -8,10 +8,11 @@ import json import os import re +import signal import shlex import subprocess import tomllib -from contextlib import redirect_stdout +from contextlib import redirect_stderr, redirect_stdout from io import StringIO from unittest.mock import patch from pathlib import Path @@ -28,11 +29,13 @@ RecordingBell, ) from agentpost.native import ( # noqa: E402 + _ManagedTerminalSignal, _antigravity_instruction, antigravity_hook, antigravity_launch, _claude_boundary_state, _emit_claude, + _emit_claude_startup, _codex_bridge_marker, _codex_remote_command, claude_boundary, @@ -66,6 +69,11 @@ ) from agentpost.presence import agent_presence # noqa: E402 from agentpost.ownership import ConsumerLease # noqa: E402 +from agentpost.ownership import ( # noqa: E402 + codex_session_digest, + consumer_lock_held, + stop_managed_consumer, +) def profile(name: str) -> Profile: @@ -229,6 +237,119 @@ def test_consumer_lease_excludes_a_separate_process(self) -> None: owner.release() self.assertEqual(result.stdout.strip(), "blocked") + def test_suspended_consumer_is_not_reported_offline_or_parallel(self) -> None: + office = self.office() + source = Path(__file__).parents[1] / "src" + script = """ +from agentpost import PostOffice +from agentpost.ownership import ConsumerLease +import os +import sys +import time +lease = ConsumerLease(PostOffice(sys.argv[1]), "k", "codex", instance_id="stopped-instance") +lease.require() +print(os.getpid(), flush=True) +try: + while True: + time.sleep(1) +except KeyboardInterrupt: + pass +finally: + lease.release() +""" + process = subprocess.Popen( + [sys.executable, "-c", script, str(self.root)], + env={**os.environ, "PYTHONPATH": str(source)}, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + try: + pid = int(process.stdout.readline().strip()) + os.kill(pid, signal.SIGSTOP) + deadline = time.monotonic() + 2 + while time.monotonic() < deadline: + state = Path(f"/proc/{pid}/stat").read_text().split(") ", 1)[1][0] + if state == "T": + break + time.sleep(0.01) + presence = agent_presence(office, "k") + self.assertEqual(presence.state, "suspended") + self.assertIn(f"pid {pid}", presence.detail) + self.assertIn("stopped-instance", presence.detail) + contender = ConsumerLease(office, "k", "codex") + with self.assertRaisesRegex( + AgentPostError, + r"suspended inbound consumer.*consumer-stop k.*Do not create k2", + ): + contender.require() + finally: + if process.poll() is None: + os.kill(process.pid, signal.SIGINT) + os.kill(process.pid, signal.SIGCONT) + process.wait(timeout=3) + if process.stdout is not None: + process.stdout.close() + if process.stderr is not None: + process.stderr.close() + self.assertEqual(agent_presence(office, "k").state, "offline") + + def test_consumer_stop_releases_exact_managed_instance_without_touching_mail( + self, + ) -> None: + office = self.office() + sent = office.send("cx", "k", "preserve me") + source = Path(__file__).parents[1] / "src" + script = """ +from agentpost import PostOffice +from agentpost.ownership import ConsumerLease +import os +import sys +import time +lease = ConsumerLease(PostOffice(sys.argv[1]), "k", "codex", instance_id="exact-instance") +lease.require() +print(os.getpid(), flush=True) +try: + while True: + time.sleep(1) +except KeyboardInterrupt: + pass +finally: + lease.release() +""" + process = subprocess.Popen( + [sys.executable, "-c", script, str(self.root)], + env={**os.environ, "PYTHONPATH": str(source)}, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + try: + pid = int(process.stdout.readline().strip()) + with patch("agentpost.ownership._is_managed_launcher", return_value=True): + owner = stop_managed_consumer( + office, + "k", + "exact-instance", + timeout=3, + ) + self.assertEqual(owner["pid"], pid) + process.wait(timeout=3) + finally: + if process.poll() is None: + process.kill() + process.wait(timeout=3) + if process.stdout is not None: + process.stdout.close() + if process.stderr is not None: + process.stderr.close() + lock = self.root / "agents" / "k" / "adapter" / "consumer.lock" + self.assertFalse(consumer_lock_held(lock)) + self.assertEqual( + [record.letter.message_id for record in office.list_messages("k", "unread")], + [sent.message_id], + ) + def test_notification_failure_does_not_undo_delivery(self) -> None: class BrokenBell: def notify(self, agent, message_id, mode): @@ -355,6 +476,26 @@ def test_claude_pointer_read_is_retry_safe_before_explicit_claim(self) -> None: "unread message not found|already claimed", ) + def test_claude_startup_batches_mail_into_one_consent_gate(self) -> None: + office = self.office() + sent = ( + office.send("cx", "k", "first startup message"), + office.send("cx", "k", "second startup message", notify="immediate"), + ) + records = [office.read("k", item.message_id) for item in sent] + output = StringIO() + with redirect_stdout(output): + _emit_claude_startup("k", records) + notice = output.getvalue() + self.assertIn("AgentPost startup notice: 2 unread message(s)", notice) + self.assertIn("mailbox k", notice) + for item in sent: + self.assertIn(item.message_id, notice) + self.assertIn("ask whether to inspect", notice) + self.assertNotIn("agentpost read", notice) + self.assertNotIn("agentpost next", notice) + self.assertEqual(len(office.list_messages("k", "unread")), 2) + def test_idle_waits_for_completion_while_immediate_surfaces(self) -> None: bell = BoundaryBell() office = self.office(bell) @@ -454,7 +595,7 @@ def test_claude_doctor_requires_current_enabled_project_entry(self) -> None: plugin_list = [ { "id": "agentpost@agentpost-local", - "version": "0.0.7", + "version": "0.0.8", "enabled": True, "projectPath": str(Path(self.temp.name) / "other"), }, @@ -476,12 +617,12 @@ def test_claude_doctor_requires_current_enabled_project_entry(self) -> None: self.assertFalse(stale.ok) self.assertIn("stale version 0.0.4", stale.detail) - plugin_list[1]["version"] = "0.0.7" + plugin_list[1]["version"] = "0.0.8" completed.stdout = json.dumps(plugin_list) with patch("agentpost.installer.subprocess.run", return_value=completed): current = _doctor_claude(project)[0] self.assertTrue(current.ok) - self.assertEqual(_claude_plugin_version(), "0.0.7") + self.assertEqual(_claude_plugin_version(), "0.0.8") def test_codex_snapshot_is_machine_readable_and_non_claiming(self) -> None: office = self.office() @@ -760,17 +901,14 @@ def test_codex_user_prompt_hook_injects_without_claiming(self) -> None: hook_output = result["hookSpecificOutput"] self.assertEqual(hook_output["hookEventName"], "UserPromptSubmit") first_id, second_id = (item.message_id for item in sent) - self.assertEqual( - hook_output["additionalContext"], - f"AgentPost has 2 unread message(s) for cx: {first_id}, {second_id}. " - "Load the agentpost skill if available. Inspect exactly the listed " - f"Message-ID(s) with: `agentpost read cx '{first_id}'`; " - f"`agentpost read cx '{second_id}'`. Do not list, read, claim, or " - "process any other unread mail. Claim each only when starting its " - f"work with: `agentpost next cx --message-id '{first_id}'`; " - f"`agentpost next cx --message-id '{second_id}'`. Reply by Message-ID " - "when appropriate and give the user a short synopsis.", - ) + instruction = hook_output["additionalContext"] + self.assertIn("AgentPost startup notice: 2 unread message(s)", instruction) + self.assertIn("mailbox cx", instruction) + self.assertIn(first_id, instruction) + self.assertIn(second_id, instruction) + self.assertIn("ask whether to inspect", instruction) + self.assertNotIn("agentpost read", instruction) + self.assertNotIn("agentpost next", instruction) self.assertEqual(len(office.list_messages("cx", "unread")), 2) observed = json.loads( codex_hook_marker(office, "cx", "user-prompt-submit").read_text() @@ -778,6 +916,151 @@ def test_codex_user_prompt_hook_injects_without_claiming(self) -> None: self.assertEqual(observed["session_id"], "session-1") self.assertEqual(observed["event"], "user-prompt-submit") + output = StringIO() + event = StringIO(json.dumps({"cwd": str(project), "session_id": "session-1"})) + with patch.dict("os.environ", {"AGENTPOST_ROOT": str(self.root)}, clear=False): + with patch("sys.stdin", event), redirect_stdout(output): + self.assertEqual(codex_hook("user-prompt-submit", "generation-3"), 0) + self.assertEqual(output.getvalue().strip(), "{}") + + live = office.send("k", "cx", "arrived after startup") + output = StringIO() + event = StringIO(json.dumps({"cwd": str(project), "session_id": "session-1"})) + with patch.dict("os.environ", {"AGENTPOST_ROOT": str(self.root)}, clear=False): + with patch("sys.stdin", event), redirect_stdout(output): + self.assertEqual(codex_hook("user-prompt-submit", "generation-3"), 0) + live_instruction = json.loads(output.getvalue())["hookSpecificOutput"][ + "additionalContext" + ] + self.assertIn(f"agentpost read cx '{live.message_id}'", live_instruction) + self.assertNotIn(first_id, live_instruction) + + def test_codex_mail_after_an_empty_session_start_is_live(self) -> None: + office = self.office() + project = Path(self.temp.name) / "cx-empty-start-project" + project.mkdir() + office.register_profile( + Profile( + name="cx", + display_name="CX", + cli="codex", + kind="project", + summary="Agent cx", + projects=("cx",), + project_roots=(str(project),), + ) + ) + environment = {"AGENTPOST_ROOT": str(self.root)} + event = {"cwd": str(project), "session_id": "empty-start-session"} + + output = StringIO() + with patch.dict("os.environ", environment, clear=False): + with patch( + "sys.stdin", StringIO(json.dumps(event)) + ), redirect_stdout(output): + self.assertEqual(codex_hook("session-start", "generation-3"), 0) + self.assertEqual(output.getvalue().strip(), "{}") + + live = office.send("k", "cx", "arrived after empty startup") + output = StringIO() + with patch.dict("os.environ", environment, clear=False): + with patch( + "sys.stdin", StringIO(json.dumps(event)) + ), redirect_stdout(output): + self.assertEqual(codex_hook("user-prompt-submit", "generation-3"), 0) + instruction = json.loads(output.getvalue())["hookSpecificOutput"][ + "additionalContext" + ] + self.assertNotIn("AgentPost startup notice", instruction) + self.assertIn(f"agentpost read cx '{live.message_id}'", instruction) + + def test_codex_resume_rejects_same_thread_under_another_seat(self) -> None: + office = self.office() + project = Path(self.temp.name) / "shared-codex-project" + project.mkdir() + for name in ("c", "cr"): + office.register_profile( + Profile( + name=name, + display_name=name.upper(), + cli="codex", + kind="role", + summary=f"Role {name}", + projects=("shared",), + project_roots=(str(project),), + ) + ) + thread_id = "019fcbb6-a29f-7eb1-ab24-d2d53a05f061" + owner = ConsumerLease( + office, + "c", + "codex", + instance_id="owner-instance", + session_digest=codex_session_digest(thread_id), + ) + owner.require() + try: + with patch("agentpost.ownership._is_managed_launcher", return_value=True): + with patch("sys.stdin.isatty", return_value=True): + with self.assertRaisesRegex( + AgentPostError, + r"already managed under AgentPost seat c.*consumer-stop c", + ): + codex_launch( + office, + project, + ["resume", thread_id], + agent="cr", + ) + finally: + owner.release() + + def test_codex_launcher_converts_terminal_suspend_to_clean_shutdown(self) -> None: + office = self.office() + project = Path(self.temp.name) / "suspend-project" + project.mkdir() + + class FakeProcess: + returncode = None + + def poll(self): + return self.returncode + + def terminate(self): + self.returncode = 0 + + def wait(self, timeout=None): + return self.returncode + + server = FakeProcess() + bridge = FakeProcess() + errors = StringIO() + with patch("agentpost.native._free_loopback_port", return_value=4321), \ + patch("agentpost.native._wait_for_app_server"), \ + patch("agentpost.native.subprocess.Popen", side_effect=[server, bridge]), \ + patch( + "agentpost.native.subprocess.call", + side_effect=_ManagedTerminalSignal(signal.SIGTSTP), + ), \ + patch("sys.stdin.isatty", return_value=True), \ + redirect_stderr(errors): + self.assertEqual( + codex_launch( + office, + project, + ["resume", "thread-1"], + agent="cx", + ), + 148, + ) + self.assertIn("stopped cleanly rather than suspended", errors.getvalue()) + self.assertIn( + "agentpost codex --agent cx resume thread-1", + errors.getvalue(), + ) + self.assertFalse((self.root / "agents/cx/adapter/consumer.json").exists()) + self.assertFalse(_codex_bridge_marker(office, "cx").exists()) + def test_codex_hook_stamps_before_bridge_environment_suppression(self) -> None: office = self.office() project = Path(self.temp.name) / "cx-project" @@ -1576,7 +1859,11 @@ def test_antigravity_hooks_inject_each_unread_id_once_without_claiming(self) -> with patch("sys.stdin", StringIO(json.dumps(event))), redirect_stdout(output): self.assertEqual(antigravity_hook("pre-invocation"), 0) injected = json.loads(output.getvalue()) - self.assertIn(first.message_id, injected["injectSteps"][0]["ephemeralMessage"]) + startup_notice = injected["injectSteps"][0]["ephemeralMessage"] + self.assertIn(first.message_id, startup_notice) + self.assertIn("AgentPost startup notice", startup_notice) + self.assertIn("ask whether to inspect", startup_notice) + self.assertNotIn("agentpost read", startup_notice) self.assertEqual(len(office.list_messages("ag", "unread")), 1) self.assertEqual(agent_presence(office, "ag").state, "working") @@ -1605,10 +1892,100 @@ def test_antigravity_hooks_inject_each_unread_id_once_without_claiming(self) -> self.assertEqual(stopped["decision"], "continue") self.assertIn(second.message_id, stopped["reason"]) self.assertNotIn(first.message_id, stopped["reason"]) + self.assertIn("agentpost read", stopped["reason"]) self.assertEqual(len(office.list_messages("ag", "unread")), 2) self.assertEqual(agent_presence(office, "ag").state, "idle") self.assertFalse(armed(office, "ag")[0]) + def test_antigravity_new_runtime_epoch_gates_same_unread_set_again(self) -> None: + office = self.office() + project = Path(self.temp.name) / "antigravity-reload-project" + project.mkdir() + office.register_profile( + Profile( + name="ag", + display_name="Antigravity", + cli="antigravity", + kind="project", + summary="Antigravity reload test", + projects=("antigravity-reload",), + project_roots=(str(project),), + ) + ) + sent = office.send("cx", "ag", "still unread after reload") + event = { + "conversationId": "same-conversation", + "workspacePaths": [str(project)], + } + notices = [] + with patch.dict("os.environ", {"AGENTPOST_ROOT": str(self.root)}, clear=False): + with patch( + "agentpost.native._host_runtime_epoch", + side_effect=("runtime-one", "runtime-two"), + ): + for _ in range(2): + output = StringIO() + with patch("sys.stdin", StringIO(json.dumps(event))), redirect_stdout(output): + self.assertEqual(antigravity_hook("pre-invocation"), 0) + notices.append( + json.loads(output.getvalue())["injectSteps"][0][ + "ephemeralMessage" + ] + ) + for notice in notices: + self.assertIn("AgentPost startup notice", notice) + self.assertIn(sent.message_id, notice) + self.assertNotIn("agentpost read", notice) + self.assertEqual(len(office.list_messages("ag", "unread")), 1) + + def test_antigravity_mail_after_an_empty_start_is_live(self) -> None: + office = self.office() + project = Path(self.temp.name) / "antigravity-empty-start-project" + project.mkdir() + office.register_profile( + Profile( + name="ag", + display_name="Antigravity", + cli="antigravity", + kind="project", + summary="Antigravity empty startup test", + projects=("antigravity-empty-start",), + project_roots=(str(project),), + ) + ) + event = { + "conversationId": "empty-start-conversation", + "workspacePaths": [str(project)], + } + environment = {"AGENTPOST_ROOT": str(self.root)} + + output = StringIO() + with patch.dict("os.environ", environment, clear=False): + with patch( + "agentpost.native._host_runtime_epoch", return_value="runtime-one" + ): + with patch( + "sys.stdin", StringIO(json.dumps(event)) + ), redirect_stdout(output): + self.assertEqual(antigravity_hook("pre-invocation"), 0) + self.assertEqual(json.loads(output.getvalue()), {"injectSteps": []}) + + live = office.send("cx", "ag", "arrived after empty startup") + output = StringIO() + with patch.dict("os.environ", environment, clear=False): + with patch( + "agentpost.native._host_runtime_epoch", return_value="runtime-one" + ): + with patch( + "sys.stdin", StringIO(json.dumps(event)) + ), redirect_stdout(output): + self.assertEqual(antigravity_hook("pre-invocation"), 0) + instruction = json.loads(output.getvalue())["injectSteps"][0][ + "ephemeralMessage" + ] + self.assertNotIn("AgentPost startup notice", instruction) + self.assertIn(f"agentpost read ag '{live.message_id}'", instruction) + def test_antigravity_pointer_is_exact_and_commands_are_executable(self) -> None: office = self.office() sent = ( diff --git a/tests/test_cli.py b/tests/test_cli.py index 587b19d..123f176 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -9,7 +9,7 @@ from dataclasses import replace from io import StringIO from pathlib import Path -from unittest.mock import patch +from unittest.mock import ANY, patch sys.path.insert(0, str(Path(__file__).parents[1] / "src")) @@ -993,6 +993,32 @@ def test_watch_help_states_it_does_not_connect_the_mailbox(self) -> None: ): self.assertIn(phrase, help_text) + def test_consumer_stop_requires_and_reports_one_exact_instance(self) -> None: + owner = { + "adapter": "codex", + "pid": 1234, + "instance_id": "exact-instance", + } + output = StringIO() + with patch("agentpost.cli.stop_managed_consumer", return_value=owner) as stop: + with redirect_stdout(output): + result = main( + [ + "--root", + str(self.root), + "consumer-stop", + "app", + "--instance", + "exact-instance", + ] + ) + self.assertEqual(result, 0) + stop.assert_called_once_with(ANY, "app", "exact-instance") + self.assertEqual( + output.getvalue().strip(), + "STOPPED\tapp\tcodex\tpid=1234\tinstance=exact-instance", + ) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_codex_session.py b/tests/test_codex_session.py index ec7b0d7..5ab6f9f 100644 --- a/tests/test_codex_session.py +++ b/tests/test_codex_session.py @@ -213,7 +213,11 @@ def test_hook_uses_attachment_at_the_next_boundary_without_claiming(self) -> Non instruction = json.loads(output.getvalue())["hookSpecificOutput"][ "additionalContext" ] - self.assertIn(f"for pbeocx: {sent.message_id}", instruction) + self.assertIn("AgentPost startup notice", instruction) + self.assertIn("mailbox pbeocx", instruction) + self.assertIn(sent.message_id, instruction) + self.assertIn("ask whether to inspect", instruction) + self.assertNotIn("agentpost read", instruction) self.assertEqual(len(self.office.list_messages("pbeocx", "unread")), 1) observed = json.loads( codex_hook_marker( @@ -534,7 +538,7 @@ def test_doctor_reports_exact_attachment_separately_from_stale_aggregate( 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(f"observed {CODEX_HOOK_GENERATION}", attached.detail) self.assertIn("reported separately", attached.detail) aggregate = checks["codex-generation"] self.assertFalse(aggregate.ok) diff --git a/tests/test_examples.py b/tests/test_examples.py index d911186..8c4583f 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -345,29 +345,13 @@ def send_json(value: dict) -> None: self.assertIsNotNone(turn) instruction = turn["params"]["input"][0]["text"] for message_id in message_ids: - self.assertEqual(instruction.count(message_id), 3) - self.assertIn( - f"`agentpost read cr '{message_id}'`", - instruction, - ) - self.assertIn( - f"`agentpost next cr --message-id '{message_id}'`", - instruction, - ) - read_positions = [ - instruction.index(f"`agentpost read cr '{message_id}'`") - for message_id in message_ids - ] - claim_positions = [ - instruction.index( - f"`agentpost next cr --message-id '{message_id}'`" - ) - for message_id in message_ids - ] - self.assertEqual(read_positions, sorted(read_positions)) - self.assertEqual(claim_positions, sorted(claim_positions)) + self.assertEqual(instruction.count(message_id), 1) + self.assertIn("AgentPost startup notice", instruction) + self.assertIn("ask whether to inspect", instruction) + self.assertNotIn("agentpost read", instruction) + self.assertNotIn("agentpost next", instruction) self.assertNotIn("agentpost list", instruction) - self.assertIn("messages may be intentionally deferred", instruction) + self.assertIn("leave every message untouched", instruction) deadline = time.monotonic() + 5 while startup_attention.path.exists() and time.monotonic() < deadline: time.sleep(0.05) @@ -699,7 +683,10 @@ def test_shared_skill_fails_closed_on_reconnect_readiness(self) -> None: self.assertIn("agentpost armed NAME", skill) self.assertIn("prove durable access only", skill) self.assertIn("while `armed` reports `QUEUED`", skill) - self.assertIn("offer the user the first unused numbered mailbox", skill) + self.assertRegex(skill, r"first\s+unused\s+numbered\s+mailbox") + self.assertIn("## Mail workflow", skill) + self.assertIn("### Startup consent gate", skill) + self.assertIn("attention gate, not authorization", skill) self.assertIn("numbered identity is a separate durable mailbox", skill) self.assertIn("move mail already addressed to `NAME`", skill) self.assertIn("agentpost attach NAME", skill) diff --git a/tests/test_runtime.py b/tests/test_runtime.py index 4bc5ff3..b00f5c7 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -81,6 +81,7 @@ def test_runtime_start_catches_up_and_never_claims(self) -> None: with AgentRuntime("app", root=self.root, interval=0.01) as runtime: batch = runtime.get(timeout=1) self.assertEqual(batch[0].message_id, sent.message_id) + self.assertEqual(batch[0].origin, "startup") self.assertEqual(len(self.office.list_messages("app", "unread")), 1) def test_callback_failure_retries_in_order_without_duplicating_queue(self) -> None: