15 KiB
D4 Wrapper Cache Implementation Plan
Scope
Two intercepts only, targeting the two largest agent-side waste buckets from the D4 analysis:
| Pattern | D4 count (10-session run) | % of agent-side traffic |
|---|---|---|
gh pr list --repo R --head B --json ... --limit 1 |
598 | 65.3% |
gh issue view N --json ... |
75 | 8.2% |
Combined: 673 / 916 = 73.5% of all agent-side gh calls.
Everything else passes through unchanged.
Storage
Location
$AO_DATA_DIR/.ghcache/$AO_SESSION/
$AO_DATA_DIR= sessions directory (already available in agent env)$AO_SESSION= session ID (already available in agent env).ghcacheis invisible tolistMetadata()(metadata.ts:366filtersname.startsWith("."))
File format
Each cache entry = two files:
{key}.stdout # exact gh stdout (byte-identical to what real gh returned)
{key}.ts # single line: epoch seconds when entry was written
Example:
$AO_DATA_DIR/.ghcache/issue-30-fix/
pr-discovery-feat--issue-30.stdout # [{"number":57,"url":"...","title":"..."}]
pr-discovery-feat--issue-30.ts # 1713567890
issue-ctx-31.stdout # {"number":31,"title":"...","body":"..."}
issue-ctx-31.ts # 1713567850
Why two files instead of embedding timestamp
The .stdout file is returned to the agent as-is via cat. No parsing, no stripping, no risk of corrupting JSON output. The .ts file is a one-line epoch used only by the cache-freshness check.
Intercept 1: PR Discovery
What agents are doing
From D4 traces, every agent session repeatedly runs:
gh pr list --repo iamasx/api-test --head feat/issue-X --json number,url,title,headRefName,baseRefName,isDraft --limit 1
The 10-session run showed:
- 75 identical calls for
feat/issue-30 - 74 identical calls for
feat/issue-27 - 74 identical calls for
feat/issue-26 - 73 identical calls for
feat/issue-22 - ...
Once branch feat/issue-30 has PR #57, that answer never changes. Yet the agent keeps asking.
Match conditions
ALL must be true:
$1=pr,$2=list--head <branch>flag present--limit 1present- None of these flags present:
--search,--state,--assignee,--label,--jq,--template
If any condition fails → passthrough to real gh.
Cache key
pr-discovery-{sanitized_branch}
Where sanitized_branch = --head value with non-alphanumeric chars (except ., _, -) replaced by -:
safe_branch=$(printf '%s' "$head_val" | tr -c 'a-zA-Z0-9._-' '-')
TTL
- Positive result (non-empty JSON array, i.e., stdout is not
[]): infinite within session. - Negative result (
[]or empty): not cached. The PR might be created at any moment.
Cache-read flow
1. Parse args, extract --head value and --repo value
2. Compute cache_key = "pr-discovery-{safe_branch}"
3. If cache file exists (positive = infinite TTL):
cat "$cache_dir/$cache_key.stdout"
exit 0
4. Else:
call real gh, capture stdout to tmpfile
if exit_code == 0 AND stdout is not "[]" and not empty:
write tmpfile → $cache_dir/$cache_key.stdout
write epoch → $cache_dir/$cache_key.ts
cat tmpfile
exit $exit_code
Integration with gh pr create write intercept
The existing pr/create intercept (wrapper lines 212-244) already captures the PR URL and number after a successful gh pr create. Extend it to also populate the PR discovery cache:
After the existing metadata writes:
# Populate PR discovery cache so subsequent gh pr list --head hits cache
_branch="$(read_ao_metadata branch)"
if [[ -n "$_branch" && -n "$pr_url" && -n "$pr_number" ]]; then
_safe_branch=$(printf '%s' "$_branch" | tr -c 'a-zA-Z0-9._-' '-')
_cache_key="pr-discovery-${_safe_branch}"
_cache_dir="$(ao_cache_dir)"
if [[ -n "$_cache_dir" ]]; then
# Write a minimal but valid gh pr list JSON response.
# Contains only the fields we know. If the agent later asks for
# fields not present here, those fields will be null/missing in
# the cached response. This is acceptable because:
# - The most common query shape includes these exact fields
# - Any missing-field issue self-corrects on the next real call
# if the agent retries or asks for different fields
_draft_val="${report_draft:-false}"
printf '[{"number":%s,"url":"%s","headRefName":"%s","isDraft":%s}]\n' \
"$pr_number" "$pr_url" "$_branch" "$_draft_val" \
> "$_cache_dir/${_cache_key}.stdout.tmp.$$"
mv "$_cache_dir/${_cache_key}.stdout.tmp.$$" "$_cache_dir/${_cache_key}.stdout"
date +%s > "$_cache_dir/${_cache_key}.ts"
fi
fi
This means: the moment gh pr create succeeds, the very next gh pr list --head for that branch hits cache with zero API calls.
Projected reduction
From D4 10-session data: 598 calls → ~10 (one real call per session, the first discovery). ~588 calls eliminated (98%).
Intercept 2: Issue Context
What agents are doing
From D4 traces, agents repeatedly fetch the same issue:
gh issue view 31 --json number,title,body,state,labels,assignees
- issue 31 viewed 9 times
- issue 30 viewed 9 times
- issue 29 viewed 9 times
- issues 28, 27, 26, 24, 23, 22 viewed 7 times each
- issue 25 viewed 6 times
Issue metadata (title, body, labels) does not change during a typical agent work session. Fetching once is sufficient.
Match conditions
ALL must be true:
$1=issue,$2=view- Third positional arg is a number (the issue identifier)
- None of these flags present:
--web,--comments,--jq,--template
If any condition fails → passthrough.
Cache key
issue-ctx-{issue_id}
The issue ID is the numeric identifier extracted from the positional arg. No sanitization needed (it's a number).
TTL
300 seconds (5 minutes). Issue content can change externally (someone edits the title or adds a label), but this is rare during an active agent session. A 5-minute window is conservative enough to avoid serving truly stale data while eliminating most repeated fetches.
Cache-read flow
1. Parse args, extract issue ID (first positional arg after "issue view")
2. Compute cache_key = "issue-ctx-{issue_id}"
3. If cache file exists AND age < 300 seconds:
cat "$cache_dir/$cache_key.stdout"
exit 0
4. Else:
call real gh, capture stdout to tmpfile
if exit_code == 0:
write tmpfile → $cache_dir/$cache_key.stdout
write epoch → $cache_dir/$cache_key.ts
cat tmpfile
exit $exit_code
Age check
ao_cache_fresh() {
local cache_key="$1" max_age="$2"
local cache_dir
cache_dir="$(ao_cache_dir)" || return 1
local ts_file="$cache_dir/${cache_key}.ts"
local stdout_file="$cache_dir/${cache_key}.stdout"
[[ -f "$stdout_file" && -f "$ts_file" ]] || return 1
# max_age=0 means infinite TTL
[[ "$max_age" -eq 0 ]] && return 0
local cached_ts now
cached_ts=$(cat "$ts_file" 2>/dev/null) || return 1
now=$(date +%s)
(( now - cached_ts < max_age ))
}
Projected reduction
From D4 10-session data: 75 calls → ~20 (one real call per issue per 5-minute window). ~55 calls eliminated (73%).
Helper Functions
Added to AO_METADATA_HELPER (sourced by both gh and git wrappers):
read_ao_metadata()
read_ao_metadata() {
local key="$1"
local ao_dir="${AO_DATA_DIR:-}"
local ao_session="${AO_SESSION:-}"
[[ -z "$ao_dir" || -z "$ao_session" ]] && return 1
case "$ao_session" in */* | *..*) return 1 ;; esac
case "$ao_dir" in
"$HOME"/.ao/* | "$HOME"/.agent-orchestrator/* | /tmp/*) ;;
*) return 1 ;;
esac
local metadata_file="$ao_dir/$ao_session"
[[ -f "$metadata_file" ]] || return 1
[[ "$key" =~ ^[a-zA-Z0-9_-]+$ ]] || return 1
local line
line=$(grep "^${key}=" "$metadata_file" 2>/dev/null | head -1) || return 1
printf '%s' "${line#*=}"
}
ao_cache_dir()
ao_cache_dir() {
local ao_dir="${AO_DATA_DIR:-}"
local ao_session="${AO_SESSION:-}"
[[ -z "$ao_dir" || -z "$ao_session" ]] && return 1
case "$ao_session" in */* | *..*) return 1 ;; esac
case "$ao_dir" in
"$HOME"/.ao/* | "$HOME"/.agent-orchestrator/* | /tmp/*) ;;
*) return 1 ;;
esac
local d="$ao_dir/.ghcache/$ao_session"
mkdir -p "$d" 2>/dev/null || return 1
printf '%s' "$d"
}
ao_cache_fresh()
As defined in the issue-context section above.
ao_cache_read()
ao_cache_read() {
local cache_key="$1"
local cache_dir
cache_dir="$(ao_cache_dir)" || return 1
cat "$cache_dir/${cache_key}.stdout"
}
ao_cache_write()
# Stdin is piped in. Writes atomically.
ao_cache_write() {
local cache_key="$1"
local cache_dir
cache_dir="$(ao_cache_dir)" || return 1
local tmp="$cache_dir/${cache_key}.stdout.tmp.$$"
cat > "$tmp" && mv "$tmp" "$cache_dir/${cache_key}.stdout"
date +%s > "$cache_dir/${cache_key}.ts"
}
New Wrapper Structure
The GH_WRAPPER dispatch changes from:
# Current:
log_gh_invocation "$@"
case "$1/$2" in
pr/create) ... ;;
*) exec "$real_gh" "$@" ;;
esac
To:
log_gh_invocation "$@"
# ── Cacheable reads ──────────────────────────────────────
# 1. PR discovery: gh pr list --head <B> --limit 1
if [[ "$1" == "pr" && "$2" == "list" ]]; then
<parse args, check match conditions>
if <matched>; then
cache_key="pr-discovery-${safe_branch}"
if ao_cache_fresh "$cache_key" 0; then
ao_cache_read "$cache_key"
exit 0
fi
<call real gh, capture stdout>
if [[ $exit_code -eq 0 ]] && <stdout is not empty/[]>; then
<write to cache>
fi
<output stdout>
exit $exit_code
fi
fi
# 2. Issue context: gh issue view <N> --json ...
if [[ "$1" == "issue" && "$2" == "view" ]]; then
<parse args, check match conditions>
if <matched>; then
cache_key="issue-ctx-${issue_id}"
if ao_cache_fresh "$cache_key" 300; then
ao_cache_read "$cache_key"
exit 0
fi
<call real gh, capture stdout>
if [[ $exit_code -eq 0 ]]; then
<write to cache>
fi
<output stdout>
exit $exit_code
fi
fi
# ── Write intercepts (existing, enhanced) ────────────────
case "$1/$2" in
pr/create)
<existing pr create logic>
<NEW: populate pr-discovery cache after success>
exit $exit_code
;;
*)
exec "$real_gh" "$@"
;;
esac
Session-Manager Changes
Make PATH wrappers universal for all agents
packages/core/src/session-manager.ts
At the setupWorkspaceHooks call site (~line 1524), add wrapper installation:
// Setup agent hooks for automatic metadata updates
try {
if (plugins.agent.setupWorkspaceHooks) {
await plugins.agent.setupWorkspaceHooks(workspacePath, { dataDir: sessionsDir });
}
// Always install shared wrappers — every agent gets gh/git interception
await setupPathWrapperWorkspace(workspacePath);
} catch (err) {
await cleanupWorktreeAndMetadata();
throw err;
}
At all 3 runtime.create() sites (lines ~1257, ~1608, ~2697), inject PATH and GH_PATH after the agent environment spread:
environment: {
...environment,
PATH: buildAgentPath(environment["PATH"] ?? process.env["PATH"]),
GH_PATH: PREFERRED_GH_PATH,
AO_SESSION: sessionId,
AO_DATA_DIR: sessionsDir,
// ... rest unchanged
},
Remove per-plugin boilerplate
Remove buildAgentPath() and setupPathWrapperWorkspace() calls from:
packages/plugins/agent-codex/src/index.tspackages/plugins/agent-aider/src/index.tspackages/plugins/agent-opencode/src/index.tspackages/plugins/agent-cursor/src/index.ts
These plugins no longer need to import or call these functions. The session manager handles it.
Bump wrapper version
packages/core/src/agent-workspace-hooks.ts:35:
const WRAPPER_VERSION = "0.4.0";
Existing workspaces auto-refresh when the version marker doesn't match.
Cache Cleanup
On session delete/archive
In deleteMetadata() (metadata.ts:263), add cleanup of the session's cache directory:
// Clean up gh cache for this session
const cachePath = join(dataDir, ".ghcache", sessionId);
try { rmSync(cachePath, { recursive: true, force: true }); } catch { /* best effort */ }
No cross-session leakage
Each session has its own cache subdirectory keyed by $AO_SESSION. A restored session gets a fresh directory. No stale data from previous runs.
Safety
| Scenario | Handling |
|---|---|
$AO_DATA_DIR or $AO_SESSION unset |
All cache functions return 1 (failure). Wrapper falls through to real gh. Agent sees no difference. |
| Cache directory creation fails (permissions, disk full) | ao_cache_dir returns 1. Cache write silently skipped. Real gh is called. |
gh pr list --head returns [] (no PR yet) |
Not cached. Every subsequent call goes to real gh until a PR exists. |
Agent uses --jq or --template |
Match conditions reject these flags. Passthrough to real gh. |
Agent uses different --json field sets for the same branch/issue |
Cached stdout is the exact response from the first call. If the first call had --json number,url,title,... and a later call asks for --json number,url only, the cached response has more fields than requested — gh clients tolerate this. If the later call asks for fields not in the cached response, it gets a response without those fields. This is a theoretical edge case — the D4 traces show agents use the same --json field set consistently. |
| Concurrent cache writes from parallel commands | Atomic write pattern: write to $file.tmp.$$, then mv. Same pattern used by existing metadata writes. |
| Clock skew (container, VM) | Only affects TTL accuracy. A few seconds of drift on a 300s TTL is negligible. |
Files Changed
| File | Change |
|---|---|
packages/core/src/agent-workspace-hooks.ts |
Add cache helpers to AO_METADATA_HELPER. Add read intercepts + cache-write-on-create to GH_WRAPPER. Bump WRAPPER_VERSION. |
packages/core/src/session-manager.ts |
Add setupPathWrapperWorkspace() call. Add buildAgentPath() + GH_PATH to all 3 environment sites. |
packages/core/src/metadata.ts |
Add .ghcache cleanup in deleteMetadata(). |
packages/plugins/agent-codex/src/index.ts |
Remove buildAgentPath(), setupPathWrapperWorkspace(), GH_PATH from plugin. |
packages/plugins/agent-aider/src/index.ts |
Same removal. |
packages/plugins/agent-opencode/src/index.ts |
Same removal. |
packages/plugins/agent-cursor/src/index.ts |
Same removal. |
packages/core/src/__tests__/agent-workspace-hooks.test.ts |
Tests for cache helpers, PR discovery intercept, issue context intercept, gh pr create cache population, passthrough on unsupported flags, TTL expiry. |
Projected Impact
From the D4 10-session / 22-minute run (916 agent-side gh calls):
| Pattern | Before | After | Saved |
|---|---|---|---|
gh pr list --head |
598 | ~10 | ~588 (98%) |
gh issue view |
75 | ~20 | ~55 (73%) |
| Subtotal (in scope) | 673 | ~30 | ~643 (96%) |
| Everything else (unchanged) | 243 | 243 | 0 |
| Total | 916 | ~273 | ~643 (70%) |
The two intercepts eliminate ~70% of all agent-side gh traffic and ~96% of the traffic they target.