Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions docs/vrs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -664,6 +664,26 @@ atomic inbox file → DING attempt → agent reads → archive receipt
composer cannot create a short-lived PTY probe on every inbox poll. Inbox
reads do not wake the sidecar; only mutations bypass its bounded poll cadence.

### Bounded maintained-provider inbox view

Maintained provider boundaries render an ordered FIFO prefix with complete
message bodies before the model decides how to handle it. The provider-neutral
`st2.inbox-delivery.v1` JSON envelope is capped at 16 messages and 16 KiB.
Bodies are never truncated. An oversized head is identified without exposing a
partial body, and overflow stays unread behind the head for a later delivery.

Codex native app-server delivery sends this envelope in its existing typed user
message. Claude SessionStart and UserPromptSubmit hooks attach the same envelope
on the provider-supported additional-context channel. Unknown or custom
transports keep the short metadata DING and may obtain the view read-only with
`st2 message delivery`.

Delivery does not claim, settle, archive, or otherwise mutate a message. The
agent uses the existing exact `message reply` and `message archive` operations,
whose archive receipts remain the only durable handled authority. This contract
is provider-neutral so the driver extraction tracked by issue #162 can move the
thin Codex and Claude consumers without changing its schema, bounds, or tests.

## State and scope

- **R08:** Presence and activity status are separate signals. The catalog must
Expand Down
3 changes: 2 additions & 1 deletion examples/native/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
These maintained, hand-authored declarations are the canonical starting points:

