* 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>
* fix: keep tmux session alive after agent exit (closes#1756)
Two related fixes:
1. runtime-tmux: append `exec "${SHELL:-/bin/bash}" -i` to the launch
command so the pane drops to an interactive shell when the agent
exits, instead of letting the empty pane take down the whole tmux
session. The lifecycle still detects agent termination via
`agent.isProcessRunning` and transitions the session to
`agent_process_exited` — the runtime just stays usable so the user
can run shell commands or manually re-launch the agent.
2. mux-websocket: add a `tmux has-session` guard at the top of
`pty.onExit`. When the tmux session is genuinely gone (e.g. `ao
stop` killed it out from under a still-subscribed dashboard), skip
the three doomed `attach-session` spawns introduced by the
MAX_REATTACH_ATTEMPTS bound in #1640 and notify subscribers
immediately. The bound from #1640 still covers transient
tmux-server hiccups where the session does still exist.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(mux): make tmuxHasSession async to avoid blocking the event loop
The has-session probe added in #1756 ran via execFileSync inside
node-pty's onExit callback, freezing every WebSocket connection,
HTTP request, and in-flight terminal for up to the 5 s subprocess
timeout whenever an agent exited and tmux was slow to respond.
Switch tmuxHasSession to promisified execFile and await it from the
onExit handler, mirroring the execFileAsync pattern in runtime-tmux.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* 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>
* fix(web): drop = prefix from set-option in mux-websocket (closes#1714)
In tmux 3.4 the `=` exact-match prefix only works with `has-session` and
`attach-session`. For `set-option`, the prefix is silently ignored, so
`mouse on` and `status off` never get applied — breaking scroll wheel in
the dashboard terminal and leaving the tmux status bar visible.
Use the bare session id for the two `set-option` calls; keep `=` on
`attach-session` where it is correct.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(web): move exactTmuxTarget next to attach-session
Address review feedback: the `=`-prefixed target is only used by
attach-session, so declare it adjacent to that call. Comment now
sits above the set-option calls it actually explains.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Prateek <karnalprateek@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(web): bound PTY re-attach loop with grace-period counter reset (#1639)
When `ao stop` (or any external action) kills a tmux session out from
under a still-subscribed dashboard, the mux server's PTY exit handler
attempts to re-attach. The MAX_REATTACH_ATTEMPTS=3 cap was supposed to
prevent unbounded respawning, but it was never engaging because the
counter was reset to 0 immediately after each "successful" `open()` —
where success only meant the new PTY was *spawned*, not that it
*survived*. When the underlying tmux session is gone, attach-session
exits ~40 ms after spawn, the exit handler fires again with counter=0,
and the loop runs at ~80 spawns/sec.
Diagnostic data captured on the issue: a single 1.5-second burst
produced 119 spawn↔exit cycles, raising the process's PTY fd count
from ~15 to ~153. Sustained for a few seconds, this exhausts the
macOS system PTY pool (kern.tty.ptmx_max=511), after which nothing
on the system can spawn a new PTY (tmux, VS Code terminal, ao spawn,
etc.) until the leaking process is killed.
Fix:
- Remove the `terminal.reattachAttempts = 0` reset inside the exit
handler.
- Schedule a delayed reset via setTimeout in `open()`, gated on the
closure-captured `pty` reference still being terminal.pty after
REATTACH_RESET_GRACE_MS (5 s).
Effect: tight crash loops cannot reset the counter (PTY exits before
grace expires) and hit MAX_REATTACH_ATTEMPTS within ~150 ms, after
which the server emits "exited" and stops respawning. A long-lived
PTY that crashes hours later still gets a fresh retry budget.
Adds an integration test that reproduces the runaway scenario by
killing the tmux session externally and asserting "exited" arrives
within 2 s. Without this fix the test hangs and times out — the
exact symptom of the bug.
Note: this addresses the dominant runaway behaviour. A separate
~1 fd/cycle leak in node-pty 1.1.0 itself (each spawn opens 3
PTY-class fds in the parent, each exit releases only 2) remains and
will be tracked separately — likely a node-pty upgrade.
Fixes: #1639
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fixup(web): track grace timer + add recovery test (#1639 PR review)
Address two PR review notes on #1640:
1. Greptile P2 — store the grace-period timer handle on ManagedTerminal
and clearTimeout it in the unsubscribe cleanup path. The closure
guard already prevented any incorrect counter reset, so this is
tidiness rather than a bug fix: it eliminates the up-to-5 s window
where the timer's closure kept the killed PTY and evicted terminal
object reachable. Also clears any prior timer when scheduling a new
one in open() so back-to-back re-attaches don't pile up dead closures.
2. Copilot — add an integration test for the recovery path. The
existing runaway test exercises the case where the counter must NOT
reset (PTY crashes inside grace); the new test exercises the case
where the counter MUST reset (PTY survives grace, then crashes
later). Without the grace timer firing correctly, a single transient
blip during startup would permanently consume the retry budget.
Test takes ~5.5 s because it uses the production grace period; per-test
timeout raised to 15 s. Vitest runs tests in parallel so this doesn't
serialize the suite.
Refs: #1639, #1640
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* fix(qa): ISSUE-001 mobile kanban columns
* Fix terminal tmux targeting for session detail views
* fix(web): unblock event loop in tmux-name resolution and drop dup CSS
- mux-websocket: switch resolveExactTmuxName from execFileSync to
promisified execFile so a slow tmux call no longer stalls the
WebSocket message handler. Propagate async through TerminalManager.open
/ subscribe and the pty.onExit reattach path.
- globals.css: remove duplicate `.kanban-board { grid-template-columns:
minmax(0, 1fr) }` rule. The 767px breakpoint already covers it.
Addresses Greptile feedback on PR #1608.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(web): drop defensive tmux-name precheck
The has-session precheck in resolveExactTmuxName was UX padding around
the actual fix (using tmux's `=` exact-match prefix). Without the
precheck:
- attach-session fails naturally on a stale tmux name
- the existing reattach + exit-notify path surfaces the failure
- open()/subscribe() can stay sync — no event-loop concern, no async
cascade through the WS message handler
Net: -64 / +21 in mux-websocket.ts. Reverts the integration test that
relied on the precheck to its pre-PR id-based form.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Prateek <karnalprateek@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* 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>
* 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>
* fix(web): resolve tmux sessions when storageKey is wrapped ({hash}-{projectName})
ao-core names tmux sessions as `{storageKey}-{sessionId}`, where
storageKey can be either a bare 12-hex hash or the legacy wrapped form
`{hash}-{projectName}`. The resolver only handled the bare-hash form, so
the dashboard terminal never attached for any project whose
storageKey includes the project-name segment (the default on 0.3.0).
Fix (closes#1486): resolve the owning storageKey from disk via
`~/.agent-orchestrator/{storageKey}/sessions/{sessionId}`, then ask tmux
for the exact `{storageKey}-{sessionId}` name via `has-session -t =`.
The on-disk record is authoritative, which avoids ambiguous suffix
matches (e.g. looking up `app-1` must not resolve a distinct session
`{hash}-my-app-1`). If multiple storageKeys own the sessionId (rare,
same-sessionId across projects), each candidate is probed so a stale
metadata dir can't shadow the live session of another project. The
tmux-list-sessions fallback still recovers bare-hash sessions when the
on-disk record is absent.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(web): filter readdir to directories only in tmux resolver
readdirSync without withFileTypes includes plain files, so a stray
file matching STORAGE_KEY_PATTERN (e.g. a backup file named
`aabbccddeef0`) would trigger an unnecessary existsSync probe.
Harmless for correctness but an avoidable syscall per attach.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The tmux status bar was explicitly turned off when attaching a PTY
to a tmux session via the web terminal. This hid useful session
context (session name, window list, datetime).
Change status from 'off' to 'on' so users and agents can see the
tmux status bar in both CLI and web terminal views.
Closes#1469
Co-authored-by: AO Bot <ao-bot@composio.dev>
* 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>
Renames all npm package scopes from @composio/* to @aoagents/* and
updates GitHub repo references from ComposioHQ/agent-orchestrator
to aoagents/ao throughout the codebase.
- All package.json names and dependencies
- README badges, links, and install instructions
- Documentation references
- Changeset config
- Source code imports and test files
- Export SessionBroadcaster for testing
- Add 10 tests covering subscribe, broadcast, fetchSnapshot, SSE
reconnection, disconnect, and error isolation
- Covers the major uncovered lines in mux-websocket.ts (66-194)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Mirrors the isolation pattern already used in pty.onData — if one
subscriber's ws.send throws mid-loop the remaining subscribers still
receive the session update.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
tsconfig.server.json sets rootDir:"server" so importing from
../src/lib/mux-protocol.ts violated the boundary and broke the CI
typecheck step. Reverted to local type definitions with a comment
linking to the canonical source.
Also adds tests to bring diff coverage above 80%:
- MuxProvider.test.tsx: 30 tests covering lifecycle, message handling,
and all terminal operations (subscribeTerminal, write, open, close,
resize, reconnect, cleanup)
- api-routes.test.ts: GET /api/sessions/patches coverage (success,
field shape, project filter, error path)
- useSessionEvents.test.ts: mux-path tests (muxSessions snapshot
dispatch, membership-change scheduleRefresh, mux-active SSE bypass,
cleanup on unmount)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add reattachAttempts counter to ManagedTerminal and MAX_REATTACH_ATTEMPTS
constant (3). The onExit handler only re-calls open() while the counter is
below the cap, resets it to 0 on a successful attach, and falls through to
notify exit callbacks once the limit is reached — preventing a crash-loop
where a PTY that exits immediately after spawn triggers infinite re-attaches.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
A throwing callback (e.g. ws.send on a closed socket) would abort the
for-of loop, causing remaining subscribers to miss the data chunk and
skipping the ring buffer update — corrupting replay history for future
reconnections. Wrapping each call in try-catch keeps all subscribers
independent and ensures the buffer update always runs.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
In the ws library, "error" is always followed by "close", so the close
handler's cleanup (clearInterval, unsubscribe sessions, unsubscribe all
terminals) was running twice. Reduced the error handler to just the
console.error log and let close handle cleanup exclusively.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Replace the duplicated ClientMessage/ServerMessage/SessionPatch local
type definitions in mux-websocket.ts with a single import type from
src/lib/mux-protocol.ts — eliminates the risk of the two copies drifting
apart as the protocol evolves
- Add ws.readyState === WebSocket.OPEN guard to the ws.send call inside
the terminal-operation catch block, consistent with all other sends in
the file; prevents a throw that would bubble into the outer catch and
replace the real error with a misleading "Invalid message format"
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- SessionBroadcaster.connect(): capture controller in a local variable and
only clear this.abortController in finally if it still equals the local
controller; prevents a concurrent connect() call's controller being
nullified by an older connect()'s finally block
- Heartbeat: send ws.ping() before incrementing missedPongs so all
MAX_MISSED_PONGS pings are actually transmitted before terminating
(previously terminated after MAX_MISSED_PONGS-1 sent pings)
- Remove TerminalManager.close(): never called by the mux handler (which
uses per-subscriber unsubscribe instead) and subtly broken — it killed
the PTY without clearing subscribers, which would have triggered an
immediate re-attach via onExit
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Move buffer send + subscribe inside !subscriptions.has(id) guard so
reconnecting clients don't receive the history replay twice (once on
first open, once after wsRef is reassigned on reconnect)
- Remove `terminals` field from MuxContextValue and useMemo — it was
populated from a write-only ref and never consumed by any component
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- MuxProvider: handle exited and error terminal messages — exited
removes the terminal from openedTerminalsRef (preventing re-open on
reconnect) and writes a red notice into the xterm stream; error is
logged to console
- MuxProvider: remove dead buffersRef/bufferBytesRef — client-side
ring buffer was never read; the server already delivers history on
open
- mux-websocket: use ws.terminate() instead of ws.close() on heartbeat
timeout — an unresponsive peer won't complete the close handshake,
so terminate() immediately destroys the socket and frees resources
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- mux-websocket: wire up the exited ServerMessage — add exitCallbacks
to ManagedTerminal, pass onExit param through subscribe(), and fire
callbacks when PTY exits and re-attach fails so clients get notified
- DirectTerminal: fix displayStatus to show "error" when there is a
local error (e.g. xterm.js load failure) regardless of mux state
- useSessionEvents: SSE effect's mux early-return path now returns a
cleanup function that clears refreshTimerRef and aborts in-flight
requests on unmount, preventing post-unmount dispatch calls
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- DirectTerminal: remove stale muxStatus read from xterm setup effect
(muxStatus was captured at mount, leaving reload button permanently
disabled); reload button now checks muxStatus directly on each render
- TerminalManager: kill PTY and delete map entry when last subscriber
unsubscribes, preventing orphaned node-pty processes when all mux
clients disconnect
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- MuxProvider: assign wsRef.current immediately after construction so
cleanup can close a WebSocket that is still in CONNECTING state;
add isDestroyedRef to prevent the close handler from scheduling
reconnects after the component unmounts
- mux-websocket: client "close" message now only unsubscribes that
client — removed terminalManager.close() which killed the shared PTY
for every other connected client
- useSessionEvents: mux effect cleanup no longer clears the debounce
timer; only aborts in-flight requests, preventing rapid mux snapshots
from starving the membership-change refresh
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Use native WS ping frames for heartbeat so idle browser clients are not
incorrectly disconnected (browser auto-responds to native ping with pong)
- Pass runtime config to buildMuxWsUrl() so dynamic port/proxy path set via
TERMINAL_WS_PATH env var is actually used (was fetched but never consumed)
- Delay initial WS connect until runtime config fetch resolves, preventing
race condition on first load
- shutdown() now terminates all mux clients and closes the WSS, preventing
orphaned PTY processes on restart
- ws 'error' handler now unsubscribes terminal callbacks alongside session
subscription to prevent leaks in edge cases where 'close' does not follow
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace three separate real-time channels (per-terminal WS, SSE, HTTP poll)
with a single persistent multiplexed WebSocket at /mux.
Architecture:
- Browser ↔ MuxProvider owns one WS connection per tab (/mux)
- Terminal I/O, resize, open/close all flow over mux channels
- Session status patches delivered via a shared SSE relay:
mux server subscribes once to Next.js /api/events (SSE) and
broadcasts to all connected browser clients — no per-client polling
- Manual WS upgrade routing fixes ws library limitation with multiple
WebSocketServer instances on the same HTTP server
Remove:
- terminal-websocket.ts (legacy ttyd-based per-session server, port 14800)
- Per-terminal WebSocket connections from DirectTerminal
- Per-client 5 s HTTP polling for session patches
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Implements backend multiplexed WebSocket server on /mux endpoint:
- Add mux-protocol.ts with ClientMessage and ServerMessage types
- Create TerminalManager class for managing PTY processes independently
- Implement mux-websocket.ts with attachMuxWebSocket() function
- Wire mux server into existing direct-terminal-ws server
- Support multiple terminals over single persistent WebSocket connection
- Implement 50KB ring buffer for background terminal output
- Add heartbeat and reconnection logic
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- checkTmux() now attempts auto-install (brew/apt/dnf) before erroring
- Config generation uses runtime: "process" when tmux unavailable
- start.ts tries auto-install during config creation, falls back gracefully
- Fix restart bookkeeping in start-all.ts: slot-based tracking prevents
duplicate children entries that broke cleanup countdown
- Updated design doc reflecting all fixes and review feedback
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Replace CPU-burning spin loops in running-state.ts with async setTimeout
and jittered backoff; make all exports async
- Fix TOCTOU race in acquireLock by extracting tryAcquire helper with
retry loop instead of force-remove-and-retry-once
- Hoist dynamic imports in addProjectToConfig/choice-2 to static imports
- Add try/finally around readline in detectAgentRuntime
- Validate session prefix uniqueness in "Start new orchestrator" menu
- Add ConfigNotFoundError class to @composio/ao-core, replace fragile
string matching in ao start with instanceof check
- Fix setup.sh to recommend `ao start` instead of deprecated `ao init`
- Fix start-all.ts: resolve next binary with fallback, wait for children
on SIGTERM instead of immediate process.exit
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Rewrite init.test.ts to match simplified deprecated init command
(old tests referenced removed --output, --auto, --smart flags)
- Add eslint-disable for consistent-type-imports in direct-terminal-ws.ts
(node-pty is optional, must use import() type annotations)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Publish @composio/ao-web dashboard as npm package (removed private flag,
added files field, production entry point, node-pty made optional)
- CLI auto-detects dev vs production mode for dashboard startup
- Use local next binary instead of npx in production start-all.ts
- findWebDir() throws with install-specific guidance instead of returning
broken path
- Fix CI-silent failure: setup.sh and ao-update.sh exit 1 on non-interactive
npm link failure
- Deduplicate detectDefaultBranch into shared cli/lib/git-utils.ts
- Add EACCES permission guidance to README.md and SETUP.md
- Move resolveProjectIdForSessionId to @composio/ao-core
- Update design doc with problems #8-13, changes #9-12, known limitations
section, and expanded test plan
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: add end-to-end observability across core, web, and terminal
Instrument lifecycle/API/websocket flows with correlation-aware metrics and operator health surfaces so the system can self-diagnose and escalate failures.
* fix: realign session restore set and unblock claim PR typecheck
* fix(web): restore project-filtered sessions route after main merge
* fix(core): remove unused orchestrator import to unblock lint
* fix(core): always record lifecycle poll failures and remove dead review branches
* fix(web): harden websocket metrics and reuse SSE observers
* fix(web): preserve primary session API errors when services bootstrap fails
* fix(web): use full orchestrator config type in SSE observer helper
* fix(web): restore project-scoped SSE and guard observability error paths
* fix(web): address remaining Bugbot review gaps
* fix(web): align SSE project attribution and share session project resolver
* fix(web): require request arg for SSE route and align tests
* fix(web): record websocket error disconnects as failures
* fix(web): use path alias for session project resolver import
* fix(web): include active connection count in disconnect metrics
* docs: add design research artifacts — briefs, token reference, screenshots
Comprehensive design research package for the ao dashboard, session
detail page, and orchestrator terminal. Produced via competitive analysis
of 14 products (Linear, Vercel, Railway, Fly.io, Inngest, WandB, LangSmith,
Supabase, and more) + Playwright CSS extraction from live sites + full
codebase audit.
Artifacts:
- docs/design/design-brief.md Main design brief (v2, Playwright-updated)
- docs/design/session-detail-design-brief.md /sessions/[id] design spec
- docs/design/orchestrator-terminal-design-brief.md Orchestrator page spec
- docs/design/token-reference.css Drop-in CSS replacement for globals.css
- docs/design/competitive-analysis-raw.md Raw research notes, all 14 sites
- docs/design/design-brief-v1.md Original text-only brief (pre-Playwright)
- docs/design/README.md Index + research methods summary
- docs/design/screenshots/linear-homepage.png Playwright-captured screenshot
- docs/design/screenshots/railway-homepage.png Playwright-captured screenshot
Key findings:
- Linear CSS token values verified via Playwright (body bg #08090A, accent
#7070FF, Berkeley Mono monospace, type scale, radius, transitions)
- Recommended palette: #0C0C11 base (blue-cast dark vs current GitHub #0d1117)
- Highest-impact change: load Inter Variable via next/font/google
- Orchestrator terminal needs visual differentiation (violet accent, status strip)
- token-reference.css is ready to drop into packages/web/src/app/globals.css
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(web): redesign dashboard, session detail, and orchestrator terminal
Implements a cohesive dense dark-mode design system across all three main views.
- New color token palette: #0c0c11 base, #141419 surface, #1c1c25 elevated
- Accent blue #5b7ef8, status semantics (ready/error/attention/working/idle/done)
- Violet accent #a371f7 reserved for orchestrator
- Inter Variable + JetBrains Mono loaded via next/font with CSS variables
- activity-pulse keyframe for live agent dots
- AttentionZone header: dot + label + flex divider + count pill + chevron
- Sessions laid out in responsive 1→2→3 column grid
- Solid green merge button (translateY hover), no confirm() dialog
- Breadcrumb nav: ← Agent Orchestrator / {session-id} [orchestrator badge]
- CSS 8×8px activity dot with pulse animation replaces emoji labels
- Merge-ready state: green-bordered banner with checkmark icon
- Orchestrator sessions show zone counts strip (merge/respond/review counts)
- xterm.js dark theme (#0a0a0f bg, #d4d4d8 fg, full 16-color ANSI palette)
- variant prop: "agent" (blue cursor) vs "orchestrator" (violet cursor)
- Dynamic height prop instead of fixed 600px; fullscreen toggle with SVG icons
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* refactor(web): strip rainbow stats, clean header, IBM Plex Sans typography
- Replace Inter with IBM Plex Sans (technical tool aesthetic, distinctive numerics)
- Replace 4-color big-number stats bar with a single compact inline status line
in the header: "35 sessions · 1 working · 9 PRs" — no decorative colors
- Remove the two-tone "Agent (blue) Orchestrator (white)" title — just "Orchestrator"
- Remove ClientTimestamp (useless) — replaced by orchestrator nav link
- Zone headers: colored dot only (semantic), neutral uppercase label, plain count
— removes the rainbow-colored label text that read as a widget template
- Add subtle radial gradient glow at top of page for depth
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(web): kanban layout, amber accent, full-width, bigger stats
- Switch accent from blue (#5b7ef8) to amber/gold (#d18616) throughout
- Replace grid layout with horizontal Kanban columns for active zones
(merge, respond, review, pending, working), Done stays full-width below
- Remove max-w-[1100px] constraint — full viewport width
- Header stats numbers 20px bold (was 12px), orchestrator link is now a
visible bordered button
- AttentionZone gains variant="column" for Kanban mode (compact header
with count pill, vertical card stack)
- Update all hardcoded rgba(91,126,248,...) in SessionCard to amber
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(web): layout, alert sizing, column order, button feel
- Kanban column order: working→pending→review→respond→merge
(left = in progress, right = ready to ship)
- Columns use flex-1 min-w-[200px] to fill available width
instead of fixed 260px leaving half the page empty
- Alert badges: inline-flex wrapper prevents stretching to full
row width when wrapping
- Terminal button: add bg-subtle fill so it reads as a button
- PR number (#91): remove opaque pill background, now plain
amber text link — clearly a hyperlink
- Merge PR button: pt-0.5 spacer above the action area
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(web): don't cache rate-limited partial PR data
When GitHub rate limits fire, enrichSessionPR was caching the
bad partial data (0 additions, CI failing) for 60 seconds, causing
the dashboard to show incorrect data for the full TTL window.
- Skip cache write when majority of API calls failed
- Downgrade console.error → console.warn (this is handled/expected)
The next page refresh will retry live API calls, so data recovers
as soon as the rate limit window resets.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(web): graceful GitHub API rate limit handling in UI
When the GitHub plugin hits rate limits, the dashboard now:
- Shows a single amber banner: "GitHub API rate limited — PR data
(CI status, review state, sizes) may be stale. Will retry on
next refresh."
- Hides CI badge, review decision, and size pill on PR cards
(they'd show wrong values: +0 -0 XS, CI failing)
- Shows a subtle "⚠ PR data rate limited" note on affected cards
instead of misleading alert badges
- Skips CI/review/conflict-based attention zone classification
for rate-limited PRs (prevents sessions moving to Review due
to phantom "CI failing" from the fallback value)
- Doesn't cache partial rate-limited data so next refresh retries
live API calls as soon as the rate limit window resets
What still works when rate limited:
- Session ID, title, branch, PR number/link
- Session activity status (working/spawning/etc.)
- Merge button if mergeability was already cached
- Restore/terminate/send actions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(web): dashboard redesign — glassmorphism, Kanban, rate-limit handling, perf fix
Design:
- Kanban layout: active zones as flex columns (working→pending→review→respond→merge),
Done as full-width grid below
- GitHub dark color palette (main's tokens) with glassmorphic card surfaces
(rgba bg + backdrop-blur) and subtle blue/violet body gradient
- Activity state shown as labeled pill (● active / ● idle etc.) instead of bare dot
- Session card: title on its own row, larger font, inline-flex alert badges (no stretch)
- PR number rendered as plain accent link, not a blue pill badge
- Terminal button has background fill to feel like a button
- Info circle icon replaces alarming warning triangle for rate-limit indicators
- "1 working" → "1 active" in header stats
- PR table constrained to max-w-[900px] and centered
- Orchestrator session no longer uses purple accent
Rate limiting:
- isPRRateLimited() helper; getAttentionLevel() skips PR classification when limited
- Rate-limited banner in Dashboard; suppressed CI/size/review badges in PRStatus
- SessionCard shows subtle "PR data rate limited" indicator; getAlerts() returns []
- serialize.ts: rate-limited enrichment results cached for 5 min (not 60s) to stop
retrying 168 failing API calls every minute
Performance:
- page.tsx: 4s hard timeout on PR enrichment — serves stale data fast instead of
blocking SSR for 75s under rate limiting
- cache.ts: TTLCache.set() accepts optional ttl override for per-entry control
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: suppress stale size/CI/review in PR table when rate limited
PRTableRow now shows "—" for size, CI, and review columns when GitHub
API is rate limited, matching the card view which already hides these.
Prevents misleading "+0 -0 XS" size and "needs review" labels from the
default fallback values.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat: 3D card effect with depth shadow and top-edge shine
Cards now clearly pop against the dark background:
- Solid gradient bg (rgba(28,36,47) → rgba(18,23,31)) instead of
near-invisible rgba(22,27,34,0.8) surface
- Layered box-shadow: contact shadow + diffuse depth + inset top highlight
that simulates light hitting the card's top edge (the "shine")
- Hover: card lifts 2px with deeper shadow
- Merge-ready: green-tinted bg with green ambient glow + stronger lift on hover
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: restore text legibility inside session cards
The darker solid card gradient made muted/secondary text nearly
invisible — #484f58 (text-muted) had only ~2:1 contrast on the
new card bg. Override the color tokens locally within .session-card
to GitHub's established dark-mode legibility values:
--color-text-muted: #484f58 → #656d76 (3.8:1 on card bg)
--color-text-secondary: #7d8590 → #8b949e (6.2:1 on card bg)
--color-text-tertiary: #484f58 → #656d76
Scoped to .session-card so the rest of the UI is unchanged.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: address bugbot comments — fonts, review zone, ActivityDot, orchestrator btn
- layout.tsx: add IBM Plex Sans weight 700 (was missing, font-bold falling
back to 600)
- DirectTerminal.tsx: use "IBM Plex Mono" instead of unloaded "JetBrains Mono"
- SessionDetail.tsx: add review zone to OrchestratorStatusStrip (was omitted,
sessions with CI failures were invisible in the strip)
- ActivityDot.tsx: extract shared component, remove duplicate implementations
in SessionCard.tsx and SessionDetail.tsx
- Dashboard.tsx: redesign orchestrator button with 3D glass style matching
card aesthetic (blue-tinted bg, depth shadow, hover lift)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(web): lint — eqeqeq, duplicate import, unused var
- ActivityDot.tsx: != → !== (eqeqeq rule)
- PRStatus.tsx: merge duplicate @/lib/types imports into one
- SessionCard.tsx: remove unused activityIcon import
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(web): elevate session detail + orchestrator page design
- Nav: glass backdrop-blur effect with chevron back link
- Header: detail-card 3D treatment with left-border accent keyed to activity color
- Meta chips: bordered pill style with subtle bg instead of flat text
- Status tag: pill badge for status instead of plain text
- PR card: detail-card 3D treatment, border-color reflects PR state
- PR merged badge: purple pill instead of gray text
- Unresolved count: red pill badge in section header
- Blockers section: renamed "Issues" → "Blockers"
- Terminal section: colored bar indicator instead of plain label
- Orchestrator status strip: total agent count + per-zone colored pills
- globals.css: add .nav-glass and .detail-card classes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(web): fetchZoneCounts parses body.sessions, delayed 2s to avoid contention
The /api/sessions endpoint returns `{ sessions: [...] }` not a bare array.
fetchZoneCounts was treating the whole response object as an array, so
zone counts were always zero on the orchestrator detail page.
Also delays the initial fetchZoneCounts call by 2s so it doesn't contend
with the session fetch on page load (both hit /api/sessions which is slow
when GitHub enrichment is running).
Also includes: Playwright kill-Chrome-for-Testing tip in CLAUDE.md,
toned-down detail-card shadow in globals.css.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* perf+test(web): cache-first PR enrichment, skip exited sessions, fix component tests
Performance improvements:
- enrichSessionPR() now accepts cacheOnly option and returns boolean
- /api/sessions/[id]: serve from cache immediately, only block on first load
- /api/sessions: skip PR enrichment for EXITED sessions (no longer changing)
- cache: increase default TTL from 60s to 5 minutes
Test fixes (match redesigned SessionCard + AttentionZone):
- "restore session" (header) → "restore"; expanded panel still shows "restore session"
- "merge PR #N" → "Merge PR #N" (capital M)
- "CI status unknown" → "CI unknown"
- "ask to fix CI" / "ask to fix CI" → "ask to fix"
- "terminate session" → "terminate"
- Zone labels: RESPOND/WORKING/DONE → Respond/Working/Done (CSS uppercase is visual only)
- "working" zone no longer collapsed by default; collapse tests now use "done" zone
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(core): ActivityDetection with timestamp propagation
- Add ActivityDetection interface { state, timestamp? } to types.ts
- Agent getActivityState() returns ActivityDetection | null instead of
ActivityState | null, allowing timestamp from JSONL mtime to propagate
- session-manager updates session.lastActivityAt when detected.timestamp
is more recent — fixes "active 22h ago" showing stale timestamps
- Update all agent plugins (claude-code, aider, codex, opencode) to
return ActivityDetection objects
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(web): dismissible rate limit banner + 60min rate-limit cache TTL
- Add X dismiss button to GitHub API rate limit banner in Dashboard.tsx
so it can be closed during demos
- Extend rate-limited PR cache TTL from 5min to 60min — GitHub GraphQL
rate limits reset hourly, no point retrying every 5 minutes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(web): address Cursor Bugbot review comments on PR #125
- Dashboard StatusLine: active sessions count now uses var(--color-status-working)
(blue) instead of neutral text color, matching the design system semantics
- SessionCard: isReadyToMerge now guards against rate-limited state — a card
with stale cached mergeability data won't show green merge-ready styling
- DirectTerminal: add `variant` to useEffect dependency array (was missing,
causing stale cursor/selection colors if variant changed after mount)
- agent-aider: include `timestamp: chatMtime` in all ActivityDetection returns,
matching the pattern used by agent-claude-code (enables accurate lastActivityAt)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(ci): resolve lint, typecheck, and test failures
Lint:
- Remove unused parseJsonlFile function (superseded by parseJsonlFileTail)
- Remove dead lastLogModified stat() call in getSessionInfo (field was
removed from AgentSessionInfo but the filesystem read was left behind)
Typecheck + Tests (ActivityDetection):
- session-manager.test.ts: update mocks to return { state: "active" } /
{ state: "idle" } instead of bare strings — getActivityState() returns
ActivityDetection | null, not ActivityState | null
- integration tests (aider, claude-code, codex, opencode): update imports
from ActivityState → ActivityDetection, variable types, comparisons
(activityState?.state !== "exited"), and assertions (?.state).toBe()
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: parseJsonlFileTail uses readFile for small files; enrich exited sessions with PRs
- parseJsonlFileTail now calls stat() then readFile() for files smaller than
maxBytes, falling back to open()/handle.read() only for large files. This
fixes the test infrastructure (which mocks readFile but not open) and also
fixes a scope bug where `offset` was declared inside an inner try block but
referenced outside both try blocks.
- Math.max(0, NaN) returns NaN not 0, so size must default to 0 when stat
returns a mock without a size field: `const { size = 0 } = await stat(...)`.
- Update activity-detection.test.ts: getActivityState() now returns
ActivityDetection objects, so tests use (await ...)?.state comparisons.
- Remove stale lastLogModified test (field was removed from AgentSessionInfo).
- Remove EXITED skip guard from api/sessions/route.ts: exited sessions can
still have open, merge-ready PRs that need enrichment on the dashboard.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: comprehensive code review fixes — tests, timestamps, UI correctness
Address gaps identified in code review of the ActivityDetection PR:
Core / Session Manager:
- Add `timestamp` to all `{ state: "exited" }` returns in all 4 agent plugins
(claude-code, aider, codex, opencode) using consistent `exitedAt = new Date()` pattern
- Add 2 new session-manager tests: timestamp propagation when detection timestamp
is newer, and no-downgrade when detection timestamp is older
- Fix `parseJsonlFileTail` lint error: remove useless `= 0` initializer (value was
always overwritten before use; catch block returns early)
Web package — tests:
- Fix 3 `api-routes.test.ts` failures: `sessionsGET()` needs a Request object since
the route reads `request.url` for `?active=true` query param
- Fix `serialize.test.ts` rate-limit test: spy on `console.warn` (what the code uses)
not `console.error`
- Add 5 `ActivityDot` component tests covering all activity states, unknown states,
null activity, and dotOnly mode
Web package — UI correctness:
- Fix `relativeTime()` in SessionDetail to guard against invalid/empty ISO strings
- Fix timer Map leak: add `timersRef.current.clear()` in cleanup effect after forEach
- Add `encodeURIComponent` to sessionId in message fetch URL
Server — race condition fix:
- Guard `activeSessions.delete` in pty.onExit, ws.on("close"), and ws.on("error")
against stale handlers deleting a newly-registered session with the same ID.
Fixes flaky integration test where afterEach's pty.kill() fired asynchronously
after the next test had already set up a new session with the same session ID.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(web): narrow PREnrichmentData types to eliminate unsafe casts in serialize
PREnrichmentData.ciStatus and .reviewDecision were typed as string,
requiring unsafe `as` casts when reading from cache into DashboardPR.
Narrow them to the same literal union types used by DashboardPR, making
the casts unnecessary. Also narrow ciChecks[].status to match CoreCICheck.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat: make terminal server ports configurable to fix multi-dashboard EADDRINUSE
When multiple ao dashboards run simultaneously (e.g., ao on port 3000,
integrator on port 3002), both try to start terminal WebSocket servers
on hardcoded ports 3001/3003, causing EADDRINUSE. Add terminalPort and
directTerminalPort to config schema so each instance can use unique ports.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: use optional() instead of default() for terminal port schema
Zod .default() always fills in the value, making config.terminalPort
never undefined and the env var fallback in buildDashboardEnv dead code.
Switch to .optional() so the priority chain works correctly:
config value > TERMINAL_PORT env var > hardcoded default.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: move terminal server defaults from 3001/3003 to 14800/14801
The 3000-3009 range is the most contested in dev tooling (Next.js
auto-increments, BrowserSync, Grafana, Rails, Express all default to
3000+). Port 14800-14899 has zero IANA registrations, zero known dev
tool conflicts, and is safely below OS ephemeral ranges.
Updated all hardcoded fallbacks, .env.local.example, docker-compose
port mappings, and documentation.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: auto-detect available terminal ports for zero-config multi-dashboard
When no terminal ports are configured (no config, no env vars),
buildDashboardEnv now probes for an available port pair starting at
14800. The second `ao start` automatically gets 14802/14803 (or the
next free pair), eliminating EADDRINUSE without any user configuration.
Port detection scans in steps of 2 to keep the pair consecutive.
Explicit config/env values bypass auto-detection entirely.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: update onboarding test to use new default terminal port (14801)
The onboarding integration test had port 3003 hardcoded for the
WebSocket health check. Updated to read from DIRECT_TERMINAL_PORT
env var with 14801 as the default, matching the new port defaults.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* fix: terminal servers compatible with hash-based architecture
The terminal WebSocket servers (direct-terminal-ws and terminal-websocket)
used config.dataDir to validate sessions, which no longer exists in the
hash-based architecture. Also fixed node-pty failing to find tmux via
posix_spawnp.
Changes:
- Remove config.dataDir dependency, validate via `tmux has-session` instead
- Add resolveTmuxSession() to map user-facing IDs (ao-15) to hash-prefixed
tmux names (8474d6f29887-ao-15)
- Use explicit tmux path discovery (findTmux) since node-pty's posix_spawnp
doesn't reliably inherit PATH
- Include /opt/homebrew/bin in fallback PATH for macOS ARM
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: add session resolution to ttyd server and use exact tmux matching
- Add findTmux() and resolveTmuxSession() to terminal-websocket.ts
(previously only in direct-terminal-ws.ts), fixing hash-prefixed
session lookup for the ttyd-based terminal server
- Use tmux exact match prefix (=sessionId) in has-session checks
to prevent ao-1 from matching ao-15 via prefix matching
- Add server compatibility tests that verify both servers handle
hash-based architecture correctly (14 tests)
- Include server/ in tsconfig and vitest config for typecheck coverage
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor: extract tmux-utils and add proper unit tests
- Extract findTmux(), resolveTmuxSession(), validateSessionId() into
shared server/tmux-utils.ts — eliminates duplication between
direct-terminal-ws.ts and terminal-websocket.ts
- Add 20 real unit tests with injected mocks that test actual behavior:
- findTmux: candidate priority, fallback to bare name
- resolveTmuxSession: exact match, hash-prefix resolution,
= prefix for preventing tmux prefix matching (ao-1 vs ao-15),
null when no session found, tmux not running
- validateSessionId: path traversal, shell injection, whitespace
- Slim down server-compatibility.test.ts to 10 structural checks
(imports tmux-utils, no loadConfig, no config.dataDir, no existsSync)
Total: 30 tests — all pass on fix branch, 8 fail on main
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* test: add real integration tests for direct-terminal-ws
- Refactor direct-terminal-ws to export createDirectTerminalServer()
factory so tests can control server lifecycle without side effects
- Add 10 integration tests that create real tmux sessions, start the
real server, connect via WebSocket, and verify the full flow:
- Health endpoint returns 200
- Missing session parameter → close 1008
- Path traversal in session ID → close 1008
- Shell injection in session ID → close 1008
- Nonexistent tmux session → close 1008
- Real tmux session → connects and receives terminal output
- Hash-prefixed session resolution works end-to-end
- Can send input and receive echoed output
- Resize messages don't crash the connection
- Unknown HTTP path → 404
- Tests create/destroy tmux sessions in beforeAll/afterAll
- Server runs on random port (port 0) to avoid conflicts
- Total test suite: 40 tests (20 unit + 10 compatibility + 10 integration)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* ci: add web server tests to CI pipeline
The web package was explicitly excluded from CI test runs
(pnpm -r --filter '!@composio/ao-web' test). Add a test-web job
that installs tmux, starts the tmux server, and runs the web
package tests (unit + integration).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: address CI failures and bugbot review comments
- Fix lint: replace require() with ESM import in integration test
- Fix base-path mismatch: ttyd now uses user-facing sessionId for
--base-path/URL and actual tmux name for attach-session
- Fix bare "tmux" in ttyd spawn args: use TMUX constant (full path)
- Scope CI test-web job to server/__tests__/ to avoid pre-existing
failures in src/__tests__/
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* test: comprehensive unit and integration test coverage
Unit tests (77): validateSessionId covers all injection vectors (shell,
path traversal, command substitution, special chars, unicode, control
chars), findTmux covers all candidate paths and error types,
resolveTmuxSession covers exact match, hash-prefix resolution, suffix
matching precision, edge cases (single char, long lists, multiple
hyphens, different tmux paths).
Integration tests (45): health endpoint lifecycle (active count tracks
connections/disconnections), HTTP routing (404s for all non-health
paths), WebSocket validation (11 injection/traversal vectors), terminal
connection (resize, multi-resize, invalid JSON, non-resize JSON),
hash-prefixed resolution (suffix match, command passthrough, session key
tracking, cross-match prevention), terminal I/O (Ctrl-C, Tab, Enter,
empty messages, rapid keystrokes, multi-line), connection lifecycle
(cleanup, rapid connect/disconnect, error recovery), server creation
(independent instances).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: validate 12-char hex prefix in hash-prefixed session resolution
The previous endsWith("-{sessionId}") suffix match was ambiguous:
"hash-my-app-1" would falsely match a lookup for "app-1". Now validates
that the prefix matches the exact format generated by generateConfigHash
(12-char lowercase hex) before comparing the remainder.
Added unit tests for the ambiguity case and invalid prefix formats.
Updated integration test session names to use proper 12-char hex prefixes.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* feat: implement seamless onboarding with enhanced documentation
- Add comprehensive README.md (18KB) with quick start, core concepts, and FAQ
- Add detailed SETUP.md (16.5KB) with prerequisites, integration guides, and troubleshooting
- Add examples/ directory with 5 ready-to-use config templates:
- simple-github.yaml: Minimal GitHub setup
- linear-team.yaml: Linear integration
- multi-project.yaml: Multiple repos
- auto-merge.yaml: Aggressive automation
- codex-integration.yaml: Using Codex agent
- Add environment detection (git repo, remote, branch, auth status)
- Auto-fill prompts with smart defaults from detected environment
- Add prerequisite validation (git, tmux, gh CLI)
- Show actionable next steps and warnings
- Parse owner/repo from git remote automatically
- Detect LINEAR_API_KEY and SLACK_WEBHOOK_URL in environment
- Prompt for Linear team ID when Linear tracker selected
- Format all files with Prettier for consistency
Reduces onboarding time from 30+ minutes to ~5 minutes:
1. Install CLI: `npm install -g @composio/ao-cli`
2. Run init: `ao init` (auto-detects everything)
3. Spawn agent: `ao spawn my-project ISSUE-123`
Users no longer need to:
- Manually parse git remote URLs
- Look up current branch names
- Remember YAML syntax
- Search for Linear team IDs
- Debug missing prerequisites
- ✅ pnpm build - All packages compile
- ✅ pnpm typecheck - No TypeScript errors
- ✅ pnpm lint - No new linting issues
- ✅ pnpm format - All files formatted
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
* docs: update installation instructions to reflect npm not yet published
Package is not published to npm yet, so users must build from source.
Updated README.md and SETUP.md to:
- Make 'build from source' the primary installation method
- Add note that npm publishing is coming soon
- Include pnpm as a prerequisite
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
* feat: add ao init --auto --smart for zero-config setup
Implements intelligent config generation with project type detection.
## What's New
### ao init --auto
- Zero prompts - auto-generates config with smart defaults
- Detects: git repo, remote, branch, languages, frameworks, tools
- Generates project-specific agentRules based on detected tech stack
### Project Detection
- Languages: TypeScript, JavaScript, Python, Go, Rust
- Frameworks: React, Next.js, Vue, Express, FastAPI, Django, Flask
- Tools: pnpm workspaces, test frameworks
- Package managers: pnpm, yarn, npm
### Rule Templates
Created templates for:
- base.md - Universal best practices
- typescript.md - TS strict mode, ESM, type imports
- javascript.md - Modern ES6+ patterns
- react.md - Hooks, composition, best practices
- nextjs.md - App Router, Server Components
- python.md - Type hints, PEP 8
- go.md - Error handling, defer patterns
- pnpm-workspaces.md - Monorepo commands
### Example Output
```bash
ao init --auto
# Detects:
# ✓ TypeScript + pnpm workspaces
# ✓ React + Next.js
# ✓ Vitest
# Generates:
agentRules: |
Always run tests before pushing.
Use TypeScript strict mode.
Use ESM modules with .js extensions.
Use React best practices (hooks, composition).
Before pushing: pnpm build && pnpm typecheck && pnpm lint && pnpm test
```
## Benefits
- **5 seconds** instead of 5 minutes
- **Zero config knowledge** required
- **Context-aware rules** tailored to your stack
- **Still customizable** - edit the generated config
## Future: --smart (AI-powered)
Flag added but not yet implemented. Will use Claude Code to:
- Analyze CLAUDE.md, CONTRIBUTING.md
- Read CI/CD config
- Generate custom rules based on project patterns
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
* fix: detect repo default branch instead of current branch
Fixes Bugbot issue: "Current branch wrongly suggested as default base branch"
## Problem
detectEnvironment was using `git branch --show-current` to suggest
defaultBranch in the config. If a user ran `ao init` while on a feature
branch like `feat/my-work`, the wizard would suggest that feature branch
as the default, causing agents to branch from the wrong base.
## Solution
Added detectDefaultBranch() function with 3 fallback methods:
1. git symbolic-ref refs/remotes/origin/HEAD (most reliable)
2. GitHub API via gh CLI (if ownerRepo known)
3. Check common branch names: main, master, next, develop
Now EnvironmentInfo tracks both:
- currentBranch: The checked-out branch (for display only)
- defaultBranch: The repo's base branch (for config)
## Testing
Tested on feat/seamless-onboarding branch:
- Current branch: feat/seamless-onboarding (displayed)
- Default branch: main (correctly detected for config)
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
* fix: prevent duplicate framework detection in Python projects
Fixes Bugbot issue: "Duplicate frameworks when multiple Python config files exist"
## Problem
When both requirements.txt and pyproject.toml exist and mention the same
framework (e.g., FastAPI), the detection loop added it to the frameworks
array twice, causing duplicate rules in the generated config.
## Solution
Added addFramework() helper that checks if framework already exists before
adding to the array. Also prevents pytest from being set multiple times as
testFramework.
## Testing
Verified with test repo containing both files with FastAPI:
- Before: Would add 'fastapi' twice
- After: Only adds 'fastapi' once ✓
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
* fix: address Bugbot review comments
- Remove redundant conditional in --smart flag (both branches were identical)
- Include templates directory in npm package files
* fix: add existence check for base.md template file
Add existsSync guard before reading base.md to handle missing templates gracefully, consistent with other template file reads.
* fix: use direct tool invocation instead of which command
Replace 'which' with direct tool invocation (tmux -V, gh --version)
for better portability on minimal Linux systems where 'which' may
not be installed.
* fix: address Bugbot review comments
- Simplify gh auth status check to rely on exit code instead of output string
- Remove async from synchronous functions (detectProjectType, generateRulesFromTemplates)
* feat: add setup script for one-command installation
Add scripts/setup.sh that:
- Installs pnpm if not present
- Installs dependencies
- Builds all packages
- Links CLI globally
Updated README with simplified setup instructions using the script.
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
* fix: correct npm link command in setup script
Remove incorrect -g flag from npm link command. The correct syntax is to cd into the package directory and run npm link without flags.
* fix: address Bugbot review comments on init command
- Validate --smart flag requires --auto (prevents silent ignore)
- Fix path validation to check user-specified path (not CWD)
These fixes address medium and low severity issues found by Cursor Bugbot
in PR #66 review.
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
* docs: add DirectTerminal troubleshooting and fix setup script
- Add TROUBLESHOOTING.md documenting node-pty posix_spawnp error
- Update setup.sh to rebuild node-pty from source (fixes DirectTerminal)
- Ensures seamless onboarding with working terminal out-of-the-box
Resolves DirectTerminal WebSocket failures from incompatible prebuilt binaries.
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
* fix: resolve variable scope issue in init command validation
- Move path variable outside if block to fix TypeScript scope error
- Only validate path existence if projectId is provided
- Use inline tilde expansion instead of missing expandHome import
Fixes build error that prevented setup.sh from completing.
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
* fix: automate node-pty rebuild to eliminate terminal issues
- Add postinstall hook to automatically rebuild node-pty after pnpm install
- Create scripts/rebuild-node-pty.js for automatic rebuild with error handling
- Remove manual node-pty rebuild from setup.sh (now automatic)
This ensures DirectTerminal works correctly on every installation without
manual intervention. Fixes posix_spawnp errors from incompatible prebuilt
binaries across different systems and installations.
Resolves issue where users would encounter blank terminals after setup.
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
* docs: update TROUBLESHOOTING with automatic node-pty rebuild
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
* docs: add comprehensive README with quick start guide
- 3-line magical setup: clone → setup → init → start
- Architecture overview with plugin slots table
- Usage examples and auto-reaction configuration
- Links to detailed docs (SETUP.md, TROUBLESHOOTING.md, examples/)
- Philosophy: push not pull, amplify judgment
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
* fix: resolve ESLint errors in rebuild-node-pty script
- Add scripts directory configuration to eslint.config.js
- Configure Node.js globals (console, process) for scripts
- Remove unused error variable from catch block
Fixes lint CI failure.
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
* fix: warn when auto mode uses placeholder repo value
- Detect when 'owner/repo' placeholder is used in --auto mode
- Show warning: 'Could not detect GitHub repository'
- Update next steps to emphasize editing config when placeholder used
- Prevents silent failures when spawning agents with invalid repo
Addresses Bugbot review comment about silent placeholder values.
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
* feat: implement DirectTerminal with XDA clipboard support
Fixes clipboard functionality in web terminal without requiring iTerm2 attachment.
## Problem
Browser clipboard (Cmd+C/Ctrl+C) only worked when an iTerm2 client was attached
to the tmux session. Users had to keep iTerm2 tabs open in the background for
clipboard to work in the web dashboard.
## Root Cause
- tmux uses XDA (Extended Device Attributes) queries to detect terminal capabilities
- xterm.js doesn't implement XDA (marked as TODO in their codebase)
- Without XDA response, tmux doesn't enable clipboard support (TTYC_MS)
- iTerm2 responds to XDA, enabling clipboard for the entire session
## Solution
Implemented DirectTerminal component with custom XDA handler:
- Registers CSI > q handler using xterm.js parser API
- Responds with XTerm identification: DCS > | XTerm(370) ST
- tmux detects "XTerm(" and enables clipboard capability
- OSC 52 sequences flow: tmux → WebSocket → xterm.js → navigator.clipboard
## Changes
- Add DirectTerminal component with XDA handler
- Add direct-terminal-ws WebSocket server using node-pty
- Replace Terminal with DirectTerminal in SessionDetail
- Add comprehensive test page at /dev/terminal-test documenting:
- Root cause analysis
- Implementation details
- Node version requirements (requires Node 20.x due to node-pty)
- Debugging journey and lessons learned
- Fix ttyd port recycling to prevent EADDRINUSE errors
## Testing
Visit http://localhost:3000/dev/terminal-test for side-by-side comparison
and complete documentation.
Investigation time: 12+ hours (Feb 15-16, 2026)
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
* fix: lint and typecheck errors
- Remove unused expectedWidth variable in DirectTerminal
- Add test files to eslint ignores
* fix: wrap useSearchParams in Suspense boundary
- Add Suspense wrapper to /dev/terminal-test page
- Add Suspense wrapper to /test-direct page
- Fixes Next.js build prerender error
* fix: address Bugbot review comments
- Fix useEffect cleanup memory leak in DirectTerminal
- Remove accidentally committed build artifacts
- Remove test scripts from repository root
- Add build/ to .gitignore
* fix: address additional Bugbot comments
- Re-enable mouse mode in ttyd terminal (was temporarily disabled)
- Fix hardcoded macOS paths in direct-terminal-ws
- Use 'tmux' from PATH instead of hardcoded /opt/homebrew/bin/tmux
- Use os.userInfo() for username fallback instead of 'equinox'
- Use process.env.PATH for cross-platform compatibility
Note: Bugbot comment about XDA response is incorrect - terminal.write()
is the correct API for responding to XDA queries. The implementation works
as confirmed by user testing.
* perf: fix slow terminal scrolling
- Remove status and error from useEffect dependencies (was recreating terminal on every state change)
- Add scroll performance settings: scrollSensitivity, fastScrollModifier
- Terminal now only recreates when sessionId changes
* fix: pass startFullscreen prop to DirectTerminal in SessionDetail
The fullscreen query parameter was being read from URL but not passed
through to the DirectTerminal component. This caused the fullscreen=true
query param to be ignored when viewing session detail pages.
Fixes:
- Pass startFullscreen prop from SessionDetail to DirectTerminal
- Enables ?fullscreen=true to work on session detail pages
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
* fix: enable mouse mode for DirectTerminal scrolling
DirectTerminal was not scrollable because tmux mouse mode was not enabled
for the sessions. The ttyd implementation already had this, but the
DirectTerminal WebSocket server was missing it.
Changes:
- Add spawn import from node:child_process
- Enable tmux mouse mode on session connection
- Hide tmux status bar for cleaner appearance
This makes DirectTerminal scrolling work the same as ttyd terminals.
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
* fix: reduce scroll speed in DirectTerminal for smoother experience
Reduced scroll sensitivity from 3 to 1 lines per wheel tick for more
natural scrolling behavior. Also reduced fast scroll (with Alt) from 5 to 3.
Changes:
- scrollSensitivity: 3 → 1 (normal scroll speed)
- fastScrollSensitivity: 5 → 3 (Alt+scroll speed)
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
* feat: auto-select two different sessions for terminal test page
When no query params are provided, the test page now automatically fetches
available sessions and picks two different ones for side-by-side comparison.
This avoids port conflicts between ttyd and DirectTerminal by default.
Changes:
- Fetch sessions from /api/sessions on mount
- Use first two available sessions as defaults (or fall back to hardcoded)
- Query params still work for manual override
- Warning only shows when sessions are actually the same
Examples:
- /dev/terminal-test (auto-picks two sessions)
- /dev/terminal-test?old_session=X&new_session=Y (manual override)
- /dev/terminal-test?session=X (uses same session for both)
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
* fix: filter out terminated sessions in terminal test auto-selection
The auto-selection was picking terminated/exited sessions, causing
WebSocket connection failures when trying to attach to non-existent
tmux sessions (PTY exit code 1).
Now filters to only use sessions with activity !== "exited" for
auto-selection, ensuring terminals can actually connect.
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
* feat: add server-side active session filtering to API
Instead of filtering terminated sessions on the client, added proper
API support with ?active=true query parameter to filter server-side.
Changes:
- GET /api/sessions?active=true - Returns only non-exited sessions
- Updated terminal test page to use new API parameter
- Properly maintains session/dashboard alignment during filtering
- Removed client-side filtering logic
This is cleaner, more efficient, and follows proper API design patterns.
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
* refactor: replace magic strings with ACTIVITY_STATE constants
Added ACTIVITY_STATE constants to @composio/ao-core types and replaced
all hardcoded "exited", "waiting_input", "blocked" strings with proper
constants throughout the codebase.
Changes:
- Added ACTIVITY_STATE constant object to core/types.ts
- Replaced magic strings in API route (/api/sessions)
- Replaced magic strings in lib/types.ts (getAttentionLevel)
- Properly typed constants with satisfies Record<string, ActivityState>
This prevents typos, improves IDE autocomplete, and makes refactoring
easier by having a single source of truth for activity state values.
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
* fix: move WebSocket servers outside src/ to fix webpack build error
Moved terminal-websocket.ts and direct-terminal-ws.ts from src/server/
to server/ (outside src/) to prevent Next.js webpack from trying to
bundle them with client code.
These are standalone Node.js WebSocket servers (not Next.js API routes)
that should not be part of the Next.js build. Webpack was failing when
encountering node:child_process imports.
Changes:
- Moved src/server/*.ts to server/*.ts
- Updated package.json scripts to point to new location
Fixes: Module build failed: UnhandledSchemeError with node:child_process
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
* fix: update server file path in terminal test page docs
Updated documentation to reflect new server file location after moving
files from src/server/ to server/.
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
* fix: import from @composio/ao-core/types to avoid bundling server code
The main @composio/ao-core export includes tmux utilities that use
node:child_process, which webpack cannot bundle for the client.
The package already exports a /types entry point that only includes
types and constants (no server utilities).
Changes:
- Import from @composio/ao-core/types instead of @composio/ao-core
- This prevents webpack from trying to bundle tmux.js in client code
Fixes webpack error: UnhandledSchemeError with node:child_process
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
* fix: combine duplicate imports to resolve lint errors
* fix: update .env.local.example to match code defaults (port 3003)
Addresses bugbot comment: Documentation showed port 3002 but both
server (direct-terminal-ws.ts) and client (DirectTerminal.tsx) default
to 3003. This mismatch could cause connection failures if developers
set only DIRECT_TERMINAL_PORT without NEXT_PUBLIC_DIRECT_TERMINAL_PORT.
Updated documentation to reflect actual code defaults.
---------
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>