feat(windows): complete Windows support (#1025)
* fix: project builds on Windows Replace Unix cp -r with Node.js fs.cpSync in CLI build script. Add webpack snapshot config to prevent Next.js scanning Windows junction points. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(core): add cross-platform adapter (platform.ts) Centralizes all platform-branching logic: shell resolution (pwsh > powershell > cmd), process tree kill (taskkill on Windows), port-based PID discovery (netstat on Windows), runtime defaults, and env defaults. Addresses blockers B05, B06, B07, B08 from Windows compatibility proposal. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(core): platform-aware runtime default (tmux on Unix, process on Windows) B04: Config and docs now reflect platform-specific defaults. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(cli): use platform-aware runtime fallback in start command B01/B02: ensureTmux() is only called when runtime resolves to 'tmux'. On Windows, runtime defaults to 'process', skipping tmux entirely. * fix(core): tighten Windows PID port matching * test(core): add mocked tests for platform.ts to fix diff coverage Adds platform.mock.test.ts with 25 tests covering Windows-specific branches (resolveWindowsShell fallbacks, killProcessTree, findPidByPort) and Unix error-handling/env-var fallback chains that require mocking node:child_process. Pushes platform.ts diff coverage from 45.9% to ≥80%. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(core): fix TypeScript errors in platform.mock.test.ts Return ChildProcess from execFile mock implementations and use "" instead of undefined for execFileSync mock return value to satisfy strict types. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(windows): cross-platform process management (PR 2/6) (#1028) Fixes B05 and B06: replaces all Unix-only process management with the platform adapter so AO works on Windows with runtime: process. - dashboard stop/rebuild uses findPidByPort() (netstat on Windows, lsof on Unix) - runtime-process destroy uses killProcessTree() (taskkill /T /F on Windows, negative-PID SIGKILL on Unix) with conditional detached flag - start.ts restart, ao stop --all, and stop-dashboard paths all switched from process.kill() to killProcessTree() so child processes are reaped - lifecycle-service stopLifecycleWorker() replaced with killProcessTree() - Fixed destroy() hang: exit listener now registered before awaiting kill so fast-exiting processes don't fire before the listener attaches Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(cli): spawn dashboard with detached:true on Unix for process group kill Dashboard was spawned with detached:false, so killProcessTree's process.kill(-pid) failed with ESRCH (not the group leader) and fell back to killing only the listening process, orphaning Next.js workers. Matches the runtime-process pattern: detached:!isWindows() makes the dashboard the process group leader on Unix so the negative-PID group kill in killProcessTree correctly reaps all children. On Windows, detached:false is preserved — taskkill /T /F handles the tree kill by PID regardless of process group membership. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(cli): forward SIGINT/SIGTERM to dashboard group; restore lifecycle stop semantics Two Bugbot issues from the detached-dashboard and killProcessTree changes: 1. Dashboard Ctrl+C orphan: with detached:true on Unix, the dashboard is in its own process group and does not receive SIGINT from the terminal. Add process.once(SIGINT/SIGTERM) handlers that forward the signal via killProcessTree so the entire dashboard group is reaped on exit. Handlers are cleaned up when the dashboard exits to avoid leaks. 2. stopLifecycleWorker wrong return value: killProcessTree swallows ESRCH, so a stale PID entry (process already dead) now returned true ("stopped") instead of false ("not running"). Add an isProcessRunning guard before the kill to restore the original semantics. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(cli): prevent double SIGTERM dispatch when both SIGINT and SIGTERM arrive The forward handler now self-removes both listeners before calling killProcessTree, so a second signal cannot invoke it again on an already-dead PID. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(core): respect signal parameter in killProcessTree on Windows SIGTERM now uses taskkill /T /PID (WM_CLOSE, graceful) instead of /F, preserving the SIGTERM→wait→SIGKILL escalation used by lifecycle-service, start.ts, and runtime-process. SIGKILL keeps /T /F /PID (force). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(core): update killProcessTree Windows tests for signal-aware taskkill Split the single taskkill test into two: SIGTERM uses /T /PID (graceful) and SIGKILL uses /T /F /PID (force), matching the updated implementation. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(core,cli): add missing coverage for platform.ts and lifecycle-service - platform.mock.test.ts: cover Windows getEnvDefaults PATH fallback (line 148) - lifecycle-service.test.ts: cover stopLifecycleWorker stale-PID path, normal SIGTERM kill, and SIGKILL escalation after timeout Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(cli): suppress consistent-type-imports lint error in test mock factory Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(agents): add process-runtime PID check to isProcessRunning (#1031) B13 (P0): All 4 agent plugins now check PID directly when runtime is 'process' instead of only scanning ps -eo for tmux TTYs. Enables correct dashboard activity state on Windows. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(windows): platform-aware shell for postCreate, script-runner, symlinks (PR 4/6) (#1032) fix(windows): platform-aware shell for workspaces and script-runner (B07, B08, B19) B07: workspace-worktree and workspace-clone use getShell() instead of sh -c. B08: script-runner spawns scripts in file-mode on Unix (so $1/$2/$3 reach positional args) and uses getShell() on Windows; AO_BASH_PATH override uses || so empty string is treated as unset. B19: workspace-worktree symlink falls back to cpSync on Windows. Also fixes path separator check in workspace-worktree to use path.sep instead of hardcoded "/" for correct Windows behaviour. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(test): use mockReturnValueOnce for pwsh shell mock to prevent test pollution vi.clearAllMocks() clears call history but not mockReturnValue implementations. The Windows pwsh shell tests were polluting subsequent tests that expected the default sh mock. Changed to mockReturnValueOnce so the override is consumed by the single call and subsequent tests get the default sh implementation. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(cli): script-runner always uses bash on Unix, getShell() only on Windows getShell() on Unix returns process.env.SHELL || /bin/sh, which may be zsh, fish, or plain sh. When a script file is passed as an argument to these shells (file mode), the #!/bin/bash shebang is ignored and bash-specific syntax in ao-doctor.sh / ao-update.sh / setup.sh breaks. Fix: hardcode bash on Unix (AO_BASH_PATH still overrides it), use getShell() only on Windows where bash is unavailable. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(core): use lazy factory for Zod runtime default z.string().default(getDefaultRuntime()) evaluates getDefaultRuntime() once at module load time, creating hidden coupling between import order and platform detection. Using a factory function ensures the default is resolved lazily each time it is needed, making the intent explicit. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(cli): add detached: !isWindows() to dashboard spawn for correct process cleanup Without detached:true on Unix, killProcessTree(pid) calls process.kill(-pid) which targets a process group the child never owns — ESRCH causes fallback to direct kill, leaving grandchild processes alive. Matches the pattern already used in start.ts lines 782 and 795. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(cli): forward SIGINT/SIGTERM to detached dashboard child on Unix Detached children run in their own process group, so Ctrl+C does not reach them. Without forwarding, the dashboard holds the port after the parent exits. Matches the identical pattern in start.ts lines 1154-1165. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(core): guard killProcessTree against pid <= 0 On Unix, -0 === 0 in JS, so killProcessTree(0) would call process.kill(0) which sends the signal to every process in the calling process's group, killing AO itself. findPidByPort can return "0" since it passes the truthiness check and the /^\d+$/ regex. Guard pid <= 0 at the top of killProcessTree and return early. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(windows): Node.js metadata wrappers + Claude Code hook + pwsh shell (squash merge PR5) Squash merges feat/windows-hooks-and-launch (PR #1033) into PR1. Blockers addressed: - B16: ~/.ao/bin/gh and git wrappers are now Node.js scripts + .cmd shims on Windows (bash on Unix unchanged). WRAPPER_VERSION bumped to 0.3.0 to force reinstall. - B17: Claude Code PostToolUse hook uses JSON.parse/fs built-ins on Windows instead of bash+jq+grep+sed. Atomic writes via temp file + renameSync. chmod skipped on Windows. - B18: runtime-process uses getShell().cmd + shellInfo.args() instead of shell:true (which resolves to cmd.exe on Windows). getShell() returns pwsh > powershell.exe > cmd.exe on Windows, bash on Unix. Conflict resolution: - runtime-process spawn: kept PR5's getShell() approach (B18 fix), removed shell:true which PR1 had as a placeholder. - runtime-process destroy: kept PR1's cleaner killProcessTree delegation (PR5 had inline platform-branching written before killProcessTree was integrated into this worktree). - Tests: merged PR5's new Windows compatibility tests, adjusted assertions to match PR1's killProcessTree(pid, signal) API. Fixed Windows compat tests that expected old spawn(launchCommand, opts) signature. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(core): deduplicate Node.js wrapper updateAoMetadata and add AO_DATA_DIR path validation - Extract shared NODE_UPDATE_AO_METADATA constant used by both gh/git wrappers - Add AO_DATA_DIR validation matching bash ao-metadata-helper.sh (must be under ~/.ao/, ~/.agent-orchestrator/, or tmpdir) - Bump WRAPPER_VERSION to 0.5.0 to force reinstall with new security check - Update version in agent-codex and core tests to match 0.5.0 - Fix CI test failures from WRAPPER_VERSION bump (0.2.0 → 0.4.0 → 0.5.0) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(core): add IPv6 netstat test for findPidByPort + mark _resetShellCache as @internal - Add test case for IPv6 LISTENING entries ([::]:3000) in Windows netstat output - Add @internal JSDoc to _resetShellCache to clarify it is test-only Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(cli): use -File mode on Windows for script-runner so positional args are forwarded PowerShell -Command does not forward argv elements after the command string to the script — they are treated as top-level PowerShell args and silently dropped. Using -File passes remaining args as positional parameters ($1, $2, …) to the script, so e.g. `ao doctor --fix` correctly reaches the script on Windows. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(cli,core): remove unused getEnvDefaults export, add SIGKILL fallback in signal forward handler - Remove getEnvDefaults from @composio/ao-core public API — no production callers; the function remains in platform.ts for future use (B10/B11) - Add 5 s SIGKILL fallback in dashboard.ts and start.ts forward() handlers so the parent process cannot hang indefinitely if the child ignores SIGTERM Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(cli): extract forwardSignalsToChild utility, fix ESM build script, reject Windows absolute symlinks - Extract forwardSignalsToChild(pid, child) into shell.ts — eliminates verbatim duplication of the SIGTERM/SIGKILL forwarding logic between dashboard.ts and start.ts - Fix build script: replace node -e "require(...)" with node --input-type=commonjs -e "require(...)" — required because package is "type":"module" and require is not available in node -e by default in ESM context - Fix duplicate import in shell.ts (no-duplicate-imports lint error) - Reject Windows drive-letter (C:\) and UNC (\server\share) paths in symlink validation — previously only Unix absolute paths starting with "/" were blocked Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(cli): clear SIGKILL fallback timer when child exits cleanly If the child exits before the 5-second escalation window, the fallback setTimeout would still fire and call process.exit(1). Track the timer in the outer scope so the child.once("exit") handler can cancel it. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: resolve two open bugbot issues on windows-platform-adapter PR - fix(platform): always use /bin/sh on Unix instead of \$SHELL so postCreate commands and runtime launches work correctly when the user's login shell is non-POSIX (fish, nushell, etc.) - fix(script-runner): detect cmd.exe fallback on Windows and throw a clear, actionable error pointing to AO_BASH_PATH rather than passing the PowerShell-specific -File flag to cmd.exe (which produces a cryptic error and still can't run bash scripts) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(cli): gate lifecycle worker detached flag behind !isWindows() Spawning with detached: true unconditionally creates a new console window on Windows. Use the same !isWindows() pattern established elsewhere in this PR so the process group behaviour is correct on both platforms. killProcessTree already uses taskkill /T on Windows so cleanup is unaffected. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(cli): require AO_BASH_PATH for all Windows shells, not just cmd.exe pwsh and powershell.exe cannot run bash scripts any more than cmd.exe can — shebangs are ignored and bash-specific syntax fails. The previous guard only matched cmd.exe, allowing pwsh (the most common Windows fallback) to silently invoke -File on a bash script and produce a confusing PowerShell syntax error. Simplify to: throw on any Windows shell without AO_BASH_PATH. Also removes the now-dead getShell() call and -File code path from script-runner. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(core): always force-kill on Windows; fix stale .cjs JSDoc killProcessTree: drop the SIGTERM-without-/F branch on Windows. taskkill without /F sends WM_CLOSE which is unreliable for headless Node.js console processes — they may silently survive, leaving orphaned processes. Always pass /F so termination is guaranteed. Callers that do SIGTERM→wait→SIGKILL escalation are unaffected: SIGKILL simply finds the process already dead. agent-workspace-hooks: correct JSDoc that still said <name>.js after the extension was changed to .cjs (forced CJS mode). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(cli): remove trivial findRunningDashboardPid wrapper The function was a one-line pass-through to findPidByPort with no added logic. Callers now import findPidByPort from @composio/ao-core directly. waitForPortFree calls findPidByPort inline. Deleted the test file that only tested the pass-through. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(cli): make forwardSignalsToChild idempotent via WeakSet guard Prevents duplicate SIGINT/SIGTERM handlers if called more than once for the same ChildProcess — avoids double killProcessTree and racing process.exit(1) from stacked SIGKILL fallback timers. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(agent-claude-code): three Windows correctness fixes C-1: Add AO_DATA_DIR allowlist validation to METADATA_UPDATER_SCRIPT_NODE. The Node.js PostToolUse hook now validates AO_DATA_DIR against ~/.ao/, ~/.agent-orchestrator/, and os.tmpdir() before writing — matching the protection already in ao-metadata-helper.sh and the Node.js wrappers in agent-workspace-hooks.ts. I-1: Guard getCachedProcessList() against Windows. ps -eo pid,tty,args is Unix-only; the guard makes the intent explicit and avoids a spurious execFile call when a stale tmux handle is encountered on Windows. I-2: Read systemPromptFile content synchronously on Windows instead of using $(cat ...) bash command substitution, which is not understood by PowerShell or cmd.exe. Tests added for all three fixes. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(agents): apply Windows correctness fixes to aider, codex, opencode Mirrors the fixes already applied to agent-claude-code: I-1: Guard ps -eo pid,tty,args behind isWindows() in isProcessRunning for all three agents. ps is Unix-only; a stale tmux handle on Windows would silently return false via exception catch. The explicit guard makes intent clear and avoids the unnecessary execFile call. I-2: Inline systemPromptFile content on Windows instead of $(cat ...) bash command substitution (aider: --system-prompt; opencode: prompt value). codex is unaffected — it passes the path via -c flag and reads the file itself. Tests: added isWindows mock (default false) to all three test suites to prevent real isWindows()=true on Windows from triggering the new guard in existing Unix-path tests. Added Windows-specific tests for each fix. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(core): use F_OK and skip bare extension in Windows gh/git wrappers On Windows, fs.constants.X_OK is equivalent to F_OK (execute bit does not exist), so any existing file passes the check. The empty extension was also tried first, meaning a bare file named "gh" would be selected over gh.exe. Switch to F_OK and only check .exe/.cmd extensions. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(windows): QA fixes — dashboard spawn, shellEscape, tmux hint, PID fallback T04: resolveNextBin() skips POSIX .bin/next shim on Windows; invokes next/dist/bin/next via process.execPath instead (ENOENT fix). T18: shellEscape() branches on isWindows() — PowerShell uses '' doubling, Unix uses POSIX '\'' escaping. All 4 agent plugins benefit automatically. T06 secondary: tmux attach hint in `ao spawn` output gated behind runtimeName === "tmux" (process runtime has no tmux session). T06/T11: runtime-process destroy() and isAlive() fall back to handle.data.pid when the in-memory processes Map is empty — fixes cross-process CLI calls (ao session ls / ao session kill). * fix(web): prevent nft EPERM on Windows home directory junction points Next.js nft (Node File Tracer) scans homedir() at build time and hits EPERM on Windows junction points (e.g. Application Data). Adds homedir()/** to TraceEntryPointsPlugin.traceIgnores on Windows server builds. * Revert "Merge branch 'main' into feat/windows-platform-adapter" This reverts commit5ba7644548, reversing changes made to5da9bedf5c. * Reapply "Merge branch 'main' into feat/windows-platform-adapter" This reverts commit6a326a07e3. * 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 (commit4958512d). 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 (commit582c5373) 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 in1d8c8f75to 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 ford04fad33/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. Reverts3557e556. 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 ineaa27b9bpulled 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 Revertsb3f522f9and004b2a79. 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>
This commit is contained in:
parent
be69a580fb
commit
0f5ae0b01d
|
|
@ -0,0 +1,54 @@
|
|||
---
|
||||
"@aoagents/ao-core": minor
|
||||
"@aoagents/ao-cli": minor
|
||||
"@aoagents/ao": minor
|
||||
"@aoagents/ao-plugin-runtime-process": minor
|
||||
"@aoagents/ao-plugin-runtime-tmux": minor
|
||||
"@aoagents/ao-plugin-agent-claude-code": minor
|
||||
"@aoagents/ao-plugin-agent-codex": minor
|
||||
"@aoagents/ao-plugin-agent-aider": minor
|
||||
"@aoagents/ao-plugin-agent-opencode": minor
|
||||
"@aoagents/ao-plugin-workspace-worktree": minor
|
||||
"@aoagents/ao-plugin-workspace-clone": minor
|
||||
"@aoagents/ao-plugin-tracker-github": minor
|
||||
"@aoagents/ao-plugin-tracker-linear": minor
|
||||
"@aoagents/ao-plugin-scm-github": minor
|
||||
"@aoagents/ao-plugin-notifier-desktop": minor
|
||||
"@aoagents/ao-plugin-notifier-slack": minor
|
||||
"@aoagents/ao-plugin-notifier-webhook": minor
|
||||
"@aoagents/ao-plugin-notifier-composio": minor
|
||||
"@aoagents/ao-plugin-terminal-iterm2": minor
|
||||
"@aoagents/ao-plugin-terminal-web": minor
|
||||
"@aoagents/ao-web": minor
|
||||
---
|
||||
|
||||
feat: native Windows support
|
||||
|
||||
AO now runs natively on Windows. The default runtime on Windows is `process`
|
||||
(ConPTY via `node-pty` + named pipes — no tmux, no WSL); the dashboard,
|
||||
agents (claude-code, codex, kimicode, aider, opencode, cursor), `ao doctor`,
|
||||
and `ao update` all work out of the box. Each session gets a small detached
|
||||
pty-host helper that wraps a ConPTY behind `\\.\pipe\ao-pty-<sessionId>`,
|
||||
registered so `ao stop` can reach it.
|
||||
|
||||
A new cross-platform abstraction layer (`packages/core/src/platform.ts`)
|
||||
centralises every platform branch behind helpers like `isWindows()`,
|
||||
`getDefaultRuntime()`, `getShell()`, `killProcessTree()`, `findPidByPort()`,
|
||||
and `getEnvDefaults()`. Path comparison uses `pathsEqual` /
|
||||
`canonicalCompareKey` to handle NTFS case-insensitivity. PATH wrappers for
|
||||
agent plugins (`gh`, `git`) ship as `.cjs` + `.cmd` shims on Windows;
|
||||
`script-runner` runs `.ps1` siblings of `.sh` scripts via PowerShell. New
|
||||
`ao-doctor.ps1` / `ao-update.ps1` shipped.
|
||||
|
||||
`ao open` is now cross-platform: it sources sessions from `sm.list()`
|
||||
instead of `tmux list-sessions` (so `runtime-process` sessions on Windows
|
||||
appear), and the open action branches per OS — `open-iterm-tab` stays the
|
||||
macOS path, native handling on Windows and Linux.
|
||||
|
||||
Behaviour on macOS and Linux is unchanged. Every Windows path is gated
|
||||
behind `isWindows()`; `runtime-tmux` and the bash hook flows are untouched.
|
||||
|
||||
See `docs/CROSS_PLATFORM.md` for the developer reference (helper inventory,
|
||||
EPERM-vs-ESRCH gotcha, PowerShell-vs-bash differences, pre-merge checklist).
|
||||
The Windows runtime architecture (pty-host, pipe protocol, registry, sweep,
|
||||
mux WS Windows branch) is documented in `docs/ARCHITECTURE.md`.
|
||||
|
|
@ -15,7 +15,7 @@ Agent Orchestrator is a TypeScript monorepo for managing parallel AI coding agen
|
|||
|
||||
## Review Focus
|
||||
|
||||
- **Security**: Watch for command injection (especially in shell/tmux/git commands), AppleScript injection, GraphQL injection, unsanitized user input in API routes
|
||||
- **Security**: Watch for command injection (especially in shell/tmux/git/PowerShell commands and Windows named-pipe session IDs — `validateSessionId()` should guard those), AppleScript injection, GraphQL injection, unsanitized user input in API routes
|
||||
- **Shell execution**: Prefer `execFile` over `exec` to avoid shell injection. Flag any use of `exec` or string concatenation in shell commands
|
||||
- **Plugin pattern**: Plugins must export `{ manifest, create } satisfies PluginModule<T>` with types from `@aoagents/ao-core`
|
||||
- **Type safety**: Flag `as unknown as T` casts, unguarded `JSON.parse`, and type re-declarations that should import from core
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ These are the areas where Copilot review adds the most value: issues CI cannot c
|
|||
- Core utilities exported from `@aoagents/ao-core`
|
||||
|
||||
**7. Resource cleanup.** Check that:
|
||||
- File handles, subprocesses, and tmux sessions are cleaned up on all exit paths: success, error, and early return
|
||||
- File handles, subprocesses, and runtime sessions (tmux on Unix, ConPTY pty-host processes on Windows) are cleaned up on all exit paths: success, error, and early return
|
||||
- `destroy()` methods exist and use best-effort semantics
|
||||
- There are no resource leaks in error paths
|
||||
|
||||
|
|
@ -124,7 +124,7 @@ These files have a wide blast radius and deserve extra scrutiny:
|
|||
| `packages/core/src/lifecycle-manager.ts` | State machine and polling loop with subtle state dependencies. |
|
||||
| `packages/core/src/session-manager.ts` | Session CRUD + stale runtime reconciliation. `list()` persists `runtime_lost` to disk when enrichment detects dead runtimes. Invariant violations can cause phantom `killed` or `exited` sessions. |
|
||||
| `packages/core/src/lifecycle-state.ts` | Canonical lifecycle → legacy status mapping. New terminal reasons (e.g. `runtime_lost`) must be added to `deriveLegacyStatus()`. |
|
||||
| `packages/cli/src/commands/start.ts` | ao start/stop + Ctrl+C shutdown. Cross-project scoping logic is subtle — `ao stop <project>` must not kill parent process. |
|
||||
| `packages/cli/src/commands/start.ts` | ao start/stop + Ctrl+C shutdown. Cross-project scoping logic is subtle — `ao stop <project>` must not kill parent process. On Windows, also calls `sweepWindowsPtyHosts()` to gracefully tear down detached ConPTY pty-host processes that `taskkill /T` cannot reach. |
|
||||
| `packages/core/src/config.ts` | Zod validation schema. Changes affect every `ao` command. |
|
||||
| `packages/core/src/index.ts` | Stable public API. Do not break it without deprecation. |
|
||||
| `packages/web/src/app/globals.css` | Design tokens used by 50+ components. Renaming tokens breaks the UI. |
|
||||
|
|
|
|||
|
|
@ -10,6 +10,11 @@ concurrency:
|
|||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
# Minimal token scope — all jobs only checkout, install, build, and test.
|
||||
# None of them push code, comment on PRs, or call mutating GitHub APIs.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Lint
|
||||
|
|
@ -25,8 +30,12 @@ jobs:
|
|||
- run: pnpm lint
|
||||
|
||||
typecheck:
|
||||
name: Typecheck
|
||||
runs-on: ubuntu-latest
|
||||
name: Typecheck (${{ matrix.os }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
|
|
@ -36,15 +45,19 @@ jobs:
|
|||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
# Build all non-web packages
|
||||
- run: pnpm -r --filter '!@aoagents/ao-web' build
|
||||
- run: pnpm -r --filter "!@aoagents/ao-web" build
|
||||
# Typecheck all non-web packages
|
||||
- run: pnpm -r --filter '!@aoagents/ao-web' typecheck
|
||||
- run: pnpm -r --filter "!@aoagents/ao-web" typecheck
|
||||
# Build web (Next.js build includes its own typecheck)
|
||||
- run: pnpm --filter @aoagents/ao-web build
|
||||
|
||||
test:
|
||||
name: Test
|
||||
runs-on: ubuntu-latest
|
||||
name: Test (${{ matrix.os }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
|
|
@ -53,12 +66,23 @@ jobs:
|
|||
node-version: 20
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm -r --filter '!@aoagents/ao-web' build
|
||||
- run: pnpm -r --filter "!@aoagents/ao-web" build
|
||||
# Verify node-pty's Windows prebuild loads cleanly before any test that
|
||||
# depends on it. A broken prebuild fails this step in seconds with a
|
||||
# clear "node-pty" stack rather than a buried integration-test failure.
|
||||
- name: Verify node-pty prebuild (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
working-directory: packages/plugins/runtime-process
|
||||
run: node -e "const p=require('node-pty');const t=p.spawn('cmd.exe',['/c','exit'],{cols:80,rows:24});t.onExit(({exitCode})=>process.exit(exitCode));setTimeout(()=>process.exit(2),5000)"
|
||||
- run: pnpm test
|
||||
|
||||
test-web:
|
||||
name: Test (Web)
|
||||
runs-on: ubuntu-latest
|
||||
name: Test Web (${{ matrix.os }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
|
|
@ -66,11 +90,18 @@ jobs:
|
|||
with:
|
||||
node-version: 20
|
||||
cache: pnpm
|
||||
# tmux is the Linux/macOS terminal runtime backing direct-terminal-ws
|
||||
# integration tests. Windows uses runtime-process + named pipes (covered
|
||||
# by mux-websocket-windows.test.ts) — those tmux tests self-skip.
|
||||
- name: Install tmux
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y tmux
|
||||
- name: Start tmux server
|
||||
if: runner.os == 'Linux'
|
||||
run: tmux start-server
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm -r --filter '!@aoagents/ao-web' build
|
||||
- name: Run web server tests (unit + integration)
|
||||
run: pnpm --filter @aoagents/ao-web exec vitest run server/__tests__/
|
||||
- run: pnpm -r --filter "!@aoagents/ao-web" build
|
||||
# Full web suite — components, hooks, libs, app routes, and server tests.
|
||||
# Previously this job was scoped to server/__tests__/ only; broadening it
|
||||
# closes a long-standing coverage gap on both Linux and Windows.
|
||||
- run: pnpm --filter @aoagents/ao-web test
|
||||
|
|
|
|||
|
|
@ -68,3 +68,4 @@ agent-orchestrator.yaml
|
|||
# OS-specific files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
package-lock.json
|
||||
|
|
|
|||
10
AGENTS.md
10
AGENTS.md
|
|
@ -47,3 +47,13 @@ Full guidelines with AO-specific context: see "Working Principles" in CLAUDE.md.
|
|||
- Ctrl+C on `ao start` performs full graceful shutdown (same as `ao stop`)
|
||||
- `LastStopState` includes `otherProjects` for cross-project session restore on next `ao start`
|
||||
- Dashboard sidebar always shows ALL projects' sessions regardless of active project view
|
||||
|
||||
## Cross-Platform (Windows) Compatibility
|
||||
|
||||
AO ships on macOS, Linux, **and Windows**. All three are first-class.
|
||||
|
||||
**Golden Rule:** Never write `process.platform === "win32"` in new code. Use `isWindows()` from `@aoagents/ao-core`. If you need branching the helpers don't cover, add it to `packages/core/src/platform.ts` — never inline at the call site. Inline checks bypass the central platform-mock test pattern and become silent regressions.
|
||||
|
||||
**Read `docs/CROSS_PLATFORM.md` before merging any change that touches:** process spawning/killing/signalling, file paths, shell commands, network binding, POSIX shell-outs (`tmux`, `lsof`, etc.), runtime/agent/workspace plugins, agent-plugin internals (`setupPathWrapperWorkspace`, `getActivityState`, `formatLaunchCommand`, `isProcessRunning`, `detect()`), the Windows pty-host pipe protocol or registry, or any new `process.platform === "win32"` check.
|
||||
|
||||
That doc has the **full helper inventory** (every import path), the EPERM-vs-ESRCH gotcha when probing processes, path case-insensitivity rules, PowerShell-vs-bash differences (`& ` call-operator, `$env:VAR`, no `/dev/null`, no `$(cat …)`, `.cmd` shim resolution via `shell: isWindows()`), IPv6 `localhost` stalls on Windows, agent-plugin Windows specifics, the test pattern for mocking `process.platform`, and a 10-point pre-merge checklist. CLAUDE.md has the quick-reference helper table; CROSS_PLATFORM.md has the depth.
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ ao-1, ao-2 (agent-orchestrator)
|
|||
ss-1, ss-2 (safe-split)
|
||||
```
|
||||
|
||||
### Tmux Session Names (Globally Unique)
|
||||
### Runtime Session Names (Globally Unique)
|
||||
|
||||
```
|
||||
{hash}-{sessionPrefix}-{num}
|
||||
|
|
@ -107,6 +107,8 @@ a3b4c5d6e7f8-ao-1
|
|||
f1e2d3c4b5a6-int-1 (different checkout, no collision!)
|
||||
```
|
||||
|
||||
On Unix this is the tmux session name. On Windows (where the default runtime is `process`, not `tmux`) the same string identifies the named pipe path `\\.\pipe\ao-pty-{sessionId}` and is recorded in `~/.agent-orchestrator/windows-pty-hosts.json`.
|
||||
|
||||
### Prefix Generation (Clean Heuristic)
|
||||
|
||||
```typescript
|
||||
|
|
@ -159,7 +161,7 @@ project=integrator
|
|||
issue=INT-100
|
||||
branch=feat/INT-100
|
||||
status=working
|
||||
tmuxName=a3b4c5d6e7f8-int-1
|
||||
tmuxName=a3b4c5d6e7f8-int-1 # Unix; on Windows the runtime handle is `pipePath=\\.\pipe\ao-pty-<sessionId>` plus `ptyHostPid`
|
||||
worktree=/Users/alice/.agent-orchestrator/a3b4c5d6e7f8-integrator/worktrees/int-1
|
||||
createdAt=2026-02-17T10:30:00Z
|
||||
pr=https://github.com/ComposioHQ/integrator/pull/123
|
||||
|
|
@ -187,7 +189,7 @@ ao list integrator
|
|||
# Spawn new session
|
||||
ao spawn integrator INT-100
|
||||
|
||||
# Attach to session (orchestrator finds tmux name)
|
||||
# Attach to session (orchestrator finds the runtime handle: tmux name on Unix, named pipe on Windows)
|
||||
ao attach int-1
|
||||
|
||||
# Kill session
|
||||
|
|
|
|||
55
CLAUDE.md
55
CLAUDE.md
|
|
@ -228,6 +228,61 @@ Strong success criteria let you loop independently. Weak criteria ("make it work
|
|||
- `deriveLegacyStatus()` maps canonical lifecycle to legacy status — new terminal reasons must be added here
|
||||
- Tab completions merge local config + global config to show all projects
|
||||
|
||||
## Cross-Platform (Windows) Compatibility
|
||||
|
||||
AO ships on macOS, Linux, **and Windows**. All three are first-class.
|
||||
|
||||
### The Golden Rule
|
||||
|
||||
> **Never write `process.platform === "win32"` in new code. Use `isWindows()` from `@aoagents/ao-core`. If you need branching the helpers don't cover, add it to `packages/core/src/platform.ts` (or one of the targeted helper modules below) — never inline at the call site.**
|
||||
|
||||
The codebase has a deliberate set of cross-platform abstractions. Every platform helper is centrally tested by mocking `process.platform`; inline checks bypass those tests and become silent regressions. Whenever you'd type `process.platform`, stop and check the helper inventory in `docs/CROSS_PLATFORM.md` first.
|
||||
|
||||
### Read `docs/CROSS_PLATFORM.md` before merging if you touch any of:
|
||||
|
||||
- Process spawning, killing, signalling, or process-tree teardown (`child_process`, `process.kill`, runtime plugins)
|
||||
- File paths — comparison, joining, walking, anything OS-specific
|
||||
- Shell commands (`exec`, `execFile`, command strings, redirections, PowerShell-vs-bash)
|
||||
- Network binding, sockets, anything that says `localhost`
|
||||
- Shell-outs to POSIX tools (`tmux`, `lsof`, `pkill`, `which`, coreutils)
|
||||
- Adding any new `if (process.platform === "win32")` check (it should go into `platform.ts` instead — see the Golden Rule)
|
||||
- Runtime / agent / workspace plugin code that runs on both `runtime-tmux` and `runtime-process`
|
||||
- Agent-plugin internals: `setupPathWrapperWorkspace`, `getActivityState`, `formatLaunchCommand`, `isProcessRunning`, `detect()`
|
||||
- The Windows pty-host pipe protocol or registry (`pty-client.ts`, `windows-pty-registry.ts`, `sweepWindowsPtyHosts`)
|
||||
|
||||
### Quick reference: helpers to use instead of raw platform checks
|
||||
|
||||
All importable from `@aoagents/ao-core` unless noted:
|
||||
|
||||
| Need | Use |
|
||||
|------|-----|
|
||||
| OS check | `isWindows()` |
|
||||
| Pick runtime | `getDefaultRuntime()` |
|
||||
| Resolve shell (PowerShell vs `/bin/sh`) | `getShell()` |
|
||||
| Kill process + descendants | `killProcessTree(pid, signal?)` |
|
||||
| Find PID listening on a port | `findPidByPort(port)` |
|
||||
| Default env (HOME / TMPDIR / SHELL / PATH / USER) | `getEnvDefaults()` |
|
||||
| Compare paths (case-insensitive on NTFS/APFS) | `pathsEqual()` / `canonicalCompareKey()` from `cli/src/lib/path-equality.ts` |
|
||||
| Escape shell args | `shellEscape()` |
|
||||
| Install agent PATH wrappers (`gh`/`git`) | `setupPathWrapperWorkspace(workspacePath)` |
|
||||
| Build env PATH with `~/.ao/bin` prepended | `buildAgentPath(basePath?)` |
|
||||
| Tail JSONL | `readLastJsonlEntry` / `readLastActivityEntry` |
|
||||
| Activity-state contract helpers | `checkActivityLogState`, `getActivityFallbackState`, `classifyTerminalActivity`, `recordTerminalActivity`, `appendActivityEntry` |
|
||||
| Windows pty-host registry (used by `ao stop`) | `registerWindowsPtyHost`, `getWindowsPtyHosts`, `unregisterWindowsPtyHost`, `clearWindowsPtyHostRegistry` |
|
||||
| Reap orphan pty-hosts on `ao stop` | `sweepWindowsPtyHosts()` from `@aoagents/ao-plugin-runtime-process` |
|
||||
| Talk to a Windows pty-host over its named pipe | `getPipePath`, `connectPtyHost`, `ptyHostSendMessage`, `ptyHostGetOutput`, `ptyHostIsAlive`, `ptyHostKill` from `@aoagents/ao-plugin-runtime-process` |
|
||||
| Validate user-supplied session ID before pipe/shell use | `validateSessionId()` from `@/server/tmux-utils` |
|
||||
| Resolve a session's Windows pipe path | `resolvePipePath()` from `@/server/tmux-utils` |
|
||||
| POSIX-only Ctrl+C signal forwarding | `forwardSignalsToChild()` from `cli/src/lib/shell.ts` (guard with `!isWindows()`) |
|
||||
| Defensive PowerShell sweep of orphan pty-hosts | `stopStaleWindowsPtyHosts(projectDir)` from `web/src/lib/windows-pty-cleanup.ts` |
|
||||
|
||||
`docs/CROSS_PLATFORM.md` has the full helper reference with import paths, the EPERM-vs-ESRCH gotcha when probing processes (with a copyable code snippet), path case-insensitivity rules, PowerShell-vs-bash differences (`& ` call-operator, `$env:VAR`, no `/dev/null`, no `$(cat …)`, `.cmd`/`.bat`/`.exe` shim resolution via `shell: isWindows()`), the IPv6 `localhost` stall on Windows, agent-plugin Windows specifics, the test pattern for mocking `process.platform`, and a 10-point pre-merge checklist. **Run through that checklist for any non-trivial change.**
|
||||
|
||||
### Environment variables to know about
|
||||
|
||||
- `AO_SHELL` — overrides `getShell()` resolution (escape hatch for Git Bash users on Windows). Args inferred from basename: `cmd` → `/c`, `bash`/`sh`/`zsh` → `-c`, anything else → `-Command`.
|
||||
- `AO_BASH_PATH` — used by `script-runner.ts` on Windows to locate bash before falling back to Git Bash auto-detection. WSL bash is excluded (it sees Linux paths from a Windows cwd, breaking script semantics).
|
||||
|
||||
## Conventions
|
||||
|
||||
### Code Style
|
||||
|
|
|
|||
|
|
@ -27,7 +27,10 @@ Include:
|
|||
|
||||
## Development Setup
|
||||
|
||||
**Prerequisites**: Node.js 20+, pnpm 9.15+, Git 2.25+, tmux, gh CLI
|
||||
**Prerequisites**: Node.js 20+, pnpm 9.15+, Git 2.25+, gh CLI
|
||||
|
||||
- **Unix (macOS/Linux)**: also install `tmux` — it is the default runtime.
|
||||
- **Windows**: tmux is **not** required. The default runtime on Windows is `process` (ConPTY via `node-pty`), and PowerShell is the default shell. See [docs/CROSS_PLATFORM.md](docs/CROSS_PLATFORM.md) for what's different on Windows when contributing.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/ComposioHQ/agent-orchestrator.git
|
||||
|
|
|
|||
14
README.md
14
README.md
|
|
@ -23,7 +23,7 @@ Spawn parallel AI coding agents, each in its own git worktree. Agents autonomous
|
|||
|
||||
Agent Orchestrator manages fleets of AI coding agents working in parallel on your codebase. Each agent gets its own git worktree, its own branch, and its own PR. When CI fails, the agent fixes it. When reviewers leave comments, the agent addresses them. You only get pulled in when human judgment is needed.
|
||||
|
||||
**Agent-agnostic** (Claude Code, Codex, Aider) · **Runtime-agnostic** (tmux, Docker) · **Tracker-agnostic** (GitHub, Linear)
|
||||
**Agent-agnostic** (Claude Code, Codex, Aider) · **Runtime-agnostic** (tmux, ConPTY/process, Docker) · **Tracker-agnostic** (GitHub, Linear)
|
||||
|
||||
<div align="center">
|
||||
|
||||
|
|
@ -45,7 +45,9 @@ Agent Orchestrator manages fleets of AI coding agents working in parallel on you
|
|||
|
||||
## Quick Start
|
||||
|
||||
> **Prerequisites:** [Node.js 20+](https://nodejs.org), [Git 2.25+](https://git-scm.com), [tmux](https://github.com/tmux/tmux/wiki/Installing), [`gh` CLI](https://cli.github.com). Install tmux via `brew install tmux` (macOS) or `sudo apt install tmux` (Linux).
|
||||
> **Prerequisites:** [Node.js 20+](https://nodejs.org), [Git 2.25+](https://git-scm.com), [`gh` CLI](https://cli.github.com), and:
|
||||
> - **macOS / Linux:** [tmux](https://github.com/tmux/tmux/wiki/Installing) — install via `brew install tmux` or `sudo apt install tmux`.
|
||||
> - **Windows:** PowerShell 7+ recommended. tmux is **not** required — AO uses native ConPTY via the `runtime-process` plugin (the default on Windows). Set `AO_SHELL=bash` if you have Git Bash and prefer it.
|
||||
|
||||
### Install
|
||||
|
||||
|
|
@ -135,7 +137,7 @@ $schema: https://raw.githubusercontent.com/ComposioHQ/agent-orchestrator/main/sc
|
|||
port: 3000
|
||||
|
||||
defaults:
|
||||
runtime: tmux
|
||||
runtime: tmux # default on macOS / Linux; on Windows the default is `process` (ConPTY)
|
||||
agent: claude-code
|
||||
workspace: worktree
|
||||
notifiers: [desktop]
|
||||
|
|
@ -177,20 +179,22 @@ AO keeps your Mac awake while running, so you can access the dashboard remotely
|
|||
# agent-orchestrator.yaml
|
||||
$schema: https://raw.githubusercontent.com/ComposioHQ/agent-orchestrator/main/schema/config.schema.json
|
||||
power:
|
||||
preventIdleSleep: true # Default on macOS, no-op on Linux
|
||||
preventIdleSleep: true # Default on macOS; no-op on Linux and Windows
|
||||
```
|
||||
|
||||
Set to `false` if you want to allow idle sleep while AO runs.
|
||||
|
||||
**Lid-close limitation:** macOS enforces lid-close sleep at the hardware level — no userspace assertion can override it. If you need remote access while traveling with the lid closed, use [clamshell mode](https://support.apple.com/en-us/102505) (external power + display + input device).
|
||||
|
||||
**Linux / Windows:** AO does not currently hold a wake assertion on these platforms. On Linux, idle-sleep behaviour is governed by your desktop environment / `systemd-logind`; configure that directly. On Windows, set the OS power plan if remote access matters while idle.
|
||||
|
||||
## Plugin Architecture
|
||||
|
||||
Seven plugin slots. Lifecycle stays in core.
|
||||
|
||||
| Slot | Default | Alternatives |
|
||||
| --------- | ----------- | ------------------------ |
|
||||
| Runtime | tmux | process |
|
||||
| Runtime | tmux (macOS/Linux) / process (Windows) | process, docker |
|
||||
| Agent | claude-code | codex, aider, cursor, opencode, kimicode |
|
||||
| Workspace | worktree | clone |
|
||||
| Tracker | github | linear, gitlab |
|
||||
|
|
|
|||
22
SETUP.md
22
SETUP.md
|
|
@ -18,7 +18,9 @@ Comprehensive guide to installing, configuring, and troubleshooting Agent Orches
|
|||
git --version
|
||||
```
|
||||
|
||||
- **tmux** (for tmux runtime) - Terminal multiplexer for session management
|
||||
- **Terminal runtime** — varies by OS:
|
||||
|
||||
**On macOS / Linux:** `tmux` is required (it's the default runtime).
|
||||
|
||||
```bash
|
||||
tmux -V
|
||||
|
|
@ -33,6 +35,8 @@ Comprehensive guide to installing, configuring, and troubleshooting Agent Orches
|
|||
sudo dnf install tmux
|
||||
```
|
||||
|
||||
**On Windows:** tmux is **not** required. AO uses native ConPTY via the `runtime-process` plugin (the default on Windows). PowerShell 7+ is recommended; if you have Git Bash and prefer bash semantics for shell-out commands, set `AO_SHELL=bash` in your environment. WSL is not required.
|
||||
|
||||
- **GitHub CLI** (for GitHub integration) - Required for PR creation, issue management
|
||||
|
||||
```bash
|
||||
|
|
@ -147,7 +151,7 @@ If a config already exists, the new project is appended. If not, one is created
|
|||
- **Project type** — language, framework, test runner, package manager
|
||||
- **Agent runtime** — which AI agents are installed (Claude Code, Codex, Aider, OpenCode)
|
||||
- **Free port** — if configured port is busy, auto-finds the next available
|
||||
- **tmux** — warns if not installed
|
||||
- **tmux** — warns if not installed (skipped on Windows; AO uses ConPTY there and tmux is not required)
|
||||
- **GitHub CLI** — checks `gh auth status`
|
||||
|
||||
### Manual Configuration
|
||||
|
|
@ -192,7 +196,7 @@ Agent Orchestrator has 8 plugin slots. All are swappable:
|
|||
|
||||
| Slot | Purpose | Default | Alternatives |
|
||||
| ------------- | -------------------- | ------------- | ----------------------------------------------- |
|
||||
| **Runtime** | How sessions run | `tmux` | `process`, `docker`, `kubernetes`, `ssh`, `e2b` |
|
||||
| **Runtime** | How sessions run | `tmux` (macOS/Linux) / `process` (Windows; ConPTY via node-pty) | `process`, `docker`, `kubernetes`, `ssh`, `e2b` |
|
||||
| **Agent** | AI coding assistant | `claude-code` | `codex`, `aider`, `goose`, custom |
|
||||
| **Workspace** | Workspace isolation | `worktree` | `clone`, `copy` |
|
||||
| **Tracker** | Issue tracking | `github` | `linear`, `jira`, custom |
|
||||
|
|
@ -288,7 +292,7 @@ Override defaults per project:
|
|||
```yaml
|
||||
projects:
|
||||
frontend:
|
||||
runtime: tmux
|
||||
runtime: tmux # default on macOS/Linux; on Windows use `process`
|
||||
agent: claude-code
|
||||
workspace: worktree
|
||||
|
||||
|
|
@ -403,7 +407,7 @@ ao doctor
|
|||
ao doctor --fix
|
||||
```
|
||||
|
||||
`ao doctor` reports deterministic PASS/WARN/FAIL checks for PATH and launcher resolution, required binaries, tmux and GitHub CLI health, stale AO temp files, config support directories, and core build/runtime sanity. `--fix` only applies safe fixes such as creating missing AO support directories, refreshing the local launcher link, and removing stale AO temp files.
|
||||
`ao doctor` reports deterministic PASS/WARN/FAIL checks for PATH and launcher resolution, required binaries, terminal-runtime health (tmux on Unix; PowerShell / `runtime-process` on Windows), GitHub CLI health, stale AO temp files, config support directories, and core build/runtime sanity. It runs and is supported on Windows. `--fix` only applies safe fixes such as creating missing AO support directories, refreshing the local launcher link, and removing stale AO temp files.
|
||||
|
||||
### Run `ao update`
|
||||
|
||||
|
|
@ -414,7 +418,7 @@ git switch main
|
|||
ao update
|
||||
```
|
||||
|
||||
`ao update` is intentionally conservative: it requires a clean working tree on `main`, fast-forwards from `origin/main`, reinstalls dependencies, clean-rebuilds the critical core/CLI/web packages, refreshes the launcher with `npm link`, and runs CLI smoke tests. Use `ao update --skip-smoke` to stop after rebuild, or `ao update --smoke-only` to rerun just the smoke checks.
|
||||
`ao update` is intentionally conservative: it requires a clean working tree on `main`, fast-forwards from `origin/main`, reinstalls dependencies, clean-rebuilds the critical core/CLI/web packages, refreshes the launcher with `npm link`, and runs CLI smoke tests. Works on macOS, Linux, and Windows (Windows uses the bundled `ao-update.ps1` script automatically). Use `ao update --skip-smoke` to stop after rebuild, or `ao update --smoke-only` to rerun just the smoke checks.
|
||||
|
||||
### "No agent-orchestrator.yaml found"
|
||||
|
||||
|
|
@ -432,7 +436,7 @@ cp examples/simple-github.yaml agent-orchestrator.yaml
|
|||
|
||||
### "tmux not found"
|
||||
|
||||
**Problem:** tmux is not installed (required for tmux runtime).
|
||||
**Problem:** tmux is not installed (required for the tmux runtime — the default on macOS and Linux).
|
||||
|
||||
**Solution:**
|
||||
|
||||
|
|
@ -447,6 +451,8 @@ sudo apt install tmux
|
|||
sudo dnf install tmux
|
||||
```
|
||||
|
||||
**On Windows:** this error should not appear in normal use. If it does, your config has `runtime: tmux` set explicitly. Switch to `runtime: process` (or remove the override — `process` is the Windows default), and AO will use ConPTY natively without tmux.
|
||||
|
||||
### "gh auth failed"
|
||||
|
||||
**Problem:** GitHub CLI is not authenticated.
|
||||
|
|
@ -682,7 +688,7 @@ notifiers:
|
|||
A session is an isolated workspace where an agent works on a single issue. Each session has:
|
||||
|
||||
- Its own git worktree or clone
|
||||
- Its own tmux session (or Docker container, etc.)
|
||||
- Its own runtime session — a tmux session on macOS/Linux, a ConPTY pty-host process on Windows (or a Docker container, etc.)
|
||||
- Its own metadata (branch, PR, status)
|
||||
- Its own event log
|
||||
|
||||
|
|
|
|||
|
|
@ -31,8 +31,9 @@ port: 3000
|
|||
# # that is still active at merge time. Default 5 min.
|
||||
|
||||
# Default plugins (these are the defaults — you can omit this section)
|
||||
# runtime defaults to 'tmux' on Linux/macOS, 'process' on Windows
|
||||
defaults:
|
||||
runtime: tmux # tmux | process
|
||||
# runtime: tmux # tmux (Linux/macOS default) | process (Windows default)
|
||||
agent: claude-code # claude-code | codex | aider | opencode | cursor | kimicode
|
||||
# orchestrator:
|
||||
# agent: claude-code
|
||||
|
|
|
|||
|
|
@ -23,11 +23,11 @@ graph TB
|
|||
|
||||
subgraph MuxServer["② WebSocket Server — :14801 (separate Node process)"]
|
||||
MuxWS["ws://host:14801/mux\nMultiplexed — two sub-channels\nover one connection"]
|
||||
TermMgr["TerminalManager\n(node-pty → tmux PTY)"]
|
||||
TermMgr["TerminalManager (Unix)\n(node-pty → tmux PTY)\n— or —\nNamed-pipe relay (Windows)\nhandleWindowsPipeMessage →\n\\\\.\\pipe\\ao-pty-{id}"]
|
||||
Broadcaster["SessionBroadcaster\n(setInterval every 3s →\nGET /api/sessions/patches)"]
|
||||
end
|
||||
|
||||
subgraph Agents["AI Agents (one tmux window each)"]
|
||||
subgraph Agents["AI Agents (one tmux window per session on Unix; one ConPTY pty-host per session on Windows)"]
|
||||
ClaudeCode["Claude Code"]
|
||||
Codex["Codex"]
|
||||
Aider["Aider"]
|
||||
|
|
@ -57,7 +57,7 @@ graph TB
|
|||
MuxWS -- "session patches\n→ useSessionEvents()\n→ useMuxSessionActivity()" --> UI
|
||||
|
||||
%% Mux auto-recovery calls back to Next.js
|
||||
TermMgr -- "① HTTP POST /api/sessions/:id/restore\n(auto-recovery when tmux dies)" --> Sessions
|
||||
TermMgr -- "① HTTP POST /api/sessions/:id/restore\n(auto-recovery when the runtime dies:\ntmux daemon on Unix, pty-host on Windows)" --> Sessions
|
||||
|
||||
%% External
|
||||
Sessions -- "REST calls" --> GitHub
|
||||
|
|
@ -110,14 +110,14 @@ sequenceDiagram
|
|||
participant XTerm as xterm.js
|
||||
participant MuxClient as MuxProvider (browser)
|
||||
participant MuxWS as WS Server :14801/mux
|
||||
participant PTY as node-pty (tmux)
|
||||
participant PTY as PTY (Unix: node-pty → tmux; Windows: named pipe → ConPTY pty-host)
|
||||
participant Next as Next.js :3000
|
||||
|
||||
MuxClient->>MuxWS: connect ws://localhost:14801/mux
|
||||
|
||||
Note over MuxClient,MuxWS: Open a terminal
|
||||
MuxClient->>MuxWS: {ch:"terminal", id:"sess-1", type:"open"}
|
||||
MuxWS->>PTY: attach tmux PTY
|
||||
MuxWS->>PTY: attach (Unix: tmux PTY; Windows: connect named pipe)
|
||||
MuxWS-->>MuxClient: {ch:"terminal", id:"sess-1", type:"opened"}
|
||||
|
||||
Note over MuxClient,MuxWS: Terminal I/O
|
||||
|
|
@ -137,7 +137,7 @@ sequenceDiagram
|
|||
Note over MuxWS,Next: Auto-recovery (session dead)
|
||||
MuxWS->>Next: POST /api/sessions/sess-1/restore
|
||||
Next-->>MuxWS: 200 OK
|
||||
MuxWS->>PTY: reattach to new tmux session
|
||||
MuxWS->>PTY: reattach (Unix: new tmux session; Windows: reopen named pipe)
|
||||
```
|
||||
|
||||
**Message types:**
|
||||
|
|
@ -182,7 +182,7 @@ graph LR
|
|||
|
||||
The CLI (`ao start`) forks two long-running processes:
|
||||
- **Next.js** on `:3000` — serves the dashboard and all REST routes
|
||||
- **Terminal WS server** on `:14801` — handles multiplexed WebSocket + PTY management + session patch polling
|
||||
- **Terminal WS server** on `:14801` — handles multiplexed WebSocket + PTY management + session patch polling. PTY transport is platform-specific: tmux via `node-pty` on Unix, named-pipe relay (`handleWindowsPipeMessage` → `\\.\pipe\ao-pty-{sessionId}`) on Windows. Both paths use the same outer mux protocol.
|
||||
|
||||
Both processes share no in-memory state; coordination happens through flat files in `~/.agent-orchestrator/` and HTTP calls from the WS server to Next.js.
|
||||
|
||||
|
|
@ -202,3 +202,132 @@ Both processes share no in-memory state; coordination happens through flat files
|
|||
| WS server restores session | HTTP POST | `:14801` → `:3000/api/sessions/:id/restore` |
|
||||
| GitHub notifies of CI / PR | HTTP POST | GitHub → `:3000/api/webhooks/github` |
|
||||
| CLI queries sessions | HTTP GET | `ao` CLI → `:3000/api/sessions` |
|
||||
|
||||
---
|
||||
|
||||
## Windows Runtime Architecture
|
||||
|
||||
On Windows the high-level component map (HTTP API, mux WS server, dashboard, flat-file storage) is identical, but the **PTY transport layer is different** because tmux is not available natively. This section describes only what's different.
|
||||
|
||||
> For the developer-facing rules of "how do I write code that works on both," see [`docs/CROSS_PLATFORM.md`](CROSS_PLATFORM.md). The section below is the architectural reference for *what was built*.
|
||||
|
||||
### Default runtime
|
||||
|
||||
`getDefaultRuntime()` from `@aoagents/ao-core` returns `"process"` on Windows and `"tmux"` everywhere else. A fresh Windows install therefore loads the `runtime-process` plugin without requiring YAML edits. Users on Unix who want the process runtime opt in via `runtime: process` in `agent-orchestrator.yaml`.
|
||||
|
||||
### The pty-host helper process
|
||||
|
||||
Because `node-pty` ConPTY sessions are tied to the lifetime of the host Node process, the orchestrator can't simply spawn ConPTY directly inside Next.js or the mux WS server: those processes restart, get killed by `taskkill /T`, etc. Instead, each AO session on Windows owns a small dedicated helper process — the **pty-host**.
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph SessionWindows["AO Session (Windows)"]
|
||||
AOStart["ao start / spawn"]
|
||||
PtyHost["pty-host.cjs<br/>(detached Node child)"]
|
||||
Pipe["Named pipe<br/>\\.\pipe\ao-pty-{sessionId}"]
|
||||
ConPty["ConPTY<br/>(node-pty)"]
|
||||
Agent["Agent process<br/>(claude-code, codex, …)"]
|
||||
end
|
||||
|
||||
AOStart -- "spawn detached" --> PtyHost
|
||||
PtyHost -- "open server" --> Pipe
|
||||
PtyHost -- "spawn" --> ConPty
|
||||
ConPty -- "PTY I/O" --> Agent
|
||||
|
||||
MuxWS["Mux WS server\nhandleWindowsPipeMessage"] -- "connect (net.Socket)" --> Pipe
|
||||
Browser["Browser xterm.js"] -- "WS frames" --> MuxWS
|
||||
```
|
||||
|
||||
Implemented in `packages/plugins/runtime-process/src/pty-host.ts` (also runnable as a `.cjs` script). Key properties:
|
||||
|
||||
- Spawned `detached: true, windowsHide: true` by `runtime-process` and `unref`'d so it survives parent exit (mirrors tmux daemon behaviour).
|
||||
- Signals readiness by printing `READY:<pid>` to stdout; the spawner waits for that line (10 s timeout) before considering the session up.
|
||||
- Maintains a 1000-line rolling output buffer, ANSI-faithful, replayed to every new client connection (this is the "scrollback on attach" equivalent of `tmux attach`).
|
||||
- Intercepts `SIGTERM`/`SIGINT`/`SIGHUP`/`SIGBREAK`/`beforeExit`/`uncaughtException`/`exit` and always calls `pty.kill()` before exiting. Without this, ConPTY's `conpty_console_list_agent.exe` orphans and triggers a Windows Error Reporting dialog (`0x800700e8`).
|
||||
|
||||
### Pipe protocol
|
||||
|
||||
The pty-host exposes a small binary protocol over `\\.\pipe\ao-pty-{sessionId}`. Messages share a 5-byte header — `[1-byte type][4-byte big-endian length]` — followed by the payload.
|
||||
|
||||
| Type | Direction | Meaning |
|
||||
|------|-----------|---------|
|
||||
| `0x01` `MSG_TERMINAL_DATA` | host → client | Raw PTY output bytes |
|
||||
| `0x02` `MSG_TERMINAL_INPUT` | client → host | User keystrokes (chunked into ≤512 chars with 15 ms gaps to avoid ConPTY input-buffer truncation) |
|
||||
| `0x03` `MSG_RESIZE` | client → host | JSON `{cols, rows}` |
|
||||
| `0x04` / `0x05` `MSG_GET_OUTPUT_REQ` / `_RES` | client ↔ host | Request and return scrollback buffer |
|
||||
| `0x06` / `0x07` `MSG_STATUS_REQ` / `_RES` | client ↔ host | Liveness check (`{alive, pid, exitCode?}`) |
|
||||
| `0x08` `MSG_KILL_REQ` | client → host | Cooperative shutdown (host disposes ConPTY then exits) |
|
||||
|
||||
Client helpers in `packages/plugins/runtime-process/src/pty-client.ts`:
|
||||
- `connectPtyHost`, `ptyHostSendMessage`, `ptyHostGetOutput`, `ptyHostIsAlive`, `ptyHostKill`, plus `getPipePath(sessionId)` → `\\.\pipe\ao-pty-{sessionId}`.
|
||||
- `MessageParser` skips interleaved data frames so request/response pairs work over a busy pipe.
|
||||
|
||||
### Mux WS server: tmux vs Windows pipe relay
|
||||
|
||||
`packages/web/server/mux-websocket.ts` branches by platform:
|
||||
|
||||
- **Unix**: instantiates `TerminalManager` (node-pty → tmux PTY) and dispatches all `terminal` channel messages to it.
|
||||
- **Windows**: skips `TerminalManager` entirely and routes through `handleWindowsPipeMessage(msg, ws, winPipes, winPipeBuffers, deps)`, which maps each `(projectId, sessionId)` to a `net.Socket` connected to its pipe. `open` opens the socket, `data` writes a `0x02` framed message, `resize` writes `0x03`, `close` ends the socket. Inbound `0x01` frames are forwarded back as WebSocket `{ch:"terminal", type:"data"}` payloads; `0x07` with `alive:false` becomes `exited`.
|
||||
- The pipe path is resolved by `resolvePipePath(sessionId, projectId?)` in `packages/web/server/tmux-utils.ts`, which reads the session's metadata file (V2 layout `~/.agent-orchestrator/projects/{projectId}/sessions/{sessionId}.json`, V1 fallback) and returns the `pipePath` field that `runtime-process` wrote at spawn time.
|
||||
- `findTmux()` returns `null` on Windows; `direct-terminal-ws.ts` logs `Windows mode — using named pipe relay to PTY hosts` and starts the same WS server with no tmux dependency.
|
||||
|
||||
### Pty-host registry — `~/.agent-orchestrator/windows-pty-hosts.json`
|
||||
|
||||
Because pty-hosts run detached, `taskkill /T` on the parent ao-start process cannot reach them. To allow `ao stop` to find and clean them up, every spawned pty-host is recorded in a small JSON registry.
|
||||
|
||||
`packages/core/src/windows-pty-registry.ts`:
|
||||
- `registerWindowsPtyHost(entry)` — write/replace the entry on spawn.
|
||||
- `getWindowsPtyHosts()` — read all entries; auto-prune any whose PID is gone (probed via `process.kill(pid, 0)`, treating `EPERM` as alive).
|
||||
- `unregisterWindowsPtyHost(sessionId)` — remove on session destroy.
|
||||
- `clearWindowsPtyHostRegistry()` — wipe (for tests / recovery).
|
||||
|
||||
`sweepWindowsPtyHosts()` (in `runtime-process`) iterates the registry: for each live entry it sends a graceful `MSG_KILL_REQ` over the pipe, polls up to 500 ms for the process to exit (treating `EPERM` as still alive), then `killProcessTree(ptyHostPid, "SIGKILL")` for stragglers. It is called by `ao stop` and `ao stop --all` before tearing down the parent process.
|
||||
|
||||
### Process map (Windows variant)
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph Host
|
||||
CLI["ao CLI"]
|
||||
Next["Next.js :3000"]
|
||||
MuxSrv["Terminal WS :14801"]
|
||||
Sweep["sweepWindowsPtyHosts()<br/>(called by ao stop)"]
|
||||
end
|
||||
|
||||
subgraph Sessions["Per-session pty-hosts (detached)"]
|
||||
PH1["pty-host #1<br/>\\.\pipe\ao-pty-id1"]
|
||||
PH2["pty-host #2<br/>\\.\pipe\ao-pty-id2"]
|
||||
end
|
||||
|
||||
subgraph Storage["Flat files"]
|
||||
Reg["~/.agent-orchestrator/<br/>windows-pty-hosts.json"]
|
||||
Meta["~/.agent-orchestrator/<br/>projects/{id}/sessions/*"]
|
||||
end
|
||||
|
||||
CLI -- "spawn detached" --> PH1
|
||||
CLI -- "spawn detached" --> PH2
|
||||
PH1 -- "register" --> Reg
|
||||
PH2 -- "register" --> Reg
|
||||
MuxSrv -- "resolvePipePath()<br/>reads metadata" --> Meta
|
||||
MuxSrv -- "net.Socket connect" --> PH1
|
||||
MuxSrv -- "net.Socket connect" --> PH2
|
||||
Sweep -- "MSG_KILL_REQ → killProcessTree" --> PH1
|
||||
Sweep -- "MSG_KILL_REQ → killProcessTree" --> PH2
|
||||
Sweep -- "read entries" --> Reg
|
||||
```
|
||||
|
||||
### Shell resolution
|
||||
|
||||
`getShell()` in `packages/core/src/platform.ts` is platform-aware and cached:
|
||||
|
||||
- **Unix**: `/bin/sh -c` (always; never `$SHELL` — non-interactive launches must not depend on the user's login shell).
|
||||
- **Windows** (`resolveWindowsShell`): in priority order — `AO_SHELL` env override → `pwsh` on PATH → `%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe` (absolute path, robust to degraded PATH) → `powershell` on PATH → `%ComSpec%` (`cmd.exe`, last resort).
|
||||
|
||||
Args are inferred from the basename: `cmd` → `/c`, `bash`/`sh`/`zsh` → `-c`, anything PowerShell-shaped → `-Command`. `AO_SHELL` is the supported escape hatch (e.g. for Git Bash users).
|
||||
|
||||
### Other Windows-specific touch points
|
||||
|
||||
- **CLI** — `ao start` no longer detaches its dashboard child on Windows (so Ctrl+C reaches the whole console group); `forwardSignalsToChild` is Unix-only. `ao stop` calls `sweepWindowsPtyHosts()` before terminating the parent. `script-runner.ts` runs `.ps1` siblings of `.sh` scripts directly on Windows; otherwise it tries `AO_BASH_PATH` then auto-detects Git Bash (WSL bash is excluded — it sees Linux paths from a Windows cwd).
|
||||
- **Agent plugins** — `setupPathWrapperWorkspace()` generates `.cjs` + `.cmd` wrapper pairs (instead of bash scripts) for `gh`/`git` interception. `formatLaunchCommand` for codex / kimicode prepends `& ` so PowerShell parses the quoted binary path as a call expression. `agent-claude-code` ships a Node.js metadata-updater (`.cjs`) hook in place of the bash version; system-prompt files are inlined rather than `$(cat …)`-substituted.
|
||||
- **Path-equality** — `packages/cli/src/lib/path-equality.ts` (`pathsEqual`, `canonicalCompareKey`) handles NTFS case-insensitivity and drive-letter case differences when comparing project paths in `ao start`.
|
||||
- **`stopStaleWindowsPtyHosts(projectDir)`** in `packages/web/src/lib/windows-pty-cleanup.ts` is a defensive sweeper used by the dashboard to clean up orphan pty-hosts found via a PowerShell `Get-CimInstance Win32_Process` query.
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ ao completion zsh # Print the zsh completion script
|
|||
|
||||
## Commands the orchestrator agent uses
|
||||
|
||||
These are primarily invoked by the orchestrator agent running inside a tmux session. You can use them manually if needed, but the orchestrator handles this automatically.
|
||||
These are primarily invoked by the orchestrator agent running inside a runtime session (a tmux window on macOS/Linux; a ConPTY pty-host on Windows). You can use them manually if needed, but the orchestrator handles this automatically.
|
||||
|
||||
```bash
|
||||
ao spawn [issue] # Spawn an agent (project auto-detected from cwd)
|
||||
|
|
@ -64,9 +64,9 @@ compinit
|
|||
With Oh My Zsh, write the generated file to `${ZSH_CUSTOM:-~/.oh-my-zsh/custom}/plugins/ao/_ao`
|
||||
and add `ao` to the `plugins=(...)` list in `~/.zshrc`.
|
||||
|
||||
`ao doctor` checks PATH and launcher resolution, required binaries, configured plugin resolution, tmux and GitHub CLI health, config support directories, stale AO temp files, and core build/runtime sanity.
|
||||
`ao doctor` checks PATH and launcher resolution, required binaries, configured plugin resolution, terminal-runtime health (tmux on Unix; PowerShell / `runtime-process` on Windows), GitHub CLI health, config support directories, stale AO temp files, and core build/runtime sanity. Runs and is supported on macOS, Linux, and Windows.
|
||||
|
||||
`ao update` fast-forwards the local install on `main`, reinstalls dependencies, clean-rebuilds core packages, refreshes the launcher, and runs smoke tests. Use `ao update --skip-smoke` to stop after rebuild, or `ao update --smoke-only` to rerun just the smoke checks.
|
||||
`ao update` fast-forwards the local install on `main`, reinstalls dependencies, clean-rebuilds core packages, refreshes the launcher, and runs smoke tests. Works on macOS, Linux, and Windows (Windows uses the bundled `ao-update.ps1` script automatically). Use `ao update --skip-smoke` to stop after rebuild, or `ao update --smoke-only` to rerun just the smoke checks.
|
||||
|
||||
## Multi-Project Rollout
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,389 @@
|
|||
# Cross-Platform Compatibility
|
||||
|
||||
> **Read this before merging any change that touches process spawning, path handling, shell commands, network binding, file I/O, runtime/agent/workspace plugins, or anything that does platform-specific work.**
|
||||
>
|
||||
> AO ships on macOS, Linux, **and Windows**. All three are first-class — every change must keep all three working.
|
||||
|
||||
---
|
||||
|
||||
## The Golden Rule
|
||||
|
||||
> **Never write `process.platform === "win32"` in new code. Use `isWindows()` from `@aoagents/ao-core`. If you need branching the helper doesn't cover, add it to `packages/core/src/platform.ts` (or one of the targeted helpers in [the inventory](#helper-inventory)) — never inline at the call site.**
|
||||
|
||||
This isn't stylistic. The branching in `platform.ts` is centrally tested with `Object.defineProperty(process, "platform", …)` so both Windows and POSIX paths are exercised on every CI runner. Inline `process.platform` checks are invisible to that test pattern, drift out of sync, and produce the bugs that took weeks to track down on the way to shipping the Windows port.
|
||||
|
||||
If you find yourself typing `process.platform`:
|
||||
|
||||
1. Stop. Look at the [helper inventory below](#helper-inventory) — almost certainly the helper you need already exists.
|
||||
2. If it doesn't, ask: "Could a future feature also need this branch?" Almost always yes. Add a function to `platform.ts` (or the closest existing helper module) and test both branches.
|
||||
3. Only if the branch is genuinely a one-off (e.g. a single test guarding a Linux-only assertion) is an inline check acceptable, and even then prefer `isWindows()` for readability.
|
||||
|
||||
---
|
||||
|
||||
## When to read this file
|
||||
|
||||
If your change does **any** of the following, you must read the relevant section below:
|
||||
|
||||
| If you're touching… | …read |
|
||||
|---------------------|-------|
|
||||
| `process.spawn`, `child_process`, runtime plugins | [The two runtimes](#the-two-runtimes), [Process management](#process-management-gotchas) |
|
||||
| `process.kill`, signals, process-tree teardown | [Process management](#process-management-gotchas) |
|
||||
| Anything with file paths (compare, join, walk) | [Paths](#paths) |
|
||||
| Shell commands (`exec`, command strings) | [Shell](#shell) |
|
||||
| `server.listen`, sockets, `localhost` | [Networking](#networking) |
|
||||
| tmux / lsof / pkill / which / coreutils shell-outs | [POSIX-only tools](#posix-only-tools) |
|
||||
| Adding a new `if (process.platform === "win32")` | [The Golden Rule](#the-golden-rule), [Helper inventory](#helper-inventory) |
|
||||
| Agent plugins (PATH wrappers, hooks, launch commands) | [Agent plugin helpers](#agent-plugin-helpers) |
|
||||
| Activity detection / JSONL processing | [Activity-state helpers](#activity-state-helpers) |
|
||||
| Tests for any of the above | [Testing for cross-platform behaviour](#testing-for-cross-platform-behaviour) |
|
||||
| Anything else? | At minimum, the [pre-merge checklist](#pre-merge-checklist) |
|
||||
|
||||
---
|
||||
|
||||
## Helper inventory
|
||||
|
||||
Every helper you need to write Windows-safe code. **Memorise the imports — these are the building blocks.**
|
||||
|
||||
### Platform check + defaults — `packages/core/src/platform.ts`
|
||||
|
||||
```ts
|
||||
import {
|
||||
isWindows,
|
||||
getDefaultRuntime,
|
||||
getShell,
|
||||
killProcessTree,
|
||||
findPidByPort,
|
||||
getEnvDefaults,
|
||||
} from "@aoagents/ao-core";
|
||||
```
|
||||
|
||||
| Symbol | Purpose | Notes |
|
||||
|--------|---------|-------|
|
||||
| `isWindows(): boolean` | The canonical OS check. **Always use this** instead of `process.platform === "win32"`. | Constant-time. Trivially mockable in tests. |
|
||||
| `getDefaultRuntime(): "tmux" \| "process"` | Returns `"process"` on Windows, `"tmux"` elsewhere. Used by `ao start` / startup-preflight to default runtime selection. | Don't hardcode `"tmux"`. |
|
||||
| `getShell(): { cmd, args(command) }` | Resolves the shell for non-interactive command execution. POSIX → `/bin/sh -c`. Windows → priority order: `AO_SHELL` env override → `pwsh` → `powershell.exe` (absolute path, robust to degraded PATH) → `powershell` → `cmd.exe`. Cached. | Use this whenever you need to run *any* shellish string. Don't assume bash. |
|
||||
| `killProcessTree(pid, signal?)` | Kills a process and its descendants. Windows → `taskkill /T /F /PID <pid>`. POSIX → `process.kill(-pid, signal)` with direct-PID fallback. Guards `pid > 0`. | **Never write `process.kill(-pid, …)` directly.** Negative PIDs are POSIX-only. |
|
||||
| `findPidByPort(port): Promise<string \| null>` | Finds the LISTENING PID on a port. Windows → parses `netstat -ano`. POSIX → `lsof -ti :PORT -sTCP:LISTEN`. | Use this; don't shell-out yourself. |
|
||||
| `getEnvDefaults(): { HOME, SHELL, TMPDIR, PATH, USER }` | Returns platform-correct env defaults: Windows reads `USERPROFILE`/`TEMP`/`USERNAME`, POSIX reads `HOME`/`SHELL`/`TMPDIR`/`USER`. | Use instead of hardcoding `/tmp`, `~`, `$HOME`. |
|
||||
| `_resetShellCache()` | Test-only — clears the cached shell resolution. | `@internal`. |
|
||||
|
||||
### Path equality — `packages/cli/src/lib/path-equality.ts`
|
||||
|
||||
```ts
|
||||
import { pathsEqual, canonicalCompareKey } from "../../src/lib/path-equality.js";
|
||||
```
|
||||
|
||||
| Symbol | Purpose |
|
||||
|--------|---------|
|
||||
| `pathsEqual(a, b): boolean` | "Same filesystem entry" comparison. Resolves both via `realpathSync` (falls back to literal on error), then lowercases on Windows so `D:\Foo` == `d:\foo`. |
|
||||
| `canonicalCompareKey(input): string` | Stable Map/Set key for a path. Expands `~`, resolves to absolute, calls `realpathSync`, lowercases on Windows. |
|
||||
|
||||
**Rule:** never compare paths with `===`. Always go through these.
|
||||
|
||||
### Windows pty-host registry — `packages/core/src/windows-pty-registry.ts`
|
||||
|
||||
Only used by Windows runtime code, but exported from `@aoagents/ao-core` so the CLI's `ao stop` can find detached pty-hosts that `taskkill /T` cannot reach.
|
||||
|
||||
```ts
|
||||
import {
|
||||
registerWindowsPtyHost,
|
||||
unregisterWindowsPtyHost,
|
||||
getWindowsPtyHosts,
|
||||
clearWindowsPtyHostRegistry,
|
||||
} from "@aoagents/ao-core";
|
||||
```
|
||||
|
||||
| Symbol | Purpose |
|
||||
|--------|---------|
|
||||
| `registerWindowsPtyHost(entry)` | Add/replace a `{sessionId, ptyHostPid, pipePath}` entry in `~/.agent-orchestrator/windows-pty-hosts.json`. Called when `runtime-process` spawns a pty-host. |
|
||||
| `unregisterWindowsPtyHost(sessionId)` | Remove on session destroy. |
|
||||
| `getWindowsPtyHosts(): WindowsPtyHostEntry[]` | Return all entries whose PID is still alive (probes via `process.kill(pid, 0)` treating `EPERM` as alive). Auto-prunes dead ones. |
|
||||
| `clearWindowsPtyHostRegistry()` | Wipe the file (recovery / tests). |
|
||||
|
||||
### Pty-host client (Windows pipe protocol) — `packages/plugins/runtime-process/src/pty-client.ts`
|
||||
|
||||
Use these whenever you need to talk to a Windows pty-host over its named pipe. The mux WS server, `runtime-process`, and `sweepWindowsPtyHosts` all go through this module — never write to a `\\.\pipe\…` directly.
|
||||
|
||||
```ts
|
||||
import {
|
||||
getPipePath,
|
||||
connectPtyHost,
|
||||
ptyHostSendMessage,
|
||||
ptyHostGetOutput,
|
||||
ptyHostIsAlive,
|
||||
ptyHostKill,
|
||||
MessageParser,
|
||||
encodeMessage,
|
||||
} from "@aoagents/ao-plugin-runtime-process";
|
||||
```
|
||||
|
||||
| Symbol | Purpose |
|
||||
|--------|---------|
|
||||
| `getPipePath(sessionId)` | Returns `\\.\pipe\ao-pty-<sessionId>`. Don't construct the path manually. |
|
||||
| `connectPtyHost(pipePath, timeoutMs?)` | Open a `net.Socket` to the named pipe with timeout. |
|
||||
| `ptyHostSendMessage(pipePath, message)` | Send keystrokes; chunks into ≤512-char pieces with 15 ms gaps to dodge ConPTY input-buffer truncation. |
|
||||
| `ptyHostGetOutput(pipePath, lines?)` | Request scrollback buffer. Returns `""` on timeout. |
|
||||
| `ptyHostIsAlive(pipePath)` | Liveness probe; `true` ≡ pipe reachable. |
|
||||
| `ptyHostKill(pipePath)` | Cooperative shutdown (host disposes ConPTY then exits). Silently succeeds if pipe is unreachable. |
|
||||
| `MessageParser`, `encodeMessage` | Frame-protocol primitives if you're writing new pty-host integrations. |
|
||||
|
||||
### Pty-host sweep — `packages/plugins/runtime-process/src/index.ts`
|
||||
|
||||
```ts
|
||||
import { sweepWindowsPtyHosts } from "@aoagents/ao-plugin-runtime-process";
|
||||
```
|
||||
|
||||
`sweepWindowsPtyHosts(): Promise<{ attempted, gracefullyExited, forceKilled, failed }>` — iterates the registry, sends graceful `MSG_KILL_REQ`, polls up to 500 ms, then `killProcessTree` for stragglers. Called by `ao stop`. **No-op on non-Windows.**
|
||||
|
||||
The exit-poll inside this function is the canonical EPERM/ESRCH pattern — copy it whenever you probe a Windows process for liveness:
|
||||
|
||||
```ts
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
process.kill(entry.ptyHostPid, 0);
|
||||
} catch (err: unknown) {
|
||||
// EPERM = alive but unsignalable (cross-context on Windows) → fall through to force-kill.
|
||||
// ESRCH (or anything else) = process is gone → mark exited.
|
||||
if ((err as { code?: string }).code !== "EPERM") {
|
||||
exited = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 25));
|
||||
}
|
||||
```
|
||||
|
||||
### Web-side helpers
|
||||
|
||||
```ts
|
||||
// packages/web/server/tmux-utils.ts
|
||||
import { validateSessionId, resolvePipePath } from "@/server/tmux-utils";
|
||||
|
||||
// packages/web/src/lib/windows-pty-cleanup.ts
|
||||
import { stopStaleWindowsPtyHosts } from "@/lib/windows-pty-cleanup";
|
||||
```
|
||||
|
||||
| Symbol | Purpose |
|
||||
|--------|---------|
|
||||
| `validateSessionId(id): boolean` | Charset/length guard. **Always validate any session ID before using it in a tmux command, named-pipe path, or shell argument** — these are user-controllable inputs. |
|
||||
| `resolvePipePath(sessionId, projectId?)` | Reads the session metadata file and returns the `pipePath` field stored by `runtime-process`. Returns `null` on non-Windows. Used by the mux WS server when relaying pipe traffic. |
|
||||
| `stopStaleWindowsPtyHosts(projectDir)` | Defensive sweeper. Uses a PowerShell `Get-CimInstance Win32_Process` query to find pty-hosts whose command line contains a project dir, then `taskkill`'s them. No-op on non-Windows. Use as a recovery escape hatch, not in the hot path. |
|
||||
|
||||
### Agent plugin helpers — `packages/core/src/agent-workspace-hooks.ts`
|
||||
|
||||
```ts
|
||||
import { setupPathWrapperWorkspace, buildAgentPath } from "@aoagents/ao-core";
|
||||
```
|
||||
|
||||
| Symbol | Purpose |
|
||||
|--------|---------|
|
||||
| `setupPathWrapperWorkspace(workspacePath)` | Installs `~/.ao/bin` PATH wrappers for `gh` / `git` so AO can intercept agent commands. **Cross-platform.** On Windows it generates `.cjs` + `.cmd` wrapper pairs (skipping bash); on Unix it generates the bash equivalents. Every agent plugin that uses PATH-wrapper interception (codex, kimicode, aider, opencode) must call this — never reimplement. |
|
||||
| `buildAgentPath(basePath?)` | Prepends `~/.ao/bin` to PATH using the right separator (`;` on Windows, `:` on Unix). Use when constructing the agent's env. |
|
||||
|
||||
### Activity-state helpers — `packages/core/src/activity-log.ts` and `utils.ts`
|
||||
|
||||
```ts
|
||||
import {
|
||||
appendActivityEntry,
|
||||
readLastActivityEntry,
|
||||
checkActivityLogState,
|
||||
getActivityFallbackState,
|
||||
classifyTerminalActivity,
|
||||
recordTerminalActivity,
|
||||
readLastJsonlEntry,
|
||||
} from "@aoagents/ao-core";
|
||||
```
|
||||
|
||||
`getActivityFallbackState` is **mandatory** for new agent plugins. See [the agent-plugin section in the root CLAUDE.md](../CLAUDE.md#agent-plugin-implementation-standards) for the full contract — but the relevant cross-platform note is: AO activity JSONL works the same on all platforms, so write your activity-detection logic against it, not against tmux capture-pane / ps output.
|
||||
|
||||
### Shell escaping — `packages/core/src/utils.ts`
|
||||
|
||||
```ts
|
||||
import { shellEscape } from "@aoagents/ao-core";
|
||||
```
|
||||
|
||||
`shellEscape(arg)` produces a safely-quoted argument. Always use it when interpolating any value into a shell command line, even on Windows. Windows quoting rules are messier than POSIX and the helper handles them.
|
||||
|
||||
### CLI signal forwarding — `packages/cli/src/lib/shell.ts`
|
||||
|
||||
```ts
|
||||
import { forwardSignalsToChild } from "../lib/shell.js";
|
||||
```
|
||||
|
||||
`forwardSignalsToChild(pid, child)` — call **only on POSIX** (`if (!isWindows() && pid)`). On Windows, Ctrl+C reaches the entire console group natively; explicit forwarding is harmful (double-signals).
|
||||
|
||||
### Environment variables to know
|
||||
|
||||
| Variable | Effect |
|
||||
|----------|--------|
|
||||
| `AO_SHELL` | Override `getShell()` resolution. Set to an absolute path or shell name (`pwsh`, `cmd`, `bash`, …). Args are inferred from basename. The supported escape hatch for Git Bash users on Windows. |
|
||||
| `AO_BASH_PATH` | Used by `script-runner.ts` on Windows to locate bash before falling back to Git Bash auto-detection. WSL bash is intentionally excluded. |
|
||||
|
||||
---
|
||||
|
||||
## The two runtimes
|
||||
|
||||
| Platform | Default runtime | How PTYs work |
|
||||
|----------|----------------|---------------|
|
||||
| macOS / Linux | `tmux` | Real tmux server, POSIX signals, Unix sockets |
|
||||
| Windows | `process` | `node-pty` + ConPTY, named pipes (`\\.\pipe\ao-pty-…`), pty-host helper process |
|
||||
|
||||
Pick the runtime via `getDefaultRuntime()`, never hardcode. Plugin code that runs across runtimes must handle both — for Windows that means no `tmux` shell-outs, no SIGTERM/SIGKILL group kills, no POSIX-only tools.
|
||||
|
||||
For the architectural detail of how the Windows pty-host, named-pipe protocol, and mux WS Windows branch fit together, see the **"Windows Runtime Architecture"** section at the bottom of [`docs/ARCHITECTURE.md`](ARCHITECTURE.md).
|
||||
|
||||
---
|
||||
|
||||
## Process management gotchas
|
||||
|
||||
- **`process.kill(pid, 0)` distinguishes liveness on POSIX, but on Windows it can throw `EPERM`** when the target exists in a different security context. Treat `EPERM` as *alive but unsignalable* (fall through to force-kill); only `ESRCH` (or any other code) means the process is gone. The pattern is shown in the [`sweepWindowsPtyHosts` snippet above](#pty-host-sweep--packagespluginsruntime-processsrcindexts) — copy it, don't bare-`catch`. The same pattern lives in `runtime-process` `destroy()` (around line 290) and was the bug fix that prompted this section.
|
||||
- **Never `process.kill(-pid, …)`** to kill a process group. Negative PIDs are POSIX-only and become a no-op or worse on Windows. Use `killProcessTree()`.
|
||||
- **Graceful shutdown before SIGKILL on Windows**: SIGKILL'ing the pty-host while ConPTY is mid-spawn orphans `conpty_console_list_agent.exe` and triggers a Windows Error Reporting dialog (`0x800700e8`). Send the cooperative kill (`ptyHostKill`) first, poll for exit ~500 ms, **then** `killProcessTree`.
|
||||
- **`pid <= 0` guard**: `process.kill(0, …)` signals the *current process group* on Unix. Always guard `pid > 0` before signalling.
|
||||
- **Detached children**: on Windows `ao start` does NOT detach its dashboard child (so Ctrl+C reaches the whole console group natively); on POSIX it does. Use `detached: !isWindows()` rather than always-`true` or always-`false`.
|
||||
|
||||
## Paths
|
||||
|
||||
- **Filesystem case-insensitive on Windows (NTFS) and macOS (default APFS)**, case-sensitive on Linux. `D:\Foo` and `d:\foo` are the same directory; `/foo` and `/Foo` are not. Compare paths via `pathsEqual()`, never `===`.
|
||||
- **Always use `path.join()` / `path.sep`**. Never hardcode `/` or `\` separators. Never split paths on `/` to walk segments.
|
||||
- **Drive letters and UNC paths exist.** A path can start with `C:\`, `\\?\C:\`, `\\server\share\`, or `D:`. Don't assume paths begin with `/`.
|
||||
- **Paths can contain spaces** (`C:\Program Files\…`, `C:\Users\Some Name\…`). Always quote when interpolating into shell commands; prefer `execFile` over `exec`.
|
||||
- **HOME / tmp paths differ**: use `getEnvDefaults()` rather than hardcoding `/tmp`, `~`, or `$HOME`.
|
||||
- **Drive-letter slugs**: when encoding a path as a filename slug (used by Claude Code's session-JSONL lookup), `C:\Users\dev\project` → `C--Users-dev-project`. Preserve the leading drive-letter dash; don't strip the colon-replacement.
|
||||
|
||||
## Shell
|
||||
|
||||
- **Default shell on Windows is PowerShell**, not bash. Bash syntax (`&&` chains, `$VAR`, `2>/dev/null`, here-docs) won't work in `cmd.exe` and is only partially supported by PowerShell. When you need to run *anything* shellish from Node, prefer `execFile` with explicit args; if you must use a shell, route through `getShell()`.
|
||||
- **PowerShell call operator**: a launch command that begins with a quoted absolute path needs `& ` prepended on Windows (e.g. `& "C:\path\to\bin.exe" arg1`) or PowerShell parses the quoted path as a string expression. The `agent-codex` and `agent-kimicode` plugins do this in `formatLaunchCommand`.
|
||||
- **No `/dev/null`** on Windows — use `NUL`, or just discard the stream in Node.
|
||||
- **Env vars in PowerShell**: `$env:NAME`, not `$NAME`. Line continuation is backtick (`` ` ``), not backslash.
|
||||
- **`.cmd` / `.bat` / `.exe` shims**: spawning npm-installed CLIs (e.g. `codex`, `where`) needs `shell: true` on Windows so `PATHEXT` is consulted; otherwise Node only finds extensionless executables. Pattern: `spawn(cmd, args, { shell: isWindows(), windowsHide: true })`.
|
||||
- **`windowsHide: true`** on every `spawn`/`execFile` you don't want flashing a console window.
|
||||
- **Always `shellEscape()`** any value that ends up in a shell command line, even on Windows. Windows quoting rules are tricky and the helper handles them.
|
||||
- **Avoid pipes / redirection in shell strings** — they don't behave consistently across cmd.exe / PowerShell / bash. Build the pipeline in Node with stream APIs instead.
|
||||
- **`$(cat …)` substitution** doesn't exist in PowerShell or cmd.exe. If you're inlining a file's contents into a command line, read it in Node and pass the contents as an argument (e.g. `--append-system-prompt <content>`).
|
||||
|
||||
## Networking
|
||||
|
||||
- **Bind to `127.0.0.1` explicitly, not `localhost`**, when starting local servers. On Windows `localhost` resolves to `::1` first; if the server only listens on IPv4 the client stalls ~21 s before the kernel falls back. The same problem reverses if you bind IPv6-only.
|
||||
- **Named pipes** are the Windows IPC primitive (`\\.\pipe\…`); the relay code already handles them in `mux-websocket.ts` via `handleWindowsPipeMessage`. Don't introduce Unix-socket assumptions in new code paths.
|
||||
- **Firewall prompts**: any `0.0.0.0` bind on Windows can pop a Windows Defender Firewall prompt the first time it runs. Stick to loopback unless there's a real reason.
|
||||
- **Pipe path injection**: a pipe path is constructed from a session ID; always validate that ID with `validateSessionId()` before passing to `getPipePath()` or interpolating into any system call.
|
||||
|
||||
## POSIX-only tools
|
||||
|
||||
`tmux`, `screen`, `lsof`, `pkill`, `which`, most coreutils — gone on Windows. If you need their function, either branch through `platform.ts` or use a Node API instead.
|
||||
|
||||
Examples already in `platform.ts`:
|
||||
- `findPidByPort` uses `netstat -ano` on Windows vs `lsof` elsewhere
|
||||
- `killProcessTree` uses `taskkill /T /F` vs POSIX signal-based kill
|
||||
- `getShell` resolves PowerShell on Windows vs `/bin/sh` on POSIX
|
||||
|
||||
If you find yourself reaching for a POSIX-only binary in new code, **add the Windows alternative to `platform.ts`** rather than gating the feature.
|
||||
|
||||
## Agent plugin specifics (Windows)
|
||||
|
||||
When writing or modifying an agent plugin (`packages/plugins/agent-*`), these are the patterns to follow:
|
||||
|
||||
- **Use `setupPathWrapperWorkspace`** for PATH-wrapper interception (gh / git). It auto-handles bash vs `.cmd`+`.cjs` wrappers per platform.
|
||||
- **`isProcessRunning`** must short-circuit on Windows when it would have used tmux or `ps -eo`: `if (isWindows()) return false` (or implement a real Windows check via tasklist / signal-0 with EPERM handling — never assume tmux exists).
|
||||
- **`detect()`** spawn options should be `{ shell: isWindows(), windowsHide: true }` so `.cmd` shims resolve via `PATHEXT` and no console window flashes.
|
||||
- **Stderr suppression** — the cursor plugin's `detect()` previously bled stderr to the user's console on Windows; it now uses `stdio: ['ignore', 'pipe', 'ignore']` for the probe. Match that pattern.
|
||||
- **`getCachedProcessList()`** (Claude Code) should return `""` on Windows — `ps -eo` doesn't exist.
|
||||
- **`formatLaunchCommand`**: when the binary is at a quoted absolute path, prepend `& ` on Windows so PowerShell parses it as a call.
|
||||
- **`systemPromptFile`**: instead of `$(cat <file>)` shell substitution, read the file in Node and inline as `--append-system-prompt <content>`.
|
||||
- **Codex binary resolution**: prefer `.cmd` shims (npm) over `.exe` (Cargo) on Windows; use `where.exe` (not `which`).
|
||||
|
||||
## Activity-state helpers
|
||||
|
||||
The activity-detection contract in CLAUDE.md is platform-agnostic — same JSONL on all platforms — but the inputs (terminal output) come from different runtimes. Use `recordTerminalActivity` from core (which delegates to `classifyTerminalActivity` → `appendActivityEntry`) so you don't have to think about platform.
|
||||
|
||||
The mandatory `getActivityFallbackState` step (see CLAUDE.md "Activity detection architecture") is what keeps the dashboard alive when a native agent API is unavailable — which on Windows happens more often than on Unix because more things shell-out and fail silently. Skipping it has historically broken stuck-detection on Windows.
|
||||
|
||||
---
|
||||
|
||||
## Testing for cross-platform behaviour
|
||||
|
||||
CI runs on Linux, macOS, and Windows. To make platform-specific code reviewable in a single host environment and to catch regressions even when one runner is unavailable:
|
||||
|
||||
- Any new function in `platform.ts` (or platform-branching elsewhere) must have **both** an `it.skipIf(process.platform !== "win32")` test and a POSIX test. See `packages/cli/__tests__/lib/path-equality.test.ts` for the pattern (it mocks `process.platform` via `Object.defineProperty` to exercise both branches on a single CI host).
|
||||
- For process-kill / EPERM-handling code, add a unit test that simulates `process.kill` throwing `{ code: "EPERM" }` and asserts force-kill is still attempted. The `runtime-process` test suite has examples (look for "win32 destroy when graceful shutdown times out").
|
||||
- Plugin tests that hit a tmux runtime must `skipIf(isWindows())`. Plugin tests that hit `runtime-process` should run on all platforms.
|
||||
- For path code, test mixed-case inputs and inputs with spaces.
|
||||
|
||||
Pattern for mocking platform on Linux CI:
|
||||
|
||||
```ts
|
||||
let originalPlatform: PropertyDescriptor | undefined;
|
||||
beforeEach(() => {
|
||||
originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
|
||||
});
|
||||
afterEach(() => {
|
||||
if (originalPlatform) Object.defineProperty(process, "platform", originalPlatform);
|
||||
});
|
||||
function setPlatform(p: NodeJS.Platform) {
|
||||
Object.defineProperty(process, "platform", { value: p, configurable: true });
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pre-merge checklist
|
||||
|
||||
Before saying "done" on any feature, verify each of these (or mark N/A with reasoning):
|
||||
|
||||
1. **No raw `process.platform` checks** — used `isWindows()` from `@aoagents/ao-core`?
|
||||
2. **Process spawning** — used `runtime-process` (Windows) or `runtime-tmux` (POSIX) abstractions? Shell-out used `shellEscape` + `getShell` or `execFile`? `windowsHide: true` and `shell: isWindows()` for `.cmd`/`.bat` resolution?
|
||||
3. **Process killing** — distinguished `EPERM` from `ESRCH`? No negative PIDs? Used `killProcessTree`? Guarded `pid > 0`? Cooperative kill before force-kill on Windows?
|
||||
4. **Paths** — used `pathsEqual` for comparison? `path.join` for construction? No `===`, no hardcoded `/` or `\`?
|
||||
5. **Shell** — no bash-isms (`&&` chains, `$(cat)`, `$VAR`, `/dev/null`)? `& ` prefix for quoted-path PowerShell calls? Routed through `getShell()` or used `execFile`?
|
||||
6. **Networking** — explicit `127.0.0.1` instead of `localhost`? Validated session IDs before constructing pipe paths?
|
||||
7. **Runtimes** — both `runtime-tmux` and `runtime-process` paths covered? `isProcessRunning` works for tmux TTY *and* PID signal-0 *with EPERM handling*?
|
||||
8. **Agent plugins** — `setupPathWrapperWorkspace` instead of bash hooks? `getActivityFallbackState` fallback in `getActivityState`?
|
||||
9. **New platform branching** — went into `platform.ts` (or another shared helper), not inline at call sites?
|
||||
10. **Tests** — both Windows and POSIX branches covered (mock `process.platform` if you can't run on both)?
|
||||
|
||||
If you can't say "yes" or "N/A" to all ten, your change probably breaks Windows.
|
||||
|
||||
---
|
||||
|
||||
## Quick reference: "where do I import X from?"
|
||||
|
||||
```ts
|
||||
// Platform check, runtime/shell/env defaults, process kill, port lookup
|
||||
import {
|
||||
isWindows, getDefaultRuntime, getShell,
|
||||
killProcessTree, findPidByPort, getEnvDefaults,
|
||||
shellEscape,
|
||||
setupPathWrapperWorkspace, buildAgentPath,
|
||||
registerWindowsPtyHost, unregisterWindowsPtyHost,
|
||||
getWindowsPtyHosts, clearWindowsPtyHostRegistry,
|
||||
appendActivityEntry, readLastActivityEntry,
|
||||
checkActivityLogState, getActivityFallbackState,
|
||||
classifyTerminalActivity, recordTerminalActivity,
|
||||
readLastJsonlEntry,
|
||||
} from "@aoagents/ao-core";
|
||||
|
||||
// Path comparison (CLI package)
|
||||
import { pathsEqual, canonicalCompareKey }
|
||||
from "../../src/lib/path-equality.js";
|
||||
|
||||
// Windows pty-host pipe protocol + sweep
|
||||
import {
|
||||
getPipePath, connectPtyHost, ptyHostSendMessage,
|
||||
ptyHostGetOutput, ptyHostIsAlive, ptyHostKill,
|
||||
MessageParser, encodeMessage,
|
||||
sweepWindowsPtyHosts,
|
||||
} from "@aoagents/ao-plugin-runtime-process";
|
||||
|
||||
// Web-side helpers
|
||||
import { validateSessionId, resolvePipePath }
|
||||
from "@/server/tmux-utils";
|
||||
import { stopStaleWindowsPtyHosts }
|
||||
from "@/lib/windows-pty-cleanup";
|
||||
|
||||
// CLI-only signal forwarding (POSIX only — guard with !isWindows())
|
||||
import { forwardSignalsToChild } from "../lib/shell.js";
|
||||
```
|
||||
|
||||
If a helper you need isn't in this list, that's a strong signal you should add it to `platform.ts` (or the closest existing module) rather than write platform-branching at the call site.
|
||||
|
|
@ -22,7 +22,7 @@ Every abstraction is a swappable plugin. All interfaces are defined in [`package
|
|||
|
||||
| Slot | Interface | Default | Alternatives |
|
||||
| --------- | ----------- | ------------- | ---------------------------------------- |
|
||||
| Runtime | `Runtime` | `tmux` | `process`, `docker`, `k8s`, `ssh`, `e2b` |
|
||||
| Runtime | `Runtime` | `tmux` (Unix) / `process` (Windows; ConPTY via node-pty) | `process`, `docker`, `k8s`, `ssh`, `e2b` |
|
||||
| Agent | `Agent` | `claude-code` | `codex`, `aider`, `cursor`, `kimicode`, `opencode` |
|
||||
| Workspace | `Workspace` | `worktree` | `clone` |
|
||||
| Tracker | `Tracker` | `github` | `linear` |
|
||||
|
|
@ -44,7 +44,7 @@ const dataDir = `~/.agent-orchestrator/${instanceId}`;
|
|||
This means:
|
||||
|
||||
- Multiple orchestrator checkouts on the same machine never collide
|
||||
- Session names are globally unique in tmux: `{hash}-{prefix}-{num}`
|
||||
- Runtime handles are globally unique: `{hash}-{prefix}-{num}` (tmux session name on Unix; suffix of the named pipe `\\.\pipe\ao-pty-{sessionId}` on Windows)
|
||||
- User-facing names stay clean: `ao-1`, `myapp-2`
|
||||
|
||||
### Session Lifecycle
|
||||
|
|
@ -388,8 +388,11 @@ cat ~/.agent-orchestrator/{hash}-{project}/sessions/{session-id}
|
|||
# Check API state
|
||||
curl http://localhost:3000/api/sessions/{session-id}
|
||||
|
||||
# Attach to tmux session directly
|
||||
# Attach to the runtime session directly
|
||||
# Unix:
|
||||
tmux attach -t {hash}-{prefix}-{num}
|
||||
# Windows: there's no tmux. Use the AO command, which connects to \\.\pipe\ao-pty-<sessionId>:
|
||||
ao session attach <sessionId>
|
||||
|
||||
# Enable verbose logging
|
||||
AO_LOG_LEVEL=debug ao start
|
||||
|
|
@ -469,10 +472,10 @@ Debuggability: `cat ~/.agent-orchestrator/a3b4-myapp/sessions/ao-1` shows full s
|
|||
Simpler local setup (no ngrok), survives orchestrator restarts, works offline. CI/review state is fetched, not pushed.
|
||||
|
||||
**Why plugin slots?**
|
||||
Swappability: use tmux locally, Docker in CI, Kubernetes in prod — without changing application code. Testability: mock any plugin in unit tests. Extensibility: users add company-specific plugins without forking.
|
||||
Swappability: use `process` (ConPTY) on Windows, tmux on Linux/macOS, Docker in CI, Kubernetes in prod — without changing application code. The `Runtime` interface is the layer that lets the same agent/workspace/tracker stack run across all of them. Testability: mock any plugin in unit tests. Extensibility: users add company-specific plugins without forking.
|
||||
|
||||
**Why hash-based namespacing?**
|
||||
Multiple orchestrator checkouts on the same machine don't collide in tmux or on disk. Different checkouts get different hashes; projects within the same config share a hash.
|
||||
Multiple orchestrator checkouts on the same machine don't collide at the runtime layer (tmux session names on Unix, named-pipe paths on Windows) or on disk. Different checkouts get different hashes; projects within the same config share a hash.
|
||||
|
||||
**Why ESM with `.js` extensions?**
|
||||
Node.js ESM requires explicit extensions on local imports. All packages use `"type": "module"`. Missing extensions cause runtime errors.
|
||||
|
|
|
|||
|
|
@ -3,9 +3,10 @@ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync } from "node:
|
|||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
const { mockExec, mockExecSilent } = vi.hoisted(() => ({
|
||||
const { mockExec, mockExecSilent, mockFindPidByPort } = vi.hoisted(() => ({
|
||||
mockExec: vi.fn(),
|
||||
mockExecSilent: vi.fn(),
|
||||
mockFindPidByPort: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/shell.js", () => ({
|
||||
|
|
@ -13,6 +14,15 @@ vi.mock("../../src/lib/shell.js", () => ({
|
|||
execSilent: mockExecSilent,
|
||||
}));
|
||||
|
||||
vi.mock("@aoagents/ao-core", async (importOriginal) => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||
const actual = await importOriginal<typeof import("@aoagents/ao-core")>();
|
||||
return {
|
||||
...actual,
|
||||
findPidByPort: mockFindPidByPort,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("ora", () => ({
|
||||
default: () => ({
|
||||
start: vi.fn().mockReturnThis(),
|
||||
|
|
@ -29,6 +39,7 @@ beforeEach(() => {
|
|||
tmpDir = mkdtempSync(join(tmpdir(), "ao-dashboard-test-"));
|
||||
mockExec.mockReset();
|
||||
mockExecSilent.mockReset();
|
||||
mockFindPidByPort.mockReset();
|
||||
mockExec.mockResolvedValue({ stdout: "", stderr: "" });
|
||||
});
|
||||
|
||||
|
|
@ -68,27 +79,6 @@ describe("cleanNextCache", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("findRunningDashboardPid", () => {
|
||||
it("returns PID when a process is listening", async () => {
|
||||
mockExecSilent.mockResolvedValue("12345");
|
||||
|
||||
const { findRunningDashboardPid } = await import("../../src/lib/dashboard-rebuild.js");
|
||||
|
||||
const pid = await findRunningDashboardPid(3000);
|
||||
expect(pid).toBe("12345");
|
||||
expect(mockExecSilent).toHaveBeenCalledWith("lsof", ["-ti", ":3000", "-sTCP:LISTEN"]);
|
||||
});
|
||||
|
||||
it("returns null when no process is listening", async () => {
|
||||
mockExecSilent.mockResolvedValue(null);
|
||||
|
||||
const { findRunningDashboardPid } = await import("../../src/lib/dashboard-rebuild.js");
|
||||
|
||||
const pid = await findRunningDashboardPid(3000);
|
||||
expect(pid).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isInstalledUnderNodeModules", () => {
|
||||
it("returns true for a Unix node_modules path segment", async () => {
|
||||
const { isInstalledUnderNodeModules } = await import("../../src/lib/dashboard-rebuild.js");
|
||||
|
|
@ -285,7 +275,9 @@ describe("looksLikeStaleBuild pattern matching", () => {
|
|||
});
|
||||
|
||||
describe("findRunningDashboardPidsForWebDir", () => {
|
||||
it("returns only listeners whose cwd matches the web directory", async () => {
|
||||
// Unix-only: Windows code path skips lsof and uses findPidByPort (no cwd check),
|
||||
// by design — see findRunningDashboardPidsForWebDir in dashboard-rebuild.ts.
|
||||
it.skipIf(process.platform === "win32")("returns only listeners whose cwd matches the web directory", async () => {
|
||||
const webDir = join(tmpDir, "packages", "web");
|
||||
mkdirSync(webDir, { recursive: true });
|
||||
|
||||
|
|
@ -302,7 +294,7 @@ describe("findRunningDashboardPidsForWebDir", () => {
|
|||
expect(mockExecSilent).toHaveBeenCalledWith("lsof", ["-a", "-p", "111", "-d", "cwd", "-Fn"]);
|
||||
});
|
||||
|
||||
it("deduplicates dashboard pids found on multiple ports", async () => {
|
||||
it.skipIf(process.platform === "win32")("deduplicates dashboard pids found on multiple ports", async () => {
|
||||
const webDir = join(tmpDir, "packages", "web");
|
||||
mkdirSync(webDir, { recursive: true });
|
||||
|
||||
|
|
@ -317,4 +309,47 @@ describe("findRunningDashboardPidsForWebDir", () => {
|
|||
|
||||
await expect(findRunningDashboardPidsForWebDir(webDir, [3000, 3001])).resolves.toEqual(["111"]);
|
||||
});
|
||||
|
||||
// Windows-runif parallels: on Windows, the function intentionally skips the
|
||||
// lsof + cwd verification (lsof doesn't exist) and trusts findPidByPort. The
|
||||
// tests above assert lsof behavior; these assert the Windows path runs the
|
||||
// findPidByPort branch and produces correct dedup semantics.
|
||||
it.runIf(process.platform === "win32")(
|
||||
"returns all pids on the listed ports via findPidByPort on Windows",
|
||||
async () => {
|
||||
const webDir = join(tmpDir, "packages", "web");
|
||||
mkdirSync(webDir, { recursive: true });
|
||||
|
||||
mockFindPidByPort.mockImplementation(async (port: number) =>
|
||||
port === 3000 ? "111" : port === 3001 ? "222" : null,
|
||||
);
|
||||
|
||||
const { findRunningDashboardPidsForWebDir } =
|
||||
await import("../../src/lib/dashboard-rebuild.js");
|
||||
|
||||
const pids = await findRunningDashboardPidsForWebDir(webDir, [3000, 3001, 3002]);
|
||||
expect(pids.sort()).toEqual(["111", "222"]);
|
||||
// lsof must NOT be invoked on Windows.
|
||||
expect(mockExecSilent).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it.runIf(process.platform === "win32")(
|
||||
"deduplicates dashboard pids found on multiple ports on Windows",
|
||||
async () => {
|
||||
const webDir = join(tmpDir, "packages", "web");
|
||||
mkdirSync(webDir, { recursive: true });
|
||||
|
||||
// Same pid claimed on two ports (e.g. parent + child Next.js workers
|
||||
// sharing the listener) — must collapse to one entry.
|
||||
mockFindPidByPort.mockResolvedValue("111");
|
||||
|
||||
const { findRunningDashboardPidsForWebDir } =
|
||||
await import("../../src/lib/dashboard-rebuild.js");
|
||||
|
||||
await expect(findRunningDashboardPidsForWebDir(webDir, [3000, 3001])).resolves.toEqual([
|
||||
"111",
|
||||
]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,35 +1,109 @@
|
|||
import type * as ChildProcess from "node:child_process";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
const { mockExec, mockConfigRef, mockTmux } = vi.hoisted(() => ({
|
||||
const {
|
||||
mockExec,
|
||||
mockSpawn,
|
||||
mockConfigRef,
|
||||
mockListRef,
|
||||
mockOpenUrl,
|
||||
mockIsMacRef,
|
||||
mockIsWindowsRef,
|
||||
mockRunningRef,
|
||||
} = vi.hoisted(() => ({
|
||||
mockExec: vi.fn(),
|
||||
mockTmux: vi.fn(),
|
||||
mockSpawn: vi.fn(),
|
||||
mockConfigRef: { current: null as Record<string, unknown> | null },
|
||||
mockListRef: { current: [] as Array<{ id: string; projectId: string; lifecycle: { session: { state: string } } }> },
|
||||
mockOpenUrl: vi.fn(),
|
||||
mockIsMacRef: { current: true },
|
||||
mockIsWindowsRef: { current: false },
|
||||
mockRunningRef: { current: { pid: 1, port: 3000, projects: [] } as { pid: number; port: number; projects: string[] } | null },
|
||||
}));
|
||||
|
||||
vi.mock("node:child_process", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof ChildProcess>();
|
||||
return { ...actual, spawn: mockSpawn };
|
||||
});
|
||||
|
||||
vi.mock("../../src/lib/shell.js", () => ({
|
||||
exec: mockExec,
|
||||
execSilent: vi.fn(),
|
||||
tmux: mockTmux,
|
||||
tmux: vi.fn(),
|
||||
git: vi.fn(),
|
||||
gh: vi.fn(),
|
||||
getTmuxSessions: async () => {
|
||||
const output = await mockTmux("list-sessions", "-F", "#{session_name}");
|
||||
if (!output) return [];
|
||||
return output.split("\n").filter(Boolean);
|
||||
},
|
||||
getTmuxSessions: vi.fn(),
|
||||
getTmuxActivity: vi.fn().mockResolvedValue(null),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/create-session-manager.js", () => ({
|
||||
getSessionManager: async () => ({
|
||||
list: async () => mockListRef.current,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/web-dir.js", () => ({
|
||||
openUrl: mockOpenUrl,
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/running-state.js", () => ({
|
||||
getRunning: async () => mockRunningRef.current,
|
||||
}));
|
||||
|
||||
vi.mock("@aoagents/ao-core", () => ({
|
||||
loadConfig: () => mockConfigRef.current,
|
||||
isMac: () => mockIsMacRef.current,
|
||||
isWindows: () => mockIsWindowsRef.current,
|
||||
isTerminalSession: (s: { lifecycle?: { session?: { state?: string } } }) =>
|
||||
s.lifecycle?.session?.state === "terminated" || s.lifecycle?.session?.state === "done",
|
||||
}));
|
||||
|
||||
import { Command } from "commander";
|
||||
import { registerOpen } from "../../src/commands/open.js";
|
||||
|
||||
// Fictional fixture path used only inside the in-memory mock config below.
|
||||
// Not anyone's real filesystem path — assertions reference this constant so
|
||||
// the test verifies "config.projects[id].path flows through to wt's -d flag",
|
||||
// independent of the literal value.
|
||||
const TEST_REPO_PATH = "/fixtures/test-repo";
|
||||
|
||||
let program: Command;
|
||||
let consoleSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
function makeSession(id: string, projectId: string, state = "working") {
|
||||
const sessionState =
|
||||
state === "terminated"
|
||||
? {
|
||||
state,
|
||||
reason: "runtime_lost",
|
||||
terminatedAt: "2026-05-04T19:51:10.488Z",
|
||||
}
|
||||
: { state, reason: "task_in_progress", terminatedAt: null };
|
||||
const runtimeState =
|
||||
state === "terminated"
|
||||
? { state: "missing", reason: "process_missing" }
|
||||
: { state: "alive", reason: "process_running" };
|
||||
return {
|
||||
id,
|
||||
projectId,
|
||||
lifecycle: {
|
||||
session: sessionState,
|
||||
runtime: runtimeState,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeSpawnChild() {
|
||||
const handlers: Record<string, () => void> = {};
|
||||
return {
|
||||
on: vi.fn((event: string, cb: () => void) => {
|
||||
handlers[event] = cb;
|
||||
return undefined;
|
||||
}),
|
||||
unref: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockConfigRef.current = {
|
||||
dataDir: "/tmp/ao",
|
||||
|
|
@ -55,6 +129,12 @@ beforeEach(() => {
|
|||
path: "/home/user/backend",
|
||||
defaultBranch: "main",
|
||||
},
|
||||
"test-repo": {
|
||||
name: "Test Repo",
|
||||
repo: "org/test-repo",
|
||||
path: TEST_REPO_PATH,
|
||||
defaultBranch: "main",
|
||||
},
|
||||
},
|
||||
notifiers: {},
|
||||
notificationRouting: {},
|
||||
|
|
@ -71,20 +151,27 @@ beforeEach(() => {
|
|||
});
|
||||
|
||||
mockExec.mockReset();
|
||||
mockTmux.mockReset();
|
||||
mockSpawn.mockReset();
|
||||
mockOpenUrl.mockReset();
|
||||
mockListRef.current = [];
|
||||
mockIsMacRef.current = true;
|
||||
mockIsWindowsRef.current = false;
|
||||
mockRunningRef.current = { pid: 1, port: 3000, projects: [] };
|
||||
mockExec.mockResolvedValue({ stdout: "", stderr: "" });
|
||||
mockSpawn.mockReturnValue(makeSpawnChild());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("open command", () => {
|
||||
describe("open command (macOS)", () => {
|
||||
it("opens all sessions when target is 'all'", async () => {
|
||||
mockTmux.mockImplementation(async (...args: string[]) => {
|
||||
if (args[0] === "list-sessions") return "app-1\napp-2\nbackend-1";
|
||||
return null;
|
||||
});
|
||||
mockListRef.current = [
|
||||
makeSession("app-1", "my-app"),
|
||||
makeSession("app-2", "my-app"),
|
||||
makeSession("backend-1", "backend"),
|
||||
];
|
||||
|
||||
await program.parseAsync(["node", "test", "open", "all"]);
|
||||
|
||||
|
|
@ -96,10 +183,7 @@ describe("open command", () => {
|
|||
});
|
||||
|
||||
it("opens all sessions when no target given", async () => {
|
||||
mockTmux.mockImplementation(async (...args: string[]) => {
|
||||
if (args[0] === "list-sessions") return "app-1";
|
||||
return null;
|
||||
});
|
||||
mockListRef.current = [makeSession("app-1", "my-app")];
|
||||
|
||||
await program.parseAsync(["node", "test", "open"]);
|
||||
|
||||
|
|
@ -108,10 +192,11 @@ describe("open command", () => {
|
|||
});
|
||||
|
||||
it("opens sessions for a specific project", async () => {
|
||||
mockTmux.mockImplementation(async (...args: string[]) => {
|
||||
if (args[0] === "list-sessions") return "app-1\napp-2\nbackend-1";
|
||||
return null;
|
||||
});
|
||||
mockListRef.current = [
|
||||
makeSession("app-1", "my-app"),
|
||||
makeSession("app-2", "my-app"),
|
||||
makeSession("backend-1", "backend"),
|
||||
];
|
||||
|
||||
await program.parseAsync(["node", "test", "open", "my-app"]);
|
||||
|
||||
|
|
@ -122,25 +207,8 @@ describe("open command", () => {
|
|||
expect(output).not.toContain("backend-1");
|
||||
});
|
||||
|
||||
it("matches hashed tmux worker session names", async () => {
|
||||
mockTmux.mockImplementation(async (...args: string[]) => {
|
||||
if (args[0] === "list-sessions") return "1686e4aaaeaa-app-1\nbackend-1";
|
||||
return null;
|
||||
});
|
||||
|
||||
await program.parseAsync(["node", "test", "open", "my-app"]);
|
||||
|
||||
const output = consoleSpy.mock.calls.map((c) => String(c[0])).join("\n");
|
||||
expect(output).toContain("Opening 1 session");
|
||||
expect(output).toContain("1686e4aaaeaa-app-1");
|
||||
expect(output).not.toContain("backend-1");
|
||||
});
|
||||
|
||||
it("opens a single session by name", async () => {
|
||||
mockTmux.mockImplementation(async (...args: string[]) => {
|
||||
if (args[0] === "list-sessions") return "app-1\napp-2";
|
||||
return null;
|
||||
});
|
||||
mockListRef.current = [makeSession("app-1", "my-app"), makeSession("app-2", "my-app")];
|
||||
|
||||
await program.parseAsync(["node", "test", "open", "app-1"]);
|
||||
|
||||
|
|
@ -150,10 +218,7 @@ describe("open command", () => {
|
|||
});
|
||||
|
||||
it("rejects unknown target", async () => {
|
||||
mockTmux.mockImplementation(async (...args: string[]) => {
|
||||
if (args[0] === "list-sessions") return "app-1";
|
||||
return null;
|
||||
});
|
||||
mockListRef.current = [makeSession("app-1", "my-app")];
|
||||
|
||||
await expect(program.parseAsync(["node", "test", "open", "nonexistent"])).rejects.toThrow(
|
||||
"process.exit(1)",
|
||||
|
|
@ -161,10 +226,7 @@ describe("open command", () => {
|
|||
});
|
||||
|
||||
it("passes --new-window flag to open-iterm-tab", async () => {
|
||||
mockTmux.mockImplementation(async (...args: string[]) => {
|
||||
if (args[0] === "list-sessions") return "app-1";
|
||||
return null;
|
||||
});
|
||||
mockListRef.current = [makeSession("app-1", "my-app")];
|
||||
|
||||
await program.parseAsync(["node", "test", "open", "-w", "app-1"]);
|
||||
|
||||
|
|
@ -172,33 +234,82 @@ describe("open command", () => {
|
|||
});
|
||||
|
||||
it("falls back gracefully when open-iterm-tab fails", async () => {
|
||||
mockTmux.mockImplementation(async (...args: string[]) => {
|
||||
if (args[0] === "list-sessions") return "app-1";
|
||||
return null;
|
||||
});
|
||||
mockListRef.current = [makeSession("app-1", "my-app")];
|
||||
mockExec.mockRejectedValue(new Error("command not found"));
|
||||
|
||||
await program.parseAsync(["node", "test", "open", "app-1"]);
|
||||
|
||||
const output = consoleSpy.mock.calls.map((c) => String(c[0])).join("\n");
|
||||
expect(output).toContain("http://localhost:3000/projects/my-app/sessions/app-1");
|
||||
expect(mockOpenUrl).toHaveBeenCalledWith(
|
||||
"http://localhost:3000/projects/my-app/sessions/app-1",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the owning project for orchestrator sessions", async () => {
|
||||
mockTmux.mockImplementation(async (...args: string[]) => {
|
||||
if (args[0] === "list-sessions") return "app-orchestrator";
|
||||
return null;
|
||||
});
|
||||
mockExec.mockRejectedValue(new Error("command not found"));
|
||||
it("excludes terminated sessions from aggregate targets", async () => {
|
||||
mockListRef.current = [
|
||||
makeSession("app-1", "my-app"),
|
||||
makeSession("app-dead", "my-app", "terminated"),
|
||||
];
|
||||
|
||||
await program.parseAsync(["node", "test", "open", "app-orchestrator"]);
|
||||
await program.parseAsync(["node", "test", "open", "all"]);
|
||||
|
||||
const output = consoleSpy.mock.calls.map((c) => String(c[0])).join("\n");
|
||||
expect(output).toContain("http://localhost:3000/projects/my-app/sessions/app-orchestrator");
|
||||
expect(output).toContain("Opening 1 session");
|
||||
expect(output).toContain("app-1");
|
||||
expect(output).not.toContain("app-dead");
|
||||
});
|
||||
|
||||
it("includes a terminated session when looked up by name (opens dashboard with death reason)", async () => {
|
||||
mockListRef.current = [makeSession("app-dead", "my-app", "terminated")];
|
||||
|
||||
await program.parseAsync(["node", "test", "open", "app-dead"]);
|
||||
|
||||
expect(mockExec).not.toHaveBeenCalled();
|
||||
expect(mockOpenUrl).toHaveBeenCalledWith(
|
||||
"http://localhost:3000/projects/my-app/sessions/app-dead",
|
||||
);
|
||||
const output = consoleSpy.mock.calls.map((c) => String(c[0])).join("\n");
|
||||
expect(output).toContain("(terminated)");
|
||||
expect(output).toContain("session=runtime_lost");
|
||||
expect(output).toContain("runtime=process_missing");
|
||||
expect(output).toContain("ao session restore app-dead");
|
||||
});
|
||||
|
||||
it("--browser forces dashboard URL even on macOS", async () => {
|
||||
mockListRef.current = [makeSession("app-1", "my-app")];
|
||||
|
||||
await program.parseAsync(["node", "test", "open", "-b", "app-1"]);
|
||||
|
||||
expect(mockExec).not.toHaveBeenCalled();
|
||||
expect(mockOpenUrl).toHaveBeenCalledWith(
|
||||
"http://localhost:3000/projects/my-app/sessions/app-1",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the live daemon's port from running-state, not config", async () => {
|
||||
mockListRef.current = [makeSession("app-1", "my-app")];
|
||||
mockExec.mockRejectedValue(new Error("no iterm"));
|
||||
mockRunningRef.current = { pid: 42, port: 4173, projects: ["my-app"] };
|
||||
|
||||
await program.parseAsync(["node", "test", "open", "app-1"]);
|
||||
|
||||
expect(mockOpenUrl).toHaveBeenCalledWith(
|
||||
"http://localhost:4173/projects/my-app/sessions/app-1",
|
||||
);
|
||||
});
|
||||
|
||||
it("warns when daemon is not running (URL fallback may not load)", async () => {
|
||||
mockListRef.current = [makeSession("app-1", "my-app")];
|
||||
mockExec.mockRejectedValue(new Error("no iterm"));
|
||||
mockRunningRef.current = null;
|
||||
|
||||
await program.parseAsync(["node", "test", "open", "app-1"]);
|
||||
|
||||
const output = consoleSpy.mock.calls.map((c) => String(c[0])).join("\n");
|
||||
expect(output).toContain("daemon does not appear to be running");
|
||||
});
|
||||
|
||||
it("shows 'No sessions to open' when none exist", async () => {
|
||||
mockTmux.mockResolvedValue(null);
|
||||
mockListRef.current = [];
|
||||
|
||||
await program.parseAsync(["node", "test", "open", "my-app"]);
|
||||
|
||||
|
|
@ -206,3 +317,101 @@ describe("open command", () => {
|
|||
expect(output).toContain("No sessions to open");
|
||||
});
|
||||
});
|
||||
|
||||
describe("open command (Windows)", () => {
|
||||
beforeEach(() => {
|
||||
mockIsMacRef.current = false;
|
||||
mockIsWindowsRef.current = true;
|
||||
});
|
||||
|
||||
it("spawns Windows Terminal running `ao session attach <id>`", async () => {
|
||||
mockListRef.current = [makeSession("tr-orchestrator", "test-repo")];
|
||||
|
||||
await program.parseAsync(["node", "test", "open", "tr-orchestrator"]);
|
||||
|
||||
expect(mockSpawn).toHaveBeenCalledTimes(1);
|
||||
const [cmd, args] = mockSpawn.mock.calls[0];
|
||||
expect(cmd).toBe("wt.exe");
|
||||
expect(args).toEqual([
|
||||
"-w", "0", "new-tab",
|
||||
"--title", "ao:tr-orchestrator",
|
||||
"-d", TEST_REPO_PATH,
|
||||
"cmd.exe", "/k", "ao", "session", "attach", "tr-orchestrator",
|
||||
]);
|
||||
expect(mockOpenUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to `cmd /k` when wt.exe is unavailable", async () => {
|
||||
mockListRef.current = [makeSession("tr-orchestrator", "test-repo")];
|
||||
mockSpawn.mockImplementationOnce(() => {
|
||||
throw new Error("ENOENT: wt.exe not found");
|
||||
});
|
||||
mockSpawn.mockImplementationOnce(() => makeSpawnChild());
|
||||
|
||||
await program.parseAsync(["node", "test", "open", "tr-orchestrator"]);
|
||||
|
||||
expect(mockSpawn).toHaveBeenCalledTimes(2);
|
||||
expect(mockSpawn.mock.calls[1][0]).toBe("cmd.exe");
|
||||
expect(mockSpawn.mock.calls[1][1]).toEqual([
|
||||
"/c", "start", "ao:tr-orchestrator",
|
||||
"/d", TEST_REPO_PATH,
|
||||
"cmd.exe", "/k", "ao", "session", "attach", "tr-orchestrator",
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back to dashboard URL when both terminal launchers fail", async () => {
|
||||
mockListRef.current = [makeSession("tr-orchestrator", "test-repo")];
|
||||
mockSpawn.mockImplementation(() => {
|
||||
throw new Error("ENOENT");
|
||||
});
|
||||
|
||||
await program.parseAsync(["node", "test", "open", "tr-orchestrator"]);
|
||||
|
||||
expect(mockOpenUrl).toHaveBeenCalledWith(
|
||||
"http://localhost:3000/projects/test-repo/sessions/tr-orchestrator",
|
||||
);
|
||||
});
|
||||
|
||||
it("--browser skips terminal spawn and opens URL directly", async () => {
|
||||
mockListRef.current = [makeSession("tr-orchestrator", "test-repo")];
|
||||
|
||||
await program.parseAsync(["node", "test", "open", "-b", "tr-orchestrator"]);
|
||||
|
||||
expect(mockSpawn).not.toHaveBeenCalled();
|
||||
expect(mockOpenUrl).toHaveBeenCalledWith(
|
||||
"http://localhost:3000/projects/test-repo/sessions/tr-orchestrator",
|
||||
);
|
||||
});
|
||||
|
||||
it("opens dashboard URL for terminated sessions instead of attempting attach", async () => {
|
||||
mockListRef.current = [makeSession("tr-orchestrator", "test-repo", "terminated")];
|
||||
|
||||
await program.parseAsync(["node", "test", "open", "tr-orchestrator"]);
|
||||
|
||||
expect(mockSpawn).not.toHaveBeenCalled();
|
||||
expect(mockOpenUrl).toHaveBeenCalledWith(
|
||||
"http://localhost:3000/projects/test-repo/sessions/tr-orchestrator",
|
||||
);
|
||||
const output = consoleSpy.mock.calls.map((c) => String(c[0])).join("\n");
|
||||
expect(output).toContain("(terminated)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("open command (Linux)", () => {
|
||||
beforeEach(() => {
|
||||
mockIsMacRef.current = false;
|
||||
mockIsWindowsRef.current = false;
|
||||
});
|
||||
|
||||
it("opens the dashboard URL (no terminal-spawn helper exists)", async () => {
|
||||
mockListRef.current = [makeSession("app-1", "my-app")];
|
||||
|
||||
await program.parseAsync(["node", "test", "open", "app-1"]);
|
||||
|
||||
expect(mockSpawn).not.toHaveBeenCalled();
|
||||
expect(mockExec).not.toHaveBeenCalled();
|
||||
expect(mockOpenUrl).toHaveBeenCalledWith(
|
||||
"http://localhost:3000/projects/my-app/sessions/app-1",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -144,20 +144,22 @@ describe("send command", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("detects busy session and waits via agent plugin", async () => {
|
||||
mockTmux.mockImplementation(async (...args: string[]) => {
|
||||
if (args[0] === "has-session") return "";
|
||||
if (args[0] === "capture-pane") return "some output";
|
||||
return "";
|
||||
});
|
||||
it(
|
||||
"detects busy session and waits via agent plugin",
|
||||
async () => {
|
||||
mockTmux.mockImplementation(async (...args: string[]) => {
|
||||
if (args[0] === "has-session") return "";
|
||||
if (args[0] === "capture-pane") return "some output";
|
||||
return "";
|
||||
});
|
||||
|
||||
// First call: active (busy), second call: idle, third call: active (verification)
|
||||
mockDetectActivity
|
||||
.mockReturnValueOnce("active") // busy
|
||||
.mockReturnValueOnce("idle") // now idle
|
||||
.mockReturnValueOnce("active"); // verification: processing
|
||||
// First call: active (busy), second call: idle, third call: active (verification)
|
||||
mockDetectActivity
|
||||
.mockReturnValueOnce("active") // busy
|
||||
.mockReturnValueOnce("idle") // now idle
|
||||
.mockReturnValueOnce("active"); // verification: processing
|
||||
|
||||
await program.parseAsync(["node", "test", "send", "my-session", "fix", "the", "bug"]);
|
||||
await program.parseAsync(["node", "test", "send", "my-session", "fix", "the", "bug"]);
|
||||
|
||||
// Should have eventually sent the message
|
||||
expect(mockExec).toHaveBeenCalledWith("tmux", [
|
||||
|
|
@ -167,7 +169,7 @@ describe("send command", () => {
|
|||
"-l",
|
||||
"fix the bug",
|
||||
]);
|
||||
}, 15000);
|
||||
}, 30_000);
|
||||
|
||||
it("skips busy detection with --no-wait", async () => {
|
||||
mockTmux.mockImplementation(async (...args: string[]) => {
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ const {
|
|||
mockGh,
|
||||
mockExec,
|
||||
mockSpawn,
|
||||
mockIsWindows,
|
||||
mockConfigRef,
|
||||
mockSessionManager,
|
||||
sessionsDirRef,
|
||||
|
|
@ -39,6 +40,7 @@ const {
|
|||
mockGh: vi.fn(),
|
||||
mockExec: vi.fn(),
|
||||
mockSpawn: vi.fn(),
|
||||
mockIsWindows: vi.fn().mockReturnValue(false),
|
||||
mockConfigRef: { current: null as Record<string, unknown> | null },
|
||||
mockSessionManager: {
|
||||
list: vi.fn(),
|
||||
|
|
@ -71,6 +73,16 @@ vi.mock("node:child_process", async (importOriginal) => {
|
|||
};
|
||||
});
|
||||
|
||||
const mockNetConnect = vi.fn();
|
||||
vi.mock("node:net", async (importOriginal) => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||
const actual = await importOriginal<typeof import("node:net")>();
|
||||
return {
|
||||
...actual,
|
||||
connect: (...args: unknown[]) => mockNetConnect(...args),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../src/lib/shell.js", () => ({
|
||||
tmux: mockTmux,
|
||||
exec: mockExec,
|
||||
|
|
@ -96,6 +108,8 @@ vi.mock("@aoagents/ao-core", async (importOriginal) => {
|
|||
return {
|
||||
...actual,
|
||||
loadConfig: () => mockConfigRef.current,
|
||||
isWindows: () => mockIsWindows(),
|
||||
generateConfigHash: () => "abcdef123456",
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -735,6 +749,7 @@ describe("session attach", () => {
|
|||
});
|
||||
|
||||
it("fails when tmux session does not exist", async () => {
|
||||
mockIsWindows.mockReturnValue(false);
|
||||
mockSessionManager.get.mockResolvedValue(null);
|
||||
mockTmux.mockResolvedValue(null);
|
||||
|
||||
|
|
@ -742,6 +757,207 @@ describe("session attach", () => {
|
|||
program.parseAsync(["node", "test", "session", "attach", "unknown-1"]),
|
||||
).rejects.toThrow("process.exit(1)");
|
||||
});
|
||||
|
||||
it("connects to named pipe on Windows", async () => {
|
||||
mockIsWindows.mockReturnValue(true);
|
||||
mockSessionManager.get.mockResolvedValue({
|
||||
id: "app-1",
|
||||
projectId: "my-app",
|
||||
status: "working",
|
||||
activity: null,
|
||||
branch: null,
|
||||
issueId: null,
|
||||
pr: null,
|
||||
workspacePath: null,
|
||||
runtimeHandle: { id: "hash-app-1", runtimeName: "process", data: { pipePath: "\\\\.\\pipe\\ao-pty-hash-app-1" } },
|
||||
agentInfo: null,
|
||||
createdAt: new Date(),
|
||||
lastActivityAt: new Date(),
|
||||
metadata: {},
|
||||
} satisfies Session);
|
||||
|
||||
const mockSocket = new EventEmitter();
|
||||
Object.assign(mockSocket, { destroy: vi.fn(), write: vi.fn() });
|
||||
mockNetConnect.mockReturnValue(mockSocket);
|
||||
|
||||
// Fire the command — it awaits an infinite promise, so don't await it.
|
||||
// The process.exit mock throws, which surfaces synchronously through emit().
|
||||
void program.parseAsync(["node", "test", "session", "attach", "app-1"]);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
mockSocket.emit("connect");
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
// Exercise binary protocol: send terminal data (0x01)
|
||||
const termData = Buffer.from("hello");
|
||||
const dataFrame = Buffer.alloc(5 + termData.length);
|
||||
dataFrame.writeUInt8(0x01, 0);
|
||||
dataFrame.writeUInt32BE(termData.length, 1);
|
||||
termData.copy(dataFrame, 5);
|
||||
const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
||||
mockSocket.emit("data", dataFrame);
|
||||
expect(writeSpy).toHaveBeenCalledWith(termData);
|
||||
writeSpy.mockRestore();
|
||||
|
||||
// Exercise stdin relay: send input data (becomes MSG_TERMINAL_INPUT = 0x02)
|
||||
const inputData = Buffer.from("ls\r");
|
||||
process.stdin.emit("data", inputData);
|
||||
expect((mockSocket as { write: ReturnType<typeof vi.fn> }).write).toHaveBeenCalled();
|
||||
const written = (mockSocket as { write: ReturnType<typeof vi.fn> }).write.mock.calls.at(-1)![0] as Buffer;
|
||||
expect(written.readUInt8(0)).toBe(0x02); // MSG_TERMINAL_INPUT
|
||||
expect(written.subarray(5).toString()).toBe("ls\r");
|
||||
|
||||
// close handler calls process.exit(0) which throws synchronously through emit
|
||||
expect(() => mockSocket.emit("close")).toThrow("process.exit(0)");
|
||||
expect(mockNetConnect).toHaveBeenCalledWith("\\\\.\\pipe\\ao-pty-hash-app-1");
|
||||
// Remove stdin listeners to prevent cross-test contamination
|
||||
process.stdin.removeAllListeners("data");
|
||||
mockIsWindows.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it("handles PTY exit status on Windows", async () => {
|
||||
mockIsWindows.mockReturnValue(true);
|
||||
mockSessionManager.get.mockResolvedValue({
|
||||
id: "app-1",
|
||||
projectId: "my-app",
|
||||
status: "working",
|
||||
activity: null,
|
||||
branch: null,
|
||||
issueId: null,
|
||||
pr: null,
|
||||
workspacePath: null,
|
||||
runtimeHandle: { id: "hash-app-1", runtimeName: "process", data: {} },
|
||||
agentInfo: null,
|
||||
createdAt: new Date(),
|
||||
lastActivityAt: new Date(),
|
||||
metadata: {},
|
||||
} satisfies Session);
|
||||
|
||||
const mockSocket = new EventEmitter();
|
||||
Object.assign(mockSocket, { destroy: vi.fn(), write: vi.fn() });
|
||||
mockNetConnect.mockReturnValue(mockSocket);
|
||||
|
||||
void program.parseAsync(["node", "test", "session", "attach", "app-1"]);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
mockSocket.emit("connect");
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
// Exercise PTY exit status (MSG_STATUS_RES = 0x07, alive=false)
|
||||
// process.exit is inside try/catch in the data handler, so the mock throw
|
||||
// gets swallowed. Verify via side effects instead.
|
||||
const statusPayload = Buffer.from(JSON.stringify({ alive: false, exitCode: 42 }));
|
||||
const statusFrame = Buffer.alloc(5 + statusPayload.length);
|
||||
statusFrame.writeUInt8(0x07, 0);
|
||||
statusFrame.writeUInt32BE(statusPayload.length, 1);
|
||||
statusPayload.copy(statusFrame, 5);
|
||||
mockSocket.emit("data", statusFrame);
|
||||
|
||||
// cleanup() was called (socket destroyed)
|
||||
expect((mockSocket as { destroy: ReturnType<typeof vi.fn> }).destroy).toHaveBeenCalled();
|
||||
// process.exit was called with the exit code from the status message
|
||||
expect(process.exit).toHaveBeenCalledWith(42);
|
||||
|
||||
mockIsWindows.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it("detaches on Ctrl+backslash on Windows", async () => {
|
||||
mockIsWindows.mockReturnValue(true);
|
||||
mockSessionManager.get.mockResolvedValue({
|
||||
id: "app-1",
|
||||
projectId: "my-app",
|
||||
status: "working",
|
||||
activity: null,
|
||||
branch: null,
|
||||
issueId: null,
|
||||
pr: null,
|
||||
workspacePath: null,
|
||||
runtimeHandle: { id: "hash-app-1", runtimeName: "process", data: {} },
|
||||
agentInfo: null,
|
||||
createdAt: new Date(),
|
||||
lastActivityAt: new Date(),
|
||||
metadata: {},
|
||||
} satisfies Session);
|
||||
|
||||
const mockSocket = new EventEmitter();
|
||||
Object.assign(mockSocket, { destroy: vi.fn(), write: vi.fn() });
|
||||
mockNetConnect.mockReturnValue(mockSocket);
|
||||
|
||||
// Temporarily replace process.exit with a non-throwing spy so it doesn't
|
||||
// propagate through EventEmitter and prevent subsequent listener calls.
|
||||
// The global beforeEach spy throws, which breaks emit() propagation for
|
||||
// listeners registered on process.stdin (a shared singleton).
|
||||
const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
|
||||
|
||||
void program.parseAsync(["node", "test", "session", "attach", "app-1"]);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
mockSocket.emit("connect");
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
// Ctrl+\ (0x1c) triggers detach
|
||||
process.stdin.emit("data", Buffer.from([0x1c]));
|
||||
|
||||
expect((mockSocket as { destroy: ReturnType<typeof vi.fn> }).destroy).toHaveBeenCalled();
|
||||
expect(exitSpy).toHaveBeenCalledWith(0);
|
||||
exitSpy.mockRestore();
|
||||
|
||||
// Remove the stdin listener we attached to prevent cross-test contamination
|
||||
process.stdin.removeAllListeners("data");
|
||||
mockIsWindows.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it("falls back to config hash when runtimeHandle is missing on Windows", async () => {
|
||||
mockIsWindows.mockReturnValue(true);
|
||||
mockSessionManager.get.mockResolvedValue(null);
|
||||
|
||||
const mockSocket = new EventEmitter();
|
||||
Object.assign(mockSocket, { destroy: vi.fn() });
|
||||
mockNetConnect.mockReturnValue(mockSocket);
|
||||
|
||||
void program.parseAsync(["node", "test", "session", "attach", "app-1"]);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
// Should use config hash fallback for pipe path
|
||||
expect(mockNetConnect).toHaveBeenCalled();
|
||||
const pipePath = mockNetConnect.mock.calls[0][0] as string;
|
||||
expect(pipePath).toMatch(/\\\\\.\\pipe\\ao-pty-/);
|
||||
|
||||
// Clean up: trigger error to exit
|
||||
expect(() => mockSocket.emit("error", new Error("ENOENT"))).toThrow("process.exit(1)");
|
||||
mockIsWindows.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it("shows error when pipe not available on Windows", async () => {
|
||||
mockIsWindows.mockReturnValue(true);
|
||||
mockSessionManager.get.mockResolvedValue({
|
||||
id: "app-1",
|
||||
projectId: "my-app",
|
||||
status: "working",
|
||||
activity: null,
|
||||
branch: null,
|
||||
issueId: null,
|
||||
pr: null,
|
||||
workspacePath: null,
|
||||
runtimeHandle: { id: "hash-app-1", runtimeName: "process", data: { pipePath: "\\\\.\\pipe\\ao-pty-hash-app-1" } },
|
||||
agentInfo: null,
|
||||
createdAt: new Date(),
|
||||
lastActivityAt: new Date(),
|
||||
metadata: {},
|
||||
} satisfies Session);
|
||||
|
||||
const mockSocket = new EventEmitter();
|
||||
Object.assign(mockSocket, { destroy: vi.fn() });
|
||||
mockNetConnect.mockReturnValue(mockSocket);
|
||||
|
||||
// Fire the command — it awaits an infinite promise, so don't await it.
|
||||
void program.parseAsync(["node", "test", "session", "attach", "app-1"]);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
// error handler calls process.exit(1) which throws synchronously through emit
|
||||
expect(() => mockSocket.emit("error", new Error("connect ENOENT"))).toThrow("process.exit(1)");
|
||||
mockIsWindows.mockReturnValue(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("session claim-pr", () => {
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ const {
|
|||
mockSessionManager,
|
||||
mockWaitForPortAndOpen,
|
||||
mockSpawn,
|
||||
mockFindPidByPort,
|
||||
mockKillProcessTree,
|
||||
mockStartProjectSupervisor,
|
||||
} = vi.hoisted(() => ({
|
||||
mockExec: vi.fn(),
|
||||
|
|
@ -52,6 +54,8 @@ const {
|
|||
},
|
||||
mockWaitForPortAndOpen: vi.fn().mockResolvedValue(undefined),
|
||||
mockSpawn: vi.fn(),
|
||||
mockFindPidByPort: vi.fn(),
|
||||
mockKillProcessTree: vi.fn(),
|
||||
mockStartProjectSupervisor: vi.fn(),
|
||||
}));
|
||||
|
||||
|
|
@ -138,6 +142,8 @@ vi.mock("@aoagents/ao-core", async (importOriginal) => {
|
|||
if (path) return actual.loadConfig(path);
|
||||
return mockConfigRef.current;
|
||||
},
|
||||
findPidByPort: mockFindPidByPort,
|
||||
killProcessTree: mockKillProcessTree,
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -327,7 +333,11 @@ beforeEach(async () => {
|
|||
vi.mocked(webDir.findFreePort).mockResolvedValue(3000);
|
||||
vi.mocked(webDir.buildDashboardEnv).mockResolvedValue({});
|
||||
const projectDetection = await import("../../src/lib/project-detection.js");
|
||||
vi.mocked(projectDetection.detectProjectType).mockReturnValue({ languages: [], frameworks: [], tools: [] });
|
||||
vi.mocked(projectDetection.detectProjectType).mockReturnValue({
|
||||
languages: [],
|
||||
frameworks: [],
|
||||
tools: [],
|
||||
});
|
||||
vi.mocked(projectDetection.generateRulesFromTemplates).mockReturnValue(null);
|
||||
vi.mocked(projectDetection.formatProjectTypeForDisplay).mockReturnValue("");
|
||||
|
||||
|
|
@ -373,6 +383,10 @@ beforeEach(async () => {
|
|||
});
|
||||
mockWaitForPortAndOpen.mockReset();
|
||||
mockWaitForPortAndOpen.mockResolvedValue(undefined);
|
||||
mockFindPidByPort.mockReset();
|
||||
mockFindPidByPort.mockResolvedValue(null);
|
||||
mockKillProcessTree.mockReset();
|
||||
mockKillProcessTree.mockResolvedValue(undefined);
|
||||
mockStartProjectSupervisor.mockReset();
|
||||
mockStartProjectSupervisor.mockResolvedValue({ stop: vi.fn(), reconcileNow: vi.fn() });
|
||||
mockDetectOpenClawInstallation.mockReset();
|
||||
|
|
@ -424,7 +438,10 @@ function makeConfig(projects: Record<string, Record<string, unknown>>): Record<s
|
|||
configPath: join(tmpDir, "agent-orchestrator.yaml"),
|
||||
port: 3000,
|
||||
defaults: {
|
||||
runtime: "tmux",
|
||||
// Use "process" so the test runs on every platform without
|
||||
// tripping ensureTmux. Tests that exercise the tmux preflight
|
||||
// path set runtime explicitly.
|
||||
runtime: "process",
|
||||
agent: "claude-code",
|
||||
workspace: "worktree",
|
||||
notifiers: [],
|
||||
|
|
@ -646,17 +663,13 @@ describe("start command — URL argument", () => {
|
|||
mockExecSilent.mockResolvedValue("Logged in");
|
||||
|
||||
mockSpawn.mockImplementation(
|
||||
(
|
||||
cmd: string,
|
||||
args: string[],
|
||||
_opts?: { cwd?: string; env?: NodeJS.ProcessEnv },
|
||||
) => {
|
||||
if (cmd === "gh" && args[0] === "repo" && args[1] === "clone") {
|
||||
createFakeRepo(repoDir, "https://github.com/owner/my-app.git", {
|
||||
"Cargo.toml": "",
|
||||
});
|
||||
}
|
||||
return createSpawnChild({ closeCode: 0 });
|
||||
(cmd: string, args: string[], _opts?: { cwd?: string; env?: NodeJS.ProcessEnv }) => {
|
||||
if (cmd === "gh" && args[0] === "repo" && args[1] === "clone") {
|
||||
createFakeRepo(repoDir, "https://github.com/owner/my-app.git", {
|
||||
"Cargo.toml": "",
|
||||
});
|
||||
}
|
||||
return createSpawnChild({ closeCode: 0 });
|
||||
},
|
||||
);
|
||||
|
||||
|
|
@ -695,25 +708,21 @@ describe("start command — URL argument", () => {
|
|||
});
|
||||
|
||||
mockSpawn.mockImplementation(
|
||||
(
|
||||
cmd: string,
|
||||
args: string[],
|
||||
_opts?: { cwd?: string; env?: NodeJS.ProcessEnv },
|
||||
) => {
|
||||
if (cmd === "git" && args[0] === "clone") {
|
||||
const url = String(args[3] ?? "");
|
||||
// SSH attempt fails (simulate non-zero exit)
|
||||
if (url.startsWith("git@")) {
|
||||
return createSpawnChild({ closeCode: 1 });
|
||||
(cmd: string, args: string[], _opts?: { cwd?: string; env?: NodeJS.ProcessEnv }) => {
|
||||
if (cmd === "git" && args[0] === "clone") {
|
||||
const url = String(args[3] ?? "");
|
||||
// SSH attempt fails (simulate non-zero exit)
|
||||
if (url.startsWith("git@")) {
|
||||
return createSpawnChild({ closeCode: 1 });
|
||||
}
|
||||
|
||||
// HTTPS fallback succeeds
|
||||
createFakeRepo(repoDir, "https://github.com/owner/my-app.git", {
|
||||
"Cargo.toml": "",
|
||||
});
|
||||
}
|
||||
|
||||
// HTTPS fallback succeeds
|
||||
createFakeRepo(repoDir, "https://github.com/owner/my-app.git", {
|
||||
"Cargo.toml": "",
|
||||
});
|
||||
}
|
||||
|
||||
return createSpawnChild({ closeCode: 0 });
|
||||
return createSpawnChild({ closeCode: 0 });
|
||||
},
|
||||
);
|
||||
|
||||
|
|
@ -755,7 +764,7 @@ describe("start command — URL argument", () => {
|
|||
[
|
||||
"port: 4000",
|
||||
"defaults:",
|
||||
" runtime: tmux",
|
||||
" runtime: process",
|
||||
" agent: claude-code",
|
||||
" workspace: worktree",
|
||||
" notifiers: [desktop]",
|
||||
|
|
@ -796,7 +805,7 @@ describe("start command — URL argument", () => {
|
|||
[
|
||||
"port: 4000",
|
||||
"defaults:",
|
||||
" runtime: tmux",
|
||||
" runtime: process",
|
||||
" agent: claude-code",
|
||||
" workspace: worktree",
|
||||
" notifiers: [desktop]",
|
||||
|
|
@ -872,7 +881,20 @@ describe("start command — non-interactive install safety", () => {
|
|||
it("does not auto-install tmux when missing in non-interactive mode", async () => {
|
||||
mockIsHumanCaller.mockReturnValue(false);
|
||||
|
||||
mockConfigRef.current = makeConfig({ "my-app": makeProject() });
|
||||
// This test exercises the tmux preflight path, so the config must
|
||||
// explicitly select runtime: tmux (makeConfig defaults to process).
|
||||
// Pin the platform to linux so the Windows branch (which exits before
|
||||
// calling execSilent) doesn't short-circuit the tmux -V check we're
|
||||
// asserting on.
|
||||
const tmuxConfig = makeConfig({ "my-app": makeProject() }) as {
|
||||
defaults: Record<string, unknown>;
|
||||
};
|
||||
tmuxConfig.defaults.runtime = "tmux";
|
||||
mockConfigRef.current = tmuxConfig;
|
||||
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
|
||||
Object.defineProperty(process, "platform", { value: "linux", configurable: true });
|
||||
|
||||
mockExecSilent.mockImplementation(async (cmd: string, args: string[] = []) => {
|
||||
if (cmd === "git" && args[0] === "--version") return "git version 2.43.0";
|
||||
if (cmd === "tmux" && args[0] === "-V") return null;
|
||||
|
|
@ -881,9 +903,15 @@ describe("start command — non-interactive install safety", () => {
|
|||
return null;
|
||||
});
|
||||
|
||||
await expect(
|
||||
program.parseAsync(["node", "test", "start", "--no-dashboard", "--no-orchestrator"]),
|
||||
).rejects.toThrow("process.exit(1)");
|
||||
try {
|
||||
await expect(
|
||||
program.parseAsync(["node", "test", "start", "--no-dashboard", "--no-orchestrator"]),
|
||||
).rejects.toThrow("process.exit(1)");
|
||||
} finally {
|
||||
if (originalPlatform) {
|
||||
Object.defineProperty(process, "platform", originalPlatform);
|
||||
}
|
||||
}
|
||||
|
||||
expect(hasPrivilegedInstallAttempt()).toBe(false);
|
||||
expect(mockExec.mock.calls.some((call) => String(call[0]) === "tmux")).toBe(false);
|
||||
|
|
@ -1228,10 +1256,10 @@ describe("start command — orchestrator session strategy display", () => {
|
|||
|
||||
await program.parseAsync(["node", "test", "start", "--rebuild", "--no-orchestrator"]);
|
||||
|
||||
expect(dashboardRebuild.rebuildDashboardProductionArtifacts).toHaveBeenCalledWith(tmpDir, [
|
||||
3000,
|
||||
3001,
|
||||
]);
|
||||
expect(dashboardRebuild.rebuildDashboardProductionArtifacts).toHaveBeenCalledWith(
|
||||
tmpDir,
|
||||
[3000, 3001],
|
||||
);
|
||||
});
|
||||
|
||||
it("opens the most recent orchestrator session page when multiple existing orchestrators found with dashboard enabled and reuse is explicit", async () => {
|
||||
|
|
@ -1743,87 +1771,172 @@ describe("stop command", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("finds orphaned dashboard on a reassigned port via port scan", async () => {
|
||||
it("calls killProcessTree with numeric PID when findPidByPort returns a PID", async () => {
|
||||
mockConfigRef.current = makeConfig({ "my-app": makeProject() });
|
||||
mockSessionManager.get.mockResolvedValue({ id: "app-orchestrator", status: "running" });
|
||||
mockSessionManager.kill.mockResolvedValue({ cleaned: true, alreadyTerminated: false });
|
||||
// Port 3000 has nothing, but port 3001 has the orphaned dashboard
|
||||
mockDashboardOnPort(3001, "99999");
|
||||
|
||||
await program.parseAsync(["node", "test", "stop"]);
|
||||
|
||||
const output = vi
|
||||
.mocked(console.log)
|
||||
.mock.calls.map((c) => c.join(" "))
|
||||
.join("\n");
|
||||
expect(output).toContain("was on port 3001");
|
||||
});
|
||||
|
||||
it("skips non-dashboard processes during port scan", async () => {
|
||||
mockConfigRef.current = makeConfig({ "my-app": makeProject() });
|
||||
mockSessionManager.get.mockResolvedValue({ id: "app-orchestrator", status: "running" });
|
||||
mockSessionManager.kill.mockResolvedValue({ cleaned: true, alreadyTerminated: false });
|
||||
// Port 3000 has nothing, port 3001 has an unrelated process,
|
||||
// port 3002 has the actual dashboard
|
||||
mockExec.mockImplementation(async (cmd: string, args: string[] = []) => {
|
||||
if (cmd === "kill") return { stdout: "", stderr: "" };
|
||||
if (cmd === "ps") {
|
||||
const pid = args[1];
|
||||
if (pid === "11111") return { stdout: "python -m http.server 3001", stderr: "" };
|
||||
if (pid === "22222")
|
||||
return { stdout: "node /fake/web/dist-server/start-all.js", stderr: "" };
|
||||
return { stdout: "", stderr: "" };
|
||||
}
|
||||
if (cmd === "lsof") {
|
||||
const portArg = args.find((a) => a.startsWith(":"));
|
||||
if (portArg === ":3001") return { stdout: "11111", stderr: "" };
|
||||
if (portArg === ":3002") return { stdout: "22222", stderr: "" };
|
||||
}
|
||||
mockSessionManager.list.mockResolvedValue([]);
|
||||
mockFindPidByPort.mockResolvedValue("1234");
|
||||
// killDashboardOnPort verifies the PID is an AO dashboard via `ps` on Unix
|
||||
// before killing. Stub it to return a matching cmdline so we reach the kill.
|
||||
mockExec.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === "ps") return { stdout: "node /fake/web/dist-server/start-all.js", stderr: "" };
|
||||
throw new Error("no process");
|
||||
});
|
||||
|
||||
await program.parseAsync(["node", "test", "stop"]);
|
||||
|
||||
const output = vi
|
||||
.mocked(console.log)
|
||||
.mock.calls.map((c) => c.join(" "))
|
||||
.join("\n");
|
||||
// Should skip port 3001 (python) and find the dashboard on 3002
|
||||
expect(output).toContain("was on port 3002");
|
||||
expect(mockFindPidByPort).toHaveBeenCalledWith(3000);
|
||||
expect(mockKillProcessTree).toHaveBeenCalledWith(1234);
|
||||
});
|
||||
|
||||
it("only kills dashboard PIDs when port has mixed processes", async () => {
|
||||
it("does not call killProcessTree when findPidByPort returns null", async () => {
|
||||
mockConfigRef.current = makeConfig({ "my-app": makeProject() });
|
||||
mockSessionManager.get.mockResolvedValue({ id: "app-orchestrator", status: "running" });
|
||||
mockSessionManager.kill.mockResolvedValue({ cleaned: true, alreadyTerminated: false });
|
||||
// Port 3000 has two processes: a dashboard and an unrelated sidecar
|
||||
mockExec.mockImplementation(async (cmd: string, args: string[] = []) => {
|
||||
if (cmd === "kill") {
|
||||
// Only the dashboard PID should be killed, not the sidecar
|
||||
expect(args).toEqual(["11111"]);
|
||||
return { stdout: "", stderr: "" };
|
||||
}
|
||||
if (cmd === "ps") {
|
||||
const pid = args[1];
|
||||
if (pid === "11111")
|
||||
return { stdout: "node /fake/web/dist-server/start-all.js", stderr: "" };
|
||||
if (pid === "22222") return { stdout: "nginx: worker process", stderr: "" };
|
||||
return { stdout: "", stderr: "" };
|
||||
}
|
||||
if (cmd === "lsof") {
|
||||
const portArg = args.find((a) => a.startsWith(":"));
|
||||
if (portArg === ":3000") return { stdout: "11111\n22222", stderr: "" };
|
||||
}
|
||||
throw new Error("no process");
|
||||
});
|
||||
mockSessionManager.list.mockResolvedValue([]);
|
||||
mockFindPidByPort.mockResolvedValue(null);
|
||||
|
||||
await program.parseAsync(["node", "test", "stop"]);
|
||||
|
||||
const output = vi
|
||||
.mocked(console.log)
|
||||
.mock.calls.map((c) => c.join(" "))
|
||||
.join("\n");
|
||||
expect(output).toContain("Dashboard stopped");
|
||||
expect(mockFindPidByPort).toHaveBeenCalledWith(3000);
|
||||
expect(mockKillProcessTree).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Recovers from issue #645: when the configured port was busy at start, the
|
||||
// dashboard auto-reassigned to port+N and `ao stop` couldn't find it. The
|
||||
// port-scan fallback in stopDashboard walks port+1..port+MAX_PORT_SCAN.
|
||||
// Skip on Windows: killDashboardOnPort skips the `ps` cmdline verification
|
||||
// there (uses netstat trust), so the assertions on `ps` output don't apply.
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"finds orphaned dashboard on a reassigned port via port scan",
|
||||
async () => {
|
||||
mockConfigRef.current = makeConfig({ "my-app": makeProject() });
|
||||
mockSessionManager.list.mockResolvedValue([]);
|
||||
// Port 3000 has nothing; port 3001 has the orphaned dashboard
|
||||
mockFindPidByPort.mockImplementation(async (port: number) =>
|
||||
port === 3001 ? "99999" : null,
|
||||
);
|
||||
// ps cmdline check inside killDashboardOnPort must pass for the kill to fire
|
||||
mockExec.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === "ps") return { stdout: "node /fake/web/dist-server/start-all.js", stderr: "" };
|
||||
throw new Error("no process");
|
||||
});
|
||||
|
||||
await program.parseAsync(["node", "test", "stop"]);
|
||||
|
||||
expect(mockKillProcessTree).toHaveBeenCalledWith(99999);
|
||||
const output = vi
|
||||
.mocked(console.log)
|
||||
.mock.calls.map((c) => c.join(" "))
|
||||
.join("\n");
|
||||
expect(output).toContain("was on port 3001");
|
||||
},
|
||||
);
|
||||
|
||||
// Windows parallel: the port-scan fallback must still find the orphaned
|
||||
// dashboard, but killDashboardOnPort intentionally skips the `ps` cmdline
|
||||
// check (no `ps` on Windows; we trust netstat output via findPidByPort).
|
||||
// Ensures a developer who breaks the Windows port-scan path is caught.
|
||||
it.runIf(process.platform === "win32")(
|
||||
"finds orphaned dashboard on a reassigned port via port scan (Windows)",
|
||||
async () => {
|
||||
mockConfigRef.current = makeConfig({ "my-app": makeProject() });
|
||||
mockSessionManager.list.mockResolvedValue([]);
|
||||
mockFindPidByPort.mockImplementation(async (port: number) =>
|
||||
port === 3001 ? "99999" : null,
|
||||
);
|
||||
|
||||
await program.parseAsync(["node", "test", "stop"]);
|
||||
|
||||
expect(mockKillProcessTree).toHaveBeenCalledWith(99999);
|
||||
// `ps` must NOT be invoked on Windows — the cmdline verification is
|
||||
// skipped by design in killDashboardOnPort.
|
||||
const psCalls = mockExec.mock.calls.filter((c) => c[0] === "ps");
|
||||
expect(psCalls).toHaveLength(0);
|
||||
const output = vi
|
||||
.mocked(console.log)
|
||||
.mock.calls.map((c) => c.join(" "))
|
||||
.join("\n");
|
||||
expect(output).toContain("was on port 3001");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// runtime fallback — platform-aware default (B01/B02/B21)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("start command — platform-aware runtime fallback", () => {
|
||||
it("does not call ensureTmux when config has no runtime and platform is win32", async () => {
|
||||
// Config with no defaults.runtime — the fallback kicks in.
|
||||
const configWithoutRuntime: Record<string, unknown> = {
|
||||
configPath: join(tmpDir, "agent-orchestrator.yaml"),
|
||||
port: 3000,
|
||||
defaults: {
|
||||
// runtime intentionally absent
|
||||
agent: "claude-code",
|
||||
workspace: "worktree",
|
||||
notifiers: [],
|
||||
},
|
||||
projects: { "my-app": makeProject() },
|
||||
notifiers: {},
|
||||
notificationRouting: {},
|
||||
reactions: {},
|
||||
};
|
||||
mockConfigRef.current = configWithoutRuntime;
|
||||
|
||||
// Simulate Windows — getDefaultRuntime() will return "process".
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
|
||||
Object.defineProperty(process, "platform", { value: "win32", configurable: true });
|
||||
|
||||
try {
|
||||
await program.parseAsync(["node", "test", "start", "--no-dashboard", "--no-orchestrator"]);
|
||||
} finally {
|
||||
if (originalPlatform) {
|
||||
Object.defineProperty(process, "platform", originalPlatform);
|
||||
}
|
||||
}
|
||||
|
||||
// ensureTmux() calls execSilent("tmux", ["-V"]) — it must NOT have been called.
|
||||
const tmuxChecks = mockExecSilent.mock.calls.filter(
|
||||
(call) =>
|
||||
String(call[0]) === "tmux" && Array.isArray(call[1]) && (call[1] as string[])[0] === "-V",
|
||||
);
|
||||
expect(tmuxChecks).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("calls ensureTmux when config has no runtime and platform is linux", async () => {
|
||||
// Same config without runtime, but on a non-Windows platform.
|
||||
const configWithoutRuntime: Record<string, unknown> = {
|
||||
configPath: join(tmpDir, "agent-orchestrator.yaml"),
|
||||
port: 3000,
|
||||
defaults: {
|
||||
agent: "claude-code",
|
||||
workspace: "worktree",
|
||||
notifiers: [],
|
||||
},
|
||||
projects: { "my-app": makeProject() },
|
||||
notifiers: {},
|
||||
notificationRouting: {},
|
||||
reactions: {},
|
||||
};
|
||||
mockConfigRef.current = configWithoutRuntime;
|
||||
|
||||
// Simulate Linux — getDefaultRuntime() returns "tmux", ensureTmux() must fire.
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
|
||||
Object.defineProperty(process, "platform", { value: "linux", configurable: true });
|
||||
|
||||
try {
|
||||
await program.parseAsync(["node", "test", "start", "--no-dashboard", "--no-orchestrator"]);
|
||||
} finally {
|
||||
if (originalPlatform) {
|
||||
Object.defineProperty(process, "platform", originalPlatform);
|
||||
}
|
||||
}
|
||||
|
||||
// ensureTmux() must have checked for tmux availability.
|
||||
const tmuxChecks = mockExecSilent.mock.calls.filter(
|
||||
(call) =>
|
||||
String(call[0]) === "tmux" && Array.isArray(call[1]) && (call[1] as string[])[0] === "-V",
|
||||
);
|
||||
expect(tmuxChecks.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("targeted stop does NOT kill parent process or dashboard", async () => {
|
||||
|
|
@ -1980,15 +2093,14 @@ describe("stop command", () => {
|
|||
mockSessionManager.list.mockResolvedValue([]);
|
||||
mockExec.mockRejectedValue(new Error("no process"));
|
||||
|
||||
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true);
|
||||
|
||||
await program.parseAsync(["node", "test", "stop"]);
|
||||
|
||||
expect(killSpy).toHaveBeenCalledWith(99999, "SIGTERM");
|
||||
// Stop now goes through killProcessTree (which is module-mocked above),
|
||||
// not a direct process.kill — that's how it gets `taskkill /T /F` on
|
||||
// Windows and process-group kill on Unix. Assert on the mock.
|
||||
expect(mockKillProcessTree).toHaveBeenCalledWith(99999, "SIGTERM");
|
||||
expect(mockUnregister).toHaveBeenCalled();
|
||||
expect(mockRemoveProjectFromRunning).not.toHaveBeenCalled();
|
||||
|
||||
killSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("targeted stop records last-stop with correct project scope", async () => {
|
||||
|
|
@ -2267,7 +2379,7 @@ describe("start command — already-running detection", () => {
|
|||
globalConfigPath,
|
||||
yamlStringify(
|
||||
{
|
||||
defaults: { runtime: "tmux", agent: "claude-code", workspace: "worktree", notifiers: [] },
|
||||
defaults: { runtime: "process", agent: "claude-code", workspace: "worktree", notifiers: [] },
|
||||
projects: {
|
||||
"my-app": {
|
||||
name: "My App",
|
||||
|
|
@ -2312,8 +2424,7 @@ describe("start command — already-running detection", () => {
|
|||
) {
|
||||
return "https://github.com/org/new-repo.git";
|
||||
}
|
||||
if (args[0] === "symbolic-ref" && workingDir === repoDir)
|
||||
return "refs/remotes/origin/main";
|
||||
if (args[0] === "symbolic-ref" && workingDir === repoDir) return "refs/remotes/origin/main";
|
||||
if (args[0] === "rev-parse" && args[1] === "--verify" && workingDir === repoDir)
|
||||
return "abc";
|
||||
return null;
|
||||
|
|
@ -2416,8 +2527,7 @@ describe("start command — already-running detection", () => {
|
|||
});
|
||||
|
||||
mockWaitForExit.mockResolvedValue(true);
|
||||
|
||||
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true);
|
||||
mockKillProcessTree.mockResolvedValue(undefined);
|
||||
|
||||
mockPromptSelect.mockResolvedValue("restart");
|
||||
|
||||
|
|
@ -2431,7 +2541,10 @@ describe("start command — already-running detection", () => {
|
|||
// Startup after restart may throw — that's OK for this test
|
||||
}
|
||||
|
||||
expect(killSpy).toHaveBeenCalledWith(9999, "SIGTERM");
|
||||
// killExistingDaemon delegates to killProcessTree (taskkill /T /F on Windows,
|
||||
// process group signalling on Unix) instead of raw process.kill, so dead
|
||||
// grandchildren of the daemon don't leak.
|
||||
expect(mockKillProcessTree).toHaveBeenCalledWith(9999, "SIGTERM");
|
||||
expect(mockUnregister).toHaveBeenCalled();
|
||||
|
||||
const output = vi
|
||||
|
|
@ -2439,8 +2552,6 @@ describe("start command — already-running detection", () => {
|
|||
.mock.calls.map((c) => c.join(" "))
|
||||
.join("\n");
|
||||
expect(output).toContain("Stopped existing instance");
|
||||
|
||||
killSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("creates new orchestrator entry when human caller selects 'new'", async () => {
|
||||
|
|
@ -2460,7 +2571,7 @@ describe("start command — already-running detection", () => {
|
|||
configPath,
|
||||
yamlStringify(
|
||||
{
|
||||
defaults: { runtime: "tmux", agent: "claude-code", workspace: "worktree", notifiers: [] },
|
||||
defaults: { runtime: "process", agent: "claude-code", workspace: "worktree", notifiers: [] },
|
||||
projects: {
|
||||
"my-app": {
|
||||
name: "My App",
|
||||
|
|
@ -2514,7 +2625,7 @@ describe("start command — already-running detection", () => {
|
|||
const { stringify: yamlStringify } = await import("yaml");
|
||||
const originalYaml = yamlStringify(
|
||||
{
|
||||
defaults: { runtime: "tmux", agent: "claude-code", workspace: "worktree", notifiers: [] },
|
||||
defaults: { runtime: "process", agent: "claude-code", workspace: "worktree", notifiers: [] },
|
||||
projects: {
|
||||
"my-app": {
|
||||
name: "My App",
|
||||
|
|
@ -2567,7 +2678,7 @@ describe("start command — path-based deduplication in addProjectToConfig", ()
|
|||
configPath,
|
||||
yamlStringify(
|
||||
{
|
||||
defaults: { runtime: "tmux", agent: "claude-code", workspace: "worktree", notifiers: [] },
|
||||
defaults: { runtime: "process", agent: "claude-code", workspace: "worktree", notifiers: [] },
|
||||
projects: {
|
||||
"my-app": {
|
||||
name: "My App",
|
||||
|
|
@ -2620,7 +2731,7 @@ describe("start command — path-based deduplication in addProjectToConfig", ()
|
|||
configPath,
|
||||
yamlStringify(
|
||||
{
|
||||
defaults: { runtime: "tmux", agent: "claude-code", workspace: "worktree", notifiers: [] },
|
||||
defaults: { runtime: "process", agent: "claude-code", workspace: "worktree", notifiers: [] },
|
||||
projects: {
|
||||
"old-name": {
|
||||
name: "Old Name",
|
||||
|
|
@ -2680,7 +2791,7 @@ describe("start command — global registry mutations", () => {
|
|||
globalConfigPath,
|
||||
yamlStringify(
|
||||
{
|
||||
defaults: { runtime: "tmux", agent: "claude-code", workspace: "worktree", notifiers: [] },
|
||||
defaults: { runtime: "process", agent: "claude-code", workspace: "worktree", notifiers: [] },
|
||||
projects: {
|
||||
current: {
|
||||
projectId: "current",
|
||||
|
|
@ -2781,7 +2892,7 @@ describe("start command — global registry mutations", () => {
|
|||
globalConfigPath,
|
||||
yamlStringify(
|
||||
{
|
||||
defaults: { runtime: "tmux", agent: "claude-code", workspace: "worktree", notifiers: [] },
|
||||
defaults: { runtime: "process", agent: "claude-code", workspace: "worktree", notifiers: [] },
|
||||
projects: {
|
||||
current: {
|
||||
projectId: "current",
|
||||
|
|
|
|||
|
|
@ -30,7 +30,10 @@ function setMtime(path: string, ageMs: number): void {
|
|||
utimesSync(path, t, t);
|
||||
}
|
||||
|
||||
describe("bun-tmp-janitor", () => {
|
||||
// Skipped on Windows: startBunTmpJanitor() is a no-op on win32 (opencode ships
|
||||
// no Windows binary, and the kernel disallows unlinking mapped files), so the
|
||||
// behavioural tests below have no work to assert against.
|
||||
describe.skipIf(process.platform === "win32")("bun-tmp-janitor", () => {
|
||||
beforeEach(() => {
|
||||
mockedDir = mkdtempSync(join(tmpdir(), "ao-bun-janitor-test-"));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import type * as AoCore from "@aoagents/ao-core";
|
||||
|
||||
const { mockUnregister, mockWaitForExit, mockProcessKill } = vi.hoisted(() => ({
|
||||
const { mockUnregister, mockWaitForExit, mockKillProcessTree } = vi.hoisted(() => ({
|
||||
mockUnregister: vi.fn(),
|
||||
mockWaitForExit: vi.fn(),
|
||||
mockProcessKill: vi.fn(),
|
||||
mockKillProcessTree: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/running-state.js", () => ({
|
||||
|
|
@ -11,6 +12,14 @@ vi.mock("../../src/lib/running-state.js", () => ({
|
|||
waitForExit: mockWaitForExit,
|
||||
}));
|
||||
|
||||
vi.mock("@aoagents/ao-core", async () => {
|
||||
const actual = await vi.importActual<typeof AoCore>("@aoagents/ao-core");
|
||||
return {
|
||||
...actual,
|
||||
killProcessTree: mockKillProcessTree,
|
||||
};
|
||||
});
|
||||
|
||||
import { attachToDaemon, killExistingDaemon } from "../../src/lib/daemon.js";
|
||||
import type { RunningState } from "../../src/lib/running-state.js";
|
||||
|
||||
|
|
@ -26,17 +35,8 @@ beforeEach(() => {
|
|||
mockUnregister.mockReset();
|
||||
mockUnregister.mockResolvedValue(undefined);
|
||||
mockWaitForExit.mockReset();
|
||||
mockProcessKill.mockReset();
|
||||
// Spy is installed per-test and restored in afterEach so the mocked
|
||||
// process.kill cannot leak into sibling test files when Vitest reuses
|
||||
// worker threads.
|
||||
vi.spyOn(process, "kill").mockImplementation(((
|
||||
pid: number,
|
||||
signal?: string | number,
|
||||
) => {
|
||||
mockProcessKill(pid, signal);
|
||||
return true;
|
||||
}) as typeof process.kill);
|
||||
mockKillProcessTree.mockReset();
|
||||
mockKillProcessTree.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -58,10 +58,9 @@ describe("attachToDaemon", () => {
|
|||
const daemon = attachToDaemon(fakeRunning);
|
||||
const result = await daemon.notifyProjectChange();
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
"http://localhost:3000/api/projects/reload",
|
||||
{ method: "POST" },
|
||||
);
|
||||
expect(fetchSpy).toHaveBeenCalledWith("http://localhost:3000/api/projects/reload", {
|
||||
method: "POST",
|
||||
});
|
||||
fetchSpy.mockRestore();
|
||||
});
|
||||
|
||||
|
|
@ -79,9 +78,7 @@ describe("attachToDaemon", () => {
|
|||
});
|
||||
|
||||
it("notifyProjectChange returns a reasoned failure when fetch throws", async () => {
|
||||
const fetchSpy = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockRejectedValue(new Error("ECONNREFUSED"));
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("ECONNREFUSED"));
|
||||
const daemon = attachToDaemon(fakeRunning);
|
||||
const result = await daemon.notifyProjectChange();
|
||||
expect(result.ok).toBe(false);
|
||||
|
|
@ -93,21 +90,21 @@ describe("attachToDaemon", () => {
|
|||
});
|
||||
|
||||
describe("killExistingDaemon", () => {
|
||||
it("SIGTERMs the daemon, awaits exit, and unregisters on the happy path", async () => {
|
||||
it("uses killProcessTree(SIGTERM), awaits exit, and unregisters on the happy path", async () => {
|
||||
mockWaitForExit.mockResolvedValueOnce(true);
|
||||
await killExistingDaemon(fakeRunning);
|
||||
expect(mockProcessKill).toHaveBeenCalledWith(12345, "SIGTERM");
|
||||
expect(mockProcessKill).toHaveBeenCalledTimes(1);
|
||||
expect(mockKillProcessTree).toHaveBeenCalledWith(12345, "SIGTERM");
|
||||
expect(mockKillProcessTree).toHaveBeenCalledTimes(1);
|
||||
expect(mockWaitForExit).toHaveBeenCalledWith(12345, 5000);
|
||||
expect(mockUnregister).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("escalates to SIGKILL when SIGTERM does not exit within the timeout", async () => {
|
||||
it("escalates to SIGKILL via killProcessTree when SIGTERM does not exit", async () => {
|
||||
mockWaitForExit.mockResolvedValueOnce(false);
|
||||
mockWaitForExit.mockResolvedValueOnce(true);
|
||||
await killExistingDaemon(fakeRunning);
|
||||
expect(mockProcessKill).toHaveBeenNthCalledWith(1, 12345, "SIGTERM");
|
||||
expect(mockProcessKill).toHaveBeenNthCalledWith(2, 12345, "SIGKILL");
|
||||
expect(mockKillProcessTree).toHaveBeenNthCalledWith(1, 12345, "SIGTERM");
|
||||
expect(mockKillProcessTree).toHaveBeenNthCalledWith(2, 12345, "SIGKILL");
|
||||
expect(mockUnregister).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
|
@ -120,12 +117,15 @@ describe("killExistingDaemon", () => {
|
|||
expect(mockUnregister).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("treats already-dead processes as success (process.kill throws ESRCH)", async () => {
|
||||
mockProcessKill.mockImplementation(() => {
|
||||
throw new Error("ESRCH");
|
||||
});
|
||||
it("treats killProcessTree errors as best-effort and still unregisters when process is gone", async () => {
|
||||
// killProcessTree itself swallows errors internally, but defend against
|
||||
// a future regression by ensuring an unexpected throw does not crash
|
||||
// unregister() when the process has actually exited.
|
||||
mockKillProcessTree.mockRejectedValueOnce(new Error("transient"));
|
||||
mockWaitForExit.mockResolvedValueOnce(true);
|
||||
await expect(killExistingDaemon(fakeRunning)).resolves.toBeUndefined();
|
||||
expect(mockUnregister).toHaveBeenCalled();
|
||||
await expect(killExistingDaemon(fakeRunning)).rejects.toThrow("transient");
|
||||
// unregister should NOT have been called in this rejection path —
|
||||
// we only want to unregister after a clean exit.
|
||||
expect(mockUnregister).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -138,7 +138,8 @@ describe("openclaw-probe", () => {
|
|||
const result = await detectOpenClawInstallation();
|
||||
|
||||
expect(result.state).toBe("running");
|
||||
expect(result.configPath).toContain(".openclaw/openclaw.json");
|
||||
expect(result.configPath).toContain(".openclaw");
|
||||
expect(result.configPath).toContain("openclaw.json");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,98 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtempSync, rmSync, mkdirSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { pathsEqual, canonicalCompareKey } from "../../src/lib/path-equality.js";
|
||||
|
||||
let tmpDir: string;
|
||||
let originalPlatform: PropertyDescriptor | undefined;
|
||||
|
||||
function setPlatform(p: NodeJS.Platform): void {
|
||||
Object.defineProperty(process, "platform", { value: p, configurable: true });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), "ao-pathseq-"));
|
||||
originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalPlatform) {
|
||||
Object.defineProperty(process, "platform", originalPlatform);
|
||||
}
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("pathsEqual", () => {
|
||||
it("returns true for the same path", () => {
|
||||
const dir = join(tmpDir, "same");
|
||||
mkdirSync(dir);
|
||||
expect(pathsEqual(dir, dir)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for clearly different paths", () => {
|
||||
const a = join(tmpDir, "a");
|
||||
const b = join(tmpDir, "b");
|
||||
mkdirSync(a);
|
||||
mkdirSync(b);
|
||||
expect(pathsEqual(a, b)).toBe(false);
|
||||
});
|
||||
|
||||
it.skipIf(process.platform !== "win32")("treats drive-letter case as equal on Windows", () => {
|
||||
// Real filesystem path so realpathSync resolves; only the input case differs.
|
||||
const dir = join(tmpDir, "case-test");
|
||||
mkdirSync(dir);
|
||||
const lowerDrive = dir.replace(/^([A-Z]):/, (_, c: string) => `${c.toLowerCase()}:`);
|
||||
const upperDrive = dir.replace(/^([a-z]):/, (_, c: string) => `${c.toUpperCase()}:`);
|
||||
expect(pathsEqual(lowerDrive, upperDrive)).toBe(true);
|
||||
});
|
||||
|
||||
it.skipIf(process.platform !== "win32")(
|
||||
"treats arbitrary path-segment case as equal on Windows",
|
||||
() => {
|
||||
const dir = join(tmpDir, "MixedCaseSegment");
|
||||
mkdirSync(dir);
|
||||
const lower = dir.toLowerCase();
|
||||
// realpathSync should resolve both to the same on-disk canonical form;
|
||||
// pathsEqual then lowercases for comparison on Windows.
|
||||
expect(pathsEqual(dir, lower)).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === "win32")("is case-sensitive on POSIX", () => {
|
||||
// Don't actually mkdir — we just want to verify the comparison logic.
|
||||
// Use a non-existent path so realpathSync falls back to the literal.
|
||||
setPlatform("linux");
|
||||
const a = "/tmp/Case-Sensitive-Test-NoExist";
|
||||
const b = "/tmp/case-sensitive-test-noexist";
|
||||
expect(pathsEqual(a, b)).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to literal comparison when realpathSync fails (path doesn't exist)", () => {
|
||||
const a = join(tmpDir, "nonexistent");
|
||||
expect(pathsEqual(a, a)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canonicalCompareKey", () => {
|
||||
it("expands ~ to HOME", () => {
|
||||
const originalHome = process.env["HOME"];
|
||||
process.env["HOME"] = tmpDir;
|
||||
try {
|
||||
const key = canonicalCompareKey("~");
|
||||
// On Windows the result is lowercased; on POSIX it's case-preserved.
|
||||
expect(key.toLowerCase()).toBe(tmpDir.toLowerCase());
|
||||
} finally {
|
||||
if (originalHome === undefined) delete process.env["HOME"];
|
||||
else process.env["HOME"] = originalHome;
|
||||
}
|
||||
});
|
||||
|
||||
it("returns the same key for equivalent inputs", () => {
|
||||
const dir = join(tmpDir, "equiv");
|
||||
mkdirSync(dir);
|
||||
expect(canonicalCompareKey(dir)).toBe(canonicalCompareKey(dir));
|
||||
});
|
||||
});
|
||||
|
|
@ -58,17 +58,24 @@ describe("script-runner", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("uses the package root for packaged installs inside node_modules", () => {
|
||||
const modulePath =
|
||||
"/usr/local/lib/node_modules/@aoagents/ao-cli/dist/lib/script-runner.js";
|
||||
// POSIX-style fixture paths in these tests reach `path.resolve()` on
|
||||
// Windows, which prepends the current drive letter and converts to
|
||||
// backslashes. Skip on Windows; the same code paths are exercised by the
|
||||
// other tests using `mkdtempSync` (which produces native paths).
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"uses the package root for packaged installs inside node_modules",
|
||||
() => {
|
||||
const modulePath =
|
||||
"/usr/local/lib/node_modules/@aoagents/ao-cli/dist/lib/script-runner.js";
|
||||
|
||||
expect(resolveScriptLayoutFromPath(modulePath)).toBe("package-install");
|
||||
expect(resolveDefaultRepoRootFromPath(modulePath)).toBe(
|
||||
"/usr/local/lib/node_modules/@aoagents/ao-cli",
|
||||
);
|
||||
});
|
||||
expect(resolveScriptLayoutFromPath(modulePath)).toBe("package-install");
|
||||
expect(resolveDefaultRepoRootFromPath(modulePath)).toBe(
|
||||
"/usr/local/lib/node_modules/@aoagents/ao-cli",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("uses the repository root for source checkouts", () => {
|
||||
it.skipIf(process.platform === "win32")("uses the repository root for source checkouts", () => {
|
||||
const modulePath =
|
||||
"/Users/test/agent-orchestrator/packages/cli/src/lib/script-runner.ts";
|
||||
|
||||
|
|
@ -84,9 +91,12 @@ describe("script-runner", () => {
|
|||
"../../src/assets/scripts",
|
||||
);
|
||||
|
||||
// Escape every regex metachar (including '\' on Windows paths) for the
|
||||
// scripts-directory portion so the assertion is path-separator-agnostic.
|
||||
const escapedDir = expectedScriptsDir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
expect(() => resolveScriptPath("does-not-exist.sh")).toThrowError(
|
||||
new RegExp(
|
||||
`Script not found: does-not-exist\\.sh\\. Expected at: .*does-not-exist\\.sh \\(scripts directory: ${expectedScriptsDir.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\$&")}\\)`,
|
||||
`Script not found: does-not-exist\\.sh\\. Expected at: .*does-not-exist\\.sh \\(scripts directory: ${escapedDir}\\)`,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
|
@ -125,7 +135,88 @@ describe("script-runner", () => {
|
|||
expect(resolveScriptLayout()).toBe("package-install");
|
||||
});
|
||||
|
||||
it("pins script execution cwd to the resolved install root", async () => {
|
||||
// -----------------------------------------------------------------------
|
||||
// Windows PowerShell branch — runRepoScript prefers <script>.ps1 over <script>.sh
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
// Windows-only: detection walks PATH for pwsh.exe / powershell.exe and
|
||||
// falls back to System32. On non-Windows hosts none of those exist, so
|
||||
// these assertions only have meaning when actually executed on Windows.
|
||||
it.skipIf(process.platform !== "win32")(
|
||||
"spawns PowerShell with -File and bypass policy when .ps1 sibling exists",
|
||||
async () => {
|
||||
const tempRoot = mkdtempSync(join(tmpdir(), "script-runner-ps-"));
|
||||
mkdirSync(join(tempRoot, ".git"), { recursive: true });
|
||||
mkdirSync(join(tempRoot, "packages", "ao"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(tempRoot, "packages", "ao", "package.json"),
|
||||
JSON.stringify({ name: "@aoagents/ao" }),
|
||||
);
|
||||
|
||||
process.env["AO_REPO_ROOT"] = tempRoot;
|
||||
const child = new EventEmitter();
|
||||
mockSpawn.mockReturnValue(child);
|
||||
setTimeout(() => child.emit("exit", 0, null), 0);
|
||||
|
||||
// ao-doctor.sh ships with a sibling ao-doctor.ps1, so the Windows
|
||||
// branch in runRepoScript() should rewrite to .ps1 and dispatch via
|
||||
// PowerShell instead of bash.
|
||||
await runRepoScript("ao-doctor.sh", ["--check", "tmux"]);
|
||||
|
||||
expect(mockSpawn).toHaveBeenCalledTimes(1);
|
||||
const [shell, args, opts] = mockSpawn.mock.calls[0] as [string, string[], { cwd: string }];
|
||||
|
||||
// PS binary: pwsh.exe / powershell.exe found on PATH or System32.
|
||||
expect(shell.toLowerCase()).toMatch(/(pwsh|powershell)\.exe$/);
|
||||
|
||||
// Args: PowerShell flags first, then -File <ao-doctor.ps1>, then user args.
|
||||
expect(args.slice(0, 5)).toEqual([
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-File",
|
||||
]);
|
||||
expect(args[5]).toMatch(/ao-doctor\.ps1$/);
|
||||
// .sh is NOT what got resolved — the rewrite to .ps1 happened.
|
||||
expect(args[5]).not.toMatch(/ao-doctor\.sh$/);
|
||||
expect(args.slice(6)).toEqual(["--check", "tmux"]);
|
||||
|
||||
// cwd is pinned to AO_REPO_ROOT just like the bash path does.
|
||||
expect(opts.cwd).toBe(tempRoot);
|
||||
|
||||
rmSync(tempRoot, { recursive: true, force: true });
|
||||
},
|
||||
);
|
||||
|
||||
// Sanity check that the rewrite is name-driven, not blind: a script that
|
||||
// doesn't end in .sh shouldn't be probed for a .ps1 sibling. The function
|
||||
// throws at resolveScriptPath because the literal name doesn't ship, so
|
||||
// we assert the error message rather than spawn shape.
|
||||
it.skipIf(process.platform !== "win32")(
|
||||
"does not rewrite to .ps1 for scripts that do not end in .sh",
|
||||
async () => {
|
||||
const tempRoot = mkdtempSync(join(tmpdir(), "script-runner-ps-noext-"));
|
||||
mkdirSync(join(tempRoot, ".git"), { recursive: true });
|
||||
mkdirSync(join(tempRoot, "packages", "ao"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(tempRoot, "packages", "ao", "package.json"),
|
||||
JSON.stringify({ name: "@aoagents/ao" }),
|
||||
);
|
||||
process.env["AO_REPO_ROOT"] = tempRoot;
|
||||
|
||||
// No .ps1 lookup happens, falls through to bash branch which then
|
||||
// resolveScriptPath fails because we ship no .nope file.
|
||||
await expect(runRepoScript("ao-doctor.nope", [])).rejects.toThrowError(
|
||||
/Script not found: ao-doctor\.nope/,
|
||||
);
|
||||
expect(mockSpawn).not.toHaveBeenCalled();
|
||||
|
||||
rmSync(tempRoot, { recursive: true, force: true });
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === "win32")("pins script execution cwd to the resolved install root", async () => {
|
||||
const tempRoot = mkdtempSync(join(tmpdir(), "script-runner-cwd-"));
|
||||
mkdirSync(join(tempRoot, ".git"), { recursive: true });
|
||||
mkdirSync(join(tempRoot, "packages", "ao"), { recursive: true });
|
||||
|
|
@ -142,7 +233,9 @@ describe("script-runner", () => {
|
|||
await runRepoScript("ao-doctor.sh", []);
|
||||
|
||||
expect(mockSpawn).toHaveBeenCalledWith(
|
||||
"bash",
|
||||
// On Windows the resolved bash is an absolute path (e.g. Git Bash);
|
||||
// on POSIX it is the literal "bash" passed through to the shell.
|
||||
expect.stringMatching(/(^bash$|bash(\.exe)?$)/),
|
||||
[expect.stringContaining("ao-doctor.sh")],
|
||||
expect.objectContaining({
|
||||
cwd: tempRoot,
|
||||
|
|
|
|||
|
|
@ -1,13 +1,20 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
const { mockExecFile } = vi.hoisted(() => ({
|
||||
const { mockExecFile, mockKillProcessTree } = vi.hoisted(() => ({
|
||||
mockExecFile: vi.fn(),
|
||||
mockKillProcessTree: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("node:child_process", () => ({
|
||||
execFile: mockExecFile,
|
||||
}));
|
||||
|
||||
vi.mock("@aoagents/ao-core", () => ({
|
||||
killProcessTree: mockKillProcessTree,
|
||||
}));
|
||||
|
||||
import {
|
||||
exec,
|
||||
execSilent,
|
||||
|
|
@ -16,8 +23,13 @@ import {
|
|||
gh,
|
||||
getTmuxSessions,
|
||||
getTmuxActivity,
|
||||
forwardSignalsToChild,
|
||||
} from "../../src/lib/shell.js";
|
||||
|
||||
function makeFakeChild(): ChildProcess {
|
||||
return new EventEmitter() as unknown as ChildProcess;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockExecFile.mockReset();
|
||||
});
|
||||
|
|
@ -182,3 +194,66 @@ describe("getTmuxActivity", () => {
|
|||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("forwardSignalsToChild", () => {
|
||||
afterEach(() => {
|
||||
process.removeAllListeners("SIGINT");
|
||||
process.removeAllListeners("SIGTERM");
|
||||
mockKillProcessTree.mockClear();
|
||||
});
|
||||
|
||||
it("registers SIGINT and SIGTERM listeners on the process", () => {
|
||||
const child = makeFakeChild();
|
||||
const before = process.listenerCount("SIGINT");
|
||||
forwardSignalsToChild(1234, child);
|
||||
expect(process.listenerCount("SIGINT")).toBe(before + 1);
|
||||
expect(process.listenerCount("SIGTERM")).toBe(before + 1);
|
||||
});
|
||||
|
||||
it("is idempotent — calling twice for the same child registers only one handler per signal", () => {
|
||||
const child = makeFakeChild();
|
||||
const before = process.listenerCount("SIGINT");
|
||||
forwardSignalsToChild(1234, child);
|
||||
forwardSignalsToChild(1234, child);
|
||||
expect(process.listenerCount("SIGINT")).toBe(before + 1);
|
||||
expect(process.listenerCount("SIGTERM")).toBe(before + 1);
|
||||
});
|
||||
|
||||
it("allows independent registration for different child objects", () => {
|
||||
const childA = makeFakeChild();
|
||||
const childB = makeFakeChild();
|
||||
const before = process.listenerCount("SIGINT");
|
||||
forwardSignalsToChild(1234, childA);
|
||||
forwardSignalsToChild(5678, childB);
|
||||
expect(process.listenerCount("SIGINT")).toBe(before + 2);
|
||||
expect(process.listenerCount("SIGTERM")).toBe(before + 2);
|
||||
});
|
||||
|
||||
it("removes SIGTERM handler when SIGINT fires", () => {
|
||||
const child = makeFakeChild();
|
||||
forwardSignalsToChild(1234, child);
|
||||
const sigtermBefore = process.listenerCount("SIGTERM");
|
||||
process.emit("SIGINT");
|
||||
expect(process.listenerCount("SIGTERM")).toBe(sigtermBefore - 1);
|
||||
expect(mockKillProcessTree).toHaveBeenCalledWith(1234, "SIGTERM");
|
||||
});
|
||||
|
||||
it("removes SIGINT handler when SIGTERM fires", () => {
|
||||
const child = makeFakeChild();
|
||||
forwardSignalsToChild(1234, child);
|
||||
const sigintBefore = process.listenerCount("SIGINT");
|
||||
process.emit("SIGTERM");
|
||||
expect(process.listenerCount("SIGINT")).toBe(sigintBefore - 1);
|
||||
expect(mockKillProcessTree).toHaveBeenCalledWith(1234, "SIGTERM");
|
||||
});
|
||||
|
||||
it("removes both signal handlers when child exits normally", () => {
|
||||
const child = makeFakeChild();
|
||||
forwardSignalsToChild(1234, child);
|
||||
const sigintBefore = process.listenerCount("SIGINT");
|
||||
const sigtermBefore = process.listenerCount("SIGTERM");
|
||||
child.emit("exit", 0, null);
|
||||
expect(process.listenerCount("SIGINT")).toBe(sigintBefore - 1);
|
||||
expect(process.listenerCount("SIGTERM")).toBe(sigtermBefore - 1);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,145 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtempSync, writeFileSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
const { mockAskYesNo, mockExecSilent } = vi.hoisted(() => ({
|
||||
mockAskYesNo: vi.fn(),
|
||||
mockExecSilent: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/install-helpers.js", () => ({
|
||||
askYesNo: mockAskYesNo,
|
||||
tryInstallWithAttempts: vi.fn(async () => false),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/shell.js", () => ({
|
||||
execSilent: mockExecSilent,
|
||||
}));
|
||||
|
||||
import { ensureTmux } from "../../src/lib/startup-preflight.js";
|
||||
|
||||
let tmpDir: string;
|
||||
let originalPlatform: PropertyDescriptor | undefined;
|
||||
|
||||
function setPlatform(p: NodeJS.Platform): void {
|
||||
Object.defineProperty(process, "platform", { value: p, configurable: true });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), "ao-preflight-test-"));
|
||||
originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
|
||||
mockAskYesNo.mockReset();
|
||||
mockExecSilent.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalPlatform) {
|
||||
Object.defineProperty(process, "platform", originalPlatform);
|
||||
}
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("ensureTmux on Windows", () => {
|
||||
it("rewrites runtime: tmux -> runtime: process when user accepts", async () => {
|
||||
setPlatform("win32");
|
||||
const configPath = join(tmpDir, "agent-orchestrator.yaml");
|
||||
const original = [
|
||||
"port: 3000",
|
||||
"defaults:",
|
||||
" runtime: tmux",
|
||||
" agent: claude-code",
|
||||
"projects: {}",
|
||||
"",
|
||||
].join("\n");
|
||||
writeFileSync(configPath, original, "utf-8");
|
||||
|
||||
mockAskYesNo.mockResolvedValueOnce(true);
|
||||
|
||||
const result = await ensureTmux(configPath);
|
||||
expect(result.switchedToProcess).toBe(true);
|
||||
|
||||
const after = readFileSync(configPath, "utf-8");
|
||||
expect(after).toContain("runtime: process");
|
||||
expect(after).not.toContain("runtime: tmux");
|
||||
// Surrounding lines preserved
|
||||
expect(after).toContain("agent: claude-code");
|
||||
expect(after).toContain("port: 3000");
|
||||
});
|
||||
|
||||
it("preserves quoting when rewriting", async () => {
|
||||
setPlatform("win32");
|
||||
const configPath = join(tmpDir, "agent-orchestrator.yaml");
|
||||
writeFileSync(configPath, 'defaults:\n runtime: "tmux"\n agent: claude-code\n', "utf-8");
|
||||
|
||||
mockAskYesNo.mockResolvedValueOnce(true);
|
||||
const result = await ensureTmux(configPath);
|
||||
expect(result.switchedToProcess).toBe(true);
|
||||
|
||||
const after = readFileSync(configPath, "utf-8");
|
||||
expect(after).toContain("runtime: process");
|
||||
});
|
||||
|
||||
it("preserves trailing comments when rewriting", async () => {
|
||||
setPlatform("win32");
|
||||
const configPath = join(tmpDir, "agent-orchestrator.yaml");
|
||||
writeFileSync(configPath, "defaults:\n runtime: tmux # legacy default\n", "utf-8");
|
||||
|
||||
mockAskYesNo.mockResolvedValueOnce(true);
|
||||
const result = await ensureTmux(configPath);
|
||||
expect(result.switchedToProcess).toBe(true);
|
||||
|
||||
const after = readFileSync(configPath, "utf-8");
|
||||
expect(after).toContain("runtime: process # legacy default");
|
||||
});
|
||||
|
||||
it("exits when user declines the rewrite", async () => {
|
||||
setPlatform("win32");
|
||||
const configPath = join(tmpDir, "agent-orchestrator.yaml");
|
||||
writeFileSync(configPath, "defaults:\n runtime: tmux\n", "utf-8");
|
||||
|
||||
mockAskYesNo.mockResolvedValueOnce(false);
|
||||
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {
|
||||
throw new Error("__process_exit__");
|
||||
}) as never);
|
||||
|
||||
await expect(ensureTmux(configPath)).rejects.toThrow("__process_exit__");
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
|
||||
// File untouched
|
||||
const after = readFileSync(configPath, "utf-8");
|
||||
expect(after).toContain("runtime: tmux");
|
||||
});
|
||||
|
||||
it("exits without prompting when configPath is missing", async () => {
|
||||
setPlatform("win32");
|
||||
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {
|
||||
throw new Error("__process_exit__");
|
||||
}) as never);
|
||||
|
||||
await expect(ensureTmux()).rejects.toThrow("__process_exit__");
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
expect(mockAskYesNo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not invoke tmux -V on Windows", async () => {
|
||||
setPlatform("win32");
|
||||
const configPath = join(tmpDir, "agent-orchestrator.yaml");
|
||||
writeFileSync(configPath, "defaults:\n runtime: tmux\n", "utf-8");
|
||||
mockAskYesNo.mockResolvedValueOnce(true);
|
||||
|
||||
await ensureTmux(configPath);
|
||||
expect(mockExecSilent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ensureTmux on Linux when tmux is present", () => {
|
||||
it("returns without prompting", async () => {
|
||||
setPlatform("linux");
|
||||
mockExecSilent.mockResolvedValueOnce("tmux 3.3a");
|
||||
const result = await ensureTmux();
|
||||
expect(result.switchedToProcess).toBe(false);
|
||||
expect(mockAskYesNo).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -255,7 +255,7 @@ describe("update-check", () => {
|
|||
process.env["XDG_CACHE_HOME"] = "/custom/cache";
|
||||
|
||||
const dir = getCacheDir();
|
||||
expect(dir).toBe("/custom/cache/ao");
|
||||
expect(dir).toMatch(/^[\\/]custom[\\/]cache[\\/]ao$/);
|
||||
|
||||
if (origXdg !== undefined) process.env["XDG_CACHE_HOME"] = origXdg;
|
||||
else delete process.env["XDG_CACHE_HOME"];
|
||||
|
|
@ -267,7 +267,7 @@ describe("update-check", () => {
|
|||
|
||||
const dir = getCacheDir();
|
||||
expect(dir).toContain(".cache");
|
||||
expect(dir).toMatch(/\/ao$/);
|
||||
expect(dir).toMatch(/[\\/]ao$/);
|
||||
|
||||
if (origXdg !== undefined) process.env["XDG_CACHE_HOME"] = origXdg;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,70 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
// Windows-only mirror of doctor-script.test.ts (which is fully skipped on Windows).
|
||||
// Exercises the PS1 port's argparse and basic execution. A developer who breaks
|
||||
// the script's syntax or top-level flow gets a clear failure here rather than at
|
||||
// runtime on a user's machine.
|
||||
|
||||
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const scriptPath = join(packageRoot, "src", "assets", "scripts", "ao-doctor.ps1");
|
||||
|
||||
function runPwsh(args: string[], extraEnv: Record<string, string> = {}) {
|
||||
return spawnSync(
|
||||
"pwsh",
|
||||
["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", scriptPath, ...args],
|
||||
{
|
||||
env: { ...process.env, ...extraEnv },
|
||||
encoding: "utf8",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
describe.runIf(process.platform === "win32")("ao-doctor.ps1", () => {
|
||||
it("prints usage and exits 0 for --help", () => {
|
||||
const result = runPwsh(["--help"]);
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toContain("Usage: ao doctor");
|
||||
expect(result.stdout).toContain("--fix");
|
||||
});
|
||||
|
||||
it("prints usage and exits 0 for -h", () => {
|
||||
const result = runPwsh(["-h"]);
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toContain("Usage: ao doctor");
|
||||
});
|
||||
|
||||
it("rejects unknown flags with exit 1", () => {
|
||||
const result = runPwsh(["--bogus-flag"]);
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain("Unknown option");
|
||||
});
|
||||
|
||||
it("runs the full check pipeline against an empty repo and reports findings", () => {
|
||||
// Point AO_REPO_ROOT at an empty directory and AO_SCRIPT_LAYOUT at
|
||||
// source-checkout so the script exercises every Check-* function. The
|
||||
// exact PASS/WARN/FAIL count depends on the runner's environment (node,
|
||||
// git, pnpm typically present on GitHub windows-latest), but the script
|
||||
// must always print a final "Results: ... PASS, ... WARN, ... FAIL" line
|
||||
// and exit either 0 (no FAILs) or 1 (FAILs). This catches a script that
|
||||
// fails to parse or crashes mid-pipeline.
|
||||
const tempRoot = mkdtempSync(join(tmpdir(), "ao-doctor-ps1-"));
|
||||
try {
|
||||
const result = runPwsh([], {
|
||||
AO_REPO_ROOT: tempRoot,
|
||||
AO_SCRIPT_LAYOUT: "source-checkout",
|
||||
AO_CONFIG_PATH: join(tempRoot, "agent-orchestrator.yaml"),
|
||||
AO_DOCTOR_TMP_ROOT: tempRoot,
|
||||
});
|
||||
expect([0, 1]).toContain(result.status);
|
||||
expect(result.stdout).toContain("Agent Orchestrator Doctor");
|
||||
expect(result.stdout).toMatch(/Results: \d+ PASS, \d+ WARN, \d+ FAIL, \d+ FIXED/);
|
||||
} finally {
|
||||
rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -91,7 +91,10 @@ function createHealthyPath(binDir: string): void {
|
|||
createFakeBinary(binDir, "ao", 'printf "/fake/ao\\n" >/dev/null\nexit 0');
|
||||
}
|
||||
|
||||
describe("ao-doctor.sh", () => {
|
||||
// Skipped on Windows: bash is required to execute the doctor script and is not
|
||||
// guaranteed to be available without Git for Windows. The Windows code path
|
||||
// (Git Bash auto-detection in runRepoScript) is exercised at runtime, not here.
|
||||
describe.skipIf(process.platform === "win32")("ao-doctor.sh", () => {
|
||||
it("reports a healthy install as PASS", () => {
|
||||
const tempRoot = mkdtempSync(join(tmpdir(), "ao-doctor-script-"));
|
||||
const fakeRepo = createHealthyRepo(tempRoot);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,56 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
// Windows-only mirror of update-script.test.ts. The bash tests skip on Windows
|
||||
// because spawnSync("bash", ...) requires Git Bash and the bash-specific scenarios
|
||||
// don't apply to the PS1 port. These tests run only on Windows and exercise the
|
||||
// PS1 script's argparse + help text — the high-frequency surface that breaks
|
||||
// when someone touches ao-update.ps1.
|
||||
//
|
||||
// Full happy-path coverage (fake git/pnpm/npm binaries via .cmd shims) deserves
|
||||
// its own diff; this file establishes the baseline so any developer who breaks
|
||||
// the PS1 script's argparse or syntax is caught immediately.
|
||||
|
||||
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const scriptPath = join(packageRoot, "src", "assets", "scripts", "ao-update.ps1");
|
||||
|
||||
function runPwsh(args: string[], extraEnv: Record<string, string> = {}) {
|
||||
return spawnSync(
|
||||
"pwsh",
|
||||
["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", scriptPath, ...args],
|
||||
{
|
||||
env: { ...process.env, ...extraEnv },
|
||||
encoding: "utf8",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
describe.runIf(process.platform === "win32")("ao-update.ps1", () => {
|
||||
it("prints usage and exits 0 for --help", () => {
|
||||
const result = runPwsh(["--help"]);
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toContain("Usage: ao update");
|
||||
expect(result.stdout).toContain("--skip-smoke");
|
||||
expect(result.stdout).toContain("--smoke-only");
|
||||
});
|
||||
|
||||
it("prints usage and exits 0 for -h", () => {
|
||||
const result = runPwsh(["-h"]);
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toContain("Usage: ao update");
|
||||
});
|
||||
|
||||
it("rejects unknown flags with exit 1", () => {
|
||||
const result = runPwsh(["--bogus-flag"]);
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain("Unknown option");
|
||||
});
|
||||
|
||||
it("rejects conflicting --skip-smoke and --smoke-only with exit 1", () => {
|
||||
const result = runPwsh(["--skip-smoke", "--smoke-only"]);
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain("Conflicting options");
|
||||
});
|
||||
});
|
||||
|
|
@ -87,20 +87,25 @@ esac\nexit 0`,
|
|||
expect(commands).toContain("npm link --force");
|
||||
});
|
||||
|
||||
it("syncs the fork with upstream via gh and fast-forwards the local checkout from upstream", () => {
|
||||
const tempRoot = mkdtempSync(join(tmpdir(), "ao-update-upstream-script-"));
|
||||
const fakeRepo = join(tempRoot, "repo");
|
||||
mkdirSync(join(fakeRepo, "packages", "cli"), { recursive: true });
|
||||
mkdirSync(join(fakeRepo, "packages", "ao"), { recursive: true });
|
||||
// Bash-script tests skipped on Windows: spawnSync("bash", ...) requires bash
|
||||
// which isn't guaranteed without Git for Windows. The Windows code path uses
|
||||
// detectWindowsBash() at runtime, exercised separately.
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"syncs the fork with upstream via gh and fast-forwards the local checkout from upstream",
|
||||
() => {
|
||||
const tempRoot = mkdtempSync(join(tmpdir(), "ao-update-upstream-script-"));
|
||||
const fakeRepo = join(tempRoot, "repo");
|
||||
mkdirSync(join(fakeRepo, "packages", "cli"), { recursive: true });
|
||||
mkdirSync(join(fakeRepo, "packages", "ao"), { recursive: true });
|
||||
|
||||
const binDir = join(tempRoot, "bin");
|
||||
mkdirSync(binDir, { recursive: true });
|
||||
const commandLog = join(tempRoot, "commands.log");
|
||||
const binDir = join(tempRoot, "bin");
|
||||
mkdirSync(binDir, { recursive: true });
|
||||
const commandLog = join(tempRoot, "commands.log");
|
||||
|
||||
createFakeBinary(
|
||||
binDir,
|
||||
"git",
|
||||
`printf 'git %s\\n' "$*" >> ${JSON.stringify(commandLog)}\ncase "$*" in\n "remote get-url origin") printf 'https://github.com/yyovil/agent-orchestrator.git\\n' ;;
|
||||
createFakeBinary(
|
||||
binDir,
|
||||
"git",
|
||||
`printf 'git %s\\n' "$*" >> ${JSON.stringify(commandLog)}\ncase "$*" in\n "remote get-url origin") printf 'https://github.com/yyovil/agent-orchestrator.git\\n' ;;
|
||||
"remote get-url upstream") printf 'https://github.com/ComposioHQ/agent-orchestrator.git\\n' ;;
|
||||
"rev-parse --is-inside-work-tree") printf 'true\\n' ;;
|
||||
"status --porcelain") ;;
|
||||
|
|
@ -111,50 +116,51 @@ esac\nexit 0`,
|
|||
"pull --ff-only upstream main") ;;
|
||||
*) ;;
|
||||
esac\nexit 0`,
|
||||
);
|
||||
createFakeBinary(
|
||||
binDir,
|
||||
"gh",
|
||||
`printf 'gh %s\\n' "$*" >> ${JSON.stringify(commandLog)}\nexit 0`,
|
||||
);
|
||||
createFakeBinary(
|
||||
binDir,
|
||||
"pnpm",
|
||||
`printf 'pnpm %s\\n' "$*" >> ${JSON.stringify(commandLog)}\nif [ "$1" = "--version" ]; then\n printf '9.15.4\\n'\nfi\nexit 0`,
|
||||
);
|
||||
createFakeBinary(
|
||||
binDir,
|
||||
"npm",
|
||||
`printf 'npm %s\\n' "$*" >> ${JSON.stringify(commandLog)}\nexit 0`,
|
||||
);
|
||||
createFakeBinary(
|
||||
binDir,
|
||||
"node",
|
||||
`printf 'node %s\\n' "$*" >> ${JSON.stringify(commandLog)}\nif [ "$1" = "--version" ]; then\n printf 'v20.11.1\\n'\nfi\nexit 0`,
|
||||
);
|
||||
);
|
||||
createFakeBinary(
|
||||
binDir,
|
||||
"gh",
|
||||
`printf 'gh %s\\n' "$*" >> ${JSON.stringify(commandLog)}\nexit 0`,
|
||||
);
|
||||
createFakeBinary(
|
||||
binDir,
|
||||
"pnpm",
|
||||
`printf 'pnpm %s\\n' "$*" >> ${JSON.stringify(commandLog)}\nif [ "$1" = "--version" ]; then\n printf '9.15.4\\n'\nfi\nexit 0`,
|
||||
);
|
||||
createFakeBinary(
|
||||
binDir,
|
||||
"npm",
|
||||
`printf 'npm %s\\n' "$*" >> ${JSON.stringify(commandLog)}\nexit 0`,
|
||||
);
|
||||
createFakeBinary(
|
||||
binDir,
|
||||
"node",
|
||||
`printf 'node %s\\n' "$*" >> ${JSON.stringify(commandLog)}\nif [ "$1" = "--version" ]; then\n printf 'v20.11.1\\n'\nfi\nexit 0`,
|
||||
);
|
||||
|
||||
const result = spawnSync("bash", [scriptPath, "--skip-smoke"], {
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${binDir}:${process.env.PATH || ""}`,
|
||||
AO_REPO_ROOT: fakeRepo,
|
||||
},
|
||||
encoding: "utf8",
|
||||
});
|
||||
const result = spawnSync("bash", [scriptPath, "--skip-smoke"], {
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${binDir}:${process.env.PATH || ""}`,
|
||||
AO_REPO_ROOT: fakeRepo,
|
||||
},
|
||||
encoding: "utf8",
|
||||
});
|
||||
|
||||
const commands = readFileSync(commandLog, "utf8");
|
||||
rmSync(tempRoot, { recursive: true, force: true });
|
||||
const commands = readFileSync(commandLog, "utf8");
|
||||
rmSync(tempRoot, { recursive: true, force: true });
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(commands).toContain(
|
||||
"gh repo sync yyovil/agent-orchestrator --source ComposioHQ/agent-orchestrator --branch main",
|
||||
);
|
||||
expect(commands).toContain("git fetch upstream main");
|
||||
expect(commands).toContain("git pull --ff-only upstream main");
|
||||
expect(commands).not.toContain("git fetch origin main");
|
||||
});
|
||||
expect(result.status).toBe(0);
|
||||
expect(commands).toContain(
|
||||
"gh repo sync yyovil/agent-orchestrator --source ComposioHQ/agent-orchestrator --branch main",
|
||||
);
|
||||
expect(commands).toContain("git fetch upstream main");
|
||||
expect(commands).toContain("git pull --ff-only upstream main");
|
||||
expect(commands).not.toContain("git fetch origin main");
|
||||
},
|
||||
);
|
||||
|
||||
it("uses forced npm link so stale global ao shims are overwritten", () => {
|
||||
it.skipIf(process.platform === "win32")("uses forced npm link so stale global ao shims are overwritten", () => {
|
||||
const tempRoot = mkdtempSync(join(tmpdir(), "ao-update-stale-shim-"));
|
||||
const fakeRepo = join(tempRoot, "repo");
|
||||
mkdirSync(join(fakeRepo, "packages", "cli"), { recursive: true });
|
||||
|
|
@ -220,7 +226,7 @@ exit 0`,
|
|||
expect(result.stdout).not.toContain("Permission denied");
|
||||
});
|
||||
|
||||
it("runs the built-in smoke commands in smoke-only mode", () => {
|
||||
it.skipIf(process.platform === "win32")("runs the built-in smoke commands in smoke-only mode", () => {
|
||||
const tempRoot = mkdtempSync(join(tmpdir(), "ao-update-smoke-"));
|
||||
const fakeRepo = join(tempRoot, "repo");
|
||||
mkdirSync(join(fakeRepo, "packages", "ao", "bin"), { recursive: true });
|
||||
|
|
@ -307,7 +313,7 @@ exit 0`,
|
|||
expect(result.stderr).toContain("commit or stash");
|
||||
});
|
||||
|
||||
it("skips rebuild but still runs smoke tests when local HEAD matches remote HEAD", () => {
|
||||
it.skipIf(process.platform === "win32")("skips rebuild but still runs smoke tests when local HEAD matches remote HEAD", () => {
|
||||
const tempRoot = mkdtempSync(join(tmpdir(), "ao-update-already-latest-"));
|
||||
const fakeRepo = join(tempRoot, "repo");
|
||||
mkdirSync(join(fakeRepo, "packages", "cli"), { recursive: true });
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@
|
|||
"node": ">=20.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc && cp -r src/assets dist/",
|
||||
"build": "tsc && node --input-type=commonjs -e \"require('fs').cpSync('src/assets','dist/assets',{recursive:true})\"",
|
||||
"dev": "tsx src/index.ts",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,352 @@
|
|||
# PowerShell port of ao-doctor.sh — Windows-native health checks for AO.
|
||||
# Invoked by `ao doctor` on Windows via runRepoScript().
|
||||
|
||||
$ErrorActionPreference = 'Continue'
|
||||
|
||||
# Manual arg parsing — matches ao-doctor.sh's `--fix` / `-h` / `--help` flags
|
||||
# rather than PowerShell's `-Fix` convention, so the calling contract is
|
||||
# identical on Linux/macOS/Windows.
|
||||
$Fix = $false
|
||||
$Help = $false
|
||||
foreach ($a in $args) {
|
||||
switch ($a) {
|
||||
'--fix' { $Fix = $true }
|
||||
'-h' { $Help = $true }
|
||||
'--help' { $Help = $true }
|
||||
default {
|
||||
Write-Error "Unknown option: $a"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($Help) {
|
||||
@'
|
||||
Usage: ao doctor [--fix]
|
||||
|
||||
Checks install, PATH, binaries, service health, stale temp files, and runtime sanity.
|
||||
|
||||
Options:
|
||||
--fix Apply safe fixes for missing launcher links, missing support dirs, and stale temp files
|
||||
'@ | Write-Host
|
||||
exit 0
|
||||
}
|
||||
|
||||
# AO_REPO_ROOT and AO_SCRIPT_LAYOUT are exported by runRepoScript().
|
||||
$RepoRoot = if ($env:AO_REPO_ROOT) { $env:AO_REPO_ROOT } else { (Resolve-Path (Join-Path $PSScriptRoot '..')).Path }
|
||||
$ScriptLayout = $env:AO_SCRIPT_LAYOUT
|
||||
if (-not $ScriptLayout) {
|
||||
if ((Test-Path (Join-Path $RepoRoot 'package.json')) -and
|
||||
(Test-Path (Join-Path $RepoRoot 'dist/index.js')) -and
|
||||
-not (Test-Path (Join-Path $RepoRoot 'packages'))) {
|
||||
$ScriptLayout = 'package-install'
|
||||
} else {
|
||||
$ScriptLayout = 'source-checkout'
|
||||
}
|
||||
}
|
||||
|
||||
$DefaultConfigHome = if ($env:USERPROFILE) { $env:USERPROFILE } else { $RepoRoot }
|
||||
|
||||
$script:PassCount = 0
|
||||
$script:WarnCount = 0
|
||||
$script:FailCount = 0
|
||||
$script:FixCount = 0
|
||||
|
||||
function Write-Pass($msg) { $script:PassCount++; Write-Host "PASS $msg" }
|
||||
function Write-Warn2($msg) { $script:WarnCount++; Write-Host "WARN $msg" }
|
||||
function Write-Fail($msg) { $script:FailCount++; Write-Host "FAIL $msg" }
|
||||
function Write-Fixed($msg) { $script:FixCount++; Write-Host "FIXED $msg" }
|
||||
|
||||
function Expand-HomePath([string]$p) {
|
||||
if ($p -like '~/*' -or $p -like '~\*') {
|
||||
return Join-Path $DefaultConfigHome $p.Substring(2)
|
||||
}
|
||||
if ($p -eq '~') { return $DefaultConfigHome }
|
||||
return $p
|
||||
}
|
||||
|
||||
function Find-AoConfig {
|
||||
if ($env:AO_CONFIG_PATH -and (Test-Path $env:AO_CONFIG_PATH)) {
|
||||
return $env:AO_CONFIG_PATH
|
||||
}
|
||||
$current = Get-Location | Select-Object -ExpandProperty Path
|
||||
while ($current) {
|
||||
foreach ($name in @('agent-orchestrator.yaml', 'agent-orchestrator.yml')) {
|
||||
$candidate = Join-Path $current $name
|
||||
if (Test-Path $candidate) { return $candidate }
|
||||
}
|
||||
$parent = Split-Path $current -Parent
|
||||
if (-not $parent -or $parent -eq $current) { break }
|
||||
$current = $parent
|
||||
}
|
||||
foreach ($candidate in @(
|
||||
(Join-Path $RepoRoot 'agent-orchestrator.yaml'),
|
||||
(Join-Path $DefaultConfigHome '.agent-orchestrator.yaml')
|
||||
)) {
|
||||
if (Test-Path $candidate) { return $candidate }
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
function Read-ConfigValue([string]$key, [string]$file) {
|
||||
$line = Get-Content $file -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_ -match "^\s*${key}:" } |
|
||||
Select-Object -First 1
|
||||
if (-not $line) { return '' }
|
||||
# Strip key, comments, quotes, surrounding whitespace.
|
||||
$val = ($line -replace "^\s*${key}:", '').Trim()
|
||||
$val = ($val -split '#', 2)[0].Trim()
|
||||
$val = $val.Trim('"').Trim("'").Trim()
|
||||
return $val
|
||||
}
|
||||
|
||||
function Ensure-Dir([string]$dir, [string]$label, [string]$fixHint) {
|
||||
if (Test-Path $dir -PathType Container) {
|
||||
Write-Pass "$label exists at $dir"
|
||||
return
|
||||
}
|
||||
if ($Fix) {
|
||||
try {
|
||||
New-Item -ItemType Directory -Path $dir -Force | Out-Null
|
||||
Write-Fixed "$label created at $dir"
|
||||
} catch {
|
||||
Write-Fail "$label could not be created at $dir. Fix: $fixHint"
|
||||
}
|
||||
return
|
||||
}
|
||||
Write-Warn2 "$label is missing at $dir. Fix: $fixHint"
|
||||
}
|
||||
|
||||
function Check-Command([string]$name, [string]$required, [string]$fixHint) {
|
||||
$resolved = Get-Command $name -ErrorAction SilentlyContinue
|
||||
if (-not $resolved) {
|
||||
if ($required -eq 'required') {
|
||||
Write-Fail "$name is not in PATH. Fix: $fixHint"
|
||||
} else {
|
||||
Write-Warn2 "$name is not in PATH. Fix: $fixHint"
|
||||
}
|
||||
return $false
|
||||
}
|
||||
Write-Pass "$name resolves to $($resolved.Source)"
|
||||
return $true
|
||||
}
|
||||
|
||||
function Check-Node {
|
||||
if (-not (Check-Command 'node' 'required' 'install Node.js 20+ and reopen your shell')) { return }
|
||||
$version = (& node --version 2>$null)
|
||||
if (-not $version) { return }
|
||||
$major = [int](($version.TrimStart('v')) -split '\.')[0]
|
||||
if ($major -lt 20) {
|
||||
Write-Fail "Node.js 20+ is required, found $version. Fix: install Node.js 20+"
|
||||
return
|
||||
}
|
||||
Write-Pass "Node.js version $version is supported"
|
||||
}
|
||||
|
||||
function Check-Git {
|
||||
if (-not (Check-Command 'git' 'required' 'install git 2.25+ and reopen your shell')) { return }
|
||||
$out = (& git --version 2>$null)
|
||||
if (-not $out) { return }
|
||||
$match = [regex]::Match($out, '(\d+)\.(\d+)')
|
||||
if (-not $match.Success) {
|
||||
Write-Fail "git 2.25+ is required, could not parse '$out'. Fix: upgrade git"
|
||||
return
|
||||
}
|
||||
$major = [int]$match.Groups[1].Value
|
||||
$minor = [int]$match.Groups[2].Value
|
||||
if ($major -lt 2 -or ($major -eq 2 -and $minor -lt 25)) {
|
||||
Write-Fail "git 2.25+ is required, found $major.$minor. Fix: upgrade git"
|
||||
return
|
||||
}
|
||||
Write-Pass "git version $major.$minor supports worktrees"
|
||||
}
|
||||
|
||||
function Check-Pnpm {
|
||||
$required = 'required'
|
||||
$hint = 'enable corepack or run npm install -g pnpm'
|
||||
if ($ScriptLayout -eq 'package-install') {
|
||||
$required = 'optional'
|
||||
$hint = 'install pnpm if you plan to use pnpm-managed repos with AO'
|
||||
}
|
||||
if (-not (Check-Command 'pnpm' $required $hint)) { return }
|
||||
$version = (& pnpm --version 2>$null)
|
||||
$shown = if ($version) { $version } else { 'unknown' }
|
||||
Write-Pass "pnpm version $shown is available"
|
||||
}
|
||||
|
||||
function Check-Launcher {
|
||||
$resolved = Get-Command 'ao' -ErrorAction SilentlyContinue
|
||||
if ($resolved) {
|
||||
Write-Pass "ao launcher resolves to $($resolved.Source)"
|
||||
return
|
||||
}
|
||||
if ($ScriptLayout -eq 'source-checkout' -and $Fix -and (Get-Command npm -ErrorAction SilentlyContinue) -and (Test-Path (Join-Path $RepoRoot 'packages/ao'))) {
|
||||
Push-Location (Join-Path $RepoRoot 'packages/ao')
|
||||
try {
|
||||
$null = & npm link --force 2>&1
|
||||
if ($LASTEXITCODE -eq 0 -and (Get-Command 'ao' -ErrorAction SilentlyContinue)) {
|
||||
Write-Fixed "ao launcher refreshed with npm link --force"
|
||||
return
|
||||
}
|
||||
} finally { Pop-Location }
|
||||
Write-Warn2 "ao launcher refresh failed. Fix: cd $RepoRoot\packages\ao; npm link --force"
|
||||
return
|
||||
}
|
||||
if ($ScriptLayout -eq 'package-install') {
|
||||
Write-Warn2 "ao launcher is not in PATH. Fix: reinstall with npm install -g @aoagents/ao@latest"
|
||||
return
|
||||
}
|
||||
Write-Warn2 "ao launcher is not in PATH. Fix: cd $RepoRoot; pwsh scripts/setup.ps1 (or run npm link --force in packages/ao)"
|
||||
}
|
||||
|
||||
function Check-Tmux {
|
||||
# tmux is not native on Windows. The default Windows runtime is `process`,
|
||||
# so tmux is informational only.
|
||||
if (Get-Command 'tmux' -ErrorAction SilentlyContinue) {
|
||||
Write-Pass "tmux is installed (note: Windows default runtime is 'process')"
|
||||
return
|
||||
}
|
||||
Write-Pass "tmux not installed (Windows default runtime is 'process' — tmux not required)"
|
||||
}
|
||||
|
||||
function Check-Gh {
|
||||
if (-not (Get-Command 'gh' -ErrorAction SilentlyContinue)) {
|
||||
Write-Warn2 "GitHub CLI is not installed. Fix: install gh from https://cli.github.com/"
|
||||
return
|
||||
}
|
||||
& gh auth status *> $null
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Pass "gh is installed and authenticated"
|
||||
return
|
||||
}
|
||||
Write-Warn2 "gh is installed but not authenticated. Fix: run gh auth login"
|
||||
}
|
||||
|
||||
function Check-InstallLayout {
|
||||
if ($ScriptLayout -eq 'package-install') {
|
||||
$checks = @(
|
||||
@{ Path = 'package.json'; Label = 'CLI package metadata is present' }
|
||||
@{ Path = 'dist/index.js'; Label = 'packaged CLI entrypoint exists' }
|
||||
@{ Path = 'dist/assets/scripts/ao-doctor.ps1'; Label = 'bundled doctor script is available' }
|
||||
@{ Path = 'dist/assets/scripts/ao-update.ps1'; Label = 'bundled update script is available' }
|
||||
)
|
||||
foreach ($c in $checks) {
|
||||
$full = Join-Path $RepoRoot $c.Path
|
||||
if (Test-Path $full) { Write-Pass $c.Label } else { Write-Fail "$($c.Label) (missing $full). Fix: reinstall @aoagents/ao" }
|
||||
}
|
||||
return
|
||||
}
|
||||
if (Test-Path (Join-Path $RepoRoot 'node_modules')) {
|
||||
Write-Pass "dependencies are installed at $RepoRoot\node_modules"
|
||||
} else {
|
||||
Write-Fail "dependencies are missing at $RepoRoot\node_modules. Fix: run pnpm install"
|
||||
}
|
||||
if (Test-Path (Join-Path $RepoRoot 'packages/core/dist/index.js')) {
|
||||
Write-Pass "core package is built"
|
||||
} else {
|
||||
Write-Fail "core package is not built. Fix: run pnpm --filter @aoagents/ao-core build"
|
||||
}
|
||||
if (Test-Path (Join-Path $RepoRoot 'packages/cli/dist/index.js')) {
|
||||
Write-Pass "CLI package is built"
|
||||
} else {
|
||||
Write-Fail "CLI package is not built. Fix: run pnpm --filter @aoagents/ao-cli build"
|
||||
}
|
||||
}
|
||||
|
||||
function Check-RuntimeSanity {
|
||||
if ($ScriptLayout -eq 'package-install') {
|
||||
$entry = Join-Path $RepoRoot 'dist/index.js'
|
||||
if (-not (Test-Path $entry)) {
|
||||
Write-Fail "packaged CLI entrypoint is missing. Fix: reinstall @aoagents/ao"
|
||||
return
|
||||
}
|
||||
& node $entry --version *> $null
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Pass "packaged CLI runtime sanity check passed (ao --version)"
|
||||
} else {
|
||||
Write-Fail "packaged CLI runtime sanity check failed. Fix: reinstall @aoagents/ao"
|
||||
}
|
||||
return
|
||||
}
|
||||
$entry = Join-Path $RepoRoot 'packages/ao/bin/ao.js'
|
||||
if (-not (Test-Path $entry)) {
|
||||
Write-Fail "launcher entrypoint is missing. Fix: reinstall from a clean checkout"
|
||||
return
|
||||
}
|
||||
& node $entry --version *> $null
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Pass "launcher runtime sanity check passed (ao --version)"
|
||||
} else {
|
||||
Write-Fail "launcher runtime sanity check failed. Fix: run pnpm build and refresh the launcher"
|
||||
}
|
||||
}
|
||||
|
||||
function Check-ConfigDirs {
|
||||
$configPath = Find-AoConfig
|
||||
if (-not $configPath) {
|
||||
Write-Warn2 "No agent-orchestrator config was found. Fix: run ao init --auto in a target repo"
|
||||
return
|
||||
}
|
||||
Write-Pass "config found at $configPath"
|
||||
$dataDir = Read-ConfigValue 'dataDir' $configPath
|
||||
$worktreeDir = Read-ConfigValue 'worktreeDir' $configPath
|
||||
if (-not $dataDir) { $dataDir = Join-Path $DefaultConfigHome '.agent-orchestrator' }
|
||||
if (-not $worktreeDir) { $worktreeDir = Join-Path $DefaultConfigHome '.worktrees' }
|
||||
$dataDir = Expand-HomePath $dataDir
|
||||
$worktreeDir = Expand-HomePath $worktreeDir
|
||||
Ensure-Dir $dataDir 'metadata directory' "New-Item -ItemType Directory -Path $dataDir -Force"
|
||||
Ensure-Dir $worktreeDir 'worktree directory' "New-Item -ItemType Directory -Path $worktreeDir -Force"
|
||||
}
|
||||
|
||||
function Check-StaleTempFiles {
|
||||
$tempRoot = if ($env:AO_DOCTOR_TMP_ROOT) { $env:AO_DOCTOR_TMP_ROOT } else { Join-Path $env:TEMP 'agent-orchestrator' }
|
||||
if (-not (Test-Path $tempRoot)) {
|
||||
Write-Pass "temp root exists check skipped because $tempRoot does not exist"
|
||||
return
|
||||
}
|
||||
$cutoff = (Get-Date).AddMinutes(-60)
|
||||
$stale = Get-ChildItem -Path $tempRoot -File -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.LastWriteTime -lt $cutoff -and ($_.Name -like 'ao-*.tmp' -or $_.Name -like 'ao-*.pid' -or $_.Name -like 'ao-*.lock') }
|
||||
|
||||
if (-not $stale -or $stale.Count -eq 0) {
|
||||
Write-Pass "no stale temp files were detected under $tempRoot"
|
||||
return
|
||||
}
|
||||
if ($Fix) {
|
||||
$deleted = 0
|
||||
foreach ($f in $stale) {
|
||||
try { Remove-Item $f.FullName -Force; $deleted++ } catch { }
|
||||
}
|
||||
if ($deleted -eq $stale.Count) {
|
||||
Write-Fixed "$deleted stale temp files removed from $tempRoot"
|
||||
} else {
|
||||
Write-Warn2 "Only removed $deleted of $($stale.Count) stale temp files from $tempRoot. Fix: inspect that directory manually"
|
||||
}
|
||||
return
|
||||
}
|
||||
Write-Warn2 "$($stale.Count) stale temp files older than 60 minutes found under $tempRoot. Fix: rerun ao doctor --fix"
|
||||
}
|
||||
|
||||
Write-Host "Agent Orchestrator Doctor`n"
|
||||
|
||||
Check-Node
|
||||
Check-Git
|
||||
Check-Pnpm
|
||||
Check-Launcher
|
||||
Check-Tmux
|
||||
Check-Gh
|
||||
Check-ConfigDirs
|
||||
Check-StaleTempFiles
|
||||
Check-InstallLayout
|
||||
Check-RuntimeSanity
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Results: $script:PassCount PASS, $script:WarnCount WARN, $script:FailCount FAIL, $script:FixCount FIXED"
|
||||
|
||||
if ($script:FailCount -gt 0) {
|
||||
Write-Error "Environment needs attention before AO is safe to update or run."
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Environment looks healthy enough to run Agent Orchestrator."
|
||||
exit 0
|
||||
|
|
@ -0,0 +1,202 @@
|
|||
# PowerShell port of ao-update.sh — Windows-native source-checkout updater for AO.
|
||||
# Invoked by `ao update` on Windows via runRepoScript() when install method is 'git'.
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# Manual arg parsing — matches ao-update.sh's `--skip-smoke` / `--smoke-only` /
|
||||
# `-h` / `--help` flags rather than PowerShell's `-SkipSmoke` convention, so the
|
||||
# calling contract is identical on Linux/macOS/Windows.
|
||||
$SkipSmoke = $false
|
||||
$SmokeOnly = $false
|
||||
$Help = $false
|
||||
foreach ($a in $args) {
|
||||
switch ($a) {
|
||||
'--skip-smoke' { $SkipSmoke = $true }
|
||||
'--smoke-only' { $SmokeOnly = $true }
|
||||
'-h' { $Help = $true }
|
||||
'--help' { $Help = $true }
|
||||
default {
|
||||
Write-Error "Unknown option: $a"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($Help) {
|
||||
@'
|
||||
Usage: ao update [--skip-smoke] [--smoke-only]
|
||||
|
||||
Fast-forwards the local Agent Orchestrator install repo to main, installs deps,
|
||||
clean-rebuilds critical packages, refreshes the ao launcher, and runs smoke tests.
|
||||
|
||||
Options:
|
||||
--skip-smoke Skip smoke tests after rebuild
|
||||
--smoke-only Run smoke tests without fetching or rebuilding
|
||||
'@ | Write-Host
|
||||
exit 0
|
||||
}
|
||||
|
||||
if ($SkipSmoke -and $SmokeOnly) {
|
||||
Write-Error "Conflicting options: use either --skip-smoke or --smoke-only, not both."
|
||||
exit 1
|
||||
}
|
||||
|
||||
$TargetBranch = if ($env:AO_UPDATE_BRANCH) { $env:AO_UPDATE_BRANCH } else { 'main' }
|
||||
$RepoRoot = if ($env:AO_REPO_ROOT) { $env:AO_REPO_ROOT } else { (Resolve-Path (Join-Path $PSScriptRoot '..')).Path }
|
||||
|
||||
function Require-Command([string]$name, [string]$fixHint) {
|
||||
if (-not (Get-Command $name -ErrorAction SilentlyContinue)) {
|
||||
Write-Error "Missing required command: $name. Fix: $fixHint"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
function Run-Cmd {
|
||||
Write-Host "-> $($args -join ' ')"
|
||||
& $args[0] @($args | Select-Object -Skip 1)
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Command failed: $($args -join ' ') (exit $LASTEXITCODE)"
|
||||
}
|
||||
}
|
||||
|
||||
function Has-Remote([string]$name) {
|
||||
& git remote get-url $name *> $null
|
||||
return ($LASTEXITCODE -eq 0)
|
||||
}
|
||||
|
||||
function Get-RemoteUrl([string]$name) {
|
||||
$url = & git remote get-url $name 2>$null
|
||||
if ($LASTEXITCODE -ne 0) { return '' }
|
||||
return $url
|
||||
}
|
||||
|
||||
function Get-GithubRepoSlug([string]$remoteName) {
|
||||
$url = Get-RemoteUrl $remoteName
|
||||
if (-not $url) { return $null }
|
||||
$patterns = @(
|
||||
'^https://github\.com/(.+?)(?:\.git)?$',
|
||||
'^http://github\.com/(.+?)(?:\.git)?$',
|
||||
'^ssh://git@github\.com/(.+?)(?:\.git)?$',
|
||||
'^git@github\.com:(.+?)(?:\.git)?$'
|
||||
)
|
||||
foreach ($p in $patterns) {
|
||||
$m = [regex]::Match($url, $p)
|
||||
if ($m.Success) { return $m.Groups[1].Value }
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
function Resolve-UpdateRemote {
|
||||
if (Has-Remote 'upstream') { return 'upstream' }
|
||||
return 'origin'
|
||||
}
|
||||
|
||||
function Sync-OriginWithUpstream {
|
||||
if (-not (Has-Remote 'origin') -or -not (Has-Remote 'upstream')) { return }
|
||||
if (-not (Get-Command 'gh' -ErrorAction SilentlyContinue)) {
|
||||
Write-Host "Skipping fork sync: gh is not installed. Local update will use upstream/$TargetBranch directly."
|
||||
return
|
||||
}
|
||||
$originRepo = Get-GithubRepoSlug 'origin'
|
||||
$upstreamRepo = Get-GithubRepoSlug 'upstream'
|
||||
if (-not $originRepo -or -not $upstreamRepo) { return }
|
||||
Write-Host ""
|
||||
Write-Host "Syncing $originRepo/$TargetBranch with $upstreamRepo/$TargetBranch via gh..."
|
||||
try {
|
||||
Run-Cmd gh repo sync $originRepo --source $upstreamRepo --branch $TargetBranch
|
||||
} catch {
|
||||
Write-Warning "Failed to sync $originRepo/$TargetBranch from $upstreamRepo/$TargetBranch via gh. Continuing with upstream/$TargetBranch for the local update."
|
||||
}
|
||||
}
|
||||
|
||||
function Run-SmokeTests {
|
||||
Write-Host ""
|
||||
Write-Host "Running smoke tests..."
|
||||
$aoBin = Join-Path $RepoRoot 'packages/ao/bin/ao.js'
|
||||
Run-Cmd node $aoBin --version
|
||||
Run-Cmd node $aoBin doctor --help
|
||||
Run-Cmd node $aoBin update --help
|
||||
}
|
||||
|
||||
function Ensure-RepoClean([string]$reason) {
|
||||
$status = & git status --porcelain
|
||||
if ($status) {
|
||||
Write-Error $reason
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
function Ensure-OnTargetBranch {
|
||||
$current = (& git branch --show-current).Trim()
|
||||
if ($current -ne $TargetBranch) {
|
||||
Write-Error "Current branch is $current, expected $TargetBranch. Fix: git switch $TargetBranch then rerun ao update."
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "Agent Orchestrator Update`n"
|
||||
|
||||
Require-Command 'node' 'install Node.js 20+'
|
||||
|
||||
Set-Location $RepoRoot
|
||||
|
||||
$UpdateRemote = Resolve-UpdateRemote
|
||||
|
||||
if (-not $SmokeOnly) {
|
||||
Require-Command 'git' 'install git 2.25+'
|
||||
Require-Command 'pnpm' 'enable corepack or run npm install -g pnpm'
|
||||
Require-Command 'npm' 'install npm with Node.js'
|
||||
|
||||
& git rev-parse --is-inside-work-tree *> $null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "The update command must run inside the Agent Orchestrator git checkout."
|
||||
exit 1
|
||||
}
|
||||
|
||||
Ensure-RepoClean 'Working tree is dirty. Fix: commit or stash local changes before running ao update.'
|
||||
Ensure-OnTargetBranch
|
||||
|
||||
Sync-OriginWithUpstream
|
||||
|
||||
Run-Cmd git fetch $UpdateRemote $TargetBranch
|
||||
|
||||
$localSha = (& git rev-parse HEAD).Trim()
|
||||
$remoteSha = (& git rev-parse "$UpdateRemote/$TargetBranch").Trim()
|
||||
|
||||
if ($localSha -eq $remoteSha) {
|
||||
Write-Host ""
|
||||
Write-Host "Already on latest version."
|
||||
} else {
|
||||
Run-Cmd git pull --ff-only $UpdateRemote $TargetBranch
|
||||
Run-Cmd pnpm install
|
||||
|
||||
Run-Cmd pnpm --filter @aoagents/ao-core clean
|
||||
Run-Cmd pnpm --filter @aoagents/ao-cli clean
|
||||
Run-Cmd pnpm --filter @aoagents/ao-web clean
|
||||
|
||||
Run-Cmd pnpm --filter @aoagents/ao-core build
|
||||
Run-Cmd pnpm --filter @aoagents/ao-cli build
|
||||
Run-Cmd pnpm --filter @aoagents/ao-web build
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Refreshing ao launcher..."
|
||||
Push-Location (Join-Path $RepoRoot 'packages/ao')
|
||||
try {
|
||||
& npm link --force
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "npm link --force failed. On Windows, retry from an elevated terminal: cd $RepoRoot\packages\ao; npm link --force"
|
||||
exit 1
|
||||
}
|
||||
} finally { Pop-Location }
|
||||
|
||||
Ensure-RepoClean 'Update modified tracked files. Inspect git status, review the changes, and rerun after restoring a clean checkout if needed.'
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $SkipSmoke) {
|
||||
Run-SmokeTests
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Update complete."
|
||||
exit 0
|
||||
|
|
@ -2,8 +2,9 @@ import { spawn } from "node:child_process";
|
|||
import { resolve } from "node:path";
|
||||
import chalk from "chalk";
|
||||
import type { Command } from "commander";
|
||||
import { loadConfig } from "@aoagents/ao-core";
|
||||
import { isWindows, loadConfig } from "@aoagents/ao-core";
|
||||
import { findWebDir, buildDashboardEnv, waitForPortAndOpen } from "../lib/web-dir.js";
|
||||
import { forwardSignalsToChild } from "../lib/shell.js";
|
||||
import {
|
||||
clearStaleCacheIfNeeded,
|
||||
isInstalledUnderNodeModules,
|
||||
|
|
@ -54,6 +55,7 @@ export function registerDashboard(program: Command): void {
|
|||
const child = spawn("node", [startScript], {
|
||||
cwd: webDir,
|
||||
stdio: ["inherit", "inherit", "pipe"],
|
||||
detached: !isWindows(),
|
||||
env,
|
||||
});
|
||||
|
||||
|
|
@ -76,6 +78,14 @@ export function registerDashboard(program: Command): void {
|
|||
process.exit(1);
|
||||
});
|
||||
|
||||
// On Unix the child is spawned with detached:true (own process group) so
|
||||
// Ctrl+C only reaches the parent's process group, not the dashboard's.
|
||||
// Forward SIGINT/SIGTERM so the child group is cleaned up on exit.
|
||||
const pid = child.pid;
|
||||
if (!isWindows() && pid) {
|
||||
forwardSignalsToChild(pid, child);
|
||||
}
|
||||
|
||||
let openAbort: AbortController | undefined;
|
||||
|
||||
if (opts.open !== false) {
|
||||
|
|
|
|||
|
|
@ -1,54 +1,115 @@
|
|||
import { spawn } from "node:child_process";
|
||||
import chalk from "chalk";
|
||||
import type { Command } from "commander";
|
||||
import { loadConfig } from "@aoagents/ao-core";
|
||||
import { exec, getTmuxSessions } from "../lib/shell.js";
|
||||
import { findProjectForSession, matchesPrefix, stripHashPrefix } from "../lib/session-utils.js";
|
||||
import {
|
||||
isMac,
|
||||
isTerminalSession,
|
||||
isWindows,
|
||||
loadConfig,
|
||||
type Session,
|
||||
} from "@aoagents/ao-core";
|
||||
import { exec } from "../lib/shell.js";
|
||||
import { getSessionManager } from "../lib/create-session-manager.js";
|
||||
import { findProjectForSession, matchesPrefix } from "../lib/session-utils.js";
|
||||
import { DEFAULT_PORT } from "../lib/constants.js";
|
||||
import { projectSessionUrl } from "../lib/routes.js";
|
||||
import { openUrl } from "../lib/web-dir.js";
|
||||
import { getRunning } from "../lib/running-state.js";
|
||||
|
||||
async function openInTerminal(sessionName: string, newWindow?: boolean): Promise<boolean> {
|
||||
async function openInIterm(sessionName: string, newWindow?: boolean): Promise<boolean> {
|
||||
try {
|
||||
const args = newWindow ? ["--new-window", sessionName] : [sessionName];
|
||||
await exec("open-iterm-tab", args);
|
||||
return true;
|
||||
} catch {
|
||||
// Fall back to tmux attach hint
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn a detached child and treat any synchronous spawn error as failure.
|
||||
* Returns true if the child was launched (its own exit code is not awaited —
|
||||
* a new console window has its own lifecycle).
|
||||
*/
|
||||
function spawnDetached(cmd: string, args: string[]): boolean {
|
||||
try {
|
||||
const child = spawn(cmd, args, { detached: true, stdio: "ignore", windowsHide: false });
|
||||
child.on("error", () => { /* swallow — caller already returned */ });
|
||||
child.unref();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a new attached console window running `ao session attach <id>`.
|
||||
* Tries Windows Terminal first (matches the iTerm-tab feel on Mac), falls
|
||||
* back to a plain `cmd /k` window which works on every Windows install.
|
||||
*
|
||||
* Both paths route through `cmd /k` rather than invoking `ao` directly:
|
||||
* `wt new-tab` and `start` both call CreateProcess on the first token, and
|
||||
* CreateProcess does not honor PATHEXT — so `ao` (really `ao.cmd`, an npm
|
||||
* shim) is reported as ERROR_FILE_NOT_FOUND (0x80070002). Letting cmd.exe
|
||||
* be the first token lets it do the .cmd resolution.
|
||||
*
|
||||
* `cwd` should be the project directory (where agent-orchestrator.yaml lives)
|
||||
* so the spawned `ao session attach` can resolve config via loadConfig's
|
||||
* upward search. Without it the new console inherits the user's homedir and
|
||||
* attach fails with "No agent-orchestrator.yaml found".
|
||||
*/
|
||||
function openWindowsConsole(sessionId: string, cwd: string | undefined): boolean {
|
||||
const title = `ao:${sessionId}`;
|
||||
const inner = ["ao", "session", "attach", sessionId];
|
||||
|
||||
const wtArgs = ["-w", "0", "new-tab", "--title", title];
|
||||
if (cwd) wtArgs.push("-d", cwd);
|
||||
wtArgs.push("cmd.exe", "/k", ...inner);
|
||||
if (spawnDetached("wt.exe", wtArgs)) return true;
|
||||
|
||||
// `start` syntax: start "title" [/d <dir>] <command> [args...]
|
||||
const startArgs = ["/c", "start", title];
|
||||
if (cwd) startArgs.push("/d", cwd);
|
||||
startArgs.push("cmd.exe", "/k", ...inner);
|
||||
return spawnDetached("cmd.exe", startArgs);
|
||||
}
|
||||
|
||||
export function registerOpen(program: Command): void {
|
||||
program
|
||||
.command("open")
|
||||
.description("Open session(s) in terminal tabs")
|
||||
.description("Open session(s) in an attached terminal (or the dashboard URL with --browser)")
|
||||
.argument("[target]", 'Session name, project ID, or "all" to open everything')
|
||||
.option("-w, --new-window", "Open in a new terminal window")
|
||||
.action(async (target: string | undefined, opts: { newWindow?: boolean }) => {
|
||||
.option("-w, --new-window", "Open in a new terminal window (macOS)")
|
||||
.option("-b, --browser", "Open the dashboard URL in a browser instead of a terminal")
|
||||
.action(async (target: string | undefined, opts: { newWindow?: boolean; browser?: boolean }) => {
|
||||
const config = loadConfig();
|
||||
const allTmux = await getTmuxSessions();
|
||||
const sm = await getSessionManager(config);
|
||||
const all = await sm.list();
|
||||
|
||||
let sessionsToOpen: string[] = [];
|
||||
// For aggregate targets ("all" / project) we hide terminated sessions —
|
||||
// mirrors Mac's old tmux-list-sessions behavior, which only ever showed
|
||||
// live sessions. For a named lookup the user is asking about a specific
|
||||
// session, so we keep terminated ones in scope and open the dashboard
|
||||
// so they can read the transcript even if the agent has died.
|
||||
let sessionsToOpen: Session[];
|
||||
|
||||
if (!target || target === "all") {
|
||||
// Open all sessions across all projects
|
||||
for (const [projectId, project] of Object.entries(config.projects)) {
|
||||
const prefix = project.sessionPrefix || projectId;
|
||||
const matching = allTmux.filter((s) => matchesPrefix(s, prefix));
|
||||
sessionsToOpen.push(...matching);
|
||||
}
|
||||
sessionsToOpen = all.filter((s) => !isTerminalSession(s));
|
||||
} else if (config.projects[target]) {
|
||||
// Open all sessions for a specific project
|
||||
const project = config.projects[target];
|
||||
const prefix = project.sessionPrefix || target;
|
||||
sessionsToOpen = allTmux.filter((s) => matchesPrefix(s, prefix));
|
||||
} else if (allTmux.includes(target)) {
|
||||
// Open a specific session
|
||||
sessionsToOpen = [target];
|
||||
sessionsToOpen = all
|
||||
.filter((s) => !isTerminalSession(s))
|
||||
.filter((s) => s.projectId === target || matchesPrefix(s.id, prefix));
|
||||
} else {
|
||||
console.error(
|
||||
chalk.red(`Unknown target: ${target}\nSpecify a session name, project ID, or "all".`),
|
||||
);
|
||||
process.exit(1);
|
||||
const match = all.find((s) => s.id === target);
|
||||
if (!match) {
|
||||
console.error(
|
||||
chalk.red(`Unknown target: ${target}\nSpecify a session name, project ID, or "all".`),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
sessionsToOpen = [match];
|
||||
}
|
||||
|
||||
if (sessionsToOpen.length === 0) {
|
||||
|
|
@ -56,24 +117,79 @@ export function registerOpen(program: Command): void {
|
|||
return;
|
||||
}
|
||||
|
||||
// Prefer the live daemon's port over the config default — they can
|
||||
// diverge if the dashboard auto-picked a free port at startup.
|
||||
const running = await getRunning();
|
||||
const port = running?.port ?? config.port ?? DEFAULT_PORT;
|
||||
if (!running && !opts.browser) {
|
||||
console.log(
|
||||
chalk.dim(
|
||||
"Note: AO daemon does not appear to be running — dashboard URL fallback may not load.",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
chalk.bold(
|
||||
`Opening ${sessionsToOpen.length} session${sessionsToOpen.length > 1 ? "s" : ""}...\n`,
|
||||
),
|
||||
);
|
||||
|
||||
const port = config.port ?? DEFAULT_PORT;
|
||||
for (const session of sessionsToOpen.sort()) {
|
||||
const opened = await openInTerminal(session, opts.newWindow);
|
||||
if (opened) {
|
||||
console.log(chalk.green(` Opened: ${session}`));
|
||||
} else {
|
||||
const sessionId = stripHashPrefix(session);
|
||||
const matchedProjectId = findProjectForSession(config, session) ?? target ?? sessionId;
|
||||
console.log(
|
||||
` ${chalk.yellow(session)} — view at: ${chalk.dim(projectSessionUrl(port, matchedProjectId, sessionId))}`,
|
||||
);
|
||||
const sorted = [...sessionsToOpen].sort((a, b) => a.id.localeCompare(b.id));
|
||||
|
||||
for (const session of sorted) {
|
||||
const projectId = session.projectId ?? findProjectForSession(config, session.id) ?? target;
|
||||
const url = projectSessionUrl(port, projectId ?? session.id, session.id);
|
||||
const dead = isTerminalSession(session);
|
||||
|
||||
// --browser, or terminated sessions (no live PTY to attach to), or
|
||||
// named-lookup of a dead session: open the dashboard URL.
|
||||
if (opts.browser || dead) {
|
||||
openUrl(url);
|
||||
if (dead) {
|
||||
const sr = session.lifecycle.session.reason;
|
||||
const rr = session.lifecycle.runtime.reason;
|
||||
const at = session.lifecycle.session.terminatedAt;
|
||||
const when = at ? new Date(at).toLocaleString() : "unknown time";
|
||||
console.log(
|
||||
` ${chalk.yellow(session.id)} ${chalk.dim("(terminated)")} — opened ${chalk.dim(url)}`,
|
||||
);
|
||||
console.log(
|
||||
chalk.dim(` died at ${when}: session=${sr}, runtime=${rr}`),
|
||||
);
|
||||
console.log(
|
||||
chalk.dim(` restart with: ao session restore ${session.id}`),
|
||||
);
|
||||
} else {
|
||||
console.log(` ${chalk.green(session.id)} — opened ${chalk.dim(url)}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isMac()) {
|
||||
const opened = await openInIterm(session.id, opts.newWindow);
|
||||
if (opened) {
|
||||
console.log(chalk.green(` Opened: ${session.id}`));
|
||||
continue;
|
||||
}
|
||||
} else if (isWindows()) {
|
||||
// The spawned `ao session attach` does loadConfig() which searches
|
||||
// upward from cwd for agent-orchestrator.yaml. Anchor the new
|
||||
// console at the project's path (where the yaml lives); if that
|
||||
// isn't in config, fall back to the worktree (yaml may live in a
|
||||
// parent directory of it).
|
||||
const projectPath = projectId ? config.projects[projectId]?.path : undefined;
|
||||
const cwd = projectPath ?? session.workspacePath ?? undefined;
|
||||
if (openWindowsConsole(session.id, cwd)) {
|
||||
console.log(chalk.green(` Opened: ${session.id} (new console)`));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Final fallback (Linux always lands here, plus any platform whose
|
||||
// terminal-spawn helper failed): open the dashboard URL.
|
||||
openUrl(url);
|
||||
console.log(` ${chalk.yellow(session.id)} — opened ${chalk.dim(url)}`);
|
||||
}
|
||||
console.log();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
import { spawn } from "node:child_process";
|
||||
import { connect as netConnect } from "node:net";
|
||||
import chalk from "chalk";
|
||||
import type { Command } from "commander";
|
||||
import {
|
||||
generateConfigHash,
|
||||
isOrchestratorSession,
|
||||
isTerminalSession,
|
||||
isWindows,
|
||||
loadConfig,
|
||||
SessionNotRestorableError,
|
||||
WorkspaceMissingError,
|
||||
|
|
@ -119,6 +122,10 @@ export function registerSession(program: Command): void {
|
|||
|
||||
const activities = await Promise.all(
|
||||
projectSessions.map((s) => {
|
||||
// On Windows, use enriched session lastActivityAt (no tmux available).
|
||||
if (isWindows()) {
|
||||
return Promise.resolve(s.lastActivityAt ? s.lastActivityAt.getTime() : null);
|
||||
}
|
||||
const tmuxTarget = s.runtimeHandle?.id ?? s.id;
|
||||
return getTmuxActivity(tmuxTarget).catch(() => null);
|
||||
}),
|
||||
|
|
@ -192,34 +199,145 @@ export function registerSession(program: Command): void {
|
|||
|
||||
session
|
||||
.command("attach")
|
||||
.description("Attach to a session's tmux window")
|
||||
.description("Attach to a session's terminal")
|
||||
.argument("<session>", "Session name to attach")
|
||||
.action(async (sessionName: string) => {
|
||||
const config = loadConfig();
|
||||
const sm = await getSessionManager(config);
|
||||
const sessionInfo = await sm.get(sessionName);
|
||||
const tmuxTarget = sessionInfo?.runtimeHandle?.id ?? sessionName;
|
||||
|
||||
const exists = await tmux("has-session", "-t", tmuxTarget);
|
||||
if (exists === null) {
|
||||
console.error(chalk.red(`Session '${sessionName}' does not exist`));
|
||||
process.exit(1);
|
||||
}
|
||||
if (isWindows()) {
|
||||
// Windows: connect to PTY host named pipe and relay raw terminal I/O
|
||||
// Prefer explicit pipePath from runtimeHandle.data if it's a valid string
|
||||
const dataPipePath = sessionInfo?.runtimeHandle?.data?.["pipePath"];
|
||||
const pipePath = typeof dataPipePath === "string" && dataPipePath
|
||||
? dataPipePath
|
||||
: `\\\\.\\pipe\\ao-pty-${
|
||||
sessionInfo?.runtimeHandle?.id ??
|
||||
(config.configPath
|
||||
? `${generateConfigHash(config.configPath)}-${sessionName}`
|
||||
: sessionName)
|
||||
}`;
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const child = spawn("tmux", ["attach", "-t", tmuxTarget], { stdio: "inherit" });
|
||||
child.once("error", (err) => reject(err));
|
||||
child.once("exit", (code) => {
|
||||
if (code === 0 || code === null) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
reject(new Error(`tmux attach exited with code ${code}`));
|
||||
const sock = netConnect(pipePath);
|
||||
|
||||
// Handler refs — set in connect, cleaned up on exit
|
||||
let sendResize: (() => void) | null = null;
|
||||
let stdinHandler: ((data: Buffer) => void) | null = null;
|
||||
|
||||
const cleanup = () => {
|
||||
if (process.stdin.isTTY) process.stdin.setRawMode(false);
|
||||
if (sendResize) process.stdout.removeListener("resize", sendResize);
|
||||
if (stdinHandler) process.stdin.removeListener("data", stdinHandler);
|
||||
sock.destroy();
|
||||
};
|
||||
|
||||
sock.on("error", (err: Error) => {
|
||||
cleanup();
|
||||
console.error(chalk.red(`Cannot attach to ${sessionName}: ${err.message}`));
|
||||
process.exit(1);
|
||||
});
|
||||
}).catch((err) => {
|
||||
console.error(chalk.red(`Failed to attach to session ${sessionName}: ${err}`));
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
sock.on("connect", () => {
|
||||
// Raw mode so keystrokes pass through directly (like tmux attach)
|
||||
if (process.stdin.isTTY) {
|
||||
process.stdin.setRawMode(true);
|
||||
}
|
||||
process.stdin.resume();
|
||||
|
||||
// Binary protocol framing buffer
|
||||
let buf = Buffer.alloc(0);
|
||||
|
||||
// PTY host → stdout
|
||||
sock.on("data", (chunk: Buffer) => {
|
||||
buf = Buffer.concat([buf, chunk]);
|
||||
while (buf.length >= 5) {
|
||||
const msgType = buf.readUInt8(0);
|
||||
const len = buf.readUInt32BE(1);
|
||||
if (buf.length < 5 + len) break;
|
||||
const payload = buf.subarray(5, 5 + len);
|
||||
buf = buf.subarray(5 + len);
|
||||
|
||||
// 0x01 = MSG_TERMINAL_DATA
|
||||
if (msgType === 0x01) {
|
||||
process.stdout.write(payload);
|
||||
}
|
||||
// 0x07 = MSG_STATUS_RES (PTY exited)
|
||||
if (msgType === 0x07) {
|
||||
try {
|
||||
const status = JSON.parse(payload.toString()) as { alive: boolean; exitCode?: number };
|
||||
if (!status.alive) {
|
||||
cleanup();
|
||||
console.log(`\n[session exited with code ${status.exitCode ?? "unknown"}]`);
|
||||
process.exit(status.exitCode ?? 0);
|
||||
}
|
||||
} catch { /* ignore parse errors */ }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// stdin → PTY host (MSG_TERMINAL_INPUT = 0x02)
|
||||
// Ctrl+\ (0x1c) = detach without killing (like tmux Ctrl+B,D)
|
||||
stdinHandler = (data: Buffer) => {
|
||||
if (data.length === 1 && data[0] === 0x1c) {
|
||||
console.log("\n[detached]");
|
||||
cleanup();
|
||||
process.exit(0);
|
||||
return;
|
||||
}
|
||||
const header = Buffer.alloc(5);
|
||||
header.writeUInt8(0x02, 0);
|
||||
header.writeUInt32BE(data.length, 1);
|
||||
sock.write(Buffer.concat([header, data]));
|
||||
};
|
||||
process.stdin.on("data", stdinHandler);
|
||||
|
||||
// Send terminal resize (MSG_RESIZE = 0x03)
|
||||
sendResize = () => {
|
||||
const payload = Buffer.from(
|
||||
JSON.stringify({ cols: process.stdout.columns, rows: process.stdout.rows }),
|
||||
);
|
||||
const header = Buffer.alloc(5);
|
||||
header.writeUInt8(0x03, 0);
|
||||
header.writeUInt32BE(payload.length, 1);
|
||||
sock.write(Buffer.concat([header, payload]));
|
||||
};
|
||||
process.stdout.on("resize", sendResize);
|
||||
sendResize(); // send initial size
|
||||
|
||||
sock.on("close", () => {
|
||||
cleanup();
|
||||
process.exit(0);
|
||||
});
|
||||
});
|
||||
|
||||
// Keep process alive until pipe closes
|
||||
await new Promise(() => {});
|
||||
} else {
|
||||
// Unix: tmux attach (unchanged)
|
||||
const tmuxTarget = sessionInfo?.runtimeHandle?.id ?? sessionName;
|
||||
|
||||
const exists = await tmux("has-session", "-t", tmuxTarget);
|
||||
if (exists === null) {
|
||||
console.error(chalk.red(`Session '${sessionName}' does not exist`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const child = spawn("tmux", ["attach", "-t", tmuxTarget], { stdio: "inherit" });
|
||||
child.once("error", (err) => reject(err));
|
||||
child.once("exit", (code) => {
|
||||
if (code === 0 || code === null) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
reject(new Error(`tmux attach exited with code ${code}`));
|
||||
});
|
||||
}).catch((err) => {
|
||||
console.error(chalk.red(`Failed to attach to session ${sessionName}: ${err}`));
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
session
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
*/
|
||||
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { existsSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
||||
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { resolve, basename, dirname } from "node:path";
|
||||
import { cwd } from "node:process";
|
||||
import chalk from "chalk";
|
||||
|
|
@ -25,6 +25,10 @@ import {
|
|||
configToYaml,
|
||||
isCanonicalGlobalConfigPath,
|
||||
isTerminalSession,
|
||||
getDefaultRuntime,
|
||||
isWindows,
|
||||
findPidByPort,
|
||||
killProcessTree,
|
||||
loadLocalProjectConfigDetailed,
|
||||
registerProjectInGlobalConfig,
|
||||
getGlobalConfigPath,
|
||||
|
|
@ -35,7 +39,7 @@ import {
|
|||
writeLocalProjectConfig,
|
||||
} from "@aoagents/ao-core";
|
||||
import { parse as yamlParse, stringify as yamlStringify } from "yaml";
|
||||
import { exec, execSilent, git } from "../lib/shell.js";
|
||||
import { exec, execSilent, forwardSignalsToChild, git } from "../lib/shell.js";
|
||||
import { getSessionManager } from "../lib/create-session-manager.js";
|
||||
import { listLifecycleWorkers } from "../lib/lifecycle-service.js";
|
||||
import { startBunTmpJanitor } from "../lib/bun-tmp-janitor.js";
|
||||
|
|
@ -94,6 +98,7 @@ import {
|
|||
import { ensureGit, runtimePreflight } from "../lib/startup-preflight.js";
|
||||
import { installShutdownHandlers } from "../lib/shutdown.js";
|
||||
import { resolveOrCreateProject } from "../lib/resolve-project.js";
|
||||
import { pathsEqual } from "../lib/path-equality.js";
|
||||
|
||||
import { DEFAULT_PORT } from "../lib/constants.js";
|
||||
import { projectSessionUrl } from "../lib/routes.js";
|
||||
|
|
@ -199,10 +204,7 @@ async function resolveProject(
|
|||
const currentDirResolved = resolve(cwd());
|
||||
const cwdAlreadyInConfig = projectIds.some((id) => {
|
||||
try {
|
||||
return (
|
||||
resolve(config.projects[id].path.replace(/^~/, process.env["HOME"] || "")) ===
|
||||
currentDirResolved
|
||||
);
|
||||
return pathsEqual(config.projects[id].path, currentDirResolved);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -318,7 +320,7 @@ function ghInstallAttempts(): InstallAttempt[] {
|
|||
{ cmd: "sudo", args: ["dnf", "install", "-y", "gh"], label: "sudo dnf install -y gh" },
|
||||
];
|
||||
}
|
||||
if (process.platform === "win32") {
|
||||
if (isWindows()) {
|
||||
return [
|
||||
{
|
||||
cmd: "winget",
|
||||
|
|
@ -541,7 +543,7 @@ export async function autoCreateConfig(workingDir: string): Promise<Orchestrator
|
|||
const config: Record<string, unknown> = {
|
||||
port: port ?? DEFAULT_PORT,
|
||||
defaults: {
|
||||
runtime: "tmux",
|
||||
runtime: getDefaultRuntime(),
|
||||
agent,
|
||||
workspace: "worktree",
|
||||
notifiers: [],
|
||||
|
|
@ -576,7 +578,7 @@ export async function autoCreateConfig(workingDir: string): Promise<Orchestrator
|
|||
console.log(chalk.dim(" Add a 'repo' field (owner/repo) to the config to enable them.\n"));
|
||||
}
|
||||
|
||||
if (!env.hasTmux) {
|
||||
if (!env.hasTmux && getDefaultRuntime() === "tmux") {
|
||||
console.log(chalk.yellow("⚠ tmux not found — will prompt to install at startup"));
|
||||
}
|
||||
if (!env.hasGh) {
|
||||
|
|
@ -616,14 +618,12 @@ async function addProjectToConfig(
|
|||
const resolvedPath = resolve(projectPath.replace(/^~/, process.env["HOME"] || ""));
|
||||
|
||||
// Check if this path is already registered under any project name.
|
||||
// Use realpathSync for canonical comparison (resolves symlinks, case variants).
|
||||
// pathsEqual canonicalizes via realpathSync and lowercases on Windows so
|
||||
// drive-letter case and 8.3-vs-long-name differences don't cause a miss.
|
||||
// Done before ensureGit so already-registered paths return early without requiring git.
|
||||
const canonicalPath = realpathSync(resolvedPath);
|
||||
const existingByPath = Object.entries(config.projects).find(([, p]) => {
|
||||
try {
|
||||
return (
|
||||
realpathSync(resolve(p.path.replace(/^~/, process.env["HOME"] || ""))) === canonicalPath
|
||||
);
|
||||
return pathsEqual(p.path, resolvedPath);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -790,7 +790,7 @@ async function startDashboard(
|
|||
child = spawn("pnpm", ["run", "dev"], {
|
||||
cwd: webDir,
|
||||
stdio: "inherit",
|
||||
detached: false,
|
||||
detached: !isWindows(),
|
||||
env,
|
||||
});
|
||||
} else {
|
||||
|
|
@ -803,7 +803,7 @@ async function startDashboard(
|
|||
child = spawn("node", [startScript], {
|
||||
cwd: webDir,
|
||||
stdio: "inherit",
|
||||
detached: false,
|
||||
detached: !isWindows(),
|
||||
env,
|
||||
});
|
||||
}
|
||||
|
|
@ -953,7 +953,9 @@ async function runStartup(
|
|||
const currentProjectSessions = lastStop.projectId === projectId ? lastStop.sessionIds : [];
|
||||
if (currentProjectSessions.length > 0) {
|
||||
console.log(
|
||||
chalk.yellow(`\n ${currentProjectSessions.length} session(s) were active before last ao stop (${stoppedAgo}):`),
|
||||
chalk.yellow(
|
||||
`\n ${currentProjectSessions.length} session(s) were active before last ao stop (${stoppedAgo}):`,
|
||||
),
|
||||
);
|
||||
console.log(chalk.dim(` ${currentProjectSessions.join(", ")}\n`));
|
||||
}
|
||||
|
|
@ -1001,9 +1003,13 @@ async function runStartup(
|
|||
}
|
||||
}
|
||||
if (restoredCount === allRestoreSessions.length) {
|
||||
restoreSpinner.succeed(`Restored ${restoredCount}/${allRestoreSessions.length} session(s)`);
|
||||
restoreSpinner.succeed(
|
||||
`Restored ${restoredCount}/${allRestoreSessions.length} session(s)`,
|
||||
);
|
||||
} else {
|
||||
restoreSpinner.warn(`Restored ${restoredCount}/${allRestoreSessions.length} session(s)`);
|
||||
restoreSpinner.warn(
|
||||
`Restored ${restoredCount}/${allRestoreSessions.length} session(s)`,
|
||||
);
|
||||
}
|
||||
for (const w of warnings) {
|
||||
console.log(chalk.yellow(w));
|
||||
|
|
@ -1015,9 +1021,7 @@ async function runStartup(
|
|||
// and the remaining sessions would never be retryable. When
|
||||
// every session restored (or was skipped), clear the file.
|
||||
if (failedSessionIds.size > 0) {
|
||||
const remainingTarget = lastStop.sessionIds.filter((id) =>
|
||||
failedSessionIds.has(id),
|
||||
);
|
||||
const remainingTarget = lastStop.sessionIds.filter((id) => failedSessionIds.has(id));
|
||||
const remainingOther = otherProjects
|
||||
.map((p) => ({
|
||||
projectId: p.projectId,
|
||||
|
|
@ -1099,12 +1103,21 @@ async function runStartup(
|
|||
|
||||
// Keep dashboard process alive if it was started
|
||||
if (dashboardProcess) {
|
||||
// Kill the dashboard child when the parent exits for any reason
|
||||
// (Ctrl+C, SIGTERM from `ao stop`, normal exit, etc.).
|
||||
// We use the `exit` event instead of SIGINT/SIGTERM to avoid
|
||||
// conflicting with the shutdown handler in registerStart that
|
||||
// flushes lifecycle state and calls process.exit() with the
|
||||
// correct exit code (130 for SIGINT, 0 for SIGTERM).
|
||||
const pid = dashboardProcess.pid;
|
||||
|
||||
// On Unix the dashboard is spawned with detached:true (own process group)
|
||||
// so Ctrl+C only reaches AO's process group, not the dashboard's. Forward
|
||||
// SIGINT/SIGTERM so the dashboard group is also cleaned up on exit.
|
||||
// On Windows, detached:false keeps child in the same console —
|
||||
// Ctrl+C reaches both processes. No signal forwarding needed.
|
||||
if (!isWindows() && pid) {
|
||||
forwardSignalsToChild(pid, dashboardProcess);
|
||||
}
|
||||
|
||||
// Also kill the dashboard child when the parent exits for any reason
|
||||
// (normal exit path after lifecycle flush). The `exit` event is
|
||||
// synchronous and fires regardless of platform, so it covers the cases
|
||||
// where forwardSignalsToChild doesn't (Windows, or non-signal exits).
|
||||
/* c8 ignore start -- exit handler only fires on process termination */
|
||||
const killDashboardChild = (): void => {
|
||||
try {
|
||||
|
|
@ -1131,7 +1144,7 @@ async function runStartup(
|
|||
|
||||
/**
|
||||
* Stop dashboard server.
|
||||
* Uses lsof to find the process listening on the port, then kills it.
|
||||
* Uses platform adapter to find the process listening on the port, then kills it.
|
||||
* Best effort — if it fails, just warn the user.
|
||||
*/
|
||||
/** Pattern matching AO dashboard processes (production and dev mode). */
|
||||
|
|
@ -1144,28 +1157,22 @@ const DASHBOARD_CMD_PATTERN = /next-server|start-all\.js|next dev|ao-web/;
|
|||
*/
|
||||
async function killDashboardOnPort(port: number): Promise<boolean> {
|
||||
try {
|
||||
const { stdout } = await exec("lsof", ["-ti", `:${port}`]);
|
||||
const pids = stdout
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter((p) => p.length > 0);
|
||||
if (pids.length === 0) return false;
|
||||
const pid = await findPidByPort(port);
|
||||
if (!pid) return false;
|
||||
|
||||
// Filter to only dashboard PIDs
|
||||
const dashboardPids: string[] = [];
|
||||
for (const pid of pids) {
|
||||
// On Unix, verify the process is actually a dashboard before killing so
|
||||
// unrelated co-listeners (sidecars, SO_REUSEPORT) are left untouched.
|
||||
// findPidByPort on Windows uses netstat; we trust the port match there.
|
||||
if (!isWindows()) {
|
||||
try {
|
||||
const { stdout: cmdline } = await exec("ps", ["-p", pid, "-o", "args="]);
|
||||
if (DASHBOARD_CMD_PATTERN.test(cmdline)) {
|
||||
dashboardPids.push(pid);
|
||||
}
|
||||
const { stdout: cmdline } = await exec("ps", ["-p", String(pid), "-o", "args="]);
|
||||
if (!DASHBOARD_CMD_PATTERN.test(cmdline)) return false;
|
||||
} catch {
|
||||
// process vanished — skip
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (dashboardPids.length === 0) return false;
|
||||
|
||||
await exec("kill", dashboardPids);
|
||||
await killProcessTree(Number(pid));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
|
|
@ -1243,9 +1250,7 @@ async function attachAndSpawnOrchestrator(opts: {
|
|||
console.log(chalk.dim(` Dashboard config reloaded.`));
|
||||
} else {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
` ⚠ ${notifyResult.reason}. Refresh the page if the project doesn't show up.`,
|
||||
),
|
||||
chalk.yellow(` ⚠ ${notifyResult.reason}. Refresh the page if the project doesn't show up.`),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1309,8 +1314,7 @@ export function registerStart(program: Command): void {
|
|||
// ── Already-running detection (before any config mutation) ──
|
||||
let running = await isAlreadyRunning();
|
||||
let startNewOrchestrator = false;
|
||||
const isProjectId =
|
||||
projectArg && !isRepoUrl(projectArg) && !isLocalPath(projectArg);
|
||||
const isProjectId = projectArg && !isRepoUrl(projectArg) && !isLocalPath(projectArg);
|
||||
const projectArgIsUrlOrPath =
|
||||
!!projectArg && (isRepoUrl(projectArg) || isLocalPath(projectArg));
|
||||
|
||||
|
|
@ -1346,10 +1350,7 @@ export function registerStart(program: Command): void {
|
|||
try {
|
||||
const loadedCfg = loadConfig();
|
||||
const proj = loadedCfg.projects[p];
|
||||
return (
|
||||
proj &&
|
||||
resolve(proj.path.replace(/^~/, process.env["HOME"] || "")) === cwdResolved
|
||||
);
|
||||
return proj !== undefined && pathsEqual(proj.path, cwdResolved);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -1606,7 +1607,49 @@ export function registerStart(program: Command): void {
|
|||
* Paths contain / or ~ or . at the start.
|
||||
*/
|
||||
function isLocalPath(arg: string): boolean {
|
||||
return arg.startsWith("/") || arg.startsWith("~") || arg.startsWith("./") || arg.startsWith("..");
|
||||
if (arg.startsWith("/") || arg.startsWith("~") || arg.startsWith("./") || arg.startsWith("..")) {
|
||||
return true;
|
||||
}
|
||||
// Windows paths: drive-letter (C:\, D:/), UNC (\\server\share), or relative backslash paths.
|
||||
if (/^[A-Za-z]:[\\/]/.test(arg)) return true;
|
||||
if (arg.startsWith("\\\\") || arg.startsWith(".\\") || arg.startsWith("..\\")) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazy import + invoke the runtime-process plugin's Windows pty-host sweep.
|
||||
* Kept lazy so non-Windows users don't pay the import cost on every `ao stop`,
|
||||
* and so the cli isn't tightly coupled to the plugin's surface.
|
||||
*
|
||||
* Errors are swallowed: a sweep failure must not prevent `ao stop` from killing
|
||||
* the parent process — the user explicitly asked us to stop AO.
|
||||
*/
|
||||
async function sweepWindowsPtyHostsBeforeParentKill(): Promise<void> {
|
||||
if (!isWindows()) return;
|
||||
try {
|
||||
const mod = (await import("@aoagents/ao-plugin-runtime-process")) as {
|
||||
sweepWindowsPtyHosts?: () => Promise<{
|
||||
attempted: number;
|
||||
gracefullyExited: number;
|
||||
forceKilled: number;
|
||||
failed: number;
|
||||
}>;
|
||||
};
|
||||
if (typeof mod.sweepWindowsPtyHosts !== "function") return;
|
||||
const result = await mod.sweepWindowsPtyHosts();
|
||||
if (result.attempted > 0) {
|
||||
console.log(
|
||||
chalk.dim(
|
||||
` Swept ${result.attempted} pty-host(s): ` +
|
||||
`${result.gracefullyExited} graceful, ` +
|
||||
`${result.forceKilled} force-killed` +
|
||||
(result.failed > 0 ? `, ${result.failed} failed` : ""),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
/* sweep is best-effort; don't block ao stop on it */
|
||||
}
|
||||
}
|
||||
|
||||
export function registerStop(program: Command): void {
|
||||
|
|
@ -1623,11 +1666,15 @@ export function registerStop(program: Command): void {
|
|||
if (opts.all) {
|
||||
// --all: kill via running.json if available, then fallback to config
|
||||
if (running) {
|
||||
try {
|
||||
process.kill(running.pid, "SIGTERM");
|
||||
} catch {
|
||||
// Already dead
|
||||
}
|
||||
// Sweep detached Windows pty-hosts BEFORE killing the parent.
|
||||
// detached:true puts them outside the parent's process tree, so
|
||||
// taskkill /T cannot reach them. The sweep speaks the named-pipe
|
||||
// protocol so node-pty disposes ConPTY gracefully (avoids WER
|
||||
// 0x800700e8). No-op on non-Windows.
|
||||
await sweepWindowsPtyHostsBeforeParentKill();
|
||||
// killProcessTree handles process trees on Windows (taskkill /T /F)
|
||||
// and process groups on Unix; it swallows "already dead" internally.
|
||||
await killProcessTree(running.pid, "SIGTERM");
|
||||
await unregister();
|
||||
console.log(chalk.green(`\n✓ Stopped AO on port ${running.port}`));
|
||||
console.log(chalk.dim(` Projects: ${running.projects.join(", ")}\n`));
|
||||
|
|
@ -1695,7 +1742,9 @@ export function registerStop(program: Command): void {
|
|||
if (killedSessionIds.length === 0) {
|
||||
spinner.fail("Failed to stop any sessions");
|
||||
} else if (killedSessionIds.length < activeSessions.length) {
|
||||
spinner.warn(`Stopped ${killedSessionIds.length}/${activeSessions.length} session(s)`);
|
||||
spinner.warn(
|
||||
`Stopped ${killedSessionIds.length}/${activeSessions.length} session(s)`,
|
||||
);
|
||||
} else {
|
||||
spinner.succeed(`Stopped ${killedSessionIds.length} session(s)`);
|
||||
}
|
||||
|
|
@ -1732,9 +1781,7 @@ export function registerStop(program: Command): void {
|
|||
await writeLastStop({
|
||||
stoppedAt: new Date().toISOString(),
|
||||
projectId: _projectId,
|
||||
sessionIds: killedSessionIds.filter((id) =>
|
||||
targetActive.some((s) => s.id === id),
|
||||
),
|
||||
sessionIds: killedSessionIds.filter((id) => targetActive.some((s) => s.id === id)),
|
||||
otherProjects: otherProjects.length > 0 ? otherProjects : undefined,
|
||||
});
|
||||
}
|
||||
|
|
@ -1756,11 +1803,13 @@ export function registerStop(program: Command): void {
|
|||
// stops every per-project loop. No explicit stop call needed here —
|
||||
// this CLI invocation is a separate process with an empty active map.
|
||||
if (running) {
|
||||
try {
|
||||
process.kill(running.pid, "SIGTERM");
|
||||
} catch {
|
||||
// Already dead
|
||||
}
|
||||
// Sweep detached Windows pty-hosts BEFORE killing the parent.
|
||||
// detached:true puts them outside the parent's process tree, so
|
||||
// taskkill /T cannot reach them. The sweep speaks the named-pipe
|
||||
// protocol so node-pty disposes ConPTY gracefully (avoids WER
|
||||
// 0x800700e8). No-op on non-Windows.
|
||||
await sweepWindowsPtyHostsBeforeParentKill();
|
||||
await killProcessTree(running.pid, "SIGTERM");
|
||||
await unregister();
|
||||
}
|
||||
await stopDashboard(running?.port ?? port);
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
type AgentReportAuditEntry,
|
||||
isOrchestratorSession,
|
||||
isTerminalSession,
|
||||
isWindows,
|
||||
loadConfig,
|
||||
getProjectSessionsDir,
|
||||
readAgentReportAuditTrailAsync,
|
||||
|
|
@ -112,10 +113,16 @@ async function gatherSessionInfo(
|
|||
if (liveBranch) branch = liveBranch;
|
||||
}
|
||||
|
||||
// Get last activity time from tmux
|
||||
const tmuxTarget = session.runtimeHandle?.id ?? session.id;
|
||||
const activityTs = await getTmuxActivity(tmuxTarget);
|
||||
const lastActivity = activityTs ? formatAge(activityTs) : "-";
|
||||
// Get last activity time — use enriched session data on Windows (no tmux),
|
||||
// fall back to tmux display-message on Unix for backward compat.
|
||||
let lastActivity: string;
|
||||
if (isWindows()) {
|
||||
lastActivity = session.lastActivityAt ? formatAge(session.lastActivityAt.getTime()) : "-";
|
||||
} else {
|
||||
const tmuxTarget = session.runtimeHandle?.id ?? session.id;
|
||||
const activityTs = await getTmuxActivity(tmuxTarget);
|
||||
lastActivity = activityTs ? formatAge(activityTs) : "-";
|
||||
}
|
||||
|
||||
// Get agent's auto-generated summary via introspection
|
||||
let claudeSummary: string | null = null;
|
||||
|
|
@ -560,6 +567,10 @@ export function registerStatus(program: Command): void {
|
|||
}
|
||||
|
||||
async function showFallbackStatus(): Promise<void> {
|
||||
if (isWindows()) {
|
||||
console.log(chalk.dim("No agent-orchestrator config found. Run `ao start` first."));
|
||||
return;
|
||||
}
|
||||
const allTmux = await getTmuxSessions();
|
||||
if (allTmux.length === 0) {
|
||||
console.log(chalk.dim("No tmux sessions found."));
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@
|
|||
*/
|
||||
|
||||
import chalk from "chalk";
|
||||
import { killProcessTree } from "@aoagents/ao-core";
|
||||
import { unregister, waitForExit, type RunningState } from "./running-state.js";
|
||||
|
||||
/**
|
||||
|
|
@ -81,20 +82,18 @@ export function attachToDaemon(running: RunningState): AttachedDaemon {
|
|||
* still alive, wait another 3s. Throws if the process refuses to die.
|
||||
* Always unregisters `running.json` on success so the next `ao start` can
|
||||
* spawn a fresh daemon without hitting the "already running" gate.
|
||||
*
|
||||
* Uses {@link killProcessTree} (not raw `process.kill`) so Windows actually
|
||||
* terminates the daemon and its detached grandchildren (pty-host, dashboard
|
||||
* subprocess) via `taskkill /T /F`. On POSIX this is process-group aware
|
||||
* with a fallback to direct kill. Both paths swallow "already dead" errors
|
||||
* internally.
|
||||
*/
|
||||
export async function killExistingDaemon(running: RunningState): Promise<void> {
|
||||
try {
|
||||
process.kill(running.pid, "SIGTERM");
|
||||
} catch {
|
||||
// already dead — fall through to wait/unregister
|
||||
}
|
||||
await killProcessTree(running.pid, "SIGTERM");
|
||||
if (!(await waitForExit(running.pid, 5000))) {
|
||||
console.log(chalk.yellow(" Process didn't exit cleanly, sending SIGKILL..."));
|
||||
try {
|
||||
process.kill(running.pid, "SIGKILL");
|
||||
} catch {
|
||||
// already dead
|
||||
}
|
||||
await killProcessTree(running.pid, "SIGKILL");
|
||||
if (!(await waitForExit(running.pid, 3000))) {
|
||||
throw new Error(
|
||||
`Failed to stop AO process (PID ${running.pid}). Check permissions or stop it manually.`,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { normalize, resolve } from "node:path";
|
||||
import ora from "ora";
|
||||
import { findPidByPort, isWindows, killProcessTree } from "@aoagents/ao-core";
|
||||
import { exec, execSilent } from "./shell.js";
|
||||
|
||||
/**
|
||||
|
|
@ -31,18 +32,18 @@ export function assertDashboardRebuildSupported(webDir: string): void {
|
|||
|
||||
/**
|
||||
* Find the PID of a process listening on the given port.
|
||||
* Returns null if no process is found.
|
||||
* Returns null if no process is found. Cross-platform via core's findPidByPort.
|
||||
*/
|
||||
export async function findRunningDashboardPid(port: number): Promise<string | null> {
|
||||
const lsofOutput = await execSilent("lsof", ["-ti", `:${port}`, "-sTCP:LISTEN"]);
|
||||
if (!lsofOutput) return null;
|
||||
|
||||
const pid = lsofOutput.split("\n")[0]?.trim();
|
||||
if (!pid || !/^\d+$/.test(pid)) return null;
|
||||
return pid;
|
||||
return findPidByPort(port);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the working directory of a process. Unix only (uses lsof).
|
||||
* Returns null on Windows or when lookup fails — callers must tolerate that.
|
||||
*/
|
||||
async function getProcessCwd(pid: string): Promise<string | null> {
|
||||
if (isWindows()) return null;
|
||||
const output = await execSilent("lsof", ["-a", "-p", pid, "-d", "cwd", "-Fn"]);
|
||||
if (!output) return null;
|
||||
|
||||
|
|
@ -52,7 +53,12 @@ async function getProcessCwd(pid: string): Promise<string | null> {
|
|||
}
|
||||
|
||||
/**
|
||||
* Find live dashboard server PIDs whose current working directory is webDir.
|
||||
* Find live dashboard server PIDs serving from webDir for the given ports.
|
||||
*
|
||||
* On Unix we cross-check each port-listening PID's cwd against webDir so we
|
||||
* only kill OUR dashboard. On Windows there's no cheap cwd lookup, so we
|
||||
* fall back to "anything on these ports" — acceptable because rebuild is
|
||||
* always invoked against a known dashboard port.
|
||||
*/
|
||||
export async function findRunningDashboardPidsForWebDir(
|
||||
webDir: string,
|
||||
|
|
@ -62,6 +68,11 @@ export async function findRunningDashboardPidsForWebDir(
|
|||
const pids = new Set<string>();
|
||||
|
||||
for (const port of ports) {
|
||||
if (isWindows()) {
|
||||
const pid = await findPidByPort(port);
|
||||
if (pid) pids.add(pid);
|
||||
continue;
|
||||
}
|
||||
const output = await execSilent("lsof", ["-ti", `:${port}`, "-sTCP:LISTEN"]);
|
||||
if (!output) continue;
|
||||
for (const rawPid of output.split("\n")) {
|
||||
|
|
@ -90,9 +101,9 @@ export async function stopRunningDashboardsForWebDir(
|
|||
);
|
||||
for (const pid of pids) {
|
||||
try {
|
||||
process.kill(Number(pid), "SIGTERM");
|
||||
await killProcessTree(Number(pid), "SIGTERM");
|
||||
} catch {
|
||||
// Process already exited (ESRCH) — that's fine.
|
||||
// Process already exited — that's fine.
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -108,7 +119,7 @@ export async function stopRunningDashboardsForWebDir(
|
|||
export async function waitForPortFree(port: number, timeoutMs: number): Promise<void> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
const pid = await findRunningDashboardPid(port);
|
||||
const pid = await findPidByPort(port);
|
||||
if (!pid) return;
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
import { existsSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { spawnSync, type SpawnSyncReturns } from "node:child_process";
|
||||
|
||||
export interface ProbeResult {
|
||||
reachable: boolean;
|
||||
|
|
@ -41,11 +41,16 @@ function normalizeGatewayBaseUrl(url: string): string {
|
|||
}
|
||||
|
||||
function resolveOpenClawBinaryPath(): string | undefined {
|
||||
const shell = process.env["SHELL"] ?? "/bin/sh";
|
||||
if (!shell.startsWith("/")) return undefined;
|
||||
const result = spawnSync(shell, ["-lc", "command -v openclaw"], {
|
||||
encoding: "utf-8",
|
||||
});
|
||||
let result: SpawnSyncReturns<string>;
|
||||
if (process.platform === "win32") {
|
||||
result = spawnSync("where", ["openclaw"], { encoding: "utf-8" });
|
||||
} else {
|
||||
const shell = process.env["SHELL"] ?? "/bin/sh";
|
||||
if (!shell.startsWith("/")) return undefined;
|
||||
result = spawnSync(shell, ["-lc", "command -v openclaw"], {
|
||||
encoding: "utf-8",
|
||||
});
|
||||
}
|
||||
|
||||
if (result.status !== 0) return undefined;
|
||||
const path = result.stdout.trim();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,54 @@
|
|||
/**
|
||||
* Canonical path-equality helpers.
|
||||
*
|
||||
* On Windows, `realpathSync` can return canonically resolved paths whose
|
||||
* drive-letter case and 8.3-vs-long-name expansion differ from the input
|
||||
* even when both inputs point to the same filesystem entry (e.g. one input
|
||||
* came from a config file and another from `process.cwd()` after a chdir).
|
||||
* A naive `===` comparison misses these as "different paths" and the calling
|
||||
* code falls into "treat as new" branches — for project resolution this
|
||||
* presents to the user as phantom "register this project?" prompts even
|
||||
* when the project is already registered.
|
||||
*
|
||||
* This module centralises the comparison so every site that asks "are
|
||||
* these two paths the same on disk?" gets the same answer regardless of
|
||||
* platform. POSIX behaviour is unchanged (case-sensitive `===`).
|
||||
*/
|
||||
|
||||
import { realpathSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { isWindows } from "@aoagents/ao-core";
|
||||
|
||||
/**
|
||||
* Resolve symlinks. Falls back to the input on any filesystem error so
|
||||
* callers can still compare unreadable paths literally rather than crash.
|
||||
* Mirrors the canonicalize() helper that previously lived in
|
||||
* resolve-project.ts.
|
||||
*/
|
||||
function canonicalize(p: string): string {
|
||||
try {
|
||||
return realpathSync(p);
|
||||
} catch {
|
||||
return p;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the comparison key for a path: resolve to absolute, expand `~`,
|
||||
* canonicalize symlinks, and normalize case on Windows. Useful when the
|
||||
* caller needs a stable key for `Map`/`Set` lookups across many paths.
|
||||
*/
|
||||
export function canonicalCompareKey(input: string): string {
|
||||
const expanded = input.replace(/^~/, process.env["HOME"] ?? "");
|
||||
const canonical = canonicalize(resolve(expanded));
|
||||
return isWindows() ? canonical.toLowerCase() : canonical;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two paths for "same filesystem entry" semantics. Equivalent to
|
||||
* `canonicalCompareKey(a) === canonicalCompareKey(b)` but kept as a named
|
||||
* helper for readability at call sites.
|
||||
*/
|
||||
export function pathsEqual(a: string, b: string): boolean {
|
||||
return canonicalCompareKey(a) === canonicalCompareKey(b);
|
||||
}
|
||||
|
|
@ -14,8 +14,9 @@
|
|||
* would generate.
|
||||
*/
|
||||
|
||||
import { existsSync, realpathSync, writeFileSync } from "node:fs";
|
||||
import { existsSync, writeFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { pathsEqual } from "./path-equality.js";
|
||||
import { cwd } from "node:process";
|
||||
import {
|
||||
ConfigNotFoundError,
|
||||
|
|
@ -124,24 +125,16 @@ export interface ResolveOptions {
|
|||
|
||||
/**
|
||||
* Decide whether `arg` looks like a path (rather than a project id).
|
||||
* Matches start.ts's `isLocalPath`.
|
||||
* Matches start.ts's `isLocalPath` — including Windows drive-letter and
|
||||
* UNC patterns so e.g. `ao start C:\path\to\repo` is correctly classified.
|
||||
*/
|
||||
function isLocalPath(arg: string): boolean {
|
||||
return arg.startsWith("/") || arg.startsWith("~") || arg.startsWith("./") || arg.startsWith("..");
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve symlink chains for canonical path comparison. Falls back to the
|
||||
* input on any filesystem error so the caller can compare unreadable paths
|
||||
* literally rather than crash. Mirrors the canonical-compare pattern used
|
||||
* elsewhere in the CLI for global-registry dedup.
|
||||
*/
|
||||
function canonicalize(p: string): string {
|
||||
try {
|
||||
return realpathSync(p);
|
||||
} catch {
|
||||
return p;
|
||||
if (arg.startsWith("/") || arg.startsWith("~") || arg.startsWith("./") || arg.startsWith("..")) {
|
||||
return true;
|
||||
}
|
||||
if (/^[A-Za-z]:[\\/]/.test(arg)) return true;
|
||||
if (arg.startsWith("\\\\") || arg.startsWith(".\\") || arg.startsWith("..\\")) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -253,9 +246,7 @@ async function fromUrlIntoGlobal(arg: string, deps: ResolveDeps): Promise<Resolv
|
|||
const refreshedConfig = loadConfig(globalConfigPath);
|
||||
const project = refreshedConfig.projects[registeredId];
|
||||
if (!project) {
|
||||
throw new Error(
|
||||
`Failed to register "${registeredId}" in the global config — aborting.`,
|
||||
);
|
||||
throw new Error(`Failed to register "${registeredId}" in the global config — aborting.`);
|
||||
}
|
||||
return {
|
||||
config: refreshedConfig,
|
||||
|
|
@ -352,16 +343,15 @@ async function fromPath(arg: string, deps: ResolveDeps, opts: ResolveOptions): P
|
|||
if (opts.targetGlobalRegistry) {
|
||||
const globalPath = getGlobalConfigPath();
|
||||
const globalConfig = existsSync(globalPath) ? loadConfig(globalPath) : loadConfig();
|
||||
// Canonicalize via realpathSync so symlinked paths (e.g. macOS
|
||||
// /tmp -> /private/tmp) match an entry stored under the resolved
|
||||
// target. Without this, `ao start /tmp/foo` against a daemon whose
|
||||
// global config has /private/tmp/foo would fail to dedupe and
|
||||
// double-register the project.
|
||||
const canonicalTarget = canonicalize(resolvedPath);
|
||||
const existingEntry = Object.entries(globalConfig.projects).find(([, p]) => {
|
||||
const expanded = resolve(p.path.replace(/^~/, process.env["HOME"] || ""));
|
||||
return canonicalize(expanded) === canonicalTarget;
|
||||
});
|
||||
// pathsEqual canonicalizes via realpathSync so symlinked paths (e.g.
|
||||
// macOS /tmp -> /private/tmp) match an entry stored under the
|
||||
// resolved target, and lowercases on Windows so drive-letter / 8.3
|
||||
// case mismatches don't slip through. Without this, `ao start
|
||||
// /tmp/foo` against a daemon whose global config has /private/tmp/foo
|
||||
// would fail to dedupe and double-register the project.
|
||||
const existingEntry = Object.entries(globalConfig.projects).find(([, p]) =>
|
||||
pathsEqual(p.path, resolvedPath),
|
||||
);
|
||||
if (existingEntry) {
|
||||
return {
|
||||
config: globalConfig,
|
||||
|
|
@ -375,9 +365,7 @@ async function fromPath(arg: string, deps: ResolveDeps, opts: ResolveOptions): P
|
|||
const reloaded = loadConfig(globalConfig.configPath);
|
||||
const project = reloaded.projects[addedId];
|
||||
if (!project) {
|
||||
throw new Error(
|
||||
`Failed to register "${addedId}" in the global config — aborting.`,
|
||||
);
|
||||
throw new Error(`Failed to register "${addedId}" in the global config — aborting.`);
|
||||
}
|
||||
return {
|
||||
config: reloaded,
|
||||
|
|
@ -411,8 +399,8 @@ async function fromPath(arg: string, deps: ResolveDeps, opts: ResolveOptions): P
|
|||
|
||||
// Config exists — check if the path is already registered.
|
||||
const config = loadConfig(configPath);
|
||||
const existingEntry = Object.entries(config.projects).find(
|
||||
([, p]) => resolve(p.path.replace(/^~/, process.env["HOME"] || "")) === resolvedPath,
|
||||
const existingEntry = Object.entries(config.projects).find(([, p]) =>
|
||||
pathsEqual(p.path, resolvedPath),
|
||||
);
|
||||
|
||||
if (existingEntry) {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { spawn } from "node:child_process";
|
|||
import { existsSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { isWindows } from "@aoagents/ao-core";
|
||||
import {
|
||||
classifyInstallPath,
|
||||
hasNodeModulesAncestor,
|
||||
|
|
@ -87,21 +88,127 @@ export function resolveScriptPath(scriptName: string): string {
|
|||
return scriptPath;
|
||||
}
|
||||
|
||||
// Common Git Bash install locations on Windows. WSL's bash.exe is intentionally
|
||||
// excluded: when invoked from Windows-native Node, the spawned WSL bash sees
|
||||
// Linux paths (/mnt/c/...) while cwd is a Windows path (D:\...), which silently
|
||||
// breaks repo scripts. Users on WSL run `ao` as a Linux process anyway, where
|
||||
// process.platform === "linux" and this branch never executes.
|
||||
const WINDOWS_BASH_CANDIDATES = [
|
||||
"C:\\Program Files\\Git\\bin\\bash.exe",
|
||||
"C:\\Program Files (x86)\\Git\\bin\\bash.exe",
|
||||
"C:\\Program Files\\Git\\usr\\bin\\bash.exe",
|
||||
];
|
||||
|
||||
// Walk PATH searching for bash.exe. Lets us discover Git Bash installs on
|
||||
// non-default drives (e.g. D:\Program Files\Git) that the hardcoded list above
|
||||
// would miss. Returns the first match or null. PATH-walking is sync but
|
||||
// microseconds — no subprocess.
|
||||
function findBashOnPath(): string | null {
|
||||
const dirs = (process.env["PATH"] ?? "").split(";").filter(Boolean);
|
||||
for (const dir of dirs) {
|
||||
const candidate = resolve(dir, "bash.exe");
|
||||
if (existsSync(candidate)) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function detectWindowsBash(): string | null {
|
||||
for (const candidate of WINDOWS_BASH_CANDIDATES) {
|
||||
if (existsSync(candidate)) return candidate;
|
||||
}
|
||||
return findBashOnPath();
|
||||
}
|
||||
|
||||
// Windows: prefer PowerShell 7 (pwsh.exe), fall back to bundled Windows
|
||||
// PowerShell 5.1 (powershell.exe). Both can run our .ps1 scripts.
|
||||
function detectWindowsPowerShell(): string | null {
|
||||
const candidates = ["pwsh.exe", "powershell.exe"];
|
||||
const dirs = (process.env["PATH"] ?? "").split(";").filter(Boolean);
|
||||
for (const exe of candidates) {
|
||||
for (const dir of dirs) {
|
||||
const candidate = resolve(dir, exe);
|
||||
if (existsSync(candidate)) return candidate;
|
||||
}
|
||||
}
|
||||
// Fallback: powershell.exe ships in System32 on every supported Windows.
|
||||
const sysRoot = process.env["SystemRoot"] ?? "C:\\Windows";
|
||||
const fallback = resolve(sysRoot, "System32\\WindowsPowerShell\\v1.0\\powershell.exe");
|
||||
return existsSync(fallback) ? fallback : null;
|
||||
}
|
||||
|
||||
export function hasRepoScript(scriptName: string): boolean {
|
||||
return existsSync(getScriptPath(scriptName));
|
||||
}
|
||||
|
||||
export async function runRepoScript(scriptName: string, args: string[]): Promise<number> {
|
||||
const shell = process.env["AO_BASH_PATH"] || "bash";
|
||||
const scriptPath = resolveScriptPath(scriptName);
|
||||
const repoRoot = resolveRepoRoot();
|
||||
const scriptLayout = resolveScriptLayout();
|
||||
|
||||
return await new Promise<number>((resolveExit, reject) => {
|
||||
const child = spawn(shell, [scriptPath, ...args], {
|
||||
// Windows: prefer the .ps1 sibling of the requested .sh script and run via
|
||||
// PowerShell. .sh scripts can still be run if the user has installed Git Bash
|
||||
// and we ship no .ps1 equivalent. Booth/WSL bash isn't supported (see comment
|
||||
// on WINDOWS_BASH_CANDIDATES).
|
||||
if (isWindows()) {
|
||||
const ps1Name = scriptName.replace(/\.sh$/i, ".ps1");
|
||||
if (ps1Name !== scriptName && hasRepoScript(ps1Name)) {
|
||||
const ps = detectWindowsPowerShell();
|
||||
if (!ps) {
|
||||
throw new Error(
|
||||
"Cannot run PowerShell scripts on Windows. " +
|
||||
"Install PowerShell 7 (https://aka.ms/powershell) or ensure powershell.exe is in PATH.",
|
||||
);
|
||||
}
|
||||
const scriptPath = resolveScriptPath(ps1Name);
|
||||
// -File runs the script with positional args; -ExecutionPolicy Bypass
|
||||
// avoids machine policy blocking unsigned bundled scripts.
|
||||
return await spawnAndWait(
|
||||
ps,
|
||||
["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", scriptPath, ...args],
|
||||
repoRoot,
|
||||
scriptLayout,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let shellOverride = process.env["AO_BASH_PATH"];
|
||||
// Unix: always use bash — repo scripts have #!/bin/bash shebangs and bash-specific syntax.
|
||||
// Shebangs are only honoured by the kernel when a file is executed directly; when passed as
|
||||
// an argument to another interpreter (e.g. zsh script.sh) the shebang is ignored, so we must
|
||||
// name bash explicitly rather than using the user's $SHELL.
|
||||
// Windows: no native shell (pwsh, powershell.exe, cmd.exe) can run bash scripts —
|
||||
// shebangs are ignored and bash-specific syntax fails. Auto-detect Git Bash / WSL,
|
||||
// fall back to AO_BASH_PATH, throw with guidance if neither is found.
|
||||
if (!shellOverride && isWindows()) {
|
||||
const detected = detectWindowsBash();
|
||||
if (!detected) {
|
||||
throw new Error(
|
||||
"Cannot run repo scripts on Windows without bash. " +
|
||||
"Install Git for Windows (https://git-scm.com/download/win) or " +
|
||||
"set AO_BASH_PATH to a bash executable " +
|
||||
"(e.g. C:\\Program Files\\Git\\bin\\bash.exe).",
|
||||
);
|
||||
}
|
||||
shellOverride = detected;
|
||||
}
|
||||
|
||||
const shell = shellOverride ?? "bash";
|
||||
const scriptPath = resolveScriptPath(scriptName);
|
||||
|
||||
return await spawnAndWait(shell, [scriptPath, ...args], repoRoot, scriptLayout);
|
||||
}
|
||||
|
||||
function spawnAndWait(
|
||||
shell: string,
|
||||
shellArgs: string[],
|
||||
repoRoot: string,
|
||||
scriptLayout: ScriptLayout,
|
||||
): Promise<number> {
|
||||
return new Promise<number>((resolveExit, reject) => {
|
||||
const child = spawn(shell, shellArgs, {
|
||||
cwd: repoRoot,
|
||||
env: { ...process.env, AO_REPO_ROOT: repoRoot, AO_SCRIPT_LAYOUT: scriptLayout },
|
||||
stdio: "inherit",
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
child.on("error", reject);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { execFile as execFileCb } from "node:child_process";
|
||||
import { type ChildProcess, execFile as execFileCb } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { killProcessTree } from "@aoagents/ao-core";
|
||||
|
||||
const execFileAsync = promisify(execFileCb);
|
||||
|
||||
|
|
@ -17,6 +18,7 @@ export async function exec(
|
|||
cwd: options?.cwd,
|
||||
env: options?.env ? { ...process.env, ...options.env } : undefined,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
windowsHide: true,
|
||||
});
|
||||
return { stdout: stdout.trimEnd(), stderr: stderr.trimEnd() };
|
||||
}
|
||||
|
|
@ -53,6 +55,46 @@ export async function getTmuxSessions(): Promise<string[]> {
|
|||
return output.split("\n").filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward SIGINT/SIGTERM from the parent to a detached child's process group.
|
||||
* Required on Unix when a child is spawned with detached:true — Ctrl+C only
|
||||
* reaches the parent's process group, not the child's.
|
||||
*
|
||||
* Sends SIGTERM immediately, then escalates to SIGKILL after 5 s if the child
|
||||
* has not exited — prevents the parent from hanging indefinitely with no signal
|
||||
* handlers registered. Cleans up automatically when the child exits normally.
|
||||
*
|
||||
* Idempotent: calling with the same child object more than once is a no-op.
|
||||
*/
|
||||
const _signalForwardedChildren = new WeakSet<ChildProcess>();
|
||||
|
||||
export function forwardSignalsToChild(pid: number, child: ChildProcess): void {
|
||||
if (_signalForwardedChildren.has(child)) return;
|
||||
_signalForwardedChildren.add(child);
|
||||
let fallback: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const forward = (): void => {
|
||||
process.off("SIGINT", forward);
|
||||
process.off("SIGTERM", forward);
|
||||
void killProcessTree(pid, "SIGTERM");
|
||||
// If the child ignores SIGTERM, force-kill after 5 s so the parent exits.
|
||||
// Exit 0: this path is reached on user-initiated Ctrl+C where the child is
|
||||
// merely slow to drain (e.g. Next.js connections). Treating that as an
|
||||
// error breaks shell scripts and CI pipelines that check the exit code.
|
||||
fallback = setTimeout(() => {
|
||||
void killProcessTree(pid, "SIGKILL").finally(() => process.exit(0));
|
||||
}, 5000);
|
||||
fallback.unref();
|
||||
};
|
||||
process.once("SIGINT", forward);
|
||||
process.once("SIGTERM", forward);
|
||||
child.once("exit", () => {
|
||||
process.off("SIGINT", forward);
|
||||
process.off("SIGTERM", forward);
|
||||
if (fallback !== undefined) clearTimeout(fallback);
|
||||
});
|
||||
}
|
||||
|
||||
export async function getTmuxActivity(session: string): Promise<number | null> {
|
||||
const output = await tmux("display-message", "-t", session, "-p", "#{session_activity}");
|
||||
if (!output) return null;
|
||||
|
|
|
|||
|
|
@ -12,22 +12,21 @@
|
|||
* `runStartup` runs once at process start.
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import chalk from "chalk";
|
||||
import {
|
||||
getAoBaseDir,
|
||||
getDefaultRuntime,
|
||||
getGlobalConfigPath,
|
||||
inventoryHashDirs,
|
||||
isWindows,
|
||||
type OrchestratorConfig,
|
||||
} from "@aoagents/ao-core";
|
||||
import { execSilent } from "./shell.js";
|
||||
import { detectOpenClawInstallation } from "./openclaw-probe.js";
|
||||
import { applyOpenClawCredentials } from "./credential-resolver.js";
|
||||
import { preventIdleSleep } from "./prevent-sleep.js";
|
||||
import {
|
||||
askYesNo,
|
||||
tryInstallWithAttempts,
|
||||
type InstallAttempt,
|
||||
} from "./install-helpers.js";
|
||||
import { askYesNo, tryInstallWithAttempts, type InstallAttempt } from "./install-helpers.js";
|
||||
|
||||
function gitInstallAttempts(): InstallAttempt[] {
|
||||
if (process.platform === "darwin") {
|
||||
|
|
@ -111,13 +110,87 @@ export async function ensureGit(context: string): Promise<void> {
|
|||
process.exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* On Windows, attempt to rewrite `runtime: tmux` -> `runtime: process` in the
|
||||
* project's agent-orchestrator.yaml after asking the user. Uses a targeted
|
||||
* line replace (not a yaml round-trip) so comments and quoting are preserved.
|
||||
*
|
||||
* Returns `true` on a successful rewrite. The caller is responsible for
|
||||
* mutating the in-memory config (or reloading) so the rest of preflight
|
||||
* sees the new runtime.
|
||||
*/
|
||||
async function offerWindowsRuntimeSwitch(configPath: string): Promise<boolean> {
|
||||
console.log(chalk.yellow("\n⚠ tmux runtime is not supported on Windows."));
|
||||
console.log(chalk.dim(` Config: ${configPath}`));
|
||||
console.log(
|
||||
chalk.dim(
|
||||
" AO can rewrite `runtime: tmux` -> `runtime: process` in this file.\n" +
|
||||
" If the file is git-tracked, you'll see this as a local change.",
|
||||
),
|
||||
);
|
||||
|
||||
const accept = await askYesNo("Switch this project to runtime: process?", true, false);
|
||||
if (!accept) return false;
|
||||
|
||||
let original: string;
|
||||
try {
|
||||
original = readFileSync(configPath, "utf-8");
|
||||
} catch (err) {
|
||||
console.error(
|
||||
chalk.red(` ✗ Could not read config: ${err instanceof Error ? err.message : String(err)}`),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Match the runtime line whether quoted or unquoted, and preserve any
|
||||
// trailing comment. Anchored multiline so we don't accidentally rewrite
|
||||
// e.g. a string value on another line.
|
||||
const runtimeLineRe = /^([ \t]*runtime:[ \t]*)(?:'tmux'|"tmux"|tmux)([ \t]*(?:#.*)?)$/m;
|
||||
if (!runtimeLineRe.test(original)) {
|
||||
console.error(
|
||||
chalk.red(" ✗ Could not locate `runtime: tmux` line in config; aborting rewrite."),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
const rewritten = original.replace(runtimeLineRe, "$1process$2");
|
||||
|
||||
try {
|
||||
writeFileSync(configPath, rewritten, "utf-8");
|
||||
} catch (err) {
|
||||
console.error(
|
||||
chalk.red(` ✗ Failed to write config: ${err instanceof Error ? err.message : String(err)}`),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
console.log(chalk.green(" ✓ Updated runtime to process"));
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure tmux is available — interactive install with user consent if missing.
|
||||
* Called from runtimePreflight() so all `ao start` paths are covered.
|
||||
*
|
||||
* On Windows, tmux cannot run; instead, offer to rewrite the project config
|
||||
* to `runtime: process`. Returns `{ switchedToProcess: true }` if the rewrite
|
||||
* succeeded so the caller can update the in-memory config.
|
||||
*/
|
||||
export async function ensureTmux(): Promise<void> {
|
||||
export async function ensureTmux(configPath?: string): Promise<{ switchedToProcess: boolean }> {
|
||||
if (isWindows()) {
|
||||
if (configPath) {
|
||||
const switched = await offerWindowsRuntimeSwitch(configPath);
|
||||
if (switched) return { switchedToProcess: true };
|
||||
}
|
||||
console.error(chalk.red("\n✗ tmux runtime is not supported on Windows.\n"));
|
||||
console.log(
|
||||
chalk.bold(" Set ") +
|
||||
chalk.cyan("runtime: process") +
|
||||
chalk.bold(" in agent-orchestrator.yaml, then re-run ao start.\n"),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const hasTmux = (await execSilent("tmux", ["-V"])) !== null;
|
||||
if (hasTmux) return;
|
||||
if (hasTmux) return { switchedToProcess: false };
|
||||
|
||||
console.log(chalk.yellow('⚠ tmux is required for runtime "tmux".'));
|
||||
const shouldInstall = await askYesNo("Install tmux now?", true, false);
|
||||
|
|
@ -128,7 +201,7 @@ export async function ensureTmux(): Promise<void> {
|
|||
);
|
||||
if (installed) {
|
||||
console.log(chalk.green(" ✓ tmux installed successfully"));
|
||||
return;
|
||||
return { switchedToProcess: false };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -209,9 +282,17 @@ export async function warnAboutOpenClawStatus(config: OrchestratorConfig): Promi
|
|||
* for the lifetime of the process.
|
||||
*/
|
||||
export async function runtimePreflight(config: OrchestratorConfig): Promise<void> {
|
||||
const runtime = config.defaults?.runtime ?? "tmux";
|
||||
const runtime = config.defaults?.runtime ?? getDefaultRuntime();
|
||||
if (runtime === "tmux") {
|
||||
await ensureTmux();
|
||||
const result = await ensureTmux(config.configPath);
|
||||
if (result.switchedToProcess) {
|
||||
// Mutate in-memory config so the rest of startup uses the new runtime.
|
||||
// Disk has already been updated; subsequent loadConfig() calls will
|
||||
// see the same value.
|
||||
const defaults = config.defaults ?? {};
|
||||
defaults.runtime = "process";
|
||||
config.defaults = defaults;
|
||||
}
|
||||
}
|
||||
warnAboutLegacyStorage();
|
||||
await warnAboutOpenClawStatus(config);
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ Every interface the system uses is defined here. If you're working on any part o
|
|||
|
||||
**Main interfaces:**
|
||||
|
||||
- `Runtime` — where sessions execute (tmux, docker, k8s)
|
||||
- `Runtime` — where sessions execute (tmux on Unix, `process` / ConPTY via node-pty on Windows, docker, k8s)
|
||||
- `Agent` — AI coding tool adapter (claude-code, codex, aider)
|
||||
- `Workspace` — code isolation (worktree, clone)
|
||||
- `Tracker` — issue tracking (GitHub Issues, Linear)
|
||||
|
|
@ -236,6 +236,6 @@ This package is a dependency of all other packages. Build it first if working on
|
|||
|
||||
**Why plugin slots?**
|
||||
|
||||
- Swappability: use tmux locally, docker in CI, k8s in prod
|
||||
- Swappability: use tmux on Linux/macOS, `process` (ConPTY) on Windows, docker in CI, k8s in prod — same agent/workspace stack across all of them
|
||||
- Testability: mock plugins for tests
|
||||
- Extensibility: users can add custom plugins (e.g., company-specific notifier)
|
||||
|
|
|
|||
|
|
@ -444,7 +444,9 @@ describe("applyAgentReport", () => {
|
|||
).toThrow(/not found/);
|
||||
});
|
||||
|
||||
it("bounds the on-disk audit trail to recent entries", { timeout: 15000 }, () => {
|
||||
// 260 atomic-write cycles are slow on Windows (rename + AV scan); bump the
|
||||
// per-test timeout to avoid flakes on slower filesystems.
|
||||
it("bounds the on-disk audit trail to recent entries", { timeout: 30_000 }, () => {
|
||||
for (let index = 0; index < 260; index += 1) {
|
||||
applyAgentReport(dataDir, sessionId, {
|
||||
state: "working",
|
||||
|
|
|
|||
|
|
@ -1,16 +1,19 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
buildAgentPath,
|
||||
buildNodeWrapper,
|
||||
setupPathWrapperWorkspace,
|
||||
AO_METADATA_HELPER,
|
||||
GH_WRAPPER,
|
||||
} from "../agent-workspace-hooks.js";
|
||||
|
||||
const { mockWriteFile, mockMkdir, mockReadFile, mockRename } = vi.hoisted(() => ({
|
||||
const { mockWriteFile, mockMkdir, mockReadFile, mockRename, mockIsWindows } = vi.hoisted(() => ({
|
||||
mockWriteFile: vi.fn().mockResolvedValue(undefined),
|
||||
mockMkdir: vi.fn().mockResolvedValue(undefined),
|
||||
mockReadFile: vi.fn(),
|
||||
mockRename: vi.fn().mockResolvedValue(undefined),
|
||||
mockIsWindows: vi.fn().mockReturnValue(false),
|
||||
}));
|
||||
|
||||
vi.mock("node:fs/promises", () => ({
|
||||
|
|
@ -24,10 +27,23 @@ vi.mock("node:os", () => ({
|
|||
homedir: () => "/home/testuser",
|
||||
}));
|
||||
|
||||
vi.mock("../platform.js", () => ({
|
||||
isWindows: mockIsWindows,
|
||||
}));
|
||||
|
||||
// On Windows, path.join('/home/testuser', '.ao', 'bin') => '\home\testuser\.ao\bin'
|
||||
// Use join() so assertions match the runtime path format
|
||||
const AO_BIN_DIR = join("/home/testuser", ".ao", "bin");
|
||||
|
||||
describe("buildAgentPath", () => {
|
||||
beforeEach(() => {
|
||||
mockIsWindows.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it("prepends ao bin dir to PATH", () => {
|
||||
const result = buildAgentPath("/usr/bin:/bin");
|
||||
expect(result).toMatch(/^\/home\/testuser\/.ao\/bin:/);
|
||||
expect(result).toContain(AO_BIN_DIR);
|
||||
expect(result.startsWith(AO_BIN_DIR)).toBe(true);
|
||||
expect(result).toContain("/usr/bin");
|
||||
expect(result).toContain("/bin");
|
||||
});
|
||||
|
|
@ -41,64 +57,239 @@ describe("buildAgentPath", () => {
|
|||
|
||||
it("uses default PATH when basePath is undefined", () => {
|
||||
const result = buildAgentPath(undefined);
|
||||
expect(result).toMatch(/^\/home\/testuser\/.ao\/bin:/);
|
||||
expect(result).toContain(AO_BIN_DIR);
|
||||
expect(result.startsWith(AO_BIN_DIR)).toBe(true);
|
||||
expect(result).toContain("/usr/bin");
|
||||
});
|
||||
|
||||
it("ensures /usr/local/bin is early for gh resolution", () => {
|
||||
const result = buildAgentPath("/usr/bin:/bin");
|
||||
const entries = result.split(":");
|
||||
const aoIdx = entries.indexOf("/home/testuser/.ao/bin");
|
||||
const aoIdx = entries.indexOf(AO_BIN_DIR);
|
||||
const ghIdx = entries.indexOf("/usr/local/bin");
|
||||
expect(aoIdx).toBe(0);
|
||||
expect(ghIdx).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setupPathWrapperWorkspace", () => {
|
||||
describe("setupPathWrapperWorkspace (Unix)", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockIsWindows.mockReturnValue(false);
|
||||
mockReadFile.mockRejectedValue(new Error("ENOENT"));
|
||||
});
|
||||
|
||||
it("creates ao bin directory", async () => {
|
||||
await setupPathWrapperWorkspace("/workspace");
|
||||
expect(mockMkdir).toHaveBeenCalledWith("/home/testuser/.ao/bin", { recursive: true });
|
||||
expect(mockMkdir).toHaveBeenCalledWith(AO_BIN_DIR, { recursive: true });
|
||||
});
|
||||
|
||||
it("writes wrapper scripts when version marker is missing", async () => {
|
||||
await setupPathWrapperWorkspace("/workspace");
|
||||
// atomicWriteFile writes to .tmp then renames
|
||||
expect(mockRename).toHaveBeenCalled();
|
||||
// .ao/AGENTS.md is written directly
|
||||
// .ao/AGENTS.md is written directly (path.join may use / or \ depending on platform)
|
||||
const agentsMdWrites = mockWriteFile.mock.calls.filter((c: unknown[]) =>
|
||||
String(c[0]).includes(".ao/AGENTS.md"),
|
||||
String(c[0]).includes("AGENTS.md"),
|
||||
);
|
||||
expect(agentsMdWrites).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("skips wrapper rewrite when version matches", async () => {
|
||||
mockReadFile
|
||||
.mockResolvedValueOnce("0.6.0") // version marker matches
|
||||
.mockResolvedValueOnce("0.7.0") // version marker matches
|
||||
.mockRejectedValueOnce(new Error("ENOENT")); // AGENTS.md doesn't exist
|
||||
|
||||
await setupPathWrapperWorkspace("/workspace");
|
||||
|
||||
// Only metadata helper rename (1), no gh/git/marker renames
|
||||
// No gh/git/marker renames when version matches
|
||||
const renamedPaths = mockRename.mock.calls.map((c: unknown[]) => String(c[0]));
|
||||
expect(renamedPaths.filter((p: string) => p.includes("/gh."))).toHaveLength(0);
|
||||
expect(renamedPaths.filter((p: string) => p.includes("/git."))).toHaveLength(0);
|
||||
expect(renamedPaths.filter((p: string) => p.includes("gh."))).toHaveLength(0);
|
||||
expect(renamedPaths.filter((p: string) => p.includes("git."))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("writes .ao/AGENTS.md with session context", async () => {
|
||||
await setupPathWrapperWorkspace("/workspace");
|
||||
|
||||
const agentsMdWrites = mockWriteFile.mock.calls.filter((c: unknown[]) =>
|
||||
String(c[0]).includes(".ao/AGENTS.md"),
|
||||
String(c[0]).includes("AGENTS.md"),
|
||||
);
|
||||
expect(agentsMdWrites).toHaveLength(1);
|
||||
expect(String(agentsMdWrites[0][1])).toContain("Agent Orchestrator");
|
||||
});
|
||||
|
||||
it("writes bash wrappers (not .cmd) on Unix", async () => {
|
||||
await setupPathWrapperWorkspace("/workspace");
|
||||
|
||||
const writtenPaths = mockWriteFile.mock.calls.map((c: unknown[]) => String(c[0]));
|
||||
const renamedPaths = mockRename.mock.calls.map((c: unknown[]) => String(c[1]));
|
||||
const allPaths = [...writtenPaths, ...renamedPaths];
|
||||
|
||||
// Should have bash scripts for gh and git (no extension)
|
||||
const hasBashGh = allPaths.some((p: string) => p.endsWith("/gh") || p.endsWith("\\gh"));
|
||||
const hasBashGit = allPaths.some((p: string) => p.endsWith("/git") || p.endsWith("\\git"));
|
||||
expect(hasBashGh).toBe(true);
|
||||
expect(hasBashGit).toBe(true);
|
||||
|
||||
// Should NOT have .cmd shims on Unix
|
||||
const hasCmdFiles = allPaths.some((p: string) => p.endsWith(".cmd"));
|
||||
expect(hasCmdFiles).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setupPathWrapperWorkspace (Windows)", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockIsWindows.mockReturnValue(true);
|
||||
mockReadFile.mockRejectedValue(new Error("ENOENT"));
|
||||
});
|
||||
|
||||
it("generates .cmd shims instead of bash scripts", async () => {
|
||||
await setupPathWrapperWorkspace("C:\\workspace");
|
||||
|
||||
// atomicWriteFile: writeFile(tmp) -> rename(tmp, final)
|
||||
const renamedFinal = mockRename.mock.calls.map((c: unknown[]) => String(c[1]));
|
||||
|
||||
const hasCmdGh = renamedFinal.some((p: string) => p.endsWith("gh.cmd"));
|
||||
const hasCmdGit = renamedFinal.some((p: string) => p.endsWith("git.cmd"));
|
||||
expect(hasCmdGh).toBe(true);
|
||||
expect(hasCmdGit).toBe(true);
|
||||
});
|
||||
|
||||
it("generates .cjs wrapper files on Windows", async () => {
|
||||
await setupPathWrapperWorkspace("C:\\workspace");
|
||||
|
||||
const renamedFinal = mockRename.mock.calls.map((c: unknown[]) => String(c[1]));
|
||||
|
||||
const hasCjsGh = renamedFinal.some((p: string) => p.endsWith("gh.cjs"));
|
||||
const hasCjsGit = renamedFinal.some((p: string) => p.endsWith("git.cjs"));
|
||||
expect(hasCjsGh).toBe(true);
|
||||
expect(hasCjsGit).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT generate bash wrappers on Windows", async () => {
|
||||
await setupPathWrapperWorkspace("C:\\workspace");
|
||||
|
||||
const renamedFinal = mockRename.mock.calls.map((c: unknown[]) => String(c[1]));
|
||||
|
||||
// Bash wrappers have no extension — check nothing ends with just /gh or \gh
|
||||
const hasBashGh = renamedFinal.some((p: string) => p.endsWith("/gh") || p.endsWith("\\gh"));
|
||||
const hasBashGit = renamedFinal.some((p: string) => p.endsWith("/git") || p.endsWith("\\git"));
|
||||
expect(hasBashGh).toBe(false);
|
||||
expect(hasBashGit).toBe(false);
|
||||
});
|
||||
|
||||
it("does NOT generate ao-metadata-helper.sh on Windows", async () => {
|
||||
await setupPathWrapperWorkspace("C:\\workspace");
|
||||
|
||||
const renamedFinal = mockRename.mock.calls.map((c: unknown[]) => String(c[1]));
|
||||
const hasHelper = renamedFinal.some((p: string) => p.includes("ao-metadata-helper"));
|
||||
expect(hasHelper).toBe(false);
|
||||
});
|
||||
|
||||
it(".cmd shim content delegates to node wrapper", async () => {
|
||||
await setupPathWrapperWorkspace("C:\\workspace");
|
||||
|
||||
// Find .cmd write content — tmp file is written then renamed
|
||||
const tmpWrites = mockWriteFile.mock.calls;
|
||||
const cmdWrite = tmpWrites.find((c: unknown[]) => {
|
||||
const content = String(c[1]);
|
||||
return content.includes("@node") && content.includes("%~dp0");
|
||||
});
|
||||
expect(cmdWrite).toBeDefined();
|
||||
expect(String(cmdWrite![1])).toMatch(/@node "%~dp0(gh|git)\.cjs" %\*/);
|
||||
});
|
||||
|
||||
it("writes .ao/AGENTS.md on Windows too", async () => {
|
||||
await setupPathWrapperWorkspace("C:\\workspace");
|
||||
|
||||
const agentsMdWrites = mockWriteFile.mock.calls.filter((c: unknown[]) =>
|
||||
String(c[0]).includes("AGENTS.md"),
|
||||
);
|
||||
expect(agentsMdWrites).toHaveLength(1);
|
||||
expect(String(agentsMdWrites[0][1])).toContain("Agent Orchestrator");
|
||||
});
|
||||
|
||||
it("uses semicolon as PATH delimiter on Windows", () => {
|
||||
const result = buildAgentPath("C:\\tools;C:\\Windows\\System32");
|
||||
// On Windows, delimiter should be ;
|
||||
expect(result).toContain(";");
|
||||
expect(result).toContain("C:\\tools");
|
||||
expect(result).toContain("C:\\Windows\\System32");
|
||||
// The ao bin dir should be first entry (joined with ;)
|
||||
const entries = result.split(";");
|
||||
expect(entries[0]).toBe(AO_BIN_DIR);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildNodeWrapper", () => {
|
||||
it("gh wrapper contains PR URL extraction pattern", () => {
|
||||
const script = buildNodeWrapper("gh", "");
|
||||
expect(script).toContain("pr/create");
|
||||
expect(script).toContain("pr/merge");
|
||||
// The regex pattern for github PR URLs (escaped inside a JS regex literal in the generated script)
|
||||
expect(script).toContain("github");
|
||||
expect(script).toContain("pull");
|
||||
expect(script).toContain("updateAoMetadata");
|
||||
});
|
||||
|
||||
it("gh wrapper contains metadata update calls for pr_open", () => {
|
||||
const script = buildNodeWrapper("gh", "");
|
||||
expect(script).toContain("pr_open");
|
||||
expect(script).toContain('"pr"');
|
||||
expect(script).toContain('"status"');
|
||||
});
|
||||
|
||||
it("git wrapper intercepts checkout -b and switch -c", () => {
|
||||
const script = buildNodeWrapper("git", "");
|
||||
expect(script).toContain("checkout/-b");
|
||||
expect(script).toContain("switch/-c");
|
||||
expect(script).toContain("updateAoMetadata");
|
||||
expect(script).toContain('"branch"');
|
||||
});
|
||||
|
||||
it("git wrapper skips non-feature branches", () => {
|
||||
const script = buildNodeWrapper("git", "");
|
||||
// The script should check for / or - in branch name
|
||||
expect(script).toContain('branch.includes("/") || branch.includes("-")');
|
||||
});
|
||||
|
||||
it("wrappers use path.delimiter for PATH splitting", () => {
|
||||
const ghScript = buildNodeWrapper("gh", "");
|
||||
const gitScript = buildNodeWrapper("git", "");
|
||||
expect(ghScript).toContain("path.delimiter");
|
||||
expect(gitScript).toContain("path.delimiter");
|
||||
});
|
||||
|
||||
it("gh wrapper uses explicit realBinaryPath when provided, with existsSync fallback", () => {
|
||||
const script = buildNodeWrapper("gh", "C:\\tools\\gh.exe");
|
||||
expect(script).toContain("C:\\\\tools\\\\gh.exe");
|
||||
// fallback must NOT be dead code — fs.existsSync guard ensures findRealGh() runs
|
||||
// when the hardcoded path is missing at runtime
|
||||
expect(script).toContain("fs.existsSync");
|
||||
expect(script).toContain("findRealGh()");
|
||||
});
|
||||
|
||||
it("updateAoMetadata in node wrappers handles V2 .json metadata format", () => {
|
||||
const ghScript = buildNodeWrapper("gh", "");
|
||||
// Must try the .json extension first (V2 storage format)
|
||||
expect(ghScript).toContain('aoSession + ".json"');
|
||||
// Must fall back to bare name (V1 legacy format)
|
||||
expect(ghScript).toContain("path.join(resolvedDir, aoSession)");
|
||||
// Must handle JSON.parse/stringify for V2 format
|
||||
expect(ghScript).toContain("JSON.parse");
|
||||
expect(ghScript).toContain("JSON.stringify");
|
||||
// Must handle key=value lines for V1 format
|
||||
expect(ghScript).toContain('split("\\n")');
|
||||
});
|
||||
|
||||
it("updateAoMetadata is shared between gh and git wrappers", () => {
|
||||
const ghScript = buildNodeWrapper("gh", "");
|
||||
const gitScript = buildNodeWrapper("git", "");
|
||||
// Both wrappers should contain the V2 JSON format support
|
||||
expect(ghScript).toContain('aoSession + ".json"');
|
||||
expect(gitScript).toContain('aoSession + ".json"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("AO_METADATA_HELPER", () => {
|
||||
|
|
@ -183,10 +374,10 @@ describe("GH_WRAPPER", () => {
|
|||
it("includes --json fields in PR discovery cache key", () => {
|
||||
// _ao_json is captured from --json and --json= forms
|
||||
expect(GH_WRAPPER).toContain('_ao_json=""');
|
||||
expect(GH_WRAPPER).toContain('--json) _ao_json=');
|
||||
expect(GH_WRAPPER).toContain('--json=*) _ao_json=');
|
||||
expect(GH_WRAPPER).toContain("--json) _ao_json=");
|
||||
expect(GH_WRAPPER).toContain("--json=*) _ao_json=");
|
||||
// json fields are included in the raw key fed to sha256
|
||||
expect(GH_WRAPPER).toContain('-j-${_ao_json}');
|
||||
expect(GH_WRAPPER).toContain("-j-${_ao_json}");
|
||||
});
|
||||
|
||||
it("includes --json fields in issue context cache key", () => {
|
||||
|
|
@ -230,7 +421,7 @@ describe("GH_WRAPPER", () => {
|
|||
});
|
||||
|
||||
it("uses current wrapper version in trace logging", () => {
|
||||
expect(GH_WRAPPER).toContain("0.6.0");
|
||||
expect(GH_WRAPPER).toContain("0.7.0");
|
||||
});
|
||||
|
||||
it("logs cache outcomes (hit/miss-stored/miss-negative/miss-error) to trace", () => {
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import {
|
|||
resolveCloneTarget,
|
||||
sanitizeProjectId,
|
||||
} from "../config-generator.js";
|
||||
import { getDefaultRuntime } from "../platform.js";
|
||||
|
||||
describe("withConfigSchema", () => {
|
||||
it("uses canonical schema URL for missing schema", () => {
|
||||
|
|
@ -274,7 +275,7 @@ describe("generateConfigFromUrl", () => {
|
|||
);
|
||||
expect(config.port).toBe(3000);
|
||||
expect(config.defaults).toEqual({
|
||||
runtime: "tmux",
|
||||
runtime: getDefaultRuntime(),
|
||||
agent: "claude-code",
|
||||
workspace: "worktree",
|
||||
notifiers: [],
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
|
||||
describe("config defaults", () => {
|
||||
const originalPlatform = process.platform;
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, "platform", { value: originalPlatform, writable: true });
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("defaults runtime to 'process' on win32", async () => {
|
||||
vi.resetModules();
|
||||
Object.defineProperty(process, "platform", { value: "win32", writable: true });
|
||||
const { getDefaultConfig } = await import("../config.js");
|
||||
const config = getDefaultConfig();
|
||||
expect(config.defaults.runtime).toBe("process");
|
||||
});
|
||||
|
||||
it("defaults runtime to 'tmux' on linux", async () => {
|
||||
vi.resetModules();
|
||||
Object.defineProperty(process, "platform", { value: "linux", writable: true });
|
||||
const { getDefaultConfig } = await import("../config.js");
|
||||
const config = getDefaultConfig();
|
||||
expect(config.defaults.runtime).toBe("tmux");
|
||||
});
|
||||
});
|
||||
|
|
@ -16,6 +16,7 @@ describe("global-config storage identity", () => {
|
|||
let tempRoot: string;
|
||||
let configPath: string;
|
||||
let originalHome: string | undefined;
|
||||
let originalUserProfile: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tempRoot = join(
|
||||
|
|
@ -25,12 +26,15 @@ describe("global-config storage identity", () => {
|
|||
mkdirSync(tempRoot, { recursive: true });
|
||||
configPath = join(tempRoot, "config.yaml");
|
||||
originalHome = process.env["HOME"];
|
||||
originalUserProfile = process.env["USERPROFILE"];
|
||||
process.env["HOME"] = tempRoot;
|
||||
process.env["USERPROFILE"] = tempRoot;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env["HOME"] = originalHome;
|
||||
rmSync(tempRoot, { recursive: true, force: true });
|
||||
process.env["USERPROFILE"] = originalUserProfile;
|
||||
rmSync(tempRoot, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
||||
});
|
||||
|
||||
function createRepo(repoName: string, originUrl?: string): string {
|
||||
|
|
@ -487,4 +491,14 @@ describe("global-config storage identity", () => {
|
|||
postCreate: ["pnpm install"],
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults the global runtime to the platform-appropriate value", () => {
|
||||
// The Zod default and makeEmptyGlobalConfig() must defer to
|
||||
// getDefaultRuntime() so Windows-loaded projects don't inherit
|
||||
// tmux (which is intentionally unavailable on win32).
|
||||
writeFileSync(configPath, "port: 3000\nprojects: {}\n");
|
||||
const config = loadGlobalConfig(configPath);
|
||||
const expected = process.platform === "win32" ? "process" : "tmux";
|
||||
expect(config?.defaults?.runtime).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1280,12 +1280,21 @@ describe("check (single session)", () => {
|
|||
|
||||
try {
|
||||
lm.start(60_000);
|
||||
await vi.waitFor(() => {
|
||||
const adoptedCount = [sessionA.branch, sessionB.branch].filter(
|
||||
(branch) => branch === "shared-branch",
|
||||
).length;
|
||||
expect(adoptedCount).toBe(1);
|
||||
});
|
||||
// Poll for the cycle to finish — Windows fs is slower, a fixed 25ms wait
|
||||
// can race past the lifecycle list() call before adoption resolves.
|
||||
const deadline = Date.now() + 2000;
|
||||
while (Date.now() < deadline) {
|
||||
if (vi.mocked(mockSessionManager.list).mock.calls.length >= 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
break;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
|
||||
const adoptedCount = [sessionA.branch, sessionB.branch].filter(
|
||||
(branch) => branch === "shared-branch",
|
||||
).length;
|
||||
expect(adoptedCount).toBe(1);
|
||||
expect(mockSessionManager.list).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
lm.stop();
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import type * as ChildProcess from "node:child_process";
|
||||
import { mkdirSync, writeFileSync, rmSync, existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir, homedir } from "node:os";
|
||||
|
|
@ -313,7 +314,12 @@ describe("convertKeyValueToJson", () => {
|
|||
);
|
||||
});
|
||||
|
||||
describe("migrateStorage", () => {
|
||||
// Skipped on Windows: tests migrate FROM the legacy hash-dir layout that
|
||||
// shipped only on Linux/macOS in V1 (Windows wasn't supported). On Windows
|
||||
// the legacy layout never exists, so these scenarios cannot occur in real
|
||||
// installs, and several fixtures use ':' in filenames or rely on POSIX
|
||||
// rename semantics that NTFS does not provide.
|
||||
describe.skipIf(process.platform === "win32")("migrateStorage", () => {
|
||||
let testDir: string;
|
||||
let aoBaseDir: string;
|
||||
let configPath: string;
|
||||
|
|
@ -329,7 +335,11 @@ describe("migrateStorage", () => {
|
|||
rmSync(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("migrates a single project with one session", async () => {
|
||||
// Skipped on Windows: tests migrate FROM the legacy hash-dir layout that
|
||||
// shipped only on Linux/macOS in V1 (Windows wasn't supported). On Windows
|
||||
// the legacy layout never exists, so this code path can't be exercised, and
|
||||
// some fixtures use ':' in filenames or rely on POSIX rename semantics.
|
||||
it.skipIf(process.platform === "win32")("migrates a single project with one session", async () => {
|
||||
// Setup: hash dir with one worker session and worktree
|
||||
const hashDir = join(aoBaseDir, "aaaaaa000000-myproject");
|
||||
mkdirSync(join(hashDir, "sessions"), { recursive: true });
|
||||
|
|
@ -534,7 +544,7 @@ describe("migrateStorage", () => {
|
|||
expect(existsSync(join(aoBaseDir, "aaaaaa000000-empty-project"))).toBe(false);
|
||||
});
|
||||
|
||||
it("dry run makes no changes", async () => {
|
||||
it.skipIf(process.platform === "win32")("dry run makes no changes", async () => {
|
||||
const hashDir = join(aoBaseDir, "aaaaaa000000-myproject");
|
||||
mkdirSync(join(hashDir, "sessions"), { recursive: true });
|
||||
writeFileSync(
|
||||
|
|
@ -567,7 +577,7 @@ describe("migrateStorage", () => {
|
|||
expect(result.sessions).toBe(0);
|
||||
});
|
||||
|
||||
it("flattens archives into sessions/ as terminated records", async () => {
|
||||
it.skipIf(process.platform === "win32")("flattens archives into sessions/ as terminated records", async () => {
|
||||
const hashDir = join(aoBaseDir, "aaaaaa000000-myproject");
|
||||
mkdirSync(join(hashDir, "sessions", "archive"), { recursive: true });
|
||||
|
||||
|
|
@ -817,7 +827,7 @@ describe("migrateStorage", () => {
|
|||
expect(existsSync(`${hashDir}.migrated`)).toBe(true);
|
||||
});
|
||||
|
||||
it("handles ENOTEMPTY when .migrated target already exists from interrupted run", async () => {
|
||||
it.skipIf(process.platform === "win32")("handles ENOTEMPTY when .migrated target already exists from interrupted run", async () => {
|
||||
const hashDir = join(aoBaseDir, "aaaaaa000000-myproject");
|
||||
mkdirSync(join(hashDir, "sessions"), { recursive: true });
|
||||
writeFileSync(
|
||||
|
|
@ -845,7 +855,7 @@ describe("migrateStorage", () => {
|
|||
expect(logs.some((l) => l.includes("already exists"))).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves config and migration marker when retiring a legacy dir fails", async () => {
|
||||
it.skipIf(process.platform === "win32")("preserves config and migration marker when retiring a legacy dir fails", async () => {
|
||||
const hashDir = join(aoBaseDir, "aaaaaa000000-myproject");
|
||||
mkdirSync(join(hashDir, "sessions"), { recursive: true });
|
||||
writeFileSync(
|
||||
|
|
@ -919,7 +929,7 @@ describe("migrateStorage", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("rollbackStorage", () => {
|
||||
describe.skipIf(process.platform === "win32")("rollbackStorage", () => {
|
||||
let testDir: string;
|
||||
let aoBaseDir: string;
|
||||
let configPath: string;
|
||||
|
|
@ -979,7 +989,7 @@ describe("rollbackStorage", () => {
|
|||
expect(configContent).toContain("aaaaaa000000-myproject");
|
||||
});
|
||||
|
||||
it("does not treat flattened archive records as post-migration sessions", async () => {
|
||||
it.skipIf(process.platform === "win32")("does not treat flattened archive records as post-migration sessions", async () => {
|
||||
mkdirSync(join(aoBaseDir, "aaaaaa000000-myproject.migrated", "sessions", "archive"), {
|
||||
recursive: true,
|
||||
});
|
||||
|
|
@ -1273,7 +1283,7 @@ describe("rollbackStorage", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("migration edge cases", () => {
|
||||
describe.skipIf(process.platform === "win32")("migration edge cases", () => {
|
||||
let testDir: string;
|
||||
let aoBaseDir: string;
|
||||
let configPath: string;
|
||||
|
|
@ -1335,9 +1345,13 @@ describe("migration edge cases", () => {
|
|||
);
|
||||
|
||||
vi.resetModules();
|
||||
vi.doMock("node:child_process", () => ({
|
||||
execSync: vi.fn(() => "be-1\n"),
|
||||
}));
|
||||
// Use importOriginal so platform.ts's top-level promisify(execFile) keeps
|
||||
// working when storage-v2 → atomic-write.ts pulls platform.ts into the
|
||||
// module graph. A bare-object mock would leave execFile undefined.
|
||||
vi.doMock("node:child_process", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof ChildProcess>();
|
||||
return { ...actual, execSync: vi.fn(() => "be-1\n") };
|
||||
});
|
||||
|
||||
const { migrateStorage: migrateStorageWithMock } = await import("../migration/storage-v2.js");
|
||||
|
||||
|
|
@ -1595,7 +1609,7 @@ describe("migration edge cases", () => {
|
|||
// The migrator must move ~/.claude/projects/<old-encoded>/ → <new-encoded>/
|
||||
// so chat history survives migration; otherwise the next ao start →
|
||||
// restore launches a fresh `claude` and the conversation is lost.
|
||||
it(
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"relinks ~/.claude/projects/<old-encoded>/ to <new-encoded>/ for migrated worktrees",
|
||||
async () => {
|
||||
const origHome = process.env["HOME"];
|
||||
|
|
@ -1723,7 +1737,7 @@ describe("migration edge cases", () => {
|
|||
// path no longer matches and `getRestoreCommand` returns null. The
|
||||
// migrator must rewrite that cwd in place so Codex restore continues to
|
||||
// find the prior thread.
|
||||
it(
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"rewrites ~/.codex/sessions/**/rollout-*.jsonl session_meta.cwd for migrated worktrees",
|
||||
async () => {
|
||||
const origHome = process.env["HOME"];
|
||||
|
|
|
|||
|
|
@ -44,6 +44,8 @@ describe("generateOrchestratorPrompt dist smoke test", () => {
|
|||
execFileSync(pnpmCommand, ["build"], {
|
||||
cwd: packageRoot,
|
||||
stdio: "pipe",
|
||||
// Node 18+ requires shell:true to spawn .cmd/.bat on Windows (CVE-2024-27980).
|
||||
shell: process.platform === "win32",
|
||||
});
|
||||
|
||||
const { generateOrchestratorPrompt } = await import(`${distModuleUrl}?t=${Date.now()}`);
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ describe("generateOrchestratorPrompt", () => {
|
|||
expect(prompt).toContain("do not edit repository files or implement fixes");
|
||||
});
|
||||
|
||||
it("mandates ao send and bans raw tmux access", async () => {
|
||||
it("mandates ao send and bans raw runtime access", async () => {
|
||||
const generateOrchestratorPrompt = await loadGenerateOrchestratorPrompt();
|
||||
const prompt = generateOrchestratorPrompt({
|
||||
config,
|
||||
|
|
@ -73,7 +73,11 @@ describe("generateOrchestratorPrompt", () => {
|
|||
});
|
||||
|
||||
expect(prompt).toContain("Always use `ao send`");
|
||||
expect(prompt).toContain("never use raw `tmux send-keys`");
|
||||
// Platform-neutral runtime warning — must call out both tmux (Unix) and the
|
||||
// Windows named-pipe path so the orchestrator agent doesn't try either.
|
||||
expect(prompt).toContain("never bypass it by writing to the runtime layer directly");
|
||||
expect(prompt).toContain("tmux send-keys");
|
||||
expect(prompt).toContain("named pipe");
|
||||
expect(prompt).toContain("ao send --no-wait");
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,639 @@
|
|||
/**
|
||||
* Extended tests for platform.ts covering mocked child_process paths.
|
||||
*
|
||||
* These complement platform.test.ts by mocking node:child_process so that
|
||||
* Windows-specific branches, error-handling paths, and env-var fallback
|
||||
* chains can be exercised on any OS.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import * as childProcess from "node:child_process";
|
||||
import * as os from "node:os";
|
||||
|
||||
vi.mock("node:child_process", () => ({
|
||||
execFile: vi.fn(),
|
||||
execFileSync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("node:os", () => ({
|
||||
homedir: vi.fn(() => "/mock/home"),
|
||||
userInfo: vi.fn(() => ({ username: "mockuser" })),
|
||||
}));
|
||||
|
||||
vi.mock("node:fs", () => ({
|
||||
existsSync: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
// Stable mock references — safe across the module cache lifetime.
|
||||
const mockExecFile = vi.mocked(childProcess.execFile);
|
||||
const mockExecFileSync = vi.mocked(childProcess.execFileSync);
|
||||
const mockHomedir = vi.mocked(os.homedir);
|
||||
const mockUserInfo = vi.mocked(os.userInfo);
|
||||
const fs = await import("node:fs");
|
||||
const mockExistsSync = vi.mocked(fs.existsSync);
|
||||
|
||||
const originalPlatform = process.platform;
|
||||
|
||||
// promisify passes callback as last arg: fn(cmd, args, callback)
|
||||
// Standard promisify resolves with the first non-error value, so we pass an
|
||||
// object { stdout, stderr } as that value so destructuring in platform.ts works.
|
||||
type ExecCb = (err: Error | null, result: { stdout: string; stderr: string }) => void;
|
||||
|
||||
// promisify(execFile) calls execFile with (cmd, args, callback) when no options
|
||||
// are passed, or (cmd, args, options, callback) when options like windowsHide are
|
||||
// supplied. Pick whichever trailing arg is a function so tests work either way.
|
||||
function pickCallback(args: unknown[]): ExecCb {
|
||||
for (let i = args.length - 1; i >= 0; i--) {
|
||||
if (typeof args[i] === "function") return args[i] as ExecCb;
|
||||
}
|
||||
throw new Error("execFile mock: no callback arg found");
|
||||
}
|
||||
|
||||
function resolveExecFile(stdout: string): void {
|
||||
mockExecFile.mockImplementationOnce((...args: unknown[]) => {
|
||||
pickCallback(args)(null, { stdout, stderr: "" });
|
||||
return {} as ReturnType<typeof childProcess.execFile>;
|
||||
});
|
||||
}
|
||||
|
||||
function rejectExecFile(err: Error): void {
|
||||
mockExecFile.mockImplementationOnce((...args: unknown[]) => {
|
||||
pickCallback(args)(err, { stdout: "", stderr: "" });
|
||||
return {} as ReturnType<typeof childProcess.execFile>;
|
||||
});
|
||||
}
|
||||
|
||||
function setPlatform(p: string): void {
|
||||
Object.defineProperty(process, "platform", { value: p, writable: true, configurable: true });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setPlatform(originalPlatform);
|
||||
// Default: absolute powershell path doesn't exist (let probe-based tests run).
|
||||
// Specific tests override this when exercising the absolute-path branch.
|
||||
mockExistsSync.mockReturnValue(false);
|
||||
// AO_SHELL short-circuits auto-detection; clear unless a test sets it.
|
||||
delete process.env["AO_SHELL"];
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
setPlatform(originalPlatform);
|
||||
const mod = await import("../platform.js");
|
||||
mod._resetShellCache();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// resolveWindowsShell (tested via getShell)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("resolveWindowsShell", () => {
|
||||
beforeEach(async () => {
|
||||
const mod = await import("../platform.js");
|
||||
mod._resetShellCache();
|
||||
});
|
||||
|
||||
it("falls back to powershell.exe via PATH probe when pwsh missing and absolute path missing", async () => {
|
||||
setPlatform("win32");
|
||||
const savedPath = process.env["PATH"];
|
||||
const savedPathExt = process.env["PATHEXT"];
|
||||
process.env["PATH"] = "C:\\fake\\bin";
|
||||
process.env["PATHEXT"] = ".EXE";
|
||||
|
||||
// pwsh missing on PATH; absolute powershell missing; powershell.exe present on PATH.
|
||||
mockExistsSync.mockImplementation((p: unknown) => {
|
||||
const path = String(p);
|
||||
if (path === "C:\\fake\\bin\\powershell.EXE") return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
try {
|
||||
const mod = await import("../platform.js");
|
||||
mod._resetShellCache();
|
||||
const shell = mod.getShell();
|
||||
|
||||
expect(shell.cmd).toBe("C:\\fake\\bin\\powershell.EXE");
|
||||
expect(shell.args("echo hi")).toEqual(["-Command", "echo hi"]);
|
||||
} finally {
|
||||
if (savedPath !== undefined) process.env["PATH"] = savedPath;
|
||||
else delete process.env["PATH"];
|
||||
if (savedPathExt !== undefined) process.env["PATHEXT"] = savedPathExt;
|
||||
else delete process.env["PATHEXT"];
|
||||
}
|
||||
});
|
||||
|
||||
it("uses absolute powershell.exe path when pwsh missing and absolute path exists", async () => {
|
||||
setPlatform("win32");
|
||||
const savedRoot = process.env["SystemRoot"];
|
||||
const savedPath = process.env["PATH"];
|
||||
const savedPathExt = process.env["PATHEXT"];
|
||||
process.env["SystemRoot"] = "C:\\Windows";
|
||||
process.env["PATH"] = "C:\\fake\\bin";
|
||||
process.env["PATHEXT"] = ".EXE";
|
||||
|
||||
// pwsh missing on PATH; absolute powershell exists.
|
||||
mockExistsSync.mockImplementation((p: unknown) => {
|
||||
return String(p) === "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe";
|
||||
});
|
||||
|
||||
try {
|
||||
const mod = await import("../platform.js");
|
||||
mod._resetShellCache();
|
||||
const shell = mod.getShell();
|
||||
|
||||
expect(shell.cmd).toBe("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe");
|
||||
expect(shell.args("echo hi")).toEqual(["-Command", "echo hi"]);
|
||||
} finally {
|
||||
if (savedRoot !== undefined) process.env["SystemRoot"] = savedRoot;
|
||||
else delete process.env["SystemRoot"];
|
||||
if (savedPath !== undefined) process.env["PATH"] = savedPath;
|
||||
else delete process.env["PATH"];
|
||||
if (savedPathExt !== undefined) process.env["PATHEXT"] = savedPathExt;
|
||||
else delete process.env["PATHEXT"];
|
||||
}
|
||||
});
|
||||
|
||||
it("honors AO_SHELL override before any auto-detection", async () => {
|
||||
setPlatform("win32");
|
||||
const saved = process.env["AO_SHELL"];
|
||||
process.env["AO_SHELL"] = "C:\\custom\\pwsh.exe";
|
||||
|
||||
try {
|
||||
const mod = await import("../platform.js");
|
||||
mod._resetShellCache();
|
||||
const shell = mod.getShell();
|
||||
expect(shell.cmd).toBe("C:\\custom\\pwsh.exe");
|
||||
expect(shell.args("echo hi")).toEqual(["-Command", "echo hi"]);
|
||||
// Auto-detection probes are skipped entirely
|
||||
expect(mockExecFileSync).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
if (saved !== undefined) process.env["AO_SHELL"] = saved;
|
||||
else delete process.env["AO_SHELL"];
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
["cmd.exe", ["/c", "echo hi"]],
|
||||
["C:\\Windows\\System32\\cmd.exe", ["/c", "echo hi"]],
|
||||
["bash", ["-c", "echo hi"]],
|
||||
["bash.exe", ["-c", "echo hi"]],
|
||||
["/usr/bin/sh", ["-c", "echo hi"]],
|
||||
["pwsh", ["-Command", "echo hi"]],
|
||||
["powershell.exe", ["-Command", "echo hi"]],
|
||||
])("AO_SHELL=%s infers args from basename", async (override, expectedArgs) => {
|
||||
setPlatform("win32");
|
||||
const saved = process.env["AO_SHELL"];
|
||||
process.env["AO_SHELL"] = override;
|
||||
|
||||
try {
|
||||
const mod = await import("../platform.js");
|
||||
mod._resetShellCache();
|
||||
const shell = mod.getShell();
|
||||
expect(shell.cmd).toBe(override);
|
||||
expect(shell.args("echo hi")).toEqual(expectedArgs);
|
||||
expect(mockExecFileSync).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
if (saved !== undefined) process.env["AO_SHELL"] = saved;
|
||||
else delete process.env["AO_SHELL"];
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to cmd.exe when both pwsh and powershell.exe are unavailable", async () => {
|
||||
setPlatform("win32");
|
||||
mockExecFileSync.mockImplementation(() => {
|
||||
throw new Error("not found");
|
||||
});
|
||||
|
||||
const mod = await import("../platform.js");
|
||||
mod._resetShellCache();
|
||||
const shell = mod.getShell();
|
||||
|
||||
expect(shell.cmd).toMatch(/cmd\.exe/i);
|
||||
expect(shell.args("echo hi")).toEqual(["/c", "echo hi"]);
|
||||
});
|
||||
|
||||
it("uses ComSpec env var for the cmd fallback when set", async () => {
|
||||
setPlatform("win32");
|
||||
mockExecFileSync.mockImplementation(() => {
|
||||
throw new Error("not found");
|
||||
});
|
||||
|
||||
const savedComSpec = process.env["ComSpec"];
|
||||
process.env["ComSpec"] = "C:\\Windows\\System32\\cmd.exe";
|
||||
|
||||
try {
|
||||
const mod = await import("../platform.js");
|
||||
mod._resetShellCache();
|
||||
const shell = mod.getShell();
|
||||
expect(shell.cmd).toBe("C:\\Windows\\System32\\cmd.exe");
|
||||
} finally {
|
||||
if (savedComSpec !== undefined) process.env["ComSpec"] = savedComSpec;
|
||||
else delete process.env["ComSpec"];
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// killProcessTree
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("killProcessTree", () => {
|
||||
it("calls taskkill with /T /F /PID for SIGTERM on Windows (always force-kill)", async () => {
|
||||
setPlatform("win32");
|
||||
resolveExecFile(""); // taskkill succeeds
|
||||
|
||||
const mod = await import("../platform.js");
|
||||
await mod.killProcessTree(1234, "SIGTERM");
|
||||
|
||||
// On Windows we always pass /F regardless of signal — taskkill without /F sends
|
||||
// WM_CLOSE which is unreliable for headless Node.js console processes.
|
||||
expect(mockExecFile).toHaveBeenCalledWith(
|
||||
"taskkill",
|
||||
["/T", "/F", "/PID", "1234"],
|
||||
expect.objectContaining({ windowsHide: true }),
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it("calls taskkill with /T /F /PID for SIGKILL on Windows", async () => {
|
||||
setPlatform("win32");
|
||||
resolveExecFile(""); // taskkill succeeds
|
||||
|
||||
const mod = await import("../platform.js");
|
||||
await mod.killProcessTree(1234, "SIGKILL");
|
||||
|
||||
expect(mockExecFile).toHaveBeenCalledWith(
|
||||
"taskkill",
|
||||
["/T", "/F", "/PID", "1234"],
|
||||
expect.objectContaining({ windowsHide: true }),
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not throw when taskkill fails (process already dead)", async () => {
|
||||
setPlatform("win32");
|
||||
rejectExecFile(new Error("process not found"));
|
||||
|
||||
const mod = await import("../platform.js");
|
||||
await expect(mod.killProcessTree(1234)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("sends signal to the process group (negative PID) on Unix", async () => {
|
||||
setPlatform("linux");
|
||||
const killSpy = vi.spyOn(process, "kill").mockReturnValue(true);
|
||||
|
||||
const mod = await import("../platform.js");
|
||||
await mod.killProcessTree(5678);
|
||||
|
||||
expect(killSpy).toHaveBeenCalledWith(-5678, "SIGTERM");
|
||||
killSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("passes SIGKILL through on Unix", async () => {
|
||||
setPlatform("linux");
|
||||
const killSpy = vi.spyOn(process, "kill").mockReturnValue(true);
|
||||
|
||||
const mod = await import("../platform.js");
|
||||
await mod.killProcessTree(5678, "SIGKILL");
|
||||
|
||||
expect(killSpy).toHaveBeenCalledWith(-5678, "SIGKILL");
|
||||
killSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("falls back to direct kill when process-group kill fails on Unix", async () => {
|
||||
setPlatform("linux");
|
||||
const killSpy = vi.spyOn(process, "kill").mockImplementation((pid) => {
|
||||
if ((pid as number) < 0) throw new Error("no such process group");
|
||||
return true;
|
||||
});
|
||||
|
||||
const mod = await import("../platform.js");
|
||||
await mod.killProcessTree(9999);
|
||||
|
||||
expect(killSpy).toHaveBeenCalledWith(-9999, "SIGTERM");
|
||||
expect(killSpy).toHaveBeenCalledWith(9999, "SIGTERM");
|
||||
killSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("does not throw when both Unix kills fail", async () => {
|
||||
setPlatform("linux");
|
||||
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => {
|
||||
throw new Error("no such process");
|
||||
});
|
||||
|
||||
const mod = await import("../platform.js");
|
||||
await expect(mod.killProcessTree(9999)).resolves.toBeUndefined();
|
||||
killSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("returns immediately without killing anything when pid is 0", async () => {
|
||||
setPlatform("linux");
|
||||
const killSpy = vi.spyOn(process, "kill").mockReturnValue(true);
|
||||
|
||||
const mod = await import("../platform.js");
|
||||
await mod.killProcessTree(0);
|
||||
|
||||
expect(killSpy).not.toHaveBeenCalled();
|
||||
expect(mockExecFile).not.toHaveBeenCalled();
|
||||
killSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("returns immediately without killing anything when pid is negative", async () => {
|
||||
setPlatform("linux");
|
||||
const killSpy = vi.spyOn(process, "kill").mockReturnValue(true);
|
||||
|
||||
const mod = await import("../platform.js");
|
||||
await mod.killProcessTree(-1);
|
||||
|
||||
expect(killSpy).not.toHaveBeenCalled();
|
||||
expect(mockExecFile).not.toHaveBeenCalled();
|
||||
killSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// findPidByPort
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("findPidByPort", () => {
|
||||
it("finds PID from netstat LISTENING line on Windows", async () => {
|
||||
setPlatform("win32");
|
||||
// netstat -ano output: local address is parts[1], PID is the last part
|
||||
const netstatOutput = [
|
||||
" Proto Local Address Foreign Address State PID",
|
||||
" TCP 0.0.0.0:3000 0.0.0.0:0 LISTENING 42",
|
||||
" TCP 0.0.0.0:8080 0.0.0.0:0 LISTENING 99",
|
||||
].join("\n");
|
||||
resolveExecFile(netstatOutput);
|
||||
|
||||
const mod = await import("../platform.js");
|
||||
const pid = await mod.findPidByPort(3000);
|
||||
|
||||
expect(pid).toBe("42");
|
||||
});
|
||||
|
||||
it("does not match port 300 when searching for port 3000 (negative lookahead)", async () => {
|
||||
setPlatform("win32");
|
||||
// Port 30000 should not match a search for port 3000
|
||||
const netstatOutput =
|
||||
" TCP 0.0.0.0:30000 0.0.0.0:0 LISTENING 77\n";
|
||||
resolveExecFile(netstatOutput);
|
||||
|
||||
const mod = await import("../platform.js");
|
||||
const pid = await mod.findPidByPort(3000);
|
||||
|
||||
expect(pid).toBeNull();
|
||||
});
|
||||
|
||||
it("finds PID from IPv6 LISTENING line on Windows", async () => {
|
||||
setPlatform("win32");
|
||||
// netstat -ano includes IPv6 entries like [::]:3000 when server binds to all interfaces
|
||||
const netstatOutput = [
|
||||
" Proto Local Address Foreign Address State PID",
|
||||
" TCP [::]:3000 [::]:0 LISTENING 55",
|
||||
].join("\n");
|
||||
resolveExecFile(netstatOutput);
|
||||
|
||||
const mod = await import("../platform.js");
|
||||
const pid = await mod.findPidByPort(3000);
|
||||
|
||||
expect(pid).toBe("55");
|
||||
});
|
||||
|
||||
it("returns null when no LISTENING line matches on Windows", async () => {
|
||||
setPlatform("win32");
|
||||
resolveExecFile(" TCP 0.0.0.0:8080 0.0.0.0:0 LISTENING 99\n");
|
||||
|
||||
const mod = await import("../platform.js");
|
||||
const pid = await mod.findPidByPort(3000);
|
||||
|
||||
expect(pid).toBeNull();
|
||||
});
|
||||
|
||||
it("finds PID from lsof output on Unix", async () => {
|
||||
setPlatform("linux");
|
||||
resolveExecFile("12345\n");
|
||||
|
||||
const mod = await import("../platform.js");
|
||||
const pid = await mod.findPidByPort(3000);
|
||||
|
||||
expect(pid).toBe("12345");
|
||||
expect(mockExecFile).toHaveBeenCalledWith(
|
||||
"lsof",
|
||||
["-ti", ":3000", "-sTCP:LISTEN"],
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null when lsof output is empty on Unix", async () => {
|
||||
setPlatform("linux");
|
||||
resolveExecFile("");
|
||||
|
||||
const mod = await import("../platform.js");
|
||||
const pid = await mod.findPidByPort(3000);
|
||||
|
||||
expect(pid).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when lsof output is not a valid PID on Unix", async () => {
|
||||
setPlatform("linux");
|
||||
resolveExecFile("not-a-pid\n");
|
||||
|
||||
const mod = await import("../platform.js");
|
||||
const pid = await mod.findPidByPort(3000);
|
||||
|
||||
expect(pid).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when execFile throws (command not found)", async () => {
|
||||
setPlatform("linux");
|
||||
rejectExecFile(new Error("command not found: lsof"));
|
||||
|
||||
const mod = await import("../platform.js");
|
||||
const pid = await mod.findPidByPort(3000);
|
||||
|
||||
expect(pid).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getEnvDefaults — fallback chains
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("getEnvDefaults", () => {
|
||||
describe("Windows fallbacks", () => {
|
||||
beforeEach(async () => {
|
||||
const mod = await import("../platform.js");
|
||||
mod._resetShellCache();
|
||||
// Default vi.fn() returns undefined (success) so pwsh is picked by getShell()
|
||||
});
|
||||
|
||||
it("uses TMP when TEMP is not set", async () => {
|
||||
setPlatform("win32");
|
||||
const savedTemp = process.env["TEMP"];
|
||||
const savedTmp = process.env["TMP"];
|
||||
delete process.env["TEMP"];
|
||||
process.env["TMP"] = "C:\\Temp";
|
||||
|
||||
try {
|
||||
const mod = await import("../platform.js");
|
||||
mod._resetShellCache();
|
||||
const env = mod.getEnvDefaults();
|
||||
expect(env.TMPDIR).toBe("C:\\Temp");
|
||||
} finally {
|
||||
if (savedTemp !== undefined) process.env["TEMP"] = savedTemp;
|
||||
else delete process.env["TEMP"];
|
||||
if (savedTmp !== undefined) process.env["TMP"] = savedTmp;
|
||||
else delete process.env["TMP"];
|
||||
}
|
||||
});
|
||||
|
||||
it("uses hardcoded fallback when neither TEMP nor TMP is set", async () => {
|
||||
setPlatform("win32");
|
||||
const savedTemp = process.env["TEMP"];
|
||||
const savedTmp = process.env["TMP"];
|
||||
delete process.env["TEMP"];
|
||||
delete process.env["TMP"];
|
||||
|
||||
try {
|
||||
const mod = await import("../platform.js");
|
||||
mod._resetShellCache();
|
||||
const env = mod.getEnvDefaults();
|
||||
expect(env.TMPDIR).toBe("C:\\Windows\\Temp");
|
||||
} finally {
|
||||
if (savedTemp !== undefined) process.env["TEMP"] = savedTemp;
|
||||
else delete process.env["TEMP"];
|
||||
if (savedTmp !== undefined) process.env["TMP"] = savedTmp;
|
||||
else delete process.env["TMP"];
|
||||
}
|
||||
});
|
||||
|
||||
it("uses homedir() when USERPROFILE is not set", async () => {
|
||||
setPlatform("win32");
|
||||
mockHomedir.mockReturnValue("C:\\Users\\fallback");
|
||||
const savedUserProfile = process.env["USERPROFILE"];
|
||||
delete process.env["USERPROFILE"];
|
||||
|
||||
try {
|
||||
const mod = await import("../platform.js");
|
||||
mod._resetShellCache();
|
||||
const env = mod.getEnvDefaults();
|
||||
expect(env.HOME).toBe("C:\\Users\\fallback");
|
||||
} finally {
|
||||
if (savedUserProfile !== undefined) process.env["USERPROFILE"] = savedUserProfile;
|
||||
else delete process.env["USERPROFILE"];
|
||||
}
|
||||
});
|
||||
|
||||
it("uses userInfo().username when USERNAME is not set", async () => {
|
||||
setPlatform("win32");
|
||||
mockUserInfo.mockReturnValue({ username: "winuser" } as ReturnType<typeof os.userInfo>);
|
||||
const savedUsername = process.env["USERNAME"];
|
||||
delete process.env["USERNAME"];
|
||||
|
||||
try {
|
||||
const mod = await import("../platform.js");
|
||||
mod._resetShellCache();
|
||||
const env = mod.getEnvDefaults();
|
||||
expect(env.USER).toBe("winuser");
|
||||
} finally {
|
||||
if (savedUsername !== undefined) process.env["USERNAME"] = savedUsername;
|
||||
else delete process.env["USERNAME"];
|
||||
}
|
||||
});
|
||||
|
||||
it("uses empty string when PATH is not set on Windows", async () => {
|
||||
setPlatform("win32");
|
||||
const savedPath = process.env["PATH"];
|
||||
delete process.env["PATH"];
|
||||
|
||||
try {
|
||||
const mod = await import("../platform.js");
|
||||
mod._resetShellCache();
|
||||
const env = mod.getEnvDefaults();
|
||||
expect(env.PATH).toBe("");
|
||||
} finally {
|
||||
if (savedPath !== undefined) process.env["PATH"] = savedPath;
|
||||
else delete process.env["PATH"];
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Unix fallbacks", () => {
|
||||
it("uses homedir() when HOME is not set", async () => {
|
||||
setPlatform("linux");
|
||||
mockHomedir.mockReturnValue("/home/fallback");
|
||||
const savedHome = process.env["HOME"];
|
||||
delete process.env["HOME"];
|
||||
|
||||
try {
|
||||
const mod = await import("../platform.js");
|
||||
const env = mod.getEnvDefaults();
|
||||
expect(env.HOME).toBe("/home/fallback");
|
||||
} finally {
|
||||
if (savedHome !== undefined) process.env["HOME"] = savedHome;
|
||||
else delete process.env["HOME"];
|
||||
}
|
||||
});
|
||||
|
||||
it("uses /bin/bash when SHELL is not set", async () => {
|
||||
setPlatform("linux");
|
||||
const savedShell = process.env["SHELL"];
|
||||
delete process.env["SHELL"];
|
||||
|
||||
try {
|
||||
const mod = await import("../platform.js");
|
||||
const env = mod.getEnvDefaults();
|
||||
expect(env.SHELL).toBe("/bin/bash");
|
||||
} finally {
|
||||
if (savedShell !== undefined) process.env["SHELL"] = savedShell;
|
||||
else delete process.env["SHELL"];
|
||||
}
|
||||
});
|
||||
|
||||
it("uses /tmp when TMPDIR is not set", async () => {
|
||||
setPlatform("linux");
|
||||
const savedTmpdir = process.env["TMPDIR"];
|
||||
delete process.env["TMPDIR"];
|
||||
|
||||
try {
|
||||
const mod = await import("../platform.js");
|
||||
const env = mod.getEnvDefaults();
|
||||
expect(env.TMPDIR).toBe("/tmp");
|
||||
} finally {
|
||||
if (savedTmpdir !== undefined) process.env["TMPDIR"] = savedTmpdir;
|
||||
else delete process.env["TMPDIR"];
|
||||
}
|
||||
});
|
||||
|
||||
it("uses default PATH when PATH is not set", async () => {
|
||||
setPlatform("linux");
|
||||
const savedPath = process.env["PATH"];
|
||||
delete process.env["PATH"];
|
||||
|
||||
try {
|
||||
const mod = await import("../platform.js");
|
||||
const env = mod.getEnvDefaults();
|
||||
expect(env.PATH).toBe("/usr/local/bin:/usr/bin:/bin");
|
||||
} finally {
|
||||
if (savedPath !== undefined) process.env["PATH"] = savedPath;
|
||||
else delete process.env["PATH"];
|
||||
}
|
||||
});
|
||||
|
||||
it("uses userInfo().username when USER is not set", async () => {
|
||||
setPlatform("linux");
|
||||
mockUserInfo.mockReturnValue({ username: "fallback_user" } as ReturnType<typeof os.userInfo>);
|
||||
const savedUser = process.env["USER"];
|
||||
delete process.env["USER"];
|
||||
|
||||
try {
|
||||
const mod = await import("../platform.js");
|
||||
const env = mod.getEnvDefaults();
|
||||
expect(env.USER).toBe("fallback_user");
|
||||
} finally {
|
||||
if (savedUser !== undefined) process.env["USER"] = savedUser;
|
||||
else delete process.env["USER"];
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
import { describe, it, expect, afterEach } from "vitest";
|
||||
|
||||
describe("platform adapter", () => {
|
||||
const originalPlatform = process.platform;
|
||||
|
||||
afterEach(async () => {
|
||||
Object.defineProperty(process, "platform", { value: originalPlatform });
|
||||
// Reset the shell cache after each test so platform changes take effect
|
||||
const mod = await import("../platform.js");
|
||||
mod._resetShellCache();
|
||||
});
|
||||
|
||||
function setPlatform(p: string) {
|
||||
Object.defineProperty(process, "platform", { value: p });
|
||||
}
|
||||
|
||||
describe("isWindows", () => {
|
||||
it("returns true on win32", async () => {
|
||||
setPlatform("win32");
|
||||
const mod = await import("../platform.js");
|
||||
mod._resetShellCache();
|
||||
expect(mod.isWindows()).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false on linux", async () => {
|
||||
setPlatform("linux");
|
||||
const mod = await import("../platform.js");
|
||||
mod._resetShellCache();
|
||||
expect(mod.isWindows()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDefaultRuntime", () => {
|
||||
it("returns 'process' on win32", async () => {
|
||||
setPlatform("win32");
|
||||
const mod = await import("../platform.js");
|
||||
expect(mod.getDefaultRuntime()).toBe("process");
|
||||
});
|
||||
|
||||
it("returns 'tmux' on linux", async () => {
|
||||
setPlatform("linux");
|
||||
const mod = await import("../platform.js");
|
||||
expect(mod.getDefaultRuntime()).toBe("tmux");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getShell", () => {
|
||||
it("always returns /bin/sh on unix (ignores $SHELL)", async () => {
|
||||
setPlatform("linux");
|
||||
const mod = await import("../platform.js");
|
||||
mod._resetShellCache();
|
||||
const shell = mod.getShell();
|
||||
// getShell() must always return /bin/sh on Unix regardless of $SHELL,
|
||||
// so that postCreate commands and runtime launches work correctly even
|
||||
// when the user's login shell is fish, nushell, or other non-POSIX shells.
|
||||
expect(shell.cmd).toBe("/bin/sh");
|
||||
expect(shell.args("echo hi")).toEqual(["-c", "echo hi"]);
|
||||
});
|
||||
|
||||
it("returns powershell or cmd on win32", async () => {
|
||||
setPlatform("win32");
|
||||
const mod = await import("../platform.js");
|
||||
mod._resetShellCache();
|
||||
const shell = mod.getShell();
|
||||
expect(shell.cmd).toMatch(/pwsh|powershell|cmd/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEnvDefaults", () => {
|
||||
it("returns Unix-style defaults on linux", async () => {
|
||||
setPlatform("linux");
|
||||
const mod = await import("../platform.js");
|
||||
const env = mod.getEnvDefaults();
|
||||
expect(env.TMPDIR).toBe(process.env.TMPDIR || "/tmp");
|
||||
});
|
||||
|
||||
it("returns Windows-style defaults on win32", async () => {
|
||||
setPlatform("win32");
|
||||
const mod = await import("../platform.js");
|
||||
const env = mod.getEnvDefaults();
|
||||
expect(env.TMPDIR).toBe(process.env.TEMP || process.env.TMP || "C:\\Windows\\Temp");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -34,6 +34,7 @@ vi.mock("node:child_process", () => {
|
|||
|
||||
import { createPluginRegistry } from "../plugin-registry.js";
|
||||
import { createSessionManager } from "../session-manager.js";
|
||||
import { closeDb } from "../events-db.js";
|
||||
import { createLifecycleManager } from "../lifecycle-manager.js";
|
||||
import { writeMetadata } from "../metadata.js";
|
||||
import { getProjectSessionsDir, getProjectDir } from "../paths.js";
|
||||
|
|
@ -60,6 +61,8 @@ let mockAgent: Agent;
|
|||
let mockWorkspace: Workspace;
|
||||
let config: OrchestratorConfig;
|
||||
let project: OrchestratorConfig["projects"][string];
|
||||
let previousHome: string | undefined;
|
||||
let previousUserProfile: string | undefined;
|
||||
|
||||
function mockGh(result: unknown): void {
|
||||
ghMock.mockResolvedValueOnce({ stdout: JSON.stringify(result) });
|
||||
|
|
@ -91,8 +94,6 @@ function makeSession(overrides: Partial<Session> = {}): Session {
|
|||
// Setup / teardown
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let previousHome: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
|
|
@ -107,7 +108,12 @@ beforeEach(() => {
|
|||
|
||||
mkdirSync(env.tmpDir, { recursive: true });
|
||||
previousHome = process.env["HOME"];
|
||||
// On Windows, Node's homedir() reads USERPROFILE — overriding HOME alone is
|
||||
// insufficient. We override both so AO base-dir resolution lands in tmpDir
|
||||
// regardless of platform.
|
||||
previousUserProfile = process.env["USERPROFILE"];
|
||||
process.env["HOME"] = env.tmpDir;
|
||||
process.env["USERPROFILE"] = env.tmpDir;
|
||||
env.configPath = join(env.tmpDir, "agent-orchestrator.yaml");
|
||||
writeFileSync(env.configPath, "projects: {}\n");
|
||||
|
||||
|
|
@ -156,11 +162,21 @@ beforeEach(() => {
|
|||
mkdirSync(env.sessionsDir, { recursive: true });
|
||||
|
||||
env.cleanup = () => {
|
||||
// closeDb releases the activity-events.db SQLite lock so Windows can
|
||||
// unlink it; rmSync's maxRetries alone doesn't break the lock.
|
||||
closeDb();
|
||||
// V2 storage: project files live under getProjectDir(projectId).
|
||||
// maxRetries/retryDelay handle Windows EBUSY (antivirus / search indexer
|
||||
// briefly holding files); they're no-ops on Unix.
|
||||
const projectDir = getProjectDir("my-app");
|
||||
if (existsSync(projectDir)) {
|
||||
rmSync(projectDir, { recursive: true, force: true });
|
||||
rmSync(projectDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
||||
}
|
||||
rmSync(env.tmpDir, { recursive: true, force: true });
|
||||
rmSync(env.tmpDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
||||
if (previousHome === undefined) delete process.env["HOME"];
|
||||
else process.env["HOME"] = previousHome;
|
||||
if (previousUserProfile === undefined) delete process.env["USERPROFILE"];
|
||||
else process.env["USERPROFILE"] = previousUserProfile;
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -364,7 +380,7 @@ describe("plugin integration", () => {
|
|||
expect(result.killed).toContain("app-1");
|
||||
// Verify the gh CLI was called with the right args
|
||||
expect(ghMock).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/(?:^|\/)?gh$/),
|
||||
expect.stringMatching(/(?:^|[\\/])gh(?:\.(?:exe|cmd|bat))?$/i),
|
||||
expect.arrayContaining(["issue", "view", "99", "--repo", "acme/app"]),
|
||||
expect.any(Object),
|
||||
);
|
||||
|
|
@ -451,7 +467,7 @@ describe("plugin integration", () => {
|
|||
expect(result.skipped).toContain("app-1");
|
||||
// Verify gh CLI was called for PR state check
|
||||
expect(ghMock).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/(?:^|\/)?gh$/),
|
||||
expect.stringMatching(/(?:^|[\\/])gh(?:\.(?:exe|cmd|bat))?$/i),
|
||||
expect.arrayContaining(["pr", "view", "42"]),
|
||||
expect.any(Object),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ describe("portfolio-projects", () => {
|
|||
let oldGlobalConfig: string | undefined;
|
||||
let oldConfigPath: string | undefined;
|
||||
let oldHome: string | undefined;
|
||||
let oldUserProfile: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tempRoot = join(tmpdir(), `ao-portfolio-projects-${Date.now()}-${Math.random().toString(16).slice(2)}`);
|
||||
|
|
@ -30,7 +31,9 @@ describe("portfolio-projects", () => {
|
|||
oldGlobalConfig = process.env["AO_GLOBAL_CONFIG"];
|
||||
oldConfigPath = process.env["AO_CONFIG_PATH"];
|
||||
oldHome = process.env["HOME"];
|
||||
oldUserProfile = process.env["USERPROFILE"];
|
||||
process.env["HOME"] = tempRoot;
|
||||
process.env["USERPROFILE"] = tempRoot;
|
||||
clearConfigCache();
|
||||
});
|
||||
|
||||
|
|
@ -41,8 +44,10 @@ describe("portfolio-projects", () => {
|
|||
else process.env["AO_CONFIG_PATH"] = oldConfigPath;
|
||||
if (oldHome === undefined) delete process.env["HOME"];
|
||||
else process.env["HOME"] = oldHome;
|
||||
if (oldUserProfile === undefined) delete process.env["USERPROFILE"];
|
||||
else process.env["USERPROFILE"] = oldUserProfile;
|
||||
clearConfigCache();
|
||||
rmSync(tempRoot, { recursive: true, force: true });
|
||||
rmSync(tempRoot, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
||||
});
|
||||
|
||||
it("resolves effective project config from the canonical global registry", () => {
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ describe("portfolio-registry", () => {
|
|||
let oldGlobalConfig: string | undefined;
|
||||
let oldConfigPath: string | undefined;
|
||||
let oldHome: string | undefined;
|
||||
let oldUserProfile: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tempRoot = join(tmpdir(), `ao-portfolio-${Date.now()}-${Math.random().toString(16).slice(2)}`);
|
||||
|
|
@ -29,7 +30,9 @@ describe("portfolio-registry", () => {
|
|||
oldGlobalConfig = process.env["AO_GLOBAL_CONFIG"];
|
||||
oldConfigPath = process.env["AO_CONFIG_PATH"];
|
||||
oldHome = process.env["HOME"];
|
||||
oldUserProfile = process.env["USERPROFILE"];
|
||||
process.env["HOME"] = tempRoot;
|
||||
process.env["USERPROFILE"] = tempRoot;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -39,7 +42,9 @@ describe("portfolio-registry", () => {
|
|||
else process.env["AO_CONFIG_PATH"] = oldConfigPath;
|
||||
if (oldHome === undefined) delete process.env["HOME"];
|
||||
else process.env["HOME"] = oldHome;
|
||||
rmSync(tempRoot, { recursive: true, force: true });
|
||||
if (oldUserProfile === undefined) delete process.env["USERPROFILE"];
|
||||
else process.env["USERPROFILE"] = oldUserProfile;
|
||||
rmSync(tempRoot, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
||||
});
|
||||
|
||||
it("reads projects from the canonical global config path instead of AO_CONFIG_PATH discovery", () => {
|
||||
|
|
|
|||
|
|
@ -15,18 +15,23 @@ function writeSessionFile(projectId: string, sessionId: string, data: Record<str
|
|||
describe("portfolio-session-service", () => {
|
||||
let tempRoot: string;
|
||||
let oldHome: string | undefined;
|
||||
let oldUserProfile: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tempRoot = join(tmpdir(), `ao-portfolio-sessions-${Date.now()}-${Math.random().toString(16).slice(2)}`);
|
||||
mkdirSync(tempRoot, { recursive: true });
|
||||
oldHome = process.env["HOME"];
|
||||
oldUserProfile = process.env["USERPROFILE"];
|
||||
process.env["HOME"] = tempRoot;
|
||||
process.env["USERPROFILE"] = tempRoot;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (oldHome === undefined) delete process.env["HOME"];
|
||||
else process.env["HOME"] = oldHome;
|
||||
rmSync(tempRoot, { recursive: true, force: true });
|
||||
if (oldUserProfile === undefined) delete process.env["USERPROFILE"];
|
||||
else process.env["USERPROFILE"] = oldUserProfile;
|
||||
rmSync(tempRoot, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
||||
});
|
||||
|
||||
it("lists worker sessions and excludes orchestrators, disabled projects, and degraded projects", async () => {
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ describe("project resolver", () => {
|
|||
let tempRoot: string;
|
||||
let configPath: string;
|
||||
let originalHome: string | undefined;
|
||||
let originalUserProfile: string | undefined;
|
||||
let originalGlobalConfig: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
|
|
@ -34,19 +35,22 @@ describe("project resolver", () => {
|
|||
configPath = join(tempRoot, ".agent-orchestrator", "config.yaml");
|
||||
mkdirSync(tempRoot, { recursive: true });
|
||||
originalHome = process.env["HOME"];
|
||||
originalUserProfile = process.env["USERPROFILE"];
|
||||
originalGlobalConfig = process.env["AO_GLOBAL_CONFIG"];
|
||||
process.env["HOME"] = tempRoot;
|
||||
process.env["USERPROFILE"] = tempRoot;
|
||||
process.env["AO_GLOBAL_CONFIG"] = configPath;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env["HOME"] = originalHome;
|
||||
process.env["USERPROFILE"] = originalUserProfile;
|
||||
if (originalGlobalConfig === undefined) {
|
||||
delete process.env["AO_GLOBAL_CONFIG"];
|
||||
} else {
|
||||
process.env["AO_GLOBAL_CONFIG"] = originalGlobalConfig;
|
||||
}
|
||||
rmSync(tempRoot, { recursive: true, force: true });
|
||||
rmSync(tempRoot, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
||||
});
|
||||
|
||||
it("resolves registry-only projects with shared defaults", () => {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
|
@ -17,6 +17,29 @@ import type { OrchestratorConfig, PluginRegistry, Runtime, Workspace } from "../
|
|||
|
||||
const PROJECT_ID = "app";
|
||||
|
||||
// Isolate tests from the real user home so parallel workers don't race on
|
||||
// ~/.agent-orchestrator/111111111111/sessions/.
|
||||
let fakeHome: string;
|
||||
let originalHome: string | undefined;
|
||||
let originalUserProfile: string | undefined;
|
||||
beforeAll(() => {
|
||||
fakeHome = join(tmpdir(), `ao-recovery-home-${randomUUID()}`);
|
||||
mkdirSync(fakeHome, { recursive: true });
|
||||
originalHome = process.env["HOME"];
|
||||
originalUserProfile = process.env["USERPROFILE"];
|
||||
process.env["HOME"] = fakeHome;
|
||||
process.env["USERPROFILE"] = fakeHome;
|
||||
});
|
||||
afterAll(() => {
|
||||
if (originalHome === undefined) delete process.env["HOME"];
|
||||
else process.env["HOME"] = originalHome;
|
||||
if (originalUserProfile === undefined) delete process.env["USERPROFILE"];
|
||||
else process.env["USERPROFILE"] = originalUserProfile;
|
||||
if (fakeHome && existsSync(fakeHome)) {
|
||||
rmSync(fakeHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
||||
}
|
||||
});
|
||||
|
||||
function makeConfig(rootDir: string): OrchestratorConfig {
|
||||
return {
|
||||
configPath: join(rootDir, "agent-orchestrator.yaml"),
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import type {
|
|||
Agent,
|
||||
} from "../../types.js";
|
||||
import { setupTestContext, teardownTestContext, makeHandle, type TestContext } from "../test-utils.js";
|
||||
import { installMockOpencode, installMockOpencodeSequence } from "./opencode-helpers.js";
|
||||
import { installMockOpencode, installMockOpencodeSequence, PATH_SEP } from "./opencode-helpers.js";
|
||||
|
||||
let ctx: TestContext;
|
||||
let tmpDir: string;
|
||||
|
|
@ -245,7 +245,7 @@ describe("send", () => {
|
|||
]),
|
||||
deleteLogPath,
|
||||
);
|
||||
process.env.PATH = `${mockBin}:${originalPath ?? ""}`;
|
||||
process.env.PATH = `${mockBin}${PATH_SEP}${originalPath ?? ""}`;
|
||||
|
||||
writeMetadata(sessionsDir, "app-1", {
|
||||
worktree: "/tmp",
|
||||
|
|
@ -279,7 +279,7 @@ describe("send", () => {
|
|||
]),
|
||||
deleteLogPath,
|
||||
);
|
||||
process.env.PATH = `${mockBin}:${originalPath ?? ""}`;
|
||||
process.env.PATH = `${mockBin}${PATH_SEP}${originalPath ?? ""}`;
|
||||
|
||||
writeMetadata(sessionsDir, "app-1", {
|
||||
worktree: "/tmp",
|
||||
|
|
@ -326,7 +326,7 @@ describe("send", () => {
|
|||
deleteLogPath,
|
||||
listLogPath,
|
||||
);
|
||||
process.env.PATH = `${mockBin}:${originalPath ?? ""}`;
|
||||
process.env.PATH = `${mockBin}${PATH_SEP}${originalPath ?? ""}`;
|
||||
|
||||
writeMetadata(sessionsDir, "app-1", {
|
||||
worktree: "/tmp",
|
||||
|
|
@ -372,7 +372,7 @@ describe("send", () => {
|
|||
deleteLogPath,
|
||||
listLogPath,
|
||||
);
|
||||
process.env.PATH = `${mockBin}:${originalPath ?? ""}`;
|
||||
process.env.PATH = `${mockBin}${PATH_SEP}${originalPath ?? ""}`;
|
||||
|
||||
writeMetadata(sessionsDir, "app-1", {
|
||||
worktree: "/tmp",
|
||||
|
|
@ -433,7 +433,7 @@ describe("remap", () => {
|
|||
]),
|
||||
deleteLogPath,
|
||||
);
|
||||
process.env.PATH = `${mockBin}:${originalPath ?? ""}`;
|
||||
process.env.PATH = `${mockBin}${PATH_SEP}${originalPath ?? ""}`;
|
||||
|
||||
writeMetadata(sessionsDir, "app-1", {
|
||||
worktree: "/tmp",
|
||||
|
|
@ -466,7 +466,7 @@ describe("remap", () => {
|
|||
deleteLogPath,
|
||||
3,
|
||||
);
|
||||
process.env.PATH = `${mockBin}:${originalPath ?? ""}`;
|
||||
process.env.PATH = `${mockBin}${PATH_SEP}${originalPath ?? ""}`;
|
||||
|
||||
writeMetadata(sessionsDir, "app-1", {
|
||||
worktree: "/tmp",
|
||||
|
|
@ -488,7 +488,7 @@ describe("remap", () => {
|
|||
it("throws when OpenCode session id mapping is missing", async () => {
|
||||
const deleteLogPath = join(tmpDir, "opencode-delete-missing-remap.log");
|
||||
const mockBin = installMockOpencode(tmpDir, "[]", deleteLogPath);
|
||||
process.env.PATH = `${mockBin}:${originalPath ?? ""}`;
|
||||
process.env.PATH = `${mockBin}${PATH_SEP}${originalPath ?? ""}`;
|
||||
|
||||
writeMetadata(sessionsDir, "app-1", {
|
||||
worktree: "/tmp",
|
||||
|
|
@ -515,7 +515,7 @@ describe("remap", () => {
|
|||
]),
|
||||
deleteLogPath,
|
||||
);
|
||||
process.env.PATH = `${mockBin}:${originalPath ?? ""}`;
|
||||
process.env.PATH = `${mockBin}${PATH_SEP}${originalPath ?? ""}`;
|
||||
|
||||
writeMetadata(sessionsDir, "app-1", {
|
||||
worktree: "/tmp",
|
||||
|
|
@ -546,7 +546,7 @@ describe("remap", () => {
|
|||
]),
|
||||
deleteLogPath,
|
||||
);
|
||||
process.env.PATH = `${mockBin}:${originalPath ?? ""}`;
|
||||
process.env.PATH = `${mockBin}${PATH_SEP}${originalPath ?? ""}`;
|
||||
|
||||
writeMetadata(sessionsDir, "app-1", {
|
||||
worktree: "/tmp",
|
||||
|
|
@ -578,7 +578,7 @@ describe("remap", () => {
|
|||
]),
|
||||
deleteLogPath,
|
||||
);
|
||||
process.env.PATH = `${mockBin}:${originalPath ?? ""}`;
|
||||
process.env.PATH = `${mockBin}${PATH_SEP}${originalPath ?? ""}`;
|
||||
|
||||
config.projects["my-app"] = {
|
||||
...config.projects["my-app"]!,
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ import type {
|
|||
SCM,
|
||||
} from "../../types.js";
|
||||
import { setupTestContext, teardownTestContext, makeHandle, type TestContext } from "../test-utils.js";
|
||||
import { installMockOpencode, installMockOpencodeWithNotFoundDelete } from "./opencode-helpers.js";
|
||||
import { installMockOpencode, installMockOpencodeWithNotFoundDelete, PATH_SEP } from "./opencode-helpers.js";
|
||||
|
||||
let ctx: TestContext;
|
||||
let tmpDir: string;
|
||||
|
|
@ -170,7 +170,7 @@ describe("kill", () => {
|
|||
it("does not purge mapped OpenCode session on default kill", async () => {
|
||||
const deleteLogPath = join(tmpDir, "opencode-delete-kill-default.log");
|
||||
const mockBin = installMockOpencode(tmpDir, "[]", deleteLogPath);
|
||||
process.env.PATH = `${mockBin}:${originalPath ?? ""}`;
|
||||
process.env.PATH = `${mockBin}${PATH_SEP}${originalPath ?? ""}`;
|
||||
|
||||
writeMetadata(sessionsDir, "app-1", {
|
||||
worktree: "/tmp/ws",
|
||||
|
|
@ -191,7 +191,7 @@ describe("kill", () => {
|
|||
it("purges mapped OpenCode session when requested", async () => {
|
||||
const deleteLogPath = join(tmpDir, "opencode-delete-kill-purge.log");
|
||||
const mockBin = installMockOpencode(tmpDir, "[]", deleteLogPath);
|
||||
process.env.PATH = `${mockBin}:${originalPath ?? ""}`;
|
||||
process.env.PATH = `${mockBin}${PATH_SEP}${originalPath ?? ""}`;
|
||||
|
||||
writeMetadata(sessionsDir, "app-1", {
|
||||
worktree: "/tmp/ws",
|
||||
|
|
@ -213,7 +213,7 @@ describe("kill", () => {
|
|||
it("skips purge when mapped OpenCode session id is invalid", async () => {
|
||||
const deleteLogPath = join(tmpDir, "opencode-delete-kill-invalid.log");
|
||||
const mockBin = installMockOpencode(tmpDir, "[]", deleteLogPath);
|
||||
process.env.PATH = `${mockBin}:${originalPath ?? ""}`;
|
||||
process.env.PATH = `${mockBin}${PATH_SEP}${originalPath ?? ""}`;
|
||||
|
||||
writeMetadata(sessionsDir, "app-1", {
|
||||
worktree: "/tmp/ws",
|
||||
|
|
@ -278,7 +278,7 @@ describe("cleanup", () => {
|
|||
it("deletes mapped OpenCode session during cleanup for closed PRs", async () => {
|
||||
const deleteLogPath = join(tmpDir, "opencode-delete.log");
|
||||
const mockBin = installMockOpencode(tmpDir, "[]", deleteLogPath);
|
||||
process.env.PATH = `${mockBin}:${originalPath ?? ""}`;
|
||||
process.env.PATH = `${mockBin}${PATH_SEP}${originalPath ?? ""}`;
|
||||
|
||||
const mockSCM: SCM = {
|
||||
name: "mock-scm",
|
||||
|
|
@ -326,7 +326,7 @@ describe("cleanup", () => {
|
|||
|
||||
it("treats missing mapped OpenCode session as already cleaned for closed PRs", async () => {
|
||||
const mockBin = installMockOpencodeWithNotFoundDelete(tmpDir, "[]");
|
||||
process.env.PATH = `${mockBin}:${originalPath ?? ""}`;
|
||||
process.env.PATH = `${mockBin}${PATH_SEP}${originalPath ?? ""}`;
|
||||
|
||||
const mockSCM: SCM = {
|
||||
name: "mock-scm",
|
||||
|
|
@ -374,7 +374,7 @@ describe("cleanup", () => {
|
|||
it("deletes mapped OpenCode session from terminated killed sessions", async () => {
|
||||
const deleteLogPath = join(tmpDir, "opencode-delete-archived.log");
|
||||
const mockBin = installMockOpencode(tmpDir, "[]", deleteLogPath);
|
||||
process.env.PATH = `${mockBin}:${originalPath ?? ""}`;
|
||||
process.env.PATH = `${mockBin}${PATH_SEP}${originalPath ?? ""}`;
|
||||
|
||||
writeMetadata(sessionsDir, "app-6", {
|
||||
worktree: "/tmp",
|
||||
|
|
@ -400,7 +400,7 @@ describe("cleanup", () => {
|
|||
it("does not skip terminated cleanup for matching session IDs in other projects", async () => {
|
||||
const deleteLogPath = join(tmpDir, "opencode-delete-archived-cross-project.log");
|
||||
const mockBin = installMockOpencode(tmpDir, "[]", deleteLogPath);
|
||||
process.env.PATH = `${mockBin}:${originalPath ?? ""}`;
|
||||
process.env.PATH = `${mockBin}${PATH_SEP}${originalPath ?? ""}`;
|
||||
|
||||
const project2Path = join(tmpDir, "my-app-2");
|
||||
const configWithSecondProject: OrchestratorConfig = {
|
||||
|
|
@ -454,7 +454,7 @@ describe("cleanup", () => {
|
|||
it("skips invalid terminated OpenCode session ids during cleanup", async () => {
|
||||
const deleteLogPath = join(tmpDir, "opencode-delete-archived-invalid.log");
|
||||
const mockBin = installMockOpencode(tmpDir, "[]", deleteLogPath);
|
||||
process.env.PATH = `${mockBin}:${originalPath ?? ""}`;
|
||||
process.env.PATH = `${mockBin}${PATH_SEP}${originalPath ?? ""}`;
|
||||
|
||||
writeMetadata(sessionsDir, "app-8", {
|
||||
worktree: "/tmp",
|
||||
|
|
@ -481,7 +481,7 @@ describe("cleanup", () => {
|
|||
it("does not delete terminated OpenCode sessions in cleanup dry-run", async () => {
|
||||
const deleteLogPath = join(tmpDir, "opencode-delete-archived-dry-run.log");
|
||||
const mockBin = installMockOpencode(tmpDir, "[]", deleteLogPath);
|
||||
process.env.PATH = `${mockBin}:${originalPath ?? ""}`;
|
||||
process.env.PATH = `${mockBin}${PATH_SEP}${originalPath ?? ""}`;
|
||||
|
||||
writeMetadata(sessionsDir, "app-7", {
|
||||
worktree: "/tmp",
|
||||
|
|
@ -585,7 +585,7 @@ describe("cleanup", () => {
|
|||
it("never cleans the canonical orchestrator session even with stale worker-like metadata", async () => {
|
||||
const deleteLogPath = join(tmpDir, "opencode-delete-orchestrator.log");
|
||||
const mockBin = installMockOpencode(tmpDir, "[]", deleteLogPath);
|
||||
process.env.PATH = `${mockBin}:${originalPath ?? ""}`;
|
||||
process.env.PATH = `${mockBin}${PATH_SEP}${originalPath ?? ""}`;
|
||||
|
||||
const deadRuntime: Runtime = {
|
||||
...mockRuntime,
|
||||
|
|
@ -654,7 +654,7 @@ describe("cleanup", () => {
|
|||
it("never cleans terminated orchestrator mappings even when metadata looks stale", async () => {
|
||||
const deleteLogPath = join(tmpDir, "opencode-delete-archived-orchestrator.log");
|
||||
const mockBin = installMockOpencode(tmpDir, "[]", deleteLogPath);
|
||||
process.env.PATH = `${mockBin}:${originalPath ?? ""}`;
|
||||
process.env.PATH = `${mockBin}${PATH_SEP}${originalPath ?? ""}`;
|
||||
|
||||
writeMetadata(sessionsDir, "app-orchestrator", {
|
||||
worktree: "/tmp",
|
||||
|
|
|
|||
|
|
@ -6,6 +6,9 @@ import {
|
|||
import { join } from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
const isWin = process.platform === "win32";
|
||||
export const PATH_SEP = isWin ? ";" : ":";
|
||||
|
||||
export function installMockOpencode(
|
||||
tmpDir: string,
|
||||
sessionListJson: string,
|
||||
|
|
@ -15,28 +18,53 @@ export function installMockOpencode(
|
|||
): string {
|
||||
const binDir = join(tmpDir, "mock-bin");
|
||||
mkdirSync(binDir, { recursive: true });
|
||||
const scriptPath = join(binDir, "opencode");
|
||||
writeFileSync(
|
||||
scriptPath,
|
||||
[
|
||||
"#!/usr/bin/env bash",
|
||||
"set -euo pipefail",
|
||||
'if [[ "$1" == "session" && "$2" == "list" ]]; then',
|
||||
listLogPath ? ` printf '%s\n' "$*" >> '${listLogPath.replace(/'/g, "'\\''")}'` : "",
|
||||
listDelaySeconds > 0 ? ` sleep ${listDelaySeconds}` : "",
|
||||
` printf '%s\n' '${sessionListJson.replace(/'/g, "'\\''")}'`,
|
||||
" exit 0",
|
||||
"fi",
|
||||
'if [[ "$1" == "session" && "$2" == "delete" ]]; then',
|
||||
` printf '%s\n' "$*" >> '${deleteLogPath.replace(/'/g, "'\\''")}'`,
|
||||
" exit 0",
|
||||
"fi",
|
||||
"exit 1",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf-8",
|
||||
);
|
||||
chmodSync(scriptPath, 0o755);
|
||||
|
||||
if (isWin) {
|
||||
const jsPath = join(binDir, "opencode.js");
|
||||
writeFileSync(
|
||||
jsPath,
|
||||
`const fs = require("fs");
|
||||
const args = process.argv.slice(2);
|
||||
if (args[0] === "session" && args[1] === "list") {
|
||||
${listLogPath ? `fs.appendFileSync(${JSON.stringify(listLogPath)}, args.join(" ") + "\\n");` : ""}
|
||||
${listDelaySeconds > 0 ? `const end = Date.now() + ${listDelaySeconds * 1000}; while (Date.now() < end) {}` : ""}
|
||||
process.stdout.write(${JSON.stringify(sessionListJson)} + "\\n");
|
||||
process.exit(0);
|
||||
}
|
||||
if (args[0] === "session" && args[1] === "delete") {
|
||||
fs.appendFileSync(${JSON.stringify(deleteLogPath)}, args.join(" ") + "\\n");
|
||||
process.exit(0);
|
||||
}
|
||||
process.exit(1);
|
||||
`,
|
||||
"utf-8",
|
||||
);
|
||||
const cmdPath = join(binDir, "opencode.cmd");
|
||||
writeFileSync(cmdPath, `@node "%~dp0opencode.js" %*\r\n`, "utf-8");
|
||||
} else {
|
||||
const scriptPath = join(binDir, "opencode");
|
||||
writeFileSync(
|
||||
scriptPath,
|
||||
[
|
||||
"#!/usr/bin/env bash",
|
||||
"set -euo pipefail",
|
||||
'if [[ "$1" == "session" && "$2" == "list" ]]; then',
|
||||
listLogPath ? ` printf '%s\n' "$*" >> '${listLogPath.replace(/'/g, "'\\''")}'` : "",
|
||||
listDelaySeconds > 0 ? ` sleep ${listDelaySeconds}` : "",
|
||||
` printf '%s\n' '${sessionListJson.replace(/'/g, "'\\''")}'`,
|
||||
" exit 0",
|
||||
"fi",
|
||||
'if [[ "$1" == "session" && "$2" == "delete" ]]; then',
|
||||
` printf '%s\n' "$*" >> '${deleteLogPath.replace(/'/g, "'\\''")}'`,
|
||||
" exit 0",
|
||||
"fi",
|
||||
"exit 1",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf-8",
|
||||
);
|
||||
chmodSync(scriptPath, 0o755);
|
||||
}
|
||||
return binDir;
|
||||
}
|
||||
|
||||
|
|
@ -48,45 +76,76 @@ export function installMockOpencodeSequence(
|
|||
): string {
|
||||
const binDir = join(tmpDir, "mock-bin-sequence");
|
||||
mkdirSync(binDir, { recursive: true });
|
||||
const scriptPath = join(binDir, "opencode");
|
||||
const sequencePath = join(tmpDir, `opencode-sequence-${randomUUID()}.txt`);
|
||||
writeFileSync(sequencePath, "0\n", "utf-8");
|
||||
|
||||
const cases = sessionListJsons
|
||||
.map((entry, index) => {
|
||||
const escaped = entry.replace(/'/g, "'\\''");
|
||||
return `if [[ "$idx" == "${index}" ]]; then printf '%s\\n' '${escaped}'; exit 0; fi`;
|
||||
})
|
||||
.join("\n");
|
||||
const final = sessionListJsons.at(-1)?.replace(/'/g, "'\\''") ?? "[]";
|
||||
if (isWin) {
|
||||
const jsPath = join(binDir, "opencode.js");
|
||||
const jsonEntries = sessionListJsons.map((j) => JSON.stringify(j)).join(", ");
|
||||
const finalJson = JSON.stringify(sessionListJsons.at(-1) ?? "[]");
|
||||
writeFileSync(
|
||||
jsPath,
|
||||
`const fs = require("fs");
|
||||
const args = process.argv.slice(2);
|
||||
const sequencePath = ${JSON.stringify(sequencePath)};
|
||||
if (args[0] === "session" && args[1] === "list") {
|
||||
${listLogPath ? `fs.appendFileSync(${JSON.stringify(listLogPath)}, args.join(" ") + "\\n");` : ""}
|
||||
const idx = parseInt(fs.readFileSync(sequencePath, "utf-8").trim(), 10);
|
||||
fs.writeFileSync(sequencePath, String(idx + 1) + "\\n");
|
||||
const entries = [${jsonEntries}];
|
||||
const result = idx < entries.length ? entries[idx] : ${finalJson};
|
||||
process.stdout.write(result + "\\n");
|
||||
process.exit(0);
|
||||
}
|
||||
if (args[0] === "session" && args[1] === "delete") {
|
||||
fs.appendFileSync(${JSON.stringify(deleteLogPath)}, args.join(" ") + "\\n");
|
||||
process.exit(0);
|
||||
}
|
||||
process.exit(1);
|
||||
`,
|
||||
"utf-8",
|
||||
);
|
||||
const cmdPath = join(binDir, "opencode.cmd");
|
||||
writeFileSync(cmdPath, `@node "%~dp0opencode.js" %*\r\n`, "utf-8");
|
||||
} else {
|
||||
const scriptPath = join(binDir, "opencode");
|
||||
|
||||
writeFileSync(
|
||||
scriptPath,
|
||||
[
|
||||
"#!/usr/bin/env bash",
|
||||
"set -euo pipefail",
|
||||
'if [[ "$1" == "session" && "$2" == "list" ]]; then',
|
||||
listLogPath ? ` printf '%s\n' "$*" >> '${listLogPath.replace(/'/g, "'\\''")}'` : "",
|
||||
` seq_file='${sequencePath.replace(/'/g, "'\\''")}'`,
|
||||
' idx=$(cat "$seq_file")',
|
||||
" next=$((idx + 1))",
|
||||
' printf "%s\n" "$next" > "$seq_file"',
|
||||
` ${cases}`,
|
||||
` printf '%s\\n' '${final}'`,
|
||||
" exit 0",
|
||||
"fi",
|
||||
'if [[ "$1" == "session" && "$2" == "delete" ]]; then',
|
||||
` printf '%s\n' "$*" >> '${deleteLogPath.replace(/'/g, "'\\''")}'`,
|
||||
" exit 0",
|
||||
"fi",
|
||||
"exit 1",
|
||||
"",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n"),
|
||||
"utf-8",
|
||||
);
|
||||
chmodSync(scriptPath, 0o755);
|
||||
const cases = sessionListJsons
|
||||
.map((entry, index) => {
|
||||
const escaped = entry.replace(/'/g, "'\\''");
|
||||
return `if [[ "$idx" == "${index}" ]]; then printf '%s\\n' '${escaped}'; exit 0; fi`;
|
||||
})
|
||||
.join("\n");
|
||||
const final = sessionListJsons.at(-1)?.replace(/'/g, "'\\''") ?? "[]";
|
||||
|
||||
writeFileSync(
|
||||
scriptPath,
|
||||
[
|
||||
"#!/usr/bin/env bash",
|
||||
"set -euo pipefail",
|
||||
'if [[ "$1" == "session" && "$2" == "list" ]]; then',
|
||||
listLogPath ? ` printf '%s\n' "$*" >> '${listLogPath.replace(/'/g, "'\\''")}'` : "",
|
||||
` seq_file='${sequencePath.replace(/'/g, "'\\''")}'`,
|
||||
' idx=$(cat "$seq_file")',
|
||||
" next=$((idx + 1))",
|
||||
' printf "%s\n" "$next" > "$seq_file"',
|
||||
` ${cases}`,
|
||||
` printf '%s\\n' '${final}'`,
|
||||
" exit 0",
|
||||
"fi",
|
||||
'if [[ "$1" == "session" && "$2" == "delete" ]]; then',
|
||||
` printf '%s\n' "$*" >> '${deleteLogPath.replace(/'/g, "'\\''")}'`,
|
||||
" exit 0",
|
||||
"fi",
|
||||
"exit 1",
|
||||
"",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n"),
|
||||
"utf-8",
|
||||
);
|
||||
chmodSync(scriptPath, 0o755);
|
||||
}
|
||||
return binDir;
|
||||
}
|
||||
|
||||
|
|
@ -96,26 +155,49 @@ export function installMockOpencodeWithNotFoundDelete(
|
|||
): string {
|
||||
const binDir = join(tmpDir, "mock-bin-not-found");
|
||||
mkdirSync(binDir, { recursive: true });
|
||||
const scriptPath = join(binDir, "opencode");
|
||||
writeFileSync(
|
||||
scriptPath,
|
||||
[
|
||||
"#!/usr/bin/env bash",
|
||||
"set -euo pipefail",
|
||||
'if [[ "$1" == "session" && "$2" == "list" ]]; then',
|
||||
` printf '%s\n' '${sessionListJson.replace(/'/g, "'\\''")}'`,
|
||||
" exit 0",
|
||||
"fi",
|
||||
'if [[ "$1" == "session" && "$2" == "delete" ]]; then',
|
||||
' printf "Error: Session not found: %s\\n" "$3" >&2',
|
||||
" exit 1",
|
||||
"fi",
|
||||
"exit 1",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf-8",
|
||||
);
|
||||
chmodSync(scriptPath, 0o755);
|
||||
|
||||
if (isWin) {
|
||||
const jsPath = join(binDir, "opencode.js");
|
||||
writeFileSync(
|
||||
jsPath,
|
||||
`const fs = require("fs");
|
||||
const args = process.argv.slice(2);
|
||||
if (args[0] === "session" && args[1] === "list") {
|
||||
process.stdout.write(${JSON.stringify(sessionListJson)} + "\\n");
|
||||
process.exit(0);
|
||||
}
|
||||
if (args[0] === "session" && args[1] === "delete") {
|
||||
process.stderr.write("Error: Session not found: " + args[2] + "\\n");
|
||||
process.exit(1);
|
||||
}
|
||||
process.exit(1);
|
||||
`,
|
||||
"utf-8",
|
||||
);
|
||||
const cmdPath = join(binDir, "opencode.cmd");
|
||||
writeFileSync(cmdPath, `@node "%~dp0opencode.js" %*\r\n`, "utf-8");
|
||||
} else {
|
||||
const scriptPath = join(binDir, "opencode");
|
||||
writeFileSync(
|
||||
scriptPath,
|
||||
[
|
||||
"#!/usr/bin/env bash",
|
||||
"set -euo pipefail",
|
||||
'if [[ "$1" == "session" && "$2" == "list" ]]; then',
|
||||
` printf '%s\n' '${sessionListJson.replace(/'/g, "'\\''")}'`,
|
||||
" exit 0",
|
||||
"fi",
|
||||
'if [[ "$1" == "session" && "$2" == "delete" ]]; then',
|
||||
' printf "Error: Session not found: %s\\n" "$3" >&2',
|
||||
" exit 1",
|
||||
"fi",
|
||||
"exit 1",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf-8",
|
||||
);
|
||||
chmodSync(scriptPath, 0o755);
|
||||
}
|
||||
return binDir;
|
||||
}
|
||||
|
||||
|
|
@ -125,25 +207,46 @@ export function installMockGit(
|
|||
): string {
|
||||
const binDir = join(tmpDir, "mock-git-bin");
|
||||
mkdirSync(binDir, { recursive: true });
|
||||
const scriptPath = join(binDir, "git");
|
||||
const refs = remoteBranches
|
||||
.map((branch) => `deadbeef\trefs/heads/${branch}`)
|
||||
.join("\\n")
|
||||
.replace(/'/g, "'\\''");
|
||||
writeFileSync(
|
||||
scriptPath,
|
||||
[
|
||||
"#!/usr/bin/env bash",
|
||||
"set -euo pipefail",
|
||||
'if [[ "$1" == "ls-remote" && "$2" == "--heads" && "$3" == "origin" ]]; then',
|
||||
` printf '%b\\n' '${refs}'`,
|
||||
" exit 0",
|
||||
"fi",
|
||||
"exit 1",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf-8",
|
||||
);
|
||||
chmodSync(scriptPath, 0o755);
|
||||
|
||||
if (isWin) {
|
||||
const refs = remoteBranches
|
||||
.map((branch) => `deadbeef\trefs/heads/${branch}`)
|
||||
.join("\n");
|
||||
const jsPath = join(binDir, "git.js");
|
||||
writeFileSync(
|
||||
jsPath,
|
||||
`const args = process.argv.slice(2);
|
||||
if (args[0] === "ls-remote" && args[1] === "--heads" && args[2] === "origin") {
|
||||
process.stdout.write(${JSON.stringify(refs)} + "\\n");
|
||||
process.exit(0);
|
||||
}
|
||||
process.exit(1);
|
||||
`,
|
||||
"utf-8",
|
||||
);
|
||||
const cmdPath = join(binDir, "git.cmd");
|
||||
writeFileSync(cmdPath, `@node "%~dp0git.js" %*\r\n`, "utf-8");
|
||||
} else {
|
||||
const scriptPath = join(binDir, "git");
|
||||
const refs = remoteBranches
|
||||
.map((branch) => `deadbeef\trefs/heads/${branch}`)
|
||||
.join("\\n")
|
||||
.replace(/'/g, "'\\''");
|
||||
writeFileSync(
|
||||
scriptPath,
|
||||
[
|
||||
"#!/usr/bin/env bash",
|
||||
"set -euo pipefail",
|
||||
'if [[ "$1" == "ls-remote" && "$2" == "--heads" && "$3" == "origin" ]]; then',
|
||||
` printf '%b\\n' '${refs}'`,
|
||||
" exit 0",
|
||||
"fi",
|
||||
"exit 1",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf-8",
|
||||
);
|
||||
chmodSync(scriptPath, 0o755);
|
||||
}
|
||||
return binDir;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import type {
|
|||
Session,
|
||||
} from "../../types.js";
|
||||
import { setupTestContext, teardownTestContext, makeHandle, type TestContext } from "../test-utils.js";
|
||||
import { installMockOpencode } from "./opencode-helpers.js";
|
||||
import { installMockOpencode, PATH_SEP } from "./opencode-helpers.js";
|
||||
|
||||
let ctx: TestContext;
|
||||
let tmpDir: string;
|
||||
|
|
@ -578,7 +578,7 @@ describe("get", () => {
|
|||
]),
|
||||
deleteLogPath,
|
||||
);
|
||||
process.env.PATH = `${mockBin}:${originalPath ?? ""}`;
|
||||
process.env.PATH = `${mockBin}${PATH_SEP}${originalPath ?? ""}`;
|
||||
|
||||
writeMetadata(sessionsDir, "app-1", {
|
||||
worktree: "/tmp",
|
||||
|
|
@ -612,7 +612,7 @@ describe("get", () => {
|
|||
0,
|
||||
listLogPath,
|
||||
);
|
||||
process.env.PATH = `${mockBin}:${originalPath ?? ""}`;
|
||||
process.env.PATH = `${mockBin}${PATH_SEP}${originalPath ?? ""}`;
|
||||
|
||||
writeMetadata(sessionsDir, "app-1", {
|
||||
worktree: "/tmp",
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ import {
|
|||
type Workspace,
|
||||
} from "../../types.js";
|
||||
import { setupTestContext, teardownTestContext, makeHandle, type TestContext } from "../test-utils.js";
|
||||
import { installMockOpencode } from "./opencode-helpers.js";
|
||||
import { installMockOpencode, PATH_SEP } from "./opencode-helpers.js";
|
||||
|
||||
let ctx: TestContext;
|
||||
let tmpDir: string;
|
||||
|
|
@ -337,7 +337,7 @@ describe("restore", () => {
|
|||
mkdirSync(wsPath, { recursive: true });
|
||||
const deleteLogPath = join(tmpDir, "opencode-restore-validation.log");
|
||||
const mockBin = installMockOpencode(tmpDir, "[]", deleteLogPath);
|
||||
process.env.PATH = `${mockBin}:${originalPath ?? ""}`;
|
||||
process.env.PATH = `${mockBin}${PATH_SEP}${originalPath ?? ""}`;
|
||||
|
||||
writeMetadata(sessionsDir, "app-1", {
|
||||
worktree: wsPath,
|
||||
|
|
@ -386,7 +386,7 @@ describe("restore", () => {
|
|||
]),
|
||||
deleteLogPath,
|
||||
);
|
||||
process.env.PATH = `${mockBin}:${originalPath ?? ""}`;
|
||||
process.env.PATH = `${mockBin}${PATH_SEP}${originalPath ?? ""}`;
|
||||
|
||||
writeMetadata(sessionsDir, "app-1", {
|
||||
worktree: wsPath,
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ import {
|
|||
makeHandle,
|
||||
type TestContext,
|
||||
} from "../test-utils.js";
|
||||
import { installMockOpencode, installMockGit } from "./opencode-helpers.js";
|
||||
import { installMockOpencode, installMockGit, PATH_SEP } from "./opencode-helpers.js";
|
||||
|
||||
let ctx: TestContext;
|
||||
let tmpDir: string;
|
||||
|
|
@ -73,7 +73,7 @@ describe("spawn", () => {
|
|||
expect(mockWorkspace.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
projectId: "my-app",
|
||||
worktreeDir: expect.stringContaining("projects/my-app/worktrees"),
|
||||
worktreeDir: expect.stringMatching(/projects[\\/]my-app[\\/]worktrees/),
|
||||
}),
|
||||
);
|
||||
// Verify agent launch command was requested
|
||||
|
|
@ -181,7 +181,9 @@ describe("spawn", () => {
|
|||
|
||||
const call = (mockRuntime.create as ReturnType<typeof vi.fn>).mock.calls[0]?.[0];
|
||||
expect(call?.environment?.PATH).not.toBe("/should/not/win");
|
||||
expect(call?.environment?.PATH).toContain(".ao/bin");
|
||||
// Use platform-aware path so the assertion works on both POSIX and Windows
|
||||
// (where buildAgentPath joins with backslashes via path.join).
|
||||
expect(call?.environment?.PATH).toContain(join(".ao", "bin"));
|
||||
expect(call?.environment?.GH_PATH).not.toBe("/should/not/win");
|
||||
expect(call?.environment?.GH_PATH).toBe("/usr/local/bin/gh");
|
||||
});
|
||||
|
|
@ -429,7 +431,7 @@ describe("spawn", () => {
|
|||
|
||||
it("skips remote session branches when allocating a fresh session id", async () => {
|
||||
const mockGitBin = installMockGit(tmpDir, ["session/app-22"]);
|
||||
process.env.PATH = `${mockGitBin}:${originalPath ?? ""}`;
|
||||
process.env.PATH = `${mockGitBin}${PATH_SEP}${originalPath ?? ""}`;
|
||||
mkdirSync(config.projects["my-app"]!.path, { recursive: true });
|
||||
|
||||
const sm = createSessionManager({ config, registry: mockRegistry });
|
||||
|
|
@ -624,7 +626,7 @@ describe("spawn", () => {
|
|||
it("deletes old issue mappings and starts fresh when opencodeIssueSessionStrategy is delete", async () => {
|
||||
const deleteLogPath = join(tmpDir, "opencode-delete-issue.log");
|
||||
const mockBin = installMockOpencode(tmpDir, "[]", deleteLogPath);
|
||||
process.env.PATH = `${mockBin}:${originalPath ?? ""}`;
|
||||
process.env.PATH = `${mockBin}${PATH_SEP}${originalPath ?? ""}`;
|
||||
|
||||
const opencodeAgent: Agent = {
|
||||
...mockAgent,
|
||||
|
|
@ -1798,7 +1800,7 @@ describe("spawn", () => {
|
|||
]),
|
||||
deleteLogPath,
|
||||
);
|
||||
process.env.PATH = `${mockBin}:${originalPath ?? ""}`;
|
||||
process.env.PATH = `${mockBin}${PATH_SEP}${originalPath ?? ""}`;
|
||||
|
||||
const opencodeAgent: Agent = {
|
||||
...mockAgent,
|
||||
|
|
@ -1861,7 +1863,7 @@ describe("spawn", () => {
|
|||
]),
|
||||
deleteLogPath,
|
||||
);
|
||||
process.env.PATH = `${mockBin}:${originalPath ?? ""}`;
|
||||
process.env.PATH = `${mockBin}${PATH_SEP}${originalPath ?? ""}`;
|
||||
|
||||
const opencodeAgent: Agent = {
|
||||
...mockAgent,
|
||||
|
|
@ -1910,7 +1912,7 @@ describe("spawn", () => {
|
|||
]),
|
||||
deleteLogPath,
|
||||
);
|
||||
process.env.PATH = `${mockBin}:${originalPath ?? ""}`;
|
||||
process.env.PATH = `${mockBin}${PATH_SEP}${originalPath ?? ""}`;
|
||||
|
||||
const opencodeAgent: Agent = {
|
||||
...mockAgent,
|
||||
|
|
@ -1962,7 +1964,7 @@ describe("spawn", () => {
|
|||
]),
|
||||
deleteLogPath,
|
||||
);
|
||||
process.env.PATH = `${mockBin}:${originalPath ?? ""}`;
|
||||
process.env.PATH = `${mockBin}${PATH_SEP}${originalPath ?? ""}`;
|
||||
|
||||
const opencodeAgent: Agent = {
|
||||
...mockAgent,
|
||||
|
|
@ -2012,7 +2014,7 @@ describe("spawn", () => {
|
|||
]),
|
||||
deleteLogPath,
|
||||
);
|
||||
process.env.PATH = `${mockBin}:${originalPath ?? ""}`;
|
||||
process.env.PATH = `${mockBin}${PATH_SEP}${originalPath ?? ""}`;
|
||||
|
||||
const opencodeAgent: Agent = {
|
||||
...mockAgent,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { tmpdir } from "node:os";
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { getProjectSessionsDir, getProjectDir } from "../paths.js";
|
||||
import { resetOpenCodeSessionListCache } from "../session-manager.js";
|
||||
import { closeDb } from "../events-db.js";
|
||||
import { createInitialCanonicalLifecycle, deriveLegacyStatus } from "../lifecycle-state.js";
|
||||
import { createActivitySignal } from "../activity-signal.js";
|
||||
import type {
|
||||
|
|
@ -300,7 +301,11 @@ export function createTestEnvironment(): TestEnvironment {
|
|||
const tmpDir = join(tmpdir(), `ao-test-lifecycle-${randomUUID()}`);
|
||||
mkdirSync(tmpDir, { recursive: true });
|
||||
const previousHome = process.env["HOME"];
|
||||
const previousUserProfile = process.env["USERPROFILE"];
|
||||
process.env["HOME"] = tmpDir;
|
||||
// os.homedir() on Windows reads USERPROFILE, not HOME — must override both
|
||||
// or parallel vitest workers share the real home dir and race on storage.
|
||||
process.env["USERPROFILE"] = tmpDir;
|
||||
|
||||
const configPath = join(tmpDir, "agent-orchestrator.yaml");
|
||||
writeFileSync(configPath, "projects: {}\n");
|
||||
|
|
@ -345,11 +350,21 @@ export function createTestEnvironment(): TestEnvironment {
|
|||
} else {
|
||||
process.env["HOME"] = previousHome;
|
||||
}
|
||||
if (previousUserProfile === undefined) {
|
||||
delete process.env["USERPROFILE"];
|
||||
} else {
|
||||
process.env["USERPROFILE"] = previousUserProfile;
|
||||
}
|
||||
// V2 storage: project files live under getProjectDir(projectId).
|
||||
// maxRetries handles Windows EBUSY (antivirus/indexer transient locks).
|
||||
// closeDb releases the better-sqlite3 lock on activity-events.db so
|
||||
// Windows can unlink it; without this rmSync fails with EBUSY.
|
||||
closeDb();
|
||||
const projectDir = getProjectDir("my-app");
|
||||
if (existsSync(projectDir)) {
|
||||
rmSync(projectDir, { recursive: true, force: true });
|
||||
rmSync(projectDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
||||
}
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
rmSync(tmpDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
||||
};
|
||||
|
||||
return { tmpDir, configPath, sessionsDir, config, cleanup };
|
||||
|
|
@ -370,15 +385,19 @@ export interface TestContext {
|
|||
config: OrchestratorConfig;
|
||||
originalPath: string | undefined;
|
||||
originalHome: string | undefined;
|
||||
originalUserProfile: string | undefined;
|
||||
}
|
||||
|
||||
export function setupTestContext(): TestContext {
|
||||
resetOpenCodeSessionListCache();
|
||||
const originalPath = process.env.PATH;
|
||||
const originalHome = process.env["HOME"];
|
||||
const originalUserProfile = process.env["USERPROFILE"];
|
||||
const tmpDir = join(tmpdir(), `ao-test-session-mgr-${randomUUID()}`);
|
||||
mkdirSync(tmpDir, { recursive: true });
|
||||
process.env["HOME"] = tmpDir;
|
||||
// os.homedir() reads USERPROFILE on Windows, not HOME
|
||||
process.env["USERPROFILE"] = tmpDir;
|
||||
|
||||
const configPath = join(tmpDir, "agent-orchestrator.yaml");
|
||||
writeFileSync(configPath, "projects: {}\n");
|
||||
|
|
@ -436,6 +455,7 @@ export function setupTestContext(): TestContext {
|
|||
config,
|
||||
originalPath,
|
||||
originalHome,
|
||||
originalUserProfile,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -446,11 +466,20 @@ export function teardownTestContext(ctx: TestContext): void {
|
|||
} else {
|
||||
process.env["HOME"] = ctx.originalHome;
|
||||
}
|
||||
if (ctx.originalUserProfile === undefined) {
|
||||
delete process.env["USERPROFILE"];
|
||||
} else {
|
||||
process.env["USERPROFILE"] = ctx.originalUserProfile;
|
||||
}
|
||||
// closeDb releases the activity-events.db SQLite lock so Windows can
|
||||
// unlink the file; rmSync's maxRetries alone doesn't break the lock.
|
||||
closeDb();
|
||||
// V2 storage: getProjectDir(projectId). Retry options handle Windows EBUSY.
|
||||
const projectDir = getProjectDir("my-app");
|
||||
if (existsSync(projectDir)) {
|
||||
rmSync(projectDir, { recursive: true, force: true });
|
||||
rmSync(projectDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
||||
}
|
||||
rmSync(ctx.tmpDir, { recursive: true, force: true });
|
||||
rmSync(ctx.tmpDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
isRetryableHttpStatus,
|
||||
normalizeRetryConfig,
|
||||
readLastJsonlEntry,
|
||||
shellEscape,
|
||||
} from "../utils.js";
|
||||
import { parsePrFromUrl } from "../utils/pr.js";
|
||||
|
||||
|
|
@ -175,6 +176,40 @@ describe("retry utilities", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("shellEscape", () => {
|
||||
it("wraps simple string in single quotes", () => {
|
||||
expect(shellEscape("hello")).toBe("'hello'");
|
||||
});
|
||||
|
||||
it("handles empty string", () => {
|
||||
expect(shellEscape("")).toBe("''");
|
||||
});
|
||||
|
||||
// On Windows this tests PowerShell escaping; on Unix this tests POSIX escaping
|
||||
// Both should produce valid output for their respective shells
|
||||
it("escapes embedded single quotes", () => {
|
||||
const result = shellEscape("it's");
|
||||
if (process.platform === "win32") {
|
||||
expect(result).toBe("'it''s'");
|
||||
} else {
|
||||
expect(result).toBe("'it'\\''s'");
|
||||
}
|
||||
});
|
||||
|
||||
it("escapes multiple single quotes", () => {
|
||||
const result = shellEscape("it's a 'test'");
|
||||
if (process.platform === "win32") {
|
||||
expect(result).toBe("'it''s a ''test'''");
|
||||
} else {
|
||||
expect(result).toBe("'it'\\''s a '\\''test'\\'''");
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves strings without quotes unchanged", () => {
|
||||
expect(shellEscape("claude-opus-4-5")).toBe("'claude-opus-4-5'");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parsePrFromUrl", () => {
|
||||
it("parses GitHub PR URLs", () => {
|
||||
expect(parsePrFromUrl("https://github.com/foo/bar/pull/123")).toEqual({
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { writeFile, mkdir, readFile, rename } from "node:fs/promises";
|
|||
import { join } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { isWindows } from "./platform.js";
|
||||
|
||||
// =============================================================================
|
||||
// Constants
|
||||
|
|
@ -34,7 +35,7 @@ function getAoBinDir(): string {
|
|||
}
|
||||
|
||||
/** Current version of wrapper scripts — bump when scripts change */
|
||||
const WRAPPER_VERSION = "0.6.0";
|
||||
const WRAPPER_VERSION = "0.7.0";
|
||||
|
||||
// =============================================================================
|
||||
// PATH Builder
|
||||
|
|
@ -45,7 +46,10 @@ const WRAPPER_VERSION = "0.6.0";
|
|||
* Deduplicates entries and ensures /usr/local/bin is early for gh resolution.
|
||||
*/
|
||||
export function buildAgentPath(basePath: string | undefined): string {
|
||||
const inherited = (basePath ?? DEFAULT_PATH).split(":").filter(Boolean);
|
||||
const delimiter = isWindows() ? ";" : ":";
|
||||
const inherited = (basePath ?? (isWindows() ? "" : DEFAULT_PATH))
|
||||
.split(delimiter)
|
||||
.filter(Boolean);
|
||||
const ordered: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
|
|
@ -56,11 +60,13 @@ export function buildAgentPath(basePath: string | undefined): string {
|
|||
};
|
||||
|
||||
add(getAoBinDir());
|
||||
add(PREFERRED_GH_BIN_DIR);
|
||||
if (!isWindows()) {
|
||||
add(PREFERRED_GH_BIN_DIR);
|
||||
}
|
||||
|
||||
for (const entry of inherited) add(entry);
|
||||
|
||||
return ordered.join(":");
|
||||
return ordered.join(delimiter);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
|
@ -606,6 +612,262 @@ fi
|
|||
exit \$exit_code
|
||||
`;
|
||||
|
||||
// =============================================================================
|
||||
// Node.js Wrapper Scripts (Windows)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Build a Node.js wrapper script for a given binary (gh or git).
|
||||
*
|
||||
* On Windows, bash scripts cannot be executed directly, so we generate:
|
||||
* - <name>.cjs — the actual interception logic (Node.js, forced CJS mode)
|
||||
* - <name>.cmd — a tiny CMD shim: @node "%~dp0<name>.cjs" %*
|
||||
*
|
||||
* The .js script replicates what the bash wrapper does:
|
||||
* - gh: intercepts `gh pr create` and `gh pr merge`
|
||||
* - git: intercepts `git checkout -b` and `git switch -c`
|
||||
*
|
||||
* @param name - "gh" or "git"
|
||||
* @param realBinaryPath - Absolute path to the real binary, or empty string to
|
||||
* resolve at runtime via PATH (excluding the wrapper dir).
|
||||
*/
|
||||
export function buildNodeWrapper(name: "gh" | "git", realBinaryPath: string): string {
|
||||
if (name === "gh") {
|
||||
return buildGhNodeWrapper(realBinaryPath);
|
||||
}
|
||||
return buildGitNodeWrapper(realBinaryPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared Node.js snippet: updateAoMetadata function used by both gh and git wrappers.
|
||||
* Validates session, key, and AO_DATA_DIR before writing metadata.
|
||||
*/
|
||||
const NODE_UPDATE_AO_METADATA = `\
|
||||
// ---------------------------------------------------------------------------
|
||||
// Metadata update (shared by gh/git wrappers)
|
||||
// ---------------------------------------------------------------------------
|
||||
function updateAoMetadata(key, value) {
|
||||
const aoDir = process.env["AO_DATA_DIR"] || "";
|
||||
const aoSession = process.env["AO_SESSION"] || "";
|
||||
if (!aoDir || !aoSession) return;
|
||||
|
||||
// Validate session — no path separators or traversal
|
||||
if (aoSession.includes("/") || aoSession.includes("\\\\") || aoSession.includes("..")) return;
|
||||
|
||||
// Validate key
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(key)) return;
|
||||
|
||||
// Validate aoDir is under expected locations (mirrors bash ao-metadata-helper.sh)
|
||||
const os = require("os");
|
||||
const home = os.homedir();
|
||||
const sep = path.sep;
|
||||
let resolvedDir;
|
||||
try { resolvedDir = fs.realpathSync(aoDir); } catch { resolvedDir = path.resolve(aoDir); }
|
||||
const allowed = [path.join(home, ".ao"), path.join(home, ".agent-orchestrator"), os.tmpdir()];
|
||||
if (!allowed.some(a => resolvedDir === a || resolvedDir.startsWith(a + sep))) return;
|
||||
|
||||
// Try V2 (.json) first, then fall back to V1 (bare) — mirrors bash ao-metadata-helper.sh
|
||||
let metadataFile = path.join(resolvedDir, aoSession + ".json");
|
||||
if (!fs.existsSync(metadataFile)) {
|
||||
metadataFile = path.join(resolvedDir, aoSession);
|
||||
}
|
||||
if (!fs.existsSync(metadataFile)) return;
|
||||
|
||||
// Strip newlines from value
|
||||
const cleanValue = String(value).replace(/[\\r\\n]/g, "");
|
||||
|
||||
let content;
|
||||
try { content = fs.readFileSync(metadataFile, "utf8"); } catch { return; }
|
||||
|
||||
const tmpFile = metadataFile + ".tmp." + process.pid;
|
||||
try {
|
||||
if (metadataFile.endsWith(".json")) {
|
||||
// V2 JSON format
|
||||
let d;
|
||||
try { d = JSON.parse(content); } catch { return; }
|
||||
d[key] = cleanValue;
|
||||
fs.writeFileSync(tmpFile, JSON.stringify(d, null, 2), "utf8");
|
||||
} else {
|
||||
// V1 key=value format
|
||||
const lines = content.split("\\n");
|
||||
const keyPrefix = key + "=";
|
||||
const idx = lines.findIndex(l => l.startsWith(keyPrefix));
|
||||
if (idx >= 0) {
|
||||
lines[idx] = key + "=" + cleanValue;
|
||||
} else {
|
||||
lines.push(key + "=" + cleanValue);
|
||||
}
|
||||
fs.writeFileSync(tmpFile, lines.join("\\n"), "utf8");
|
||||
}
|
||||
fs.renameSync(tmpFile, metadataFile);
|
||||
} catch {
|
||||
try { fs.unlinkSync(tmpFile); } catch {}
|
||||
}
|
||||
}`;
|
||||
|
||||
function buildGhNodeWrapper(realBinaryPath: string): string {
|
||||
return `#!/usr/bin/env node
|
||||
// ao gh wrapper (Windows Node.js) — auto-updates session metadata on PR operations
|
||||
"use strict";
|
||||
const { spawnSync } = require("child_process");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Real binary resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
const AO_BIN_DIR = path.dirname(__filename);
|
||||
|
||||
function findRealGh() {
|
||||
const explicit = process.env["GH_PATH"] || "";
|
||||
if (explicit) {
|
||||
try {
|
||||
const resolved = path.resolve(explicit);
|
||||
const dir = path.dirname(resolved);
|
||||
if (dir !== AO_BIN_DIR && fs.existsSync(resolved)) return resolved;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Walk PATH, skip wrapper directory
|
||||
const pathDirs = (process.env["PATH"] || "").split(path.delimiter);
|
||||
for (const dir of pathDirs) {
|
||||
if (!dir || path.resolve(dir) === AO_BIN_DIR) continue;
|
||||
// Windows executables always have an extension (.exe/.cmd). Skip the bare
|
||||
// no-extension case — on Windows X_OK is identical to F_OK (execute bit
|
||||
// doesn't exist), so a bare text file named "gh" would otherwise be
|
||||
// selected before gh.exe.
|
||||
for (const ext of [".exe", ".cmd"]) {
|
||||
const candidate = path.join(dir, "gh" + ext);
|
||||
try {
|
||||
fs.accessSync(candidate, fs.constants.F_OK);
|
||||
return candidate;
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
${NODE_UPDATE_AO_METADATA}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
const realGh = ${realBinaryPath ? `(fs.existsSync(${JSON.stringify(realBinaryPath)}) ? ${JSON.stringify(realBinaryPath)} : findRealGh())` : "findRealGh()"};
|
||||
if (!realGh) {
|
||||
process.stderr.write("ao-wrapper: gh not found in PATH\\n");
|
||||
process.exit(127);
|
||||
}
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const sub1 = args[0] || "";
|
||||
const sub2 = args[1] || "";
|
||||
const key = sub1 + "/" + sub2;
|
||||
|
||||
if (key === "pr/create" || key === "pr/merge") {
|
||||
const result = spawnSync(realGh, args, {
|
||||
stdio: ["inherit", "pipe", "pipe"],
|
||||
encoding: "utf8",
|
||||
});
|
||||
|
||||
if (result.stdout) process.stdout.write(result.stdout);
|
||||
if (result.stderr) process.stderr.write(result.stderr);
|
||||
|
||||
if (result.status === 0) {
|
||||
const output = (result.stdout || "") + (result.stderr || "");
|
||||
if (key === "pr/create") {
|
||||
const match = output.match(/https:\\/\\/github\\.com\\/[^/]+\\/[^/]+\\/pull\\/[0-9]+/);
|
||||
if (match) {
|
||||
updateAoMetadata("pr", match[0]);
|
||||
updateAoMetadata("status", "pr_open");
|
||||
}
|
||||
} else if (key === "pr/merge") {
|
||||
updateAoMetadata("status", "merged");
|
||||
}
|
||||
}
|
||||
|
||||
process.exit(result.status ?? 1);
|
||||
} else {
|
||||
const result = spawnSync(realGh, args, { stdio: "inherit" });
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
function buildGitNodeWrapper(realBinaryPath: string): string {
|
||||
return `#!/usr/bin/env node
|
||||
// ao git wrapper (Windows Node.js) — auto-updates session metadata on branch operations
|
||||
"use strict";
|
||||
const { spawnSync } = require("child_process");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Real binary resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
const AO_BIN_DIR = path.dirname(__filename);
|
||||
|
||||
function findRealGit() {
|
||||
const pathDirs = (process.env["PATH"] || "").split(path.delimiter);
|
||||
for (const dir of pathDirs) {
|
||||
if (!dir || path.resolve(dir) === AO_BIN_DIR) continue;
|
||||
// Windows executables always have an extension (.exe/.cmd). Skip the bare
|
||||
// no-extension case — on Windows X_OK is identical to F_OK (execute bit
|
||||
// doesn't exist), so a bare text file named "git" would otherwise be
|
||||
// selected before git.exe.
|
||||
for (const ext of [".exe", ".cmd"]) {
|
||||
const candidate = path.join(dir, "git" + ext);
|
||||
try {
|
||||
fs.accessSync(candidate, fs.constants.F_OK);
|
||||
return candidate;
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
${NODE_UPDATE_AO_METADATA}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
const realGit = ${realBinaryPath ? `(fs.existsSync(${JSON.stringify(realBinaryPath)}) ? ${JSON.stringify(realBinaryPath)} : findRealGit())` : "findRealGit()"};
|
||||
if (!realGit) {
|
||||
process.stderr.write("ao-wrapper: git not found in PATH\\n");
|
||||
process.exit(127);
|
||||
}
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const result = spawnSync(realGit, args, { stdio: "inherit" });
|
||||
const exitCode = result.status ?? 1;
|
||||
|
||||
if (exitCode === 0) {
|
||||
const sub1 = args[0] || "";
|
||||
const sub2 = args[1] || "";
|
||||
const key = sub1 + "/" + sub2;
|
||||
|
||||
if (key === "checkout/-b" || key === "switch/-c") {
|
||||
const branch = args[2];
|
||||
if (branch) updateAoMetadata("branch", branch);
|
||||
} else if (sub1 === "checkout" || sub1 === "switch") {
|
||||
// Existing branch switch — only track feature-looking branches (contain / or -)
|
||||
let branch = sub2;
|
||||
// If sub2 is a flag, the actual branch name is in args[2]
|
||||
if (branch && branch.startsWith("-")) branch = args[2] || "";
|
||||
if (
|
||||
branch &&
|
||||
branch !== "HEAD" &&
|
||||
!branch.startsWith("-") &&
|
||||
(branch.includes("/") || branch.includes("-"))
|
||||
) {
|
||||
updateAoMetadata("branch", branch);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
process.exit(exitCode);
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Section appended to AGENTS.md as a secondary signal. The PATH-based wrappers
|
||||
* handle metadata updates automatically, but AGENTS.md reinforces the intent
|
||||
|
|
@ -664,12 +926,30 @@ export async function setupPathWrapperWorkspace(workspacePath: string): Promise<
|
|||
}
|
||||
|
||||
if (needsUpdate) {
|
||||
await atomicWriteFile(join(getAoBinDir(), "ao-metadata-helper.sh"), AO_METADATA_HELPER, 0o755);
|
||||
// Write wrappers atomically, then write the version marker last.
|
||||
// If we crash between wrapper writes and marker write, the next
|
||||
// invocation will redo the writes (safe: wrappers are idempotent).
|
||||
await atomicWriteFile(join(getAoBinDir(), "gh"), GH_WRAPPER, 0o755);
|
||||
await atomicWriteFile(join(getAoBinDir(), "git"), GIT_WRAPPER, 0o755);
|
||||
if (isWindows()) {
|
||||
// On Windows: generate Node.js .js wrappers + .cmd shims.
|
||||
// Bash scripts can't be executed directly on Windows.
|
||||
// Write wrappers atomically, then write the version marker last.
|
||||
for (const name of ["gh", "git"] as const) {
|
||||
const wrapperBase = join(getAoBinDir(), name);
|
||||
const nodeScript = buildNodeWrapper(name, "");
|
||||
// Use .cjs extension to force CJS mode regardless of any parent package.json "type" field
|
||||
await atomicWriteFile(wrapperBase + ".cjs", nodeScript, 0o644);
|
||||
// .cmd shim: delegates to node <wrapper>.cjs forwarding all args
|
||||
await atomicWriteFile(wrapperBase + ".cmd", `@node "%~dp0${name}.cjs" %*\r\n`, 0o644);
|
||||
}
|
||||
} else {
|
||||
await atomicWriteFile(
|
||||
join(getAoBinDir(), "ao-metadata-helper.sh"),
|
||||
AO_METADATA_HELPER,
|
||||
0o755,
|
||||
);
|
||||
// Write wrappers atomically, then write the version marker last.
|
||||
// If we crash between wrapper writes and marker write, the next
|
||||
// invocation will redo the writes (safe: wrappers are idempotent).
|
||||
await atomicWriteFile(join(getAoBinDir(), "gh"), GH_WRAPPER, 0o755);
|
||||
await atomicWriteFile(join(getAoBinDir(), "git"), GIT_WRAPPER, 0o755);
|
||||
}
|
||||
await atomicWriteFile(markerPath, WRAPPER_VERSION, 0o644);
|
||||
}
|
||||
|
||||
|
|
@ -678,5 +958,9 @@ export async function setupPathWrapperWorkspace(workspacePath: string): Promise<
|
|||
// repo-tracked AGENTS.md to avoid polluting worktrees with dirty state.
|
||||
const aoAgentsMdPath = join(workspacePath, ".ao", "AGENTS.md");
|
||||
await mkdir(join(workspacePath, ".ao"), { recursive: true });
|
||||
await writeFile(aoAgentsMdPath, AO_AGENTS_MD_SECTION.trimStart(), "utf-8");
|
||||
// On Windows, ao-metadata-helper.sh is never created — use a platform-appropriate section
|
||||
const agentsMdContent = isWindows()
|
||||
? `## Agent Orchestrator (ao) Session\n\nYou are running inside an Agent Orchestrator managed workspace.\nSession metadata is updated automatically via shell wrappers.\n`
|
||||
: AO_AGENTS_MD_SECTION.trimStart();
|
||||
await writeFile(aoAgentsMdPath, agentsMdContent, "utf-8");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,32 @@
|
|||
import { renameSync, writeFileSync } from "node:fs";
|
||||
import { renameSync, writeFileSync, unlinkSync } from "node:fs";
|
||||
import { isWindows } from "./platform.js";
|
||||
|
||||
// Windows file-locking workaround. `renameSync` can fail with EPERM/EACCES when
|
||||
// antivirus, the Windows indexer, or another process briefly holds a handle on
|
||||
// the destination path. The failures are transient — a short retry loop is the
|
||||
// standard fix (same pattern Node's own `fs.rm` uses internally).
|
||||
const RENAME_RETRIES = isWindows() ? 10 : 0;
|
||||
const RENAME_RETRY_DELAY_MS = 50;
|
||||
|
||||
function renameWithRetry(src: string, dest: string): void {
|
||||
let lastError: unknown;
|
||||
for (let attempt = 0; attempt <= RENAME_RETRIES; attempt++) {
|
||||
try {
|
||||
renameSync(src, dest);
|
||||
return;
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
const code = (err as NodeJS.ErrnoException)?.code;
|
||||
if (code !== "EPERM" && code !== "EACCES" && code !== "EBUSY") throw err;
|
||||
if (attempt === RENAME_RETRIES) break;
|
||||
const deadline = Date.now() + RENAME_RETRY_DELAY_MS;
|
||||
while (Date.now() < deadline) {
|
||||
// busy-wait; renameSync is sync so we can't await
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically write a file by writing to a temp file then renaming.
|
||||
|
|
@ -7,5 +35,14 @@ import { renameSync, writeFileSync } from "node:fs";
|
|||
export function atomicWriteFileSync(filePath: string, content: string): void {
|
||||
const tmpPath = `${filePath}.tmp.${process.pid}.${Date.now()}`;
|
||||
writeFileSync(tmpPath, content, "utf-8");
|
||||
renameSync(tmpPath, filePath);
|
||||
try {
|
||||
renameWithRetry(tmpPath, filePath);
|
||||
} catch (err) {
|
||||
try {
|
||||
unlinkSync(tmpPath);
|
||||
} catch {
|
||||
// best-effort cleanup
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { existsSync, readFileSync } from "node:fs";
|
|||
import { join, resolve } from "node:path";
|
||||
import { stringify as yamlStringify } from "yaml";
|
||||
import { generateSessionPrefix } from "./paths.js";
|
||||
import { getDefaultRuntime } from "./platform.js";
|
||||
|
||||
// Main is the canonical config-schema contract; schema evolution must remain
|
||||
// forward-compatible and additive for older CLIs that stamp this URL.
|
||||
|
|
@ -283,7 +284,7 @@ export function generateConfigFromUrl(options: GenerateConfigOptions): Record<st
|
|||
return withConfigSchema({
|
||||
port,
|
||||
defaults: {
|
||||
runtime: "tmux",
|
||||
runtime: getDefaultRuntime(),
|
||||
agent: "claude-code",
|
||||
workspace: "worktree",
|
||||
notifiers: [],
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import {
|
|||
type OrchestratorConfig,
|
||||
} from "./types.js";
|
||||
import { generateSessionPrefix } from "./paths.js";
|
||||
import { getDefaultRuntime } from "./platform.js";
|
||||
import {
|
||||
getGlobalConfigPath,
|
||||
isCanonicalGlobalConfigPath,
|
||||
|
|
@ -268,7 +269,7 @@ const ProjectConfigSchema = z.object({
|
|||
});
|
||||
|
||||
const DefaultPluginsSchema = z.object({
|
||||
runtime: z.string().default("tmux"),
|
||||
runtime: z.string().default(() => getDefaultRuntime()),
|
||||
agent: z.string().default("claude-code"),
|
||||
workspace: z.string().default("worktree"),
|
||||
notifiers: z.array(z.string()).default([]),
|
||||
|
|
|
|||
|
|
@ -140,6 +140,27 @@ export function isActivityEventsFtsEnabled(): boolean {
|
|||
return _ftsEnabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the cached DB connection and reset module state.
|
||||
*
|
||||
* On Windows the SQLite handle holds an exclusive file lock; without an
|
||||
* explicit close, callers cannot remove `activity-events.db` (or its parent
|
||||
* directory) until the process exits. Tests that recreate the AO base dir
|
||||
* across runs must call this before `rmSync`.
|
||||
*/
|
||||
export function closeDb(): void {
|
||||
if (_db) {
|
||||
try {
|
||||
_db.close();
|
||||
} catch {
|
||||
// best-effort: connection may already be closed
|
||||
}
|
||||
_db = null;
|
||||
}
|
||||
_dbFailed = false;
|
||||
_ftsEnabled = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the lazily-initialized DB connection.
|
||||
* Returns null if better-sqlite3 failed to load or init — callers should treat null as no-op.
|
||||
|
|
|
|||
|
|
@ -35,13 +35,24 @@ async function resolveGhBinary(): Promise<string> {
|
|||
.split(delimiter)
|
||||
.filter((entry) => entry && entry !== aoBinDir);
|
||||
|
||||
// On Windows the binary is `gh.exe` (or `gh.cmd` for npm shims). Honor
|
||||
// PATHEXT so we match whatever the user actually installed.
|
||||
const exts =
|
||||
process.platform === "win32"
|
||||
? (process.env["PATHEXT"]?.split(";").filter(Boolean) ?? [".EXE", ".CMD", ".BAT"]).map(
|
||||
(e) => e.toLowerCase(),
|
||||
)
|
||||
: [""];
|
||||
|
||||
for (const dir of dirs) {
|
||||
const candidate = join(dir, "gh");
|
||||
try {
|
||||
await access(candidate, constants.X_OK);
|
||||
return candidate;
|
||||
} catch {
|
||||
// Not found or not executable — try next
|
||||
for (const ext of exts) {
|
||||
const candidate = join(dir, `gh${ext}`);
|
||||
try {
|
||||
await access(candidate, constants.X_OK);
|
||||
return candidate;
|
||||
} catch {
|
||||
// Not found or not executable — try next
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { withFileLockSync } from "./file-lock.js";
|
|||
import { ProjectResolveError } from "./types.js";
|
||||
import { generateSessionPrefix } from "./paths.js";
|
||||
import { normalizeOriginUrl } from "./storage-key.js";
|
||||
import { getDefaultRuntime } from "./platform.js";
|
||||
|
||||
function globalConfigLockPath(configPath: string): string {
|
||||
return `${configPath}.lock`;
|
||||
|
|
@ -163,7 +164,7 @@ export const GlobalConfigSchema = z
|
|||
/** Cross-project defaults — projects inherit when fields are omitted. */
|
||||
defaults: z
|
||||
.object({
|
||||
runtime: z.string().default("tmux"),
|
||||
runtime: z.string().default(() => getDefaultRuntime()),
|
||||
agent: z.string().default("claude-code"),
|
||||
workspace: z.string().default("worktree"),
|
||||
notifiers: z.array(z.string()).default(["composio", "desktop"]),
|
||||
|
|
@ -1023,7 +1024,7 @@ function makeEmptyGlobalConfig(): GlobalConfig {
|
|||
port: 3000,
|
||||
readyThresholdMs: 300_000,
|
||||
defaults: {
|
||||
runtime: "tmux",
|
||||
runtime: getDefaultRuntime(),
|
||||
agent: "claude-code",
|
||||
workspace: "worktree",
|
||||
notifiers: ["composio", "desktop"],
|
||||
|
|
|
|||
|
|
@ -272,6 +272,17 @@ export {
|
|||
validateAndStoreOrigin,
|
||||
} from "./paths.js";
|
||||
|
||||
// Platform adapter — centralized cross-platform branching
|
||||
export {
|
||||
isWindows,
|
||||
isMac,
|
||||
getDefaultRuntime,
|
||||
getShell,
|
||||
killProcessTree,
|
||||
findPidByPort,
|
||||
getEnvDefaults,
|
||||
} from "./platform.js";
|
||||
|
||||
export { normalizeOriginUrl, relativeSubdir, deriveStorageKey } from "./storage-key.js";
|
||||
|
||||
// Global config — Option C hybrid architecture (global registry + local behavior)
|
||||
|
|
@ -375,9 +386,17 @@ export type {
|
|||
|
||||
export { atomicWriteFileSync } from "./atomic-write.js";
|
||||
|
||||
export {
|
||||
registerWindowsPtyHost,
|
||||
unregisterWindowsPtyHost,
|
||||
getWindowsPtyHosts,
|
||||
clearWindowsPtyHostRegistry,
|
||||
type WindowsPtyHostEntry,
|
||||
} from "./windows-pty-registry.js";
|
||||
|
||||
// Activity event logging — structured diagnostic event trail
|
||||
export { recordActivityEvent, droppedEventCount } from "./activity-events.js";
|
||||
export { isActivityEventsFtsEnabled } from "./events-db.js";
|
||||
export { isActivityEventsFtsEnabled, closeDb } from "./events-db.js";
|
||||
export type {
|
||||
ActivityEventInput,
|
||||
ActivityEventKind,
|
||||
|
|
|
|||
|
|
@ -191,7 +191,13 @@ export async function getCachedOpenCodeSessionList(options?: {
|
|||
const promise: Promise<OpenCodeSessionListEntry[]> = execFileAsync(
|
||||
"opencode",
|
||||
["session", "list", "--format", "json"],
|
||||
{ timeout: timeoutMs, env: getOpenCodeChildEnv() },
|
||||
{
|
||||
timeout: timeoutMs,
|
||||
env: getOpenCodeChildEnv(),
|
||||
// On Windows, execFile cannot resolve .cmd shim extensions without
|
||||
// invoking the shell; windowsHide:true suppresses the conhost popup.
|
||||
...(process.platform === "win32" ? { shell: true, windowsHide: true } : {}),
|
||||
},
|
||||
)
|
||||
.then(({ stdout }) => {
|
||||
const entries = parseSessionListStdout(stdout);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,237 @@
|
|||
import { execFile as execFileCb } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { homedir, userInfo } from "node:os";
|
||||
import { existsSync } from "node:fs";
|
||||
|
||||
const execFileAsync = promisify(execFileCb);
|
||||
|
||||
/**
|
||||
* Cross-platform adapter.
|
||||
*
|
||||
* All platform-branching logic lives here. Every other module imports
|
||||
* from this file instead of doing ad-hoc process.platform checks.
|
||||
*/
|
||||
|
||||
export function isWindows(): boolean {
|
||||
return process.platform === "win32";
|
||||
}
|
||||
|
||||
export function isMac(): boolean {
|
||||
return process.platform === "darwin";
|
||||
}
|
||||
|
||||
export function getDefaultRuntime(): "tmux" | "process" {
|
||||
return isWindows() ? "process" : "tmux";
|
||||
}
|
||||
|
||||
// -- Shell resolution --
|
||||
|
||||
interface ShellInfo {
|
||||
cmd: string;
|
||||
args: (command: string) => string[];
|
||||
}
|
||||
|
||||
let cachedShell: ShellInfo | null = null;
|
||||
|
||||
/**
|
||||
* Infer the command-string flag for a given shell from its basename.
|
||||
* pwsh / powershell → -Command, cmd → /c, bash / sh / zsh → -c.
|
||||
* Default to PowerShell args (the historical behaviour) for unknown shells.
|
||||
*/
|
||||
function inferShellArgsFlag(cmd: string): (command: string) => string[] {
|
||||
const base = cmd
|
||||
.replace(/\\/g, "/")
|
||||
.split("/")
|
||||
.pop()!
|
||||
.toLowerCase()
|
||||
.replace(/\.exe$/, "");
|
||||
if (base === "cmd") return (c) => ["/c", c];
|
||||
if (base === "bash" || base === "sh" || base === "zsh" || base === "dash") {
|
||||
return (c) => ["-c", c];
|
||||
}
|
||||
// pwsh, powershell, and anything else fall back to PowerShell-style args.
|
||||
return (c) => ["-Command", c];
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk PATH looking for an executable. Windows-only: only ever called from
|
||||
* resolveWindowsShell. Hard-coded `;` separator and `\` path join regardless
|
||||
* of host OS so unit tests that simulate Windows on a Linux CI runner produce
|
||||
* canonical Windows paths.
|
||||
*/
|
||||
function findOnPath(name: string): string | null {
|
||||
const exts = process.env["PATHEXT"]?.split(";").filter(Boolean) ?? [
|
||||
".COM",
|
||||
".EXE",
|
||||
".BAT",
|
||||
".CMD",
|
||||
];
|
||||
const dirs = (process.env["PATH"] ?? "").split(";").filter(Boolean);
|
||||
for (const dir of dirs) {
|
||||
const base = dir.endsWith("\\") || dir.endsWith("/") ? dir.slice(0, -1) : dir;
|
||||
for (const ext of [...exts, ""]) {
|
||||
const candidate = `${base}\\${name}${ext}`;
|
||||
if (existsSync(candidate)) return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveWindowsShell(): ShellInfo {
|
||||
// Explicit override — set AO_SHELL to an absolute path or shell name
|
||||
// (e.g. "powershell.exe", "pwsh", "cmd.exe", "bash"). Args are inferred
|
||||
// from the basename so cmd / bash / sh are usable, not just PowerShell.
|
||||
const override = process.env["AO_SHELL"];
|
||||
if (override) {
|
||||
return { cmd: override, args: inferShellArgsFlag(override) };
|
||||
}
|
||||
|
||||
// Prefer pwsh (PowerShell Core, cross-platform). PATH-walk via existsSync
|
||||
// rather than execFileSync — a missing pwsh would otherwise block the event
|
||||
// loop for the spawn timeout on every cold start (this resolver is sync).
|
||||
const pwshPath = findOnPath("pwsh");
|
||||
if (pwshPath) {
|
||||
return { cmd: pwshPath, args: (c) => ["-Command", c] };
|
||||
}
|
||||
|
||||
// Fall back to powershell.exe (Windows PowerShell, always on Win 10+).
|
||||
// Use the absolute path because the spawning process may have a degraded
|
||||
// PATH that doesn't include C:\Windows\System32 (e.g. Next.js dashboard
|
||||
// children spawned without full system PATH inheritance). Without this,
|
||||
// we'd fall through to cmd.exe — which breaks any launch command that uses
|
||||
// PowerShell syntax (e.g. Codex's `& 'codex' ...`).
|
||||
const systemRoot = process.env["SystemRoot"] || "C:\\Windows";
|
||||
const psAbsolute = `${systemRoot}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`;
|
||||
if (existsSync(psAbsolute)) {
|
||||
return { cmd: psAbsolute, args: (c) => ["-Command", c] };
|
||||
}
|
||||
|
||||
// Try PATH lookup as a final PowerShell attempt (unusual installs).
|
||||
const psPathLookup = findOnPath("powershell");
|
||||
if (psPathLookup) {
|
||||
return { cmd: psPathLookup, args: (c) => ["-Command", c] };
|
||||
}
|
||||
|
||||
// Last resort: cmd.exe. Note that agent launch commands often use PowerShell
|
||||
// syntax (e.g. the `&` call operator) and will fail under cmd.exe. Setting
|
||||
// AO_SHELL is the supported escape hatch.
|
||||
const comspec = process.env["ComSpec"] || "cmd.exe";
|
||||
return { cmd: comspec, args: (c) => ["/c", c] };
|
||||
}
|
||||
|
||||
export function getShell(): ShellInfo {
|
||||
if (cachedShell) return cachedShell;
|
||||
|
||||
if (isWindows()) {
|
||||
cachedShell = resolveWindowsShell();
|
||||
} else {
|
||||
// Always use /bin/sh, not $SHELL. postCreate commands and runtime launches are
|
||||
// non-interactive; using $SHELL would break if the user's login shell is
|
||||
// non-POSIX (e.g. fish, nushell). /bin/sh is guaranteed POSIX-compliant on all Unix systems.
|
||||
cachedShell = { cmd: "/bin/sh", args: (c) => ["-c", c] };
|
||||
}
|
||||
|
||||
return cachedShell;
|
||||
}
|
||||
|
||||
/** Reset cached shell (for testing)
|
||||
* @internal
|
||||
*/
|
||||
export function _resetShellCache(): void {
|
||||
cachedShell = null;
|
||||
}
|
||||
|
||||
// -- Process tree kill --
|
||||
|
||||
export async function killProcessTree(
|
||||
pid: number,
|
||||
signal: "SIGTERM" | "SIGKILL" = "SIGTERM",
|
||||
): Promise<void> {
|
||||
// pid=0 means "current process group" on Unix (-0 === 0 in JS), which would
|
||||
// kill AO itself. pid<0 is never valid. Guard both.
|
||||
if (pid <= 0) return;
|
||||
if (isWindows()) {
|
||||
// Always use /F (force) on Windows. taskkill without /F sends WM_CLOSE, which
|
||||
// only works for GUI windows; headless Node.js console processes may ignore it,
|
||||
// leaving orphaned processes. Callers that do SIGTERM→wait→SIGKILL escalation
|
||||
// are unaffected: the SIGKILL step simply finds the process already dead.
|
||||
const args = ["/T", "/F", "/PID", String(pid)];
|
||||
try {
|
||||
await execFileAsync("taskkill", args, { windowsHide: true });
|
||||
} catch {
|
||||
// Process may already be dead
|
||||
}
|
||||
} else {
|
||||
// Unix: negative PID kills the process group
|
||||
try {
|
||||
process.kill(-pid, signal);
|
||||
} catch {
|
||||
// Process group may not exist, try direct kill
|
||||
try {
|
||||
process.kill(pid, signal);
|
||||
} catch {
|
||||
// Already dead
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- Port-based PID discovery --
|
||||
|
||||
export async function findPidByPort(port: number): Promise<string | null> {
|
||||
try {
|
||||
if (isWindows()) {
|
||||
// netstat -ano shows all connections with PIDs
|
||||
const { stdout } = await execFileAsync("netstat", ["-ano"], { windowsHide: true });
|
||||
const portPattern = new RegExp(`:${port}(?!\\d)`);
|
||||
for (const line of stdout.split("\n")) {
|
||||
// Match LISTENING state on the target local port exactly
|
||||
const parts = line.trim().split(/\s+/);
|
||||
const localAddress = parts[1];
|
||||
if (line.includes("LISTENING") && localAddress && portPattern.test(localAddress)) {
|
||||
const pid = parts[parts.length - 1];
|
||||
if (pid && /^\d+$/.test(pid)) return pid;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} else {
|
||||
// Unix: lsof
|
||||
const { stdout } = await execFileAsync("lsof", ["-ti", `:${port}`, "-sTCP:LISTEN"]);
|
||||
const pid = stdout.trim().split("\n")[0]?.trim();
|
||||
if (!pid || !/^\d+$/.test(pid)) return null;
|
||||
return pid;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// -- Environment defaults --
|
||||
|
||||
interface EnvDefaults {
|
||||
HOME: string;
|
||||
SHELL: string;
|
||||
TMPDIR: string;
|
||||
PATH: string;
|
||||
USER: string;
|
||||
}
|
||||
|
||||
export function getEnvDefaults(): EnvDefaults {
|
||||
if (isWindows()) {
|
||||
return {
|
||||
HOME: process.env["USERPROFILE"] || homedir(),
|
||||
SHELL: getShell().cmd,
|
||||
TMPDIR: process.env["TEMP"] || process.env["TMP"] || "C:\\Windows\\Temp",
|
||||
PATH: process.env["PATH"] || "",
|
||||
USER: process.env["USERNAME"] || userInfo().username,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
HOME: process.env["HOME"] || homedir(),
|
||||
SHELL: process.env["SHELL"] || "/bin/bash",
|
||||
TMPDIR: process.env["TMPDIR"] || "/tmp",
|
||||
PATH: process.env["PATH"] || "/usr/local/bin:/usr/bin:/bin",
|
||||
USER: process.env["USER"] || userInfo().username,
|
||||
};
|
||||
}
|
||||
|
|
@ -10,7 +10,7 @@ Your role is to coordinate and manage worker agent sessions. You do NOT write co
|
|||
- Any code change, test run tied to implementation, git branch work, or PR takeover must be delegated to a **worker session**.
|
||||
- The orchestrator session must never own a PR. Never claim a PR into the orchestrator session, and never treat the orchestrator as the worker responsible for implementation.
|
||||
- If an investigation discovers follow-up work, either spawn a worker session or direct an existing worker session with clear instructions.
|
||||
- **Always use `ao send` to communicate with sessions** - never use raw `tmux send-keys` or `tmux capture-pane`. Direct tmux access bypasses busy detection, retry logic, and input sanitization, and breaks multi-line input for some agents (e.g. Codex).
|
||||
- **Always use `ao send` to communicate with sessions** - never bypass it by writing to the runtime layer directly (e.g. `tmux send-keys` / `tmux capture-pane` on Unix, or writing to the named pipe `\\.\pipe\ao-pty-<sessionId>` on Windows). Direct runtime access bypasses busy detection, retry logic, and input sanitization, and breaks multi-line input for some agents (e.g. Codex).
|
||||
- When a session might be busy, use `ao send --no-wait <session> <message>` to send without waiting for the session to become idle.
|
||||
|
||||
## Project Info
|
||||
|
|
@ -65,7 +65,7 @@ ao open {{projectId}}{{REPO_CONFIGURED_SECTION_END}}
|
|||
{{REPO_CONFIGURED_SECTION_START}}- `ao batch-spawn <issues...>`: Spawn multiple sessions in parallel (project auto-detected)
|
||||
{{REPO_CONFIGURED_SECTION_END}}- `ao session ls [-p project]`: List all sessions (optionally filter by project)
|
||||
{{REPO_CONFIGURED_SECTION_START}}- `ao session claim-pr <pr> [session]`: Attach an existing PR to a worker session
|
||||
{{REPO_CONFIGURED_SECTION_END}}- `ao session attach <session>`: Attach to a session's tmux window
|
||||
{{REPO_CONFIGURED_SECTION_END}}- `ao session attach <session>`: Attach to a session's terminal (a tmux window on Unix; a ConPTY pty-host on Windows)
|
||||
- `ao session kill <session>`: Kill a specific session
|
||||
- `ao session cleanup [-p project]`: Kill cleanup-eligible sessions (closed work or dead runtimes)
|
||||
- `ao send <session> <message>`: Send a message to a running session
|
||||
|
|
@ -81,7 +81,7 @@ When you spawn a session:
|
|||
|
||||
1. A git worktree is created from `{{projectDefaultBranch}}`
|
||||
2. A feature branch is created (e.g., `feat/INT-1234` for issues, `session/<id>` for prompt-driven)
|
||||
3. A tmux session is started (e.g., `{{projectSessionPrefix}}-1`)
|
||||
3. A runtime session is started (e.g., `{{projectSessionPrefix}}-1`) — tmux session on Unix, ConPTY pty-host on Windows
|
||||
4. The agent is launched with context about the issue or prompt
|
||||
5. Metadata is written to the project-specific sessions directory
|
||||
|
||||
|
|
|
|||
|
|
@ -83,9 +83,7 @@ import {
|
|||
resetOpenCodeSessionListCache as resetSharedOpenCodeSessionListCache,
|
||||
type OpenCodeSessionListEntry,
|
||||
} from "./opencode-shared.js";
|
||||
import {
|
||||
writeWorkspaceOpenCodeAgentsMd,
|
||||
} from "./opencode-agents-md.js";
|
||||
import { writeWorkspaceOpenCodeAgentsMd } from "./opencode-agents-md.js";
|
||||
import { writeOpenCodeConfig } from "./opencode-config.js";
|
||||
import { CleanupStack } from "./cleanup-stack.js";
|
||||
import {
|
||||
|
|
@ -105,6 +103,10 @@ import {
|
|||
const execFileAsync = promisify(execFile);
|
||||
const OPENCODE_DISCOVERY_TIMEOUT_MS = 10_000;
|
||||
const OPENCODE_INTERACTIVE_DISCOVERY_TIMEOUT_MS = 10_000;
|
||||
// On Windows, execFile cannot resolve .cmd shim extensions without invoking the shell.
|
||||
// windowsHide:true suppresses the conhost popup that the shell would otherwise flash.
|
||||
const EXEC_SHELL_OPTION =
|
||||
process.platform === "win32" ? ({ shell: true, windowsHide: true } as const) : ({} as const);
|
||||
|
||||
function errorIncludesSessionNotFound(err: unknown): boolean {
|
||||
if (!(err instanceof Error)) return false;
|
||||
|
|
@ -125,6 +127,7 @@ async function deleteOpenCodeSession(sessionId: string): Promise<void> {
|
|||
try {
|
||||
await execFileAsync("opencode", ["session", "delete", validatedSessionId], {
|
||||
timeout: 30_000,
|
||||
...EXEC_SHELL_OPTION,
|
||||
env: getOpenCodeChildEnv(),
|
||||
});
|
||||
// Drop cached list immediately so reuse / remap / restore call sites
|
||||
|
|
@ -240,10 +243,7 @@ const DISPLAY_NAME_MAX_LENGTH = 80;
|
|||
* Returns `undefined` when no usable context exists so callers can skip
|
||||
* writing the field entirely.
|
||||
*/
|
||||
function deriveDisplayName(input: {
|
||||
issueTitle?: string;
|
||||
prompt?: string;
|
||||
}): string | undefined {
|
||||
function deriveDisplayName(input: { issueTitle?: string; prompt?: string }): string | undefined {
|
||||
const pickLine = (text: string): string => {
|
||||
const line = text
|
||||
.split(/\r?\n/)
|
||||
|
|
@ -259,7 +259,10 @@ function deriveDisplayName(input: {
|
|||
const codePoints = Array.from(collapsed);
|
||||
if (codePoints.length <= DISPLAY_NAME_MAX_LENGTH) return collapsed;
|
||||
// Leave room for the ellipsis character.
|
||||
return `${codePoints.slice(0, DISPLAY_NAME_MAX_LENGTH - 1).join("").trimEnd()}…`;
|
||||
return `${codePoints
|
||||
.slice(0, DISPLAY_NAME_MAX_LENGTH - 1)
|
||||
.join("")
|
||||
.trimEnd()}…`;
|
||||
};
|
||||
|
||||
if (input.issueTitle && input.issueTitle.trim()) {
|
||||
|
|
@ -297,7 +300,7 @@ async function getTmuxForegroundCommand(sessionName: string): Promise<string | n
|
|||
const { stdout } = await execFileAsync(
|
||||
"tmux",
|
||||
["display-message", "-p", "-t", sessionName, "#{pane_current_command}"],
|
||||
{ timeout: 5_000 },
|
||||
{ timeout: 5_000, windowsHide: true },
|
||||
);
|
||||
const command = stdout.trim();
|
||||
return command.length > 0 ? command : null;
|
||||
|
|
@ -366,13 +369,16 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
}
|
||||
|
||||
function normalizePath(path: string): string {
|
||||
return resolve(path).replace(/\/$/, "");
|
||||
return resolve(path).replace(/[/\\]$/, "");
|
||||
}
|
||||
|
||||
function isPathInside(path: string, parentPath: string): boolean {
|
||||
const normalizedPath = normalizePath(path);
|
||||
const normalizedParent = normalizePath(parentPath);
|
||||
return normalizedPath === normalizedParent || normalizedPath.startsWith(`${normalizedParent}/`);
|
||||
const sep = process.platform === "win32" ? "\\" : "/";
|
||||
return (
|
||||
normalizedPath === normalizedParent || normalizedPath.startsWith(`${normalizedParent}${sep}`)
|
||||
);
|
||||
}
|
||||
|
||||
function getManagedWorkspaceRoots(projectId: string, projectPath: string): string[] {
|
||||
|
|
@ -432,10 +438,13 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
}
|
||||
|
||||
function requiresNativeRestore(agentName: string): boolean {
|
||||
// kimicode is intentionally excluded: kimi's session dir only exists if the
|
||||
// previous launch ran far enough to write to ~/.kimi/sessions. A failed
|
||||
// launch (e.g. the --agent-file YAML crash) leaves no session to resume,
|
||||
// and falling back to a fresh getLaunchCommand is the only sensible choice.
|
||||
return (
|
||||
agentName === "claude-code" ||
|
||||
agentName === "codex" ||
|
||||
agentName === "kimicode" ||
|
||||
agentName === "opencode"
|
||||
);
|
||||
}
|
||||
|
|
@ -507,12 +516,10 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
}
|
||||
|
||||
const SESSION_CACHE_TTL_MS = 35_000;
|
||||
let sessionCache:
|
||||
| {
|
||||
sessions: Session[];
|
||||
expiresAt: number;
|
||||
}
|
||||
| null = null;
|
||||
let sessionCache: {
|
||||
sessions: Session[];
|
||||
expiresAt: number;
|
||||
} | null = null;
|
||||
const ensureOrchestratorPromises = new Map<string, Promise<Session>>();
|
||||
|
||||
function invalidateCache(): void {
|
||||
|
|
@ -776,6 +783,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
{
|
||||
cwd: project.path,
|
||||
timeout: 5_000,
|
||||
...EXEC_SHELL_OPTION,
|
||||
},
|
||||
);
|
||||
|
||||
|
|
@ -867,8 +875,9 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
);
|
||||
// After config validation, plugin is always set if tracker/scm exists
|
||||
// (either from user config or auto-generated from package/path)
|
||||
const tracker =
|
||||
project.tracker?.plugin ? registry.get<Tracker>("tracker", project.tracker.plugin) : null;
|
||||
const tracker = project.tracker?.plugin
|
||||
? registry.get<Tracker>("tracker", project.tracker.plugin)
|
||||
: null;
|
||||
const scm = project.scm?.plugin ? registry.get<SCM>("scm", project.scm.plugin) : null;
|
||||
|
||||
return { runtime, agent, workspace, tracker, scm };
|
||||
|
|
@ -1008,7 +1017,12 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
// Fabricated handles (constructed as fallback for external sessions) should
|
||||
// NOT override status to "killed" — we don't know if the session ever had
|
||||
// a tmux session, and we'd clobber meaningful statuses like "pr_open".
|
||||
if (handleFromMetadata && session.runtimeHandle && plugins.runtime && session.status !== "spawning") {
|
||||
if (
|
||||
handleFromMetadata &&
|
||||
session.runtimeHandle &&
|
||||
plugins.runtime &&
|
||||
session.status !== "spawning"
|
||||
) {
|
||||
try {
|
||||
const alive = await plugins.runtime.isAlive(session.runtimeHandle);
|
||||
if (!alive) {
|
||||
|
|
@ -1652,7 +1666,8 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
AO_CALLER_TYPE: "orchestrator",
|
||||
AO_PROJECT_ID: orchestratorConfig.projectId,
|
||||
AO_CONFIG_PATH: config.configPath,
|
||||
...(config.port !== undefined && config.port !== null && { AO_PORT: String(config.port) }),
|
||||
...(config.port !== undefined &&
|
||||
config.port !== null && { AO_PORT: String(config.port) }),
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
|
|
@ -1950,9 +1965,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
expiresAt: Date.now() + SESSION_CACHE_TTL_MS,
|
||||
};
|
||||
|
||||
return projectId
|
||||
? sessions.filter((session) => session.projectId === projectId)
|
||||
: sessions;
|
||||
return projectId ? sessions.filter((session) => session.projectId === projectId) : sessions;
|
||||
}
|
||||
|
||||
async function get(sessionId: SessionId): Promise<Session | null> {
|
||||
|
|
@ -2556,8 +2569,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
try {
|
||||
await sendWithConfirmation(prepared);
|
||||
} catch (err) {
|
||||
const shouldRetryWithRestore =
|
||||
prepared.restoredAt === undefined && isRestorable(prepared);
|
||||
const shouldRetryWithRestore = prepared.restoredAt === undefined && isRestorable(prepared);
|
||||
|
||||
if (!shouldRetryWithRestore) {
|
||||
if (err instanceof Error) {
|
||||
|
|
@ -2614,7 +2626,8 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
);
|
||||
|
||||
for (const { sessionName, raw: otherRaw } of activeRecords) {
|
||||
if (!otherRaw || isOrchestratorSessionRecord(sessionName, otherRaw, project.sessionPrefix)) continue;
|
||||
if (!otherRaw || isOrchestratorSessionRecord(sessionName, otherRaw, project.sessionPrefix))
|
||||
continue;
|
||||
|
||||
const samePr = otherRaw["pr"] === pr.url;
|
||||
const sameBranch =
|
||||
|
|
@ -2860,6 +2873,22 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
: {}),
|
||||
},
|
||||
};
|
||||
// Orchestrator launches need the original systemPromptFile so the agent
|
||||
// boots as the orchestrator (not a bare TUI). spawnOrchestrator wrote it to
|
||||
// {baseDir}/orchestrator-prompt-{sessionId}.md and references it via
|
||||
// agentLaunchConfig.systemPromptFile. On restore we must re-attach it,
|
||||
// otherwise getLaunchCommand() (the fallback when getRestoreCommand returns
|
||||
// null — e.g. Codex with no resumable thread for the worktree) starts a
|
||||
// plain agent without orchestrator instructions.
|
||||
const orchestratorSystemPromptFile = ((): string | undefined => {
|
||||
if (selection.role !== "orchestrator") return undefined;
|
||||
// V2 storage: orchestrator-prompt-{sessionId}.md lives in the project dir
|
||||
// (~/.agent-orchestrator/projects/{projectId}/), not the legacy hashed base dir.
|
||||
const baseDir = getProjectDir(projectId);
|
||||
const file = join(baseDir, `orchestrator-prompt-${sessionId}.md`);
|
||||
return existsSync(file) ? file : undefined;
|
||||
})();
|
||||
|
||||
const agentLaunchConfig = {
|
||||
sessionId,
|
||||
projectConfig: projectConfigForLaunch,
|
||||
|
|
@ -2868,6 +2897,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
permissions: selection.role === "orchestrator" ? "permissionless" : selection.permissions,
|
||||
model: selection.model,
|
||||
subagent: selection.subagent,
|
||||
...(orchestratorSystemPromptFile && { systemPromptFile: orchestratorSystemPromptFile }),
|
||||
};
|
||||
|
||||
if (plugins.agent.getRestoreCommand) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,16 @@
|
|||
import { createHash } from "node:crypto";
|
||||
import { relative, resolve, sep } from "node:path";
|
||||
|
||||
// Normalize a filesystem path to a posix-style string so the storage key hash
|
||||
// is stable across Windows/macOS/Linux. Without this, the same repo produces a
|
||||
// different storage key on Windows (drive letters + backslashes) than on Unix.
|
||||
function toPosixPath(p: string): string {
|
||||
const resolved = resolve(p);
|
||||
// On Windows, strip drive letter and flip separators. On Unix this is a no-op.
|
||||
const driveStripped = resolved.replace(/^[A-Za-z]:/, "");
|
||||
return driveStripped.split(sep).join("/");
|
||||
}
|
||||
|
||||
export interface StorageKeyInput {
|
||||
originUrl: string | null;
|
||||
gitRoot: string;
|
||||
|
|
@ -43,7 +53,7 @@ export function relativeSubdir(gitRoot: string, projectPath: string): string {
|
|||
}
|
||||
|
||||
export function deriveStorageKey({ originUrl, gitRoot, projectPath }: StorageKeyInput): string {
|
||||
const normalizedOrigin = originUrl !== null ? normalizeOriginUrl(originUrl) : `local://${resolve(gitRoot)}`;
|
||||
const normalizedOrigin = originUrl !== null ? normalizeOriginUrl(originUrl) : `local://${toPosixPath(gitRoot)}`;
|
||||
const subdir = relativeSubdir(gitRoot, projectPath);
|
||||
const raw = `${normalizedOrigin}#${subdir}`;
|
||||
return createHash("sha256").update(raw).digest("hex").slice(0, 12);
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { execFile } from "node:child_process";
|
|||
/** Run a tmux command and return stdout. */
|
||||
function tmux(...args: string[]): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile("tmux", args, { timeout: 10_000 }, (error, stdout, stderr) => {
|
||||
execFile("tmux", args, { timeout: 10_000, windowsHide: true }, (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
// tmux exits non-zero for many benign cases (no sessions, etc.)
|
||||
reject(new Error(`tmux ${args[0]} failed: ${stderr || error.message}`));
|
||||
|
|
|
|||
|
|
@ -4,14 +4,20 @@
|
|||
|
||||
import { open, stat } from "node:fs/promises";
|
||||
import type { OrchestratorConfig } from "./types.js";
|
||||
import { isWindows } from "./platform.js";
|
||||
|
||||
/**
|
||||
* POSIX-safe shell escaping: wraps value in single quotes,
|
||||
* escaping any embedded single quotes as '\\'' .
|
||||
* Shell-safe escaping for the platform's default shell.
|
||||
*
|
||||
* Safe for use in both `sh -c` and `execFile` contexts.
|
||||
* - Unix (/bin/sh): wraps in single quotes, escapes embedded ' as '\''
|
||||
* - Windows (PowerShell): wraps in single quotes, escapes embedded ' as ''
|
||||
*/
|
||||
export function shellEscape(arg: string): string {
|
||||
if (isWindows()) {
|
||||
// PowerShell: single-quoted strings use '' for embedded single quotes
|
||||
return "'" + arg.replace(/'/g, "''") + "'";
|
||||
}
|
||||
// POSIX sh: single-quoted strings use '\'' for embedded single quotes
|
||||
return "'" + arg.replace(/'/g, "'\\''") + "'";
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,121 @@
|
|||
/**
|
||||
* Sideband registry of live Windows pty-host processes.
|
||||
*
|
||||
* The runtime-process plugin spawns each pty-host with `detached: true` so it
|
||||
* survives parent exit (mirroring the tmux daemon model on Linux). That same
|
||||
* detachment means `taskkill /T /F /PID <ao_pid>` cannot reach pty-hosts —
|
||||
* they're in their own console group, outside the parent's process tree.
|
||||
*
|
||||
* Per-session JSON metadata also can't be the source of truth for cleanup:
|
||||
* if a worktree is rm-rf'd or session JSON is lost (legacy storage cleanup,
|
||||
* crash mid-write, manual recovery), the runtime-side processes become
|
||||
* unreachable and `ao stop` orphans them silently.
|
||||
*
|
||||
* This registry is a flat list at `~/.agent-orchestrator/windows-pty-hosts.json`
|
||||
* that AO writes on spawn and reads on `ao stop` (and on next `ao start`'s
|
||||
* orphan sweep, future work). It exists outside session JSON so cleanup of
|
||||
* sessions never severs AO's ability to find and graceful-kill the hosts.
|
||||
*
|
||||
* Reads auto-prune entries whose PID is no longer alive.
|
||||
*/
|
||||
import { existsSync, readFileSync, unlinkSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { atomicWriteFileSync } from "./atomic-write.js";
|
||||
|
||||
export interface WindowsPtyHostEntry {
|
||||
sessionId: string;
|
||||
ptyHostPid: number;
|
||||
pipePath: string;
|
||||
registeredAt: string;
|
||||
}
|
||||
|
||||
// Resolved lazily so tests that mock node:os (vitest mock factories run after
|
||||
// module evaluation) can override `homedir()` before the first read/write.
|
||||
function getRegistryFile(): string {
|
||||
return join(homedir(), ".agent-orchestrator", "windows-pty-hosts.json");
|
||||
}
|
||||
|
||||
function readRaw(): WindowsPtyHostEntry[] {
|
||||
const file = getRegistryFile();
|
||||
if (!existsSync(file)) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(file, "utf-8")) as unknown;
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed.filter(
|
||||
(e): e is WindowsPtyHostEntry =>
|
||||
typeof e === "object" &&
|
||||
e !== null &&
|
||||
typeof (e as WindowsPtyHostEntry).sessionId === "string" &&
|
||||
typeof (e as WindowsPtyHostEntry).ptyHostPid === "number" &&
|
||||
typeof (e as WindowsPtyHostEntry).pipePath === "string",
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function writeRaw(entries: WindowsPtyHostEntry[]): void {
|
||||
const file = getRegistryFile();
|
||||
if (entries.length === 0) {
|
||||
try {
|
||||
unlinkSync(file);
|
||||
} catch {
|
||||
/* file may not exist */
|
||||
}
|
||||
return;
|
||||
}
|
||||
atomicWriteFileSync(file, JSON.stringify(entries, null, 2));
|
||||
}
|
||||
|
||||
function isAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (err: unknown) {
|
||||
return (err as { code?: string }).code === "EPERM";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add (or replace) an entry for a freshly-spawned pty-host.
|
||||
* If a stale entry for the same `sessionId` exists, it's overwritten.
|
||||
*/
|
||||
export function registerWindowsPtyHost(entry: Omit<WindowsPtyHostEntry, "registeredAt">): void {
|
||||
const next = readRaw().filter((e) => e.sessionId !== entry.sessionId);
|
||||
next.push({ ...entry, registeredAt: new Date().toISOString() });
|
||||
writeRaw(next);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an entry by `sessionId`. No-op if absent.
|
||||
*/
|
||||
export function unregisterWindowsPtyHost(sessionId: string): void {
|
||||
const before = readRaw();
|
||||
const next = before.filter((e) => e.sessionId !== sessionId);
|
||||
if (next.length === before.length) return;
|
||||
writeRaw(next);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read all live entries, auto-pruning entries whose PID is no longer alive.
|
||||
* The on-disk file is rewritten if any entries were pruned.
|
||||
*/
|
||||
export function getWindowsPtyHosts(): WindowsPtyHostEntry[] {
|
||||
const all = readRaw();
|
||||
const live = all.filter((e) => isAlive(e.ptyHostPid));
|
||||
if (live.length !== all.length) writeRaw(live);
|
||||
return live;
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort: delete the entire registry file. Used for tests and recovery.
|
||||
*/
|
||||
export function clearWindowsPtyHostRegistry(): void {
|
||||
writeRaw([]);
|
||||
}
|
||||
|
||||
/** Exported for tests. */
|
||||
export function __getWindowsPtyRegistryFile(): string {
|
||||
return getRegistryFile();
|
||||
}
|
||||
|
|
@ -20,7 +20,10 @@ import { randomUUID } from "node:crypto";
|
|||
import { migrateStorage } from "@aoagents/ao-core";
|
||||
import { makeSession } from "./helpers/session-factory.js";
|
||||
|
||||
describe("migrate-storage → agent-codex.getRestoreCommand round-trip", () => {
|
||||
// Skipped on Windows: exercises migration FROM the legacy hash-dir layout
|
||||
// that shipped only on Linux/macOS in V1. Windows installs never have that
|
||||
// state, and the fixtures rely on POSIX path semantics that don't apply on NTFS.
|
||||
describe.skipIf(process.platform === "win32")("migrate-storage → agent-codex.getRestoreCommand round-trip", () => {
|
||||
let testDir: string;
|
||||
let aoBaseDir: string;
|
||||
let configPath: string;
|
||||
|
|
|
|||
|
|
@ -29,11 +29,15 @@ describe("notifier-desktop integration", () => {
|
|||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockPlatform.mockReturnValue("darwin");
|
||||
mockExecFile.mockImplementation(
|
||||
(_cmd: string, _args: string[], cb: (err: Error | null) => void) => {
|
||||
cb(null);
|
||||
},
|
||||
);
|
||||
mockExecFile.mockImplementation((..._args: unknown[]) => {
|
||||
// execFile is called as (cmd, args, cb) on darwin/linux and as
|
||||
// (cmd, args, opts, cb) on win32 — pick whichever trailing arg is the
|
||||
// callback so both shapes work.
|
||||
const cb = _args.find((a) => typeof a === "function") as
|
||||
| ((err: Error | null) => void)
|
||||
| undefined;
|
||||
cb?.(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("config -> behavior flow", () => {
|
||||
|
|
@ -139,15 +143,32 @@ describe("notifier-desktop integration", () => {
|
|||
expect(args).not.toContain("--urgency=critical");
|
||||
});
|
||||
|
||||
it("win32 -> no execFile call, warns", async () => {
|
||||
it("win32 -> powershell.exe with EncodedCommand toast XML", async () => {
|
||||
mockPlatform.mockReturnValue("win32");
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
const notifier = desktopPlugin.create();
|
||||
await notifier.notify(makeEvent());
|
||||
await notifier.notify(makeEvent({ message: "ci test" }));
|
||||
|
||||
expect(mockExecFile).toHaveBeenCalledWith(
|
||||
"powershell.exe",
|
||||
expect.arrayContaining(["-EncodedCommand"]),
|
||||
expect.objectContaining({ windowsHide: true }),
|
||||
expect.any(Function),
|
||||
);
|
||||
const args = mockExecFile.mock.calls[0][1] as string[];
|
||||
const encoded = args[args.indexOf("-EncodedCommand") + 1];
|
||||
const script = Buffer.from(encoded, "base64").toString("utf16le");
|
||||
expect(script).toContain("ToastNotificationManager");
|
||||
expect(script).toContain("ci test");
|
||||
});
|
||||
|
||||
it("unsupported platform -> no execFile, warns", async () => {
|
||||
mockPlatform.mockReturnValue("freebsd");
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const notifier = desktopPlugin.create();
|
||||
await notifier.notify(makeEvent());
|
||||
expect(mockExecFile).not.toHaveBeenCalled();
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("win32"));
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("freebsd"));
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { afterAll, describe, expect, it } from "vitest";
|
||||
import { tmpdir } from "node:os";
|
||||
import processPlugin from "@aoagents/ao-plugin-runtime-process";
|
||||
import type { RuntimeHandle } from "@aoagents/ao-core";
|
||||
import { sleep } from "./helpers/polling.js";
|
||||
|
|
@ -8,6 +9,14 @@ describe("runtime-process (integration)", () => {
|
|||
const sessionId = `proc-inttest-${Date.now()}`;
|
||||
let handle: RuntimeHandle;
|
||||
|
||||
// Platform-native stdin→stdout echo. We can't use `node -e ...` here:
|
||||
// on Windows the launch command goes through `pwsh -Command "..."`, which
|
||||
// treats a quoted path as a string literal rather than invoking it, so
|
||||
// node never actually runs. `findstr "x*"` matches every line and prints it
|
||||
// verbatim — Windows' closest builtin to `cat`.
|
||||
const echoCommand = process.platform === "win32" ? `findstr "x*"` : "cat";
|
||||
const workspacePath = tmpdir();
|
||||
|
||||
afterAll(async () => {
|
||||
try {
|
||||
await runtime.destroy(handle);
|
||||
|
|
@ -19,8 +28,8 @@ describe("runtime-process (integration)", () => {
|
|||
it("creates a child process", async () => {
|
||||
handle = await runtime.create({
|
||||
sessionId,
|
||||
workspacePath: "/tmp",
|
||||
launchCommand: "cat", // cat echoes stdin to stdout
|
||||
workspacePath,
|
||||
launchCommand: echoCommand,
|
||||
environment: { AO_TEST: "1" },
|
||||
});
|
||||
|
||||
|
|
@ -35,10 +44,21 @@ describe("runtime-process (integration)", () => {
|
|||
|
||||
it("sendMessage writes to stdin and output is captured", async () => {
|
||||
await runtime.sendMessage(handle, "hello from test");
|
||||
await sleep(200); // give time for stdout to be captured
|
||||
const output = await runtime.getOutput(handle);
|
||||
// Poll until the payload appears instead of using a fixed sleep: round-trip
|
||||
// latency varies wildly across platforms and runners (Unix direct-stdin:
|
||||
// ~ms; Windows ConPTY through named pipe + pwsh + findstr startup: hundreds
|
||||
// of ms to seconds under AV / cold-cache conditions). A fixed sleep either
|
||||
// flakes or wastes time. Polling for the substring (not just non-empty
|
||||
// output) is also robust to incidental shell banners arriving first.
|
||||
const deadline = Date.now() + 10_000;
|
||||
let output = "";
|
||||
while (Date.now() < deadline) {
|
||||
output = await runtime.getOutput(handle);
|
||||
if (output.includes("hello from test")) break;
|
||||
await sleep(100);
|
||||
}
|
||||
expect(output).toContain("hello from test");
|
||||
});
|
||||
}, 15_000);
|
||||
|
||||
it("getMetrics returns uptime", async () => {
|
||||
const metrics = await runtime.getMetrics!(handle);
|
||||
|
|
@ -55,8 +75,8 @@ describe("runtime-process (integration)", () => {
|
|||
await expect(
|
||||
runtime.create({
|
||||
sessionId,
|
||||
workspacePath: "/tmp",
|
||||
launchCommand: "cat",
|
||||
workspacePath,
|
||||
launchCommand: echoCommand,
|
||||
environment: {},
|
||||
}),
|
||||
).rejects.toThrow("already exists");
|
||||
|
|
|
|||
|
|
@ -11,12 +11,19 @@ vi.mock("node:fs/promises", async (importOriginal) => {
|
|||
});
|
||||
|
||||
// Mock activity log utilities from core
|
||||
const { mockAppendActivityEntry, mockReadLastActivityEntry, mockRecordTerminalActivity } =
|
||||
vi.hoisted(() => ({
|
||||
mockAppendActivityEntry: vi.fn().mockResolvedValue(undefined),
|
||||
mockReadLastActivityEntry: vi.fn().mockResolvedValue(null),
|
||||
mockRecordTerminalActivity: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
const {
|
||||
mockAppendActivityEntry,
|
||||
mockReadLastActivityEntry,
|
||||
mockRecordTerminalActivity,
|
||||
mockIsWindows,
|
||||
mockReadFileSync,
|
||||
} = vi.hoisted(() => ({
|
||||
mockAppendActivityEntry: vi.fn().mockResolvedValue(undefined),
|
||||
mockReadLastActivityEntry: vi.fn().mockResolvedValue(null),
|
||||
mockRecordTerminalActivity: vi.fn().mockResolvedValue(undefined),
|
||||
mockIsWindows: vi.fn(() => false),
|
||||
mockReadFileSync: vi.fn(() => ""),
|
||||
}));
|
||||
|
||||
vi.mock("@aoagents/ao-core", async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>;
|
||||
|
|
@ -25,9 +32,15 @@ vi.mock("@aoagents/ao-core", async (importOriginal) => {
|
|||
appendActivityEntry: mockAppendActivityEntry,
|
||||
readLastActivityEntry: mockReadLastActivityEntry,
|
||||
recordTerminalActivity: mockRecordTerminalActivity,
|
||||
isWindows: mockIsWindows,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("node:fs", async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>;
|
||||
return { ...actual, readFileSync: mockReadFileSync };
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hoisted mocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -181,9 +194,13 @@ describe("getLaunchCommand", () => {
|
|||
expect(cmd).toBe("aider --yes --model 'sonnet' --message 'Go'");
|
||||
});
|
||||
|
||||
it("escapes single quotes in prompt (POSIX shell escaping)", () => {
|
||||
it("escapes single quotes in prompt", () => {
|
||||
const cmd = agent.getLaunchCommand(makeLaunchConfig({ prompt: "it's broken" }));
|
||||
expect(cmd).toContain("--message 'it'\\''s broken'");
|
||||
if (process.platform === "win32") {
|
||||
expect(cmd).toContain("--message 'it''s broken'");
|
||||
} else {
|
||||
expect(cmd).toContain("--message 'it'\\''s broken'");
|
||||
}
|
||||
});
|
||||
|
||||
it("omits optional flags when not provided", () => {
|
||||
|
|
@ -192,6 +209,15 @@ describe("getLaunchCommand", () => {
|
|||
expect(cmd).not.toContain("--model");
|
||||
expect(cmd).not.toContain("--message");
|
||||
});
|
||||
|
||||
it("inlines systemPromptFile content on Windows instead of $(cat ...)", () => {
|
||||
mockIsWindows.mockReturnValueOnce(true);
|
||||
mockReadFileSync.mockReturnValueOnce("You are a senior engineer.");
|
||||
const cmd = agent.getLaunchCommand(makeLaunchConfig({ systemPromptFile: "C:\\prompts\\sys.md" }));
|
||||
expect(cmd).toContain("--system-prompt");
|
||||
expect(cmd).toContain("You are a senior engineer.");
|
||||
expect(cmd).not.toContain("$(cat");
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
|
|
@ -282,6 +308,39 @@ describe("isProcessRunning", () => {
|
|||
});
|
||||
expect(await agent.isProcessRunning(makeTmuxHandle())).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for tmux handle on Windows without spawning ps", async () => {
|
||||
mockIsWindows.mockReturnValue(true);
|
||||
mockExecFileAsync.mockRejectedValue(new Error("ps not available on Windows"));
|
||||
expect(await agent.isProcessRunning(makeTmuxHandle())).toBe(false);
|
||||
expect(mockExecFileAsync).not.toHaveBeenCalledWith("ps", expect.anything(), expect.anything());
|
||||
mockIsWindows.mockReturnValue(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isProcessRunning with process runtime", () => {
|
||||
it("returns true for a live PID", async () => {
|
||||
const handle = {
|
||||
id: "test-session",
|
||||
runtimeName: "process",
|
||||
data: { pid: process.pid }, // current process — known alive
|
||||
};
|
||||
const agent = create();
|
||||
const result = await agent.isProcessRunning(handle as unknown as RuntimeHandle);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for a dead PID", async () => {
|
||||
const killSpy = vi.spyOn(process, "kill").mockImplementationOnce(() => {
|
||||
const err = Object.assign(new Error("ESRCH"), { code: "ESRCH" });
|
||||
throw err;
|
||||
});
|
||||
const handle = { id: "test", runtimeName: "process", data: { pid: 999999 } };
|
||||
const agent = create();
|
||||
const result = await agent.isProcessRunning(handle as any);
|
||||
expect(result).toBe(false);
|
||||
killSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
hasRecentCommits,
|
||||
DEFAULT_READY_THRESHOLD_MS,
|
||||
DEFAULT_ACTIVE_WINDOW_MS,
|
||||
isWindows,
|
||||
type Agent,
|
||||
type AgentSessionInfo,
|
||||
type AgentLaunchConfig,
|
||||
|
|
@ -22,7 +23,7 @@ import { execFile, execFileSync } from "node:child_process";
|
|||
import { promisify } from "node:util";
|
||||
import { stat, access, readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { constants } from "node:fs";
|
||||
import { constants, readFileSync } from "node:fs";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
|
|
@ -107,7 +108,12 @@ function createAiderAgent(): Agent {
|
|||
}
|
||||
|
||||
if (config.systemPromptFile) {
|
||||
parts.push("--system-prompt", `"$(cat ${shellEscape(config.systemPromptFile)})"`);
|
||||
if (isWindows()) {
|
||||
const content = readFileSync(config.systemPromptFile, "utf-8");
|
||||
parts.push("--system-prompt", shellEscape(content));
|
||||
} else {
|
||||
parts.push("--system-prompt", `"$(cat ${shellEscape(config.systemPromptFile)})"`);
|
||||
}
|
||||
} else if (config.systemPrompt) {
|
||||
parts.push("--system-prompt", shellEscape(config.systemPrompt));
|
||||
}
|
||||
|
|
@ -207,6 +213,8 @@ function createAiderAgent(): Agent {
|
|||
async isProcessRunning(handle: RuntimeHandle): Promise<boolean> {
|
||||
try {
|
||||
if (handle.runtimeName === "tmux" && handle.id) {
|
||||
// ps -eo is Unix-only; guard against stale tmux handles on Windows
|
||||
if (isWindows()) return false;
|
||||
const { stdout: ttyOut } = await execFileAsync(
|
||||
"tmux",
|
||||
["list-panes", "-t", handle.id, "-F", "#{pane_tty}"],
|
||||
|
|
@ -294,7 +302,11 @@ export function create(): Agent {
|
|||
|
||||
export function detect(): boolean {
|
||||
try {
|
||||
execFileSync("aider", ["--version"], { stdio: "ignore" });
|
||||
execFileSync("aider", ["--version"], {
|
||||
stdio: "ignore",
|
||||
shell: isWindows(),
|
||||
windowsHide: true,
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ function makeSession(overrides: Partial<Session> = {}): Session {
|
|||
workspacePath,
|
||||
runtimeHandle: handle,
|
||||
agentInfo: null,
|
||||
createdAt: new Date(),
|
||||
createdAt: new Date(Date.now() - 24 * 60 * 60 * 1000), // 1 day ago (well before any JSONL entries)
|
||||
lastActivityAt: new Date(),
|
||||
metadata: {},
|
||||
...overrides,
|
||||
|
|
@ -76,7 +76,7 @@ describe("Claude Code Activity Detection", () => {
|
|||
});
|
||||
|
||||
it("handles Windows paths (no leading slash)", () => {
|
||||
expect(toClaudeProjectPath("C:\\Users\\dev\\project")).toBe("C-Users-dev-project");
|
||||
expect(toClaudeProjectPath("C:\\Users\\dev\\project")).toBe("C--Users-dev-project");
|
||||
});
|
||||
|
||||
it("handles consecutive dots and slashes", () => {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { join as pathJoin } from "node:path";
|
||||
import {
|
||||
createActivitySignal,
|
||||
type Session,
|
||||
|
|
@ -14,22 +15,26 @@ const {
|
|||
mockExecFileAsync,
|
||||
mockReaddir,
|
||||
mockReadFile,
|
||||
mockReadFileSync,
|
||||
mockStat,
|
||||
mockHomedir,
|
||||
mockWriteFile,
|
||||
mockMkdir,
|
||||
mockChmod,
|
||||
mockExistsSync,
|
||||
mockIsWindows,
|
||||
} = vi.hoisted(() => ({
|
||||
mockExecFileAsync: vi.fn(),
|
||||
mockReaddir: vi.fn(),
|
||||
mockReadFile: vi.fn(),
|
||||
mockReadFileSync: vi.fn(() => ""),
|
||||
mockStat: vi.fn(),
|
||||
mockHomedir: vi.fn(() => "/mock/home"),
|
||||
mockWriteFile: vi.fn().mockResolvedValue(undefined),
|
||||
mockMkdir: vi.fn().mockResolvedValue(undefined),
|
||||
mockChmod: vi.fn().mockResolvedValue(undefined),
|
||||
mockExistsSync: vi.fn().mockReturnValue(false),
|
||||
mockIsWindows: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
vi.mock("node:child_process", () => {
|
||||
|
|
@ -50,12 +55,21 @@ vi.mock("node:fs/promises", () => ({
|
|||
|
||||
vi.mock("node:fs", () => ({
|
||||
existsSync: mockExistsSync,
|
||||
readFileSync: mockReadFileSync,
|
||||
}));
|
||||
|
||||
vi.mock("node:os", () => ({
|
||||
homedir: mockHomedir,
|
||||
}));
|
||||
|
||||
vi.mock("@aoagents/ao-core", async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>;
|
||||
return {
|
||||
...actual,
|
||||
isWindows: mockIsWindows,
|
||||
};
|
||||
});
|
||||
|
||||
import {
|
||||
create,
|
||||
manifest,
|
||||
|
|
@ -63,6 +77,7 @@ import {
|
|||
resetPsCache,
|
||||
toClaudeProjectPath,
|
||||
METADATA_UPDATER_SCRIPT,
|
||||
METADATA_UPDATER_SCRIPT_NODE,
|
||||
} from "./index.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -148,6 +163,8 @@ beforeEach(() => {
|
|||
vi.clearAllMocks();
|
||||
resetPsCache();
|
||||
mockHomedir.mockReturnValue("/mock/home");
|
||||
// Default: non-Windows so existing tests are unaffected
|
||||
mockIsWindows.mockReturnValue(false);
|
||||
});
|
||||
|
||||
describe("toClaudeProjectPath", () => {
|
||||
|
|
@ -175,8 +192,10 @@ describe("toClaudeProjectPath", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("strips Windows drive colons and folds backslashes", () => {
|
||||
expect(toClaudeProjectPath("C:\\Users\\dev\\foo")).toBe("C-Users-dev-foo");
|
||||
it("encodes Windows drive colons and backslashes as dashes", () => {
|
||||
// Verified on-disk: Claude Code on Windows produces `C--Users-dev-foo`
|
||||
// (the colon position is a dash, not stripped). See commit 582c5373.
|
||||
expect(toClaudeProjectPath("C:\\Users\\dev\\foo")).toBe("C--Users-dev-foo");
|
||||
});
|
||||
|
||||
it("collapses any other non-alphanumeric character into a dash", () => {
|
||||
|
|
@ -291,6 +310,17 @@ describe("getLaunchCommand", () => {
|
|||
expect(cmd).not.toMatch(/\s-p\s/);
|
||||
expect(cmd).toContain("-- 'Do the task'");
|
||||
});
|
||||
|
||||
it("inlines systemPromptFile content on Windows instead of $(cat ...)", () => {
|
||||
mockIsWindows.mockReturnValueOnce(true);
|
||||
mockReadFileSync.mockReturnValueOnce("You are a helpful assistant.");
|
||||
const cmd = agent.getLaunchCommand(
|
||||
makeLaunchConfig({ systemPromptFile: "C:\\prompts\\system.md", prompt: "Do the task" }),
|
||||
);
|
||||
expect(cmd).toContain("--append-system-prompt");
|
||||
expect(cmd).toContain("You are a helpful assistant.");
|
||||
expect(cmd).not.toContain("$(cat");
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
|
|
@ -417,6 +447,15 @@ describe("isProcessRunning", () => {
|
|||
});
|
||||
expect(await agent.isProcessRunning(makeTmuxHandle())).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for tmux handle on Windows without spawning ps", async () => {
|
||||
mockIsWindows.mockReturnValue(true);
|
||||
// ps should never be called — getCachedProcessList guards against Windows
|
||||
mockExecFileAsync.mockRejectedValue(new Error("ps not available on Windows"));
|
||||
expect(await agent.isProcessRunning(makeTmuxHandle())).toBe(false);
|
||||
expect(mockExecFileAsync).not.toHaveBeenCalledWith("ps", expect.anything(), expect.anything());
|
||||
mockIsWindows.mockReturnValue(false);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
|
|
@ -540,7 +579,7 @@ describe("getSessionInfo", () => {
|
|||
mockJsonlFiles('{"type":"user","message":{"content":"hello"}}');
|
||||
await agent.getSessionInfo(makeSession({ workspacePath: "/Users/dev/.worktrees/ao/ao-3" }));
|
||||
expect(mockReaddir).toHaveBeenCalledWith(
|
||||
"/mock/home/.claude/projects/-Users-dev--worktrees-ao-ao-3",
|
||||
pathJoin("/mock/home", ".claude", "projects", "-Users-dev--worktrees-ao-ao-3"),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -553,7 +592,12 @@ describe("getSessionInfo", () => {
|
|||
}),
|
||||
);
|
||||
expect(mockReaddir).toHaveBeenCalledWith(
|
||||
"/mock/home/.claude/projects/-Users-dev--agent-orchestrator-projects-graph-isomorphism-d185b44d56-worktrees-gi-orchestrator",
|
||||
pathJoin(
|
||||
"/mock/home",
|
||||
".claude",
|
||||
"projects",
|
||||
"-Users-dev--agent-orchestrator-projects-graph-isomorphism-d185b44d56-worktrees-gi-orchestrator",
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -952,7 +996,7 @@ describe("hook setup — relative path (symlink-safe)", () => {
|
|||
);
|
||||
expect(scriptWrite).toBeDefined();
|
||||
expect(scriptWrite![0]).toBe(
|
||||
"/Users/equinox/.worktrees/integrator/integrator-5/.claude/metadata-updater.sh",
|
||||
pathJoin("/Users/equinox/.worktrees/integrator/integrator-5", ".claude", "metadata-updater.sh"),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -961,3 +1005,151 @@ describe("hook setup — relative path (symlink-safe)", () => {
|
|||
expect(mockWriteFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// setupWorkspaceHooks on win32 — Node.js hook script
|
||||
// =========================================================================
|
||||
describe("setupWorkspaceHooks on win32", () => {
|
||||
const agent = create();
|
||||
|
||||
/** Extract the hook command written to settings.json */
|
||||
function getWrittenHookCommand(): string {
|
||||
const settingsWrite = mockWriteFile.mock.calls.find(
|
||||
([path]: unknown[]) => typeof path === "string" && path.endsWith("settings.json"),
|
||||
);
|
||||
expect(settingsWrite).toBeDefined();
|
||||
const parsed = JSON.parse(settingsWrite![1] as string);
|
||||
return parsed.hooks.PostToolUse[0].hooks[0].command;
|
||||
}
|
||||
|
||||
/** Get the content written to the hook script file */
|
||||
function getWrittenScriptContent(ext: string): string | undefined {
|
||||
const scriptWrite = mockWriteFile.mock.calls.find(
|
||||
([path]: unknown[]) => typeof path === "string" && path.endsWith(ext),
|
||||
);
|
||||
return scriptWrite ? (scriptWrite[1] as string) : undefined;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockIsWindows.mockReturnValue(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockIsWindows.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it("writes a Node.js hook script instead of bash on Windows", async () => {
|
||||
await agent.setupWorkspaceHooks!(
|
||||
"C:\\\\Users\\\\dev\\\\workspace",
|
||||
{} as WorkspaceHooksConfig,
|
||||
);
|
||||
|
||||
// The .cjs file must have been written (.cjs forces CJS mode in ESM workspaces)
|
||||
const cjsContent = getWrittenScriptContent("metadata-updater.cjs");
|
||||
expect(cjsContent).toBeDefined();
|
||||
expect(cjsContent).toContain("#!/usr/bin/env node");
|
||||
|
||||
// Must not contain bash-isms
|
||||
expect(cjsContent).not.toContain("#!/usr/bin/env bash");
|
||||
expect(cjsContent).not.toContain("jq");
|
||||
expect(cjsContent).not.toContain("grep");
|
||||
expect(cjsContent).not.toContain("sed");
|
||||
|
||||
// The .sh and .js files must NOT have been written
|
||||
const shContent = getWrittenScriptContent("metadata-updater.sh");
|
||||
expect(shContent).toBeUndefined();
|
||||
const jsContent = getWrittenScriptContent("metadata-updater.js");
|
||||
expect(jsContent).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses node command in settings.json hook command on Windows", async () => {
|
||||
await agent.setupWorkspaceHooks!(
|
||||
"C:\\\\Users\\\\dev\\\\workspace",
|
||||
{} as WorkspaceHooksConfig,
|
||||
);
|
||||
|
||||
const hookCommand = getWrittenHookCommand();
|
||||
expect(hookCommand).toBe("node .claude/metadata-updater.cjs");
|
||||
expect(hookCommand).not.toContain(".sh");
|
||||
});
|
||||
|
||||
it("skips chmod on win32", async () => {
|
||||
await agent.setupWorkspaceHooks!(
|
||||
"C:\\\\Users\\\\dev\\\\workspace",
|
||||
{} as WorkspaceHooksConfig,
|
||||
);
|
||||
|
||||
expect(mockChmod).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("exports METADATA_UPDATER_SCRIPT_NODE with Node.js shebang", () => {
|
||||
expect(METADATA_UPDATER_SCRIPT_NODE).toContain("#!/usr/bin/env node");
|
||||
expect(METADATA_UPDATER_SCRIPT_NODE).not.toContain("jq");
|
||||
expect(METADATA_UPDATER_SCRIPT_NODE).not.toContain("grep");
|
||||
expect(METADATA_UPDATER_SCRIPT_NODE).not.toContain("sed");
|
||||
});
|
||||
|
||||
it("Node.js hook script handles gh pr create detection", () => {
|
||||
expect(METADATA_UPDATER_SCRIPT_NODE).toContain("gh");
|
||||
expect(METADATA_UPDATER_SCRIPT_NODE).toContain("pr");
|
||||
expect(METADATA_UPDATER_SCRIPT_NODE).toContain("create");
|
||||
expect(METADATA_UPDATER_SCRIPT_NODE).toContain("updateMetadataKey");
|
||||
expect(METADATA_UPDATER_SCRIPT_NODE).toContain("pr_open");
|
||||
});
|
||||
|
||||
it("Node.js hook script handles git checkout -b detection", () => {
|
||||
expect(METADATA_UPDATER_SCRIPT_NODE).toContain("checkout");
|
||||
expect(METADATA_UPDATER_SCRIPT_NODE).toContain("-b");
|
||||
});
|
||||
|
||||
it("Node.js hook script handles gh pr merge detection", () => {
|
||||
expect(METADATA_UPDATER_SCRIPT_NODE).toContain("pr\\s+merge");
|
||||
expect(METADATA_UPDATER_SCRIPT_NODE).toContain("merged");
|
||||
});
|
||||
|
||||
it("Node.js hook script validates AO_DATA_DIR against allowed directories", () => {
|
||||
// Must contain the allowlist check mirroring ao-metadata-helper.sh and
|
||||
// the Node.js wrappers in agent-workspace-hooks.ts (C-1 security fix)
|
||||
expect(METADATA_UPDATER_SCRIPT_NODE).toContain("allowedBases");
|
||||
expect(METADATA_UPDATER_SCRIPT_NODE).toContain("realpathSync");
|
||||
expect(METADATA_UPDATER_SCRIPT_NODE).toContain(".ao");
|
||||
expect(METADATA_UPDATER_SCRIPT_NODE).toContain(".agent-orchestrator");
|
||||
expect(METADATA_UPDATER_SCRIPT_NODE).toContain("os.tmpdir");
|
||||
});
|
||||
|
||||
it("does not add duplicate hook entry when called twice on Windows", async () => {
|
||||
// First call creates the hook
|
||||
await agent.setupWorkspaceHooks!(
|
||||
"C:\\\\Users\\\\dev\\\\workspace",
|
||||
{} as WorkspaceHooksConfig,
|
||||
);
|
||||
|
||||
// Simulate second call: settings.json now contains the .cjs hook
|
||||
const firstSettings = mockWriteFile.mock.calls.find(
|
||||
([path]: unknown[]) => typeof path === "string" && path.endsWith("settings.json"),
|
||||
);
|
||||
expect(firstSettings).toBeDefined();
|
||||
mockReadFile.mockResolvedValueOnce(firstSettings![1] as string);
|
||||
vi.clearAllMocks();
|
||||
mockIsWindows.mockReturnValue(true);
|
||||
|
||||
// Second call — should UPDATE the existing hook, not add a duplicate
|
||||
await agent.setupWorkspaceHooks!(
|
||||
"C:\\\\Users\\\\dev\\\\workspace",
|
||||
{} as WorkspaceHooksConfig,
|
||||
);
|
||||
|
||||
const secondSettings = mockWriteFile.mock.calls.find(
|
||||
([path]: unknown[]) => typeof path === "string" && path.endsWith("settings.json"),
|
||||
);
|
||||
expect(secondSettings).toBeDefined();
|
||||
const parsed = JSON.parse(secondSettings![1] as string);
|
||||
const hookEntries = parsed.hooks.PostToolUse as Array<{ hooks: Array<{ command: string }> }>;
|
||||
// Count all hook commands matching our metadata updater
|
||||
const metadataHooks = hookEntries.flatMap((e) => e.hooks).filter(
|
||||
(h) => h.command.includes("metadata-updater"),
|
||||
);
|
||||
// Must be exactly 1 — no duplicates
|
||||
expect(metadataHooks).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import {
|
|||
shellEscape,
|
||||
readLastJsonlEntry,
|
||||
normalizeAgentPermissionMode,
|
||||
isWindows,
|
||||
DEFAULT_READY_THRESHOLD_MS,
|
||||
DEFAULT_ACTIVE_WINDOW_MS,
|
||||
type Agent,
|
||||
|
|
@ -18,7 +19,7 @@ import {
|
|||
} from "@aoagents/ao-core";
|
||||
import { execFile, execFileSync } from "node:child_process";
|
||||
import { readdir, readFile, stat, open, writeFile, mkdir, chmod } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { basename, join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
|
@ -211,6 +212,173 @@ echo '{}'
|
|||
exit 0
|
||||
`;
|
||||
|
||||
// =============================================================================
|
||||
// Metadata Updater Hook Script — Node.js (Windows)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Node.js equivalent of METADATA_UPDATER_SCRIPT for Windows.
|
||||
* Reads JSON from stdin, parses it with Node built-ins, and updates the
|
||||
* key=value metadata file. No bash, jq, grep, sed, or chmod needed.
|
||||
* Exported for testing.
|
||||
*/
|
||||
export const METADATA_UPDATER_SCRIPT_NODE = `#!/usr/bin/env node
|
||||
// Metadata Updater Hook for Agent Orchestrator (Node.js — Windows)
|
||||
//
|
||||
// This PostToolUse hook automatically updates session metadata when:
|
||||
// - gh pr create: extracts PR URL and writes to metadata
|
||||
// - git checkout -b / git switch -c: extracts branch name and writes to metadata
|
||||
// - gh pr merge: updates status to "merged"
|
||||
|
||||
const { readFileSync, writeFileSync, renameSync, existsSync, realpathSync } = require("node:fs");
|
||||
const { join, sep, resolve: resolvePath } = require("node:path");
|
||||
const os = require("node:os");
|
||||
|
||||
const AO_DATA_DIR = process.env.AO_DATA_DIR || join(process.env.HOME || process.env.USERPROFILE || "", ".ao-sessions");
|
||||
const AO_SESSION = process.env.AO_SESSION || "";
|
||||
|
||||
// Read hook input from stdin (fd 0 is cross-platform, no /dev/stdin needed)
|
||||
let inputRaw = "";
|
||||
try {
|
||||
inputRaw = readFileSync(0, "utf-8");
|
||||
} catch {
|
||||
inputRaw = "";
|
||||
}
|
||||
|
||||
let input;
|
||||
try {
|
||||
input = JSON.parse(inputRaw || "{}");
|
||||
} catch {
|
||||
process.stdout.write("{}\\n");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const toolName = input.tool_name || "";
|
||||
const command = (input.tool_input && input.tool_input.command) || "";
|
||||
const output = input.tool_response || "";
|
||||
const exitCode = typeof input.exit_code === "number" ? input.exit_code : 0;
|
||||
|
||||
// Only process successful commands
|
||||
if (exitCode !== 0) {
|
||||
process.stdout.write("{}\\n");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Only process Bash tool calls
|
||||
if (toolName !== "Bash") {
|
||||
process.stdout.write("{}\\n");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Validate AO_SESSION is set
|
||||
if (!AO_SESSION) {
|
||||
process.stdout.write(JSON.stringify({ systemMessage: "AO_SESSION environment variable not set, skipping metadata update" }) + "\\n");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Validate AO_SESSION contains no path traversal components
|
||||
if (AO_SESSION.includes("/") || AO_SESSION.includes("\\\\") || AO_SESSION.includes("..")) {
|
||||
process.stdout.write(JSON.stringify({ systemMessage: "AO_SESSION contains invalid path characters, skipping metadata update" }) + "\\n");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Validate AO_DATA_DIR is within an allowed base directory (mirrors ao-metadata-helper.sh)
|
||||
const home = os.homedir();
|
||||
let resolvedAoDir;
|
||||
try { resolvedAoDir = realpathSync(AO_DATA_DIR); } catch { resolvedAoDir = resolvePath(AO_DATA_DIR); }
|
||||
const allowedBases = [join(home, ".ao"), join(home, ".agent-orchestrator"), os.tmpdir()];
|
||||
if (!allowedBases.some((a) => resolvedAoDir === a || resolvedAoDir.startsWith(a + sep))) {
|
||||
process.stdout.write(JSON.stringify({ systemMessage: "AO_DATA_DIR is outside allowed directories, skipping metadata update" }) + "\\n");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const metadataFile = join(AO_DATA_DIR, AO_SESSION);
|
||||
|
||||
if (!existsSync(metadataFile)) {
|
||||
process.stdout.write(JSON.stringify({ systemMessage: "Metadata file not found: " + metadataFile }) + "\\n");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update or append a key=value line in the metadata file (atomic via temp file).
|
||||
*/
|
||||
function updateMetadataKey(key, value) {
|
||||
const lines = readFileSync(metadataFile, "utf-8").split("\\n");
|
||||
let found = false;
|
||||
const updated = lines.map((line) => {
|
||||
if (line.startsWith(key + "=")) {
|
||||
found = true;
|
||||
return key + "=" + value;
|
||||
}
|
||||
return line;
|
||||
});
|
||||
if (!found) {
|
||||
// Insert before the trailing empty line (if any) so the file ends cleanly
|
||||
updated.push(key + "=" + value);
|
||||
}
|
||||
const tmpFile = metadataFile + ".tmp." + process.pid;
|
||||
writeFileSync(tmpFile, updated.join("\\n"), "utf-8");
|
||||
renameSync(tmpFile, metadataFile);
|
||||
}
|
||||
|
||||
// Strip leading cd ... && / cd ... ; prefixes (agents frequently cd into a
|
||||
// worktree before running the real command)
|
||||
let cleanCommand = command;
|
||||
const cdPrefixRe = /^\\s*cd\\s+\\S.*?\\s+(?:&&|;)\\s+(.*)/;
|
||||
let m;
|
||||
while ((m = cdPrefixRe.exec(cleanCommand)) !== null && /^\\s*cd\\s/.test(cleanCommand)) {
|
||||
cleanCommand = m[1];
|
||||
}
|
||||
|
||||
// Detect: gh pr create
|
||||
if (/^gh\\s+pr\\s+create/.test(cleanCommand)) {
|
||||
const prMatch = output.match(/https:\\/\\/github[.]com\\/[^/]+\\/[^/]+\\/pull\\/\\d+/);
|
||||
if (prMatch) {
|
||||
const prUrl = prMatch[0];
|
||||
updateMetadataKey("pr", prUrl);
|
||||
updateMetadataKey("status", "pr_open");
|
||||
process.stdout.write(JSON.stringify({ systemMessage: "Updated metadata: PR created at " + prUrl }) + "\\n");
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
// Detect: git checkout -b <branch> or git switch -c <branch>
|
||||
const checkoutNewBranch = cleanCommand.match(/^git\\s+checkout\\s+-b\\s+(\\S+)/) ||
|
||||
cleanCommand.match(/^git\\s+switch\\s+-c\\s+(\\S+)/);
|
||||
if (checkoutNewBranch) {
|
||||
const branch = checkoutNewBranch[1];
|
||||
if (branch) {
|
||||
updateMetadataKey("branch", branch);
|
||||
process.stdout.write(JSON.stringify({ systemMessage: "Updated metadata: branch = " + branch }) + "\\n");
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
// Detect: git checkout <branch> or git switch <branch> (without -b/-c)
|
||||
// Only update if branch looks like a feature branch (contains / or -)
|
||||
const checkoutBranch = cleanCommand.match(/^git\\s+checkout\\s+([^\\s-]+[/-][^\\s]+)/) ||
|
||||
cleanCommand.match(/^git\\s+switch\\s+([^\\s-]+[/-][^\\s]+)/);
|
||||
if (checkoutBranch) {
|
||||
const branch = checkoutBranch[1];
|
||||
if (branch && branch !== "HEAD") {
|
||||
updateMetadataKey("branch", branch);
|
||||
process.stdout.write(JSON.stringify({ systemMessage: "Updated metadata: branch = " + branch }) + "\\n");
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
// Detect: gh pr merge
|
||||
if (/^gh\\s+pr\\s+merge/.test(cleanCommand)) {
|
||||
updateMetadataKey("status", "merged");
|
||||
process.stdout.write(JSON.stringify({ systemMessage: "Updated metadata: status = merged" }) + "\\n");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// No matching command
|
||||
process.stdout.write("{}\\n");
|
||||
process.exit(0);
|
||||
`;
|
||||
|
||||
// =============================================================================
|
||||
// Plugin Manifest
|
||||
// =============================================================================
|
||||
|
|
@ -233,18 +401,19 @@ export const manifest = {
|
|||
*
|
||||
* Verified against Claude Code's actual on-disk slugs: every non-alphanumeric
|
||||
* character (other than `-`) is replaced with `-`. That includes `/`, `.`,
|
||||
* and crucially `_` — AO's per-project data dirs are named like
|
||||
* `:`, and crucially `_` — AO's per-project data dirs are named like
|
||||
* `<sanitized>_<hash>`, and without underscore folding the slug AO computes
|
||||
* misses the directory Claude actually wrote (issue #1611).
|
||||
*
|
||||
* Windows drive letters keep their special handling: `C:\Users\...` → strip
|
||||
* the colon, then encode → `C-Users-...`.
|
||||
* Windows: `C:\Users\dev\project` → `C--Users-dev-project` — Claude leaves the
|
||||
* colon-position as a dash rather than stripping it. Verified via on-disk QA
|
||||
* during the Windows port (commit 582c5373). Stripping the colon (as #1611
|
||||
* inadvertently did) breaks JSONL lookup on Windows.
|
||||
*
|
||||
* Exported for testing purposes.
|
||||
*/
|
||||
export function toClaudeProjectPath(workspacePath: string): string {
|
||||
// Handle Windows drive letters (C:\Users\... → C-Users-...)
|
||||
const normalized = workspacePath.replace(/\\/g, "/").replace(/:/g, "");
|
||||
const normalized = workspacePath.replace(/\\/g, "/");
|
||||
return normalized.replace(/[^a-zA-Z0-9-]/g, "-");
|
||||
}
|
||||
|
||||
|
|
@ -456,6 +625,10 @@ export function resetPsCache(): void {
|
|||
}
|
||||
|
||||
async function getCachedProcessList(): Promise<string> {
|
||||
// ps -eo is a Unix-only command; on Windows the tmux branch is never taken
|
||||
// in normal operation, but guard here to avoid a spurious spawn error if
|
||||
// a stale tmux handle is encountered.
|
||||
if (isWindows()) return "";
|
||||
const now = Date.now();
|
||||
if (psCache && now - psCache.timestamp < PS_CACHE_TTL_MS) {
|
||||
// Cache hit — return resolved output or wait for in-flight request
|
||||
|
|
@ -595,10 +768,9 @@ function classifyTerminalOutput(terminalOutput: string): ActivityState {
|
|||
* @param workspacePath - Path to the workspace directory
|
||||
* @param hookCommand - Command string for the hook (can use variables like $CLAUDE_PROJECT_DIR)
|
||||
*/
|
||||
async function setupHookInWorkspace(workspacePath: string, hookCommand: string): Promise<void> {
|
||||
async function setupHookInWorkspace(workspacePath: string): Promise<void> {
|
||||
const claudeDir = join(workspacePath, ".claude");
|
||||
const settingsPath = join(claudeDir, "settings.json");
|
||||
const hookScriptPath = join(claudeDir, "metadata-updater.sh");
|
||||
|
||||
// Create .claude directory if it doesn't exist
|
||||
try {
|
||||
|
|
@ -607,9 +779,22 @@ async function setupHookInWorkspace(workspacePath: string, hookCommand: string):
|
|||
// Directory might already exist
|
||||
}
|
||||
|
||||
// Write the metadata updater script
|
||||
await writeFile(hookScriptPath, METADATA_UPDATER_SCRIPT, "utf-8");
|
||||
await chmod(hookScriptPath, 0o755); // Make executable
|
||||
// On Windows: write a Node.js hook script, skip chmod (not needed).
|
||||
// On Unix: write the bash hook script and make it executable.
|
||||
let hookCommand: string;
|
||||
if (isWindows()) {
|
||||
const hookScriptPath = join(claudeDir, "metadata-updater.cjs");
|
||||
await writeFile(hookScriptPath, METADATA_UPDATER_SCRIPT_NODE, "utf-8");
|
||||
// No chmod — Windows uses file extension for executability
|
||||
// Use `node` to invoke the script (Windows won't run .js via shebang)
|
||||
// Use .cjs extension to force CJS mode regardless of workspace package.json "type" field
|
||||
hookCommand = "node .claude/metadata-updater.cjs";
|
||||
} else {
|
||||
const hookScriptPath = join(claudeDir, "metadata-updater.sh");
|
||||
await writeFile(hookScriptPath, METADATA_UPDATER_SCRIPT, "utf-8");
|
||||
await chmod(hookScriptPath, 0o755); // Make executable
|
||||
hookCommand = ".claude/metadata-updater.sh";
|
||||
}
|
||||
|
||||
// Read existing settings if present
|
||||
let existingSettings: Record<string, unknown> = {};
|
||||
|
|
@ -639,7 +824,12 @@ async function setupHookInWorkspace(workspacePath: string, hookCommand: string):
|
|||
const hDef = hooksList[j];
|
||||
if (typeof hDef !== "object" || hDef === null || Array.isArray(hDef)) continue;
|
||||
const def = hDef as Record<string, unknown>;
|
||||
if (typeof def["command"] === "string" && def["command"].includes("metadata-updater.sh")) {
|
||||
if (
|
||||
typeof def["command"] === "string" &&
|
||||
(def["command"].includes("metadata-updater.sh") ||
|
||||
def["command"].includes("metadata-updater.js") ||
|
||||
def["command"].includes("metadata-updater.cjs"))
|
||||
) {
|
||||
hookIndex = i;
|
||||
hookDefIndex = j;
|
||||
break;
|
||||
|
|
@ -698,10 +888,17 @@ function createClaudeCodeAgent(): Agent {
|
|||
}
|
||||
|
||||
if (config.systemPromptFile) {
|
||||
// Use shell command substitution to read from file at launch time.
|
||||
// This avoids tmux truncation when inlining 2000+ char prompts.
|
||||
// The double quotes allow $() expansion; inner path is single-quoted for safety.
|
||||
parts.push("--append-system-prompt", `"$(cat ${shellEscape(config.systemPromptFile)})"`);
|
||||
if (isWindows()) {
|
||||
// Windows: $(cat ...) is bash syntax, not understood by PowerShell/cmd.exe.
|
||||
// Read the file synchronously and inline the content instead.
|
||||
const content = readFileSync(config.systemPromptFile, "utf-8");
|
||||
parts.push("--append-system-prompt", shellEscape(content));
|
||||
} else {
|
||||
// Unix: use shell command substitution to read from file at launch time.
|
||||
// This avoids tmux truncation when inlining 2000+ char prompts.
|
||||
// The double quotes allow $() expansion; inner path is single-quoted for safety.
|
||||
parts.push("--append-system-prompt", `"$(cat ${shellEscape(config.systemPromptFile)})"`);
|
||||
}
|
||||
} else if (config.systemPrompt) {
|
||||
parts.push("--append-system-prompt", shellEscape(config.systemPrompt));
|
||||
}
|
||||
|
|
@ -781,6 +978,12 @@ function createClaudeCodeAgent(): Agent {
|
|||
return null;
|
||||
}
|
||||
|
||||
// If the JSONL entry predates this session, it's from a previous session
|
||||
// in the same worktree. Treat as no data (agent hasn't written yet).
|
||||
if (session.createdAt && entry.modifiedAt < session.createdAt) {
|
||||
return { state: "idle", timestamp: session.createdAt };
|
||||
}
|
||||
|
||||
const ageMs = Date.now() - entry.modifiedAt.getTime();
|
||||
const timestamp = entry.modifiedAt;
|
||||
|
||||
|
|
@ -874,7 +1077,7 @@ function createClaudeCodeAgent(): Agent {
|
|||
async setupWorkspaceHooks(workspacePath: string, _config: WorkspaceHooksConfig): Promise<void> {
|
||||
// Relative path so that symlinked .claude/ dirs across worktrees
|
||||
// all produce the same settings.json (last writer doesn't clobber).
|
||||
await setupHookInWorkspace(workspacePath, ".claude/metadata-updater.sh");
|
||||
await setupHookInWorkspace(workspacePath);
|
||||
},
|
||||
|
||||
async postLaunchSetup(_session: Session): Promise<void> {
|
||||
|
|
@ -894,8 +1097,13 @@ export function create(): Agent {
|
|||
|
||||
export function detect(): boolean {
|
||||
try {
|
||||
// Use --version instead of `which` for cross-platform compatibility (Windows has no `which`)
|
||||
execFileSync("claude", ["--version"], { stdio: "ignore" });
|
||||
// Use --version instead of `which` for cross-platform compatibility (Windows has no `which`).
|
||||
// shell:true on Windows so cmd.exe consults PATHEXT and finds .cmd shims (npm-installed CLIs).
|
||||
execFileSync("claude", ["--version"], {
|
||||
stdio: "ignore",
|
||||
shell: isWindows(),
|
||||
windowsHide: true,
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ const {
|
|||
mockCreateReadStream,
|
||||
mockHomedir,
|
||||
mockReadLastJsonlEntry,
|
||||
mockIsWindows,
|
||||
} = vi.hoisted(() => ({
|
||||
mockExecFileAsync: vi.fn(),
|
||||
mockWriteFile: vi.fn().mockResolvedValue(undefined),
|
||||
|
|
@ -36,6 +37,7 @@ const {
|
|||
mockCreateReadStream: vi.fn(),
|
||||
mockHomedir: vi.fn(() => "/mock/home"),
|
||||
mockReadLastJsonlEntry: vi.fn(),
|
||||
mockIsWindows: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
vi.mock("node:child_process", () => {
|
||||
|
|
@ -74,10 +76,12 @@ vi.mock("@aoagents/ao-core", async (importOriginal) => {
|
|||
return {
|
||||
...actual,
|
||||
readLastJsonlEntry: mockReadLastJsonlEntry,
|
||||
isWindows: mockIsWindows,
|
||||
};
|
||||
});
|
||||
|
||||
import { Readable } from "node:stream";
|
||||
import { join as pathJoin } from "node:path";
|
||||
import {
|
||||
create,
|
||||
manifest,
|
||||
|
|
@ -305,7 +309,11 @@ describe("getLaunchCommand", () => {
|
|||
|
||||
it("escapes single quotes in prompt (POSIX shell escaping)", () => {
|
||||
const cmd = agent.getLaunchCommand(makeLaunchConfig({ prompt: "it's broken" }));
|
||||
expect(cmd).toContain("-- 'it'\\''s broken'");
|
||||
if (process.platform === "win32") {
|
||||
expect(cmd).toContain("-- 'it''s broken'");
|
||||
} else {
|
||||
expect(cmd).toContain("-- 'it'\\''s broken'");
|
||||
}
|
||||
});
|
||||
|
||||
it("escapes dangerous characters in prompt", () => {
|
||||
|
|
@ -527,6 +535,14 @@ describe("isProcessRunning", () => {
|
|||
it("returns false for non-numeric PID", async () => {
|
||||
expect(await agent.isProcessRunning(makeProcessHandle("not-a-pid"))).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for tmux handle on Windows without spawning ps", async () => {
|
||||
mockIsWindows.mockReturnValue(true);
|
||||
mockExecFileAsync.mockRejectedValue(new Error("ps not available on Windows"));
|
||||
expect(await agent.isProcessRunning(makeTmuxHandle())).toBe(false);
|
||||
expect(mockExecFileAsync).not.toHaveBeenCalledWith("ps", expect.anything(), expect.anything());
|
||||
mockIsWindows.mockReturnValue(false);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
|
|
@ -1635,29 +1651,31 @@ describe("resolveCodexBinary", () => {
|
|||
});
|
||||
|
||||
it("checks ~/.cargo/bin/codex as fallback (Rust-based codex)", async () => {
|
||||
const expectedPath = pathJoin("/mock/home", ".cargo", "bin", "codex");
|
||||
mockExecFileAsync.mockRejectedValue(new Error("not found"));
|
||||
mockStat.mockImplementation((path: string) => {
|
||||
if (path === "/mock/home/.cargo/bin/codex") {
|
||||
mockStat.mockImplementation((p: string) => {
|
||||
if (p === expectedPath) {
|
||||
return Promise.resolve({ mtimeMs: 1000 });
|
||||
}
|
||||
return Promise.reject(new Error("ENOENT"));
|
||||
});
|
||||
|
||||
const result = await resolveCodexBinary();
|
||||
expect(result).toBe("/mock/home/.cargo/bin/codex");
|
||||
expect(result).toBe(expectedPath);
|
||||
});
|
||||
|
||||
it("checks ~/.npm/bin/codex as fallback", async () => {
|
||||
const expectedPath = pathJoin("/mock/home", ".npm", "bin", "codex");
|
||||
mockExecFileAsync.mockRejectedValue(new Error("not found"));
|
||||
mockStat.mockImplementation((path: string) => {
|
||||
if (path === "/mock/home/.npm/bin/codex") {
|
||||
mockStat.mockImplementation((p: string) => {
|
||||
if (p === expectedPath) {
|
||||
return Promise.resolve({ mtimeMs: 1000 });
|
||||
}
|
||||
return Promise.reject(new Error("ENOENT"));
|
||||
});
|
||||
|
||||
const result = await resolveCodexBinary();
|
||||
expect(result).toBe("/mock/home/.npm/bin/codex");
|
||||
expect(result).toBe(expectedPath);
|
||||
});
|
||||
|
||||
it("returns 'codex' when not found anywhere", async () => {
|
||||
|
|
@ -1750,10 +1768,16 @@ describe("setupWorkspaceHooks", () => {
|
|||
});
|
||||
});
|
||||
|
||||
// (Legacy wrapper-write tests removed: the plugin no longer installs wrappers
|
||||
// or writes ao-metadata-helper.sh / gh / git / .ao-version — session-manager
|
||||
// owns that path now. See main's test refresh in #1487.)
|
||||
|
||||
// =========================================================================
|
||||
// Shell wrapper content verification
|
||||
// =========================================================================
|
||||
describe("shell wrapper content", () => {
|
||||
// Skip on Windows: these tests verify Unix shell-script wrapper contents
|
||||
// (ao-metadata-helper.sh / gh / git wrappers) which don't apply to PowerShell.
|
||||
describe.skipIf(process.platform === "win32")("shell wrapper content", () => {
|
||||
beforeEach(() => {
|
||||
// Force wrapper installation by making version marker miss
|
||||
mockReadFile.mockRejectedValue(new Error("ENOENT"));
|
||||
|
|
@ -1852,9 +1876,7 @@ describe("shell wrapper content", () => {
|
|||
|
||||
it("extracts PR URL from gh pr create output", async () => {
|
||||
const content = await getWrapperContent("gh");
|
||||
expect(content).toContain(
|
||||
"grep -Eo 'https?://[^/]+/[^/]+/[^/]+/pull/[0-9]+'",
|
||||
);
|
||||
expect(content).toContain("grep -Eo 'https?://[^/]+/[^/]+/[^/]+/pull/[0-9]+'");
|
||||
expect(content).toContain("update_ao_metadata pr");
|
||||
});
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue