agents: add safe cross-model delegation

This commit is contained in:
2026-07-09 16:04:34 -07:00
parent dd18277bc2
commit 97c9c1fe39
11 changed files with 345 additions and 3 deletions

6
.gitignore vendored
View File

@@ -62,6 +62,8 @@ gotools
!/dotfiles/claude/settings.json
!/dotfiles/claude/settings.local.json
!/dotfiles/claude/settings.local.json.example
!/dotfiles/claude/agents/
!/dotfiles/claude/agents/codex-delegator.md
# Expose the shared agent skills library to Claude Code, which only reads
# ~/.claude/skills. This is a symlink to ../agents/skills (the canonical
# store, also surfaced at ~/.agents/skills); without the allowlist the
@@ -70,8 +72,10 @@ gotools
# Same story for Codex: ~/.codex resolves into dotfiles/codex on nix-darwin,
# so the codex-history repo and live Codex state nest inside this worktree.
# Allowlist only the HM-managed config.
# Allowlist only HM-managed configuration and custom agents.
/dotfiles/codex/*
!/dotfiles/codex/AGENTS.md
!/dotfiles/codex/agents/
!/dotfiles/codex/agents/claude-delegator.toml
!/dotfiles/codex/config.toml
!/dotfiles/codex/skills

View File

@@ -1,10 +1,18 @@
# Agentic Session Preferences
## Delegating coding work to subagents
- Delegate all coding tasks to subagents (via the Agent tool) rather than editing code directly in the main session. The main session orchestrates: it plans, delegates, reviews, and integrates.
- When the primary model is Fable, strongly prefer delegating coding tasks to subagents (via the Agent tool). Fable should usually act as the orchestrator: planning, delegating, reviewing, and integrating. It may still work directly when delegation would add disproportionate overhead or the task cannot be usefully separated.
- For other primary models, subagent delegation is optional rather than required. Use judgment: delegate when work is meaningfully parallelizable, independently scoped, or benefits from a separate implementation/review pass; work directly when that is simpler and more efficient.
- The primary agent remains responsible for reviewing and integrating delegated work.
- Use judgement to select the model tier per task. Opus/medium is sufficient for simple, well-specified tasks. For harder tasks where architecture and design taste matter, prefer a stronger tier (e.g. fable).
- Whenever you pick a model tier for an agent, record a one-line justification for that choice (in your reasoning/CoT or a brief note in the delegating message) so the decision is auditable. Tie the justification to what the agent must actually decide at execution time, not just the topic's importance — a task specified tightly enough that the taste is already discharged doesn't need the stronger tier. If you can't articulate why the cheaper tier is insufficient, default to it.
- This applies to writing, editing, and refactoring code. Non-coding work (reading, searching, planning, running commands, answering questions) does not need to be delegated.
- These guidelines apply to writing, editing, and refactoring code. Non-coding work (reading, searching, planning, running commands, answering questions) does not need to be delegated.
## Cross-model delegation
- Use cross-model delegation only when the user requests it or model diversity or an independent check would be useful; it is never mandatory.
- Codex should prefer the `claude_delegator` agent, and Claude should prefer the `codex-delegator` agent. Use `$cross-agent-delegation` or its `ask-claude` and `ask-codex` wrappers when direct invocation is simpler.
- Permit at most one cross-model handoff and never recursively delegate. Keep the child read-only and advisory by default, with only one writer per worktree.
- The parent agent owns review, verification, and integration of the child's output.
## Sharing dev-server / preview links
- When sharing a local server or preview URL, always prefer this machine's Tailscale address over `127.0.0.1`/`localhost`/LAN IPs, so the link opens from any device on the tailnet.

View File

@@ -0,0 +1,18 @@
---
name: cross-agent-delegation
description: Safely delegate between Codex and Claude through read-only CLI wrappers. Use when the user explicitly asks Codex or Claude to delegate to the other, or when a workflow explicitly calls for an independent cross-model second opinion.
---
# Cross-Agent Delegation
Use a cheap native driver agent when available. Invoke the wrapper directly when that is simpler:
- Pipe a self-contained prompt to `ask-claude` or `ask-codex` on stdin.
- Reserve `--base64 '<payload>'` for native driver agents as injection-safe transport. A driver must encode the complete task itself and never embed raw delegated task text in shell source.
- Treat the returned plain text as advisory output; the wrappers are read-only and ephemeral.
- Review and verify the output in the parent agent before using it.
- Never include credentials, secrets, or unnecessary personal data in the prompt.
- Permit at most one cross-model handoff. Never ask the child to invoke either CLI or delegate again.
- Keep one writer per worktree. Have the parent make any resulting edits.
Environment variables documented in the scripts may override their conservative model, effort, timeout, turn, and budget defaults.

View File

@@ -0,0 +1,4 @@
interface:
display_name: "Cross-agent delegation"
short_description: "Safely consult the other coding agent"
default_prompt: "Use $cross-agent-delegation to get an independent cross-model second opinion."

View File

@@ -0,0 +1,135 @@
#!/usr/bin/env bash
set -euo pipefail
input_mode=stdin
base64_payload=
case "$#" in
0) ;;
2)
if [ "$1" != "--base64" ]; then
echo "ask-claude: expected a prompt on stdin or --base64 <payload>" >&2
exit 2
fi
input_mode=base64
base64_payload=$2
;;
*)
echo "ask-claude: expected a prompt on stdin or --base64 <payload>" >&2
exit 2
;;
esac
if [ "$input_mode" = base64 ]; then
if [ -z "$base64_payload" ] || [[ ! "$base64_payload" =~ ^([A-Za-z0-9+/]{4})*([A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$ ]]; then
echo "ask-claude: invalid RFC4648 base64 payload or padding" >&2
exit 2
fi
fi
if [ "${CROSS_AGENT_DEPTH:-0}" != "0" ]; then
echo "ask-claude: recursive cross-agent delegation is not allowed" >&2
exit 2
fi
for required_command in claude timeout mktemp jq cat chmod rm; do
if ! command -v "$required_command" >/dev/null 2>&1; then
echo "ask-claude: required command not found: $required_command" >&2
exit 127
fi
done
if [ "$input_mode" = base64 ] && ! command -v base64 >/dev/null 2>&1; then
echo "ask-claude: required command not found: base64" >&2
exit 127
fi
export CROSS_AGENT_DEPTH=1
timeout_value=${CROSS_AGENT_TIMEOUT:-10m}
model=${CROSS_AGENT_CLAUDE_MODEL:-haiku}
effort=${CROSS_AGENT_CLAUDE_EFFORT:-low}
max_turns=${CROSS_AGENT_CLAUDE_MAX_TURNS:-4}
max_budget_usd=${CROSS_AGENT_CLAUDE_MAX_BUDGET_USD:-0.25}
umask 077
tmp_dir=$(mktemp -d "${TMPDIR:-/tmp}/ask-claude.XXXXXX")
cleanup() {
rm -rf -- "$tmp_dir"
}
trap cleanup EXIT
trap 'exit 129' HUP
trap 'exit 130' INT
trap 'exit 143' TERM
prompt_file="$tmp_dir/prompt"
result_file="$tmp_dir/result.json"
if [ "$input_mode" = base64 ]; then
if base64 --decode </dev/null >/dev/null 2>&1; then
base64_decode=(base64 --decode)
elif base64 -D </dev/null >/dev/null 2>&1; then
base64_decode=(base64 -D)
else
echo "ask-claude: base64 command supports neither --decode nor -D" >&2
exit 1
fi
if ! printf '%s' "$base64_payload" | "${base64_decode[@]}" >"$prompt_file"; then
echo "ask-claude: failed to decode base64 payload" >&2
exit 2
fi
else
cat >"$prompt_file"
fi
chmod 600 "$prompt_file"
if [ ! -s "$prompt_file" ]; then
echo "ask-claude: prompt on stdin must not be empty" >&2
exit 2
fi
guard_instruction='Act only as a read-only advisory child. Do not invoke Codex or Claude, directly or indirectly. Do not spawn, delegate to, or communicate with any other agent. Do not edit, create, delete, move, or rename files. Return only your analysis or recommendation to the parent.'
set +e
timeout --foreground "$timeout_value" \
claude -p \
--model "$model" \
--effort "$effort" \
--max-turns "$max_turns" \
--max-budget-usd "$max_budget_usd" \
--output-format json \
--permission-mode dontAsk \
--tools Read,Grep,Glob \
--allowedTools Read,Grep,Glob \
--no-session-persistence \
--no-chrome \
--append-system-prompt "$guard_instruction" \
<"$prompt_file" >"$result_file"
status=$?
set -e
if [ "$status" -ne 0 ]; then
if [ "$status" -eq 124 ]; then
echo "ask-claude: timed out after $timeout_value" >&2
else
echo "ask-claude: claude exited with status $status" >&2
fi
exit "$status"
fi
if ! jq -e . "$result_file" >/dev/null 2>&1; then
echo "ask-claude: claude returned invalid JSON" >&2
exit 1
fi
if jq -e '(.is_error? // false) == true' "$result_file" >/dev/null; then
echo "ask-claude: claude reported an error" >&2
jq -r '.result? // empty' "$result_file" >&2
exit 1
fi
if ! jq -e '.result? | type == "string"' "$result_file" >/dev/null; then
echo "ask-claude: claude response does not contain a string result" >&2
exit 1
fi
jq -r '.result' "$result_file"

View File

@@ -0,0 +1,123 @@
#!/usr/bin/env bash
set -euo pipefail
input_mode=stdin
base64_payload=
case "$#" in
0) ;;
2)
if [ "$1" != "--base64" ]; then
echo "ask-codex: expected a prompt on stdin or --base64 <payload>" >&2
exit 2
fi
input_mode=base64
base64_payload=$2
;;
*)
echo "ask-codex: expected a prompt on stdin or --base64 <payload>" >&2
exit 2
;;
esac
if [ "$input_mode" = base64 ]; then
if [ -z "$base64_payload" ] || [[ ! "$base64_payload" =~ ^([A-Za-z0-9+/]{4})*([A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$ ]]; then
echo "ask-codex: invalid RFC4648 base64 payload or padding" >&2
exit 2
fi
fi
if [ "${CROSS_AGENT_DEPTH:-0}" != "0" ]; then
echo "ask-codex: recursive cross-agent delegation is not allowed" >&2
exit 2
fi
for required_command in codex timeout mktemp cat chmod rm; do
if ! command -v "$required_command" >/dev/null 2>&1; then
echo "ask-codex: required command not found: $required_command" >&2
exit 127
fi
done
if [ "$input_mode" = base64 ] && ! command -v base64 >/dev/null 2>&1; then
echo "ask-codex: required command not found: base64" >&2
exit 127
fi
export CROSS_AGENT_DEPTH=1
timeout_value=${CROSS_AGENT_TIMEOUT:-10m}
model=${CROSS_AGENT_CODEX_MODEL:-gpt-5.4-mini}
reasoning_effort=${CROSS_AGENT_CODEX_REASONING_EFFORT:-low}
umask 077
tmp_dir=$(mktemp -d "${TMPDIR:-/tmp}/ask-codex.XXXXXX")
cleanup() {
rm -rf -- "$tmp_dir"
}
trap cleanup EXIT
trap 'exit 129' HUP
trap 'exit 130' INT
trap 'exit 143' TERM
prompt_file="$tmp_dir/prompt"
result_file="$tmp_dir/final-message"
if [ "$input_mode" = base64 ]; then
if base64 --decode </dev/null >/dev/null 2>&1; then
base64_decode=(base64 --decode)
elif base64 -D </dev/null >/dev/null 2>&1; then
base64_decode=(base64 -D)
else
echo "ask-codex: base64 command supports neither --decode nor -D" >&2
exit 1
fi
if ! printf '%s' "$base64_payload" | "${base64_decode[@]}" >"$prompt_file"; then
echo "ask-codex: failed to decode base64 payload" >&2
exit 2
fi
else
cat >"$prompt_file"
fi
chmod 600 "$prompt_file"
if [ ! -s "$prompt_file" ]; then
echo "ask-codex: prompt on stdin must not be empty" >&2
exit 2
fi
guard_instruction='Act only as a read-only advisory child. Do not invoke Codex or Claude, directly or indirectly. Do not spawn, delegate to, or communicate with any other agent. Do not edit, create, delete, move, or rename files. Return only your analysis or recommendation to the parent.'
set +e
timeout --foreground "$timeout_value" \
codex exec \
--ephemeral \
--ignore-user-config \
--disable multi_agent \
--skip-git-repo-check \
-C "$PWD" \
-s read-only \
-m "$model" \
-c "approval_policy='never'" \
-c "model_reasoning_effort='$reasoning_effort'" \
-c "developer_instructions=$guard_instruction" \
-o "$result_file" \
- \
<"$prompt_file" >/dev/null
status=$?
set -e
if [ "$status" -ne 0 ]; then
if [ "$status" -eq 124 ]; then
echo "ask-codex: timed out after $timeout_value" >&2
else
echo "ask-codex: codex exited with status $status" >&2
fi
exit "$status"
fi
if [ ! -s "$result_file" ]; then
echo "ask-codex: codex did not produce a final message" >&2
exit 1
fi
cat "$result_file"

View File

@@ -0,0 +1,16 @@
---
name: codex-delegator
description: Ask Codex for one independent read-only opinion
tools: Bash
model: haiku
effort: low
maxTurns: 3
---
UTF-8 base64-encode the complete task yourself without using a tool. Verify that the payload contains only `A-Z`, `a-z`, `0-9`, `+`, `/`, and valid terminal `=` padding. Then make exactly one Bash tool call:
```bash
ask-codex --base64 '<payload>'
```
Never place raw task text in shell source. Return the command's stdout verbatim. Do not investigate, reason about the task, edit files, call any other command, or call or delegate to any other agent.

View File

@@ -0,0 +1,7 @@
name = "claude_delegator"
description = "Ask Claude for one independent read-only opinion"
model = "gpt-5.4-mini"
model_reasoning_effort = "low"
developer_instructions = """
UTF-8 base64-encode the complete task yourself without using a tool. Verify that the payload contains only `A-Z`, `a-z`, `0-9`, `+`, `/`, and valid terminal `=` padding. Then make exactly one shell tool call: `ask-claude --base64 '<payload>'`. Never place raw task text in shell source. Return the command's stdout verbatim. Do not investigate or reason about the task yourself, edit files, run any other command, or call, spawn, communicate with, or delegate to any other agent.
"""

11
dotfiles/lib/bin/ask-claude Executable file
View File

@@ -0,0 +1,11 @@
#!/usr/bin/env bash
set -euo pipefail
target=${AGENTS_HOME:-$HOME/.agents}/skills/cross-agent-delegation/scripts/ask-claude
if [ ! -x "$target" ]; then
echo "ask-claude: canonical script is not executable: $target" >&2
exit 127
fi
exec "$target" "$@"

11
dotfiles/lib/bin/ask-codex Executable file
View File

@@ -0,0 +1,11 @@
#!/usr/bin/env bash
set -euo pipefail
target=${AGENTS_HOME:-$HOME/.agents}/skills/cross-agent-delegation/scripts/ask-codex
if [ ! -x "$target" ]; then
echo "ask-codex: canonical script is not executable: $target" >&2
exit 127
fi
exec "$target" "$@"

View File

@@ -69,6 +69,11 @@ in {
force = true;
source = oos "${cfg.worktreeCodexDir}/AGENTS.md";
};
".codex/agents/claude-delegator.toml" = {
force = true;
source = oos "${cfg.worktreeCodexDir}/agents/claude-delegator.toml";
};
};
home.activation.prepareCodexDirectory = lib.hm.dag.entryBefore ["checkLinkTargets"] ''