* fix(scm-github): silence HTTP 304 warnings in ETag guards and use observer logging
ETag guard functions and the GraphQL batch handler used raw console.warn()/
console.error() that fired on every poll cycle, including expected HTTP 304
(Not Modified) responses. This flooded the orchestrator terminal with noise.
- Add 304 fallback check in error message for cases where gh CLI doesn't
populate stdout/stderr on non-zero exit
- Migrate all 4 raw console.warn()/console.error() calls to observer?.log()
for consistency with the rest of the file
- Thread observer parameter through shouldRefreshPREnrichment and the three
ETag guard functions (checkPRListETag, checkCommitStatusETag,
checkReviewCommentsETag)
- Update test to verify observer-based logging instead of console spy
Closes#1580
* fix(scm-github): use word boundary in 304 regex to prevent false positives
Use \b304\b instead of /304/ to avoid matching substrings like "3040"
or "30400" in error messages.
Closes#1580
* fix(scm-github): use is304() for error message fallback and thread observer into getReviewThreads
1. Replace \b304\b regex with is304() (anchored to HTTP status line) in all
three ETag guard fallback paths. Prevents false positives from URL paths
like "pulls/304/comments" appearing in Node's execFile error messages.
2. Capture instance-level observer in createGitHubSCM() and pass it to
checkReviewCommentsETag and getReviewThreads error logging. Non-304
errors on the review polling path are now visible via observer instead
of being silently swallowed by lifecycle's catch.
3. Restore "error" severity for partial batch failures in
enrichSessionsPRBatchImpl (was incorrectly downgraded to "warn").
4. Add tests for Guard 1 URL false-positive, Guard 2 error logging,
and Guard 2 HTTP 304 fallback paths.
Closes#1580
* test(scm-github): add Guard 3 (checkReviewCommentsETag) tests
Add 5 tests for the review comments ETag guard covering:
- 200 response (changed) and 304 response (unchanged)
- Error with observer logging
- HTTP 304 in error message treated as cache hit
- URL containing "304" NOT treated as cache hit (false-positive prevention)
This completes the test coverage for all three ETag guard functions'
error and 304-fallback paths, as requested in review.
Closes#1580
* feat: add SQLite-backed activity event logging layer
Implements a structured diagnostic event trail for the orchestrator.
When unexpected behavior occurs (stuck sessions, silent CI failures,
missed PR transitions), operators can now reconstruct timelines with
`ao events` rather than guessing from logs.
Key design decisions driven by Codex review:
- FTS5 external-content table uses INSERT/DELETE triggers so search
actually works without manual rebuild
- ts_epoch (epoch ms) used for all time comparisons to avoid text vs
SQLite datetime() ambiguity near cutoff
- PRAGMA busy_timeout=3000 handles WAL lock contention across
CLI/lifecycle/web processes
- user_version schema versioning for future migrations
- EventType renamed to ActivityEventKind to avoid collision with
existing types.ts export
- Event names match existing vocabulary (ci.failing, review.pending)
- ActivityStateCache (Map) tracks previous activity state so
lifecycle-manager can emit activity.transition diffs
- session.spawn_failed captures failed spawns via wrapper try/catch
- better-sqlite3 in optionalDependencies: AO keeps working if native
build fails; getDb() returns null and writes become no-ops
New files:
- packages/core/src/events-db.ts — lazy DB init, WAL, schema+triggers
- packages/core/src/activity-events.ts — write API, sanitizer
- packages/core/src/query-activity-events.ts — query + FTS + stats
- packages/cli/src/commands/events.ts — `ao events list/search/stats`
Wired into:
- lifecycle-manager.ts: lifecycle.transition + activity.transition
- session-manager.ts: session.spawned, session.spawn_failed, session.killed
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(activity-events): address PR review comments
- redactValue: remove value-string regex check that silently erased
error messages mentioning auth terms (key-based redaction is sufficient)
- sanitizeData: reject payloads >16KB instead of slicing (sliced JSON
is malformed and corrupts JSON output)
- lifecycle-manager: prune activityStateCache alongside states in the
per-poll stale-entry cleanup loop (prevents unbounded growth)
- events CLI: warn on unrecognised --since duration format instead of
silently applying no time filter
- searchActivityEvents: accept optional projectId and push it into SQL
WHERE clause instead of post-LIMIT in-memory filter
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(activity-events): review pass fixes
- formatRow: pad level string before chalk-wrapping so ANSI codes
don't corrupt column alignment in ao events output
- events-db: add PRAGMA synchronous=NORMAL (WAL+NORMAL is standard
recommendation; avoids per-write fsync in the poll hot path)
- events-db: emit console.warn when _dbFailed is set so operators know
events are being dropped instead of failing silently forever
- activity-events: remove dead redactValue wrapper (was a no-op after
the previous fix; call site now directly assigns the value)
- activity-events: remove unused session.cleanup from ActivityEventKind
(no call site emitted it; dead API surface)
- query-activity-events: add optional limit param to searchActivityEvents
(default 100, max 1000, parameterized); add --limit flag to ao events search
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(activity-events): widen source/kind to allow plugin-defined values
ActivityEventInput.source and .kind accept ActivityEventSource|string
and ActivityEventKind|string respectively, so new event sources (e.g.
scm-github, notifier-slack) don't require editing core types.
The named union members are preserved for IDE autocomplete on known values.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(events): level column alignment, filter kind widening, negative limit guard
- events.ts: padEnd(5) → padEnd(9) so level column aligns with the 9-char LEVEL header
- query-activity-events.ts: ActivityEventFilter.kind widened to ActivityEventKind|string
for consistency with ActivityEventInput.kind (plugin-defined kinds can now be queried)
- query-activity-events.ts: negative/NaN limit values sanitized with Number.isFinite +
Math.max(1,...) to prevent SQLite LIMIT -N returning the full table
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(events): FTS alias, credential URL sanitization, periodic retention
- query-activity-events.ts: use full table name in MATCH (activity_events_fts
MATCH ?) instead of alias to avoid 'no such column: fts' in some FTS5 builds
- activity-events.ts: redact https://token@host URL credentials in string values;
add hourly retention sweep so long-lived processes don't grow DB indefinitely
- events-fts-integration.test.ts: real SQLite integration test for FTS5 search,
projectId filter, and epoch-based time filter
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(lint): remove inline import() type annotations in integration test
ESLint @typescript-eslint/consistent-type-imports forbids import() in type
positions — replaced with any to keep the test working without the type imports.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(events): parse data to structured JSON in --json output; add test proving value sanitizer correctness
- ao events list/search --json now parses ActivityEvent.data from JSON
string back to a structured object, making it jq-friendly without
requiring double-parsing by callers (P1 finding from PR review)
- Add test "preserves error messages that mention sensitive words in
values" to refute Greptile's false-positive P1 finding: SENSITIVE_KEY_RE
only matches key names, not string values; "token expired" and
"authorization header missing" values are preserved correctly
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(events): BM25 relevance ordering for FTS search; add versioned JSON envelope
Search now orders by FTS5 rank (BM25) instead of ts_epoch, returning most relevant
events first. --json output now wraps events in { version, query, meta, events }
for stable CLI contracts.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(events): quote FTS tokens to prevent false negatives on operator-like terms
Searching for words like "OR", "AND", or "NOT" would produce empty results
because SQLite FTS5 treated them as operators after token joining. Wrapping
each token in double quotes forces literal phrase matching.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(tests): update FTS assertion for quoted tokens; raise timeout on slow audit test
query-activity-events test expected the old unquoted join format; update to
match the quoted form added in the previous commit. The agent-report audit
trail test occasionally exceeds the 5 s default on CI runners; raise to 15 s.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(qa): ISSUE-001 - type events stats entries
* test(events): cover session and lifecycle event call sites
* test(core): wait for lifecycle branch adoption
* chore: add activity events changeset
* fix activity event review feedback
* batch prune old activity events
* record activity event when spawn starts
* Fix activity event FTS rebuild and sanitization guard
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* Initial plan
* chore: bump all workspace package versions from 0.2.5 to 0.3.0
Agent-Logs-Url: https://github.com/ComposioHQ/agent-orchestrator/sessions/da5b2769-e7d4-4d08-a60c-bd5f695d1ca7
Co-authored-by: harshitsinghbhandari <212377671+harshitsinghbhandari@users.noreply.github.com>
* fix: update package-version test to expect 0.3.0
Agent-Logs-Url: https://github.com/ComposioHQ/agent-orchestrator/sessions/ad61e33e-417f-4482-b06c-0b60826b7f2d
Co-authored-by: harshitsinghbhandari <212377671+harshitsinghbhandari@users.noreply.github.com>
* chore: revert non-ao version bumps
Only @aoagents/ao drives the 'ao update available' prompt
(packages/cli/src/lib/update-check.ts compares against the
@aoagents/ao registry version and reads the local @aoagents/ao
package.json). All other workspace bumps are unnecessary.
* chore: align workspace versions with npm registry
Catch up source-of-truth package.json versions to what is already
published on npm. The registry reflects releases done via Changesets;
the in-tree files had drifted to 0.2.5.
0.2.5 -> 0.3.0: cli, core, web, agent-aider, agent-claude-code,
agent-codex, agent-opencode, notifier-composio,
notifier-desktop, notifier-slack, notifier-webhook,
runtime-process, runtime-tmux, scm-github,
terminal-iterm2, terminal-web, tracker-github,
tracker-linear, workspace-clone, workspace-worktree
0.2.5 -> 0.2.6: notifier-discord, notifier-openclaw, scm-gitlab,
tracker-gitlab
0.1.0 -> 0.1.1: agent-cursor
Also updates agent-codex package-version.test.ts to expect 0.3.0.
* test(cli): use future version in update-check cache test
The cache-fresh test assumed getCurrentVersion() returned a value
older than the cached latestVersion. With packages/ao now at 0.3.0
and resolvable from cli via pnpm's hoisted store at test time,
getCurrentVersion() returns 0.3.0, so isOutdated against a cached
latestVersion of 0.3.0 is false and the assertion fails.
Use 99.0.0 in the cache so the comparison stays meaningful regardless
of the current installed version.
---------
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: harshitsinghbhandari <212377671+harshitsinghbhandari@users.noreply.github.com>
Co-authored-by: harshitsinghbhandari <claudeagain@pkarnal.com>
* fix: serialize ao start and stop numbered orchestrators (#1306)
* fix: restore dead orchestrators on start (#1306)
* fix: harden startup lock handling (#1306)
* feat(core): enrich events with PR title, description, and URL
Adds PR and issue context to all event payloads sent to notifiers.
External consumers (Telegram, Discord, n8n) can now display meaningful
information without making additional API calls.
Changes:
- Add buildEventContext() helper to extract PR/issue context from session
- Enrich all createEvent() calls with context data (pr, issueId, issueTitle, branch)
- Store issueTitle in session metadata during spawn
- Add issueTitle field to SessionMetadata interface
- Update executeReaction() to accept session for context access
- Add tests for event enrichment
The context includes:
- pr: { url, title, number, branch } when PR exists
- issueId: issue identifier
- issueTitle: issue title (from tracker during spawn)
- branch: session branch name
Events before PR creation gracefully omit PR fields (pr: null).
Existing webhook consumers that ignore unknown fields are unaffected.
Closes#1226
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix(core): address review comments for event enrichment
- Add issueTitle to readMetadata/writeMetadata for proper persistence
- Create ReactionSessionContext type for type-safe system events
- Replace unsafe `as unknown as Session` cast with proper union type
- Add end-to-end test verifying issueTitle persistence during spawn
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix(cli): include lock file path in startup lock error message
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: address review feedback — fd safety, kill-all resilience, issueTitle restore
- Restore try/catch/finally in tryAcquire for fd leak prevention
- Wrap stop command's sm.list()+kill in try/catch so dashboard shutdown
always runs even on session listing failure
- Add per-iteration error handling in kill-all loop with partial failure
reporting (spinner.warn for mixed results)
- Unify allSessionPrefixes derivation between start and stop commands
- Propagate issueTitle through archive restore path
- Add clarifying comments on agentInfo.summary fallback and intentional
prNumber/prUrl duplication in event data
- Add ora warn mock for stop tests
- Update changeset to minor (event enrichment is a feature) and add CLI
changeset for stop resilience
- Add tests for kill-all error mid-loop and issueTitle archive restore
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: address harsh-batheja review — title/summary split, lock grace, context namespace
- Separate PR title from agent summary in EventContext: title is null
until enrichment cache populates; summary is a distinct field so
webhook consumers never confuse the task summary for a PR title.
- Restore UNPARSEABLE_LOCK_GRACE_MS (5s mtime grace) and
isStaleUnparseableLock lost during merge conflict resolution —
prevents lockfile-steal race when process A just created the file
but hasn't written metadata yet.
- Fix orchestrator sort: extract numeric suffix instead of
localeCompare so -10 sorts after -2, not before.
- Namespace context under data.context instead of spreading into data
to prevent field collisions with reaction-specific keys.
- Add schemaVersion: 2 to all enriched events so consumers can
migrate away from top-level prNumber/prUrl (kept for compat,
marked for removal in v3).
- Update event enrichment tests for nested context structure.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: resolve merge conflicts — use formatReviewCommentsMessage, fix batch enrichment mock
- Replace formatAutomatedCommentsMessage with upstream's formatReviewCommentsMessage
for automated review comment dispatch (fixes type mismatch with ReviewComment[])
- Make createMockSCM's enrichSessionsPRBatch dynamically resolve from individual
method mocks so test overrides (e.g. getPRState("closed")) propagate correctly
- Add explicit enrichSessionsPRBatch to merge-conflict-tracking test to avoid
unexpected getMergeability calls from the dynamic mock
- Remove all debug console.log statements added during troubleshooting
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: wire up maybeDispatchCIFailureDetails and record dispatch hash on transition
The function was defined but never called after merge conflict resolution
dropped the call site. Added it back to the Promise.allSettled alongside
maybeDispatchReviewBacklog and maybeDispatchMergeConflicts.
Also updated the transition-reaction early-return to record the dispatch
hash, since the transition path now enriches the CI message with detailed
check info from the batch cache — preventing duplicate sends on subsequent
polls.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* test: remove stale duplicate test from rebase
Removes the orphaned numbered-orchestrator restoration test left over
from feat/1226 history. Upstream's canonical model test (same name,
expects "app-orchestrator" via ensureOrchestrator) supersedes it.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* docs: remove misleading CLI CHANGELOG entry
The "Restore the most recently active dead orchestrator on ao start"
entry described upstream's ensureOrchestrator behavior (#1487), not
work contributed by this PR.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* docs(changeset): correct scope and drop stale CLI changeset
- Rewrite the @aoagents/ao-core changeset to describe only what feat/1226
contributes: event enrichment with schemaVersion: 2, issueTitle
persistence, executeReaction refactor, maybeDispatchCIFailureDetails,
and bugbot-comments enrichment. Drop the false claims about adding
spawn-target and format-automated-comments (those modules came from
upstream PRs #1330 and #1334).
- Delete stop-kill-all-resilience.md — its claims (kill-all loop,
fd-safety in tryAcquire, allSessionPrefixes unify) are no longer
in the branch after the rebase took upstream's canonical stop logic.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
* feat(plugin): add kimicode agent plugin
Add @aoagents/ao-plugin-agent-kimicode implementing the Agent interface
for MoonshotAI's Kimi Code CLI. Follows the AO activity JSONL + PATH
wrapper pattern established by agent-aider/opencode, with a native-ish
signal sourced from ~/.kimi/<session>/ mtimes when present.
- Full Agent interface: getLaunchCommand (--yolo, --model, --agent-file),
getEnvironment (AO_SESSION_ID + ~/.ao/bin PATH + GH_PATH), detectActivity,
getActivityState (5-step cascade with mandatory JSONL entry fallback),
isProcessRunning (tmux TTY + PID signal-0, matches `.kimi`/`uv run kimi`),
getSessionInfo (state.json parsing), getRestoreCommand (--resume <id>
with --continue fallback), setupWorkspaceHooks, postLaunchSetup,
recordActivity, detect().
- Post-launch prompt delivery — kimi's `-p` implicitly enables --print and
exits, which would break interactive supervised sessions.
- 58 unit tests covering all 7 mandatory getActivityState cases plus
manifest, launch, env, prompt classification, process detection,
session info extraction, restore command, and detect().
- Register in cli/src/lib/plugins.ts, detect-agent.ts, plugin-registry.json,
cli package deps, and update user-facing docs / yaml examples.
Closes#1384
* fix(plugin): register kimicode in core BUILTIN_PLUGINS and web services
The CLI-side registration in packages/cli/src/lib/plugins.ts only covers
`getAgentByName` callers. Code paths that go through the shared plugin
registry (session-manager, doctor, plugin, verify CLI commands, and the
web dashboard's services singleton) use `createPluginRegistry()` +
`loadBuiltins()` / explicit `register()`, which bypass the CLI map.
Without this wiring:
- `pnpm ao doctor` / `ao plugin` / `ao verify` wouldn't see kimicode
- Web dashboard would fail to render sessions with `agent: kimicode`
because the webpack-bundled services.ts couldn't resolve the plugin
Add kimicode to:
- packages/core/src/plugin-registry.ts BUILTIN_PLUGINS
- packages/web/package.json dependencies
- packages/web/src/lib/services.ts static imports + register call
Caught while comparing against #1395 (kimi-2-6-code plugin), which added
the same registry entry.
* fix(plugin-kimicode): address review feedback
Critical (from @harshitsinghbhandari, verified against kimi-cli source):
- Remove `promptDelivery: "post-launch"` — `-p`/`--prompt` is just a prompt
string alias (also `--command`/`-c`), NOT a mode switch. The non-interactive
flag is `--print`, which we never set. Inline delivery via `--prompt` is
reliable and avoids the post-launch sendMessage() delay.
- Drop unchecked `as string` casts in getRestoreCommand in favor of typeof
guards + `?? undefined` so null model values don't silently leak.
Medium (performance):
- Add 30s per-workspace cache to findKimiSessionMatch (mirrors codex's
SESSION_FILE_CACHE_TTL_MS) so the ~/.kimi/ scan doesn't run 12×/min per
active session. Cache keyed by workspacePath; cleared via the new
`_resetSessionMatchCache` test-only export between test cases.
Minor (correctness):
- Collapse findKimiSessionDir + readKimiSessionState into one
findKimiSessionMatch that returns {dir, state} from a single state.json
read. Previously the file was parsed twice per getSessionInfo /
getRestoreCommand call.
- Wire config.subagent → `kimi --agent <name>` (default / okabe / custom).
- Tighten detectActivity patterns so "I approve of this approach" and
"Earlier I failed to connect" no longer falsely trigger waiting_input /
blocked. Regexes are now line-anchored with `^`/`$` + `\b` word boundaries.
Tests: 58 → 71 (all green). New cases cover:
- Native-signal ready/idle decay (previously only active was tested)
- Cascade ordering: JSONL waiting_input wins over a matching native signal
- Malformed state.json in both getSessionInfo and getRestoreCommand
- `work_dir` alias accepted in addition to `cwd`
- project.agentConfig.model preferred over state.json's recorded model
- False-positive narration guards for both regex tightenings
* refactor(plugin-kimicode): clean up after second-round review
All changes are non-behavioral perf/style cleanups flagged during my second
review pass — no user-visible changes.
- Consolidate double JSON.parse in findKimiSessionMatchUncached: the previous
pass parsed each candidate state.json once to extract cwd and a second time
to extract session_id/model/title. Replaced both helpers with a single
`parseKimiState(raw)` that returns all four fields in one traversal.
- Carry state.json's mtime through KimiSessionMatch so getKimiLiveSignalMtime
(renamed from getKimiSessionMtime) doesn't re-stat state.json — the winner's
mtime was already captured during the scan. Live-signal probe is now limited
to context.jsonl + wire.jsonl (the per-turn files) and runs them in parallel
via Promise.all instead of sequential awaits.
- Fold state.json mtime and the live-signal mtime into a single "freshest"
timestamp in getActivityState so a recently-written context.jsonl wins even
when state.json is stale.
- Tighten appendApprovalFlags signature: `string | undefined` → proper
`AgentPermissionInput | undefined` so typos at call sites fail at compile
time.
- Stricter detect(): don't trust every binary named `kimi` — verify the
--version output mentions kimi/kimi-cli/kimi-code, and fall back to
`kimi info` for builds that print a bare version number. Rejects unrelated
tools that happen to install a `kimi` binary.
Tests: 71 → 75. New coverage:
- detect() accepts kimi-cli vendor strings
- detect() falls back to `kimi info` when --version is ambiguous
- detect() rejects an unrelated `kimi` binary
- Native signal picks the fresher of state.json vs context.jsonl mtimes
* fix(plugin-kimicode): correct session layout discovered via smoke test
Installing kimi-cli 1.38.0 locally (\`uv tool install kimi-cli\`) and running
it once revealed the plugin's session-discovery logic was built on wrong
assumptions about the on-disk layout.
Observed layout (kimi-cli 1.38.0):
~/.kimi/sessions/<md5(cwd)>/<session-uuid>/
context.jsonl — conversation history
wire.jsonl — turn events (TurnBegin/TurnEnd with user_input payload)
Differences from my original assumptions:
- Sessions are nested under \`sessions/\` (not direct subdirectories of
\`~/.kimi/\`).
- The workspace is identified by an MD5 hash of the absolute path, not by
a \`cwd\` field stored in a state file.
- There is no \`state.json\`. No \`title\`, \`model\`, or \`cost\` is persisted.
- The session ID is the UUID directory name and is accepted as-is by
\`kimi --resume <uuid>\`.
- The old \`--continue\` fallback is unnecessary — if we found the directory,
we always know its UUID.
Fixes:
- \`findKimiSessionMatch\` now computes \`md5(workspacePath)\` with node:crypto
and lists \`~/.kimi/sessions/<hash>/\` directly. No more full-tree scan of
\`~/.kimi/\`, no more \`readFile\` of a fictional \`state.json\`.
- \`getKimiLiveSignalMtime\` keeps the parallel \`Promise.all\` stat of
context.jsonl + wire.jsonl (the only files that exist).
- \`getSessionInfo\` streams the first \`TurnBegin\` out of wire.jsonl as a
best-effort summary, with a 1 MB byte ceiling. agentSessionId is the UUID.
- \`getRestoreCommand\` drops the \`--continue\` fallback branch — a found dir
always has a usable UUID.
Verified end-to-end against the real kimi-cli 1.38 binary on this machine:
- \`detect()\` → true
- \`getLaunchCommand\` output parses cleanly when run with \`--help\`
- \`getSessionInfo\` extracts the actual first user prompt ("say hello")
- \`getRestoreCommand\` produces the same UUID kimi itself prints as the
resume hint: \`kimi -r 6ec34626-aedf-4659-a061-c5fbfa4cf166\`
Tests remain at 75 green. Coverage is now against real on-disk layouts
using temp directories with MD5-hashed bucket names — no mock-structure
drift from reality.
* fix(plugin-kimicode): address follow-up review issues
Follow-up to the issues filed as a review comment on the PR.
[MED] detect() too loose (\bkimi\b matches unrelated binaries)
The old regex accepted plain "kimi" alone because the (?:cli|code)?
suffix was optional — any binary whose output contains "kimi" passed.
Real kimi-cli's --version prints just "kimi, version X.Y.Z" (no suffix),
so --version alone can't distinguish it from, say, a hypothetical
keyboard-input-manager named kimi. Switch to `kimi info` exclusively;
real kimi-cli prints "kimi-cli version: ..." which is a distinct vendor
string. Regex now requires "kimi-cli" / "kimi-code" / "moonshot"
literally. Added maxBuffer cap (4 KB) so a hostile binary can't flood
detect() with MB-scale output.
[MED] --work-dir not passed — investigated, not actionable in this PR
AgentLaunchConfig doesn't expose session.workspacePath — only
projectConfig.path (the project root), which would actively break
discovery if passed. Runtime cwd handling is load-bearing. Left a
comment explaining the constraint and pointing at the core-types
change needed to fix it properly.
[LOW] Empty-bucket race returned transient null
During session creation kimi mkdirs the UUID directory before writing
context.jsonl / wire.jsonl. getKimiLiveSignalMtime returned null in
that window and findKimiSessionMatch returned null, flickering the
dashboard to "no signal". Fall back to the UUID directory's own mtime
when live files are absent.
[LOW] isProcessRunning matched "kimi" anywhere in ps args
Old regex /(?:^|\/)\.?kimi(?:\s|$)|(?:\s|^)kimi(?:\s|$)/ matched
`cat kimi.log`, `vim ~/.kimi/config.toml`, etc. Anchor to argv[0]
instead — only the executable itself, or a python/uv/node runner
followed by `kimi` as the first positional argument, counts.
[NIT] Symlink normalization
kimi's process reads cwd via os.getcwd(), which returns the realpath on
Linux. If AO hands us a symlinked workspacePath, our MD5(symlink) won't
match kimi's MD5(realpath). realpath-resolve with a best-effort fallback
to the raw string (preserves behavior when the path doesn't exist yet).
Tests: 75 → 80. New coverage:
- detect() vendor-string matrix: kimi-cli / kimi-code / moonshot accepted,
unrelated "kimi keyboard input manager" rejected
- isProcessRunning rejects `cat kimi.log` / `vim ~/.kimi/config.toml`
- isProcessRunning accepts `python -m kimi`
- Native signal falls back to UUID-dir mtime during the empty-bucket race
- Symlinked workspace path matches the realpath-hashed bucket
Verified end-to-end against real kimi-cli 1.38.0:
- detect() → true (via `kimi info` vendor match)
- getSessionInfo → correct summary + UUID
- getRestoreCommand → matches kimi's own resume hint
* fix(plugin-kimicode): address inline review from illegalcall
Addresses all 10 inline comments on PR #1390.
Load-bearing fixes:
[#6 line 327] detectActivity ordering was wrong
The old code checked the idle prompt (`^kimi>\s*$`) before approval/error
patterns. Real kimi UI re-renders `kimi>` on the last line when asking for
a confirmation, so \`(Y)es/(N)o\\nkimi>\` was misclassified as idle and the
session would sit forever looking quiet while actually blocked on input.
Reordered to: waiting_input → blocked → idle → active. Matches codex/aider.
[#2,#4,#8 lines 128,154,493] No stable AO↔Kimi session binding
Discovery was pure (path-hash + recency). If the user ran kimi manually in
the same repo, or two AO sessions shared a workspace hash, AO would attach
to the wrong UUID — summary / activity / --resume target all corrupted.
Now:
- \`session.metadata.kimiSessionId\` pins a specific UUID when set; no
fallback to recency when the pin misses (fails closed, no silent drift).
- Unpinned lookups filter UUIDs by \`liveMtime >= session.createdAt - 60s\`
so stray dirs from prior AO sessions don't attach.
- findKimiSessionMatch now takes the whole Session (not just workspacePath)
so createdAt + metadata are available.
[#3 line 141] Any recent subdir was treated as a real session
Stray temp dirs and crash leftovers would match on mtime, producing
\`kimi --resume <garbage>\` and bogus active states. Now require
context.jsonl OR wire.jsonl to exist before trusting a dir. The race
fallback (empty UUID dir → dir mtime) is removed — the JSONL activity
fallback in getActivityState covers the startup window instead.
[#5 line 191] Symlink follow outside ~/.kimi/sessions/
\`stat()\` / \`createReadStream()\` followed symlinks without rebinding, so
a bucket entry that's a symlink to \`/dev/zero\` or \`/etc/passwd\` would
hang forever or leak data. Added \`isInsideKimiSessions(path)\` that realpaths
the candidate and rejects anything outside the sessions root. Every
bucket entry is checked before use.
Smaller cleanups:
[#1 line 89] Cache: 30s negative TTL + unbounded growth
Negative results now cached 2s so a session appearing mid-poll is picked
up on the next cycle. Expired entries evicted on read. Cache capped at
256 entries with oldest-expiry pruning. Key changed to (workspacePath,
pinnedUuid) so two AO sessions in the same bucket can't poison each
other's cache entry.
[#7 line 440] Duplicate argv0Re regex — use the const.
[#9 line 532] maxBuffer: 4096 → 65536. Future \`kimi info\` releases that add
plugin listings or telemetry banners won't silently break detect() with
swallowed ENOBUFS.
[#10 test line 650] macOS test breakage: /var/folders is a symlink to
/private/var/folders, so fakeHome under tmpdir() is a symlink path, while
the plugin realpaths before hashing. Wrap the mkdtempSync in realpathSync
so tests agree with the plugin on the canonical path. Linux CI masked this.
Tests: 80 → 86. New coverage:
- detectActivity classifies confirmation-then-prompt-rerender as waiting_input
- detectActivity classifies error-then-prompt-rerender as blocked
- createdAt floor filter (ignores UUIDs from before the AO session)
- Pinned kimiSessionId wins over recency
- Pinned UUID missing returns null (no silent fallback)
- Negative cache TTL ~2s (session appearing mid-poll picked up next cycle)
- Empty UUID dir without live files is rejected (no stray-dir attach)
Verified end-to-end against real kimi-cli 1.38.0: detect() true,
getSessionInfo extracts correct summary + UUID, getRestoreCommand matches
kimi's own resume hint.
* fix(plugin-kimicode): use kimi.json for workspace mapping and add --work-dir
Read ~/.kimi/kimi.json work_dirs[] as the authoritative workspace-to-session
mapping. When last_session_id is populated, prefer it over the directory-mtime
recency heuristic — kimi itself wrote it. Falls back gracefully to the existing
MD5 hash scan when kimi.json is absent or last_session_id is null.
Add --work-dir to getLaunchCommand using projectConfig.path to establish an
explicit cwd contract, preventing shell-rc / tmux-hook drift from causing the
MD5(cwd) hash to diverge from kimi's session bucket.
* fix(plugin-kimicode): plumb workspacePath into AgentLaunchConfig
The kimicode plugin's --work-dir was passing projectConfig.path, which
breaks worktree-mode workspaces. In worktree mode, projectConfig.path is
the original repo root while session.workspacePath is the per-session
checkout — they differ. Either kimi would write to the project root
(breaking worktree isolation) or md5(projectConfig.path) would diverge
from md5(session.workspacePath), so getActivityState/getSessionInfo would
never find this session's bucket.
Fix:
- Add optional `workspacePath` field to AgentLaunchConfig.
- Plumb it through all 3 launch call sites in session-manager.ts.
- kimicode getLaunchCommand uses config.workspacePath, falling back to
config.projectConfig.path when undefined.
- Tests for the divergent-paths case.
Public-interface change: AgentLaunchConfig grows one optional field.
Invariants preserved:
- Agent.getLaunchCommand signature unchanged — still takes one
AgentLaunchConfig.
- Existing plugins (claude-code, aider, codex, opencode) compile and run
unchanged; the new field is optional and they ignore it.
- Clone-mode workspaces (where workspacePath === projectConfig.path)
produce the same launch command as before.
- Fallback to projectConfig.path keeps callers that don't pass the new
field working — no flag day required.
* fix(plugin-kimicode): capture baseline pre-launch to close startup race
captureKimiBaseline() previously ran in postLaunchSetup, which races
against kimi's own startup writes. If kimi created its UUID directory
before postLaunchSetup ran, that UUID landed in `preExistingUuids` and
was filtered out forever — so `findKimiSessionMatch` returned null
permanently for that session.
Fix:
- Add optional `preLaunchSetup(workspacePath)` to the Agent interface,
invoked from session-manager AFTER the workspace exists but BEFORE
`runtime.create()` spawns the agent.
- Move captureKimiBaseline from postLaunchSetup to preLaunchSetup in
the kimicode plugin.
- Test asserts the new UUID is attached even when written immediately
after preLaunchSetup runs (i.e. in the race window).
Public-interface change: Agent.preLaunchSetup is optional. Existing
plugins (claude-code, aider, codex, opencode) compile and behave
unchanged. Only kimicode opts in.
Invariants preserved:
- Workspace exists before preLaunchSetup runs (called after the
worktree/clone is created, never before).
- Failures in preLaunchSetup propagate just like other launch-path
failures — the existing try/catch covers it.
- captureKimiBaseline is still write-once (returns early if the
baseline file already exists), so restore preserves the original
partition.
* fix(plugin-kimicode): persist UUID pin to disk instead of dead metadata
The session.metadata.kimiSessionId branch was treated as the highest-
priority signal but nothing ever populated it. That left the entire
"AO↔kimi UUID binding" mechanism dead — discovery fell through to the
recency heuristic on every call, so a manual `kimi` run in the same
workspace, a sibling AO session sharing a bucket, or any drift in
kimi's directory layout could attach the wrong session.
Fix:
- Remove the dead session.metadata.kimiSessionId branch from
findKimiSessionMatchUncached and the cache key.
- Add a workspace-local pin file (.ao/kimi-session-id.json). Once
findKimiSessionMatchUncached identifies a winner via the recency
heuristic (or via kimi.json's last_session_id soft-pin), it writes
the UUID to the pin file. Subsequent calls read the pin file as the
highest-priority signal and skip the heuristic entirely — locking
in the AO↔kimi binding for the rest of the session lifetime.
- Cache key simplified to workspacePath alone since the pin is now
persistent and cannot drift between calls.
- Tests cover: pin wins over recency, first match writes the pin,
pin holds when a newer non-pinned UUID appears later.
Mechanism mirrors the existing .ao/kimi-baseline.json pattern (also
file-based, write-once, lives in the workspace).
* refactor(plugin-kimicode): extract session-discovery into its own module
index.ts had grown to 880 lines after the pin-file fix landed. The
discovery layer (kimi.json parsing, baseline capture, pin file, hash
bucket scan, cache) is one cohesive responsibility — pulling it out
keeps both files under the 500-line mark and makes the precedence
rules legible.
- New file: session-discovery.ts. Opens with a decision-table comment
documenting the precedence (pin file → kimi.json soft-pin → recency
heuristic) so future readers see the rule before the code.
- Public surface: captureKimiBaseline, findKimiSessionMatch,
KimiSessionMatch, kimiShareDir, _resetSessionMatchCache.
- index.ts re-exports _resetSessionMatchCache so the existing test
imports keep working.
- No behavioral change — all 98 tests pass unchanged.
* test(plugin-kimicode): worktree-mode end-to-end discovery test
Adds a test where workspacePath (per-session worktree) and
projectConfig.path (repo root) are different paths. Asserts that
discovery hashes workspacePath — not projectConfig.path — for the
kimi bucket lookup. Previously this scenario was untested; the bug
fixed in 9fcc1d9 (--work-dir using projectConfig.path) would have
been caught by this test.
Combined with the earlier --work-dir tests in 9fcc1d9, the worktree
divergent-paths case is now exercised at both the launch site
(getLaunchCommand) and the discovery site (getRestoreCommand) end
to end.
* fix(plugin-kimicode): sandbox-check live-signal files against symlinks
Addresses illegalcall's review comment (id 3127022353): the existing
isInsideKimiSessions check verified the session DIRECTORY but not its
children. A symlinked context.jsonl, wire.jsonl, or wire.jsonl pointing
at /etc/passwd, /dev/zero, or a FIFO would be silently followed by
stat() / createReadStream() — leaking reads, hanging on devices, or
escaping the kimi-sessions sandbox.
Fix:
- New isKimiSessionFile(path) helper using lstat + isFile() — rejects
symlinks, sockets, FIFOs, block/char devices. lstat (not stat) so we
see the symlink itself before the kernel resolves it.
- getKimiLiveSignalMtime swapped to lstat-based check; non-regular
files contribute no mtime.
- extractKimiSummary refuses to open wire.jsonl when it isn't a
regular file.
- Tests cover both paths: getActivityState rejects a session whose
live-signal files are symlinked outside the bucket; getSessionInfo
returns null summary when wire.jsonl is symlinked even if context.jsonl
is real.
* fix(plugin-kimicode): apply baseline + createdAt filters to kimi.json soft-pin
The kimi.json soft-pin used to record a candidate UUID before the baseline
and createdAt filters were applied, so a stale last_session_id pointing at
a pre-AO UUID (manual `kimi` run, kimi.json lag) would be captured into
.ao/kimi-session-id.json and route every later getActivityState /
getSessionInfo / getRestoreCommand call at the wrong conversation, with
no self-healing path.
Move the baseline + createdAt floor checks above the soft-pin branch so
the soft-pin candidate goes through the same gates as the recency contest.
Add two regression tests:
- soft-pin pointing at a baseline UUID is rejected and the AO pin file
records the legitimate AO-spawned UUID instead
- soft-pin pointing at a UUID older than session.createdAt - 60s is
rejected by the createdAt floor
Both tests fail on the prior code and pass after the fix.
* fix: clear stale Next.js cache on version upgrade (#986)
After upgrading @composio/ao via npm, `ao start` served the old UI because
Next.js runtime cache (.next/cache) persisted from the previous version.
Adds a hybrid fix:
- Postinstall hook clears .next/cache and writes a version stamp
- Runtime guard in `ao start` and `ao dashboard` compares stamp against
package version; on mismatch, clears .next/cache and restamps
- Build-time script writes stamp after `next build` (monorepo path)
Only .next/cache is deleted — shipped build artifacts (.next/server,
.next/static, BUILD_ID) are never touched, keeping npm installs intact.
Closes#986
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(lint): add Node.js globals for package-level scripts
The ESLint config only covered root-level scripts/, not
packages/*/scripts/. This caused `no-undef` errors for `console`
and `process` in packages/web/scripts/stamp-version.js.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: ensure postinstall cache clearing runs on all platforms
Restructure postinstall.js so the node-pty chmod fix is wrapped in a
conditional block instead of using early process.exit(0). The previous
exits on Windows, missing node-pty, or missing spawn-helper prevented
the cache-clearing code from ever running on those systems.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address review comments on stamp-version ordering and cache catch logging
- Move stamp-version.js after tsc in web build script so a tsc failure
does not leave a fresh stamp paired with a stale server bundle.
- Log skipped cache version checks via console.debug instead of swallowing
silently, to aid debugging without blocking dashboard startup.
Addresses review feedback from @illegalcall on PR #1022.
* fix: resolve ao-web in postinstall cache cleanup
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(cli): skip rebuild when git install is already on latest version
After `git fetch`, compare local HEAD to remote HEAD. If they match,
print "Already on latest version." and exit without running pnpm install,
clean, build, or npm link.
Without this check, `ao update` re-ran the full rebuild on every
invocation even when nothing had changed, because the git path in
`handleGitUpdate` (unlike the npm path) never called `checkForUpdate()`
to short-circuit before delegating to the shell script.
Fixes#1584
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(cli): keep running smoke tests when already on latest version
The previous fix exited 0 immediately on the "already on latest" path,
which silently skipped smoke tests that would otherwise verify the
install. Restructure with an else-branch so the rebuild block is
skipped but execution continues to the smoke-test gate, preserving
the prior smoke-test behavior for the no-update case.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* refactor(web): remove SSE entirely — browser uses WebSocket only
- Delete GET /api/events route (no consumers remain)
- Refactor SessionBroadcaster: replaces SSE stream fetch with a plain
setInterval polling GET /api/sessions/patches every 3s, eliminating
the last server-side SSE consumer
- Remove EventSource from useSessionEvents; hook is now WebSocket-only
via mux.sessions; rename SSEAttentionMap → AttentionMap and
sseAttentionLevels → attentionLevels throughout
- Replace useSSESessionActivity with useMuxSessionActivity — thin
selector over useMux().sessions, no network call
- Delete SSESnapshotEvent and SSEActivityEvent types from lib/types.ts
- Delete Dashboard.renderCadence.test.tsx (SSE-specific test)
- Update ARCHITECTURE.md to reflect the simplified two-protocol design
(HTTP + WebSocket only; no SSE anywhere in the system)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* refactor(web): address PR review comments
- useMuxSessionActivity: switch to useMuxOptional (consistent with page.tsx),
add useMemo for referential stability
- useSessionEvents: validate patch.status against VALID_SESSION_STATUSES before
casting; type all three VALID_* sets with satisfies for exhaustiveness
- mux-websocket: remove leading underscores from private fields (intervalId,
polling) — private modifier already conveys intent
- Dashboard.renderCadence test: port from SSE/EventSource to MuxProvider mock;
covers same-membership-snapshot-only-rerenders-changed-card invariant
- Remove .feature-plans/pending/remove-browser-sse.md (duplicated in PR body)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(web): guard broadcast against stale fetch after disconnect
If disconnect() runs while fetchSnapshot() is in flight, the .then
callback would still fire broadcast() into an empty (or re-populated)
subscriber set. Guard with intervalId !== null so stale resolutions
after the last subscriber leaves are silently dropped.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(web): remove SSE-specific tests from emptyState suite
The upstream added two tests for live load-error banners driven by SSE
onmessage events. Since this PR removes SSE entirely, those tests can't
pass and the SSE mock setup is no longer needed.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(web): restore liveSessionsResolved to prevent premature banner dismiss
mux?.status === "connected" fires on WebSocket handshake before any
session data arrives. In the SSR-failure scenario (dashboardLoadError
set), this was dismissing the error banner as soon as the WS opened,
leaving users with a silent empty dashboard.
Restore liveSessionsResolved: set it only from the first successful
HTTP /api/sessions refresh or mux snapshot (same semantics as main).
The reset action from the initialSessions effect intentionally does
not set it (liveResolved flag absent = SSR-only reset, not live data).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(web): port live load-error banner from SSE to WS transport
SessionBroadcaster now emits { ch: "sessions", type: "error" } on fetch
failure instead of silently returning null. MuxProvider surfaces the error
as lastError on the context. useSessionEvents restores loadError reducer
state, synced from muxLastError, cleared on successful snapshot or HTTP
refresh. Dashboard renders the live error banner via loadError ?? ssrLoadError.
Two emptyState tests ported to drive errors through MuxProvider mock.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Gaurav Bhola <fastestdevalive@users.noreply.github.com>
* fix(core): preserve reaction tracker across status oscillation (#1409)
The reaction tracker retry budget was resetting on every status exit,
allowing infinite CI failure and merge conflict messages instead of
escalating to a human after the configured retry limit.
* fix(core): hoist PERSISTENT_REACTION_KEYS to module scope, document notify priority change
Address review feedback:
- Move PERSISTENT_REACTION_KEYS to module level (avoids per-call Set allocation)
- Document that executeReaction's notify path defaults to "info" priority
(was "warning" in old direct-dispatch code); users with action:"notify"
should set priority explicitly in their config
* fix(core): address review feedback on reaction tracker oscillation
1. Remove dead "merge-conflicts" from PERSISTENT_REACTION_KEYS —
statusToEventType never emits "merge.conflicts" so the transition
handler can never reach it. Merge-conflict tracker lifecycle is
managed in maybeDispatchMergeConflicts.
2. Fix escalation poisoning dedup flag — only set
lastMergeConflictDispatched when the result is non-escalated.
Escalation hands off to the human; the dedup flag must not
suppress future agent dispatches.
3. Add incident boundary to persistent trackers — clear tracker
after escalation (executeReaction) and when merge-conflicts
resolve. Prevents permanent budget exhaustion in long-lived
sessions.
4. Preserve "warning" priority for merge-conflict notify action —
default priority to "warning" on enrichedConfig, matching old
direct-dispatch behavior.
Fixes review comments on #1531.
* fix(core): bound ci-failed escalation — silence after escalate, reset on stable CI pass
After escalation, the ci-failed tracker is now marked escalated=true instead
of being deleted. This prevents the infinite re-escalation loop introduced
by deleting the tracker (every 3rd oscillation cycle got a fresh budget,
causing retries:2 to produce unbounded agent messages + human pages).
Resolution: once escalated, the tracker short-circuits all further dispatches
until CI has been passing for CI_PASSING_STABLE_THRESHOLD (2) consecutive polls.
This ensures "stable passing" isn't confused with brief pending→passing flicker.
Fixes the residual unbounded-dispatch bug flagged in the illegalcall review
on #1531.
* fix(core): count only 'passing' toward ci stable window — exclude 'pending'
'pending' (emitted while a CI run is in progress) was incorrectly counting
toward the 2-poll stable-passing threshold, wiping the escalated tracker
between failures in the exact production scenario the fix was meant to bound.
With real GitHub CI, every transition out of 'failing' goes through 'pending'
while the new check-run starts (~60 polls for a 5-min CI run). Two consecutive
pending polls (10s) would clear the tracker before the run completed, giving
each failure cycle a fresh budget — identical to the original #1409 symptom.
Fix: require ciStatus === "passing" (not !== "failing") before incrementing
stableCount. Pending/none reset the stability window the same as failing.
Adds two regression tests:
- pending CI does not count toward ci-failed tracker resolution
- only passing CI resets ci-failed tracker — pending mid-run does not interfere
* fix: reduce opencode session list churn
* fix: make bun-tmp-janitor cross-platform and move to process-level boot
- Extend platform support from Linux-only to Linux + macOS (win32 skipped
since opencode ships no Windows binary and the kernel disallows unlinking
mapped files there)
- Use os.tmpdir() instead of hardcoded /tmp to handle macOS $TMPDIR paths
- Extend file pattern from \.so to \.(so|dylib) to cover macOS dylib leaks
- Move startBunTmpJanitor() from ensureLifecycleWorker() (per-project) to
the process-level boot in registerStart() immediately after register(),
where the single-instance contract is already in force
- Drop the project-observer-bound onSweep closure that incorrectly attributed
janitor health to whichever project happened to start first; replaced with
a simple stderr warn on errors (no project context needed for a process-wide
sweep of /tmp)
- Move stopBunTmpJanitor() into the SIGINT/SIGTERM shutdown handler in
registerStart() alongside stopAllLifecycleWorkers()
- Remove startBunTmpJanitor/stopBunTmpJanitor from lifecycle-service.ts
entirely; lifecycle workers have no business knowing about a process-wide
OS resource
* fix(opencode): address PR #1478 review (TMPDIR isolation, shared cache, janitor cleanup)
Implements all seven findings from the PR #1478 review:
Core / agent-opencode:
- New @aoagents/ao-core/opencode-shared module owns the single TTL cache
+ in-flight dedup for 'opencode session list' (was duplicated across
core and the plugin, doubling spawns per poll cycle).
- TTL dropped from 3s to 500ms so the send-confirmation loop's
updatedAt > baselineUpdatedAt delivery signal can actually fire.
- New invalidateOpenCodeSessionListCache() called by deleteOpenCodeSession
so reuse / remap / restore code paths cannot observe a deleted id.
- New getOpenCodeChildEnv() / getOpenCodeTmpDir(): every opencode child
spawned by core, the plugin, or the agent runtime points TMPDIR/TMP/TEMP
at ~/.agent-orchestrator/.bun-tmp. Bounds the janitor's blast radius
to AO-owned files even on shared hosts.
CLI janitor:
- Sweeps only the AO-owned tmp dir (not the system /tmp).
- Filters synchronously before spawning per-entry stat/unlink work.
- stopBunTmpJanitor() is now async and awaits any in-flight sweep so
SIGTERM cannot exit while unlink() is mid-flight; start.ts shutdown
handler awaits it.
- onSweep callback in start.ts now logs successful reclaims, not just
errors, so operators can confirm the janitor is doing useful work.
Tests:
- packages/core/__tests__/opencode-shared.test.ts (TTL contract,
TMPDIR location, env merge semantics).
- packages/cli/__tests__/lib/bun-tmp-janitor.test.ts (sweep behavior,
stop-awaits-in-flight, pattern matching, missing-dir tolerance).
* chore: remove review postmortem artifact
* fix(cli): remove start command non-null assertions
* refactor(cli): unify interactive spawn helper
Consolidate interactive child-process spawning into runInteractiveCommand by adding an optional options bag (cwd/env and error context). This removes duplicate spawn logic and ensures consistent TTY-forwarding and error formatting for installer and clone flows.
Made-with: Cursor
* test(cli): mock interactive spawn in start URL clone tests
Update start URL clone tests to mock node:child_process spawn (stdio: inherit) instead of exec(), matching the interactive clone behavior and preventing hangs/timeouts.
Made-with: Cursor
* test(cli): standardize spawn mocks for start URL clone
Use an EventEmitter-based ChildProcess helper for spawn() in start command tests, matching existing repo patterns and reducing repetitive per-test mock return objects.
Made-with: Cursor
* test(cli): remove unknown cast from SessionManager mock
Add missing SessionManager methods to the start command test mock so we can type it as SessionManager directly without an unknown double-cast.
Made-with: Cursor
* test(cli): type spawn mock args in start tests
Replace unknown-typed spawn mock parameters with concrete cmd/args/options types to improve readability while keeping behavior unchanged.
Made-with: Cursor
* fix(test): remove duplicate mockSessionManager.restore key
Drop the duplicate restore mock introduced during main-branch merge conflict resolution.
* refactor(core): switch metadata format from key=value to JSON and add V2 path functions
Phase 1-2 of the storage redesign: adds new projectId-based path functions
(getProjectDir, getProjectSessionsDir, etc.) alongside deprecated storageKey-based
ones, and switches metadata serialization from key=value flat files to JSON with
.json extension. Structured fields (runtimeHandle, statePayload) are stored as
proper JSON objects instead of stringified strings within key=value.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor: wire V2 projectId-based paths and remove storageKey system
Switch all consumers from hash-based storage paths to projectId-based
paths (Phase 4) and completely remove the storageKey system (Phase 5).
Phase 4 — V2 path wiring:
- session-manager.ts: all 9 getProjectSessionsDir() calls use projectId
- lifecycle-manager.ts, recovery/scanner.ts, recovery/actions.ts: V2 paths
- portfolio-session-service.ts: JSON metadata + projectId-based paths
- web routes (sessions/[id], projects/[id]): V2 paths
- cli report command: V2 paths
- All test files updated with HOME isolation for parallel safety
Phase 5 — storageKey removal:
- Types: removed storageKey from ProjectConfig, PortfolioProject,
DegradedProjectEntry
- Schemas: removed from ProjectConfigSchema, GlobalProjectEntrySchema
- Removed: StorageKeyCollisionError, deriveProjectStorageIdentity,
ensureProjectStorageIdentity, findStorageKeyOwner, relinkProject,
relinkProjectInGlobalConfig, applyWrappedLocalStorageKeys,
moveStorageDirectory, countSessionEntries
- CLI: removed `project relink` command
- Web: removed storageKey from settings UI, simplified collision handling
- Simplified registerProjectInGlobalConfig and resolveProjectIdentity
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(core): restructure SessionMetadata types for storage redesign Phase 3
Complete the typed field restructuring on SessionMetadata:
- statePayload/stateVersion → lifecycle?: CanonicalSessionLifecycle
- runtimeHandle: string → RuntimeHandle (with backward-compat parsing)
- prAutoDetect: "on"/"off" → boolean (with legacy string conversion)
- dashboardPort/terminalWsPort/directTerminalWsPort → nested dashboard object
- LifecycleDecision: flat detecting* fields → nested detecting object
Includes migration command (ao migrate-storage), V2 path functions,
storageKey removal, and updated test plan (to-test.md).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(core): address review findings for storage redesign migration
Fix all HIGH-priority review findings and blockers from external review:
- Detect bare 12-hex hash directories during migration inventory
- Skip observability directories during migration
- Detect V2 tmux session naming patterns for active session check
- Derive status from lifecycle when not stored in migrated JSON
- Fix rollback to preserve storageKey format and post-migration data
- Extract shared flattenToStringRecord utility to avoid duplication
- Handle prAutoDetect "true"/"false" string variants
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(core): update displayName test for JSON metadata format
The upstream displayName test asserted key=value file format and
bare filename. Update to check JSON content and .json extension.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(core): fix runtimeHandle type in upstream restore test
The upstream displayName restore test passed runtimeHandle as
JSON.stringify(makeHandle(...)) — a string. Our type change requires
the RuntimeHandle object directly.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(core): address PR review comments
- Handle empty files from reserveSessionId() in mutateMetadata() —
treat empty/whitespace content as empty record instead of throwing
on JSON.parse
- Fix archive doc comment: archives live under <sessionsDir>/archive/,
not <projectDir>/archive/
- Remove migration test file from gitleaks path allowlist — no false
positives are triggered, so blanket file exclusion is unnecessary
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: use targeted regex instead of path allowlist for gitleaks
Replace the blanket file allowlist with a regex matching the specific
test placeholder hash "abcdef012345" that triggers the generic-api-key
rule. This keeps secret scanning active for the migration test file.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: replace high-entropy test placeholder to avoid gitleaks false positive
Use `aaaaaa000000` instead of `abcdef012345` as the dummy 12-hex-char
hash in migration tests. The old value triggered gitleaks' generic-api-key
rule when combined with `storageKey:` in YAML-like test fixtures. This
eliminates the need for any gitleaks allowlist entry for this file.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(cli): auto-register flat local config in ao start
When running `ao start` in a directory with a flat
agent-orchestrator.yaml (no `projects:` key) that isn't registered
in the global config, the Zod validation error was surfacing as a
raw error dump. Now auto-registers the project in the global config
and retries, matching the behavior of `ao start <path>`.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(core): correct migration error message to use ao session kill
The error message referenced `ao kill --all` which doesn't exist.
The correct command is `ao session kill --all`.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(core): address review findings — worktree paths, archive location, recovery log
- Migration now writes absolute worktree paths instead of relative
(relative paths resolved against cwd, not project dir, breaking restore)
- Archive directory moved from projects/{pid}/archive/ to
projects/{pid}/sessions/archive/ to match runtime deleteMetadata behavior
- getProjectArchiveDir() updated to return sessions/archive/ consistently
- fixArchiveFilename() handles sanitized timestamps (dashes replacing colons)
- getRecoveryLogPath() fallback uses AO base dir instead of synthetic
projects/_recovery/ directory
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(core): update metadata hooks for JSON format and .json extension
Both the Claude Code PostToolUse hook and the PATH wrapper hooks
(gh/git) were constructing metadata paths without .json extension and
using key=value sed to update metadata. This broke after the storage
V2 migration which uses .json files with JSON content.
Changes:
- Try {sessionId}.json first, fall back to bare {sessionId} for
pre-migration layouts
- Detect JSON format (first char '{') and use jq for updates
- Fall back to key=value sed for legacy metadata files
- Bump WRAPPER_VERSION 0.3.0 → 0.4.0 to force wrapper reinstall
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(core): reset lifecycle on restore and keep killed sessions in active metadata
Two runtime bugs fixed:
1. Restore: lifecycle object was not reset — lifecycle manager read the old
terminal state and immediately transitioned back to Done. Now resets
lifecycle to working/alive via cloneLifecycle + buildLifecycleMetadataPatch.
2. Kill: sessions were immediately archived, making them invisible to list()
and get(). Dashboard showed "Page not found" instead of Done/Terminated.
Now keeps killed sessions in active metadata with terminal status.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(core): address storage redesign review findings
Fix CI blocker and several correctness/consistency issues found during
review of PR #1466:
1. Fix codex plugin test failures — WRAPPER_VERSION bumped to 0.4.0 in
agent-workspace-hooks.ts but codex tests still expected 0.3.0
2. Add agentReport and reportWatcher to jsonFields in
unflattenFromStringRecord — these object fields were missing from the
known-fields set, causing silent data corruption on mutateMetadata
roundtrip (object → string → stays string instead of reparsing)
3. Normalize prAutoDetect writes from "off" to "false" in
session-manager — the JSON round-trip converts "off" to boolean false
on disk, which flattens to "false" on read-back. Writing "false"
directly avoids the ambiguity and matches the round-trip behavior
4. Fix STORAGE_REDESIGN.md to match implementation — archive path is
sessions/archive/ (not a sibling of sessions/), and status is still
persisted (computed-only deferred to follow-up)
5. Keep detecting fields at top level during migration — the lifecycle
manager reads detectingAttempts/detectingStartedAt/detectingEvidenceHash
from session.metadata (top-level), not from lifecycle.detecting.
Nesting them during migration caused silent reset on first poll
6. Remove to-test.md development artifact (895 lines)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(core): remove unused readMetadata import in lifecycle test
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(core): fix 3 critical migration issues
1. Orchestrator blindness: stop extracting orchestrators to orphaned
orchestrator.json — write them to sessions/ where runtime reads from.
2. Pre-lifecycle "unknown": preserve status in migrated JSON when no
statePayload exists, preventing readMetadata fallback to "unknown".
3. Archive timestamp collision: add counter to archive filenames to
prevent same-millisecond overwrites. Fix dead-code ternary.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(core): eliminate status dual truth, fix jsonFields whitelist, add rollback dry-run and tests
- Status is now computed on read from lifecycle (single source of truth).
deriveLegacyStatus maps session.reason to specific terminal statuses
(killed, cleanup, errored) instead of relying on stored previousStatus.
- Remove jsonFields whitelist in unflattenFromStringRecord — auto-detect
JSON by checking if value starts with { or [. Prevents silent
stringification of new JSON fields.
- Add dryRun option to rollbackStorage and wire through CLI --dry-run.
- Add 18 tests for V2 path functions (getProjectDir, assertSafeProjectId,
compactTimestamp, parseTmuxNameV2, etc.).
- Add migration edge case tests: worktree dir migration, pre-lifecycle
status preservation, archive filename uniqueness, active session blocking.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(core): fix stray worktree recursion, rollback data loss, and worktree path rewrite
- moveStrayWorktrees now recurses into ~/.worktrees/{projectId}/{sessionId}/
(default workspace plugin layout) instead of only scanning top-level entries
- Rollback checks for post-migration sessions before deleting project dirs,
preserving sessions created after migration with a warning
- Worktree path rewrite only fires when the destination directory actually
exists, keeping original paths for worktrees not yet moved
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(core): reset terminal PR state on session restore
When restoring a session whose PR was already merged/closed, the
lifecycle manager would immediately re-detect the merged PR and
terminate the session again — making restore useless for merged sessions.
On restore, if pr.state is "merged" or "closed", reset it to "none"
with reason "cleared_on_restore". This lets the session run freely;
if the agent creates a new PR, auto-detect picks it up normally.
Also clears mergedPendingCleanupSince to prevent stale cleanup.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(core): remove stale previousStatus args and unused SessionStatus import
Two call sites in lifecycle-manager.ts still passed session.status as
a second argument to deriveLegacyStatus and buildLifecycleMetadataPatch
after the previousStatus parameter was removed. Also removes unused
SessionStatus import from metadata.ts.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(core): address PR review comments — parser, docs, delete route, prefix sanitization
- parseTmuxNameV2: allow hyphens in prefix to match sessionPrefix
validation ([a-zA-Z0-9_-]+), fixing "my-app-1" parsing
- SessionMetadata: update stale doc comments — JSON format, no hash prefix
- DELETE /api/projects/[id]: report actual removedStorageDir based on
whether the directory existed before deletion
- start.ts registerFlatConfig: sanitize projectId before deriving
sessionPrefix, matching config-generator.ts behavior
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(agent-claude-code): add --dangerously-skip-permissions for all restored sessions
getRestoreCommand only added the flag for orchestrator sessions, but
getLaunchCommand adds it for any session with permissionless/auto-edit.
This caused restored worker sessions to lose permissionless mode.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(core): skip .migrated dirs in inventory to prevent .migrated.migrated on re-run
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(core): rollback worktree preservation, scoped tmux detection, JSON parse whitelist
- Move worktrees back to restored hash dirs before deleting project dir on rollback
- Scope v2OrchestratorPattern to known project prefixes instead of matching any tmux session
- Restrict unflattenFromStringRecord JSON parsing to known structured fields only
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(cli): show "Add this project" option in ao start project picker
When running ao start in a git repo that isn't registered, the project
selector now includes an option to add the current directory as a new
project instead of requiring the user to run a separate command.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(cli): show "Add project" in already-running menu when cwd is unregistered
When AO is already running and the user runs ao start from an
unregistered git repo, the menu now offers to add that directory
as a new project alongside the existing open/restart/quit options.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(cli): add --reports flag to ao status for agent report history
Adds --reports option to `ao status` that displays the agent report
audit trail per session. Accepts "full" for all entries or a positive
integer for the last N entries.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(cli): replace removed storageKey reference with getProjectSessionsDir in status command
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(core,web): address PR review issues — crash safety, atomic ops, corrupt data handling, test fixes
- metadata.ts: handle corrupt JSON gracefully (return null instead of crashing), use atomic renameSync for archive, conditionally persist status only when lifecycle is not an object
- storage-v2.ts: add crash-safety marker file for migration, fix archive filename handling for .json suffix and compact timestamps, use Date parsing for duplicate session resolution
- lifecycle-state.ts: add JSDoc and clarify deriveLegacyStatus default case behavior
- lifecycle-transition.ts: add JSDoc clarifying buildTransitionMetadataPatch scope
- AddProjectModal.test.tsx: fix pre-existing jsdom localStorage mock so saveRecentPath works in tests
- Add tests for corrupt JSON handling, migration markers, and crash recovery
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(core): harden storage-redesign against edge cases (EC-1 through EC-8, EC-14, EC-27)
Address 10 edge cases found during systematic review of storage redesign:
- EC-1: Wrap mutateMetadata read-modify-write in withFileLockSync to prevent race conditions
- EC-2: Replace existsSync+readFileSync TOCTOU pattern with try-catch in readMetadata/readMetadataRaw
- EC-3: Append PID to archive filenames to prevent same-second collision
- EC-4/5: Add crossDeviceMove helper with EXDEV fallback (cpSync+rmSync) for migration renames
- EC-6/13: Restrict project ID validation to [a-zA-Z0-9][a-zA-Z0-9._-]* with 128-char max
- EC-7: Guard rollback rename against pre-existing target directory
- EC-8: Add mtime+path tiebreaker for duplicate session resolution
- EC-14: Fix misleading "Resuming" log message in migration
- EC-27: Extend readMetadataRaw status override to handle statePayload-only sessions
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(core,cli): prevent silent data loss on upgrade — V1 detection, git worktree repair, storageKey preservation
Three P0 fixes for storage-redesign migration UX:
1. Warn on `ao start` when legacy hash-based directories exist,
telling users to run `ao migrate-storage` before sessions disappear.
2. Run `git worktree repair` from each project's repo root after
migration moves worktree directories — fixes broken git references
that would otherwise make git status/push fail inside moved worktrees.
3. Preserve `storageKey` in global config allowlist so it isn't silently
stripped on load before migration has a chance to use it.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(core): skip active session check during migrate-storage --dry-run
Dry run is read-only — blocking on active sessions defeats the purpose
of previewing what migration would do.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(integration-tests): update archive filename regex for PID suffix
EC-3 appended -p{pid} to archive filenames to prevent same-second
collisions. Update the integration test regex to match the new format.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(core): address final merge review — Zod schema gaps, worktree repair, rollback safety, status priority
5 fixes from final review:
1. Add storageKey to GlobalProjectEntrySchema (Zod) so it survives
parse→save round-trips until migration strips it.
2. Add 5 missing reason values to lifecycle Zod schemas
(auto_cleanup, pr_merged, cleared_on_restore, pr_merged_cleanup)
so lifecycle isn't silently reconstructed from stale status on restart.
3. Run repairGitWorktrees when stray worktrees are moved, not only
when hash-dir worktrees are moved (was checking wrong counter).
4. Count archived post-migration sessions in rollback safety check
so rollback warns before silently deleting user's archived data.
5. Fix portfolio-session-service status priority to prefer lifecycle-
derived status over stored, matching metadata.ts behavior.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(core): harden storage redesign migration rollback
* fix(core,cli,web): allocate suffixed project ids on duplicate names
* fix(core,cli): graceful migration errors + skip orchestrator selector
- Migration: wrap per-project migration in try/catch so one failure
doesn't abort the entire run. Handle ENOTEMPTY when .migrated target
already exists from an interrupted previous run.
- CLI: ao start now always opens the selected orchestrator's dashboard
page directly instead of the orchestrator selector.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(core,cli): align with upstream to reduce merge conflicts
Bump WRAPPER_VERSION from 0.4.0 to 0.6.0 to match upstream's gh CLI
tracer changes (#1238), and update start.test.ts URL assertion to use
canonical orchestrator IDs (no number suffix) per upstream's orchestrator
identity fix (#1487). These pre-merge alignments eliminate 3 of the 11
conflicts when merging upstream/main.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: resolve Phase 1+2 merge conflicts with upstream/main (#1487, #1238)
* fix(core,web): allow restoring merged sessions
Remove "merged" from NON_RESTORABLE_STATUSES and delete the
hasMergedLifecyclePR guard so sessions with merged PRs can be
restored like any other terminal session. Previously clicking
"Restore" on a merged session returned a misleading 409 error
("session is not in a terminal state") — the session was terminal,
just explicitly blocked.
Also fix Dashboard.tsx to show the restore button for merged
sessions and improve the error message in restore() to include
the actual status and activity state.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Implement hashed project identity
* fix(core,cli,web): address PR #1466 review findings
1. Patch session JSON worktree field after moving stray worktrees
2. Preserve migration marker and skip config stripping on partial failure
3. Sanitize legacy project IDs with unsafe characters during migration
4. Use sed-based JSON update when jq is unavailable instead of corrupting
JSON metadata with key=value fallback
5. Fall back to flat local config repo during first registration when
git origin provides no repo identity
6. Return and print effective registered project ID from ao project add
7. Update web route tests to use effective hashed project IDs and fix
repairWrappedLocalProjectConfig to find entries by content fallback
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(core): use strict equality to satisfy eqeqeq lint rule
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(core,web): address Copilot review comments
1. parseTmuxNameV2: accept digit-leading prefixes to match the config
schema validation ([a-zA-Z0-9_-]+)
2. DELETE /api/projects/[id]: return 400 for unsafe project IDs instead
of letting getProjectDir throw into the 500 catch-all
3. POST /api/projects: return structured 409 on collision with
existingProjectId, suggestedProjectId, and suggestion fields so the
AddProjectModal collision UI actually works
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(core,workspace): route new worktrees to V2 project directory
The workspace-worktree plugin defaulted to ~/.worktrees/ for all new
worktrees, bypassing the V2 layout entirely. New sessions created
worktrees at ~/.worktrees/{projectId}/{sessionId} instead of
~/.agent-orchestrator/projects/{projectId}/worktrees/{sessionId}.
Add optional worktreeDir to WorkspaceCreateConfig so session-manager
can pass getProjectWorktreesDir(projectId) per spawn/restore call.
The plugin uses this override when provided, falling back to the
plugin-level default for backward compat.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(cli): prefix unused addCwdOption variable to satisfy lint
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(core): address migration review findings and orchestrator tmux double-prefix
Migration (storage-v2.ts):
- Use atomicWriteFileSync for all session JSON writes (crash safety)
- Wrap stripStorageKeysFromConfig in withFileLockSync (concurrency safety)
- Add case-insensitive projectId collision detection (macOS HFS+/APFS)
- Call repairGitWorktrees in rollback path (git worktree ref repair)
- Skip stray worktree moves for failed projects (partial-failure safety)
Session manager:
- Fix orchestrator tmux name double-prefix: getOrchestratorSessionId
already returns "{prefix}-orchestrator", so tmuxName should use
sessionId directly, not "${prefix}-${sessionId}"
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(core): remove archive path functions from paths.ts and index.ts
Remove getProjectArchiveDir, getArchiveFilePath, and compactTimestamp
from V2 path helpers as part of archive system removal. Sessions will
stay in sessions/ with lifecycle.state: "terminated" instead of being
moved to sessions/archive/. Callers in metadata.ts and migration will
be updated in subsequent tasks.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(core): remove archive logic from metadata.ts
Remove archive system from metadata layer: simplify deleteMetadata to
permanent-only deletion, delete readArchivedMetadataRaw and
updateArchivedMetadata functions, and update unit/integration tests.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(core): remove archive code from session-manager.ts
Remove all archive-related logic from the session manager now that
terminated sessions stay in sessions/ instead of being moved to an
archive directory.
Changes:
- Remove readArchivedMetadataRaw/updateArchivedMetadata imports
- Delete listArchivedSessionIds and markArchivedOpenCodeCleanup functions
- Remove archive search from findOpenCodeSessionIds
- Remove listArchivedSessionIds from reserveNextSessionIdentity
- Replace archive fallback in kill() with readMetadataRaw + lifecycle check
- Remove archive fallback in restore() (findSessionRecord finds all sessions)
- Replace archive iteration in cleanup() with terminated session iteration
- Remove boolean archive flag from all deleteMetadata calls
- Remove unused readdirSync import
- Update lifecycle and restore tests to use terminated state instead of archive
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(core): stop archiving sessions on cleanup in recovery/actions.ts
Remove the deleteMetadata call that archived sessions after marking them
terminated. Sessions now remain in sessions/ with terminated state.
Also remove the now-unused deleteMetadata import.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* refactor(core): flatten archives into sessions/ during migration instead of copying to archive dir
Remove the archive system from storage-v2 migration: old V1 archives are now
flattened into sessions/ as terminated session records instead of being copied
to sessions/archive/. Duplicate sessions across hash dirs are skipped with a
warning instead of being archived. Remove fixArchiveFilename(), compactTimestamp
import, archives field from result types, and archive counting from rollback.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(core): remove archive directory filter from listMetadata
The isFile() check already excludes directories. Archive filter was only
needed when sessions/archive/ was actively used.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(core): update tests to match archive-removal behavior
cleanupSession in recovery/actions.ts now marks sessions as terminated
instead of deleting metadata. Updated two recovery-actions tests to
assert on terminated status instead of file deletion. Also fixed
metadata and integration tests for the new deleteMetadata signature
(no boolean archive arg).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(core): address build/test issues from archive removal
- Fix writeMetadata calls missing required fields in test files
- Remove boolean archive arg from deleteMetadata calls in integration tests
- Update recovery-actions tests to expect terminated state instead of deletion
- Remove unused readdirSync import from migration test
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* docs: update handoff doc — archiving removed
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* docs: remove handoff document
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(cli): add last-stop state persistence for ao stop/start restore
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(cli): ao stop kills all active sessions and records them
ao stop now kills all active sessions (orchestrator + workers), not just
the orchestrator. Killed session IDs are saved to last-stop.json for
restore on next ao start.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(cli): ao start offers to restore sessions from last ao stop
On interactive startup, if last-stop.json exists with sessions for the
current project, the user is prompted to restore them. The orchestrator
is skipped (already restored by ensureOrchestrator). The file is cleared
after the prompt regardless of choice.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(cli): update stop tests for all-sessions kill behavior
Update test mocks to return proper KillResult shape and adjust test
assertions for the new all-sessions stop behavior. Add console.log
fallback for killed session IDs (non-TTY/test capture).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(core): address review — sed JSON corruption, sanitizeBasename dot
- Replace sed-based JSON fallback with node -e in workspace hooks and
claude-code plugin. sed "s|}|...|" replaces the first } per line,
corrupting nested JSON (lifecycle, runtimeHandle). node is a hard dep
and handles nested objects correctly via JSON.parse/stringify.
- Drop . from sanitizeBasename allowed chars — config.ts Zod schema
rejects dots in project keys, so my.app_hash would fail loadConfig.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: address storage redesign review issues
* fix(core): persist stale runtime state + show cross-project sessions in ao stop/start
- session-manager: persist lifecycle to disk when enrichment detects dead
runtime (missing/exited) — prevents stale "alive" metadata from keeping
terminated sessions on the active sidebar (ao-100 bug)
- lifecycle-state: map runtime_lost reason to "killed" legacy status
- ao stop: list ALL sessions across projects, not just targeted project;
display and record cross-project sessions in last-stop.json
- ao start: show sessions from other projects that were stopped, so user
knows they need separate ao start for those projects
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(cli): scope ao stop to target project when explicit arg is given
ao stop (no arg) kills all sessions across all projects since it also
kills the parent ao start process. ao stop <project> now correctly
scopes to just that project's sessions.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(cli): show all projects in tab completions by merging global config
listProjects() only read the local config (found via cwd search), which
may contain just one project. Now also reads the global config at
~/.agent-orchestrator/config.yaml to include all registered projects
in shell completions for ao stop, ao start, etc.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(cli): fall back to global config when project arg not in local config
ao stop <project> and ao start <project> failed when cwd has a local
agent-orchestrator.yaml that doesn't contain the targeted project.
Now falls back to ~/.agent-orchestrator/config.yaml which has all
registered projects, matching what tab completions already show.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(cli): ao stop <project> must not kill parent process or dashboard
ao stop donna was killing the parent ao start process and dashboard,
which serve ALL projects. Now only kills the parent process and
dashboard when no project arg is given (full shutdown). When targeting
a specific project, only that project's sessions are killed.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(cli): always load global config for ao stop to see all projects
sm.list() iterates config.projects to find sessions. When loadConfig()
finds the local agent-orchestrator.yaml (1 project), ao stop only sees
that project's sessions — other projects' tmux sessions survive. Now
ao stop always loads the global config which has all registered projects.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(cli): ao start restores all sessions including cross-project ones
ao start showed sessions from all projects but only restored the
current project's sessions. Now restores all sessions listed, using
the global config so the session manager can see all projects.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(web): sidebar shows all sessions regardless of active project
Remove project scoping from useSessionEvents so the sidebar always
receives sessions from every project. Kanban filtering is applied
client-side via a projectSessions memo.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(cli): Ctrl+C on ao start performs full graceful shutdown
Previously Ctrl+C only stopped lifecycle workers and exited, leaving
sessions alive in tmux and not recording last-stop state for restore.
Now the SIGINT/SIGTERM handler mirrors ao stop: kills all sessions,
records last-stop state, and unregisters from running.json. A 10s
timeout ensures the process always exits even if cleanup hangs.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* docs: update architecture docs for CLI, lifecycle, and dashboard changes
- CLAUDE.md: add canonical lifecycle states/reasons, stale runtime
reconciliation, LastStopState + running.json to storage section,
config resolution note, CLI behavior section (ao start/stop/Ctrl+C),
key files (lifecycle-state.ts, running-state.ts, start.ts, sidebar)
- AGENTS.md: add lifecycle-state.ts, start.ts, running-state.ts to key
files, add CLI behavior notes section
- copilot-instructions.md: add lifecycle-state.ts + start.ts to
high-risk files, add common mistakes for runtime_lost, sidebar
scoping, and ao stop project scoping
- DESIGN.md: add decision log entry for sidebar cross-project sessions
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* docs: update PR behavior dashboard with behavioral fixes and cross-project CLI
Add sections for stale runtime reconciliation, dashboard sidebar
scoping, tab completions, config resolution, Ctrl+C graceful shutdown,
and documentation updates. Update stats to 90 files, +6481/-2421.
Update ao stop/start panels with cross-project behavior. Update
summary with runtime reconciliation and cross-project CLI verdicts.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* Revert "docs: update PR behavior dashboard with behavioral fixes and cross-project CLI"
This reverts commit 6d968b9ff5.
* fix(cli): add removeProjectFromRunning and targeted stop tests
- Add removeProjectFromRunning() to running-state.ts — removes a
project from running.json so ao start <project> can restart without
hitting the "already running" gate after ao stop <project>
- Add projectNeedsRestart check in ao start — skips "already running"
menu when the project was removed from running.json by a targeted stop
- Add 6 tests for targeted stop behavior: no parent kill, no unregister,
removes project from running.json, kills correct sessions, full stop
still tears down parent+dashboard, last-stop records correct scope
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* docs: update handoff docs with accurate checkout recipes and CLI details
Fix checkout instructions to not assume everyone has the same fork as
origin — add separate sections for the PR author vs new contributors.
Correct stop.ts references (doesn't exist — stop logic is in start.ts).
Document removeProjectFromRunning, projectNeedsRestart gate, isProjectId
guard, and Ctrl+C signal handler details.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(cli): handle URL/path args when AO is already running
Previously `ao start <URL>` or `ao start <path>` while the daemon was
already running silently ignored the arg and showed a menu about cwd.
The user's URL was dropped.
Now, for TTY callers, when AO is already running and a URL/path arg is
provided:
- If the project is already registered AND in running.projects, just
open the dashboard. No menu, no re-clone.
- Otherwise, register the project against the active config (clone for
URLs via handleUrlStart, or addProjectToConfig for paths) and open
the existing dashboard. Don't fall through to runStartup — that would
spawn a duplicate dashboard on a different port.
Non-TTY callers (scripts/agents) keep the old "AO is already running"
message and do NOT mutate config behind the user's back.
Adds two tests:
- Path arg already registered + running → opens dashboard, no menu, no
YAML mutation.
- Path arg unregistered + AO running → registers without prompting, no
menu, prints "Opening the dashboard".
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(cli): register URL/path args in global config and spawn orchestrator
Previously `ao start <URL>` while AO was already running would register
the new project in the cwd's local config (polluting an unrelated
project's YAML) and tell the user to `ao stop && ao start <id>` to
actually spawn the orchestrator — clunky and surprising.
Now the flow:
- Always register against ~/.agent-orchestrator/config.yaml (global),
never the cwd's local config. URLs go through handleUrlStart to
clone, then are re-registered globally; paths go through
addProjectToConfig with a global-config arg so it routes to
registerProjectInGlobalConfig.
- Spawn the orchestrator session via sm.ensureOrchestrator so the
dashboard immediately shows it.
- Warn that lifecycle polling for the new project requires
`ao stop && ao start <id>` (the running daemon's worker can only
poll projects it knew about at startup).
- Open the existing dashboard. No duplicate dashboard, no menu.
Already-registered + running case unchanged: just open the dashboard.
Tests updated to set AO_GLOBAL_CONFIG so the global lookup is isolated
from the test machine's real config, and to assert ensureOrchestrator
is called with the new project ID.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(cli): reload dashboard config after registering new project
After `ao start <URL/path>` registers a new project in the global
config, the running dashboard's services cache still holds the stale
config — so the project page 404s until the daemon is restarted.
Hit POST /api/projects/reload (which invalidates the services cache)
right after registering. Failure to reach the dashboard is non-fatal:
print a hint to refresh the page.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(cli): repair wrapped local config after URL clone
handleUrlStart writes a legacy wrapped (`projects:`) agent-orchestrator.yaml
inside the cloned repo. After registering the project against the global
config, the project resolver hits the wrapped local config and routes the
project into degradedProjects (with a resolveError) — so loadConfig drops
it from config.projects and ao start would throw "Failed to register".
Call repairWrappedLocalProjectConfig() right after the global registration
to convert the wrapped config to the flat format the new resolver expects.
Best-effort: if repair fails, defaults fill in behavior.
Cleanup note: any wrapped local configs from earlier runs (and stale
~/.agent-orchestrator/config.yaml entries from earlier test runs that
pre-dated AO_GLOBAL_CONFIG isolation) need manual cleanup.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(cli): clone+register flat config directly, surface empty-repo errors
Replaces the previous "register, then repair the wrapped config" hack
with a single-shot clone-and-register flow that produces a valid flat
local config from the start.
Why the previous flow was wrong:
- handleUrlStart writes a legacy wrapped (`projects:`) agent-orchestrator.yaml
inside the cloned repo. The new global-config resolver rejects that
shape and routes the project into `degradedProjects`, which breaks
`loadConfig().projects[id]` lookups and 404s the dashboard route.
- repairWrappedLocalProjectConfig() papered over that — but the right
fix is to never write a wrapped config in the first place.
What this does instead, when `ao start <URL>` runs while the daemon
is alive:
1. Parse the URL, resolve the clone target, clone (or reuse).
2. Detect the actual default branch via `git symbolic-ref refs/remotes/origin/HEAD`,
falling back to local HEAD. Returns null for empty repos.
3. If the repo is empty (no commits / no refs), fail early with a
clear actionable message — otherwise ensureOrchestrator throws a
confusing "Unable to resolve base ref" deep inside the worktree
plugin.
4. registerProjectInGlobalConfig with identity only (path, repo,
defaultBranch, sessionPrefix derived from project ID).
5. writeLocalProjectConfig with behavior only (scm + tracker plugin
choices, derived from the host platform). Skip the write if the
repo already commits its own agent-orchestrator.yaml.
6. Refresh the global config and spawn the orchestrator session.
Drops `repairWrappedLocalProjectConfig` import — no longer needed.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(migration): keep agent-report and report-watcher metadata flat
Migration was nesting six agent-report keys (agentReportedState, At,
Note, PrUrl, PrNumber, PrIsDraft) and four report-watcher keys
(reportWatcherLastAuditedAt, ActiveTrigger, TriggerActivatedAt,
TriggerCount) into `agentReport` / `reportWatcher` wrapper objects.
The live runtime readers — parseExistingAgentReport in agent-report.ts
and the report-watcher writes in lifecycle-manager.ts — read these
keys flat off `session.metadata`. readMetadataRaw() then runs the
result through flattenToStringRecord(), which JSON.stringify()s any
object value into a single string under the wrapper key. It does NOT
unfold the nested object back into the flat keys the readers expect.
Net effect: any V1 session that had a non-empty agent report or a
non-zero report-watcher trigger count silently lost that state after
migration. The active-tmux gate in `ao migrate-storage` blunts the
worst case (sessions are terminated by the time migration runs, so
the freshness window often expires the data anyway), but reports
within the 5-minute freshness window and dashboard "last reported"
fidelity are still affected.
Fix: keep these ten keys flat in the V2 JSON, identical to the
existing handling for the `detecting*` fields. Same rationale, same
shape. Adds a regression test that asserts the flat keys round-trip
through migration and rewrites the two grouping tests to assert the
new flat shape.
Reported on PR #1466 by @ashish921998.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* docs(prompt): teach orchestrator to read agent reports via ao status --reports
The orchestrator system prompt explained the worker-only `ao report`
command and the freshness/precedence rules around agent reports, but
never told the orchestrator how to inspect them. The CLI flag
`ao status --reports <full | N>` already exists for exactly this
purpose — surface it in Monitoring Progress and cross-reference it
from the Explicit Agent Reports section so the orchestrator has an
obvious read path when an inferred status disagrees with what the
worker self-reported.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(cli): attach to existing daemon for ao start <project> after targeted stop
Reported on PR #1466 as P1: after `ao stop <project>` removed the project
from running.json (via removeProjectFromRunning) but left the parent
ao start process alive, `ao start <project>` took the projectNeedsRestart
path and fell through to runStartup(). runStartup() then started a SECOND
dashboard on a new port and overwrote running.json — leaving two AO
processes running, with running.json pointing at only the new one and the
original parent's lifecycle worker still polling.
Fix: when running && projectArg is a project ID && project not in
running.projects, attach to the existing daemon instead of falling through
to runStartup. The new branch:
- Loads the project from the global config and refuses with a clear error
if it isn't registered there.
- Spawns the orchestrator session via the live session manager
(sm.ensureOrchestrator).
- Calls the new addProjectToRunning() helper to put the project back into
running.json so subsequent `ao stop` (no args) sees it and `ao spawn`
doesn't print the "running instance is not polling project X" warning.
- Reloads the dashboard's services cache via POST /api/projects/reload so
the project page works on the existing dashboard.
- Surfaces a yellow warning that lifecycle polling for the new project
isn't attached without a full daemon restart — same architectural caveat
documented in the URL/path attach branch and tracked separately as the
dynamic project supervisor follow-up issue (#1522).
- Works for both TTY and non-TTY callers; non-TTY just skips the
openUrl + dashboard popup.
Adds addProjectToRunning() in running-state.ts symmetric to the existing
removeProjectFromRunning(): file-locked, idempotent, no-op when state is
missing or already lists the project.
Adds a regression test that asserts:
- mockRegister is NOT called (no second daemon registration)
- ensureOrchestrator is called with the requested projectId
- addProjectToRunning is called with the projectId
- The interactive menu is NOT shown
- Output contains the expected "Attaching to running AO instance" /
"reattached to running daemon" lines
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* docs: add scripts/demo-pr-1466.sh — end-to-end reviewer demo
Self-contained, sandboxed walkthrough of every PR #1466 behavior change.
Designed to be recorded as a screencast — section banners replace
narration, no live typing, deterministic output.
Six acts:
1. Migration V1 → V2 (live: seed hash dirs + key=value, dry-run, execute,
show V2 layout, verify @ashish921998 fix that agent-report keys stay
flat after migration, prove rollback safety on rerun)
2. Cross-project CLI P1 fix (filter the regression test by name and run
it live — asserts no second daemon is spawned by ao start <project>
after ao stop <project>)
3. Dashboard sidebar shows all projects (display the Dashboard.tsx fix)
4. Restore from ao stop / Ctrl+C (last-stop.json round-trip)
5. Ctrl+C graceful shutdown handler with 10s hard timeout
6. Empty-repo guard for ao start <URL> (the detectClonedRepoDefaultBranch
null path that surfaces a useful error before ensureOrchestrator)
Then prints the final 560 / 981 test summary so the recording ends on
a green CI signal.
Sandbox notes:
• $HOME is overridden to /tmp/ao-demo-1466 for the duration of the
script so getAoBaseDir() resolves there. The operator's real
~/.agent-orchestrator is never touched.
• A REAL_HOME is captured before the override and restored when
running the full test suites, since vitest needs the operator's
real config path to avoid cross-test contamination.
• Re-run is idempotent — rm -rf $DEMO_HOME at the top recreates
the sandbox from scratch.
Verified runs end-to-end on storage-redesign with exit code 0.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* demo: richer fixture — 2 projects, 6 sessions, real source trees
Earlier seed was a single empty repo with one session and a 2-line README.
Reviewers would dismiss it as not credible migration evidence.
New seed:
• 2 source projects (myproject, frontend), each a real TS package layout
with package.json, tsconfig.json, src/lib/, tests/, .gitignore, README,
and 6 commits of history. 8 files per repo.
• 6 sessions across the 2 hash dirs, in varied states:
- ao-1 (working, agent-report state + report-watcher counters + PR
fields — headline @ashish921998 flat-key fix in one record)
- ao-2 (V1-archived, terminated, manually_killed)
- ao-3 (stuck, with report-watcher trigger active)
- my-orchestrator-1 (kind=orchestrator)
- fe-1 (working, PR open with PR fields)
- fe-2 (V1-archived, terminated, runtime_lost)
• Real git worktree for ao-1 with an actual diff file —
proves worktree migration moves files and rewrites git refs.
• Pre-seeded global config.yaml lists both projects so the migrator
has identity to project against.
Migration handles all 6 sessions (4 active + 2 archived → flattened) and
the 1 worktree. The verification step inspects ao-1.json post-migration
and asserts every flat agent-report / report-watcher key from the
@ashish921998 fix is present, with no nested wrapper objects.
Also fixes:
• MIGRATED_PROJECT used to grab alphabetically-first directory which
made the JSON read crash when frontend won — hardcoded to myproject.
• Before-display referenced $HASH_DIR/archive but the actual archive
location is $HASH_DIR/sessions/archive — corrected.
End-to-end verified: exit 0, "PASS — agent-report flat-key contract
preserved", 560/560 CLI + 981/981 core tests in the final summary.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(migration): relink Claude Code session storage when worktrees move
Reported in PR #1466 QA: after `ao migrate-storage`, restoring a session
launches a fresh `claude` instance — chat history is gone.
Root cause: Claude Code keys session JSONLs by the encoded form of the
workspace cwd (~/.claude/projects/<encoded>/<session-uuid>.jsonl, where
encoded = cwd with `/` and `.` replaced by `-`, see toClaudeProjectPath
in agent-claude-code/src/index.ts). The migrator moves worktrees from
~/.agent-orchestrator/{hash}-{project}/worktrees/{sid} to
~/.agent-orchestrator/projects/{projectId}/worktrees/{sid}, which
produces a different encoded path. The agent's session JSONLs are still
at the old encoded path and stay orphaned. getRestoreCommand looks under
the new encoded path, finds nothing, returns null — and the caller
falls back to a fresh launch.
Fix: track every (oldWorkspacePath, newWorkspacePath) pair across both
migration phases (per-project migrateProject and the cross-project
moveStrayWorktrees), then call relinkClaudeSessionStorage after all
worktree moves complete. The relink renames each
~/.claude/projects/<old-encoded>/ → <new-encoded>/. Skip when source
doesn't exist (no Claude history) or target already exists (manual
reconciliation needed). Same step is invoked in reverse from
rollbackStorage so `--rollback` undoes the relink.
The encoding helper is duplicated locally in migration/storage-v2.ts to
avoid pulling the agent plugin into core/migration just for one string
transformation. Kept in sync by hand; if the plugin's encoding ever
changes, both copies need to update together.
Codex stores sessions date-sharded with the cwd embedded inside each
JSONL's session_meta line, so the same physical-rename trick doesn't
apply. Codex relinking is left as a follow-up — the comment in
relinkClaudeSessionStorage points at it.
Two regression tests added:
• Happy path: V1 worktree at OLD encoded path with a JSONL inside
Claude's projects dir; after migrateStorage the JSONL is at the
NEW encoded path and the OLD dir is gone. claudeSessionsRelinked === 1.
• Safety: target dir already exists at the new encoded path; migration
skips the relink, neither dir is touched, claudeSessionsRelinked === 0.
Tests use HOME override to sandbox ~/.claude/ so the runner's real
agent-storage is never touched.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(boundary): four cross-module seams flagged in PR #1466 review
- Migration: rewrite Codex rollout session_meta.cwd for moved
worktrees so getRestoreCommand keeps finding the old thread.
Mirrors the Claude relink with a single-line in-place rewrite.
- CLI start: stop adding the project to running.projects in the
attach-to-existing-daemon branch. Lifecycle polling cannot be
attached mid-flight, so claiming coverage made `ao spawn`
silently suppress its "instance is not polling X" warning.
- CLI stop: defensively drop foreign sessions before the kill
loop when a project arg is given. `sm.list(projectId)` already
scopes, but the kill loop is destructive enough to deserve a
consumer-side guard.
- Web DELETE /api/projects/[id]: validate the id through
getProjectDir BEFORE calling cleanupManagedWorkspaces so a
malformed key never reaches a workspace plugin.
Adds regression tests for each.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(boundary): four more cross-module seams flagged in PR #1466 review
- Recovery actions (cleanup/escalate/recoverSession-on-max-attempts)
now mutate the canonical lifecycle alongside the flat status. For
V2 sessions readMetadataRaw derives status from lifecycle, so the
prior flat-only writes were silently overridden on the next read.
- Targeted `ao stop <project>` no longer calls
removeProjectFromRunning. The parent process's in-memory lifecycle
worker keeps polling that project (a child CLI cannot reach into
parent memory), so running.projects must keep listing it to remain
truthful. The attach branch in `ao start <project>` now triggers
on any project-id arg with a live daemon, regardless of
running.projects content; the polling-not-attached warning fires
only when the project is genuinely not in running.projects.
- `ao start` restore loop preserves last-stop.json for sessions that
fail to restore (transient workspace/runtime errors) instead of
clearing the only persisted record. Successful or fully-failed
flows still clear it.
- New integration round-trip: migrate a Codex JSONL with the old
worktree cwd, then call the real agent-codex.getRestoreCommand
with the migrated workspacePath and assert it returns
`codex resume <threadId>`.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(review): four illegalcall PR review findings on PR #1466
- writeMetadata sites in session-manager (spawn + ensureOrchestrator)
spread `buildLifecycleMetadataPatch` (string-typed patch) into a
typed SessionMetadata literal, which silently wrote `lifecycle` as
a JSON string and made freshly-spawned sessions read with
`lifecycle: undefined` until the first poll round-trip.
Override the spread with the canonical object form and drop the
metadata.ts safety net that compensated for the bug.
- Migration archive-flatten regex `/^([a-zA-Z0-9_-]+?)_\d/` was lazy
and captured `team` for `team_1-7_<ts>.json`. Replace with an
anchor on the timestamp suffix in both call sites so any sessionId
containing `_<digit>` is parsed correctly.
- Migration duplicate-sessionId resolution renamed-the-loser to
`${sessionId}__from-${hash}` rather than silently dropping it.
Both records survive in V2; the rename is logged.
- `running-state.ts` writes `running.json` and `last-stop.json` via
`atomicWriteFileSync` (temp+rename) so a crash mid-write cannot
leave torn JSON that orphans an alive AO process or erases the
next-start restore prompt.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(metadata): preserve corrupt session JSON before overwriting
mutateMetadata used to merge against an empty record and atomically
rewrite when parseMetadataContent returned null on corrupt JSON. The
original bytes were lost — the user had no signal anything was wrong,
the file just became "not corrupt anymore — and missing fields".
Side-rename the file to `<path>.corrupt-<ts>` and warn before the
rewrite so forensics survive. Adds two regression tests and drops the
stale STORAGE_REDESIGN.md reference comment.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* chore(lint): fix 4 errors introduced by recent boundary fixes
- storage-v2.ts:656,1453 — drop unnecessary `\-` escape inside `[…]`
character class (no-useless-escape).
- storage-v2.ts:979 — replace inline `import("node:fs").Dirent` type
annotation with a top-level `Dirent` named import (consistent-type-imports).
- recovery/actions.ts:11 — merge the second `../types.js` `import type`
into the existing line (no-duplicate-imports).
Tests + typecheck unchanged (991 passing).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(cli): propagate reloaded config out of resolveProject after add
The interactive "Add <cwd>" menu path in `resolveProject` registers
the project in the global config (with a hashed id like
`mail-automate_3e4d45c2ba`) and reloads the config internally to
fetch the new project entry. It returned only `{projectId, project}`,
so the outer caller kept the pre-add `config` reference — which has
no key for the just-added project.
Downstream that surfaced as:
Failed to start lifecycle worker:
Unknown project: mail-automate_3e4d45c2ba
because `ensureLifecycleWorker(config, projectId)` checks
`config.projects[projectId]` against the stale config.
`resolveProject` and `resolveProjectByRepo` now also return the
(possibly reloaded) config; the three call sites pick it up via
`({ projectId, project, config } = await resolveProject(...))`.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* feat: add config schema support for agent-orchestrator.yaml (#1370)
Expose a committed JSON Schema and inject the canonical $schema URL into generated and updated configs so editors can autocomplete and validate AO config files.
* fix schema injection edge cases and shared constant
* fix: address config schema review feedback (#1370)
* fix(cli): avoid missing repo scripts on global installs
* refactor(cli): moved ao-update.sh script into assets.
Entire-Checkpoint: b91ccadead1c
* Fix packaged doctor and update fallback
* Harden install detection and script runner
* fix(cli): align ao script launchers with packages/ao
---------
Co-authored-by: AO Bot <ao-bot@composio.dev>
* fix: codex worker session prompt file
* fix: clean up buildPrompt
* fix: revert system prompt change
* fix: cleanup user prompt
* feat: add opencode agents.md file write for worker agent
* feat: update writer function to write dynamic prompt
* fix: restore function to pass right agent role
* fix: lintfix
* fix: revert opencode agents.md file creation
* feat: add support for dynamic OpenCode configuration
* fix: update OpenCode section identifiers snd docs
* chore: update tests and changeset
* chore: lintfix
* chore: fix test
* fix(prompt-builder): include issue ID in task prompt when user prompt is not provided
The inline SessionDetailPRCard in SessionDetail.tsx shadowed the standalone
export, so any future change to PR card behavior had to be made twice.
Consolidate on the standalone file (canonical API: lifecyclePrReason +
injected onAskAgentToFix), port the branch-copy / conflict-actions /
mergeabilityReliable guard from the inline copy, and extract a shared
hasMergeConflicts(pr) helper so buildBlockerChips and the component body
share one source of truth.
Closes#1425
Co-authored-by: Prateek <karnalprateek@gmail.com>
Co-authored-by: AO Bot <ao-bot@composio.dev>
* refactor(web): split DirectTerminal.tsx into focused modules
Closes#769.
DirectTerminal.tsx was 951 lines — 2.4x over the 400-line component limit
(C-04) — with theme data, font helpers, the xterm setup effect, the
fullscreen resize effect, clipboard handlers, and the entire chrome UI
bundled into one file. The April 2026 audit comment on the issue
confirmed the UI revamp had landed and the structural split was still
wanted.
Extracted into components/terminal/:
- terminal-themes.ts — dark/light ITheme builder
- terminal-font.ts — font size constants, getStoredFontSize,
resolveMonoFontFamily
- terminal-clipboard.ts — XDA (CSI > q) handler, OSC 52 decoder, copy
keybinding
- useXtermTerminal.ts — owns the xterm instance, mux wiring, selection
preservation, touch scroll, and the reconnect/theme/font-size effects
- useFullscreenResize.ts — the RAF-polling fullscreen resize effect
- TerminalControls.tsx — chromed bar + chromeless floating controls
with OpenCode reload logic
DirectTerminal.tsx is now 130 lines of composition. buildTerminalThemes
and resolveMonoFontFamily are re-exported so existing tests continue to
import them from @/components/DirectTerminal.
The ~280-line xterm setup effect in useXtermTerminal stays intact; its
selection buffering, programmatic-scroll tracking, and cleanup ordering
are too tightly coupled to split further without passing refs between
6 hooks. Further splitting would add indirection without reducing
complexity.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test(web): add unit tests for extracted terminal submodules
The DirectTerminal split in the previous commit created testable pure-
logic modules that had been buried inside the original mega-effect.
Adding tests for the two that benefit most:
- terminal-clipboard.test.ts (14 tests) — mocks the xterm parser surface
and exercises the XDA identity response, OSC 52 base64 decoding
(including UTF-8 multi-byte), malformed/invalid-base64 tolerance,
clipboard rejection swallowing, and the Cmd+C / Ctrl+Shift+C copy
handler (including that plain Ctrl+C falls through so SIGINT still
reaches the shell).
- terminal-font.test.ts (7 tests) — covers getStoredFontSize default,
clamp-to-min, clamp-to-max, non-numeric fallback, parseInt leading-
integer behaviour, and the localStorage-throws fallback path.
Skipped hook/component tests for useXtermTerminal, useFullscreenResize,
and TerminalControls — those are React integration surfaces and the
existing DirectTerminal.render.test.tsx already exercises them through
the composed component.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test(web): replace control-char regex with string matchers
The XDA response assertion used /^\x1bP.*\x1b\\$/ which tripped
no-control-regex (error in next build's lint step, failing both Lint
and Typecheck CI jobs). Switched to startsWith/endsWith against the
same literal escape sequences — same assertion, no regex involved.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* refactor(web): address review comments on terminal split
- terminal-clipboard.ts: drop the XDA debug console.log — it was a
diagnostic from the original fix that fires every time tmux probes
the terminal for clipboard capability (adds log noise in prod)
- useXtermTerminal.ts: hoist `let programmaticScroll = false` above
the subscribeTerminal() call so the closure captures an initialised
binding; removes the TDZ hazard if the mux ever delivers callbacks
synchronously (safe today since WebSocket callbacks are async)
- terminal-themes.ts: collapse the identical agentAccent/orchAccent
constants into a single `accent`; the existing equality tests
between agent and orchestrator variants still pass
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Prateek <karnalprateek@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* refactor(web): break down SessionDetail.tsx into focused components
Split the 1089-line SessionDetail page into smaller, focused modules so
every file stays under the 400-line component cap (CLAUDE.md C-04).
- SessionDetail.tsx: 1089 -> 248 lines, now orchestrates layout only
- SessionDetailHeader.tsx (new): topbar with PR popover + kill/restore
- OrchestratorStatusStrip.tsx (new): orchestrator zone counters
- SessionDetailPRCard.tsx: wired in, with merge-conflict actions and
reliability check that previously lived inline in SessionDetail
- PRCommentThread.tsx (new): unresolved review comment list
- Reuses session-detail-utils and session-detail-agent-actions, which
were previously orphaned dead code
Closes#770
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test(web): unit-test logic in SessionDetail extractions
Cover the pure logic in the modules extracted by #770:
- buildBlockerChips (PR card): 12 cases for CI/review/conflict/draft
branching, lifecycle hints, and the unenriched/rate-limited
reliability gate
- buildZoneStats (orchestrator strip): zone filter + canonical order
- buildAgentFixMessage (agent actions): bugbot vs plain bodies and
truncation against title/description/total budgets
Pure JSX shells (PRCommentThread, header markup) stay covered by the
existing integration tests.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(web): wire lifecyclePrReason into PR popover
Pass session.lifecycle?.prReason to SessionDetailPRCard from the topbar
header so blocker chips derived from lifecycle state (ci_failing,
changes_requested) surface in the popover.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Prateek <karnalprateek@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
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>
* 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>
* 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