Before this, vitest worker isolation only overrode HOME, but on Windows
os.homedir() reads USERPROFILE. Tests that wrote to ~/.agent-orchestrator
ended up sharing the real user's data directory across workers, leading
to flaky cross-test pollution.
- test-utils: createTestEnvironment / setupTestContext now override both
HOME and USERPROFILE to the per-test fake home, restore both on
teardown. rmSync uses maxRetries:5/retryDelay:50 to ride out the same
Windows file-lock window that atomic-write retries cover.
- core test files (global-config, plugin-integration, portfolio-*,
project-resolver, recovery-actions, orchestrator-prompt*): set fake
USERPROFILE alongside HOME and use the retry-aware rmSync.
- update-check.test: normalize path separators in assertions
(path.replace(/\/g, "/")) so script-path matching works on Windows
without forcing the production code to emit posix paths.
- orchestrator-prompt.dist.test: pass shell:true on Windows to execFileSync
for .cmd targets, working around Node CVE-2024-27980's hardening.
Removed lifecycle-service.test.ts — it covered stopLifecycleWorker, which
was deleted when lifecycle was moved in-process during the merge with
main. The remaining lifecycle paths are exercised by lifecycle-manager
tests in core.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(cli): derive projectId from prefixed issue id on spawn
When `ao spawn <projectId>/<issue>` is used in a multi-project config,
route the spawn to the prefixed project and strip the prefix from the
issue id. Previously the projectId fell back to whichever project
`ao start` was running for, tagging cross-project sessions with the
wrong project (and session prefix).
Applies to both `ao spawn` and `ao batch-spawn`; batch-spawn errors
out if issues mix multiple project prefixes.
Fixes#1329
* refactor(core,cli): lift spawn target resolution into core
Move the issue-prefix → project routing from the CLI into a reusable
core utility, `resolveSpawnTarget(projects, issueRef, fallback?)`.
- Exposes routing to any spawn entry point (CLI, web API, programmatic).
- Accepts either a project id or sessionPrefix as the prefix; project id
wins on collision.
- `ao batch-spawn` now groups issues by resolved project and preflights
once per group instead of erroring on mixed prefixes.
Tests:
- 8 unit tests for `resolveSpawnTarget` in core.
- CLI spawn.test.ts: adds a sessionPrefix routing test (23 tests total).
Refs #1329
* test(cli): cover batch-spawn grouping; tighten resolveSpawnTarget API
Self-review found three gaps:
- `resolveSpawnTarget` accepted `undefined` issueRef and returned
`{ issueId: "" }` with a fallback project. Dead path — both callers
guard against undefined. Drop it and make issueRef required.
- No tests for `batch-spawn`. Added two:
- routes cross-project prefixed issues to the correct project and
lists each project's sessions separately
- skips a prefixed issue when the target project already has an
active session for it
- `spawn` and `batch-spawn` help text didn't document the
`<projectId>/<issue>` or `<sessionPrefix>/<issue>` forms.
* fix(core): guard resolveSpawnTarget against prototype-key matches
Second review pass found a latent bug:
Plain JS objects inherit `__proto__`, `constructor`, `toString`,
`hasOwnProperty`, etc. from Object.prototype. The previous
`if (projects[prefix])` check entered the routing branch for any of
these keys, mis-routing a user typing `ao spawn __proto__/42` with
`projectId: "__proto__"`. Downstream session-manager would then receive
a junk project object (Object.prototype itself) and fail with a
non-obvious error.
Fix: use `Object.prototype.hasOwnProperty.call(projects, prefix)` so
only actual configured project ids match.
Also:
- Document the case-sensitive matching semantic explicitly.
- Lock in the batch-spawn grouping contract by asserting exact
`list()` and `spawn()` call counts in the cross-project test.
* feat(cli): hide terminal session rows by default in session ls
- Filter with TERMINAL_STATUSES unless --include-terminated
- Hint when completed sessions are hidden; docs/CLI.md updated
Made-with: Cursor
* fix(cli): keep session ls JSON inventory stable
Address review concerns by preserving terminal sessions in `ao session ls --json` output while keeping terminal rows hidden by default in text output. Use the core terminal-session predicate, improve hidden-row messaging, and add regression tests for mixed/hinted and JSON behavior.
Made-with: Cursor
* fix(cli): resolve rebase conflicts with latest session ls behavior
Align session command and CLI docs with the current mainline JSON contract (`data` + `meta.hiddenTerminatedCount`) while preserving terminal-filter defaults and updated regression coverage.
Made-with: Cursor
* fix: fail send when killed session delivery is not confirmed
* fix(core): drop requireConfirmation throw that re-introduced duplicate-message bug
The PR's sendWithConfirmation added a throw when confirmation heuristics
did not flip within SEND_CONFIRMATION_ATTEMPTS on a restored session.
But runtimePlugin.sendMessage had already fired, so the throw bubbled up
to the lifecycle manager's catch-all, leaving lastCIFailureDispatchHash
(and its merge-conflict twin) unset. Next poll re-dispatched the same
message — exactly the duplicate-message bug that commit 77685a5 removed.
Real fix for #1074 is preserved: restoreForDelivery still throws when
waitForRestoredSession returns false, which happens before sendMessage
fires. Killed sessions that cannot be revived are still reported as
failures, with no duplicate-send risk.
- sendWithConfirmation no longer takes requireConfirmation; unconfirmed
delivery always returns (soft success).
- prepareSession returns Session (the tuple only existed to drive the
removed throw).
- send's retry predicate reverts to prepared.restoredAt === undefined
&& isRestorable(prepared).
- Adds regression test: restored session + sendMessage fires +
confirmation never flips → send() resolves.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* feat(cli): filter terminated sessions from ao session ls / ao status by default
Closes#1310. Terminated sessions (killed/terminated/done/merged/errored/cleanup,
plus lifecycle-driven terminal states) are now hidden from `ao session ls` and
`ao status` by default. A dim footer reports how many were hidden and how to
surface them. Pass `--include-terminated` to restore the full list.
JSON output wraps into `{ data: [...], meta: { hiddenTerminatedCount } }` on
both commands so text and machine-readable views tell the same story. This is a
breaking change for script consumers of `--json`; `--include-terminated` is the
escape hatch.
Orthogonal to `-a, --all` (orchestrator visibility, unchanged). Restore of
terminated sessions by id is unaffected — that path goes through `sm.get`, not
`sm.list`.
Docs (`SETUP.md`, `docs/CLI.md`) updated to match. Tests cover both the legacy
status branch and the canonical lifecycle branch of `isTerminalSession`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(cli): drop unused `lc` param in lifecycle-alive test case
ESLint's no-unused-vars rejects unprefixed unused args. The "alive — should
remain visible" branch of the new lifecycle-driven filter test in
`session.test.ts` took `lc` but never touched it. Switch to `()`. Matches the
equivalent case in `status.test.ts`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(core): preserve pr.state=merged when legacy metadata lacks pr= URL
Review blocker on PR #1340: a metadata file with `status=merged` but no `pr=`
URL was still showing as active in `ao session ls` / `ao status` by default.
Root cause: `synthesizePRState()` in lifecycle-state.ts short-circuited to
`{ state: "none" }` whenever no PR URL was present, ignoring the fact that the
legacy `status` column already encodes terminal truth. Once lifecycle was
synthesized as `session.state="idle"` + `pr.state="none"`, `deriveLegacyStatus`
returned `"idle"` and `isTerminalSession()` (lifecycle branch) returned false.
The new CLI filter then let the session through.
Fix: when legacy `status === "merged"` and no URL is available, synthesize
`pr.state="merged", reason="merged"` with `number: null, url: null`. The
terminal signal survives the flat-metadata → canonical-lifecycle round trip.
Also:
- Export `sessionFromMetadata` from the core barrel. CLI tests and external
consumers need it to round-trip metadata through the canonical lifecycle.
- Update CLI `buildSessionsFromDir` helpers to route through `sessionFromMetadata`
so mocked `sm.list()` reflects production reconstruction (the old shortcut
bypassed synthesis entirely and was the reason the bug slipped past the
original test suite).
- Add regression tests: one at the core level (`parseCanonicalLifecycle` for
merged-without-URL) and one integration-style test per CLI command asserting
the reviewer's exact repro produces the expected filtered output.
- One pre-existing test expectation updated: when metadata has `status=working`
and `pr=<url>`, the reconstructed status is `pr_open`, not `working`. That's
what production `sm.list()` has always returned; the test was previously
hiding behind the reconstruction shortcut.
Changeset bumped to include ao-core (patch).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(cli): route review-check helper through sessionFromMetadata
Last remaining test-fidelity shortcut flagged by codex on PR #1340. The
`buildSessionsFromDir` helper in review-check.test.ts fabricated Session
objects by hand, bypassing the canonical lifecycle reconstruction that
production `sm.list()` runs. Doesn't affect review-check's actual behavior
(which reads `session.metadata["pr"]` directly), but aligns this test with
the equivalent helpers in session.test.ts and status.test.ts so future
lifecycle changes don't silently skip this surface.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fixes#1048. ao start used to allocate a fresh `{prefix}-orchestrator-N`
on every invocation instead of reattaching to the previous session, and
the dashboard's orchestrator link pointed at a different id than the
CLI just printed.
Changes:
runStartup (packages/cli/src/commands/start.ts):
- On startup, list existing orchestrators for the project, partition
them into live (runtime still running) and restorable (terminal but
sm.restore()-able) buckets, pick the most-recently-active from the
chosen bucket, and reuse/restore that id instead of spawning a new
one. Only spawn fresh when both buckets are empty.
- Live is preferred UNCONDITIONALLY over restorable — a newer killed
record can never beat an older-but-running one. Without this, a
cross-bucket sort could resurrect a killed record via sm.restore()
while the live orchestrator kept running, leaving two alive.
- Restored sessions get an explicit "(restored)" marker in the CLI
summary so the resurfaced id isn't a surprise.
- The phantom `${prefix}-orchestrator` id constant is removed from
every URL print, browser-open target, and summary line. Everything
now uses the real selected id.
registerStop (same file):
- ao stop now resolves the real orchestrator via sm.list(projectId)
+ isOrchestratorSession filter + most-recently-active sort, then
calls sm.kill on that id. The old phantom `${prefix}-orchestrator`
target never matched a real numbered record, so ao stop was a
silent no-op and the orchestrator kept running on disk between
start cycles. sm.list-failure warning no longer duplicates with the
generic "no orchestrator found" message.
isOrchestratorSession (packages/core/src/types.ts):
- Tightened: legacy bare-id records (`{projectId}-orchestrator` with
no role metadata) are no longer recognized as orchestrators by the
public predicate. This was the source of the dashboard/CLI id
divergence — stale bare records with a different prefix than the
numbered form were leaking into the dashboard's orchestrator list.
session-manager repair (packages/core/src/session-manager.ts):
- Split `isOrchestratorSessionRecord` (permissive, used by cleanup
protection) from a new `isRepairableOrchestratorRecord` (stricter,
used only by repairSingleSessionMetadataOnRead and
repairSessionMetadataOnRead).
- The strict repair predicate accepts role-stamped records, the bare
`{sessionPrefix}-orchestrator` correct-prefix legacy shape, and the
numbered `{sessionPrefix}-orchestrator-N` worktree shape. It
rejects foreign bare names like `{projectId}-orchestrator`, so
those records never get `role: orchestrator` backfilled on read
and therefore can no longer pass `isOrchestratorSession()` in real
`sm.list()` output via the role-metadata branch.
Tests added (~12):
- runStartup: live reuse, restore-on-killed, ignore-stale-bare
legacy records, live-beats-restorable regression, multi-live
reuse, URL fallback when --no-orchestrator.
- ao stop: kills the actual numbered id (not the phantom), handles
multiple orchestrators, tolerates sm.list throwing.
- isOrchestratorSession: rejects stale bare ids without role
metadata; accepts bare ids with role metadata stamped.
- listDashboardOrchestrators: stale bare excluded, numbered live
included, role-stamped legacy included.
- session-manager repair: does not backfill role onto foreign bare-id
records (issue #1048 regression guard).
Unblocks: review comments from cursor[bot] (dead else-if branch,
double messaging, redundant isTerminalSession check) and illegalcall
(cross-bucket sort, repair-backfill bypass of predicate tightening) —
all addressed in-place with the multi-orchestrator model preserved.
Verified: core 606/606, cli 450/450, typecheck clean across core/cli/web.
- New repo-utils.ts with extractOwnerRepo() and isValidRepoString()
shared across detectEnvironment, autoCreateConfig, addProjectToConfig
- Remote regex now supports GitLab subgroup paths (group/subgroup/repo)
- Repo validation accepts multi-segment paths (owner/repo and deeper)
- Updated prompt text to mention group/subgroup/repo format
- Updated ProjectConfig.repo docstring to be provider-neutral
- Tests import from shared helpers instead of duplicating regex
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- detect-env: test GitHub/GitLab HTTPS/SSH remote extraction, unknown
hosts returning null, missing remote, and non-git directories
- repo-validation: test the anchored regex accepts owner/repo, rejects
empty, lone slash, missing segments, whitespace, and nested paths
- config-validation: test SCM/tracker inference skipped when repo is
missing or has no slash, and inferred correctly with owner/repo
- scm-webhooks: test eventMatchesProject returns false when project
has no repo configured
- orchestrator-prompt: existing test covers repo:undefined → "not configured"
- prompt-builder: existing test covers BASE_AGENT_PROMPT_NO_REPO selection
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
afterEach calls vi.restoreAllMocks() which restores the top-level mock
to the real function. Use vi.spyOn on the module namespace instead of
vi.mocked on the destructured import so the mock survives restoration.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The test has no ownerRepo detected, which triggers the interactive
repo prompt. Mock isHumanCaller to false so the prompt is skipped
in the non-interactive test environment.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Move ensureGit() after path dedup check so already-registered paths
return early without requiring git
- Use realpathSync for canonical path comparison (resolves symlinks,
case variants, trailing slashes)
- Bail out with error when both SIGTERM and SIGKILL fail to stop AO
instead of unconditionally unregistering a live instance
- Rewrite path-dedup test to exercise the path-arg branch via
AO_CONFIG_PATH, covering addProjectToConfig's dedup lines
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The test was mocking detectEnvironment with isGitRepo: false, which now
correctly triggers the fail-fast error for non-git directories. Updated
to isGitRepo: true since the test is verifying config generation
defaults, not non-git behavior.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1. Replace SIGINT/SIGTERM handlers with a process `exit` handler to avoid
conflicting with the shutdown handler that flushes lifecycle state and
exits with the correct code (130 for SIGINT).
2. Expand dashboard process pattern to match dev mode (next dev, ao-web)
in addition to production (next-server, start-all.js).
3. Only kill dashboard-matching PIDs from lsof output, leaving unrelated
co-listeners (sidecars, SO_REUSEPORT) untouched.
4. Use killDashboardOnPort for the first port attempt too, preventing
blind kills on the configured port when running.json is stale.
5. Add test for mixed-PID filtering on a single port.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1. Port scan now uses killDashboardOnPort() which checks `ps -p <pid> -o args=`
for next-server/start-all.js before killing, avoiding collateral damage to
unrelated services on nearby ports.
2. SIGINT/SIGTERM handlers now call process.exit() after killing the child,
restoring default exit behavior so Ctrl+C doesn't leave the parent hung
if the child is unresponsive.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds test for stopDashboard finding an orphaned dashboard on a reassigned
port via the port-range scan fallback. Marks signal handler body with
c8 ignore since it only fires on process termination.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Two bugs caused `ao stop` to fail when the dashboard port was auto-reassigned:
1. Ctrl+C did not propagate to the dashboard child process because Node.js
doesn't guarantee signal forwarding. Added SIGINT/SIGTERM/exit handlers
in runStartup() to explicitly kill the dashboard child.
2. stopDashboard() only checked the configured port, missing orphaned
dashboards on reassigned ports. Refactored into killOnPort() helper
and added a port-range scan fallback (up to MAX_PORT_SCAN ports).
Closes#645
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The "errors when multiple projects and no arg" test expects the error
path (not the prompt path) in resolveProject. Set mockIsHumanCaller
to false so the test reliably hits the non-interactive error branch.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Cover the moved isAlreadyRunning() check (non-TTY exit, quit, open,
restart, new orchestrator) and the path-based dedup guard in
addProjectToConfig(). Uses hoisted mocks for isAlreadyRunning,
isHumanCaller, promptSelect, unregister, and waitForExit to ensure
proper test isolation.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* refactor(cli): remove lifecycle-worker subprocess model, run polling in-process
Replaces the per-project `ao lifecycle-worker` subprocess with an in-process
polling loop managed inside `lifecycle-service`. All registered projects are
now polled from the single long-lived `ao start` process.
- Drops the `lifecycle-worker` command and its registration.
- Rewrites `lifecycle-service` around a `Map<projectId, ActiveLoop>`,
with SIGINT/SIGTERM/beforeExit graceful shutdown.
- Removes PID-file coordination (PID file, log file, status, etc.) —
these only existed to track subprocess state.
- Per-project error isolation is preserved: the core lifecycle manager's
`pollAll()` already catches per-cycle errors, and stop failures in one
project can't prevent others from stopping.
- Updates `start`/`spawn` callers and tests to drop the PID/logFile shape.
- Adds `lifecycle-service.test.ts` covering idempotency, unknown projects,
error isolation across projects, and graceful stop-all.
Closes#1185
* fix(cli): address bugbot review on lifecycle-service in-process refactor
- `ao spawn` / `ao batch-spawn` no longer call `ensureLifecycleWorker`.
That call used to start `setInterval` polling in the one-shot spawn
process, which (a) kept the CLI alive forever after the session spawned
and (b) duplicated polling already running in `ao start`. Replaced with
a `warnIfAONotRunning()` helper that checks `running.json` and prints a
hint if the orchestrator isn't up.
- `ao stop` no longer calls `stopLifecycleWorker`. That call always ran
against a fresh in-memory map (stop is a separate process from start),
so it always returned false and printed "Lifecycle worker not running"
misleadingly. SIGTERM to the `ao start` PID already triggers the shared
shutdown handler in `lifecycle-service`, which stops every loop.
- Drop duplicated shutdown closure inside `registerSignalsOnce` — signal
handlers now reference `stopAllLifecycleWorkers` directly.
- Update tests accordingly: spawn.test.ts mocks `running-state.getRunning`,
start.test.ts drops `stopLifecycleWorker` expectations.
* fix(cli): drop SIGINT/SIGTERM listeners from lifecycle-service
Installing listeners for those signals removes Node.js's default
"exit on signal" behavior (per Node docs: "its default behavior will
be removed — Node.js will no longer exit"). Since the registered
listener doesn't call process.exit(), the `ao start` process would
hang on SIGTERM with the setInterval timer keeping the event loop
alive forever — effectively breaking `ao stop`.
Default signal handling terminates the process cleanly; the OS
reclaims the interval timer and dashboard child. `stopAllLifecycleWorkers`
stays exported for callers that want explicit cleanup before exit.
* fix(cli): project-scoped spawn warning + flush lifecycle health on exit
Addresses reviewer feedback on PR #1186:
- `warnIfAONotRunning` now takes a projectId and warns not only when no
AO is running, but also when the running instance isn't polling the
target project (e.g. `ao start A` then `ao spawn` in B left users
silent about the fact that B wasn't being polled).
- `running.json` now records only the project this `ao start` actually
polls, not every project in config. Previously this list was a lie —
`ensureLifecycleWorker` is called for the selected project only.
- `ao start` installs SIGINT/SIGTERM handlers that call
`stopAllLifecycleWorkers()` (flushing per-project "stopped" health
state) and then `process.exit()`. Installing the handler safely
requires an explicit exit because SIGINT/SIGTERM listeners remove
Node's default exit behavior.
* fix(cli): remove dead stopLifecycleWorker, add missing test mock
Addresses bugbot comments on PR #1186:
- Delete `stopLifecycleWorker` from `lifecycle-service.ts`. It was only
called from `ao stop` in the old subprocess model; in the in-process
model, SIGTERM to the `ao start` pid + the shutdown handler in
`start.ts` covers cleanup. No production caller remains.
- Add `stopAllLifecycleWorkers: vi.fn()` to `start.test.ts`'s
`lifecycle-service.js` mock. Without it, `vi.mock` replaced the module
and the named import resolved to undefined; the shutdown handler's
try/catch would silently swallow the resulting TypeError, hiding any
regression in how shutdown is wired.
- Update `lifecycle-service.test.ts` to drop references to the removed
export (one test removed, one repurposed as a no-op smoke test for
`stopAllLifecycleWorkers` against an empty active map).
Resolved conflict in orchestrator-prompt.ts — kept "runtime session"
wording (Windows-aware) and took main's additions for prompt-driven
session naming and freeform --prompt spawning docs.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(cli): install-aware update command, startup notifier, and doctor version check
Make `ao update` detect whether AO was installed via npm or git source and
route accordingly: git installs use the existing shell script, npm installs
prompt to run `npm install -g @aoagents/ao@latest`, and unknown installs
print guidance. Add `--check` flag for JSON version info output.
Add a startup version notifier that reads a cached update check (no network
on startup) and prints a one-liner to stderr when outdated. Background
cache refresh runs after command completion via unref'd timer.
Add a version freshness check to `ao doctor` using cached data only (no
network dependency in diagnostics).
Closes#1136
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* test(cli): high coverage for update service, command routing, and notifier
Add comprehensive tests per maintainer request for full coverage of all
update paths:
update-check.ts (57 tests):
- isVersionOutdated: major/minor/patch comparisons, equal, newer, missing parts
- detectInstallMethod: all 4 scenarios (git, npm-global, partial, unknown)
- readCachedUpdateInfo: fresh, expired, 23h boundary, version mismatch,
invalid JSON, missing fields, empty file
- fetchLatestVersion: success, 404, 500, network error, timeout, non-JSON,
missing version, non-string version, AbortSignal presence
- checkForUpdate: cache hit, cache bypass with force, stale cache fetch,
cache write on success, no cache write on failure, version match,
unreachable registry, result shape
- maybeShowUpdateNotice: positive print case, not outdated, no cache,
non-TTY, AO_NO_UPDATE_NOTIFIER, CI, AGENT_ORCHESTRATOR_CI,
each skipArg (update/doctor/--version/-V/--help/-h)
- scheduleBackgroundRefresh: does not throw
update.ts (17 tests):
- conflicting flags rejection
- --check: valid JSON output, forces fresh fetch, no side effects
- git: default args, --skip-smoke, --smoke-only, cache invalidation
- npm-global: no script-runner, already up-to-date, registry unreachable
exits non-zero, --skip-smoke warning, forces fresh fetch
- unknown: help message, shows latest version, registry unreachable,
suggests npm command
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(cli): address all review feedback — coverage gaps, code fixes, Codex findings
Code fixes:
- Extract classifyInstallPath() for testable npm-global detection
- Convert IS_TTY from module-level constant to isTTY() function call
(testable without import-time mocking)
- Fix non-TTY npm update silently exiting 0 → now exits 1 so scripts
detect that no upgrade happened (Codex P2)
- Handle pre-release version tags in isVersionOutdated() — strips
suffixes before comparing, handles NaN safely
- Export getCacheDir() and writeCache() for test coverage
Test coverage (69 + 24 + 9 + 3 = 105 tests):
- classifyInstallPath: npm-global (Unix + Windows paths), git, partial, unknown
- isVersionOutdated: pre-release tags, NaN handling, same-with-prerelease
- getCacheDir: XDG_CACHE_HOME override, fallback to ~/.cache
- writeCache: success, EACCES mkdir failure, ENOSPC write failure
- handleNpmUpdate full flow: confirm → spawn success, spawn failure with
sudo suggestion, spawn error (ENOENT), non-TTY exit, flag warning,
user decline, registry unreachable
- --check with unreachable registry still outputs valid JSON
- maybeShowUpdateNotice: happy path print, AGENT_ORCHESTRATOR_CI guard,
each skipArg individually
- scheduleBackgroundRefresh: no-throw, swallows errors
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(cli): move background refresh earlier, distinguish local vs global node_modules
- Move scheduleBackgroundRefresh() before parseAsync() so the fetch runs
in parallel with command execution. Short-lived commands now have a
better chance of seeding the cache before exit. (Codex P2)
- Distinguish global npm installs (lib/node_modules, .pnpm store) from
local project node_modules (npx, linked installs). Local installs now
classify as "unknown" instead of "npm-global" to avoid suggesting
npm install -g to npx users. (Codex P2)
- Add tests: pnpm global store path, nvm global path, npx local path
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(lint): remove forbidden import() type annotations in test mocks
Replace `vi.importActual<typeof import("node:fs")>(...)` with untyped
`vi.importActual(...)` to satisfy @typescript-eslint/consistent-type-imports.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(cli): don't misclassify local pnpm installs as npm-global
Local pnpm projects have node_modules/.pnpm/ which matched the /.pnpm/
heuristic. Replace with /pnpm/global/ check which only matches pnpm's
actual global store path (~/.local/share/pnpm/global/...).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(cli): non-TTY exits 0, remove misleading sudo suggestion
- Non-TTY ao update now exits 0 after printing the command. Exit 1
implied error but the user just needs to run the command manually.
- Remove blanket sudo suggestion on npm failure. npm can exit 1 for
many reasons (network, version conflict, engine mismatch). Just
print the exit code and let the user diagnose.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(cli): pnpm global support, defer background refresh after parseAsync
- Detect pnpm global installs separately from npm global. pnpm users
now see `pnpm add -g @aoagents/ao@latest` instead of the npm command.
New InstallMethod value: "pnpm-global".
- Move scheduleBackgroundRefresh() to .then() after parseAsync() completes.
Prevents in-flight fetch from holding the event loop open during
short-lived commands when the registry is slow/offline. (Codex P2)
- Use info.recommendedCommand instead of hardcoded npm string in
handleNpmUpdate, so the correct package manager command is always used.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(cli): use actual command name in error message, not hardcoded "npm"
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(cli): handle signaled npm update exits
* fix(cli): detect prereleases behind stable releases
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Fix test flakiness: Use configurable property descriptor for process.platform
mocking to prevent TypeError on property redefinition
- Fix spawn detection: Return null when caffeinate fails to spawn (child.pid
undefined) instead of returning handle that does nothing
- Fix TypeScript: Make power config optional in OrchestratorConfig interface
to avoid breaking existing consumers (populated post-validation)
- Add test for spawn failure case
Co-Authored-By: Claude <noreply@anthropic.com>
- Fix CI diff coverage and review feedback
- Fix test reliability for session attach, stop, and Windows attach
- Add coverage for session attach binary protocol and edge cases
- Clean up stdin listener + add resolvePipePath tests
- Address review comments + coverage for pipe relay
- Make 80 failing tests pass on Windows (cross-platform mocks, path assertions)
- Fix production code: execFile shell option for .cmd, isPathInside separator,
openclaw binary detection via `where` on Windows
- Add platform-aware test assertions for shell escaping, PATH handling, hooks
- Add signal forwarding comment for Windows dashboard process
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add idle sleep prevention on macOS using `caffeinate -i -w <pid>` to keep
the Mac awake while AO is running, enabling remote dashboard access (e.g.,
via Tailscale) without the machine going to sleep.
Changes:
- Add `preventIdleSleep()` helper in packages/cli/src/lib/prevent-sleep.ts
- Add `power.preventIdleSleep` config option (defaults to true on macOS)
- Wire sleep prevention into `ao start` command
- Add tests for the helper function and config validation
- Document the feature in README and example config
Note: Lid-close sleep is enforced by macOS hardware and cannot be prevented
by userspace assertions. Use clamshell mode for that use case.
Closes#1072
Co-Authored-By: Claude <noreply@anthropic.com>
- session.ts: use isOrchestratorSessionName (renamed in main) for JSON output
- start.test.ts: update stop command tests to use sm.list() instead of sm.get()
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Our Windows-specific files were written before the @composio → @aoagents
rename landed. Update all affected imports across runtime-process,
workspace-clone, workspace-worktree, cli commands, and test files.
Prevents duplicate SIGINT/SIGTERM handlers if called more than once for
the same ChildProcess — avoids double killProcessTree and racing
process.exit(1) from stacked SIGKILL fallback timers.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The function was a one-line pass-through to findPidByPort with no added
logic. Callers now import findPidByPort from @composio/ao-core directly.
waitForPortFree calls findPidByPort inline. Deleted the test file that
only tested the pass-through.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
pwsh and powershell.exe cannot run bash scripts any more than cmd.exe
can — shebangs are ignored and bash-specific syntax fails. The previous
guard only matched cmd.exe, allowing pwsh (the most common Windows
fallback) to silently invoke -File on a bash script and produce a
confusing PowerShell syntax error.
Simplify to: throw on any Windows shell without AO_BASH_PATH. Also
removes the now-dead getShell() call and -File code path from
script-runner.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>