Commit Graph

1208 Commits

Author SHA1 Message Date
Harshit Singh Bhandari 845fffdfd9
fix(runtime-tmux,web): keep tmux session alive after agent exit (#1758)
* fix: keep tmux session alive after agent exit (closes #1756)

Two related fixes:

1. runtime-tmux: append `exec "${SHELL:-/bin/bash}" -i` to the launch
   command so the pane drops to an interactive shell when the agent
   exits, instead of letting the empty pane take down the whole tmux
   session. The lifecycle still detects agent termination via
   `agent.isProcessRunning` and transitions the session to
   `agent_process_exited` — the runtime just stays usable so the user
   can run shell commands or manually re-launch the agent.

2. mux-websocket: add a `tmux has-session` guard at the top of
   `pty.onExit`. When the tmux session is genuinely gone (e.g. `ao
   stop` killed it out from under a still-subscribed dashboard), skip
   the three doomed `attach-session` spawns introduced by the
   MAX_REATTACH_ATTEMPTS bound in #1640 and notify subscribers
   immediately. The bound from #1640 still covers transient
   tmux-server hiccups where the session does still exist.

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

* fix(mux): make tmuxHasSession async to avoid blocking the event loop

The has-session probe added in #1756 ran via execFileSync inside
node-pty's onExit callback, freezing every WebSocket connection,
HTTP request, and in-flight terminal for up to the 5 s subprocess
timeout whenever an agent exited and tmux was slow to respond.

Switch tmuxHasSession to promisified execFile and await it from the
onExit handler, mirroring the execFileAsync pattern in runtime-tmux.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 19:41:03 +05:30
Harshit Singh Bhandari a33b2ba0ef
fix(workspace-worktree): restore re-attaches existing branch instead of recreating with -b (#1742)
* fix(workspace-worktree): restore re-attaches existing branch instead of recreating with -b

Restoring a session whose worktree directory was cleaned up but whose
branch still existed locally would 422 with `fatal: a branch named <X>
already exists`. The recovery path in `restore()` unconditionally fell
through to `git worktree add -b`, even though `destroy()` deliberately
preserves session branches so the user's commits aren't lost.

When the local branch already exists, restore now clears any stale
worktree registration at the target path and retries `git worktree add
<path> <branch>` (no -b/-B). The existing -b fallback is preserved
verbatim for the case where the local branch is genuinely missing
(only the remote ref exists). -B is intentionally not used — it would
force-reset the branch back to the base ref and silently discard the
session's commits, which is the opposite of restore's intent.

Test coverage:
- 6 new unit tests covering the recovery path, cleanup tolerance,
  error propagation, and "no -b/-B" invariants
- 2 updated existing unit tests (now mock the new refExists check)
- 2 new integration tests exercising real git: branch preservation
  on clean restore, and recovery from a dirty teardown that left a
  stale registry entry

Closes #1741

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

* test(workspace-worktree): explicitly create main branch in restore integration tests

CI runs git with a different `init.defaultBranch` than the local dev
environment, so the bare clone has no `main` branch when the test
attempts to push. Mirror the existing tests in this file (which also
call `git switch -c <branch>` before the first commit).

Fixes integration-test failures on PR #1742.

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

* fix(workspace-worktree): rmSync stale workspace dir before retry

Follow-up to the previous commit on this branch. The recovery path
cleared the git worktree registry but didn't touch the filesystem, so
when restore was triggered by a stale junk directory at the workspace
path (workspace.exists() returns false because rev-parse fails on a
non-working-tree dir), the retry would fail with the same error:

    fatal: '<workspacePath>' already exists

Replace the inline `worktree prune` cleanup with a call to the existing
`clearStaleWorktreePath()` helper, which handles all three states:

  - dir gone → no-op
  - dir present and not registered → rmSync
  - dir present and still registered after prune → throws (safety:
    never delete a registered worktree)

This mirrors how create() already handles the same stale-state cases
upfront via clearStaleWorktreePath at the top of its flow.

Test coverage:
- 1 new unit test: rmSyncs a stale workspace directory before retry
- 1 new unit test: refuses to rmSync a still-registered worktree dir
  (data safety — error must propagate, not be swallowed)
- 1 new integration test on real git: dir physically present as
  non-working-tree leftover, restore must rmSync it before retry,
  and the session commit must survive

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

* refactor(workspace-worktree): extract restore helpers, drop redundant prune

Addresses review feedback on PR #1742:

1. Extract two named helpers, reattachExistingBranch and
   createBranchFromBase, so restore()'s catch block reads as the
   bifurcation it actually is — 2 lines per branch, no nested
   try/catch hierarchy. Behavior is unchanged.

2. Drop the redundant `worktree prune` that ran inside the recovery
   path (via clearStaleWorktreePath). The entry-point prune in
   restore() is sufficient. reattachExistingBranch now inlines the
   existsSync + isRegisteredWorktree + rmSync sequence directly,
   keeping the data-safety guard ("refuse to rmSync a registered
   worktree") intact but skipping the second prune call.

The catch block shrinks from ~46 lines to 12 (the rest moves into
the helpers, where the docstrings can explain WHY each branch exists
without cluttering the call site).

Tests: same 64 unit + 13 integration tests still pass — the mock
sequences in the recovery-path tests no longer expect a second prune
call, since the new code doesn't make one.

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

* fix(workspace-worktree): address Copilot review on PR #1742

Two real concerns flagged in inline review:

1. isRegisteredWorktree did strict string equality on paths. If
   `workspacePath` was passed in a non-canonical form (trailing slash,
   ".." segments) and git reported the canonical path, the check would
   false-negative — and the subsequent rmSync in cleanupStaleWorkspacePath
   would silently delete a still-registered worktree (data loss). Fix
   by resolve()-normalizing both sides before comparison.

2. createBranchFromBase (the "branch missing locally" recovery path)
   skipped the stale-path cleanup that reattachExistingBranch did. So
   if `workspacePath` had a stale dir AND the branch was missing,
   `git worktree add -b ...` would fail with the same "<path> already
   exists" error this PR was fixing for the re-attach case. Fix by
   factoring the cleanup into cleanupStaleWorkspacePath, called from
   both helpers.

Test coverage:
- Unit: path normalization safety — workspacePath with trailing slash
  vs canonical registered path must still throw "still registered"
  (proves rmSync is NOT called)
- Unit: createBranchFromBase clears stale dir before -b add
- Integration: branch missing locally + stale dir at workspacePath →
  restore must clean dir AND recreate branch from origin (commit
  preserved end-to-end on real git)

Existing 2 "branch missing" tests updated to mock the new cleanup
calls in createBranchFromBase.

66 unit tests + 14 integration tests pass.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 22:07:32 +05:30
Priyanshu Choudhary 13c5a50d02
ci: drop windows-latest from typecheck matrix (#1747)
* ci: drop windows-latest from typecheck matrix

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

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

With windows-latest dropped, the single-OS matrix was just indirection
and produced a noisy 'Typecheck (ubuntu-latest)' job name. Use a direct
runs-on instead.
2026-05-09 01:09:26 +05:30
Priyanshu Choudhary 0f5ae0b01d
feat(windows): complete Windows support (#1025)
* fix: project builds on Windows

Replace Unix cp -r with Node.js fs.cpSync in CLI build script.
Add webpack snapshot config to prevent Next.js scanning Windows junction points.

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

* feat(core): add cross-platform adapter (platform.ts)

Centralizes all platform-branching logic: shell resolution (pwsh > powershell > cmd),
process tree kill (taskkill on Windows), port-based PID discovery (netstat on Windows),
runtime defaults, and env defaults.

Addresses blockers B05, B06, B07, B08 from Windows compatibility proposal.

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

* feat(core): platform-aware runtime default (tmux on Unix, process on Windows)

B04: Config and docs now reflect platform-specific defaults.

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

* fix(cli): use platform-aware runtime fallback in start command

B01/B02: ensureTmux() is only called when runtime resolves to 'tmux'.
On Windows, runtime defaults to 'process', skipping tmux entirely.

* fix(core): tighten Windows PID port matching

* test(core): add mocked tests for platform.ts to fix diff coverage

Adds platform.mock.test.ts with 25 tests covering Windows-specific
branches (resolveWindowsShell fallbacks, killProcessTree, findPidByPort)
and Unix error-handling/env-var fallback chains that require mocking
node:child_process. Pushes platform.ts diff coverage from 45.9% to ≥80%.

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

* fix(core): fix TypeScript errors in platform.mock.test.ts

Return ChildProcess from execFile mock implementations and use "" instead
of undefined for execFileSync mock return value to satisfy strict types.

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

* fix(windows): cross-platform process management (PR 2/6) (#1028)

Fixes B05 and B06: replaces all Unix-only process management with the
  platform adapter so AO works on Windows with runtime: process.

  - dashboard stop/rebuild uses findPidByPort() (netstat on Windows, lsof on Unix)
  - runtime-process destroy uses killProcessTree() (taskkill /T /F on Windows,
    negative-PID SIGKILL on Unix) with conditional detached flag
  - start.ts restart, ao stop --all, and stop-dashboard paths all switched
    from process.kill() to killProcessTree() so child processes are reaped
  - lifecycle-service stopLifecycleWorker() replaced with killProcessTree()
  - Fixed destroy() hang: exit listener now registered before awaiting kill
    so fast-exiting processes don't fire before the listener attaches

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

* fix(cli): spawn dashboard with detached:true on Unix for process group kill

Dashboard was spawned with detached:false, so killProcessTree's
process.kill(-pid) failed with ESRCH (not the group leader) and fell
back to killing only the listening process, orphaning Next.js workers.

Matches the runtime-process pattern: detached:!isWindows() makes the
dashboard the process group leader on Unix so the negative-PID group
kill in killProcessTree correctly reaps all children. On Windows,
detached:false is preserved — taskkill /T /F handles the tree kill
by PID regardless of process group membership.

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

* fix(cli): forward SIGINT/SIGTERM to dashboard group; restore lifecycle stop semantics

Two Bugbot issues from the detached-dashboard and killProcessTree changes:

1. Dashboard Ctrl+C orphan: with detached:true on Unix, the dashboard is in
   its own process group and does not receive SIGINT from the terminal. Add
   process.once(SIGINT/SIGTERM) handlers that forward the signal via
   killProcessTree so the entire dashboard group is reaped on exit. Handlers
   are cleaned up when the dashboard exits to avoid leaks.

2. stopLifecycleWorker wrong return value: killProcessTree swallows ESRCH,
   so a stale PID entry (process already dead) now returned true ("stopped")
   instead of false ("not running"). Add an isProcessRunning guard before
   the kill to restore the original semantics.

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

* fix(cli): prevent double SIGTERM dispatch when both SIGINT and SIGTERM arrive

The forward handler now self-removes both listeners before calling
killProcessTree, so a second signal cannot invoke it again on an
already-dead PID.

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

* fix(core): respect signal parameter in killProcessTree on Windows

SIGTERM now uses taskkill /T /PID (WM_CLOSE, graceful) instead of /F,
preserving the SIGTERM→wait→SIGKILL escalation used by lifecycle-service,
start.ts, and runtime-process. SIGKILL keeps /T /F /PID (force).

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

* test(core): update killProcessTree Windows tests for signal-aware taskkill

Split the single taskkill test into two: SIGTERM uses /T /PID (graceful)
and SIGKILL uses /T /F /PID (force), matching the updated implementation.

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

* test(core,cli): add missing coverage for platform.ts and lifecycle-service

- platform.mock.test.ts: cover Windows getEnvDefaults PATH fallback (line 148)
- lifecycle-service.test.ts: cover stopLifecycleWorker stale-PID path,
  normal SIGTERM kill, and SIGKILL escalation after timeout

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

* fix(cli): suppress consistent-type-imports lint error in test mock factory

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

* fix(agents): add process-runtime PID check to isProcessRunning (#1031)

B13 (P0): All 4 agent plugins now check PID directly when runtime is
'process' instead of only scanning ps -eo for tmux TTYs. Enables correct
dashboard activity state on Windows.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(windows): platform-aware shell for postCreate, script-runner, symlinks (PR 4/6) (#1032)

fix(windows): platform-aware shell for workspaces and script-runner (B07, B08, B19)

  B07: workspace-worktree and workspace-clone use getShell() instead of sh -c.
  B08: script-runner spawns scripts in file-mode on Unix (so $1/$2/$3 reach
       positional args) and uses getShell() on Windows; AO_BASH_PATH override
       uses || so empty string is treated as unset.
  B19: workspace-worktree symlink falls back to cpSync on Windows.

  Also fixes path separator check in workspace-worktree to use path.sep
  instead of hardcoded "/" for correct Windows behaviour.

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

* fix(test): use mockReturnValueOnce for pwsh shell mock to prevent test pollution

vi.clearAllMocks() clears call history but not mockReturnValue implementations.
The Windows pwsh shell tests were polluting subsequent tests that expected the
default sh mock. Changed to mockReturnValueOnce so the override is consumed
by the single call and subsequent tests get the default sh implementation.

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

* fix(cli): script-runner always uses bash on Unix, getShell() only on Windows

getShell() on Unix returns process.env.SHELL || /bin/sh, which may be zsh,
fish, or plain sh. When a script file is passed as an argument to these
shells (file mode), the #!/bin/bash shebang is ignored and bash-specific
syntax in ao-doctor.sh / ao-update.sh / setup.sh breaks.

Fix: hardcode bash on Unix (AO_BASH_PATH still overrides it), use getShell()
only on Windows where bash is unavailable.

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

* fix(core): use lazy factory for Zod runtime default

z.string().default(getDefaultRuntime()) evaluates getDefaultRuntime() once
at module load time, creating hidden coupling between import order and
platform detection. Using a factory function ensures the default is resolved
lazily each time it is needed, making the intent explicit.

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

* fix(cli): add detached: !isWindows() to dashboard spawn for correct process cleanup

Without detached:true on Unix, killProcessTree(pid) calls process.kill(-pid)
which targets a process group the child never owns — ESRCH causes fallback to
direct kill, leaving grandchild processes alive. Matches the pattern already
used in start.ts lines 782 and 795.

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

* fix(cli): forward SIGINT/SIGTERM to detached dashboard child on Unix

Detached children run in their own process group, so Ctrl+C does not reach
them. Without forwarding, the dashboard holds the port after the parent exits.
Matches the identical pattern in start.ts lines 1154-1165.

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

* fix(core): guard killProcessTree against pid <= 0

On Unix, -0 === 0 in JS, so killProcessTree(0) would call process.kill(0)
which sends the signal to every process in the calling process's group,
killing AO itself. findPidByPort can return "0" since it passes the
truthiness check and the /^\d+$/ regex. Guard pid <= 0 at the top of
killProcessTree and return early.

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

* feat(windows): Node.js metadata wrappers + Claude Code hook + pwsh shell (squash merge PR5)

Squash merges feat/windows-hooks-and-launch (PR #1033) into PR1.

Blockers addressed:
- B16: ~/.ao/bin/gh and git wrappers are now Node.js scripts + .cmd shims on
  Windows (bash on Unix unchanged). WRAPPER_VERSION bumped to 0.3.0 to force
  reinstall.
- B17: Claude Code PostToolUse hook uses JSON.parse/fs built-ins on Windows
  instead of bash+jq+grep+sed. Atomic writes via temp file + renameSync.
  chmod skipped on Windows.
- B18: runtime-process uses getShell().cmd + shellInfo.args() instead of
  shell:true (which resolves to cmd.exe on Windows). getShell() returns
  pwsh > powershell.exe > cmd.exe on Windows, bash on Unix.

Conflict resolution:
- runtime-process spawn: kept PR5's getShell() approach (B18 fix), removed
  shell:true which PR1 had as a placeholder.
- runtime-process destroy: kept PR1's cleaner killProcessTree delegation
  (PR5 had inline platform-branching written before killProcessTree was
  integrated into this worktree).
- Tests: merged PR5's new Windows compatibility tests, adjusted assertions to
  match PR1's killProcessTree(pid, signal) API. Fixed Windows compat tests
  that expected old spawn(launchCommand, opts) signature.

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

* fix(core): deduplicate Node.js wrapper updateAoMetadata and add AO_DATA_DIR path validation

- Extract shared NODE_UPDATE_AO_METADATA constant used by both gh/git wrappers
- Add AO_DATA_DIR validation matching bash ao-metadata-helper.sh (must be under ~/.ao/, ~/.agent-orchestrator/, or tmpdir)
- Bump WRAPPER_VERSION to 0.5.0 to force reinstall with new security check
- Update version in agent-codex and core tests to match 0.5.0
- Fix CI test failures from WRAPPER_VERSION bump (0.2.0 → 0.4.0 → 0.5.0)

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

* test(core): add IPv6 netstat test for findPidByPort + mark _resetShellCache as @internal

- Add test case for IPv6 LISTENING entries ([::]:3000) in Windows netstat output
- Add @internal JSDoc to _resetShellCache to clarify it is test-only

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

* fix(cli): use -File mode on Windows for script-runner so positional args are forwarded

PowerShell -Command does not forward argv elements after the command string to the
script — they are treated as top-level PowerShell args and silently dropped. Using
-File passes remaining args as positional parameters ($1, $2, …) to the script, so
e.g. `ao doctor --fix` correctly reaches the script on Windows.

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

* fix(cli,core): remove unused getEnvDefaults export, add SIGKILL fallback in signal forward handler

- Remove getEnvDefaults from @composio/ao-core public API — no production callers;
  the function remains in platform.ts for future use (B10/B11)
- Add 5 s SIGKILL fallback in dashboard.ts and start.ts forward() handlers so the
  parent process cannot hang indefinitely if the child ignores SIGTERM

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

* refactor(cli): extract forwardSignalsToChild utility, fix ESM build script, reject Windows absolute symlinks

- Extract forwardSignalsToChild(pid, child) into shell.ts — eliminates verbatim
  duplication of the SIGTERM/SIGKILL forwarding logic between dashboard.ts and start.ts
- Fix build script: replace node -e "require(...)" with node --input-type=commonjs -e
  "require(...)" — required because package is "type":"module" and require is not
  available in node -e by default in ESM context
- Fix duplicate import in shell.ts (no-duplicate-imports lint error)
- Reject Windows drive-letter (C:\) and UNC (\server\share) paths in symlink
  validation — previously only Unix absolute paths starting with "/" were blocked

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

* fix(cli): clear SIGKILL fallback timer when child exits cleanly

If the child exits before the 5-second escalation window, the fallback
setTimeout would still fire and call process.exit(1). Track the timer
in the outer scope so the child.once("exit") handler can cancel it.

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

* fix: resolve two open bugbot issues on windows-platform-adapter PR

- fix(platform): always use /bin/sh on Unix instead of \$SHELL so
  postCreate commands and runtime launches work correctly when the
  user's login shell is non-POSIX (fish, nushell, etc.)

- fix(script-runner): detect cmd.exe fallback on Windows and throw a
  clear, actionable error pointing to AO_BASH_PATH rather than passing
  the PowerShell-specific -File flag to cmd.exe (which produces a
  cryptic error and still can't run bash scripts)

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

* fix(cli): gate lifecycle worker detached flag behind !isWindows()

Spawning with detached: true unconditionally creates a new console
window on Windows. Use the same !isWindows() pattern established
elsewhere in this PR so the process group behaviour is correct on
both platforms. killProcessTree already uses taskkill /T on Windows
so cleanup is unaffected.

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

* fix(cli): require AO_BASH_PATH for all Windows shells, not just cmd.exe

pwsh and powershell.exe cannot run bash scripts any more than cmd.exe
can — shebangs are ignored and bash-specific syntax fails. The previous
guard only matched cmd.exe, allowing pwsh (the most common Windows
fallback) to silently invoke -File on a bash script and produce a
confusing PowerShell syntax error.

Simplify to: throw on any Windows shell without AO_BASH_PATH. Also
removes the now-dead getShell() call and -File code path from
script-runner.

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

* fix(core): always force-kill on Windows; fix stale .cjs JSDoc

killProcessTree: drop the SIGTERM-without-/F branch on Windows.
taskkill without /F sends WM_CLOSE which is unreliable for headless
Node.js console processes — they may silently survive, leaving orphaned
processes. Always pass /F so termination is guaranteed. Callers that
do SIGTERM→wait→SIGKILL escalation are unaffected: SIGKILL simply
finds the process already dead.

agent-workspace-hooks: correct JSDoc that still said <name>.js after
the extension was changed to .cjs (forced CJS mode).

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

* refactor(cli): remove trivial findRunningDashboardPid wrapper

The function was a one-line pass-through to findPidByPort with no added
logic. Callers now import findPidByPort from @composio/ao-core directly.
waitForPortFree calls findPidByPort inline. Deleted the test file that
only tested the pass-through.

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

* fix(cli): make forwardSignalsToChild idempotent via WeakSet guard

Prevents duplicate SIGINT/SIGTERM handlers if called more than once for
the same ChildProcess — avoids double killProcessTree and racing
process.exit(1) from stacked SIGKILL fallback timers.

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

* fix(agent-claude-code): three Windows correctness fixes

C-1: Add AO_DATA_DIR allowlist validation to METADATA_UPDATER_SCRIPT_NODE.
     The Node.js PostToolUse hook now validates AO_DATA_DIR against
     ~/.ao/, ~/.agent-orchestrator/, and os.tmpdir() before writing —
     matching the protection already in ao-metadata-helper.sh and the
     Node.js wrappers in agent-workspace-hooks.ts.

I-1: Guard getCachedProcessList() against Windows. ps -eo pid,tty,args
     is Unix-only; the guard makes the intent explicit and avoids a
     spurious execFile call when a stale tmux handle is encountered on
     Windows.

I-2: Read systemPromptFile content synchronously on Windows instead of
     using $(cat ...) bash command substitution, which is not understood
     by PowerShell or cmd.exe.

Tests added for all three fixes.

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

* fix(agents): apply Windows correctness fixes to aider, codex, opencode

Mirrors the fixes already applied to agent-claude-code:

I-1: Guard ps -eo pid,tty,args behind isWindows() in isProcessRunning for
     all three agents. ps is Unix-only; a stale tmux handle on Windows
     would silently return false via exception catch. The explicit guard
     makes intent clear and avoids the unnecessary execFile call.

I-2: Inline systemPromptFile content on Windows instead of $(cat ...)
     bash command substitution (aider: --system-prompt; opencode: prompt
     value). codex is unaffected — it passes the path via -c flag and
     reads the file itself.

Tests: added isWindows mock (default false) to all three test suites to
prevent real isWindows()=true on Windows from triggering the new guard
in existing Unix-path tests. Added Windows-specific tests for each fix.

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

* fix(core): use F_OK and skip bare extension in Windows gh/git wrappers

On Windows, fs.constants.X_OK is equivalent to F_OK (execute bit does
not exist), so any existing file passes the check. The empty extension
was also tried first, meaning a bare file named "gh" would be selected
over gh.exe. Switch to F_OK and only check .exe/.cmd extensions.

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

* fix(windows): QA fixes — dashboard spawn, shellEscape, tmux hint, PID fallback

T04: resolveNextBin() skips POSIX .bin/next shim on Windows; invokes
next/dist/bin/next via process.execPath instead (ENOENT fix).

T18: shellEscape() branches on isWindows() — PowerShell uses '' doubling,
Unix uses POSIX '\'' escaping. All 4 agent plugins benefit automatically.

T06 secondary: tmux attach hint in `ao spawn` output gated behind
runtimeName === "tmux" (process runtime has no tmux session).

T06/T11: runtime-process destroy() and isAlive() fall back to
handle.data.pid when the in-memory processes Map is empty — fixes
cross-process CLI calls (ao session ls / ao session kill).

* fix(web): prevent nft EPERM on Windows home directory junction points

Next.js nft (Node File Tracer) scans homedir() at build time and hits
EPERM on Windows junction points (e.g. Application Data). Adds homedir()/**
to TraceEntryPointsPlugin.traceIgnores on Windows server builds.

* Revert "Merge branch 'main' into feat/windows-platform-adapter"

This reverts commit 5ba7644548, reversing
changes made to 5da9bedf5c.

* Reapply "Merge branch 'main' into feat/windows-platform-adapter"

This reverts commit 6a326a07e3.

* fix(windows): update @composio imports to @aoagents scope

Our Windows-specific files were written before the @composio → @aoagents
rename landed. Update all affected imports across runtime-process,
workspace-clone, workspace-worktree, cli commands, and test files.

* feat(windows): add PTY host for ConPTY terminal sessions

Windows equivalent of the tmux daemon. Per-session detached pty-host.js
process owns a ConPTY (via node-pty), listens on a named pipe
(\.\pipe\ao-pty-{hash}-{sessionId}), and relays terminal I/O to any
connected client.

- runtime-process: spawn pty-host on Windows, route sendMessage/getOutput/
  isAlive/destroy through named pipe protocol
- mux-websocket: named pipe relay for dashboard terminal (skip TerminalManager
  on Windows), resolvePipePath via generateConfigHash (instant, no pipe scan)
- direct-terminal-ws: use real mux server on Windows instead of placeholder
- tmux-utils: findTmux returns null on Windows, resolvePipePath added
- orchestrator-prompt: runtime-agnostic language
- opencode: fix isProcessRunning tmux-before-guard bug (W29)

Addresses blockers W01-W12, W23, W24, W28, W29, W33-W35.
Unix behavior completely unchanged.

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

* fix(windows): QA fixes — session attach, ao stop, activity detection

- cli/session: add Windows ao session attach via named pipe relay with
  raw stdin mode and Ctrl+\ to detach; skip getTmuxActivity on Windows
- cli/start: fix ao stop looking for "tr-orchestrator" instead of the
  actual numbered session (e.g. tr-orchestrator-5) — also fixes Linux
- agent-claude-code: fix toClaudeProjectPath dropping Windows drive colon
  (C:\→C- not C) breaking JSONL lookup; ignore stale JSONL entries from
  previous sessions in reused worktrees
- pty-client: use \r (carriage return) instead of \n for PTY Enter key

Addresses blockers W13 (partial), W14.

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

* fix: resolve merge conflicts from main — rename isOrchestratorSession, update stop tests

- session.ts: use isOrchestratorSessionName (renamed in main) for JSON output
- start.test.ts: update stop command tests to use sm.list() instead of sm.get()

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

* fix: address CI failures and review comments

- session.ts: restore isOrchestratorSession from core (checks both name
  and metadata role) instead of name-only isOrchestratorSessionName
- session.ts: remove unused allSessionPrefixes after merge conflict
- start.test.ts: update stop command tests for sm.list() flow
- toClaudeProjectPath: remove speculative space replacement, keep only
  verified chars (/ : .) — addresses review comment about Unix breakage

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

* fix(windows): address review feedback, fix tests, and improve Windows coverage

- Fix CI diff coverage and review feedback
- Fix test reliability for session attach, stop, and Windows attach
- Add coverage for session attach binary protocol and edge cases
- Clean up stdin listener + add resolvePipePath tests
- Address review comments + coverage for pipe relay
- Make 80 failing tests pass on Windows (cross-platform mocks, path assertions)
- Fix production code: execFile shell option for .cmd, isPathInside separator,
  openclaw binary detection via `where` on Windows
- Add platform-aware test assertions for shell escaping, PATH handling, hooks
- Add signal forwarding comment for Windows dashboard process

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

* fix(windows): QA fixes — activity timestamps, prompt delivery, pty-host keep-alive

- session.ts/status.ts: use session.lastActivityAt on Windows (no tmux)
- session-manager.ts: stabilize ConPTY output before sending post-launch prompt
  to prevent prompts being swallowed during agent startup splash screen
- pty-client.ts: split message + Enter into two writes (300ms gap) to match
  tmux send-keys behavior; fix isAlive to return true while pipe is connectable
  regardless of whether the agent process inside has exited
- pty-host.ts: keep named pipe server alive after agent exits (mirrors tmux
  session persistence) so clients can still attach and view scrollback

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

* fix(core): Windows-stable storage hash and atomic write retry

storage-key: normalize to POSIX form (strip drive letter, replace backslash
with forward slash) before hashing so identical repos produce identical
hashes across Windows and Unix, and across different Windows working
directories of the same checkout.

atomic-write: retry renameSync up to 10x with 50ms backoff when Windows
returns EPERM/EACCES/EBUSY — antivirus, file indexer, or backup software
briefly hold handles to recently-written files. Cleans up the temp file
on final failure so subsequent retries don't trip "file exists".

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

* fix(windows): plugin runtime gaps + ConPTY graceful shutdown

runtime-process:
- Reserve the per-instance processes-map slot before the platform split
  so the Windows ConPTY branch participates in duplicate-create detection
  and getMetrics/getAttachInfo bookkeeping. Previously, Windows returned
  a handle without storing it, so duplicate session IDs were silently
  accepted and getMetrics always reported 0 uptime.
- Add 500ms graceful-exit poll before SIGKILLing the pty-host on destroy
  so node-pty can dispose its ConPTY handle. Skipping this orphaned the
  conpty_console_list_agent helper and triggered Windows Error Reporting
  dialogs (0x800700e8) on real runs, not just tests.
- pty-host: install SIGTERM/SIGINT/SIGHUP/SIGBREAK/beforeExit handlers
  that drive the same shutdown sequence (kill pty, drop clients, close
  pipe, exit after 50ms grace), and route MSG_KILL_REQ through the same
  path. Previously MSG_KILL_REQ only called pty.kill() and left the host
  process lingering.
- Add windowsHide:true to the pty-host child spawn so node-pty's helper
  console window stays hidden on errors.

workspace-worktree: normalize paths to a comparable POSIX form
(backslash→slash, lowercase drive letter) when matching git worktree
list --porcelain output against project directories. git emits
forward-slash paths on Windows; path.join produces native backslashes
— the comparison failed and list() returned empty.

agent-opencode: guard tmux/ps usage with isWindows() in isProcessRunning
so process-runtime sessions on Windows take the PID-signal path instead
of attempting Unix-only commands.

cli/start: detect Windows local paths in isLocalPath (drive letter
prefix, UNC path, .\, ..\) so spawn arguments like C:\... aren't
mistaken for project names.

integration test: replace cat + /tmp with platform-native echo (findstr
"x*" on Windows, cat on Unix) and os.tmpdir(); the original used
Unix-only tooling and would never run on Windows.

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

* test(windows): isolate USERPROFILE per test, normalize path assertions

Before this, vitest worker isolation only overrode HOME, but on Windows
os.homedir() reads USERPROFILE. Tests that wrote to ~/.agent-orchestrator
ended up sharing the real user's data directory across workers, leading
to flaky cross-test pollution.

- test-utils: createTestEnvironment / setupTestContext now override both
  HOME and USERPROFILE to the per-test fake home, restore both on
  teardown. rmSync uses maxRetries:5/retryDelay:50 to ride out the same
  Windows file-lock window that atomic-write retries cover.
- core test files (global-config, plugin-integration, portfolio-*,
  project-resolver, recovery-actions, orchestrator-prompt*): set fake
  USERPROFILE alongside HOME and use the retry-aware rmSync.
- update-check.test: normalize path separators in assertions
  (path.replace(/\/g, "/")) so script-path matching works on Windows
  without forcing the production code to emit posix paths.
- orchestrator-prompt.dist.test: pass shell:true on Windows to execFileSync
  for .cmd targets, working around Node CVE-2024-27980's hardening.

Removed lifecycle-service.test.ts — it covered stopLifecycleWorker, which
was deleted when lifecycle was moved in-process during the merge with
main. The remaining lifecycle paths are exercised by lifecycle-manager
tests in core.

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

* fix(core): restore must rewrite statePayload.runtime.handle, not just top-level

Restore was writing the freshly-spawned runtime handle to the top-level
metadata `runtimeHandle` key, but the canonical lifecycle parser prefers
`statePayload.runtime.handle` and falls back to the top-level only when
statePayload is missing. The next lifecycle tick read the stale handle
from statePayload and rewrote both keys from it, silently undoing the
restore's update.

Symptom: a session restored after AO restart kept the old PID in
metadata. Lifecycle probe found that PID dead (it was from a previous
boot) and the dashboard rendered the orchestrator as exited/killed even
though a new process was actually running.

Fix: rebuild the canonical lifecycle with the new handle via
buildUpdatedLifecycle() and persist via lifecycleMetadataUpdates() so
statePayload and runtimeHandle stay in sync. Mirrors the pattern used
in kill/spawn paths.

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

* test(cli): mock exec for ps verification in stop test

killDashboardOnPort runs a unix-only `ps` cmdline check before killing.
Without mocking exec, the call rejects and the catch returns false, so
killProcessTree is never called and the assertion fails on Linux CI.

* fix(windows): suppress console flashes, register process runtime, auto-detect Git Bash, chunk PTY input

- platform.ts: add windowsHide:true to pwsh/powershell/taskkill/netstat
  spawns so AO no longer flashes a console window for each subprocess.
- script-runner.ts: auto-detect Git Bash at the common install paths on
  Windows when AO_BASH_PATH is unset; tighter error if neither auto-detect
  nor override succeeds. WSL bash intentionally excluded — invoking it
  from Windows-native Node mixes Linux paths with Windows cwd and
  silently breaks repo scripts. Also adds windowsHide:true to the spawn.
- web/services.ts: register @aoagents/ao-plugin-runtime-process so the
  dashboard can spawn sessions on projects using runtime: process
  (the Windows default per getDefaultRuntime).
- pty-client.ts: chunk ptyHostSendMessage into 512-char frames with a
  15ms gap so large prompts (~3-4KB+) are no longer truncated by
  ConPTY's input buffer. Cross-platform safe; Unix PTYs absorb chunks
  at full speed. Trailing Enter still sent as a separate frame after
  the existing 300ms pause.
- Mock test helpers updated to handle the (cmd, args, options, callback)
  arity introduced by passing windowsHide; new test covers Git Bash
  auto-detection.

* fix(windows): add windowsHide to remaining subprocess spawns

Sweeps the spawn sites missed by the first pass — `ao stop`, session
list, opencode introspection, tmux helpers, and worktree git/postCreate
all bypassed the previous fix and still flashed conhost on Windows.

- cli/lib/shell.ts: exec helper (used by git/gh/tmux wrappers)
- core/session-manager.ts: EXEC_SHELL_OPTION + standalone tmux call
- core/tmux.ts: tmux execFile helper
- plugins/workspace-worktree: git wrapper + rev-parse + postCreate shell
- workspace-worktree tests: assertions updated for the new options shape

* fix(codex): make agent-codex work on Windows

Three Windows-specific gaps that combined to make every Codex spawn fail
on PowerShell with "Unexpected token '-c' in expression or statement":

- formatLaunchCommand(): prepend `& ` to the joined launch string when
  running on Windows. shellEscape quotes the resolved binary path
  ('C:\Users\...\codex.cmd'), and PowerShell parses a leading quoted
  string as an expression — without the call operator the next flag
  triggers a parser error before codex is ever invoked. bash treats
  the same string as a normal command, so the prefix is Windows-only.
  Applied at both getLaunchCommand and getRestoreCommand exits.

- resolveCodexBinary(): add a Windows branch using `where.exe` instead
  of `which`. Prefers codex.cmd (npm shim) over codex.exe (Cargo build),
  then falls back to %APPDATA%\npm\codex.{cmd,exe} and ~\.cargo\bin
  for users whose PATH doesn't yet include the install dir. Lookup runs
  with windowsHide:true so the search itself doesn't flash a console.

- sessionFileMatchesCwd(): compare paths via a canonical form
  (forward slashes, lowercased drive letter) so Codex JSONL rollout
  files can still be located when payload.cwd uses a different slash
  direction or drive-letter case than the workspace path AO computes
  via path.join. Without this, dashboard activity/cost stay empty
  for Codex sessions on Windows.

* fix(windows): resolve gh.exe via PATHEXT and fix path-shape test regexes

resolveGhBinary() searched PATH for a literal "gh" file and threw on
Windows where the binary is gh.exe (or gh.cmd for npm shims). All gh
calls in tracker-github and scm-github failed before reaching execFile,
which made spawn() fall back from tracker-derived branch names and made
cleanup() skip the gh-driven kill paths entirely.

Honor PATHEXT on win32 so the resolver matches gh.exe/.cmd/.bat. Update
the four affected integration assertions to accept Windows path shapes.
Also bump the runtime-process sendMessage sleep on Windows — ConPTY
pipe round-trip needs more headroom than the Unix direct-stdin path.

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

* fix(windows): junctions/hardlinks for symlinks, WinRT toast notifier, DPI re-fit

workspace-worktree: when symlinkSync EPERMs on Windows (no admin /
Developer Mode), try a junction for directories and a hardlink for
files before falling back to recursive cpSync. The previous fallback
copied node_modules into every worktree — slow and bloated.

notifier-desktop: add a win32 branch using PowerShell + WinRT toast XML
(no third-party deps). The script is base64-encoded as -EncodedCommand
to sidestep PowerShell argument tokenization. Toast failures log a
warning instead of rejecting so a stripped-down SKU or disabled
notifications can't crash the lifecycle.

DirectTerminal: re-fit on devicePixelRatio changes via matchMedia.
ResizeObserver doesn't fire when only DPR changes (e.g. dragging the
window between monitors at different scales on Windows), leaving an
unrendered stripe to the right of the last column until manual resize.

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

* fix(test): update notifier-desktop integration test for Windows toast support

I missed this duplicate test in the integration-tests package when I
added the Windows branch to notifier-desktop. The mock callback used the
3-arg execFile signature (cmd, args, cb) but the new win32 path calls
execFile with 4 args (cmd, args, opts, cb), so the callback landed in
the opts slot and "cb is not a function" broke CI on Linux.

Make the mock signature-agnostic and replace the win32 "no execFile,
warns" assertion with one that verifies the EncodedCommand toast script.
Add a separate freebsd case for the actual unsupported-platform path.

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

* docs: consolidate six Windows port plans into one closeout

All 11 punch-list tasks shipped (junctions, WinRT toast, DPR re-fit
landed last) plus the foundational PTY-host / runtime-process work.
Stop fix is the only deferred item, waiting on upstream PR #1496.

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

* fix(agents): use shell:true on Windows in detect() to honor PATHEXT

execFileSync with a bare command name on Windows does not consult
PATHEXT — it only finds literal .exe files. CLIs installed via
npm install -g land at %APPDATA%\npm\<name>.cmd, which detect() can't
see, so AO reports the agent as not installed.

Add shell: isWindows() so cmd.exe handles PATHEXT and finds .cmd shims.
Adds windowsHide: true while we're there to suppress conhost flashes.

Affects all 5 agent plugins: aider, claude-code, codex, cursor, opencode.
Reproduced with codex installed via npm on a Windows EC2 box where
where.exe codex resolved to codex.cmd but detect() returned false.

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

* fix(windows): probe absolute powershell path so PATH-degraded children don't fall through to cmd.exe

The dashboard (Next.js) sometimes spawns the runtime-process pty-host with a PATH that
lacks C:\Windows\System32. Both `pwsh` and `powershell.exe` probes in
resolveWindowsShell() then fail and we drop to cmd.exe — which can't execute the
PowerShell-syntax launch commands agents emit (e.g. Codex's `& 'codex' ...`),
producing `'&' was unexpected at this time.` and an immediately-exited orchestrator.

Probe %SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe directly via
existsSync — this path is guaranteed on Windows 10+ and doesn't depend on PATH.
Also add an AO_SHELL env override as an explicit escape hatch.

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

* fix: restore orchestrator session with its systemPromptFile

When restoring an orchestrator session whose agent has no resumable thread for
the worktree (e.g. Codex when the rollout file's cwd doesn't match), restore()
falls back to getLaunchCommand(agentLaunchConfig). The fallback's
agentLaunchConfig was missing systemPromptFile, so Codex booted as a bare TUI
with no orchestrator instructions — the dashboard terminal showed the default
"Write tests for @filename" prompt instead of the orchestrator running.

spawnOrchestrator writes the prompt to {baseDir}/orchestrator-prompt-{sessionId}.md
and threads it through agentLaunchConfig.systemPromptFile (session-manager.ts:1687).
Re-attach the same file on restore when the role is orchestrator and the file
still exists on disk.

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

* fix(test): assert negative pid in start full-stop test

killProcessTree on Unix targets the process group first via
process.kill(-pid, signal); only falls back to positive pid if that
throws. The test mock returns true, so only the negative-pid call
ever fires. Update assertion to match the actual call.

Caught by CI on Linux (test was Windows-skipped locally).

* fix(test): assert on killProcessTree mock, not process.kill

killProcessTree is module-mocked at the top of start.test.ts, so
process.kill is never invoked by the stop command — the spy
assertion would always see 0 calls on Linux CI. Assert on the
mock directly. Mock is platform-agnostic, so the skipIf is gone.

* fix(windows): node wrapper updateAoMetadata supports V2 .json metadata format

The Windows Node.js gh/git wrappers in NODE_UPDATE_AO_METADATA only tried
the bare session path (e.g. ao-154), but V2 storage uses ao-154.json files.
This caused silent metadata update failures on Windows — PR URLs written by
agents via `gh pr create` were never recorded in session metadata.

Fix mirrors bash ao-metadata-helper.sh: try .json first (V2), fall back to
bare name (V1/legacy). Also adds JSON.parse/stringify handling for V2 JSON
format instead of the key=value line-splitting that only worked for V1.

Bump WRAPPER_VERSION 0.6.0 → 0.7.0 to force reinstall on existing setups.

* chore: remove accidentally committed package-lock.json files

* feat(windows): pty-host registry + sweep on stop and project delete

Windows pty-hosts spawn detached so they survive parent exit (mirroring tmux
on Unix). That same detachment means taskkill /T cannot reach them on
graceful shutdown — they live in their own console group, outside the
parent's process tree. Per-session metadata can't be the source of truth
either: rm -rf'd worktrees, mid-write crashes, or manual recovery sever
AO's only handle to the host PIDs and orphan them silently.

This adds a sideband registry at ~/.agent-orchestrator/windows-pty-hosts.json
that AO writes on spawn (runtime-process) and reads on shutdown (cli/start.ts
sweepWindowsPtyHosts) and project delete (web/.../route.ts via
stopStaleWindowsPtyHosts). Reads auto-prune entries whose PID is gone, so
the registry is self-healing across crashes.

Sweep is graceful-first: each entry gets ptyHostKill via its named pipe,
500 ms grace probe, then killProcessTree as the hard fallback. The result
("swept N pty-host(s): G graceful, F force-killed") goes to the ao stop
log so users can see cleanup happened.

Verified live: spawn registers, destroy unregisters, ao stop --all sweeps,
PID 0 entries auto-prune on next read.

* fix(windows): retry worktree rmSync on file-handle drain race

After ao kills a runtime, the just-exited pty-host's child processes
(conpty_console_list_agent.exe, the agent's spawned shell, .git/index.lock)
still hold open handles inside the worktree for ~30 s–2 min while Windows
drains them. rmSync(force: true) deletes individual files but the parent
rmdir blocks with EBUSY/ENOTEMPTY/EPERM, leaving an empty orphan directory
under ~/.agent-orchestrator/projects/*/worktrees/.

destroy()'s catch-block fallback now calls removeDirWithRetry, which on
Windows retries with backoff [0, 100, 250, 500, 1000, 2000] ms checking
existsSync between attempts, and throws a descriptive error if the
directory survives all six. Non-Windows behaviour is unchanged (single
rmSync).

The thrown error escapes to session-manager.ts:kill which already swallows
it, so callers see no behaviour change today — but observability layers
can hook in later to surface real failures instead of silent orphans.

Addresses the Windows subset of #1562 (the cross-platform stale
.git/worktrees/<id>/ registration is still tracked there separately).

* fix(windows): code-review hardening — shell args, runtime default, sessionId, V2 pipe path

Four small fixes flagged in review of the Windows port:

- core/platform.ts: AO_SHELL override now infers args flag from the shell
  basename (cmd → /c, bash/sh/zsh → -c, anything else → -Command). Previously
  every override got PowerShell args, so AO_SHELL=cmd or AO_SHELL=bash
  silently broke run-command flows.

- core/global-config.ts: defaults.runtime now resolves to getDefaultRuntime()
  (process on Windows, tmux elsewhere) instead of the hardcoded "tmux".
  First-run on Windows no longer writes a config that immediately fails
  runtime resolution.

- web/server/mux-websocket.ts: validateSessionId now runs on the Windows
  named-pipe relay path. The Unix branch validates inside TerminalManager;
  the Windows path bypassed it entirely, so an unsanitised id became both
  a map key and was interpolated into a pipe path downstream.

- web/server/tmux-utils.ts: resolvePipePath now reads the V2 JSON layout
  (~/.agent-orchestrator/projects/{projectId}/sessions/{id}.json) first,
  then falls back to V1 line-delimited metadata for users who haven't run
  ao migrate-storage. The single-source-of-truth note is still accurate;
  the search just covers both layouts during the migration window.

Each change has a paired unit test.

* chore: drop superseded windows-port-closeout plan

* fix(core): lazy-resolve homedir() in windows-pty-registry

REGISTRY_FILE was computed at module load via homedir(), which fired
before vitest mock factories for `node:os` could install. Tests that
mock node:os (notifier-desktop, terminal-iterm2, agent-claude-code
activity-detection) hit either a TDZ error or "homedir not defined on
mock" because the mock isn't bound at evaluation time.

Resolve the path lazily inside readRaw/writeRaw so each call honours
the current mock. Also rename the test helper export from a const to
a function (__getWindowsPtyRegistryFile) so tests can read the
post-mock value.

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

* fix(cli): exit 0 when SIGKILL fallback fires on slow Ctrl+C

forwardSignalsToChild's 5 s fallback called process.exit(1) after
SIGKILL, which marks user-initiated Ctrl+C as an error whenever the
child is merely slow to drain (Next.js connection draining is the
common case). Shell scripts and CI pipelines that check the AO exit
code break.

Use exit 0 — graceful user shutdown is not a failure even if the
child needed force-killing. Reported by greptile review on PR #1025.

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

* fix(core): drop synchronous shell probes that block event loop

resolveWindowsShell ran execFileSync("pwsh", ["-Version"], { timeout: 5000 })
on every cold start. On the common case (Windows 10/11 with no pwsh
installed) the call blocks the Node event loop for the full 5 s timeout,
stalling AO startup, runtime spawns, and postCreate hooks.

Walk PATH ourselves via existsSync — the lookup is microseconds and
needs no subprocess. Cascade unchanged: AO_SHELL → pwsh on PATH →
absolute powershell.exe → powershell on PATH → cmd.exe.

Reported by greptile review on PR #1025.

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

* fix(runtime-process): treat EPERM as alive in pty-host destroy probe

destroy()'s 500 ms graceful-shutdown loop probes the pty-host with
process.kill(pid, 0) and treats any throw as "process gone, clean
exit". On Windows, cross-context processes can return EPERM — the
process is alive but we lack permission to signal it. Returning
early in that case orphans the pty-host and skips killProcessTree.

Detect EPERM and break out of the wait loop so the orphan falls
through to killProcessTree. Other error codes (ESRCH etc.) still
mean the process is gone.

Reported by Copilot review on PR #1025.

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

* fix(cli): discover Git Bash via PATH walk for non-default installs

WINDOWS_BASH_CANDIDATES only checks C:\Program Files{,(x86)}\Git, so
users who installed Git for Windows on a different drive (e.g.
D:\Program Files\Git\) hit "Cannot run repo scripts on Windows
without bash" even though Git Bash is available. AO_BASH_PATH is the
documented escape hatch but should not be required.

Add a PATH-walk fallback that finds bash.exe wherever Git's bin dir
sits — Git for Windows adds itself to PATH at install time, so this
covers the typical non-default-drive case without a subprocess or
registry lookup.

Reported by greptile review on PR #1025.

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

* fix(core): hard-code Windows path separators in findOnPath

Using path.delimiter / path.join in the PATH walker meant Linux CI
ran the test with `:` as the splitter and `/` as the joiner. The unit
test simulates Windows by setting PATH="C:\fake\bin" — on Linux
that splits to ["C", "\fake\bin"] and produces "C/powershell.EXE",
neither of which match the mocked existsSync.

findOnPath is only ever called from resolveWindowsShell, so use `;`
and `\` unconditionally. The runtime data — process.env values,
mocked existsSync — is what's being tested, not host-OS path logic.

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

* feat(windows): PowerShell repo-script runner + ao-doctor/ao-update.ps1

runRepoScript on Windows now prefers a .ps1 sibling of the requested .sh
script and runs it via pwsh.exe (or bundled powershell.exe as fallback).
Adds ao-doctor.ps1 and ao-update.ps1 as Windows equivalents of the
existing bash scripts.

* test(cli): add missing mockExecSilent hoist in dashboard.test.ts

The findRunningDashboardPidsForWebDir tests reference mockExecSilent
but it was never declared in vi.hoisted, so they crashed with
ReferenceError before any assertion ran. Add the missing hoist and
wire execSilent into the shell.js mock.

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

* fix(windows): echo projectId in pipe relay messages so MuxProvider routes correctly

MuxProvider keys subscribers under `${projectId}:${id}` when projectId is
provided. The Windows pipe relay was dropping projectId from outbound
messages, so the client routed by id alone and the subscriber bucket
mismatched — leaving the xterm pane blank on
/projects/[id]/sessions/[id].

Echo projectId on every outbound terminal frame (opened/data/exited/error)
so the Windows path matches the Unix tmux relay's behavior.

* test(core): respect TMPDIR in platform defaults test

* fix(runtime): harden dashboard launch shutdown

* fix(windows): scope pipe maps and resolvePipePath by projectId

The Windows pipe relay was project-scoped only on outbound WS frames.
Server-side storage and pipe-path resolution still keyed by bare session
id, so two projects sharing a session id on the same mux connection
would collide on the same socket/buffer entry, and resolvePipePath
returned the first matching project's metadata regardless of caller
intent. Brings the Windows path in line with the Unix subscriptionKey
contract.

- resolvePipePath(sessionId, projectId?, fs?) reads only the caller's
  project metadata when projectId is provided; legacy callers keep the
  walk-all-projects fallback.
- winPipes / winPipeBuffers keyed by \${projectId}:\${id}.
- projectId threaded through handleWindowsPipeMessage data/resize/close
  cast sites.
- Tests cover the project-collision case in both mux-websocket and
  tmux-utils.

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

* fix(windows): clear 10 Windows test failures

Real fix:
- events-db: add closeDb() to release the better-sqlite3 file lock on
  activity-events.db. Without it, Windows callers cannot rmSync the AO
  base dir while the connection is open. Test teardowns in
  test-utils.ts and plugin-integration.test.ts now call closeDb()
  before rm to fix 4 EBUSY failures.

Test-only:
- tmux-utils.test.ts: normalize backslashes to forward slashes in two
  resolveTmuxSession 'hash-prefix' tests; matches the pattern already
  used by sibling tests in the same file.
- dashboard.test.ts, script-runner.test.ts, update-script.test.ts:
  add it.skipIf(process.platform === 'win32') to four tests that
  assert Unix-specific behavior (lsof cwd matching, posix script
  paths, ao-update.sh smoke). Each file already uses the same skip
  pattern for sibling tests.

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

* test(cli): port-scan fallback + Windows PowerShell branch coverage

start.test.ts: re-add the orphaned-dashboard port-scan test that was
lost during the merge from main (commit 4958512d). When the dashboard
auto-reassigns to port+N because the configured port was busy, ao stop
must walk port+1..port+MAX_PORT_SCAN to find it. Skipped on Windows
because killDashboardOnPort skips the ps cmdline verification there.

script-runner.test.ts: add coverage for the Windows PowerShell branch
in runRepoScript. Two Windows-only tests assert (1) ao-doctor.sh is
rewritten to ao-doctor.ps1 and dispatched via pwsh.exe / powershell.exe
with -NoProfile -NonInteractive -ExecutionPolicy Bypass -File and
forwarded user args, and (2) the rewrite is .sh-suffix-driven, not
blind, so a non-.sh script does not get a .ps1 lookup.

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

* fix(agent-kimicode): make plugin Windows-compatible

Seven blocking issues prevented kimicode from working on Windows. None
were guarded by isWindows checks because the plugin was authored
without importing it. Symptoms ranged from silent agent launch
failures to misclassified process state to total session-discovery
breakage.

1. getLaunchCommand emitted bare command strings ("kimi --work-dir
   ...") which PowerShell parses as a quoted expression rather than
   executing. Wrap with formatLaunchCommand() so Windows gets the
   "& " call operator, matching agent-codex.

2. isProcessRunning called ps -eo on the tmux branch with no platform
   guard. ps does not exist on Windows, so a stale tmux handle would
   throw and misclassify a live agent as exited. Added the same
   isWindows() return-false guard agent-codex uses.

3. resolveWorkspacePath called realpath() unconditionally. On Windows,
   Node's realpath silently canonicalizes non-existent paths instead
   of throwing ENOENT — turning "/workspace/test" into
   "D:\workspace\test" and diverging the session-discovery hash from
   any caller that hashed the raw input. Stat first so the catch path
   is reached uniformly across platforms.

4. isInsideKimiSessions hardcoded "/" as the path separator in the
   sandbox check. realpath returns native paths (backslashes on
   Windows) so candReal.startsWith(rootReal + "/") never matched —
   every candidate was rejected and findKimiSessionMatch returned
   null forever. Use path.sep.

5. getEnvironment set PATH and GH_PATH locally with hardcoded POSIX
   values. session-manager already injects both for every agent
   plugin, so the local writes were dead code that masked the
   Windows-aware central logic. Drop them; mirror agent-codex.

6. getLaunchCommand passed config.systemPromptFile via --agent-file,
   but kimi expects --agent-file to be a YAML agent spec, not arbitrary
   markdown. AO writes the orchestrator prompt as a plain .md file, so
   kimi exited with 'Invalid YAML in agent spec file: expected
   <document start>, but found <block sequence start>' on the first
   bullet. Read the file synchronously and inline its contents into
   --prompt instead, concatenating with any existing config.prompt.

7. session-manager listed kimicode in requiresNativeRestore, so when
   getRestoreCommand returned null (because the previous launch failed
   before kimi wrote any session data), AO threw
   SessionNotRestorableError instead of falling back to a fresh
   getLaunchCommand. Removed kimicode from the allowlist; falling back
   is the only sensible behavior when there is no session on disk to
   resume.

Tests: switched the per-suite workspace constant to a per-test
mkdtemp-scoped path so a coincidental directory at /workspace/test
on the host doesn't make Windows realpath canonicalize it. Mocked
isWindows so platform-aware production code can be exercised
deterministically. Made the shell-escape prompt assertion
platform-aware (POSIX 'backslash-quote' vs PowerShell double-quote).
Replaced the --agent-file tests with system-prompt-content-into-prompt
assertions backed by a real temp file under fakeHome.

Result: all 103 tests pass on Windows (was 30 failures pre-fix).

* fix(agent-claude-code): preserve Windows drive-letter slug encoding

The merge of origin/main #1611 ("fold underscores in Claude project
slug") inadvertently regressed Windows behavior. #1611 kept the
pre-existing `.replace(/:/g, "")` so `C:\Users\dev\foo` slug-encoded
to `C-Users-dev-foo` (single dash), but Windows-side QA had already
established (commit 582c5373) that real Claude Code on Windows
produces `C--Users-dev-foo` — the colon position becomes a dash,
not stripped. Stripping the colon broke JSONL lookup on Windows so
session info / restore / metadata persistence all silently failed.

Two test files disagreed after the merge: activity-detection.test.ts
expected the Windows-correct double-dash form (kept by my merge),
while index.test.ts expected origin's single-dash form (added by
#1611). Linux CI ran activity-detection's case against the
single-dash impl and failed loudly.

Fix: drop the redundant `.replace(/:/g, "")`. The broader
`[^a-zA-Z0-9-]` regex already handles the colon as a dash, which
matches Claude's actual on-disk encoding on Windows. Updated
index.test.ts to expect `C--Users-dev-foo` and fixed an unrelated
local-Windows test bug where a hardcoded POSIX path string was
compared against a `pathJoin` result (passes on Linux CI but fails
locally on Windows).

Underscore folding from #1611 is preserved.

* fix(cli): Windows platform adapter follow-ups

Three independent Windows correctness fixes bundled with their tests:

* daemon.ts: killExistingDaemon now uses killProcessTree (taskkill /T /F)
  instead of raw process.kill so detached grandchildren of the daemon
  (pty-host, dashboard subprocess) are reached on Windows. POSIX behavior
  is preserved via killProcessTree's process-group fallback.

* startup-preflight.ts: on Windows, when the project config selects
  runtime: tmux, offer to rewrite the line to runtime: process in the
  project YAML instead of prompting "install tmux?". The rewrite is a
  targeted line-replace (not yaml round-trip) so comments and quoting
  are preserved. Decline -> hard exit with manual-fix guidance.

* path-equality: new pathsEqual / canonicalCompareKey helpers used by
  start.ts and resolve-project.ts for "same filesystem entry" checks.
  realpathSync on Windows can return canonical paths whose drive-letter
  case or 8.3-vs-long-name expansion differs from the input even when
  both resolve to the same on-disk entry, which made naive === comparisons
  miss and surface as phantom "register this project?" prompts on
  re-runs of `ao start <path>`. Lowercases on Windows; POSIX is
  unchanged.

Also fixes resolve-project.ts's isLocalPath to recognize Windows path
patterns (drive-letter, UNC, .\, ..\) so `ao start C:\path\to\repo`
takes the path branch instead of being mis-classified as a project id.

Test changes: makeConfig now defaults to runtime: process so tests run
on every platform without tripping the Windows-tmux exit; the one tmux
preflight test pins process.platform = 'linux'. The "kills existing
process" test asserts on killProcessTree instead of process.kill.
On-disk yaml fixtures in start.test.ts switch from runtime: tmux to
runtime: process for the same reason.

New tests: 7 in startup-preflight.test.ts (Windows rewrite, decline
exit, missing configPath exit, comment+quoting preservation, Linux
pass-through), 8 in path-equality.test.ts (drive-letter case, segment
case, POSIX case-sensitivity, realpathSync fallback, ~ expansion),
killProcessTree assertions added to daemon.test.ts.

Verified non-issues during the audit (no code change): ao stop graceful
shutdown gap (the work was already moved into ao stop itself in a prior
refactor; running.json/last-stop/sessions are persisted before the
parent kill, and stale state is self-healing on next read);
better-sqlite3 cross-platform binary (optionalDependencies +
files: ['dist'], no prebuilt .node bundled in any release artifact);
ao-doctor / ao-update PowerShell rewrite (script-runner already
rewrites .sh -> .ps1 on Windows, .ps1 siblings ship in assets/scripts/,
covered by an existing Windows-only test); bun-tmp-janitor leak
(janitor is a no-op on Windows because opencode ships no win32 binary
and Windows refuses to unlink mapped files).

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

* fix(cursor): silence stderr bleed-through in detect() on Windows

execFileSync("agent", ["--help"]) with encoding but no explicit stdio
inherits stderr from the parent process. On Windows with shell:true,
cmd.exe prints "'agent' is not recognized as an internal or external
command" to the terminal even though the exception is caught.

Fix: add stdio:["ignore","pipe","ignore"] to capture stdout (needed for
Cursor marker checks) and discard stderr. Mirrors the pattern used by
the kimicode plugin's detect(). Also adds a 5s timeout as a safety net.

Zero behavior change on macOS/Linux: shell:false means Node throws ENOENT
directly with no subprocess output, so the try/catch already handles it.

Fixes the spurious error printed during ao start first-run setup on Windows.

Co-authored-by: Priyanchew <57816400+Priyanchew@users.noreply.github.com>

* fix(runtime-process): preserve EPERM in Windows pty-host sweep exit-poll

The catch in sweepWindowsPtyHosts treated every error as "process exited",
including EPERM. On Windows EPERM means the pty-host exists but the caller
lacks permission to signal it (cross-context), so the orphan was skipping
the killProcessTree force-kill step and leaking. Mirror the destroy() logic
at line 290: only flag exited on non-EPERM (typically ESRCH).

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

* test(runtime-process): poll for payload instead of fixed sleep

The Windows ConPTY round-trip (named pipe -> pty-host -> pwsh -> findstr
-> rolling buffer) varies from hundreds of ms to seconds depending on
runner load, AV scanners, and cold caches. The previous 1500 ms fixed
sleep flaked on slow Windows runners (observed empty getOutput buffer at
sample time). Replace it with a 10 s deadline poll that checks for the
actual payload substring, robust to both timing variance and incidental
shell banners arriving first.

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

* docs: document cross-platform abstractions and reflect Windows support

Adds docs/CROSS_PLATFORM.md as the canonical reference for cross-platform
development: the "Golden Rule" (no raw process.platform === "win32" — use
isWindows() and the helpers in platform.ts), a full inventory of every
platform helper (platform.ts, path-equality, windows-pty-registry,
pty-client, sweepWindowsPtyHosts, validateSessionId, resolvePipePath,
setupPathWrapperWorkspace, activity-state helpers, AO_SHELL/AO_BASH_PATH),
the EPERM-vs-ESRCH gotcha when probing processes, PowerShell-vs-bash
differences, IPv6 localhost stalls, agent-plugin specifics, and a 10-point
pre-merge checklist.

Updates internal docs (CLAUDE.md, AGENTS.md, docs/ARCHITECTURE.md,
docs/DEVELOPMENT.md, CONTRIBUTING.md, .github/copilot-instructions.md,
.cursor/BUGBOT.md, packages/core/README.md, packages/plugins/runtime-tmux/
README.md, packages/core/src/prompts/orchestrator.md, ARCHITECTURE.md) to
remove tmux-only / POSIX-only claims, point at the new doc, and (in
docs/ARCHITECTURE.md) describe the Windows runtime architecture: pty-host
helper, named-pipe protocol, registry, sweep, mux WS Windows branch.

Updates user-facing docs (README.md, SETUP.md, docs/CLI.md) to split
prerequisites by OS (no tmux on Windows), reflect that ao doctor and
ao update work on Windows, and note that power.preventIdleSleep is a
no-op on Linux and Windows.

Updates the agent-orchestrator skill (skills/agent-orchestrator/SKILL.md
and references/config.md) so it advertises Windows support, drops tmux
from the required-bins list, and gives the right Windows guidance for the
"spawn tmux ENOENT" error.

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

* test(core): update orchestrator-prompt test for cross-platform runtime warning

The orchestrator system prompt was rewritten in 1d8c8f75 to call out both
tmux send-keys (Unix) and the Windows named-pipe write path so the
orchestrator agent doesn't try either. The test still asserted the old
literal "never use raw \`tmux send-keys\`" string. Update it to assert the
new platform-neutral phrasing plus the presence of both runtime mentions.

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

* ci(windows): matrix Linux+Windows + close coverage gaps

Adds windows-latest to typecheck/test/test-web matrices (lint stays
Linux-only; nothing ESLint catches differs by OS). fail-fast: false
so one OS's failure never masks the other's. tmux install steps gate
on runner.os == 'Linux' since Windows uses runtime-process. test job
adds a node-pty prebuild smoke step on Windows so a future ABI break
fails fast with a clear message. test-web is broadened from
server/__tests__/ to the full vitest suite — closes a pre-existing
Linux-too gap and ensures component/hook/lib tests run on Windows.

Closes three completeness gaps where Windows code paths existed but
no test exercised them:

1. session.test.ts (5 tests): "tests Windows behavior, skips on
   Windows" defensive pattern. Tests fully mock isWindows + net.connect
   + child_process — flipping skipIf(win32) to plain it() runs them on
   both OSes. All 45 tests pass on Windows.

2. dashboard.test.ts (+2 tests): findRunningDashboardPidsForWebDir
   has parallel POSIX (lsof + cwd verification) and Windows
   (findPidByPort, no cwd check) implementations. Existing tests
   asserted lsof; new runIf(win32) tests assert findPidByPort path
   plus dedup across multiple ports.

3. start.test.ts (+1 test): port-scan fallback for orphaned
   dashboards skips ps cmdline verification on Windows by design.
   Existing test asserted ps was called; new runIf(win32) parallel
   asserts ps was NOT called and the kill still fires.

Adds first PS1 script test coverage (previously zero):

4. update-ps1.test.ts (4 tests): argparse — --help/-h, unknown flag,
   conflicting --skip-smoke + --smoke-only.

5. doctor-ps1.test.ts (4 tests): argparse + full check pipeline
   smoke. The pipeline test runs every Check-* function against an
   empty repo and asserts the script exits cleanly with a "Results: N
   PASS, N WARN, N FAIL, N FIXED" summary line — catches PS1 syntax
   errors and crashes mid-pipeline.

Net effect: CLI suite went from 622 -> 630 passing tests on Windows
(5 unskipped + 8 new); skipped count dropped from 25 -> 20. All other
suites unchanged.

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

* ci(windows): add minimal permissions block to CI workflow

CodeQL flagged the workflow as missing an explicit permissions
declaration (security/code-scanning/61). All jobs are read-only
(checkout, install, build, test) — contents: read is sufficient.
Matches the workflow-level pattern already used in coverage.yml.

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

* chore: add changeset for native Windows support

Minor bump across the linked package group. The next release PR will
consume this and bump from 0.4.0 to 0.5.0.

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

* fix(cli): make ao open work cross-platform

Mac-only assumptions broke `ao open` on Windows and Linux:

- source of truth was `tmux list-sessions`, which is empty without tmux
- the open action shelled out to `open-iterm-tab`, a macOS helper

Switch the source of truth to `sm.list()` (works on every platform — also
handles `runtime-process` sessions on Windows) and branch the open action:

- macOS: `open-iterm-tab` (unchanged), tmux attach inside iTerm
- Windows: `wt new-tab cmd /k ao session attach <id>` for live sessions,
  with `cmd /c start cmd /k ...` as the no-`wt` fallback. Both paths route
  through `cmd /k` because `wt` and `start` call CreateProcess directly,
  which doesn't honor PATHEXT and reports 0x80070002 for `ao` (really
  `ao.cmd`). New tab anchors at `config.projects[id].path` so the spawned
  attach can resolve `agent-orchestrator.yaml` via loadConfig's upward
  search; without this attach fails with "No agent-orchestrator.yaml found"
  when the user's homedir is the inherited cwd.
- Linux: dashboard URL via `openUrl()`. No consistent terminal-spawn API
  across DEs, so we don't try.

Other behavior changes:

- read the live daemon's port from `running.json` so URLs stay correct
  when the dashboard auto-picked a non-default port
- warn when the daemon is not running (URL fallback won't load)
- aggregate targets (`all`, `<project>`) hide terminated sessions; named
  lookup keeps them in scope and opens the dashboard with the death
  reason inline (`died at <ts>: session=<reason>, runtime=<reason>`) plus
  a `ao session restore <id>` hint
- new `--browser` flag forces the URL path on any platform

Add `isMac()` to `platform.ts` (per the project rule that platform checks
live in one place rather than spread as ad-hoc `process.platform === ...`
guards) and re-export from core.

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

* fix(cli): satisfy lint on ao open changes

- replace inline `import("node:child_process")` type annotation in vi.mock
  with a top-of-file `import type * as ChildProcess` (consistent-type-imports)
- drop the `[]` initializer on `sessionsToOpen` since every branch assigns
  before any read (no-useless-assignment)

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

* chore: add changeset for cross-platform ao open fix

Patch entry for d04fad33 / 32345ba8. Linked group already minor-bumping
via the Windows-support changeset, so this just contributes a distinct
CHANGELOG line.

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

* chore: fold ao open fix into the Windows-support changeset

Single umbrella entry is the right place for it — the separate patch
changeset was redundant given the linked-group minor bump already in
flight. Reverts 3557e556.

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

* Changes before error encountered

Agent-Logs-Url: https://github.com/ComposioHQ/agent-orchestrator/sessions/9f3caf0b-66fb-4eff-bd3f-7fb9ab889630

Co-authored-by: Priyanchew <57816400+Priyanchew@users.noreply.github.com>

* test(core): mock node:child_process via importOriginal in migration test

The atomic-write.ts → platform.js refactor in eaa27b9b pulled platform.ts
into migration-storage-v2.test.ts's module graph. platform.ts evaluates
promisify(execFile) at top level, but the test's bare-object child_process
mock omitted execFile, so the dynamic import crashed with "No 'execFile'
export is defined on the 'node:child_process' mock".

Switch to vi.doMock with importOriginal so any unmocked exports stay real.
This is robust against future imports adding more child_process surface.

Only Ubuntu CI surfaced the regression — the failing test sits inside
describe.skipIf(process.platform === "win32") so the windows-latest leg
never executed it.

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

* test(core): hoist child_process type to satisfy consistent-type-imports

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

* chore(perf): add AO_PERF-gated instrumentation for dashboard load

Temporary tracing added to diagnose 15-20s dashboard terminal load on Mac
and Windows. Gated on AO_PERF=1 (server) and NEXT_PUBLIC_AO_PERF=1
(client) so production paths stay untouched. To be removed once the
bottleneck is fixed.

Wrap points:
- core/perf.ts: perfMark / perfTime helpers + perfCid
- web /api/sessions/[id]: per-stage timings (getServices, sm.get, audit,
  enrichMetadata, total)
- core/session-manager: runtime.isAlive, agent.getActivityState,
  agent.getSessionInfo, ensureHandleAndEnrich
- agent-codex: findCodexSessionFile (scanned/opened/matched counts) +
  cache hit marker
- runtime-process/pty-client: connect outcome + isAlive (split
  connectMs vs statusMs)
- web/lib/serialize: enrich legs (agentSummary vs issueTitle) timed
  independently while still running concurrently
- web/sessions/[id]/page.tsx: client.fetch.start/end with cid header
  forwarded for end-to-end correlation
- web/MuxProvider: ws.open + ws.firstByte per terminal

* fix(core): drop bogus session.agent reference from perf extras

Session has no `agent` field — typed as a metadata key, not a
top-level property. CI typecheck caught what local rtk-filtered
output had hidden. The session-id cid already disambiguates per-session
so the extra wasn't load-bearing.

* revert: remove AO_PERF instrumentation

Reverts b3f522f9 and 004b2a79. The perf marks pinpointed that the
server API path is fast — total <50ms after warm-up — so the 30s
dashboard-terminal delay lives in the WS / xterm path, not in the
session-manager hot path that this instrumentation covered.

Will re-instrument that layer (mux-websocket terminal-open ->
opened-sent -> firstByte) separately when we resume the investigation.

* fix(core): drop unused isWindows import after post-launch removal

The merge took main's no-op for post-launch prompt delivery, which was
the only user of isWindows() in this file. Removing the dangling import
to unblock lint.

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: yyovil <itsyyovil@gmail.com>
2026-05-09 00:10:53 +05:30
Atul Pandey be69a580fb
fix: reset stale worktree session branches (#1650) (#1652)
Co-authored-by: Dhruv Sharma <dhruvcoding67@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 21:56:37 +05:30
i-trytoohard 9bfd7656bb
fix(cli): refuse to spawn when daemon is not polling the project (#1460)
* fix(cli): refuse to spawn when daemon is not polling the project

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

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

Closes #1455

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

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

---------

Co-authored-by: Prateek <karnalprateek@gmail.com>
2026-05-08 18:13:42 +05:30
Adil Shaikh 1981d471ca
fix(core): deliver prompt inline via positional arg, remove post-launch polling (#1583)
Claude Code's positional [prompt] argument keeps it in interactive mode;
only -p/--print triggers headless one-shot exit. The entire post-launch
polling mechanism was built on the wrong assumption.

Changes:
- Claude Code plugin: pass prompt as positional arg in getLaunchCommand
- Core types: remove promptDelivery field from Agent interface
- Session manager: remove post-launch polling/retry block
- Prompt builder: clarify wording ("title, description, and labels"
  instead of "full issue details"), unify # format
- Tracker-github: match prompt wording update
- CLI spawn: remove dead-code promptDelivered warning

Closes #1582
2026-05-08 17:44:10 +05:30
i-trytoohard b9b20e19d1
chore: release 0.6.0 (#1723)
* chore: release 0.6.0

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

---------

Co-authored-by: Prateek <karnalprateek@gmail.com>
2026-05-08 02:37:44 +05:30
i-trytoohard a2afccc69a
fix(web): disable xterm scrollback to prevent terminal right-side clipping (#1678)
* fix(web): disable xterm scrollback to prevent right-side clipping

FitAddon.proposeDimensions() reserves 14px (DEFAULT_SCROLL_BAR_WIDTH)
for the scrollbar when scrollback > 0. The custom CSS sets the actual
scrollbar to 5px with overflow-y: overlay (deprecated in Chrome 114+).
This mismatch causes the terminal to calculate the wrong number of
columns — content gets clipped under the scrollbar on the right side.

tmux already provides scrollback and copy-mode, so xterm's scrollback
is redundant. Setting scrollback: 0 eliminates the scrollbar entirely
and makes FitAddon's width calculation match the rendered output.

Fixes #1677

* chore(web): refresh stale scrollback comments per greptile

* fix(web): reset letter-spacing on .xterm to fix right-side char clipping

The body has letter-spacing: -0.011em (~-0.176px at 16px) for typography
refinement. xterm's DOM renderer measures cell width with that spacing
inherited (~7.83px), then sets an inline letter-spacing override on
.xterm-rows that cancels the body inherit, leaving glyphs to render at
their natural ~8.00px width.

The mismatch — measured 7.83px cells vs rendered 8.00px glyphs —
accumulates as cols grow, eventually overflowing .xterm-rows > div and
getting clipped by its overflow: hidden. At 124 cols the drift was
~21px, chopping ~3 chars off the right edge.

Resetting letter-spacing on .xterm makes both phases agree and reduces
the drift to sub-pixel rounding (~2px worst case at any reasonable
cols).

---------

Co-authored-by: AO Bot <ao-bot@composio.dev>
Co-authored-by: Prateek <karnalprateek@gmail.com>
2026-05-08 02:15:57 +05:30
i-trytoohard d0dff3b93f
fix(web): drop = prefix from set-option in mux-websocket (closes #1714) (#1715)
* fix(web): drop = prefix from set-option in mux-websocket (closes #1714)

In tmux 3.4 the `=` exact-match prefix only works with `has-session` and
`attach-session`. For `set-option`, the prefix is silently ignored, so
`mouse on` and `status off` never get applied — breaking scroll wheel in
the dashboard terminal and leaving the tmux status bar visible.

Use the bare session id for the two `set-option` calls; keep `=` on
`attach-session` where it is correct.

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

* refactor(web): move exactTmuxTarget next to attach-session

Address review feedback: the `=`-prefixed target is only used by
attach-session, so declare it adjacent to that call. Comment now
sits above the set-option calls it actually explains.

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-07 19:08:21 +05:30
Harshit Singh Bhandari 0f539a3d4e
fix(cli): reload dashboard config after adding project from already-running menu (#1706)
When `ao start` finds an existing daemon and the user picks "Add <dir>",
the project is written to the global config but the dashboard's cached
services (loaded once into globalThis) never see it, so visiting the new
project page renders notFound(). Call notifyProjectChange() after the
write — same pattern as attachAndSpawnOrchestrator — so the dashboard
invalidates its cache before the browser opens.
2026-05-07 18:43:19 +05:30
Harshit Singh Bhandari 5c1d56aea4
fix(web): bound PTY re-attach loop with grace-period counter reset (#1640)
* fix(web): bound PTY re-attach loop with grace-period counter reset (#1639)

When `ao stop` (or any external action) kills a tmux session out from
under a still-subscribed dashboard, the mux server's PTY exit handler
attempts to re-attach. The MAX_REATTACH_ATTEMPTS=3 cap was supposed to
prevent unbounded respawning, but it was never engaging because the
counter was reset to 0 immediately after each "successful" `open()` —
where success only meant the new PTY was *spawned*, not that it
*survived*. When the underlying tmux session is gone, attach-session
exits ~40 ms after spawn, the exit handler fires again with counter=0,
and the loop runs at ~80 spawns/sec.

Diagnostic data captured on the issue: a single 1.5-second burst
produced 119 spawn↔exit cycles, raising the process's PTY fd count
from ~15 to ~153. Sustained for a few seconds, this exhausts the
macOS system PTY pool (kern.tty.ptmx_max=511), after which nothing
on the system can spawn a new PTY (tmux, VS Code terminal, ao spawn,
etc.) until the leaking process is killed.

Fix:
- Remove the `terminal.reattachAttempts = 0` reset inside the exit
  handler.
- Schedule a delayed reset via setTimeout in `open()`, gated on the
  closure-captured `pty` reference still being terminal.pty after
  REATTACH_RESET_GRACE_MS (5 s).

Effect: tight crash loops cannot reset the counter (PTY exits before
grace expires) and hit MAX_REATTACH_ATTEMPTS within ~150 ms, after
which the server emits "exited" and stops respawning. A long-lived
PTY that crashes hours later still gets a fresh retry budget.

Adds an integration test that reproduces the runaway scenario by
killing the tmux session externally and asserting "exited" arrives
within 2 s. Without this fix the test hangs and times out — the
exact symptom of the bug.

Note: this addresses the dominant runaway behaviour. A separate
~1 fd/cycle leak in node-pty 1.1.0 itself (each spawn opens 3
PTY-class fds in the parent, each exit releases only 2) remains and
will be tracked separately — likely a node-pty upgrade.

Fixes: #1639

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

* fixup(web): track grace timer + add recovery test (#1639 PR review)

Address two PR review notes on #1640:

1. Greptile P2 — store the grace-period timer handle on ManagedTerminal
   and clearTimeout it in the unsubscribe cleanup path. The closure
   guard already prevented any incorrect counter reset, so this is
   tidiness rather than a bug fix: it eliminates the up-to-5 s window
   where the timer's closure kept the killed PTY and evicted terminal
   object reachable. Also clears any prior timer when scheduling a new
   one in open() so back-to-back re-attaches don't pile up dead closures.

2. Copilot — add an integration test for the recovery path. The
   existing runaway test exercises the case where the counter must NOT
   reset (PTY crashes inside grace); the new test exercises the case
   where the counter MUST reset (PTY survives grace, then crashes
   later). Without the grace timer firing correctly, a single transient
   blip during startup would permanently consume the retry budget.

Test takes ~5.5 s because it uses the production grace period; per-test
timeout raised to 15 s. Vitest runs tests in parallel so this doesn't
serialize the suite.

Refs: #1639, #1640

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 18:37:44 +05:30
i-trytoohard 496c3717ab
fix(runtime-tmux): disable tmux status bar + dead code cleanup (#1711)
* fix(runtime-tmux): disable tmux status bar at session creation

Closes #1709.

#1683 added `set-option ... status off` to `core/src/tmux.ts::newSession()`,
but no code in the workspace imports or calls that helper — worker sessions
are spawned by the `runtime-tmux` plugin, which was not modified. The green
status bar was therefore still visible at session creation, only being
suppressed once the web layer's WebSocket connection ran its own
`set-option` (with a flash window before that).

Add the same call to runtime-tmux immediately after `tmux new-session` so
the bar is hidden from the moment the session exists, regardless of
whether anyone ever attaches via the web terminal.

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

* chore(core): remove unused newSession helper

The `newSession` function in `core/src/tmux.ts` and its `newTmuxSession`
re-export in `core/src/index.ts` had zero callers anywhere in the
workspace (verified via grep across packages/, excluding dist and tests).
Worker sessions are spawned by the `runtime-tmux` plugin, which has its
own implementation. The status-bar fix from #1683 lived only in this
helper and was therefore never executed — see #1709 and the prior
commit which moves the fix to the actual spawn path.

Removes:
- `newSession` and `NewSessionOptions` from core/src/tmux.ts
- `newSession as newTmuxSession` re-export from core/src/index.ts
- The corresponding test block in core/src/__tests__/tmux.test.ts

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

* chore(core): demote unused GhTraceResult export to internal

`GhTraceResult` was exported from `core/src/gh-trace.ts` but never
re-exported from `core/src/index.ts` and never imported anywhere in
the workspace. Its only consumer is `writeTraceEntry()` inside the
same file, where it's used as a parameter type. Demoted to a private
interface.

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

* fix(runtime-tmux): kill session if set-option fails

Move the `set-option ... status off` call inside the existing try/catch
so a failure (e.g. the 5-second tmux command timeout firing on a slow
host) triggers `kill-session` cleanup instead of leaving an orphaned
tmux session behind. Renames the surfaced error to
"Failed to configure or launch session" since the try block now covers
both configuration and the launch send-keys.

Adds a regression test that asserts kill-session is called when
set-option throws.

Addresses review feedback on #1711.

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

---------

Co-authored-by: Prateek <karnalprateek@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 14:35:26 +05:30
i-trytoohard 40aeb78c09
feat(core): add per-project env block to ProjectConfig (#1679)
* feat(core): add per-project env block to ProjectConfig

Adds an optional `env: Record<string, string>` field to ProjectConfig
that forwards environment variables into worker session runtimes. Useful
for scoping per-project tokens like GH_TOKEN to pin gh auth per project.

The merge order in session-manager runtime.create environment is:
agent.getEnvironment → PATH/GH_PATH → AO_AGENT_GH_TRACE → project.env →
AO_* internals. AO-internal vars always win over user-supplied values.

Closes #169

* fix(core): protect PATH and GH_PATH from project.env override

Per greptile review on #1679: spreading `project.env` after PATH/GH_PATH
let a user-supplied PATH or GH_PATH silently clobber the carefully
constructed agent path. Apply the same "protected key" treatment as
AO_* internals — spread project.env BEFORE PATH/GH_PATH/AO_AGENT_GH_TRACE
at all three runtime.create call sites (worker spawn, orchestrator spawn,
restore). Extend the precedence test to assert PATH and GH_PATH still
win over a colliding project.env entry.

---------

Co-authored-by: Prateek <karnalprateek@gmail.com>
2026-05-07 11:29:19 +05:30
Dhruv Sharma fc7d76ad54
feat(core): wire activity events into lifecycle-manager failure paths (#1511) (#1620)
* rebase: forward branch onto main + resolve activity-events kind union conflict

* feat(core): wire scm/runtime/agent plugin-call failure events

Adds activity-event evidence for previously-silent failure paths in
lifecycle-manager.ts so the RCA agent can answer 'why did X happen?':

- scm.batch_enrich_failed (line 617 catch)
- scm.detect_pr_succeeded (line 658 success path)
- scm.detect_pr_failed (line 664 catch)
- scm.review_fetch_failed (line 1517 catch)
- scm.poll_pr_failed (line 1132 catch)
- runtime.probe_failed (line 938 catch)
- agent.process_probe_failed (lines 1054 + 1139 catches, with where field)
- agent.activity_probe_failed (line 1062 outer catch)

Plus 6 new tests covering the call shapes.

Invariants preserved (per CLAUDE.md):
- B1 state-mutate-before-emit: each emit follows existing observer call
- B2 never throws: recordActivityEvent best-effort by design
- B3 re-entrancy guard unchanged
- B4 Promise.allSettled semantics unchanged

* feat(core): wire reaction lifecycle activity events

Adds AE evidence around reaction triggers, escalations, and failures so
RCA can answer 'did AO try to auto-fix this? did it succeed?':

- reaction.action_succeeded (combined for send-to-agent / notify / auto-merge,
  with data.action variant) — fires after each successful reaction action
- reaction.send_to_agent_failed — fires in the previously-silent catch when
  sessionManager.send throws inside a send-to-agent reaction
- reaction.escalated — fires alongside the existing notifyHuman escalation
  with data.escalationCause = 'max_retries' | 'max_duration'

Plus 3 new tests covering the call shapes.

Invariants preserved: emits land after the existing notifyHuman/return
paths so state mutation order is unchanged.

* feat(core): wire auto-cleanup, poll-cycle, detecting escalation events

Adds AE evidence around session destruction, poll loop failures, and the
detecting→stuck transition so RCA can answer 'when did my session get
cleaned up?', 'did the polling loop crash?', and 'why did AO mark this
session stuck?':

- session.auto_cleanup_deferred — agent busy, cleanup deferred
- session.auto_cleanup_completed — kill succeeded, runtime + worktree gone
- session.auto_cleanup_failed (level=error) — kill threw, session stays merged
- lifecycle.poll_failed (level=error) — pollAll outer catch fired
- detecting.escalated — first cycle that promotes detecting→stuck, with
  cause = max_attempts | max_duration. Guarded by detectingEscalatedAt
  metadata so it fires once per escalation, not on every poll while stuck.

Plus 5 new tests covering the call shapes and the idempotency guard.

Invariants preserved:
- Auto-cleanup events fire AFTER existing observer.recordOperation (B1)
- detecting.escalated emits ONCE per escalation (invariant B9 in
  .context/lifecycle-manager-instrumentation.md)
- poll_failed emits inside the existing pollAll catch — flow unchanged

* feat(core): wire report_watcher.triggered activity event

Adds AE evidence when the report watcher fires (no_acknowledge / stale_report
/ agent_needs_input). RCA: 'AO thinks my agent is stuck — why?'

- report_watcher.triggered (level=warn) — emitted alongside the existing
  observer.recordOperation, only when a trigger is non-null (per invariant
  in .context/lifecycle-manager-instrumentation.md §B9)

Plus 1 test exercising the no_acknowledge trigger path.

* fix(core): one-shot guard on report_watcher.triggered AE emit

Live-observed regression: report_watcher.triggered fired 116 times in
production over a few hours because the emit was unguarded and re-fired
every 30s poll while a trigger stayed active. Symptom was massive event
flood for stuck/no-acknowledge/stale conditions.

Fix: gate the emit on the existing isNewTrigger variable (same one-shot
guard pattern used for detecting.escalated). The observer.recordOperation
above remains unguarded by design (it's a metric/heartbeat); the AE trail
is for actionable evidence only.

Adds a regression test that drives the same trigger across two polls and
asserts the AE event fires only on the first.

* fix(core): address Greptile feedback on PR #1620

Two findings from Greptile (issue same as Codex P2 #1):

1. scm.batch_enrich_failed omitted projectId/sessionId — when the
   lifecycle worker is project-scoped (deps.projectId set), this event
   is effectively project-scoped too. Without projectId, queries like
   `ao events list --project todo-app --type scm.batch_enrich_failed`
   return zero results, defeating the purpose of the instrumentation.
   Fix: pass scopedProjectId when set. Unscoped (multi-project) supervisors
   still leave projectId null because the batch crosses project boundaries.

2. Misleading field name pendingSinceMs in session.auto_cleanup_deferred
   data — the local variable of the same name is a Unix epoch timestamp,
   but the data field stored `Date.now() - pendingSinceMs` (an elapsed
   duration). RCA agents would mis-interpret it as a timestamp and compute
   a 1970-era "pending since" date. Renamed to pendingElapsedMs.

* fix(core): address Codex review on PR #1620

- lifecycle.poll_failed: keep summary generic, route raw error text
  through `data.errorMessage` only. sanitizeSummary just truncates;
  sanitizeData redacts credential URLs. Since FTS5 indexes summary,
  interpolating subprocess error output (which can include
  https://x-oauth-basic:TOKEN@github.com/... from git/gh) made
  credentials persistently searchable.

- reaction.escalated: expand escalationCause to
  "max_retries" | "max_attempts" | "max_duration" and mirror the
  trigger checks. Numeric escalateAfter is an attempt-count gate, not
  a duration; previously got misattributed to "max_duration" whenever
  retries was unset (built-in defaults use {escalateAfter: 2}).

Adds two regression tests as guards for both behaviors.

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

* fix(core): replace import() type annotation with import type to satisfy lint

CI's @typescript-eslint/consistent-type-imports rule rejects inline
`typeof import("../activity-events.js")` inside the vi.mock factory.
Hoist it to a top-level `import type * as ActivityEventsModule` so the
type lives in a proper import declaration; vi.mock factory resolution
is unaffected (type-only imports emit no runtime code).

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

* fix(core): keep report_watcher.triggered summary generic to plug FTS leak

auditResult.message for the agent_needs_input trigger embeds the
free-form report.note supplied via `ao report --note "..."`. Since
sanitizeSummary only truncates and FTS5 indexes the summary column,
a note containing a credential URL would be persistently searchable
from the events DB. Same class of bug as the prior poll_failed fix.

Summary becomes generic ("<trigger> triggered"); the full message
continues to flow through `data.message` where sanitizeData redacts
credential URLs.

Adds a regression test that seeds a needs_input report with a
credential-bearing note and asserts the summary stays clean.

Reported by @ashish921998 in PR #1620.

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

* fix(core): redact token-shaped secrets in activity-event data (P1)

Both `summary` and `data` columns are FTS5-indexed (events-db.ts:58-59).
Prior fixes moved raw error/report text from `summary` to `data.message` /
`data.errorMessage`, on the assumption that sanitizeData() would scrub it.
That assumption was incomplete: sanitizeData only redacted credential URLs
and entire values under sensitive *key* names. Token-shaped substrings
(`Bearer …`, `ghp_…`, `sk-…`, JWTs, `AKIA…`, ALL_CAPS_TOKEN=value) under
non-sensitive keys like `message`/`errorMessage` were stored as-is and
made searchable via FTS.

Adds a TOKEN_PATTERNS array applied to every string value during
sanitization, plus a 500-char per-string cap (matching sanitizeSummary's
existing precedent — limits blast radius if a new token format slips past
the patterns).

Patterns cover: Bearer headers, GitHub PATs (classic + fine-grained),
OpenAI/Anthropic sk- keys, Slack xox- tokens, AWS access key IDs, JWTs,
and ENV-style assignments scoped to ALL_CAPS keys ending in
TOKEN/PASSWORD/SECRET/etc.

Tests:
- 10 new sanitizeString unit tests (one per token shape + prose-preservation
  regression guard + 500-char cap + nested array/object recursion)
- 1 new FTS5 integration test that drives recordActivityEvent → real SQLite
  → both direct row read and FTS MATCH must return zero token leakage

Test fixtures use string concatenation across the prefix boundary so
literal token shapes don't appear in source (gitleaks pre-commit guard).

Reported by @ashish921998 in PR #1620.

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

* fix(core): bound credential-URL regex to prevent ReDoS (CodeQL alert)

CodeQL flagged CREDENTIAL_URL_RE as polynomial: input shaped like
`http://http://http://...` with no terminating `@` caused O(n²)
backtracking because the unbounded `[^@\s]+` greedily spanned multiple
`http://` prefixes before failing at end-of-string and walking back.

Two-part fix:
1. Exclude `/` from the userinfo character class — this is also semantically
   correct since RFC 3986 userinfo cannot contain unencoded `/`.
2. Add a hard length cap (200 chars) on the userinfo segment as a belt-and-
   braces guard against future pathological inputs.

The fix is observable: 14KB pathological input completes in single-digit
ms post-fix vs multiple seconds pre-fix. Adds a regression test that
runs the pathological input through the full sanitize pipeline and
asserts <100ms completion.

Reported by GitHub Advanced Security on PR #1620.

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

* fix(core): replace CREDENTIAL_URL_RE regex with linear scan

The bounded {1,200} quantifier in CREDENTIAL_URL_RE let credential URLs
with >200-char userinfo pass through unredacted. Since data is FTS5-indexed,
those credentials became searchable (P1 from PR #1620 review).

Replace the regex with a simple linear scan (redactCredentialUrls) that:
- Has no length limit — scans until @, space, or /
- Is O(n) with no regex backtracking (fixes CodeQL polynomial-regex alert)
- Matches http:// and https:// case-insensitively (preserves old /gi behavior)

Adds regression tests for:
- >200-char userinfo bypass
- URLs without userinfo (no false positives)
- Multiple credential URLs in one string
- Pathological ReDoS-shaped input still completes in <100ms

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: AO Bot <ao-bot@composio.dev>
2026-05-06 22:14:21 +05:30
i-trytoohard f617ae0746
fix(core): disable tmux status bar at session creation (#1683)
* fix(core): disable tmux status bar at session creation

The tmux green status bar was visible in web terminals because
newSession() never set status off. The global tmux config has
status on, and the web layer only disabled it on WebSocket connect.

Now we hide the status bar immediately after session creation so it's
never visible, regardless of when (or if) the web UI connects.

Fixes #1682

* test(core): update tmux newSession test for status off call

The new set-option status off call adds a 5th execFile invocation.
Update the test expectations to match the new call sequence.

---------

Co-authored-by: AO Bot <ao-bot@composio.dev>
2026-05-06 21:32:22 +05:30
i-trytoohard 8fee6c0e2d
chore: release 0.5.0 (#1676)
* chore(release): add changesets for 0.5.0 and bump codex version test

- Add changesets for #1643 (orchestrator worktree adoption), #1549
  (sidebar empty-state), and #1608 (terminal attach + mux routing).
- Update agent-codex package-version.test.ts expectation from 0.4.0
  to 0.5.0 so the test no longer fails after the upcoming version bump.

* chore: release 0.5.0

---------

Co-authored-by: i-trytoohard <193449657+i-trytoohard@users.noreply.github.com>
2026-05-06 16:44:45 +05:30
yyovil be061a3989
fix(core): adopt orphaned orchestrator worktrees (#1643)
* fix(core): adopt orphaned orchestrator worktrees (#1641)

* fix: address review feedback on worktree adoption

- Normalize CRLF line endings in parseWorktreeList for cross-platform support
- Collapse duplicate classifySpawnError payload blocks into single condition
- Filter prunable/deleted worktree entries in findManagedWorkspace
- Add GIT_TIMEOUT to git() helper for all execFileAsync calls
- Add tests for prunable entries and CRLF parsing

* fix: update test assertions for git() helper timeout

All git() calls now pass timeout: GIT_TIMEOUT to execFileAsync.
Update toHaveBeenCalledWith assertions to include the new option.
postCreate sh -c calls remain unchanged (direct execFileAsync).

* fix: mock existsSync in findManagedWorkspace tests

The existsSync(entry.path) filter added for prunable worktree detection
needs existsSync to return true for valid worktree paths in adoption tests.

* fix: use mockReturnValueOnce to prevent existsSync mock leaking

vi.clearAllMocks() does not reset mockReturnValue, only mock history.
Using mockReturnValueOnce ensures existsSync stubs don't leak to
subsequent tests and cause clearStaleWorktreePath to consume git mocks.

---------

Co-authored-by: AO Bot <ao-bot@composio.dev>
2026-05-05 20:34:00 +05:30
i-trytoohard ea320312db
refactor(core): extract hasRecentCommits helper into @aoagents/ao-core (#1437)
* refactor(core): extract hasRecentCommits helper into @aoagents/ao-core

Deduplicate the byte-identical hasRecentCommits(workspacePath) helper
that was copy-pasted between agent-aider and agent-cursor. Exposes the
helper from core with a parameterized window so future agent plugins
using git-commit-based activity detection can share it.

Closes #1423

* test(core): make hasRecentCommits custom-window test discriminate on the parameter

The previous assertion passed the default (60) to hasRecentCommits, so a
bug where windowSeconds was silently ignored would still have passed.
Use a commit backdated ~2 minutes and assert both a 30s window excludes
it and a 600s window includes it — proving the parameter is forwarded
to `git log --since=...`.

Addresses Greptile review on #1437.

---------

Co-authored-by: Prateek <karnalprateek@gmail.com>
2026-05-05 18:58:53 +05:30
i-trytoohard 3a69722940
chore(cli): remove deprecated 'ao init' command (#1438)
* chore(cli): remove deprecated 'ao init' command

Delete the `init` command and its deprecation shim. `ao start` already
auto-creates the config on first run in an unconfigured repo, so the
separate entry point is redundant.

- Remove `packages/cli/src/commands/init.ts` and its test.
- Remove `registerInit` call from the CLI program.
- Drop `createConfigOnly()` from start.ts (only the init shim used it);
  export `autoCreateConfig` so the existing default-config test can
  invoke it directly.
- Update user-facing "Run `ao init` first" messages in `verify`/`status`
  to point to `ao start`.
- Refresh stale `ao init` references in ao-doctor, onboarding test,
  openclaw setup doc, and the config/types doc comments.

Closes #1420

* docs: remove ao init website docs

Agent-Logs-Url: https://github.com/ComposioHQ/agent-orchestrator/sessions/41663963-ca49-4673-982f-ca10dee68d31

Co-authored-by: miniMaddy <77185670+miniMaddy@users.noreply.github.com>

---------

Co-authored-by: Prateek <karnalprateek@gmail.com>
Co-authored-by: iamasx <adilshaikh4064@gmail.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: miniMaddy <77185670+miniMaddy@users.noreply.github.com>
2026-05-05 18:43:55 +05:30
Harsh Batheja eb06a4d090
fix(web): render empty-state in sidebar when no projects configured (#1549)
* fix(web): render empty-state in sidebar when no projects configured

A fresh-install user with zero projects saw a blank sidebar and had no
way to open AddProjectModal from it. The early-return was originally
projects.length <= 1 (#381), softened to === 0 in #927, but no empty-
state UI was added at the same time.

Replace the null branch with a small ProjectSidebarEmpty sibling that
reuses the existing header (with the + button wired to AddProjectModal),
shows a one-line explainer, and renders only the ThemeToggle in the
footer (the show-killed/show-done/settings buttons are meaningless with
zero projects).

* fix(web): mark sidebar + button SVGs aria-hidden

The decorative SVG inside the labeled + buttons (empty-state and
populated sidebar) should not be announced — screen readers should rely
on the button's aria-label. Adds aria-hidden="true" to both for
consistency.

* fix(web): always mount sidebar so empty-state renders on fresh installs

Dashboard previously gated the sidebar on projects.length >= 1, leaving
ProjectSidebarEmpty unreachable. ProjectSidebar handles both cases now,
so drop the gate and add a dashboard-level test for the zero-project
path.

* fix(web): honor collapsed prop in empty sidebar branch

ProjectSidebarEmpty discarded the collapsed prop, so on a fresh install
the wrapper shrank to 44px while the inner sidebar stayed 224px and
overlapped the main content. Render a 44px-wide rail with just the +
button when collapsed, matching the populated sidebar's collapse path.
2026-05-04 20:13:11 +05:30
Ashish Huddar d0fde88f2a
Fix direct terminal attach and keep project-scoped mux routing (#1608)
* fix(qa): ISSUE-001 mobile kanban columns

* Fix terminal tmux targeting for session detail views

* fix(web): unblock event loop in tmux-name resolution and drop dup CSS

- mux-websocket: switch resolveExactTmuxName from execFileSync to
  promisified execFile so a slow tmux call no longer stalls the
  WebSocket message handler. Propagate async through TerminalManager.open
  / subscribe and the pty.onExit reattach path.
- globals.css: remove duplicate `.kanban-board { grid-template-columns:
  minmax(0, 1fr) }` rule. The 767px breakpoint already covers it.

Addresses Greptile feedback on PR #1608.

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

* refactor(web): drop defensive tmux-name precheck

The has-session precheck in resolveExactTmuxName was UX padding around
the actual fix (using tmux's `=` exact-match prefix). Without the
precheck:
- attach-session fails naturally on a stale tmux name
- the existing reattach + exit-notify path surfaces the failure
- open()/subscribe() can stay sync — no event-loop concern, no async
  cascade through the WS message handler

Net: -64 / +21 in mux-websocket.ts. Reverts the integration test that
relied on the precheck to its pre-PR id-based form.

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 19:55:10 +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 7c7ffb5624
fix(web): source sidebar orchestrator from API field, not session list (#1623)
* fix(web): source sidebar orchestrator from API field, not session list

PR #1615 merged the initial implementation but missed the follow-up
fix. The merged sidebar code looks up the orchestrator inside the
`sessions` prop, but /api/sessions/route.ts strips ALL orchestrators
from that array before returning (they're exposed via a separate
`orchestrators` field on the same response). Result: the new menu
entry — and the existing icon button next to the dashboard icon —
never render for any project.

Replace the broken in-sidebar derivation with a new `orchestrators`
prop on ProjectSidebar. Each parent passes the data it already has:

  - Dashboard.tsx: passes `activeOrchestrators` (already in scope)
  - PullRequestsPage.tsx: passes `orchestratorLinks` (already in scope)
  - sessions/[id]/page.tsx: stores the `orchestrators` field already
    returned by /api/sessions and threads it through SessionPageShell
    and SessionDetail as `sidebarOrchestrators`

Tests refocused: the sidebar now just renders what the prop says, so
the live-vs-terminal selection lives in the API
(selectPreferredOrchestratorId) and is no longer the sidebar's
responsibility.

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

* style: fix indentation on sidebarOrchestrators prop in SessionPage

Addresses Greptile review comment on PR #1623.

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

* refactor(web): use DashboardOrchestratorLink for API response type

Addresses non-blocking review feedback on PR #1623. The /api/sessions
response actually returns DashboardOrchestratorLink shape (which
includes projectName), so use that as the response type instead of
the narrower ProjectSidebarOrchestrator. The sidebar prop type stays
narrow on purpose — it's the minimum the sidebar needs to render.

Also document the "one orchestrator per project" Map invariant.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 00:09:43 +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
Harshit Singh Bhandari 71253105cd
refactor(core): replace spawn rollback ladder with CleanupStack (#1616)
* refactor(core): replace spawn rollback ladder with CleanupStack

Replace the four nested try/catch + cleanupSpawnWorkspaceAndMetadata
helper in _spawnInner with a single LIFO CleanupStack. Each side
effect (reserved metadata, workspace, prompt files, runtime handle)
pushes its undo as soon as the resource exists; on success we
dismiss(), on failure we runAll().

Why: adding a new spawn step previously required extending every
prior cleanup block. Easy to forget; no compiler check. The stack
makes rollback structural — a new step pushes one cleanup, no risk
of leaving prior resources behind. runAll() is fault-tolerant by
design: a throwing cleanup never short-circuits the rest.

Behavior is preserved. Adds characterization tests for the worker
spawn rollback paths (none existed before — only spawnOrchestrator
was covered):
  - workspace.create failure cleans reserved metadata
  - runtime.create failure destroys worktree + cleans metadata
  - postLaunchSetup failure destroys runtime + worktree + metadata
  - one cleanup throwing does not skip subsequent cleanups

Refs #1603 (PR 1 of the ao spawn refactor plan).

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

* refactor(core): make CleanupStack runAll() terminal, symmetric with dismiss()

Address review feedback on PR #1616: previously `dismiss()` → `push()` was
a documented no-op but `runAll()` → `push()` would silently queue cleanups
that fired on a subsequent `runAll()`. Asymmetric and surprising.

Set `this.dismissed = true` at the top of `runAll()` so both terminal
states (success via dismiss, failure via runAll) reject further pushes
identically. Add a regression test pinning the new symmetric behavior.

The "idempotent runAll" test continues to pass (early-return path now
fires via the dismissed flag instead of the empty-stack short-circuit).

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

* refactor(core): surface CleanupStack errors and cover postCreate rollback

Address review feedback on PR #1616:

- Pass an onError callback to cleanupStack.runAll() in _spawnInner that
  logs cleanup failures via console.error. The previous /* best effort */
  pattern silently swallowed errors during rollback; now the same errors
  are surfaced for debugging without changing behavior (cleanup errors
  still don't propagate, subsequent cleanups still run).
- Add a characterization test for the workspace.postCreate failure path.
  This was the only rollback path without a test — the stack handled it
  correctly already, but pinning it down prevents regression.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 22:43:21 +05:30
Harshit Singh Bhandari e465a4702d
fix(agent-claude-code): fold underscores in Claude project slug (#1611) (#1612)
`toClaudeProjectPath` only normalized `/`, `.`, and `:` — but Claude Code's
on-disk slug also folds underscores (and other non-alphanumerics) to `-`.
AO project data dirs are named `<sanitized>_<hash>` (e.g.
`graph-isomorphism_d185b44d56`), so the slug AO computed pointed at a
directory that never existed. Cascading failures:

- `getSessionInfo` couldn't read the JSONL → `claudeSessionUuid` never
  got persisted to session metadata.
- On restore, `getRestoreCommand`'s metadata lookup found nothing AND its
  workspace-scan fallback also missed (same bad slug), returning `null`.
- Session-manager's native-restore guard then threw
  `SessionNotRestorableError` → API returned 409.

Verified on-disk: every session under projects without underscores has
`claudeSessionUuid` persisted; every session under projects with
underscores does not. The orchestrator angle in #1611 is the loudest
symptom — orchestrators die early so they have nothing else to fall back
on — but the same bug silently broke worker restore in any multi-project
setup.

Fix: replace `[/.]` with `[^a-zA-Z0-9-]` in the slug regex, matching
Claude Code's actual encoding. Adds direct unit tests for
`toClaudeProjectPath` covering the underscore case plus existing paths,
and a regression test in the `getSessionInfo` path-conversion suite.

Fixes #1611.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 20:40:18 +05:30
Harshit Singh Bhandari 3b9ba3122e
feat(web): add 'Open orchestrator' to sidebar 3-dot menu (#1615)
* feat(web): add 'Open orchestrator' to sidebar 3-dot menu

Adds a labeled menu entry above 'Project settings' that navigates to
the project's orchestrator session. The orchestrator is the most-used
session in any project, but today the only path to it is the unlabeled
icon button next to the dashboard icon - easy to miss for new users.

The entry is hidden when no live orchestrator exists (matching the
existing icon-button pattern), so the menu shrinks gracefully on
projects where 'ao start' has never run or has stopped.

Closes #1613

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

* refactor(web): drop redundant guard in orchestrator menu render

Hold the validated session in `liveOrchestrator` instead of a separate
boolean flag. TypeScript narrows automatically from the assignment, so
the render condition no longer needs `&& orchestratorSession` to satisfy
the type checker.

Addresses review feedback on #1613.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 20:39:57 +05:30
Adil Shaikh 703d5844f0
fix(core): deliver enriched review content on changes_requested transition (#1578)
* fix(core): deliver enriched review content on changes_requested transition

The transition reaction for changes_requested sent a generic message
("Details will follow shortly") but the backlog dispatch that carries
the actual review comment bodies was blocked by its own deduplication
logic — the transition handler recorded the fingerprint hash as
"dispatched" without ever sending the enriched content.

Remove the premature hash recording and the transition guard so the
backlog dispatch fires in the same poll cycle, delivering actual review
comment details (file paths, line numbers, authors, bodies) to the
agent immediately.

Closes #1558

* fix(core): update stale comments from review feedback

- Update throttle-bypass comment to reflect removed Branch B
- Fix test comment: second check is throttled, not just fingerprint match

* fix(core): prevent double-billing reaction attempts on changes_requested transition

When a changes_requested transition fires, the transition handler calls
executeReaction (attempt 1) and then maybeDispatchReviewBacklog calls it
again for the enriched message (attempt 2). With retries:1, this caused
premature escalation on the very first transition poll.

Fix: when the transition handler already fired executeReaction for the
same reaction key, send the enriched payload directly via
sessionManager.send — bypassing the reaction tracker entirely.

Also moves lastReviewBacklogCheckAt after the SCM fetch so a failed
getReviewThreads call doesn't block retries for 2 minutes.

Fixes #1578

* fix(core): gate review bypass on send-to-agent action type

The direct sessionManager.send bypass (introduced to prevent double-billing
reaction attempts) fired unconditionally, ignoring reactionConfig.action.
With action: "notify", the enriched review content was pushed to the agent's
stdin instead of routing through notifyHuman.

Gate the bypass on action === "send-to-agent" so notify configs fall through
to executeReaction which routes correctly. Applied to both human and
automated review comment paths.

Adds test verifying action: "notify" does not call sessionManager.send
and does fire the notifier.

Fixes #1578
2026-05-03 18:30:47 +05:30
Adil Shaikh cf5a418a48
fix(scm-github): silence HTTP 304 warnings in ETag guards (#1581)
* fix(scm-github): silence HTTP 304 warnings in ETag guards and use observer logging

ETag guard functions and the GraphQL batch handler used raw console.warn()/
console.error() that fired on every poll cycle, including expected HTTP 304
(Not Modified) responses. This flooded the orchestrator terminal with noise.

- Add 304 fallback check in error message for cases where gh CLI doesn't
  populate stdout/stderr on non-zero exit
- Migrate all 4 raw console.warn()/console.error() calls to observer?.log()
  for consistency with the rest of the file
- Thread observer parameter through shouldRefreshPREnrichment and the three
  ETag guard functions (checkPRListETag, checkCommitStatusETag,
  checkReviewCommentsETag)
- Update test to verify observer-based logging instead of console spy

Closes #1580

* fix(scm-github): use word boundary in 304 regex to prevent false positives

Use \b304\b instead of /304/ to avoid matching substrings like "3040"
or "30400" in error messages.

Closes #1580

* fix(scm-github): use is304() for error message fallback and thread observer into getReviewThreads

1. Replace \b304\b regex with is304() (anchored to HTTP status line) in all
   three ETag guard fallback paths. Prevents false positives from URL paths
   like "pulls/304/comments" appearing in Node's execFile error messages.

2. Capture instance-level observer in createGitHubSCM() and pass it to
   checkReviewCommentsETag and getReviewThreads error logging. Non-304
   errors on the review polling path are now visible via observer instead
   of being silently swallowed by lifecycle's catch.

3. Restore "error" severity for partial batch failures in
   enrichSessionsPRBatchImpl (was incorrectly downgraded to "warn").

4. Add tests for Guard 1 URL false-positive, Guard 2 error logging,
   and Guard 2 HTTP 304 fallback paths.

Closes #1580

* test(scm-github): add Guard 3 (checkReviewCommentsETag) tests

Add 5 tests for the review comments ETag guard covering:
- 200 response (changed) and 304 response (unchanged)
- Error with observer logging
- HTTP 304 in error message treated as cache hit
- URL containing "304" NOT treated as cache hit (false-positive prevention)

This completes the test coverage for all three ETag guard functions'
error and 304-fallback paths, as requested in review.

Closes #1580
2026-05-03 18:30:27 +05:30
Ashish Huddar ab65d12356
Fix native restore fallback for Claude and Codex sessions (#1602)
* Fix native session restore fallback for Claude and Codex

* Address restore metadata review comments

* Fix metadata normalization lint

* Address PR metadata review feedback

* Prevent fresh restore fallback for native agents
2026-05-01 21:27:17 +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
Chirag Arora b2cdf7adab
fix(web): scope terminal tmux resolution by project (#1551)
* fix(web): scope terminal resolution by project

* fix(web): avoid suffix false-positives in project-scoped tmux key match

Made-with: Cursor

* fix(web): scope fullscreen terminal resize by project

Made-with: Cursor
2026-05-01 16:15:43 +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
Ashish Huddar ac9ab7d63e
fix(qa): ISSUE-001 — enforce project prefix boundaries (#1601) 2026-05-01 15:32:45 +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 fc0e51f7bb
fix: always enable filesystem browsing (#1596) (#1599) 2026-05-01 12:51:43 +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 818f11f987
fix(cli): register project command (#1576) 2026-04-30 20:34:30 +05:30
fastestdevalive 68756105fb
refactor(web): replace SSE with WebSocket polling for session updates (#1259)
* refactor(web): remove SSE entirely — browser uses WebSocket only

- Delete GET /api/events route (no consumers remain)
- Refactor SessionBroadcaster: replaces SSE stream fetch with a plain
  setInterval polling GET /api/sessions/patches every 3s, eliminating
  the last server-side SSE consumer
- Remove EventSource from useSessionEvents; hook is now WebSocket-only
  via mux.sessions; rename SSEAttentionMap → AttentionMap and
  sseAttentionLevels → attentionLevels throughout
- Replace useSSESessionActivity with useMuxSessionActivity — thin
  selector over useMux().sessions, no network call
- Delete SSESnapshotEvent and SSEActivityEvent types from lib/types.ts
- Delete Dashboard.renderCadence.test.tsx (SSE-specific test)
- Update ARCHITECTURE.md to reflect the simplified two-protocol design
  (HTTP + WebSocket only; no SSE anywhere in the system)

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

* refactor(web): address PR review comments

- useMuxSessionActivity: switch to useMuxOptional (consistent with page.tsx),
  add useMemo for referential stability
- useSessionEvents: validate patch.status against VALID_SESSION_STATUSES before
  casting; type all three VALID_* sets with satisfies for exhaustiveness
- mux-websocket: remove leading underscores from private fields (intervalId,
  polling) — private modifier already conveys intent
- Dashboard.renderCadence test: port from SSE/EventSource to MuxProvider mock;
  covers same-membership-snapshot-only-rerenders-changed-card invariant
- Remove .feature-plans/pending/remove-browser-sse.md (duplicated in PR body)

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

* fix(web): guard broadcast against stale fetch after disconnect

If disconnect() runs while fetchSnapshot() is in flight, the .then
callback would still fire broadcast() into an empty (or re-populated)
subscriber set. Guard with intervalId !== null so stale resolutions
after the last subscriber leaves are silently dropped.

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

* fix(web): remove SSE-specific tests from emptyState suite

The upstream added two tests for live load-error banners driven by SSE
onmessage events. Since this PR removes SSE entirely, those tests can't
pass and the SSE mock setup is no longer needed.

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

* fix(web): restore liveSessionsResolved to prevent premature banner dismiss

mux?.status === "connected" fires on WebSocket handshake before any
session data arrives. In the SSR-failure scenario (dashboardLoadError
set), this was dismissing the error banner as soon as the WS opened,
leaving users with a silent empty dashboard.

Restore liveSessionsResolved: set it only from the first successful
HTTP /api/sessions refresh or mux snapshot (same semantics as main).
The reset action from the initialSessions effect intentionally does
not set it (liveResolved flag absent = SSR-only reset, not live data).

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

* fix(web): port live load-error banner from SSE to WS transport

SessionBroadcaster now emits { ch: "sessions", type: "error" } on fetch
failure instead of silently returning null. MuxProvider surfaces the error
as lastError on the context. useSessionEvents restores loadError reducer
state, synced from muxLastError, cleared on successful snapshot or HTTP
refresh. Dashboard renders the live error banner via loadError ?? ssrLoadError.
Two emptyState tests ported to drive errors through MuxProvider mock.

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Gaurav Bhola <fastestdevalive@users.noreply.github.com>
2026-04-29 13:05:04 -07:00
Adil Shaikh 88f691807d
fix(core): preserve reaction tracker across status oscillation (#1531)
* fix(core): preserve reaction tracker across status oscillation (#1409)

The reaction tracker retry budget was resetting on every status exit,
allowing infinite CI failure and merge conflict messages instead of
escalating to a human after the configured retry limit.

* fix(core): hoist PERSISTENT_REACTION_KEYS to module scope, document notify priority change

Address review feedback:
- Move PERSISTENT_REACTION_KEYS to module level (avoids per-call Set allocation)
- Document that executeReaction's notify path defaults to "info" priority
  (was "warning" in old direct-dispatch code); users with action:"notify"
  should set priority explicitly in their config

* fix(core): address review feedback on reaction tracker oscillation

1. Remove dead "merge-conflicts" from PERSISTENT_REACTION_KEYS —
   statusToEventType never emits "merge.conflicts" so the transition
   handler can never reach it. Merge-conflict tracker lifecycle is
   managed in maybeDispatchMergeConflicts.

2. Fix escalation poisoning dedup flag — only set
   lastMergeConflictDispatched when the result is non-escalated.
   Escalation hands off to the human; the dedup flag must not
   suppress future agent dispatches.

3. Add incident boundary to persistent trackers — clear tracker
   after escalation (executeReaction) and when merge-conflicts
   resolve. Prevents permanent budget exhaustion in long-lived
   sessions.

4. Preserve "warning" priority for merge-conflict notify action —
   default priority to "warning" on enrichedConfig, matching old
   direct-dispatch behavior.

Fixes review comments on #1531.

* fix(core): bound ci-failed escalation — silence after escalate, reset on stable CI pass

After escalation, the ci-failed tracker is now marked escalated=true instead
of being deleted. This prevents the infinite re-escalation loop introduced
by deleting the tracker (every 3rd oscillation cycle got a fresh budget,
causing retries:2 to produce unbounded agent messages + human pages).

Resolution: once escalated, the tracker short-circuits all further dispatches
until CI has been passing for CI_PASSING_STABLE_THRESHOLD (2) consecutive polls.
This ensures "stable passing" isn't confused with brief pending→passing flicker.

Fixes the residual unbounded-dispatch bug flagged in the illegalcall review
on #1531.

* fix(core): count only 'passing' toward ci stable window — exclude 'pending'

'pending' (emitted while a CI run is in progress) was incorrectly counting
toward the 2-poll stable-passing threshold, wiping the escalated tracker
between failures in the exact production scenario the fix was meant to bound.

With real GitHub CI, every transition out of 'failing' goes through 'pending'
while the new check-run starts (~60 polls for a 5-min CI run). Two consecutive
pending polls (10s) would clear the tracker before the run completed, giving
each failure cycle a fresh budget — identical to the original #1409 symptom.

Fix: require ciStatus === "passing" (not !== "failing") before incrementing
stableCount. Pending/none reset the stability window the same as failing.

Adds two regression tests:
- pending CI does not count toward ci-failed tracker resolution
- only passing CI resets ci-failed tracker — pending mid-run does not interfere
2026-04-30 01:07:02 +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