Pulls in 2 main commits (#1487 orchestrator identity fix, #1238 gh CLI
tracer + scm/tracker migration Phase A1a) plus the auto-merged
follow-on changes from upstream.
Conflict resolutions:
- packages/core/src/session-manager.ts:
Took main's reorganized opencode-agents-md import + new
getOrchestratorSessionId import. Kept main's reformatted sessionCache
type and added the new ensureOrchestratorPromises Map.
- packages/core/src/agent-workspace-hooks.ts:
Took main's WRAPPER_VERSION = "0.6.0" (and matching test fixture).
- packages/core/src/__tests__/agent-workspace-hooks.test.ts:
Merged both sides' imports — kept branch's buildNodeWrapper +
node:path/join (still used in test body) and added main's
AO_METADATA_HELPER + GH_WRAPPER constant exports.
- packages/plugins/agent-codex/src/index.ts:
Dropped now-unused PREFERRED_GH_PATH import (env injection moved to
session-manager). Kept isWindows — needed by branch's new
formatLaunchCommand, resolveCodexBinaryWindows, and pre-existing
isProcessRunning code.
- packages/plugins/agent-codex/src/index.test.ts:
Took main's collapsed PATH/GH_PATH undefined assertions and no-op
setupWorkspaceHooks test — the agent no longer constructs PATH or
installs wrappers (session-manager owns those paths now). Removed
the orphaned wrapper-write tests. Kept branch's
describe.skipIf(win32) on the shell-wrapper-content block (those
tests verify Unix shell-script content that genuinely can't run on
PowerShell).
- packages/plugins/agent-aider/src/index.test.ts +
packages/plugins/agent-cursor/src/index.test.ts:
Took main's `expect(env["PATH"]).toBeUndefined()` — agents no
longer set PATH directly.
- packages/web/server/mux-websocket.ts:
Kept branch's Windows pipe handler branch and updated the inner
Unix `terminalManager.open(id, tmuxName)` call to main's new
signature with the optional tmuxName argument.
Verified: typecheck clean, lint 0 errors, full build (28 pkgs), tests
942/946 passing — the 4 failing tests in plugin-integration.test.ts
also fail on main itself (verified by running main's untouched test
file), so they're pre-existing flakes unrelated to this merge.
Set outputFileTracingRoot in next.config.js to the monorepo root
via path.join(__dirname, '../..'). This prevents Next.js from
inferring the workspace root when lockfiles exist above the checkout,
eliminating the noisy startup warning in foreground dashboard logs.
Closes#1492
Co-authored-by: AO Bot <ao-bot@composio.dev>
Three Windows-specific gaps that combined to make every Codex spawn fail
on PowerShell with "Unexpected token '-c' in expression or statement":
- formatLaunchCommand(): prepend `& ` to the joined launch string when
running on Windows. shellEscape quotes the resolved binary path
('C:\Users\...\codex.cmd'), and PowerShell parses a leading quoted
string as an expression — without the call operator the next flag
triggers a parser error before codex is ever invoked. bash treats
the same string as a normal command, so the prefix is Windows-only.
Applied at both getLaunchCommand and getRestoreCommand exits.
- resolveCodexBinary(): add a Windows branch using `where.exe` instead
of `which`. Prefers codex.cmd (npm shim) over codex.exe (Cargo build),
then falls back to %APPDATA%\npm\codex.{cmd,exe} and ~\.cargo\bin
for users whose PATH doesn't yet include the install dir. Lookup runs
with windowsHide:true so the search itself doesn't flash a console.
- sessionFileMatchesCwd(): compare paths via a canonical form
(forward slashes, lowercased drive letter) so Codex JSONL rollout
files can still be located when payload.cwd uses a different slash
direction or drive-letter case than the workspace path AO computes
via path.join. Without this, dashboard activity/cost stay empty
for Codex sessions on Windows.
* feat(core): add opt-in gh CLI tracer and migrate scm/tracker plugins
Introduces execGhObserved() in @aoagents/ao-core: a thin wrapper around
execFile("gh", ...) that writes a JSONL trace row to $AO_GH_TRACE_FILE
on both success and failure. Captures status line, HTTP status, ETag,
rate-limit headers, duration, stdout/stderr byte counts, exit code, and
signal. No-op when the env var is unset, so default behavior is
unchanged.
Migrates three call sites to the observer:
- scm-github/graphql-batch.ts — PR-list guard, commit-status guard,
GraphQL batch query
- scm-github/index.ts — gh() and ghInDir() helpers
- tracker-github/index.ts — internal gh() helper
This is Phase A1a of experiments/PLAN.md: tracer infrastructure +
migration. The full GhRunner contract (Promise<GhResult>,
GhRunnerError.ghResult on reject, body capture, redaction, 64 KB cap)
lands in A1b along with the scorecard baseline.
Also adds experiments/ reference docs: the v2.3 plan, the gh-CLI call
catalog, two ETag verification writeups, and a trace harness + summary
script.
* docs(experiments): add A1a validation status and A1b blockers
Record the five A1b pre-freeze blockers surfaced by Adil's 1,487-row
baseline and an independent drill run: graphql-batch missing -i,
extractOperation flag mis-bucketing, analyzer not segmenting burn by
reset window, CLI-subcommand opacity (GH_DEBUG=api stderr vs coarse
/rate_limit bracket — not equivalent), and sessionId/projectId not
threaded through plugin callsites. Note bare gh() helper cleanup as
known-open follow-up.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(tracer): close A1b blockers 1-4 — graphql-batch visibility, operation naming, analyzer segmentation
- Add -i flag to executeBatchQuery in graphql-batch.ts and split HTTP
headers from JSON body before parsing, making all gh.api.graphql-batch
rows visible to status and rate-limit analysis (was 186 invisible rows)
- Fix extractOperation() in gh-trace.ts to walk past -* flags before
picking the operation segment, eliminating the gh.api.--method bucket
- Add per-reset-window burn segmentation to both analyzers so runs
straddling a reset boundary produce per-window deltas instead of a
single invalid cross-reset delta
- Add experiment scripts: analyze-trace.mjs (deep trace analysis) and
drill-tracer.mjs (standalone tracer exerciser)
- Document Gap 1 decision in PLAN.md: accept CLI subcommands as opaque
for A1, bracket A2 runs with /rate_limit snapshots for coarse burn
- Add progress timeline to PLAN.md showing A→B→C track dependencies
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* docs(experiments): add A2 baseline matrix runbook
Practical execution plan for the Phase A2 scenario x scale x topology
matrix: 7 priority cells, per-cell procedure, /rate_limit bracketing
for Gap 1 subcommand burn, output format for baseline.md, and the
scorecard that gates Track B.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(tracer): guard stderr/stdout against undefined, bound operation cardinality
Addresses code review findings:
1. Guard Buffer.byteLength and parseIncludedHttpResponse against
undefined stderr/stdout — fixes 48 SCM test regressions where
mocked execFile paths don't populate stderr
2. extractOperation() now takes only the first path segment of REST
URLs (e.g. "repos" from "repos/acme/repo/pulls/123/...") to keep
operation bucket cardinality bounded and stable across runs
3. Fix A2 runbook /rate_limit snapshots to produce valid JSON using
jq's now|todate instead of appending raw timestamp
4. Add blocker 5 dependency to runbook prereqs and per-session cells
All 140 SCM tests pass (0 failures).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* docs(experiments): add rate-limiting research artifacts
Baseline measurements, discussion notes, benchmark harness spec,
and updated master plan from two independent trace runs at 5-6 sessions.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(experiments): add benchmark harness for GH rate-limit measurement
Three modes: setup (spawn sessions, wait for PRs), measure (trace API
calls over a fixed window, produce scorecard), report (recompute from
existing trace). Node.js stdlib only, shells out to ao CLI and gh CLI.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(scm-github): handle 304 Not Modified in ETag guard catch blocks (B1)
`gh api -i` exits code 1 on HTTP 304 responses, causing the catch blocks
in checkPRListETag and checkCommitStatusETag to assume the resource changed
and trigger unnecessary GraphQL batch queries every poll cycle.
Fix: inspect stdout/stderr in the catch block for the 304 status line before
falling back to "assume changed". Also unifies the 304 detection regex to
handle HTTP/1.1, HTTP/2, and HTTP/2.0 status lines, and adds rateLimit
introspection to the batch GraphQL query.
Benchmark result (quiet-steady, 5 sessions, 15 min):
- GraphQL points/hr: 260/5,000 (5%) — down from 820–1,416 pre-fix
- ETag guard 304 rate: 100%
- GraphQL batch calls during measurement: 0
Also fixes the benchmark harness to create placeholder tmux sessions with a
claude symlink so the lifecycle actually polls sessions instead of
short-circuiting to "killed".
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* docs(experiments): update plan and notes with B1 benchmark results
B1 fix validated at 5, 10, and 20 sessions in quiet-steady state:
- 5 sessions: 260 GraphQL pts/hr (5% budget)
- 10 sessions: 640 pts/hr (13%)
- 20 sessions: 680 pts/hr (14%) — sub-linear scaling confirmed
- 50-session projection: ~800-1000 pts/hr (16-20%)
- ETag guard 304 rate: 100% at all scale points
- graphql-batch calls: 0 during measurement at all scale points
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(core): log gh wrapper invocations for D1
* fix(core): preserve wrapper logging for dash-prefixed gh args
* feat(core): add gh wrapper cache for PR discovery and issue context (D4)
Add read-through caching to the ~/.ao/bin/gh wrapper, targeting the two
largest agent-side waste buckets identified in D4 analysis:
1. PR discovery (gh pr list --head): infinite TTL for positive results.
598 calls → ~10 per 10-session run (98% reduction).
2. Issue context (gh issue view): 300s TTL.
75 calls → ~20 per 10-session run (73% reduction).
The wrapper now caches successful read-only responses in
$AO_DATA_DIR/.ghcache/$AO_SESSION/ and serves them on subsequent
identical calls. Negative results (empty []) are never cached.
gh pr create populates the PR discovery cache immediately.
Also lifts PATH wrapper installation from individual agent plugins into
session-manager, making it universal for all agents including Claude Code:
- session-manager injects PATH + GH_PATH into every runtime.create()
- session-manager calls setupPathWrapperWorkspace() for all agents
- Removes duplicate buildAgentPath/setupPathWrapperWorkspace boilerplate
from codex, aider, opencode, and cursor plugins
Includes D4 implementation plans in experiments/.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* docs(experiments): add full capacity discovery (5→50 sessions) and CI churn results
Complete scaling curve measured: 50 sessions uses only ~28% of GraphQL
budget with 100% ETag guard hit rate at every scale. Poll cycle lag
identified as first bottleneck (66s at 50 sessions vs 30s target).
CI churn benchmark shows ETag invalidation is a latency problem, not
a rate-limit problem (+9% GraphQL, +4.4x p50 latency).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* docs(experiments): record real-agent catastrophe and Track D handoff
5-real-agent run on todo-app exhausted GraphQL bucket in 31 min (~9572 pts/hr,
~37x quiet-steady at the same session count). AO polling consumed ~10 calls;
the rest came from agents themselves via the metadata-only ~/.ao/bin/gh
wrapper, which has no tracing. Captures findings, adds Track D (agent-side
gh consumption) plus B5 (migrate remaining bare gh callsites to
execGhObserved), and includes the runbook + benchmark scripts Adil will
build on for the cross-machine reproduction.
* feat(core): add cache-hit/miss tracing to gh wrapper (D4)
The wrapper trace now logs a cacheResult entry for every cacheable
command: hit, miss-stored, miss-negative, or miss-error. This makes
benchmark runs conclusive — you can count cache hits vs real gh calls
directly from the JSONL trace instead of inferring from rate-limit
deltas.
Bump wrapper version to 0.4.1.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(scm-github): replace repo-scoped Guard 1 with PR-scoped ETag checks (D4)
Guard 1 now checks GET /repos/{owner}/{repo}/pulls/{number} per PR
instead of GET /repos/{owner}/{repo}/pulls?... per repo. This means:
- Only changed PRs flow into the GraphQL batch
- Unchanged PRs are served directly from the enrichment cache
- shouldRefreshPREnrichment returns a refresh plan (prsToRefresh +
cachedResults) instead of a boolean
When 1 of 10 PRs changes, the old guard refreshed all 10 via GraphQL.
Now only the 1 changed PR is fetched; the other 9 are served from cache
at zero GraphQL cost.
Trade-off: more REST guard calls (1 per PR instead of 1 per repo), but
304 responses cost zero rate limit points.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Forward AO_AGENT_GH_TRACE to session runtimes
* revert: remove PR-scoped ETag guards (Change 3)
Reverts 25ae6013. The per-PR Guard 1 added more REST calls (1 per PR
instead of 1 per repo) without meaningful GraphQL savings at 10-session
scale. Core REST delta went from 16 to 142 while GraphQL rate stayed
flat. The repo-scoped guard is sufficient for current workloads.
Preserves the subsequent 6fc64f4f commit (AO_AGENT_GH_TRACE forwarding).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(core): use real gh binary in execGhObserved, bypass wrapper
execGhObserved() was calling bare "gh" which resolved to ~/.ao/bin/gh
(the wrapper) when that directory was in PATH. This caused:
- AO-side gh calls going through the agent wrapper
- All trace rows with aoSession=null polluting the agent trace
- Cache functions silently failing (no AO_SESSION in AO process)
Now strips ~/.ao/bin from PATH and resolves the real gh binary
(e.g. /opt/homebrew/bin/gh) at startup. Cached after first resolution.
AO process → execGhObserved → real gh → AO_GH_TRACE_FILE
Agent process → ~/.ao/bin/gh wrapper → AO_AGENT_GH_TRACE + cache
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(core): harden gh wrapper caching and agent-side tracing
Cache correctness:
- Include --json fields in cache key (prevents stale partial responses)
- Only cache stdout, not stderr (prevents warning contamination)
- Fix trailing newline inconsistency in PR discovery cache
- Support --key=value arg syntax for all cached flags
- Remove PR create cache pre-population (hardcoded fields, no JSON escaping)
- Log miss-write-failed when ao_cache_write fails (previously silent)
Agent trace improvements:
- Add operation field to invocation rows (gh.pr.list, gh.issue.view, etc.)
- Add durationMs, exitCode, ok to cache outcome rows
- Log passthrough for all non-cached code paths (pr/create, default case)
- Replace exec with child process in default case to enable post-call tracing
Bump wrapper version to 0.6.0.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(runtime-tmux): re-export PATH after shell init to survive macOS path_helper
macOS zsh runs path_helper during shell startup which resets PATH,
wiping entries set via tmux new-session -e. This caused ~/.ao/bin
to be lost, so the gh/git wrappers were never intercepting agent
calls — no caching, no tracing, no metadata auto-updates.
Fix: send `export PATH=...` via send-keys after the shell has
initialized but before the launch command, ensuring PATH sticks.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(runtime-tmux): use launch script for PATH re-export instead of send-keys
The previous send-keys approach sent 1000+ literal keystrokes for the
PATH value, which broke terminal input buffers and caused stuck quote
prompts. Instead, include the PATH export in the launch script file
which is executed directly — no terminal buffer issues.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* docs(experiments): add AO-side gh rate-limit trace report
5-session, 15-minute trace analysis with full call breakdown,
ETag guard effectiveness, anomaly investigation, and ranked
reduction opportunities.
Key findings:
- GraphQL at 41%/hr with 5 sessions (bottleneck at ~12 sessions)
- 47% of calls are individual REST fallbacks that batch should cover
- Review thread GraphQL calls (55/15min) can be folded into batch
- detectPR() and guard failures are working as designed
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* docs(experiments): add AO rate-limit reduction plan with Step 1
Step 1: Remove individual REST fallback from determineStatus().
110 calls (65 pr view + 45 pr checks) eliminated per 15-min window.
Batch enrichment covers all PRs every 30s — fallback is unnecessary
insurance for an event that never occurred in real traces.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* experiments(m2): drop agent-trace gate for claude-code
Claude Code uses native PostToolUse hooks (.claude/settings.json), bypassing
the ~/.ao/bin/gh PATH wrapper, so AO_AGENT_GH_TRACE stays empty even when
Claude makes gh calls. The previous smoke gate required AGENT_ROWS>0 and
aborted every claude-code M2 batch at smoke.
- limit-finder.sh: add REQUIRE_AGENT_TRACE env + --no-require-agent-trace flag,
gate the AGENT_ROWS integrity check behind it.
- m2-ab-run.sh: bump SMOKE_DURATION to 420s; auto-pass --no-require-agent-trace
when AGENT=claude-code; simplify smoke_check to gate only on AO_ROWS>0
(B1 lives in AO-side scm-github, measured by AO trace).
Follow-up tracked as task #39: instrument Claude's hook/tool path or document
that AO_AGENT_GH_TRACE does not cover Claude Code.
* feat(tracker-github): cache issue reads in-process (5 min TTL)
The lifecycle worker polls getIssue/isCompleted repeatedly for the same
issue across a session. Trace data from a 5-session tier-5 bench run
showed the same (repo, issue) pair fetched 64+ times with >97% duplicate
rate — ~744 of 4,059 AO gh calls in 10 minutes were redundant issue views.
Adds an in-process Map<string, CachedIssue> per createGitHubTracker()
instance, keyed by `${repo}#${id}`, TTL 5 min, bounded to 500 entries
(LRU evict-oldest on overflow).
- getIssue: read-through cache, populate on miss
- isCompleted: routes through getIssue (was a separate narrow gh call)
- updateIssue: invalidate the entry before mutating
- createIssue: unchanged, naturally populates via the existing getIssue
- Failures are not cached
Cache lives inside createGitHubTracker so each create() returns an
isolated cache (test isolation comes for free).
Expected reduction: ~744 → ~15 gh issue view calls per tier-5 run.
Tests: 41 existing + 10 new cache tests, all passing.
* feat(scm-github): cache 5 gh pr view callsites with per-method TTLs
The lifecycle worker repeatedly polls each PR for state, summary, reviews,
and review decision. Trace data showed gh pr view was the single largest
AO-side endpoint at 1,280 calls per 5-session tier-5 run with >97% duplicate
rate (e.g. PR #184 polled 86× for --json state alone in 11.5 minutes).
Adds an in-process per-instance cache inside createGitHubSCM(), keyed by
${owner}/${repo}#${prKey}:${method} so different field-sets stay isolated.
Per-method TTLs balance reduction against staleness on decision-influencing
fields:
- resolvePR: 60s (identity metadata only)
- getPRState: 5s
- getPRSummary: 5s (includes state)
- getReviews: 5s
- getReviewDecision: 5s
assignPRToCurrentUser, mergePR, and closePR each invalidate the entire PR
cache for that PR after the mutation, so AO never sees stale state from its
own writes. Failures are not cached.
getCIChecksFromStatusRollup and getMergeability are intentionally NOT cached
here — those need ETag-based revalidation, not blind TTL, and will land
separately.
Expected reduction: ~1,165 of ~1,280 gh pr view calls per tier-5 run.
Tests: 73 existing + 12 new cache tests, all 153 passing.
* feat(scm-github): cache CI checks, mergeability, pending comments, detectPR
Completes the AO-side hot-read caching alongside the prior PR view cache.
All use 5s TTL per the approved policy for decision-influencing fields —
well under one lifecycle poll cycle so state transitions are still seen
next pass.
- getCIChecks (gh pr checks): 5s TTL
- getMergeability (composite pr view + CI + state): 5s TTL on the composite
- getPendingComments (gh api graphql review threads): 5s TTL —
ETag doesn't help on GraphQL per Experiment 2
- detectPR (gh pr list --head BRANCH): 5s TTL, POSITIVE-ONLY.
Empty results are never cached so a freshly created PR is discovered
on the very next poll. The branch-keyed cache entry is invalidated
by mergePR/closePR alongside the number-keyed entries.
Combined with the prior PR view cache, covers the top 6 AO-side gh
operation categories that accounted for ~85% of calls in tier-5 traces.
Tests: 85 existing + 9 new cache tests, all 162 passing.
* experiments(m2): parse REPO from yaml before using it in banner
m2-ab-run.sh referenced $REPO in the header banner before parsing it,
causing 'unbound variable' abort under 'set -u'. Parse it right after
CONFIG_FILE is set.
* test(core): mock full Issue shape in plugin-integration cleanup tests
After tracker-github routed isCompleted() through getIssue() to share
the issue cache, these mocks needed the full Issue shape (number, title,
body, url, state, stateReason, labels, assignees) instead of the narrow
{state} shape that worked when isCompleted made its own --json state call.
* perf(scm-github): tune cache TTLs based on trace replay
Replayed feat run1 + main run2 tier-5 traces (4059 + 1748 rows, 38 min, 5
sessions each) against the shipped cache logic. Three TTLs were materially
under-tuned for the actual lifecycle poll cadence:
- detectPR: 5s → 30s (was 0.5% hit rate; per-branch poll cadence
is ~90s, so 5s caught nothing. 30s catches
intra-cycle bursts when multiple sessions
share a branch. Positive-only stays.)
- getReviewDecision: 5s → 10s (within "10-30s TTL or ETag" policy)
- getPendingComments: 5s → 10s (same policy class)
All three are still well under one poll cycle; freshness contract unchanged
in practice. Other TTLs (5s on state/CI/mergeability, 60s on resolvePR,
5min on issue) hit the targets they were set for and stay as-is.
Replay results before/after:
- feat run1: 53.7% → 57.8% reduction (2179 → 2345 hits of 4059 calls)
- main run2: 47.4% → 52.6% reduction
- Net: ~55% AO-side gh calls eliminated across both traces
Adds experiments/cache-replay.mjs — a counterfactual replay tool that
walks an execGhObserved JSONL trace and simulates per-method cache hits
with the shipped TTLs. Useful as a regression check when tweaking cache
policy.
Tests: 162/162 passing.
* docs(experiments): add cache freshness check runbook
Seven-step manual runbook to validate the cache TTL contract doesn't
cause workflow lag. Covers each cached method with:
- exact gh CLI trigger command
- what to observe in the dashboard / lifecycle log
- pass/fail threshold (TTL + 30s poll cycle)
Companion to experiments/cache-replay.mjs — replay measures how much
we saved, runbook measures whether we lost anything in the process.
* docs(experiments): add Step 2 — consolidate review comment fetching
Single GraphQL call replaces GraphQL + REST for review comments.
Include comment data in agent reaction message to eliminate
agent-side gh read calls. Update future steps.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* docs(experiments): add duplicate API traffic analysis
Three independent sources hit GitHub API for the same PRs:
1. Dashboard serialize.ts — individual REST calls, no batch, no cache
2. CLI lifecycle manager — batch + guards
3. Web lifecycle manager — same batch + guards, 3s offset
~50% of all API traffic is pure duplication. Dashboard and dual
lifecycle managers are the root causes.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* docs(experiments): add full cache architecture to duplicate traffic analysis
Three independent cache layers across two processes with zero shared
state. Web process creates its own plugin registry, SCM plugin, lifecycle
manager, and dashboard cache — all hitting GitHub independently.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* docs(experiments): add shared PR enrichment plan
Persist batch enrichment + review comments to session metadata files.
Dashboard reads from disk instead of making its own GitHub API calls.
Remove web's duplicate lifecycle manager.
Eliminates ~268 calls / 15 min (58% of all traffic). Dashboard data
gets fresher (30s vs 5min). Single writer (CLI lifecycle), web only reads.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* docs(experiments): update Step 1 — remove all three fallback paths
Remove fallback in determineStatus(), maybeDispatchCIFailureDetails(),
and maybeDispatchMergeConflicts(). All three follow the same pattern:
batch cache hit → use it, cache miss → skip (wait 30s for next batch).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* docs(experiments): promote Step 3 (remove dead reviews field) + detail Step 5 (issue caching)
Step 3: Remove reviews(last: 5) from batch query — fetched but never
consumed, reduces GraphQL complexity on every batch call.
Step 5: Persist issue data to session metadata at spawn — eliminates
27 gh issue view calls per 15 min (both processes re-fetch independently).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(core): remove individual REST fallback from lifecycle polling
Remove fallback paths in determineStatus(), maybeDispatchCIFailureDetails(),
and maybeDispatchMergeConflicts() that made individual REST calls when the
batch enrichment cache missed. The batch runs every 30s — a cache miss
means the data arrives on the next cycle, not that it's lost.
Also add populatePREnrichmentCache() call to check() so single-session
checks also use the batch path.
Eliminates ~110 individual pr view/pr checks calls per 15-min window
(24% of all AO-side traffic).
* feat(core): consolidate review comment fetching into single GraphQL call
Add getReviewThreads() to SCM interface — returns all review threads
(human + bot) with isBot flag from a single GraphQL query. Lifecycle
manager splits locally for separate reaction pipelines.
- Eliminates the REST getAutomatedComments() call (40 calls / 15 min)
- Reaction messages now include inline comment data (file, line, author,
body, URL) so agents don't need to re-fetch via gh api
- Default config messages updated to not tell agents to call gh
- getAutomatedComments kept as optional for backward compatibility
* perf(scm-github): remove unused reviews(last: 5) from batch query
The batch query fetched reviews with author, state, submittedAt but
the data was never consumed — only used in a validation check.
The reviewDecision scalar field provides everything AO needs.
Reduces GraphQL complexity cost on every batch call.
* docs(experiments): add post-optimization trace report (Steps 1-3)
5-session, 17-minute trace after removing REST fallback, consolidating
review comments, and removing dead reviews field.
Results: GraphQL 35%/hr (was 41%), REST <1% (was 3%), automated
comment REST calls eliminated. 54% of remaining traffic is redundant
(duplicate lifecycle manager + dashboard individual calls).
* docs(experiments): add trace file gist link to post-optimization report
* feat(core,web): shared PR enrichment — dashboard reads from metadata
CLI lifecycle manager now persists batch enrichment data and review
comments to session metadata files (prEnrichment + prReviewComments
keys). The web dashboard reads from metadata instead of calling
GitHub API.
Changes:
- lifecycle-manager: add persistPREnrichmentToMetadata() after poll,
write prReviewComments in maybeDispatchReviewBacklog()
- serialize: replace enrichSessionPR (6 API calls) with metadata read
- services: stop web lifecycle polling (keep for webhook checks)
- cache: remove prCache (no longer needed)
- routes: remove timeout wrappers and cacheOnly pattern
Eliminates ~237 calls / 15 min (54% of all AO-side traffic).
Dashboard data freshness improves from 5min to 30s.
* fix(web): remove unused beforeEach import in serialize test
* docs(experiments): add final trace report — 56% GraphQL reduction achieved
5-session, 24-min trace after all optimizations including shared
enrichment. GraphQL 905/hr (was 2,072), REST 5/hr (was 168).
Single lifecycle manager confirmed. Dashboard API calls eliminated.
Max sessions before budget exhaustion: ~27 (was ~12).
* docs(experiments): add REST budget breakdown to final report
* fix(core): use storageKey for getSessionsDir in persistPREnrichmentToMetadata
* fix(test): use OpenCodeSessionManager type in plugin-integration tests
* fix(test): update bugbot-comments and auto-cleanup tests for new review API
* fix(web): fix syntax error and missing import from rebase
* docs(experiments): add complete rate-limiting change log and update final report numbers
* fix(web): fix tmux session resolution for legacy wrapped storageKeys
* fix(web): pass tmuxName directly to terminal server instead of reverse-resolving
* perf(core): gate detectPR behind Guard 1 ETag — skip when PR list unchanged
* perf(core): always run Guard 1 for all repos, dedup issue views, include threadId in review messages
* feat(core): add Guard 3 (review ETag), enrich review data with summaries, dedup issue views, gate detectPR for all repos
* fix(web): reuse cached tmuxSessionId on re-open, add 15-session trace report and comparison docs
* perf(scm-github): reduce contexts to first:10, add -i to review GraphQL for rate limit tracing
* feat(core): merge CI details into transition, enrich merge conflict message, reduce batch contexts, add graphqlCost tracing
* chore(experiments): remove working artifacts, keep final reports and reference docs
* chore: remove experiments directory
* refactor: remove getAutomatedComments from SCM interface and all implementations
* fix: address all PR review comments
- gh-trace: make binary resolution async via fs.access (no event loop
blocking, no shell injection), cache mkdir for trace writes, async
fire-and-forget appendFile, document 10MB maxBuffer rationale
- lifecycle-manager: log detectPR failures via observer instead of
silent catch, add getPRState fallback for terminal states
(merged/closed) when batch enrichment cache misses
- scm-gitlab: implement getReviewThreads with bot+human threads and
isBot flag, fixing silent feature regression after
getAutomatedComments removal
- scm-github: clear reviewThreadsCache in invalidatePRCache, document
first:11 CI checks cost budget
- runtime-tmux: use printf+JSON.stringify for PATH export to prevent
shell injection from single quotes
- agent-workspace-hooks: add cache timestamp sanity check, include
--repo in cache keys to prevent cross-repo collisions
- services: document dashboard dependency on CLI polling
- tests: update gh binary path assertions for resolved paths
* fix: address all 17 PR review comments
- gh-trace: use path.delimiter, cache-only-on-success, last HTTP status
line, await writes with warn-once, redact secrets, gate JSON.parse
- agent-workspace-hooks: validate cache keys, redact wrapper trace args,
sha256 cache keys to prevent collisions, 120s TTL ceiling
- session-manager: skip PATH wrappers for claude-code (native hooks)
- types: add deprecation JSDoc for getReviewThreads
- graphql-batch: clear Guard 3 in clearETagCache, re-read ETag on 304,
switch Guard 2 to check-runs endpoint, drop per_page=1 from Guard 3
- Add gh-trace unit tests for extractOperation, redactArgs, parseHttp
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: adil <adil.business4064@gmail.com>
Co-authored-by: iamasx <adilshaikh4064@gmail.com>
- platform.ts: add windowsHide:true to pwsh/powershell/taskkill/netstat
spawns so AO no longer flashes a console window for each subprocess.
- script-runner.ts: auto-detect Git Bash at the common install paths on
Windows when AO_BASH_PATH is unset; tighter error if neither auto-detect
nor override succeeds. WSL bash intentionally excluded — invoking it
from Windows-native Node mixes Linux paths with Windows cwd and
silently breaks repo scripts. Also adds windowsHide:true to the spawn.
- web/services.ts: register @aoagents/ao-plugin-runtime-process so the
dashboard can spawn sessions on projects using runtime: process
(the Windows default per getDefaultRuntime).
- pty-client.ts: chunk ptyHostSendMessage into 512-char frames with a
15ms gap so large prompts (~3-4KB+) are no longer truncated by
ConPTY's input buffer. Cross-platform safe; Unix PTYs absorb chunks
at full speed. Trailing Enter still sent as a separate frame after
the existing 300ms pause.
- Mock test helpers updated to handle the (cmd, args, options, callback)
arity introduced by passing windowsHide; new test covers Git Bash
auto-detection.
killDashboardOnPort runs a unix-only `ps` cmdline check before killing.
Without mocking exec, the call rejects and the catch returns false, so
killProcessTree is never called and the assertion fails on Linux CI.
Resolve tmux-utils.ts conflict: rewrite resolvePipePath to read
runtimeHandle.data.pipePath from on-disk session metadata instead of
recomputing a hash from AO_CONFIG_PATH. Mirrors the storageKey
disambiguation pattern upstream introduced for resolveTmuxSession in
PR #1488 (eca3001c). Recomputing the hash drifted from the runtime's
deriveStorageKey on Windows (raw backslash paths vs POSIX-normalized),
so the dashboard's named-pipe relay was connecting to the wrong path
and falling back to ENOENT while ao session attach worked fine.
Also normalize path separators in 6 upstream resolveTmuxSession tests
that hard-coded Unix forward slashes in their exists() mocks; on
Windows path.join produces backslashes and the assertions never
matched.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Restore was writing the freshly-spawned runtime handle to the top-level
metadata `runtimeHandle` key, but the canonical lifecycle parser prefers
`statePayload.runtime.handle` and falls back to the top-level only when
statePayload is missing. The next lifecycle tick read the stale handle
from statePayload and rewrote both keys from it, silently undoing the
restore's update.
Symptom: a session restored after AO restart kept the old PID in
metadata. Lifecycle probe found that PID dead (it was from a previous
boot) and the dashboard rendered the orchestrator as exited/killed even
though a new process was actually running.
Fix: rebuild the canonical lifecycle with the new handle via
buildUpdatedLifecycle() and persist via lifecycleMetadataUpdates() so
statePayload and runtimeHandle stay in sync. Mirrors the pattern used
in kill/spawn paths.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Before this, vitest worker isolation only overrode HOME, but on Windows
os.homedir() reads USERPROFILE. Tests that wrote to ~/.agent-orchestrator
ended up sharing the real user's data directory across workers, leading
to flaky cross-test pollution.
- test-utils: createTestEnvironment / setupTestContext now override both
HOME and USERPROFILE to the per-test fake home, restore both on
teardown. rmSync uses maxRetries:5/retryDelay:50 to ride out the same
Windows file-lock window that atomic-write retries cover.
- core test files (global-config, plugin-integration, portfolio-*,
project-resolver, recovery-actions, orchestrator-prompt*): set fake
USERPROFILE alongside HOME and use the retry-aware rmSync.
- update-check.test: normalize path separators in assertions
(path.replace(/\/g, "/")) so script-path matching works on Windows
without forcing the production code to emit posix paths.
- orchestrator-prompt.dist.test: pass shell:true on Windows to execFileSync
for .cmd targets, working around Node CVE-2024-27980's hardening.
Removed lifecycle-service.test.ts — it covered stopLifecycleWorker, which
was deleted when lifecycle was moved in-process during the merge with
main. The remaining lifecycle paths are exercised by lifecycle-manager
tests in core.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
runtime-process:
- Reserve the per-instance processes-map slot before the platform split
so the Windows ConPTY branch participates in duplicate-create detection
and getMetrics/getAttachInfo bookkeeping. Previously, Windows returned
a handle without storing it, so duplicate session IDs were silently
accepted and getMetrics always reported 0 uptime.
- Add 500ms graceful-exit poll before SIGKILLing the pty-host on destroy
so node-pty can dispose its ConPTY handle. Skipping this orphaned the
conpty_console_list_agent helper and triggered Windows Error Reporting
dialogs (0x800700e8) on real runs, not just tests.
- pty-host: install SIGTERM/SIGINT/SIGHUP/SIGBREAK/beforeExit handlers
that drive the same shutdown sequence (kill pty, drop clients, close
pipe, exit after 50ms grace), and route MSG_KILL_REQ through the same
path. Previously MSG_KILL_REQ only called pty.kill() and left the host
process lingering.
- Add windowsHide:true to the pty-host child spawn so node-pty's helper
console window stays hidden on errors.
workspace-worktree: normalize paths to a comparable POSIX form
(backslash→slash, lowercase drive letter) when matching git worktree
list --porcelain output against project directories. git emits
forward-slash paths on Windows; path.join produces native backslashes
— the comparison failed and list() returned empty.
agent-opencode: guard tmux/ps usage with isWindows() in isProcessRunning
so process-runtime sessions on Windows take the PID-signal path instead
of attempting Unix-only commands.
cli/start: detect Windows local paths in isLocalPath (drive letter
prefix, UNC path, .\, ..\) so spawn arguments like C:\... aren't
mistaken for project names.
integration test: replace cat + /tmp with platform-native echo (findstr
"x*" on Windows, cat on Unix) and os.tmpdir(); the original used
Unix-only tooling and would never run on Windows.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
storage-key: normalize to POSIX form (strip drive letter, replace backslash
with forward slash) before hashing so identical repos produce identical
hashes across Windows and Unix, and across different Windows working
directories of the same checkout.
atomic-write: retry renameSync up to 10x with 50ms backoff when Windows
returns EPERM/EACCES/EBUSY — antivirus, file indexer, or backup software
briefly hold handles to recently-written files. Cleans up the temp file
on final failure so subsequent retries don't trip "file exists".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(cli): hide legacy spawn second positional (#1490)
Keep ao spawn help aligned with the real interface by exposing a single optional issue positional and rejecting extra positional arguments with replacement usage before any spawn work starts.
* fix(cli): align spawn usage with optional issue (#1490)
Keep the arity error and its test aligned with the optional [issue] contract surfaced by ao spawn help so review-thread guidance and user-facing usage match exactly.
* fix(web): resolve tmux sessions when storageKey is wrapped ({hash}-{projectName})
ao-core names tmux sessions as `{storageKey}-{sessionId}`, where
storageKey can be either a bare 12-hex hash or the legacy wrapped form
`{hash}-{projectName}`. The resolver only handled the bare-hash form, so
the dashboard terminal never attached for any project whose
storageKey includes the project-name segment (the default on 0.3.0).
Fix (closes#1486): resolve the owning storageKey from disk via
`~/.agent-orchestrator/{storageKey}/sessions/{sessionId}`, then ask tmux
for the exact `{storageKey}-{sessionId}` name via `has-session -t =`.
The on-disk record is authoritative, which avoids ambiguous suffix
matches (e.g. looking up `app-1` must not resolve a distinct session
`{hash}-my-app-1`). If multiple storageKeys own the sessionId (rare,
same-sessionId across projects), each candidate is probed so a stale
metadata dir can't shadow the live session of another project. The
tmux-list-sessions fallback still recovers bare-hash sessions when the
on-disk record is absent.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(web): filter readdir to directories only in tmux resolver
readdirSync without withFileTypes includes plain files, so a stray
file matching STORAGE_KEY_PATTERN (e.g. a backup file named
`aabbccddeef0`) would trigger an unnecessary existsSync probe.
Harmless for correctness but an avoidable syscall per attach.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(web): prevent kanban card blink on attention-level column changes (#1446)
* fix(web): address SessionCard review feedback
* fix(web): preserve #1446 first-entry animation on remount
Avoid mutating enteredSessionIds inside the state initializer so dev remounts still show the first visible kanban entrance animation.
Add a regression test that covers unmounting before the first animation frame and verifies later remounts still skip the animation.
---------
Co-authored-by: yyovil <itsyyovil@gmail.com>
* feat: add zsh completion for ao (#1371)
Add a generated zsh completion command and dynamic completion backend so ao can tab-complete projects and session IDs without relying on jq or brittle text parsing. Document standard zsh and Oh My Zsh install paths, and cover the new flow with CLI tests.
* fix(cli): address copilot completion review feedback for PR 1374
* fix completion and harden local workflow parsing
* Update packages/cli/src/lib/completion.ts
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
* fix completion regressions and agent-ci review feedback
---------
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
* fix(web): fix scroll-to-bottom button in terminal
Two bugs with the scroll-to-bottom button:
1. Button disappeared as soon as the user started swiping down, instead
of staying visible until the live tail was actually reached.
Root cause: the touch-scroll onScrollTowardLatest callback immediately
set followOutput=true on the first downward swipe. Removed that
callback — in normal buffer, terminal.onScroll decides based on real
viewport position; in alternate buffer (tmux), the button stays
visible until the user explicitly clicks it.
2. Clicking the button did nothing when running inside tmux.
Root cause: the terminal runs in xterm's alternate buffer when tmux
is active. terminal.scrollToBottom() has no effect in alt buffer
(xterm has no scrollback there — the user is viewing tmux copy-mode
scrollback). The button click now detects the buffer type: normal
buffer uses terminal.scrollToBottom(); alternate buffer sends "q" to
exit tmux copy-mode and return to the live tail.
Additionally replaced the previous DOM viewport.scrollTop approach
and DOM scroll event listener with terminal.scrollToBottom() and
terminal.onScroll respectively — xterm v6 may update scrollTop via
requestAnimationFrame, making raw DOM scroll events unreliable.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(web): move orchestrator session info to top bar; show orchestrator button on mobile
Two session-detail page fixes for orchestrator sessions:
1. Orchestrator session info (status, branch, PR pills) now lives in the
same top bar used by regular sessions, instead of a large stacked
strip above the terminal. Adds an inline "orchestrator" badge and a
compact row of per-zone agent-count pills (merge / respond / review /
working / pending / done) next to the existing status + branch pills.
Removes the now-unused OrchestratorTopStrip and _OrchestratorStatusStrip
helpers (~270 lines removed).
2. The orchestrator navigation link button on worker session pages was
hidden on mobile (≤640px) due to a topbar-desktop-only class. Removed
the class so it appears on mobile as an icon-only button (the text
label is already suppressed on mobile via topbar-btn-label CSS).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(web): remove unused crumbHref/crumbLabel vars to fix lint
* fix(web): allow topbar zone pills to wrap; hide labels on mobile
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* perf(core): cache sessionManager.list() to prevent redundant I/O on every poll
sessionManager.list() performs O(n) synchronous disk reads and async subprocess
calls (runtime liveness checks, activity detection) per session on every
invocation. Multiple concurrent callers — SSE poll (5s), lifecycle manager (30s),
API refreshes, backlog poller — all trigger independent full-I/O list() calls.
As sessions accumulate, each call gets proportionally slower, causing the
dashboard to take increasingly long to load.
Add an in-memory cache with 2-second TTL and request coalescing:
- Cached results are returned within the TTL window (no I/O)
- Concurrent calls to list() share a single in-flight request
- Cache is invalidated on any mutation (spawn, kill, cleanup, restore, etc.)
This collapses redundant I/O when multiple callers poll within seconds of each
other, directly fixing the slow dashboard load after prolonged running.
Closes#1049
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(core): prevent stale in-flight list() results from caching after invalidation
Address review feedback: invalidateListCache() now clears the in-flight
promise map and increments a generation counter. When a list() call
completes, it only writes to cache if no invalidation happened during
the fetch. This prevents a race where kill() invalidates the cache
mid-flight but the stale promise's result overwrites it with a fresh
timestamp.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(core): add post-mutation cache invalidation and inflight promise ownership check
Address two review findings:
1. Post-mutation invalidation: invalidateListCache() was only called at
the start of mutations. A concurrent list() running mid-mutation would
read pre-mutation disk state and cache it with a fresh timestamp. Now
invalidateListCache() is also called after mutations complete, clearing
any stale cache entries populated during the mutation window.
2. Inflight promise ownership: the finally block unconditionally deleted
the inflight cache entry by key. If invalidation replaced it with a
new promise, the old finally would delete the replacement. Now it
checks that the stored promise is still its own before deleting.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(web): skip PR enrichment for terminal sessions in /api/sessions
Prevent unnecessary GitHub API calls for dead sessions by filtering out
sessions in terminal states (killed, done, merged, terminated, cleanup)
from PR enrichment. This reduces API calls from ~108 to ~48 on the test
machine's configuration, directly addressing the slow dashboard load issue.
Motivation: PR enrichment was calling enrichSessionPR() for all sessions
with a PR field, including killed sessions. With 18 sessions having PRs
and 10 of them killed, this resulted in 60 wasted API calls per dashboard
load. The metadata enrichment already correctly skips terminal sessions —
this aligns the PR enrichment behavior to match.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(web): keep only terminal PR enrichment optimization
* fix(web): preserve terminal PR cache for #1049
* fix(web): refresh open PRs for #1049
Only treat merged and closed PRs as terminal for dashboard PR enrichment so killed runtimes with still-open PRs keep receiving live SCM updates. Fall back to a blocking refresh on cold-cache terminal PRs so merged and closed sessions can self-heal after cache expiry.
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(skills): add ao-weekly-release skill for automated release notes (#1231)
Adds the in-repo skill files the bot cron invokes every Thursday 10:00 IST
to generate and post weekly release notes to Discord. Keeping the skill
under version control means merged PRs to main are picked up on the next
run with no manual redeployment.
- SKILL.md: output contract, style constraints, failure-mode table, and
the update workflow so future editors know what the runner must guarantee
- run.py: deterministic stdlib-only runner that queries gh for the latest
release, merged PRs, commits, contributors, and stars in a 7-day window
and renders a publishable markdown post in the established house style
The runner never fabricates data — every rendered value traces back to a
gh or git command, and missing data surfaces as "(unavailable)" or a
non-zero exit code so the cron can post a "quiet week" message instead.
Refs #1231
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(skills): address review findings in release notes runner (#1231)
1. Install command: read version from npm registry instead of deriving
from the GitHub release tag, which uses scoped npm tags like
@composio/ao-cli@0.2.2 and produced malformed specifiers.
2. gh release create: same root cause — use npm version, not tag.
3. Full changelog link: resolve window dates to commit SHAs via
git rev-list / GitHub API instead of main@{date} reflog syntax,
which only works locally and 404s on GitHub.
4. Truncation count: track total omitted PRs across the initial
MAX_HIGHLIGHTS cap and the body-size truncation pass, and render
one accurate overflow line at the end.
5. Highlight bullets: skip the theme verb prefix when the cleaned
title already starts with a verb (avoids "Added add …" stutter),
and strip trailing (#NNN) refs from titles before appending the
canonical (#{pr.number}) to avoid doubled refs.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(cli): derive projectId from prefixed issue id on spawn
When `ao spawn <projectId>/<issue>` is used in a multi-project config,
route the spawn to the prefixed project and strip the prefix from the
issue id. Previously the projectId fell back to whichever project
`ao start` was running for, tagging cross-project sessions with the
wrong project (and session prefix).
Applies to both `ao spawn` and `ao batch-spawn`; batch-spawn errors
out if issues mix multiple project prefixes.
Fixes#1329
* refactor(core,cli): lift spawn target resolution into core
Move the issue-prefix → project routing from the CLI into a reusable
core utility, `resolveSpawnTarget(projects, issueRef, fallback?)`.
- Exposes routing to any spawn entry point (CLI, web API, programmatic).
- Accepts either a project id or sessionPrefix as the prefix; project id
wins on collision.
- `ao batch-spawn` now groups issues by resolved project and preflights
once per group instead of erroring on mixed prefixes.
Tests:
- 8 unit tests for `resolveSpawnTarget` in core.
- CLI spawn.test.ts: adds a sessionPrefix routing test (23 tests total).
Refs #1329
* test(cli): cover batch-spawn grouping; tighten resolveSpawnTarget API
Self-review found three gaps:
- `resolveSpawnTarget` accepted `undefined` issueRef and returned
`{ issueId: "" }` with a fallback project. Dead path — both callers
guard against undefined. Drop it and make issueRef required.
- No tests for `batch-spawn`. Added two:
- routes cross-project prefixed issues to the correct project and
lists each project's sessions separately
- skips a prefixed issue when the target project already has an
active session for it
- `spawn` and `batch-spawn` help text didn't document the
`<projectId>/<issue>` or `<sessionPrefix>/<issue>` forms.
* fix(core): guard resolveSpawnTarget against prototype-key matches
Second review pass found a latent bug:
Plain JS objects inherit `__proto__`, `constructor`, `toString`,
`hasOwnProperty`, etc. from Object.prototype. The previous
`if (projects[prefix])` check entered the routing branch for any of
these keys, mis-routing a user typing `ao spawn __proto__/42` with
`projectId: "__proto__"`. Downstream session-manager would then receive
a junk project object (Object.prototype itself) and fail with a
non-obvious error.
Fix: use `Object.prototype.hasOwnProperty.call(projects, prefix)` so
only actual configured project ids match.
Also:
- Document the case-sensitive matching semantic explicitly.
- Lock in the batch-spawn grouping contract by asserting exact
`list()` and `spawn()` call counts in the cross-project test.
* fix(core): dispatch detailed bugbot review comments to agents
The bugbot-comments reaction previously sent a generic "fix the issues"
message, leaving the agent to discover comments on its own. The typical
agent flow — fetch `GET /pulls/{pr}/comments` (first page only) and
filter for a bugbot marker — misses new comments when the response is
paginated, and trusts a stale `gh pr checks` status.
Now we format the already-fetched `AutomatedComment[]` into a detailed
message that lists each comment (severity, path:line, bot name, excerpt,
URL) and embeds explicit API-level guidance so the agent can verify via
`/reviews` + `/reviews/{id}/comments` + paginated `/pulls/{pr}/comments`
(checking `in_reply_to_id` for replies) instead of the naive first-page
scan.
Closes#895
* refactor(core): extract bugbot comment formatter, address review
Follow-up to #895 review:
- Extract formatAutomatedCommentsMessage to a module-level helper
(packages/core/src/format-automated-comments.ts) so it is directly
unit-testable rather than closure-scoped inside createLifecycleManager.
- Accept optional PRInfo and interpolate owner/repo/number into the
verification guidance when dispatched from the lifecycle; fall back
to OWNER/REPO/PR placeholders otherwise.
- Drop the dead `if (c.url)` guard (url is required on AutomatedComment).
- Append `…` when the first-line excerpt is truncated at 160 chars.
- Simplify the config.ts default message to a short fallback and note
inline that the lifecycle dispatcher injects the rich listing.
- Add 11 focused unit tests for the formatter (interpolation, ellipsis,
first-line extraction, missing path/line, severity rendering, ordering).
* fix(core): address review feedback on bugbot detail dispatch
Resolves the 6 comments from @illegalcall on #1334:
- H: Sentinel-gate the default-message override — export
DEFAULT_BUGBOT_COMMENTS_MESSAGE from config.ts and only replace the
reaction message when it matches the sentinel. User-customized
messages in project YAML are left untouched.
- H: Add --paginate to step 2 of the verification guidance. Step 2 was
previously missing --paginate, reintroducing the exact pagination
failure mode #895 is meant to fix.
- H: Prompt-injection mitigation — strip backticks from comment body
excerpts, wrap each excerpt in a code span so the content cannot
break out, and add an "untrusted third-party data" preamble telling
the agent not to treat excerpts as instructions.
- M: Preserve line number when c.line === 0 (file-level or 0-indexed
tools). Was previously treated as falsy and dropped.
- M: Skip leading blank lines in excerpt extraction; strip leading
markdown heading markers (### Title) and whole-line bold/italic
wrappers before truncating.
- L: Correct the "reply resolves the thread" wording — replying alone
does not resolve a review thread on GitHub; resolution is a separate
"Resolve conversation" action.
Also adds a patch-level changeset for @aoagents/ao-core and expands
the test suite to 811 tests (was 803) covering sentinel override,
custom-message passthrough, line===0, backtick escaping, heading
stripping, and step-2 pagination regression.
The tmux status bar was explicitly turned off when attaching a PTY
to a tmux session via the web terminal. This hid useful session
context (session name, window list, datetime).
Change status from 'off' to 'on' so users and agents can see the
tmux status bar in both CLI and web terminal views.
Closes#1469
Co-authored-by: AO Bot <ao-bot@composio.dev>
* feat(sessions): derive display names from task context (#1220)
Orchestrator and ad-hoc sessions used to render as "Orchestrator/Ao
Orchestrator 8" or "Ao 42" — humanized branch names that revealed
nothing about the work. getSessionTitle()'s fallback chain landed on
humanizeBranch() whenever no PR or enriched issue title was available,
and humanizeBranch() didn't know to strip the "orchestrator/" prefix
or recognise that "ao-orchestrator-8" is just the session ID.
Fix it at two levels:
Level 1 — humanizeBranch() cleanup
- Add "orchestrator" to the prefix-strip regex.
- Accept an optional sessionId argument; when the stripped branch is
just the session ID (e.g. "orchestrator/ao-orchestrator-8" -> "ao-
orchestrator-8"), return an empty string so getSessionTitle() can
fall through to the next meaningful fallback instead of rendering
noise.
Level 2 — persisted displayName derived at spawn time
- New optional SessionMetadata.displayName field, captured from the
best available context when the session is spawned: issue title
for tracker-backed sessions, the first line of a user prompt for
prompt-only sessions, and the first line of the system prompt for
orchestrators. Values are collapsed to a single line and truncated
to 80 chars with an ellipsis.
- metadata.ts serialises and reads the new field.
- DashboardSession gains a displayName field; sessionToDashboard()
pulls it from session.metadata.
- getSessionTitle() gains a new fallback ordered above the humanized
branch, so sessions remain identifiable even when the tracker API
is unavailable or an orchestrator has no attached issue.
Tests:
- metadata.test.ts: displayName round-trip.
- spawn.test.ts: displayName derivation from issue title, user prompt
(first line), system prompt; truncation with ellipsis; omission
when there is no context.
- format.test.ts: orchestrator/ prefix handling, session-ID collapse,
displayName fallback, orchestrator bug repro ("Audit test coverage
for session-manager" instead of "Orchestrator/Ao Orchestrator 8"),
fall-through to summary/status when displayName is absent.
Refs #1220
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(format): prefer cleaned displayName over raw userPrompt (#1220)
Cursor bugbot review on #1224 caught that for prompt-only sessions,
`userPrompt` (step 3) always shadowed `displayName` (step 4) in
`getSessionTitle`. Both are populated from the same `spawnConfig.prompt`
at spawn time: `userPrompt` stores the raw multi-line original, and
`displayName` is the single-line 80-char-truncated version produced by
`deriveDisplayName`. Because `userPrompt` was checked first, the careful
cleanup never reached the dashboard — kanban cards and session tabs
rendered the full raw prompt instead of the clean version the PR was
supposed to deliver.
Swap the order so `displayName` wins when present. `userPrompt` remains
as a safety-net fallback for sessions spawned before `displayName`
existed or where derivation failed.
Add regression tests:
- prefers cleaned displayName over raw userPrompt for prompt-only sessions
- falls through to raw userPrompt when displayName is absent
Refs #1220
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(sessions): address review comments on displayName PR
- ProjectSidebar: drop session.branch short-circuit so displayName/cleaned
branch fallback is reached (was rendering raw branch for any session
with a branch, defeating the new fallback chain).
- session-manager restore: preserve displayName when rewriting metadata
from archive so restored sessions keep their derived title. Add
regression test.
- deriveDisplayName: truncate on code-point boundaries via Array.from so
emoji at the boundary aren't cleaved into lone UTF-16 surrogates. Same
fix applied to the tab-title truncate in web session page.
- Add regression test covering surrogate-boundary truncation.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Follow up #1320 by hiding the debug bundle control by default and only showing it in development or with . Move the button to the left header cluster and add regression tests for default-hidden and debug-enabled visibility.
Made-with: Cursor
* fix(web): resolve dashboard issue URLs from tracker
Build issue URLs from tracker.issueUrl when sessions store identifier-only issue IDs so dashboard links, labels, and title enrichment stay valid for GitHub-style numeric issues.
Made-with: Cursor
* fix(web): harden issue URL enrichment and add regressions
Guard issue enrichment against URL double-wrapping, free-text synthetic links, and invalid tracker URL output while logging failures. Add regression coverage for URL-shaped issue ids, free-text issue ids, tracker issueUrl failures, and failed issue-title lookup throttling.
Made-with: Cursor
* fix(web): remove unreachable issue-url branch for lint
Drop the duplicate else-if path in enrichSessionIssue that was already covered by the absolute-url guard, resolving no-dupe-else-if and unblocking lint/typecheck/web onboarding checks.
Made-with: Cursor
* fix(web): surface dashboard SSR load failures
When getServices or session listing throws during SSR, show an error banner
and skip the empty-state CTA instead of implying there are zero sessions.
Disables realtime hooks while the banner is shown to avoid noisy reconnects.
Made-with: Cursor
* fix(web): make dashboard SSR errors recoverable
Narrow dashboard fatal errors to service/bootstrap failures and fail-soft on enrichment so transient metadata issues do not blank the page. Keep realtime updates active after SSR load errors, sanitize multiline error messages, and add regression tests.
Made-with: Cursor
* fix(web): address dashboard SSR recovery and attention-zone SSR
- Gate hiding the SSR load-error banner on liveSessionsResolved from
useSessionEvents (successful /api/sessions refresh, SSE snapshot, or mux),
not session count; use cache: no-store on session refresh fetches.
- Apply attentionZones from config immediately after getServices(); wrap
sessionManager.list() in its own try/catch so list failures keep zone config.
- Treat session.status merged like merged PR for Done cards (hide Restore).
- Align ProjectSidebar orchestrator fixture id with isOrchestratorSession pattern.
Made-with: Cursor
* feat(cli): hide terminal session rows by default in session ls
- Filter with TERMINAL_STATUSES unless --include-terminated
- Hint when completed sessions are hidden; docs/CLI.md updated
Made-with: Cursor
* fix(cli): keep session ls JSON inventory stable
Address review concerns by preserving terminal sessions in `ao session ls --json` output while keeping terminal rows hidden by default in text output. Use the core terminal-session predicate, improve hidden-row messaging, and add regression tests for mixed/hinted and JSON behavior.
Made-with: Cursor
* fix(cli): resolve rebase conflicts with latest session ls behavior
Align session command and CLI docs with the current mainline JSON contract (`data` + `meta.hiddenTerminatedCount`) while preserving terminal-filter defaults and updated regression coverage.
Made-with: Cursor
* feat(web): merge conflict compare link and branch copy on PR card
- Add buildGitHubCompareUrl helper with unit tests
- Show compare + copy head branch when open PR has merge conflicts
- Add SessionDetail regression test for conflict affordances
Made-with: Cursor
* fix(web): harden merge-conflict PR actions
Address review feedback by correcting the changeset package name, encoding all compare URL path segments, and making conflict actions resilient to unenriched/rate-limited PR data and clipboard/timer edge cases. Add regression tests for URL encoding and conflict-action gating.
Made-with: Cursor
* fix(web): normalize browser timer handles in session detail
Use browser timer handle types consistently in the session detail PR card copy-feedback flow so Next.js typechecking does not mix Node Timeout and DOM number timer signatures.
Made-with: Cursor
The VPS deploy workflow SSHes in as root but operates on files owned by
aoagent via the /root/agent-orchestrator → /home/aoagent/agent-orchestrator
symlink. Each deploy leaves new pnpm/bun/cache entries owned by root inside
an otherwise aoagent-owned tree. Switching the SSH user to aoagent
eliminates this ownership drift.
Add the repository Copilot instructions file based on the AO draft and clean up wording, grammar, and markdown formatting while preserving the original guidance.
* fix: fail send when killed session delivery is not confirmed
* fix(core): drop requireConfirmation throw that re-introduced duplicate-message bug
The PR's sendWithConfirmation added a throw when confirmation heuristics
did not flip within SEND_CONFIRMATION_ATTEMPTS on a restored session.
But runtimePlugin.sendMessage had already fired, so the throw bubbled up
to the lifecycle manager's catch-all, leaving lastCIFailureDispatchHash
(and its merge-conflict twin) unset. Next poll re-dispatched the same
message — exactly the duplicate-message bug that commit 77685a5 removed.
Real fix for #1074 is preserved: restoreForDelivery still throws when
waitForRestoredSession returns false, which happens before sendMessage
fires. Killed sessions that cannot be revived are still reported as
failures, with no duplicate-send risk.
- sendWithConfirmation no longer takes requireConfirmation; unconfirmed
delivery always returns (soft success).
- prepareSession returns Session (the tuple only existed to drive the
removed throw).
- send's retry predicate reverts to prepared.restoredAt === undefined
&& isRestorable(prepared).
- Adds regression test: restored session + sendMessage fires +
confirmation never flips → send() resolves.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* feat(cli): filter terminated sessions from ao session ls / ao status by default
Closes#1310. Terminated sessions (killed/terminated/done/merged/errored/cleanup,
plus lifecycle-driven terminal states) are now hidden from `ao session ls` and
`ao status` by default. A dim footer reports how many were hidden and how to
surface them. Pass `--include-terminated` to restore the full list.
JSON output wraps into `{ data: [...], meta: { hiddenTerminatedCount } }` on
both commands so text and machine-readable views tell the same story. This is a
breaking change for script consumers of `--json`; `--include-terminated` is the
escape hatch.
Orthogonal to `-a, --all` (orchestrator visibility, unchanged). Restore of
terminated sessions by id is unaffected — that path goes through `sm.get`, not
`sm.list`.
Docs (`SETUP.md`, `docs/CLI.md`) updated to match. Tests cover both the legacy
status branch and the canonical lifecycle branch of `isTerminalSession`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(cli): drop unused `lc` param in lifecycle-alive test case
ESLint's no-unused-vars rejects unprefixed unused args. The "alive — should
remain visible" branch of the new lifecycle-driven filter test in
`session.test.ts` took `lc` but never touched it. Switch to `()`. Matches the
equivalent case in `status.test.ts`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(core): preserve pr.state=merged when legacy metadata lacks pr= URL
Review blocker on PR #1340: a metadata file with `status=merged` but no `pr=`
URL was still showing as active in `ao session ls` / `ao status` by default.
Root cause: `synthesizePRState()` in lifecycle-state.ts short-circuited to
`{ state: "none" }` whenever no PR URL was present, ignoring the fact that the
legacy `status` column already encodes terminal truth. Once lifecycle was
synthesized as `session.state="idle"` + `pr.state="none"`, `deriveLegacyStatus`
returned `"idle"` and `isTerminalSession()` (lifecycle branch) returned false.
The new CLI filter then let the session through.
Fix: when legacy `status === "merged"` and no URL is available, synthesize
`pr.state="merged", reason="merged"` with `number: null, url: null`. The
terminal signal survives the flat-metadata → canonical-lifecycle round trip.
Also:
- Export `sessionFromMetadata` from the core barrel. CLI tests and external
consumers need it to round-trip metadata through the canonical lifecycle.
- Update CLI `buildSessionsFromDir` helpers to route through `sessionFromMetadata`
so mocked `sm.list()` reflects production reconstruction (the old shortcut
bypassed synthesis entirely and was the reason the bug slipped past the
original test suite).
- Add regression tests: one at the core level (`parseCanonicalLifecycle` for
merged-without-URL) and one integration-style test per CLI command asserting
the reviewer's exact repro produces the expected filtered output.
- One pre-existing test expectation updated: when metadata has `status=working`
and `pr=<url>`, the reconstructed status is `pr_open`, not `working`. That's
what production `sm.list()` has always returned; the test was previously
hiding behind the reconstruction shortcut.
Changeset bumped to include ao-core (patch).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(cli): route review-check helper through sessionFromMetadata
Last remaining test-fidelity shortcut flagged by codex on PR #1340. The
`buildSessionsFromDir` helper in review-check.test.ts fabricated Session
objects by hand, bypassing the canonical lifecycle reconstruction that
production `sm.list()` runs. Doesn't affect review-check's actual behavior
(which reads `session.metadata["pr"]` directly), but aligns this test with
the equivalent helpers in session.test.ts and status.test.ts so future
lifecycle changes don't silently skip this surface.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(core): auto-terminate sessions on PR merge (#1309)
When a session's PR was detected as merged, the session transitioned
to status "merged" but its tmux runtime, worktree, and metadata were
never cleaned up — leaving zombie tmux sessions and stale entries in
`ao status` / `ao session ls`. Users worked around this with an
external watchdog. Close the loop in AO itself.
Changes:
- `kill()` gains an optional `reason` and returns `KillResult`
(`cleaned` / `alreadyTerminated`), with short-circuit paths so
repeated calls on archived sessions are safe no-ops instead of
throwing `SessionNotFoundError`.
- New `LifecycleConfig` (`autoCleanupOnMerge: true` default,
`mergeCleanupIdleGraceMs: 5 min`) so operators can opt out when
they need merged worktrees preserved for inspection.
- `lifecycle-manager` runs `maybeAutoCleanupOnMerge` at the end of
each `checkSession`. Reactions and notifications observe the live
session first; cleanup runs last. If the agent is still `active` /
`waiting_input` / `blocked`, cleanup is deferred and retried on
the next poll until the agent idles or the grace window elapses
(prevents killing an agent mid-task).
- New `CanonicalSessionReason` / `CanonicalRuntimeReason` variants
(`pr_merged`, `auto_cleanup`) so observability distinguishes
automated teardown from manual kills.
Scope is deliberately narrow to `merged`: `done` / `errored` often
need the worktree preserved for debugging; `killed` would self-recurse.
Follows Codex review feedback (conditional pass): scope narrowed,
reactions-before-cleanup ordering, idleness safety gate, real
idempotency guards, config opt-in.
6 new unit tests cover: idle agent cleanup, active agent deferral,
grace-window force-cleanup, config opt-out, terminated/killed no
self-recursion, kill() failure retry.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(core): clean up lifecycle config access per review
Address review comment on PR #1311. The `config.lifecycle` field is
typed optional but always populated by Zod — the old guard chain
(`if (lifecycleConfig && lifecycleConfig.autoCleanupOnMerge === false)`)
obscured that duality. Destructure with defaults at the call site so
the contract is visible in one place, and document why the field stays
optional (hand-constructed test configs) on the interface.
Matches the existing `power?: PowerConfig` pattern — keeps churn to
zero across 60 test config literals while removing the ambiguous guard.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(core): surface auto-cleanup-on-merge in config, docs, and UI
Followup to DX audit on #1311. The lifecycle cleanup behavior was
operational but invisible — config key only in TS types, no changeset
for downstream consumers, missing observability spec, and a dashboard
summary that actively contradicted the new default-on behavior.
- agent-orchestrator.yaml.example: add commented `lifecycle:` block
with both keys so operators discover the knob in the primary
config reference.
- .changeset/auto-cleanup-on-merge.md: minor bump for @aoagents/ao-core
with migration note (default-on, opt-out via config).
- docs/observability.md: document the three new lifecycle_poll
operations (merge_cleanup.completed / deferred / failed) so
dashboard/alert authors have a spec.
- packages/web/src/lib/serialize.ts: replace stale summary
"PR merged; worker is still available for a keep-or-kill decision"
with "PR merged; worker session will be cleaned up automatically".
The old copy is wrong under default-on auto-cleanup.
Deferred to follow-up issues:
- Health surface degradation on repeated cleanup failure (the
operator-facing gap Codex flagged — failures emit a metric but
don't downgrade /api/observability health).
- Dashboard "cleaning up in Nm" indicator for the deferred state.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(core,web): address PR review feedback for auto-cleanup on merge
- serialize.ts: only claim "will be cleaned up automatically" when
mergedPendingCleanupSince marker is present; otherwise show neutral
"PR merged". Avoids lying when autoCleanupOnMerge is opted out.
- lifecycle-manager.ts: use ACTIVITY_STATE constants instead of
hardcoded strings, matching the existing SESSION_STATUS.MERGED usage.
- config.ts: keep mergeCleanupIdleGraceMs=0 as a valid escape hatch
(immediate cleanup), but reject 1..9999 with a units-mistake error
so users typing `5` (intending seconds) get a clear message.
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Follow-up to the first round of review comments:
- Guard `document.fonts` add/removeEventListener with feature detection.
Without the guard, environments where FontFaceSet doesn't expose
EventTarget (jsdom test mocks, older browsers) threw a TypeError
during xterm init and the terminal silently failed to attach.
- Update DirectTerminal.render.test.tsx mock so `document.fonts`
exposes addEventListener/removeEventListener stubs, restoring
happy-path coverage of the init code path.
- Export `resolveMonoFontFamily` and add unit tests covering:
CSS var present (prepended to fallback), CSS var absent
(fallback only), and the invariant that no `var(...)` token ever
leaks into the output.
- Clarify in the docblock why we read `--font-jetbrains-mono` and
deliberately avoid `--font-mono` (the latter re-wraps in `var(...)`
and would re-introduce the bug).
Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
- Resolve --font-jetbrains-mono via getComputedStyle at runtime so
xterm honours the app's configured mono font token instead of a
hard-coded "JetBrains Mono" fallback. next/font generates a unique
family name (stored in the CSS custom property) that now gets fed
to xterm alongside the fallback stack.
- Re-resolve the font-family on `document.fonts` `loadingdone` so
xterm picks up the generated name once it registers.
- Change SessionDetail's DirectTerminal loading skeleton from a fixed
h-[440px] to h-full, so the terminal area stays viewport-sized
during the lazy-load window instead of locking to 440px.
Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
The v5 -> v6 xterm.js upgrade regressed the in-browser terminal: each
cell rendered visibly wider and shorter than the glyph inside it,
making horizontal spacing too wide and vertical spacing too tight.
Root causes:
1. `fontFamily` contained `var(--font-jetbrains-mono)`. xterm's char
measurement ultimately hits canvas `ctx.font`, which cannot resolve
CSS custom properties. The var token poisoned the font string so
measurement fell back to a default font while DOM rows still rendered
in JetBrains Mono — cell width vs glyph width drifted apart.
2. xterm v6 defaults `lineHeight` to 1.0. Combined with JetBrains Mono's
tall x-height, rows visually collided.
3. `document.fonts.ready` can resolve before next/font's
`font-display: swap` actually paints JetBrains Mono, so the initial
`fit()` measures against the fallback font. xterm does not re-measure
when the swap later lands.
4. The fit-target div had `p-1.5` padding, skewing FitAddon's cols/rows
computation.
Fixes applied to `DirectTerminal.tsx`:
- Drop `var(...)` from `fontFamily`; use a plain font stack.
- Set `lineHeight: 1.2` to restore vertical breathing room.
- Add a `document.fonts` `loadingdone` listener that clears the
texture atlas and re-fits when the webfont swap completes. Cleaned
up in the effect teardown.
- Remove `p-1.5` from the terminal ref div.
Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>