Commit Graph

57 Commits

Author SHA1 Message Date
suraj_markup bf892ca03f
fix(canary): include all publishable packages (#1997)
* fix(canary): include all publishable packages, not just linked group

* fix(canary): avoid glob dependency in fallback changeset

* fix(canary): harden publishable package scan

---------

Co-authored-by: suraj-markup <suraj@composio.dev>
2026-05-22 05:30:09 +05:30
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
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
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
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
Harshit Singh Bhandari 36fed87b2e
refactor(core): storage redesign — projectId-based paths, JSON metadata (#1466)
* refactor(core): switch metadata format from key=value to JSON and add V2 path functions

Phase 1-2 of the storage redesign: adds new projectId-based path functions
(getProjectDir, getProjectSessionsDir, etc.) alongside deprecated storageKey-based
ones, and switches metadata serialization from key=value flat files to JSON with
.json extension. Structured fields (runtimeHandle, statePayload) are stored as
proper JSON objects instead of stringified strings within key=value.

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

* refactor: wire V2 projectId-based paths and remove storageKey system

Switch all consumers from hash-based storage paths to projectId-based
paths (Phase 4) and completely remove the storageKey system (Phase 5).

Phase 4 — V2 path wiring:
- session-manager.ts: all 9 getProjectSessionsDir() calls use projectId
- lifecycle-manager.ts, recovery/scanner.ts, recovery/actions.ts: V2 paths
- portfolio-session-service.ts: JSON metadata + projectId-based paths
- web routes (sessions/[id], projects/[id]): V2 paths
- cli report command: V2 paths
- All test files updated with HOME isolation for parallel safety

Phase 5 — storageKey removal:
- Types: removed storageKey from ProjectConfig, PortfolioProject,
  DegradedProjectEntry
- Schemas: removed from ProjectConfigSchema, GlobalProjectEntrySchema
- Removed: StorageKeyCollisionError, deriveProjectStorageIdentity,
  ensureProjectStorageIdentity, findStorageKeyOwner, relinkProject,
  relinkProjectInGlobalConfig, applyWrappedLocalStorageKeys,
  moveStorageDirectory, countSessionEntries
- CLI: removed `project relink` command
- Web: removed storageKey from settings UI, simplified collision handling
- Simplified registerProjectInGlobalConfig and resolveProjectIdentity

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

* refactor(core): restructure SessionMetadata types for storage redesign Phase 3

Complete the typed field restructuring on SessionMetadata:
- statePayload/stateVersion → lifecycle?: CanonicalSessionLifecycle
- runtimeHandle: string → RuntimeHandle (with backward-compat parsing)
- prAutoDetect: "on"/"off" → boolean (with legacy string conversion)
- dashboardPort/terminalWsPort/directTerminalWsPort → nested dashboard object
- LifecycleDecision: flat detecting* fields → nested detecting object

Includes migration command (ao migrate-storage), V2 path functions,
storageKey removal, and updated test plan (to-test.md).

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

* fix(core): address review findings for storage redesign migration

Fix all HIGH-priority review findings and blockers from external review:
- Detect bare 12-hex hash directories during migration inventory
- Skip observability directories during migration
- Detect V2 tmux session naming patterns for active session check
- Derive status from lifecycle when not stored in migrated JSON
- Fix rollback to preserve storageKey format and post-migration data
- Extract shared flattenToStringRecord utility to avoid duplication
- Handle prAutoDetect "true"/"false" string variants

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

* fix(core): update displayName test for JSON metadata format

The upstream displayName test asserted key=value file format and
bare filename. Update to check JSON content and .json extension.

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

* fix(core): fix runtimeHandle type in upstream restore test

The upstream displayName restore test passed runtimeHandle as
JSON.stringify(makeHandle(...)) — a string. Our type change requires
the RuntimeHandle object directly.

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

* fix(core): address PR review comments

- Handle empty files from reserveSessionId() in mutateMetadata() —
  treat empty/whitespace content as empty record instead of throwing
  on JSON.parse
- Fix archive doc comment: archives live under <sessionsDir>/archive/,
  not <projectDir>/archive/
- Remove migration test file from gitleaks path allowlist — no false
  positives are triggered, so blanket file exclusion is unnecessary

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

* fix: use targeted regex instead of path allowlist for gitleaks

Replace the blanket file allowlist with a regex matching the specific
test placeholder hash "abcdef012345" that triggers the generic-api-key
rule. This keeps secret scanning active for the migration test file.

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

* fix: replace high-entropy test placeholder to avoid gitleaks false positive

Use `aaaaaa000000` instead of `abcdef012345` as the dummy 12-hex-char
hash in migration tests. The old value triggered gitleaks' generic-api-key
rule when combined with `storageKey:` in YAML-like test fixtures. This
eliminates the need for any gitleaks allowlist entry for this file.

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

* fix(cli): auto-register flat local config in ao start

When running `ao start` in a directory with a flat
agent-orchestrator.yaml (no `projects:` key) that isn't registered
in the global config, the Zod validation error was surfacing as a
raw error dump. Now auto-registers the project in the global config
and retries, matching the behavior of `ao start <path>`.

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

* fix(core): correct migration error message to use ao session kill

The error message referenced `ao kill --all` which doesn't exist.
The correct command is `ao session kill --all`.

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

* fix(core): address review findings — worktree paths, archive location, recovery log

- Migration now writes absolute worktree paths instead of relative
  (relative paths resolved against cwd, not project dir, breaking restore)
- Archive directory moved from projects/{pid}/archive/ to
  projects/{pid}/sessions/archive/ to match runtime deleteMetadata behavior
- getProjectArchiveDir() updated to return sessions/archive/ consistently
- fixArchiveFilename() handles sanitized timestamps (dashes replacing colons)
- getRecoveryLogPath() fallback uses AO base dir instead of synthetic
  projects/_recovery/ directory

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

* fix(core): update metadata hooks for JSON format and .json extension

Both the Claude Code PostToolUse hook and the PATH wrapper hooks
(gh/git) were constructing metadata paths without .json extension and
using key=value sed to update metadata. This broke after the storage
V2 migration which uses .json files with JSON content.

Changes:
- Try {sessionId}.json first, fall back to bare {sessionId} for
  pre-migration layouts
- Detect JSON format (first char '{') and use jq for updates
- Fall back to key=value sed for legacy metadata files
- Bump WRAPPER_VERSION 0.3.0 → 0.4.0 to force wrapper reinstall

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

* fix(core): reset lifecycle on restore and keep killed sessions in active metadata

Two runtime bugs fixed:

1. Restore: lifecycle object was not reset — lifecycle manager read the old
   terminal state and immediately transitioned back to Done. Now resets
   lifecycle to working/alive via cloneLifecycle + buildLifecycleMetadataPatch.

2. Kill: sessions were immediately archived, making them invisible to list()
   and get(). Dashboard showed "Page not found" instead of Done/Terminated.
   Now keeps killed sessions in active metadata with terminal status.

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

* fix(core): address storage redesign review findings

Fix CI blocker and several correctness/consistency issues found during
review of PR #1466:

1. Fix codex plugin test failures — WRAPPER_VERSION bumped to 0.4.0 in
   agent-workspace-hooks.ts but codex tests still expected 0.3.0

2. Add agentReport and reportWatcher to jsonFields in
   unflattenFromStringRecord — these object fields were missing from the
   known-fields set, causing silent data corruption on mutateMetadata
   roundtrip (object → string → stays string instead of reparsing)

3. Normalize prAutoDetect writes from "off" to "false" in
   session-manager — the JSON round-trip converts "off" to boolean false
   on disk, which flattens to "false" on read-back. Writing "false"
   directly avoids the ambiguity and matches the round-trip behavior

4. Fix STORAGE_REDESIGN.md to match implementation — archive path is
   sessions/archive/ (not a sibling of sessions/), and status is still
   persisted (computed-only deferred to follow-up)

5. Keep detecting fields at top level during migration — the lifecycle
   manager reads detectingAttempts/detectingStartedAt/detectingEvidenceHash
   from session.metadata (top-level), not from lifecycle.detecting.
   Nesting them during migration caused silent reset on first poll

6. Remove to-test.md development artifact (895 lines)

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

* fix(core): remove unused readMetadata import in lifecycle test

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

* fix(core): fix 3 critical migration issues

1. Orchestrator blindness: stop extracting orchestrators to orphaned
   orchestrator.json — write them to sessions/ where runtime reads from.
2. Pre-lifecycle "unknown": preserve status in migrated JSON when no
   statePayload exists, preventing readMetadata fallback to "unknown".
3. Archive timestamp collision: add counter to archive filenames to
   prevent same-millisecond overwrites. Fix dead-code ternary.

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

* fix(core): eliminate status dual truth, fix jsonFields whitelist, add rollback dry-run and tests

- Status is now computed on read from lifecycle (single source of truth).
  deriveLegacyStatus maps session.reason to specific terminal statuses
  (killed, cleanup, errored) instead of relying on stored previousStatus.
- Remove jsonFields whitelist in unflattenFromStringRecord — auto-detect
  JSON by checking if value starts with { or [. Prevents silent
  stringification of new JSON fields.
- Add dryRun option to rollbackStorage and wire through CLI --dry-run.
- Add 18 tests for V2 path functions (getProjectDir, assertSafeProjectId,
  compactTimestamp, parseTmuxNameV2, etc.).
- Add migration edge case tests: worktree dir migration, pre-lifecycle
  status preservation, archive filename uniqueness, active session blocking.

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

* fix(core): fix stray worktree recursion, rollback data loss, and worktree path rewrite

- moveStrayWorktrees now recurses into ~/.worktrees/{projectId}/{sessionId}/
  (default workspace plugin layout) instead of only scanning top-level entries
- Rollback checks for post-migration sessions before deleting project dirs,
  preserving sessions created after migration with a warning
- Worktree path rewrite only fires when the destination directory actually
  exists, keeping original paths for worktrees not yet moved

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

* fix(core): reset terminal PR state on session restore

When restoring a session whose PR was already merged/closed, the
lifecycle manager would immediately re-detect the merged PR and
terminate the session again — making restore useless for merged sessions.

On restore, if pr.state is "merged" or "closed", reset it to "none"
with reason "cleared_on_restore". This lets the session run freely;
if the agent creates a new PR, auto-detect picks it up normally.
Also clears mergedPendingCleanupSince to prevent stale cleanup.

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

* fix(core): remove stale previousStatus args and unused SessionStatus import

Two call sites in lifecycle-manager.ts still passed session.status as
a second argument to deriveLegacyStatus and buildLifecycleMetadataPatch
after the previousStatus parameter was removed. Also removes unused
SessionStatus import from metadata.ts.

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

* fix(core): address PR review comments — parser, docs, delete route, prefix sanitization

- parseTmuxNameV2: allow hyphens in prefix to match sessionPrefix
  validation ([a-zA-Z0-9_-]+), fixing "my-app-1" parsing
- SessionMetadata: update stale doc comments — JSON format, no hash prefix
- DELETE /api/projects/[id]: report actual removedStorageDir based on
  whether the directory existed before deletion
- start.ts registerFlatConfig: sanitize projectId before deriving
  sessionPrefix, matching config-generator.ts behavior

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

* fix(agent-claude-code): add --dangerously-skip-permissions for all restored sessions

getRestoreCommand only added the flag for orchestrator sessions, but
getLaunchCommand adds it for any session with permissionless/auto-edit.
This caused restored worker sessions to lose permissionless mode.

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

* fix(core): skip .migrated dirs in inventory to prevent .migrated.migrated on re-run

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

* fix(core): rollback worktree preservation, scoped tmux detection, JSON parse whitelist

- Move worktrees back to restored hash dirs before deleting project dir on rollback
- Scope v2OrchestratorPattern to known project prefixes instead of matching any tmux session
- Restrict unflattenFromStringRecord JSON parsing to known structured fields only

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

* feat(cli): show "Add this project" option in ao start project picker

When running ao start in a git repo that isn't registered, the project
selector now includes an option to add the current directory as a new
project instead of requiring the user to run a separate command.

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

* feat(cli): show "Add project" in already-running menu when cwd is unregistered

When AO is already running and the user runs ao start from an
unregistered git repo, the menu now offers to add that directory
as a new project alongside the existing open/restart/quit options.

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

* feat(cli): add --reports flag to ao status for agent report history

Adds --reports option to `ao status` that displays the agent report
audit trail per session. Accepts "full" for all entries or a positive
integer for the last N entries.

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

* fix(cli): replace removed storageKey reference with getProjectSessionsDir in status command

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

* fix(core,web): address PR review issues — crash safety, atomic ops, corrupt data handling, test fixes

- metadata.ts: handle corrupt JSON gracefully (return null instead of crashing), use atomic renameSync for archive, conditionally persist status only when lifecycle is not an object
- storage-v2.ts: add crash-safety marker file for migration, fix archive filename handling for .json suffix and compact timestamps, use Date parsing for duplicate session resolution
- lifecycle-state.ts: add JSDoc and clarify deriveLegacyStatus default case behavior
- lifecycle-transition.ts: add JSDoc clarifying buildTransitionMetadataPatch scope
- AddProjectModal.test.tsx: fix pre-existing jsdom localStorage mock so saveRecentPath works in tests
- Add tests for corrupt JSON handling, migration markers, and crash recovery

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

* fix(core): harden storage-redesign against edge cases (EC-1 through EC-8, EC-14, EC-27)

Address 10 edge cases found during systematic review of storage redesign:

- EC-1: Wrap mutateMetadata read-modify-write in withFileLockSync to prevent race conditions
- EC-2: Replace existsSync+readFileSync TOCTOU pattern with try-catch in readMetadata/readMetadataRaw
- EC-3: Append PID to archive filenames to prevent same-second collision
- EC-4/5: Add crossDeviceMove helper with EXDEV fallback (cpSync+rmSync) for migration renames
- EC-6/13: Restrict project ID validation to [a-zA-Z0-9][a-zA-Z0-9._-]* with 128-char max
- EC-7: Guard rollback rename against pre-existing target directory
- EC-8: Add mtime+path tiebreaker for duplicate session resolution
- EC-14: Fix misleading "Resuming" log message in migration
- EC-27: Extend readMetadataRaw status override to handle statePayload-only sessions

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

* fix(core,cli): prevent silent data loss on upgrade — V1 detection, git worktree repair, storageKey preservation

Three P0 fixes for storage-redesign migration UX:

1. Warn on `ao start` when legacy hash-based directories exist,
   telling users to run `ao migrate-storage` before sessions disappear.

2. Run `git worktree repair` from each project's repo root after
   migration moves worktree directories — fixes broken git references
   that would otherwise make git status/push fail inside moved worktrees.

3. Preserve `storageKey` in global config allowlist so it isn't silently
   stripped on load before migration has a chance to use it.

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

* fix(core): skip active session check during migrate-storage --dry-run

Dry run is read-only — blocking on active sessions defeats the purpose
of previewing what migration would do.

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

* fix(integration-tests): update archive filename regex for PID suffix

EC-3 appended -p{pid} to archive filenames to prevent same-second
collisions. Update the integration test regex to match the new format.

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

* fix(core): address final merge review — Zod schema gaps, worktree repair, rollback safety, status priority

5 fixes from final review:

1. Add storageKey to GlobalProjectEntrySchema (Zod) so it survives
   parse→save round-trips until migration strips it.

2. Add 5 missing reason values to lifecycle Zod schemas
   (auto_cleanup, pr_merged, cleared_on_restore, pr_merged_cleanup)
   so lifecycle isn't silently reconstructed from stale status on restart.

3. Run repairGitWorktrees when stray worktrees are moved, not only
   when hash-dir worktrees are moved (was checking wrong counter).

4. Count archived post-migration sessions in rollback safety check
   so rollback warns before silently deleting user's archived data.

5. Fix portfolio-session-service status priority to prefer lifecycle-
   derived status over stored, matching metadata.ts behavior.

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

* fix(core): harden storage redesign migration rollback

* fix(core,cli,web): allocate suffixed project ids on duplicate names

* fix(core,cli): graceful migration errors + skip orchestrator selector

- Migration: wrap per-project migration in try/catch so one failure
  doesn't abort the entire run. Handle ENOTEMPTY when .migrated target
  already exists from an interrupted previous run.
- CLI: ao start now always opens the selected orchestrator's dashboard
  page directly instead of the orchestrator selector.

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

* fix(core,cli): align with upstream to reduce merge conflicts

Bump WRAPPER_VERSION from 0.4.0 to 0.6.0 to match upstream's gh CLI
tracer changes (#1238), and update start.test.ts URL assertion to use
canonical orchestrator IDs (no number suffix) per upstream's orchestrator
identity fix (#1487). These pre-merge alignments eliminate 3 of the 11
conflicts when merging upstream/main.

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

* fix: resolve Phase 1+2 merge conflicts with upstream/main (#1487, #1238)

* fix(core,web): allow restoring merged sessions

Remove "merged" from NON_RESTORABLE_STATUSES and delete the
hasMergedLifecyclePR guard so sessions with merged PRs can be
restored like any other terminal session. Previously clicking
"Restore" on a merged session returned a misleading 409 error
("session is not in a terminal state") — the session was terminal,
just explicitly blocked.

Also fix Dashboard.tsx to show the restore button for merged
sessions and improve the error message in restore() to include
the actual status and activity state.

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

* Implement hashed project identity

* fix(core,cli,web): address PR #1466 review findings

1. Patch session JSON worktree field after moving stray worktrees
2. Preserve migration marker and skip config stripping on partial failure
3. Sanitize legacy project IDs with unsafe characters during migration
4. Use sed-based JSON update when jq is unavailable instead of corrupting
   JSON metadata with key=value fallback
5. Fall back to flat local config repo during first registration when
   git origin provides no repo identity
6. Return and print effective registered project ID from ao project add
7. Update web route tests to use effective hashed project IDs and fix
   repairWrappedLocalProjectConfig to find entries by content fallback

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

* fix(core): use strict equality to satisfy eqeqeq lint rule

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

* fix(core,web): address Copilot review comments

1. parseTmuxNameV2: accept digit-leading prefixes to match the config
   schema validation ([a-zA-Z0-9_-]+)
2. DELETE /api/projects/[id]: return 400 for unsafe project IDs instead
   of letting getProjectDir throw into the 500 catch-all
3. POST /api/projects: return structured 409 on collision with
   existingProjectId, suggestedProjectId, and suggestion fields so the
   AddProjectModal collision UI actually works

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

* fix(core,workspace): route new worktrees to V2 project directory

The workspace-worktree plugin defaulted to ~/.worktrees/ for all new
worktrees, bypassing the V2 layout entirely. New sessions created
worktrees at ~/.worktrees/{projectId}/{sessionId} instead of
~/.agent-orchestrator/projects/{projectId}/worktrees/{sessionId}.

Add optional worktreeDir to WorkspaceCreateConfig so session-manager
can pass getProjectWorktreesDir(projectId) per spawn/restore call.
The plugin uses this override when provided, falling back to the
plugin-level default for backward compat.

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

* fix(cli): prefix unused addCwdOption variable to satisfy lint

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

* fix(core): address migration review findings and orchestrator tmux double-prefix

Migration (storage-v2.ts):
- Use atomicWriteFileSync for all session JSON writes (crash safety)
- Wrap stripStorageKeysFromConfig in withFileLockSync (concurrency safety)
- Add case-insensitive projectId collision detection (macOS HFS+/APFS)
- Call repairGitWorktrees in rollback path (git worktree ref repair)
- Skip stray worktree moves for failed projects (partial-failure safety)

Session manager:
- Fix orchestrator tmux name double-prefix: getOrchestratorSessionId
  already returns "{prefix}-orchestrator", so tmuxName should use
  sessionId directly, not "${prefix}-${sessionId}"

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

* refactor(core): remove archive path functions from paths.ts and index.ts

Remove getProjectArchiveDir, getArchiveFilePath, and compactTimestamp
from V2 path helpers as part of archive system removal. Sessions will
stay in sessions/ with lifecycle.state: "terminated" instead of being
moved to sessions/archive/. Callers in metadata.ts and migration will
be updated in subsequent tasks.

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

* refactor(core): remove archive logic from metadata.ts

Remove archive system from metadata layer: simplify deleteMetadata to
permanent-only deletion, delete readArchivedMetadataRaw and
updateArchivedMetadata functions, and update unit/integration tests.

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

* refactor(core): remove archive code from session-manager.ts

Remove all archive-related logic from the session manager now that
terminated sessions stay in sessions/ instead of being moved to an
archive directory.

Changes:
- Remove readArchivedMetadataRaw/updateArchivedMetadata imports
- Delete listArchivedSessionIds and markArchivedOpenCodeCleanup functions
- Remove archive search from findOpenCodeSessionIds
- Remove listArchivedSessionIds from reserveNextSessionIdentity
- Replace archive fallback in kill() with readMetadataRaw + lifecycle check
- Remove archive fallback in restore() (findSessionRecord finds all sessions)
- Replace archive iteration in cleanup() with terminated session iteration
- Remove boolean archive flag from all deleteMetadata calls
- Remove unused readdirSync import
- Update lifecycle and restore tests to use terminated state instead of archive

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

* fix(core): stop archiving sessions on cleanup in recovery/actions.ts

Remove the deleteMetadata call that archived sessions after marking them
terminated. Sessions now remain in sessions/ with terminated state.
Also remove the now-unused deleteMetadata import.

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

* refactor(core): flatten archives into sessions/ during migration instead of copying to archive dir

Remove the archive system from storage-v2 migration: old V1 archives are now
flattened into sessions/ as terminated session records instead of being copied
to sessions/archive/. Duplicate sessions across hash dirs are skipped with a
warning instead of being archived. Remove fixArchiveFilename(), compactTimestamp
import, archives field from result types, and archive counting from rollback.

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

* refactor(core): remove archive directory filter from listMetadata

The isFile() check already excludes directories. Archive filter was only
needed when sessions/archive/ was actively used.

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

* fix(core): update tests to match archive-removal behavior

cleanupSession in recovery/actions.ts now marks sessions as terminated
instead of deleting metadata. Updated two recovery-actions tests to
assert on terminated status instead of file deletion. Also fixed
metadata and integration tests for the new deleteMetadata signature
(no boolean archive arg).

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

* fix(core): address build/test issues from archive removal

- Fix writeMetadata calls missing required fields in test files
- Remove boolean archive arg from deleteMetadata calls in integration tests
- Update recovery-actions tests to expect terminated state instead of deletion
- Remove unused readdirSync import from migration test

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

* docs: update handoff doc — archiving removed

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

* docs: remove handoff document

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

* feat(cli): add last-stop state persistence for ao stop/start restore

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

* feat(cli): ao stop kills all active sessions and records them

ao stop now kills all active sessions (orchestrator + workers), not just
the orchestrator. Killed session IDs are saved to last-stop.json for
restore on next ao start.

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

* feat(cli): ao start offers to restore sessions from last ao stop

On interactive startup, if last-stop.json exists with sessions for the
current project, the user is prompted to restore them. The orchestrator
is skipped (already restored by ensureOrchestrator). The file is cleared
after the prompt regardless of choice.

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

* fix(cli): update stop tests for all-sessions kill behavior

Update test mocks to return proper KillResult shape and adjust test
assertions for the new all-sessions stop behavior. Add console.log
fallback for killed session IDs (non-TTY/test capture).

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

* fix(core): address review — sed JSON corruption, sanitizeBasename dot

- Replace sed-based JSON fallback with node -e in workspace hooks and
  claude-code plugin. sed "s|}|...|" replaces the first } per line,
  corrupting nested JSON (lifecycle, runtimeHandle). node is a hard dep
  and handles nested objects correctly via JSON.parse/stringify.
- Drop . from sanitizeBasename allowed chars — config.ts Zod schema
  rejects dots in project keys, so my.app_hash would fail loadConfig.

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

* fix: address storage redesign review issues

* fix(core): persist stale runtime state + show cross-project sessions in ao stop/start

- session-manager: persist lifecycle to disk when enrichment detects dead
  runtime (missing/exited) — prevents stale "alive" metadata from keeping
  terminated sessions on the active sidebar (ao-100 bug)
- lifecycle-state: map runtime_lost reason to "killed" legacy status
- ao stop: list ALL sessions across projects, not just targeted project;
  display and record cross-project sessions in last-stop.json
- ao start: show sessions from other projects that were stopped, so user
  knows they need separate ao start for those projects

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

* fix(cli): scope ao stop to target project when explicit arg is given

ao stop (no arg) kills all sessions across all projects since it also
kills the parent ao start process. ao stop <project> now correctly
scopes to just that project's sessions.

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

* fix(cli): show all projects in tab completions by merging global config

listProjects() only read the local config (found via cwd search), which
may contain just one project. Now also reads the global config at
~/.agent-orchestrator/config.yaml to include all registered projects
in shell completions for ao stop, ao start, etc.

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

* fix(cli): fall back to global config when project arg not in local config

ao stop <project> and ao start <project> failed when cwd has a local
agent-orchestrator.yaml that doesn't contain the targeted project.
Now falls back to ~/.agent-orchestrator/config.yaml which has all
registered projects, matching what tab completions already show.

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

* fix(cli): ao stop <project> must not kill parent process or dashboard

ao stop donna was killing the parent ao start process and dashboard,
which serve ALL projects. Now only kills the parent process and
dashboard when no project arg is given (full shutdown). When targeting
a specific project, only that project's sessions are killed.

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

* fix(cli): always load global config for ao stop to see all projects

sm.list() iterates config.projects to find sessions. When loadConfig()
finds the local agent-orchestrator.yaml (1 project), ao stop only sees
that project's sessions — other projects' tmux sessions survive. Now
ao stop always loads the global config which has all registered projects.

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

* fix(cli): ao start restores all sessions including cross-project ones

ao start showed sessions from all projects but only restored the
current project's sessions. Now restores all sessions listed, using
the global config so the session manager can see all projects.

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

* fix(web): sidebar shows all sessions regardless of active project

Remove project scoping from useSessionEvents so the sidebar always
receives sessions from every project. Kanban filtering is applied
client-side via a projectSessions memo.

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

* fix(cli): Ctrl+C on ao start performs full graceful shutdown

Previously Ctrl+C only stopped lifecycle workers and exited, leaving
sessions alive in tmux and not recording last-stop state for restore.
Now the SIGINT/SIGTERM handler mirrors ao stop: kills all sessions,
records last-stop state, and unregisters from running.json. A 10s
timeout ensures the process always exits even if cleanup hangs.

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

* docs: update architecture docs for CLI, lifecycle, and dashboard changes

- CLAUDE.md: add canonical lifecycle states/reasons, stale runtime
  reconciliation, LastStopState + running.json to storage section,
  config resolution note, CLI behavior section (ao start/stop/Ctrl+C),
  key files (lifecycle-state.ts, running-state.ts, start.ts, sidebar)
- AGENTS.md: add lifecycle-state.ts, start.ts, running-state.ts to key
  files, add CLI behavior notes section
- copilot-instructions.md: add lifecycle-state.ts + start.ts to
  high-risk files, add common mistakes for runtime_lost, sidebar
  scoping, and ao stop project scoping
- DESIGN.md: add decision log entry for sidebar cross-project sessions

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

* docs: update PR behavior dashboard with behavioral fixes and cross-project CLI

Add sections for stale runtime reconciliation, dashboard sidebar
scoping, tab completions, config resolution, Ctrl+C graceful shutdown,
and documentation updates. Update stats to 90 files, +6481/-2421.
Update ao stop/start panels with cross-project behavior. Update
summary with runtime reconciliation and cross-project CLI verdicts.

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

* Revert "docs: update PR behavior dashboard with behavioral fixes and cross-project CLI"

This reverts commit 6d968b9ff5.

* fix(cli): add removeProjectFromRunning and targeted stop tests

- Add removeProjectFromRunning() to running-state.ts — removes a
  project from running.json so ao start <project> can restart without
  hitting the "already running" gate after ao stop <project>
- Add projectNeedsRestart check in ao start — skips "already running"
  menu when the project was removed from running.json by a targeted stop
- Add 6 tests for targeted stop behavior: no parent kill, no unregister,
  removes project from running.json, kills correct sessions, full stop
  still tears down parent+dashboard, last-stop records correct scope

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

* docs: update handoff docs with accurate checkout recipes and CLI details

Fix checkout instructions to not assume everyone has the same fork as
origin — add separate sections for the PR author vs new contributors.
Correct stop.ts references (doesn't exist — stop logic is in start.ts).
Document removeProjectFromRunning, projectNeedsRestart gate, isProjectId
guard, and Ctrl+C signal handler details.

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

* fix(cli): handle URL/path args when AO is already running

Previously `ao start <URL>` or `ao start <path>` while the daemon was
already running silently ignored the arg and showed a menu about cwd.
The user's URL was dropped.

Now, for TTY callers, when AO is already running and a URL/path arg is
provided:

- If the project is already registered AND in running.projects, just
  open the dashboard. No menu, no re-clone.
- Otherwise, register the project against the active config (clone for
  URLs via handleUrlStart, or addProjectToConfig for paths) and open
  the existing dashboard. Don't fall through to runStartup — that would
  spawn a duplicate dashboard on a different port.

Non-TTY callers (scripts/agents) keep the old "AO is already running"
message and do NOT mutate config behind the user's back.

Adds two tests:
- Path arg already registered + running → opens dashboard, no menu, no
  YAML mutation.
- Path arg unregistered + AO running → registers without prompting, no
  menu, prints "Opening the dashboard".

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

* fix(cli): register URL/path args in global config and spawn orchestrator

Previously `ao start <URL>` while AO was already running would register
the new project in the cwd's local config (polluting an unrelated
project's YAML) and tell the user to `ao stop && ao start <id>` to
actually spawn the orchestrator — clunky and surprising.

Now the flow:

- Always register against ~/.agent-orchestrator/config.yaml (global),
  never the cwd's local config. URLs go through handleUrlStart to
  clone, then are re-registered globally; paths go through
  addProjectToConfig with a global-config arg so it routes to
  registerProjectInGlobalConfig.
- Spawn the orchestrator session via sm.ensureOrchestrator so the
  dashboard immediately shows it.
- Warn that lifecycle polling for the new project requires
  `ao stop && ao start <id>` (the running daemon's worker can only
  poll projects it knew about at startup).
- Open the existing dashboard. No duplicate dashboard, no menu.

Already-registered + running case unchanged: just open the dashboard.

Tests updated to set AO_GLOBAL_CONFIG so the global lookup is isolated
from the test machine's real config, and to assert ensureOrchestrator
is called with the new project ID.

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

* fix(cli): reload dashboard config after registering new project

After `ao start <URL/path>` registers a new project in the global
config, the running dashboard's services cache still holds the stale
config — so the project page 404s until the daemon is restarted.

Hit POST /api/projects/reload (which invalidates the services cache)
right after registering. Failure to reach the dashboard is non-fatal:
print a hint to refresh the page.

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

* fix(cli): repair wrapped local config after URL clone

handleUrlStart writes a legacy wrapped (`projects:`) agent-orchestrator.yaml
inside the cloned repo. After registering the project against the global
config, the project resolver hits the wrapped local config and routes the
project into degradedProjects (with a resolveError) — so loadConfig drops
it from config.projects and ao start would throw "Failed to register".

Call repairWrappedLocalProjectConfig() right after the global registration
to convert the wrapped config to the flat format the new resolver expects.
Best-effort: if repair fails, defaults fill in behavior.

Cleanup note: any wrapped local configs from earlier runs (and stale
~/.agent-orchestrator/config.yaml entries from earlier test runs that
pre-dated AO_GLOBAL_CONFIG isolation) need manual cleanup.

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

* fix(cli): clone+register flat config directly, surface empty-repo errors

Replaces the previous "register, then repair the wrapped config" hack
with a single-shot clone-and-register flow that produces a valid flat
local config from the start.

Why the previous flow was wrong:
- handleUrlStart writes a legacy wrapped (`projects:`) agent-orchestrator.yaml
  inside the cloned repo. The new global-config resolver rejects that
  shape and routes the project into `degradedProjects`, which breaks
  `loadConfig().projects[id]` lookups and 404s the dashboard route.
- repairWrappedLocalProjectConfig() papered over that — but the right
  fix is to never write a wrapped config in the first place.

What this does instead, when `ao start <URL>` runs while the daemon
is alive:

1. Parse the URL, resolve the clone target, clone (or reuse).
2. Detect the actual default branch via `git symbolic-ref refs/remotes/origin/HEAD`,
   falling back to local HEAD. Returns null for empty repos.
3. If the repo is empty (no commits / no refs), fail early with a
   clear actionable message — otherwise ensureOrchestrator throws a
   confusing "Unable to resolve base ref" deep inside the worktree
   plugin.
4. registerProjectInGlobalConfig with identity only (path, repo,
   defaultBranch, sessionPrefix derived from project ID).
5. writeLocalProjectConfig with behavior only (scm + tracker plugin
   choices, derived from the host platform). Skip the write if the
   repo already commits its own agent-orchestrator.yaml.
6. Refresh the global config and spawn the orchestrator session.

Drops `repairWrappedLocalProjectConfig` import — no longer needed.

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

* fix(migration): keep agent-report and report-watcher metadata flat

Migration was nesting six agent-report keys (agentReportedState, At,
Note, PrUrl, PrNumber, PrIsDraft) and four report-watcher keys
(reportWatcherLastAuditedAt, ActiveTrigger, TriggerActivatedAt,
TriggerCount) into `agentReport` / `reportWatcher` wrapper objects.

The live runtime readers — parseExistingAgentReport in agent-report.ts
and the report-watcher writes in lifecycle-manager.ts — read these
keys flat off `session.metadata`. readMetadataRaw() then runs the
result through flattenToStringRecord(), which JSON.stringify()s any
object value into a single string under the wrapper key. It does NOT
unfold the nested object back into the flat keys the readers expect.

Net effect: any V1 session that had a non-empty agent report or a
non-zero report-watcher trigger count silently lost that state after
migration. The active-tmux gate in `ao migrate-storage` blunts the
worst case (sessions are terminated by the time migration runs, so
the freshness window often expires the data anyway), but reports
within the 5-minute freshness window and dashboard "last reported"
fidelity are still affected.

Fix: keep these ten keys flat in the V2 JSON, identical to the
existing handling for the `detecting*` fields. Same rationale, same
shape. Adds a regression test that asserts the flat keys round-trip
through migration and rewrites the two grouping tests to assert the
new flat shape.

Reported on PR #1466 by @ashish921998.

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

* docs(prompt): teach orchestrator to read agent reports via ao status --reports

The orchestrator system prompt explained the worker-only `ao report`
command and the freshness/precedence rules around agent reports, but
never told the orchestrator how to inspect them. The CLI flag
`ao status --reports <full | N>` already exists for exactly this
purpose — surface it in Monitoring Progress and cross-reference it
from the Explicit Agent Reports section so the orchestrator has an
obvious read path when an inferred status disagrees with what the
worker self-reported.

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

* fix(cli): attach to existing daemon for ao start <project> after targeted stop

Reported on PR #1466 as P1: after `ao stop <project>` removed the project
from running.json (via removeProjectFromRunning) but left the parent
ao start process alive, `ao start <project>` took the projectNeedsRestart
path and fell through to runStartup(). runStartup() then started a SECOND
dashboard on a new port and overwrote running.json — leaving two AO
processes running, with running.json pointing at only the new one and the
original parent's lifecycle worker still polling.

Fix: when running && projectArg is a project ID && project not in
running.projects, attach to the existing daemon instead of falling through
to runStartup. The new branch:

- Loads the project from the global config and refuses with a clear error
  if it isn't registered there.
- Spawns the orchestrator session via the live session manager
  (sm.ensureOrchestrator).
- Calls the new addProjectToRunning() helper to put the project back into
  running.json so subsequent `ao stop` (no args) sees it and `ao spawn`
  doesn't print the "running instance is not polling project X" warning.
- Reloads the dashboard's services cache via POST /api/projects/reload so
  the project page works on the existing dashboard.
- Surfaces a yellow warning that lifecycle polling for the new project
  isn't attached without a full daemon restart — same architectural caveat
  documented in the URL/path attach branch and tracked separately as the
  dynamic project supervisor follow-up issue (#1522).
- Works for both TTY and non-TTY callers; non-TTY just skips the
  openUrl + dashboard popup.

Adds addProjectToRunning() in running-state.ts symmetric to the existing
removeProjectFromRunning(): file-locked, idempotent, no-op when state is
missing or already lists the project.

Adds a regression test that asserts:
- mockRegister is NOT called (no second daemon registration)
- ensureOrchestrator is called with the requested projectId
- addProjectToRunning is called with the projectId
- The interactive menu is NOT shown
- Output contains the expected "Attaching to running AO instance" /
  "reattached to running daemon" lines

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

* docs: add scripts/demo-pr-1466.sh — end-to-end reviewer demo

Self-contained, sandboxed walkthrough of every PR #1466 behavior change.
Designed to be recorded as a screencast — section banners replace
narration, no live typing, deterministic output.

Six acts:
  1. Migration V1 → V2 (live: seed hash dirs + key=value, dry-run, execute,
     show V2 layout, verify @ashish921998 fix that agent-report keys stay
     flat after migration, prove rollback safety on rerun)
  2. Cross-project CLI P1 fix (filter the regression test by name and run
     it live — asserts no second daemon is spawned by ao start <project>
     after ao stop <project>)
  3. Dashboard sidebar shows all projects (display the Dashboard.tsx fix)
  4. Restore from ao stop / Ctrl+C (last-stop.json round-trip)
  5. Ctrl+C graceful shutdown handler with 10s hard timeout
  6. Empty-repo guard for ao start <URL> (the detectClonedRepoDefaultBranch
     null path that surfaces a useful error before ensureOrchestrator)

Then prints the final 560 / 981 test summary so the recording ends on
a green CI signal.

Sandbox notes:
  • $HOME is overridden to /tmp/ao-demo-1466 for the duration of the
    script so getAoBaseDir() resolves there. The operator's real
    ~/.agent-orchestrator is never touched.
  • A REAL_HOME is captured before the override and restored when
    running the full test suites, since vitest needs the operator's
    real config path to avoid cross-test contamination.
  • Re-run is idempotent — rm -rf $DEMO_HOME at the top recreates
    the sandbox from scratch.

Verified runs end-to-end on storage-redesign with exit code 0.

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

* demo: richer fixture — 2 projects, 6 sessions, real source trees

Earlier seed was a single empty repo with one session and a 2-line README.
Reviewers would dismiss it as not credible migration evidence.

New seed:
  • 2 source projects (myproject, frontend), each a real TS package layout
    with package.json, tsconfig.json, src/lib/, tests/, .gitignore, README,
    and 6 commits of history. 8 files per repo.
  • 6 sessions across the 2 hash dirs, in varied states:
      - ao-1 (working, agent-report state + report-watcher counters + PR
        fields — headline @ashish921998 flat-key fix in one record)
      - ao-2 (V1-archived, terminated, manually_killed)
      - ao-3 (stuck, with report-watcher trigger active)
      - my-orchestrator-1 (kind=orchestrator)
      - fe-1 (working, PR open with PR fields)
      - fe-2 (V1-archived, terminated, runtime_lost)
  • Real git worktree for ao-1 with an actual diff file —
    proves worktree migration moves files and rewrites git refs.
  • Pre-seeded global config.yaml lists both projects so the migrator
    has identity to project against.

Migration handles all 6 sessions (4 active + 2 archived → flattened) and
the 1 worktree. The verification step inspects ao-1.json post-migration
and asserts every flat agent-report / report-watcher key from the
@ashish921998 fix is present, with no nested wrapper objects.

Also fixes:
  • MIGRATED_PROJECT used to grab alphabetically-first directory which
    made the JSON read crash when frontend won — hardcoded to myproject.
  • Before-display referenced $HASH_DIR/archive but the actual archive
    location is $HASH_DIR/sessions/archive — corrected.

End-to-end verified: exit 0, "PASS — agent-report flat-key contract
preserved", 560/560 CLI + 981/981 core tests in the final summary.

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

* fix(migration): relink Claude Code session storage when worktrees move

Reported in PR #1466 QA: after `ao migrate-storage`, restoring a session
launches a fresh `claude` instance — chat history is gone.

Root cause: Claude Code keys session JSONLs by the encoded form of the
workspace cwd (~/.claude/projects/<encoded>/<session-uuid>.jsonl, where
encoded = cwd with `/` and `.` replaced by `-`, see toClaudeProjectPath
in agent-claude-code/src/index.ts). The migrator moves worktrees from
~/.agent-orchestrator/{hash}-{project}/worktrees/{sid} to
~/.agent-orchestrator/projects/{projectId}/worktrees/{sid}, which
produces a different encoded path. The agent's session JSONLs are still
at the old encoded path and stay orphaned. getRestoreCommand looks under
the new encoded path, finds nothing, returns null — and the caller
falls back to a fresh launch.

Fix: track every (oldWorkspacePath, newWorkspacePath) pair across both
migration phases (per-project migrateProject and the cross-project
moveStrayWorktrees), then call relinkClaudeSessionStorage after all
worktree moves complete. The relink renames each
~/.claude/projects/<old-encoded>/ → <new-encoded>/. Skip when source
doesn't exist (no Claude history) or target already exists (manual
reconciliation needed). Same step is invoked in reverse from
rollbackStorage so `--rollback` undoes the relink.

The encoding helper is duplicated locally in migration/storage-v2.ts to
avoid pulling the agent plugin into core/migration just for one string
transformation. Kept in sync by hand; if the plugin's encoding ever
changes, both copies need to update together.

Codex stores sessions date-sharded with the cwd embedded inside each
JSONL's session_meta line, so the same physical-rename trick doesn't
apply. Codex relinking is left as a follow-up — the comment in
relinkClaudeSessionStorage points at it.

Two regression tests added:
  • Happy path: V1 worktree at OLD encoded path with a JSONL inside
    Claude's projects dir; after migrateStorage the JSONL is at the
    NEW encoded path and the OLD dir is gone. claudeSessionsRelinked === 1.
  • Safety: target dir already exists at the new encoded path; migration
    skips the relink, neither dir is touched, claudeSessionsRelinked === 0.

Tests use HOME override to sandbox ~/.claude/ so the runner's real
agent-storage is never touched.

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

* fix(boundary): four cross-module seams flagged in PR #1466 review

- Migration: rewrite Codex rollout session_meta.cwd for moved
  worktrees so getRestoreCommand keeps finding the old thread.
  Mirrors the Claude relink with a single-line in-place rewrite.
- CLI start: stop adding the project to running.projects in the
  attach-to-existing-daemon branch. Lifecycle polling cannot be
  attached mid-flight, so claiming coverage made `ao spawn`
  silently suppress its "instance is not polling X" warning.
- CLI stop: defensively drop foreign sessions before the kill
  loop when a project arg is given. `sm.list(projectId)` already
  scopes, but the kill loop is destructive enough to deserve a
  consumer-side guard.
- Web DELETE /api/projects/[id]: validate the id through
  getProjectDir BEFORE calling cleanupManagedWorkspaces so a
  malformed key never reaches a workspace plugin.

Adds regression tests for each.

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

* fix(boundary): four more cross-module seams flagged in PR #1466 review

- Recovery actions (cleanup/escalate/recoverSession-on-max-attempts)
  now mutate the canonical lifecycle alongside the flat status. For
  V2 sessions readMetadataRaw derives status from lifecycle, so the
  prior flat-only writes were silently overridden on the next read.
- Targeted `ao stop <project>` no longer calls
  removeProjectFromRunning. The parent process's in-memory lifecycle
  worker keeps polling that project (a child CLI cannot reach into
  parent memory), so running.projects must keep listing it to remain
  truthful. The attach branch in `ao start <project>` now triggers
  on any project-id arg with a live daemon, regardless of
  running.projects content; the polling-not-attached warning fires
  only when the project is genuinely not in running.projects.
- `ao start` restore loop preserves last-stop.json for sessions that
  fail to restore (transient workspace/runtime errors) instead of
  clearing the only persisted record. Successful or fully-failed
  flows still clear it.
- New integration round-trip: migrate a Codex JSONL with the old
  worktree cwd, then call the real agent-codex.getRestoreCommand
  with the migrated workspacePath and assert it returns
  `codex resume <threadId>`.

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

* fix(review): four illegalcall PR review findings on PR #1466

- writeMetadata sites in session-manager (spawn + ensureOrchestrator)
  spread `buildLifecycleMetadataPatch` (string-typed patch) into a
  typed SessionMetadata literal, which silently wrote `lifecycle` as
  a JSON string and made freshly-spawned sessions read with
  `lifecycle: undefined` until the first poll round-trip.
  Override the spread with the canonical object form and drop the
  metadata.ts safety net that compensated for the bug.
- Migration archive-flatten regex `/^([a-zA-Z0-9_-]+?)_\d/` was lazy
  and captured `team` for `team_1-7_<ts>.json`. Replace with an
  anchor on the timestamp suffix in both call sites so any sessionId
  containing `_<digit>` is parsed correctly.
- Migration duplicate-sessionId resolution renamed-the-loser to
  `${sessionId}__from-${hash}` rather than silently dropping it.
  Both records survive in V2; the rename is logged.
- `running-state.ts` writes `running.json` and `last-stop.json` via
  `atomicWriteFileSync` (temp+rename) so a crash mid-write cannot
  leave torn JSON that orphans an alive AO process or erases the
  next-start restore prompt.

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

* fix(metadata): preserve corrupt session JSON before overwriting

mutateMetadata used to merge against an empty record and atomically
rewrite when parseMetadataContent returned null on corrupt JSON. The
original bytes were lost — the user had no signal anything was wrong,
the file just became "not corrupt anymore — and missing fields".

Side-rename the file to `<path>.corrupt-<ts>` and warn before the
rewrite so forensics survive. Adds two regression tests and drops the
stale STORAGE_REDESIGN.md reference comment.

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

* chore(lint): fix 4 errors introduced by recent boundary fixes

- storage-v2.ts:656,1453 — drop unnecessary `\-` escape inside `[…]`
  character class (no-useless-escape).
- storage-v2.ts:979 — replace inline `import("node:fs").Dirent` type
  annotation with a top-level `Dirent` named import (consistent-type-imports).
- recovery/actions.ts:11 — merge the second `../types.js` `import type`
  into the existing line (no-duplicate-imports).

Tests + typecheck unchanged (991 passing).

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

* fix(cli): propagate reloaded config out of resolveProject after add

The interactive "Add <cwd>" menu path in `resolveProject` registers
the project in the global config (with a hashed id like
`mail-automate_3e4d45c2ba`) and reloads the config internally to
fetch the new project entry. It returned only `{projectId, project}`,
so the outer caller kept the pre-add `config` reference — which has
no key for the just-added project.

Downstream that surfaced as:

    Failed to start lifecycle worker:
    Unknown project: mail-automate_3e4d45c2ba

because `ensureLifecycleWorker(config, projectId)` checks
`config.projects[projectId]` against the stale config.

`resolveProject` and `resolveProjectByRepo` now also return the
(possibly reloaded) config; the three call sites pick it up via
`({ projectId, project, config } = await resolveProject(...))`.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-28 17:55:53 +05:30
Ashish Huddar f3ce113c4c
Add multi-project storage, resolution, and project settings support (#1343)
* feat: add content-addressed project storage keys

* Add per-project resolution and hardened project routing

* Fix storage-key test isolation

* feat(web): redesign Add Project modal with Finder-native layout

* feat: multi-project support with project sidebar, settings, and improved routing

Add per-project configuration in global-config, project-aware CLI commands
(start/spawn/open/session), workspace-worktree project resolution, redesigned
ProjectSidebar with settings modal, repair flow for degraded projects, project
detail page with loading state, reload API endpoint, and comprehensive tests.

* Fix legacy config storage keys and duplicate project flow

* Fix multi-project storage migration and collision handling

* Fix merge regressions in startup and config handling

* Externalize yaml and zod from the web server bundle

* Fix session prefix matching for hashed tmux names

* Fix multi-project migration regressions

* Ignore generated worktree files in ESLint

* Fallback reload config for local-only projects

* Speed up session refresh and redirect after kill

* Use fresh session lists for dashboard polling

* fix(web): remove unused direct terminal child state

* test(web): mock router in merge conflict actions coverage

* docs: call out filesystem browse rollout requirement

* Add portfolio tests and remove unused decomposer export
2026-04-21 17:45:55 +05:30
Harsh Batheja 64badbd388
fix: run deploy as aoagent instead of root (#1378)
The VPS deploy workflow SSHes in as root but operates on files owned by
aoagent via the /root/agent-orchestrator → /home/aoagent/agent-orchestrator
symlink. Each deploy leaves new pnpm/bun/cache entries owned by root inside
an otherwise aoagent-owned tree. Switching the SSH user to aoagent
eliminates this ownership drift.
2026-04-21 01:45:10 +05:30
Harshit Singh Bhandari 99f15aeb2a
docs: add copilot instructions guidance (#1331)
Add the repository Copilot instructions file based on the AO draft and clean up wording, grammar, and markdown formatting while preserving the original guidance.
2026-04-21 01:08:56 +05:30
yyovil f4916bea63 fix: select matching gitleaks binary for arm64 runners 2026-04-19 18:49:04 +05:30
fastestdevalive 254ecd1098
feat(web): terminal layout, mobile UX, sidebar & instant session navigation (#1278)
* feat(web): fix terminal height to fill viewport, prevent outside scrolling

* feat(web): add touch scroll, font size control, and accurate fit to DirectTerminal

* feat(web): add mobile sidebar overlay, hamburger toggle, and reconnect indicator

* feat(web): add hamburger and reconnect pill to topbar

* fix(web): use xterm instead of @xterm/xterm for terminal-touch-scroll import

Upstream uses xterm@5.3.0 (not @xterm/xterm), fix the type import accordingly.

* ci: make pnpm audit strict step non-blocking

npm's legacy audit endpoint (/npm/v1/security/audits) is returning 410 Gone
as it's being retired. Add continue-on-error until pnpm ships support for
the new bulk advisory endpoint.

* fix(web): replace TerminalLike with minimal interface compatible with xterm@5.3.0

xterm@5.3.0 does not have 'input' or 'attachCustomWheelEventHandler' methods
(those are @xterm/xterm v6 APIs). Define a minimal structural interface
matching only the members actually used in the file.

* fix(web): replace mobile back button with hamburger sidebar toggle on session page

On mobile, the session detail page now shows a hamburger button instead of a back arrow, which toggles the sidebar via SidebarContext. This provides better navigation consistency with the main dashboard.

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

* fix(web): filter sidebar sessions strictly by projectId to prevent prefix collision

Sessions are now filtered to only show those whose projectId matches a configured project. This prevents sessions from different AO projects (e.g., 'ao-' and 'unl-' prefixes) from being incorrectly merged under the same project entry.

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

* fix(web): add ResizeObserver and deferred fit to fix blank terminal on mount

The terminal now uses a 100ms deferred fit timeout to ensure the container has been sized before measuring. Additionally, a ResizeObserver monitors the terminal container and calls fit() whenever its size changes, ensuring the terminal refits when flex layouts settle.

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

* fix(web): debounce session refresh to prevent aborting in-flight fetch calls

The useSessionEvents hook now tracks when the last fetch was started and skips scheduling a new refresh if one was started less than 500ms ago. This prevents aggressive AbortController usage that was canceling in-flight requests unnecessarily. Also avoid rescheduling if a debounce timer is already pending.

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

* test(web): update SessionDetail mobile test for new sidebar toggle button

Update the test to expect the new "Toggle sidebar" aria-label instead of the previous "Back to dashboard" label on the mobile floating header.

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

* fix(web): active session amber color, compact session meta row, tighter font controls

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

* fix(web): address review feedback — fontSize rerender, dead code, test cleanup

- DirectTerminal: remove fontSize from main init useEffect deps. A dedicated
  effect below mutates terminal.options.fontSize in place; keeping it in the
  init deps tore down and recreated the terminal (and WebSocket) on every
  stepper click, losing scrollback and flashing content.
- Remove dead ReconnectingPill component — it rendered null with a placeholder
  comment. Delete the file, import, and render site rather than leaving a shell.
- ProjectSidebar: make the done-filter consistent by using getAttentionLevel
  in the sessionsByProject collector. Previously the collector filtered by the
  narrow `status === "done"` while the render sites filtered by the broader
  `getAttentionLevel(s) === "done"`, so the project badge count could include
  merged/killed/terminated sessions that the rendered list hid.
- Drop the two gutted tests that tested JS primitives rather than the component
  (layout-height.test.tsx, DirectTerminal.test.tsx). They provided false
  coverage. Update ProjectSidebar.test.tsx for the new anchor-based session
  rows (click behavior now queries role="link", and clicking the project
  toggle expands rather than navigates — the separate dashboard button handles
  navigation).

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

* fix(web): make SessionDetail topbar tests pass after redesign

Reconcile the rebased-upstream tests with our redesigned SessionDetail:

Component:
- Show session.id in the topbar next to the headline. The ID is a stable
  identifier (useful for copy/paste) while the headline changes with PR/
  issue titles.
- Gate the topbar Orchestrator link on `!isOrchestrator` so we don't link
  the orchestrator page to itself.
- Convert the PR popover toggle from a <button> to an <a href={pr.url}>.
  Plain click still toggles the popover; ctrl/cmd-click opens the PR on
  GitHub in a new tab. aria-label="PR #N" keeps the accessible name stable
  regardless of the rendered chevron.

Tests:
- Mobile tests scope orchestrator link queries to the topbar via
  within(getByRole("banner")) because MobileBottomNav also has an
  "Orchestrator" entry.
- Desktop test opens the PR popover before asserting the PR detail
  contents (blocker chips, file count, unresolved comments) — they now
  live inside the popover rather than inline.

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

* fix(web): restore behavioral tests + remove dead mobileOpen prop

Adds missing behavioral coverage the reviewer flagged on Dashboard.mobile.test.tsx:

- Termination confirmation flow: clicking the kill button once enters a
  confirming state (aria-label becomes "Confirm terminate session") without
  firing the kill request; a second click POSTs to /api/sessions/:id/kill.
- CI check chip rendering: SessionCard renders passing CI check names as chips
  when the session has an enriched PR.

Removes the dead `mobileOpen` prop from ProjectSidebar:

- It was declared but immediately aliased as `_mobileOpen` and never used —
  the actual mobile overlay state is driven by the `sidebar-wrapper--mobile-open`
  class on the parent wrapper div in Dashboard/SessionDetail.
- PullRequestsPage was passing it too but never wired the wrapper class, so
  its hamburger buttons were silently no-ops. Wrapped its sidebar in the same
  sidebar-wrapper + backdrop pattern so the buckled hamburgers now actually
  open the sidebar overlay on mobile.

Not in scope for this round (per reviewer): attention bucket filter /
MobileBottomNav on Dashboard — Dashboard doesn't implement those features,
so there's no behavior to assert. Those would need a feature add, not a test.

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

* fix(core): listCached stale-data bug + drop inline styles in DirectTerminal

Addresses the reviewer's critical and minor feedback on PR #1278.

Critical — listCached was stale across mutation paths

  listCached() had a 35s TTL, invalidated only by spawn() and kill().
  Every other metadata-writing path (claimPR, restore, send, remap,
  cleanup, lifecycle-manager's direct updateMetadata calls for PR
  detection and state transitions) left stale data visible to
  /api/sessions for up to 35s.

  Fix:
  - Wrap updateMetadata / writeMetadata / deleteMetadata inside
    createSessionManager() so every in-file mutation auto-invalidates
    the cache. The raw imports are aliased as _rawXxx and only used
    by the wrappers. No call site needs to remember to invalidate.
  - Expose invalidateCache() on the SessionManager interface for
    callers that write metadata outside this module.
  - lifecycle-manager.ts: call sessionManager.invalidateCache() after
    its two direct updateMetadata sites (PR detection, state update)
    so polling-driven mutations are visible on the next poll.
  - Move the _cache / invalidateCache declarations to the top of the
    closure so the mutation wrappers can reference them.
  - Update createMockSessionManager + api-routes.test.ts mocks to
    satisfy the expanded interface.
  - New regression test: external mutation + sm.invalidateCache() →
    next listCached() re-reads disk.

C-02 — inline styles on the terminal-container div

  DirectTerminal.tsx was using style={{ overflow, display,
  flexDirection, flex, minHeight }} — all static values moved to
  Tailwind utilities: "w-full p-1.5 flex flex-col flex-1 min-h-0
  overflow-hidden".

Not addressed in this commit (noted as out-of-scope refactor):

  C-04 400-line cap on DirectTerminal.tsx. Splitting fontSizeControls
  and touch-scroll wiring into sub-components is worthwhile but
  mechanical — separate PR.

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 00:15:57 +05:30
i-trytoohard 36a64c98b7
chore: bump all package versions to 0.2.5 (#1190)
* chore: release 0.2.5

Realign main with npm registry after off-branch publish of 0.2.3/0.2.4.
Bump all 21 linked packages to 0.2.5 and cherry-pick the startup-grace-period
fix for #989 (was in 5e4244a8 but never merged to main).

Also sync non-linked plugin versions (notifier-discord, notifier-openclaw,
scm-gitlab, tracker-gitlab) to their current npm versions.

* Revert "chore: release 0.2.5"

This reverts commit eb17f32834.

* chore: bump all package versions to 0.2.5, remove release workflow

- Bump all 25 packages to 0.2.5 to realign with npm registry
- Update package-version test to expect 0.2.5
- Remove stale .changeset/linear-spawn-branch-name.md
- Delete .github/workflows/release.yml (changesets-based NPM publish)

---------

Co-authored-by: Prateek <karnalprateek@gmail.com>
Co-authored-by: AO Bot <ao-bot@composio.dev>
2026-04-13 05:47:08 +05:30
fireddd dd5a4c9600 fix(ci): add continue-on-error to coverage comment step
Fork PR tokens cannot create or update comments on the upstream repo.
Adding continue-on-error prevents the entire coverage job from failing
when comment posting is blocked by GitHub permissions.

Closes #913

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 02:28:59 +05:30
fireddd 4844e2eafa fix(ci): handle 403 when updating coverage comment on fork PRs
Fork PR tokens cannot PATCH comments owned by a different integration.
Fall back to creating a new comment when the update fails instead of
failing the entire step.

Closes #913

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 02:25:01 +05:30
fireddd 10f906c4d8 Merge remote-tracking branch 'upstream/main' into ci/coverage-report-913
# Conflicts:
#	.github/workflows/coverage.yml
2026-04-10 02:16:53 +05:30
fireddd 23f4bd6bfe fix(ci): prevent Node.js code injection in plugin directory name
Replace `node -e` with `jq` for reading plugin package names in the
coverage workflow. The previous approach interpolated $plugin_dir
directly into a JavaScript expression, which could allow code injection
via crafted directory names.

Closes #913

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 02:08:01 +05:30
Prateek 967e864f5a chore: rename @composio scope to @aoagents across all packages
Renames all npm package scopes from @composio/* to @aoagents/* and
updates GitHub repo references from ComposioHQ/agent-orchestrator
to aoagents/ao throughout the codebase.

- All package.json names and dependencies
- README badges, links, and install instructions
- Documentation references
- Changeset config
- Source code imports and test files
2026-04-09 15:59:33 +00:00
fireddd a89951b023 fix: remove dead has_coverage output variable (#913)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 17:37:29 +05:30
fireddd 7516929e57 fix: address review feedback — sanitize base_ref and always update comment
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 12:26:50 +05:30
fireddd 568bb6819f chore: remove unnecessary console.log from coverage script (#913)
The report is written to coverage-comment.md — logging it to stdout
just adds noise to CI step output.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 23:13:39 +05:30
fireddd dfae0ee6e5 fix: use --coverage.enabled instead of --coverage flag (#913)
Avoid mixing boolean --coverage flag with dot-notation
--coverage.reporter=json which can conflict in vitest v4.
Use --coverage.enabled for explicit, compatible behavior.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 23:12:32 +05:30
fireddd 6846c89b85 refactor: use istanbul-lib-coverage for accurate line coverage (#913)
Replace manual statementMap parsing with istanbul-lib-coverage's
getLineCoverage() and toSummary() APIs. This handles multi-statement
lines, branch boundaries, and edge cases correctly instead of
approximating from statement spans.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 23:11:01 +05:30
fireddd 06fe717d28 ci: add non-blocking test coverage report to PRs (#913)
Replace the blocking diff-cover workflow with a non-blocking coverage
reporter that posts a PR comment showing lines covered, lines not
covered, overall coverage percentage, and uncovered line ranges —
scoped to only the files changed in the PR.

Closes #913

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 23:00:23 +05:30
Dhruv Sharma 1d8ed3ef8e ci: harden VPS deploy workflow 2026-04-02 12:44:50 +05:30
Dhruv Sharma d0983913fc ci: print runner-visible VPS host fingerprints 2026-04-02 12:41:56 +05:30
Dhruv Sharma 65e971349a ci: add temporary deploy fingerprint recovery mode 2026-04-02 12:39:56 +05:30
Dhruv Sharma 1c1e7e08b1 ci: add temporary VPS fingerprint helper 2026-04-02 12:38:10 +05:30
Dhruv Sharma 2a77fcd0dd ci: force full checkout on VPS deploy 2026-04-02 12:21:00 +05:30
Dhruv Sharma 42866c6a0e ci: add manual VPS deploy trigger for branch testing 2026-04-02 12:13:39 +05:30
Dhruv Sharma 377d312a84 ci: fix VPS deploy guard and fingerprint config 2026-04-02 11:21:43 +05:30
Dhruv Sharma 7534afe61f ci: fix SSH fingerprint to use RSA host key
ED25519 fingerprint was pinned but action negotiates RSA,
causing host key mismatch. Swap to correct RSA fingerprint.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 04:18:51 +05:30
Dhruv Sharma 088c1e5f09 ci: remove SSH host fingerprint check
Throwaway box, fingerprint check was causing mismatch failures.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 04:17:23 +05:30
Dhruv Sharma 2ef3141856 ci: guard against stale reruns and pin SSH host fingerprint
- Skip deploy if CI SHA is not the current tip of main (prevents stale
  rerun from rolling back production)
- Add SSH host fingerprint to prevent MITM during deployment

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 02:14:14 +05:30
Dhruv Sharma 3d9d317cc2 ci: deploy exact CI-passed SHA, ignore PR runs
- Checkout the specific commit that passed CI instead of origin/main tip
- Only deploy on push events to main, skip PR CI runs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 01:34:19 +05:30
Dhruv Sharma c10d620adf ci: wait for CI to pass before deploying to VPS
Uses workflow_run trigger so deploy only runs after CI succeeds on main.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 01:26:48 +05:30
Dhruv Sharma 50589b17de ci: add auto-deploy workflow for VPS
Deploys to Hetzner VPS on every push to main via SSH.
Pulls latest, installs deps, builds, and restarts pm2 services.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 00:05:31 +05:30
Dhruv Sharma bb8ac3a68d add GitHub Actions workflow for deploying to VPS 2026-03-31 23:44:16 +05:30
fireddd 68eafe9f0d ci: only run coverage for packages with changed files
Uses dorny/paths-filter to detect which packages have changes.
Tests and coverage only run for affected packages — e.g. a
web-only change won't trigger core or CLI tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 09:28:31 +05:30
fireddd e6c3a06427 ci: add diff coverage workflow enforcing 80% on changed code only
Uses diff-cover to check that newly added or modified lines in PRs
have at least 80% test coverage, without requiring the entire
codebase to meet the threshold.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 09:26:36 +05:30
harshitsinghbhandari b49c69ba78 fix: last 10 commits for secrets check 2026-03-27 22:38:58 +05:30
copilot-swe-agent[bot] 91117f9e36
ci: fix gitleaks checksum filename, install dir, fetch-depth, and PR SHA fetch
Agent-Logs-Url: https://github.com/ComposioHQ/agent-orchestrator/sessions/87c2a42f-535a-4051-ab50-1f3fcc5aac1d

Co-authored-by: harshitsinghbhandari <212377671+harshitsinghbhandari@users.noreply.github.com>
2026-03-27 05:26:08 +00:00
harshitsinghbhandari c762fa1453 ci: add gitleaks checksum verification and optimize fetch-depth 2026-03-27 10:08:56 +05:30
AO Bot 70fe5369c3 fix: use correct gitleaks --log-opts syntax with commit range (#721)
The previous approach incorrectly used --since-commit (not a valid
gitleaks flag) and --log-opts= (equals syntax not supported). Fix by
using --log-opts with a space separator and a proper git commit range
(base..head) for PR scans.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 14:25:52 +00:00
AO Bot f0bcb7b758 fix: replace gitleaks-action v2 with free CLI to fix org license error (#721)
The gitleaks/gitleaks-action@v2 requires a paid license for organization
repos, causing the Security workflow to fail on every PR. Replace it with
a direct `gitleaks detect` CLI invocation which is free and doesn't
require a license key.

On PRs, the scan is scoped to only the commits in the PR (via
--since-commit) for faster feedback. On push/schedule, it scans the
full repo history.

Closes #721

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 14:23:06 +00:00
suraj-markup ebfa33c978 fix: disable husky in release workflow to unblock npm publish
The changesets action commits version bumps internally, but the husky
pre-commit hook requires gitleaks which isn't installed on the CI runner.
Set HUSKY=0 to skip hooks in the release job.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 18:44:37 +05:30
prateek 5ffd063550
ci: add workflow_dispatch trigger to release workflow (#153)
Allows manual triggering of the release workflow for testing.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 22:21:44 +05:30
prateek 59c490a3af
fix: dashboard config discovery + CLI service layer refactoring (#70)
* fix: config discovery, activity detection, and metadata port storage

- findConfigFile() checks AO_CONFIG_PATH env var (resolved to absolute path)
- loadConfig() delegates to findConfigFile() for consistent validation
- Pure Node.js readLastJsonlEntry (no external tail binary), safe for
  multi-byte UTF-8 at chunk boundaries
- Added "ready" activity state to agent plugins
- Store dashboardPort, terminalWsPort, directTerminalWsPort in session
  metadata so ao stop targets the correct processes
- Zod schema port default aligned with TypeScript interface

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

* fix: dashboard config discovery + CLI service layer refactoring

- Config discovery via AO_CONFIG_PATH env var
- Auto port detection with PortManager
- Activity detection with ready state, pure Node.js readLastLine
- 5 CLI services: ConfigService, PortManager, DashboardManager, MetadataService, ProcessManager
- Store all service ports in metadata for ao stop
- Set NEXT_PUBLIC_ env vars for frontend terminal components
- Multi-byte UTF-8 safe readLastJsonlEntry
- Tests for all new services and utils

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

* fix: address bugbot review comments (port fallback + systemPrompt)

1. Align port fallback to 3000 everywhere (matching Zod schema default):
   - start.ts: config.port ?? 3000
   - dashboard.ts: config.port ?? 3000
   - types.ts JSDoc: "defaults to 3000"
   - orchestrator-prompt.ts: already correct at 3000

2. Add --append-system-prompt to Claude Code plugin's getLaunchCommand
   so orchestrator context is actually passed to the Claude agent.
   Previously systemPrompt was generated but silently dropped.

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

* fix: remove dead ConfigService mock from status test

The vi.mock for ConfigService.js referenced a deleted module.
Config mocking is already handled by the @composio/ao-core mock.

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

* fix: extract shared buildDashboardEnv to eliminate duplication

Dashboard env construction (AO_CONFIG_PATH, PORT, NEXT_PUBLIC_*) was
duplicated between start.ts and dashboard.ts. Extracted into
buildDashboardEnv() in web-dir.ts (already shared by both commands).

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 17:08:48 +05:30
prateek dcfee04e1b
fix: terminal servers compatible with hash-based architecture (#87)
* fix: terminal servers compatible with hash-based architecture

The terminal WebSocket servers (direct-terminal-ws and terminal-websocket)
used config.dataDir to validate sessions, which no longer exists in the
hash-based architecture. Also fixed node-pty failing to find tmux via
posix_spawnp.

Changes:
- Remove config.dataDir dependency, validate via `tmux has-session` instead
- Add resolveTmuxSession() to map user-facing IDs (ao-15) to hash-prefixed
  tmux names (8474d6f29887-ao-15)
- Use explicit tmux path discovery (findTmux) since node-pty's posix_spawnp
  doesn't reliably inherit PATH
- Include /opt/homebrew/bin in fallback PATH for macOS ARM

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

* fix: add session resolution to ttyd server and use exact tmux matching

- Add findTmux() and resolveTmuxSession() to terminal-websocket.ts
  (previously only in direct-terminal-ws.ts), fixing hash-prefixed
  session lookup for the ttyd-based terminal server
- Use tmux exact match prefix (=sessionId) in has-session checks
  to prevent ao-1 from matching ao-15 via prefix matching
- Add server compatibility tests that verify both servers handle
  hash-based architecture correctly (14 tests)
- Include server/ in tsconfig and vitest config for typecheck coverage

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

* refactor: extract tmux-utils and add proper unit tests

- Extract findTmux(), resolveTmuxSession(), validateSessionId() into
  shared server/tmux-utils.ts — eliminates duplication between
  direct-terminal-ws.ts and terminal-websocket.ts
- Add 20 real unit tests with injected mocks that test actual behavior:
  - findTmux: candidate priority, fallback to bare name
  - resolveTmuxSession: exact match, hash-prefix resolution,
    = prefix for preventing tmux prefix matching (ao-1 vs ao-15),
    null when no session found, tmux not running
  - validateSessionId: path traversal, shell injection, whitespace
- Slim down server-compatibility.test.ts to 10 structural checks
  (imports tmux-utils, no loadConfig, no config.dataDir, no existsSync)

Total: 30 tests — all pass on fix branch, 8 fail on main

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

* test: add real integration tests for direct-terminal-ws

- Refactor direct-terminal-ws to export createDirectTerminalServer()
  factory so tests can control server lifecycle without side effects
- Add 10 integration tests that create real tmux sessions, start the
  real server, connect via WebSocket, and verify the full flow:
  - Health endpoint returns 200
  - Missing session parameter → close 1008
  - Path traversal in session ID → close 1008
  - Shell injection in session ID → close 1008
  - Nonexistent tmux session → close 1008
  - Real tmux session → connects and receives terminal output
  - Hash-prefixed session resolution works end-to-end
  - Can send input and receive echoed output
  - Resize messages don't crash the connection
  - Unknown HTTP path → 404
- Tests create/destroy tmux sessions in beforeAll/afterAll
- Server runs on random port (port 0) to avoid conflicts
- Total test suite: 40 tests (20 unit + 10 compatibility + 10 integration)

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

* ci: add web server tests to CI pipeline

The web package was explicitly excluded from CI test runs
(pnpm -r --filter '!@composio/ao-web' test). Add a test-web job
that installs tmux, starts the tmux server, and runs the web
package tests (unit + integration).

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

* fix: address CI failures and bugbot review comments

- Fix lint: replace require() with ESM import in integration test
- Fix base-path mismatch: ttyd now uses user-facing sessionId for
  --base-path/URL and actual tmux name for attach-session
- Fix bare "tmux" in ttyd spawn args: use TMUX constant (full path)
- Scope CI test-web job to server/__tests__/ to avoid pre-existing
  failures in src/__tests__/

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

* test: comprehensive unit and integration test coverage

Unit tests (77): validateSessionId covers all injection vectors (shell,
path traversal, command substitution, special chars, unicode, control
chars), findTmux covers all candidate paths and error types,
resolveTmuxSession covers exact match, hash-prefix resolution, suffix
matching precision, edge cases (single char, long lists, multiple
hyphens, different tmux paths).

Integration tests (45): health endpoint lifecycle (active count tracks
connections/disconnections), HTTP routing (404s for all non-health
paths), WebSocket validation (11 injection/traversal vectors), terminal
connection (resize, multi-resize, invalid JSON, non-resize JSON),
hash-prefixed resolution (suffix match, command passthrough, session key
tracking, cross-match prevention), terminal I/O (Ctrl-C, Tab, Enter,
empty messages, rapid keystrokes, multi-line), connection lifecycle
(cleanup, rapid connect/disconnect, error recovery), server creation
(independent instances).

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

* fix: validate 12-char hex prefix in hash-prefixed session resolution

The previous endsWith("-{sessionId}") suffix match was ambiguous:
"hash-my-app-1" would falsely match a lookup for "app-1". Now validates
that the prefix matches the exact format generated by generateConfigHash
(12-char lowercase hex) before comparing the remainder.

Added unit tests for the ambiguity case and invalid prefix formats.
Updated integration test session names to use proper 12-char hex prefixes.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 03:28:55 +05:30
prateek 599710296d
fix: migrate to hash-based project isolation architecture
Complete migration to hash-based directory structure for project isolation. All bugbot issues resolved.
2026-02-18 00:19:55 +05:30