Merge remote-tracking branch 'origin/main' into claude/happy-spence-e5b227

# Conflicts:
#	.gitignore
This commit is contained in:
Ashish Huddar 2026-05-09 00:44:06 +05:30
commit f56338cbab
284 changed files with 17868 additions and 4103 deletions

View File

@ -1,6 +0,0 @@
---
"@aoagents/ao-core": patch
"@aoagents/ao-cli": patch
---
Add SQLite-backed activity event logging for session and lifecycle diagnostics, plus `ao events` commands for listing, searching, and inspecting event log stats.

View File

@ -1,7 +0,0 @@
---
"@aoagents/ao-plugin-agent-codex": patch
"@aoagents/ao-plugin-agent-claude-code": patch
"@aoagents/ao-web": patch
---
Improve Claude Code and Codex session cost estimates to account for cached-token spend, make Codex restore commands fall back to approval prompts for worker sessions instead of blindly reusing dangerous bypass flags, and register the Codex plugin in the web dashboard so native activity detection works there.

View File

@ -1,19 +0,0 @@
---
"@aoagents/ao-core": minor
---
Sessions whose PRs are detected as merged now auto-terminate (tmux kill + worktree remove + metadata archive) instead of lingering in the active `sessions/` directory with a `merged` status. `ao status` and `ao session ls` stay clean without an external watchdog.
Enabled by default. Guarded by an idleness check so in-flight agents are not killed mid-task; deferred cleanups retry on each lifecycle poll until the agent idles or a 5-minute grace window elapses.
Opt out or tune via the new top-level `lifecycle` config in `agent-orchestrator.yaml`:
```yaml
lifecycle:
autoCleanupOnMerge: false # preserve merged worktrees for inspection
mergeCleanupIdleGraceMs: 300000 # grace window before forcing cleanup
```
`sessionManager.kill()` now takes an optional `reason` (`"manually_killed" | "pr_merged" | "auto_cleanup"`) and returns `KillResult` (`{ cleaned, alreadyTerminated }`) instead of `void`. All existing call sites ignore the return value so this is backward-compatible in practice.
Closes #1309. Part of #536.

View File

@ -1,5 +0,0 @@
---
"@aoagents/ao-cli": patch
---
`ao session ls` hides terminal sessions in text output by default; use `--include-terminated` for the full text list.

View File

@ -1,5 +0,0 @@
---
"@aoagents/ao-web": patch
---
Make session detail agent reports collapsible and add explicit audit attribution for the session, actor, and report source command.

View File

@ -1,12 +0,0 @@
---
"@aoagents/ao-cli": minor
"@aoagents/ao-core": patch
---
`ao session ls` and `ao status` now hide terminated sessions (`killed`, `terminated`, `done`, `merged`, `errored`, `cleanup`) by default. A dim footer reports how many were hidden and how to surface them. Pass `--include-terminated` to restore the previous unfiltered output.
Core change: `parseCanonicalLifecycle()` now preserves `pr.state="merged"` when reconstructing legacy metadata with `status=merged` but no `pr=` URL (previously collapsed to `pr.state="none"`, which made `isTerminalSession()` return false for those sessions). Also exports `sessionFromMetadata` so consumers can round-trip flat metadata through the canonical lifecycle.
**Breaking — JSON output shape:** `ao session ls --json` and `ao status --json` now emit `{ data: [...], meta: { hiddenTerminatedCount: number } }` instead of a bare array. Scripts consuming the JSON must read `.data` for the session list. `--include-terminated` restores full data and reports `hiddenTerminatedCount: 0`.
The existing `-a, --all` flag still only governs orchestrator visibility on `ao session ls` — it does **not** re-enable terminated sessions. Combine with `--include-terminated` when you want both.

View File

@ -1,5 +0,0 @@
---
"@aoagents/ao-core": patch
---
Stop carrying forward `stuck` / `probe_failure` session truth when the runtime is still confirmed alive and activity is merely unavailable, and degrade that combination to `detecting` until stronger evidence arrives.

View File

