Commit Graph

1250 Commits

Author SHA1 Message Date
i-trytoohard 79716849ff fix(web): move ao-core from transpilePackages to serverExternalPackages
Next.js errors when a package is in both lists:
  'transpilePackages' conflicts with 'serverExternalPackages'

Remove @aoagents/ao-core from transpilePackages and keep it only
in serverExternalPackages. This prevents webpack from bundling
ao-core and baking import.meta.url as the build machine's path.
2026-05-20 07:34:21 +00:00
i-trytoohard eec8ffe2ad fix(web): externalize ao-core and better-sqlite3 from Next.js bundle
Fixes #1930.

Next.js bundles @aoagents/ao-core (via transpilePackages) and bakes
import.meta.url as the build machine's absolute path into the output.
At runtime on any other machine (especially Windows), createRequire()
receives a non-existent file:// URL and fails.

Adding @aoagents/ao-core and better-sqlite3 to serverExternalPackages
tells Next.js to not bundle these — they're resolved at runtime from
node_modules, so import.meta.url resolves to the actual install path.

This fixes:
- events-db.ts: createRequire(import.meta.url)("better-sqlite3")
- update-cache.ts: createRequire(fileURLToPath(import.meta.url))
- The Windows ERR_INVALID_ARG_VALUE error in the user report
2026-05-19 22:47:03 +00:00
i-trytoohard 45de80ce3f
fix(canary): list all 27 linked packages in dummy changeset (#1882)
* fix(canary): list all 27 linked packages in dummy changeset

Only @aoagents/ao was listed, so changeset version --snapshot only
bumped that one package. The other 26 stayed at their stable version
and changeset publish skipped them as already published.

* fix(canary): generate dummy changeset from config.json

Reads linked packages from .changeset/config.json at runtime
instead of hardcoding them. Avoids drift when packages are
added/removed. Also replaces 1000-char printf with readable
node script.

* fix(canary): use heredoc for node script to avoid shell quoting issues

The double-quoted node -e broke on the \" inside the JS string.
Heredoc avoids all shell quoting problems.

* fix(canary): escape newlines in JS heredoc strings

Single-quoted JS strings cannot span real newlines.
Use \n escape sequences instead of literal line breaks.

* fix(canary): outdent heredoc terminator to column 0

YAML `run: |` strips 10-space base indentation, so `SCRIPT`
at 12-space YAML indent lands at column 2 in the shell.
Bash `<<` requires the terminator at column 0.
Move to 10-space indent (= column 0 after YAML strip).
2026-05-20 04:14:44 +05:30
Harshit Singh Bhandari 8c71bdebbd
fix(agent-claude-code): harden activity detection against 5 real-world edge cases (#1932)
* 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).
2026-05-19 22:55:05 +05:30
Harshit Singh Bhandari a610601158
refactor(agent-claude-code): split activity detection; remove dead JSONL cases; detect blocked from api_error (#1927)
* 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.
2026-05-19 18:00:54 +05:30
Adil Shaikh 298057044f
feat(notifier): make notifier system robust with manual harness and desktop setup (#1736)
* fix(notifier-desktop): use terminal-notifier on macOS for click-to-open support

On macOS, when terminal-notifier is installed (brew install terminal-notifier),
desktop notifications now open the dashboard URL when clicked instead of
opening Script Editor. Falls back to osascript when terminal-notifier is
not available.

New config option `dashboardUrl` controls the click-through URL:
  notifiers.desktop.dashboardUrl: "http://localhost:8080"

Refs #1579

* feat(cli): add manual notifier test harness

* feat(cli): add native desktop notifier setup

* fix(cli): handle denied desktop notification permission

* fix(notifier): support composio actions api

* fix(notifier): use composio entity execution

* feat(notifier): add composio setup flows

* Fix notifier setup flows

* feat: add dashboard notifier

* Make notifier payloads semantic v3

* chore: use ao-agent as composio default user

* Improve notifier setup and rich notifications

* Improve desktop notification UX

* Update AO notifier app icon

* Remove redundant dashboard notification actions

* Potential fix for pull request finding 'CodeQL / Client-side cross-site scripting'

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* Fix code scanning notifier alerts

* Address notifier bot review comments

* Address remaining notifier bot reviews

* Update notifier integration assertions after merge

* Fix notifier test fallout after merge

* Address notifier PR review comments

* Fix notifier bot follow-up comments

* Add notification delivery observability

* chore: add notifier logging screenshot

* fix: address notifier logging ci failures

---------

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-05-19 14:48:40 +05:30
Harshit Singh Bhandari 11c07de258
fix(web): bound project page wrapper so dashboard body scrolls (closes #1923) (#1929)
* fix(web): make dashboard body scroll as one unit (closes #1923)

PR #1925 added internal scroll on .done-bar__cards, but the
desired UX is page-level scroll — kanban above, Done/Terminated
below, scrolling as one unit (the pre-#927 behavior). Internal
scroll on the cards container produced a separate scrollable
region inside an otherwise locked layout.

Root cause: af2af115 (#927, Warm Terminal design refresh) added
`flex: 1; min-height: 0` to .kanban-board-wrap. That made the
wrap greedily consume all remaining body height, so body content
always equalled body height exactly — `.dashboard-main__body
{ overflow-y: auto }` never triggered.

Drop .kanban-board-wrap from the combined flex:1 rule. It now
takes its content size (= the inner .kanban-board's fixed
`calc(100vh - 240px)`). When done-bar expands below, body
content exceeds body height and the existing overflow-y: auto
produces natural page scroll.

.board-wrapper (empty state + loading skeleton) keeps flex:1 —
it relies on filling space to vertically center its CTA.

Also revert the now-unneeded .done-bar__cards max-height +
overflow-y + scrollbar styling from #1925.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(web): make <main> a flex column so body's overflow-y actually scrolls

The body-level scroll has been broken since af2af115 (#927). The
JSX is <main className="dashboard-main flex-1 ...">, but neither
.dashboard-main nor any Tailwind class on <main> sets
display: flex. So .dashboard-main__body's `flex: 1` and
`overflow-y: auto` never functioned — body simply took content
height, never had overflow to scroll, and <main>'s overflow:
hidden clipped it.

Pre-regression markup was a <div> with `overflow-y-auto`
directly on it (the dashboard container itself scrolled). The
af2af115 refactor moved overflow to a nested body and assumed
main was a flex column — but never declared it.

Add `flex flex-col` to <main> in both Dashboard.tsx and the
projects/[projectId]/loading.tsx skeleton. Body's flex:1 now
correctly fills <main>'s height, overflow-y: auto activates
when body content exceeds, and the kanban-board-wrap fix from
f7f843cd lets that overflow actually happen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(web): bound project page wrapper + lock kanban/done from flex-shrink

The real clipper was .dashboard-shell--desktop (height 900px,
overflow: hidden), not anything inside Dashboard. The project
page wrapper (<div className="flex-1 min-h-screen ...">) was a
non-flex block with min-height: 100vh, so .dashboard-main--
desktop grew to its content height (~1411px) instead of being
bounded by the shell's 900px. As a result .dashboard-main__body
had overflow-y: auto but also grew to content height (~1308px),
so it never overflowed and no scrollbar appeared anywhere.

Verified in devtools: with this fix .dashboard-main__body
clientHeight is 797 and scrollHeight is 1308 — body becomes
the single vertical scroll container, kanban above and Done
section below, scrolling as one unit.

Changes:

1. /projects/[projectId]/page.tsx wrapper: 'flex-1 min-h-screen'
   -> 'flex min-h-0 min-w-0 flex-1'. Makes the wrapper a
   bounded flex item so .dashboard-main--desktop inherits a
   constrained height from the shell row instead of growing
   past it.

2. globals.css: flex-shrink: 0 on .kanban-board-wrap and
   .done-bar. Prevents body's flex column from crushing the
   kanban to make room for the expanded done section. Both
   children now assert their content size; body content
   exceeds body height; overflow-y: auto activates.

Root cause credit to harshitsinghbhandari — found via devtools
after my three prior attempts (#1925 internal scroll on cards,
plus the two prior commits on this branch) all addressed the
wrong layer of the layout tree.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(web): add flex-col to project page wrapper + regression test

Follow-up to 110a4225 — the wrapper was 'flex min-h-0 min-w-0
flex-1 ...' but missing flex-col. Because Dashboard renders
multiple siblings when inside ProjectLayoutClient (UpdateBanner,
ConnectionBar, mainPanel, bottomSheet), any visible
UpdateBanner becomes a horizontal flex sibling next to the
dashboard shell instead of stacking above it. ConnectionBar is
fixed-positioned, bottomSheet is overlay — but UpdateBanner is
in normal flow and exposes the row-vs-column bug whenever it's
visible.

Add flex-col so siblings stack vertically.

Also add a regression test in page.test.tsx that the Dashboard
parent has the bounded-flex classes (flex, min-h-0, min-w-0,
flex-1) and no longer has min-h-screen, so future refactors
can't silently re-break the dashboard's scroll container.

Credit to harshitsinghbhandari for catching the missing flex-col
and writing the test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(web): wrap loading skeleton in same bounded flex container

Addresses greptile P1 review feedback on PR #1929. The project
page (page.tsx) wraps <Dashboard> in <div className="flex min-h-0
min-w-0 flex-1 flex-col bg-..."> so .dashboard-main--desktop
inherits the project shell's 900px constraint instead of growing
past it. The loading skeleton (loading.tsx) renders
.dashboard-main--desktop directly as the root, landing in the
same layout slot WITHOUT that constraint — so during a slow load
the skeleton would still overflow the shell and reproduce the
original clip behavior.

Wrap the skeleton in the same outer bounded flex container as
page.tsx for layout parity.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 14:38:24 +05:30
Harshit Singh Bhandari ee3fb5d33a
fix(web): make Done/Terminated dashboard section scrollable (#1923) (#1925)
* fix(web): make Done/Terminated dashboard section scrollable (#1923)

The collapsible Done/Terminated cards grid had no max-height or
overflow handling, so it grew unbounded and was clipped by the
ancestor's overflow:hidden once enough terminated sessions
accumulated. The regression came from the Warm Terminal design
refresh (#927) which added `flex: 1` to `.kanban-board-wrap`,
defeating the body-level `overflow-y: auto` that previously
allowed natural page scroll past the done section.

Mirror the existing `.kanban-column-body` pattern by giving the
cards grid its own internal scroll container and matching thin
scrollbar styling. Each scrolling region owns its own scroll;
the Warm Terminal flex layout is preserved.


* style(web): document done-bar__cards max-height magic number

Addresses greptile P2 review feedback on PR #1925. The 320px in
calc(100vh - 320px) is the assumed chrome above the cards
(dashboard header + subhead + body padding + done-bar toggle and
margins). Adding a comment makes the implicit ancestor-height
contract explicit, and points to the kanban-board rule that
uses the same viewport-anchored pattern.
2026-05-19 12:08:35 +05:30
Dhruv Sharma f3e45959e6
Add orchestrator-driven code review board (#1871)
* feat: add orchestrator-driven code review board

* feat: wire review findings back to workers

* feat(web): send review feedback to workers

* fix(core): mark stale review runs outdated

* fix: restore reviewer flow after main merge

* Fix review lock lint failure

* Guard concurrent review executions

---------

Co-authored-by: Madhav Kumar <lakshy1523@gmail.com>
2026-05-19 11:47:57 +05:30
Harshit Singh Bhandari 07c90996d2
fix(web): show Restore button for every exited session, including pr_merged (#1909)
* fix(web): show Restore button for every exited session, including pr_merged

The Restore button was hidden for sessions exited with `pr_merged` reason
(legacy status `cleanup`) on the dashboard kanban and absent altogether
from the session-detail "Terminal ended" panel. The core `isRestorable()`
helper already allowed restoring these sessions; the dashboard helpers
were out of sync, compensating for a non-existent core constraint.

- `isDashboardSessionRestorable` now gates on `NON_RESTORABLE_STATUSES`
  only, matching core's `isRestorable`. Merged-but-running sessions
  (runtime still alive) remain non-restorable.
- `DoneCard` no longer hides Restore for merged sessions.
- `SessionEndedSummary` now exposes a prominent `Restore session` button
  alongside `Open PR` / `Back to dashboard`, so users don't have to find
  the small icon button in the header.

Closes #1907

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(web): add :focus-visible styles to ended-summary action buttons

Address PR #1909 review feedback. The new Restore session button (and
existing Open PR / Back to dashboard pills) lacked a visible focus ring,
making keyboard navigation invisible. Apply the same accent outline used
elsewhere in the dashboard.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 02:50:34 +05:30
Dhruv Sharma 6d48022c87
feat(cli): wire activity events into CLI commands and supervisor lifecycle (#1698)
* 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>
2026-05-18 21:04:25 +05:30
Dhruv Sharma c1e43f61dc
feat(core): round out session-manager activity events (#1657) (#1697)
* feat(core): round out session-manager activity event instrumentation (#1657)

PR #1620 wired activity events for the worker-spawn happy path
(spawn_started/spawned/spawn_failed/killed). This extends the same
pattern across the rest of session-manager's failure surfaces so RCA
can answer questions today's logs can't:

- Did the orchestrator spawn fail? (orchestrator path had no AE
  envelope at all; now wrapped like worker spawn)
- Did the agent's startup prompt deliver, or did all 3 retries fail?
- Did `runtime.destroy` / `workspace.destroy` succeed during kill, or
  silently leak?
- Did `runtime_lost` reconciliation fire for this session?
- Did `ao send` actually deliver?

New event kinds (added to ActivityEventKind union):
  session.kill_started, session.prompt_delivery_failed, session.send_failed,
  session.restore_failed, session.restore_fallback, session.rollback_started,
  session.rollback_step_failed, session.workspace_hooks_failed,
  session.cleanup_error, session.orchestrator_conflict,
  runtime.lost_detected, runtime.lost_persist_failed, runtime.destroy_failed,
  workspace.destroy_failed, agent.opencode_purge_failed,
  tracker.issue_fetch_failed, tracker.generate_prompt_failed,
  metadata.corrupt_detected

Coverage (per #1657 spec):
- All 8 MUST emits implemented and have a regression test in
  session-manager-instrumentation.test.ts
- 13 SHOULD emits implemented (cleanup loop, orchestrator subpaths,
  kill silent catches, send/restore failure paths, etc.)
- 4 COULD emits implemented
- Out-of-scope per-list enrichment paths intentionally NOT instrumented

Invariants preserved (extends #1620's B1-B16):
- B1: state mutation BEFORE event emission (runtime_lost persist
  happens before the AE fires; verified by test)
- B2: never wrap recordActivityEvent in try/catch
- B11: prompt content excluded from prompt_delivery_failed data
  (verified by test that asserts the prompt string is absent)
- B16: failure-only — emit on final retry exhaustion only, not per
  attempt
- B25 (new): cleanup-stack rollbacks emit per-step via the onError
  callback hook, not in aggregate

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: address session activity event review

* fix: address session activity event review

* fix(core): avoid duplicate restore failure events

* chore(ci): retrigger checks

* Fix orchestrator activity event instrumentation

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: whoisasx <adil.business4064@gmail.com>
2026-05-18 19:53:55 +05:30
neversettle 73ffd4ab13
fix(cli): fall back to local config when global config is missing in project supervisor (#1809)
* chore(npm): suppress prebuild-install deprecation warning

The prebuild-install package is no longer maintained, but it's a transitive
dependency of better-sqlite3 (optional) and node-pty (optional). Both packages
continue to use it reliably for downloading prebuilt binaries across platforms.

Suppressing this warning avoids noise in npm install output while we await
upstream changes. See https://github.com/ComposioHQ/agent-orchestrator/issues/1752

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>

* fix(cli): warn users when spawning in permissionless mode

When agentConfig.permissions is unset, the schema defaults to
"permissionless", which passes --dangerously-skip-permissions to Claude
Code. Claude then shows a one-time confirmation prompt inside the tmux
session; if dismissed, the session exits silently with no user-facing
error — making it very hard to debug.

Add a post-spawn warning in ao spawn output explaining the mode and
how to either accept the prompt or switch to interactive mode. Also
improve the config-instruction docs to describe each permissions value
and the silent-exit risk.

Fixes #1754

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>

* fix(cli): direct users to dashboard terminal for permissionless prompt

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>

* removed unwanted changes

* fix(cli): fall back to local config when global config is missing in project supervisor

There are two separate things reading config:
- Dashboard (Next.js web server): loadDashboardConfig() — tries global config, falls back to local.
- Project supervisor (CLI process): loadConfig(getGlobalConfigPath()) only — no fallback.

When a user runs ao start with a local agent-orchestrator.yaml that has never been
registered in ~/.agent-orchestrator/config.yaml, the supervisor silently returned
with 0 lifecycle workers, leaving sessions frozen in their last known state and
PR status never updating.

loadSupervisorConfig() mirrors the dashboard fallback: try global config first,
fall back to loadConfig() (local discovery) on ENOENT. ConfigNotFoundError (no
config anywhere) is handled by isMissingGlobalConfigError so the supervisor still
exits cleanly when AO has never been configured.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(cli): address review feedback and fix failing supervisor tests

Review feedback from greptile:
- Remove unreachable ConfigNotFoundError branch from loadSupervisorConfig's
  catch — loadConfig(globalConfigPath) cannot throw it with a non-null path.
  The error still propagates from the fallback loadConfig() (no args) and is
  caught by isMissingConfigError at the outer scope.
- Rename isMissingGlobalConfigError → isMissingConfigError. After extending it
  to catch ConfigNotFoundError (the "no config anywhere" case), the old name
  was misleading.

Test fix (CI was failing):
- Add ConfigNotFoundError to the vi.mock factory so tests that exercise the
  fallback path compile.
- Add coverage for the new fallback paths:
  - ENOENT on global config → falls back to loadConfig() (local discovery)
  - Non-missing-config errors (e.g. invalid yaml) propagate up
  - No config anywhere → supervisor exits cleanly, no workers attached

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(cli): drop unused config-instruction module

The getConfigInstruction() helper and its config-help subcommand were the
module's only consumers, so remove both. The schema URL referenced via
CONFIG_SCHEMA_URL still lives in core and is reachable from the yaml's
$schema field for editor-side completion.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(cli): tighten loadSupervisorConfig comment

Replace the rationale paragraph with a one-line description of what the
function does. The "why" lives in the PR description and commit history.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(cli): scope loadSupervisorConfig ENOENT guard to the global config path

Review feedback from greptile: the ENOENT catch was broader than the matching
guard in isMissingConfigError. If the global config exists but references a
missing nested file, the supervisor would silently fall back to the local
config instead of surfacing the configuration error.

Mirror the path check from isMissingConfigError so only ENOENT for the global
config path itself triggers the fallback. Add a test covering the nested-file
case.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: restore trailing newline in .npmrc

Review feedback from greptile. The trailing newline was accidentally dropped
by an earlier "removed unwanted changes" commit on this branch and is
unrelated to the supervisor fix.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Revert "chore(cli): drop unused config-instruction module"

This reverts commit cb58e15d02.

* revert the unwanted comments

* Addressing comments to make the resolved path as a fallback for supervisor

* test(cli): assert local fallback configPath propagates distinct from global

Reviewer nit: the ENOENT-fallback test was returning makeConfig with the
default `/tmp/global-config.yaml` path, so the assertion on
ensureLifecycleWorker could pass even if the supervisor accidentally
propagated the global path. Use a distinct local path in the fallback
fixture so the regression coverage is honest.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
2026-05-18 19:19:39 +05:30
Dhruv Sharma be9afb36a4
feat(core): record activity events for config, plugin-registry, and storage migration (#1696)
* feat(core): record activity events for config, plugin-registry, and storage migration

Closes #1658.

Surfaces failures that previously left no trace in `ao events list`:

- Config: project_resolve_failed (degraded project), project_malformed,
  project_invalid, migrated. Local-config errors carry the project's
  projectId so `ao events list --project <id>` finds them.
- Plugin registry: load_failed (built-in and external), validation_failed,
  specifier_failed. External plugin failures are now queryable instead of
  living only in stderr.
- Migration: blocked (active sessions), project_failed (per-project),
  rename_failed (.migrated rename), completed (one summary with totals),
  rollback_skipped (post-migration sessions present).
- Agent report: api.agent_report.transition_rejected and apply_failed for
  observability into rejected reports.

Migration emits at most one event per milestone (per project failure +
single completion summary) so event volume scales with errors, not input
size. Config events redact sensitive keys via the existing sanitizer.

Adds regression tests for all four MUST emits.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(core): avoid duplicate config activity events

* test(core): cover blocked storage migration event

* test(core): satisfy lint in migration event mock

* fix(core): emit migration completed for no-op runs

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: whoisasx <adil.business4064@gmail.com>
2026-05-18 19:16:34 +05:30
Dhruv Sharma 73bed33c2e
feat(web): activity events for webhooks and mux WebSocket (#1656) (#1693)
* feat(web): activity events for webhooks and mux WebSocket (#1656)

Closes #1656 — adds the 10 activity events called out in the issue, covering
webhook ingress (4) and the mux WebSocket terminal server (6). Builds on the
ActivityEvent infrastructure landed in #1620.

Webhook events (api source):
- api.webhook_unverified (warn) — 401 signature verification failure
- api.webhook_rejected   (warn) — 413 payload exceeds maxBodyBytes
- api.webhook_received   (info|warn) — 202 success, with parse/lifecycle error counts
- api.webhook_failed     (error) — 500 outer catch / pipeline crash

Mux WS events (ui source — Node-side server only):
- ui.terminal_connected     — one per mux WS connection
- ui.terminal_disconnected  — one per close
- ui.terminal_heartbeat_lost (warn) — once on 3 missed pongs (was console-only)
- ui.terminal_pty_lost       (warn) — fires only when subscribers are still attached,
  distinguishing "PTY actually died" from "user closed browser"
- ui.terminal_protocol_error (warn) — invalid mux client message
- ui.session_broadcast_failed (warn) — emitted on the healthy→failing transition
  only; re-arms after a successful poll so a long outage yields one event, not 20/min

Invariants honored: no raw payloads or signatures in `data`, no client IPs in
`summary` (kept in `data` only), no per-keystroke / per-pong fan-out — only on
state transitions. `api.webhook_unverified` is the security-audit event; data
captures `slug` and `remoteAddr` but never the failed signature.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(web): harden webhook and PTY activity events

* chore(ci): retrigger checks

* fix(web): record PTY loss across mux exit paths

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: whoisasx <adil.business4064@gmail.com>
2026-05-18 18:54:00 +05:30
Dhruv Sharma ff0c3b741d
feat(web): record activity events for API mutation routes (#1695)
* feat(web): record activity events for API mutation routes

Wire recordActivityEvent calls into all POST/PATCH/DELETE handlers under
packages/web/src/app/api/ so RCA can answer "did the user click X?" beyond
just success/failure status codes. Source: "api" (added in #1620).

Coverage:
- session mutations: spawn, kill, send, message, restore, remap (with
  failure variants for SessionNotRestorable/WorkspaceMissing/etc.)
- orchestrator + PR: orchestrator spawn, PR merge (with rejected/failed)
- project + config: add, update, remove, repair, reload
- issue + verify + labels: issue create, verify, label setup

Sanitization rules preserved:
- never include request/response bodies
- *_message_* events include messageLength only, never the message text
- project_updated records changedKeys, never values (config can carry tokens)

Tests: regression coverage for the 12 MUST emits across two new test
files, plus negative-path sanitization assertions.

Closes #1655

* fix(web): refine api activity failure events

* fix(web): align project activity event tests

* fix(web): avoid orchestrator spawn failure double emit

---------

Co-authored-by: whoisasx <adil.business4064@gmail.com>
2026-05-18 17:28:10 +05:30
Dhruv Sharma fcedb25031
feat(core): activity events for recovery, metadata corruption, agent-report (#1692)
* feat(core): activity events for recovery, metadata corruption, agent-report

Wires activity events into three forensic-critical paths so RCA can
reconstruct what happened after the fact:

1. recovery subsystem
   - recovery.session_failed (MUST) per failed session in runRecovery
   - recovery.action_failed (SHOULD) on recoverSession outer catch
   Adds "recovery" to ActivityEventSource so `ao events list --source
   recovery` reconstructs an `ao recover` invocation timeline.

2. metadata corruption
   - metadata.corrupt_detected (MUST) when mutateMetadata renames a
     corrupt session-metadata file to .corrupt-{ts}. Includes
     data.renamedTo and a 200-char data.contentSample (B11) for forensic
     recovery. Previously only console.warn — silent overwrites had no
     queryable signal.

3. agent-report apply path
   - api.agent_report.transition_rejected (SHOULD)
   - api.agent_report.session_not_found (COULD)

Per B22, recovery events fire per session, not per probe step. Per B11,
metadata.corrupt_detected truncates contentSample to 200 chars (full
file would exceed the 16KB sanitizer cap).

Closes #1660

* fix(core): align forensic activity event metadata

* fix(core): cover single-session recovery failures

* fix(core): improve corrupt metadata event attribution

* fix(cli): expose activity event source filter

---------

Co-authored-by: whoisasx <adil.business4064@gmail.com>
2026-05-18 17:27:36 +05:30
Dhruv Sharma befd910eeb
feat: emit plugin-internal activity events for distinct failure shapes (#1699)
* feat: emit plugin-internal activity events for distinct failure shapes

Plugins (scm/tracker/workspace/notifier) call recordActivityEvent directly
to surface failure shapes the lifecycle layer can't distinguish from
generic "plugin call failed":

- scm-github: scm.gh_unavailable (gh CLI missing, deduped per-process),
  scm.batch_enrich_pr_failed (one PR fails inside an OK batch),
  scm.ci_summary_failclosed (getCIChecks threw, fell closed to "failing")
- scm-gitlab: scm.ci_summary_failclosed, scm.review_fetch_failed
- workspace-worktree: workspace.post_create_failed (which command failed),
  workspace.branch_collision (concurrent session on same branch),
  workspace.destroy_fell_back (rmSync after git failure → stale metadata)
- workspace-clone: workspace.branch_collision, workspace.corrupt_clone_skipped
- tracker-linear: tracker.dep_missing (Composio SDK not installed,
  deduped per-process), tracker.api_timeout (30s abort)
- notifier-openclaw: notifier.auth_failed (401/403 distinct from 5xx),
  notifier.unreachable (ECONNREFUSED)
- notifier-discord: notifier.rate_limited (429 budget exhausted)
- notifier-composio: notifier.dep_missing (deduped per-process)

Adds "tracker", "workspace", "notifier" to ActivityEventSource and the
13 new kinds to ActivityEventKind. Includes regression tests for the 5
MUST emits.

Closes #1659

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: refine plugin activity event classification

* fix(openclaw): retry transient unreachable errors before logging

* Deduplicate corrupt clone activity events

* Deduplicate poll-path activity events

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: whoisasx <adil.business4064@gmail.com>
2026-05-18 17:25:23 +05:30
i-trytoohard 33789445d2
fix: remove 3 orphaned submodule refs leaked by PR #1819 (#1913)
PR #1819 accidentally introduced ao-pr1483, hermes-agent, and libkrunfw
as submodule entries (mode 160000) with no .gitmodules. These are
orphaned refs that serve no purpose and should not be on main.

Co-authored-by: AO Bot <ao-bot@composio.dev>
2026-05-18 04:43:09 +05:30
i-trytoohard d5d0f077ad
fix(cli): rebuild better-sqlite3 on install + quieter ABI-mismatch warning (closes #1822) (#1824)
* fix(cli): rebuild better-sqlite3 when binding is missing

* fix(cli): support Windows postinstall rebuild shims

---------

Co-authored-by: i-trytoohard <193449657+i-trytoohard@users.noreply.github.com>
2026-05-18 04:02:26 +05:30
i-trytoohard 87c6a3d9f4
fix(web): hide unenriched PR diff stats (#1912)
Co-authored-by: i-trytoohard <193449657+i-trytoohard@users.noreply.github.com>
2026-05-18 03:48:28 +05:30
Priyanshu Choudhary eac581768d
fix: recover native session restore fallback (#1797)
* fix: recover native session restore fallback

Persist native agent restore metadata from dead runtimes and fall back to a fresh launch when native restore context is missing, so stuck sessions do not permanently block startup.

* fix: avoid redundant dead-session metadata discovery

* test: harden Linear list issue polling
2026-05-18 01:37:39 +05:30
Pritom Mazumdar 406b26e837
feat(web): sidebar and dashboard header UI/UX polish (#1846)
* perf(web): eliminate sidebar re-renders on every SSE tick

Resolves #1844

The SSE hook delivers a new sessions array reference every 5 seconds
even when content is identical, causing sessionsByProject to recompute
and all session rows to re-render on every tick.

Three fixes in ProjectSidebar.tsx:
* sessionsKey + sessionsRef: replace unstable array reference in memo
  deps with a content-derived string; memo only fires on real changes
* SessionRow memoized component: rows skip re-renders when props are
  unchanged; navigate and startRename stabilised with useCallback
* SessionDot memoized: status indicator skips re-renders when level
  prop is unchanged

A quiet SSE tick now touches zero React components in the sidebar.

* fix(web): add displayName, displayNameUserSet, branch to sessionsKey hash

Without these fields, a session rename delivered via SSE did not
trigger sessionsByProject to recompute. The stale session object
held the old displayName, and once the optimistic pendingRename
was cleared the sidebar silently reverted to the pre-rename title.

Addresses review feedback on PR #1846.

* feat(web): sidebar and dashboard header UI/UX polish

Removes state text labels from sidebar session rows so the colored dot
is the sole status indicator, matching the intended design. Fixes the
sidebar compact header height to align with the 48px main header.
Adds session count summary pills to the dashboard project header.
Converts CopyDebugBundleButton to an icon-only compact form so the
actions row stays vertically centered. Fixes the project page wrapper
missing flex-1 which caused a right-side viewport gap in the horizontal
shell layout.

* fix(web): resolve lint errors from UI/UX polish

Remove LEVEL_LABELS, _isLoading prefix for unused loading var, and dead
title variable from ProjectSidebar. Remove unused isDashboardSessionStatus
and isActivityStateValue from the project session page. Remove
react-hooks/exhaustive-deps eslint-disable comments for a rule not in the
ESLint config. Stabilize startRename via pendingRenamesRef so the callback
does not recreate on every rename state change, preventing unnecessary
SessionRow re-renders. Remove non-null assertion in Dashboard.tsx
handleToggleSidebar with a null guard.

* fix(web): replace native Node 25 localStorage stub with full in-memory mock

Node.js 25 exposes a native localStorage via --localstorage-file that lacks
.clear() and .key(), causing all UpdateBanner tests to throw TypeError.
Replace the global with a complete in-memory implementation so test suites
work across all Node versions.

* fix(web): seed sidebar with all sessions on hard refresh and eliminate per-project layout re-render

- Hoist sidebar layout from projects/[projectId]/layout.tsx to projects/layout.tsx so it renders
  once for the entire /projects/* subtree and never re-mounts when switching between projects
- Pass getDashboardPageData("all") so initial sessions cover every project, not just the primary
- Extract ProjectLayoutClient from the old client layout for clean server/client split
- Simplify per-project empty state to "No active sessions" only, removing the second hint line
- Restore accidentally deleted Dashboard empty-state test for zero-projects install
- Fix Reflect.deleteProperty lint error in localStorage mock; drop unused AttentionLevel import
  and dead effectiveDisplayName/pending variables from ProjectSidebar editing block

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(web): address PR review — orchestrators prop, sessionsKey, badge count, mobile overlay, tests

* fix(web): remove duplicate skeleton sidebar from project loading state

* fix(web): restore orchestrator button and eliminate Session unavailable flash

Re-add the orchestrator icon + menu item that were removed during the merge
conflict resolution. Convert the icon from a <Link> to an <a> that calls
navigate() with the full session object so ProjectSessionPage gets an
instant sessionStorage cache hit instead of starting with session=null
and briefly showing the "Session unavailable" error card.

* fix(web): eliminate Session unavailable flash on orchestrator navigation

React Strict Mode aborts the first fetchSession() during its unmount/remount
cycle. The aborted finally reset fetchingSessionRef but set loading=false,
briefly showing the error card before the retry completed. Fix: keep loading=true
on abort (no session yet), and immediately retry via fetchSession() once the
ref is clear. mountedRef guards the retry so it only fires on Strict Mode
remounts — not on genuine navigation-away unmounts, which would leak requests.

* fix(web): fix working pill count and layout of error states in project session page

- Dashboard topbar "working" pill now counts only actively working sessions,
  not working + pending (which inflated the number incorrectly)
- Error/loading/missing states in ProjectSessionPage wrapped in
  dashboard-main--desktop so they fill the flex shell beside the sidebar
  instead of shrinking to content width

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 23:45:35 +05:30
Harshit Singh Bhandari 94981dc0fd
feat(web,core): "Launch Orchestrator (clean context)" button (#1904)
* feat(web,core): "Launch Orchestrator (clean context)" button

Adds a dashboard action that replaces the project's canonical
orchestrator with a fresh one — killing any existing orchestrator,
deleting its metadata, and spawning a new session with no carryover.

Why: users had no way to start an orchestrator with a clean slate from
the dashboard. Previous orchestrator context (conversation history,
stale state) silently carried over via the existing "Open Orchestrator"
flow, which only worked for first-time spawn anyway.

- core: new SessionManager.relaunchOrchestrator(config) that kills +
  deletes existing metadata then calls spawnOrchestrator. Ignores
  project.orchestratorSessionStrategy — replacement is the whole point.
  Coalesces concurrent calls via a dedicated relaunchOrchestratorPromises
  map (separate from ensureOrchestratorPromises since the semantics
  differ — a relaunch behind an ensure must not return the existing
  session).
- web: POST /api/orchestrators accepts { clean: true } to route to the
  new method. OrchestratorSelector renders a "Launch Orchestrator
  (clean context)" button that uses window.confirm() before discarding
  an existing orchestrator; no confirm when none exists.

Closes #1900, closes #1080.


* fix(core,web): address PR #1904 review

- core: cross-map race between ensureOrchestrator and relaunchOrchestrator.
  Each now awaits the other's in-flight promise (keyed by sessionId) before
  proceeding. Prevents (a) relaunch skipping the kill while ensure's
  spawnOrchestrator is mid-reservation, and (b) ensure returning a session
  that relaunch is about to kill. Adds two race regression tests.

- web: align handleSpawnNew with handleRelaunchClean via the void expression
  form; add "Launching..." in-progress label to the clean-context button and
  a test that asserts it renders during POST.


* refactor(web): rip out Orchestrator Selector page; relocate clean-launch action

There is only ever one orchestrator per project, so the /orchestrators
selector page is meaningless. Delete it along with its component, tests,
and the unused mapSessionsToOrchestrators util. Drop GET /api/orchestrators
(only consumer was the deleted page). Remove /orchestrators from project
revalidate lists.

The "Launch Orchestrator (clean context)" action that previously lived on
the deleted page now appears in two places:

- Dashboard header: a "Relaunch (clean)" button renders alongside the
  Orchestrator link whenever a project orchestrator exists. Uses
  window.confirm before discarding state.
- Orchestrator session page: a "Relaunch (clean)" button in the
  SessionDetailHeader for live orchestrator sessions, calling
  POST /api/orchestrators with clean:true and reloading the session view.

* refactor(web): remove Relaunch (clean) action from the Dashboard

Keep the clean-launch action only on the orchestrator session page —
that's where the user has the context to decide on a destructive
restart. The Dashboard header just links to the orchestrator (or shows
the existing Spawn Orchestrator button when none exists).

* fix(web): surface relaunch failures with an inline error banner

After confirm + POST /api/orchestrators with clean:true, the previous
implementation only logged failures to console.error — leaving the user
on a stale page with no signal that the destructive action partially
executed. relaunchOrchestrator kills before respawning, so a failed
respawn means the server has no orchestrator while the client still
renders the old session view.

Add local relaunchError state, set it on catch (parsed from the JSON
error response when available), and render a dismissible error banner
above the terminal area. The banner explicitly warns the user that the
previous orchestrator may already be terminated and points them at the
project dashboard to retry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(web,core): address PR #1904 review from @i-trytoohard

- web: navigate to the new orchestrator's session path (from POST
  response) instead of window.location.reload(). Orchestrator session
  IDs are fixed per project so the path is the same in practice, but
  reading from the response is the right contract and a hard nav forces
  the terminal WebSocket to reconnect cleanly against the new tmux.

- web: remove the `!terminalEnded` gate on the Relaunch (clean) button
  in SessionDetailHeader. Terminated orchestrators are exactly when the
  user wants to relaunch — hiding the button there was wrong.

- core: log a warning instead of silently swallowing when an in-flight
  cross-map promise (ensure waiting on relaunch, or relaunch waiting on
  ensure) rejects before its caller proceeds. The catch-and-continue
  semantics are correct (the caller will re-check state anyway) but
  invisible failures were a debugging hazard.

Adds a regression test that the button stays visible on terminated
orchestrator sessions and that successful relaunch navigates via
window.location.href.
2026-05-17 21:16:25 +05:30
Harshit Singh Bhandari ce6b47adf8
fix(core): keep actionable activity sticky (#1902)
`waiting_input`/`blocked` no longer decay on wallclock; clears only on process death or newer entry.

Close #1894.
Reference #1899.
2026-05-17 20:03:56 +05:30
Harshit Singh Bhandari cb7b83d223
fix(agent-claude-code): add AO-JSONL safety net cascade (closes #1897) (#1903)
* 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.
2026-05-17 20:03:07 +05:30
Adil Shaikh 667d1dedfc
fix(core): sm.list() no longer writes terminated state to disk (#1737)
* fix(core): sm.list() no longer writes terminated state to disk (#1735)

sm.list() was bypassing the lifecycle manager's probe decision matrix by
persisting terminated state immediately on a single isAlive() failure.
The dashboard's 3s poll via /api/sessions/patches called sm.list() ~10x
more often than the lifecycle manager, so a transient runtime failure
would permanently kill the session before the lifecycle manager could
evaluate all three probes (runtime, process, activity).

Changes:
- sm.list() now persists "detecting" instead of "terminated" when it
  detects a dead runtime, so the lifecycle manager's resolveProbeDecision
  pipeline remains the single authority on terminal decisions.
- /api/sessions/patches now calls listCached() instead of list(),
  preventing the dashboard's 3s poll from probing runtimes directly.
  The cache TTL (35s) aligns with the lifecycle manager's 30s poll.
- Updated CLAUDE.md invariants to reflect the new behavior.

* fix(core): skip re-persisting detecting state on subsequent list() calls

Check the on-disk lifecycle state (raw metadata) instead of the
in-memory state when deciding whether to persist. Enrichment already
sets detecting in-memory, so the previous guard always skipped the
persist block. Using the on-disk state ensures:
- First detection: persists detecting + lastTransitionAt
- Subsequent calls: skips re-write, preserving the original timestamp
2026-05-16 19:05:49 +05:30
i-trytoohard e6ad078d7a
fix(web): kill RSC prefetch storm + dedupe in-flight fetches + retry on transient timeout (closes #1855) (#1856)
* fix: reduce session page fetch starvation

* fix: address fetch dedupe review feedback

* fix: preserve body-read timeouts in client fetch

---------

Co-authored-by: i-trytoohard <193449657+i-trytoohard@users.noreply.github.com>
2026-05-15 12:00:56 +05:30
i-trytoohard 3fb23cfb48
feat(lifecycle): inject failed-job/step + log tail into CI-failure agent messages (closes #1807) (#1810)
* 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>
2026-05-15 05:48:45 +05:30
i-trytoohard d44e708525
fix(core): clear terminatedAt/completedAt when restoring a session out of terminal state (closes #1831) (#1834)
* fix(core): clear terminal markers on non-terminal lifecycle transitions

Clear stale completedAt/terminatedAt whenever lifecycle helpers settle on a non-terminal session state, including restore-to-working paths.

Preserved invariants: terminal-state idempotence remains intact because done/terminated states keep their terminal markers; session state transitions still flow through existing code that updates lastTransitionAt before marker hygiene runs.

* fix(core): share terminal marker hygiene

Move non-terminal marker clearing into lifecycle-state so restore and lifecycle-transition paths use one terminal-state source of truth.

Preserved invariants: terminal-state idempotence still keeps done/terminated markers; existing transition code continues to update lastTransitionAt before marker hygiene runs.

---------

Co-authored-by: i-trytoohard <193449657+i-trytoohard@users.noreply.github.com>
2026-05-15 04:46:35 +05:30
i-trytoohard 6fb18cb47e
fix(web): authoritative session.state for terminated UI rendering (closes #1832) (#1833)
* fix(web): honor lifecycle state for terminal session UI

* fix(web): address terminal state review feedback

* chore: retrigger integration ci

* fix(web): trim terminal state change scope

* fix(web): preserve legacy terminated helper behavior

---------

Co-authored-by: i-trytoohard <193449657+i-trytoohard@users.noreply.github.com>
2026-05-15 04:30:44 +05:30
i-trytoohard 7d324b537d
fix(cli): reap daemon children on stop+SIGINT, sweep orphans on start (closes #1848) (#1849)
* feat(core): add managed daemon child registry

* fix(cli): reap daemon children on stop and shutdown

* test(cli): cover daemon child reaping

* chore: version packages for 0.9.0

* fix(core): avoid regex in orphan process scan

* test(web): expect managed child spawn helper

* fix(core): let daemon shutdown own signal exit

* fix(web): mark shutdown ownership before spawning children

* fix(core): wait for managed children before fallback exit

* docs: document daemon process management architecture

* fix(core): scope daemon child sweeps by owner pid

* docs: link process architecture to PR changes

* docs: clarify legacy messaging watcher status

* chore(core): remove unused messaging watcher orphan pattern

* chore: remove version bump from orphan reaping PR

* docs: remove process management design draft

---------

Co-authored-by: i-trytoohard <193449657+i-trytoohard@users.noreply.github.com>
2026-05-15 03:38:09 +05:30
i-trytoohard 89ad185195
fix(agent-plugins,lifecycle): distinguish indeterminate probe from "not found" + bump ps timeout (closes #1838) (#1839)
* fix(agent-plugins): preserve sessions on indeterminate probes

* fix(core): gate recovery activity on successful process probe

* refactor(core): centralize process probe indeterminate handling

---------

Co-authored-by: i-trytoohard <193449657+i-trytoohard@users.noreply.github.com>
2026-05-14 21:50:39 +05:30
github-actions[bot] ee2f4256f4
chore: version packages (#1812)
* 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>
2026-05-13 03:50:07 +05:30
suraj_markup 15158aa7b2
refactor(release): split publish into two-repo model — GitHub release public, npm publish private (#1815)
* refactor(release): split publish into two-repo model

Org compliance forbids npm publish credentials in public repositories.
Move the npm publish step out of this repo and into a private
ComposioHQ/ao-publisher repo, triggered via repository_dispatch.

Public repo (this one) now only:
- Bumps versions via changesets/action (no `publish:` argument)
- Creates git tags via `pnpm changeset tag`
- Creates the GitHub release (stable or prerelease)
- Dispatches `publish-npm-stable` / `publish-npm-nightly` to ao-publisher

The only secret needed here is PUBLISHER_DISPATCH_TOKEN, a fine-grained
PAT scoped to ao-publisher with `repository_dispatch:write`. NPM_TOKEN
lives in ao-publisher's secret store and never enters this repo.

Removed from both workflows: NODE_AUTH_TOKEN env, NPM_CONFIG_PROVENANCE
env, and `registry-url` on setup-node — none are needed when no npm
publish happens here. Canary also gains a skip-if-unchanged guard so
cron ticks during quiet stretches don't republish identical SHAs.

CONTRIBUTING.md "Release Setup" → "Release Architecture": documents the
two-repo split, secret layout, rotation procedure, and the stable vs.
nightly flows.

Requires PUBLISHER_DISPATCH_TOKEN secret + ao-publisher repo setup by
maintainers (out of scope for this PR — see PR description).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(release): address Greptile review findings on two-repo split

Three review findings on PR #1815:

1. (P1) canary.yml missing git commit before tagging
   `pnpm changeset version --snapshot` modifies package.json but does
   not commit. `pnpm changeset tag` would then tag the pre-snapshot
   HEAD, so ao-publisher would check out un-bumped versions.

   Add a "Commit snapshot version bumps" step with `git diff --cached
   --quiet || git commit` (defensive: skip if nothing to commit) and
   the `[skip ci]` marker. The commit is never pushed to main — only
   the tags are pushed and the orphan commit travels with them.

2. (P2) release.yml `--target main` race condition
   If a commit lands on main between `git push --follow-tags` and
   `gh release create --target main`, the release commitish drifts
   and auto-generated notes pull in unrelated commits.

   Create an explicit `vX.Y.Z` git tag pointing at the version-bump
   commit, push it, and drop `--target`. `gh release create` then
   resolves the commitish from the existing tag — no race window.

3. (P2) canary.yml skip-guard suppresses post-stable nightlies
   `gh release list --limit 1` returns the most recent release by
   date, which could be a stable from `release.yml`. An explicit
   `workflow_dispatch` nightly right after a stable cut would be
   suppressed.

   Filter to prereleases only:
     gh release list --json tagName,isPrerelease \
       --jq '[.[] | select(.isPrerelease)][0].tagName // empty'

   If no prerelease exists yet, the jq returns empty and the guard
   falls through naturally (LAST_SHA empty → condition false →
   nightly proceeds).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ci: retrigger checks (event dropped on previous push)

* fix(release): make release.yml idempotent and dispatch reachable on re-run

The previous design used a single `released` output (based on `after >
before` tag count) to gate both `Create GitHub release` and
`Dispatch npm publish`. On a re-run, all tags are already on the
remote and `fetch-depth: 0` brings them down, so `pnpm changeset tag`
adds nothing, `after == before`, `released=false`, and both steps are
skipped — breaking the "re-run recovers cleanly" claim, especially
the common case where the first run failed only at dispatch because
`PUBLISHER_DISPATCH_TOKEN` was missing.

Refactor into a `Determine release state` step that emits three
independent signals:

- `is_release_commit` — version-bump signal. Detected by comparing
  `packages/ao/package.json` version against its value in HEAD^.
  A Version Packages merge changes this; a regular commit does not.
  This is the filter that prevents the dispatch from firing on every
  commit to main (which all have `hasChangesets == 'false'`).
- `tag_on_remote` — whether the `vX.Y.Z` tag exists at origin.
- `release_exists` — whether the matching GitHub release exists.

Each downstream step is gated on its own piece of state:

- Tag push: `is_release_commit && !tag_on_remote`
- Release create: `is_release_commit && !release_exists`
- Dispatch: `is_release_commit` (always fires on a release commit)

The dispatch fires unconditionally on a release commit, even when
tag and release already exist on the remote. That guarantees recovery
from the most common failure mode (first run succeeded everywhere
except dispatch). The publisher must be idempotent against already-
published versions for this to be safe — `pnpm changeset publish`
already has this property since it skips packages whose current
version is already on the registry.

Update CONTRIBUTING.md → "Release Architecture":
- Add "Idempotency contract for ao-publisher" section spelling out the
  no-op-on-already-published requirement.
- Add "Recovery" section explaining that re-running the failed
  workflow is the canonical recovery path, plus a manual `gh api`
  fallback for cases where re-running isn't practical.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(canary): skip-guard anchors on tag parent, not the orphan snapshot

The previous P1 fix added a "Commit snapshot version bumps" step before
`pnpm changeset tag`, which moved the snapshot tag onto an orphan
commit (the version-bump commit) rather than main HEAD. The
skip-if-unchanged guard still compared `git rev-list -n 1 "$LAST_TAG"`
against `GITHUB_SHA`, but `LAST_TAG` now resolves to the orphan
snapshot commit's SHA, never the main SHA. The two could never be
equal → `skip=true` was unreachable → every cron tick republished
regardless of whether main had advanced.

Use `${LAST_TAG}^` to anchor on the snapshot commit's first parent —
which is the main HEAD at the time the previous nightly ran — and
compare that against the current `GITHUB_SHA`. Now the guard fires
correctly when main hasn't advanced since the last nightly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(release): single umbrella tag per release, drop per-package tags

`pnpm changeset tag` creates one tag per publishable package (~27 here)
on every release. At 5 nightlies/week × 52 weeks × 27 packages that's
roughly 7 000 tags/year just from canary — pure decoration since
`ao-publisher` only consumes the umbrella `vX.Y.Z` tag. The per-
package tags also cause partial-recovery conflicts: `git push --tags`
on a re-run trips over tags that were pushed by the prior run.

Drop the `pnpm changeset tag` call from both workflows and replace
`git push origin --tags` with `git push origin "v$version"`. Push
exactly one umbrella tag per release.

CONTRIBUTING.md → "Release Architecture" updated:
- Flow diagram replaces "changeset tag → push" with "push vX.Y.Z tag"
- New paragraph spells out the single-tag-per-release policy and why
  we skip `pnpm changeset tag`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(canary): workflow_dispatch bypasses skip-guard for recovery

When a nightly pushes the tag but fails at `gh release create` or
dispatch (e.g. `PUBLISHER_DISPATCH_TOKEN` missing), a re-run via
`workflow_dispatch` was silently skipped: `LAST_TAG` resolves to the
just-pushed nightly, `${LAST_TAG}^` equals `GITHUB_SHA`, and the
binary skip guard fires before any of the downstream steps we want
to retry.

`release.yml` handles the equivalent case by gating each step on
independent state. Canary has a single binary gate, so it needs a
different escape hatch: `workflow_dispatch` always proceeds. The
human trigger is itself the signal that we want to run regardless
of whether the source SHA looks unchanged. Cron-driven runs keep
the SHA-equality dedup so quiet stretches don't republish the same
SHA on every tick.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(release): replace ao-publisher dispatch with AO cron poll model

Remove the ao-publisher dispatch steps and PUBLISHER_DISPATCH_TOKEN from
both release.yml and canary.yml. npm publishing is now handled by an AO
cron job on a private server that polls GitHub releases and publishes
when a new tag is ahead of the current npm version.

Changes:
- release.yml: remove dispatch step, keep tag + GitHub release
- canary.yml: remove dispatch step, keep tag + GitHub prerelease
- CONTRIBUTING.md: rewrite Release Architecture for two-stage model
  (public CI → GitHub release, private cron → npm publish)

* fix(release): address review — remove env/id-token, scope git add, fix wording

- Remove environment: release from both workflows (no npm publish here,
  could block on required reviewers)
- Remove id-token: write permission (unused, no OIDC needed)
- Scope git add in canary to package.json and .changeset/ only (avoid
  staging build artifacts)
- Fix 'orphan commit' wording → 'snapshot commit' (not actually orphan)
- Fix nightly version format 0.0.0-* → X.Y.Z-nightly-<sha>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: i-trytoohard[bot] <1484917231245856808@users.noreply.github.com>
2026-05-13 03:29:04 +05:30
i-trytoohard d95c9def10
fix(cli): first-run startup creates global config with project registered (#1766) (#1819)
* fix(cli): first-run startup creates global config with project registered

Regression from #1781: persistUpdateChannel() created an empty global config
({ projects: {} }) before autoCreateConfig() registered the project. Dashboard
loaded this empty config - zero projects - session not found.

Two-part fix:
1. autoCreateConfig() now calls registerProjectInGlobalConfig() after creating
   the local yaml, so the global config is bootstrapped with the project
   before maybePromptForUpdateChannel() or startDashboard() run.
2. persistUpdateChannel() and maybePromptForUpdateChannel() early-return when
   no global config exists, preventing empty-husk creation.

Fixes #1766

* fix(cli): fix type error in persistUpdateChannel and update JSDoc

---------

Co-authored-by: AO Bot <ao-bot@composio.dev>
2026-05-13 03:28:50 +05:30
suraj_markup 7c46dc92a4
feat(release): weekly release train — channels, onboarding, dashboard banner, cron (#1781)
* 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>
2026-05-12 23:11:09 +05:30
i-trytoohard 0133bcdc88
chore: add bug-triage skill for agent-driven issue triage (#1725)
* chore: add bug-triage skill for agent-driven issue triage

Add .skills/bug-triage/ with SKILL.md and push_fix_to_github.py script.

The skill provides a complete triage workflow:
- Gather bug context from chat/issues/live observation
- Search for duplicate GitHub issues
- File well-structured issues with root cause analysis
- Push fix PRs via GitHub API (no local checkout needed)
- Git archaeology (git log -S) for regression tracking
- NPM package regression diffing
- Remote code inspection without local clone

Reference the skill in AGENTS.md so any agent working on this repo
can discover and follow the triage workflow automatically.

Tested across 100+ real bug triages on ComposioHQ/agent-orchestrator.

* chore: add clickable issue/PR links as real-world examples

Add concrete examples with links to actual issues and PRs:
- #1129: deep diagnosis vs surface-level triage
- #1151: placeholder URL RCA
- #1391: CSS regression via git log -S archaeology
- PR #1523: optional TypeScript interface fields
- PR #1608: npm package regression diffing

* chore: add formatting rule — always linkify issue/PR references

Any agent following this skill must include clickable URLs when
mentioning issues or PRs. Bare '#123' without links is not allowed.

* fix: address review feedback — use existing skills/ dir, fix bugs

- Move .skills/ → skills/ (repo already has a skills/ directory)
- Fix label name: 'priority:medium' → 'priority: medium' (with space)
- Fix issue-assets branch naming: use slug instead of issue number
  (issue doesn't exist yet at upload time)
- Fix base64 command: portable across Linux and macOS (tr -d '\n')
- push_fix_to_github.py: allow empty NEW_STRING for deletion edits
- push_fix_to_github.py: warn on multiple OLD_STRING matches
- push_fix_to_github.py: create branch before fetching file (SHA race)
- push_fix_to_github.py: configurable BASE_BRANCH (not hardcoded main)
- Update all path references in AGENTS.md and SKILL.md

* fix: correct stale .skills/ path in AGENTS.md How to load section

* feat: add cross-platform triage awareness (Windows/macOS/Linux)

Add Step 1b covering:
- When to ask for OS/shell/runtime/reproducibility
- Common Windows-specific bug patterns (paths, shell syntax, ConPTY,
  named pipes, NTFS case-insensitivity, localhost IPv6 stalls)
- Key cross-platform files (platform.ts, CROSS_PLATFORM.md, etc.)
- Tagging OS-specific issues with 'to-reproduce'

Based on the actual Windows support implementation in #1025
(platform.ts, runtime-process, CROSS_PLATFORM.md).

* feat: add 6 triage improvements from real failure patterns

1. Environment Info Collection (Step 1):
   - Standard template: OS, shell, runtime, AO version, Node version, install method
   - Prevents wasted time tracing wrong code versions

2. Duplicate Search Strategy (Step 2):
   - Search by symptom, component, AND error message
   - Always search --state all (open + closed — bugs regress)
   - Check PRs too (fixes sometimes land without issues)

3. Stop-and-Ask Triggers (Step 1c):
   - Explicit criteria: 3 failed hypotheses, can't reproduce, upstream bug,
     UI-only bug without screenshot, unknown environment
   - Includes template for asking the reporter

4. Pre-Submission Checklist (Step 4.1b):
   - Reporter attribution, commit hash, AO version, confidence score,
     cross-links, concrete reproduction steps, screenshots ready
   - Verify all before creating the issue

5. Confidence Scoring (Step 4.4):
   - High/Medium/Low with clear criteria
   - Maps to labels: bug only / to-explore / to-reproduce
   - Example from PR #1608 where high confidence was wrong

6. Cross-Linking Related Issues (Step 4.5):
   - Search by subsystem after filing
   - Include Related section with one-line descriptions
   - Helps maintainers see patterns across issues

7. Subsystem-Specific Triage Quick Reference:
   - Table mapping subsystems to required info and key files
   - Common misrouting patterns (terminal, stuck session, config)

* feat: add report gate, local diagnostics with ao events, deduplicate

New sections:
- Step 0a: Platform-specific context gathering (Discord/Slack/GitHub/live)
- Step 0b: Minimum Viable Report Gate — required fields (what/where/when)
  plus 2-of-4 supporting (OS, version, reproducibility, steps)
- Step 0c: Local Diagnostics — auto-gather environment, process health,
  AO event log (ao events list/search/stats), session state files,
  reproducibility testing. Covers ao events commands with all flags.

Deduplication:
- Step 1b: removed repeated OS/shell/runtime questions (now in Step 1.2)
- Step 1c: removed 'environment unknown' and 'can't reproduce' triggers
  (covered by report gate in Step 0b)

* refactor: compress bug-triage skill from 575→311 lines

Merge overlapping sections (Steps 0/0b/0c/1 → single Gather+Investigate flow),
move reference material to Appendix, deduplicate pitfalls, compress code blocks.
All factual content preserved: commands, file paths, labels, examples, links.

* docs: add skills/ README with agent-specific install instructions

Covers Claude Code, Codex CLI, Cursor, Windsurf, Copilot, Gemini CLI,
and Agent Orchestrator. Includes available skills table and how to write
new skills.

* docs: add Skills section to CLAUDE.md referencing skills/ directory

Links all 4 skills with when-to-load guidance, plus pointer to
skills/README.md for installing into other agents.

* fix: resolve PR review comments — remove Hermes-specific refs, portable base64

- Replace execute_code references with agent-agnostic 'Python script' wording
- Replace base64 -d (Linux-only) with python3 -c (portable across macOS/Linux/Windows)

---------

Co-authored-by: AO Bot <ao-bot@composio.dev>
2026-05-12 20:51:00 +05:30
Syed Laraib Ahmed 334611b375
feat(web): add PWA manifest and wire mobile accordion on dashboard (#1476)
* feat(web): add PWA manifest and wire mobile accordion on dashboard

- Add manifest.json with standalone display mode and theme colors
- Link manifest in layout.tsx generateMetadata
- Pass compactMobile, collapsed, onToggle to AttentionZone from Dashboard
  so the mobile accordion (already implemented) is now activated

Closes #175

* fix(web): add missing PWA icons and split icon purposes

- Add icon-192.png and icon-512.png to public/ (were 404ing on PWA install)
- Split combined 'any maskable' into separate icon entries per W3C
  best practice to avoid safe-zone cropping in non-maskable contexts
- Apply Prettier formatting to Dashboard.tsx (long SVG attribute lines)

* fix(web): resolve merge conflict, fix types, use dynamic icon routes

- Resolve Dashboard.tsx merge conflict with upstream refactored useSessionEvents
- Type handleZoneToggle as (level: AttentionLevel) per reviewer request
- Start all zones collapsed on mobile for better UX (collapsedZones init)
- Add scope to manifest.json per PWA best practice
- Switch manifest icons from static PNGs to dynamic /icon-192 and /icon-512
  routes that use the existing renderIconElement system (branded icons)
- Remove static icon-192.png and icon-512.png black square placeholders

* fix(web): wire BottomSheet preview, fix mobile tests, update manifest

- Add onPreview to AttentionZone, opens BottomSheet on mobile tap
- Add previewSession state and bottom sheet handlers to Dashboard
- Default collapsedZones to done+working only, not all zones
- Fix isMergeReady to use server attentionLevels instead of client recompute
- Restore isMerged guard on DoneCard restore button
- Update manifest.ts with scope, orientation, maskable icon entries
- Update manifest.test.ts to expect new fields
- Update Dashboard.mobile.test.tsx for MobileSessionRow compact row structure
- Revert inverted Dashboard.doneBar.test.tsx assertion

* fix(web): add display:flex to kanban-board mobile override
2026-05-11 14:04:43 +05:30
Ashish Huddar 23ac3a23ae
fix: make update checks install-method aware (#1595)
* fix: scope update checks by install method (#1592)

* fix: address install-aware update review comments (#1592)
2026-05-10 22:20:59 +05:30
Harshit Singh Bhandari fe33bb7330
feat: enable worker→orchestrator dialogue via `ao send` with auto-sender prefix (#1787)
* feat: enable workers to message orchestrator via AO_ORCHESTRATOR_SESSION_ID

Worker sessions can already be messaged by the orchestrator via `ao send`,
but the reverse direction was undiscoverable: workers had no way to learn
their orchestrator's session ID. The transport already exists (`ao send`
routes to any session in the project, and the orchestrator's ID is
deterministic at `${sessionPrefix}-orchestrator`) — only the discoverability
piece was missing.

Changes:
- Add `orchestratorSessionId?: SessionId` to `AgentLaunchConfig`.
- Populate it in spawnWorker and the worker restore path; deliberately
  omit it for spawnOrchestrator (an orchestrator is not its own parent).
- Each agent plugin (claude-code, codex, opencode, aider, cursor, kimicode)
  now injects `AO_ORCHESTRATOR_SESSION_ID` into the agent env when the
  field is present.
- Worker prompt preamble teaches the new channel with two restraints:
  (1) only ping when genuinely blocked, (2) always prefix with
  `[from $AO_SESSION_ID]` because the orchestrator receives raw input
  with no `from:` metadata.

No new CLI verb, no new file format, no new transport — just env wiring
plus prompt copy.

Closes #1786

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: only set AO_ORCHESTRATOR_SESSION_ID when orchestrator metadata exists

Greptile review on #1787: spawn() unconditionally injected the env var
for every worker, including ad-hoc `ao spawn` workers in projects that
never had an orchestrator running. The AgentLaunchConfig JSDoc claimed
the field was unset for ad-hoc sessions, but the implementation didn't
honor it — so a worker following the prompt's `ao send
$AO_ORCHESTRATOR_SESSION_ID …` instruction would get an opaque
"session does not exist" error.

Both spawn() and restore() now check `readMetadataRaw(sessionsDir,
"<prefix>-orchestrator")` and only propagate the field when the
orchestrator's metadata is actually on disk. Existence-on-disk is the
right signal: if metadata was ever written for the canonical
orchestrator ID, the orchestrator workflow is in play for this project.

Tests updated:
- spawn: split into two cases — "passes when orchestrator exists" (now
  spawns the orchestrator first) and "omits for ad-hoc workers".
- restore: added a third case for ad-hoc worker restore (no orchestrator
  metadata) alongside the existing worker-with-orchestrator and
  orchestrator-restore cases.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: document orchestrator-send command for POSIX, PowerShell, and cmd.exe

Greptile review on #1787: the prompt's `ao send $AO_ORCHESTRATOR_SESSION_ID
"[from $AO_SESSION_ID] …"` example is bash-only. On Windows PowerShell
(the default shell), bare `$AO_ORCHESTRATOR_SESSION_ID` resolves to
$null, so the command silently sends to an empty session ID instead of
the orchestrator. CROSS_PLATFORM.md flags exactly this footgun.

Both prompt variants now show the correct form for the three shells AO
supports as a first-class platform: POSIX bash/zsh, PowerShell
(`$env:NAME`), and cmd.exe (`%NAME%`). The agent picks the form for its
shell. Quotes added around the POSIX expansion as a defensive measure
in case the env var ever expands to whitespace.

prompt-builder tests now assert all three syntaxes appear in both the
full and no-repo prompts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor: literal-render orchestrator ID in prompt; auto-prefix [from <id>] in ao send

Two simplifications collapsed into the same feature now that the
worker→orchestrator dialogue lives in the prompt + send.ts only:

1. **Drop AO_ORCHESTRATOR_SESSION_ID env var entirely.** The orchestrator
   session ID is deterministic (`${sessionPrefix}-orchestrator`) and known
   at prompt-build time, so prompt-builder just renders it literally:

       ao send my-orchestrator "<your message>"

   No env var, no AgentLaunchConfig field, no per-plugin wiring, no
   PowerShell/cmd.exe/POSIX shell-syntax variants. The orchestrator
   existence check moves into session-manager's buildPrompt call site —
   one place instead of duplicated across env injection + prompt mention.

2. **Auto-prefix `[from $AO_SESSION_ID]` in `ao send` itself.** The prompt
   no longer teaches the agent to self-identify because that's
   infrastructure's job. send.ts wraps the message when AO_SESSION_ID is
   set, which covers all session→session traffic (worker→orchestrator,
   orchestrator→worker, worker→worker). Humans running ao send from
   their own terminal stay unprefixed.

Net: 21 files changed, +174 / −249. Zero plugin code touched. No
cross-platform shell footgun.

Files:
- types.ts: drop orchestratorSessionId field
- session-manager.ts: drop spawn/restore field injection; pass
  orchestratorSessionId into buildPrompt instead, gated on metadata
  existence on disk
- 6 agent plugins: drop AO_ORCHESTRATOR_SESSION_ID env injection + tests
- prompt-builder.ts: add orchestratorSessionId to PromptBuildConfig;
  conditionally emit "Talking to the Orchestrator" section with literal
  ID; remove the section from the static base prompts
- send.ts: auto-prefix when AO_SESSION_ID is set
- send.test.ts: 3 new tests for prefix-set / prefix-unset / SessionManager
  delivery; existing tests preserved by clearing AO_SESSION_ID in beforeEach
- changeset: scope drops to ao-core + ao-cli only

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:50:43 +05:30
Harshit Singh Bhandari 71326bc87e
feat(web): allow renaming worker sessions in the sidebar (#1748)
* feat(web): allow renaming worker sessions in the sidebar

Closes #1647

Adds an inline rename UX to each worker session row in the sidebar. A
small pencil button appears on row hover; clicking it swaps the label
for an input pre-filled with the current title. Enter persists via
PATCH /api/sessions/:id, Escape cancels, and an empty value clears the
field — reverting the session to its default title.

The rename writes to the existing displayName metadata field, which is
now the highest-priority signal in getSessionTitle so a user-chosen
label always beats PR/issue titles. The session ID (ao-N) remains
canonical — only display surfaces are affected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(web): gate displayName promotion on user-set flag

PR review flagged that promoting `displayName` to the top of
`getSessionTitle` regressed every existing session: spawn-time
auto-derived `displayName` would shadow live PR/issue titles for
sessions the user never explicitly renamed.

Adds a `displayNameUserSet` boolean flag to SessionMetadata and
DashboardSession. The dashboard fallback chain promotes `displayName`
above PR/issue titles only when this flag is true; auto-derived
spawn-time values stay at their original position (below PR/issue,
above userPrompt).

PATCH /api/sessions/:id sets `displayNameUserSet=true` when the user
types a name, and clears it when they revert. Sidebar gates its
displayName preference on the flag too, so non-renamed rows keep the
existing branch-first behavior.

Also addresses review #3 (rename-while-pending pre-fill) and #4
(double-submit guard on Enter+blur).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(web): address review feedback on session rename PR

- ProjectSidebar: gate effective displayName on displayNameUserSet so
  auto-derived spawn-time names no longer shadow live PR/issue titles
  in the sidebar (mirrors the gate already in format.ts:getSessionTitle).
  Adds a regression test.
- ProjectSidebar: drop unreachable `?? currentTitle` from startRename
  initial value — the right side of the nullish-coalescing always returns
  a string, so the fallback is already handled by the `|| currentTitle`
  on the next line.
- ProjectSidebar: reveal rename pencil on `group-focus-within` so keyboard
  users tabbing through the session links discover the affordance, not
  just pointer users.
- globals.css: change rename button + input border-radius from 3px to 0
  to match the repo's --radius-base: 0 design rule for UI controls.
- core/metadata: accept legacy "on"/"off" strings for displayNameUserSet
  in readMetadata for parity with prAutoDetect (defensive — the storage
  write path already converts to boolean via unflattenFromStringRecord).
  Adds coverage for all six accepted forms.
- web/serialize: drop dead `=== "on"` check on displayNameUserSet —
  Session.metadata is Record<string, string> and the value can only ever
  be "true" / "false" after flattenToStringRecord.

Refs #1647.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 19:41:48 +05:30
Harshit Singh Bhandari 845fffdfd9
fix(runtime-tmux,web): keep tmux session alive after agent exit (#1758)
* 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>
2026-05-10 19:41:03 +05:30
Harshit Singh Bhandari a33b2ba0ef
fix(workspace-worktree): restore re-attaches existing branch instead of recreating with -b (#1742)
* 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>
2026-05-09 22:07:32 +05:30
Priyanshu Choudhary 13c5a50d02
ci: drop windows-latest from typecheck matrix (#1747)
* ci: drop windows-latest from typecheck matrix

tsc is OS-agnostic; the windows-latest run duplicates the ubuntu pass
and adds ~5 min of CI time per push. Real Windows regressions surface
in the unit/web test matrix, which still runs on both OSes.

* ci: collapse typecheck matrix to a plain ubuntu-latest job

With windows-latest dropped, the single-OS matrix was just indirection
and produced a noisy 'Typecheck (ubuntu-latest)' job name. Use a direct
runs-on instead.
2026-05-09 01:09:26 +05:30
Priyanshu Choudhary 0f5ae0b01d
feat(windows): complete Windows support (#1025)
* 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>
2026-05-09 00:10:53 +05:30
Atul Pandey be69a580fb
fix: reset stale worktree session branches (#1650) (#1652)
Co-authored-by: Dhruv Sharma <dhruvcoding67@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 21:56:37 +05:30
i-trytoohard 9bfd7656bb
fix(cli): refuse to spawn when daemon is not polling the project (#1460)
* fix(cli): refuse to spawn when daemon is not polling the project

`ao spawn` and `ao batch-spawn` used to print a stderr warning and then
create the session anyway when the running AO daemon did not include the
target project in its polling set (or when no daemon was running at all).
The resulting sessions got full worktrees and tmux panes but no
lifecycle reactions — CI-failure routing, review comments, revive
transitions, and the event log were silently dead.

Promote the warning to a hard error so sessions are never created in a
state where the lifecycle manager won't run for them. The error message
tells the user which `ao start` invocation will fix it.

Closes #1455

* test(cli): cover batch-spawn daemon-polling enforcement

`spawn` and `batch-spawn` share the `ensureAOPollingProject` helper, but
only `spawn` had tests for the new fail-fast behavior. Add matching
tests for `batch-spawn` so a future refactor that breaks its guard is
caught.

---------

Co-authored-by: Prateek <karnalprateek@gmail.com>
2026-05-08 18:13:42 +05:30
Adil Shaikh 1981d471ca
fix(core): deliver prompt inline via positional arg, remove post-launch polling (#1583)
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
2026-05-08 17:44:10 +05:30
i-trytoohard b9b20e19d1
chore: release 0.6.0 (#1723)
* chore: release 0.6.0

* test(agent-codex): bump version pin to 0.6.0

---------

Co-authored-by: Prateek <karnalprateek@gmail.com>
2026-05-08 02:37:44 +05:30