Commit Graph

140 Commits

Author SHA1 Message Date
Ashish Huddar 87e3fce535 Merge origin/main into remote control 2026-05-22 14:16:33 +05:30
yyovil 00fb9f9a59
fix: use persisted session agent for enrichment (#1996)
* fix: use persisted session agent for enrichment

* fix: enrich projectless sessions by persisted agent

* fix: backfill legacy session agent metadata

* fix: rely on normalized session agent identity

* test: include normalized agent in lifecycle instrumentation fixtures
2026-05-22 04:41:08 +05:30
yyovil 1ed0719770
feat: add Grok agent plugin (#1862)
* feat: add Grok agent plugin

* fix: return indeterminate for grok probe failures

* fix: wait for grok worktree readiness before prompts
2026-05-22 00:57:29 +05:30
Ashish Huddar d87ff12291 fix remote access review blockers 2026-05-20 12:30:51 +05:30
Ashish Huddar 0f1a549cb5 Merge remote-tracking branch 'origin/main' into codex/implement-remote-control
# Conflicts:
#	packages/core/src/global-config.ts
#	packages/web/src/components/Dashboard.tsx
#	packages/web/src/components/SessionDetailHeader.tsx
2026-05-20 11:16:12 +05:30
Ashish Huddar 939b85f63e Refine AO agent coordination docs 2026-05-20 00:16:08 +05:30
Adil Shaikh 298057044f
feat(notifier): make notifier system robust with manual harness and desktop setup (#1736)
* fix(notifier-desktop): use terminal-notifier on macOS for click-to-open support

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

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

Refs #1579

* feat(cli): add manual notifier test harness

* feat(cli): add native desktop notifier setup

* fix(cli): handle denied desktop notification permission

* fix(notifier): support composio actions api

* fix(notifier): use composio entity execution

* feat(notifier): add composio setup flows

* Fix notifier setup flows

* feat: add dashboard notifier

* Make notifier payloads semantic v3

* chore: use ao-agent as composio default user

* Improve notifier setup and rich notifications

* Improve desktop notification UX

* Update AO notifier app icon

* Remove redundant dashboard notification actions

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

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

* Fix code scanning notifier alerts

* Address notifier bot review comments

* Address remaining notifier bot reviews

* Update notifier integration assertions after merge

* Fix notifier test fallout after merge

* Address notifier PR review comments

* Fix notifier bot follow-up comments

* Add notification delivery observability

* chore: add notifier logging screenshot

* fix: address notifier logging ci failures

---------

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-05-19 14:48:40 +05:30
Dhruv Sharma f3e45959e6
Add orchestrator-driven code review board (#1871)
* feat: add orchestrator-driven code review board

* feat: wire review findings back to workers

* feat(web): send review feedback to workers

* fix(core): mark stale review runs outdated

* fix: restore reviewer flow after main merge

* Fix review lock lint failure

* Guard concurrent review executions

---------

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

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

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

Closes #1907

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

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

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

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 02:50:34 +05:30
Dhruv Sharma 73bed33c2e
feat(web): activity events for webhooks and mux WebSocket (#1656) (#1693)
* feat(web): activity events for webhooks and mux WebSocket (#1656)

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

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

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

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

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

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

* chore(ci): retrigger checks

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: whoisasx <adil.business4064@gmail.com>
2026-05-18 18:54:00 +05:30
Harshit Singh Bhandari 94981dc0fd
feat(web,core): "Launch Orchestrator (clean context)" button (#1904)
* feat(web,core): "Launch Orchestrator (clean context)" button

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

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

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

Closes #1900, closes #1080.


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

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

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


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

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

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

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

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

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

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

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

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

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

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

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

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

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

Adds a regression test that the button stays visible on terminated
orchestrator sessions and that successful relaunch navigates via
window.location.href.
2026-05-17 21:16:25 +05:30
Ashish Huddar e87428cafa Harden remote terminal auth and dev proxy 2026-05-17 16:56:50 +05:30
i-trytoohard e6ad078d7a
fix(web): kill RSC prefetch storm + dedupe in-flight fetches + retry on transient timeout (closes #1855) (#1856)
* fix: reduce session page fetch starvation

* fix: address fetch dedupe review feedback

* fix: preserve body-read timeouts in client fetch

---------

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

* fix(web): address terminal state review feedback

* chore: retrigger integration ci

* fix(web): trim terminal state change scope

* fix(web): preserve legacy terminated helper behavior

---------

Co-authored-by: i-trytoohard <193449657+i-trytoohard@users.noreply.github.com>
2026-05-15 04:30:44 +05:30
Harshit Singh Bhandari 71326bc87e
feat(web): allow renaming worker sessions in the sidebar (#1748)
* feat(web): allow renaming worker sessions in the sidebar

Closes #1647

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

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

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

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

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

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

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

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

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

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

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

Refs #1647.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 19:41:48 +05:30
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
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 ac9ab7d63e
fix(qa): ISSUE-001 — enforce project prefix boundaries (#1601) 2026-05-01 15:32:45 +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
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
Ashish Huddar f674422a66
Fix orchestrator identity to use one canonical session per project (#1487)
* Add canonical orchestrator identity

* Coalesce concurrent ensureOrchestrator calls

* Fix canonical orchestrator follow-ups

* Move orchestrator id helper into web utils
2026-04-25 18:57:31 +05:30
Dhruv Sharma a8bc746947
feat(core): opt-in gh CLI tracer + scm/tracker migration (Phase A1a) (#1238)
* feat(core): add opt-in gh CLI tracer and migrate scm/tracker plugins

Introduces execGhObserved() in @aoagents/ao-core: a thin wrapper around
execFile("gh", ...) that writes a JSONL trace row to $AO_GH_TRACE_FILE
on both success and failure. Captures status line, HTTP status, ETag,
rate-limit headers, duration, stdout/stderr byte counts, exit code, and
signal. No-op when the env var is unset, so default behavior is
unchanged.

Migrates three call sites to the observer:
- scm-github/graphql-batch.ts — PR-list guard, commit-status guard,
  GraphQL batch query
- scm-github/index.ts — gh() and ghInDir() helpers
- tracker-github/index.ts — internal gh() helper

This is Phase A1a of experiments/PLAN.md: tracer infrastructure +
migration. The full GhRunner contract (Promise<GhResult>,
GhRunnerError.ghResult on reject, body capture, redaction, 64 KB cap)
lands in A1b along with the scorecard baseline.

Also adds experiments/ reference docs: the v2.3 plan, the gh-CLI call
catalog, two ETag verification writeups, and a trace harness + summary
script.

* docs(experiments): add A1a validation status and A1b blockers

Record the five A1b pre-freeze blockers surfaced by Adil's 1,487-row
baseline and an independent drill run: graphql-batch missing -i,
extractOperation flag mis-bucketing, analyzer not segmenting burn by
reset window, CLI-subcommand opacity (GH_DEBUG=api stderr vs coarse
/rate_limit bracket — not equivalent), and sessionId/projectId not
threaded through plugin callsites. Note bare gh() helper cleanup as
known-open follow-up.

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

* fix(tracer): close A1b blockers 1-4 — graphql-batch visibility, operation naming, analyzer segmentation

- Add -i flag to executeBatchQuery in graphql-batch.ts and split HTTP
  headers from JSON body before parsing, making all gh.api.graphql-batch
  rows visible to status and rate-limit analysis (was 186 invisible rows)
- Fix extractOperation() in gh-trace.ts to walk past -* flags before
  picking the operation segment, eliminating the gh.api.--method bucket
- Add per-reset-window burn segmentation to both analyzers so runs
  straddling a reset boundary produce per-window deltas instead of a
  single invalid cross-reset delta
- Add experiment scripts: analyze-trace.mjs (deep trace analysis) and
  drill-tracer.mjs (standalone tracer exerciser)
- Document Gap 1 decision in PLAN.md: accept CLI subcommands as opaque
  for A1, bracket A2 runs with /rate_limit snapshots for coarse burn
- Add progress timeline to PLAN.md showing A→B→C track dependencies

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

* docs(experiments): add A2 baseline matrix runbook

Practical execution plan for the Phase A2 scenario x scale x topology
matrix: 7 priority cells, per-cell procedure, /rate_limit bracketing
for Gap 1 subcommand burn, output format for baseline.md, and the
scorecard that gates Track B.

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

* fix(tracer): guard stderr/stdout against undefined, bound operation cardinality

Addresses code review findings:
1. Guard Buffer.byteLength and parseIncludedHttpResponse against
   undefined stderr/stdout — fixes 48 SCM test regressions where
   mocked execFile paths don't populate stderr
2. extractOperation() now takes only the first path segment of REST
   URLs (e.g. "repos" from "repos/acme/repo/pulls/123/...") to keep
   operation bucket cardinality bounded and stable across runs
3. Fix A2 runbook /rate_limit snapshots to produce valid JSON using
   jq's now|todate instead of appending raw timestamp
4. Add blocker 5 dependency to runbook prereqs and per-session cells

All 140 SCM tests pass (0 failures).

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

* docs(experiments): add rate-limiting research artifacts

Baseline measurements, discussion notes, benchmark harness spec,
and updated master plan from two independent trace runs at 5-6 sessions.

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

* feat(experiments): add benchmark harness for GH rate-limit measurement

Three modes: setup (spawn sessions, wait for PRs), measure (trace API
calls over a fixed window, produce scorecard), report (recompute from
existing trace). Node.js stdlib only, shells out to ao CLI and gh CLI.

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

* fix(scm-github): handle 304 Not Modified in ETag guard catch blocks (B1)

`gh api -i` exits code 1 on HTTP 304 responses, causing the catch blocks
in checkPRListETag and checkCommitStatusETag to assume the resource changed
and trigger unnecessary GraphQL batch queries every poll cycle.

Fix: inspect stdout/stderr in the catch block for the 304 status line before
falling back to "assume changed". Also unifies the 304 detection regex to
handle HTTP/1.1, HTTP/2, and HTTP/2.0 status lines, and adds rateLimit
introspection to the batch GraphQL query.

Benchmark result (quiet-steady, 5 sessions, 15 min):
- GraphQL points/hr: 260/5,000 (5%) — down from 820–1,416 pre-fix
- ETag guard 304 rate: 100%
- GraphQL batch calls during measurement: 0

Also fixes the benchmark harness to create placeholder tmux sessions with a
claude symlink so the lifecycle actually polls sessions instead of
short-circuiting to "killed".

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

* docs(experiments): update plan and notes with B1 benchmark results

B1 fix validated at 5, 10, and 20 sessions in quiet-steady state:
- 5 sessions: 260 GraphQL pts/hr (5% budget)
- 10 sessions: 640 pts/hr (13%)
- 20 sessions: 680 pts/hr (14%) — sub-linear scaling confirmed
- 50-session projection: ~800-1000 pts/hr (16-20%)
- ETag guard 304 rate: 100% at all scale points
- graphql-batch calls: 0 during measurement at all scale points

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

* feat(core): log gh wrapper invocations for D1

* fix(core): preserve wrapper logging for dash-prefixed gh args

* feat(core): add gh wrapper cache for PR discovery and issue context (D4)

Add read-through caching to the ~/.ao/bin/gh wrapper, targeting the two
largest agent-side waste buckets identified in D4 analysis:

1. PR discovery (gh pr list --head): infinite TTL for positive results.
   598 calls → ~10 per 10-session run (98% reduction).
2. Issue context (gh issue view): 300s TTL.
   75 calls → ~20 per 10-session run (73% reduction).

The wrapper now caches successful read-only responses in
$AO_DATA_DIR/.ghcache/$AO_SESSION/ and serves them on subsequent
identical calls. Negative results (empty []) are never cached.
gh pr create populates the PR discovery cache immediately.

Also lifts PATH wrapper installation from individual agent plugins into
session-manager, making it universal for all agents including Claude Code:

- session-manager injects PATH + GH_PATH into every runtime.create()
- session-manager calls setupPathWrapperWorkspace() for all agents
- Removes duplicate buildAgentPath/setupPathWrapperWorkspace boilerplate
  from codex, aider, opencode, and cursor plugins

Includes D4 implementation plans in experiments/.

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

* docs(experiments): add full capacity discovery (5→50 sessions) and CI churn results

Complete scaling curve measured: 50 sessions uses only ~28% of GraphQL
budget with 100% ETag guard hit rate at every scale. Poll cycle lag
identified as first bottleneck (66s at 50 sessions vs 30s target).
CI churn benchmark shows ETag invalidation is a latency problem, not
a rate-limit problem (+9% GraphQL, +4.4x p50 latency).

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

* docs(experiments): record real-agent catastrophe and Track D handoff

5-real-agent run on todo-app exhausted GraphQL bucket in 31 min (~9572 pts/hr,
~37x quiet-steady at the same session count). AO polling consumed ~10 calls;
the rest came from agents themselves via the metadata-only ~/.ao/bin/gh
wrapper, which has no tracing. Captures findings, adds Track D (agent-side
gh consumption) plus B5 (migrate remaining bare gh callsites to
execGhObserved), and includes the runbook + benchmark scripts Adil will
build on for the cross-machine reproduction.

* feat(core): add cache-hit/miss tracing to gh wrapper (D4)

The wrapper trace now logs a cacheResult entry for every cacheable
command: hit, miss-stored, miss-negative, or miss-error. This makes
benchmark runs conclusive — you can count cache hits vs real gh calls
directly from the JSONL trace instead of inferring from rate-limit
deltas.

Bump wrapper version to 0.4.1.

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

* feat(scm-github): replace repo-scoped Guard 1 with PR-scoped ETag checks (D4)

Guard 1 now checks GET /repos/{owner}/{repo}/pulls/{number} per PR
instead of GET /repos/{owner}/{repo}/pulls?... per repo. This means:

- Only changed PRs flow into the GraphQL batch
- Unchanged PRs are served directly from the enrichment cache
- shouldRefreshPREnrichment returns a refresh plan (prsToRefresh +
  cachedResults) instead of a boolean

When 1 of 10 PRs changes, the old guard refreshed all 10 via GraphQL.
Now only the 1 changed PR is fetched; the other 9 are served from cache
at zero GraphQL cost.

Trade-off: more REST guard calls (1 per PR instead of 1 per repo), but
304 responses cost zero rate limit points.

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

* Forward AO_AGENT_GH_TRACE to session runtimes

* revert: remove PR-scoped ETag guards (Change 3)

Reverts 25ae6013. The per-PR Guard 1 added more REST calls (1 per PR
instead of 1 per repo) without meaningful GraphQL savings at 10-session
scale. Core REST delta went from 16 to 142 while GraphQL rate stayed
flat. The repo-scoped guard is sufficient for current workloads.

Preserves the subsequent 6fc64f4f commit (AO_AGENT_GH_TRACE forwarding).

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

* fix(core): use real gh binary in execGhObserved, bypass wrapper

execGhObserved() was calling bare "gh" which resolved to ~/.ao/bin/gh
(the wrapper) when that directory was in PATH. This caused:
- AO-side gh calls going through the agent wrapper
- All trace rows with aoSession=null polluting the agent trace
- Cache functions silently failing (no AO_SESSION in AO process)

Now strips ~/.ao/bin from PATH and resolves the real gh binary
(e.g. /opt/homebrew/bin/gh) at startup. Cached after first resolution.

AO process → execGhObserved → real gh → AO_GH_TRACE_FILE
Agent process → ~/.ao/bin/gh wrapper → AO_AGENT_GH_TRACE + cache

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

* fix(core): harden gh wrapper caching and agent-side tracing

Cache correctness:
- Include --json fields in cache key (prevents stale partial responses)
- Only cache stdout, not stderr (prevents warning contamination)
- Fix trailing newline inconsistency in PR discovery cache
- Support --key=value arg syntax for all cached flags
- Remove PR create cache pre-population (hardcoded fields, no JSON escaping)
- Log miss-write-failed when ao_cache_write fails (previously silent)

Agent trace improvements:
- Add operation field to invocation rows (gh.pr.list, gh.issue.view, etc.)
- Add durationMs, exitCode, ok to cache outcome rows
- Log passthrough for all non-cached code paths (pr/create, default case)
- Replace exec with child process in default case to enable post-call tracing

Bump wrapper version to 0.6.0.

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

* fix(runtime-tmux): re-export PATH after shell init to survive macOS path_helper

macOS zsh runs path_helper during shell startup which resets PATH,
wiping entries set via tmux new-session -e. This caused ~/.ao/bin
to be lost, so the gh/git wrappers were never intercepting agent
calls — no caching, no tracing, no metadata auto-updates.

Fix: send `export PATH=...` via send-keys after the shell has
initialized but before the launch command, ensuring PATH sticks.

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

* fix(runtime-tmux): use launch script for PATH re-export instead of send-keys

The previous send-keys approach sent 1000+ literal keystrokes for the
PATH value, which broke terminal input buffers and caused stuck quote
prompts. Instead, include the PATH export in the launch script file
which is executed directly — no terminal buffer issues.

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

* docs(experiments): add AO-side gh rate-limit trace report

5-session, 15-minute trace analysis with full call breakdown,
ETag guard effectiveness, anomaly investigation, and ranked
reduction opportunities.

Key findings:
- GraphQL at 41%/hr with 5 sessions (bottleneck at ~12 sessions)
- 47% of calls are individual REST fallbacks that batch should cover
- Review thread GraphQL calls (55/15min) can be folded into batch
- detectPR() and guard failures are working as designed

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

* docs(experiments): add AO rate-limit reduction plan with Step 1

Step 1: Remove individual REST fallback from determineStatus().
110 calls (65 pr view + 45 pr checks) eliminated per 15-min window.
Batch enrichment covers all PRs every 30s — fallback is unnecessary
insurance for an event that never occurred in real traces.

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

* experiments(m2): drop agent-trace gate for claude-code

Claude Code uses native PostToolUse hooks (.claude/settings.json), bypassing
the ~/.ao/bin/gh PATH wrapper, so AO_AGENT_GH_TRACE stays empty even when
Claude makes gh calls. The previous smoke gate required AGENT_ROWS>0 and
aborted every claude-code M2 batch at smoke.

- limit-finder.sh: add REQUIRE_AGENT_TRACE env + --no-require-agent-trace flag,
  gate the AGENT_ROWS integrity check behind it.
- m2-ab-run.sh: bump SMOKE_DURATION to 420s; auto-pass --no-require-agent-trace
  when AGENT=claude-code; simplify smoke_check to gate only on AO_ROWS>0
  (B1 lives in AO-side scm-github, measured by AO trace).

Follow-up tracked as task #39: instrument Claude's hook/tool path or document
that AO_AGENT_GH_TRACE does not cover Claude Code.

* feat(tracker-github): cache issue reads in-process (5 min TTL)

The lifecycle worker polls getIssue/isCompleted repeatedly for the same
issue across a session. Trace data from a 5-session tier-5 bench run
showed the same (repo, issue) pair fetched 64+ times with >97% duplicate
rate — ~744 of 4,059 AO gh calls in 10 minutes were redundant issue views.

Adds an in-process Map<string, CachedIssue> per createGitHubTracker()
instance, keyed by `${repo}#${id}`, TTL 5 min, bounded to 500 entries
(LRU evict-oldest on overflow).

- getIssue: read-through cache, populate on miss
- isCompleted: routes through getIssue (was a separate narrow gh call)
- updateIssue: invalidate the entry before mutating
- createIssue: unchanged, naturally populates via the existing getIssue
- Failures are not cached

Cache lives inside createGitHubTracker so each create() returns an
isolated cache (test isolation comes for free).

Expected reduction: ~744 → ~15 gh issue view calls per tier-5 run.

Tests: 41 existing + 10 new cache tests, all passing.

* feat(scm-github): cache 5 gh pr view callsites with per-method TTLs

The lifecycle worker repeatedly polls each PR for state, summary, reviews,
and review decision. Trace data showed gh pr view was the single largest
AO-side endpoint at 1,280 calls per 5-session tier-5 run with >97% duplicate
rate (e.g. PR #184 polled 86× for --json state alone in 11.5 minutes).

Adds an in-process per-instance cache inside createGitHubSCM(), keyed by
${owner}/${repo}#${prKey}:${method} so different field-sets stay isolated.
Per-method TTLs balance reduction against staleness on decision-influencing
fields:

- resolvePR: 60s (identity metadata only)
- getPRState: 5s
- getPRSummary: 5s (includes state)
- getReviews: 5s
- getReviewDecision: 5s

assignPRToCurrentUser, mergePR, and closePR each invalidate the entire PR
cache for that PR after the mutation, so AO never sees stale state from its
own writes. Failures are not cached.

getCIChecksFromStatusRollup and getMergeability are intentionally NOT cached
here — those need ETag-based revalidation, not blind TTL, and will land
separately.

Expected reduction: ~1,165 of ~1,280 gh pr view calls per tier-5 run.

Tests: 73 existing + 12 new cache tests, all 153 passing.

* feat(scm-github): cache CI checks, mergeability, pending comments, detectPR

Completes the AO-side hot-read caching alongside the prior PR view cache.
All use 5s TTL per the approved policy for decision-influencing fields —
well under one lifecycle poll cycle so state transitions are still seen
next pass.

- getCIChecks (gh pr checks): 5s TTL
- getMergeability (composite pr view + CI + state): 5s TTL on the composite
- getPendingComments (gh api graphql review threads): 5s TTL —
  ETag doesn't help on GraphQL per Experiment 2
- detectPR (gh pr list --head BRANCH): 5s TTL, POSITIVE-ONLY.
  Empty results are never cached so a freshly created PR is discovered
  on the very next poll. The branch-keyed cache entry is invalidated
  by mergePR/closePR alongside the number-keyed entries.

Combined with the prior PR view cache, covers the top 6 AO-side gh
operation categories that accounted for ~85% of calls in tier-5 traces.

Tests: 85 existing + 9 new cache tests, all 162 passing.

* experiments(m2): parse REPO from yaml before using it in banner

m2-ab-run.sh referenced $REPO in the header banner before parsing it,
causing 'unbound variable' abort under 'set -u'. Parse it right after
CONFIG_FILE is set.

* test(core): mock full Issue shape in plugin-integration cleanup tests

After tracker-github routed isCompleted() through getIssue() to share
the issue cache, these mocks needed the full Issue shape (number, title,
body, url, state, stateReason, labels, assignees) instead of the narrow
{state} shape that worked when isCompleted made its own --json state call.

* perf(scm-github): tune cache TTLs based on trace replay

Replayed feat run1 + main run2 tier-5 traces (4059 + 1748 rows, 38 min, 5
sessions each) against the shipped cache logic. Three TTLs were materially
under-tuned for the actual lifecycle poll cadence:

- detectPR:           5s → 30s   (was 0.5% hit rate; per-branch poll cadence
                                  is ~90s, so 5s caught nothing. 30s catches
                                  intra-cycle bursts when multiple sessions
                                  share a branch. Positive-only stays.)
- getReviewDecision:  5s → 10s   (within "10-30s TTL or ETag" policy)
- getPendingComments: 5s → 10s   (same policy class)

All three are still well under one poll cycle; freshness contract unchanged
in practice. Other TTLs (5s on state/CI/mergeability, 60s on resolvePR,
5min on issue) hit the targets they were set for and stay as-is.

Replay results before/after:
- feat run1:  53.7% → 57.8% reduction (2179 → 2345 hits of 4059 calls)
- main run2:  47.4% → 52.6% reduction
- Net: ~55% AO-side gh calls eliminated across both traces

Adds experiments/cache-replay.mjs — a counterfactual replay tool that
walks an execGhObserved JSONL trace and simulates per-method cache hits
with the shipped TTLs. Useful as a regression check when tweaking cache
policy.

Tests: 162/162 passing.

* docs(experiments): add cache freshness check runbook

Seven-step manual runbook to validate the cache TTL contract doesn't
cause workflow lag. Covers each cached method with:

- exact gh CLI trigger command
- what to observe in the dashboard / lifecycle log
- pass/fail threshold (TTL + 30s poll cycle)

Companion to experiments/cache-replay.mjs — replay measures how much
we saved, runbook measures whether we lost anything in the process.

* docs(experiments): add Step 2 — consolidate review comment fetching

Single GraphQL call replaces GraphQL + REST for review comments.
Include comment data in agent reaction message to eliminate
agent-side gh read calls. Update future steps.

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

* docs(experiments): add duplicate API traffic analysis

Three independent sources hit GitHub API for the same PRs:
1. Dashboard serialize.ts — individual REST calls, no batch, no cache
2. CLI lifecycle manager — batch + guards
3. Web lifecycle manager — same batch + guards, 3s offset

~50% of all API traffic is pure duplication. Dashboard and dual
lifecycle managers are the root causes.

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

* docs(experiments): add full cache architecture to duplicate traffic analysis

Three independent cache layers across two processes with zero shared
state. Web process creates its own plugin registry, SCM plugin, lifecycle
manager, and dashboard cache — all hitting GitHub independently.

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

* docs(experiments): add shared PR enrichment plan

Persist batch enrichment + review comments to session metadata files.
Dashboard reads from disk instead of making its own GitHub API calls.
Remove web's duplicate lifecycle manager.

Eliminates ~268 calls / 15 min (58% of all traffic). Dashboard data
gets fresher (30s vs 5min). Single writer (CLI lifecycle), web only reads.

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

* docs(experiments): update Step 1 — remove all three fallback paths

Remove fallback in determineStatus(), maybeDispatchCIFailureDetails(),
and maybeDispatchMergeConflicts(). All three follow the same pattern:
batch cache hit → use it, cache miss → skip (wait 30s for next batch).

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

* docs(experiments): promote Step 3 (remove dead reviews field) + detail Step 5 (issue caching)

Step 3: Remove reviews(last: 5) from batch query — fetched but never
consumed, reduces GraphQL complexity on every batch call.

Step 5: Persist issue data to session metadata at spawn — eliminates
27 gh issue view calls per 15 min (both processes re-fetch independently).

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

* feat(core): remove individual REST fallback from lifecycle polling

Remove fallback paths in determineStatus(), maybeDispatchCIFailureDetails(),
and maybeDispatchMergeConflicts() that made individual REST calls when the
batch enrichment cache missed. The batch runs every 30s — a cache miss
means the data arrives on the next cycle, not that it's lost.

Also add populatePREnrichmentCache() call to check() so single-session
checks also use the batch path.

Eliminates ~110 individual pr view/pr checks calls per 15-min window
(24% of all AO-side traffic).

* feat(core): consolidate review comment fetching into single GraphQL call

Add getReviewThreads() to SCM interface — returns all review threads
(human + bot) with isBot flag from a single GraphQL query. Lifecycle
manager splits locally for separate reaction pipelines.

- Eliminates the REST getAutomatedComments() call (40 calls / 15 min)
- Reaction messages now include inline comment data (file, line, author,
  body, URL) so agents don't need to re-fetch via gh api
- Default config messages updated to not tell agents to call gh
- getAutomatedComments kept as optional for backward compatibility

* perf(scm-github): remove unused reviews(last: 5) from batch query

The batch query fetched reviews with author, state, submittedAt but
the data was never consumed — only used in a validation check.
The reviewDecision scalar field provides everything AO needs.

Reduces GraphQL complexity cost on every batch call.

* docs(experiments): add post-optimization trace report (Steps 1-3)

5-session, 17-minute trace after removing REST fallback, consolidating
review comments, and removing dead reviews field.

Results: GraphQL 35%/hr (was 41%), REST <1% (was 3%), automated
comment REST calls eliminated. 54% of remaining traffic is redundant
(duplicate lifecycle manager + dashboard individual calls).

* docs(experiments): add trace file gist link to post-optimization report

* feat(core,web): shared PR enrichment — dashboard reads from metadata

CLI lifecycle manager now persists batch enrichment data and review
comments to session metadata files (prEnrichment + prReviewComments
keys). The web dashboard reads from metadata instead of calling
GitHub API.

Changes:
- lifecycle-manager: add persistPREnrichmentToMetadata() after poll,
  write prReviewComments in maybeDispatchReviewBacklog()
- serialize: replace enrichSessionPR (6 API calls) with metadata read
- services: stop web lifecycle polling (keep for webhook checks)
- cache: remove prCache (no longer needed)
- routes: remove timeout wrappers and cacheOnly pattern

Eliminates ~237 calls / 15 min (54% of all AO-side traffic).
Dashboard data freshness improves from 5min to 30s.

* fix(web): remove unused beforeEach import in serialize test

* docs(experiments): add final trace report — 56% GraphQL reduction achieved

5-session, 24-min trace after all optimizations including shared
enrichment. GraphQL 905/hr (was 2,072), REST 5/hr (was 168).
Single lifecycle manager confirmed. Dashboard API calls eliminated.
Max sessions before budget exhaustion: ~27 (was ~12).

* docs(experiments): add REST budget breakdown to final report

* fix(core): use storageKey for getSessionsDir in persistPREnrichmentToMetadata

* fix(test): use OpenCodeSessionManager type in plugin-integration tests

* fix(test): update bugbot-comments and auto-cleanup tests for new review API

* fix(web): fix syntax error and missing import from rebase

* docs(experiments): add complete rate-limiting change log and update final report numbers

* fix(web): fix tmux session resolution for legacy wrapped storageKeys

* fix(web): pass tmuxName directly to terminal server instead of reverse-resolving

* perf(core): gate detectPR behind Guard 1 ETag — skip when PR list unchanged

* perf(core): always run Guard 1 for all repos, dedup issue views, include threadId in review messages

* feat(core): add Guard 3 (review ETag), enrich review data with summaries, dedup issue views, gate detectPR for all repos

* fix(web): reuse cached tmuxSessionId on re-open, add 15-session trace report and comparison docs

* perf(scm-github): reduce contexts to first:10, add -i to review GraphQL for rate limit tracing

* feat(core): merge CI details into transition, enrich merge conflict message, reduce batch contexts, add graphqlCost tracing

* chore(experiments): remove working artifacts, keep final reports and reference docs

* chore: remove experiments directory

* refactor: remove getAutomatedComments from SCM interface and all implementations

* fix: address all PR review comments

- gh-trace: make binary resolution async via fs.access (no event loop
  blocking, no shell injection), cache mkdir for trace writes, async
  fire-and-forget appendFile, document 10MB maxBuffer rationale
- lifecycle-manager: log detectPR failures via observer instead of
  silent catch, add getPRState fallback for terminal states
  (merged/closed) when batch enrichment cache misses
- scm-gitlab: implement getReviewThreads with bot+human threads and
  isBot flag, fixing silent feature regression after
  getAutomatedComments removal
- scm-github: clear reviewThreadsCache in invalidatePRCache, document
  first:11 CI checks cost budget
- runtime-tmux: use printf+JSON.stringify for PATH export to prevent
  shell injection from single quotes
- agent-workspace-hooks: add cache timestamp sanity check, include
  --repo in cache keys to prevent cross-repo collisions
- services: document dashboard dependency on CLI polling
- tests: update gh binary path assertions for resolved paths

* fix: address all 17 PR review comments

- gh-trace: use path.delimiter, cache-only-on-success, last HTTP status
  line, await writes with warn-once, redact secrets, gate JSON.parse
- agent-workspace-hooks: validate cache keys, redact wrapper trace args,
  sha256 cache keys to prevent collisions, 120s TTL ceiling
- session-manager: skip PATH wrappers for claude-code (native hooks)
- types: add deprecation JSDoc for getReviewThreads
- graphql-batch: clear Guard 3 in clearETagCache, re-read ETag on 304,
  switch Guard 2 to check-runs endpoint, drop per_page=1 from Guard 3
- Add gh-trace unit tests for extractOperation, redactArgs, parseHttp

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: adil <adil.business4064@gmail.com>
Co-authored-by: iamasx <adilshaikh4064@gmail.com>
2026-04-25 18:57:04 +05:30
Ashish Huddar 0538e07b65
Fix root dashboard project selection (#1480)
* style(design): strengthen dashboard hierarchy

* fix(web): prefer current repo dashboard project

* Revert "style(design): strengthen dashboard hierarchy"

This reverts commit 5db28280e1.

* Fix root dashboard project selection

* fix(web): bound session loading and reduce dashboard polling

* Fix project-name test temp path and reuse loaded config

* fix(web): clear recovered errors and restore fresh detail polling

* fix: stabilize dashboard session handling

* Fix ambiguous project-name fallback discovery

* Fix dashboard session selection regressions

* Remove unused orchestrator session imports
2026-04-24 18:35:38 +05:30
Ashish Huddar 6e59fddc71
feat(sessions): derive display names from task context (#1220) (#1224)
* feat(sessions): derive display names from task context (#1220)

Orchestrator and ad-hoc sessions used to render as "Orchestrator/Ao
Orchestrator 8" or "Ao 42" — humanized branch names that revealed
nothing about the work. getSessionTitle()'s fallback chain landed on
humanizeBranch() whenever no PR or enriched issue title was available,
and humanizeBranch() didn't know to strip the "orchestrator/" prefix
or recognise that "ao-orchestrator-8" is just the session ID.

Fix it at two levels:

Level 1 — humanizeBranch() cleanup
- Add "orchestrator" to the prefix-strip regex.
- Accept an optional sessionId argument; when the stripped branch is
  just the session ID (e.g. "orchestrator/ao-orchestrator-8" -> "ao-
  orchestrator-8"), return an empty string so getSessionTitle() can
  fall through to the next meaningful fallback instead of rendering
  noise.

Level 2 — persisted displayName derived at spawn time
- New optional SessionMetadata.displayName field, captured from the
  best available context when the session is spawned: issue title
  for tracker-backed sessions, the first line of a user prompt for
  prompt-only sessions, and the first line of the system prompt for
  orchestrators. Values are collapsed to a single line and truncated
  to 80 chars with an ellipsis.
- metadata.ts serialises and reads the new field.
- DashboardSession gains a displayName field; sessionToDashboard()
  pulls it from session.metadata.
- getSessionTitle() gains a new fallback ordered above the humanized
  branch, so sessions remain identifiable even when the tracker API
  is unavailable or an orchestrator has no attached issue.

Tests:
- metadata.test.ts: displayName round-trip.
- spawn.test.ts: displayName derivation from issue title, user prompt
  (first line), system prompt; truncation with ellipsis; omission
  when there is no context.
- format.test.ts: orchestrator/ prefix handling, session-ID collapse,
  displayName fallback, orchestrator bug repro ("Audit test coverage
  for session-manager" instead of "Orchestrator/Ao Orchestrator 8"),
  fall-through to summary/status when displayName is absent.

Refs #1220

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

* fix(format): prefer cleaned displayName over raw userPrompt (#1220)

Cursor bugbot review on #1224 caught that for prompt-only sessions,
`userPrompt` (step 3) always shadowed `displayName` (step 4) in
`getSessionTitle`. Both are populated from the same `spawnConfig.prompt`
at spawn time: `userPrompt` stores the raw multi-line original, and
`displayName` is the single-line 80-char-truncated version produced by
`deriveDisplayName`. Because `userPrompt` was checked first, the careful
cleanup never reached the dashboard — kanban cards and session tabs
rendered the full raw prompt instead of the clean version the PR was
supposed to deliver.

Swap the order so `displayName` wins when present. `userPrompt` remains
as a safety-net fallback for sessions spawned before `displayName`
existed or where derivation failed.

Add regression tests:
- prefers cleaned displayName over raw userPrompt for prompt-only sessions
- falls through to raw userPrompt when displayName is absent

Refs #1220

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

* fix(sessions): address review comments on displayName PR

- ProjectSidebar: drop session.branch short-circuit so displayName/cleaned
  branch fallback is reached (was rendering raw branch for any session
  with a branch, defeating the new fallback chain).
- session-manager restore: preserve displayName when rewriting metadata
  from archive so restored sessions keep their derived title. Add
  regression test.
- deriveDisplayName: truncate on code-point boundaries via Array.from so
  emoji at the boundary aren't cleaved into lone UTF-16 surrogates. Same
  fix applied to the tab-title truncate in web session page.
- Add regression test covering surrogate-boundary truncation.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-22 12:24:03 +05:30
Ashish Huddar f3ce113c4c
Add multi-project storage, resolution, and project settings support (#1343)
* feat: add content-addressed project storage keys

* Add per-project resolution and hardened project routing

* Fix storage-key test isolation

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

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

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

* Fix legacy config storage keys and duplicate project flow

* Fix multi-project storage migration and collision handling

* Fix merge regressions in startup and config handling

* Externalize yaml and zod from the web server bundle

* Fix session prefix matching for hashed tmux names

* Fix multi-project migration regressions

* Ignore generated worktree files in ESLint

* Fallback reload config for local-only projects

* Speed up session refresh and redirect after kill

* Use fresh session lists for dashboard polling

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

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

* docs: call out filesystem browse rollout requirement

* Add portfolio tests and remove unused decomposer export
2026-04-21 17:45:55 +05:30
Chirag Arora f86d7f856f
fix(web): resolve dashboard issue URLs from tracker (#1353)
* fix(web): resolve dashboard issue URLs from tracker

Build issue URLs from tracker.issueUrl when sessions store identifier-only issue IDs so dashboard links, labels, and title enrichment stay valid for GitHub-style numeric issues.

Made-with: Cursor

* fix(web): harden issue URL enrichment and add regressions

Guard issue enrichment against URL double-wrapping, free-text synthetic links, and invalid tracker URL output while logging failures. Add regression coverage for URL-shaped issue ids, free-text issue ids, tracker issueUrl failures, and failed issue-title lookup throttling.

Made-with: Cursor

* fix(web): remove unreachable issue-url branch for lint

Drop the duplicate else-if path in enrichSessionIssue that was already covered by the absolute-url guard, resolving no-dupe-else-if and unblocking lint/typecheck/web onboarding checks.

Made-with: Cursor
2026-04-21 16:01:12 +05:30
Chirag Arora 380b5d5a7c
fix(web): surface dashboard SSR load failures (#1316)
* fix(web): surface dashboard SSR load failures

When getServices or session listing throws during SSR, show an error banner
and skip the empty-state CTA instead of implying there are zero sessions.
Disables realtime hooks while the banner is shown to avoid noisy reconnects.

Made-with: Cursor

* fix(web): make dashboard SSR errors recoverable

Narrow dashboard fatal errors to service/bootstrap failures and fail-soft on enrichment so transient metadata issues do not blank the page. Keep realtime updates active after SSR load errors, sanitize multiline error messages, and add regression tests.

Made-with: Cursor

* fix(web): address dashboard SSR recovery and attention-zone SSR

- Gate hiding the SSR load-error banner on liveSessionsResolved from
  useSessionEvents (successful /api/sessions refresh, SSE snapshot, or mux),
  not session count; use cache: no-store on session refresh fetches.
- Apply attentionZones from config immediately after getServices(); wrap
  sessionManager.list() in its own try/catch so list failures keep zone config.
- Treat session.status merged like merged PR for Done cards (hide Restore).
- Align ProjectSidebar orchestrator fixture id with isOrchestratorSession pattern.

Made-with: Cursor
2026-04-21 11:38:02 +05:30
Chirag Arora fed25d5d29
feat(web): merge conflict compare link and copy branch on session PR card (#1318)
* feat(web): merge conflict compare link and branch copy on PR card

- Add buildGitHubCompareUrl helper with unit tests
- Show compare + copy head branch when open PR has merge conflicts
- Add SessionDetail regression test for conflict affordances

Made-with: Cursor

* fix(web): harden merge-conflict PR actions

Address review feedback by correcting the changeset package name, encoding all compare URL path segments, and making conflict actions resilient to unenriched/rate-limited PR data and clipboard/timer edge cases. Add regression tests for URL encoding and conflict-action gating.

Made-with: Cursor

* fix(web): normalize browser timer handles in session detail

Use browser timer handle types consistently in the session detail PR card copy-feedback flow so Next.js typechecking does not mix Node Timeout and DOM number timer signatures.

Made-with: Cursor
2026-04-21 11:35:29 +05:30
Ashish Huddar faaddb15df
feat(core): auto-terminate sessions on PR merge (#1309) (#1311)
* feat(core): auto-terminate sessions on PR merge (#1309)

When a session's PR was detected as merged, the session transitioned
to status "merged" but its tmux runtime, worktree, and metadata were
never cleaned up — leaving zombie tmux sessions and stale entries in
`ao status` / `ao session ls`. Users worked around this with an
external watchdog. Close the loop in AO itself.

Changes:
- `kill()` gains an optional `reason` and returns `KillResult`
  (`cleaned` / `alreadyTerminated`), with short-circuit paths so
  repeated calls on archived sessions are safe no-ops instead of
  throwing `SessionNotFoundError`.
- New `LifecycleConfig` (`autoCleanupOnMerge: true` default,
  `mergeCleanupIdleGraceMs: 5 min`) so operators can opt out when
  they need merged worktrees preserved for inspection.
- `lifecycle-manager` runs `maybeAutoCleanupOnMerge` at the end of
  each `checkSession`. Reactions and notifications observe the live
  session first; cleanup runs last. If the agent is still `active` /
  `waiting_input` / `blocked`, cleanup is deferred and retried on
  the next poll until the agent idles or the grace window elapses
  (prevents killing an agent mid-task).
- New `CanonicalSessionReason` / `CanonicalRuntimeReason` variants
  (`pr_merged`, `auto_cleanup`) so observability distinguishes
  automated teardown from manual kills.

Scope is deliberately narrow to `merged`: `done` / `errored` often
need the worktree preserved for debugging; `killed` would self-recurse.

Follows Codex review feedback (conditional pass): scope narrowed,
reactions-before-cleanup ordering, idleness safety gate, real
idempotency guards, config opt-in.

6 new unit tests cover: idle agent cleanup, active agent deferral,
grace-window force-cleanup, config opt-out, terminated/killed no
self-recursion, kill() failure retry.

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

* refactor(core): clean up lifecycle config access per review

Address review comment on PR #1311. The `config.lifecycle` field is
typed optional but always populated by Zod — the old guard chain
(`if (lifecycleConfig && lifecycleConfig.autoCleanupOnMerge === false)`)
obscured that duality. Destructure with defaults at the call site so
the contract is visible in one place, and document why the field stays
optional (hand-constructed test configs) on the interface.

Matches the existing `power?: PowerConfig` pattern — keeps churn to
zero across 60 test config literals while removing the ambiguous guard.

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

* docs(core): surface auto-cleanup-on-merge in config, docs, and UI

Followup to DX audit on #1311. The lifecycle cleanup behavior was
operational but invisible — config key only in TS types, no changeset
for downstream consumers, missing observability spec, and a dashboard
summary that actively contradicted the new default-on behavior.

- agent-orchestrator.yaml.example: add commented `lifecycle:` block
  with both keys so operators discover the knob in the primary
  config reference.
- .changeset/auto-cleanup-on-merge.md: minor bump for @aoagents/ao-core
  with migration note (default-on, opt-out via config).
- docs/observability.md: document the three new lifecycle_poll
  operations (merge_cleanup.completed / deferred / failed) so
  dashboard/alert authors have a spec.
- packages/web/src/lib/serialize.ts: replace stale summary
  "PR merged; worker is still available for a keep-or-kill decision"
  with "PR merged; worker session will be cleaned up automatically".
  The old copy is wrong under default-on auto-cleanup.

Deferred to follow-up issues:
- Health surface degradation on repeated cleanup failure (the
  operator-facing gap Codex flagged — failures emit a metric but
  don't downgrade /api/observability health).
- Dashboard "cleaning up in Nm" indicator for the deferred state.

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

* fix(core,web): address PR review feedback for auto-cleanup on merge

- serialize.ts: only claim "will be cleaned up automatically" when
  mergedPendingCleanupSince marker is present; otherwise show neutral
  "PR merged". Avoids lying when autoCleanupOnMerge is opted out.
- lifecycle-manager.ts: use ACTIVITY_STATE constants instead of
  hardcoded strings, matching the existing SESSION_STATUS.MERGED usage.
- config.ts: keep mergeCleanupIdleGraceMs=0 as a valid escape hatch
  (immediate cleanup), but reject 1..9999 with a units-mistake error
  so users typing `5` (intending seconds) get a clear message.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 12:56:07 +05:30
Harsh Batheja 81489079b2
fix(cli,core): reuse orchestrator sessions across ao start; fix dashboard id mismatch (#1075)
Fixes #1048. ao start used to allocate a fresh `{prefix}-orchestrator-N`
on every invocation instead of reattaching to the previous session, and
the dashboard's orchestrator link pointed at a different id than the
CLI just printed.

Changes:

runStartup (packages/cli/src/commands/start.ts):
  - On startup, list existing orchestrators for the project, partition
    them into live (runtime still running) and restorable (terminal but
    sm.restore()-able) buckets, pick the most-recently-active from the
    chosen bucket, and reuse/restore that id instead of spawning a new
    one. Only spawn fresh when both buckets are empty.
  - Live is preferred UNCONDITIONALLY over restorable — a newer killed
    record can never beat an older-but-running one. Without this, a
    cross-bucket sort could resurrect a killed record via sm.restore()
    while the live orchestrator kept running, leaving two alive.
  - Restored sessions get an explicit "(restored)" marker in the CLI
    summary so the resurfaced id isn't a surprise.
  - The phantom `${prefix}-orchestrator` id constant is removed from
    every URL print, browser-open target, and summary line. Everything
    now uses the real selected id.

registerStop (same file):
  - ao stop now resolves the real orchestrator via sm.list(projectId)
    + isOrchestratorSession filter + most-recently-active sort, then
    calls sm.kill on that id. The old phantom `${prefix}-orchestrator`
    target never matched a real numbered record, so ao stop was a
    silent no-op and the orchestrator kept running on disk between
    start cycles. sm.list-failure warning no longer duplicates with the
    generic "no orchestrator found" message.

isOrchestratorSession (packages/core/src/types.ts):
  - Tightened: legacy bare-id records (`{projectId}-orchestrator` with
    no role metadata) are no longer recognized as orchestrators by the
    public predicate. This was the source of the dashboard/CLI id
    divergence — stale bare records with a different prefix than the
    numbered form were leaking into the dashboard's orchestrator list.

session-manager repair (packages/core/src/session-manager.ts):
  - Split `isOrchestratorSessionRecord` (permissive, used by cleanup
    protection) from a new `isRepairableOrchestratorRecord` (stricter,
    used only by repairSingleSessionMetadataOnRead and
    repairSessionMetadataOnRead).
  - The strict repair predicate accepts role-stamped records, the bare
    `{sessionPrefix}-orchestrator` correct-prefix legacy shape, and the
    numbered `{sessionPrefix}-orchestrator-N` worktree shape. It
    rejects foreign bare names like `{projectId}-orchestrator`, so
    those records never get `role: orchestrator` backfilled on read
    and therefore can no longer pass `isOrchestratorSession()` in real
    `sm.list()` output via the role-metadata branch.

Tests added (~12):
  - runStartup: live reuse, restore-on-killed, ignore-stale-bare
    legacy records, live-beats-restorable regression, multi-live
    reuse, URL fallback when --no-orchestrator.
  - ao stop: kills the actual numbered id (not the phantom), handles
    multiple orchestrators, tolerates sm.list throwing.
  - isOrchestratorSession: rejects stale bare ids without role
    metadata; accepts bare ids with role metadata stamped.
  - listDashboardOrchestrators: stale bare excluded, numbered live
    included, role-stamped legacy included.
  - session-manager repair: does not backfill role onto foreign bare-id
    records (issue #1048 regression guard).

Unblocks: review comments from cursor[bot] (dead else-if branch,
double messaging, redundant isTerminalSession check) and illegalcall
(cross-bucket sort, repair-backfill bypass of predicate tightening) —
all addressed in-place with the multi-orchestrator model preserved.

Verified: core 606/606, cli 450/450, typecheck clean across core/cli/web.
2026-04-19 13:12:28 +05:30
fastestdevalive 254ecd1098
feat(web): terminal layout, mobile UX, sidebar & instant session navigation (#1278)
* feat(web): fix terminal height to fill viewport, prevent outside scrolling

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

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

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

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

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

* ci: make pnpm audit strict step non-blocking

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Reconcile the rebased-upstream tests with our redesigned SessionDetail:

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

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

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

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

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

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

Removes the dead `mobileOpen` prop from ProjectSidebar:

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

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

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

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

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

Critical — listCached was stale across mutation paths

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 00:15:57 +05:30
harshitsinghbhandari bd7239257f fix: register codex web activity plugin 2026-04-18 14:15:29 +05:30
harshitsinghbhandari bcdda4b9ba fix: address PR #1300 requested changes 2026-04-18 11:13:53 +05:30
harshitsinghbhandari 5f3cdf4e5e fix: remove unused TERMINAL_STATUSES import
Fixes CI lint error in dashboard-page-data.ts.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-18 00:58:27 +05:30
harshitsinghbhandari 0d03c46ca5 Merge upstream/main into session/aa-22-lifecycle-fixes
Resolved conflicts in:
- packages/web/src/lib/types.ts (merged canonical type imports with DashboardAttentionZoneMode)
- packages/web/src/lib/dashboard-page-data.ts (kept upstream imports)
- packages/web/src/components/Dashboard.tsx (kept upstream mobileKanbanOrder)

Updated getAttentionLevel to support mode parameter from upstream
while preserving lifecycle-aware helper functions.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-18 00:53:06 +05:30
Ashish Huddar 237c99f76e
feat(web): collapse attention zones to 4 with 5-zone feature flag (#1202)
* feat(web): collapse attention zones to 4 with feature flag for 5 (#1201)

Simplify the dashboard to a 4-zone kanban by default (WORKING,
PENDING, ACTION, READY), merging the former REVIEW + RESPOND
columns into a single ACTION zone that just asks "does the human
need to do something?". The card-level badges still surface the
underlying granular state (ci_failed, needs_input, changes_requested),
so nothing is lost — it just moves off the column header.

Add a `dashboard.attentionZones` config (defaults to "simple") that
opts back into the original 5-zone layout for power users. The
function-level `getAttentionLevel(session, mode)` still defaults to
"detailed" so card-level call sites (SessionCard, BottomSheet,
ProjectSidebar, etc.) keep their granular behavior untouched. Only
the Dashboard kanban and SSE server routes read the config and pass
the mode.

Closes #1201

* fix(web): address PR review feedback on attention zones (#1201)

- Drop .strict() from DashboardConfigSchema (Cursor Bugbot):
  matches other schemas in config.ts so typos in the dashboard
  block gracefully default instead of crashing the whole loader.

- DynamicFavicon: keep `action` at yellow, not red (Codex P2).
  In simple mode, `action` collapses respond + review, which
  means it necessarily catches routine review work (ci_failed,
  changes_requested) that was previously yellow. Escalating to
  red would make every typical PR scream critical. Only the
  detailed-mode `respond` bucket still triggers red.

- useSessionEvents default → "detailed" (Codex P3). The hook
  default now matches getAttentionLevel's default so callers
  that seed with `getAttentionLevel(s)` (no mode) — notably
  PullRequestsPage — stay consistent with their seed after
  the first live SSE/refresh snapshot. Dashboard explicitly
  passes the config's attentionZones to opt into simple mode.

- Expand mobile action chip (Codex P3). When a session
  collapses to `action`, derive the most specific underlying
  cause (needs input, stuck, errored, ci failed, changes,
  waiting, conflicts) instead of falling through to the
  generic "action" label, so mobile users keep the reason to
  intervene.

- DynamicFavicon tests: add cases for `action` staying yellow
  and `respond` still escalating when both are present.

* fix(web): thread attentionZones config through PullRequestsPage (#1201)

Cursor Bugbot caught a deeper version of the same inconsistency
my previous fix didn't fully resolve: the server SSE route uses
`config.dashboard?.attentionZones ?? "simple"`, but PullRequestsPage
was seeding `initialAttentionLevels` and calling useSessionEvents
without passing any mode (falling back to the hook's "detailed"
default). Result: snapshots from the server arrived as "action"/
"merge", then scheduleRefresh recomputed them client-side as
"respond"/"review", and the favicon oscillated between yellow
and red on every refresh cycle.

Fix: plumb pageData.attentionZones through prs/page.tsx into
PullRequestsPage, use it in both the seed and useSessionEvents.
Now the seed, the server SSE, and the client refresh all use the
same mode — stable, no flicker.

* fix(web): accurate action labels for crashed agents + yellow tone (#1201)

Two Cursor Bugbot findings on the action-zone collapse work:

1. [Medium] Mobile chip mislabeled crashed agents as "needs input".
   When an agent's activity is `exited` and the session is in the
   action bucket, the agent has crashed — sending it a message
   won't help, the user needs to restart. Split the label: exited
   → "crashed", blocked → "blocked".

2. [Low] ProjectMetric for Action used tone="error" (red), which
   contradicts the favicon fix's rationale. The action bucket
   catches routine review work (ci_failed, changes_requested)
   that was previously orange/yellow severity; screaming red in
   the project overview grid would have the same cry-wolf problem
   as the favicon did. Use tone="orange" (the less severe of the
   two merged buckets) to match.

* refactor(web): make attentionZones required in useSessionEvents (#1201)

Cursor Bugbot caught that the hook default of "detailed" mismatches
the server SSE route default of "simple" — a latent footgun for any
future caller that forgets to pass the mode explicitly. The exact
oscillation bug I already fixed once for PullRequestsPage would
silently come back on the next page that uses this hook.

Real fix: refactor the hook to take an options object and make
`attentionZones` required with no default. TypeScript now enforces
that every caller explicitly passes the mode the server is using.
You literally cannot call useSessionEvents without supplying it.

Also cleaner: the hook had 6 positional parameters, which is past
the point where an options object helps readability. Dashboard and
PullRequestsPage call sites now read as self-documenting.

* fix(web): address human code review on attention zones (#1201)

Review from i-trytoohard on commit 8d27db79:

1. [Medium] `ZoneCounts` in sessions/[id]/page.tsx had an `action`
   field that was always 0 — the page always computes in detailed
   mode (getAttentionLevel with no mode = detailed), and the
   consumer (`OrchestratorZones` in SessionDetail) never reads
   an `action` field either. Removed the dead field, added a
   `continue` guard for the never-reached "action" case so
   TypeScript narrows the index correctly, and documented the
   intent in a header comment.

2. [Medium] The simple-mode action chip had 10+ sub-conditions
   with no tests. Extracted the label logic into a pure
   `getActionChipLabel(session)` helper and added 16 unit tests
   covering every branch: status-based signals (needs_input,
   stuck, errored, ci_failed, changes_requested), activity-based
   (waiting_input, exited→crashed, blocked), PR-based (failing
   CI, changes_requested, conflicts), precedence between signal
   classes, and the generic fallback.

3. [Low] `LEVEL_LABELS.action` in ProjectSidebar is dead code
   today (sidebar uses detailed mode). Added a comment explaining
   why the entry still exists (TypeScript exhaustiveness on
   `Record<AttentionLevel, string>` + forward-compat).

* fix(web): address inline review on attention zones (#1201)

- Reorder getActionChipLabel to mirror getDetailedAttentionLevel: respond-class
  activity (waiting_input/exited/blocked) now outranks review-class status
  (ci_failed/changes_requested). A crashed agent with changes_requested no
  longer reads as "changes" and hides the crash.
- Paint data-level="action" dot with --color-accent-orange to match the
  favicon's yellow-severity treatment of the collapsed bucket.
- Tighten attentionLevel on the mux boundary (mux-protocol, mux-websocket,
  useSessionEvents) from string to AttentionLevel so invalid values like "none"
  no longer launder through into DynamicFavicon's count.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 22:24:25 +05:30
harshitsinghbhandari 1cbf6579c2 fix: address PR review feedback for session audit trail 2026-04-17 21:06:08 +05:30
harshitsinghbhandari 28ce462ef0 feat: unify session detail auditability and lifecycle stability (#123 #124 #126) 2026-04-17 20:48:02 +05:30
harshitsinghbhandari f1769ec910 fix: address final lifecycle review findings (#122) 2026-04-17 19:52:15 +05:30
harshitsinghbhandari ca8c4cc7ab fix: address lifecycle review feedback (#122)
Keep reconstructed activity and activitySignal coherent, preserve stronger lifecycle states when repeated polls only surface weak evidence, and switch release notes from manual changelog edits to a Changesets entry. This also stabilizes the respond/working path investigated in issue #126.
2026-04-17 19:38:32 +05:30
harshitsinghbhandari dd83a11503 fix: model missing activity evidence explicitly (#122)
Represent missing activity probes as first-class signal states so lifecycle inference only treats valid idle evidence as proof. This prevents false stuck transitions, keeps API/UI lifecycle truth aligned, and makes root monorepo verification deterministic by serializing recursive build and typecheck.
2026-04-17 19:28:56 +05:30
harshitsinghbhandari 08667c8cb5 fix(web): keep closed-unmerged sessions actionable 2026-04-17 14:42:25 +05:30
harshitsinghbhandari da1e2ceae2 fix: restore Next-resolvable serialize types import (#119) 2026-04-17 13:43:26 +05:30
harshitsinghbhandari d3cf4ae3de fix: address PR review comments on stage 5 rollout (#119) 2026-04-17 13:36:09 +05:30
harshitsinghbhandari 690242e379 fix: keep active PR sessions in working lane
Prevent open PR sessions with only  PR truth from being misclassified as pending during the Stage 5 rollout, and add coverage for the regression.
2026-04-17 13:15:11 +05:30
harshitsinghbhandari 88cd9e7ed1 merge: resolve sessions-redone conflicts for stage5
Preserve the split lifecycle observability and UI truth model while retaining the latest sessions-redone PR transition handling and dependency updates.
2026-04-17 13:09:13 +05:30
harshitsinghbhandari 7072143138 feat: expose split lifecycle truth in dashboard
Implement the Stage 5 rollout so API and UI consumers stop depending on one overloaded status and operators can see structured transition evidence without breaking older session metadata.
2026-04-17 13:00:26 +05:30
harshitsinghbhandari b5c969c6b4 fix: address PR review policy regressions (#118) 2026-04-17 11:39:13 +05:30
harshitsinghbhandari a45eb322bb fix: decouple PR truth from worker session policy 2026-04-17 10:44:35 +05:30