@ -1,12 +0,0 @@
---
"@aoagents/ao-core": minor
---
Enrich lifecycle events with PR/issue context for webhook consumers. All events now carry `data.context` with `pr` (url, title, number, branch), `issueId`, `issueTitle`, `summary`, and `branch` when available, plus `data.schemaVersion: 2`.
Additional changes:
- Persist `issueTitle` in session metadata during spawn so it survives across restarts and is available for event enrichment.
- Refactor `executeReaction()` to accept a `Session` object instead of separate `sessionId`/`projectId` arguments.
- Add `maybeDispatchCIFailureDetails()` — when a session enters `ci_failed`, the agent receives a follow-up message with the failed check names and URLs (deduped via fingerprint so subsequent polls don't re-send the same failure set).
- `bugbot-comments` reaction dispatches an enriched message listing every automated comment inline, so the agent doesn't need to re-fetch via `gh api`.

View File

@ -1,10 +0,0 @@
"@aoagents/ao-cli": patch
"@aoagents/ao-web": patch
"@aoagents/ao-plugin-agent-codex": patch
---
Fix restore behavior across AO session recovery flows.
- restore the latest dead-but-restorable orchestrator on `ao start` instead of silently spawning a new orchestrator when tmux is gone
- make worker session orchestrator navigation prefer the most recently active live orchestrator for the project
- make permissionless Codex restores preserve dangerous bypass semantics so resumed workers behave like fresh permissionless launches

View File

@ -1,5 +0,0 @@
---
"@aoagents/ao-web": patch
---
Keep closed-unmerged sessions actionable in the dashboard by removing them from the done lane unless the runtime actually ended, and hide restore controls for merged sessions that are intentionally non-restorable.

View File

@ -1,7 +0,0 @@
---
"@aoagents/ao-web": patch
---
Fix DirectTerminal "can't find session" when the project uses a wrapped storageKey. `ao-core` names tmux sessions as `{storageKey}-{sessionId}`, where `storageKey` can be either a bare 12-char hash or the legacy wrapped form `{hash}-{projectName}` (e.g. `361287ebbad1-smx-foundation`). The web resolver only handled the bare-hash form, so lookups for sessions like `sf-orchestrator-1` against the tmux name `361287ebbad1-smx-foundation-sf-orchestrator-1` always returned `null` and the terminal never attached (#1486).
The resolver now looks up the owning storageKey on disk (from the session record at `~/.agent-orchestrator/{storageKey}/sessions/{sessionId}`) and asks tmux for the exact `{storageKey}-{sessionId}` name. The on-disk record is the authoritative disambiguator, so sessions whose IDs happen to be suffixes of other session IDs (e.g. looking up `app-1` while `my-app-1` exists in the same project) cannot be falsely matched. If the on-disk record is missing, the resolver still recovers bare-hash sessions via the tmux session listing as a fallback.

View File

@ -1,10 +0,0 @@
"@aoagents/ao-cli": patch
"@aoagents/ao-core": patch
"@aoagents/ao-web": patch
---
Make project orchestrators deterministic and idempotent.
- ensure each project uses the canonical `{prefix}-orchestrator` session instead of creating numbered main orchestrators
- make `ao start`, the dashboard, and the orchestrator API reuse or restore the canonical session
- keep legacy numbered orchestrators visible as stale sessions without treating them as the main orchestrator

View File

@ -1,5 +0,0 @@
---
"@aoagents/ao-core": patch
---
Harden worker branch refresh during lifecycle polling by preserving branch metadata through transient detached Git states, skipping orchestrators and active open PRs, and preventing duplicate branch adoption within a single poll cycle.

View File

@ -1,5 +0,0 @@
---
"@aoagents/ao-core": patch
---
Keep lifecycle observability and batch diagnostic logs out of user-visible terminal stderr by routing them into AO's observability audit files instead, while preserving structured traces for debugging and regression coverage.

View File

@ -1,11 +0,0 @@
---
"@aoagents/ao-core": minor
"@aoagents/ao-cli": minor
---
Allow workers to report non-terminal PR workflow events like `pr-created`, `draft-pr-created`, and `ready-for-review` with optional PR URL/number metadata, while keeping merged and closed PR state SCM-owned.
**Migration:** `Session` now carries canonical lifecycle truth in `session.lifecycle`
and explicit activity-evidence metadata in `session.activitySignal`. Third-party
callers that construct `Session` objects directly must populate those fields or
route through the core session helpers that synthesize them.

View File

@ -1,6 +0,0 @@
---
"@aoagents/ao-core": patch
"@aoagents/ao-web": patch
---
Model activity evidence explicitly across lifecycle inference and dashboard rendering so missing or failed probes cannot spuriously produce idle or stuck interpretations. This also stabilizes repeated polls by preserving stronger prior lifecycle states when the only new evidence is weak or unavailable.

View File

@ -1,14 +0,0 @@
---
"@aoagents/ao-core": minor
---
Add centralized lifecycle transitions and report watcher for agent monitoring.
- **Lifecycle transitions (#137)**: Centralize all lifecycle state mutations through `applyLifecycleDecision()` for consistent timestamp handling, atomic metadata persistence, and observability.
- **Detecting bounds (#138)**: Add time-based (5 min) and attempt-based (3 attempts) bounds to detecting state with evidence hashing to prevent counter reset on unchanged probe results.
- **Report watcher (#140)**: Background trigger system that audits agent reports for anomalies (no_acknowledge, stale_report, agent_needs_input) and integrates with the reaction engine.
New exports:
- `applyLifecycleDecision`, `applyDecisionToLifecycle`, `buildTransitionMetadataPatch`, `createStateTransitionDecision`
- `DETECTING_MAX_ATTEMPTS`, `DETECTING_MAX_DURATION_MS`, `hashEvidence`, `isDetectingTimedOut`
- `auditAgentReports`, `checkAcknowledgeTimeout`, `checkStaleReport`, `checkBlockedAgent`, `shouldAuditSession`, `getReactionKeyForTrigger`, `DEFAULT_REPORT_WATCHER_CONFIG`, `REPORT_WATCHER_METADATA_KEYS`

View File

@ -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`.

View File

@ -1,37 +0,0 @@
---
"@aoagents/ao-core": patch
"@aoagents/ao-cli": patch
"@aoagents/ao-plugin-agent-opencode": patch
---
opencode: bound /tmp blast radius and consolidate session-list cache
Addresses review feedback on PR #1478:
- **TMPDIR isolation.** Every `opencode` child we spawn now points at
`~/.agent-orchestrator/.bun-tmp/` via `TMPDIR`/`TMP`/`TEMP`. Bun's
embedded shared-library extraction lands there instead of the system
`/tmp`, so the cli janitor only ever sweeps AO-owned files. Other
users' or other applications' Bun artifacts on a shared host can no
longer be touched by the regex.
- **Single shared session-list cache.** Core and the agent-opencode
plugin previously kept independent caches; per poll cycle the system
spawned at least two `opencode session list` processes instead of
one. Both consumers now use the shared cache exported from
`@aoagents/ao-core` (`getCachedOpenCodeSessionList`).
- **TTL no longer covers the send-confirmation loop.** The cache TTL
dropped from 3s to 500ms so the
`updatedAt > baselineUpdatedAt` delivery signal in
`sendWithConfirmation` actually fires. Concurrent callers still
share the in-flight promise.
- **Delete invalidates the cache.** `deleteOpenCodeSession` now calls
`invalidateOpenCodeSessionListCache()` on success so reuse, remap,
and restore code paths cannot observe a deleted session id within
the TTL window.
- **Janitor reliability.** `sweepOnce` now filters synchronously
before allocating per-file promises (matters on hosts with thousands
of `/tmp` entries), and `stopBunTmpJanitor()` is now async and awaits
any in-flight sweep so SIGTERM cannot exit while `unlink` is mid-flight.
- **Janitor observability.** The sweep callback in `ao start` now logs
successful reclaims, not just errors, so operators can confirm the
janitor is doing useful work.

View File

@ -1,16 +0,0 @@
---
"@aoagents/ao-core": minor
"@aoagents/ao-cli": patch
"@aoagents/ao-web": patch
"@aoagents/ao-plugin-scm-github": patch
"@aoagents/ao-plugin-scm-gitlab": patch
"@aoagents/ao-plugin-tracker-github": patch
"@aoagents/ao-plugin-tracker-gitlab": patch
---
Make `ProjectConfig.repo` optional to support projects without a configured remote.
**Migration:** `ProjectConfig.repo` is now `string | undefined` instead of `string`.
External plugins that access `project.repo` directly (e.g. `project.repo.split("/")`) must
add a null check first. Use a guard like `if (!project.repo) return null;` or a helper that
throws with a descriptive error.

View File

@ -1,7 +0,0 @@
---
"@aoagents/ao-core": patch
"@aoagents/ao-cli": patch
"@aoagents/ao-web": patch
---
Tighten the session lifecycle review follow-ups by debouncing report-watcher reactions, restoring the shared Geist/JetBrains font setup, wiring recovery validation to real agent activity probes, adding direct coverage for `ao report`, activity-signal classification, and dashboard lifecycle audit panels, fixing the remaining lifecycle-state regressions around legacy merged-session rehydration and malformed canonical payload parsing, making agent-report metadata writes atomic, persisting canonical payloads for legacy sessions on read, stabilizing detecting evidence hashes, and removing the remaining inline-style cleanup debt from the session detail view. Follow-on fixes also split the Session Detail view into smaller components, harden PR URL parsing and wrapper capture for GitHub Enterprise and GitLab-style hosts, redact sensitive observability payload fields, bound on-disk audit logs, and align cleanup wording with the current merged-session lifecycle policy.

View File

@ -1,5 +0,0 @@
---
"@aoagents/ao-web": patch
---
Refactor SessionDetail.tsx by extracting the topbar header, PR card, and unresolved comment thread into dedicated components. The previously-orphaned SessionDetailPRCard, session-detail-utils, and session-detail-agent-actions modules are now wired in. All files are under the 400-line component limit.

View File

@ -1,19 +0,0 @@
---
"@aoagents/ao-plugin-scm-github": patch
---
scm-github: cache 4 more hot-path reads (CI, mergeability, pending comments, detectPR)
Completes the bulk of the AO-side caching work alongside the prior PR view
cache. Per-method TTLs match the approved policy: 5s max for
decision-influencing fields.
- `getCIChecks` (`gh pr checks`): 5s TTL
- `getMergeability` (composite `pr view` + CI + state): 5s TTL on the composite result
- `getPendingComments` (`gh api graphql` review threads): 5s TTL — ETag doesn't help on GraphQL per Experiment 2
- `detectPR` (`gh pr list --head BRANCH`): 5s TTL, **positive-only**`[]` results are never cached so a freshly created PR surfaces on the next poll. Branch-keyed entry is invalidated by `mergePR`/`closePR` alongside the number-keyed entries.
Combined with the prior PR view cache, this covers the top 6 AO-side gh
operation categories that accounted for ~85% of calls in tier-5 bench traces.
Tests: 85 existing + 9 new cache tests, all 162 passing.

View File

@ -1,33 +0,0 @@
---
"@aoagents/ao-plugin-scm-github": patch
---
scm-github: cache 5 `gh pr view` callsites with per-method TTLs
The lifecycle worker repeatedly polls each PR for state, summary, reviews,
and review decision. Trace data showed `gh pr view` was the single largest
AO-side endpoint at 1,280 calls per 5-session tier-5 run with >97% duplicate
rate (e.g. PR #184 polled 86× for `--json state` alone in 11.5 minutes).
Adds an in-process per-instance cache inside `createGitHubSCM()`, keyed by
`${owner}/${repo}#${prKey}:${method}` so different field-sets stay isolated.
Per-method TTLs balance reduction against staleness on decision-influencing
fields:
- `resolvePR`: 60s (identity metadata only — number, url, title, branch refs, isDraft)
- `getPRState`: 5s
- `getPRSummary`: 5s
- `getReviews`: 5s
- `getReviewDecision`: 5s
`assignPRToCurrentUser`, `mergePR`, and `closePR` each invalidate the entire
PR cache for that PR after the mutation, so AO never sees stale state from
its own writes. Failures are not cached.
`getCIChecksFromStatusRollup` and `getMergeability` are intentionally NOT
cached here — those need ETag-based revalidation, not blind TTL, and will
land in a follow-up change.
Expected reduction: ~1,165 of ~1,280 `gh pr view` calls per tier-5 run.
Tests: 73 existing + 12 new cache tests, all passing.

View File

@ -1,7 +0,0 @@
---
"@aoagents/ao-core": patch
"@aoagents/ao-cli": patch
"@aoagents/ao-web": patch
---
Split orchestrator-only detail views from worker detail views, add an auditable history for `ao acknowledge` / `ao report`, and preserve canonical `needs_input` / `stuck` lifecycle states when polling only has weak or unchanged evidence.

View File

@ -1,5 +0,0 @@
---
"@aoagents/ao-core": minor
---
Improve lifecycle detection to use bounded `detecting` retries when runtime, process, and activity evidence disagree, and make recovery validation escalate probe uncertainty for human review instead of treating it as cleanup-safe death.

View File

@ -1,6 +0,0 @@
---
"@aoagents/ao-core": patch
"@aoagents/ao-web": patch
---
Decouple canonical session state from PR state so workers stay idle while waiting on reviews or merged/closed PR decisions, stop cleanup from auto-killing merged PR sessions, and make the dashboard/rendered labels follow canonical PR truth instead of inferring it from legacy lifecycle aliases.

View File

@ -1,6 +0,0 @@
---
"@aoagents/ao-core": patch
"@aoagents/ao-web": patch
---
Expose split session, PR, and runtime lifecycle truth in dashboard API payloads, render that truth directly in session cards and detail views, and extend lifecycle observability with structured transition evidence, reasons, and recovery context while preserving legacy metadata compatibility.

View File

@ -1,16 +0,0 @@
---
"@aoagents/ao-plugin-tracker-github": patch
---
tracker-github: cache `gh issue view` responses in-process (5 min TTL, bounded LRU)
The lifecycle worker polls `getIssue` and `isCompleted` repeatedly for the same
issue across a session. In a 5-session tier-5 bench run (10 min), trace data
showed the same `(repo, issue)` pair fetched 64+ times with >97% duplicate rate.
This change caches the full `Issue` object per `(repo, identifier)` for 5
minutes inside each `createGitHubTracker()` instance. `isCompleted` now routes
through `getIssue` to share the cache. `updateIssue` invalidates the cache
entry on any mutation. Failures are not cached.
Expected reduction: ~744 `gh issue view` calls per tier-5 run → ~15 calls.

View File

@ -1,5 +0,0 @@
---
"@composio/ao-web": patch
---
Add a dashboard control to copy observability diagnostics and page context to the clipboard for support and issue reports.

View File

@ -1,5 +0,0 @@
---
"@aoagents/ao-web": patch
---
Show GitHub compare and copy-branch actions on session PR detail when the PR has merge conflicts.

View File

@ -1,5 +0,0 @@
---
"@aoagents/ao-core": patch
---
Split worker session prompts into persistent system instructions and task-only input, materialize OpenCode worker/orchestrator instructions into session-scoped `AGENTS.md`, and keep restore behavior aligned with the updated AO prompt markers.

View File

@ -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

View File

@ -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. |

View File

@ -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

1
.gitignore vendored
View File

@ -69,3 +69,4 @@ agent-orchestrator.yaml
.DS_Store
Thumbs.db
.gstack/
package-lock.json

View File

@ -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.

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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 |

View File

@ -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

View File

@ -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
@ -79,6 +80,12 @@ projects:
# deliveryHeader: x-github-delivery
# maxBodyBytes: 1048576
# Per-project environment variables forwarded into worker session runtimes.
# Useful for scoping per-project tokens (e.g. pinning gh auth via GH_TOKEN).
# AO-internal vars (AO_SESSION, AO_PROJECT_ID, etc.) always take precedence.
# env:
# GH_TOKEN: ghp_xxx
# Files to symlink into workspaces
# symlinks: [.env, .claude]

View File

@ -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.

View File

@ -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

389
docs/CROSS_PLATFORM.md Normal file
View File

@ -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.

View File

@ -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.

View File

@ -5,7 +5,7 @@ How to set up the Agent Orchestrator (AO) plugin for OpenClaw so the AI bot dele
## Prerequisites
- [OpenClaw](https://openclaw.ai) installed and running
- [Agent Orchestrator](https://github.com/ComposioHQ/agent-orchestrator) installed with `ao init` completed in your repo
- [Agent Orchestrator](https://github.com/ComposioHQ/agent-orchestrator) installed with `ao start` completed in your repo
- `ao`, `gh`, `tmux`, and `node` available in PATH
- GitHub CLI (`gh`) authenticated

View File

@ -1,4 +1,34 @@
# @composio/ao
# @aoagents/ao
## 0.6.0
### Patch Changes
- Updated dependencies [0f539a3]
- @aoagents/ao-cli@0.6.0
## 0.5.0
### Patch Changes
- Updated dependencies [3a69722]
- @aoagents/ao-cli@0.5.0
## 0.4.0
### Patch Changes
- Updated dependencies [2306078]
- Updated dependencies [f09cc72]
- Updated dependencies [f330a1e]
- Updated dependencies [e1bb51f]
- Updated dependencies [f674422]
- Updated dependencies [e7ad928]
- Updated dependencies [4701122]
- Updated dependencies [c8af50f]
- Updated dependencies [bcdda4b]
- Updated dependencies [1cbf657]
- @aoagents/ao-cli@0.4.0
## 0.2.2

View File

@ -1,6 +1,6 @@
{
"name": "@aoagents/ao",
"version": "0.3.0",
"version": "0.6.0",
"description": "Orchestrate parallel AI coding agents — global CLI wrapper",
"license": "MIT",
"type": "module",

View File

@ -1,4 +1,204 @@
# @composio/ao-cli
# @aoagents/ao-cli
## 0.6.0
### Patch Changes
- 0f539a3: Fix dashboard 404 after adding a project from the "AO is already running" menu. The CLI now notifies the running daemon to reload its cached config so the new project's page is reachable immediately.
- Updated dependencies
- Updated dependencies
- Updated dependencies [40aeb78]
- Updated dependencies
- Updated dependencies
- Updated dependencies
- Updated dependencies
- @aoagents/ao-core@0.6.0
- @aoagents/ao-web@0.6.0
- @aoagents/ao-plugin-runtime-tmux@0.6.0
- @aoagents/ao-plugin-agent-aider@0.6.0
- @aoagents/ao-plugin-agent-claude-code@0.6.0
- @aoagents/ao-plugin-agent-codex@0.6.0
- @aoagents/ao-plugin-agent-cursor@0.1.4
- @aoagents/ao-plugin-agent-kimicode@0.1.3
- @aoagents/ao-plugin-agent-opencode@0.6.0
- @aoagents/ao-plugin-notifier-composio@0.6.0
- @aoagents/ao-plugin-notifier-desktop@0.6.0
- @aoagents/ao-plugin-notifier-discord@0.2.9
- @aoagents/ao-plugin-notifier-openclaw@0.2.9
- @aoagents/ao-plugin-notifier-slack@0.6.0
- @aoagents/ao-plugin-notifier-webhook@0.6.0
- @aoagents/ao-plugin-runtime-process@0.6.0
- @aoagents/ao-plugin-scm-github@0.6.0
- @aoagents/ao-plugin-terminal-iterm2@0.6.0
- @aoagents/ao-plugin-terminal-web@0.6.0
- @aoagents/ao-plugin-tracker-github@0.6.0
- @aoagents/ao-plugin-tracker-linear@0.6.0
- @aoagents/ao-plugin-workspace-clone@0.6.0
- @aoagents/ao-plugin-workspace-worktree@0.6.0
## 0.5.0
### Minor Changes
- 3a69722: Remove the deprecated `ao init` command. Use `ao start` instead — it auto-creates the config on first run in an unconfigured repo.
### Patch Changes
- Updated dependencies [dd07b6b]
- Updated dependencies [dd07b6b]
- Updated dependencies [dd07b6b]
- @aoagents/ao-core@0.5.0
- @aoagents/ao-web@0.5.0
- @aoagents/ao-plugin-agent-aider@0.5.0
- @aoagents/ao-plugin-agent-claude-code@0.5.0
- @aoagents/ao-plugin-agent-codex@0.5.0
- @aoagents/ao-plugin-agent-cursor@0.1.3
- @aoagents/ao-plugin-agent-kimicode@0.1.2
- @aoagents/ao-plugin-agent-opencode@0.5.0
- @aoagents/ao-plugin-notifier-composio@0.5.0
- @aoagents/ao-plugin-notifier-desktop@0.5.0
- @aoagents/ao-plugin-notifier-discord@0.2.8
- @aoagents/ao-plugin-notifier-openclaw@0.2.8
- @aoagents/ao-plugin-notifier-slack@0.5.0
- @aoagents/ao-plugin-notifier-webhook@0.5.0
- @aoagents/ao-plugin-runtime-process@0.5.0
- @aoagents/ao-plugin-runtime-tmux@0.5.0
- @aoagents/ao-plugin-scm-github@0.5.0
- @aoagents/ao-plugin-terminal-iterm2@0.5.0
- @aoagents/ao-plugin-terminal-web@0.5.0
- @aoagents/ao-plugin-tracker-github@0.5.0
- @aoagents/ao-plugin-tracker-linear@0.5.0
- @aoagents/ao-plugin-workspace-clone@0.5.0
- @aoagents/ao-plugin-workspace-worktree@0.5.0
## 0.4.0
### Minor Changes
- f330a1e: `ao session ls` and `ao status` now hide terminated sessions (`killed`, `terminated`, `done`, `merged`, `errored`, `cleanup`) by default. A dim footer reports how many were hidden and how to surface them. Pass `--include-terminated` to restore the previous unfiltered output.
Core change: `parseCanonicalLifecycle()` now preserves `pr.state="merged"` when reconstructing legacy metadata with `status=merged` but no `pr=` URL (previously collapsed to `pr.state="none"`, which made `isTerminalSession()` return false for those sessions). Also exports `sessionFromMetadata` so consumers can round-trip flat metadata through the canonical lifecycle.
**Breaking — JSON output shape:** `ao session ls --json` and `ao status --json` now emit `{ data: [...], meta: { hiddenTerminatedCount: number } }` instead of a bare array. Scripts consuming the JSON must read `.data` for the session list. `--include-terminated` restores full data and reports `hiddenTerminatedCount: 0`.
The existing `-a, --all` flag still only governs orchestrator visibility on `ao session ls` — it does **not** re-enable terminated sessions. Combine with `--include-terminated` when you want both.
- e7ad928: Allow workers to report non-terminal PR workflow events like `pr-created`, `draft-pr-created`, and `ready-for-review` with optional PR URL/number metadata, while keeping merged and closed PR state SCM-owned.
**Migration:** `Session` now carries canonical lifecycle truth in `session.lifecycle`
and explicit activity-evidence metadata in `session.activitySignal`. Third-party
callers that construct `Session` objects directly must populate those fields or
route through the core session helpers that synthesize them.
### Patch Changes
- 2306078: Add SQLite-backed activity event logging for session and lifecycle diagnostics, plus `ao events` commands for listing, searching, and inspecting event log stats.
- f09cc72: `ao session ls` hides terminal sessions in text output by default; use `--include-terminated` for the full text list.
- e1bb51f: Fix restore behavior across AO session recovery flows.
- restore the latest dead-but-restorable orchestrator on `ao start` instead of silently spawning a new orchestrator when tmux is gone
- make worker session orchestrator navigation prefer the most recently active live orchestrator for the project
- make permissionless Codex restores preserve dangerous bypass semantics so resumed workers behave like fresh permissionless launches
- f674422: Make project orchestrators deterministic and idempotent.
- ensure each project uses the canonical `{prefix}-orchestrator` session instead of creating numbered main orchestrators
- make `ao start`, the dashboard, and the orchestrator API reuse or restore the canonical session
- keep legacy numbered orchestrators visible as stale sessions without treating them as the main orchestrator
- 4701122: opencode: bound /tmp blast radius and consolidate session-list cache
Addresses review feedback on PR #1478:
- **TMPDIR isolation.** Every `opencode` child we spawn now points at
`~/.agent-orchestrator/.bun-tmp/` via `TMPDIR`/`TMP`/`TEMP`. Bun's
embedded shared-library extraction lands there instead of the system
`/tmp`, so the cli janitor only ever sweeps AO-owned files. Other
users' or other applications' Bun artifacts on a shared host can no
longer be touched by the regex.
- **Single shared session-list cache.** Core and the agent-opencode
plugin previously kept independent caches; per poll cycle the system
spawned at least two `opencode session list` processes instead of
one. Both consumers now use the shared cache exported from
`@aoagents/ao-core` (`getCachedOpenCodeSessionList`).
- **TTL no longer covers the send-confirmation loop.** The cache TTL
dropped from 3s to 500ms so the
`updatedAt > baselineUpdatedAt` delivery signal in
`sendWithConfirmation` actually fires. Concurrent callers still
share the in-flight promise.
- **Delete invalidates the cache.** `deleteOpenCodeSession` now calls
`invalidateOpenCodeSessionListCache()` on success so reuse, remap,
and restore code paths cannot observe a deleted session id within
the TTL window.
- **Janitor reliability.** `sweepOnce` now filters synchronously
before allocating per-file promises (matters on hosts with thousands
of `/tmp` entries), and `stopBunTmpJanitor()` is now async and awaits
any in-flight sweep so SIGTERM cannot exit while `unlink` is mid-flight.
- **Janitor observability.** The sweep callback in `ao start` now logs
successful reclaims, not just errors, so operators can confirm the
janitor is doing useful work.
- c8af50f: Make `ProjectConfig.repo` optional to support projects without a configured remote.
**Migration:** `ProjectConfig.repo` is now `string | undefined` instead of `string`.
External plugins that access `project.repo` directly (e.g. `project.repo.split("/")`) must
add a null check first. Use a guard like `if (!project.repo) return null;` or a helper that
throws with a descriptive error.
- bcdda4b: Tighten the session lifecycle review follow-ups by debouncing report-watcher reactions, restoring the shared Geist/JetBrains font setup, wiring recovery validation to real agent activity probes, adding direct coverage for `ao report`, activity-signal classification, and dashboard lifecycle audit panels, fixing the remaining lifecycle-state regressions around legacy merged-session rehydration and malformed canonical payload parsing, making agent-report metadata writes atomic, persisting canonical payloads for legacy sessions on read, stabilizing detecting evidence hashes, and removing the remaining inline-style cleanup debt from the session detail view. Follow-on fixes also split the Session Detail view into smaller components, harden PR URL parsing and wrapper capture for GitHub Enterprise and GitLab-style hosts, redact sensitive observability payload fields, bound on-disk audit logs, and align cleanup wording with the current merged-session lifecycle policy.
- 1cbf657: Split orchestrator-only detail views from worker detail views, add an auditable history for `ao acknowledge` / `ao report`, and preserve canonical `needs_input` / `stuck` lifecycle states when polling only has weak or unchanged evidence.
- Updated dependencies [2306078]
- Updated dependencies [b0d0994]
- Updated dependencies [faaddb1]
- Updated dependencies [0cf0190]
- Updated dependencies [f330a1e]
- Updated dependencies [a862327]
- Updated dependencies [331f1ce]
- Updated dependencies [e465a47]
- Updated dependencies [703d584]
- Updated dependencies [e1bb51f]
- Updated dependencies [08667c8]
- Updated dependencies [eca3001]
- Updated dependencies [f674422]
- Updated dependencies [62353eb]
- Updated dependencies [bd36c7b]
- Updated dependencies [e7ad928]
- Updated dependencies [ca8c4cc]
- Updated dependencies [7b82374]
- Updated dependencies [4701122]
- Updated dependencies [c8af50f]
- Updated dependencies [bcdda4b]
- Updated dependencies [eb7314b]
- Updated dependencies [a8bc746]
- Updated dependencies [a8bc746]
- Updated dependencies [1cbf657]
- Updated dependencies [c447c7c]
- Updated dependencies [a45eb32]
- Updated dependencies [7072143]
- Updated dependencies [a8bc746]
- Updated dependencies [e518562]
- Updated dependencies [fed25d5]
- Updated dependencies [ed2dcea]
- @aoagents/ao-core@0.4.0
- @aoagents/ao-plugin-agent-codex@0.4.0
- @aoagents/ao-plugin-agent-claude-code@0.4.0
- @aoagents/ao-web@0.4.0
- @aoagents/ao-plugin-agent-opencode@0.4.0
- @aoagents/ao-plugin-scm-github@0.4.0
- @aoagents/ao-plugin-tracker-github@0.4.0
- @aoagents/ao-plugin-agent-aider@0.4.0
- @aoagents/ao-plugin-agent-cursor@0.1.2
- @aoagents/ao-plugin-agent-kimicode@0.1.1
- @aoagents/ao-plugin-notifier-composio@0.4.0
- @aoagents/ao-plugin-notifier-desktop@0.4.0
- @aoagents/ao-plugin-notifier-discord@0.2.7
- @aoagents/ao-plugin-notifier-openclaw@0.2.7
- @aoagents/ao-plugin-notifier-slack@0.4.0
- @aoagents/ao-plugin-notifier-webhook@0.4.0
- @aoagents/ao-plugin-runtime-process@0.4.0
- @aoagents/ao-plugin-runtime-tmux@0.4.0
- @aoagents/ao-plugin-terminal-iterm2@0.4.0
- @aoagents/ao-plugin-terminal-web@0.4.0
- @aoagents/ao-plugin-tracker-linear@0.4.0
- @aoagents/ao-plugin-workspace-clone@0.4.0
- @aoagents/ao-plugin-workspace-worktree@0.4.0
## 0.2.2

View File

@ -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",
]);
},
);
});

View File

@ -1,28 +0,0 @@
import { describe, it, expect } from "vitest";
import { Command } from "commander";
import { registerInit } from "../../src/commands/init.js";
describe("init command", () => {
it("registers as a deprecated command", () => {
const program = new Command();
registerInit(program);
const initCmd = program.commands.find((c) => c.name() === "init");
expect(initCmd).toBeDefined();
expect(initCmd!.description()).toContain("deprecated");
});
it("has no --output, --auto, or --smart flags", () => {
const program = new Command();
registerInit(program);
const initCmd = program.commands.find((c) => c.name() === "init");
expect(initCmd).toBeDefined();
const optionNames = initCmd!.options.map((o) => o.long);
expect(optionNames).not.toContain("--output");
expect(optionNames).not.toContain("--auto");
expect(optionNames).not.toContain("--smart");
});
});

View File

@ -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",
);
});
});

