From 49dabb11ea0be7e93794b25860bb86ea29d7ae38 Mon Sep 17 00:00:00 2001 From: ikma Date: Mon, 13 Jul 2026 10:59:44 -0400 Subject: [PATCH] agent: route interactive wakes through sessions --- .mise/tasks/agent/_default | 80 ++++++++++----------- README.md | 18 ++--- README.tsx | 7 +- test/agent/agent.bats | 138 ++++++++++++++++++++----------------- test/agent/helpers.bash | 39 +---------- 5 files changed, 132 insertions(+), 150 deletions(-) diff --git a/.mise/tasks/agent/_default b/.mise/tasks/agent/_default index fa71d53..8fa7850 100755 --- a/.mise/tasks/agent/_default +++ b/.mise/tasks/agent/_default @@ -2,9 +2,9 @@ #MISE description="Start an agent session (interactive or headless)" #MISE quiet=true #USAGE flag "--headless" default=#false help="Non-interactive mode: process message and exit" -#USAGE flag "--session " default="" help="Session file path to resume" +#USAGE flag "--session " default="" help="Session ID or name to resume" #USAGE flag "--timeout " default="" help="Timeout in seconds (headless only, stored as session metadata — not yet enforced by sessions wake)" -#USAGE flag "--model " default="" help="Model to use (required for --headless; provider-qualified, e.g. openai-codex/gpt-5.5)" +#USAGE flag "--model " default="" help="Model to use (required; provider-qualified, e.g. openai-codex/gpt-5.5)" #USAGE arg "[message]" default="" help="Initial message (required for --headless, optional for interactive)" set -e @@ -31,59 +31,55 @@ if [ "$HEADLESS" = "true" ] && [ -z "$MESSAGE" ]; then exit 1 fi -if [ "$HEADLESS" = "true" ] && [ -z "$MODEL" ]; then - echo "Error: --headless requires --model " >&2 - echo "Usage: shimmer agent --headless --model \"do something\"" >&2 +if [ -z "$MODEL" ]; then + if [ "$HEADLESS" = "true" ]; then + echo "Error: --headless requires --model " >&2 + echo "Usage: shimmer agent --headless --model \"do something\"" >&2 + else + echo "Error: --model is required" >&2 + echo "Usage: shimmer agent --model [\"message\"]" >&2 + fi exit 1 fi -if [ "$HEADLESS" = "true" ] && [[ "$MODEL" != */* ]]; then +if [[ "$MODEL" != */* ]]; then echo "Error: --model must be provider-qualified (for example: openai-codex/gpt-5.5)" >&2 exit 1 fi +CWD="${SHIMMER_CALLER_PWD:-${CALLER_PWD:-.}}" if [ "$HEADLESS" = "true" ]; then - # Headless mode creates a tracked session and launches it in the background - # via sessions new + sessions wake. The agent runs inside zmx (backgrounded), - # and can be monitored with `sessions read`. - CWD="${SHIMMER_CALLER_PWD:-${CALLER_PWD:-.}}" SESSION_NAME="${GIT_AUTHOR_NAME}-headless-$(date +%s)" +else + SESSION_NAME="${GIT_AUTHOR_NAME}-interactive-$(date +%s)" +fi - # Prepare the long-lived agent runtime without discarding selected-agent - # environment such as GIT_AUTHOR_NAME. - shimmer_prepare_agent_child_env +# Prepare the long-lived agent runtime without discarding selected-agent +# environment such as GIT_AUTHOR_NAME. +shimmer_prepare_agent_child_env - if ! command -v sessions &>/dev/null; then - echo "sessions not found on PATH. Install it: shiv install sessions" >&2 - exit 1 - fi +if ! command -v sessions &>/dev/null; then + echo "sessions not found on PATH. Install it: shiv install sessions" >&2 + exit 1 +fi - # Create a tracked session (or resume an existing one) - if [ -n "$SESSION" ]; then - # Resuming an existing session file - SESSION_ID="$SESSION" - else - NEW_ARGS=("$SESSION_NAME" --cwd "$CWD" --meta "agent.name=$GIT_AUTHOR_NAME") - SESSION_ID=$(sessions new "${NEW_ARGS[@]}") - fi +# Create a tracked session (or resume an existing one). +if [ -n "$SESSION" ]; then + SESSION_ID="$SESSION" +else + NEW_ARGS=("$SESSION_NAME" --cwd "$CWD" --meta "agent.name=$GIT_AUTHOR_NAME") + SESSION_ID=$(sessions new "${NEW_ARGS[@]}") +fi - # Wake the session — foreground + headless (blocks until done) - WAKE_ARGS=("$SESSION_ID" --headless --message "$MESSAGE" --model "$MODEL") +# Keep foreground interactive wakes attached to the caller's TTY. Headless +# wakes also remain foreground so the command blocks until the agent exits. +WAKE_ARGS=("$SESSION_ID") +if [ "$HEADLESS" = "true" ]; then + WAKE_ARGS+=(--headless --message "$MESSAGE" --model "$MODEL") [ -n "$TIMEOUT" ] && WAKE_ARGS+=(--meta "timeout=$TIMEOUT") - sessions wake "${WAKE_ARGS[@]}" else - # Interactive mode calls the agent harness directly. - CWD="${SHIMMER_CALLER_PWD:-${CALLER_PWD:-.}}" - cd "$CWD" - shimmer_prepare_agent_child_env - HARNESS="${AGENT_HARNESS:-pi}" - if ! command -v "$HARNESS" &>/dev/null; then - echo "$HARNESS not found on PATH." >&2 - exit 1 - fi - - ARGS=() - [ -n "$SESSION" ] && ARGS+=(--session "$SESSION") - [ -n "$MESSAGE" ] && ARGS+=("$MESSAGE") - exec "$HARNESS" "${ARGS[@]}" + WAKE_ARGS+=(--model "$MODEL") + [ -n "$MESSAGE" ] && WAKE_ARGS+=(--message "$MESSAGE") fi + +exec sessions wake "${WAKE_ARGS[@]}" diff --git a/README.md b/README.md index de6e3c6..2c5dbf5 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Identity, dispatch, generated CI, and session plumbing for agent homes.

