* feat(core): allow source: "hook" in ActivityLogEntry (#1941)
First step of the activity-detection hook refactor: extend the AO
activity-JSONL schema so platform-event hooks (Claude Code's
PermissionRequest / Stop / StopFailure / Notification / ...) can write
entries with explicit provenance, distinct from terminal-derived
("terminal") and agent-native-JSONL ("native") writes.
No behaviour change yet — hook writers come in subsequent commits.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(agent-claude-code): add activity-updater hook scripts (#1941)
Adds bash + Node script source strings (`ACTIVITY_UPDATER_SCRIPT` /
`ACTIVITY_UPDATER_SCRIPT_NODE`) that translate Claude Code lifecycle
hooks into AO activity-JSONL entries with `source: "hook"`.
Event mapping is intentional and verified against the live Claude Code
hooks reference (code.claude.com/docs/en/hooks, not the older anthropic.com
URL the RFC referenced):
- SessionStart / Stop / SubagentStop → ready
- UserPromptSubmit / PreToolUse / PostToolUse /
PostToolUseFailure / PreCompact / PostCompact /
SubagentStart / PostToolBatch → active
- PermissionRequest → waiting_input
- Notification(permission_prompt | idle_prompt) → waiting_input
- Notification(auth_success | elicitation_*) → no-op (the RFC's
blanket "Notification → waiting_input" would false-fire here)
- StopFailure → blocked
- everything else (SessionEnd, TaskCreated, ...) → no-op
Event name comes from the stdin JSON payload's `hook_event_name`
field — the RFC's proposed `$CLAUDE_HOOK_EVENT_NAME` env var does not
exist in Claude Code. The script never blocks Claude (`exit 0` on every
path, including parse failures and disk-full).
Bash variant uses `node -p 'new Date().toISOString()'` for the timestamp
because BSD date doesn't support `%3N`. Node is a hard runtime dep of
Claude Code so this is always available.
Plugin wiring + regex-layer removal come in subsequent commits — this
commit only adds the scripts and their 52-test (bash × node) parity
suite.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(agent-claude-code): register activity-updater on every relevant hook (#1941)
`setupWorkspaceHooks` now installs the activity-updater script alongside
the existing metadata-updater and registers it on every Claude Code
event that carries activity information:
SessionStart, UserPromptSubmit, PreToolUse, PostToolUse,
PostToolUseFailure, PostToolBatch, Notification, PermissionRequest,
Stop, StopFailure, SubagentStart, SubagentStop, PreCompact, PostCompact
The metadata-updater stays registered on PostToolUse(Bash) only — git/gh
side-effect detection is unrelated to activity classification, and
splitting them keeps each script tight.
Implementation notes:
- Hook registration is now a declarative table (`HookRegistration[]`)
fed through a shared `upsertHookEntry` helper. Calling
`setupWorkspaceHooks` twice updates our entries in place; any
user-installed Stop/PreToolUse/... hook is preserved alongside ours.
- Activity-updater hooks register with matcher "" — Claude Code's
empty-string matcher fires on every variant of the event (e.g. every
Notification regardless of `notification_type`). Variant filtering
happens inside the script.
- Timeout for activity-updater is 2000ms (vs metadata-updater's 5000ms)
— the script does a single JSON parse + append.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(agent-claude-code): retire terminal-regex layer (#1941)
`classifyTerminalOutput` was the source of the 15-commit churn in #1932:
every Claude UI tweak (footer wording, status verb, spinner glyph) broke
a heuristic and needed a tightening pattern. With Claude lifecycle hooks
now writing authoritative state directly to `.ao/activity.jsonl`
(`source: "hook"`), the regex layer is structurally obsolete.
This commit removes the patterns and reduces `classifyTerminalOutput` to
a stable `return "idle"` stub:
- `recordActivity` is no longer implemented on the Claude agent — the
hooks ARE the activity producer. Lifecycle manager guards
`agent.recordActivity?` so this is a clean drop.
- `detectActivity` is kept on the Agent interface (still required by
Aider/OpenCode/Codex fallback) but on Claude is now a constant
"idle" — the lifecycle's terminal-output fallback path therefore
records a neutral signal and the JSONL cascade is the only source
of truth for active/ready/waiting_input/blocked.
- Native Claude JSONL handling and `NOISE_JSONL_TYPES` are unchanged —
those operate on Claude's own session files, not on terminal pixels.
Tests that exercised the retired heuristics (~200 LOC of regex-pattern
assertions in `detectActivity` + `recordActivity` integration tests) are
replaced with:
- One it.each guarding that every previously-classified input now
returns "idle" (locks in the no-signal contract).
- A direct assertion that `agent.recordActivity` is undefined.
- New tests that write hook-sourced JSONL entries
(`source: "hook"`, trigger like "PermissionRequest (Bash)") and
verify the cascade surfaces them correctly.
Net: −200 LOC of heuristic, ditto of tests, zero regression risk because
the cascade already accepted any `source` value as long as the entry
parsed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(changeset): claude activity hooks (#1941)
ao-core: minor (extends ActivityLogEntry.source / ActivitySignalSource
with "hook" — new value, no consumer break).
ao-plugin-agent-claude-code: minor (new activity-updater hook scripts,
terminal-regex layer retired).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(agent-claude-code): address copilot review on #1945 (#1941)
Four reviewer concerns, all valid:
1. `upsertHookEntry` assumed `hooks[event]` is always an array. A
malformed/legacy settings.json with a non-array value there would
crash on `.push`. Normalize via `Array.isArray(existing) ? existing : []`
and start fresh on bad input. New test: malformed object instead of
array round-trips cleanly with our entry added.
2. `upsertHookEntry` unconditionally overwrote `entry.matcher` on
updates. If a user has co-located their own hook def in the same
`{ matcher, hooks: [...] }` object as ours, resetting the matcher
changes when the user's def fires. Now only refresh matcher when
the entry contains a single hook def (ours). New test: user hook
sharing an entry with us keeps its `Edit|Write` matcher across
re-setup calls.
3. Claude agent's `detectActivity` comment claimed the lifecycle
manager would "override" its result via the JSONL cascade. Not
accurate — lifecycle calls `detectActivity` only when
`getActivityState` returned null, and that path doesn't write to
.ao/activity.jsonl. Reworded to describe the actual behaviour:
`detectActivity` is the no-signal fallback when there's no JSONL
and no hook entry yet, and "idle" is the conservative answer.
4. `classifyTerminalOutput`'s docstring promised that the Claude
agent's `detectActivity` would delegate to it "rather than inlining
`() => "idle"`", but `detectActivity` actually inlined `return "idle"`.
Restored the delegation so the rationale matches the code.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(agent-claude-code): skip bash activity-updater suite on Windows (#1941)
Greptile review on #1945: `execSync('bash "..."')` throws ENOENT on
Windows because bash isn't a native shell there, and the catch block
leaves `lastEntry = null` — making all ~26 bash-variant cases fail on
Windows CI.
Skip the bash suite on Windows via `describe.skipIf(... isWindows())`
(matches the convention used in
packages/core/src/__tests__/migration-storage-v2.test.ts). The Node
variant suite runs on every platform and is the canonical Windows path
for the activity-updater anyway, so parity between the two
implementations is still verified on Linux/macOS CI.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(agent-claude-code): escape control chars in bash trigger output (#1941)
i-trytoohard's PR review flagged that bash's escape_json only handles \
and " — not \n / \r / \t / \b / \f — creating asymmetry with the Node
variant where JSON.stringify covers everything. Bounded today by
Claude's event/tool/error-name enums never containing control chars,
but adds latent risk if a future trigger source isn't equally clean.
Five-line fix: extend escape_json with the five common JSON control-char
escapes so both implementations stay in lockstep against any future
trigger payload shape.
Locks the parity with a new round-trip test that smuggles
\n / \t / \r / \\ / " through error_type — confirms exactly one JSONL
line is written (no literal newline splitting one entry into two) and
the parsed trigger round-trips bit-for-bit on both bash and Node.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(agent-claude-code): drop useless \$ escape in bash heredoc (#1941)
Lint job on #1945 failed with five `no-useless-escape` errors after the
escape-control-chars fix. The five new lines in escape_json wrote
`\$'\\n'` inside the JS template literal, but `$` is only special in JS
template literals when followed by `{` — outside of interpolation it
needs no backslash. Bash output is byte-identical (still emits `$'\n'`
for ANSI-C quoting), so the 54 round-trip tests stay green.
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(agent-claude-code): map bookkeeping JSONL types to ready, not active
Claude writes several types AFTER finishing a turn — `file-history-snapshot`, `attachment`, `pr-link`, `queue-operation`, `permission-mode`, `last-prompt`, `ai-title`, `agent-color`, `agent-name`, `custom-title`. Until now they fell through to the `default` switch branch and looked `active` for 30s, making finished sessions appear busy. This was almost certainly the root cause of "Claude looks like it's still working when it's done" reports (the #1908 family).
Add explicit cases mapping each to `ready`/`idle` by age, same as `assistant`/`summary`. Three existing tests that asserted these returned `active` were testing the buggy behavior — updated to the correct behavior, plus four new tests covering `attachment`, `permission-mode`, and `ai-title`.
* fix(agent-claude-code): broaden process regex to match real install variants
`(?:^|\/)claude(?:\s|$)` rejected several legitimate Claude installs, causing AO to declare sessions `exited` while Claude was still running:
- `claude-code` (some Anthropic CLI variants)
- `claude.exe` / `claude.js` / `claude.cjs` (Windows / shim installs)
- `.claude` (dot-prefix shim)
- `node /opt/.../@anthropic-ai/claude-code/cli.js` (npm shim — path-contains-claude case)
New regex `(?:^|\/)(?:\.)?claude(?:[-.][\w-]+)*(?:[\s/]|$)` allows optional dot prefix, hyphen/dot suffix chains, and `/` terminator (for paths with claude as a path component). Still anchored at `/` or start-of-line so `claudia`/`claudine` etc. don't false-match.
Tests: 7-case `it.each` for each install shape returning true, dedicated rejection test for `claudia`/`claudine`. Old strict test (`does not match claude-code`) deleted — it was testing the bug.
* fix(agent-claude-code): warn on non-ENOENT failures reading ~/.claude/projects/
Previously `findLatestSessionFile` swallowed every readdir error silently, so a permission-denied or fd-exhausted misconfig on `~/.claude/projects/<slug>` would leave the session looking permanently `idle` on the dashboard with zero telemetry — debugging this took hours in the past (filed as one of the gotchas in #1927's description).
Now: ENOENT (dir hasn't been created yet) stays silent because that's normal during the early lifecycle. Every other errno (EACCES, EPERM, EMFILE, etc.) emits a single console.warn naming the dir and the errno, plus noting that activity will fall back to the AO JSONL only. Caller behavior unchanged (still returns null).
Tests: one for EACCES (via chmod 0o000) confirming warn fires with the errno, one for the missing-dir case confirming warn does NOT fire.
* fix(agent-claude-code): resolve symlinked workspace paths before slugifying
If AO records `session.workspacePath` as a symlink (e.g. `/Users/me/symlinks/repo`) and Claude resolves the target before computing its on-disk slug (e.g. `/Users/me/code/repo`), the two slugs diverge and `findLatestSessionFile` looks in an empty `~/.claude/projects/<wrong-slug>/` dir forever. Session looks permanently `idle` on the dashboard with no telemetry.
Add `resolveWorkspaceForClaude(workspacePath)` (try realpathSync, fall back to literal on error). Use it in all three sites that slugify a workspace path: `getClaudeActivityState`, `getSessionInfo`, `getRestoreCommand`. Re-exported from `index.ts` for downstream consumers.
Kept `toClaudeProjectPath` as a pure string transform — the realpath happens in the caller, so the slug function stays trivially testable.
New test confirms: write JSONL under the target's slug, call getActivityState with the symlink path, expect `ready` (was `null` before the fix).
Test-setup also needed `realpathSync(mkdtempSync(...))` because /var/folders is itself a symlink on macOS, otherwise existing tests would set up JSONL under one slug and the code would now look under another.
* fix(agent-claude-code): disambiguate multi-session via claudeSessionUuid
When two Claude sessions are running in the same workspace, `findLatestSessionFile` picked newest-mtime — which is the WRONG session's JSONL whenever its sibling has just written. The `getSessionInfo` method already captures `session.metadata.claudeSessionUuid` (the UUID Claude uses as its JSONL filename), but `getClaudeActivityState` was ignoring it.
`findLatestSessionFile` now accepts an optional `preferredUuid`. If provided, it checks `<projectDir>/<preferredUuid>.jsonl` first via `stat()` and returns that path on success. Falls back to newest-mtime when the UUID is missing or the named file doesn't exist yet (fresh session not yet introspected, or file rotated/removed).
`getClaudeActivityState` reads `session.metadata.claudeSessionUuid` (coerces unknown→string, trims, treats empty as undefined) and passes it through.
Tests:
- prefers UUID-named JSONL when set, ignoring newer sibling file
- falls back to newest-mtime when UUID-named file doesn't exist
* chore: changeset for claude-activity-edge-cases
* fix(agent-claude-code): use ES import for realpathSync in test setup
CI lint rejected the `require()` form. Move realpathSync to the top-level node:fs import.
* refactor(agent-claude-code): drop dead tool_use and result switch cases
Both types were inherited from the Agent-interface spec but never actually emitted by Claude (verified on disk for #1927). They now fall to the `default` branch.
Behavior change:
- `tool_use` → previously in user/progress branch → identical to default. No real-world effect.
- `result` → previously in assistant/summary branch (never active) → now default (can be active when fresh). No real-world effect because Claude doesn't emit `result`.
If Claude ever introduces these types and we want different semantics, we add explicit cases back. Until then, the dead switch entries were noise.
Tests:
- Removed `returns 'active' for recent 'tool_use' entry` (default branch does the same)
- Removed `returns 'ready' for recent 'result' entry` (was testing dead behavior)
- Added `unknown types fall through to default branch — fresh → active` to lock the default-branch semantics for any future Claude type addition.
* feat(agent-claude-code): detect blocked from terminal regex too
Until now `blocked` was only sourced from native JSONL (`{type:"system", subtype:"api_error", level:"error"}` from #1927). The terminal-regex pipeline (`detectActivity` → `recordTerminalActivity` → AO activity-JSONL → `checkActivityLogState`) only emitted `waiting_input`/`idle`/`active`, never `blocked`. So when Claude's native JSONL was unreadable (slug drift, fresh session pre-introspection, etc.) the blocked state was unreachable via the safety net.
Patterns observed empirically by capturing tmux output during a real api.anthropic.com block (api blocked via /etc/hosts, fresh `ao spawn` session, `tmux capture-pane` after retries started):
⎿ Unable to connect to API (ConnectionRefused)
Retrying in 19s · attempt 7/10
Add two matches to `classifyTerminalOutput`:
- /Unable to connect to API/i — primary error wording
- /Retrying in \\d+s.*attempt \\d+\\/\\d+/i — retry counter (fires even when error scrolled off)
Placed BEFORE the existing waiting_input checks because Claude's static UI footer contains `bypass permissions on (shift+tab to cycle)` which the existing `bypass.*permissions` regex matches — without this ordering, a real blocked state would lose to that incidental match.
Tests cover: real captured output, alternate error code (FailedToOpenSocket), retry counter alone, and the bypass-permissions-footer precedence case.
* fix(agent-claude-code): address review feedback on PR #1932
Three issues raised by @greptile-apps review:
1. console.warn flooded on every poll (P1). getClaudeActivityState runs on
a polling interval — without a dedupe, a single EACCES path would log
60+ lines/minute indefinitely. Added module-level Set<string> keyed by
projectDir; warn only on first miss per path for the process lifetime.
Exported resetWarnedReaddirPaths for test isolation.
2. realpathSync blocked the event loop in fully-async callers (P2).
Switched to async realpath from node:fs/promises. Updated the three
call sites (getClaudeActivityState, getSessionInfo, getRestoreCommand)
to await. resolveWorkspaceForClaude is now Promise<string>.
3. Test regex /failed to read.*EACCES|EPERM/ parsed as
(failed to read.*EACCES) | (EPERM) because | has lowest precedence.
Wrapped the alternation in a non-capturing group: (?:EACCES|EPERM).
Also added a new test confirming the dedupe works: three consecutive polls
against an EACCES path produce exactly one warn.
* fix(agent-claude-code): skip UI-noise JSONL types when reading last entry
Regression caused by the earlier bookkeeping-types fix on this branch (commit 39c2b1dd). That commit moved `permission-mode`, `ai-title`, `agent-color`, `agent-name`, `custom-title` into the explicit ready/idle bookkeeping case alongside `file-history-snapshot` etc. — but these five types are UI-state snapshots Claude writes at random times (session attach, permission-mode change, title regeneration). They are NOT correlated with real activity.
Empirical proof: ao-144 had 73 trailing `permission-mode` + 73 trailing `ai-title` entries written over 6 dormant days. Last real assistant message was 2026-05-13 12:57. With the earlier fix in place, the dashboard oscillated between `ready` (recent noise mtime) and `idle` (older noise mtime) instead of staying `idle`. User reported "I am not seeing active but ready for all session" — this is what they were seeing.
Fix: define `NOISE_JSONL_TYPES` containing the five UI-state types. When the literal last entry is one of these, skip the native-JSONL classification and fall through to the AO activity-JSONL pipeline (terminal-derived). If that's also empty, the existing `staleNativeState` fallback returns `idle` from `session.createdAt`, which is correct for dormant sessions.
For sessions that ARE actively working but happen to have a noise type as the latest line, the AO JSONL fallback's terminal scrape catches the active state.
Removed the five noise types from the explicit bookkeeping case in the switch. Kept `file-history-snapshot`, `attachment`, `pr-link`, `queue-operation`, `last-prompt` — these plausibly correlate with real activity (file edits, PR creation, prompt submission).
Tests: updated `permission-mode → ready` to `→ idle`, same for `ai-title`. Added explicit tests for the other three noise types and a test confirming the AO JSONL pipeline still wins when last native entry is noise but AO has actionable state.
* fix(agent-claude-code): tighten waiting_input regex to ignore static UI footer
The existing `/bypass.*permissions/i` regex matched Claude's persistent UI footer `⏵⏵ bypass permissions on (shift+tab to cycle)` — which is visible on EVERY active Claude session. As long as native JSONL classification returned a definitive answer, this didn't matter. The previous noise-skip commit on this branch (faf43042) made many more sessions fall through to the AO JSONL pipeline, which exposed the bug: ao-143/144/151 all flipped to waiting_input even though they were genuinely dormant.
Tightened the regex to require "all future" — matching the actual permission-bypass prompt ("bypass all future permissions for this session") rather than the footer toggle ("bypass permissions on"). The other two waiting_input regexes (`Do you want to proceed?`, `(Y)es...(N)o`) are unchanged.
Verified: dashboard's stored AO activity-JSONL entries had `state: "waiting_input"` with trigger snippets containing the persistent footer text. With this fix, classifyTerminalOutput on the exact same terminal capture returns active instead.
Tests: existing `bypass all future permissions for this session` test still passes (the prompt phrase contains the new tighter pattern). Added a regression test confirming the static footer alone does NOT match waiting_input.
* fix(agent-claude-code): treat pr-link as re-snapshot noise too
Empirical evidence from production: ao-160's JSONL had the same PR (#1911) written as a `pr-link` entry 33 times in the last 200 lines, alternating with permission-mode/ai-title in the same trio pattern. Internal timestamps showed re-emissions ~1 minute apart (15:24:40, 15:26:27, 15:27:42 — same PR, three writes within ~3 minutes).
User-visible bug: ao-139 (last real work 11h ago) and ao-160 (last real work over a day ago) were both showing as `ready` on the dashboard because the file mtime kept moving forward via these pr-link re-snapshots, and pr-link was in the explicit ready/idle bookkeeping case.
Fix: move `pr-link` from the bookkeeping case into NOISE_JSONL_TYPES so the cascade skips it and falls through to the AO JSONL pipeline (and ultimately to the stale-native idle fallback for genuinely dormant sessions). Real PR creation is still observable via the assistant message that asked for it and via the gh-tracker.
Verified: 200/200 plugin tests pass. The existing pr-link test updated to expect idle (matches new behavior); added comment with the empirical evidence.
* fix(agent-claude-code): tighten terminal classification — default to idle, gerund+ellipsis = active
Root cause of dashboard showing ready for dormant sessions ao-143/144/151/154/160/139: classifyTerminalOutput defaulted to "active" for any output that didn't match a specific idle/blocked/waiting_input pattern. Claude's persistent UI footer + empty input area always look the same between "just finished" and "currently working", and the existing lastLine idle-check missed dormant sessions because the LAST line is the static footer, not the ❯ prompt.
So every poll cycle, recordTerminalActivity wrote "active" entries to AO activity-JSONL for dormant sessions. The cascade's age-decayed fallback then surfaced those as ready (30s–5min decay) forever. Pr-link being treated as turn-end bookkeeping in 4e0e64ab made things even worse (sessions oscillated between ready and idle).
Fix — invert the default:
- classifyTerminalOutput now defaults to "idle" and only returns "active"
when an explicit active-work indicator is present.
- Strongest active signal: gerund-form status word followed by trailing
ellipsis "…". Claude rotates many spinner glyphs (✻ ✽ · ⠁ ⠈ etc.) and
status words (Germinating, Fluttering, Pondering, Mulling, Crafting,
Thinking, Reasoning…), but the gerund+ellipsis combo is consistent.
Past-tense lines like "✻ Worked for 11s" or "✻ Crunched for 11s" lack
the ellipsis and stay idle.
- Removed reliance on:
- bare "esc to interrupt" (in static footer, not just active)
- "Working" without ellipsis (false-matches "working on issue #N")
- "⎿" line prefix (also appears in past tool-result content)
- Word-based fallbacks kept for specific cases: Reading file / Writing to
/ Searching codebase / Press up to edit queued / Germinating /
Crunched for Ns / Thinking…|Working…
- Widened tail window from 5 to 12 lines because Claude's spinner line
sits 6-8 lines above the bottom (above input area + footer).
Verified live on tmux captures from 6 dormant sessions (ao-143/144/151/154/160/139) and 1 active session (ao-161): correct classification for all 7. Previous code returned "active" for all 6 dormant ones; new code returns "idle".
Tests: 203/203 pass. Updated the "non-empty output with no patterns" test to expect idle (matches new behavior — was testing the bug). Added dormant-pane and active-pane regression tests using real captured strings.
* fix(agent-claude-code): tighten terminal classification — default to idle, gerund+ellipsis = active
Followup to f1fc21bc: also drop the "/\bCrunched\s+for\s+\d+\s*s/i" pattern. It's past-tense like "Worked for Ns" — Claude shows "✻ Crunched for 22s" as a turn-complete summary, not an active indicator. ao-154's terminal had exactly this line, making every poll write "active" to AO activity-JSONL.
After this commit the only past-tense traps left would also lack the gerund+ellipsis signal, so the gerund+ellipsis check above handles all real active spinners. The remaining word-based fallbacks (Germinating, Thinking…/Working…, Reading file, Writing to, Searching codebase, Press up to edit queued) all stay because they're either gerund-form or specific enough to be unambiguous.
Live-verified on tmux captures from all 7 sessions:
- ao-161 (active turn, "Fluttering…") → active ✅
- ao-143 ("Worked for 11s") → idle ✅
- ao-154 ("Crunched for 22s") → idle ✅ (was returning active before this commit)
- ao-144/151/160/139 (no active indicators in pane) → idle ✅
Tests: 203/203.
* fix(agent-claude-code): bound blocked detection to wideTail, not full buffer
Per @greptile-apps P1 review on PR #1932: `Unable to connect to API` / `Retrying in Ns · attempt N/M` were being matched against the entire `terminalOutput` rather than the last-12-lines `wideTail`. If Claude hit an api_error, retried successfully, and then generated enough subsequent output to push the error off the visible window but not out of full scrollback, every subsequent poll would re-detect "blocked" forever.
Move the `wideTail = lines.slice(-12).join("\n")` definition earlier in the function and use it for the two blocked patterns too. All existing blocked test fixtures are ≤12 lines so they pass unchanged.
Added a regression test that simulates the recovered-and-continued scenario: error text early in the input, then 15+ lines of subsequent work, then an active spinner at the bottom. Old code returned "blocked" (saw the error in the full buffer); new code returns "active" (wideTail only sees the spinner).
* refactor(agent-claude-code): split activity detection into its own module; remove dead JSONL cases
Move `getActivityState`, `findClaudeProcess`, the ps-cache, `classifyTerminalOutput`, `findLatestSessionFile`, and `toClaudeProjectPath` into a new `activity-detection.ts`. `index.ts` shrinks by ~190 lines and now delegates via thin wrappers; `toClaudeProjectPath` and `resetPsCache` are re-exported so existing unit-test and integration-test import sites keep working.
Drop two switch branches that could never fire: `case "permission_request"` → `waiting_input` and `case "error"` → `blocked`. Verified by reading every JSONL under `~/.claude/projects/`: Claude emits `agent-color, agent-name, ai-title, assistant, attachment, custom-title, file-history-snapshot, last-prompt, permission-mode, pr-link, progress, queue-operation, summary, system, user` — but never a top-level `permission_request` or `error`. Permission prompts have no native-JSONL signal at all (they only sit in the terminal until the user answers); API errors arrive under `type:"system"` with `subtype:"api_error"` / `level:"error"`, not as a top-level `error` type. `waiting_input` and `blocked` continue to flow through the terminal-regex → AO activity-JSONL path that was added upstream — that path is now the only source, which matches reality.
Behavior preserved: the cascade (process check → native JSONL → AO actionable → AO age-decay fallback → stale-native) is byte-equivalent for every type Claude actually emits. Four tests that wrote fake `permission_request` / `error` JSONL deleted.
* feat(agent-claude-code,core): detect blocked from Claude api_error JSONL entries
Extend `readLastJsonlEntry` in core to also surface top-level `subtype` and `level` fields (additive — existing return shape unchanged). Map `{type:"system", level:"error"}` to `blocked` in `getClaudeActivityState`.
Claude writes API errors as `{type:"system", subtype:"api_error", level:"error", cause:{code:"ConnectionRefused"|"FailedToOpenSocket"|...}, retryAttempt:N, maxRetries:10}`. When the LAST JSONL entry has that shape, Claude is mid-retry-loop or has exhausted retries — surfacing as `blocked` lets stuck-detection fire. Other `system` subtypes (`compact_boundary`, `local_command`, `turn_duration`, `away_summary`, etc.) continue to map to `ready`/`idle` by age via the `entry.lastLevel !== "error"` branch.
Closes the only remaining "no live producer" gap from the PR description: previously `blocked` had a slot in the cascade but no writer. Now native JSONL fills it directly, no AO activity-log roundtrip needed.
Tests:
- "blocked for 'system' api_error (level: error)"
- "ready for non-error 'system' subtypes (compact_boundary)"
- "'system' api_error ignores staleness (always blocked)"
Existing `system` test (no level field) continues to map to `ready` — verified.
* fix(agent-claude-code): require api_error subtype AND error level for blocked
Tighten the system-entry gate per @greptile-apps review on #1927. Today only `api_error` carries `level:"error"` under `type:"system"`, but pinning both fields makes intent explicit and prevents silent drift if Claude ever adds a non-fatal error-level diagnostic later.
Test locks the tightened gate: `{type:"system", subtype:"future_diagnostic", level:"error"}` → `ready` (not `blocked`).
* test(core): cover lastSubtype/lastLevel extraction in readLastJsonlEntry
Per reviewer note on #1927: the consumer side (claude-code plugin) tested the new fields, but the producer side in core didn't. Lock the extraction behavior so future refactors of `readLastJsonlEntry` can't regress silently.
Covers: real Claude api_error shape, absent fields, non-string field types.
* feat(cli): wire activity events into CLI commands and supervisor lifecycle (#1654)
Adds "cli" to ActivityEventSource and emits ~30 activity events across the CLI
surface so `ao events list --source cli` can answer RCA questions like:
- Did AO start cleanly? When? (cli.start_invoked / cli.start_failed)
- Was AO shut down gracefully or did it crash? (cli.shutdown_signal /
cli.shutdown_completed / cli.shutdown_force_exit / cli.stale_running_pruned)
- Did ao spawn / ao update / ao stop / ao migrate-storage fail and why?
- Did the auto-restore prompt actually restore sessions?
Instrumented files:
- packages/core/src/activity-events.ts (cli source)
- packages/cli/src/lib/shutdown.ts (signal/completed/failed/force_exit/session_kill_failed)
- packages/cli/src/commands/start.ts (start_invoked/start_failed/restore_*/stop_*/daemon_*/last_stop_* /config_migrated)
- packages/cli/src/commands/spawn.ts (spawn_invoked/spawn_failed)
- packages/cli/src/commands/update.ts (update_invoked/update_failed)
- packages/cli/src/commands/setup.ts (setup_failed)
- packages/cli/src/commands/migrate-storage.ts (migration_completed/migration_failed)
- packages/cli/src/commands/project.ts (project_register_failed)
- packages/cli/src/lib/resolve-project.ts (project_resolve_failed/config_recovered/config_recovery_failed)
- packages/cli/src/lib/running-state.ts (lock_timeout/stale_running_pruned)
- packages/cli/src/lib/credential-resolver.ts (credential_load_failed)
All emits are sync, never wrapped in try/catch (recordActivityEvent never
throws), and put raw error text in data.errorMessage (not summary, which is
FTS-indexed and not credential-sanitized).
Tests:
- 16 new instrumentation tests across shutdown, migrate-storage, update,
resolve-project, and start/stop action paths covering MUST emits.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix: correct CLI activity event semantics
* fix(cli): emit migrate-storage invocation event
* fix(cli): record last-stop write failures
* fix(linear): retry transient API failures
* fix(cli): stabilize update instrumentation test
* fix(cli): stub process probes in stop instrumentation tests
* chore(ci): retrigger checks
* Fix CLI failure event review issues
* fix: bound linear integration cleanup
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: whoisasx <adil.business4064@gmail.com>
Co-authored-by: Adil Shaikh <106678504+whoisasx@users.noreply.github.com>
* fix(agent-claude-code): add AO-JSONL safety net cascade
claude-code now has the same AO-JSONL fallback cascade as codex; protects against ~/.claude/projects slug drift (cf. #1611, commit 582c5373).
Closes#1897. References #1899.
* fix(agent-claude-code): let stale native activity use AO fallback
When Claude's latest native JSONL entry predates the AO session, fall through to the AO activity JSONL cascade before preserving the prior idle default. This keeps terminal-derived waiting_input from being hidden by an old native file.
Addresses review on #1903. References #1899 and closes#1897.
* feat(core): add CI failure summary SCM contract
Add an optional SCM getCIFailureSummary hook and shared CIFailureSummary shape so providers can return failed jobs, failed steps, run URLs, and bounded log tails without changing existing SCM implementations.
* feat(scm-github): summarize failed CI logs
Use failed GitHub check URLs to locate Actions run/job IDs, fetch gh run view --log-failed output, parse the step column from the gh log format, and cap each failed-job log tail at 120 lines. Return null when no failed run logs can be fetched so lifecycle can fall back cleanly.
* feat(lifecycle): send actionable CI failure details
Prefer SCM CI failure summaries when composing ci-failed agent messages, including failed job/step, run URL, and log tail. Fall back to check names/statuses for SCM plugins without getCIFailureSummary, and remove the generic default ci-failed text so default handling relies on the lifecycle-composed payload.
State invariants preserved: this only changes ci-failed reaction payload composition and dispatch hashing. It does not change lifecycle status decisions, state-machine transitions, ci-failed persistence, retry/escalation thresholds, or stable-passing tracker reset semantics.
* fix(lifecycle): harden CI failure summaries
Address review feedback: escape log-tail lines that could close markdown fences, report the last failed-step column from gh failed logs, and pass already-known failed checks into getCIFailureSummary to avoid duplicate CI check fetches.
State invariants preserved: lifecycle transition decisions, retry/escalation budgets, persistent ci-failed tracker behavior, and stable CI passing reset semantics remain unchanged.
* refactor(lifecycle): centralize CI failure check lookup
Deduplicate CI failure check lookup, filtering, and fingerprint generation across transition-time enrichment and follow-up CI-failure dispatch while preserving the existing fetch/no-fetch split.
State invariants preserved: lifecycle transition decisions, reaction retry/escalation behavior, ci-failed tracker persistence, and stable-passing reset semantics are unchanged.
* fix(scm-github): fetch in-progress failed job logs
* fix(lifecycle): label CI failure URLs explicitly
---------
Co-authored-by: i-trytoohard <193449657+i-trytoohard@users.noreply.github.com>
* chore: version packages
* ci: nudge — trigger required checks on bot PR
* test(agent-codex): drop self-defeating hardcoded version assertion
Same file as the earlier fix on this branch — changesets/action regenerated from main, bringing the bad test back. This deletion will land on main when this Version PR merges, so future Version PR regenerations won't re-introduce it.
---------
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: suraj-markup <sk9261712674@gmail.com>
* feat(release): weekly release train — channels, onboarding, dashboard banner, cron
Implements the full release pipeline described in release-process.html
(supersedes #1525, which only had the workflow scaffolding).
A. Release infrastructure — .github/workflows/canary.yml triggers on a cron
('30 17 * * 5,6,0,1,2', i.e. 23:00 IST Fri–Tue) plus workflow_dispatch,
without the stale-SHA guard or the merged-PR-comment step from #1525
(cron has no merged-PR context). release.yml uses changesets/action.
.changeset/config.json adds the snapshot template and moves the private
@aoagents/ao-web to ignore[].
B. Channel awareness (packages/cli/src/lib/update-check.ts) — new
updateChannel field in the global-config Zod schema (stable | nightly
| manual; defaults to manual so existing users see no surprise installs).
fetchLatestVersion now reads dist-tags[channel] from the registry;
isVersionOutdated compares prerelease segments numerically + lexically
so SHA-suffixed nightlies sort correctly. maybeShowUpdateNotice and
scheduleBackgroundRefresh skip entirely on manual.
C. Active-session guard (packages/cli/src/commands/update.ts) — before
any handle*Update proceeds, sm.list() filters for working/idle/
needs_input/stuck and refuses with `N session(s) active. Run
`ao stop` first.` instead of auto-stopping (per the design doc:
surprise-killing user work is worse than refusing).
D. Soft auto-install + onboarding — handleNpmUpdate skips the confirm
prompt on stable/nightly. New packages/cli/src/lib/update-channel-
onboarding.ts prompts once on the first `ao start` after this lands;
ask-once gate keyed on the absence of updateChannel in the global
config; dismissal persists `manual`. New `ao config set updateChannel
<value>` command (also handles installMethod).
E. Dashboard banner — packages/web/src/app/api/version/route.ts reads
the same cache file the CLI writes (~/.cache/ao/update-check.json,
XDG-aware) and rejects cache entries from a different channel.
packages/web/src/app/api/update/route.ts duplicates the active-session
guard so the dashboard can return a structured 409. New UpdateBanner
component wired into Dashboard.tsx — Tailwind only, var(--color-*)
tokens, dismissible per-version via localStorage, deferred fetch so
it doesn't shift the call order in existing dashboard tests.
F. Bun + Homebrew detection (update-check.ts) — new classifiers for
~/.bun/install/global/ (auto-installs `bun add -g @aoagents/ao@<channel>`)
and /Cellar/ao/ (notice-only — `brew upgrade ao`, never auto-install
because brew owns the symlinks). New installMethod override field in
the global config to pin detection when path heuristics fail.
Tests: +155 (B/C/F unit, onboarding ask-once gate, /api/version + /api/update,
UpdateBanner visibility/dismiss/click). pnpm test, pnpm typecheck, pnpm lint
all green for the changes; the same 10 pre-existing test failures observed
on main are still present (all environment-dependent: ~/.cache/ao state, codex
binary install, /private path canonicalization on macOS).
Closes#1525
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(release-train): CI failures + Greptile review feedback
CI fixes:
- Web /api/update spawn ENOENT — attach `child.on("error", ...)` so the
asynchronous spawn-error event from a missing `ao` binary doesn't bubble
up as an unhandled error and crash vitest. The route already returns 202
before the error fires; on real installs the user sees "no version change"
if the install fails.
- start.test.ts pollution — runStartup calls `maybePromptForUpdateChannel`,
which (with isHumanCaller mocked to true) writes to the real
~/.agent-orchestrator/config.yaml on the CI runner via persistUpdateChannel.
Subsequent tests then load that newly-created (empty-projects) config and
report "No projects configured" instead of the expected "project not found".
Fix: stub `update-channel-onboarding.js` in start.test.ts so runStartup
is a no-op for the channel prompt.
Review feedback:
- (P1) `runtime: "tmux"` hardcoded default in `persistUpdateChannel` and
`loadOrInit` would lock Windows users into a non-functional tmux config
when they dismiss the channel prompt. Both now use `getDefaultRuntime()`,
matching `makeEmptyGlobalConfig` in core's global-config.ts.
- (P2) `hasChosenUpdateChannel` JSDoc inverted — the second "True when"
bullet actually described the False case. Rewritten with separate
True/False sections that match the implementation.
- (P2) `isVersionOutdated` was duplicated between the CLI and the dashboard
/api/version route. Moved to a new shared module
`packages/core/src/version-compare.ts`, exported from `@aoagents/ao-core`,
consumed by both CLI (re-exports as `isVersionOutdated`) and the web route
directly. Added 14 unit tests in core for the canonical implementation.
Defensive: `maybePromptForUpdateChannel` now validates the prompt result via
`UpdateChannelSchema.safeParse` before persisting — never writes `undefined`
or an unrecognized string to disk.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(release-train): Windows spawn + dismiss-while-blocked review feedback
- (P1) `ao update` silently never ran on Windows because `spawn("ao", ...)`
doesn't consult PATHEXT, so npm's `ao.cmd` shim wasn't found and the
async ENOENT was swallowed by the error handler. Add `shell: isWindows()`
+ `windowsHide: true` per the cross-platform guide.
- (P1) Dismiss button was inert when the banner was in the `blocked` (409)
or `error` phase — `setDismissedFor` set the localStorage flag but the
hide condition required `phase === "idle"`, so the banner stayed pinned
until reload. `handleDismiss` now resets phase to idle (and clears the
error message) so the existing condition fires. Added a regression test
covering dismiss from the 409 path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(release-train): runNpmInstall on Windows — shell:true so PATHEXT resolves npm.cmd
(P1) The dashboard /api/update spawn got `shell: isWindows()` + `windowsHide:
true` in 9f29131d, but `runNpmInstall` in the CLI's `ao update` command was
still missing the same fix. On Windows, `spawn("npm", ...)` without a shell
wrapper doesn't consult PATHEXT, so npm/pnpm/bun's `*.cmd` shims never
resolve and the install silently ENOENTs.
Mirror the fix into runNpmInstall — it's the single spawn site behind every
non-git, non-homebrew install path (npm-global, pnpm-global, bun-global,
unknown), so this one change covers all four install methods.
Tests:
- Mock `isWindows` from @aoagents/ao-core so the spawn options can be
inspected per-platform.
- Assert `shell: true, windowsHide: true, stdio: "inherit"` on Windows.
- Assert `shell: false` on macOS / Linux.
- Parametrize over pnpm-global / bun-global to confirm the same options flow
through every npm-style install command.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(release-train): /api/version reads cached.isOutdated for git installs
(P1) The dashboard banner never appeared for git-installed users because
`/api/version` ran `isVersionOutdated(current, "origin/main")`, and
`parseVersion("origin/main")` produces NaN parts that the early-exit guard
catches with `return false`. Git installs cache `latestVersion` as a git
ref (not a semver) and a precomputed `isOutdated` flag from `git fetch +
merge-base`; the CLI special-cases this in `update-check.ts`. Mirror the
same pattern here:
cached.installMethod === "git"
? cached.isOutdated === true
: isVersionOutdated(current, latest)
Also extend the local CacheData with `installMethod?: string` and
`isOutdated?: boolean` so the new branch type-checks. Kept as `string`
rather than importing the CLI's `InstallMethod` type — the literal "git"
compare is the only thing that matters here, and the web package shouldn't
take a dep on @aoagents/ao-cli.
Two new tests cover the git-install path: one asserts isOutdated=true is
trusted from the cache, the other asserts isOutdated=false (current with
origin) is trusted too.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(release-train): must-fix #3+#4 — global-config layout + git-only flag guard
#3 — ensureNoActiveSessions now consults loadGlobalConfig() first as a quick
"any projects registered?" check, then routes through loadConfig(globalPath)
only when the registry actually has projects (loadConfig dispatches to
buildEffectiveConfigFromGlobalConfigPath when given the canonical global path
— see packages/core/src/config.ts). Defends against AO_GLOBAL_CONFIG override
to a non-canonical path. Three new tests cover: registered-projects path
fires the guard correctly; empty registry returns early without building a
SessionManager; missing global file returns early without even reading it.
#4 — Restored the rejection of git-only flags on non-git installs. Users
copy/pasting `ao update --skip-smoke` from older docs would silently no-op
on npm/pnpm/bun installs. Now exits non-zero with:
"--skip-smoke only applies to git installs (current install: npm-global)."
Test it.each across npm/pnpm/bun/homebrew/unknown plus a positive test that
git installs still accept the flag.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(release-train): #2 should-fix — channel-switch prompt
When a stable user runs `ao config set updateChannel nightly` and then
`ao update`, isVersionOutdated(0.5.0, 0.5.0-nightly-abc) returns false (per
semver, prerelease < stable on equal base). The old code printed "Already
on latest nightly" and exited without installing — confusing, because the
install command we'd run is genuinely a different dist-tag.
Fix: snapshot the previously-cached channel BEFORE forcing a refresh, then
detect a switch via `previousChannel !== activeChannel && !info.isOutdated`.
On switch:
- Don't take the "already on latest" early-return.
- Print a yellow "Channel switch detected: was X, now Y." notice.
- Force a confirm prompt regardless of stable/nightly soft-install,
defaulting to "no" (channel-switch should be explicit). Manual users
still see their normal prompt.
Onboarding copy now includes one line about channel switches: "switching
later prompts before installing the other channel's build."
4 new tests: explicit switch fires the prompt + installs on yes; declines
on no; same-channel doesn't fire (back to "Already on latest"); first-ever
update with no previous cache doesn't fire either.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(release-train): polish — drop setTimeout, dedup defaults, share cache, dedup export
#5 — UpdateBanner no longer wraps its mount fetch in setTimeout(0). Production
code shouldn't bend to test mock ordering. Instead, the two brittle Dashboard
tests that relied on `mockImplementationOnce` queue ordering now route by URL
via `mockImplementation`, and the cadence test asserts "no other endpoints
were touched" instead of "no fetch was touched at all". Also added a
deliberate "no interval / re-fetch" comment per #6.
#7 — Promoted core's `makeEmptyGlobalConfig` to the public
`createDefaultGlobalConfig` (kept the internal alias for back-compat). Both
the CLI's `persistUpdateChannel` and `loadOrInit` (in `ao config`) now call
it instead of inlining the same defaults block. Single source of truth.
#8 — New `packages/core/src/update-cache.ts` exports
`getUpdateCheckCachePath`, `readUpdateCheckCacheRaw`, and
`getInstalledAoVersion`. The CLI's `update-check.ts` keeps its richer
install-method/channel/git-rev validation but now delegates path resolution
and version lookup to core. The dashboard's `/api/version` route drops its
duplicated `getCachePath`/`readCache`/`getCurrentVersion` and consumes from
core directly. Cache layout is one file, not two.
#9 — Removed the duplicate `export { isManualOnlyInstall }` from
`update.ts` (also dropped the unused import). The canonical export lives in
`update-check.ts`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(release-train): cosmetic — workflow rename note, design tokens, changeset trim
#1 release.yml: added a comment above `workflows: [CI]` warning that GitHub
matches by name (not filename) and silently no-ops on mismatch — so a
rename of ci.yml's `name:` field would mean releases stop triggering.
#10 UpdateBanner: replaced text-[13px] / text-[12px] with text-sm / text-xs
to match the dashboard's chrome scale.
#6 Banner refresh: noted in the existing useEffect comment that we don't
re-fetch — re-evaluate if "user kept tab open for days, missed an
update" becomes a real complaint.
#11 .changeset/release-train.md: dropped @aoagents/ao-web from the version
bump list. The package is `private: true` and in changeset's ignore[],
so listing it was cosmetic and would just clutter the eventual release
notes with a non-published artifact.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(release-train): illegalcall review — global guard, channel scoping, publishable web
(#1, PRRT_kwDORPZUAc6BHFDf) — `ensureNoActiveSessions` now ALWAYS loads
from the canonical global config, never from project-local. The previous
code preferred `loadConfig()` (local search-upward) when run inside a repo,
which made `sm.list()` enumerate only that project's sessions — active work
in other registered projects would be missed and the install would proceed.
New regression test asserts that a session in `other-project` blocks the
update even when invoked from `this-project`'s cwd. Existing global-config
tests retained.
(#2, PRRT_kwDORPZUAc6BHFOl) + (#4, PRRT_kwDORPZUAc6BHFon) — Reverted the
`private: true` on @aoagents/ao-web. Because @aoagents/ao-cli has a
workspace:* runtime dep on it (for `findWebDir()`/dashboard files), pnpm
rewrites the dep on publish to a literal version — keeping ao-web private
would make `npm install -g @aoagents/ao` fail. Restored ao-web to the
changeset linked group, removed it from `ignore[]`, restored the release-
train changeset entry, added publishConfig + repository metadata.
New `scripts/check-publishable-deps.mjs` walks every package and asserts
that no publishable package has a workspace:* runtime dep on a `private:
true` package. Wired into both release.yml and canary.yml before the
publish step so any future regression is caught at CI rather than at the
user's `npm install`. Verified the script catches the inverse condition.
(#3, PRRT_kwDORPZUAc6BHFaV) — `readCachedUpdateInfo` now treats a missing
`data.channel` as a miss when an explicit channel is provided. Previous
logic only rejected when both data.channel and channel were set AND
differed, so a legacy cache entry (pre-channel-scoping) could keep returning
stale stable state to a user who had since switched to nightly until the
24h TTL expired. Existing fixtures bumped to include `channel` where the
test exercises the checkForUpdate / maybeShowUpdateNotice path; new
regression test exercises the legacy-no-channel case.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(release-train): SHA-suffix nightly compare — never miss a banner
(P1, PRRT_kwDORPZUAc6BJLrW) Git SHAs are uniformly-random hex, so the old
`comparePrereleaseSegments` lexical fallback gave the wrong answer ~50% of
the time on snapshot tags. Concretely: user installs `0.5.0-nightly-f00d123`,
CI publishes `0.5.0-nightly-0dead01`, and `'f' < '0'` returns false → banner
never shows.
Fix: when two prerelease segments are both non-numeric and differ, treat the
left side as older (return -1). The cache layer always carries the registry's
CURRENT dist-tag, so any non-numeric mismatch on the same base means the
installed copy is behind by construction. Numeric ordering (`rc.1 < rc.2`)
and numeric-vs-non-numeric (`0.5.0-1 < 0.5.0-alpha`) are unchanged.
Tradeoff: a user who manually installed `0.5.0-beta` while the registry only
publishes `0.5.0-alpha` would see a spurious banner. AO's release pipeline
only emits SHA-suffixed nightly prereleases, so the scenario doesn't occur in
practice — documented in the function's JSDoc.
Updated two misleadingly-named tests ("orders SHA-suffixed nightlies
lexically") that had been asserting the buggy behavior; new tests cover the
specific case from the review (`nightly-f00d123` vs `nightly-0dead01`) and
preserve the numeric-ordering invariant.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(release-train): multi-project active-session proof + changeset note
(#1, PRRT_kwDORPZUAc6BHFDf follow-up) Dhruv asked for proof — not a comment —
that loadConfig(globalPath) actually enumerates across all registered
projects, not just the cwd's. New test in update.test.ts seeds proj-a and
proj-b in the global config, places one active session in each (one
"working", one "needs_input"), and asserts the refusal stderr lists BOTH
session ids AND the total count says "2 sessions active". The test is
specifically named so it shows up in `vitest run -t "Dhruv proof"`.
Verified `pnpm changeset version` locally — @aoagents/ao-web, ao-cli,
and ao all bump to 0.7.0 together via the linked group, confirming the
install-404 class of bug is gone.
Also updated the release-train changeset to drop the stale "moves the
private @aoagents/ao-web to ignore" line — that contradicts the current
state (ao-web is publishable and in the linked group).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(release-train): Ashish P1+P2 — dashboard banner now actually installs
P1 — Dashboard banner click was a no-op for npm users.
POST /api/update spawns `ao update` with `stdio: "ignore"`, which makes
`isTTY()` return false in the child. The old handleNpmUpdate hit the
non-TTY branch ("Run: ...") and exited without installing. Banner returned
202 "started"; nothing actually happened.
Fix (Ashish's option c, with an env-var bridge):
- /api/update spawns with `AO_NON_INTERACTIVE_INSTALL=1` on the env.
- handleNpmUpdate computes `interactive = isTTY() && !isApiInvoked()`.
- Restructured so the early-return only fires for non-TTY + non-API
(piped output): we still print "Run: ..." for that case, matching the
old contract. API-invoked path now actually runs runNpmInstall, skipping
the confirm prompt (would hang the detached child forever).
Three new CLI tests:
- AO_NON_INTERACTIVE_INSTALL=1 → spawn invoked even with isTTY=false.
- The piped-output case (no env var, no TTY) still prints "Run: ...".
- Active-session guard still fires in the API-invoked path (defense in
depth — the route's own guard isn't single point of trust).
P2 — First nightly opt-in stuck on "Already on latest stable".
Repro: user on stable 0.5.0, runs `ao config set updateChannel nightly`,
runs `ao update`. previousChannel was undefined, isOutdated was false
(semver: prerelease < stable on equal base), so the early return fired
and the install never ran.
Fix: new `isFirstChannelOptIn` branch — `previousChannel === undefined
&& info.currentVersion !== info.latestVersion && !info.isOutdated`. Force
the same prompt path the channel-switch case uses (default=no, explicit
consent). Confirmed install path covered by a new test that mirrors the
repro exactly.
The pre-existing "no previous cache → no prompt" test asserted the OLD
buggy behavior; rewritten to assert the canonical case (no prior cache
AND versions match → still "Already on latest", no prompt).
P2 — Dashboard /api/version legacy cache.
Same class as Dhruv #3, this time on the web side. Old code:
const cacheMatchesChannel = !cache?.channel || cache.channel === channel;
A legacy entry without `channel` would short-circuit `!cache?.channel`
and serve stale latestVersion. Fixed to require `cache.channel === channel`
explicitly. New regression test seeds a no-channel entry and asserts
{ latest: null, isOutdated: false, checkedAt: null }.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(release-train): Dhruv edge-case — running.json is the live source of truth
(PRRT_kwDORPZUAc6BUIUK) The active-session guard previously short-circuited
on empty global config, which missed the case where:
- User runs `ao start` from a repo with a local agent-orchestrator.yaml
and no global registration.
- running.json lists that project as currently being polled.
- Sessions live on disk under ~/.agent-orchestrator/{hash}-{projectId}/.
In that state, `loadGlobalConfig().projects` is empty so the old early
return fired and `ao update` would proceed while a daemon was actively
supervising the user's in-flight work.
Fix: consult `getRunning()` BEFORE falling back to the global registry.
When running.json reports projects, trust its configPath (could be a local
project yaml OR the canonical global path — `loadConfig` dispatches on
shape) and build the SessionManager from there. The global fallback is now
the no-daemon-running case, where on-disk sessions get reconciled by
SessionManager enrichment.
Three new tests in update.test.ts:
- `refuses when sessions exist in a locally-registered project not in
global config (Dhruv edge-case)` — seeds running.json with a local-only
project + working session, asserts refusal + that loadConfig was called
with running.configPath (NOT the global path).
- `returns true (allows update) when running.json is gone and global is
empty` — covers the genuinely-safe case.
- `trusts running.json over an inconsistent global config` — when both
signals exist, the live one (running.json) wins and loadGlobalConfig is
never consulted.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(release-train): shift canary cron 23:00 → 23:30 IST
Schedule moves to 23:30 IST = 18:00 UTC. Cron expression changes from
`30 17 * * 5,6,0,1,2` to `0 18 * * 5,6,0,1,2`. Same DOW window
(Fri,Sat,Sun,Mon,Tue) so the bake window (Wed–Thu) is unaffected.
Files touched (all consistent):
- .github/workflows/canary.yml — cron expression + comment block
- .changeset/release-train.md — schedule string in feature description
- CONTRIBUTING.md — "Testing your changes" callout
Verified `grep -rn '23:00 IST|17:30 UTC|"30 17'` returns zero matches.
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: keep tmux session alive after agent exit (closes#1756)
Two related fixes:
1. runtime-tmux: append `exec "${SHELL:-/bin/bash}" -i` to the launch
command so the pane drops to an interactive shell when the agent
exits, instead of letting the empty pane take down the whole tmux
session. The lifecycle still detects agent termination via
`agent.isProcessRunning` and transitions the session to
`agent_process_exited` — the runtime just stays usable so the user
can run shell commands or manually re-launch the agent.
2. mux-websocket: add a `tmux has-session` guard at the top of
`pty.onExit`. When the tmux session is genuinely gone (e.g. `ao
stop` killed it out from under a still-subscribed dashboard), skip
the three doomed `attach-session` spawns introduced by the
MAX_REATTACH_ATTEMPTS bound in #1640 and notify subscribers
immediately. The bound from #1640 still covers transient
tmux-server hiccups where the session does still exist.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(mux): make tmuxHasSession async to avoid blocking the event loop
The has-session probe added in #1756 ran via execFileSync inside
node-pty's onExit callback, freezing every WebSocket connection,
HTTP request, and in-flight terminal for up to the 5 s subprocess
timeout whenever an agent exited and tmux was slow to respond.
Switch tmuxHasSession to promisified execFile and await it from the
onExit handler, mirroring the execFileAsync pattern in runtime-tmux.
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(workspace-worktree): restore re-attaches existing branch instead of recreating with -b
Restoring a session whose worktree directory was cleaned up but whose
branch still existed locally would 422 with `fatal: a branch named <X>
already exists`. The recovery path in `restore()` unconditionally fell
through to `git worktree add -b`, even though `destroy()` deliberately
preserves session branches so the user's commits aren't lost.
When the local branch already exists, restore now clears any stale
worktree registration at the target path and retries `git worktree add
<path> <branch>` (no -b/-B). The existing -b fallback is preserved
verbatim for the case where the local branch is genuinely missing
(only the remote ref exists). -B is intentionally not used — it would
force-reset the branch back to the base ref and silently discard the
session's commits, which is the opposite of restore's intent.
Test coverage:
- 6 new unit tests covering the recovery path, cleanup tolerance,
error propagation, and "no -b/-B" invariants
- 2 updated existing unit tests (now mock the new refExists check)
- 2 new integration tests exercising real git: branch preservation
on clean restore, and recovery from a dirty teardown that left a
stale registry entry
Closes#1741
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(workspace-worktree): explicitly create main branch in restore integration tests
CI runs git with a different `init.defaultBranch` than the local dev
environment, so the bare clone has no `main` branch when the test
attempts to push. Mirror the existing tests in this file (which also
call `git switch -c <branch>` before the first commit).
Fixes integration-test failures on PR #1742.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(workspace-worktree): rmSync stale workspace dir before retry
Follow-up to the previous commit on this branch. The recovery path
cleared the git worktree registry but didn't touch the filesystem, so
when restore was triggered by a stale junk directory at the workspace
path (workspace.exists() returns false because rev-parse fails on a
non-working-tree dir), the retry would fail with the same error:
fatal: '<workspacePath>' already exists
Replace the inline `worktree prune` cleanup with a call to the existing
`clearStaleWorktreePath()` helper, which handles all three states:
- dir gone → no-op
- dir present and not registered → rmSync
- dir present and still registered after prune → throws (safety:
never delete a registered worktree)
This mirrors how create() already handles the same stale-state cases
upfront via clearStaleWorktreePath at the top of its flow.
Test coverage:
- 1 new unit test: rmSyncs a stale workspace directory before retry
- 1 new unit test: refuses to rmSync a still-registered worktree dir
(data safety — error must propagate, not be swallowed)
- 1 new integration test on real git: dir physically present as
non-working-tree leftover, restore must rmSync it before retry,
and the session commit must survive
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(workspace-worktree): extract restore helpers, drop redundant prune
Addresses review feedback on PR #1742:
1. Extract two named helpers, reattachExistingBranch and
createBranchFromBase, so restore()'s catch block reads as the
bifurcation it actually is — 2 lines per branch, no nested
try/catch hierarchy. Behavior is unchanged.
2. Drop the redundant `worktree prune` that ran inside the recovery
path (via clearStaleWorktreePath). The entry-point prune in
restore() is sufficient. reattachExistingBranch now inlines the
existsSync + isRegisteredWorktree + rmSync sequence directly,
keeping the data-safety guard ("refuse to rmSync a registered
worktree") intact but skipping the second prune call.
The catch block shrinks from ~46 lines to 12 (the rest moves into
the helpers, where the docstrings can explain WHY each branch exists
without cluttering the call site).
Tests: same 64 unit + 13 integration tests still pass — the mock
sequences in the recovery-path tests no longer expect a second prune
call, since the new code doesn't make one.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(workspace-worktree): address Copilot review on PR #1742
Two real concerns flagged in inline review:
1. isRegisteredWorktree did strict string equality on paths. If
`workspacePath` was passed in a non-canonical form (trailing slash,
".." segments) and git reported the canonical path, the check would
false-negative — and the subsequent rmSync in cleanupStaleWorkspacePath
would silently delete a still-registered worktree (data loss). Fix
by resolve()-normalizing both sides before comparison.
2. createBranchFromBase (the "branch missing locally" recovery path)
skipped the stale-path cleanup that reattachExistingBranch did. So
if `workspacePath` had a stale dir AND the branch was missing,
`git worktree add -b ...` would fail with the same "<path> already
exists" error this PR was fixing for the re-attach case. Fix by
factoring the cleanup into cleanupStaleWorkspacePath, called from
both helpers.
Test coverage:
- Unit: path normalization safety — workspacePath with trailing slash
vs canonical registered path must still throw "still registered"
(proves rmSync is NOT called)
- Unit: createBranchFromBase clears stale dir before -b add
- Integration: branch missing locally + stale dir at workspacePath →
restore must clean dir AND recreate branch from origin (commit
preserved end-to-end on real git)
Existing 2 "branch missing" tests updated to mock the new cleanup
calls in createBranchFromBase.
66 unit tests + 14 integration tests pass.
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: project builds on Windows
Replace Unix cp -r with Node.js fs.cpSync in CLI build script.
Add webpack snapshot config to prevent Next.js scanning Windows junction points.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(core): add cross-platform adapter (platform.ts)
Centralizes all platform-branching logic: shell resolution (pwsh > powershell > cmd),
process tree kill (taskkill on Windows), port-based PID discovery (netstat on Windows),
runtime defaults, and env defaults.
Addresses blockers B05, B06, B07, B08 from Windows compatibility proposal.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(core): platform-aware runtime default (tmux on Unix, process on Windows)
B04: Config and docs now reflect platform-specific defaults.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(cli): use platform-aware runtime fallback in start command
B01/B02: ensureTmux() is only called when runtime resolves to 'tmux'.
On Windows, runtime defaults to 'process', skipping tmux entirely.
* fix(core): tighten Windows PID port matching
* test(core): add mocked tests for platform.ts to fix diff coverage
Adds platform.mock.test.ts with 25 tests covering Windows-specific
branches (resolveWindowsShell fallbacks, killProcessTree, findPidByPort)
and Unix error-handling/env-var fallback chains that require mocking
node:child_process. Pushes platform.ts diff coverage from 45.9% to ≥80%.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(core): fix TypeScript errors in platform.mock.test.ts
Return ChildProcess from execFile mock implementations and use "" instead
of undefined for execFileSync mock return value to satisfy strict types.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(windows): cross-platform process management (PR 2/6) (#1028)
Fixes B05 and B06: replaces all Unix-only process management with the
platform adapter so AO works on Windows with runtime: process.
- dashboard stop/rebuild uses findPidByPort() (netstat on Windows, lsof on Unix)
- runtime-process destroy uses killProcessTree() (taskkill /T /F on Windows,
negative-PID SIGKILL on Unix) with conditional detached flag
- start.ts restart, ao stop --all, and stop-dashboard paths all switched
from process.kill() to killProcessTree() so child processes are reaped
- lifecycle-service stopLifecycleWorker() replaced with killProcessTree()
- Fixed destroy() hang: exit listener now registered before awaiting kill
so fast-exiting processes don't fire before the listener attaches
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(cli): spawn dashboard with detached:true on Unix for process group kill
Dashboard was spawned with detached:false, so killProcessTree's
process.kill(-pid) failed with ESRCH (not the group leader) and fell
back to killing only the listening process, orphaning Next.js workers.
Matches the runtime-process pattern: detached:!isWindows() makes the
dashboard the process group leader on Unix so the negative-PID group
kill in killProcessTree correctly reaps all children. On Windows,
detached:false is preserved — taskkill /T /F handles the tree kill
by PID regardless of process group membership.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(cli): forward SIGINT/SIGTERM to dashboard group; restore lifecycle stop semantics
Two Bugbot issues from the detached-dashboard and killProcessTree changes:
1. Dashboard Ctrl+C orphan: with detached:true on Unix, the dashboard is in
its own process group and does not receive SIGINT from the terminal. Add
process.once(SIGINT/SIGTERM) handlers that forward the signal via
killProcessTree so the entire dashboard group is reaped on exit. Handlers
are cleaned up when the dashboard exits to avoid leaks.
2. stopLifecycleWorker wrong return value: killProcessTree swallows ESRCH,
so a stale PID entry (process already dead) now returned true ("stopped")
instead of false ("not running"). Add an isProcessRunning guard before
the kill to restore the original semantics.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(cli): prevent double SIGTERM dispatch when both SIGINT and SIGTERM arrive
The forward handler now self-removes both listeners before calling
killProcessTree, so a second signal cannot invoke it again on an
already-dead PID.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(core): respect signal parameter in killProcessTree on Windows
SIGTERM now uses taskkill /T /PID (WM_CLOSE, graceful) instead of /F,
preserving the SIGTERM→wait→SIGKILL escalation used by lifecycle-service,
start.ts, and runtime-process. SIGKILL keeps /T /F /PID (force).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* test(core): update killProcessTree Windows tests for signal-aware taskkill
Split the single taskkill test into two: SIGTERM uses /T /PID (graceful)
and SIGKILL uses /T /F /PID (force), matching the updated implementation.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* test(core,cli): add missing coverage for platform.ts and lifecycle-service
- platform.mock.test.ts: cover Windows getEnvDefaults PATH fallback (line 148)
- lifecycle-service.test.ts: cover stopLifecycleWorker stale-PID path,
normal SIGTERM kill, and SIGKILL escalation after timeout
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(cli): suppress consistent-type-imports lint error in test mock factory
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(agents): add process-runtime PID check to isProcessRunning (#1031)
B13 (P0): All 4 agent plugins now check PID directly when runtime is
'process' instead of only scanning ps -eo for tmux TTYs. Enables correct
dashboard activity state on Windows.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(windows): platform-aware shell for postCreate, script-runner, symlinks (PR 4/6) (#1032)
fix(windows): platform-aware shell for workspaces and script-runner (B07, B08, B19)
B07: workspace-worktree and workspace-clone use getShell() instead of sh -c.
B08: script-runner spawns scripts in file-mode on Unix (so $1/$2/$3 reach
positional args) and uses getShell() on Windows; AO_BASH_PATH override
uses || so empty string is treated as unset.
B19: workspace-worktree symlink falls back to cpSync on Windows.
Also fixes path separator check in workspace-worktree to use path.sep
instead of hardcoded "/" for correct Windows behaviour.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(test): use mockReturnValueOnce for pwsh shell mock to prevent test pollution
vi.clearAllMocks() clears call history but not mockReturnValue implementations.
The Windows pwsh shell tests were polluting subsequent tests that expected the
default sh mock. Changed to mockReturnValueOnce so the override is consumed
by the single call and subsequent tests get the default sh implementation.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(cli): script-runner always uses bash on Unix, getShell() only on Windows
getShell() on Unix returns process.env.SHELL || /bin/sh, which may be zsh,
fish, or plain sh. When a script file is passed as an argument to these
shells (file mode), the #!/bin/bash shebang is ignored and bash-specific
syntax in ao-doctor.sh / ao-update.sh / setup.sh breaks.
Fix: hardcode bash on Unix (AO_BASH_PATH still overrides it), use getShell()
only on Windows where bash is unavailable.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(core): use lazy factory for Zod runtime default
z.string().default(getDefaultRuntime()) evaluates getDefaultRuntime() once
at module load time, creating hidden coupling between import order and
platform detection. Using a factory function ensures the default is resolved
lazily each time it is needed, making the intent explicit.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(cli): add detached: !isWindows() to dashboard spawn for correct process cleanup
Without detached:true on Unix, killProcessTree(pid) calls process.kill(-pid)
which targets a process group the child never owns — ESRCH causes fallback to
direct kill, leaving grandchild processes alive. Matches the pattern already
used in start.ts lines 782 and 795.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(cli): forward SIGINT/SIGTERM to detached dashboard child on Unix
Detached children run in their own process group, so Ctrl+C does not reach
them. Without forwarding, the dashboard holds the port after the parent exits.
Matches the identical pattern in start.ts lines 1154-1165.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(core): guard killProcessTree against pid <= 0
On Unix, -0 === 0 in JS, so killProcessTree(0) would call process.kill(0)
which sends the signal to every process in the calling process's group,
killing AO itself. findPidByPort can return "0" since it passes the
truthiness check and the /^\d+$/ regex. Guard pid <= 0 at the top of
killProcessTree and return early.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(windows): Node.js metadata wrappers + Claude Code hook + pwsh shell (squash merge PR5)
Squash merges feat/windows-hooks-and-launch (PR #1033) into PR1.
Blockers addressed:
- B16: ~/.ao/bin/gh and git wrappers are now Node.js scripts + .cmd shims on
Windows (bash on Unix unchanged). WRAPPER_VERSION bumped to 0.3.0 to force
reinstall.
- B17: Claude Code PostToolUse hook uses JSON.parse/fs built-ins on Windows
instead of bash+jq+grep+sed. Atomic writes via temp file + renameSync.
chmod skipped on Windows.
- B18: runtime-process uses getShell().cmd + shellInfo.args() instead of
shell:true (which resolves to cmd.exe on Windows). getShell() returns
pwsh > powershell.exe > cmd.exe on Windows, bash on Unix.
Conflict resolution:
- runtime-process spawn: kept PR5's getShell() approach (B18 fix), removed
shell:true which PR1 had as a placeholder.
- runtime-process destroy: kept PR1's cleaner killProcessTree delegation
(PR5 had inline platform-branching written before killProcessTree was
integrated into this worktree).
- Tests: merged PR5's new Windows compatibility tests, adjusted assertions to
match PR1's killProcessTree(pid, signal) API. Fixed Windows compat tests
that expected old spawn(launchCommand, opts) signature.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(core): deduplicate Node.js wrapper updateAoMetadata and add AO_DATA_DIR path validation
- Extract shared NODE_UPDATE_AO_METADATA constant used by both gh/git wrappers
- Add AO_DATA_DIR validation matching bash ao-metadata-helper.sh (must be under ~/.ao/, ~/.agent-orchestrator/, or tmpdir)
- Bump WRAPPER_VERSION to 0.5.0 to force reinstall with new security check
- Update version in agent-codex and core tests to match 0.5.0
- Fix CI test failures from WRAPPER_VERSION bump (0.2.0 → 0.4.0 → 0.5.0)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* test(core): add IPv6 netstat test for findPidByPort + mark _resetShellCache as @internal
- Add test case for IPv6 LISTENING entries ([::]:3000) in Windows netstat output
- Add @internal JSDoc to _resetShellCache to clarify it is test-only
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(cli): use -File mode on Windows for script-runner so positional args are forwarded
PowerShell -Command does not forward argv elements after the command string to the
script — they are treated as top-level PowerShell args and silently dropped. Using
-File passes remaining args as positional parameters ($1, $2, …) to the script, so
e.g. `ao doctor --fix` correctly reaches the script on Windows.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(cli,core): remove unused getEnvDefaults export, add SIGKILL fallback in signal forward handler
- Remove getEnvDefaults from @composio/ao-core public API — no production callers;
the function remains in platform.ts for future use (B10/B11)
- Add 5 s SIGKILL fallback in dashboard.ts and start.ts forward() handlers so the
parent process cannot hang indefinitely if the child ignores SIGTERM
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* refactor(cli): extract forwardSignalsToChild utility, fix ESM build script, reject Windows absolute symlinks
- Extract forwardSignalsToChild(pid, child) into shell.ts — eliminates verbatim
duplication of the SIGTERM/SIGKILL forwarding logic between dashboard.ts and start.ts
- Fix build script: replace node -e "require(...)" with node --input-type=commonjs -e
"require(...)" — required because package is "type":"module" and require is not
available in node -e by default in ESM context
- Fix duplicate import in shell.ts (no-duplicate-imports lint error)
- Reject Windows drive-letter (C:\) and UNC (\server\share) paths in symlink
validation — previously only Unix absolute paths starting with "/" were blocked
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(cli): clear SIGKILL fallback timer when child exits cleanly
If the child exits before the 5-second escalation window, the fallback
setTimeout would still fire and call process.exit(1). Track the timer
in the outer scope so the child.once("exit") handler can cancel it.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: resolve two open bugbot issues on windows-platform-adapter PR
- fix(platform): always use /bin/sh on Unix instead of \$SHELL so
postCreate commands and runtime launches work correctly when the
user's login shell is non-POSIX (fish, nushell, etc.)
- fix(script-runner): detect cmd.exe fallback on Windows and throw a
clear, actionable error pointing to AO_BASH_PATH rather than passing
the PowerShell-specific -File flag to cmd.exe (which produces a
cryptic error and still can't run bash scripts)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(cli): gate lifecycle worker detached flag behind !isWindows()
Spawning with detached: true unconditionally creates a new console
window on Windows. Use the same !isWindows() pattern established
elsewhere in this PR so the process group behaviour is correct on
both platforms. killProcessTree already uses taskkill /T on Windows
so cleanup is unaffected.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(cli): require AO_BASH_PATH for all Windows shells, not just cmd.exe
pwsh and powershell.exe cannot run bash scripts any more than cmd.exe
can — shebangs are ignored and bash-specific syntax fails. The previous
guard only matched cmd.exe, allowing pwsh (the most common Windows
fallback) to silently invoke -File on a bash script and produce a
confusing PowerShell syntax error.
Simplify to: throw on any Windows shell without AO_BASH_PATH. Also
removes the now-dead getShell() call and -File code path from
script-runner.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(core): always force-kill on Windows; fix stale .cjs JSDoc
killProcessTree: drop the SIGTERM-without-/F branch on Windows.
taskkill without /F sends WM_CLOSE which is unreliable for headless
Node.js console processes — they may silently survive, leaving orphaned
processes. Always pass /F so termination is guaranteed. Callers that
do SIGTERM→wait→SIGKILL escalation are unaffected: SIGKILL simply
finds the process already dead.
agent-workspace-hooks: correct JSDoc that still said <name>.js after
the extension was changed to .cjs (forced CJS mode).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* refactor(cli): remove trivial findRunningDashboardPid wrapper
The function was a one-line pass-through to findPidByPort with no added
logic. Callers now import findPidByPort from @composio/ao-core directly.
waitForPortFree calls findPidByPort inline. Deleted the test file that
only tested the pass-through.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(cli): make forwardSignalsToChild idempotent via WeakSet guard
Prevents duplicate SIGINT/SIGTERM handlers if called more than once for
the same ChildProcess — avoids double killProcessTree and racing
process.exit(1) from stacked SIGKILL fallback timers.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(agent-claude-code): three Windows correctness fixes
C-1: Add AO_DATA_DIR allowlist validation to METADATA_UPDATER_SCRIPT_NODE.
The Node.js PostToolUse hook now validates AO_DATA_DIR against
~/.ao/, ~/.agent-orchestrator/, and os.tmpdir() before writing —
matching the protection already in ao-metadata-helper.sh and the
Node.js wrappers in agent-workspace-hooks.ts.
I-1: Guard getCachedProcessList() against Windows. ps -eo pid,tty,args
is Unix-only; the guard makes the intent explicit and avoids a
spurious execFile call when a stale tmux handle is encountered on
Windows.
I-2: Read systemPromptFile content synchronously on Windows instead of
using $(cat ...) bash command substitution, which is not understood
by PowerShell or cmd.exe.
Tests added for all three fixes.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(agents): apply Windows correctness fixes to aider, codex, opencode
Mirrors the fixes already applied to agent-claude-code:
I-1: Guard ps -eo pid,tty,args behind isWindows() in isProcessRunning for
all three agents. ps is Unix-only; a stale tmux handle on Windows
would silently return false via exception catch. The explicit guard
makes intent clear and avoids the unnecessary execFile call.
I-2: Inline systemPromptFile content on Windows instead of $(cat ...)
bash command substitution (aider: --system-prompt; opencode: prompt
value). codex is unaffected — it passes the path via -c flag and
reads the file itself.
Tests: added isWindows mock (default false) to all three test suites to
prevent real isWindows()=true on Windows from triggering the new guard
in existing Unix-path tests. Added Windows-specific tests for each fix.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(core): use F_OK and skip bare extension in Windows gh/git wrappers
On Windows, fs.constants.X_OK is equivalent to F_OK (execute bit does
not exist), so any existing file passes the check. The empty extension
was also tried first, meaning a bare file named "gh" would be selected
over gh.exe. Switch to F_OK and only check .exe/.cmd extensions.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(windows): QA fixes — dashboard spawn, shellEscape, tmux hint, PID fallback
T04: resolveNextBin() skips POSIX .bin/next shim on Windows; invokes
next/dist/bin/next via process.execPath instead (ENOENT fix).
T18: shellEscape() branches on isWindows() — PowerShell uses '' doubling,
Unix uses POSIX '\'' escaping. All 4 agent plugins benefit automatically.
T06 secondary: tmux attach hint in `ao spawn` output gated behind
runtimeName === "tmux" (process runtime has no tmux session).
T06/T11: runtime-process destroy() and isAlive() fall back to
handle.data.pid when the in-memory processes Map is empty — fixes
cross-process CLI calls (ao session ls / ao session kill).
* fix(web): prevent nft EPERM on Windows home directory junction points
Next.js nft (Node File Tracer) scans homedir() at build time and hits
EPERM on Windows junction points (e.g. Application Data). Adds homedir()/**
to TraceEntryPointsPlugin.traceIgnores on Windows server builds.
* Revert "Merge branch 'main' into feat/windows-platform-adapter"
This reverts commit 5ba7644548, reversing
changes made to 5da9bedf5c.
* Reapply "Merge branch 'main' into feat/windows-platform-adapter"
This reverts commit 6a326a07e3.
* fix(windows): update @composio imports to @aoagents scope
Our Windows-specific files were written before the @composio → @aoagents
rename landed. Update all affected imports across runtime-process,
workspace-clone, workspace-worktree, cli commands, and test files.
* feat(windows): add PTY host for ConPTY terminal sessions
Windows equivalent of the tmux daemon. Per-session detached pty-host.js
process owns a ConPTY (via node-pty), listens on a named pipe
(\.\pipe\ao-pty-{hash}-{sessionId}), and relays terminal I/O to any
connected client.
- runtime-process: spawn pty-host on Windows, route sendMessage/getOutput/
isAlive/destroy through named pipe protocol
- mux-websocket: named pipe relay for dashboard terminal (skip TerminalManager
on Windows), resolvePipePath via generateConfigHash (instant, no pipe scan)
- direct-terminal-ws: use real mux server on Windows instead of placeholder
- tmux-utils: findTmux returns null on Windows, resolvePipePath added
- orchestrator-prompt: runtime-agnostic language
- opencode: fix isProcessRunning tmux-before-guard bug (W29)
Addresses blockers W01-W12, W23, W24, W28, W29, W33-W35.
Unix behavior completely unchanged.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(windows): QA fixes — session attach, ao stop, activity detection
- cli/session: add Windows ao session attach via named pipe relay with
raw stdin mode and Ctrl+\ to detach; skip getTmuxActivity on Windows
- cli/start: fix ao stop looking for "tr-orchestrator" instead of the
actual numbered session (e.g. tr-orchestrator-5) — also fixes Linux
- agent-claude-code: fix toClaudeProjectPath dropping Windows drive colon
(C:\→C- not C) breaking JSONL lookup; ignore stale JSONL entries from
previous sessions in reused worktrees
- pty-client: use \r (carriage return) instead of \n for PTY Enter key
Addresses blockers W13 (partial), W14.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: resolve merge conflicts from main — rename isOrchestratorSession, update stop tests
- session.ts: use isOrchestratorSessionName (renamed in main) for JSON output
- start.test.ts: update stop command tests to use sm.list() instead of sm.get()
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address CI failures and review comments
- session.ts: restore isOrchestratorSession from core (checks both name
and metadata role) instead of name-only isOrchestratorSessionName
- session.ts: remove unused allSessionPrefixes after merge conflict
- start.test.ts: update stop command tests for sm.list() flow
- toClaudeProjectPath: remove speculative space replacement, keep only
verified chars (/ : .) — addresses review comment about Unix breakage
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(windows): address review feedback, fix tests, and improve Windows coverage
- Fix CI diff coverage and review feedback
- Fix test reliability for session attach, stop, and Windows attach
- Add coverage for session attach binary protocol and edge cases
- Clean up stdin listener + add resolvePipePath tests
- Address review comments + coverage for pipe relay
- Make 80 failing tests pass on Windows (cross-platform mocks, path assertions)
- Fix production code: execFile shell option for .cmd, isPathInside separator,
openclaw binary detection via `where` on Windows
- Add platform-aware test assertions for shell escaping, PATH handling, hooks
- Add signal forwarding comment for Windows dashboard process
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(windows): QA fixes — activity timestamps, prompt delivery, pty-host keep-alive
- session.ts/status.ts: use session.lastActivityAt on Windows (no tmux)
- session-manager.ts: stabilize ConPTY output before sending post-launch prompt
to prevent prompts being swallowed during agent startup splash screen
- pty-client.ts: split message + Enter into two writes (300ms gap) to match
tmux send-keys behavior; fix isAlive to return true while pipe is connectable
regardless of whether the agent process inside has exited
- pty-host.ts: keep named pipe server alive after agent exits (mirrors tmux
session persistence) so clients can still attach and view scrollback
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(core): Windows-stable storage hash and atomic write retry
storage-key: normalize to POSIX form (strip drive letter, replace backslash
with forward slash) before hashing so identical repos produce identical
hashes across Windows and Unix, and across different Windows working
directories of the same checkout.
atomic-write: retry renameSync up to 10x with 50ms backoff when Windows
returns EPERM/EACCES/EBUSY — antivirus, file indexer, or backup software
briefly hold handles to recently-written files. Cleans up the temp file
on final failure so subsequent retries don't trip "file exists".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(windows): plugin runtime gaps + ConPTY graceful shutdown
runtime-process:
- Reserve the per-instance processes-map slot before the platform split
so the Windows ConPTY branch participates in duplicate-create detection
and getMetrics/getAttachInfo bookkeeping. Previously, Windows returned
a handle without storing it, so duplicate session IDs were silently
accepted and getMetrics always reported 0 uptime.
- Add 500ms graceful-exit poll before SIGKILLing the pty-host on destroy
so node-pty can dispose its ConPTY handle. Skipping this orphaned the
conpty_console_list_agent helper and triggered Windows Error Reporting
dialogs (0x800700e8) on real runs, not just tests.
- pty-host: install SIGTERM/SIGINT/SIGHUP/SIGBREAK/beforeExit handlers
that drive the same shutdown sequence (kill pty, drop clients, close
pipe, exit after 50ms grace), and route MSG_KILL_REQ through the same
path. Previously MSG_KILL_REQ only called pty.kill() and left the host
process lingering.
- Add windowsHide:true to the pty-host child spawn so node-pty's helper
console window stays hidden on errors.
workspace-worktree: normalize paths to a comparable POSIX form
(backslash→slash, lowercase drive letter) when matching git worktree
list --porcelain output against project directories. git emits
forward-slash paths on Windows; path.join produces native backslashes
— the comparison failed and list() returned empty.
agent-opencode: guard tmux/ps usage with isWindows() in isProcessRunning
so process-runtime sessions on Windows take the PID-signal path instead
of attempting Unix-only commands.
cli/start: detect Windows local paths in isLocalPath (drive letter
prefix, UNC path, .\, ..\) so spawn arguments like C:\... aren't
mistaken for project names.
integration test: replace cat + /tmp with platform-native echo (findstr
"x*" on Windows, cat on Unix) and os.tmpdir(); the original used
Unix-only tooling and would never run on Windows.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(windows): isolate USERPROFILE per test, normalize path assertions
Before this, vitest worker isolation only overrode HOME, but on Windows
os.homedir() reads USERPROFILE. Tests that wrote to ~/.agent-orchestrator
ended up sharing the real user's data directory across workers, leading
to flaky cross-test pollution.
- test-utils: createTestEnvironment / setupTestContext now override both
HOME and USERPROFILE to the per-test fake home, restore both on
teardown. rmSync uses maxRetries:5/retryDelay:50 to ride out the same
Windows file-lock window that atomic-write retries cover.
- core test files (global-config, plugin-integration, portfolio-*,
project-resolver, recovery-actions, orchestrator-prompt*): set fake
USERPROFILE alongside HOME and use the retry-aware rmSync.
- update-check.test: normalize path separators in assertions
(path.replace(/\/g, "/")) so script-path matching works on Windows
without forcing the production code to emit posix paths.
- orchestrator-prompt.dist.test: pass shell:true on Windows to execFileSync
for .cmd targets, working around Node CVE-2024-27980's hardening.
Removed lifecycle-service.test.ts — it covered stopLifecycleWorker, which
was deleted when lifecycle was moved in-process during the merge with
main. The remaining lifecycle paths are exercised by lifecycle-manager
tests in core.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(core): restore must rewrite statePayload.runtime.handle, not just top-level
Restore was writing the freshly-spawned runtime handle to the top-level
metadata `runtimeHandle` key, but the canonical lifecycle parser prefers
`statePayload.runtime.handle` and falls back to the top-level only when
statePayload is missing. The next lifecycle tick read the stale handle
from statePayload and rewrote both keys from it, silently undoing the
restore's update.
Symptom: a session restored after AO restart kept the old PID in
metadata. Lifecycle probe found that PID dead (it was from a previous
boot) and the dashboard rendered the orchestrator as exited/killed even
though a new process was actually running.
Fix: rebuild the canonical lifecycle with the new handle via
buildUpdatedLifecycle() and persist via lifecycleMetadataUpdates() so
statePayload and runtimeHandle stay in sync. Mirrors the pattern used
in kill/spawn paths.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(cli): mock exec for ps verification in stop test
killDashboardOnPort runs a unix-only `ps` cmdline check before killing.
Without mocking exec, the call rejects and the catch returns false, so
killProcessTree is never called and the assertion fails on Linux CI.
* fix(windows): suppress console flashes, register process runtime, auto-detect Git Bash, chunk PTY input
- platform.ts: add windowsHide:true to pwsh/powershell/taskkill/netstat
spawns so AO no longer flashes a console window for each subprocess.
- script-runner.ts: auto-detect Git Bash at the common install paths on
Windows when AO_BASH_PATH is unset; tighter error if neither auto-detect
nor override succeeds. WSL bash intentionally excluded — invoking it
from Windows-native Node mixes Linux paths with Windows cwd and
silently breaks repo scripts. Also adds windowsHide:true to the spawn.
- web/services.ts: register @aoagents/ao-plugin-runtime-process so the
dashboard can spawn sessions on projects using runtime: process
(the Windows default per getDefaultRuntime).
- pty-client.ts: chunk ptyHostSendMessage into 512-char frames with a
15ms gap so large prompts (~3-4KB+) are no longer truncated by
ConPTY's input buffer. Cross-platform safe; Unix PTYs absorb chunks
at full speed. Trailing Enter still sent as a separate frame after
the existing 300ms pause.
- Mock test helpers updated to handle the (cmd, args, options, callback)
arity introduced by passing windowsHide; new test covers Git Bash
auto-detection.
* fix(windows): add windowsHide to remaining subprocess spawns
Sweeps the spawn sites missed by the first pass — `ao stop`, session
list, opencode introspection, tmux helpers, and worktree git/postCreate
all bypassed the previous fix and still flashed conhost on Windows.
- cli/lib/shell.ts: exec helper (used by git/gh/tmux wrappers)
- core/session-manager.ts: EXEC_SHELL_OPTION + standalone tmux call
- core/tmux.ts: tmux execFile helper
- plugins/workspace-worktree: git wrapper + rev-parse + postCreate shell
- workspace-worktree tests: assertions updated for the new options shape
* fix(codex): make agent-codex work on Windows
Three Windows-specific gaps that combined to make every Codex spawn fail
on PowerShell with "Unexpected token '-c' in expression or statement":
- formatLaunchCommand(): prepend `& ` to the joined launch string when
running on Windows. shellEscape quotes the resolved binary path
('C:\Users\...\codex.cmd'), and PowerShell parses a leading quoted
string as an expression — without the call operator the next flag
triggers a parser error before codex is ever invoked. bash treats
the same string as a normal command, so the prefix is Windows-only.
Applied at both getLaunchCommand and getRestoreCommand exits.
- resolveCodexBinary(): add a Windows branch using `where.exe` instead
of `which`. Prefers codex.cmd (npm shim) over codex.exe (Cargo build),
then falls back to %APPDATA%\npm\codex.{cmd,exe} and ~\.cargo\bin
for users whose PATH doesn't yet include the install dir. Lookup runs
with windowsHide:true so the search itself doesn't flash a console.
- sessionFileMatchesCwd(): compare paths via a canonical form
(forward slashes, lowercased drive letter) so Codex JSONL rollout
files can still be located when payload.cwd uses a different slash
direction or drive-letter case than the workspace path AO computes
via path.join. Without this, dashboard activity/cost stay empty
for Codex sessions on Windows.
* fix(windows): resolve gh.exe via PATHEXT and fix path-shape test regexes
resolveGhBinary() searched PATH for a literal "gh" file and threw on
Windows where the binary is gh.exe (or gh.cmd for npm shims). All gh
calls in tracker-github and scm-github failed before reaching execFile,
which made spawn() fall back from tracker-derived branch names and made
cleanup() skip the gh-driven kill paths entirely.
Honor PATHEXT on win32 so the resolver matches gh.exe/.cmd/.bat. Update
the four affected integration assertions to accept Windows path shapes.
Also bump the runtime-process sendMessage sleep on Windows — ConPTY
pipe round-trip needs more headroom than the Unix direct-stdin path.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(windows): junctions/hardlinks for symlinks, WinRT toast notifier, DPI re-fit
workspace-worktree: when symlinkSync EPERMs on Windows (no admin /
Developer Mode), try a junction for directories and a hardlink for
files before falling back to recursive cpSync. The previous fallback
copied node_modules into every worktree — slow and bloated.
notifier-desktop: add a win32 branch using PowerShell + WinRT toast XML
(no third-party deps). The script is base64-encoded as -EncodedCommand
to sidestep PowerShell argument tokenization. Toast failures log a
warning instead of rejecting so a stripped-down SKU or disabled
notifications can't crash the lifecycle.
DirectTerminal: re-fit on devicePixelRatio changes via matchMedia.
ResizeObserver doesn't fire when only DPR changes (e.g. dragging the
window between monitors at different scales on Windows), leaving an
unrendered stripe to the right of the last column until manual resize.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(test): update notifier-desktop integration test for Windows toast support
I missed this duplicate test in the integration-tests package when I
added the Windows branch to notifier-desktop. The mock callback used the
3-arg execFile signature (cmd, args, cb) but the new win32 path calls
execFile with 4 args (cmd, args, opts, cb), so the callback landed in
the opts slot and "cb is not a function" broke CI on Linux.
Make the mock signature-agnostic and replace the win32 "no execFile,
warns" assertion with one that verifies the EncodedCommand toast script.
Add a separate freebsd case for the actual unsupported-platform path.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* docs: consolidate six Windows port plans into one closeout
All 11 punch-list tasks shipped (junctions, WinRT toast, DPR re-fit
landed last) plus the foundational PTY-host / runtime-process work.
Stop fix is the only deferred item, waiting on upstream PR #1496.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(agents): use shell:true on Windows in detect() to honor PATHEXT
execFileSync with a bare command name on Windows does not consult
PATHEXT — it only finds literal .exe files. CLIs installed via
npm install -g land at %APPDATA%\npm\<name>.cmd, which detect() can't
see, so AO reports the agent as not installed.
Add shell: isWindows() so cmd.exe handles PATHEXT and finds .cmd shims.
Adds windowsHide: true while we're there to suppress conhost flashes.
Affects all 5 agent plugins: aider, claude-code, codex, cursor, opencode.
Reproduced with codex installed via npm on a Windows EC2 box where
where.exe codex resolved to codex.cmd but detect() returned false.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(windows): probe absolute powershell path so PATH-degraded children don't fall through to cmd.exe
The dashboard (Next.js) sometimes spawns the runtime-process pty-host with a PATH that
lacks C:\Windows\System32. Both `pwsh` and `powershell.exe` probes in
resolveWindowsShell() then fail and we drop to cmd.exe — which can't execute the
PowerShell-syntax launch commands agents emit (e.g. Codex's `& 'codex' ...`),
producing `'&' was unexpected at this time.` and an immediately-exited orchestrator.
Probe %SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe directly via
existsSync — this path is guaranteed on Windows 10+ and doesn't depend on PATH.
Also add an AO_SHELL env override as an explicit escape hatch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: restore orchestrator session with its systemPromptFile
When restoring an orchestrator session whose agent has no resumable thread for
the worktree (e.g. Codex when the rollout file's cwd doesn't match), restore()
falls back to getLaunchCommand(agentLaunchConfig). The fallback's
agentLaunchConfig was missing systemPromptFile, so Codex booted as a bare TUI
with no orchestrator instructions — the dashboard terminal showed the default
"Write tests for @filename" prompt instead of the orchestrator running.
spawnOrchestrator writes the prompt to {baseDir}/orchestrator-prompt-{sessionId}.md
and threads it through agentLaunchConfig.systemPromptFile (session-manager.ts:1687).
Re-attach the same file on restore when the role is orchestrator and the file
still exists on disk.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(test): assert negative pid in start full-stop test
killProcessTree on Unix targets the process group first via
process.kill(-pid, signal); only falls back to positive pid if that
throws. The test mock returns true, so only the negative-pid call
ever fires. Update assertion to match the actual call.
Caught by CI on Linux (test was Windows-skipped locally).
* fix(test): assert on killProcessTree mock, not process.kill
killProcessTree is module-mocked at the top of start.test.ts, so
process.kill is never invoked by the stop command — the spy
assertion would always see 0 calls on Linux CI. Assert on the
mock directly. Mock is platform-agnostic, so the skipIf is gone.
* fix(windows): node wrapper updateAoMetadata supports V2 .json metadata format
The Windows Node.js gh/git wrappers in NODE_UPDATE_AO_METADATA only tried
the bare session path (e.g. ao-154), but V2 storage uses ao-154.json files.
This caused silent metadata update failures on Windows — PR URLs written by
agents via `gh pr create` were never recorded in session metadata.
Fix mirrors bash ao-metadata-helper.sh: try .json first (V2), fall back to
bare name (V1/legacy). Also adds JSON.parse/stringify handling for V2 JSON
format instead of the key=value line-splitting that only worked for V1.
Bump WRAPPER_VERSION 0.6.0 → 0.7.0 to force reinstall on existing setups.
* chore: remove accidentally committed package-lock.json files
* feat(windows): pty-host registry + sweep on stop and project delete
Windows pty-hosts spawn detached so they survive parent exit (mirroring tmux
on Unix). That same detachment means taskkill /T cannot reach them on
graceful shutdown — they live in their own console group, outside the
parent's process tree. Per-session metadata can't be the source of truth
either: rm -rf'd worktrees, mid-write crashes, or manual recovery sever
AO's only handle to the host PIDs and orphan them silently.
This adds a sideband registry at ~/.agent-orchestrator/windows-pty-hosts.json
that AO writes on spawn (runtime-process) and reads on shutdown (cli/start.ts
sweepWindowsPtyHosts) and project delete (web/.../route.ts via
stopStaleWindowsPtyHosts). Reads auto-prune entries whose PID is gone, so
the registry is self-healing across crashes.
Sweep is graceful-first: each entry gets ptyHostKill via its named pipe,
500 ms grace probe, then killProcessTree as the hard fallback. The result
("swept N pty-host(s): G graceful, F force-killed") goes to the ao stop
log so users can see cleanup happened.
Verified live: spawn registers, destroy unregisters, ao stop --all sweeps,
PID 0 entries auto-prune on next read.
* fix(windows): retry worktree rmSync on file-handle drain race
After ao kills a runtime, the just-exited pty-host's child processes
(conpty_console_list_agent.exe, the agent's spawned shell, .git/index.lock)
still hold open handles inside the worktree for ~30 s–2 min while Windows
drains them. rmSync(force: true) deletes individual files but the parent
rmdir blocks with EBUSY/ENOTEMPTY/EPERM, leaving an empty orphan directory
under ~/.agent-orchestrator/projects/*/worktrees/.
destroy()'s catch-block fallback now calls removeDirWithRetry, which on
Windows retries with backoff [0, 100, 250, 500, 1000, 2000] ms checking
existsSync between attempts, and throws a descriptive error if the
directory survives all six. Non-Windows behaviour is unchanged (single
rmSync).
The thrown error escapes to session-manager.ts:kill which already swallows
it, so callers see no behaviour change today — but observability layers
can hook in later to surface real failures instead of silent orphans.
Addresses the Windows subset of #1562 (the cross-platform stale
.git/worktrees/<id>/ registration is still tracked there separately).
* fix(windows): code-review hardening — shell args, runtime default, sessionId, V2 pipe path
Four small fixes flagged in review of the Windows port:
- core/platform.ts: AO_SHELL override now infers args flag from the shell
basename (cmd → /c, bash/sh/zsh → -c, anything else → -Command). Previously
every override got PowerShell args, so AO_SHELL=cmd or AO_SHELL=bash
silently broke run-command flows.
- core/global-config.ts: defaults.runtime now resolves to getDefaultRuntime()
(process on Windows, tmux elsewhere) instead of the hardcoded "tmux".
First-run on Windows no longer writes a config that immediately fails
runtime resolution.
- web/server/mux-websocket.ts: validateSessionId now runs on the Windows
named-pipe relay path. The Unix branch validates inside TerminalManager;
the Windows path bypassed it entirely, so an unsanitised id became both
a map key and was interpolated into a pipe path downstream.
- web/server/tmux-utils.ts: resolvePipePath now reads the V2 JSON layout
(~/.agent-orchestrator/projects/{projectId}/sessions/{id}.json) first,
then falls back to V1 line-delimited metadata for users who haven't run
ao migrate-storage. The single-source-of-truth note is still accurate;
the search just covers both layouts during the migration window.
Each change has a paired unit test.
* chore: drop superseded windows-port-closeout plan
* fix(core): lazy-resolve homedir() in windows-pty-registry
REGISTRY_FILE was computed at module load via homedir(), which fired
before vitest mock factories for `node:os` could install. Tests that
mock node:os (notifier-desktop, terminal-iterm2, agent-claude-code
activity-detection) hit either a TDZ error or "homedir not defined on
mock" because the mock isn't bound at evaluation time.
Resolve the path lazily inside readRaw/writeRaw so each call honours
the current mock. Also rename the test helper export from a const to
a function (__getWindowsPtyRegistryFile) so tests can read the
post-mock value.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(cli): exit 0 when SIGKILL fallback fires on slow Ctrl+C
forwardSignalsToChild's 5 s fallback called process.exit(1) after
SIGKILL, which marks user-initiated Ctrl+C as an error whenever the
child is merely slow to drain (Next.js connection draining is the
common case). Shell scripts and CI pipelines that check the AO exit
code break.
Use exit 0 — graceful user shutdown is not a failure even if the
child needed force-killing. Reported by greptile review on PR #1025.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(core): drop synchronous shell probes that block event loop
resolveWindowsShell ran execFileSync("pwsh", ["-Version"], { timeout: 5000 })
on every cold start. On the common case (Windows 10/11 with no pwsh
installed) the call blocks the Node event loop for the full 5 s timeout,
stalling AO startup, runtime spawns, and postCreate hooks.
Walk PATH ourselves via existsSync — the lookup is microseconds and
needs no subprocess. Cascade unchanged: AO_SHELL → pwsh on PATH →
absolute powershell.exe → powershell on PATH → cmd.exe.
Reported by greptile review on PR #1025.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(runtime-process): treat EPERM as alive in pty-host destroy probe
destroy()'s 500 ms graceful-shutdown loop probes the pty-host with
process.kill(pid, 0) and treats any throw as "process gone, clean
exit". On Windows, cross-context processes can return EPERM — the
process is alive but we lack permission to signal it. Returning
early in that case orphans the pty-host and skips killProcessTree.
Detect EPERM and break out of the wait loop so the orphan falls
through to killProcessTree. Other error codes (ESRCH etc.) still
mean the process is gone.
Reported by Copilot review on PR #1025.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(cli): discover Git Bash via PATH walk for non-default installs
WINDOWS_BASH_CANDIDATES only checks C:\Program Files{,(x86)}\Git, so
users who installed Git for Windows on a different drive (e.g.
D:\Program Files\Git\) hit "Cannot run repo scripts on Windows
without bash" even though Git Bash is available. AO_BASH_PATH is the
documented escape hatch but should not be required.
Add a PATH-walk fallback that finds bash.exe wherever Git's bin dir
sits — Git for Windows adds itself to PATH at install time, so this
covers the typical non-default-drive case without a subprocess or
registry lookup.
Reported by greptile review on PR #1025.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(core): hard-code Windows path separators in findOnPath
Using path.delimiter / path.join in the PATH walker meant Linux CI
ran the test with `:` as the splitter and `/` as the joiner. The unit
test simulates Windows by setting PATH="C:\fake\bin" — on Linux
that splits to ["C", "\fake\bin"] and produces "C/powershell.EXE",
neither of which match the mocked existsSync.
findOnPath is only ever called from resolveWindowsShell, so use `;`
and `\` unconditionally. The runtime data — process.env values,
mocked existsSync — is what's being tested, not host-OS path logic.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(windows): PowerShell repo-script runner + ao-doctor/ao-update.ps1
runRepoScript on Windows now prefers a .ps1 sibling of the requested .sh
script and runs it via pwsh.exe (or bundled powershell.exe as fallback).
Adds ao-doctor.ps1 and ao-update.ps1 as Windows equivalents of the
existing bash scripts.
* test(cli): add missing mockExecSilent hoist in dashboard.test.ts
The findRunningDashboardPidsForWebDir tests reference mockExecSilent
but it was never declared in vi.hoisted, so they crashed with
ReferenceError before any assertion ran. Add the missing hoist and
wire execSilent into the shell.js mock.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(windows): echo projectId in pipe relay messages so MuxProvider routes correctly
MuxProvider keys subscribers under `${projectId}:${id}` when projectId is
provided. The Windows pipe relay was dropping projectId from outbound
messages, so the client routed by id alone and the subscriber bucket
mismatched — leaving the xterm pane blank on
/projects/[id]/sessions/[id].
Echo projectId on every outbound terminal frame (opened/data/exited/error)
so the Windows path matches the Unix tmux relay's behavior.
* test(core): respect TMPDIR in platform defaults test
* fix(runtime): harden dashboard launch shutdown
* fix(windows): scope pipe maps and resolvePipePath by projectId
The Windows pipe relay was project-scoped only on outbound WS frames.
Server-side storage and pipe-path resolution still keyed by bare session
id, so two projects sharing a session id on the same mux connection
would collide on the same socket/buffer entry, and resolvePipePath
returned the first matching project's metadata regardless of caller
intent. Brings the Windows path in line with the Unix subscriptionKey
contract.
- resolvePipePath(sessionId, projectId?, fs?) reads only the caller's
project metadata when projectId is provided; legacy callers keep the
walk-all-projects fallback.
- winPipes / winPipeBuffers keyed by \${projectId}:\${id}.
- projectId threaded through handleWindowsPipeMessage data/resize/close
cast sites.
- Tests cover the project-collision case in both mux-websocket and
tmux-utils.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(windows): clear 10 Windows test failures
Real fix:
- events-db: add closeDb() to release the better-sqlite3 file lock on
activity-events.db. Without it, Windows callers cannot rmSync the AO
base dir while the connection is open. Test teardowns in
test-utils.ts and plugin-integration.test.ts now call closeDb()
before rm to fix 4 EBUSY failures.
Test-only:
- tmux-utils.test.ts: normalize backslashes to forward slashes in two
resolveTmuxSession 'hash-prefix' tests; matches the pattern already
used by sibling tests in the same file.
- dashboard.test.ts, script-runner.test.ts, update-script.test.ts:
add it.skipIf(process.platform === 'win32') to four tests that
assert Unix-specific behavior (lsof cwd matching, posix script
paths, ao-update.sh smoke). Each file already uses the same skip
pattern for sibling tests.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(cli): port-scan fallback + Windows PowerShell branch coverage
start.test.ts: re-add the orphaned-dashboard port-scan test that was
lost during the merge from main (commit 4958512d). When the dashboard
auto-reassigns to port+N because the configured port was busy, ao stop
must walk port+1..port+MAX_PORT_SCAN to find it. Skipped on Windows
because killDashboardOnPort skips the ps cmdline verification there.
script-runner.test.ts: add coverage for the Windows PowerShell branch
in runRepoScript. Two Windows-only tests assert (1) ao-doctor.sh is
rewritten to ao-doctor.ps1 and dispatched via pwsh.exe / powershell.exe
with -NoProfile -NonInteractive -ExecutionPolicy Bypass -File and
forwarded user args, and (2) the rewrite is .sh-suffix-driven, not
blind, so a non-.sh script does not get a .ps1 lookup.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(agent-kimicode): make plugin Windows-compatible
Seven blocking issues prevented kimicode from working on Windows. None
were guarded by isWindows checks because the plugin was authored
without importing it. Symptoms ranged from silent agent launch
failures to misclassified process state to total session-discovery
breakage.
1. getLaunchCommand emitted bare command strings ("kimi --work-dir
...") which PowerShell parses as a quoted expression rather than
executing. Wrap with formatLaunchCommand() so Windows gets the
"& " call operator, matching agent-codex.
2. isProcessRunning called ps -eo on the tmux branch with no platform
guard. ps does not exist on Windows, so a stale tmux handle would
throw and misclassify a live agent as exited. Added the same
isWindows() return-false guard agent-codex uses.
3. resolveWorkspacePath called realpath() unconditionally. On Windows,
Node's realpath silently canonicalizes non-existent paths instead
of throwing ENOENT — turning "/workspace/test" into
"D:\workspace\test" and diverging the session-discovery hash from
any caller that hashed the raw input. Stat first so the catch path
is reached uniformly across platforms.
4. isInsideKimiSessions hardcoded "/" as the path separator in the
sandbox check. realpath returns native paths (backslashes on
Windows) so candReal.startsWith(rootReal + "/") never matched —
every candidate was rejected and findKimiSessionMatch returned
null forever. Use path.sep.
5. getEnvironment set PATH and GH_PATH locally with hardcoded POSIX
values. session-manager already injects both for every agent
plugin, so the local writes were dead code that masked the
Windows-aware central logic. Drop them; mirror agent-codex.
6. getLaunchCommand passed config.systemPromptFile via --agent-file,
but kimi expects --agent-file to be a YAML agent spec, not arbitrary
markdown. AO writes the orchestrator prompt as a plain .md file, so
kimi exited with 'Invalid YAML in agent spec file: expected
<document start>, but found <block sequence start>' on the first
bullet. Read the file synchronously and inline its contents into
--prompt instead, concatenating with any existing config.prompt.
7. session-manager listed kimicode in requiresNativeRestore, so when
getRestoreCommand returned null (because the previous launch failed
before kimi wrote any session data), AO threw
SessionNotRestorableError instead of falling back to a fresh
getLaunchCommand. Removed kimicode from the allowlist; falling back
is the only sensible behavior when there is no session on disk to
resume.
Tests: switched the per-suite workspace constant to a per-test
mkdtemp-scoped path so a coincidental directory at /workspace/test
on the host doesn't make Windows realpath canonicalize it. Mocked
isWindows so platform-aware production code can be exercised
deterministically. Made the shell-escape prompt assertion
platform-aware (POSIX 'backslash-quote' vs PowerShell double-quote).
Replaced the --agent-file tests with system-prompt-content-into-prompt
assertions backed by a real temp file under fakeHome.
Result: all 103 tests pass on Windows (was 30 failures pre-fix).
* fix(agent-claude-code): preserve Windows drive-letter slug encoding
The merge of origin/main #1611 ("fold underscores in Claude project
slug") inadvertently regressed Windows behavior. #1611 kept the
pre-existing `.replace(/:/g, "")` so `C:\Users\dev\foo` slug-encoded
to `C-Users-dev-foo` (single dash), but Windows-side QA had already
established (commit 582c5373) that real Claude Code on Windows
produces `C--Users-dev-foo` — the colon position becomes a dash,
not stripped. Stripping the colon broke JSONL lookup on Windows so
session info / restore / metadata persistence all silently failed.
Two test files disagreed after the merge: activity-detection.test.ts
expected the Windows-correct double-dash form (kept by my merge),
while index.test.ts expected origin's single-dash form (added by
#1611). Linux CI ran activity-detection's case against the
single-dash impl and failed loudly.
Fix: drop the redundant `.replace(/:/g, "")`. The broader
`[^a-zA-Z0-9-]` regex already handles the colon as a dash, which
matches Claude's actual on-disk encoding on Windows. Updated
index.test.ts to expect `C--Users-dev-foo` and fixed an unrelated
local-Windows test bug where a hardcoded POSIX path string was
compared against a `pathJoin` result (passes on Linux CI but fails
locally on Windows).
Underscore folding from #1611 is preserved.
* fix(cli): Windows platform adapter follow-ups
Three independent Windows correctness fixes bundled with their tests:
* daemon.ts: killExistingDaemon now uses killProcessTree (taskkill /T /F)
instead of raw process.kill so detached grandchildren of the daemon
(pty-host, dashboard subprocess) are reached on Windows. POSIX behavior
is preserved via killProcessTree's process-group fallback.
* startup-preflight.ts: on Windows, when the project config selects
runtime: tmux, offer to rewrite the line to runtime: process in the
project YAML instead of prompting "install tmux?". The rewrite is a
targeted line-replace (not yaml round-trip) so comments and quoting
are preserved. Decline -> hard exit with manual-fix guidance.
* path-equality: new pathsEqual / canonicalCompareKey helpers used by
start.ts and resolve-project.ts for "same filesystem entry" checks.
realpathSync on Windows can return canonical paths whose drive-letter
case or 8.3-vs-long-name expansion differs from the input even when
both resolve to the same on-disk entry, which made naive === comparisons
miss and surface as phantom "register this project?" prompts on
re-runs of `ao start <path>`. Lowercases on Windows; POSIX is
unchanged.
Also fixes resolve-project.ts's isLocalPath to recognize Windows path
patterns (drive-letter, UNC, .\, ..\) so `ao start C:\path\to\repo`
takes the path branch instead of being mis-classified as a project id.
Test changes: makeConfig now defaults to runtime: process so tests run
on every platform without tripping the Windows-tmux exit; the one tmux
preflight test pins process.platform = 'linux'. The "kills existing
process" test asserts on killProcessTree instead of process.kill.
On-disk yaml fixtures in start.test.ts switch from runtime: tmux to
runtime: process for the same reason.
New tests: 7 in startup-preflight.test.ts (Windows rewrite, decline
exit, missing configPath exit, comment+quoting preservation, Linux
pass-through), 8 in path-equality.test.ts (drive-letter case, segment
case, POSIX case-sensitivity, realpathSync fallback, ~ expansion),
killProcessTree assertions added to daemon.test.ts.
Verified non-issues during the audit (no code change): ao stop graceful
shutdown gap (the work was already moved into ao stop itself in a prior
refactor; running.json/last-stop/sessions are persisted before the
parent kill, and stale state is self-healing on next read);
better-sqlite3 cross-platform binary (optionalDependencies +
files: ['dist'], no prebuilt .node bundled in any release artifact);
ao-doctor / ao-update PowerShell rewrite (script-runner already
rewrites .sh -> .ps1 on Windows, .ps1 siblings ship in assets/scripts/,
covered by an existing Windows-only test); bun-tmp-janitor leak
(janitor is a no-op on Windows because opencode ships no win32 binary
and Windows refuses to unlink mapped files).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(cursor): silence stderr bleed-through in detect() on Windows
execFileSync("agent", ["--help"]) with encoding but no explicit stdio
inherits stderr from the parent process. On Windows with shell:true,
cmd.exe prints "'agent' is not recognized as an internal or external
command" to the terminal even though the exception is caught.
Fix: add stdio:["ignore","pipe","ignore"] to capture stdout (needed for
Cursor marker checks) and discard stderr. Mirrors the pattern used by
the kimicode plugin's detect(). Also adds a 5s timeout as a safety net.
Zero behavior change on macOS/Linux: shell:false means Node throws ENOENT
directly with no subprocess output, so the try/catch already handles it.
Fixes the spurious error printed during ao start first-run setup on Windows.
Co-authored-by: Priyanchew <57816400+Priyanchew@users.noreply.github.com>
* fix(runtime-process): preserve EPERM in Windows pty-host sweep exit-poll
The catch in sweepWindowsPtyHosts treated every error as "process exited",
including EPERM. On Windows EPERM means the pty-host exists but the caller
lacks permission to signal it (cross-context), so the orphan was skipping
the killProcessTree force-kill step and leaking. Mirror the destroy() logic
at line 290: only flag exited on non-EPERM (typically ESRCH).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* test(runtime-process): poll for payload instead of fixed sleep
The Windows ConPTY round-trip (named pipe -> pty-host -> pwsh -> findstr
-> rolling buffer) varies from hundreds of ms to seconds depending on
runner load, AV scanners, and cold caches. The previous 1500 ms fixed
sleep flaked on slow Windows runners (observed empty getOutput buffer at
sample time). Replace it with a 10 s deadline poll that checks for the
actual payload substring, robust to both timing variance and incidental
shell banners arriving first.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* docs: document cross-platform abstractions and reflect Windows support
Adds docs/CROSS_PLATFORM.md as the canonical reference for cross-platform
development: the "Golden Rule" (no raw process.platform === "win32" — use
isWindows() and the helpers in platform.ts), a full inventory of every
platform helper (platform.ts, path-equality, windows-pty-registry,
pty-client, sweepWindowsPtyHosts, validateSessionId, resolvePipePath,
setupPathWrapperWorkspace, activity-state helpers, AO_SHELL/AO_BASH_PATH),
the EPERM-vs-ESRCH gotcha when probing processes, PowerShell-vs-bash
differences, IPv6 localhost stalls, agent-plugin specifics, and a 10-point
pre-merge checklist.
Updates internal docs (CLAUDE.md, AGENTS.md, docs/ARCHITECTURE.md,
docs/DEVELOPMENT.md, CONTRIBUTING.md, .github/copilot-instructions.md,
.cursor/BUGBOT.md, packages/core/README.md, packages/plugins/runtime-tmux/
README.md, packages/core/src/prompts/orchestrator.md, ARCHITECTURE.md) to
remove tmux-only / POSIX-only claims, point at the new doc, and (in
docs/ARCHITECTURE.md) describe the Windows runtime architecture: pty-host
helper, named-pipe protocol, registry, sweep, mux WS Windows branch.
Updates user-facing docs (README.md, SETUP.md, docs/CLI.md) to split
prerequisites by OS (no tmux on Windows), reflect that ao doctor and
ao update work on Windows, and note that power.preventIdleSleep is a
no-op on Linux and Windows.
Updates the agent-orchestrator skill (skills/agent-orchestrator/SKILL.md
and references/config.md) so it advertises Windows support, drops tmux
from the required-bins list, and gives the right Windows guidance for the
"spawn tmux ENOENT" error.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* test(core): update orchestrator-prompt test for cross-platform runtime warning
The orchestrator system prompt was rewritten in 1d8c8f75 to call out both
tmux send-keys (Unix) and the Windows named-pipe write path so the
orchestrator agent doesn't try either. The test still asserted the old
literal "never use raw \`tmux send-keys\`" string. Update it to assert the
new platform-neutral phrasing plus the presence of both runtime mentions.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* ci(windows): matrix Linux+Windows + close coverage gaps
Adds windows-latest to typecheck/test/test-web matrices (lint stays
Linux-only; nothing ESLint catches differs by OS). fail-fast: false
so one OS's failure never masks the other's. tmux install steps gate
on runner.os == 'Linux' since Windows uses runtime-process. test job
adds a node-pty prebuild smoke step on Windows so a future ABI break
fails fast with a clear message. test-web is broadened from
server/__tests__/ to the full vitest suite — closes a pre-existing
Linux-too gap and ensures component/hook/lib tests run on Windows.
Closes three completeness gaps where Windows code paths existed but
no test exercised them:
1. session.test.ts (5 tests): "tests Windows behavior, skips on
Windows" defensive pattern. Tests fully mock isWindows + net.connect
+ child_process — flipping skipIf(win32) to plain it() runs them on
both OSes. All 45 tests pass on Windows.
2. dashboard.test.ts (+2 tests): findRunningDashboardPidsForWebDir
has parallel POSIX (lsof + cwd verification) and Windows
(findPidByPort, no cwd check) implementations. Existing tests
asserted lsof; new runIf(win32) tests assert findPidByPort path
plus dedup across multiple ports.
3. start.test.ts (+1 test): port-scan fallback for orphaned
dashboards skips ps cmdline verification on Windows by design.
Existing test asserted ps was called; new runIf(win32) parallel
asserts ps was NOT called and the kill still fires.
Adds first PS1 script test coverage (previously zero):
4. update-ps1.test.ts (4 tests): argparse — --help/-h, unknown flag,
conflicting --skip-smoke + --smoke-only.
5. doctor-ps1.test.ts (4 tests): argparse + full check pipeline
smoke. The pipeline test runs every Check-* function against an
empty repo and asserts the script exits cleanly with a "Results: N
PASS, N WARN, N FAIL, N FIXED" summary line — catches PS1 syntax
errors and crashes mid-pipeline.
Net effect: CLI suite went from 622 -> 630 passing tests on Windows
(5 unskipped + 8 new); skipped count dropped from 25 -> 20. All other
suites unchanged.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* ci(windows): add minimal permissions block to CI workflow
CodeQL flagged the workflow as missing an explicit permissions
declaration (security/code-scanning/61). All jobs are read-only
(checkout, install, build, test) — contents: read is sufficient.
Matches the workflow-level pattern already used in coverage.yml.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* chore: add changeset for native Windows support
Minor bump across the linked package group. The next release PR will
consume this and bump from 0.4.0 to 0.5.0.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(cli): make ao open work cross-platform
Mac-only assumptions broke `ao open` on Windows and Linux:
- source of truth was `tmux list-sessions`, which is empty without tmux
- the open action shelled out to `open-iterm-tab`, a macOS helper
Switch the source of truth to `sm.list()` (works on every platform — also
handles `runtime-process` sessions on Windows) and branch the open action:
- macOS: `open-iterm-tab` (unchanged), tmux attach inside iTerm
- Windows: `wt new-tab cmd /k ao session attach <id>` for live sessions,
with `cmd /c start cmd /k ...` as the no-`wt` fallback. Both paths route
through `cmd /k` because `wt` and `start` call CreateProcess directly,
which doesn't honor PATHEXT and reports 0x80070002 for `ao` (really
`ao.cmd`). New tab anchors at `config.projects[id].path` so the spawned
attach can resolve `agent-orchestrator.yaml` via loadConfig's upward
search; without this attach fails with "No agent-orchestrator.yaml found"
when the user's homedir is the inherited cwd.
- Linux: dashboard URL via `openUrl()`. No consistent terminal-spawn API
across DEs, so we don't try.
Other behavior changes:
- read the live daemon's port from `running.json` so URLs stay correct
when the dashboard auto-picked a non-default port
- warn when the daemon is not running (URL fallback won't load)
- aggregate targets (`all`, `<project>`) hide terminated sessions; named
lookup keeps them in scope and opens the dashboard with the death
reason inline (`died at <ts>: session=<reason>, runtime=<reason>`) plus
a `ao session restore <id>` hint
- new `--browser` flag forces the URL path on any platform
Add `isMac()` to `platform.ts` (per the project rule that platform checks
live in one place rather than spread as ad-hoc `process.platform === ...`
guards) and re-export from core.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(cli): satisfy lint on ao open changes
- replace inline `import("node:child_process")` type annotation in vi.mock
with a top-of-file `import type * as ChildProcess` (consistent-type-imports)
- drop the `[]` initializer on `sessionsToOpen` since every branch assigns
before any read (no-useless-assignment)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* chore: add changeset for cross-platform ao open fix
Patch entry for d04fad33 / 32345ba8. Linked group already minor-bumping
via the Windows-support changeset, so this just contributes a distinct
CHANGELOG line.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* chore: fold ao open fix into the Windows-support changeset
Single umbrella entry is the right place for it — the separate patch
changeset was redundant given the linked-group minor bump already in
flight. Reverts 3557e556.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Changes before error encountered
Agent-Logs-Url: https://github.com/ComposioHQ/agent-orchestrator/sessions/9f3caf0b-66fb-4eff-bd3f-7fb9ab889630
Co-authored-by: Priyanchew <57816400+Priyanchew@users.noreply.github.com>
* test(core): mock node:child_process via importOriginal in migration test
The atomic-write.ts → platform.js refactor in eaa27b9b pulled platform.ts
into migration-storage-v2.test.ts's module graph. platform.ts evaluates
promisify(execFile) at top level, but the test's bare-object child_process
mock omitted execFile, so the dynamic import crashed with "No 'execFile'
export is defined on the 'node:child_process' mock".
Switch to vi.doMock with importOriginal so any unmocked exports stay real.
This is robust against future imports adding more child_process surface.
Only Ubuntu CI surfaced the regression — the failing test sits inside
describe.skipIf(process.platform === "win32") so the windows-latest leg
never executed it.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* test(core): hoist child_process type to satisfy consistent-type-imports
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* chore(perf): add AO_PERF-gated instrumentation for dashboard load
Temporary tracing added to diagnose 15-20s dashboard terminal load on Mac
and Windows. Gated on AO_PERF=1 (server) and NEXT_PUBLIC_AO_PERF=1
(client) so production paths stay untouched. To be removed once the
bottleneck is fixed.
Wrap points:
- core/perf.ts: perfMark / perfTime helpers + perfCid
- web /api/sessions/[id]: per-stage timings (getServices, sm.get, audit,
enrichMetadata, total)
- core/session-manager: runtime.isAlive, agent.getActivityState,
agent.getSessionInfo, ensureHandleAndEnrich
- agent-codex: findCodexSessionFile (scanned/opened/matched counts) +
cache hit marker
- runtime-process/pty-client: connect outcome + isAlive (split
connectMs vs statusMs)
- web/lib/serialize: enrich legs (agentSummary vs issueTitle) timed
independently while still running concurrently
- web/sessions/[id]/page.tsx: client.fetch.start/end with cid header
forwarded for end-to-end correlation
- web/MuxProvider: ws.open + ws.firstByte per terminal
* fix(core): drop bogus session.agent reference from perf extras
Session has no `agent` field — typed as a metadata key, not a
top-level property. CI typecheck caught what local rtk-filtered
output had hidden. The session-id cid already disambiguates per-session
so the extra wasn't load-bearing.
* revert: remove AO_PERF instrumentation
Reverts b3f522f9 and 004b2a79. The perf marks pinpointed that the
server API path is fast — total <50ms after warm-up — so the 30s
dashboard-terminal delay lives in the WS / xterm path, not in the
session-manager hot path that this instrumentation covered.
Will re-instrument that layer (mux-websocket terminal-open ->
opened-sent -> firstByte) separately when we resume the investigation.
* fix(core): drop unused isWindows import after post-launch removal
The merge took main's no-op for post-launch prompt delivery, which was
the only user of isWindows() in this file. Removing the dangling import
to unblock lint.
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: yyovil <itsyyovil@gmail.com>
Claude Code's positional [prompt] argument keeps it in interactive mode;
only -p/--print triggers headless one-shot exit. The entire post-launch
polling mechanism was built on the wrong assumption.
Changes:
- Claude Code plugin: pass prompt as positional arg in getLaunchCommand
- Core types: remove promptDelivery field from Agent interface
- Session manager: remove post-launch polling/retry block
- Prompt builder: clarify wording ("title, description, and labels"
instead of "full issue details"), unify # format
- Tracker-github: match prompt wording update
- CLI spawn: remove dead-code promptDelivered warning
Closes#1582
* fix(runtime-tmux): disable tmux status bar at session creation
Closes#1709.
#1683 added `set-option ... status off` to `core/src/tmux.ts::newSession()`,
but no code in the workspace imports or calls that helper — worker sessions
are spawned by the `runtime-tmux` plugin, which was not modified. The green
status bar was therefore still visible at session creation, only being
suppressed once the web layer's WebSocket connection ran its own
`set-option` (with a flash window before that).
Add the same call to runtime-tmux immediately after `tmux new-session` so
the bar is hidden from the moment the session exists, regardless of
whether anyone ever attaches via the web terminal.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(core): remove unused newSession helper
The `newSession` function in `core/src/tmux.ts` and its `newTmuxSession`
re-export in `core/src/index.ts` had zero callers anywhere in the
workspace (verified via grep across packages/, excluding dist and tests).
Worker sessions are spawned by the `runtime-tmux` plugin, which has its
own implementation. The status-bar fix from #1683 lived only in this
helper and was therefore never executed — see #1709 and the prior
commit which moves the fix to the actual spawn path.
Removes:
- `newSession` and `NewSessionOptions` from core/src/tmux.ts
- `newSession as newTmuxSession` re-export from core/src/index.ts
- The corresponding test block in core/src/__tests__/tmux.test.ts
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(core): demote unused GhTraceResult export to internal
`GhTraceResult` was exported from `core/src/gh-trace.ts` but never
re-exported from `core/src/index.ts` and never imported anywhere in
the workspace. Its only consumer is `writeTraceEntry()` inside the
same file, where it's used as a parameter type. Demoted to a private
interface.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(runtime-tmux): kill session if set-option fails
Move the `set-option ... status off` call inside the existing try/catch
so a failure (e.g. the 5-second tmux command timeout firing on a slow
host) triggers `kill-session` cleanup instead of leaving an orphaned
tmux session behind. Renames the surfaced error to
"Failed to configure or launch session" since the try block now covers
both configuration and the launch send-keys.
Adds a regression test that asserts kill-session is called when
set-option throws.
Addresses review feedback on #1711.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Prateek <karnalprateek@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(release): add changesets for 0.5.0 and bump codex version test
- Add changesets for #1643 (orchestrator worktree adoption), #1549
(sidebar empty-state), and #1608 (terminal attach + mux routing).
- Update agent-codex package-version.test.ts expectation from 0.4.0
to 0.5.0 so the test no longer fails after the upcoming version bump.
* chore: release 0.5.0
---------
Co-authored-by: i-trytoohard <193449657+i-trytoohard@users.noreply.github.com>
* fix(core): adopt orphaned orchestrator worktrees (#1641)
* fix: address review feedback on worktree adoption
- Normalize CRLF line endings in parseWorktreeList for cross-platform support
- Collapse duplicate classifySpawnError payload blocks into single condition
- Filter prunable/deleted worktree entries in findManagedWorkspace
- Add GIT_TIMEOUT to git() helper for all execFileAsync calls
- Add tests for prunable entries and CRLF parsing
* fix: update test assertions for git() helper timeout
All git() calls now pass timeout: GIT_TIMEOUT to execFileAsync.
Update toHaveBeenCalledWith assertions to include the new option.
postCreate sh -c calls remain unchanged (direct execFileAsync).
* fix: mock existsSync in findManagedWorkspace tests
The existsSync(entry.path) filter added for prunable worktree detection
needs existsSync to return true for valid worktree paths in adoption tests.
* fix: use mockReturnValueOnce to prevent existsSync mock leaking
vi.clearAllMocks() does not reset mockReturnValue, only mock history.
Using mockReturnValueOnce ensures existsSync stubs don't leak to
subsequent tests and cause clearStaleWorktreePath to consume git mocks.
---------
Co-authored-by: AO Bot <ao-bot@composio.dev>
* refactor(core): extract hasRecentCommits helper into @aoagents/ao-core
Deduplicate the byte-identical hasRecentCommits(workspacePath) helper
that was copy-pasted between agent-aider and agent-cursor. Exposes the
helper from core with a parameterized window so future agent plugins
using git-commit-based activity detection can share it.
Closes#1423
* test(core): make hasRecentCommits custom-window test discriminate on the parameter
The previous assertion passed the default (60) to hasRecentCommits, so a
bug where windowSeconds was silently ignored would still have passed.
Use a commit backdated ~2 minutes and assert both a 30s window excludes
it and a 600s window includes it — proving the parameter is forwarded
to `git log --since=...`.
Addresses Greptile review on #1437.
---------
Co-authored-by: Prateek <karnalprateek@gmail.com>
* feat(core): add PreflightContext + optional preflight() to plugin interfaces
Foundation for PR 2 of the ao spawn refactor: lets plugins own their own
prerequisites instead of the CLI hardcoding 'if runtime === tmux check
tmux' / 'if tracker === github check gh auth' switches.
PreflightContext describes intent (willClaimExistingPR, role) rather
than CLI flag names, so plugins never learn about flags. New flags map
to new intent fields only when a plugin actually needs them.
Adds preflight?(ctx) as an optional method on Runtime, Agent, Workspace,
Tracker, SCM. Backwards-compatible: existing plugins keep working
unchanged. Subsequent commits move checkTmux into runtime-tmux and
checkGhAuth into the github plugins, then update spawn.ts to iterate
selected plugins instead of switching on plugin names.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(plugins): implement preflight() in runtime-tmux + tracker-github + scm-github
Each plugin now owns its own prerequisite checks (tmux binary, gh auth)
behind the optional PluginModule preflight() contract added in the
previous commit. The CLI no longer needs to know which plugin needs
which tool — it just iterates the selected plugins.
- runtime-tmux: checks 'tmux -V' and throws with platform-appropriate
install hint (brew / apt / dnf / WSL)
- tracker-github: checks 'gh --version' and 'gh auth status'
unconditionally (tracker is exercised on every spawn that has an
issueId AND on lifecycle polling for issue closure)
- scm-github: same gh auth checks but only when the spawn will exercise
PR-write paths — gates on context.intent.willClaimExistingPR
Subsequent commit refactors the CLI to iterate plugins instead of
hardcoded 'if runtime === tmux' switches.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* refactor(cli): make ao spawn iterate plugin preflight, collapse project resolution
Three small changes bundled because they all touch spawn.ts:
1. Plugin-iterating preflight: replaces the hardcoded
'if runtime === tmux check tmux' / 'if tracker === github check gh
auth' switches in runSpawnPreflight with a 4-line loop that walks the
selected plugins and calls each one's optional preflight(). Plugin
internals are no longer leaked into the CLI; new plugins only need to
declare their own preflight.
2. Project-resolution collapse: the prefix/no-prefix and issue/no-issue
paths previously had three near-duplicate code blocks each with its
own try/catch around autoDetectProject. Replaced by one
resolveProjectAndIssue() helper that uses resolveSpawnTarget's
fallback parameter — caller wraps in a single try/catch.
3. Micro-deletes: drop the unused 'return session.id' in spawnSession
(callers already ignore it; the SESSION=<id> stdout line is the
scriptable contract). Drop checkTmux/checkGhAuth from lib/preflight.ts
(now in their respective plugins) along with their orphaned tests.
LOC: roughly net-zero. Wins are structural — adding runtime-podman /
tracker-jira / scm-bitbucket no longer requires editing spawn.ts.
Pre-existing start.test.ts 'stop command' failures are unrelated (verified
on upstream/main bare).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* perf(plugins): dedupe gh-auth check across tracker-github + scm-github
Address greptile P2 on PR #1622: when a project has both tracker:
github and scm: github with --claim-pr, both plugin preflights ran
'gh --version' + 'gh auth status' independently — 4 execs where 2
suffice, and two identical error messages on failure.
Add memoizeAsync(key, fn) to core (process-scoped Promise cache) and
have both github plugins share the key 'gh-cli-auth'. Second caller
hits the in-flight (or resolved) promise — zero extra subprocess
overhead, one error on failure.
Caches both successes and rejections: failed checks should never
re-run within a process (cache dies with the CLI, user fixes the
underlying issue and re-invokes).
5 unit tests for memoizeAsync covering: single-fire dedup, value
identity, distinct keys, rejection caching, concurrent in-flight dedup.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* refactor(spawn): collect-all preflight + per-plugin tests + key-namespacing docs
Address self-review feedback on PR #1622:
1. **Collect-all preflight** (spawn.ts): runSpawnPreflight previously
aborted at the first plugin's failure, so a user with multiple broken
prereqs (tmux missing AND gh logged out) had to fix-and-retry to
discover the second one. Now collects every plugin's error and
reports them together ("2 preflight checks failed:\n 1. ...\n
2. ..."). Single-failure path is unchanged — that error throws as-is
without the wrapper. Test added: 'collects every plugin's preflight
failure into one combined error'.
2. **Drop redundant workspace literal fallback** (spawn.ts):
DefaultPluginsSchema in core/config.ts applies .default("worktree")
to workspace, same as runtime/agent. The literal '?? "worktree"'
was asymmetric defensive theater — dropped to match the runtime/agent
form.
3. **memoizeAsync key-namespacing convention** (process-cache.ts):
Added a JSDoc section documenting that two callers using the same
key get shared state (intentional for cross-cutting checks like
gh-cli-auth, dangerous for plugin-internal caching). Recommends
namespacing plugin-internal keys as 'plugin-name:thing'.
4. **Per-plugin preflight unit tests**:
- runtime-tmux: tmux-present resolves; tmux-missing throws with
platform-specific install hint (verified per-platform branch)
- tracker-github: happy path, gh-not-installed, gh-not-authenticated
- scm-github: no-op when willClaimExistingPR=false (zero gh calls),
full check when true, plus install/auth failure branches
Process cache cleared in beforeEach so each test starts fresh.
Required exporting _clearProcessCacheForTests from core/index.ts
(matches existing _testUtils pattern in gh-trace.ts).
Pre-existing start.test.ts 'stop command' failures unchanged
(verified on bare upstream/main).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(test): collapse duplicate @aoagents/ao-core import in tracker-github test
eslint no-duplicate-imports caught it on CI — combined the value and
type-only imports into one statement.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* chore: release 0.4.0
Consume 33 changesets across the linked package group. All public
packages bumped to 0.4.0 and published to npm.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(agent-codex): bump package-version assertion to 0.4.0
Release gate test was still asserting 0.3.0 after the 0.4.0 bump.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: align CHANGELOG headers with @aoagents npm scope
The H1 of every package CHANGELOG.md still read @composio/* from
before the npm scope rename. Body entries that historically reference
@composio/* are left intact — they document what was true at the time
of those releases.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Prateek <karnalprateek@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`toClaudeProjectPath` only normalized `/`, `.`, and `:` — but Claude Code's
on-disk slug also folds underscores (and other non-alphanumerics) to `-`.
AO project data dirs are named `<sanitized>_<hash>` (e.g.
`graph-isomorphism_d185b44d56`), so the slug AO computed pointed at a
directory that never existed. Cascading failures:
- `getSessionInfo` couldn't read the JSONL → `claudeSessionUuid` never
got persisted to session metadata.
- On restore, `getRestoreCommand`'s metadata lookup found nothing AND its
workspace-scan fallback also missed (same bad slug), returning `null`.
- Session-manager's native-restore guard then threw
`SessionNotRestorableError` → API returned 409.
Verified on-disk: every session under projects without underscores has
`claudeSessionUuid` persisted; every session under projects with
underscores does not. The orchestrator angle in #1611 is the loudest
symptom — orchestrators die early so they have nothing else to fall back
on — but the same bug silently broke worker restore in any multi-project
setup.
Fix: replace `[/.]` with `[^a-zA-Z0-9-]` in the slug regex, matching
Claude Code's actual encoding. Adds direct unit tests for
`toClaudeProjectPath` covering the underscore case plus existing paths,
and a regression test in the `getSessionInfo` path-conversion suite.
Fixes#1611.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* 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
* 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>
* 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: 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(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(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>
* 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>
The shared gh wrapper now extracts PR URLs with a regex instead of embedding a literal github URL, so the old codex assertion was stale and broke CI on PR #1300.
Represent missing activity probes as first-class signal states so lifecycle inference only treats valid idle evidence as proof. This prevents false stuck transitions, keeps API/UI lifecycle truth aligned, and makes root monorepo verification deterministic by serializing recursive build and typecheck.