View File

@ -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[]) => {

View File

@ -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", () => {

View File

@ -52,8 +52,18 @@ vi.mock("@aoagents/ao-core", async (importOriginal) => {
};
});
// Default registry returns no plugins → preflight loop is a no-op. Tests that
// need a specific plugin's preflight to fire override mockRegistryGet.
const mockRegistryGet = vi.fn().mockReturnValue(null);
vi.mock("../../src/lib/create-session-manager.js", () => ({
getSessionManager: async (): Promise<SessionManager> => mockSessionManager as SessionManager,
getPluginRegistry: async () => ({
register: vi.fn(),
get: mockRegistryGet,
list: vi.fn().mockReturnValue([]),
loadBuiltins: vi.fn(),
loadFromConfig: vi.fn(),
}),
}));
vi.mock("../../src/lib/running-state.js", () => ({
@ -124,6 +134,7 @@ beforeEach(() => {
mockSessionManager.claimPR.mockReset();
mockExec.mockReset();
mockGetRunning.mockReset();
mockRegistryGet.mockReset().mockReturnValue(null);
mockGetRunning.mockResolvedValue({ pid: 1234, port: 3000, startedAt: "", projects: ["my-app"] });
});
@ -219,6 +230,13 @@ describe("spawn command", () => {
mkdirSync(backendSubdir, { recursive: true });
cwdSpy = vi.spyOn(process, "cwd").mockReturnValue(backendSubdir);
mockGetRunning.mockResolvedValue({
pid: 1234,
port: 3000,
startedAt: "",
projects: ["backend", "frontend"],
});
const fakeSession: Session = {
id: "be-1",
projectId: "backend",
@ -273,7 +291,7 @@ describe("spawn command", () => {
pid: 1234,
port: 3000,
startedAt: "",
projects: ["agent-orchestrator"],
projects: ["agent-orchestrator", "x402-identity"],
});
const fakeSession: Session = {
@ -325,7 +343,7 @@ describe("spawn command", () => {
pid: 1234,
port: 3000,
startedAt: "",
projects: ["agent-orchestrator"],
projects: ["agent-orchestrator", "x402-identity"],
});
const fakeSession: Session = {
@ -673,23 +691,14 @@ describe("spawn command", () => {
});
describe("spawn pre-flight checks", () => {
it("fails with clear error when tmux is not installed (default runtime)", async () => {
mockExec.mockRejectedValue(new Error("ENOENT"));
// The spawn CLI now iterates the configured plugins and calls each one's
// optional preflight(). Plugin-internal checks (e.g. checkTmux, gh auth
// status) live in the plugin packages — see runtime-tmux / tracker-github /
// scm-github tests for that coverage. These tests verify the orchestration:
// the right plugins are iterated, and the intent context is forwarded.
await expect(program.parseAsync(["node", "test", "spawn"])).rejects.toThrow(
"process.exit(1)",
);
const errors = vi
.mocked(console.error)
.mock.calls.map((c) => String(c[0]))
.join("\n");
expect(errors).toContain("tmux");
expect(mockSessionManager.spawn).not.toHaveBeenCalled();
});
it("skips tmux check when runtime is not tmux", async () => {
const fakeSession: Session = {
function makeFakeSession(overrides: Partial<Session> = {}): Session {
return {
id: "app-1",
projectId: "my-app",
status: "spawning",
@ -698,98 +707,77 @@ describe("spawn pre-flight checks", () => {
issueId: null,
pr: null,
workspacePath: "/tmp/wt",
runtimeHandle: { id: "proc-1", runtimeName: "process", data: {} },
runtimeHandle: { id: "hash-1", runtimeName: "tmux", data: {} },
agentInfo: null,
createdAt: new Date(),
lastActivityAt: new Date(),
metadata: {},
...overrides,
};
mockSessionManager.spawn.mockResolvedValue(fakeSession);
}
// Set runtime to "process"
(mockConfigRef.current as Record<string, unknown>).defaults = {
runtime: "process",
agent: "claude-code",
workspace: "worktree",
notifiers: ["desktop"],
};
it("surfaces a plugin's preflight error and aborts before sm.spawn", async () => {
mockRegistryGet.mockImplementation((slot: string) => {
if (slot === "runtime") {
return {
name: "tmux",
preflight: vi
.fn()
.mockRejectedValue(new Error("tmux is not installed. Install it: brew install tmux")),
};
}
return null;
});
// exec would fail for tmux but should never be called
mockExec.mockRejectedValue(new Error("ENOENT"));
await expect(program.parseAsync(["node", "test", "spawn"])).rejects.toThrow("process.exit(1)");
await program.parseAsync(["node", "test", "spawn"]);
expect(mockSessionManager.spawn).toHaveBeenCalled();
const errors = vi
.mocked(console.error)
.mock.calls.map((c) => String(c[0]))
.join("\n");
expect(errors).toContain("tmux is not installed");
expect(mockSessionManager.spawn).not.toHaveBeenCalled();
});
it("checks gh auth when tracker is github", async () => {
it("skips scm.preflight when --claim-pr is not provided", async () => {
const trackerPreflight = vi.fn().mockResolvedValue(undefined);
const scmPreflight = vi.fn().mockResolvedValue(undefined);
mockRegistryGet.mockImplementation((slot: string) => {
if (slot === "tracker") return { name: "github", preflight: trackerPreflight };
if (slot === "scm") return { name: "github", preflight: scmPreflight };
return null;
});
const projects = (mockConfigRef.current as Record<string, unknown>).projects as Record<
string,
Record<string, unknown>
>;
projects["my-app"].tracker = { plugin: "github" };
projects["my-app"].scm = { plugin: "github" };
// tmux check passes, gh --version passes, gh auth status fails
mockExec
.mockResolvedValueOnce({ stdout: "tmux 3.3a", stderr: "" }) // tmux -V
.mockResolvedValueOnce({ stdout: "gh version 2.40", stderr: "" }) // gh --version
.mockRejectedValueOnce(new Error("not logged in")); // gh auth status
mockSessionManager.spawn.mockResolvedValue(makeFakeSession());
await expect(program.parseAsync(["node", "test", "spawn"])).rejects.toThrow(
"process.exit(1)",
);
await program.parseAsync(["node", "test", "spawn"]);
const errors = vi
.mocked(console.error)
.mock.calls.map((c) => String(c[0]))
.join("\n");
expect(errors).toContain("not authenticated");
expect(mockSessionManager.spawn).not.toHaveBeenCalled();
expect(trackerPreflight).toHaveBeenCalled();
expect(scmPreflight).not.toHaveBeenCalled();
expect(mockSessionManager.spawn).toHaveBeenCalled();
});
it("checks gh auth when --claim-pr targets a github SCM project", async () => {
it("calls scm.preflight with willClaimExistingPR=true when --claim-pr is provided", async () => {
const scmPreflight = vi.fn().mockResolvedValue(undefined);
mockRegistryGet.mockImplementation((slot: string) => {
if (slot === "scm") return { name: "github", preflight: scmPreflight };
return null;
});
const projects = (mockConfigRef.current as Record<string, unknown>).projects as Record<
string,
Record<string, unknown>
>;
projects["my-app"].tracker = { plugin: "linear" };
projects["my-app"].scm = { plugin: "github" };
mockExec
.mockResolvedValueOnce({ stdout: "tmux 3.3a", stderr: "" })
.mockResolvedValueOnce({ stdout: "gh version 2.40", stderr: "" })
.mockRejectedValueOnce(new Error("not logged in"));
await expect(
program.parseAsync(["node", "test", "spawn", "--claim-pr", "123"]),
).rejects.toThrow("process.exit(1)");
const errors = vi
.mocked(console.error)
.mock.calls.map((c) => String(c[0]))
.join("\n");
expect(errors).toContain("not authenticated");
expect(mockSessionManager.spawn).not.toHaveBeenCalled();
});
it("handles tracker+scm github preflight when claiming during spawn", async () => {
const fakeSession: Session = {
id: "app-1",
projectId: "my-app",
status: "spawning",
activity: null,
branch: null,
issueId: null,
pr: null,
workspacePath: "/tmp/wt",
runtimeHandle: { id: "hash-app-1", runtimeName: "tmux", data: {} },
agentInfo: null,
createdAt: new Date(),
lastActivityAt: new Date(),
metadata: {},
};
mockSessionManager.spawn.mockResolvedValue(fakeSession);
mockSessionManager.spawn.mockResolvedValue(makeFakeSession());
mockSessionManager.claimPR.mockResolvedValue({
sessionId: "app-1",
projectId: "my-app",
@ -808,86 +796,59 @@ describe("spawn pre-flight checks", () => {
takenOverFrom: [],
});
const projects = (mockConfigRef.current as Record<string, unknown>).projects as Record<
string,
Record<string, unknown>
>;
projects["my-app"].tracker = { plugin: "github" };
projects["my-app"].scm = { plugin: "github" };
mockExec
.mockResolvedValueOnce({ stdout: "tmux 3.3a", stderr: "" })
.mockResolvedValueOnce({ stdout: "gh version 2.40", stderr: "" })
.mockResolvedValueOnce({ stdout: "Logged in", stderr: "" });
await program.parseAsync(["node", "test", "spawn", "--claim-pr", "123"]);
expect(mockExec).toHaveBeenCalledWith("tmux", ["-V"]);
const ghCalls = mockExec.mock.calls.filter(([command]) => command === "gh");
expect(ghCalls).toHaveLength(2);
expect(mockSessionManager.spawn).toHaveBeenCalled();
expect(mockSessionManager.claimPR).toHaveBeenCalledWith("app-1", "123", {
assignOnGithub: undefined,
});
expect(scmPreflight).toHaveBeenCalledTimes(1);
const ctx = scmPreflight.mock.calls[0]?.[0] as { intent: { willClaimExistingPR: boolean } };
expect(ctx.intent.willClaimExistingPR).toBe(true);
});
it("skips gh auth check when tracker is not github", async () => {
const fakeSession: Session = {
id: "app-1",
projectId: "my-app",
status: "spawning",
activity: null,
branch: null,
issueId: null,
pr: null,
workspacePath: "/tmp/wt",
runtimeHandle: { id: "hash-1", runtimeName: "tmux", data: {} },
agentInfo: null,
createdAt: new Date(),
lastActivityAt: new Date(),
metadata: {},
};
mockSessionManager.spawn.mockResolvedValue(fakeSession);
it("does not iterate the tracker slot when no tracker is configured", async () => {
const trackerPreflight = vi.fn().mockResolvedValue(undefined);
mockRegistryGet.mockImplementation((slot: string) => {
if (slot === "tracker") return { name: "github", preflight: trackerPreflight };
return null;
});
const projects = (mockConfigRef.current as Record<string, unknown>).projects as Record<
string,
Record<string, unknown>
>;
projects["my-app"].tracker = { plugin: "linear" };
// tmux check passes — gh should never be called
mockExec.mockResolvedValue({ stdout: "tmux 3.3a", stderr: "" });
// Project intentionally has no tracker configured.
mockSessionManager.spawn.mockResolvedValue(makeFakeSession());
await program.parseAsync(["node", "test", "spawn"]);
// Should only call tmux -V, not gh
expect(mockExec).toHaveBeenCalledWith("tmux", ["-V"]);
expect(mockExec).not.toHaveBeenCalledWith("gh", expect.anything());
expect(mockSessionManager.spawn).toHaveBeenCalled();
expect(trackerPreflight).not.toHaveBeenCalled();
});
it("distinguishes gh not installed from gh not authenticated", async () => {
it("collects every plugin's preflight failure into one combined error", async () => {
const runtimePreflight = vi.fn().mockRejectedValue(new Error("tmux is not installed"));
const trackerPreflight = vi
.fn()
.mockRejectedValue(new Error("GitHub CLI is not authenticated. Run: gh auth login"));
mockRegistryGet.mockImplementation((slot: string) => {
if (slot === "runtime") return { name: "tmux", preflight: runtimePreflight };
if (slot === "tracker") return { name: "github", preflight: trackerPreflight };
return null;
});
const projects = (mockConfigRef.current as Record<string, unknown>).projects as Record<
string,
Record<string, unknown>
>;
projects["my-app"].tracker = { plugin: "github" };
// tmux passes, gh --version fails (not installed)
mockExec
.mockResolvedValueOnce({ stdout: "tmux 3.3a", stderr: "" }) // tmux -V
.mockRejectedValueOnce(new Error("ENOENT")); // gh --version fails
await expect(program.parseAsync(["node", "test", "spawn"])).rejects.toThrow("process.exit(1)");
await expect(program.parseAsync(["node", "test", "spawn"])).rejects.toThrow(
"process.exit(1)",
);
// Both preflights ran (collect-all, not fail-fast).
expect(runtimePreflight).toHaveBeenCalled();
expect(trackerPreflight).toHaveBeenCalled();
const errors = vi
.mocked(console.error)
.mock.calls.map((c) => String(c[0]))
.join("\n");
expect(errors).toContain("not installed");
expect(errors).not.toContain("not authenticated");
expect(errors).toContain("2 preflight checks failed");
expect(errors).toContain("tmux is not installed");
expect(errors).toContain("gh auth login");
expect(mockSessionManager.spawn).not.toHaveBeenCalled();
});
});
@ -993,6 +954,12 @@ describe("batch-spawn command", () => {
};
mkdirSync(join(tmpDir, "agent-orchestrator"), { recursive: true });
mkdirSync(join(tmpDir, "x402-identity"), { recursive: true });
mockGetRunning.mockResolvedValue({
pid: 1234,
port: 3000,
startedAt: "",
projects: ["agent-orchestrator", "x402-identity"],
});
// Pre-existing active session in x402-identity for issue 20
mockSessionManager.list.mockImplementation(async (pid: string) => {
@ -1029,3 +996,91 @@ describe("batch-spawn command", () => {
});
});
});
describe("spawn daemon-polling enforcement", () => {
it("refuses to spawn when no AO daemon is running", async () => {
mockGetRunning.mockResolvedValue(null);
await expect(program.parseAsync(["node", "test", "spawn"])).rejects.toThrow(
"process.exit(1)",
);
const errors = vi
.mocked(console.error)
.mock.calls.map((c) => String(c[0]))
.join("\n");
expect(errors).toContain("AO is not running");
expect(errors).toContain("ao start");
expect(mockSessionManager.spawn).not.toHaveBeenCalled();
});
it("refuses to spawn when the running daemon is not polling the project", async () => {
mockGetRunning.mockResolvedValue({
pid: 99999,
port: 3000,
startedAt: "",
projects: ["other-project"],
});
await expect(program.parseAsync(["node", "test", "spawn"])).rejects.toThrow(
"process.exit(1)",
);
const errors = vi
.mocked(console.error)
.mock.calls.map((c) => String(c[0]))
.join("\n");
expect(errors).toContain("not polling project");
expect(errors).toContain("my-app");
expect(errors).toContain("ao start my-app");
expect(mockSessionManager.spawn).not.toHaveBeenCalled();
});
});
describe("batch-spawn daemon-polling enforcement", () => {
let batchProgram: Command;
beforeEach(() => {
batchProgram = new Command();
batchProgram.exitOverride();
registerBatchSpawn(batchProgram);
});
it("refuses to batch-spawn when no AO daemon is running", async () => {
mockGetRunning.mockResolvedValue(null);
await expect(
batchProgram.parseAsync(["node", "test", "batch-spawn", "INT-1", "INT-2"]),
).rejects.toThrow("process.exit(1)");
const errors = vi
.mocked(console.error)
.mock.calls.map((c) => String(c[0]))
.join("\n");
expect(errors).toContain("AO is not running");
expect(errors).toContain("ao start");
expect(mockSessionManager.spawn).not.toHaveBeenCalled();
});
it("refuses to batch-spawn when the running daemon is not polling the project", async () => {
mockGetRunning.mockResolvedValue({
pid: 99999,
port: 3000,
startedAt: "",
projects: ["other-project"],
});
await expect(
batchProgram.parseAsync(["node", "test", "batch-spawn", "INT-1", "INT-2"]),
).rejects.toThrow("process.exit(1)");
const errors = vi
.mocked(console.error)
.mock.calls.map((c) => String(c[0]))
.join("\n");
expect(errors).toContain("not polling project");
expect(errors).toContain("my-app");
expect(errors).toContain("ao start my-app");
expect(mockSessionManager.spawn).not.toHaveBeenCalled();
});
});

View File

@ -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,
};
});
@ -175,7 +181,6 @@ vi.mock("../../src/lib/preflight.js", () => ({
preflight: {
checkPort: vi.fn(),
checkBuilt: vi.fn(),
checkTmux: vi.fn().mockResolvedValue(undefined),
},
}));
@ -261,7 +266,7 @@ vi.mock("node:process", async (importOriginal) => {
// ---------------------------------------------------------------------------
import { Command } from "commander";
import { registerStart, registerStop, createConfigOnly } from "../../src/commands/start.js";
import { registerStart, registerStop, autoCreateConfig } from "../../src/commands/start.js";
let tmpDir: string;
let program: Command;
@ -328,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("");
@ -374,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();
@ -425,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: [],
@ -647,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 });
},
);
@ -696,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 });
},
);
@ -756,7 +764,7 @@ describe("start command — URL argument", () => {
[
"port: 4000",
"defaults:",
" runtime: tmux",
" runtime: process",
" agent: claude-code",
" workspace: worktree",
" notifiers: [desktop]",
@ -797,7 +805,7 @@ describe("start command — URL argument", () => {
[
"port: 4000",
"defaults:",
" runtime: tmux",
" runtime: process",
" agent: claude-code",
" workspace: worktree",
" notifiers: [desktop]",
@ -873,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;
@ -882,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);
@ -1229,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 () => {
@ -1744,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 () => {
@ -1981,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 () => {
@ -2134,7 +2245,7 @@ describe("start command — autoCreateConfig", () => {
const callerContext = await import("../../src/lib/caller-context.js");
vi.spyOn(callerContext, "isHumanCaller").mockReturnValue(false);
await createConfigOnly();
await autoCreateConfig(tmpDir);
const configPath = join(tmpDir, "agent-orchestrator.yaml");
expect(existsSync(configPath)).toBe(true);
@ -2268,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",
@ -2313,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;
@ -2417,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");
@ -2432,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
@ -2440,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 () => {
@ -2461,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",
@ -2515,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",
@ -2568,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",
@ -2621,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",
@ -2681,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",
@ -2782,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",

View File

@ -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-"));
});

View File

@ -0,0 +1,131 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type * as AoCore from "@aoagents/ao-core";
const { mockUnregister, mockWaitForExit, mockKillProcessTree } = vi.hoisted(() => ({
mockUnregister: vi.fn(),
mockWaitForExit: vi.fn(),
mockKillProcessTree: vi.fn(),
}));
vi.mock("../../src/lib/running-state.js", () => ({
unregister: mockUnregister,
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";
const fakeRunning: RunningState = {
pid: 12345,
configPath: "/fake/config.yaml",
port: 3000,
startedAt: "2026-05-04T00:00:00Z",
projects: ["my-app"],
};
beforeEach(() => {
mockUnregister.mockReset();
mockUnregister.mockResolvedValue(undefined);
mockWaitForExit.mockReset();
mockKillProcessTree.mockReset();
mockKillProcessTree.mockResolvedValue(undefined);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("attachToDaemon", () => {
it("returns an AttachedDaemon with the running state's port and pid", () => {
const daemon = attachToDaemon(fakeRunning);
expect(daemon.outcome).toBe("attached");
expect(daemon.port).toBe(3000);
expect(daemon.pid).toBe(12345);
});
it("notifyProjectChange POSTs /api/projects/reload and returns ok on 2xx", async () => {
const fetchSpy = vi
.spyOn(globalThis, "fetch")
.mockResolvedValue(new Response(null, { status: 200 }));
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",
});
fetchSpy.mockRestore();
});
it("notifyProjectChange returns a reasoned failure on non-2xx", async () => {
const fetchSpy = vi
.spyOn(globalThis, "fetch")
.mockResolvedValue(new Response(null, { status: 503 }));
const daemon = attachToDaemon(fakeRunning);
const result = await daemon.notifyProjectChange();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.reason).toContain("503");
}
fetchSpy.mockRestore();
});
it("notifyProjectChange returns a reasoned failure when fetch throws", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("ECONNREFUSED"));
const daemon = attachToDaemon(fakeRunning);
const result = await daemon.notifyProjectChange();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.reason).toContain("ECONNREFUSED");
}
fetchSpy.mockRestore();
});
});
describe("killExistingDaemon", () => {
it("uses killProcessTree(SIGTERM), awaits exit, and unregisters on the happy path", async () => {
mockWaitForExit.mockResolvedValueOnce(true);
await killExistingDaemon(fakeRunning);
expect(mockKillProcessTree).toHaveBeenCalledWith(12345, "SIGTERM");
expect(mockKillProcessTree).toHaveBeenCalledTimes(1);
expect(mockWaitForExit).toHaveBeenCalledWith(12345, 5000);
expect(mockUnregister).toHaveBeenCalled();
});
it("escalates to SIGKILL via killProcessTree when SIGTERM does not exit", async () => {
mockWaitForExit.mockResolvedValueOnce(false);
mockWaitForExit.mockResolvedValueOnce(true);
await killExistingDaemon(fakeRunning);
expect(mockKillProcessTree).toHaveBeenNthCalledWith(1, 12345, "SIGTERM");
expect(mockKillProcessTree).toHaveBeenNthCalledWith(2, 12345, "SIGKILL");
expect(mockUnregister).toHaveBeenCalled();
});
it("throws when SIGKILL also fails to exit, and does not unregister", async () => {
mockWaitForExit.mockResolvedValueOnce(false);
mockWaitForExit.mockResolvedValueOnce(false);
await expect(killExistingDaemon(fakeRunning)).rejects.toThrow(
/Failed to stop AO process \(PID 12345\)/,
);
expect(mockUnregister).not.toHaveBeenCalled();
});
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)).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();
});
});

View File

@ -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");
});
});

View File

@ -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));
});
});

View File

@ -1,15 +1,10 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const { mockExec, mockIsPortAvailable, mockExistsSync } = vi.hoisted(() => ({
mockExec: vi.fn(),
const { mockIsPortAvailable, mockExistsSync } = vi.hoisted(() => ({
mockIsPortAvailable: vi.fn(),
mockExistsSync: vi.fn(),
}));
vi.mock("../../src/lib/shell.js", () => ({
exec: mockExec,
}));
vi.mock("../../src/lib/web-dir.js", () => ({
isPortAvailable: mockIsPortAvailable,
}));
@ -26,7 +21,6 @@ vi.mock("../../src/lib/dashboard-rebuild.js", () => ({
import { preflight } from "../../src/lib/preflight.js";
beforeEach(() => {
mockExec.mockReset();
mockIsPortAvailable.mockReset();
mockExistsSync.mockReset();
});
@ -130,65 +124,5 @@ describe("preflight.checkBuilt", () => {
});
});
describe("preflight.checkTmux", () => {
it("passes when tmux is already installed", async () => {
mockExec.mockResolvedValue({ stdout: "tmux 3.3a", stderr: "" });
await expect(preflight.checkTmux()).resolves.toBeUndefined();
expect(mockExec).toHaveBeenCalledWith("tmux", ["-V"]);
});
it("throws with install instructions when tmux is missing", async () => {
mockExec.mockRejectedValue(new Error("ENOENT"));
const err = await preflight.checkTmux().catch((e: Error) => e);
expect(err).toBeInstanceOf(Error);
expect(err.message).toContain("tmux is not installed");
expect(err.message).toContain("Install it:");
expect(mockExec).toHaveBeenCalledTimes(1);
expect(mockExec).toHaveBeenCalledWith("tmux", ["-V"]);
});
});
describe("preflight.checkGhAuth", () => {
it("passes when gh is installed and authenticated", async () => {
mockExec.mockResolvedValue({ stdout: "ok", stderr: "" });
await expect(preflight.checkGhAuth()).resolves.toBeUndefined();
expect(mockExec).toHaveBeenCalledWith("gh", ["--version"]);
expect(mockExec).toHaveBeenCalledWith("gh", ["auth", "status"]);
});
it("throws 'not installed' when gh is missing (ENOENT)", async () => {
mockExec.mockRejectedValue(new Error("ENOENT"));
await expect(preflight.checkGhAuth()).rejects.toThrow(
"GitHub CLI (gh) is not installed",
);
// Should only call --version, not auth status
expect(mockExec).toHaveBeenCalledTimes(1);
expect(mockExec).toHaveBeenCalledWith("gh", ["--version"]);
});
it("throws 'not authenticated' when gh exists but auth fails", async () => {
mockExec
.mockResolvedValueOnce({ stdout: "gh version 2.40", stderr: "" }) // --version succeeds
.mockRejectedValueOnce(new Error("not logged in")); // auth status fails
await expect(preflight.checkGhAuth()).rejects.toThrow(
"GitHub CLI is not authenticated",
);
expect(mockExec).toHaveBeenCalledTimes(2);
});
it("includes correct fix instructions for each failure", async () => {
// Not installed → install link
mockExec.mockRejectedValue(new Error("ENOENT"));
await expect(preflight.checkGhAuth()).rejects.toThrow(
"https://cli.github.com/",
);
mockExec.mockReset();
// Not authenticated → auth login
mockExec
.mockResolvedValueOnce({ stdout: "gh version 2.40", stderr: "" })
.mockRejectedValueOnce(new Error("not logged in"));
await expect(preflight.checkGhAuth()).rejects.toThrow("gh auth login");
});
});
// checkTmux + checkGhAuth moved into the runtime-tmux / tracker-github / scm-github
// plugins as their own preflight() methods. See those plugins' tests for coverage.

View File

@ -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,

View File

@ -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);
});
});

View File

@ -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();
});
});