إلى ريموند — العمل شرف

![tasks: 86](https://img.shields.io/badge/tasks-86-4EAA25?style=flat&logo=gnubash&logoColor=white) -[![tests: 180](https://img.shields.io/badge/tests-180-brightgreen?style=flat)](test/) +[![tests: 181](https://img.shields.io/badge/tests-181-brightgreen?style=flat)](test/) ![lints: 9](https://img.shields.io/badge/lints-9-blue?style=flat) ![workflow templates: 3](https://img.shields.io/badge/workflow%20templates-3-8b5cf6?style=flat) [![sessions: 659](https://img.shields.io/badge/sessions-659-64748b?style=flat)](https://github.com/KnickKnackLabs/shimmer/issues/794) @@ -81,6 +81,8 @@ shimmer whoami shimmer agent --model openai-codex/gpt-5.5 "Inspect the failing workflow." ``` +Interactive and headless wakes require a provider-qualified model. Interactive messages are optional; pass `--session` with a session ID or name to resume an existing conversation. + ### Hosted dispatch Dispatch through the repo that owns the target agent workflow, and put the actual target PR or issue in the packet. Use a message file for anything longer than a scalar. @@ -109,13 +111,13 @@ git diff -- .github/workflows/ ## What shimmer owns -| Surface | Contract | -| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | -| `shimmer as ` | Exports local identity, token, home path, B2 settings, and command-scope git signing config. | -| `shimmer agent` | Starts interactive or headless sessions while scrubbing task-scoped mise/caller environment before handing control to pi/sessions. | -| `agent:dispatch` | Finds the right home repo, validates provider-qualified models, preserves file-backed messages, and returns the workflow run id. | -| `workflows:generate` | Turns agent rosters and workflows.yaml manifests into reusable runner workflows, per-agent entrypoints, schedules, and mention wakes. | -| `sessions:backup` | Exports local session bundles and uploads snapshots/latest pointers when blob credentials are configured. | +| Surface | Contract | +| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | +| `shimmer as ` | Exports local identity, token, home path, B2 settings, and command-scope git signing config. | +| `shimmer agent` | Creates or resumes interactive and headless sessions, scrubs task-scoped mise/caller environment, and wakes through the sessions-owned Pi runtime. | +| `agent:dispatch` | Finds the right home repo, validates provider-qualified models, preserves file-backed messages, and returns the workflow run id. | +| `workflows:generate` | Turns agent rosters and workflows.yaml manifests into reusable runner workflows, per-agent entrypoints, schedules, and mention wakes. | +| `sessions:backup` | Exports local session bundles and uploads snapshots/latest pointers when blob credentials are configured. | ## Task map diff --git a/README.tsx b/README.tsx index a9eecbd..d4bc490 100644 --- a/README.tsx +++ b/README.tsx @@ -305,6 +305,11 @@ shimmer whoami`} {" when a local shell needs the same identity and signing posture as a hosted agent run."} {localIdentityFlow} + + {"Interactive and headless wakes require a provider-qualified model. Interactive messages are optional; pass "} + --session + {" with a session ID or name to resume an existing conversation."} + Hosted dispatch @@ -331,7 +336,7 @@ shimmer whoami`} shimmer agent - {"Starts interactive or headless sessions while scrubbing task-scoped mise/caller environment before handing control to pi/sessions."} + {"Creates or resumes interactive and headless sessions, scrubs task-scoped mise/caller environment, and wakes through the sessions-owned Pi runtime."} agent:dispatch diff --git a/test/agent/agent.bats b/test/agent/agent.bats index 8e69c2b..3ce5f61 100644 --- a/test/agent/agent.bats +++ b/test/agent/agent.bats @@ -351,114 +351,128 @@ MOCK # --- Interactive mode --- -@test "interactive: calls harness without prompt injection" { +@test "interactive: requires model" { setup_agent - mock_harness mock_shimmer run shimmer agent - [ "$status" -eq 0 ] - - ! grep -q -- "--append-system-prompt" "$HARNESS_LOG" + [ "$status" -ne 0 ] + [[ "$output" == *"--model"* ]] } -@test "interactive: ignores inherited usage env from parent task" { +@test "interactive: requires provider-qualified model" { setup_agent - export usage_headless="true" - export usage_model="openai-codex/gpt-5.5" - export usage_message="stale parent message" - mock_harness mock_shimmer - run shimmer agent - [ "$status" -eq 0 ] - - ! grep -q -- "--append-system-prompt" "$HARNESS_LOG" - ! grep -q "stale parent message" "$HARNESS_LOG" + run shimmer agent --model "gpt-5.5" + [ "$status" -ne 0 ] + [[ "$output" == *"provider-qualified"* ]] } -@test "interactive: uses SHIMMER_CALLER_PWD as harness cwd before scrubbing" { +@test "interactive: creates and wakes a sessions-owned runtime without a message" { setup_agent - local caller_dir="$BATS_TEST_TMPDIR/shimmer-caller" - mkdir -p "$caller_dir" - unset CALLER_PWD - export SHIMMER_CALLER_PWD="$caller_dir" - mock_harness + export AGENT_HARNESS="/usr/bin/false" + mock_sessions_binary mock_shimmer - run shimmer agent + run shimmer agent --model "openai-codex/gpt-5.5" [ "$status" -eq 0 ] - grep -q "^PWD=$caller_dir$" "$HARNESS_ENV_LOG" + grep -q "^new test-agent-interactive-" "$SESSIONS_LOG" + grep "^new " "$SESSIONS_LOG" | grep -q "agent.name=test-agent" + grep -q "^wake mock-session-id-001 --model openai-codex/gpt-5.5$" "$SESSIONS_LOG" +} + +@test "interactive: fails clearly when sessions is unavailable after runtime PATH cleanup" { + local home="$BATS_TEST_TMPDIR/path-boundary-home" + local direct_sessions="$home/.local/share/mise/installs/shiv-sessions/0.4.1/bin" + mkdir -p "$direct_sessions" + cat > "$direct_sessions/sessions" <<'MOCK' +#!/usr/bin/env bash +echo "stale direct sessions should not run" >&2 +exit 99 +MOCK + chmod +x "$direct_sessions/sessions" + + run env -i \ + HOME="$home" \ + PATH="$direct_sessions:/usr/bin:/bin" \ + MISE_CONFIG_ROOT="$SHIMMER_DIR" \ + GIT_AUTHOR_NAME="test-agent" \ + GIT_AUTHOR_EMAIL="test-agent@ricon.family" \ + usage_headless="false" \ + usage_model="openai-codex/gpt-5.5" \ + usage_message="" \ + bash "$SHIMMER_DIR/.mise/tasks/agent/_default" # codebase:ignore bats-test-helper — isolates post-cleanup PATH without mise-added shims + [ "$status" -ne 0 ] + [[ "$output" == *"sessions not found on PATH"* ]] + [[ "$output" != *"stale direct sessions should not run"* ]] } -@test "interactive: scrubs caller context before invoking harness" { +@test "interactive: ignores inherited usage env from parent task" { setup_agent - local caller_dir="$BATS_TEST_TMPDIR/scrub-caller" - mkdir -p "$caller_dir" - export SHIMMER_CALLER_PWD="$caller_dir" - export OTHER_CALLER_PWD="/stale/other/caller" - mock_harness + export usage_headless="true" + export usage_model="stale-provider/stale-model" + export usage_message="stale parent message" + mock_sessions_binary mock_shimmer - run shimmer agent + run shimmer agent --model "openai-codex/gpt-5.5" [ "$status" -eq 0 ] - grep -q '^CALLER_PWD=$' "$HARNESS_ENV_LOG" - grep -q '^SHIMMER_CALLER_PWD=$' "$HARNESS_ENV_LOG" - grep -q '^OTHER_CALLER_PWD=$' "$HARNESS_ENV_LOG" + grep -q "^wake mock-session-id-001 --model openai-codex/gpt-5.5$" "$SESSIONS_LOG" + ! grep -q "stale parent message" "$SESSIONS_LOG" } -@test "interactive: removes mise task env and direct install PATH before invoking harness" { +@test "interactive: uses SHIMMER_CALLER_PWD as session cwd before scrubbing" { setup_agent - local caller_dir="$BATS_TEST_TMPDIR/scrub-caller" + local caller_dir="$BATS_TEST_TMPDIR/shimmer-caller" mkdir -p "$caller_dir" + unset CALLER_PWD export SHIMMER_CALLER_PWD="$caller_dir" - local installs="$HOME/.local/share/mise/installs" - local stale_sessions="$installs/shiv-sessions/0.4.1/bin" - local current_sessions="$installs/shiv-sessions/0.4.4/bin" - export PATH="$stale_sessions:/before:$current_sessions:$PATH" - export MISE_PROJECT_ROOT="/stale/project" - export MISE_ORIGINAL_CWD="/stale/original" - mock_harness + mock_sessions_binary mock_shimmer - run shimmer agent + run shimmer agent --model "openai-codex/gpt-5.5" [ "$status" -eq 0 ] - grep -q '^MISE_CONFIG_ROOT=$' "$HARNESS_ENV_LOG" - grep -q '^MISE_PROJECT_ROOT=$' "$HARNESS_ENV_LOG" - grep -q '^MISE_TASK_NAME=$' "$HARNESS_ENV_LOG" - grep -q '^usage_headless=$' "$HARNESS_ENV_LOG" - grep -q '^usage_model=$' "$HARNESS_ENV_LOG" - grep -q '^usage_message=$' "$HARNESS_ENV_LOG" - grep -q '^GIT_AUTHOR_NAME=test-agent$' "$HARNESS_ENV_LOG" - grep -q '^GIT_AUTHOR_EMAIL=test-agent@ricon.family$' "$HARNESS_ENV_LOG" - grep -q '^PATH=.*/before' "$HARNESS_ENV_LOG" - ! grep -q "^PATH=.*$stale_sessions" "$HARNESS_ENV_LOG" - ! grep -q "^PATH=.*$current_sessions" "$HARNESS_ENV_LOG" + grep "^new " "$SESSIONS_LOG" | grep -q -- "--cwd $caller_dir" } -@test "interactive: forwards session flag to harness" { +@test "interactive: preserves identity while scrubbing caller and mise task context" { setup_agent - mock_harness + export OTHER_CALLER_PWD="/stale/other/caller" + export MISE_PROJECT_ROOT="/stale/project" + export MISE_ORIGINAL_CWD="/stale/original" + mock_sessions_binary mock_shimmer - run shimmer agent --session "/tmp/my-session" + run shimmer agent --model "openai-codex/gpt-5.5" [ "$status" -eq 0 ] - grep -q -- "--session /tmp/my-session" "$HARNESS_LOG" + grep -q '^CALLER_PWD=$' "$SESSIONS_ENV_LOG" + grep -q '^SHIMMER_CALLER_PWD=$' "$SESSIONS_ENV_LOG" + grep -q '^OTHER_CALLER_PWD=$' "$SESSIONS_ENV_LOG" + grep -q '^MISE_CONFIG_ROOT=$' "$SESSIONS_ENV_LOG" + grep -q '^MISE_PROJECT_ROOT=$' "$SESSIONS_ENV_LOG" + grep -q '^MISE_TASK_NAME=$' "$SESSIONS_ENV_LOG" + grep -q '^usage_headless=$' "$SESSIONS_ENV_LOG" + grep -q '^usage_model=$' "$SESSIONS_ENV_LOG" + grep -q '^usage_message=$' "$SESSIONS_ENV_LOG" + grep -q '^GIT_AUTHOR_NAME=test-agent$' "$SESSIONS_ENV_LOG" + grep -q '^GIT_AUTHOR_EMAIL=test-agent@ricon.family$' "$SESSIONS_ENV_LOG" } -@test "interactive: forwards message to harness" { +@test "interactive: resumes an existing session and forwards the initial message" { setup_agent - mock_harness + mock_sessions_binary mock_shimmer - run shimmer agent "hello there" + run shimmer agent --session "existing-session-42" --model "openai-codex/gpt-5.5" "continue work" [ "$status" -eq 0 ] - grep -q "hello there" "$HARNESS_LOG" + ! grep -q "^new " "$SESSIONS_LOG" + grep -q "^wake existing-session-42 --model openai-codex/gpt-5.5 --message continue work$" "$SESSIONS_LOG" } @test "agent:dispatch requires model" { diff --git a/test/agent/helpers.bash b/test/agent/helpers.bash index 5abea79..26539b0 100644 --- a/test/agent/helpers.bash +++ b/test/agent/helpers.bash @@ -1,8 +1,8 @@ # Helpers for shimmer agent BATS tests # # Uses the mock-first include overlay pattern from test/helpers.bash. -# Mocks `sessions` and `pi` binaries to test agent task branching -# without real session infrastructure. +# Mocks the `sessions` binary to test agent task branching without +# launching real session infrastructure. source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/helpers.bash" source "$(cd "$(dirname "${BASH_SOURCE[0]}")/../ci" && pwd)/helpers.bash" @@ -52,38 +52,3 @@ MOCK chmod +x "$MOCK_BIN/sessions" export PATH="$MOCK_BIN:$PATH" } - - -# Create a mock harness binary and set AGENT_HARNESS to point at it. -# This avoids PATH ordering issues with mise-managed tools. -# Usage: mock_harness -mock_harness() { - MOCK_BIN="$BATS_TEST_TMPDIR/mock-bin-$$" - mkdir -p "$MOCK_BIN" - HARNESS_LOG="$BATS_TEST_TMPDIR/harness-log-$$" - HARNESS_ENV_LOG="$BATS_TEST_TMPDIR/harness-env-log-$$" - export HARNESS_LOG HARNESS_ENV_LOG - - cat > "$MOCK_BIN/mock-harness" <<'MOCK' -#!/usr/bin/env bash -echo "$@" >> "$HARNESS_LOG" -{ - printf 'PWD=%s\n' "$PWD" - printf 'CALLER_PWD=%s\n' "${CALLER_PWD-}" - printf 'SHIMMER_CALLER_PWD=%s\n' "${SHIMMER_CALLER_PWD-}" - printf 'OTHER_CALLER_PWD=%s\n' "${OTHER_CALLER_PWD-}" - printf 'MISE_CONFIG_ROOT=%s\n' "${MISE_CONFIG_ROOT-}" # codebase:ignore mcr-scope — test records scrubbed env - printf 'MISE_PROJECT_ROOT=%s\n' "${MISE_PROJECT_ROOT-}" - printf 'MISE_TASK_NAME=%s\n' "${MISE_TASK_NAME-}" - printf 'usage_headless=%s\n' "${usage_headless-}" - printf 'usage_model=%s\n' "${usage_model-}" - printf 'usage_message=%s\n' "${usage_message-}" - printf 'GIT_AUTHOR_NAME=%s\n' "${GIT_AUTHOR_NAME-}" - printf 'GIT_AUTHOR_EMAIL=%s\n' "${GIT_AUTHOR_EMAIL-}" - printf 'PATH=%s\n' "${PATH-}" -} >> "${HARNESS_ENV_LOG:-$HARNESS_LOG.env}" -MOCK - chmod +x "$MOCK_BIN/mock-harness" - export PATH="$MOCK_BIN:$PATH" - export AGENT_HARNESS="mock-harness" -}