Commit Graph

381 Commits

Author SHA1 Message Date
Priyanshu Choudhary 32345ba856 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>
2026-05-05 02:41:01 +05:30
Priyanshu Choudhary d04fad33ab 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>
2026-05-05 02:36:54 +05:30
Priyanshu Choudhary 7f1cb8a92a 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>
2026-05-05 02:03:17 +05:30
Priyanshu Choudhary deaf25a031 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>
2026-05-04 20:28:16 +05:30
Priyanshu Choudhary 41d44af32b Merge remote-tracking branch 'origin/main' into feat/windows-platform-adapter
# Conflicts:
#	packages/cli/src/commands/start.ts
#	packages/core/src/session-manager.ts
#	packages/plugins/agent-claude-code/src/index.ts
2026-05-04 15:47:14 +05:30
Harshit Singh Bhandari cd6d0292b6
refactor(cli): collapse running/not-running fork via ensureDaemon (PR B.2) (#1626)
* refactor(cli): collapse running/not-running fork via ensureDaemon (PR B.2)

Replaces the three branches that handled "AO is already running" cases in
start.ts (§3.2 URL/path-while-running, §3.3 project-id-while-running,
§3.5 non-human info dump) with a single attach pipeline that runs after
resolveOrCreateProject. The fork between attach and spawn is now a single
post-resolve decision point.

New module packages/cli/src/lib/daemon.ts owns the daemon side of that
fork:

  - attachToDaemon(running) -> AttachedDaemon { port, pid,
    notifyProjectChange() } — pure handle plus a typed
    {ok}|{ok:false,reason} cache-invalidation result, replacing the
    open-coded fetch + try/catch that lived in two places.
  - killExistingDaemon(running) — SIGTERM -> waitForExit -> SIGKILL ->
    unregister, used by the "Restart everything" menu option. Replaces
    the inline restart code in §3.4.

resolve-project.ts gains an opt:

  - { targetGlobalRegistry?: boolean } — when true, fromUrl and fromPath
    register against the global config (the daemon's source of truth)
    rather than into a cwd-local one. fromUrl's "register globally"
    branch is a near-verbatim move of the §3.2 inline clone+register
    block, including the global-registry dedup and the flat-local-config
    write that §3.2 deliberately preferred over fromUrl's wrapped yaml.
    fromCwdOrId short-circuits straight to the global registry for
    project-id args while running.

The new dispatch in start.ts:

  - Running + non-human + (no arg | URL | path) -> info dump, exit 0
    (preserved §3.5 behavior; project-id args still fall through to
    attach+spawn so automation can `ao start <id>` against a live
    daemon).
  - Running + human + no arg -> menu (preserved §3.4: open / quit /
    add / new / restart). "restart" now routes through
    killExistingDaemon and falls through to the spawn path; "new" sets
    startNewOrchestrator and falls through to the attach path.
  - resolveOrCreateProject(arg, deps, { targetGlobalRegistry: !!running })
  - Running -> attachAndSpawnOrchestrator helper (§3.2 short-circuit
    preserved: URL/path arg whose project is already in
    running.projects skips the orchestrator-spawn and just opens the
    dashboard).
  - Not running -> existing runStartup + register + handlers.

attachAndSpawnOrchestrator unifies the §3.2/§3.3 messaging behind a
single justCreated discriminator:

  - justCreated=true (URL clone or path register): "Spawning
    orchestrator session..." -> "Project '...' registered in the global
    config." -> "Orchestrator session ready: ..."
  - justCreated=false (project id or already-registered path):
    "Attaching to running AO instance..." -> "Orchestrator session
    ready: ..." -> "Project '...' reattached to running daemon (PID
    ...)"

Both flows then notifyProjectChange (warns on failure, never throws —
the dashboard might be down), print the lifecycle-attach notice when
the project isn't yet supervised, and either openUrl (human) or print
the URL (non-human).

Subsumes the B.1 follow-up (migrate §3.2 inline clone+register to share
fromUrl): the inline block is deleted outright by the fork collapse and
fromUrl now owns both the not-running and the global-registry cases.

start.ts: -1126 / +131 lines. New daemon.ts: 105 lines. resolve-project.ts:
+167 lines (the global-registry branch + a moved-from-start.ts
detectClonedRepoDefaultBranch helper).

No behavior change. Test count and failure set are identical to
upstream/main; the 8 stop-command failures pre-exist on fad75b63.
8 new daemon.test.ts tests cover attachToDaemon (port/pid wiring,
notifyProjectChange success / non-2xx / fetch-throws) and
killExistingDaemon (SIGTERM happy path, SIGKILL escalation, throw on
both-fail, ESRCH-on-already-dead).

Step 2 of PR B in ao-118's start.ts refactor plan
(~/.ao/agent-orchestrator/ao-118/plan.md). startNewOrchestrator and the
§3.4 menu options remain intact — those land in PR B.3.

* fix(cli): canonicalize paths and guard reload in fromPath global branch

Two review fixes on resolve-project.ts:

1. Restore realpathSync canonicalization in fromPath's global-registry
   branch. The original §3.2 inline block in start.ts canonicalized both
   sides before comparing, so an `ao start /tmp/foo` against a daemon
   whose global config stored /private/tmp/foo (macOS symlink) would
   dedupe correctly. The B.2 collapse used plain resolve() and would
   miss the match, calling addProjectToConfig and double-registering
   the project. New canonicalize() helper mirrors the elsewhere-used
   try-realpathSync-fallback pattern.

2. Add the missing null guard on reloaded.projects[addedId] in the same
   branch, matching fromUrlIntoGlobal's existing guard. addProjectToConfig
   could persist nothing on a write-permission error that doesn't throw,
   in which case the undefined project would propagate into
   generateOrchestratorPrompt with a useless stack trace; an explicit
   "Failed to register" error is what the URL branch already raises.

* fix(cli): scope daemon test spy and correct add-menu comment

Two review fixes:

1. Move the process.kill spy in daemon.test.ts inside beforeEach +
   afterEach (vi.restoreAllMocks). The previous module-scope spy could
   leak into sibling test files when Vitest reuses worker threads,
   silently mocking process.kill in unrelated suites and producing
   confusing failures.

2. Rewrite the misleading comment on the "add" menu branch in start.ts.
   The previous wording claimed the path "intentionally does not register
   globally", but loadConfig() walks up from cwd and returns the global
   config as a canonical fallback — so addProjectToConfig may register
   globally in that common case. The new comment honestly describes the
   canonical-aware behavior and the intentional skip of orchestrator
   spawn (the "add" choice is distinct from "new").
2026-05-04 14:28:07 +05:30
Harshit Singh Bhandari caa7f60a4b
refactor(spawn): plugin-owned preflight + collapse project resolution (#1622)
* feat(core): add PreflightContext + optional preflight() to plugin interfaces

Foundation for PR 2 of the ao spawn refactor: lets plugins own their own
prerequisites instead of the CLI hardcoding 'if runtime === tmux check
tmux' / 'if tracker === github check gh auth' switches.

PreflightContext describes intent (willClaimExistingPR, role) rather
than CLI flag names, so plugins never learn about flags. New flags map
to new intent fields only when a plugin actually needs them.

Adds preflight?(ctx) as an optional method on Runtime, Agent, Workspace,
Tracker, SCM. Backwards-compatible: existing plugins keep working
unchanged. Subsequent commits move checkTmux into runtime-tmux and
checkGhAuth into the github plugins, then update spawn.ts to iterate
selected plugins instead of switching on plugin names.

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

* feat(plugins): implement preflight() in runtime-tmux + tracker-github + scm-github

Each plugin now owns its own prerequisite checks (tmux binary, gh auth)
behind the optional PluginModule preflight() contract added in the
previous commit. The CLI no longer needs to know which plugin needs
which tool — it just iterates the selected plugins.

- runtime-tmux: checks 'tmux -V' and throws with platform-appropriate
  install hint (brew / apt / dnf / WSL)
- tracker-github: checks 'gh --version' and 'gh auth status'
  unconditionally (tracker is exercised on every spawn that has an
  issueId AND on lifecycle polling for issue closure)
- scm-github: same gh auth checks but only when the spawn will exercise
  PR-write paths — gates on context.intent.willClaimExistingPR

Subsequent commit refactors the CLI to iterate plugins instead of
hardcoded 'if runtime === tmux' switches.

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

* refactor(cli): make ao spawn iterate plugin preflight, collapse project resolution

Three small changes bundled because they all touch spawn.ts:

1. Plugin-iterating preflight: replaces the hardcoded
   'if runtime === tmux check tmux' / 'if tracker === github check gh
   auth' switches in runSpawnPreflight with a 4-line loop that walks the
   selected plugins and calls each one's optional preflight(). Plugin
   internals are no longer leaked into the CLI; new plugins only need to
   declare their own preflight.

2. Project-resolution collapse: the prefix/no-prefix and issue/no-issue
   paths previously had three near-duplicate code blocks each with its
   own try/catch around autoDetectProject. Replaced by one
   resolveProjectAndIssue() helper that uses resolveSpawnTarget's
   fallback parameter — caller wraps in a single try/catch.

3. Micro-deletes: drop the unused 'return session.id' in spawnSession
   (callers already ignore it; the SESSION=<id> stdout line is the
   scriptable contract). Drop checkTmux/checkGhAuth from lib/preflight.ts
   (now in their respective plugins) along with their orphaned tests.

LOC: roughly net-zero. Wins are structural — adding runtime-podman /
tracker-jira / scm-bitbucket no longer requires editing spawn.ts.

Pre-existing start.test.ts 'stop command' failures are unrelated (verified
on upstream/main bare).

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

* perf(plugins): dedupe gh-auth check across tracker-github + scm-github

Address greptile P2 on PR #1622: when a project has both tracker:
github and scm: github with --claim-pr, both plugin preflights ran
'gh --version' + 'gh auth status' independently — 4 execs where 2
suffice, and two identical error messages on failure.

Add memoizeAsync(key, fn) to core (process-scoped Promise cache) and
have both github plugins share the key 'gh-cli-auth'. Second caller
hits the in-flight (or resolved) promise — zero extra subprocess
overhead, one error on failure.

Caches both successes and rejections: failed checks should never
re-run within a process (cache dies with the CLI, user fixes the
underlying issue and re-invokes).

5 unit tests for memoizeAsync covering: single-fire dedup, value
identity, distinct keys, rejection caching, concurrent in-flight dedup.

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

* refactor(spawn): collect-all preflight + per-plugin tests + key-namespacing docs

Address self-review feedback on PR #1622:

1. **Collect-all preflight** (spawn.ts): runSpawnPreflight previously
   aborted at the first plugin's failure, so a user with multiple broken
   prereqs (tmux missing AND gh logged out) had to fix-and-retry to
   discover the second one. Now collects every plugin's error and
   reports them together ("2 preflight checks failed:\n  1. ...\n
   2. ..."). Single-failure path is unchanged — that error throws as-is
   without the wrapper. Test added: 'collects every plugin's preflight
   failure into one combined error'.

2. **Drop redundant workspace literal fallback** (spawn.ts):
   DefaultPluginsSchema in core/config.ts applies .default("worktree")
   to workspace, same as runtime/agent. The literal '?? "worktree"'
   was asymmetric defensive theater — dropped to match the runtime/agent
   form.

3. **memoizeAsync key-namespacing convention** (process-cache.ts):
   Added a JSDoc section documenting that two callers using the same
   key get shared state (intentional for cross-cutting checks like
   gh-cli-auth, dangerous for plugin-internal caching). Recommends
   namespacing plugin-internal keys as 'plugin-name:thing'.

4. **Per-plugin preflight unit tests**:
   - runtime-tmux: tmux-present resolves; tmux-missing throws with
     platform-specific install hint (verified per-platform branch)
   - tracker-github: happy path, gh-not-installed, gh-not-authenticated
   - scm-github: no-op when willClaimExistingPR=false (zero gh calls),
     full check when true, plus install/auth failure branches

   Process cache cleared in beforeEach so each test starts fresh.
   Required exporting _clearProcessCacheForTests from core/index.ts
   (matches existing _testUtils pattern in gh-trace.ts).

Pre-existing start.test.ts 'stop command' failures unchanged
(verified on bare upstream/main).

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

* fix(test): collapse duplicate @aoagents/ao-core import in tracker-github test

eslint no-duplicate-imports caught it on CI — combined the value and
type-only imports into one statement.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 14:03:26 +05:30
Harshit Singh Bhandari fad75b6323
refactor(cli): extract resolveOrCreateProject for the not-running path (#1621)
Replaces the per-arg-shape dispatch that lived inline in start.ts (URL,
path, project id, no arg) with a single resolveOrCreateProject(arg, deps)
call. The new module dispatches internally to fromUrl/fromPath/fromCwdOrId
helpers and returns a uniform { config, projectId, project, source,
justCreated, parsed? } shape.

Behavior preserved exactly — fromUrl is a near-verbatim move of
handleUrlStart's body (which is now deleted as it had no other callers),
fromPath mirrors the path branch's loadConfig/autoCreate/addProject
cascade, and fromCwdOrId carries over the registerFlatConfig recovery
path with the same semantics.

Dependencies that live in start.ts (addProjectToConfig, autoCreateConfig,
resolveProject, resolveProjectByRepo, registerFlatConfig, cloneRepo) are
passed in via a ResolveDeps object. This keeps the new module decoupled
from start.ts's other concerns (interactive prompts, agent detection,
project type detection) without creating a circular import.

Scope note: the "AO is already running + URL/path arg" branch in start.ts
still has its own inline clone+register block. That block deliberately
diverged from handleUrlStart (it writes a flat local config, not the
legacy wrapped one) — migrating it to share fromUrl is a small follow-up
before PR B.2 collapses the running-vs-not-running fork.

Step 1 of PR B in the start.ts refactor plan
(~/.ao/agent-orchestrator/ao-118/plan.md). Net diff: start.ts shrinks by
~146 lines, new file adds 294. Test count and failure set are identical
to upstream/main.
2026-05-04 09:44:07 +05:30
i-trytoohard ef8ac42dd4
chore: release 0.4.0 (#1625)
* chore: release 0.4.0

Consume 33 changesets across the linked package group. All public
packages bumped to 0.4.0 and published to npm.

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

* test(agent-codex): bump package-version assertion to 0.4.0

Release gate test was still asserting 0.3.0 after the 0.4.0 bump.

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

* chore: align CHANGELOG headers with @aoagents npm scope

The H1 of every package CHANGELOG.md still read @composio/* from
before the npm scope rename. Body entries that historically reference
@composio/* are left intact — they document what was true at the time
of those releases.

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

---------

Co-authored-by: Prateek <karnalprateek@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 06:57:24 +05:30
Harshit Singh Bhandari b98d8eca8e
refactor(cli): extract preflight + shutdown from start.ts (PR A) (#1617)
* refactor(cli): extract runtime preflight and install helpers from start.ts

Pulls scattered preflight logic out of start.ts into two focused modules:

- lib/install-helpers.ts: shared install primitives (askYesNo,
  runInteractiveCommand, tryInstallWithAttempts, genericInstallHints,
  canPromptForInstall, InstallAttempt) — used by ensureGit/ensureTmux,
  the agent runtime installer, and the optional gh install path.

- lib/startup-preflight.ts: the runtime checks themselves (ensureGit,
  ensureTmux, warnAboutLegacyStorage, warnAboutOpenClawStatus) plus a
  top-level runtimePreflight(config) that orchestrates tools + state
  warnings + idle-sleep + OpenClaw credentials. Distinct from the
  existing lib/preflight.ts, which validates dashboard build artifacts.

start.ts now calls runtimePreflight(config) once at the top of
runStartup instead of inlining 32 lines of orchestration; ensureGit is
imported for the three callsites that still need it directly (URL
clone, addProjectToConfig, URL-while-running branch).

No behavior change. Test count and failure set are identical to
upstream/main (8 pre-existing failures in the stop command tests).

Step 1 of PR A in the start.ts refactor plan
(~/.ao/agent-orchestrator/ao-118/plan.md). start.ts shrinks by 301
lines; net +55 LOC across the touched files (the abstractions cost
some interface overhead, as expected).

* refactor(cli): extract shutdown handler from start.ts

Moves the SIGINT/SIGTERM handler out of runStartup's closure into
lib/shutdown.ts as installShutdownHandlers({ configPath, projectId }).
The handler logic is identical: stop lifecycle workers, kill all
active sessions, record last-stop state for restore on next ao start,
unregister from running.json, await the bun-tmp janitor's final
sweep, then exit with the right code (130 for SIGINT, 0 for SIGTERM).

Also drops three now-unused start.ts imports (stopProjectSupervisor,
stopAllLifecycleWorkers, stopBunTmpJanitor) — they're consumed inside
the new shutdown module.

Note: the equivalent kill-and-record loop in `ao stop` is left
untouched. It has different verbosity (spinner, warnings, per-project
output) and different options (--purge-session) than the signal
handler. Unifying them is a behavior-shaping change that belongs with
the daemon/stop restructure in PR B, not this mechanical extraction.

Step 2 of PR A in the start.ts refactor plan
(~/.ao/agent-orchestrator/ao-118/plan.md). Test count unchanged (8
pre-existing stop-command failures from upstream/main, same set).

* refactor(cli): address PR review — idempotent shutdown + correct legacy count

Two P2 review comments from greptile-apps[bot]:

1. shutdown.ts: the 'idempotent' JSDoc claim was unbacked — shuttingDown
   was a per-invocation closure, so calling installShutdownHandlers
   twice would register duplicate listeners. Add a module-level
   handlersInstalled guard and hoist shuttingDown to module scope so
   the abstraction matches its docstring.

2. startup-preflight.ts: warnAboutLegacyStorage gates on the count of
   non-empty hash dirs but printed hashDirs.length (total). Pre-existing
   bug in the original start.ts code — a user with mostly-empty hash
   dirs would see an inflated migration count. Renamed sessionCount →
   nonEmptyDirCount (the variable always counted dirs, not sessions)
   and used it in the message.
2026-05-03 22:43:26 +05:30
Priyanshu Choudhary 0219433c37 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>
2026-05-03 18:43:32 +05:30
Priyanshu Choudhary 6430f7d38b 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>
2026-05-03 17:59:49 +05:30
Priyanshu Choudhary 993953d9da 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>
2026-05-01 22:15:50 +05:30
Priyanshu Choudhary 619be5934b Merge remote-tracking branch 'origin/main' into feat/windows-platform-adapter
# Conflicts:
#	agent-orchestrator.yaml.example
#	packages/cli/__tests__/commands/start.test.ts
#	packages/cli/__tests__/scripts/update-script.test.ts
#	packages/cli/src/commands/dashboard.ts
#	packages/cli/src/lib/dashboard-rebuild.ts
#	packages/core/src/__tests__/agent-report.test.ts
#	packages/core/src/__tests__/lifecycle-manager.test.ts
#	packages/core/src/index.ts
#	packages/web/server/mux-websocket.ts
#	packages/web/src/__tests__/filesystem-browse-api.test.ts
#	pnpm-lock.yaml
2026-05-01 21:23:49 +05:30
Priyanshu Choudhary 016575e0f2 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.
2026-05-01 21:19:59 +05:30
Ashish Huddar 2306078761
feat: add SQLite-backed activity event logging layer (#1528)
* feat: add SQLite-backed activity event logging layer

Implements a structured diagnostic event trail for the orchestrator.
When unexpected behavior occurs (stuck sessions, silent CI failures,
missed PR transitions), operators can now reconstruct timelines with
`ao events` rather than guessing from logs.

Key design decisions driven by Codex review:
- FTS5 external-content table uses INSERT/DELETE triggers so search
  actually works without manual rebuild
- ts_epoch (epoch ms) used for all time comparisons to avoid text vs
  SQLite datetime() ambiguity near cutoff
- PRAGMA busy_timeout=3000 handles WAL lock contention across
  CLI/lifecycle/web processes
- user_version schema versioning for future migrations
- EventType renamed to ActivityEventKind to avoid collision with
  existing types.ts export
- Event names match existing vocabulary (ci.failing, review.pending)
- ActivityStateCache (Map) tracks previous activity state so
  lifecycle-manager can emit activity.transition diffs
- session.spawn_failed captures failed spawns via wrapper try/catch
- better-sqlite3 in optionalDependencies: AO keeps working if native
  build fails; getDb() returns null and writes become no-ops

New files:
- packages/core/src/events-db.ts — lazy DB init, WAL, schema+triggers
- packages/core/src/activity-events.ts — write API, sanitizer
- packages/core/src/query-activity-events.ts — query + FTS + stats
- packages/cli/src/commands/events.ts — `ao events list/search/stats`

Wired into:
- lifecycle-manager.ts: lifecycle.transition + activity.transition
- session-manager.ts: session.spawned, session.spawn_failed, session.killed

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

* fix(activity-events): address PR review comments

- redactValue: remove value-string regex check that silently erased
  error messages mentioning auth terms (key-based redaction is sufficient)
- sanitizeData: reject payloads >16KB instead of slicing (sliced JSON
  is malformed and corrupts JSON output)
- lifecycle-manager: prune activityStateCache alongside states in the
  per-poll stale-entry cleanup loop (prevents unbounded growth)
- events CLI: warn on unrecognised --since duration format instead of
  silently applying no time filter
- searchActivityEvents: accept optional projectId and push it into SQL
  WHERE clause instead of post-LIMIT in-memory filter

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

* fix(activity-events): review pass fixes

- formatRow: pad level string before chalk-wrapping so ANSI codes
  don't corrupt column alignment in ao events output
- events-db: add PRAGMA synchronous=NORMAL (WAL+NORMAL is standard
  recommendation; avoids per-write fsync in the poll hot path)
- events-db: emit console.warn when _dbFailed is set so operators know
  events are being dropped instead of failing silently forever
- activity-events: remove dead redactValue wrapper (was a no-op after
  the previous fix; call site now directly assigns the value)
- activity-events: remove unused session.cleanup from ActivityEventKind
  (no call site emitted it; dead API surface)
- query-activity-events: add optional limit param to searchActivityEvents
  (default 100, max 1000, parameterized); add --limit flag to ao events search

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

* fix(activity-events): widen source/kind to allow plugin-defined values

ActivityEventInput.source and .kind accept ActivityEventSource|string
and ActivityEventKind|string respectively, so new event sources (e.g.
scm-github, notifier-slack) don't require editing core types.

The named union members are preserved for IDE autocomplete on known values.

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

* fix(events): level column alignment, filter kind widening, negative limit guard

- events.ts: padEnd(5) → padEnd(9) so level column aligns with the 9-char LEVEL header
- query-activity-events.ts: ActivityEventFilter.kind widened to ActivityEventKind|string
  for consistency with ActivityEventInput.kind (plugin-defined kinds can now be queried)
- query-activity-events.ts: negative/NaN limit values sanitized with Number.isFinite +
  Math.max(1,...) to prevent SQLite LIMIT -N returning the full table

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

* fix(events): FTS alias, credential URL sanitization, periodic retention

- query-activity-events.ts: use full table name in MATCH (activity_events_fts
  MATCH ?) instead of alias to avoid 'no such column: fts' in some FTS5 builds
- activity-events.ts: redact https://token@host URL credentials in string values;
  add hourly retention sweep so long-lived processes don't grow DB indefinitely
- events-fts-integration.test.ts: real SQLite integration test for FTS5 search,
  projectId filter, and epoch-based time filter

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

* fix(lint): remove inline import() type annotations in integration test

ESLint @typescript-eslint/consistent-type-imports forbids import() in type
positions — replaced with any to keep the test working without the type imports.

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

* fix(events): parse data to structured JSON in --json output; add test proving value sanitizer correctness

- ao events list/search --json now parses ActivityEvent.data from JSON
  string back to a structured object, making it jq-friendly without
  requiring double-parsing by callers (P1 finding from PR review)
- Add test "preserves error messages that mention sensitive words in
  values" to refute Greptile's false-positive P1 finding: SENSITIVE_KEY_RE
  only matches key names, not string values; "token expired" and
  "authorization header missing" values are preserved correctly

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

* fix(events): BM25 relevance ordering for FTS search; add versioned JSON envelope

Search now orders by FTS5 rank (BM25) instead of ts_epoch, returning most relevant
events first. --json output now wraps events in { version, query, meta, events }
for stable CLI contracts.

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

* fix(events): quote FTS tokens to prevent false negatives on operator-like terms

Searching for words like "OR", "AND", or "NOT" would produce empty results
because SQLite FTS5 treated them as operators after token joining. Wrapping
each token in double quotes forces literal phrase matching.

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

* fix(tests): update FTS assertion for quoted tokens; raise timeout on slow audit test

query-activity-events test expected the old unquoted join format; update to
match the quoted form added in the previous commit. The agent-report audit
trail test occasionally exceeds the 5 s default on CI runners; raise to 15 s.

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

* fix(qa): ISSUE-001 - type events stats entries

* test(events): cover session and lifecycle event call sites

* test(core): wait for lifecycle branch adoption

* chore: add activity events changeset

* fix activity event review feedback

* batch prune old activity events

* record activity event when spawn starts

* Fix activity event FTS rebuild and sanitization guard

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 21:13:20 +05:30
Ashish Huddar 9ca3c1fcd7
fix: force launcher relink during update (#1594)
* fix: force launcher relink during update (#1591)

* fix: address launcher refresh review feedback (#1591)

* fix: improve launcher refresh diagnostics (#1591)
2026-05-01 17:09:58 +05:30
Ashish Huddar 0a9ba4cd7f
fix: protect live dashboard artifacts (#1598)
* fix: protect live dashboard artifacts (#1589)

* fix: address dashboard artifact review (#1589)

* fix: handle dashboard rebuild port reassignment (#1589)
2026-05-01 16:09:57 +05:30
Ashish Huddar e94ff28106
fix(cli): supervise lifecycle workers for active projects (#1600)
* Refine issue checklist for dynamic lifecycle supervisor

* Harden project supervisor reconcile handling

* fix(cli): surface supervisor startup failures

* fix(cli): allow missing global config on startup
2026-05-01 15:45:06 +05:30
Copilot e548584130
chore: align workspace package.json versions with npm registry (#1587)
* Initial plan

* chore: bump all workspace package versions from 0.2.5 to 0.3.0

Agent-Logs-Url: https://github.com/ComposioHQ/agent-orchestrator/sessions/da5b2769-e7d4-4d08-a60c-bd5f695d1ca7

Co-authored-by: harshitsinghbhandari <212377671+harshitsinghbhandari@users.noreply.github.com>

* fix: update package-version test to expect 0.3.0

Agent-Logs-Url: https://github.com/ComposioHQ/agent-orchestrator/sessions/ad61e33e-417f-4482-b06c-0b60826b7f2d

Co-authored-by: harshitsinghbhandari <212377671+harshitsinghbhandari@users.noreply.github.com>

* chore: revert non-ao version bumps

Only @aoagents/ao drives the 'ao update available' prompt
(packages/cli/src/lib/update-check.ts compares against the
@aoagents/ao registry version and reads the local @aoagents/ao
package.json). All other workspace bumps are unnecessary.

* chore: align workspace versions with npm registry

Catch up source-of-truth package.json versions to what is already
published on npm. The registry reflects releases done via Changesets;
the in-tree files had drifted to 0.2.5.

  0.2.5 -> 0.3.0: cli, core, web, agent-aider, agent-claude-code,
                  agent-codex, agent-opencode, notifier-composio,
                  notifier-desktop, notifier-slack, notifier-webhook,
                  runtime-process, runtime-tmux, scm-github,
                  terminal-iterm2, terminal-web, tracker-github,
                  tracker-linear, workspace-clone, workspace-worktree
  0.2.5 -> 0.2.6: notifier-discord, notifier-openclaw, scm-gitlab,
                  tracker-gitlab
  0.1.0 -> 0.1.1: agent-cursor

Also updates agent-codex package-version.test.ts to expect 0.3.0.

* test(cli): use future version in update-check cache test

The cache-fresh test assumed getCurrentVersion() returned a value
older than the cached latestVersion. With packages/ao now at 0.3.0
and resolvable from cli via pnpm's hoisted store at test time,
getCurrentVersion() returns 0.3.0, so isOutdated against a cached
latestVersion of 0.3.0 is false and the assertion fails.

Use 99.0.0 in the cache so the comparison stays meaningful regardless
of the current installed version.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: harshitsinghbhandari <212377671+harshitsinghbhandari@users.noreply.github.com>
Co-authored-by: harshitsinghbhandari <claudeagain@pkarnal.com>
2026-05-01 15:31:04 +05:30
Harshit Singh Bhandari 9ffb1bb6e6
feat(core): enrich events with PR title, description, and URL (#1326)
* fix: serialize ao start and stop numbered orchestrators (#1306)

* fix: restore dead orchestrators on start (#1306)

* fix: harden startup lock handling (#1306)

* feat(core): enrich events with PR title, description, and URL

Adds PR and issue context to all event payloads sent to notifiers.
External consumers (Telegram, Discord, n8n) can now display meaningful
information without making additional API calls.

Changes:
- Add buildEventContext() helper to extract PR/issue context from session
- Enrich all createEvent() calls with context data (pr, issueId, issueTitle, branch)
- Store issueTitle in session metadata during spawn
- Add issueTitle field to SessionMetadata interface
- Update executeReaction() to accept session for context access
- Add tests for event enrichment

The context includes:
- pr: { url, title, number, branch } when PR exists
- issueId: issue identifier
- issueTitle: issue title (from tracker during spawn)
- branch: session branch name

Events before PR creation gracefully omit PR fields (pr: null).
Existing webhook consumers that ignore unknown fields are unaffected.

Closes #1226

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

* fix(core): address review comments for event enrichment

- Add issueTitle to readMetadata/writeMetadata for proper persistence
- Create ReactionSessionContext type for type-safe system events
- Replace unsafe `as unknown as Session` cast with proper union type
- Add end-to-end test verifying issueTitle persistence during spawn

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

* fix(cli): include lock file path in startup lock error message

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

* fix: address review feedback — fd safety, kill-all resilience, issueTitle restore

- Restore try/catch/finally in tryAcquire for fd leak prevention
- Wrap stop command's sm.list()+kill in try/catch so dashboard shutdown
  always runs even on session listing failure
- Add per-iteration error handling in kill-all loop with partial failure
  reporting (spinner.warn for mixed results)
- Unify allSessionPrefixes derivation between start and stop commands
- Propagate issueTitle through archive restore path
- Add clarifying comments on agentInfo.summary fallback and intentional
  prNumber/prUrl duplication in event data
- Add ora warn mock for stop tests
- Update changeset to minor (event enrichment is a feature) and add CLI
  changeset for stop resilience
- Add tests for kill-all error mid-loop and issueTitle archive restore

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

* fix: address harsh-batheja review — title/summary split, lock grace, context namespace

- Separate PR title from agent summary in EventContext: title is null
  until enrichment cache populates; summary is a distinct field so
  webhook consumers never confuse the task summary for a PR title.
- Restore UNPARSEABLE_LOCK_GRACE_MS (5s mtime grace) and
  isStaleUnparseableLock lost during merge conflict resolution —
  prevents lockfile-steal race when process A just created the file
  but hasn't written metadata yet.
- Fix orchestrator sort: extract numeric suffix instead of
  localeCompare so -10 sorts after -2, not before.
- Namespace context under data.context instead of spreading into data
  to prevent field collisions with reaction-specific keys.
- Add schemaVersion: 2 to all enriched events so consumers can
  migrate away from top-level prNumber/prUrl (kept for compat,
  marked for removal in v3).
- Update event enrichment tests for nested context structure.

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

* fix: resolve merge conflicts — use formatReviewCommentsMessage, fix batch enrichment mock

- Replace formatAutomatedCommentsMessage with upstream's formatReviewCommentsMessage
  for automated review comment dispatch (fixes type mismatch with ReviewComment[])
- Make createMockSCM's enrichSessionsPRBatch dynamically resolve from individual
  method mocks so test overrides (e.g. getPRState("closed")) propagate correctly
- Add explicit enrichSessionsPRBatch to merge-conflict-tracking test to avoid
  unexpected getMergeability calls from the dynamic mock
- Remove all debug console.log statements added during troubleshooting

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

* fix: wire up maybeDispatchCIFailureDetails and record dispatch hash on transition

The function was defined but never called after merge conflict resolution
dropped the call site. Added it back to the Promise.allSettled alongside
maybeDispatchReviewBacklog and maybeDispatchMergeConflicts.

Also updated the transition-reaction early-return to record the dispatch
hash, since the transition path now enriches the CI message with detailed
check info from the batch cache — preventing duplicate sends on subsequent
polls.

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

* test: remove stale duplicate test from rebase

Removes the orphaned numbered-orchestrator restoration test left over
from feat/1226 history. Upstream's canonical model test (same name,
expects "app-orchestrator" via ensureOrchestrator) supersedes it.

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

* docs: remove misleading CLI CHANGELOG entry

The "Restore the most recently active dead orchestrator on ao start"
entry described upstream's ensureOrchestrator behavior (#1487), not
work contributed by this PR.

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

* docs(changeset): correct scope and drop stale CLI changeset

- Rewrite the @aoagents/ao-core changeset to describe only what feat/1226
  contributes: event enrichment with schemaVersion: 2, issueTitle
  persistence, executeReaction refactor, maybeDispatchCIFailureDetails,
  and bugbot-comments enrichment. Drop the false claims about adding
  spawn-target and format-automated-comments (those modules came from
  upstream PRs #1330 and #1334).
- Delete stop-kill-all-resilience.md — its claims (kill-all loop,
  fd-safety in tryAcquire, allSessionPrefixes unify) are no longer
  in the branch after the rebase took upstream's canonical stop logic.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-01 15:19:09 +05:30
Harsh Batheja 00176abbd1
feat(plugin): implement kimicode agent plugin (#1390)
* feat(plugin): add kimicode agent plugin

Add @aoagents/ao-plugin-agent-kimicode implementing the Agent interface
for MoonshotAI's Kimi Code CLI. Follows the AO activity JSONL + PATH
wrapper pattern established by agent-aider/opencode, with a native-ish
signal sourced from ~/.kimi/<session>/ mtimes when present.

- Full Agent interface: getLaunchCommand (--yolo, --model, --agent-file),
  getEnvironment (AO_SESSION_ID + ~/.ao/bin PATH + GH_PATH), detectActivity,
  getActivityState (5-step cascade with mandatory JSONL entry fallback),
  isProcessRunning (tmux TTY + PID signal-0, matches `.kimi`/`uv run kimi`),
  getSessionInfo (state.json parsing), getRestoreCommand (--resume <id>
  with --continue fallback), setupWorkspaceHooks, postLaunchSetup,
  recordActivity, detect().
- Post-launch prompt delivery — kimi's `-p` implicitly enables --print and
  exits, which would break interactive supervised sessions.
- 58 unit tests covering all 7 mandatory getActivityState cases plus
  manifest, launch, env, prompt classification, process detection,
  session info extraction, restore command, and detect().
- Register in cli/src/lib/plugins.ts, detect-agent.ts, plugin-registry.json,
  cli package deps, and update user-facing docs / yaml examples.

Closes #1384

* fix(plugin): register kimicode in core BUILTIN_PLUGINS and web services

The CLI-side registration in packages/cli/src/lib/plugins.ts only covers
`getAgentByName` callers. Code paths that go through the shared plugin
registry (session-manager, doctor, plugin, verify CLI commands, and the
web dashboard's services singleton) use `createPluginRegistry()` +
`loadBuiltins()` / explicit `register()`, which bypass the CLI map.

Without this wiring:
- `pnpm ao doctor` / `ao plugin` / `ao verify` wouldn't see kimicode
- Web dashboard would fail to render sessions with `agent: kimicode`
  because the webpack-bundled services.ts couldn't resolve the plugin

Add kimicode to:
- packages/core/src/plugin-registry.ts BUILTIN_PLUGINS
- packages/web/package.json dependencies
- packages/web/src/lib/services.ts static imports + register call

Caught while comparing against #1395 (kimi-2-6-code plugin), which added
the same registry entry.

* fix(plugin-kimicode): address review feedback

Critical (from @harshitsinghbhandari, verified against kimi-cli source):
- Remove `promptDelivery: "post-launch"` — `-p`/`--prompt` is just a prompt
  string alias (also `--command`/`-c`), NOT a mode switch. The non-interactive
  flag is `--print`, which we never set. Inline delivery via `--prompt` is
  reliable and avoids the post-launch sendMessage() delay.
- Drop unchecked `as string` casts in getRestoreCommand in favor of typeof
  guards + `?? undefined` so null model values don't silently leak.

Medium (performance):
- Add 30s per-workspace cache to findKimiSessionMatch (mirrors codex's
  SESSION_FILE_CACHE_TTL_MS) so the ~/.kimi/ scan doesn't run 12×/min per
  active session. Cache keyed by workspacePath; cleared via the new
  `_resetSessionMatchCache` test-only export between test cases.

Minor (correctness):
- Collapse findKimiSessionDir + readKimiSessionState into one
  findKimiSessionMatch that returns {dir, state} from a single state.json
  read. Previously the file was parsed twice per getSessionInfo /
  getRestoreCommand call.
- Wire config.subagent → `kimi --agent <name>` (default / okabe / custom).
- Tighten detectActivity patterns so "I approve of this approach" and
  "Earlier I failed to connect" no longer falsely trigger waiting_input /
  blocked. Regexes are now line-anchored with `^`/`$` + `\b` word boundaries.

Tests: 58 → 71 (all green). New cases cover:
- Native-signal ready/idle decay (previously only active was tested)
- Cascade ordering: JSONL waiting_input wins over a matching native signal
- Malformed state.json in both getSessionInfo and getRestoreCommand
- `work_dir` alias accepted in addition to `cwd`
- project.agentConfig.model preferred over state.json's recorded model
- False-positive narration guards for both regex tightenings

* refactor(plugin-kimicode): clean up after second-round review

All changes are non-behavioral perf/style cleanups flagged during my second
review pass — no user-visible changes.

- Consolidate double JSON.parse in findKimiSessionMatchUncached: the previous
  pass parsed each candidate state.json once to extract cwd and a second time
  to extract session_id/model/title. Replaced both helpers with a single
  `parseKimiState(raw)` that returns all four fields in one traversal.
- Carry state.json's mtime through KimiSessionMatch so getKimiLiveSignalMtime
  (renamed from getKimiSessionMtime) doesn't re-stat state.json — the winner's
  mtime was already captured during the scan. Live-signal probe is now limited
  to context.jsonl + wire.jsonl (the per-turn files) and runs them in parallel
  via Promise.all instead of sequential awaits.
- Fold state.json mtime and the live-signal mtime into a single "freshest"
  timestamp in getActivityState so a recently-written context.jsonl wins even
  when state.json is stale.
- Tighten appendApprovalFlags signature: `string | undefined` → proper
  `AgentPermissionInput | undefined` so typos at call sites fail at compile
  time.
- Stricter detect(): don't trust every binary named `kimi` — verify the
  --version output mentions kimi/kimi-cli/kimi-code, and fall back to
  `kimi info` for builds that print a bare version number. Rejects unrelated
  tools that happen to install a `kimi` binary.

Tests: 71 → 75. New coverage:
- detect() accepts kimi-cli vendor strings
- detect() falls back to `kimi info` when --version is ambiguous
- detect() rejects an unrelated `kimi` binary
- Native signal picks the fresher of state.json vs context.jsonl mtimes

* fix(plugin-kimicode): correct session layout discovered via smoke test

Installing kimi-cli 1.38.0 locally (\`uv tool install kimi-cli\`) and running
it once revealed the plugin's session-discovery logic was built on wrong
assumptions about the on-disk layout.

Observed layout (kimi-cli 1.38.0):

  ~/.kimi/sessions/<md5(cwd)>/<session-uuid>/
    context.jsonl  — conversation history
    wire.jsonl     — turn events (TurnBegin/TurnEnd with user_input payload)

Differences from my original assumptions:

- Sessions are nested under \`sessions/\` (not direct subdirectories of
  \`~/.kimi/\`).
- The workspace is identified by an MD5 hash of the absolute path, not by
  a \`cwd\` field stored in a state file.
- There is no \`state.json\`. No \`title\`, \`model\`, or \`cost\` is persisted.
- The session ID is the UUID directory name and is accepted as-is by
  \`kimi --resume <uuid>\`.
- The old \`--continue\` fallback is unnecessary — if we found the directory,
  we always know its UUID.

Fixes:

- \`findKimiSessionMatch\` now computes \`md5(workspacePath)\` with node:crypto
  and lists \`~/.kimi/sessions/<hash>/\` directly. No more full-tree scan of
  \`~/.kimi/\`, no more \`readFile\` of a fictional \`state.json\`.
- \`getKimiLiveSignalMtime\` keeps the parallel \`Promise.all\` stat of
  context.jsonl + wire.jsonl (the only files that exist).
- \`getSessionInfo\` streams the first \`TurnBegin\` out of wire.jsonl as a
  best-effort summary, with a 1 MB byte ceiling. agentSessionId is the UUID.
- \`getRestoreCommand\` drops the \`--continue\` fallback branch — a found dir
  always has a usable UUID.

Verified end-to-end against the real kimi-cli 1.38 binary on this machine:
- \`detect()\` → true
- \`getLaunchCommand\` output parses cleanly when run with \`--help\`
- \`getSessionInfo\` extracts the actual first user prompt ("say hello")
- \`getRestoreCommand\` produces the same UUID kimi itself prints as the
  resume hint: \`kimi -r 6ec34626-aedf-4659-a061-c5fbfa4cf166\`

Tests remain at 75 green. Coverage is now against real on-disk layouts
using temp directories with MD5-hashed bucket names — no mock-structure
drift from reality.

* fix(plugin-kimicode): address follow-up review issues

Follow-up to the issues filed as a review comment on the PR.

[MED] detect() too loose (\bkimi\b matches unrelated binaries)
  The old regex accepted plain "kimi" alone because the (?:cli|code)?
  suffix was optional — any binary whose output contains "kimi" passed.
  Real kimi-cli's --version prints just "kimi, version X.Y.Z" (no suffix),
  so --version alone can't distinguish it from, say, a hypothetical
  keyboard-input-manager named kimi. Switch to `kimi info` exclusively;
  real kimi-cli prints "kimi-cli version: ..." which is a distinct vendor
  string. Regex now requires "kimi-cli" / "kimi-code" / "moonshot"
  literally. Added maxBuffer cap (4 KB) so a hostile binary can't flood
  detect() with MB-scale output.

[MED] --work-dir not passed — investigated, not actionable in this PR
  AgentLaunchConfig doesn't expose session.workspacePath — only
  projectConfig.path (the project root), which would actively break
  discovery if passed. Runtime cwd handling is load-bearing. Left a
  comment explaining the constraint and pointing at the core-types
  change needed to fix it properly.

[LOW] Empty-bucket race returned transient null
  During session creation kimi mkdirs the UUID directory before writing
  context.jsonl / wire.jsonl. getKimiLiveSignalMtime returned null in
  that window and findKimiSessionMatch returned null, flickering the
  dashboard to "no signal". Fall back to the UUID directory's own mtime
  when live files are absent.

[LOW] isProcessRunning matched "kimi" anywhere in ps args
  Old regex /(?:^|\/)\.?kimi(?:\s|$)|(?:\s|^)kimi(?:\s|$)/ matched
  `cat kimi.log`, `vim ~/.kimi/config.toml`, etc. Anchor to argv[0]
  instead — only the executable itself, or a python/uv/node runner
  followed by `kimi` as the first positional argument, counts.

[NIT] Symlink normalization
  kimi's process reads cwd via os.getcwd(), which returns the realpath on
  Linux. If AO hands us a symlinked workspacePath, our MD5(symlink) won't
  match kimi's MD5(realpath). realpath-resolve with a best-effort fallback
  to the raw string (preserves behavior when the path doesn't exist yet).

Tests: 75 → 80. New coverage:
- detect() vendor-string matrix: kimi-cli / kimi-code / moonshot accepted,
  unrelated "kimi keyboard input manager" rejected
- isProcessRunning rejects `cat kimi.log` / `vim ~/.kimi/config.toml`
- isProcessRunning accepts `python -m kimi`
- Native signal falls back to UUID-dir mtime during the empty-bucket race
- Symlinked workspace path matches the realpath-hashed bucket

Verified end-to-end against real kimi-cli 1.38.0:
- detect() → true (via `kimi info` vendor match)
- getSessionInfo → correct summary + UUID
- getRestoreCommand → matches kimi's own resume hint

* fix(plugin-kimicode): address inline review from illegalcall

Addresses all 10 inline comments on PR #1390.

Load-bearing fixes:

[#6 line 327] detectActivity ordering was wrong
  The old code checked the idle prompt (`^kimi>\s*$`) before approval/error
  patterns. Real kimi UI re-renders `kimi>` on the last line when asking for
  a confirmation, so \`(Y)es/(N)o\\nkimi>\` was misclassified as idle and the
  session would sit forever looking quiet while actually blocked on input.
  Reordered to: waiting_input → blocked → idle → active. Matches codex/aider.

[#2,#4,#8 lines 128,154,493] No stable AO↔Kimi session binding
  Discovery was pure (path-hash + recency). If the user ran kimi manually in
  the same repo, or two AO sessions shared a workspace hash, AO would attach
  to the wrong UUID — summary / activity / --resume target all corrupted.
  Now:
   - \`session.metadata.kimiSessionId\` pins a specific UUID when set; no
     fallback to recency when the pin misses (fails closed, no silent drift).
   - Unpinned lookups filter UUIDs by \`liveMtime >= session.createdAt - 60s\`
     so stray dirs from prior AO sessions don't attach.
   - findKimiSessionMatch now takes the whole Session (not just workspacePath)
     so createdAt + metadata are available.

[#3 line 141] Any recent subdir was treated as a real session
  Stray temp dirs and crash leftovers would match on mtime, producing
  \`kimi --resume <garbage>\` and bogus active states. Now require
  context.jsonl OR wire.jsonl to exist before trusting a dir. The race
  fallback (empty UUID dir → dir mtime) is removed — the JSONL activity
  fallback in getActivityState covers the startup window instead.

[#5 line 191] Symlink follow outside ~/.kimi/sessions/
  \`stat()\` / \`createReadStream()\` followed symlinks without rebinding, so
  a bucket entry that's a symlink to \`/dev/zero\` or \`/etc/passwd\` would
  hang forever or leak data. Added \`isInsideKimiSessions(path)\` that realpaths
  the candidate and rejects anything outside the sessions root. Every
  bucket entry is checked before use.

Smaller cleanups:

[#1 line 89] Cache: 30s negative TTL + unbounded growth
  Negative results now cached 2s so a session appearing mid-poll is picked
  up on the next cycle. Expired entries evicted on read. Cache capped at
  256 entries with oldest-expiry pruning. Key changed to (workspacePath,
  pinnedUuid) so two AO sessions in the same bucket can't poison each
  other's cache entry.

[#7 line 440] Duplicate argv0Re regex — use the const.

[#9 line 532] maxBuffer: 4096 → 65536. Future \`kimi info\` releases that add
  plugin listings or telemetry banners won't silently break detect() with
  swallowed ENOBUFS.

[#10 test line 650] macOS test breakage: /var/folders is a symlink to
  /private/var/folders, so fakeHome under tmpdir() is a symlink path, while
  the plugin realpaths before hashing. Wrap the mkdtempSync in realpathSync
  so tests agree with the plugin on the canonical path. Linux CI masked this.

Tests: 80 → 86. New coverage:
  - detectActivity classifies confirmation-then-prompt-rerender as waiting_input
  - detectActivity classifies error-then-prompt-rerender as blocked
  - createdAt floor filter (ignores UUIDs from before the AO session)
  - Pinned kimiSessionId wins over recency
  - Pinned UUID missing returns null (no silent fallback)
  - Negative cache TTL ~2s (session appearing mid-poll picked up next cycle)
  - Empty UUID dir without live files is rejected (no stray-dir attach)

Verified end-to-end against real kimi-cli 1.38.0: detect() true,
getSessionInfo extracts correct summary + UUID, getRestoreCommand matches
kimi's own resume hint.

* fix(plugin-kimicode): use kimi.json for workspace mapping and add --work-dir

Read ~/.kimi/kimi.json work_dirs[] as the authoritative workspace-to-session
mapping. When last_session_id is populated, prefer it over the directory-mtime
recency heuristic — kimi itself wrote it. Falls back gracefully to the existing
MD5 hash scan when kimi.json is absent or last_session_id is null.

Add --work-dir to getLaunchCommand using projectConfig.path to establish an
explicit cwd contract, preventing shell-rc / tmux-hook drift from causing the
MD5(cwd) hash to diverge from kimi's session bucket.

* fix(plugin-kimicode): plumb workspacePath into AgentLaunchConfig

The kimicode plugin's --work-dir was passing projectConfig.path, which
breaks worktree-mode workspaces. In worktree mode, projectConfig.path is
the original repo root while session.workspacePath is the per-session
checkout — they differ. Either kimi would write to the project root
(breaking worktree isolation) or md5(projectConfig.path) would diverge
from md5(session.workspacePath), so getActivityState/getSessionInfo would
never find this session's bucket.

Fix:
- Add optional `workspacePath` field to AgentLaunchConfig.
- Plumb it through all 3 launch call sites in session-manager.ts.
- kimicode getLaunchCommand uses config.workspacePath, falling back to
  config.projectConfig.path when undefined.
- Tests for the divergent-paths case.

Public-interface change: AgentLaunchConfig grows one optional field.

Invariants preserved:
- Agent.getLaunchCommand signature unchanged — still takes one
  AgentLaunchConfig.
- Existing plugins (claude-code, aider, codex, opencode) compile and run
  unchanged; the new field is optional and they ignore it.
- Clone-mode workspaces (where workspacePath === projectConfig.path)
  produce the same launch command as before.
- Fallback to projectConfig.path keeps callers that don't pass the new
  field working — no flag day required.

* fix(plugin-kimicode): capture baseline pre-launch to close startup race

captureKimiBaseline() previously ran in postLaunchSetup, which races
against kimi's own startup writes. If kimi created its UUID directory
before postLaunchSetup ran, that UUID landed in `preExistingUuids` and
was filtered out forever — so `findKimiSessionMatch` returned null
permanently for that session.

Fix:
- Add optional `preLaunchSetup(workspacePath)` to the Agent interface,
  invoked from session-manager AFTER the workspace exists but BEFORE
  `runtime.create()` spawns the agent.
- Move captureKimiBaseline from postLaunchSetup to preLaunchSetup in
  the kimicode plugin.
- Test asserts the new UUID is attached even when written immediately
  after preLaunchSetup runs (i.e. in the race window).

Public-interface change: Agent.preLaunchSetup is optional. Existing
plugins (claude-code, aider, codex, opencode) compile and behave
unchanged. Only kimicode opts in.

Invariants preserved:
- Workspace exists before preLaunchSetup runs (called after the
  worktree/clone is created, never before).
- Failures in preLaunchSetup propagate just like other launch-path
  failures — the existing try/catch covers it.
- captureKimiBaseline is still write-once (returns early if the
  baseline file already exists), so restore preserves the original
  partition.

* fix(plugin-kimicode): persist UUID pin to disk instead of dead metadata

The session.metadata.kimiSessionId branch was treated as the highest-
priority signal but nothing ever populated it. That left the entire
"AO↔kimi UUID binding" mechanism dead — discovery fell through to the
recency heuristic on every call, so a manual `kimi` run in the same
workspace, a sibling AO session sharing a bucket, or any drift in
kimi's directory layout could attach the wrong session.

Fix:
- Remove the dead session.metadata.kimiSessionId branch from
  findKimiSessionMatchUncached and the cache key.
- Add a workspace-local pin file (.ao/kimi-session-id.json). Once
  findKimiSessionMatchUncached identifies a winner via the recency
  heuristic (or via kimi.json's last_session_id soft-pin), it writes
  the UUID to the pin file. Subsequent calls read the pin file as the
  highest-priority signal and skip the heuristic entirely — locking
  in the AO↔kimi binding for the rest of the session lifetime.
- Cache key simplified to workspacePath alone since the pin is now
  persistent and cannot drift between calls.
- Tests cover: pin wins over recency, first match writes the pin,
  pin holds when a newer non-pinned UUID appears later.

Mechanism mirrors the existing .ao/kimi-baseline.json pattern (also
file-based, write-once, lives in the workspace).

* refactor(plugin-kimicode): extract session-discovery into its own module

index.ts had grown to 880 lines after the pin-file fix landed. The
discovery layer (kimi.json parsing, baseline capture, pin file, hash
bucket scan, cache) is one cohesive responsibility — pulling it out
keeps both files under the 500-line mark and makes the precedence
rules legible.

- New file: session-discovery.ts. Opens with a decision-table comment
  documenting the precedence (pin file → kimi.json soft-pin → recency
  heuristic) so future readers see the rule before the code.
- Public surface: captureKimiBaseline, findKimiSessionMatch,
  KimiSessionMatch, kimiShareDir, _resetSessionMatchCache.
- index.ts re-exports _resetSessionMatchCache so the existing test
  imports keep working.
- No behavioral change — all 98 tests pass unchanged.

* test(plugin-kimicode): worktree-mode end-to-end discovery test

Adds a test where workspacePath (per-session worktree) and
projectConfig.path (repo root) are different paths. Asserts that
discovery hashes workspacePath — not projectConfig.path — for the
kimi bucket lookup. Previously this scenario was untested; the bug
fixed in 9fcc1d9 (--work-dir using projectConfig.path) would have
been caught by this test.

Combined with the earlier --work-dir tests in 9fcc1d9, the worktree
divergent-paths case is now exercised at both the launch site
(getLaunchCommand) and the discovery site (getRestoreCommand) end
to end.

* fix(plugin-kimicode): sandbox-check live-signal files against symlinks

Addresses illegalcall's review comment (id 3127022353): the existing
isInsideKimiSessions check verified the session DIRECTORY but not its
children. A symlinked context.jsonl, wire.jsonl, or wire.jsonl pointing
at /etc/passwd, /dev/zero, or a FIFO would be silently followed by
stat() / createReadStream() — leaking reads, hanging on devices, or
escaping the kimi-sessions sandbox.

Fix:
- New isKimiSessionFile(path) helper using lstat + isFile() — rejects
  symlinks, sockets, FIFOs, block/char devices. lstat (not stat) so we
  see the symlink itself before the kernel resolves it.
- getKimiLiveSignalMtime swapped to lstat-based check; non-regular
  files contribute no mtime.
- extractKimiSummary refuses to open wire.jsonl when it isn't a
  regular file.
- Tests cover both paths: getActivityState rejects a session whose
  live-signal files are symlinked outside the bucket; getSessionInfo
  returns null summary when wire.jsonl is symlinked even if context.jsonl
  is real.

* fix(plugin-kimicode): apply baseline + createdAt filters to kimi.json soft-pin

The kimi.json soft-pin used to record a candidate UUID before the baseline
and createdAt filters were applied, so a stale last_session_id pointing at
a pre-AO UUID (manual `kimi` run, kimi.json lag) would be captured into
.ao/kimi-session-id.json and route every later getActivityState /
getSessionInfo / getRestoreCommand call at the wrong conversation, with
no self-healing path.

Move the baseline + createdAt floor checks above the soft-pin branch so
the soft-pin candidate goes through the same gates as the recency contest.

Add two regression tests:
- soft-pin pointing at a baseline UUID is rejected and the AO pin file
  records the legitimate AO-spawned UUID instead
- soft-pin pointing at a UUID older than session.createdAt - 60s is
  rejected by the createdAt floor

Both tests fail on the prior code and pass after the fix.
2026-05-01 14:11:30 +05:30
Ashish Huddar 2e4583b7bd
fix: clear stale Next.js cache on version upgrade (#1022)
* fix: clear stale Next.js cache on version upgrade (#986)

After upgrading @composio/ao via npm, `ao start` served the old UI because
Next.js runtime cache (.next/cache) persisted from the previous version.

Adds a hybrid fix:
- Postinstall hook clears .next/cache and writes a version stamp
- Runtime guard in `ao start` and `ao dashboard` compares stamp against
  package version; on mismatch, clears .next/cache and restamps
- Build-time script writes stamp after `next build` (monorepo path)

Only .next/cache is deleted — shipped build artifacts (.next/server,
.next/static, BUILD_ID) are never touched, keeping npm installs intact.

Closes #986

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

* fix(lint): add Node.js globals for package-level scripts

The ESLint config only covered root-level scripts/, not
packages/*/scripts/. This caused `no-undef` errors for `console`
and `process` in packages/web/scripts/stamp-version.js.

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

* fix: ensure postinstall cache clearing runs on all platforms

Restructure postinstall.js so the node-pty chmod fix is wrapped in a
conditional block instead of using early process.exit(0). The previous
exits on Windows, missing node-pty, or missing spawn-helper prevented
the cache-clearing code from ever running on those systems.

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

* fix: address review comments on stamp-version ordering and cache catch logging

- Move stamp-version.js after tsc in web build script so a tsc failure
  does not leave a fresh stamp paired with a stale server bundle.
- Log skipped cache version checks via console.debug instead of swallowing
  silently, to aid debugging without blocking dashboard startup.

Addresses review feedback from @illegalcall on PR #1022.

* fix: resolve ao-web in postinstall cache cleanup

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-01 12:23:51 +05:30
Harshit Singh Bhandari 64f67f3425
fix(cli): skip rebuild when git install is already on latest version (#1585)
* fix(cli): skip rebuild when git install is already on latest version

After `git fetch`, compare local HEAD to remote HEAD. If they match,
print "Already on latest version." and exit without running pnpm install,
clean, build, or npm link.

Without this check, `ao update` re-ran the full rebuild on every
invocation even when nothing had changed, because the git path in
`handleGitUpdate` (unlike the npm path) never called `checkForUpdate()`
to short-circuit before delegating to the shell script.

Fixes #1584

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

* fix(cli): keep running smoke tests when already on latest version

The previous fix exited 0 immediately on the "already on latest" path,
which silently skipped smoke tests that would otherwise verify the
install. Restructure with an else-branch so the rebuild block is
skipped but execution continues to the smoke-test gate, preserving
the prior smoke-test behavior for the no-update case.

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 01:04:29 +05:30
Priyanshu Choudhary daa8edf69b Merge remote-tracking branch 'origin/main' into feat/windows-platform-adapter 2026-04-30 23:37:01 +05:30
Priyanshu Choudhary efd0d408e1 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>
2026-04-30 22:59:41 +05:30
Priyanshu Choudhary 928b8e5203 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>
2026-04-30 22:56:29 +05:30
Priyanshu Choudhary 818f11f987
fix(cli): register project command (#1576) 2026-04-30 20:34:30 +05:30
Priyanshu Choudhary 8311406f20 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.
2026-04-29 21:17:03 +05:30
Priyanshu Choudhary 720cbfcb26 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.
2026-04-29 04:46:46 +05:30
Priyanshu Choudhary 170c1ca7f7 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).
2026-04-29 04:41:46 +05:30
Priyanshu Choudhary e071f63e5a Merge origin/main into feat/windows-platform-adapter
Resolves 15 conflicts and reconciles main's storage V2 redesign,
DirectTerminal hooks split, opencode shared cache, and PR refactors
with the branch's Windows platform adapter.

Test suite is fully green on Windows after this merge. Changes:

Mechanical/portable fixes:
- Path-separator-agnostic regex matchers in spawn.test.ts and
  update-check.test.ts (Windows uses backslashes).
- Fixed broken char-class regex in script-runner.test.ts path escape.
- Bash matcher accepts both POSIX (`bash`) and Windows (`bash.exe`).
- USERPROFILE override added alongside HOME in filesystem-browse-api
  test (node's os.homedir() reads USERPROFILE on Windows, not HOME).
- Outside-HOME absolute path in browse test is now platform-aware
  (C:\Windows on win32, /etc on POSIX) so realpathSync() resolves.
- Added missing enrichSessionIssue import in serialize.test.ts.
- agent-cursor execFileSync expectation loosened to objectContaining.

Windows-only test skips (with explanatory comments):
- migration-storage-v2.test.ts: 3 describe blocks skipped — they
  migrate FROM the legacy hash-dir layout that only ever shipped on
  Linux/macOS in V1. Future Windows migration coverage would need a
  Windows-shaped fixture rewrite.
- migration-codex-restore.integration.test.ts: same legacy-layout
  reason.
- bun-tmp-janitor.test.ts: startBunTmpJanitor() is a no-op on win32
  (no opencode Windows binary, kernel disallows unlinking mapped
  files).
- start.test.ts \"full stop\" test: now goes through killProcessTree()
  which calls `taskkill /T /F` on win32, not process.kill.
- script-runner.test.ts POSIX-fixture tests: skip on win32.
- filesystem-browse symlink test: skipped on win32 (symlinkSync
  requires admin or Developer Mode).

Windows fs-slowness adjustments:
- agent-report.test.ts: bumped per-test timeout to 30s for the
  audit-trail test (260 atomic-write cycles are slow on Windows due
  to AV scanning of every rename).
- lifecycle-manager.test.ts: replaced fixed 25ms wait with a
  poll-until-called pattern (deadline 2000ms, 10ms intervals) to
  remove a flake under full-suite load on Windows.

Pre-existing main test bugs (skipped, NOT introduced by this merge):
- api-routes.test.ts: 2 tests assert old async (dashboard, scm, pr,
  opts) signature of enrichSessionPR, but commit a8bc7469 on main
  simplified it to a synchronous, single-arg metadata read. Skipped
  with explanatory comment; should be filed as separate main issue.
- page.test.tsx: \"renders inline missing-session state\" references
  an undefined TestErrorBoundary symbol (commit 0538e07b on main
  removed the class but missed these usages). Page now renders 404
  inline rather than throwing, so the test would need a different
  assertion strategy. Skipped; should be filed as separate main
  issue.

Smoke-tested on Windows:
- pnpm build clean, pnpm test green, pnpm typecheck clean.
- ao --version, ao doctor (16 PASS / 1 expected WARN / 0 FAIL),
  ao update --check, ao doctor --help — all working. Bash
  auto-detect resolved to Git Bash and ran ao-doctor.sh via
  spawn() with windowsHide:true.

Follow-ups (additive, not blocking):
- Re-add main's 3 Linux port-scan unit tests in start.test.ts as
  POSIX-only tests (code path is intact in start.ts; only unit-test
  coverage is missing post-merge).
- Add Windows runRepoScript unit tests in a separate
  script-runner-windows.test.ts (branch's vi.mock-heavy tests were
  incompatible with main's real-fs tests).
2026-04-29 04:37:04 +05:30
Madhav Kumar 4701122342
fix: reduce opencode session list churn (#1478)
* fix: reduce opencode session list churn

* fix: make bun-tmp-janitor cross-platform and move to process-level boot

- Extend platform support from Linux-only to Linux + macOS (win32 skipped
  since opencode ships no Windows binary and the kernel disallows unlinking
  mapped files there)
- Use os.tmpdir() instead of hardcoded /tmp to handle macOS $TMPDIR paths
- Extend file pattern from \.so to \.(so|dylib) to cover macOS dylib leaks
- Move startBunTmpJanitor() from ensureLifecycleWorker() (per-project) to
  the process-level boot in registerStart() immediately after register(),
  where the single-instance contract is already in force
- Drop the project-observer-bound onSweep closure that incorrectly attributed
  janitor health to whichever project happened to start first; replaced with
  a simple stderr warn on errors (no project context needed for a process-wide
  sweep of /tmp)
- Move stopBunTmpJanitor() into the SIGINT/SIGTERM shutdown handler in
  registerStart() alongside stopAllLifecycleWorkers()
- Remove startBunTmpJanitor/stopBunTmpJanitor from lifecycle-service.ts
  entirely; lifecycle workers have no business knowing about a process-wide
  OS resource

* fix(opencode): address PR #1478 review (TMPDIR isolation, shared cache, janitor cleanup)

Implements all seven findings from the PR #1478 review:

Core / agent-opencode:
- New @aoagents/ao-core/opencode-shared module owns the single TTL cache
  + in-flight dedup for 'opencode session list' (was duplicated across
  core and the plugin, doubling spawns per poll cycle).
- TTL dropped from 3s to 500ms so the send-confirmation loop's
  updatedAt > baselineUpdatedAt delivery signal can actually fire.
- New invalidateOpenCodeSessionListCache() called by deleteOpenCodeSession
  so reuse / remap / restore code paths cannot observe a deleted id.
- New getOpenCodeChildEnv() / getOpenCodeTmpDir(): every opencode child
  spawned by core, the plugin, or the agent runtime points TMPDIR/TMP/TEMP
  at ~/.agent-orchestrator/.bun-tmp. Bounds the janitor's blast radius
  to AO-owned files even on shared hosts.

CLI janitor:
- Sweeps only the AO-owned tmp dir (not the system /tmp).
- Filters synchronously before spawning per-entry stat/unlink work.
- stopBunTmpJanitor() is now async and awaits any in-flight sweep so
  SIGTERM cannot exit while unlink() is mid-flight; start.ts shutdown
  handler awaits it.
- onSweep callback in start.ts now logs successful reclaims, not just
  errors, so operators can confirm the janitor is doing useful work.

Tests:
- packages/core/__tests__/opencode-shared.test.ts (TTL contract,
  TMPDIR location, env merge semantics).
- packages/cli/__tests__/lib/bun-tmp-janitor.test.ts (sweep behavior,
  stop-awaits-in-flight, pattern matching, missing-dir tolerance).

* chore: remove review postmortem artifact

* fix(cli): remove start command non-null assertions
2026-04-29 01:24:55 +05:30
Abu Talha 60e8c88100
fix(cli): make ao start URL cloning interactive to avoid SSH prompt hang (#1255)
* refactor(cli): unify interactive spawn helper

Consolidate interactive child-process spawning into runInteractiveCommand by adding an optional options bag (cwd/env and error context). This removes duplicate spawn logic and ensures consistent TTY-forwarding and error formatting for installer and clone flows.

Made-with: Cursor

* test(cli): mock interactive spawn in start URL clone tests

Update start URL clone tests to mock node:child_process spawn (stdio: inherit) instead of exec(), matching the interactive clone behavior and preventing hangs/timeouts.

Made-with: Cursor

* test(cli): standardize spawn mocks for start URL clone

Use an EventEmitter-based ChildProcess helper for spawn() in start command tests, matching existing repo patterns and reducing repetitive per-test mock return objects.

Made-with: Cursor

* test(cli): remove unknown cast from SessionManager mock

Add missing SessionManager methods to the start command test mock so we can type it as SessionManager directly without an unknown double-cast.

Made-with: Cursor

* test(cli): type spawn mock args in start tests

Replace unknown-typed spawn mock parameters with concrete cmd/args/options types to improve readability while keeping behavior unchanged.

Made-with: Cursor

* fix(test): remove duplicate mockSessionManager.restore key

Drop the duplicate restore mock introduced during main-branch merge conflict resolution.
2026-04-28 19:04:38 +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
yyovil dcfb6fe8fd
feat: add $schema support to agent-orchestrator.yaml (#1373)
* feat: add config schema support for agent-orchestrator.yaml (#1370)

Expose a committed JSON Schema and inject the canonical $schema URL into generated and updated configs so editors can autocomplete and validate AO config files.

* fix schema injection edge cases and shared constant

* fix: address config schema review feedback (#1370)
2026-04-26 19:42:45 +05:30
yyovil 32028d9362
fix(deps): bump vulnerable dependencies so pnpm audit passes cleanly (#1338)
* fix(deps): bump vulnerable dependencies

* fix(deps): align web vitest with vite 6
2026-04-26 17:01:07 +05:30
yyovil 7581af9a65
fix(cli): avoid missing repo scripts on global installs (#1252) (#1277)
* fix(cli): avoid missing repo scripts on global installs

* refactor(cli): moved ao-update.sh script into assets.

Entire-Checkpoint: b91ccadead1c

* Fix packaged doctor and update fallback

* Harden install detection and script runner

* fix(cli): align ao script launchers with packages/ao

---------

Co-authored-by: AO Bot <ao-bot@composio.dev>
2026-04-26 16:47:49 +05:30
Priyanshu Choudhary b31efacf6b Merge origin/main into feat/windows-platform-adapter
Pulls in 2 main commits (#1487 orchestrator identity fix, #1238 gh CLI
tracer + scm/tracker migration Phase A1a) plus the auto-merged
follow-on changes from upstream.

Conflict resolutions:
- packages/core/src/session-manager.ts:
  Took main's reorganized opencode-agents-md import + new
  getOrchestratorSessionId import. Kept main's reformatted sessionCache
  type and added the new ensureOrchestratorPromises Map.

- packages/core/src/agent-workspace-hooks.ts:
  Took main's WRAPPER_VERSION = "0.6.0" (and matching test fixture).

- packages/core/src/__tests__/agent-workspace-hooks.test.ts:
  Merged both sides' imports — kept branch's buildNodeWrapper +
  node:path/join (still used in test body) and added main's
  AO_METADATA_HELPER + GH_WRAPPER constant exports.

- packages/plugins/agent-codex/src/index.ts:
  Dropped now-unused PREFERRED_GH_PATH import (env injection moved to
  session-manager). Kept isWindows — needed by branch's new
  formatLaunchCommand, resolveCodexBinaryWindows, and pre-existing
  isProcessRunning code.

- packages/plugins/agent-codex/src/index.test.ts:
  Took main's collapsed PATH/GH_PATH undefined assertions and no-op
  setupWorkspaceHooks test — the agent no longer constructs PATH or
  installs wrappers (session-manager owns those paths now). Removed
  the orphaned wrapper-write tests. Kept branch's
  describe.skipIf(win32) on the shell-wrapper-content block (those
  tests verify Unix shell-script content that genuinely can't run on
  PowerShell).

- packages/plugins/agent-aider/src/index.test.ts +
  packages/plugins/agent-cursor/src/index.test.ts:
  Took main's `expect(env["PATH"]).toBeUndefined()` — agents no
  longer set PATH directly.

- packages/web/server/mux-websocket.ts:
  Kept branch's Windows pipe handler branch and updated the inner
  Unix `terminalManager.open(id, tmuxName)` call to main's new
  signature with the optional tmuxName argument.

Verified: typecheck clean, lint 0 errors, full build (28 pkgs), tests
942/946 passing — the 4 failing tests in plugin-integration.test.ts
also fail on main itself (verified by running main's untouched test
file), so they're pre-existing flakes unrelated to this merge.
2026-04-25 20:42:17 +05:30
Priyanshu Choudhary 42a519f91e 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
2026-04-25 19:09:02 +05:30
Ashish Huddar f674422a66
Fix orchestrator identity to use one canonical session per project (#1487)
* Add canonical orchestrator identity

* Coalesce concurrent ensureOrchestrator calls

* Fix canonical orchestrator follow-ups

* Move orchestrator id helper into web utils
2026-04-25 18:57:31 +05:30
Priyanshu Choudhary 3e3efed949 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.
2026-04-25 18:54:24 +05:30
Priyanshu Choudhary c1e775c925 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.
2026-04-25 17:40:06 +05:30
Priyanshu Choudhary f0eec60dfb Merge origin/main into feat/windows-platform-adapter
Resolve tmux-utils.ts conflict: rewrite resolvePipePath to read
runtimeHandle.data.pipePath from on-disk session metadata instead of
recomputing a hash from AO_CONFIG_PATH. Mirrors the storageKey
disambiguation pattern upstream introduced for resolveTmuxSession in
PR #1488 (eca3001c). Recomputing the hash drifted from the runtime's
deriveStorageKey on Windows (raw backslash paths vs POSIX-normalized),
so the dashboard's named-pipe relay was connecting to the wrong path
and falling back to ENOENT while ao session attach worked fine.

Also normalize path separators in 6 upstream resolveTmuxSession tests
that hard-coded Unix forward slashes in their exists() mocks; on
Windows path.join produces backslashes and the assertions never
matched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 16:55:30 +05:30
Priyanshu Choudhary d16d3c3f4d 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>
2026-04-25 11:25:34 +05:30
Priyanshu Choudhary 0dcbdb2673 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>
2026-04-25 11:25:19 +05:30
yyovil 83640c8b56
fix(cli): hide legacy second positional from ao spawn help (#1491)
* fix(cli): hide legacy spawn second positional (#1490)

Keep ao spawn help aligned with the real interface by exposing a single optional issue positional and rejecting extra positional arguments with replacement usage before any spawn work starts.

* fix(cli): align spawn usage with optional issue (#1490)

Keep the arity error and its test aligned with the optional [issue] contract surfaced by ao spawn help so review-thread guidance and user-facing usage match exactly.
2026-04-25 02:16:32 +05:30
yyovil b086908f60
add zsh completion support for ao (#1374)
* feat: add zsh completion for ao (#1371)

Add a generated zsh completion command and dynamic completion backend so ao can tab-complete projects and session IDs without relying on jq or brittle text parsing. Document standard zsh and Oh My Zsh install paths, and cover the new flow with CLI tests.

* fix(cli): address copilot completion review feedback for PR 1374

* fix completion and harden local workflow parsing

* Update packages/cli/src/lib/completion.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* fix completion regressions and agent-ci review feedback

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-04-24 03:48:25 +05:30
Priyanshu Choudhary c76ea4389d Merge branch 'main' into feat/windows-platform-adapter 2026-04-24 02:45:37 +05:30
Harsh Batheja 616c56cea2
fix(cli): derive projectId from prefixed issue id on spawn (#1330)
* fix(cli): derive projectId from prefixed issue id on spawn

When `ao spawn <projectId>/<issue>` is used in a multi-project config,
route the spawn to the prefixed project and strip the prefix from the
issue id. Previously the projectId fell back to whichever project
`ao start` was running for, tagging cross-project sessions with the
wrong project (and session prefix).

Applies to both `ao spawn` and `ao batch-spawn`; batch-spawn errors
out if issues mix multiple project prefixes.

Fixes #1329

* refactor(core,cli): lift spawn target resolution into core

Move the issue-prefix → project routing from the CLI into a reusable
core utility, `resolveSpawnTarget(projects, issueRef, fallback?)`.

- Exposes routing to any spawn entry point (CLI, web API, programmatic).
- Accepts either a project id or sessionPrefix as the prefix; project id
  wins on collision.
- `ao batch-spawn` now groups issues by resolved project and preflights
  once per group instead of erroring on mixed prefixes.

Tests:
- 8 unit tests for `resolveSpawnTarget` in core.
- CLI spawn.test.ts: adds a sessionPrefix routing test (23 tests total).

Refs #1329

* test(cli): cover batch-spawn grouping; tighten resolveSpawnTarget API

Self-review found three gaps:

- `resolveSpawnTarget` accepted `undefined` issueRef and returned
  `{ issueId: "" }` with a fallback project. Dead path — both callers
  guard against undefined. Drop it and make issueRef required.
- No tests for `batch-spawn`. Added two:
  - routes cross-project prefixed issues to the correct project and
    lists each project's sessions separately
  - skips a prefixed issue when the target project already has an
    active session for it
- `spawn` and `batch-spawn` help text didn't document the
  `<projectId>/<issue>` or `<sessionPrefix>/<issue>` forms.

* fix(core): guard resolveSpawnTarget against prototype-key matches

Second review pass found a latent bug:

Plain JS objects inherit `__proto__`, `constructor`, `toString`,
`hasOwnProperty`, etc. from Object.prototype. The previous
`if (projects[prefix])` check entered the routing branch for any of
these keys, mis-routing a user typing `ao spawn __proto__/42` with
`projectId: "__proto__"`. Downstream session-manager would then receive
a junk project object (Object.prototype itself) and fail with a
non-obvious error.

Fix: use `Object.prototype.hasOwnProperty.call(projects, prefix)` so
only actual configured project ids match.

Also:
- Document the case-sensitive matching semantic explicitly.
- Lock in the batch-spawn grouping contract by asserting exact
  `list()` and `spawn()` call counts in the cross-project test.
2026-04-22 22:18:39 +05:30