- [`agent-claude.kdl`](agent-claude.kdl) uses Claude Code's rules loader and
native `SessionStart`, `PreCompact`, and `StopFailure` hooks.
native `SessionStart`, `UserPromptSubmit`, `PreCompact`, and `StopFailure` hooks. The prompt hook
attaches the bounded inbox view to the same inference as a generic DING.
- [`agent-codex.kdl`](agent-codex.kdl) composes the persona and bus contract
into `AGENTS.md` and uses Codex's native `SessionStart`, `PreCompact`, and
`Stop` hooks.
Expand Down
10 changes: 10 additions & 0 deletions examples/native/agent-claude.kdl
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,16 @@ agent "<identity>" {
]
}
],
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "$ST_HOOKS/claude-user-prompt-submit.sh"
}
]
}
],
"PreCompact": [
{
"hooks": [
Expand Down
14 changes: 9 additions & 5 deletions hooks/claude-session-start.sh
Original file line number Diff line number Diff line change
@@ -1,23 +1,24 @@
#!/usr/bin/env bash
# st2 Claude SessionStart hook: restore fresh durable context and remind the model to complete its
# boot ritual. Delivered as `hookSpecificOutput.additionalContext` on stdout, the supported channel —
# exit 2 + stderr renders as a hook error and never reaches the model. Missing state remains a valid
# cold start. Fail-open: a missing dependency never prevents Claude startup.
# st2 Claude SessionStart hook: restore fresh durable context and attach a bounded inbox view.
# Delivered as `hookSpecificOutput.additionalContext` on stdout, the supported model-visible channel.
# Missing state remains a valid cold start; missing dependencies fail open.

set -u

identity="${ST_AGENT:-}"
root="${ST_ROOT:-${CATALOG:-}}"
ritual="Run the st2 boot ritual now: set your status to available, then drain your inbox by reading, acting on, replying when useful, and archiving each handled message. Before resuming or starting work, set your status to busy; set available only when yielding or ready for new work."
ritual="Run the st2 boot ritual now: set your status to available, then handle the body-bearing inbox batch already attached when present; otherwise drain once with st2 message ls --json --include-body. Reply when useful and archive each handled message. Before resuming or starting work, set your status to busy; set available only when yielding or ready for new work."

if ! command -v jq >/dev/null 2>&1; then
exit 0
fi

context=""
delivery=""
if [[ -n "$identity" && -n "$root" ]] && command -v st2 >/dev/null 2>&1; then
stale_s="${ST_REHYDRATE_STALE_S:-86400}"
context="$(st2 context read "$identity" --root "$root" --fresh-within "$stale_s" 2>/dev/null || true)"
delivery="$(st2 message delivery "$identity" --root "$root" 2>/dev/null || true)"
fi

context_block=""
Expand All @@ -34,6 +35,9 @@ additional="$ritual"
if [[ -n "$context_block" ]]; then
additional="${context_block}"$'\n\n'"${additional}"
fi
if [[ -n "$(printf '%s' "$delivery" | tr -d '[:space:]')" ]]; then
additional="${additional}"$'\n\n'"${delivery}"
fi

printf '%s' "$additional" | jq -Rs '{
continue: true,
Expand Down
25 changes: 25 additions & 0 deletions hooks/claude-user-prompt-submit.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# Attach the bounded inbox view to the same Claude inference that received a short generic DING.
# This maintained-provider adapter is stateless and fail-open; reply/archive remain authoritative.

set -u

identity="${ST_AGENT:-}"
root="${ST_ROOT:-${CATALOG:-}}"
if [[ -z "$identity" || -z "$root" ]] || ! command -v st2 >/dev/null 2>&1 || ! command -v jq >/dev/null 2>&1; then
exit 0
fi

delivery="$(st2 message delivery "$identity" --root "$root" 2>/dev/null || true)"
if [[ -z "$(printf '%s' "$delivery" | tr -d '[:space:]')" ]]; then
exit 0
fi

printf '%s' "$delivery" | jq -Rs '{
continue: true,
hookSpecificOutput: {
hookEventName: "UserPromptSubmit",
additionalContext: .
}
}'
exit 0
128 changes: 104 additions & 24 deletions src/codex_app_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ use serde_json::{Value, json};
use sha2::{Digest as _, Sha256};
use tungstenite::{Message as WebSocketMessage, WebSocket};

use crate::{ding, message, run, status};
use crate::{inbox_delivery, message, run, status};

pub const SUPPORTED_CODEX_CLI_VERSION: &str = "codex-cli 0.145.0";
const RUNTIME_SCHEMA: &str = "st2.codex-runtime.v1";
Expand Down Expand Up @@ -151,7 +151,6 @@ struct CodexDeliveryConfig {
agent_dir: PathBuf,
inbox: PathBuf,
identity: String,
this_host: String,
}

impl CodexDeliveryConfig {
Expand All @@ -169,7 +168,6 @@ impl CodexDeliveryConfig {
inbox: message::inbox_dir(&agent_dir),
agent_dir,
identity: identity.to_string(),
this_host,
})
}
}
Expand Down Expand Up @@ -246,7 +244,7 @@ struct CodexInboxDelivery {
wake: Receiver<()>,
_watcher: Option<notify::RecommendedWatcher>,
next_refresh: Instant,
head: Option<message::Message>,
unread: Vec<message::Message>,
suppressed: bool,
state: Option<CodexDeliveryState>,
pending: Option<PendingCodexDelivery>,
Expand Down Expand Up @@ -276,7 +274,7 @@ impl CodexInboxDelivery {
wake,
_watcher: watcher,
next_refresh: Instant::now(),
head: None,
unread: Vec::new(),
suppressed: false,
state,
pending: None,
Expand Down Expand Up @@ -320,7 +318,7 @@ impl CodexInboxDelivery {
}) {
self.rejected = None;
}
self.head = unread.into_iter().next();
self.unread = unread;
self.suppressed =
status::read_state(&status::status_path(&self.config.agent_dir)) == status::State::Dnd;
self.next_refresh = Instant::now() + INBOX_REFRESH_FALLBACK;
Expand All @@ -340,7 +338,7 @@ impl CodexInboxDelivery {
// must neither suppress nor acknowledge delivery to this thread.
self.clear_state()?;
}
let Some(head) = self.head.as_ref() else {
let Some(head) = self.unread.first() else {
return Ok(None);
};
if self.rejected.as_ref().is_some_and(|rejected| {
Expand All @@ -365,12 +363,11 @@ impl CodexInboxDelivery {
let client_id =
stable_client_user_message_id(&self.config.identity, state.thread_id(), &head.filename);
let filename = head.filename.clone();
let text = ding::poke_text(
&self.config.catalog_root,
&self.config.this_host,
&self.config.identity,
head,
);
// Temporary adapter seam: issue #162 can transplant this provider consumer while retaining
// the provider-neutral bounded payload contract in `inbox_delivery`.
let text = inbox_delivery::render(&self.unread)
.context("rendering maintained-provider inbox delivery")?
.text;
let request =
codex_delivery_request(request_id, state.thread_id(), &client_id, &text, &method);
self.write_state(CodexDeliveryState::attempted(
Expand Down Expand Up @@ -1738,7 +1735,6 @@ mod tests {
inbox: message::inbox_dir(&agent_dir),
agent_dir,
identity: "h.worker".into(),
this_host: "h".into(),
}
}

Expand Down Expand Up @@ -1926,6 +1922,95 @@ mod tests {
assert!(config.inbox.join(&filename).is_file());
}

#[test]
fn archiving_a_delivered_batch_releases_overflow_and_post_batch_arrivals() {
let tmp = tempfile::tempdir().unwrap();
let config = delivery_config(tmp.path());
for index in 0..=inbox_delivery::MAX_DELIVERY_MESSAGES {
message::send_to_inbox(
&config.inbox,
"h.sender",
Some(&format!("burst {index}")),
None,
&[],
&format!("body {index}"),
)
.unwrap();
}
let ordered = message::list_inbox(&config.inbox).unwrap();
let first_batch = ordered[..inbox_delivery::MAX_DELIVERY_MESSAGES]
.iter()
.map(|message| message.filename.clone())
.collect::<Vec<_>>();
let overflow = ordered[inbox_delivery::MAX_DELIVERY_MESSAGES]
.filename
.clone();

let mut delivery = inbox_delivery(tmp.path(), config.clone());
let mut idle = CodexControlState::new(&delivery.runtime, "thread-main".into());
idle.subscribed = true;
idle.observed = CodexObservedState::Idle;
let request = delivery.maybe_request(&idle).unwrap().unwrap();
let text = request["params"]["input"][0]["text"].as_str().unwrap();
assert!(text.contains(&first_batch[0]));
assert!(text.contains(first_batch.last().unwrap()));
assert!(!text.contains(&overflow));

assert!(
delivery
.accept_response(
&json!({ "id": request["id"], "result": { "turn": { "id": "turn-batch" } } }),
idle.observed(),
)
.unwrap()
);
let client_id = request["params"]["clientUserMessageId"].as_str().unwrap();
assert!(
delivery
.accept_typed_receipt(
&json!({
"method": "item/completed",
"params": {
"threadId": "thread-main",
"turnId": "turn-batch",
"item": { "type": "userMessage", "clientId": client_id }
}
}),
&idle,
)
.unwrap()
);

for filename in &first_batch {
message::archive_msg(
&config.inbox,
&message::archive_dir(&config.agent_dir),
filename,
)
.unwrap();
}
let post_batch = message::send_to_inbox(
&config.inbox,
"h.sender",
Some("post batch"),
None,
&[],
"post batch body",
)
.unwrap();

delivery.next_refresh = Instant::now();
let next = delivery.maybe_request(&idle).unwrap().unwrap();
let next_text = next["params"]["input"][0]["text"].as_str().unwrap();
assert!(next_text.contains(&overflow));
assert!(next_text.contains(&post_batch));
assert!(
first_batch
.iter()
.all(|filename| !next_text.contains(filename))
);
}

#[test]
fn only_a_completed_matching_user_message_persists_acceptance() {
let tmp = tempfile::tempdir().unwrap();
Expand Down Expand Up @@ -2174,16 +2259,11 @@ mod tests {
assert_eq!(delivery["id"], FIRST_DELIVERY_REQUEST_ID);
assert_eq!(delivery["method"], "turn/start");
assert_eq!(delivery["params"]["threadId"], "thread-main");
assert_eq!(
delivery["params"]["input"][0]["text"],
"[DING] ? h.sender: wired [id:".to_owned()
+ server_filename
.trim_end_matches(".md")
.rsplit_once('-')
.unwrap()
.1
+ "]"
);
let text = delivery["params"]["input"][0]["text"].as_str().unwrap();
assert!(text.contains("st2.inbox-delivery.v1"));
assert!(text.contains(&server_filename));
let payload: Value = serde_json::from_str(text.lines().nth(1).unwrap()).unwrap();
assert_eq!(payload["messages"][0]["body"], "body\n");
assert!(
delivery["params"]["clientUserMessageId"]
.as_str()
Expand Down
4 changes: 3 additions & 1 deletion src/hooks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,20 @@ const CODEX_SESSION_START: &[u8] = include_bytes!("../hooks/codex-session-start.
const CODEX_PRE_COMPACT: &[u8] = include_bytes!("../hooks/codex-pre-compact.sh");
const CODEX_STOP: &[u8] = include_bytes!("../hooks/codex-stop.sh");
const CLAUDE_SESSION_START: &[u8] = include_bytes!("../hooks/claude-session-start.sh");
const CLAUDE_USER_PROMPT_SUBMIT: &[u8] = include_bytes!("../hooks/claude-user-prompt-submit.sh");
const CLAUDE_PRE_COMPACT: &[u8] = include_bytes!("../hooks/claude-pre-compact.sh");
const CLAUDE_STOP_FAILURE: &[u8] = include_bytes!("../hooks/claude-stop-failure.sh");

const SCHEMA: u32 = 1;
const RECEIPT_FILE: &str = "current.json";
const SET_MANIFEST_FILE: &str = "manifest.json";
const SETS_DIR: &str = "sets";
const HOOKS: [(&str, &[u8]); 6] = [
const HOOKS: [(&str, &[u8]); 7] = [
("codex-session-start.sh", CODEX_SESSION_START),
("codex-pre-compact.sh", CODEX_PRE_COMPACT),
("codex-stop.sh", CODEX_STOP),
("claude-session-start.sh", CLAUDE_SESSION_START),
("claude-user-prompt-submit.sh", CLAUDE_USER_PROMPT_SUBMIT),
("claude-pre-compact.sh", CLAUDE_PRE_COMPACT),
("claude-stop-failure.sh", CLAUDE_STOP_FAILURE),
];
Expand Down
Loading
Loading