View File

@ -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;
});

View File

@ -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 });
}
});
});

View File

@ -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);

View File

@ -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");
});
});

View File

@ -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 });

View File

@ -1,6 +1,6 @@
{
"name": "@aoagents/ao-cli",
"version": "0.3.0",
"version": "0.6.0",
"description": "CLI for agent-orchestrator — the `ao` command",
"license": "MIT",
"type": "module",
@ -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",

View File

@ -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

View File

@ -349,7 +349,7 @@ check_config_dirs() {
local config_path data_dir worktree_dir
config_path="$(find_config || true)"
if [ -z "$config_path" ]; then
warn "No agent-orchestrator config was found. Fix: run ao init --auto in a target repo"
warn "No agent-orchestrator config was found. Fix: run ao start in a target repo"
return
fi

View File

@ -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

View File

@ -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) {

View File

@ -1,18 +0,0 @@
import chalk from "chalk";
import type { Command } from "commander";
export function registerInit(program: Command): void {
program
.command("init")
.description("[deprecated] Use 'ao start' instead — it auto-creates config on first run")
.action(async () => {
console.log(
chalk.yellow(
"⚠ 'ao init' is deprecated. Use 'ao start' instead.\n" +
" 'ao start' auto-detects your project and creates the config.\n",
),
);
const { createConfigOnly } = await import("./start.js");
await createConfigOnly();
});
}

View File

@ -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();
});

View File

@ -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

View File

@ -7,12 +7,12 @@ import {
resolveSpawnTarget,
TERMINAL_STATUSES,
type OrchestratorConfig,
type PreflightContext,
} from "@aoagents/ao-core";
import { DEFAULT_PORT } from "../lib/constants.js";
import { exec } from "../lib/shell.js";
import { banner } from "../lib/format.js";
import { getSessionManager } from "../lib/create-session-manager.js";
import { preflight } from "../lib/preflight.js";
import { getPluginRegistry, getSessionManager } from "../lib/create-session-manager.js";
import { findProjectForDirectory } from "../lib/project-resolution.js";
import { getRunning } from "../lib/running-state.js";
import { projectSessionUrl } from "../lib/routes.js";
@ -51,6 +51,49 @@ function autoDetectProject(config: OrchestratorConfig): string {
);
}
/**
* Non-throwing variant returns null when the project can't be resolved
* unambiguously. Used to feed `resolveSpawnTarget`'s fallback parameter so
* the prefix/no-prefix and issue/no-issue paths share one code path.
*/
function tryAutoDetectProject(config: OrchestratorConfig): string | null {
try {
return autoDetectProject(config);
} catch {
return null;
}
}
/**
* Resolve the project + issue from a single optional CLI argument.
*
* Single source of truth for the four cases:
* - `ao spawn` auto-detect project, no issue
* - `ao spawn 42` auto-detect project, issue=42
* - `ao spawn xid/42` prefix match, issue=42
* - `ao spawn x402-identity/42` exact projectId match, issue=42
*
* Throws (via autoDetectProject) when the project can't be resolved the
* caller wraps in one try/catch instead of duplicating it across branches.
*/
function resolveProjectAndIssue(
config: OrchestratorConfig,
issue: string | undefined,
): { projectId: string; issueId?: string } {
const fallback = tryAutoDetectProject(config);
if (issue) {
const target = resolveSpawnTarget(config.projects, issue, fallback ?? undefined);
if (target) return { projectId: target.projectId, issueId: target.issueId };
autoDetectProject(config); // throws with the real error message
throw new Error("unreachable");
}
if (!fallback) {
autoDetectProject(config); // throws
throw new Error("unreachable");
}
return { projectId: fallback };
}
interface SpawnClaimOptions {
claimPr?: string;
assignOnGithub?: boolean;
@ -59,33 +102,41 @@ interface SpawnClaimOptions {
/**
* Lifecycle polling runs in-process inside the long-lived `ao start` process.
* `ao spawn` is a one-shot CLI it can't start polling in its own process
* (the interval would keep the CLI alive forever and duplicate work). Warn
* when no `ao start` is running, or when the running instance isn't covering
* this project (e.g. `ao start A` then `ao spawn` in B).
* (the interval would keep the CLI alive forever and duplicate work).
*
* Refuse to spawn if no `ao start` is running, or if the running instance is
* not polling this project. Without an active daemon, sessions get worktrees
* and tmux panes but no lifecycle reactions (CI-failure routing, review
* comments, revive transitions, event log). That silent blackout is a
* worse failure mode than creating no session at all so fail fast with
* an actionable error.
*/
async function warnIfAONotRunning(projectId: string): Promise<void> {
async function ensureAOPollingProject(projectId: string): Promise<void> {
const running = await getRunning();
if (!running) {
console.log(
chalk.yellow(
"⚠ AO is not running — lifecycle polling is inactive. Run `ao start` so the new session is tracked.",
),
throw new Error(
`AO is not running — lifecycle polling is inactive. Run \`ao start\` before spawning sessions so they get CI/review routing and state advancement.`,
);
return;
}
if (!running.projects.includes(projectId)) {
console.log(
chalk.yellow(
`⚠ The running AO instance (pid ${running.pid}) is not polling project "${projectId}" yet. Lifecycle polling will attach within ~60s.`,
),
throw new Error(
`The running AO instance (pid ${running.pid}) is not polling project "${projectId}". Run \`ao start ${projectId}\` before spawning so sessions get tracked.`,
);
}
}
/**
* Run pre-flight checks for a project once, before any sessions are spawned.
* Validates runtime and tracker prerequisites so failures surface immediately
* rather than repeating per-session in a batch.
*
* Iterates the plugins selected for this spawn and calls each one's optional
* `preflight()`. Plugins own their own prerequisites (binary present, auth
* configured, etc.) so this CLI helper does not need to know which plugin
* needs which tool. Adding a new runtime/tracker/scm plugin only requires
* the plugin to declare its own preflight no edits here.
*
* Collects every plugin's failure rather than aborting at the first one, so a
* user with multiple broken prerequisites (e.g. tmux missing AND gh logged
* out) sees both errors in a single run instead of fixing one and re-invoking.
*/
async function runSpawnPreflight(
config: OrchestratorConfig,
@ -93,15 +144,54 @@ async function runSpawnPreflight(
options?: SpawnClaimOptions,
): Promise<void> {
const project = config.projects[projectId];
const runtime = project?.runtime ?? config.defaults.runtime;
if (runtime === "tmux") {
await preflight.checkTmux();
if (!project) return;
const ctx: PreflightContext = {
project,
intent: {
role: "worker",
willClaimExistingPR: !!options?.claimPr,
},
};
const registry = await getPluginRegistry(config);
// DefaultPluginsSchema (config.ts) defaults runtime/agent/workspace via
// .default(), so these are guaranteed strings — no literal fallback needed.
const runtimeName = project.runtime ?? config.defaults.runtime;
const agentName = project.agent ?? config.defaults.agent;
const workspaceName = project.workspace ?? config.defaults.workspace;
const trackerName = project.tracker?.plugin;
const scmName = project.scm?.plugin;
// Only iterate plugins that the spawn will actually exercise. SCM is
// skipped unless the user passed --claim-pr; otherwise an unconfigured
// gh auth would block spawns that don't touch PRs.
const candidates: Array<unknown> = [
registry.get("runtime", runtimeName),
registry.get("agent", agentName),
registry.get("workspace", workspaceName),
trackerName ? registry.get("tracker", trackerName) : null,
options?.claimPr && scmName ? registry.get("scm", scmName) : null,
];
const errors: Error[] = [];
for (const plugin of candidates) {
const preflight = (plugin as { preflight?: (ctx: PreflightContext) => Promise<void> } | null)
?.preflight;
if (!preflight) continue;
try {
await preflight.call(plugin, ctx);
} catch (err) {
errors.push(err instanceof Error ? err : new Error(String(err)));
}
}
const needsGitHubAuth =
project?.tracker?.plugin === "github" ||
(options?.claimPr && project?.scm?.plugin === "github");
if (needsGitHubAuth) {
await preflight.checkGhAuth();
if (errors.length === 1) throw errors[0];
if (errors.length > 1) {
throw new Error(
`${errors.length} preflight checks failed:\n` +
errors.map((e, i) => ` ${i + 1}. ${e.message}`).join("\n"),
);
}
}
@ -113,7 +203,7 @@ async function spawnSession(
agent?: string,
claimOptions?: SpawnClaimOptions,
prompt?: string,
): Promise<string> {
): Promise<void> {
const spinner = ora("Creating session").start();
try {
@ -158,17 +248,6 @@ async function spawnSession(
);
console.log(` View: ${chalk.dim(projectSessionUrl(port, projectId, session.id))}`);
// Warn if prompt delivery failed (for post-launch agents like Claude Code)
const promptDelivered = session.metadata?.promptDelivered;
if (promptDelivered === "false") {
console.warn(
chalk.yellow(
` ⚠ Prompt delivery failed — agent may be idle.\n` +
` Use '${chalk.cyan("ao send " + session.id + ' "message..."')}' to send instructions manually.`,
),
);
}
// Open terminal tab if requested
if (openTab) {
try {
@ -181,7 +260,6 @@ async function spawnSession(
// Output for scripting
console.log(`SESSION=${session.id}`);
return session.id;
} catch (err) {
spinner.fail("Failed to create or initialize session");
throw err;
@ -228,29 +306,11 @@ export function registerSpawn(program: Command): void {
const config = loadConfig();
let projectId: string;
let issueId: string | undefined;
if (issue) {
const prefixed = resolveSpawnTarget(config.projects, issue);
if (prefixed) {
projectId = prefixed.projectId;
issueId = prefixed.issueId;
} else {
issueId = issue;
try {
projectId = autoDetectProject(config);
} catch (err) {
console.error(chalk.red(err instanceof Error ? err.message : String(err)));
process.exit(1);
}
}
} else {
// No args: auto-detect project, no issue
try {
projectId = autoDetectProject(config);
} catch (err) {
console.error(chalk.red(err instanceof Error ? err.message : String(err)));
process.exit(1);
}
try {
({ projectId, issueId } = resolveProjectAndIssue(config, issue));
} catch (err) {
console.error(chalk.red(err instanceof Error ? err.message : String(err)));
process.exit(1);
}
if (!opts.claimPr && opts.assignOnGithub) {
@ -265,7 +325,7 @@ export function registerSpawn(program: Command): void {
try {
await runSpawnPreflight(config, projectId, claimOptions);
await warnIfAONotRunning(projectId);
await ensureAOPollingProject(projectId);
await spawnSession(config, projectId, issueId, opts.open, opts.agent, claimOptions, opts.prompt);
} catch (err) {
@ -342,7 +402,7 @@ export function registerBatchSpawn(program: Command): void {
// Pre-flight once per project group so a missing prerequisite fails fast.
try {
await runSpawnPreflight(config, groupProjectId);
await warnIfAONotRunning(groupProjectId);
await ensureAOPollingProject(groupProjectId);
} catch (err) {
console.error(chalk.red(`${err instanceof Error ? err.message : String(err)}`));
process.exit(1);

File diff suppressed because it is too large Load Diff

View File

@ -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;
@ -348,7 +355,7 @@ export function registerStatus(program: Command): void {
try {
config = loadConfig();
} catch {
console.log(chalk.yellow("No config found. Run `ao init` first."));
console.log(chalk.yellow("No config found. Run `ao start` first."));
console.log(chalk.dim("Falling back to session discovery...\n"));
await showFallbackStatus();
return;
@ -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."));

View File

@ -29,7 +29,7 @@ function resolveProject(
const ids = Object.keys(config.projects);
if (ids.length === 0) {
console.error(chalk.red("No projects configured. Run `ao init` first."));
console.error(chalk.red("No projects configured. Run `ao start` first."));
process.exit(1);
}
if (ids.length > 1) {
@ -91,7 +91,7 @@ export function registerVerify(program: Command): void {
try {
config = loadConfig();
} catch {
console.error(chalk.red("No config found. Run `ao init` first."));
console.error(chalk.red("No config found. Run `ao start` first."));
process.exit(1);
return;
}

View File

@ -0,0 +1,104 @@
/**
* Daemon attachment and lifecycle helpers.
*
* Pulls the "AO is already running" branches out of `start.ts` so the running
* vs. not-running fork in the entry command is a single decision point and
* the per-branch operations (attach, kill, dashboard cache invalidation) can
* be reused.
*
* Today this covers:
* - {@link attachToDaemon}: build a handle to an already-running daemon and
* surface dashboard cache invalidation through {@link AttachedDaemon.notifyProjectChange}.
* - {@link killExistingDaemon}: SIGTERM -> wait -> SIGKILL the daemon and
* unregister `running.json`. Used by the "Restart everything" menu option.
*
* Spawning a fresh daemon (dashboard + supervisor + register) still lives in
* `start.ts`'s `runStartup`. PR B's plan eventually folds that into a
* symmetrical `spawnDaemon` here, but doing so requires extracting last-stop
* restore + browser-open + shutdown-handler installation, which is out of
* scope for this PR.
*/
import chalk from "chalk";
import { killProcessTree } from "@aoagents/ao-core";
import { unregister, waitForExit, type RunningState } from "./running-state.js";
/**
* Return value of `notifyProjectChange`. The daemon may be unreachable (user
* stopped the dashboard manually) callers can choose to warn or throw based
* on whether they expect the daemon to be live.
*/
export type NotifyResult = { ok: true } | { ok: false; reason: string };
/**
* Handle for a daemon that was already running when this CLI invocation
* started. Distinguishes from a freshly-spawned daemon (which would be
* managed in-process by `runStartup` + `installShutdownHandlers`).
*/
export interface AttachedDaemon {
readonly outcome: "attached";
readonly port: number;
readonly pid: number;
/**
* Tell the dashboard a project was added or changed so it reloads its
* cached config. Returns `{ ok: false }` on any error; the dashboard might
* be down (user killed it manually), so callers typically warn rather than
* throw.
*/
notifyProjectChange(): Promise<NotifyResult>;
}
async function postProjectsReload(port: number): Promise<NotifyResult> {
try {
const res = await fetch(`http://localhost:${port}/api/projects/reload`, {
method: "POST",
});
if (res.ok) return { ok: true };
return { ok: false, reason: `Dashboard reload returned ${res.status}` };
} catch (err) {
return {
ok: false,
reason: err instanceof Error ? err.message : String(err),
};
}
}
/**
* Build an {@link AttachedDaemon} for an existing running daemon.
* Pure does not perform any network/filesystem I/O until the returned
* handle's methods are called.
*/
export function attachToDaemon(running: RunningState): AttachedDaemon {
return {
outcome: "attached",
port: running.port,
pid: running.pid,
notifyProjectChange: () => postProjectsReload(running.port),
};
}
/**
* Stop a running daemon synchronously: SIGTERM, wait up to 5s, SIGKILL if
* 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> {
await killProcessTree(running.pid, "SIGTERM");
if (!(await waitForExit(running.pid, 5000))) {
console.log(chalk.yellow(" Process didn't exit cleanly, sending SIGKILL..."));
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.`,
);
}
}
await unregister();
}

View File

@ -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));
}

View File

@ -0,0 +1,102 @@
/**
* Shared primitives for interactively installing missing CLI tools.
*
* Used by `startup-preflight.ts` (ensureGit, ensureTmux), the agent runtime
* installer, and the optional gh install path in autoCreateConfig. Lives in
* its own module so the preflight code can be moved out of `start.ts`
* without re-extracting these helpers later.
*/
import { spawn } from "node:child_process";
import chalk from "chalk";
import { formatCommandError } from "./cli-errors.js";
import { isHumanCaller } from "./caller-context.js";
import { promptConfirm } from "./prompts.js";
export interface InstallAttempt {
cmd: string;
args: string[];
label: string;
}
export function canPromptForInstall(): boolean {
return isHumanCaller() && Boolean(process.stdin.isTTY && process.stdout.isTTY);
}
export function genericInstallHints(command: string): string[] {
switch (command) {
case "node":
case "npm":
return ["Install Node.js/npm from https://nodejs.org/"];
case "pnpm":
return ["corepack enable && corepack prepare pnpm@latest --activate", "npm install -g pnpm"];
case "pipx":
return ["python3 -m pip install --user pipx", "python3 -m pipx ensurepath"];
default:
return [];
}
}
export async function askYesNo(
question: string,
defaultYes = true,
nonInteractiveDefault = defaultYes,
): Promise<boolean> {
if (!canPromptForInstall()) return nonInteractiveDefault;
return await promptConfirm(question, defaultYes);
}
export async function runInteractiveCommand(
cmd: string,
args: string[],
options?: {
cwd?: string;
env?: Record<string, string>;
action?: string;
installHints?: string[];
},
): Promise<void> {
await new Promise<void>((resolve, reject) => {
const child = spawn(cmd, args, {
cwd: options?.cwd,
env: options?.env ? { ...process.env, ...options.env } : process.env,
stdio: "inherit",
});
child.once("error", (err) => {
reject(
formatCommandError(err, {
cmd,
args,
action: options?.action ?? "run an interactive command",
installHints: options?.installHints ?? genericInstallHints(cmd),
}),
);
});
child.once("close", (code) => {
if (code === 0) {
resolve();
return;
}
reject(new Error(`Command failed (${code ?? "unknown"}): ${cmd} ${args.join(" ")}`));
});
});
}
export async function tryInstallWithAttempts(
attempts: InstallAttempt[],
verify: () => Promise<boolean>,
): Promise<boolean> {
for (const attempt of attempts) {
try {
console.log(chalk.dim(` Running: ${attempt.label}`));
await runInteractiveCommand(attempt.cmd, attempt.args, {
action: "run an interactive installer",
installHints: genericInstallHints(attempt.cmd),
});
if (await verify()) return true;
} catch {
// Try next installer
}
}
return verify();
}

View File

@ -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();

View File

@ -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);
}

View File

@ -1,8 +1,9 @@
/**
* Pre-flight checks for `ao start` and `ao spawn`.
* Pre-flight checks for `ao start` (port + dashboard build artifacts).
*
* Validates runtime prerequisites before entering the main command flow,
* giving clear errors instead of cryptic failures.
* Tool/auth checks for `ao spawn` live on each plugin's `preflight()` method.
* Adding a new runtime/tracker/scm therefore doesn't require editing this
* file the plugin declares its own prereqs.
*
* All checks throw on failure so callers can catch and handle uniformly.
*/
@ -10,7 +11,6 @@
import { existsSync } from "node:fs";
import { resolve, dirname } from "node:path";
import { isPortAvailable } from "./web-dir.js";
import { exec } from "./shell.js";
import { isInstalledUnderNodeModules } from "./dashboard-rebuild.js";
/**
@ -76,49 +76,7 @@ function findPackageUp(startDir: string, ...segments: string[]): string | null {
return null;
}
/**
* Check that tmux is installed.
* Throws with platform-appropriate manual install instructions when missing.
*/
async function checkTmux(): Promise<void> {
try {
await exec("tmux", ["-V"]);
return;
} catch {
// tmux not found
}
const hint =
process.platform === "darwin"
? "brew install tmux"
: process.platform === "win32"
? "tmux is not available on Windows. Use WSL: wsl --install, then: sudo apt install tmux"
: "sudo apt install tmux (Debian/Ubuntu) or sudo dnf install tmux (Fedora)";
throw new Error(`tmux is not installed. Install it: ${hint}`);
}
/**
* Check that the GitHub CLI is installed and authenticated.
* Distinguishes between "not installed" and "not authenticated"
* so the user gets the right troubleshooting guidance.
*/
async function checkGhAuth(): Promise<void> {
try {
await exec("gh", ["--version"]);
} catch {
throw new Error("GitHub CLI (gh) is not installed. Install it: https://cli.github.com/");
}
try {
await exec("gh", ["auth", "status"]);
} catch {
throw new Error("GitHub CLI is not authenticated. Run: gh auth login");
}
}
export const preflight = {
checkPort,
checkBuilt,
checkTmux,
checkGhAuth,
};

View File

@ -0,0 +1,523 @@
/**
* Unified project resolution for `ao start`.
*
* Replaces the per-arg-shape dispatch that lived inline in start.ts (URL
* handleUrlStart, path addProjectToConfig, none loadConfig/autoCreate +
* registerFlatConfig recovery, project-id resolveProject). Each shape
* still has its own helper here, but they all return the same shape so
* the caller can treat them uniformly.
*
* The same resolver runs whether or not a daemon is already up. When a
* daemon is running, callers pass `targetGlobalRegistry: true` so URL/path
* args register the project in the global config (the daemon's source of
* truth) rather than into the cwd-local config a non-running fresh start
* would generate.
*/
import { existsSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { pathsEqual } from "./path-equality.js";
import { cwd } from "node:process";
import {
ConfigNotFoundError,
detectScmPlatform,
findConfigFile,
generateConfigFromUrl,
configToYaml,
isRepoUrl,
loadConfig,
parseRepoUrl,
registerProjectInGlobalConfig,
resolveCloneTarget,
isRepoAlreadyCloned,
getGlobalConfigPath,
sanitizeProjectId,
writeLocalProjectConfig,
type OrchestratorConfig,
type ParsedRepoUrl,
type ProjectConfig,
} from "@aoagents/ao-core";
import chalk from "chalk";
import ora from "ora";
import { findFreePort } from "./web-dir.js";
import { DEFAULT_PORT } from "./constants.js";
import { ensureGit } from "./startup-preflight.js";
import { git } from "./shell.js";
export type ProjectSource = "url" | "path" | "cwd" | "existing-id";
export interface Resolved {
config: OrchestratorConfig;
projectId: string;
project: ProjectConfig;
source: ProjectSource;
/** True when this resolve call wrote new state to disk (cloned a repo,
* generated a config, or registered a project). False when the project
* was already known. Useful for dashboard cache invalidation hints. */
justCreated: boolean;
/** Populated only when source === "url". */
parsed?: ParsedRepoUrl;
}
/**
* Dependencies the resolver needs to delegate to existing start.ts helpers.
* Passed in rather than imported to keep this module decoupled from
* start.ts's other concerns (interactive prompts, agent detection, etc.).
*/
export interface ResolveDeps {
/**
* Add an unregistered local path as a new project. The signature matches
* start.ts's `addProjectToConfig`. Returns the registered project id.
*/
addProjectToConfig: (config: OrchestratorConfig, path: string) => Promise<string>;
/**
* Auto-create a config when none exists at `workingDir`. Matches
* start.ts's `autoCreateConfig`. Returns the loaded config.
*/
autoCreateConfig: (workingDir: string) => Promise<OrchestratorConfig>;
/**
* Resolve an existing project from a loaded config (handles single-
* project, explicit arg, multi-project prompt). Matches start.ts's
* `resolveProject`.
*/
resolveProject: (
config: OrchestratorConfig,
projectArg?: string,
) => Promise<{ projectId: string; project: ProjectConfig; config: OrchestratorConfig }>;
/**
* Resolve a project from a loaded config by matching the URL's
* `ownerRepo`. Matches start.ts's `resolveProjectByRepo`.
*/
resolveProjectByRepo: (
config: OrchestratorConfig,
parsed: ParsedRepoUrl,
) => Promise<{ projectId: string; project: ProjectConfig; config: OrchestratorConfig }>;
/**
* Recover a flat local config that exists but isn't registered globally.
* Matches start.ts's `registerFlatConfig`. Returns the registered id, or
* null if recovery is not possible.
*/
registerFlatConfig: (configPath: string) => Promise<string | null>;
/**
* Clone a repo into the target dir. Matches start.ts's `cloneRepo`.
*/
cloneRepo: (parsed: ParsedRepoUrl, targetDir: string, cwd: string) => Promise<void>;
}
/**
* Options that change how the resolver writes new state to disk.
*
* `targetGlobalRegistry`:
* When `true`, URL and path arguments resolve and register against the
* global config (`~/.agent-orchestrator/config.yaml`) rather than a cwd-
* local one. Used when an `ao` daemon is already running the daemon's
* project supervisor reads from the global registry, so anything we
* freshly clone or add must land there to be visible.
*
* When `false` (default), the resolver behaves as if this were the very
* first `ao start`: URL clones generate a wrapped (`projects:`) yaml in
* the cloned repo, paths register against whatever config the cwd walks
* up to find.
*/
export interface ResolveOptions {
targetGlobalRegistry?: boolean;
}
/**
* Decide whether `arg` looks like a path (rather than a project id).
* 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 {
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;
}
/**
* Detect the actual default branch of a freshly cloned repo. Prefers
* `origin/HEAD` (the remote's default), falls back to the current local
* branch. Returns null for empty repos (no commits).
*/
async function detectClonedRepoDefaultBranch(repoPath: string): Promise<string | null> {
const symref = await git(["symbolic-ref", "refs/remotes/origin/HEAD"], repoPath);
if (symref) {
const match = symref.trim().match(/^refs\/remotes\/origin\/(.+)$/);
if (match) return match[1];
}
const head = await git(["symbolic-ref", "--short", "HEAD"], repoPath);
if (head) {
const trimmed = head.trim();
if (trimmed.length > 0) return trimmed;
}
return null;
}
/**
* Clone (or reuse) a URL and register the project in the global config.
* The repo gets a flat local config (behavior only scm/tracker plugin
* choices); identity (path, repo, defaultBranch, sessionPrefix) lives in
* the global registry so the daemon's project supervisor can see it.
*
* Mirrors the inline clone+register block that previously lived in start.ts
* for the "AO already running + URL arg" case.
*/
async function fromUrlIntoGlobal(arg: string, deps: ResolveDeps): Promise<Resolved> {
const parsed = parseRepoUrl(arg);
const globalConfigPath = getGlobalConfigPath();
const globalConfig = existsSync(globalConfigPath) ? loadConfig(globalConfigPath) : loadConfig();
// Existing project with the same repo? Skip the clone entirely.
for (const [id, p] of Object.entries(globalConfig.projects)) {
if (p.repo === parsed.ownerRepo) {
return {
config: globalConfig,
projectId: id,
project: p,
source: "url",
justCreated: false,
parsed,
};
}
}
console.log(chalk.bold.cyan(`\n Cloning ${parsed.ownerRepo} (${parsed.host})\n`));
await ensureGit("repository cloning");
const cwdDir = process.cwd();
const targetDir = resolveCloneTarget(parsed, cwdDir);
if (isRepoAlreadyCloned(targetDir, parsed.cloneUrl)) {
console.log(chalk.green(` Reusing existing clone at ${targetDir}`));
} else {
try {
await deps.cloneRepo(parsed, targetDir, cwdDir);
console.log(chalk.green(` Cloned to ${targetDir}`));
} catch (err) {
throw new Error(
`Failed to clone ${parsed.ownerRepo}: ${err instanceof Error ? err.message : String(err)}`,
{ cause: err },
);
}
}
// Empty repos can't host an orchestrator worktree — fail early with a
// clear message instead of a confusing "Unable to resolve base ref" later.
const detectedBranch = await detectClonedRepoDefaultBranch(targetDir);
if (!detectedBranch) {
throw new Error(
`Repository "${parsed.ownerRepo}" appears to be empty (no commits or refs).\n` +
` AO needs at least one commit on the default branch to spawn an orchestrator.\n` +
` Push an initial commit, then re-run \`ao start ${arg}\`.`,
);
}
const platform = detectScmPlatform(parsed.host);
const requestedProjectId = sanitizeProjectId(parsed.repo);
const registeredId = registerProjectInGlobalConfig(
requestedProjectId,
parsed.repo,
targetDir,
{
repo: parsed.ownerRepo,
defaultBranch: detectedBranch,
},
globalConfigPath,
);
// Write a flat local config (behavior only, no `projects:` wrapper, no
// identity fields). Identity lives in the global registry; this file
// holds plugin choices for the project. Skip if the upstream commits its
// own agent-orchestrator.yaml — leave it for the user to reconcile.
const hasCommittedConfig =
existsSync(resolve(targetDir, "agent-orchestrator.yaml")) ||
existsSync(resolve(targetDir, "agent-orchestrator.yml"));
if (!hasCommittedConfig) {
writeLocalProjectConfig(targetDir, {
scm: { plugin: platform !== "unknown" ? platform : "github" },
tracker: {
plugin: platform === "gitlab" ? "gitlab" : "github",
},
});
}
const refreshedConfig = loadConfig(globalConfigPath);
const project = refreshedConfig.projects[registeredId];
if (!project) {
throw new Error(`Failed to register "${registeredId}" in the global config — aborting.`);
}
return {
config: refreshedConfig,
projectId: registeredId,
project,
source: "url",
justCreated: true,
parsed,
};
}
async function fromUrl(arg: string, deps: ResolveDeps, opts: ResolveOptions): Promise<Resolved> {
if (opts.targetGlobalRegistry) {
return fromUrlIntoGlobal(arg, deps);
}
console.log(chalk.bold.cyan("\n Agent Orchestrator — Quick Start\n"));
const spinner = ora();
spinner.start("Parsing repository URL");
const parsed = parseRepoUrl(arg);
spinner.succeed(`Repository: ${chalk.cyan(parsed.ownerRepo)} (${parsed.host})`);
await ensureGit("repository cloning");
const cwdDir = process.cwd();
const targetDir = resolveCloneTarget(parsed, cwdDir);
const alreadyCloned = isRepoAlreadyCloned(targetDir, parsed.cloneUrl);
if (alreadyCloned) {
console.log(chalk.green(` Reusing existing clone at ${targetDir}`));
} else {
spinner.start(`Cloning ${parsed.ownerRepo}`);
try {
spinner.stop();
await deps.cloneRepo(parsed, targetDir, cwdDir);
spinner.succeed(`Cloned to ${targetDir}`);
} catch (err) {
spinner.fail("Clone failed");
throw new Error(
`Failed to clone ${parsed.ownerRepo}: ${err instanceof Error ? err.message : String(err)}`,
{ cause: err },
);
}
}
const configPath = resolve(targetDir, "agent-orchestrator.yaml");
const configPathAlt = resolve(targetDir, "agent-orchestrator.yml");
let config: OrchestratorConfig;
let justCreated: boolean;
if (existsSync(configPath)) {
console.log(chalk.green(` Using existing config: ${configPath}`));
config = loadConfig(configPath);
justCreated = false;
} else if (existsSync(configPathAlt)) {
console.log(chalk.green(` Using existing config: ${configPathAlt}`));
config = loadConfig(configPathAlt);
justCreated = false;
} else {
spinner.start("Generating config");
const freePort = await findFreePort(DEFAULT_PORT);
const rawConfig = generateConfigFromUrl({
parsed,
repoPath: targetDir,
port: freePort ?? DEFAULT_PORT,
});
const yamlContent = configToYaml(rawConfig);
writeFileSync(configPath, yamlContent);
spinner.succeed(`Config generated: ${configPath}`);
config = loadConfig(configPath);
justCreated = true;
}
const resolved = await deps.resolveProjectByRepo(config, parsed);
return {
config: resolved.config,
projectId: resolved.projectId,
project: resolved.project,
source: "url",
justCreated,
parsed,
};
}
async function fromPath(arg: string, deps: ResolveDeps, opts: ResolveOptions): Promise<Resolved> {
const resolvedPath = resolve(arg.replace(/^~/, process.env["HOME"] || ""));
// When a daemon is already running, register against the global config
// (the daemon's source of truth) instead of whatever cwd-local config
// findConfigFile() walks up to. addProjectToConfig is canonical-global-
// aware, so handing it a config loaded from the global path routes the
// write through registerProjectInGlobalConfig.
if (opts.targetGlobalRegistry) {
const globalPath = getGlobalConfigPath();
const globalConfig = existsSync(globalPath) ? loadConfig(globalPath) : loadConfig();
// 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,
projectId: existingEntry[0],
project: existingEntry[1],
source: "path",
justCreated: false,
};
}
const addedId = await deps.addProjectToConfig(globalConfig, resolvedPath);
const reloaded = loadConfig(globalConfig.configPath);
const project = reloaded.projects[addedId];
if (!project) {
throw new Error(`Failed to register "${addedId}" in the global config — aborting.`);
}
return {
config: reloaded,
projectId: addedId,
project,
source: "path",
justCreated: true,
};
}
let configPath: string | undefined;
try {
configPath = findConfigFile() ?? undefined;
} catch {
// No config — fall through to autoCreate.
}
if (!configPath) {
// No config anywhere — auto-create at the target path (or cwd if they match).
const targetDir = resolve(cwd()) === resolvedPath ? cwd() : resolvedPath;
const config = await deps.autoCreateConfig(targetDir);
const resolved = await deps.resolveProject(config);
return {
config: resolved.config,
projectId: resolved.projectId,
project: resolved.project,
source: "path",
justCreated: true,
};
}
// Config exists — check if the path is already registered.
const config = loadConfig(configPath);
const existingEntry = Object.entries(config.projects).find(([, p]) =>
pathsEqual(p.path, resolvedPath),
);
if (existingEntry) {
return {
config,
projectId: existingEntry[0],
project: existingEntry[1],
source: "path",
justCreated: false,
};
}
// Path is new — register it.
const addedId = await deps.addProjectToConfig(config, resolvedPath);
const reloaded = loadConfig(config.configPath);
return {
config: reloaded,
projectId: addedId,
project: reloaded.projects[addedId],
source: "path",
justCreated: true,
};
}
async function fromCwdOrId(
arg: string | undefined,
deps: ResolveDeps,
opts: ResolveOptions,
): Promise<Resolved> {
// Daemon-running + project id: the global registry is the source of
// truth for project identity, so look there directly. Skipping the cwd-
// local loadConfig() also avoids first-run autoCreate prompts when the
// user just wants to attach to a known project.
if (opts.targetGlobalRegistry && arg) {
const globalPath = getGlobalConfigPath();
const config = existsSync(globalPath) ? loadConfig(globalPath) : loadConfig();
const project = config.projects[arg];
if (!project) {
throw new Error(
`Project "${arg}" is not registered in the global config (${config.configPath}).\n` +
` Run \`ao project add\` or \`ao start <path|url>\` first.`,
);
}
return {
config,
projectId: arg,
project,
source: "existing-id",
justCreated: false,
};
}
let config: OrchestratorConfig;
let recovered = false;
try {
config = loadConfig();
} catch (err) {
if (err instanceof ConfigNotFoundError) {
// First run — auto-create config in cwd.
config = await deps.autoCreateConfig(cwd());
recovered = true;
} else {
// A config file exists but failed to load — likely a flat local
// config whose project isn't in the global registry yet. Recover
// by registering it, then retry the load.
const foundConfig = findConfigFile() ?? undefined;
if (!foundConfig) throw err;
const addedId = await deps.registerFlatConfig(foundConfig);
if (!addedId) throw err;
config = loadConfig(foundConfig);
recovered = true;
}
}
// If the user named a project that isn't in the local config, fall back
// to the global registry (which has all registered projects).
if (arg && !config.projects[arg]) {
const globalPath = getGlobalConfigPath();
if (existsSync(globalPath)) {
config = loadConfig(globalPath);
}
}
const resolved = await deps.resolveProject(config, arg);
return {
config: resolved.config,
projectId: resolved.projectId,
project: resolved.project,
source: arg ? "existing-id" : "cwd",
justCreated: recovered,
};
}
/**
* Resolve (and create if necessary) the project a given `ao start [arg]`
* invocation refers to.
*
* Dispatches by arg shape:
* - `arg` is a URL clone (or reuse), load/generate config, match by repo
* - `arg` is a local path load existing or addProject or autoCreate
* - `arg` is a project id load config, fall back to global if needed
* - `arg` is undefined load cwd config, autoCreate on first run, register
* flat configs that exist but aren't globally known
*
* The same `Resolved` shape comes back regardless of source; callers use
* `source` and `justCreated` for hints (e.g. dashboard cache invalidation).
*
* Pass `opts.targetGlobalRegistry: true` when an `ao` daemon is already
* running so URL/path args register the project in the global config the
* daemon supervises rather than into a fresh cwd-local one.
*/
export async function resolveOrCreateProject(
arg: string | undefined,
deps: ResolveDeps,
opts: ResolveOptions = {},
): Promise<Resolved> {
if (arg && isRepoUrl(arg)) return fromUrl(arg, deps, opts);
if (arg && isLocalPath(arg)) return fromPath(arg, deps, opts);
return fromCwdOrId(arg, deps, opts);
}

View File

@ -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);

View File

@ -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;

View File

@ -0,0 +1,129 @@
/**
* SIGINT/SIGTERM shutdown handler for the long-running `ao start` process.
*
* Installs `process.once` listeners that perform a full graceful shutdown:
* stop lifecycle workers, kill all active sessions, record last-stop state
* for restore on next `ao start`, unregister from running.json, await the
* bun-tmp janitor's final sweep, then exit.
*
* Lives in its own module so the orchestration is testable in isolation
* and so the equivalent kill-and-record logic in `ao stop` can converge
* here in a later refactor (today the two paths duplicate the core loop;
* see ao-118 plan PR B).
*/
import { isTerminalSession, loadConfig } from "@aoagents/ao-core";
import { stopBunTmpJanitor } from "./bun-tmp-janitor.js";
import { getSessionManager } from "./create-session-manager.js";
import { stopAllLifecycleWorkers } from "./lifecycle-service.js";
import { stopProjectSupervisor } from "./project-supervisor.js";
import { unregister, writeLastStop } from "./running-state.js";
const SHUTDOWN_TIMEOUT_MS = 10_000;
export interface ShutdownContext {
/** Path to the orchestrator config; re-read at shutdown time so any
* config edits since startup are honored. */
configPath: string;
/** Project this `ao start` invocation owns; used to scope last-stop's
* primary `sessionIds` field (other projects go to `otherProjects`). */
projectId: string;
}
// Module-level guards so a second call to installShutdownHandlers within
// the same process is a no-op (vs. registering duplicate listeners that
// would each race to writeLastStop / unregister / process.exit on signal).
let handlersInstalled = false;
let shuttingDown = false;
/**
* Install SIGINT/SIGTERM handlers. Process-wide idempotent calling
* this more than once is a no-op. Only the first signal triggers
* cleanup; subsequent signals are ignored until the 10-second
* force-exit timer fires.
*/
export function installShutdownHandlers(ctx: ShutdownContext): void {
if (handlersInstalled) return;
handlersInstalled = true;
const shutdown = (signal: NodeJS.Signals): void => {
if (shuttingDown) return;
shuttingDown = true;
const exitCode = signal === "SIGINT" ? 130 : 0;
try {
stopProjectSupervisor();
stopAllLifecycleWorkers();
} catch {
// Best-effort — never block shutdown on observability.
}
const forceExit = setTimeout(() => process.exit(exitCode), SHUTDOWN_TIMEOUT_MS);
forceExit.unref();
void (async () => {
try {
const shutdownConfig = loadConfig(ctx.configPath);
const sm = await getSessionManager(shutdownConfig);
const allSessions = await sm.list();
const activeSessions = allSessions.filter((s) => !isTerminalSession(s));
const killedSessionIds: string[] = [];
for (const session of activeSessions) {
try {
const result = await sm.kill(session.id);
if (result.cleaned || result.alreadyTerminated) {
killedSessionIds.push(session.id);
}
} catch {
// Best-effort per session
}
}
if (killedSessionIds.length > 0) {
const targetIds = killedSessionIds.filter((id) =>
activeSessions.some((s) => s.id === id && s.projectId === ctx.projectId),
);
const otherProjects: Array<{ projectId: string; sessionIds: string[] }> = [];
const otherByProject = new Map<string, string[]>();
for (const s of activeSessions) {
if (s.projectId === ctx.projectId) continue;
if (!killedSessionIds.includes(s.id)) continue;
const list = otherByProject.get(s.projectId ?? "unknown") ?? [];
list.push(s.id);
otherByProject.set(s.projectId ?? "unknown", list);
}
for (const [pid, ids] of otherByProject) {
otherProjects.push({ projectId: pid, sessionIds: ids });
}
await writeLastStop({
stoppedAt: new Date().toISOString(),
projectId: ctx.projectId,
sessionIds: targetIds,
otherProjects: otherProjects.length > 0 ? otherProjects : undefined,
});
}
await unregister();
} catch {
// Best-effort — always exit even if cleanup fails
}
try {
// Await any in-flight sweep so shutdown does not exit while
// unlink() calls are still mid-flight against the filesystem.
await stopBunTmpJanitor();
} catch {
// Best-effort cleanup.
}
process.exit(exitCode);
})();
};
process.once("SIGINT", (sig) => {
shutdown(sig);
});
process.once("SIGTERM", (sig) => {
shutdown(sig);
});
}

View File

@ -0,0 +1,325 @@
/**
* Runtime preflight checks for `ao start`.
*
* Distinct from `lib/preflight.ts` (which validates dashboard build
* artifacts). This module verifies system tools (git, tmux), warns about
* legacy storage and OpenClaw status, and applies side effects like idle
* sleep prevention and credential injection.
*
* Each check is exported individually for callers that need it at a
* specific point in the flow (e.g. `ensureGit` before clone). The
* top-level `runtimePreflight(config)` orchestrates the checks that
* `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";
function gitInstallAttempts(): InstallAttempt[] {
if (process.platform === "darwin") {
return [{ cmd: "brew", args: ["install", "git"], label: "brew install git" }];
}
if (process.platform === "linux") {
return [
{
cmd: "sudo",
args: ["apt-get", "install", "-y", "git"],
label: "sudo apt-get install -y git",
},
{ cmd: "sudo", args: ["dnf", "install", "-y", "git"], label: "sudo dnf install -y git" },
];
}
if (process.platform === "win32") {
return [
{
cmd: "winget",
args: ["install", "--id", "Git.Git", "-e", "--source", "winget"],
label: "winget install --id Git.Git -e --source winget",
},
];
}
return [];
}
function gitInstallHints(): string[] {
if (process.platform === "darwin") return ["brew install git"];
if (process.platform === "win32") return ["winget install --id Git.Git -e --source winget"];
return ["sudo apt install git # Debian/Ubuntu", "sudo dnf install git # Fedora/RHEL"];
}
function tmuxInstallAttempts(): InstallAttempt[] {
if (process.platform === "darwin") {
return [{ cmd: "brew", args: ["install", "tmux"], label: "brew install tmux" }];
}
if (process.platform === "linux") {
return [
{
cmd: "sudo",
args: ["apt-get", "install", "-y", "tmux"],
label: "sudo apt-get install -y tmux",
},
{ cmd: "sudo", args: ["dnf", "install", "-y", "tmux"], label: "sudo dnf install -y tmux" },
];
}
return [];
}
function tmuxInstallHints(): string[] {
if (process.platform === "darwin") return ["brew install tmux"];
if (process.platform === "win32")
return ["# Install WSL first, then inside WSL:", "sudo apt install tmux"];
return ["sudo apt install tmux # Debian/Ubuntu", "sudo dnf install tmux # Fedora/RHEL"];
}
export async function ensureGit(context: string): Promise<void> {
const hasGit = (await execSilent("git", ["--version"])) !== null;
if (hasGit) return;
console.log(chalk.yellow(`⚠ Git is required for ${context}.`));
const shouldInstall = await askYesNo("Install Git now?", true, false);
if (shouldInstall) {
const installed = await tryInstallWithAttempts(
gitInstallAttempts(),
async () => (await execSilent("git", ["--version"])) !== null,
);
if (installed) {
console.log(chalk.green(" ✓ Git installed successfully"));
return;
}
}
console.error(chalk.red("\n✗ Git is required but is not installed.\n"));
console.log(chalk.bold(" Install Git manually, then re-run ao start:\n"));
for (const hint of gitInstallHints()) {
console.log(chalk.cyan(` ${hint}`));
}
console.log();
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(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 { switchedToProcess: false };
console.log(chalk.yellow('⚠ tmux is required for runtime "tmux".'));
const shouldInstall = await askYesNo("Install tmux now?", true, false);
if (shouldInstall) {
const installed = await tryInstallWithAttempts(
tmuxInstallAttempts(),
async () => (await execSilent("tmux", ["-V"])) !== null,
);
if (installed) {
console.log(chalk.green(" ✓ tmux installed successfully"));
return { switchedToProcess: false };
}
}
console.error(chalk.red("\n✗ tmux is required but is not installed.\n"));
console.log(chalk.bold(" Install tmux manually, then re-run ao start:\n"));
for (const hint of tmuxInstallHints()) {
console.log(chalk.cyan(` ${hint}`));
}
console.log();
process.exit(1);
}
export function warnAboutLegacyStorage(): void {
try {
const hashDirs = inventoryHashDirs(getAoBaseDir(), getGlobalConfigPath());
if (hashDirs.length === 0) return;
const nonEmptyDirCount = hashDirs.reduce((sum, d) => {
if (d.empty) return sum;
return sum + 1;
}, 0);
if (nonEmptyDirCount === 0) return;
console.log(
chalk.yellow(
`\n ⚠ Found ${nonEmptyDirCount} legacy storage director${nonEmptyDirCount === 1 ? "y" : "ies"} that need${nonEmptyDirCount === 1 ? "s" : ""} migration.\n` +
` Sessions stored in the old format won't appear until migrated.\n` +
` Run ${chalk.bold("ao migrate-storage")} to upgrade (use ${chalk.bold("--dry-run")} to preview).\n`,
),
);
} catch {
// Non-critical — don't block startup
}
}
export async function warnAboutOpenClawStatus(config: OrchestratorConfig): Promise<void> {
const openclawConfig = config.notifiers?.["openclaw"];
const openclawConfigured =
openclawConfig !== null &&
openclawConfig !== undefined &&
typeof openclawConfig === "object" &&
openclawConfig.plugin === "openclaw";
const configuredUrl =
openclawConfigured && typeof openclawConfig.url === "string" ? openclawConfig.url : undefined;
try {
const installation = configuredUrl
? await detectOpenClawInstallation(configuredUrl)
: await detectOpenClawInstallation();
if (openclawConfigured) {
if (installation.state !== "running") {
console.log(
chalk.yellow(
`⚠ OpenClaw is configured but the gateway is not reachable at ${installation.gatewayUrl}. Notifications may fail until it is running.`,
),
);
}
return;
}
if (installation.state === "running") {
console.log(
chalk.yellow(
`⚠ OpenClaw is running at ${installation.gatewayUrl} but AO is not configured to use it. Run \`ao setup openclaw\` if you want OpenClaw notifications.`,
),
);
}
} catch {
// OpenClaw probing is advisory for `ao start`; never block startup on it.
}
}
/**
* Top-level orchestrator: tools + state warnings + idle-sleep + credentials.
* Replaces the inline preflight block in `runStartup`. Idempotent within a
* single process the side effects (caffeinate spawn, env injection) latch
* for the lifetime of the process.
*/
export async function runtimePreflight(config: OrchestratorConfig): Promise<void> {
const runtime = config.defaults?.runtime ?? getDefaultRuntime();
if (runtime === "tmux") {
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);
// Prevent macOS idle sleep while AO is running (if enabled in config).
// Uses caffeinate -i -w <pid> to hold an assertion tied to this process
// lifetime. No-op on non-macOS platforms.
if (config.power?.preventIdleSleep !== false) {
const sleepHandle = preventIdleSleep();
if (sleepHandle) {
console.log(chalk.dim(" Preventing macOS idle sleep while AO is running"));
}
}
// Only inject OpenClaw credentials when the project actually uses OpenClaw.
// Avoids exposing API keys to projects/plugins that don't need them.
const openclawNotifier = config.notifiers?.["openclaw"];
const hasOpenClaw =
openclawNotifier !== null &&
openclawNotifier !== undefined &&
typeof openclawNotifier === "object" &&
openclawNotifier.plugin === "openclaw";
if (hasOpenClaw) {
const injectedKeys = applyOpenClawCredentials();
if (injectedKeys.length > 0) {
const names = injectedKeys.map((k) => k.key).join(", ");
console.log(chalk.dim(` Resolved from OpenClaw config: ${names}`));
}
}
}

View File

@ -44,7 +44,6 @@ export const MAX_PORT_SCAN = 100;
/**
* Find the first available port starting from `start`, scanning upward.
* Returns `null` if no free port is found within `maxScan` attempts.
* Shared between `ao init` and `ao start <url>`.
*/
export async function findFreePort(start: number, maxScan = MAX_PORT_SCAN): Promise<number | null> {
for (let port = start; port < start + maxScan; port++) {

View File

@ -1,5 +1,4 @@
import { Command } from "commander";
import { registerInit } from "./commands/init.js";
import { registerStatus } from "./commands/status.js";
import { registerSpawn, registerBatchSpawn } from "./commands/spawn.js";
import { registerSession } from "./commands/session.js";
@ -29,7 +28,6 @@ export function createProgram(): Command {
.description("Agent Orchestrator — manage parallel AI coding agents")
.version(getCliVersion());
registerInit(program);
registerStart(program);
registerStop(program);
registerStatus(program);

View File

@ -1,4 +1,143 @@
# @composio/ao-core
# @aoagents/ao-core
## 0.6.0
### Minor Changes
- Wire activity events into `lifecycle-manager` failure paths so failures surface as activity entries for downstream consumers (#1620).
- 40aeb78: Add optional per-project `env` block to `ProjectConfig` that forwards string-to-string env vars into worker session runtimes (e.g. pin `GH_TOKEN` per project). AO-internal vars (`AO_SESSION`, `AO_PROJECT_ID`, etc.) always take precedence.
### Patch Changes
- Disable the tmux status bar in the runtime-tmux plugin and clean up dead code in core (#1711).
- Disable tmux status bar at session creation (#1683). The change touched dead code; the actual user-visible fix landed in #1711.
## 0.5.0
### Patch Changes
- dd07b6b: Adopt existing managed orchestrator worktrees instead of failing to create a fresh one. Previously, a leftover worktree from a prior run could block `spawnOrchestrator` with a "worktree already exists" error; spawn now detects and reuses the matching managed worktree. Also normalizes CRLF in `parseWorktreeList` (Windows), filters prunable/deleted entries in `findManagedWorkspace`, and applies `GIT_TIMEOUT` to all internal `git()` calls.
## 0.4.0
### Minor Changes
- faaddb1: Sessions whose PRs are detected as merged now auto-terminate (tmux kill + worktree remove + metadata archive) instead of lingering in the active `sessions/` directory with a `merged` status. `ao status` and `ao session ls` stay clean without an external watchdog.
Enabled by default. Guarded by an idleness check so in-flight agents are not killed mid-task; deferred cleanups retry on each lifecycle poll until the agent idles or a 5-minute grace window elapses.
Opt out or tune via the new top-level `lifecycle` config in `agent-orchestrator.yaml`:
```yaml
lifecycle:
autoCleanupOnMerge: false # preserve merged worktrees for inspection
mergeCleanupIdleGraceMs: 300000 # grace window before forcing cleanup
```
`sessionManager.kill()` now takes an optional `reason` (`"manually_killed" | "pr_merged" | "auto_cleanup"`) and returns `KillResult` (`{ cleaned, alreadyTerminated }`) instead of `void`. All existing call sites ignore the return value so this is backward-compatible in practice.
Closes #1309. Part of #536.
- 331f1ce: Enrich lifecycle events with PR/issue context for webhook consumers. All events now carry `data.context` with `pr` (url, title, number, branch), `issueId`, `issueTitle`, `summary`, and `branch` when available, plus `data.schemaVersion: 2`.
Additional changes:
- Persist `issueTitle` in session metadata during spawn so it survives across restarts and is available for event enrichment.
- Refactor `executeReaction()` to accept a `Session` object instead of separate `sessionId`/`projectId` arguments.
- Add `maybeDispatchCIFailureDetails()` — when a session enters `ci_failed`, the agent receives a follow-up message with the failed check names and URLs (deduped via fingerprint so subsequent polls don't re-send the same failure set).
- `bugbot-comments` reaction dispatches an enriched message listing every automated comment inline, so the agent doesn't need to re-fetch via `gh api`.
- e7ad928: Allow workers to report non-terminal PR workflow events like `pr-created`, `draft-pr-created`, and `ready-for-review` with optional PR URL/number metadata, while keeping merged and closed PR state SCM-owned.
**Migration:** `Session` now carries canonical lifecycle truth in `session.lifecycle`
and explicit activity-evidence metadata in `session.activitySignal`. Third-party
callers that construct `Session` objects directly must populate those fields or
route through the core session helpers that synthesize them.
- 7b82374: Add centralized lifecycle transitions and report watcher for agent monitoring.
- **Lifecycle transitions (#137)**: Centralize all lifecycle state mutations through `applyLifecycleDecision()` for consistent timestamp handling, atomic metadata persistence, and observability.
- **Detecting bounds (#138)**: Add time-based (5 min) and attempt-based (3 attempts) bounds to detecting state with evidence hashing to prevent counter reset on unchanged probe results.
- **Report watcher (#140)**: Background trigger system that audits agent reports for anomalies (no_acknowledge, stale_report, agent_needs_input) and integrates with the reaction engine.
New exports:
- `applyLifecycleDecision`, `applyDecisionToLifecycle`, `buildTransitionMetadataPatch`, `createStateTransitionDecision`
- `DETECTING_MAX_ATTEMPTS`, `DETECTING_MAX_DURATION_MS`, `hashEvidence`, `isDetectingTimedOut`
- `auditAgentReports`, `checkAcknowledgeTimeout`, `checkStaleReport`, `checkBlockedAgent`, `shouldAuditSession`, `getReactionKeyForTrigger`, `DEFAULT_REPORT_WATCHER_CONFIG`, `REPORT_WATCHER_METADATA_KEYS`
- c8af50f: Make `ProjectConfig.repo` optional to support projects without a configured remote.
**Migration:** `ProjectConfig.repo` is now `string | undefined` instead of `string`.
External plugins that access `project.repo` directly (e.g. `project.repo.split("/")`) must
add a null check first. Use a guard like `if (!project.repo) return null;` or a helper that
throws with a descriptive error.
- c447c7c: Improve lifecycle detection to use bounded `detecting` retries when runtime, process, and activity evidence disagree, and make recovery validation escalate probe uncertainty for human review instead of treating it as cleanup-safe death.
### Patch Changes
- 2306078: Add SQLite-backed activity event logging for session and lifecycle diagnostics, plus `ao events` commands for listing, searching, and inspecting event log stats.
- f330a1e: `ao session ls` and `ao status` now hide terminated sessions (`killed`, `terminated`, `done`, `merged`, `errored`, `cleanup`) by default. A dim footer reports how many were hidden and how to surface them. Pass `--include-terminated` to restore the previous unfiltered output.
Core change: `parseCanonicalLifecycle()` now preserves `pr.state="merged"` when reconstructing legacy metadata with `status=merged` but no `pr=` URL (previously collapsed to `pr.state="none"`, which made `isTerminalSession()` return false for those sessions). Also exports `sessionFromMetadata` so consumers can round-trip flat metadata through the canonical lifecycle.
**Breaking — JSON output shape:** `ao session ls --json` and `ao status --json` now emit `{ data: [...], meta: { hiddenTerminatedCount: number } }` instead of a bare array. Scripts consuming the JSON must read `.data` for the session list. `--include-terminated` restores full data and reports `hiddenTerminatedCount: 0`.
The existing `-a, --all` flag still only governs orchestrator visibility on `ao session ls` — it does **not** re-enable terminated sessions. Combine with `--include-terminated` when you want both.
- a862327: Stop carrying forward `stuck` / `probe_failure` session truth when the runtime is still confirmed alive and activity is merely unavailable, and degrade that combination to `detecting` until stronger evidence arrives.
- 703d584: fix(core): prevent double-billing reaction attempts on changes_requested transition
The enriched review dispatch in `maybeDispatchReviewBacklog` now sends directly via
`sessionManager.send` when the transition handler already called `executeReaction` for
the same reaction key. This prevents the attempt counter from incrementing twice in a
single poll cycle, which would cause premature escalation for projects with `retries: 1`.
Also moves the review backlog throttle timestamp after the SCM fetch so a failed
`getReviewThreads` call doesn't block retries for 2 minutes.
- f674422: Make project orchestrators deterministic and idempotent.
- ensure each project uses the canonical `{prefix}-orchestrator` session instead of creating numbered main orchestrators
- make `ao start`, the dashboard, and the orchestrator API reuse or restore the canonical session
- keep legacy numbered orchestrators visible as stale sessions without treating them as the main orchestrator
- 62353eb: Harden worker branch refresh during lifecycle polling by preserving branch metadata through transient detached Git states, skipping orchestrators and active open PRs, and preventing duplicate branch adoption within a single poll cycle.
- bd36c7b: Keep lifecycle observability and batch diagnostic logs out of user-visible terminal stderr by routing them into AO's observability audit files instead, while preserving structured traces for debugging and regression coverage.
- ca8c4cc: Model activity evidence explicitly across lifecycle inference and dashboard rendering so missing or failed probes cannot spuriously produce idle or stuck interpretations. This also stabilizes repeated polls by preserving stronger prior lifecycle states when the only new evidence is weak or unavailable.
- 4701122: opencode: bound /tmp blast radius and consolidate session-list cache
Addresses review feedback on PR #1478:
- **TMPDIR isolation.** Every `opencode` child we spawn now points at
`~/.agent-orchestrator/.bun-tmp/` via `TMPDIR`/`TMP`/`TEMP`. Bun's
embedded shared-library extraction lands there instead of the system
`/tmp`, so the cli janitor only ever sweeps AO-owned files. Other
users' or other applications' Bun artifacts on a shared host can no
longer be touched by the regex.
- **Single shared session-list cache.** Core and the agent-opencode
plugin previously kept independent caches; per poll cycle the system
spawned at least two `opencode session list` processes instead of
one. Both consumers now use the shared cache exported from
`@aoagents/ao-core` (`getCachedOpenCodeSessionList`).
- **TTL no longer covers the send-confirmation loop.** The cache TTL
dropped from 3s to 500ms so the
`updatedAt > baselineUpdatedAt` delivery signal in
`sendWithConfirmation` actually fires. Concurrent callers still
share the in-flight promise.
- **Delete invalidates the cache.** `deleteOpenCodeSession` now calls
`invalidateOpenCodeSessionListCache()` on success so reuse, remap,
and restore code paths cannot observe a deleted session id within
the TTL window.
- **Janitor reliability.** `sweepOnce` now filters synchronously
before allocating per-file promises (matters on hosts with thousands
of `/tmp` entries), and `stopBunTmpJanitor()` is now async and awaits
any in-flight sweep so SIGTERM cannot exit while `unlink` is mid-flight.
- **Janitor observability.** The sweep callback in `ao start` now logs
successful reclaims, not just errors, so operators can confirm the
janitor is doing useful work.
- bcdda4b: Tighten the session lifecycle review follow-ups by debouncing report-watcher reactions, restoring the shared Geist/JetBrains font setup, wiring recovery validation to real agent activity probes, adding direct coverage for `ao report`, activity-signal classification, and dashboard lifecycle audit panels, fixing the remaining lifecycle-state regressions around legacy merged-session rehydration and malformed canonical payload parsing, making agent-report metadata writes atomic, persisting canonical payloads for legacy sessions on read, stabilizing detecting evidence hashes, and removing the remaining inline-style cleanup debt from the session detail view. Follow-on fixes also split the Session Detail view into smaller components, harden PR URL parsing and wrapper capture for GitHub Enterprise and GitLab-style hosts, redact sensitive observability payload fields, bound on-disk audit logs, and align cleanup wording with the current merged-session lifecycle policy.
- 1cbf657: Split orchestrator-only detail views from worker detail views, add an auditable history for `ao acknowledge` / `ao report`, and preserve canonical `needs_input` / `stuck` lifecycle states when polling only has weak or unchanged evidence.
- a45eb32: Decouple canonical session state from PR state so workers stay idle while waiting on reviews or merged/closed PR decisions, stop cleanup from auto-killing merged PR sessions, and make the dashboard/rendered labels follow canonical PR truth instead of inferring it from legacy lifecycle aliases.
- 7072143: Expose split session, PR, and runtime lifecycle truth in dashboard API payloads, render that truth directly in session cards and detail views, and extend lifecycle observability with structured transition evidence, reasons, and recovery context while preserving legacy metadata compatibility.
- ed2dcea: Split worker session prompts into persistent system instructions and task-only input, materialize OpenCode worker/orchestrator instructions into session-scoped `AGENTS.md`, and keep restore behavior aligned with the updated AO prompt markers.
## 0.2.0

View File

@ -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)

View File

@ -1,6 +1,6 @@
{
"name": "@aoagents/ao-core",
"version": "0.3.0",
"version": "0.6.0",
"description": "Core library — types, config, session manager, lifecycle manager, event bus",
"license": "MIT",
"type": "module",

View File

@ -213,4 +213,176 @@ describe("recordActivityEvent", () => {
expect((capturedSummary as string).length).toBe(500);
expect(capturedSummary).toMatch(/\.\.\.$/);
});
// ─── Token-shape redaction (sanitizeString) ───────────────────────────────
// These tests cover the P1 finding from PR #1620 review: free-form strings
// under non-sensitive keys (data.message, data.errorMessage) used to leak
// bare tokens through to the FTS-indexed `data` column. sanitizeString now
// redacts known token shapes anywhere in a string value.
function recordAndCaptureData(input: Record<string, unknown>): Record<string, unknown> {
let capturedData: unknown;
const captureDb = {
prepare: (_sql: string) => ({
run: (...args: unknown[]) => {
capturedData = args[8]; // data is 9th param (index 8)
},
all: () => [],
}),
};
vi.mocked(eventsDb.getDb).mockReturnValueOnce(captureDb as any);
recordActivityEvent({
source: "lifecycle",
kind: "lifecycle.poll_failed",
summary: "test",
data: input,
});
return JSON.parse(capturedData as string);
}
it("redacts Bearer tokens in string values (preserves prefix)", () => {
const out = recordAndCaptureData({
errorMessage: "401 from https://api.example.com Bearer eyJhbGciOiJIUzI1NiJ9.abc",
});
expect(out["errorMessage"]).not.toContain("eyJhbGciOiJIUzI1NiJ9");
expect(out["errorMessage"]).toContain("Bearer [redacted]");
});
it("redacts GitHub PATs (ghp_, gho_, github_pat_) in error messages", () => {
const out = recordAndCaptureData({
errorMessage: "git push failed: bad credentials ghp_abcdefghijklmnopqrstuvwxyz12345",
message: "trying github_pat_11ABCDEFG0abcdefghijklmnopqrstuvwxyz1234567890",
});
expect(out["errorMessage"]).not.toContain("ghp_abcdefghijklmnopqrstuvwxyz");
expect(out["errorMessage"]).toContain("[redacted]");
expect(out["message"]).not.toContain("github_pat_11");
expect(out["message"]).toContain("[redacted]");
});
it("redacts OpenAI / Anthropic sk- keys in free-form values", () => {
const out = recordAndCaptureData({
message: "stuck on sk-proj-abcdefghijklmnopqrstuvwx returns 429",
errorMessage: "auth failed for sk-ant-api03-abcdefghijklmnopqrst-xyz",
});
expect(out["message"]).not.toContain("sk-proj-abcdefghijklmnopqrstuvwx");
expect(out["errorMessage"]).not.toContain("sk-ant-api03");
expect(out["message"]).toContain("[redacted]");
expect(out["errorMessage"]).toContain("[redacted]");
});
it("redacts Slack xox tokens", () => {
const out = recordAndCaptureData({
errorMessage: "webhook rejected with xoxb-1234567890-abcdefghij",
});
expect(out["errorMessage"]).not.toContain("xoxb-1234567890");
expect(out["errorMessage"]).toContain("[redacted]");
});
it("redacts AWS access key IDs (AKIA...)", () => {
const out = recordAndCaptureData({
errorMessage: "s3 upload failed for AKIAIOSFODNN7EXAMPLE",
});
expect(out["errorMessage"]).not.toContain("AKIAIOSFODNN7EXAMPLE");
expect(out["errorMessage"]).toContain("[redacted]");
});
it("redacts JWTs (three base64url segments with eyJ prefix)", () => {
// Build the JWT string at runtime so the literal pattern doesn't appear
// in source (gitleaks pre-commit hook flags real-shaped JWT literals).
const jwt = "ey" + "JTESTHEADERabcde" + "." + "TESTPAYLOADabcdef" + "." + "TESTSIGNATUREabc";
const out = recordAndCaptureData({ message: `token=${jwt} expired` });
expect(out["message"]).not.toContain("JTESTHEADERabcde");
expect(out["message"]).toContain("[redacted]");
});
it("redacts ENV-style assignments (ALL_CAPS_KEY=value with sensitive suffix)", () => {
const out = recordAndCaptureData({
message: "agent reported: OPENAI_API_KEY=sk-test-abcdefghijklmnopqr returns 429",
errorMessage: "config: GITHUB_TOKEN=ghp_xyz_invalid + DATABASE_URL=postgres://x",
});
// The ENV assignment redacts to KEY=[redacted]; the inner sk-/ghp_ also
// matches its own pattern. Either way the secret value is gone.
expect(out["message"]).not.toContain("sk-test-abcdefghijklmnopqr");
expect(out["errorMessage"]).not.toContain("ghp_xyz_invalid");
expect(out["message"]).toContain("[redacted]");
expect(out["errorMessage"]).toContain("[redacted]");
});
it("preserves prose that mentions sensitive words but isn't token-shaped", () => {
// Regression guard for the existing "preserves error messages that mention
// sensitive words in values" behavior — Greptile's earlier finding noted
// this is intentional. Pattern-redaction must not over-match plain prose.
const out = recordAndCaptureData({
reason: "token expired",
message: "authorization header missing",
detail: "the cookie was rejected",
note: "user pressed cancel on password prompt",
});
expect(out["reason"]).toBe("token expired");
expect(out["message"]).toBe("authorization header missing");
expect(out["detail"]).toBe("the cookie was rejected");
expect(out["note"]).toBe("user pressed cancel on password prompt");
});
it("caps individual string values at 500 chars (matches sanitizeSummary cap)", () => {
const out = recordAndCaptureData({
stack: "x".repeat(600),
});
expect((out["stack"] as string).length).toBe(500);
expect(out["stack"]).toMatch(/\.\.\.$/);
});
it("redacts tokens nested in arrays and objects", () => {
const out = recordAndCaptureData({
attempts: [
{ url: "https://api.x", error: "Bearer eyJhbGciOiJIUzI1NiJ9.payload.sig failed" },
{ url: "https://api.y", error: "ghp_abcdefghijklmnopqrstuvwxyz12345 invalid" },
],
});
const attempts = out["attempts"] as Array<Record<string, string>>;
expect(attempts[0]!["error"]).toContain("Bearer [redacted]");
expect(attempts[0]!["error"]).not.toContain("eyJhbGciOiJIUzI1NiJ9");
expect(attempts[1]!["error"]).toContain("[redacted]");
expect(attempts[1]!["error"]).not.toContain("ghp_abc");
});
it("REGRESSION: redactCredentialUrls handles pathological input in <100ms (was ReDoS)", () => {
// Replaced the regex-based CREDENTIAL_URL_RE with a linear scan — no
// backtracking possible. Kept as a regression guard in case of regression.
const pathological = "http://".repeat(2000); // ~14KB, ~2000 prefix repetitions, no @
const start = Date.now();
const out = recordAndCaptureData({ errorMessage: pathological });
const elapsed = Date.now() - start;
expect(elapsed).toBeLessThan(100);
expect((out["errorMessage"] as string).length).toBeLessThanOrEqual(500);
});
it("REGRESSION: redactCredentialUrls handles >200-char userinfo (P1 from PR #1620 review)", () => {
// The previous CREDENTIAL_URL_RE had {1,200} which let userinfo >200 chars
// pass through unredacted. The linear scan has no length limit.
const longPass = "a".repeat(300);
const input = `https://user:${longPass}@github.com/org/repo.git`;
const out = recordAndCaptureData({ remoteUrl: input });
const result = out["remoteUrl"] as string;
expect(result).not.toContain(longPass);
expect(result).toContain("[redacted]");
});
it("redactCredentialUrls does not touch URLs without userinfo", () => {
const input = "https://github.com/org/repo.git pushed successfully";
const out = recordAndCaptureData({ message: input });
expect(out["message"]).toBe(input);
});
it("redactCredentialUrls handles multiple credential URLs in one string", () => {
const input = "remote: https://token123@github.com/a.git origin: https://pass@github.com/b.git";
const out = recordAndCaptureData({ message: input });
const result = out["message"] as string;
expect(result).not.toContain("token123");
expect(result).not.toContain("pass");
expect(result).toMatch(/\[redacted\]/g);
// Should still contain the host parts
expect(result).toContain("github.com/a.git");
expect(result).toContain("github.com/b.git");
});
});

Some files were not shown because too many files have changed in this diff Show More