* feat(cli): support AO_PUBLIC_URL for reverse-proxied dashboards
When AO runs inside a remote dev container or behind a reverse proxy
(Caddy/nginx/Traefik), `http://localhost:${port}` was hardcoded across
the CLI for console output, `ao open` browser launches, and the
session URLs surfaced to the orchestrator agent. None of those URLs
were reachable from outside the host.
Add an `AO_PUBLIC_URL` env var. When set, the new `dashboardUrl(port)`
helper returns it (with trailing slashes stripped) instead of the
localhost fallback. The helper replaces every user-facing
`http://localhost:${port}` literal in:
- `commands/dashboard.ts` — startup banner + browser open
- `commands/start.ts` — 12 spots: spinner, "Dashboard:" prints,
orchestrator URL fallback, `openUrl()` calls, and the running-state
reuse paths
- `lib/routes.ts` — `projectSessionUrl()` (used in the orchestrator
prompt template, so worker links land on the public hostname)
Internal IPC (`lib/daemon.ts` calling its own dashboard's
`/api/projects/reload`) is intentionally left on localhost — that
traffic never leaves the host, and routing it through a public URL
would just add latency and a failure surface.
Tests cover the env-var/localhost paths, whitespace trimming,
trailing-slash stripping, sub-path preservation, and non-default-port
URLs (`__tests__/lib/dashboard-url.test.ts`, 10 cases).
Setup guide gets a new "Public dashboard URL" entry under optional
env vars.
* docs: cover TERMINAL_WS_PATH + path-based mux routing in AO_PUBLIC_URL setup
The AO_PUBLIC_URL entry only mentioned terminal ports needing to be
reachable, which over-specifies what's required when fronting AO with
HTTPS through a reverse proxy. The dashboard's MuxProvider already
auto-detects standard ports (`loc.port === ""`/`"443"`/`"80"`) and
routes the mux WebSocket through `/ao-terminal-mux` on the same
hostname, so a single proxy rule pointing at the dashboard port is
sufficient — no extra subdomain or port forwarding for the WS.
For non-standard ports or custom paths, document the existing but
previously-undiscoverable `TERMINAL_WS_PATH` env var (read by
`/api/runtime/terminal/route.ts` and threaded through `MuxProvider`
as `proxyWsPath`).
Adds a minimal Caddy snippet so users have a working starting point.
* feat(web): accept /ao-terminal-mux as alias for /mux on direct-terminal-ws
The dashboard's MuxProvider already constructs `wss://hostname/ao-terminal-mux`
when accessed on a standard HTTPS port (443), but until now nothing on the
server side recognized that path — direct-terminal-ws only matched `/mux`,
and the Next.js dashboard doesn't handle WS upgrades at all. Deployments
fronted by a path-routing reverse proxy (cloudflared, nginx, Caddy, …) hit
the server at `/ao-terminal-mux`, fall through to Next.js, get a 404, and
the dashboard's terminal panes hang at "Connecting…" forever.
Fix is one line in the upgrade-routing allow-list: accept `/ao-terminal-mux`
in addition to `/mux`. The proxy can now route the path-based mux URL straight
at DIRECT_TERMINAL_PORT without needing a path-rewrite rule (which most
proxies — including cloudflared — don't natively support).
Existing `/mux` clients continue to work; the alias is strictly additive.
SETUP.md's AO_PUBLIC_URL section is updated to mention the path requirement
in one sentence, and a new integration test pins the behavior.
* feat(web): opt-in single-port mode (AO_PATH_BASED_MUX) for proxy-only deployments
Default behavior unchanged. When AO_PATH_BASED_MUX=1, start-all spawns a
small bundled HTTP/WS proxy on PORT that demultiplexes:
- HTTP requests forwarded to Next.js (shifted to PORT + 1000;
override with NEXT_INTERNAL_PORT)
- `wss://hostname/ao-terminal-mux` upgrades tunneled to
DIRECT_TERMINAL_PORT/mux
Use it when the reverse proxy in front of AO can only forward one
hostname:port pair upstream (e.g. Cloudflare Tunnel pointed at a single
`service:` URL with no path-based ingress, or a managed-app platform
where you don't control the proxy config). One proxy rule then
suffices — the WS path is multiplexed onto the same TCP port and
demuxed inside the AO process.
Tradeoff: one extra Node process and one extra hop per HTTP request,
in exchange for proxy-config simplicity. For deployments that *can*
do path-based routing the alias added in the previous commit
(direct-terminal-ws accepting `/ao-terminal-mux` on its own port) is
the lower-overhead path.
The new server is pure Node http; no `next` import or other extra
dependencies. It's strictly opt-in — the env-var gate keeps the code
inert by default, so existing deployments see no behavior change and
no extra startup cost.
* fix(web): correct single-port proxy header handling, WS hangs, shutdown
Addresses review feedback on single-port-server.ts:
- Strip hop-by-hop headers (RFC 9110 §7.6.1) before forwarding upstream,
including any extras named in the client Connection header. Previously
the whole header set was copied verbatim, so a client Connection: close
could tear down the keep-alive socket to Next.js.
- Add X-Forwarded-For/-Proto/-Host so the upstream sees the real client
instead of 127.0.0.1; existing values from an outer proxy are preserved.
- Handle non-101 upstream responses on the WS upgrade path. The proxy only
listened for 'upgrade', so a 404/502/mid-restart response left the client
socket hanging until TCP timeout. A 'response' handler now relays the
status and closes the connection.
- Call server.closeAllConnections() on shutdown. server.close() alone waits
for keep-alive HTTP sockets and piped WS tunnels to drain on their own,
which they never do, so shutdown always hit the 5s force-exit timer.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(web): cover single-port proxy + fix response-direction headers
Follow-up to the previous commit's review fixes, adding regression
coverage so future changes can't silently break the proxy.
- Refactor single-port-server.ts into an exported createSinglePortServer()
factory (mirrors direct-terminal-ws.ts) with a thin isMainModule()
entrypoint, so start-all.ts still spawns it as a script while tests can
drive it in-process against fake upstreams.
- Add single-port-server.integration.test.ts (5 tests, no tmux/Next.js
needed — runs on CI/Windows): hop-by-hop strip + X-Forwarded-*, 502 on
dead upstream, /ao-terminal-mux WS tunnel, non-101 upgrade relay, and
prompt shutdown with a live WS connection.
- The shutdown test caught that server.closeAllConnections() does NOT
destroy sockets already handed off via the 'upgrade' event — track
upgraded sockets explicitly and destroy them in shutdown().
- The header test caught the symmetric response-direction leak: the proxy
forwarded the upstream's Connection/Keep-Alive to the client, overriding
a client that asked for Connection: close. Strip hop-by-hop from upstream
responses too via filterResponseHeaders().
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Priyanshu Choudhary <57816400+Priyanchew@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(cli): wire activity events into CLI commands and supervisor lifecycle (#1654)
Adds "cli" to ActivityEventSource and emits ~30 activity events across the CLI
surface so `ao events list --source cli` can answer RCA questions like:
- Did AO start cleanly? When? (cli.start_invoked / cli.start_failed)
- Was AO shut down gracefully or did it crash? (cli.shutdown_signal /
cli.shutdown_completed / cli.shutdown_force_exit / cli.stale_running_pruned)
- Did ao spawn / ao update / ao stop / ao migrate-storage fail and why?
- Did the auto-restore prompt actually restore sessions?
Instrumented files:
- packages/core/src/activity-events.ts (cli source)
- packages/cli/src/lib/shutdown.ts (signal/completed/failed/force_exit/session_kill_failed)
- packages/cli/src/commands/start.ts (start_invoked/start_failed/restore_*/stop_*/daemon_*/last_stop_* /config_migrated)
- packages/cli/src/commands/spawn.ts (spawn_invoked/spawn_failed)
- packages/cli/src/commands/update.ts (update_invoked/update_failed)
- packages/cli/src/commands/setup.ts (setup_failed)
- packages/cli/src/commands/migrate-storage.ts (migration_completed/migration_failed)
- packages/cli/src/commands/project.ts (project_register_failed)
- packages/cli/src/lib/resolve-project.ts (project_resolve_failed/config_recovered/config_recovery_failed)
- packages/cli/src/lib/running-state.ts (lock_timeout/stale_running_pruned)
- packages/cli/src/lib/credential-resolver.ts (credential_load_failed)
All emits are sync, never wrapped in try/catch (recordActivityEvent never
throws), and put raw error text in data.errorMessage (not summary, which is
FTS-indexed and not credential-sanitized).
Tests:
- 16 new instrumentation tests across shutdown, migrate-storage, update,
resolve-project, and start/stop action paths covering MUST emits.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix: correct CLI activity event semantics
* fix(cli): emit migrate-storage invocation event
* fix(cli): record last-stop write failures
* fix(linear): retry transient API failures
* fix(cli): stabilize update instrumentation test
* fix(cli): stub process probes in stop instrumentation tests
* chore(ci): retrigger checks
* Fix CLI failure event review issues
* fix: bound linear integration cleanup
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: whoisasx <adil.business4064@gmail.com>
Co-authored-by: Adil Shaikh <106678504+whoisasx@users.noreply.github.com>
* chore(npm): suppress prebuild-install deprecation warning
The prebuild-install package is no longer maintained, but it's a transitive
dependency of better-sqlite3 (optional) and node-pty (optional). Both packages
continue to use it reliably for downloading prebuilt binaries across platforms.
Suppressing this warning avoids noise in npm install output while we await
upstream changes. See https://github.com/ComposioHQ/agent-orchestrator/issues/1752
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
* fix(cli): warn users when spawning in permissionless mode
When agentConfig.permissions is unset, the schema defaults to
"permissionless", which passes --dangerously-skip-permissions to Claude
Code. Claude then shows a one-time confirmation prompt inside the tmux
session; if dismissed, the session exits silently with no user-facing
error — making it very hard to debug.
Add a post-spawn warning in ao spawn output explaining the mode and
how to either accept the prompt or switch to interactive mode. Also
improve the config-instruction docs to describe each permissions value
and the silent-exit risk.
Fixes#1754
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
* fix(cli): direct users to dashboard terminal for permissionless prompt
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
* removed unwanted changes
* fix(cli): fall back to local config when global config is missing in project supervisor
There are two separate things reading config:
- Dashboard (Next.js web server): loadDashboardConfig() — tries global config, falls back to local.
- Project supervisor (CLI process): loadConfig(getGlobalConfigPath()) only — no fallback.
When a user runs ao start with a local agent-orchestrator.yaml that has never been
registered in ~/.agent-orchestrator/config.yaml, the supervisor silently returned
with 0 lifecycle workers, leaving sessions frozen in their last known state and
PR status never updating.
loadSupervisorConfig() mirrors the dashboard fallback: try global config first,
fall back to loadConfig() (local discovery) on ENOENT. ConfigNotFoundError (no
config anywhere) is handled by isMissingGlobalConfigError so the supervisor still
exits cleanly when AO has never been configured.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(cli): address review feedback and fix failing supervisor tests
Review feedback from greptile:
- Remove unreachable ConfigNotFoundError branch from loadSupervisorConfig's
catch — loadConfig(globalConfigPath) cannot throw it with a non-null path.
The error still propagates from the fallback loadConfig() (no args) and is
caught by isMissingConfigError at the outer scope.
- Rename isMissingGlobalConfigError → isMissingConfigError. After extending it
to catch ConfigNotFoundError (the "no config anywhere" case), the old name
was misleading.
Test fix (CI was failing):
- Add ConfigNotFoundError to the vi.mock factory so tests that exercise the
fallback path compile.
- Add coverage for the new fallback paths:
- ENOENT on global config → falls back to loadConfig() (local discovery)
- Non-missing-config errors (e.g. invalid yaml) propagate up
- No config anywhere → supervisor exits cleanly, no workers attached
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* chore(cli): drop unused config-instruction module
The getConfigInstruction() helper and its config-help subcommand were the
module's only consumers, so remove both. The schema URL referenced via
CONFIG_SCHEMA_URL still lives in core and is reachable from the yaml's
$schema field for editor-side completion.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* chore(cli): tighten loadSupervisorConfig comment
Replace the rationale paragraph with a one-line description of what the
function does. The "why" lives in the PR description and commit history.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(cli): scope loadSupervisorConfig ENOENT guard to the global config path
Review feedback from greptile: the ENOENT catch was broader than the matching
guard in isMissingConfigError. If the global config exists but references a
missing nested file, the supervisor would silently fall back to the local
config instead of surfacing the configuration error.
Mirror the path check from isMissingConfigError so only ENOENT for the global
config path itself triggers the fallback. Add a test covering the nested-file
case.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* chore: restore trailing newline in .npmrc
Review feedback from greptile. The trailing newline was accidentally dropped
by an earlier "removed unwanted changes" commit on this branch and is
unrelated to the supervisor fix.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Revert "chore(cli): drop unused config-instruction module"
This reverts commit cb58e15d02.
* revert the unwanted comments
* Addressing comments to make the resolved path as a fallback for supervisor
* test(cli): assert local fallback configPath propagates distinct from global
Reviewer nit: the ENOENT-fallback test was returning makeConfig with the
default `/tmp/global-config.yaml` path, so the assertion on
ensureLifecycleWorker could pass even if the supervisor accidentally
propagated the global path. Use a distinct local path in the fallback
fixture so the regression coverage is honest.
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
* feat(core): activity events for recovery, metadata corruption, agent-report
Wires activity events into three forensic-critical paths so RCA can
reconstruct what happened after the fact:
1. recovery subsystem
- recovery.session_failed (MUST) per failed session in runRecovery
- recovery.action_failed (SHOULD) on recoverSession outer catch
Adds "recovery" to ActivityEventSource so `ao events list --source
recovery` reconstructs an `ao recover` invocation timeline.
2. metadata corruption
- metadata.corrupt_detected (MUST) when mutateMetadata renames a
corrupt session-metadata file to .corrupt-{ts}. Includes
data.renamedTo and a 200-char data.contentSample (B11) for forensic
recovery. Previously only console.warn — silent overwrites had no
queryable signal.
3. agent-report apply path
- api.agent_report.transition_rejected (SHOULD)
- api.agent_report.session_not_found (COULD)
Per B22, recovery events fire per session, not per probe step. Per B11,
metadata.corrupt_detected truncates contentSample to 200 chars (full
file would exceed the 16KB sanitizer cap).
Closes#1660
* fix(core): align forensic activity event metadata
* fix(core): cover single-session recovery failures
* fix(core): improve corrupt metadata event attribution
* fix(cli): expose activity event source filter
---------
Co-authored-by: whoisasx <adil.business4064@gmail.com>
* fix(cli): rebuild better-sqlite3 when binding is missing
* fix(cli): support Windows postinstall rebuild shims
---------
Co-authored-by: i-trytoohard <193449657+i-trytoohard@users.noreply.github.com>
* chore: version packages
* ci: nudge — trigger required checks on bot PR
* test(agent-codex): drop self-defeating hardcoded version assertion
Same file as the earlier fix on this branch — changesets/action regenerated from main, bringing the bad test back. This deletion will land on main when this Version PR merges, so future Version PR regenerations won't re-introduce it.
---------
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: suraj-markup <sk9261712674@gmail.com>
* fix(cli): first-run startup creates global config with project registered
Regression from #1781: persistUpdateChannel() created an empty global config
({ projects: {} }) before autoCreateConfig() registered the project. Dashboard
loaded this empty config - zero projects - session not found.
Two-part fix:
1. autoCreateConfig() now calls registerProjectInGlobalConfig() after creating
the local yaml, so the global config is bootstrapped with the project
before maybePromptForUpdateChannel() or startDashboard() run.
2. persistUpdateChannel() and maybePromptForUpdateChannel() early-return when
no global config exists, preventing empty-husk creation.
Fixes#1766
* fix(cli): fix type error in persistUpdateChannel and update JSDoc
---------
Co-authored-by: AO Bot <ao-bot@composio.dev>
* feat(release): weekly release train — channels, onboarding, dashboard banner, cron
Implements the full release pipeline described in release-process.html
(supersedes #1525, which only had the workflow scaffolding).
A. Release infrastructure — .github/workflows/canary.yml triggers on a cron
('30 17 * * 5,6,0,1,2', i.e. 23:00 IST Fri–Tue) plus workflow_dispatch,
without the stale-SHA guard or the merged-PR-comment step from #1525
(cron has no merged-PR context). release.yml uses changesets/action.
.changeset/config.json adds the snapshot template and moves the private
@aoagents/ao-web to ignore[].
B. Channel awareness (packages/cli/src/lib/update-check.ts) — new
updateChannel field in the global-config Zod schema (stable | nightly
| manual; defaults to manual so existing users see no surprise installs).
fetchLatestVersion now reads dist-tags[channel] from the registry;
isVersionOutdated compares prerelease segments numerically + lexically
so SHA-suffixed nightlies sort correctly. maybeShowUpdateNotice and
scheduleBackgroundRefresh skip entirely on manual.
C. Active-session guard (packages/cli/src/commands/update.ts) — before
any handle*Update proceeds, sm.list() filters for working/idle/
needs_input/stuck and refuses with `N session(s) active. Run
`ao stop` first.` instead of auto-stopping (per the design doc:
surprise-killing user work is worse than refusing).
D. Soft auto-install + onboarding — handleNpmUpdate skips the confirm
prompt on stable/nightly. New packages/cli/src/lib/update-channel-
onboarding.ts prompts once on the first `ao start` after this lands;
ask-once gate keyed on the absence of updateChannel in the global
config; dismissal persists `manual`. New `ao config set updateChannel
<value>` command (also handles installMethod).
E. Dashboard banner — packages/web/src/app/api/version/route.ts reads
the same cache file the CLI writes (~/.cache/ao/update-check.json,
XDG-aware) and rejects cache entries from a different channel.
packages/web/src/app/api/update/route.ts duplicates the active-session
guard so the dashboard can return a structured 409. New UpdateBanner
component wired into Dashboard.tsx — Tailwind only, var(--color-*)
tokens, dismissible per-version via localStorage, deferred fetch so
it doesn't shift the call order in existing dashboard tests.
F. Bun + Homebrew detection (update-check.ts) — new classifiers for
~/.bun/install/global/ (auto-installs `bun add -g @aoagents/ao@<channel>`)
and /Cellar/ao/ (notice-only — `brew upgrade ao`, never auto-install
because brew owns the symlinks). New installMethod override field in
the global config to pin detection when path heuristics fail.
Tests: +155 (B/C/F unit, onboarding ask-once gate, /api/version + /api/update,
UpdateBanner visibility/dismiss/click). pnpm test, pnpm typecheck, pnpm lint
all green for the changes; the same 10 pre-existing test failures observed
on main are still present (all environment-dependent: ~/.cache/ao state, codex
binary install, /private path canonicalization on macOS).
Closes#1525
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(release-train): CI failures + Greptile review feedback
CI fixes:
- Web /api/update spawn ENOENT — attach `child.on("error", ...)` so the
asynchronous spawn-error event from a missing `ao` binary doesn't bubble
up as an unhandled error and crash vitest. The route already returns 202
before the error fires; on real installs the user sees "no version change"
if the install fails.
- start.test.ts pollution — runStartup calls `maybePromptForUpdateChannel`,
which (with isHumanCaller mocked to true) writes to the real
~/.agent-orchestrator/config.yaml on the CI runner via persistUpdateChannel.
Subsequent tests then load that newly-created (empty-projects) config and
report "No projects configured" instead of the expected "project not found".
Fix: stub `update-channel-onboarding.js` in start.test.ts so runStartup
is a no-op for the channel prompt.
Review feedback:
- (P1) `runtime: "tmux"` hardcoded default in `persistUpdateChannel` and
`loadOrInit` would lock Windows users into a non-functional tmux config
when they dismiss the channel prompt. Both now use `getDefaultRuntime()`,
matching `makeEmptyGlobalConfig` in core's global-config.ts.
- (P2) `hasChosenUpdateChannel` JSDoc inverted — the second "True when"
bullet actually described the False case. Rewritten with separate
True/False sections that match the implementation.
- (P2) `isVersionOutdated` was duplicated between the CLI and the dashboard
/api/version route. Moved to a new shared module
`packages/core/src/version-compare.ts`, exported from `@aoagents/ao-core`,
consumed by both CLI (re-exports as `isVersionOutdated`) and the web route
directly. Added 14 unit tests in core for the canonical implementation.
Defensive: `maybePromptForUpdateChannel` now validates the prompt result via
`UpdateChannelSchema.safeParse` before persisting — never writes `undefined`
or an unrecognized string to disk.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(release-train): Windows spawn + dismiss-while-blocked review feedback
- (P1) `ao update` silently never ran on Windows because `spawn("ao", ...)`
doesn't consult PATHEXT, so npm's `ao.cmd` shim wasn't found and the
async ENOENT was swallowed by the error handler. Add `shell: isWindows()`
+ `windowsHide: true` per the cross-platform guide.
- (P1) Dismiss button was inert when the banner was in the `blocked` (409)
or `error` phase — `setDismissedFor` set the localStorage flag but the
hide condition required `phase === "idle"`, so the banner stayed pinned
until reload. `handleDismiss` now resets phase to idle (and clears the
error message) so the existing condition fires. Added a regression test
covering dismiss from the 409 path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(release-train): runNpmInstall on Windows — shell:true so PATHEXT resolves npm.cmd
(P1) The dashboard /api/update spawn got `shell: isWindows()` + `windowsHide:
true` in 9f29131d, but `runNpmInstall` in the CLI's `ao update` command was
still missing the same fix. On Windows, `spawn("npm", ...)` without a shell
wrapper doesn't consult PATHEXT, so npm/pnpm/bun's `*.cmd` shims never
resolve and the install silently ENOENTs.
Mirror the fix into runNpmInstall — it's the single spawn site behind every
non-git, non-homebrew install path (npm-global, pnpm-global, bun-global,
unknown), so this one change covers all four install methods.
Tests:
- Mock `isWindows` from @aoagents/ao-core so the spawn options can be
inspected per-platform.
- Assert `shell: true, windowsHide: true, stdio: "inherit"` on Windows.
- Assert `shell: false` on macOS / Linux.
- Parametrize over pnpm-global / bun-global to confirm the same options flow
through every npm-style install command.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(release-train): /api/version reads cached.isOutdated for git installs
(P1) The dashboard banner never appeared for git-installed users because
`/api/version` ran `isVersionOutdated(current, "origin/main")`, and
`parseVersion("origin/main")` produces NaN parts that the early-exit guard
catches with `return false`. Git installs cache `latestVersion` as a git
ref (not a semver) and a precomputed `isOutdated` flag from `git fetch +
merge-base`; the CLI special-cases this in `update-check.ts`. Mirror the
same pattern here:
cached.installMethod === "git"
? cached.isOutdated === true
: isVersionOutdated(current, latest)
Also extend the local CacheData with `installMethod?: string` and
`isOutdated?: boolean` so the new branch type-checks. Kept as `string`
rather than importing the CLI's `InstallMethod` type — the literal "git"
compare is the only thing that matters here, and the web package shouldn't
take a dep on @aoagents/ao-cli.
Two new tests cover the git-install path: one asserts isOutdated=true is
trusted from the cache, the other asserts isOutdated=false (current with
origin) is trusted too.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(release-train): must-fix #3+#4 — global-config layout + git-only flag guard
#3 — ensureNoActiveSessions now consults loadGlobalConfig() first as a quick
"any projects registered?" check, then routes through loadConfig(globalPath)
only when the registry actually has projects (loadConfig dispatches to
buildEffectiveConfigFromGlobalConfigPath when given the canonical global path
— see packages/core/src/config.ts). Defends against AO_GLOBAL_CONFIG override
to a non-canonical path. Three new tests cover: registered-projects path
fires the guard correctly; empty registry returns early without building a
SessionManager; missing global file returns early without even reading it.
#4 — Restored the rejection of git-only flags on non-git installs. Users
copy/pasting `ao update --skip-smoke` from older docs would silently no-op
on npm/pnpm/bun installs. Now exits non-zero with:
"--skip-smoke only applies to git installs (current install: npm-global)."
Test it.each across npm/pnpm/bun/homebrew/unknown plus a positive test that
git installs still accept the flag.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(release-train): #2 should-fix — channel-switch prompt
When a stable user runs `ao config set updateChannel nightly` and then
`ao update`, isVersionOutdated(0.5.0, 0.5.0-nightly-abc) returns false (per
semver, prerelease < stable on equal base). The old code printed "Already
on latest nightly" and exited without installing — confusing, because the
install command we'd run is genuinely a different dist-tag.
Fix: snapshot the previously-cached channel BEFORE forcing a refresh, then
detect a switch via `previousChannel !== activeChannel && !info.isOutdated`.
On switch:
- Don't take the "already on latest" early-return.
- Print a yellow "Channel switch detected: was X, now Y." notice.
- Force a confirm prompt regardless of stable/nightly soft-install,
defaulting to "no" (channel-switch should be explicit). Manual users
still see their normal prompt.
Onboarding copy now includes one line about channel switches: "switching
later prompts before installing the other channel's build."
4 new tests: explicit switch fires the prompt + installs on yes; declines
on no; same-channel doesn't fire (back to "Already on latest"); first-ever
update with no previous cache doesn't fire either.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(release-train): polish — drop setTimeout, dedup defaults, share cache, dedup export
#5 — UpdateBanner no longer wraps its mount fetch in setTimeout(0). Production
code shouldn't bend to test mock ordering. Instead, the two brittle Dashboard
tests that relied on `mockImplementationOnce` queue ordering now route by URL
via `mockImplementation`, and the cadence test asserts "no other endpoints
were touched" instead of "no fetch was touched at all". Also added a
deliberate "no interval / re-fetch" comment per #6.
#7 — Promoted core's `makeEmptyGlobalConfig` to the public
`createDefaultGlobalConfig` (kept the internal alias for back-compat). Both
the CLI's `persistUpdateChannel` and `loadOrInit` (in `ao config`) now call
it instead of inlining the same defaults block. Single source of truth.
#8 — New `packages/core/src/update-cache.ts` exports
`getUpdateCheckCachePath`, `readUpdateCheckCacheRaw`, and
`getInstalledAoVersion`. The CLI's `update-check.ts` keeps its richer
install-method/channel/git-rev validation but now delegates path resolution
and version lookup to core. The dashboard's `/api/version` route drops its
duplicated `getCachePath`/`readCache`/`getCurrentVersion` and consumes from
core directly. Cache layout is one file, not two.
#9 — Removed the duplicate `export { isManualOnlyInstall }` from
`update.ts` (also dropped the unused import). The canonical export lives in
`update-check.ts`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(release-train): cosmetic — workflow rename note, design tokens, changeset trim
#1 release.yml: added a comment above `workflows: [CI]` warning that GitHub
matches by name (not filename) and silently no-ops on mismatch — so a
rename of ci.yml's `name:` field would mean releases stop triggering.
#10 UpdateBanner: replaced text-[13px] / text-[12px] with text-sm / text-xs
to match the dashboard's chrome scale.
#6 Banner refresh: noted in the existing useEffect comment that we don't
re-fetch — re-evaluate if "user kept tab open for days, missed an
update" becomes a real complaint.
#11 .changeset/release-train.md: dropped @aoagents/ao-web from the version
bump list. The package is `private: true` and in changeset's ignore[],
so listing it was cosmetic and would just clutter the eventual release
notes with a non-published artifact.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(release-train): illegalcall review — global guard, channel scoping, publishable web
(#1, PRRT_kwDORPZUAc6BHFDf) — `ensureNoActiveSessions` now ALWAYS loads
from the canonical global config, never from project-local. The previous
code preferred `loadConfig()` (local search-upward) when run inside a repo,
which made `sm.list()` enumerate only that project's sessions — active work
in other registered projects would be missed and the install would proceed.
New regression test asserts that a session in `other-project` blocks the
update even when invoked from `this-project`'s cwd. Existing global-config
tests retained.
(#2, PRRT_kwDORPZUAc6BHFOl) + (#4, PRRT_kwDORPZUAc6BHFon) — Reverted the
`private: true` on @aoagents/ao-web. Because @aoagents/ao-cli has a
workspace:* runtime dep on it (for `findWebDir()`/dashboard files), pnpm
rewrites the dep on publish to a literal version — keeping ao-web private
would make `npm install -g @aoagents/ao` fail. Restored ao-web to the
changeset linked group, removed it from `ignore[]`, restored the release-
train changeset entry, added publishConfig + repository metadata.
New `scripts/check-publishable-deps.mjs` walks every package and asserts
that no publishable package has a workspace:* runtime dep on a `private:
true` package. Wired into both release.yml and canary.yml before the
publish step so any future regression is caught at CI rather than at the
user's `npm install`. Verified the script catches the inverse condition.
(#3, PRRT_kwDORPZUAc6BHFaV) — `readCachedUpdateInfo` now treats a missing
`data.channel` as a miss when an explicit channel is provided. Previous
logic only rejected when both data.channel and channel were set AND
differed, so a legacy cache entry (pre-channel-scoping) could keep returning
stale stable state to a user who had since switched to nightly until the
24h TTL expired. Existing fixtures bumped to include `channel` where the
test exercises the checkForUpdate / maybeShowUpdateNotice path; new
regression test exercises the legacy-no-channel case.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(release-train): SHA-suffix nightly compare — never miss a banner
(P1, PRRT_kwDORPZUAc6BJLrW) Git SHAs are uniformly-random hex, so the old
`comparePrereleaseSegments` lexical fallback gave the wrong answer ~50% of
the time on snapshot tags. Concretely: user installs `0.5.0-nightly-f00d123`,
CI publishes `0.5.0-nightly-0dead01`, and `'f' < '0'` returns false → banner
never shows.
Fix: when two prerelease segments are both non-numeric and differ, treat the
left side as older (return -1). The cache layer always carries the registry's
CURRENT dist-tag, so any non-numeric mismatch on the same base means the
installed copy is behind by construction. Numeric ordering (`rc.1 < rc.2`)
and numeric-vs-non-numeric (`0.5.0-1 < 0.5.0-alpha`) are unchanged.
Tradeoff: a user who manually installed `0.5.0-beta` while the registry only
publishes `0.5.0-alpha` would see a spurious banner. AO's release pipeline
only emits SHA-suffixed nightly prereleases, so the scenario doesn't occur in
practice — documented in the function's JSDoc.
Updated two misleadingly-named tests ("orders SHA-suffixed nightlies
lexically") that had been asserting the buggy behavior; new tests cover the
specific case from the review (`nightly-f00d123` vs `nightly-0dead01`) and
preserve the numeric-ordering invariant.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(release-train): multi-project active-session proof + changeset note
(#1, PRRT_kwDORPZUAc6BHFDf follow-up) Dhruv asked for proof — not a comment —
that loadConfig(globalPath) actually enumerates across all registered
projects, not just the cwd's. New test in update.test.ts seeds proj-a and
proj-b in the global config, places one active session in each (one
"working", one "needs_input"), and asserts the refusal stderr lists BOTH
session ids AND the total count says "2 sessions active". The test is
specifically named so it shows up in `vitest run -t "Dhruv proof"`.
Verified `pnpm changeset version` locally — @aoagents/ao-web, ao-cli,
and ao all bump to 0.7.0 together via the linked group, confirming the
install-404 class of bug is gone.
Also updated the release-train changeset to drop the stale "moves the
private @aoagents/ao-web to ignore" line — that contradicts the current
state (ao-web is publishable and in the linked group).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(release-train): Ashish P1+P2 — dashboard banner now actually installs
P1 — Dashboard banner click was a no-op for npm users.
POST /api/update spawns `ao update` with `stdio: "ignore"`, which makes
`isTTY()` return false in the child. The old handleNpmUpdate hit the
non-TTY branch ("Run: ...") and exited without installing. Banner returned
202 "started"; nothing actually happened.
Fix (Ashish's option c, with an env-var bridge):
- /api/update spawns with `AO_NON_INTERACTIVE_INSTALL=1` on the env.
- handleNpmUpdate computes `interactive = isTTY() && !isApiInvoked()`.
- Restructured so the early-return only fires for non-TTY + non-API
(piped output): we still print "Run: ..." for that case, matching the
old contract. API-invoked path now actually runs runNpmInstall, skipping
the confirm prompt (would hang the detached child forever).
Three new CLI tests:
- AO_NON_INTERACTIVE_INSTALL=1 → spawn invoked even with isTTY=false.
- The piped-output case (no env var, no TTY) still prints "Run: ...".
- Active-session guard still fires in the API-invoked path (defense in
depth — the route's own guard isn't single point of trust).
P2 — First nightly opt-in stuck on "Already on latest stable".
Repro: user on stable 0.5.0, runs `ao config set updateChannel nightly`,
runs `ao update`. previousChannel was undefined, isOutdated was false
(semver: prerelease < stable on equal base), so the early return fired
and the install never ran.
Fix: new `isFirstChannelOptIn` branch — `previousChannel === undefined
&& info.currentVersion !== info.latestVersion && !info.isOutdated`. Force
the same prompt path the channel-switch case uses (default=no, explicit
consent). Confirmed install path covered by a new test that mirrors the
repro exactly.
The pre-existing "no previous cache → no prompt" test asserted the OLD
buggy behavior; rewritten to assert the canonical case (no prior cache
AND versions match → still "Already on latest", no prompt).
P2 — Dashboard /api/version legacy cache.
Same class as Dhruv #3, this time on the web side. Old code:
const cacheMatchesChannel = !cache?.channel || cache.channel === channel;
A legacy entry without `channel` would short-circuit `!cache?.channel`
and serve stale latestVersion. Fixed to require `cache.channel === channel`
explicitly. New regression test seeds a no-channel entry and asserts
{ latest: null, isOutdated: false, checkedAt: null }.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(release-train): Dhruv edge-case — running.json is the live source of truth
(PRRT_kwDORPZUAc6BUIUK) The active-session guard previously short-circuited
on empty global config, which missed the case where:
- User runs `ao start` from a repo with a local agent-orchestrator.yaml
and no global registration.
- running.json lists that project as currently being polled.
- Sessions live on disk under ~/.agent-orchestrator/{hash}-{projectId}/.
In that state, `loadGlobalConfig().projects` is empty so the old early
return fired and `ao update` would proceed while a daemon was actively
supervising the user's in-flight work.
Fix: consult `getRunning()` BEFORE falling back to the global registry.
When running.json reports projects, trust its configPath (could be a local
project yaml OR the canonical global path — `loadConfig` dispatches on
shape) and build the SessionManager from there. The global fallback is now
the no-daemon-running case, where on-disk sessions get reconciled by
SessionManager enrichment.
Three new tests in update.test.ts:
- `refuses when sessions exist in a locally-registered project not in
global config (Dhruv edge-case)` — seeds running.json with a local-only
project + working session, asserts refusal + that loadConfig was called
with running.configPath (NOT the global path).
- `returns true (allows update) when running.json is gone and global is
empty` — covers the genuinely-safe case.
- `trusts running.json over an inconsistent global config` — when both
signals exist, the live one (running.json) wins and loadGlobalConfig is
never consulted.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(release-train): shift canary cron 23:00 → 23:30 IST
Schedule moves to 23:30 IST = 18:00 UTC. Cron expression changes from
`30 17 * * 5,6,0,1,2` to `0 18 * * 5,6,0,1,2`. Same DOW window
(Fri,Sat,Sun,Mon,Tue) so the bake window (Wed–Thu) is unaffected.
Files touched (all consistent):
- .github/workflows/canary.yml — cron expression + comment block
- .changeset/release-train.md — schedule string in feature description
- CONTRIBUTING.md — "Testing your changes" callout
Verified `grep -rn '23:00 IST|17:30 UTC|"30 17'` returns zero matches.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat: enable workers to message orchestrator via AO_ORCHESTRATOR_SESSION_ID
Worker sessions can already be messaged by the orchestrator via `ao send`,
but the reverse direction was undiscoverable: workers had no way to learn
their orchestrator's session ID. The transport already exists (`ao send`
routes to any session in the project, and the orchestrator's ID is
deterministic at `${sessionPrefix}-orchestrator`) — only the discoverability
piece was missing.
Changes:
- Add `orchestratorSessionId?: SessionId` to `AgentLaunchConfig`.
- Populate it in spawnWorker and the worker restore path; deliberately
omit it for spawnOrchestrator (an orchestrator is not its own parent).
- Each agent plugin (claude-code, codex, opencode, aider, cursor, kimicode)
now injects `AO_ORCHESTRATOR_SESSION_ID` into the agent env when the
field is present.
- Worker prompt preamble teaches the new channel with two restraints:
(1) only ping when genuinely blocked, (2) always prefix with
`[from $AO_SESSION_ID]` because the orchestrator receives raw input
with no `from:` metadata.
No new CLI verb, no new file format, no new transport — just env wiring
plus prompt copy.
Closes#1786
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: only set AO_ORCHESTRATOR_SESSION_ID when orchestrator metadata exists
Greptile review on #1787: spawn() unconditionally injected the env var
for every worker, including ad-hoc `ao spawn` workers in projects that
never had an orchestrator running. The AgentLaunchConfig JSDoc claimed
the field was unset for ad-hoc sessions, but the implementation didn't
honor it — so a worker following the prompt's `ao send
$AO_ORCHESTRATOR_SESSION_ID …` instruction would get an opaque
"session does not exist" error.
Both spawn() and restore() now check `readMetadataRaw(sessionsDir,
"<prefix>-orchestrator")` and only propagate the field when the
orchestrator's metadata is actually on disk. Existence-on-disk is the
right signal: if metadata was ever written for the canonical
orchestrator ID, the orchestrator workflow is in play for this project.
Tests updated:
- spawn: split into two cases — "passes when orchestrator exists" (now
spawns the orchestrator first) and "omits for ad-hoc workers".
- restore: added a third case for ad-hoc worker restore (no orchestrator
metadata) alongside the existing worker-with-orchestrator and
orchestrator-restore cases.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: document orchestrator-send command for POSIX, PowerShell, and cmd.exe
Greptile review on #1787: the prompt's `ao send $AO_ORCHESTRATOR_SESSION_ID
"[from $AO_SESSION_ID] …"` example is bash-only. On Windows PowerShell
(the default shell), bare `$AO_ORCHESTRATOR_SESSION_ID` resolves to
$null, so the command silently sends to an empty session ID instead of
the orchestrator. CROSS_PLATFORM.md flags exactly this footgun.
Both prompt variants now show the correct form for the three shells AO
supports as a first-class platform: POSIX bash/zsh, PowerShell
(`$env:NAME`), and cmd.exe (`%NAME%`). The agent picks the form for its
shell. Quotes added around the POSIX expansion as a defensive measure
in case the env var ever expands to whitespace.
prompt-builder tests now assert all three syntaxes appear in both the
full and no-repo prompts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor: literal-render orchestrator ID in prompt; auto-prefix [from <id>] in ao send
Two simplifications collapsed into the same feature now that the
worker→orchestrator dialogue lives in the prompt + send.ts only:
1. **Drop AO_ORCHESTRATOR_SESSION_ID env var entirely.** The orchestrator
session ID is deterministic (`${sessionPrefix}-orchestrator`) and known
at prompt-build time, so prompt-builder just renders it literally:
ao send my-orchestrator "<your message>"
No env var, no AgentLaunchConfig field, no per-plugin wiring, no
PowerShell/cmd.exe/POSIX shell-syntax variants. The orchestrator
existence check moves into session-manager's buildPrompt call site —
one place instead of duplicated across env injection + prompt mention.
2. **Auto-prefix `[from $AO_SESSION_ID]` in `ao send` itself.** The prompt
no longer teaches the agent to self-identify because that's
infrastructure's job. send.ts wraps the message when AO_SESSION_ID is
set, which covers all session→session traffic (worker→orchestrator,
orchestrator→worker, worker→worker). Humans running ao send from
their own terminal stay unprefixed.
Net: 21 files changed, +174 / −249. Zero plugin code touched. No
cross-platform shell footgun.
Files:
- types.ts: drop orchestratorSessionId field
- session-manager.ts: drop spawn/restore field injection; pass
orchestratorSessionId into buildPrompt instead, gated on metadata
existence on disk
- 6 agent plugins: drop AO_ORCHESTRATOR_SESSION_ID env injection + tests
- prompt-builder.ts: add orchestratorSessionId to PromptBuildConfig;
conditionally emit "Talking to the Orchestrator" section with literal
ID; remove the section from the static base prompts
- send.ts: auto-prefix when AO_SESSION_ID is set
- send.test.ts: 3 new tests for prefix-set / prefix-unset / SessionManager
delivery; existing tests preserved by clearing AO_SESSION_ID in beforeEach
- changeset: scope drops to ao-core + ao-cli only
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: project builds on Windows
Replace Unix cp -r with Node.js fs.cpSync in CLI build script.
Add webpack snapshot config to prevent Next.js scanning Windows junction points.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(core): add cross-platform adapter (platform.ts)
Centralizes all platform-branching logic: shell resolution (pwsh > powershell > cmd),
process tree kill (taskkill on Windows), port-based PID discovery (netstat on Windows),
runtime defaults, and env defaults.
Addresses blockers B05, B06, B07, B08 from Windows compatibility proposal.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(core): platform-aware runtime default (tmux on Unix, process on Windows)
B04: Config and docs now reflect platform-specific defaults.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(cli): use platform-aware runtime fallback in start command
B01/B02: ensureTmux() is only called when runtime resolves to 'tmux'.
On Windows, runtime defaults to 'process', skipping tmux entirely.
* fix(core): tighten Windows PID port matching
* test(core): add mocked tests for platform.ts to fix diff coverage
Adds platform.mock.test.ts with 25 tests covering Windows-specific
branches (resolveWindowsShell fallbacks, killProcessTree, findPidByPort)
and Unix error-handling/env-var fallback chains that require mocking
node:child_process. Pushes platform.ts diff coverage from 45.9% to ≥80%.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(core): fix TypeScript errors in platform.mock.test.ts
Return ChildProcess from execFile mock implementations and use "" instead
of undefined for execFileSync mock return value to satisfy strict types.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(windows): cross-platform process management (PR 2/6) (#1028)
Fixes B05 and B06: replaces all Unix-only process management with the
platform adapter so AO works on Windows with runtime: process.
- dashboard stop/rebuild uses findPidByPort() (netstat on Windows, lsof on Unix)
- runtime-process destroy uses killProcessTree() (taskkill /T /F on Windows,
negative-PID SIGKILL on Unix) with conditional detached flag
- start.ts restart, ao stop --all, and stop-dashboard paths all switched
from process.kill() to killProcessTree() so child processes are reaped
- lifecycle-service stopLifecycleWorker() replaced with killProcessTree()
- Fixed destroy() hang: exit listener now registered before awaiting kill
so fast-exiting processes don't fire before the listener attaches
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(cli): spawn dashboard with detached:true on Unix for process group kill
Dashboard was spawned with detached:false, so killProcessTree's
process.kill(-pid) failed with ESRCH (not the group leader) and fell
back to killing only the listening process, orphaning Next.js workers.
Matches the runtime-process pattern: detached:!isWindows() makes the
dashboard the process group leader on Unix so the negative-PID group
kill in killProcessTree correctly reaps all children. On Windows,
detached:false is preserved — taskkill /T /F handles the tree kill
by PID regardless of process group membership.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(cli): forward SIGINT/SIGTERM to dashboard group; restore lifecycle stop semantics
Two Bugbot issues from the detached-dashboard and killProcessTree changes:
1. Dashboard Ctrl+C orphan: with detached:true on Unix, the dashboard is in
its own process group and does not receive SIGINT from the terminal. Add
process.once(SIGINT/SIGTERM) handlers that forward the signal via
killProcessTree so the entire dashboard group is reaped on exit. Handlers
are cleaned up when the dashboard exits to avoid leaks.
2. stopLifecycleWorker wrong return value: killProcessTree swallows ESRCH,
so a stale PID entry (process already dead) now returned true ("stopped")
instead of false ("not running"). Add an isProcessRunning guard before
the kill to restore the original semantics.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(cli): prevent double SIGTERM dispatch when both SIGINT and SIGTERM arrive
The forward handler now self-removes both listeners before calling
killProcessTree, so a second signal cannot invoke it again on an
already-dead PID.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(core): respect signal parameter in killProcessTree on Windows
SIGTERM now uses taskkill /T /PID (WM_CLOSE, graceful) instead of /F,
preserving the SIGTERM→wait→SIGKILL escalation used by lifecycle-service,
start.ts, and runtime-process. SIGKILL keeps /T /F /PID (force).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* test(core): update killProcessTree Windows tests for signal-aware taskkill
Split the single taskkill test into two: SIGTERM uses /T /PID (graceful)
and SIGKILL uses /T /F /PID (force), matching the updated implementation.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* test(core,cli): add missing coverage for platform.ts and lifecycle-service
- platform.mock.test.ts: cover Windows getEnvDefaults PATH fallback (line 148)
- lifecycle-service.test.ts: cover stopLifecycleWorker stale-PID path,
normal SIGTERM kill, and SIGKILL escalation after timeout
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(cli): suppress consistent-type-imports lint error in test mock factory
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(agents): add process-runtime PID check to isProcessRunning (#1031)
B13 (P0): All 4 agent plugins now check PID directly when runtime is
'process' instead of only scanning ps -eo for tmux TTYs. Enables correct
dashboard activity state on Windows.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(windows): platform-aware shell for postCreate, script-runner, symlinks (PR 4/6) (#1032)
fix(windows): platform-aware shell for workspaces and script-runner (B07, B08, B19)
B07: workspace-worktree and workspace-clone use getShell() instead of sh -c.
B08: script-runner spawns scripts in file-mode on Unix (so $1/$2/$3 reach
positional args) and uses getShell() on Windows; AO_BASH_PATH override
uses || so empty string is treated as unset.
B19: workspace-worktree symlink falls back to cpSync on Windows.
Also fixes path separator check in workspace-worktree to use path.sep
instead of hardcoded "/" for correct Windows behaviour.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(test): use mockReturnValueOnce for pwsh shell mock to prevent test pollution
vi.clearAllMocks() clears call history but not mockReturnValue implementations.
The Windows pwsh shell tests were polluting subsequent tests that expected the
default sh mock. Changed to mockReturnValueOnce so the override is consumed
by the single call and subsequent tests get the default sh implementation.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(cli): script-runner always uses bash on Unix, getShell() only on Windows
getShell() on Unix returns process.env.SHELL || /bin/sh, which may be zsh,
fish, or plain sh. When a script file is passed as an argument to these
shells (file mode), the #!/bin/bash shebang is ignored and bash-specific
syntax in ao-doctor.sh / ao-update.sh / setup.sh breaks.
Fix: hardcode bash on Unix (AO_BASH_PATH still overrides it), use getShell()
only on Windows where bash is unavailable.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(core): use lazy factory for Zod runtime default
z.string().default(getDefaultRuntime()) evaluates getDefaultRuntime() once
at module load time, creating hidden coupling between import order and
platform detection. Using a factory function ensures the default is resolved
lazily each time it is needed, making the intent explicit.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(cli): add detached: !isWindows() to dashboard spawn for correct process cleanup
Without detached:true on Unix, killProcessTree(pid) calls process.kill(-pid)
which targets a process group the child never owns — ESRCH causes fallback to
direct kill, leaving grandchild processes alive. Matches the pattern already
used in start.ts lines 782 and 795.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(cli): forward SIGINT/SIGTERM to detached dashboard child on Unix
Detached children run in their own process group, so Ctrl+C does not reach
them. Without forwarding, the dashboard holds the port after the parent exits.
Matches the identical pattern in start.ts lines 1154-1165.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(core): guard killProcessTree against pid <= 0
On Unix, -0 === 0 in JS, so killProcessTree(0) would call process.kill(0)
which sends the signal to every process in the calling process's group,
killing AO itself. findPidByPort can return "0" since it passes the
truthiness check and the /^\d+$/ regex. Guard pid <= 0 at the top of
killProcessTree and return early.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(windows): Node.js metadata wrappers + Claude Code hook + pwsh shell (squash merge PR5)
Squash merges feat/windows-hooks-and-launch (PR #1033) into PR1.
Blockers addressed:
- B16: ~/.ao/bin/gh and git wrappers are now Node.js scripts + .cmd shims on
Windows (bash on Unix unchanged). WRAPPER_VERSION bumped to 0.3.0 to force
reinstall.
- B17: Claude Code PostToolUse hook uses JSON.parse/fs built-ins on Windows
instead of bash+jq+grep+sed. Atomic writes via temp file + renameSync.
chmod skipped on Windows.
- B18: runtime-process uses getShell().cmd + shellInfo.args() instead of
shell:true (which resolves to cmd.exe on Windows). getShell() returns
pwsh > powershell.exe > cmd.exe on Windows, bash on Unix.
Conflict resolution:
- runtime-process spawn: kept PR5's getShell() approach (B18 fix), removed
shell:true which PR1 had as a placeholder.
- runtime-process destroy: kept PR1's cleaner killProcessTree delegation
(PR5 had inline platform-branching written before killProcessTree was
integrated into this worktree).
- Tests: merged PR5's new Windows compatibility tests, adjusted assertions to
match PR1's killProcessTree(pid, signal) API. Fixed Windows compat tests
that expected old spawn(launchCommand, opts) signature.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(core): deduplicate Node.js wrapper updateAoMetadata and add AO_DATA_DIR path validation
- Extract shared NODE_UPDATE_AO_METADATA constant used by both gh/git wrappers
- Add AO_DATA_DIR validation matching bash ao-metadata-helper.sh (must be under ~/.ao/, ~/.agent-orchestrator/, or tmpdir)
- Bump WRAPPER_VERSION to 0.5.0 to force reinstall with new security check
- Update version in agent-codex and core tests to match 0.5.0
- Fix CI test failures from WRAPPER_VERSION bump (0.2.0 → 0.4.0 → 0.5.0)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* test(core): add IPv6 netstat test for findPidByPort + mark _resetShellCache as @internal
- Add test case for IPv6 LISTENING entries ([::]:3000) in Windows netstat output
- Add @internal JSDoc to _resetShellCache to clarify it is test-only
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(cli): use -File mode on Windows for script-runner so positional args are forwarded
PowerShell -Command does not forward argv elements after the command string to the
script — they are treated as top-level PowerShell args and silently dropped. Using
-File passes remaining args as positional parameters ($1, $2, …) to the script, so
e.g. `ao doctor --fix` correctly reaches the script on Windows.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(cli,core): remove unused getEnvDefaults export, add SIGKILL fallback in signal forward handler
- Remove getEnvDefaults from @composio/ao-core public API — no production callers;
the function remains in platform.ts for future use (B10/B11)
- Add 5 s SIGKILL fallback in dashboard.ts and start.ts forward() handlers so the
parent process cannot hang indefinitely if the child ignores SIGTERM
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* refactor(cli): extract forwardSignalsToChild utility, fix ESM build script, reject Windows absolute symlinks
- Extract forwardSignalsToChild(pid, child) into shell.ts — eliminates verbatim
duplication of the SIGTERM/SIGKILL forwarding logic between dashboard.ts and start.ts
- Fix build script: replace node -e "require(...)" with node --input-type=commonjs -e
"require(...)" — required because package is "type":"module" and require is not
available in node -e by default in ESM context
- Fix duplicate import in shell.ts (no-duplicate-imports lint error)
- Reject Windows drive-letter (C:\) and UNC (\server\share) paths in symlink
validation — previously only Unix absolute paths starting with "/" were blocked
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(cli): clear SIGKILL fallback timer when child exits cleanly
If the child exits before the 5-second escalation window, the fallback
setTimeout would still fire and call process.exit(1). Track the timer
in the outer scope so the child.once("exit") handler can cancel it.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: resolve two open bugbot issues on windows-platform-adapter PR
- fix(platform): always use /bin/sh on Unix instead of \$SHELL so
postCreate commands and runtime launches work correctly when the
user's login shell is non-POSIX (fish, nushell, etc.)
- fix(script-runner): detect cmd.exe fallback on Windows and throw a
clear, actionable error pointing to AO_BASH_PATH rather than passing
the PowerShell-specific -File flag to cmd.exe (which produces a
cryptic error and still can't run bash scripts)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(cli): gate lifecycle worker detached flag behind !isWindows()
Spawning with detached: true unconditionally creates a new console
window on Windows. Use the same !isWindows() pattern established
elsewhere in this PR so the process group behaviour is correct on
both platforms. killProcessTree already uses taskkill /T on Windows
so cleanup is unaffected.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(cli): require AO_BASH_PATH for all Windows shells, not just cmd.exe
pwsh and powershell.exe cannot run bash scripts any more than cmd.exe
can — shebangs are ignored and bash-specific syntax fails. The previous
guard only matched cmd.exe, allowing pwsh (the most common Windows
fallback) to silently invoke -File on a bash script and produce a
confusing PowerShell syntax error.
Simplify to: throw on any Windows shell without AO_BASH_PATH. Also
removes the now-dead getShell() call and -File code path from
script-runner.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(core): always force-kill on Windows; fix stale .cjs JSDoc
killProcessTree: drop the SIGTERM-without-/F branch on Windows.
taskkill without /F sends WM_CLOSE which is unreliable for headless
Node.js console processes — they may silently survive, leaving orphaned
processes. Always pass /F so termination is guaranteed. Callers that
do SIGTERM→wait→SIGKILL escalation are unaffected: SIGKILL simply
finds the process already dead.
agent-workspace-hooks: correct JSDoc that still said <name>.js after
the extension was changed to .cjs (forced CJS mode).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* refactor(cli): remove trivial findRunningDashboardPid wrapper
The function was a one-line pass-through to findPidByPort with no added
logic. Callers now import findPidByPort from @composio/ao-core directly.
waitForPortFree calls findPidByPort inline. Deleted the test file that
only tested the pass-through.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(cli): make forwardSignalsToChild idempotent via WeakSet guard
Prevents duplicate SIGINT/SIGTERM handlers if called more than once for
the same ChildProcess — avoids double killProcessTree and racing
process.exit(1) from stacked SIGKILL fallback timers.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(agent-claude-code): three Windows correctness fixes
C-1: Add AO_DATA_DIR allowlist validation to METADATA_UPDATER_SCRIPT_NODE.
The Node.js PostToolUse hook now validates AO_DATA_DIR against
~/.ao/, ~/.agent-orchestrator/, and os.tmpdir() before writing —
matching the protection already in ao-metadata-helper.sh and the
Node.js wrappers in agent-workspace-hooks.ts.
I-1: Guard getCachedProcessList() against Windows. ps -eo pid,tty,args
is Unix-only; the guard makes the intent explicit and avoids a
spurious execFile call when a stale tmux handle is encountered on
Windows.
I-2: Read systemPromptFile content synchronously on Windows instead of
using $(cat ...) bash command substitution, which is not understood
by PowerShell or cmd.exe.
Tests added for all three fixes.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(agents): apply Windows correctness fixes to aider, codex, opencode
Mirrors the fixes already applied to agent-claude-code:
I-1: Guard ps -eo pid,tty,args behind isWindows() in isProcessRunning for
all three agents. ps is Unix-only; a stale tmux handle on Windows
would silently return false via exception catch. The explicit guard
makes intent clear and avoids the unnecessary execFile call.
I-2: Inline systemPromptFile content on Windows instead of $(cat ...)
bash command substitution (aider: --system-prompt; opencode: prompt
value). codex is unaffected — it passes the path via -c flag and
reads the file itself.
Tests: added isWindows mock (default false) to all three test suites to
prevent real isWindows()=true on Windows from triggering the new guard
in existing Unix-path tests. Added Windows-specific tests for each fix.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(core): use F_OK and skip bare extension in Windows gh/git wrappers
On Windows, fs.constants.X_OK is equivalent to F_OK (execute bit does
not exist), so any existing file passes the check. The empty extension
was also tried first, meaning a bare file named "gh" would be selected
over gh.exe. Switch to F_OK and only check .exe/.cmd extensions.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(windows): QA fixes — dashboard spawn, shellEscape, tmux hint, PID fallback
T04: resolveNextBin() skips POSIX .bin/next shim on Windows; invokes
next/dist/bin/next via process.execPath instead (ENOENT fix).
T18: shellEscape() branches on isWindows() — PowerShell uses '' doubling,
Unix uses POSIX '\'' escaping. All 4 agent plugins benefit automatically.
T06 secondary: tmux attach hint in `ao spawn` output gated behind
runtimeName === "tmux" (process runtime has no tmux session).
T06/T11: runtime-process destroy() and isAlive() fall back to
handle.data.pid when the in-memory processes Map is empty — fixes
cross-process CLI calls (ao session ls / ao session kill).
* fix(web): prevent nft EPERM on Windows home directory junction points
Next.js nft (Node File Tracer) scans homedir() at build time and hits
EPERM on Windows junction points (e.g. Application Data). Adds homedir()/**
to TraceEntryPointsPlugin.traceIgnores on Windows server builds.
* Revert "Merge branch 'main' into feat/windows-platform-adapter"
This reverts commit 5ba7644548, reversing
changes made to 5da9bedf5c.
* Reapply "Merge branch 'main' into feat/windows-platform-adapter"
This reverts commit 6a326a07e3.
* fix(windows): update @composio imports to @aoagents scope
Our Windows-specific files were written before the @composio → @aoagents
rename landed. Update all affected imports across runtime-process,
workspace-clone, workspace-worktree, cli commands, and test files.
* feat(windows): add PTY host for ConPTY terminal sessions
Windows equivalent of the tmux daemon. Per-session detached pty-host.js
process owns a ConPTY (via node-pty), listens on a named pipe
(\.\pipe\ao-pty-{hash}-{sessionId}), and relays terminal I/O to any
connected client.
- runtime-process: spawn pty-host on Windows, route sendMessage/getOutput/
isAlive/destroy through named pipe protocol
- mux-websocket: named pipe relay for dashboard terminal (skip TerminalManager
on Windows), resolvePipePath via generateConfigHash (instant, no pipe scan)
- direct-terminal-ws: use real mux server on Windows instead of placeholder
- tmux-utils: findTmux returns null on Windows, resolvePipePath added
- orchestrator-prompt: runtime-agnostic language
- opencode: fix isProcessRunning tmux-before-guard bug (W29)
Addresses blockers W01-W12, W23, W24, W28, W29, W33-W35.
Unix behavior completely unchanged.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(windows): QA fixes — session attach, ao stop, activity detection
- cli/session: add Windows ao session attach via named pipe relay with
raw stdin mode and Ctrl+\ to detach; skip getTmuxActivity on Windows
- cli/start: fix ao stop looking for "tr-orchestrator" instead of the
actual numbered session (e.g. tr-orchestrator-5) — also fixes Linux
- agent-claude-code: fix toClaudeProjectPath dropping Windows drive colon
(C:\→C- not C) breaking JSONL lookup; ignore stale JSONL entries from
previous sessions in reused worktrees
- pty-client: use \r (carriage return) instead of \n for PTY Enter key
Addresses blockers W13 (partial), W14.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: resolve merge conflicts from main — rename isOrchestratorSession, update stop tests
- session.ts: use isOrchestratorSessionName (renamed in main) for JSON output
- start.test.ts: update stop command tests to use sm.list() instead of sm.get()
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address CI failures and review comments
- session.ts: restore isOrchestratorSession from core (checks both name
and metadata role) instead of name-only isOrchestratorSessionName
- session.ts: remove unused allSessionPrefixes after merge conflict
- start.test.ts: update stop command tests for sm.list() flow
- toClaudeProjectPath: remove speculative space replacement, keep only
verified chars (/ : .) — addresses review comment about Unix breakage
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(windows): address review feedback, fix tests, and improve Windows coverage
- Fix CI diff coverage and review feedback
- Fix test reliability for session attach, stop, and Windows attach
- Add coverage for session attach binary protocol and edge cases
- Clean up stdin listener + add resolvePipePath tests
- Address review comments + coverage for pipe relay
- Make 80 failing tests pass on Windows (cross-platform mocks, path assertions)
- Fix production code: execFile shell option for .cmd, isPathInside separator,
openclaw binary detection via `where` on Windows
- Add platform-aware test assertions for shell escaping, PATH handling, hooks
- Add signal forwarding comment for Windows dashboard process
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(windows): QA fixes — activity timestamps, prompt delivery, pty-host keep-alive
- session.ts/status.ts: use session.lastActivityAt on Windows (no tmux)
- session-manager.ts: stabilize ConPTY output before sending post-launch prompt
to prevent prompts being swallowed during agent startup splash screen
- pty-client.ts: split message + Enter into two writes (300ms gap) to match
tmux send-keys behavior; fix isAlive to return true while pipe is connectable
regardless of whether the agent process inside has exited
- pty-host.ts: keep named pipe server alive after agent exits (mirrors tmux
session persistence) so clients can still attach and view scrollback
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(core): Windows-stable storage hash and atomic write retry
storage-key: normalize to POSIX form (strip drive letter, replace backslash
with forward slash) before hashing so identical repos produce identical
hashes across Windows and Unix, and across different Windows working
directories of the same checkout.
atomic-write: retry renameSync up to 10x with 50ms backoff when Windows
returns EPERM/EACCES/EBUSY — antivirus, file indexer, or backup software
briefly hold handles to recently-written files. Cleans up the temp file
on final failure so subsequent retries don't trip "file exists".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(windows): plugin runtime gaps + ConPTY graceful shutdown
runtime-process:
- Reserve the per-instance processes-map slot before the platform split
so the Windows ConPTY branch participates in duplicate-create detection
and getMetrics/getAttachInfo bookkeeping. Previously, Windows returned
a handle without storing it, so duplicate session IDs were silently
accepted and getMetrics always reported 0 uptime.
- Add 500ms graceful-exit poll before SIGKILLing the pty-host on destroy
so node-pty can dispose its ConPTY handle. Skipping this orphaned the
conpty_console_list_agent helper and triggered Windows Error Reporting
dialogs (0x800700e8) on real runs, not just tests.
- pty-host: install SIGTERM/SIGINT/SIGHUP/SIGBREAK/beforeExit handlers
that drive the same shutdown sequence (kill pty, drop clients, close
pipe, exit after 50ms grace), and route MSG_KILL_REQ through the same
path. Previously MSG_KILL_REQ only called pty.kill() and left the host
process lingering.
- Add windowsHide:true to the pty-host child spawn so node-pty's helper
console window stays hidden on errors.
workspace-worktree: normalize paths to a comparable POSIX form
(backslash→slash, lowercase drive letter) when matching git worktree
list --porcelain output against project directories. git emits
forward-slash paths on Windows; path.join produces native backslashes
— the comparison failed and list() returned empty.
agent-opencode: guard tmux/ps usage with isWindows() in isProcessRunning
so process-runtime sessions on Windows take the PID-signal path instead
of attempting Unix-only commands.
cli/start: detect Windows local paths in isLocalPath (drive letter
prefix, UNC path, .\, ..\) so spawn arguments like C:\... aren't
mistaken for project names.
integration test: replace cat + /tmp with platform-native echo (findstr
"x*" on Windows, cat on Unix) and os.tmpdir(); the original used
Unix-only tooling and would never run on Windows.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(windows): isolate USERPROFILE per test, normalize path assertions
Before this, vitest worker isolation only overrode HOME, but on Windows
os.homedir() reads USERPROFILE. Tests that wrote to ~/.agent-orchestrator
ended up sharing the real user's data directory across workers, leading
to flaky cross-test pollution.
- test-utils: createTestEnvironment / setupTestContext now override both
HOME and USERPROFILE to the per-test fake home, restore both on
teardown. rmSync uses maxRetries:5/retryDelay:50 to ride out the same
Windows file-lock window that atomic-write retries cover.
- core test files (global-config, plugin-integration, portfolio-*,
project-resolver, recovery-actions, orchestrator-prompt*): set fake
USERPROFILE alongside HOME and use the retry-aware rmSync.
- update-check.test: normalize path separators in assertions
(path.replace(/\/g, "/")) so script-path matching works on Windows
without forcing the production code to emit posix paths.
- orchestrator-prompt.dist.test: pass shell:true on Windows to execFileSync
for .cmd targets, working around Node CVE-2024-27980's hardening.
Removed lifecycle-service.test.ts — it covered stopLifecycleWorker, which
was deleted when lifecycle was moved in-process during the merge with
main. The remaining lifecycle paths are exercised by lifecycle-manager
tests in core.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(core): restore must rewrite statePayload.runtime.handle, not just top-level
Restore was writing the freshly-spawned runtime handle to the top-level
metadata `runtimeHandle` key, but the canonical lifecycle parser prefers
`statePayload.runtime.handle` and falls back to the top-level only when
statePayload is missing. The next lifecycle tick read the stale handle
from statePayload and rewrote both keys from it, silently undoing the
restore's update.
Symptom: a session restored after AO restart kept the old PID in
metadata. Lifecycle probe found that PID dead (it was from a previous
boot) and the dashboard rendered the orchestrator as exited/killed even
though a new process was actually running.
Fix: rebuild the canonical lifecycle with the new handle via
buildUpdatedLifecycle() and persist via lifecycleMetadataUpdates() so
statePayload and runtimeHandle stay in sync. Mirrors the pattern used
in kill/spawn paths.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(cli): mock exec for ps verification in stop test
killDashboardOnPort runs a unix-only `ps` cmdline check before killing.
Without mocking exec, the call rejects and the catch returns false, so
killProcessTree is never called and the assertion fails on Linux CI.
* fix(windows): suppress console flashes, register process runtime, auto-detect Git Bash, chunk PTY input
- platform.ts: add windowsHide:true to pwsh/powershell/taskkill/netstat
spawns so AO no longer flashes a console window for each subprocess.
- script-runner.ts: auto-detect Git Bash at the common install paths on
Windows when AO_BASH_PATH is unset; tighter error if neither auto-detect
nor override succeeds. WSL bash intentionally excluded — invoking it
from Windows-native Node mixes Linux paths with Windows cwd and
silently breaks repo scripts. Also adds windowsHide:true to the spawn.
- web/services.ts: register @aoagents/ao-plugin-runtime-process so the
dashboard can spawn sessions on projects using runtime: process
(the Windows default per getDefaultRuntime).
- pty-client.ts: chunk ptyHostSendMessage into 512-char frames with a
15ms gap so large prompts (~3-4KB+) are no longer truncated by
ConPTY's input buffer. Cross-platform safe; Unix PTYs absorb chunks
at full speed. Trailing Enter still sent as a separate frame after
the existing 300ms pause.
- Mock test helpers updated to handle the (cmd, args, options, callback)
arity introduced by passing windowsHide; new test covers Git Bash
auto-detection.
* fix(windows): add windowsHide to remaining subprocess spawns
Sweeps the spawn sites missed by the first pass — `ao stop`, session
list, opencode introspection, tmux helpers, and worktree git/postCreate
all bypassed the previous fix and still flashed conhost on Windows.
- cli/lib/shell.ts: exec helper (used by git/gh/tmux wrappers)
- core/session-manager.ts: EXEC_SHELL_OPTION + standalone tmux call
- core/tmux.ts: tmux execFile helper
- plugins/workspace-worktree: git wrapper + rev-parse + postCreate shell
- workspace-worktree tests: assertions updated for the new options shape
* fix(codex): make agent-codex work on Windows
Three Windows-specific gaps that combined to make every Codex spawn fail
on PowerShell with "Unexpected token '-c' in expression or statement":
- formatLaunchCommand(): prepend `& ` to the joined launch string when
running on Windows. shellEscape quotes the resolved binary path
('C:\Users\...\codex.cmd'), and PowerShell parses a leading quoted
string as an expression — without the call operator the next flag
triggers a parser error before codex is ever invoked. bash treats
the same string as a normal command, so the prefix is Windows-only.
Applied at both getLaunchCommand and getRestoreCommand exits.
- resolveCodexBinary(): add a Windows branch using `where.exe` instead
of `which`. Prefers codex.cmd (npm shim) over codex.exe (Cargo build),
then falls back to %APPDATA%\npm\codex.{cmd,exe} and ~\.cargo\bin
for users whose PATH doesn't yet include the install dir. Lookup runs
with windowsHide:true so the search itself doesn't flash a console.
- sessionFileMatchesCwd(): compare paths via a canonical form
(forward slashes, lowercased drive letter) so Codex JSONL rollout
files can still be located when payload.cwd uses a different slash
direction or drive-letter case than the workspace path AO computes
via path.join. Without this, dashboard activity/cost stay empty
for Codex sessions on Windows.
* fix(windows): resolve gh.exe via PATHEXT and fix path-shape test regexes
resolveGhBinary() searched PATH for a literal "gh" file and threw on
Windows where the binary is gh.exe (or gh.cmd for npm shims). All gh
calls in tracker-github and scm-github failed before reaching execFile,
which made spawn() fall back from tracker-derived branch names and made
cleanup() skip the gh-driven kill paths entirely.
Honor PATHEXT on win32 so the resolver matches gh.exe/.cmd/.bat. Update
the four affected integration assertions to accept Windows path shapes.
Also bump the runtime-process sendMessage sleep on Windows — ConPTY
pipe round-trip needs more headroom than the Unix direct-stdin path.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(windows): junctions/hardlinks for symlinks, WinRT toast notifier, DPI re-fit
workspace-worktree: when symlinkSync EPERMs on Windows (no admin /
Developer Mode), try a junction for directories and a hardlink for
files before falling back to recursive cpSync. The previous fallback
copied node_modules into every worktree — slow and bloated.
notifier-desktop: add a win32 branch using PowerShell + WinRT toast XML
(no third-party deps). The script is base64-encoded as -EncodedCommand
to sidestep PowerShell argument tokenization. Toast failures log a
warning instead of rejecting so a stripped-down SKU or disabled
notifications can't crash the lifecycle.
DirectTerminal: re-fit on devicePixelRatio changes via matchMedia.
ResizeObserver doesn't fire when only DPR changes (e.g. dragging the
window between monitors at different scales on Windows), leaving an
unrendered stripe to the right of the last column until manual resize.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(test): update notifier-desktop integration test for Windows toast support
I missed this duplicate test in the integration-tests package when I
added the Windows branch to notifier-desktop. The mock callback used the
3-arg execFile signature (cmd, args, cb) but the new win32 path calls
execFile with 4 args (cmd, args, opts, cb), so the callback landed in
the opts slot and "cb is not a function" broke CI on Linux.
Make the mock signature-agnostic and replace the win32 "no execFile,
warns" assertion with one that verifies the EncodedCommand toast script.
Add a separate freebsd case for the actual unsupported-platform path.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* docs: consolidate six Windows port plans into one closeout
All 11 punch-list tasks shipped (junctions, WinRT toast, DPR re-fit
landed last) plus the foundational PTY-host / runtime-process work.
Stop fix is the only deferred item, waiting on upstream PR #1496.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(agents): use shell:true on Windows in detect() to honor PATHEXT
execFileSync with a bare command name on Windows does not consult
PATHEXT — it only finds literal .exe files. CLIs installed via
npm install -g land at %APPDATA%\npm\<name>.cmd, which detect() can't
see, so AO reports the agent as not installed.
Add shell: isWindows() so cmd.exe handles PATHEXT and finds .cmd shims.
Adds windowsHide: true while we're there to suppress conhost flashes.
Affects all 5 agent plugins: aider, claude-code, codex, cursor, opencode.
Reproduced with codex installed via npm on a Windows EC2 box where
where.exe codex resolved to codex.cmd but detect() returned false.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(windows): probe absolute powershell path so PATH-degraded children don't fall through to cmd.exe
The dashboard (Next.js) sometimes spawns the runtime-process pty-host with a PATH that
lacks C:\Windows\System32. Both `pwsh` and `powershell.exe` probes in
resolveWindowsShell() then fail and we drop to cmd.exe — which can't execute the
PowerShell-syntax launch commands agents emit (e.g. Codex's `& 'codex' ...`),
producing `'&' was unexpected at this time.` and an immediately-exited orchestrator.
Probe %SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe directly via
existsSync — this path is guaranteed on Windows 10+ and doesn't depend on PATH.
Also add an AO_SHELL env override as an explicit escape hatch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: restore orchestrator session with its systemPromptFile
When restoring an orchestrator session whose agent has no resumable thread for
the worktree (e.g. Codex when the rollout file's cwd doesn't match), restore()
falls back to getLaunchCommand(agentLaunchConfig). The fallback's
agentLaunchConfig was missing systemPromptFile, so Codex booted as a bare TUI
with no orchestrator instructions — the dashboard terminal showed the default
"Write tests for @filename" prompt instead of the orchestrator running.
spawnOrchestrator writes the prompt to {baseDir}/orchestrator-prompt-{sessionId}.md
and threads it through agentLaunchConfig.systemPromptFile (session-manager.ts:1687).
Re-attach the same file on restore when the role is orchestrator and the file
still exists on disk.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(test): assert negative pid in start full-stop test
killProcessTree on Unix targets the process group first via
process.kill(-pid, signal); only falls back to positive pid if that
throws. The test mock returns true, so only the negative-pid call
ever fires. Update assertion to match the actual call.
Caught by CI on Linux (test was Windows-skipped locally).
* fix(test): assert on killProcessTree mock, not process.kill
killProcessTree is module-mocked at the top of start.test.ts, so
process.kill is never invoked by the stop command — the spy
assertion would always see 0 calls on Linux CI. Assert on the
mock directly. Mock is platform-agnostic, so the skipIf is gone.
* fix(windows): node wrapper updateAoMetadata supports V2 .json metadata format
The Windows Node.js gh/git wrappers in NODE_UPDATE_AO_METADATA only tried
the bare session path (e.g. ao-154), but V2 storage uses ao-154.json files.
This caused silent metadata update failures on Windows — PR URLs written by
agents via `gh pr create` were never recorded in session metadata.
Fix mirrors bash ao-metadata-helper.sh: try .json first (V2), fall back to
bare name (V1/legacy). Also adds JSON.parse/stringify handling for V2 JSON
format instead of the key=value line-splitting that only worked for V1.
Bump WRAPPER_VERSION 0.6.0 → 0.7.0 to force reinstall on existing setups.
* chore: remove accidentally committed package-lock.json files
* feat(windows): pty-host registry + sweep on stop and project delete
Windows pty-hosts spawn detached so they survive parent exit (mirroring tmux
on Unix). That same detachment means taskkill /T cannot reach them on
graceful shutdown — they live in their own console group, outside the
parent's process tree. Per-session metadata can't be the source of truth
either: rm -rf'd worktrees, mid-write crashes, or manual recovery sever
AO's only handle to the host PIDs and orphan them silently.
This adds a sideband registry at ~/.agent-orchestrator/windows-pty-hosts.json
that AO writes on spawn (runtime-process) and reads on shutdown (cli/start.ts
sweepWindowsPtyHosts) and project delete (web/.../route.ts via
stopStaleWindowsPtyHosts). Reads auto-prune entries whose PID is gone, so
the registry is self-healing across crashes.
Sweep is graceful-first: each entry gets ptyHostKill via its named pipe,
500 ms grace probe, then killProcessTree as the hard fallback. The result
("swept N pty-host(s): G graceful, F force-killed") goes to the ao stop
log so users can see cleanup happened.
Verified live: spawn registers, destroy unregisters, ao stop --all sweeps,
PID 0 entries auto-prune on next read.
* fix(windows): retry worktree rmSync on file-handle drain race
After ao kills a runtime, the just-exited pty-host's child processes
(conpty_console_list_agent.exe, the agent's spawned shell, .git/index.lock)
still hold open handles inside the worktree for ~30 s–2 min while Windows
drains them. rmSync(force: true) deletes individual files but the parent
rmdir blocks with EBUSY/ENOTEMPTY/EPERM, leaving an empty orphan directory
under ~/.agent-orchestrator/projects/*/worktrees/.
destroy()'s catch-block fallback now calls removeDirWithRetry, which on
Windows retries with backoff [0, 100, 250, 500, 1000, 2000] ms checking
existsSync between attempts, and throws a descriptive error if the
directory survives all six. Non-Windows behaviour is unchanged (single
rmSync).
The thrown error escapes to session-manager.ts:kill which already swallows
it, so callers see no behaviour change today — but observability layers
can hook in later to surface real failures instead of silent orphans.
Addresses the Windows subset of #1562 (the cross-platform stale
.git/worktrees/<id>/ registration is still tracked there separately).
* fix(windows): code-review hardening — shell args, runtime default, sessionId, V2 pipe path
Four small fixes flagged in review of the Windows port:
- core/platform.ts: AO_SHELL override now infers args flag from the shell
basename (cmd → /c, bash/sh/zsh → -c, anything else → -Command). Previously
every override got PowerShell args, so AO_SHELL=cmd or AO_SHELL=bash
silently broke run-command flows.
- core/global-config.ts: defaults.runtime now resolves to getDefaultRuntime()
(process on Windows, tmux elsewhere) instead of the hardcoded "tmux".
First-run on Windows no longer writes a config that immediately fails
runtime resolution.
- web/server/mux-websocket.ts: validateSessionId now runs on the Windows
named-pipe relay path. The Unix branch validates inside TerminalManager;
the Windows path bypassed it entirely, so an unsanitised id became both
a map key and was interpolated into a pipe path downstream.
- web/server/tmux-utils.ts: resolvePipePath now reads the V2 JSON layout
(~/.agent-orchestrator/projects/{projectId}/sessions/{id}.json) first,
then falls back to V1 line-delimited metadata for users who haven't run
ao migrate-storage. The single-source-of-truth note is still accurate;
the search just covers both layouts during the migration window.
Each change has a paired unit test.
* chore: drop superseded windows-port-closeout plan
* fix(core): lazy-resolve homedir() in windows-pty-registry
REGISTRY_FILE was computed at module load via homedir(), which fired
before vitest mock factories for `node:os` could install. Tests that
mock node:os (notifier-desktop, terminal-iterm2, agent-claude-code
activity-detection) hit either a TDZ error or "homedir not defined on
mock" because the mock isn't bound at evaluation time.
Resolve the path lazily inside readRaw/writeRaw so each call honours
the current mock. Also rename the test helper export from a const to
a function (__getWindowsPtyRegistryFile) so tests can read the
post-mock value.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(cli): exit 0 when SIGKILL fallback fires on slow Ctrl+C
forwardSignalsToChild's 5 s fallback called process.exit(1) after
SIGKILL, which marks user-initiated Ctrl+C as an error whenever the
child is merely slow to drain (Next.js connection draining is the
common case). Shell scripts and CI pipelines that check the AO exit
code break.
Use exit 0 — graceful user shutdown is not a failure even if the
child needed force-killing. Reported by greptile review on PR #1025.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(core): drop synchronous shell probes that block event loop
resolveWindowsShell ran execFileSync("pwsh", ["-Version"], { timeout: 5000 })
on every cold start. On the common case (Windows 10/11 with no pwsh
installed) the call blocks the Node event loop for the full 5 s timeout,
stalling AO startup, runtime spawns, and postCreate hooks.
Walk PATH ourselves via existsSync — the lookup is microseconds and
needs no subprocess. Cascade unchanged: AO_SHELL → pwsh on PATH →
absolute powershell.exe → powershell on PATH → cmd.exe.
Reported by greptile review on PR #1025.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(runtime-process): treat EPERM as alive in pty-host destroy probe
destroy()'s 500 ms graceful-shutdown loop probes the pty-host with
process.kill(pid, 0) and treats any throw as "process gone, clean
exit". On Windows, cross-context processes can return EPERM — the
process is alive but we lack permission to signal it. Returning
early in that case orphans the pty-host and skips killProcessTree.
Detect EPERM and break out of the wait loop so the orphan falls
through to killProcessTree. Other error codes (ESRCH etc.) still
mean the process is gone.
Reported by Copilot review on PR #1025.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(cli): discover Git Bash via PATH walk for non-default installs
WINDOWS_BASH_CANDIDATES only checks C:\Program Files{,(x86)}\Git, so
users who installed Git for Windows on a different drive (e.g.
D:\Program Files\Git\) hit "Cannot run repo scripts on Windows
without bash" even though Git Bash is available. AO_BASH_PATH is the
documented escape hatch but should not be required.
Add a PATH-walk fallback that finds bash.exe wherever Git's bin dir
sits — Git for Windows adds itself to PATH at install time, so this
covers the typical non-default-drive case without a subprocess or
registry lookup.
Reported by greptile review on PR #1025.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(core): hard-code Windows path separators in findOnPath
Using path.delimiter / path.join in the PATH walker meant Linux CI
ran the test with `:` as the splitter and `/` as the joiner. The unit
test simulates Windows by setting PATH="C:\fake\bin" — on Linux
that splits to ["C", "\fake\bin"] and produces "C/powershell.EXE",
neither of which match the mocked existsSync.
findOnPath is only ever called from resolveWindowsShell, so use `;`
and `\` unconditionally. The runtime data — process.env values,
mocked existsSync — is what's being tested, not host-OS path logic.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(windows): PowerShell repo-script runner + ao-doctor/ao-update.ps1
runRepoScript on Windows now prefers a .ps1 sibling of the requested .sh
script and runs it via pwsh.exe (or bundled powershell.exe as fallback).
Adds ao-doctor.ps1 and ao-update.ps1 as Windows equivalents of the
existing bash scripts.
* test(cli): add missing mockExecSilent hoist in dashboard.test.ts
The findRunningDashboardPidsForWebDir tests reference mockExecSilent
but it was never declared in vi.hoisted, so they crashed with
ReferenceError before any assertion ran. Add the missing hoist and
wire execSilent into the shell.js mock.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(windows): echo projectId in pipe relay messages so MuxProvider routes correctly
MuxProvider keys subscribers under `${projectId}:${id}` when projectId is
provided. The Windows pipe relay was dropping projectId from outbound
messages, so the client routed by id alone and the subscriber bucket
mismatched — leaving the xterm pane blank on
/projects/[id]/sessions/[id].
Echo projectId on every outbound terminal frame (opened/data/exited/error)
so the Windows path matches the Unix tmux relay's behavior.
* test(core): respect TMPDIR in platform defaults test
* fix(runtime): harden dashboard launch shutdown
* fix(windows): scope pipe maps and resolvePipePath by projectId
The Windows pipe relay was project-scoped only on outbound WS frames.
Server-side storage and pipe-path resolution still keyed by bare session
id, so two projects sharing a session id on the same mux connection
would collide on the same socket/buffer entry, and resolvePipePath
returned the first matching project's metadata regardless of caller
intent. Brings the Windows path in line with the Unix subscriptionKey
contract.
- resolvePipePath(sessionId, projectId?, fs?) reads only the caller's
project metadata when projectId is provided; legacy callers keep the
walk-all-projects fallback.
- winPipes / winPipeBuffers keyed by \${projectId}:\${id}.
- projectId threaded through handleWindowsPipeMessage data/resize/close
cast sites.
- Tests cover the project-collision case in both mux-websocket and
tmux-utils.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(windows): clear 10 Windows test failures
Real fix:
- events-db: add closeDb() to release the better-sqlite3 file lock on
activity-events.db. Without it, Windows callers cannot rmSync the AO
base dir while the connection is open. Test teardowns in
test-utils.ts and plugin-integration.test.ts now call closeDb()
before rm to fix 4 EBUSY failures.
Test-only:
- tmux-utils.test.ts: normalize backslashes to forward slashes in two
resolveTmuxSession 'hash-prefix' tests; matches the pattern already
used by sibling tests in the same file.
- dashboard.test.ts, script-runner.test.ts, update-script.test.ts:
add it.skipIf(process.platform === 'win32') to four tests that
assert Unix-specific behavior (lsof cwd matching, posix script
paths, ao-update.sh smoke). Each file already uses the same skip
pattern for sibling tests.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(cli): port-scan fallback + Windows PowerShell branch coverage
start.test.ts: re-add the orphaned-dashboard port-scan test that was
lost during the merge from main (commit 4958512d). When the dashboard
auto-reassigns to port+N because the configured port was busy, ao stop
must walk port+1..port+MAX_PORT_SCAN to find it. Skipped on Windows
because killDashboardOnPort skips the ps cmdline verification there.
script-runner.test.ts: add coverage for the Windows PowerShell branch
in runRepoScript. Two Windows-only tests assert (1) ao-doctor.sh is
rewritten to ao-doctor.ps1 and dispatched via pwsh.exe / powershell.exe
with -NoProfile -NonInteractive -ExecutionPolicy Bypass -File and
forwarded user args, and (2) the rewrite is .sh-suffix-driven, not
blind, so a non-.sh script does not get a .ps1 lookup.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(agent-kimicode): make plugin Windows-compatible
Seven blocking issues prevented kimicode from working on Windows. None
were guarded by isWindows checks because the plugin was authored
without importing it. Symptoms ranged from silent agent launch
failures to misclassified process state to total session-discovery
breakage.
1. getLaunchCommand emitted bare command strings ("kimi --work-dir
...") which PowerShell parses as a quoted expression rather than
executing. Wrap with formatLaunchCommand() so Windows gets the
"& " call operator, matching agent-codex.
2. isProcessRunning called ps -eo on the tmux branch with no platform
guard. ps does not exist on Windows, so a stale tmux handle would
throw and misclassify a live agent as exited. Added the same
isWindows() return-false guard agent-codex uses.
3. resolveWorkspacePath called realpath() unconditionally. On Windows,
Node's realpath silently canonicalizes non-existent paths instead
of throwing ENOENT — turning "/workspace/test" into
"D:\workspace\test" and diverging the session-discovery hash from
any caller that hashed the raw input. Stat first so the catch path
is reached uniformly across platforms.
4. isInsideKimiSessions hardcoded "/" as the path separator in the
sandbox check. realpath returns native paths (backslashes on
Windows) so candReal.startsWith(rootReal + "/") never matched —
every candidate was rejected and findKimiSessionMatch returned
null forever. Use path.sep.
5. getEnvironment set PATH and GH_PATH locally with hardcoded POSIX
values. session-manager already injects both for every agent
plugin, so the local writes were dead code that masked the
Windows-aware central logic. Drop them; mirror agent-codex.
6. getLaunchCommand passed config.systemPromptFile via --agent-file,
but kimi expects --agent-file to be a YAML agent spec, not arbitrary
markdown. AO writes the orchestrator prompt as a plain .md file, so
kimi exited with 'Invalid YAML in agent spec file: expected
<document start>, but found <block sequence start>' on the first
bullet. Read the file synchronously and inline its contents into
--prompt instead, concatenating with any existing config.prompt.
7. session-manager listed kimicode in requiresNativeRestore, so when
getRestoreCommand returned null (because the previous launch failed
before kimi wrote any session data), AO threw
SessionNotRestorableError instead of falling back to a fresh
getLaunchCommand. Removed kimicode from the allowlist; falling back
is the only sensible behavior when there is no session on disk to
resume.
Tests: switched the per-suite workspace constant to a per-test
mkdtemp-scoped path so a coincidental directory at /workspace/test
on the host doesn't make Windows realpath canonicalize it. Mocked
isWindows so platform-aware production code can be exercised
deterministically. Made the shell-escape prompt assertion
platform-aware (POSIX 'backslash-quote' vs PowerShell double-quote).
Replaced the --agent-file tests with system-prompt-content-into-prompt
assertions backed by a real temp file under fakeHome.
Result: all 103 tests pass on Windows (was 30 failures pre-fix).
* fix(agent-claude-code): preserve Windows drive-letter slug encoding
The merge of origin/main #1611 ("fold underscores in Claude project
slug") inadvertently regressed Windows behavior. #1611 kept the
pre-existing `.replace(/:/g, "")` so `C:\Users\dev\foo` slug-encoded
to `C-Users-dev-foo` (single dash), but Windows-side QA had already
established (commit 582c5373) that real Claude Code on Windows
produces `C--Users-dev-foo` — the colon position becomes a dash,
not stripped. Stripping the colon broke JSONL lookup on Windows so
session info / restore / metadata persistence all silently failed.
Two test files disagreed after the merge: activity-detection.test.ts
expected the Windows-correct double-dash form (kept by my merge),
while index.test.ts expected origin's single-dash form (added by
#1611). Linux CI ran activity-detection's case against the
single-dash impl and failed loudly.
Fix: drop the redundant `.replace(/:/g, "")`. The broader
`[^a-zA-Z0-9-]` regex already handles the colon as a dash, which
matches Claude's actual on-disk encoding on Windows. Updated
index.test.ts to expect `C--Users-dev-foo` and fixed an unrelated
local-Windows test bug where a hardcoded POSIX path string was
compared against a `pathJoin` result (passes on Linux CI but fails
locally on Windows).
Underscore folding from #1611 is preserved.
* fix(cli): Windows platform adapter follow-ups
Three independent Windows correctness fixes bundled with their tests:
* daemon.ts: killExistingDaemon now uses killProcessTree (taskkill /T /F)
instead of raw process.kill so detached grandchildren of the daemon
(pty-host, dashboard subprocess) are reached on Windows. POSIX behavior
is preserved via killProcessTree's process-group fallback.
* startup-preflight.ts: on Windows, when the project config selects
runtime: tmux, offer to rewrite the line to runtime: process in the
project YAML instead of prompting "install tmux?". The rewrite is a
targeted line-replace (not yaml round-trip) so comments and quoting
are preserved. Decline -> hard exit with manual-fix guidance.
* path-equality: new pathsEqual / canonicalCompareKey helpers used by
start.ts and resolve-project.ts for "same filesystem entry" checks.
realpathSync on Windows can return canonical paths whose drive-letter
case or 8.3-vs-long-name expansion differs from the input even when
both resolve to the same on-disk entry, which made naive === comparisons
miss and surface as phantom "register this project?" prompts on
re-runs of `ao start <path>`. Lowercases on Windows; POSIX is
unchanged.
Also fixes resolve-project.ts's isLocalPath to recognize Windows path
patterns (drive-letter, UNC, .\, ..\) so `ao start C:\path\to\repo`
takes the path branch instead of being mis-classified as a project id.
Test changes: makeConfig now defaults to runtime: process so tests run
on every platform without tripping the Windows-tmux exit; the one tmux
preflight test pins process.platform = 'linux'. The "kills existing
process" test asserts on killProcessTree instead of process.kill.
On-disk yaml fixtures in start.test.ts switch from runtime: tmux to
runtime: process for the same reason.
New tests: 7 in startup-preflight.test.ts (Windows rewrite, decline
exit, missing configPath exit, comment+quoting preservation, Linux
pass-through), 8 in path-equality.test.ts (drive-letter case, segment
case, POSIX case-sensitivity, realpathSync fallback, ~ expansion),
killProcessTree assertions added to daemon.test.ts.
Verified non-issues during the audit (no code change): ao stop graceful
shutdown gap (the work was already moved into ao stop itself in a prior
refactor; running.json/last-stop/sessions are persisted before the
parent kill, and stale state is self-healing on next read);
better-sqlite3 cross-platform binary (optionalDependencies +
files: ['dist'], no prebuilt .node bundled in any release artifact);
ao-doctor / ao-update PowerShell rewrite (script-runner already
rewrites .sh -> .ps1 on Windows, .ps1 siblings ship in assets/scripts/,
covered by an existing Windows-only test); bun-tmp-janitor leak
(janitor is a no-op on Windows because opencode ships no win32 binary
and Windows refuses to unlink mapped files).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(cursor): silence stderr bleed-through in detect() on Windows
execFileSync("agent", ["--help"]) with encoding but no explicit stdio
inherits stderr from the parent process. On Windows with shell:true,
cmd.exe prints "'agent' is not recognized as an internal or external
command" to the terminal even though the exception is caught.
Fix: add stdio:["ignore","pipe","ignore"] to capture stdout (needed for
Cursor marker checks) and discard stderr. Mirrors the pattern used by
the kimicode plugin's detect(). Also adds a 5s timeout as a safety net.
Zero behavior change on macOS/Linux: shell:false means Node throws ENOENT
directly with no subprocess output, so the try/catch already handles it.
Fixes the spurious error printed during ao start first-run setup on Windows.
Co-authored-by: Priyanchew <57816400+Priyanchew@users.noreply.github.com>
* fix(runtime-process): preserve EPERM in Windows pty-host sweep exit-poll
The catch in sweepWindowsPtyHosts treated every error as "process exited",
including EPERM. On Windows EPERM means the pty-host exists but the caller
lacks permission to signal it (cross-context), so the orphan was skipping
the killProcessTree force-kill step and leaking. Mirror the destroy() logic
at line 290: only flag exited on non-EPERM (typically ESRCH).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* test(runtime-process): poll for payload instead of fixed sleep
The Windows ConPTY round-trip (named pipe -> pty-host -> pwsh -> findstr
-> rolling buffer) varies from hundreds of ms to seconds depending on
runner load, AV scanners, and cold caches. The previous 1500 ms fixed
sleep flaked on slow Windows runners (observed empty getOutput buffer at
sample time). Replace it with a 10 s deadline poll that checks for the
actual payload substring, robust to both timing variance and incidental
shell banners arriving first.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* docs: document cross-platform abstractions and reflect Windows support
Adds docs/CROSS_PLATFORM.md as the canonical reference for cross-platform
development: the "Golden Rule" (no raw process.platform === "win32" — use
isWindows() and the helpers in platform.ts), a full inventory of every
platform helper (platform.ts, path-equality, windows-pty-registry,
pty-client, sweepWindowsPtyHosts, validateSessionId, resolvePipePath,
setupPathWrapperWorkspace, activity-state helpers, AO_SHELL/AO_BASH_PATH),
the EPERM-vs-ESRCH gotcha when probing processes, PowerShell-vs-bash
differences, IPv6 localhost stalls, agent-plugin specifics, and a 10-point
pre-merge checklist.
Updates internal docs (CLAUDE.md, AGENTS.md, docs/ARCHITECTURE.md,
docs/DEVELOPMENT.md, CONTRIBUTING.md, .github/copilot-instructions.md,
.cursor/BUGBOT.md, packages/core/README.md, packages/plugins/runtime-tmux/
README.md, packages/core/src/prompts/orchestrator.md, ARCHITECTURE.md) to
remove tmux-only / POSIX-only claims, point at the new doc, and (in
docs/ARCHITECTURE.md) describe the Windows runtime architecture: pty-host
helper, named-pipe protocol, registry, sweep, mux WS Windows branch.
Updates user-facing docs (README.md, SETUP.md, docs/CLI.md) to split
prerequisites by OS (no tmux on Windows), reflect that ao doctor and
ao update work on Windows, and note that power.preventIdleSleep is a
no-op on Linux and Windows.
Updates the agent-orchestrator skill (skills/agent-orchestrator/SKILL.md
and references/config.md) so it advertises Windows support, drops tmux
from the required-bins list, and gives the right Windows guidance for the
"spawn tmux ENOENT" error.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* test(core): update orchestrator-prompt test for cross-platform runtime warning
The orchestrator system prompt was rewritten in 1d8c8f75 to call out both
tmux send-keys (Unix) and the Windows named-pipe write path so the
orchestrator agent doesn't try either. The test still asserted the old
literal "never use raw \`tmux send-keys\`" string. Update it to assert the
new platform-neutral phrasing plus the presence of both runtime mentions.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* ci(windows): matrix Linux+Windows + close coverage gaps
Adds windows-latest to typecheck/test/test-web matrices (lint stays
Linux-only; nothing ESLint catches differs by OS). fail-fast: false
so one OS's failure never masks the other's. tmux install steps gate
on runner.os == 'Linux' since Windows uses runtime-process. test job
adds a node-pty prebuild smoke step on Windows so a future ABI break
fails fast with a clear message. test-web is broadened from
server/__tests__/ to the full vitest suite — closes a pre-existing
Linux-too gap and ensures component/hook/lib tests run on Windows.
Closes three completeness gaps where Windows code paths existed but
no test exercised them:
1. session.test.ts (5 tests): "tests Windows behavior, skips on
Windows" defensive pattern. Tests fully mock isWindows + net.connect
+ child_process — flipping skipIf(win32) to plain it() runs them on
both OSes. All 45 tests pass on Windows.
2. dashboard.test.ts (+2 tests): findRunningDashboardPidsForWebDir
has parallel POSIX (lsof + cwd verification) and Windows
(findPidByPort, no cwd check) implementations. Existing tests
asserted lsof; new runIf(win32) tests assert findPidByPort path
plus dedup across multiple ports.
3. start.test.ts (+1 test): port-scan fallback for orphaned
dashboards skips ps cmdline verification on Windows by design.
Existing test asserted ps was called; new runIf(win32) parallel
asserts ps was NOT called and the kill still fires.
Adds first PS1 script test coverage (previously zero):
4. update-ps1.test.ts (4 tests): argparse — --help/-h, unknown flag,
conflicting --skip-smoke + --smoke-only.
5. doctor-ps1.test.ts (4 tests): argparse + full check pipeline
smoke. The pipeline test runs every Check-* function against an
empty repo and asserts the script exits cleanly with a "Results: N
PASS, N WARN, N FAIL, N FIXED" summary line — catches PS1 syntax
errors and crashes mid-pipeline.
Net effect: CLI suite went from 622 -> 630 passing tests on Windows
(5 unskipped + 8 new); skipped count dropped from 25 -> 20. All other
suites unchanged.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* ci(windows): add minimal permissions block to CI workflow
CodeQL flagged the workflow as missing an explicit permissions
declaration (security/code-scanning/61). All jobs are read-only
(checkout, install, build, test) — contents: read is sufficient.
Matches the workflow-level pattern already used in coverage.yml.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* chore: add changeset for native Windows support
Minor bump across the linked package group. The next release PR will
consume this and bump from 0.4.0 to 0.5.0.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(cli): make ao open work cross-platform
Mac-only assumptions broke `ao open` on Windows and Linux:
- source of truth was `tmux list-sessions`, which is empty without tmux
- the open action shelled out to `open-iterm-tab`, a macOS helper
Switch the source of truth to `sm.list()` (works on every platform — also
handles `runtime-process` sessions on Windows) and branch the open action:
- macOS: `open-iterm-tab` (unchanged), tmux attach inside iTerm
- Windows: `wt new-tab cmd /k ao session attach <id>` for live sessions,
with `cmd /c start cmd /k ...` as the no-`wt` fallback. Both paths route
through `cmd /k` because `wt` and `start` call CreateProcess directly,
which doesn't honor PATHEXT and reports 0x80070002 for `ao` (really
`ao.cmd`). New tab anchors at `config.projects[id].path` so the spawned
attach can resolve `agent-orchestrator.yaml` via loadConfig's upward
search; without this attach fails with "No agent-orchestrator.yaml found"
when the user's homedir is the inherited cwd.
- Linux: dashboard URL via `openUrl()`. No consistent terminal-spawn API
across DEs, so we don't try.
Other behavior changes:
- read the live daemon's port from `running.json` so URLs stay correct
when the dashboard auto-picked a non-default port
- warn when the daemon is not running (URL fallback won't load)
- aggregate targets (`all`, `<project>`) hide terminated sessions; named
lookup keeps them in scope and opens the dashboard with the death
reason inline (`died at <ts>: session=<reason>, runtime=<reason>`) plus
a `ao session restore <id>` hint
- new `--browser` flag forces the URL path on any platform
Add `isMac()` to `platform.ts` (per the project rule that platform checks
live in one place rather than spread as ad-hoc `process.platform === ...`
guards) and re-export from core.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(cli): satisfy lint on ao open changes
- replace inline `import("node:child_process")` type annotation in vi.mock
with a top-of-file `import type * as ChildProcess` (consistent-type-imports)
- drop the `[]` initializer on `sessionsToOpen` since every branch assigns
before any read (no-useless-assignment)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* chore: add changeset for cross-platform ao open fix
Patch entry for d04fad33 / 32345ba8. Linked group already minor-bumping
via the Windows-support changeset, so this just contributes a distinct
CHANGELOG line.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* chore: fold ao open fix into the Windows-support changeset
Single umbrella entry is the right place for it — the separate patch
changeset was redundant given the linked-group minor bump already in
flight. Reverts 3557e556.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Changes before error encountered
Agent-Logs-Url: https://github.com/ComposioHQ/agent-orchestrator/sessions/9f3caf0b-66fb-4eff-bd3f-7fb9ab889630
Co-authored-by: Priyanchew <57816400+Priyanchew@users.noreply.github.com>
* test(core): mock node:child_process via importOriginal in migration test
The atomic-write.ts → platform.js refactor in eaa27b9b pulled platform.ts
into migration-storage-v2.test.ts's module graph. platform.ts evaluates
promisify(execFile) at top level, but the test's bare-object child_process
mock omitted execFile, so the dynamic import crashed with "No 'execFile'
export is defined on the 'node:child_process' mock".
Switch to vi.doMock with importOriginal so any unmocked exports stay real.
This is robust against future imports adding more child_process surface.
Only Ubuntu CI surfaced the regression — the failing test sits inside
describe.skipIf(process.platform === "win32") so the windows-latest leg
never executed it.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* test(core): hoist child_process type to satisfy consistent-type-imports
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* chore(perf): add AO_PERF-gated instrumentation for dashboard load
Temporary tracing added to diagnose 15-20s dashboard terminal load on Mac
and Windows. Gated on AO_PERF=1 (server) and NEXT_PUBLIC_AO_PERF=1
(client) so production paths stay untouched. To be removed once the
bottleneck is fixed.
Wrap points:
- core/perf.ts: perfMark / perfTime helpers + perfCid
- web /api/sessions/[id]: per-stage timings (getServices, sm.get, audit,
enrichMetadata, total)
- core/session-manager: runtime.isAlive, agent.getActivityState,
agent.getSessionInfo, ensureHandleAndEnrich
- agent-codex: findCodexSessionFile (scanned/opened/matched counts) +
cache hit marker
- runtime-process/pty-client: connect outcome + isAlive (split
connectMs vs statusMs)
- web/lib/serialize: enrich legs (agentSummary vs issueTitle) timed
independently while still running concurrently
- web/sessions/[id]/page.tsx: client.fetch.start/end with cid header
forwarded for end-to-end correlation
- web/MuxProvider: ws.open + ws.firstByte per terminal
* fix(core): drop bogus session.agent reference from perf extras
Session has no `agent` field — typed as a metadata key, not a
top-level property. CI typecheck caught what local rtk-filtered
output had hidden. The session-id cid already disambiguates per-session
so the extra wasn't load-bearing.
* revert: remove AO_PERF instrumentation
Reverts b3f522f9 and 004b2a79. The perf marks pinpointed that the
server API path is fast — total <50ms after warm-up — so the 30s
dashboard-terminal delay lives in the WS / xterm path, not in the
session-manager hot path that this instrumentation covered.
Will re-instrument that layer (mux-websocket terminal-open ->
opened-sent -> firstByte) separately when we resume the investigation.
* fix(core): drop unused isWindows import after post-launch removal
The merge took main's no-op for post-launch prompt delivery, which was
the only user of isWindows() in this file. Removing the dangling import
to unblock lint.
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: yyovil <itsyyovil@gmail.com>
* fix(cli): refuse to spawn when daemon is not polling the project
`ao spawn` and `ao batch-spawn` used to print a stderr warning and then
create the session anyway when the running AO daemon did not include the
target project in its polling set (or when no daemon was running at all).
The resulting sessions got full worktrees and tmux panes but no
lifecycle reactions — CI-failure routing, review comments, revive
transitions, and the event log were silently dead.
Promote the warning to a hard error so sessions are never created in a
state where the lifecycle manager won't run for them. The error message
tells the user which `ao start` invocation will fix it.
Closes#1455
* test(cli): cover batch-spawn daemon-polling enforcement
`spawn` and `batch-spawn` share the `ensureAOPollingProject` helper, but
only `spawn` had tests for the new fail-fast behavior. Add matching
tests for `batch-spawn` so a future refactor that breaks its guard is
caught.
---------
Co-authored-by: Prateek <karnalprateek@gmail.com>
Claude Code's positional [prompt] argument keeps it in interactive mode;
only -p/--print triggers headless one-shot exit. The entire post-launch
polling mechanism was built on the wrong assumption.
Changes:
- Claude Code plugin: pass prompt as positional arg in getLaunchCommand
- Core types: remove promptDelivery field from Agent interface
- Session manager: remove post-launch polling/retry block
- Prompt builder: clarify wording ("title, description, and labels"
instead of "full issue details"), unify # format
- Tracker-github: match prompt wording update
- CLI spawn: remove dead-code promptDelivered warning
Closes#1582
When `ao start` finds an existing daemon and the user picks "Add <dir>",
the project is written to the global config but the dashboard's cached
services (loaded once into globalThis) never see it, so visiting the new
project page renders notFound(). Call notifyProjectChange() after the
write — same pattern as attachAndSpawnOrchestrator — so the dashboard
invalidates its cache before the browser opens.
* chore(release): add changesets for 0.5.0 and bump codex version test
- Add changesets for #1643 (orchestrator worktree adoption), #1549
(sidebar empty-state), and #1608 (terminal attach + mux routing).
- Update agent-codex package-version.test.ts expectation from 0.4.0
to 0.5.0 so the test no longer fails after the upcoming version bump.
* chore: release 0.5.0
---------
Co-authored-by: i-trytoohard <193449657+i-trytoohard@users.noreply.github.com>
* chore(cli): remove deprecated 'ao init' command
Delete the `init` command and its deprecation shim. `ao start` already
auto-creates the config on first run in an unconfigured repo, so the
separate entry point is redundant.
- Remove `packages/cli/src/commands/init.ts` and its test.
- Remove `registerInit` call from the CLI program.
- Drop `createConfigOnly()` from start.ts (only the init shim used it);
export `autoCreateConfig` so the existing default-config test can
invoke it directly.
- Update user-facing "Run `ao init` first" messages in `verify`/`status`
to point to `ao start`.
- Refresh stale `ao init` references in ao-doctor, onboarding test,
openclaw setup doc, and the config/types doc comments.
Closes#1420
* docs: remove ao init website docs
Agent-Logs-Url: https://github.com/ComposioHQ/agent-orchestrator/sessions/41663963-ca49-4673-982f-ca10dee68d31
Co-authored-by: miniMaddy <77185670+miniMaddy@users.noreply.github.com>
---------
Co-authored-by: Prateek <karnalprateek@gmail.com>
Co-authored-by: iamasx <adilshaikh4064@gmail.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: miniMaddy <77185670+miniMaddy@users.noreply.github.com>
* refactor(cli): collapse running/not-running fork via ensureDaemon (PR B.2)
Replaces the three branches that handled "AO is already running" cases in
start.ts (§3.2 URL/path-while-running, §3.3 project-id-while-running,
§3.5 non-human info dump) with a single attach pipeline that runs after
resolveOrCreateProject. The fork between attach and spawn is now a single
post-resolve decision point.
New module packages/cli/src/lib/daemon.ts owns the daemon side of that
fork:
- attachToDaemon(running) -> AttachedDaemon { port, pid,
notifyProjectChange() } — pure handle plus a typed
{ok}|{ok:false,reason} cache-invalidation result, replacing the
open-coded fetch + try/catch that lived in two places.
- killExistingDaemon(running) — SIGTERM -> waitForExit -> SIGKILL ->
unregister, used by the "Restart everything" menu option. Replaces
the inline restart code in §3.4.
resolve-project.ts gains an opt:
- { targetGlobalRegistry?: boolean } — when true, fromUrl and fromPath
register against the global config (the daemon's source of truth)
rather than into a cwd-local one. fromUrl's "register globally"
branch is a near-verbatim move of the §3.2 inline clone+register
block, including the global-registry dedup and the flat-local-config
write that §3.2 deliberately preferred over fromUrl's wrapped yaml.
fromCwdOrId short-circuits straight to the global registry for
project-id args while running.
The new dispatch in start.ts:
- Running + non-human + (no arg | URL | path) -> info dump, exit 0
(preserved §3.5 behavior; project-id args still fall through to
attach+spawn so automation can `ao start <id>` against a live
daemon).
- Running + human + no arg -> menu (preserved §3.4: open / quit /
add / new / restart). "restart" now routes through
killExistingDaemon and falls through to the spawn path; "new" sets
startNewOrchestrator and falls through to the attach path.
- resolveOrCreateProject(arg, deps, { targetGlobalRegistry: !!running })
- Running -> attachAndSpawnOrchestrator helper (§3.2 short-circuit
preserved: URL/path arg whose project is already in
running.projects skips the orchestrator-spawn and just opens the
dashboard).
- Not running -> existing runStartup + register + handlers.
attachAndSpawnOrchestrator unifies the §3.2/§3.3 messaging behind a
single justCreated discriminator:
- justCreated=true (URL clone or path register): "Spawning
orchestrator session..." -> "Project '...' registered in the global
config." -> "Orchestrator session ready: ..."
- justCreated=false (project id or already-registered path):
"Attaching to running AO instance..." -> "Orchestrator session
ready: ..." -> "Project '...' reattached to running daemon (PID
...)"
Both flows then notifyProjectChange (warns on failure, never throws —
the dashboard might be down), print the lifecycle-attach notice when
the project isn't yet supervised, and either openUrl (human) or print
the URL (non-human).
Subsumes the B.1 follow-up (migrate §3.2 inline clone+register to share
fromUrl): the inline block is deleted outright by the fork collapse and
fromUrl now owns both the not-running and the global-registry cases.
start.ts: -1126 / +131 lines. New daemon.ts: 105 lines. resolve-project.ts:
+167 lines (the global-registry branch + a moved-from-start.ts
detectClonedRepoDefaultBranch helper).
No behavior change. Test count and failure set are identical to
upstream/main; the 8 stop-command failures pre-exist on fad75b63.
8 new daemon.test.ts tests cover attachToDaemon (port/pid wiring,
notifyProjectChange success / non-2xx / fetch-throws) and
killExistingDaemon (SIGTERM happy path, SIGKILL escalation, throw on
both-fail, ESRCH-on-already-dead).
Step 2 of PR B in ao-118's start.ts refactor plan
(~/.ao/agent-orchestrator/ao-118/plan.md). startNewOrchestrator and the
§3.4 menu options remain intact — those land in PR B.3.
* fix(cli): canonicalize paths and guard reload in fromPath global branch
Two review fixes on resolve-project.ts:
1. Restore realpathSync canonicalization in fromPath's global-registry
branch. The original §3.2 inline block in start.ts canonicalized both
sides before comparing, so an `ao start /tmp/foo` against a daemon
whose global config stored /private/tmp/foo (macOS symlink) would
dedupe correctly. The B.2 collapse used plain resolve() and would
miss the match, calling addProjectToConfig and double-registering
the project. New canonicalize() helper mirrors the elsewhere-used
try-realpathSync-fallback pattern.
2. Add the missing null guard on reloaded.projects[addedId] in the same
branch, matching fromUrlIntoGlobal's existing guard. addProjectToConfig
could persist nothing on a write-permission error that doesn't throw,
in which case the undefined project would propagate into
generateOrchestratorPrompt with a useless stack trace; an explicit
"Failed to register" error is what the URL branch already raises.
* fix(cli): scope daemon test spy and correct add-menu comment
Two review fixes:
1. Move the process.kill spy in daemon.test.ts inside beforeEach +
afterEach (vi.restoreAllMocks). The previous module-scope spy could
leak into sibling test files when Vitest reuses worker threads,
silently mocking process.kill in unrelated suites and producing
confusing failures.
2. Rewrite the misleading comment on the "add" menu branch in start.ts.
The previous wording claimed the path "intentionally does not register
globally", but loadConfig() walks up from cwd and returns the global
config as a canonical fallback — so addProjectToConfig may register
globally in that common case. The new comment honestly describes the
canonical-aware behavior and the intentional skip of orchestrator
spawn (the "add" choice is distinct from "new").
* feat(core): add PreflightContext + optional preflight() to plugin interfaces
Foundation for PR 2 of the ao spawn refactor: lets plugins own their own
prerequisites instead of the CLI hardcoding 'if runtime === tmux check
tmux' / 'if tracker === github check gh auth' switches.
PreflightContext describes intent (willClaimExistingPR, role) rather
than CLI flag names, so plugins never learn about flags. New flags map
to new intent fields only when a plugin actually needs them.
Adds preflight?(ctx) as an optional method on Runtime, Agent, Workspace,
Tracker, SCM. Backwards-compatible: existing plugins keep working
unchanged. Subsequent commits move checkTmux into runtime-tmux and
checkGhAuth into the github plugins, then update spawn.ts to iterate
selected plugins instead of switching on plugin names.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(plugins): implement preflight() in runtime-tmux + tracker-github + scm-github
Each plugin now owns its own prerequisite checks (tmux binary, gh auth)
behind the optional PluginModule preflight() contract added in the
previous commit. The CLI no longer needs to know which plugin needs
which tool — it just iterates the selected plugins.
- runtime-tmux: checks 'tmux -V' and throws with platform-appropriate
install hint (brew / apt / dnf / WSL)
- tracker-github: checks 'gh --version' and 'gh auth status'
unconditionally (tracker is exercised on every spawn that has an
issueId AND on lifecycle polling for issue closure)
- scm-github: same gh auth checks but only when the spawn will exercise
PR-write paths — gates on context.intent.willClaimExistingPR
Subsequent commit refactors the CLI to iterate plugins instead of
hardcoded 'if runtime === tmux' switches.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* refactor(cli): make ao spawn iterate plugin preflight, collapse project resolution
Three small changes bundled because they all touch spawn.ts:
1. Plugin-iterating preflight: replaces the hardcoded
'if runtime === tmux check tmux' / 'if tracker === github check gh
auth' switches in runSpawnPreflight with a 4-line loop that walks the
selected plugins and calls each one's optional preflight(). Plugin
internals are no longer leaked into the CLI; new plugins only need to
declare their own preflight.
2. Project-resolution collapse: the prefix/no-prefix and issue/no-issue
paths previously had three near-duplicate code blocks each with its
own try/catch around autoDetectProject. Replaced by one
resolveProjectAndIssue() helper that uses resolveSpawnTarget's
fallback parameter — caller wraps in a single try/catch.
3. Micro-deletes: drop the unused 'return session.id' in spawnSession
(callers already ignore it; the SESSION=<id> stdout line is the
scriptable contract). Drop checkTmux/checkGhAuth from lib/preflight.ts
(now in their respective plugins) along with their orphaned tests.
LOC: roughly net-zero. Wins are structural — adding runtime-podman /
tracker-jira / scm-bitbucket no longer requires editing spawn.ts.
Pre-existing start.test.ts 'stop command' failures are unrelated (verified
on upstream/main bare).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* perf(plugins): dedupe gh-auth check across tracker-github + scm-github
Address greptile P2 on PR #1622: when a project has both tracker:
github and scm: github with --claim-pr, both plugin preflights ran
'gh --version' + 'gh auth status' independently — 4 execs where 2
suffice, and two identical error messages on failure.
Add memoizeAsync(key, fn) to core (process-scoped Promise cache) and
have both github plugins share the key 'gh-cli-auth'. Second caller
hits the in-flight (or resolved) promise — zero extra subprocess
overhead, one error on failure.
Caches both successes and rejections: failed checks should never
re-run within a process (cache dies with the CLI, user fixes the
underlying issue and re-invokes).
5 unit tests for memoizeAsync covering: single-fire dedup, value
identity, distinct keys, rejection caching, concurrent in-flight dedup.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* refactor(spawn): collect-all preflight + per-plugin tests + key-namespacing docs
Address self-review feedback on PR #1622:
1. **Collect-all preflight** (spawn.ts): runSpawnPreflight previously
aborted at the first plugin's failure, so a user with multiple broken
prereqs (tmux missing AND gh logged out) had to fix-and-retry to
discover the second one. Now collects every plugin's error and
reports them together ("2 preflight checks failed:\n 1. ...\n
2. ..."). Single-failure path is unchanged — that error throws as-is
without the wrapper. Test added: 'collects every plugin's preflight
failure into one combined error'.
2. **Drop redundant workspace literal fallback** (spawn.ts):
DefaultPluginsSchema in core/config.ts applies .default("worktree")
to workspace, same as runtime/agent. The literal '?? "worktree"'
was asymmetric defensive theater — dropped to match the runtime/agent
form.
3. **memoizeAsync key-namespacing convention** (process-cache.ts):
Added a JSDoc section documenting that two callers using the same
key get shared state (intentional for cross-cutting checks like
gh-cli-auth, dangerous for plugin-internal caching). Recommends
namespacing plugin-internal keys as 'plugin-name:thing'.
4. **Per-plugin preflight unit tests**:
- runtime-tmux: tmux-present resolves; tmux-missing throws with
platform-specific install hint (verified per-platform branch)
- tracker-github: happy path, gh-not-installed, gh-not-authenticated
- scm-github: no-op when willClaimExistingPR=false (zero gh calls),
full check when true, plus install/auth failure branches
Process cache cleared in beforeEach so each test starts fresh.
Required exporting _clearProcessCacheForTests from core/index.ts
(matches existing _testUtils pattern in gh-trace.ts).
Pre-existing start.test.ts 'stop command' failures unchanged
(verified on bare upstream/main).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(test): collapse duplicate @aoagents/ao-core import in tracker-github test
eslint no-duplicate-imports caught it on CI — combined the value and
type-only imports into one statement.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Replaces the per-arg-shape dispatch that lived inline in start.ts (URL,
path, project id, no arg) with a single resolveOrCreateProject(arg, deps)
call. The new module dispatches internally to fromUrl/fromPath/fromCwdOrId
helpers and returns a uniform { config, projectId, project, source,
justCreated, parsed? } shape.
Behavior preserved exactly — fromUrl is a near-verbatim move of
handleUrlStart's body (which is now deleted as it had no other callers),
fromPath mirrors the path branch's loadConfig/autoCreate/addProject
cascade, and fromCwdOrId carries over the registerFlatConfig recovery
path with the same semantics.
Dependencies that live in start.ts (addProjectToConfig, autoCreateConfig,
resolveProject, resolveProjectByRepo, registerFlatConfig, cloneRepo) are
passed in via a ResolveDeps object. This keeps the new module decoupled
from start.ts's other concerns (interactive prompts, agent detection,
project type detection) without creating a circular import.
Scope note: the "AO is already running + URL/path arg" branch in start.ts
still has its own inline clone+register block. That block deliberately
diverged from handleUrlStart (it writes a flat local config, not the
legacy wrapped one) — migrating it to share fromUrl is a small follow-up
before PR B.2 collapses the running-vs-not-running fork.
Step 1 of PR B in the start.ts refactor plan
(~/.ao/agent-orchestrator/ao-118/plan.md). Net diff: start.ts shrinks by
~146 lines, new file adds 294. Test count and failure set are identical
to upstream/main.
* chore: release 0.4.0
Consume 33 changesets across the linked package group. All public
packages bumped to 0.4.0 and published to npm.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(agent-codex): bump package-version assertion to 0.4.0
Release gate test was still asserting 0.3.0 after the 0.4.0 bump.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: align CHANGELOG headers with @aoagents npm scope
The H1 of every package CHANGELOG.md still read @composio/* from
before the npm scope rename. Body entries that historically reference
@composio/* are left intact — they document what was true at the time
of those releases.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Prateek <karnalprateek@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(cli): extract runtime preflight and install helpers from start.ts
Pulls scattered preflight logic out of start.ts into two focused modules:
- lib/install-helpers.ts: shared install primitives (askYesNo,
runInteractiveCommand, tryInstallWithAttempts, genericInstallHints,
canPromptForInstall, InstallAttempt) — used by ensureGit/ensureTmux,
the agent runtime installer, and the optional gh install path.
- lib/startup-preflight.ts: the runtime checks themselves (ensureGit,
ensureTmux, warnAboutLegacyStorage, warnAboutOpenClawStatus) plus a
top-level runtimePreflight(config) that orchestrates tools + state
warnings + idle-sleep + OpenClaw credentials. Distinct from the
existing lib/preflight.ts, which validates dashboard build artifacts.
start.ts now calls runtimePreflight(config) once at the top of
runStartup instead of inlining 32 lines of orchestration; ensureGit is
imported for the three callsites that still need it directly (URL
clone, addProjectToConfig, URL-while-running branch).
No behavior change. Test count and failure set are identical to
upstream/main (8 pre-existing failures in the stop command tests).
Step 1 of PR A in the start.ts refactor plan
(~/.ao/agent-orchestrator/ao-118/plan.md). start.ts shrinks by 301
lines; net +55 LOC across the touched files (the abstractions cost
some interface overhead, as expected).
* refactor(cli): extract shutdown handler from start.ts
Moves the SIGINT/SIGTERM handler out of runStartup's closure into
lib/shutdown.ts as installShutdownHandlers({ configPath, projectId }).
The handler logic is identical: 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 with the right code (130 for SIGINT, 0 for SIGTERM).
Also drops three now-unused start.ts imports (stopProjectSupervisor,
stopAllLifecycleWorkers, stopBunTmpJanitor) — they're consumed inside
the new shutdown module.
Note: the equivalent kill-and-record loop in `ao stop` is left
untouched. It has different verbosity (spinner, warnings, per-project
output) and different options (--purge-session) than the signal
handler. Unifying them is a behavior-shaping change that belongs with
the daemon/stop restructure in PR B, not this mechanical extraction.
Step 2 of PR A in the start.ts refactor plan
(~/.ao/agent-orchestrator/ao-118/plan.md). Test count unchanged (8
pre-existing stop-command failures from upstream/main, same set).
* refactor(cli): address PR review — idempotent shutdown + correct legacy count
Two P2 review comments from greptile-apps[bot]:
1. shutdown.ts: the 'idempotent' JSDoc claim was unbacked — shuttingDown
was a per-invocation closure, so calling installShutdownHandlers
twice would register duplicate listeners. Add a module-level
handlersInstalled guard and hoist shuttingDown to module scope so
the abstraction matches its docstring.
2. startup-preflight.ts: warnAboutLegacyStorage gates on the count of
non-empty hash dirs but printed hashDirs.length (total). Pre-existing
bug in the original start.ts code — a user with mostly-empty hash
dirs would see an inflated migration count. Renamed sessionCount →
nonEmptyDirCount (the variable always counted dirs, not sessions)
and used it in the message.
* feat: add SQLite-backed activity event logging layer
Implements a structured diagnostic event trail for the orchestrator.
When unexpected behavior occurs (stuck sessions, silent CI failures,
missed PR transitions), operators can now reconstruct timelines with
`ao events` rather than guessing from logs.
Key design decisions driven by Codex review:
- FTS5 external-content table uses INSERT/DELETE triggers so search
actually works without manual rebuild
- ts_epoch (epoch ms) used for all time comparisons to avoid text vs
SQLite datetime() ambiguity near cutoff
- PRAGMA busy_timeout=3000 handles WAL lock contention across
CLI/lifecycle/web processes
- user_version schema versioning for future migrations
- EventType renamed to ActivityEventKind to avoid collision with
existing types.ts export
- Event names match existing vocabulary (ci.failing, review.pending)
- ActivityStateCache (Map) tracks previous activity state so
lifecycle-manager can emit activity.transition diffs
- session.spawn_failed captures failed spawns via wrapper try/catch
- better-sqlite3 in optionalDependencies: AO keeps working if native
build fails; getDb() returns null and writes become no-ops
New files:
- packages/core/src/events-db.ts — lazy DB init, WAL, schema+triggers
- packages/core/src/activity-events.ts — write API, sanitizer
- packages/core/src/query-activity-events.ts — query + FTS + stats
- packages/cli/src/commands/events.ts — `ao events list/search/stats`
Wired into:
- lifecycle-manager.ts: lifecycle.transition + activity.transition
- session-manager.ts: session.spawned, session.spawn_failed, session.killed
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(activity-events): address PR review comments
- redactValue: remove value-string regex check that silently erased
error messages mentioning auth terms (key-based redaction is sufficient)
- sanitizeData: reject payloads >16KB instead of slicing (sliced JSON
is malformed and corrupts JSON output)
- lifecycle-manager: prune activityStateCache alongside states in the
per-poll stale-entry cleanup loop (prevents unbounded growth)
- events CLI: warn on unrecognised --since duration format instead of
silently applying no time filter
- searchActivityEvents: accept optional projectId and push it into SQL
WHERE clause instead of post-LIMIT in-memory filter
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(activity-events): review pass fixes
- formatRow: pad level string before chalk-wrapping so ANSI codes
don't corrupt column alignment in ao events output
- events-db: add PRAGMA synchronous=NORMAL (WAL+NORMAL is standard
recommendation; avoids per-write fsync in the poll hot path)
- events-db: emit console.warn when _dbFailed is set so operators know
events are being dropped instead of failing silently forever
- activity-events: remove dead redactValue wrapper (was a no-op after
the previous fix; call site now directly assigns the value)
- activity-events: remove unused session.cleanup from ActivityEventKind
(no call site emitted it; dead API surface)
- query-activity-events: add optional limit param to searchActivityEvents
(default 100, max 1000, parameterized); add --limit flag to ao events search
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(activity-events): widen source/kind to allow plugin-defined values
ActivityEventInput.source and .kind accept ActivityEventSource|string
and ActivityEventKind|string respectively, so new event sources (e.g.
scm-github, notifier-slack) don't require editing core types.
The named union members are preserved for IDE autocomplete on known values.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(events): level column alignment, filter kind widening, negative limit guard
- events.ts: padEnd(5) → padEnd(9) so level column aligns with the 9-char LEVEL header
- query-activity-events.ts: ActivityEventFilter.kind widened to ActivityEventKind|string
for consistency with ActivityEventInput.kind (plugin-defined kinds can now be queried)
- query-activity-events.ts: negative/NaN limit values sanitized with Number.isFinite +
Math.max(1,...) to prevent SQLite LIMIT -N returning the full table
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(events): FTS alias, credential URL sanitization, periodic retention
- query-activity-events.ts: use full table name in MATCH (activity_events_fts
MATCH ?) instead of alias to avoid 'no such column: fts' in some FTS5 builds
- activity-events.ts: redact https://token@host URL credentials in string values;
add hourly retention sweep so long-lived processes don't grow DB indefinitely
- events-fts-integration.test.ts: real SQLite integration test for FTS5 search,
projectId filter, and epoch-based time filter
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(lint): remove inline import() type annotations in integration test
ESLint @typescript-eslint/consistent-type-imports forbids import() in type
positions — replaced with any to keep the test working without the type imports.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(events): parse data to structured JSON in --json output; add test proving value sanitizer correctness
- ao events list/search --json now parses ActivityEvent.data from JSON
string back to a structured object, making it jq-friendly without
requiring double-parsing by callers (P1 finding from PR review)
- Add test "preserves error messages that mention sensitive words in
values" to refute Greptile's false-positive P1 finding: SENSITIVE_KEY_RE
only matches key names, not string values; "token expired" and
"authorization header missing" values are preserved correctly
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(events): BM25 relevance ordering for FTS search; add versioned JSON envelope
Search now orders by FTS5 rank (BM25) instead of ts_epoch, returning most relevant
events first. --json output now wraps events in { version, query, meta, events }
for stable CLI contracts.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(events): quote FTS tokens to prevent false negatives on operator-like terms
Searching for words like "OR", "AND", or "NOT" would produce empty results
because SQLite FTS5 treated them as operators after token joining. Wrapping
each token in double quotes forces literal phrase matching.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(tests): update FTS assertion for quoted tokens; raise timeout on slow audit test
query-activity-events test expected the old unquoted join format; update to
match the quoted form added in the previous commit. The agent-report audit
trail test occasionally exceeds the 5 s default on CI runners; raise to 15 s.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(qa): ISSUE-001 - type events stats entries
* test(events): cover session and lifecycle event call sites
* test(core): wait for lifecycle branch adoption
* chore: add activity events changeset
* fix activity event review feedback
* batch prune old activity events
* record activity event when spawn starts
* Fix activity event FTS rebuild and sanitization guard
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* Initial plan
* chore: bump all workspace package versions from 0.2.5 to 0.3.0
Agent-Logs-Url: https://github.com/ComposioHQ/agent-orchestrator/sessions/da5b2769-e7d4-4d08-a60c-bd5f695d1ca7
Co-authored-by: harshitsinghbhandari <212377671+harshitsinghbhandari@users.noreply.github.com>
* fix: update package-version test to expect 0.3.0
Agent-Logs-Url: https://github.com/ComposioHQ/agent-orchestrator/sessions/ad61e33e-417f-4482-b06c-0b60826b7f2d
Co-authored-by: harshitsinghbhandari <212377671+harshitsinghbhandari@users.noreply.github.com>
* chore: revert non-ao version bumps
Only @aoagents/ao drives the 'ao update available' prompt
(packages/cli/src/lib/update-check.ts compares against the
@aoagents/ao registry version and reads the local @aoagents/ao
package.json). All other workspace bumps are unnecessary.
* chore: align workspace versions with npm registry
Catch up source-of-truth package.json versions to what is already
published on npm. The registry reflects releases done via Changesets;
the in-tree files had drifted to 0.2.5.
0.2.5 -> 0.3.0: cli, core, web, agent-aider, agent-claude-code,
agent-codex, agent-opencode, notifier-composio,
notifier-desktop, notifier-slack, notifier-webhook,
runtime-process, runtime-tmux, scm-github,
terminal-iterm2, terminal-web, tracker-github,
tracker-linear, workspace-clone, workspace-worktree
0.2.5 -> 0.2.6: notifier-discord, notifier-openclaw, scm-gitlab,
tracker-gitlab
0.1.0 -> 0.1.1: agent-cursor
Also updates agent-codex package-version.test.ts to expect 0.3.0.
* test(cli): use future version in update-check cache test
The cache-fresh test assumed getCurrentVersion() returned a value
older than the cached latestVersion. With packages/ao now at 0.3.0
and resolvable from cli via pnpm's hoisted store at test time,
getCurrentVersion() returns 0.3.0, so isOutdated against a cached
latestVersion of 0.3.0 is false and the assertion fails.
Use 99.0.0 in the cache so the comparison stays meaningful regardless
of the current installed version.
---------
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: harshitsinghbhandari <212377671+harshitsinghbhandari@users.noreply.github.com>
Co-authored-by: harshitsinghbhandari <claudeagain@pkarnal.com>
* fix: serialize ao start and stop numbered orchestrators (#1306)
* fix: restore dead orchestrators on start (#1306)
* fix: harden startup lock handling (#1306)
* feat(core): enrich events with PR title, description, and URL
Adds PR and issue context to all event payloads sent to notifiers.
External consumers (Telegram, Discord, n8n) can now display meaningful
information without making additional API calls.
Changes:
- Add buildEventContext() helper to extract PR/issue context from session
- Enrich all createEvent() calls with context data (pr, issueId, issueTitle, branch)
- Store issueTitle in session metadata during spawn
- Add issueTitle field to SessionMetadata interface
- Update executeReaction() to accept session for context access
- Add tests for event enrichment
The context includes:
- pr: { url, title, number, branch } when PR exists
- issueId: issue identifier
- issueTitle: issue title (from tracker during spawn)
- branch: session branch name
Events before PR creation gracefully omit PR fields (pr: null).
Existing webhook consumers that ignore unknown fields are unaffected.
Closes#1226
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix(core): address review comments for event enrichment
- Add issueTitle to readMetadata/writeMetadata for proper persistence
- Create ReactionSessionContext type for type-safe system events
- Replace unsafe `as unknown as Session` cast with proper union type
- Add end-to-end test verifying issueTitle persistence during spawn
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix(cli): include lock file path in startup lock error message
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: address review feedback — fd safety, kill-all resilience, issueTitle restore
- Restore try/catch/finally in tryAcquire for fd leak prevention
- Wrap stop command's sm.list()+kill in try/catch so dashboard shutdown
always runs even on session listing failure
- Add per-iteration error handling in kill-all loop with partial failure
reporting (spinner.warn for mixed results)
- Unify allSessionPrefixes derivation between start and stop commands
- Propagate issueTitle through archive restore path
- Add clarifying comments on agentInfo.summary fallback and intentional
prNumber/prUrl duplication in event data
- Add ora warn mock for stop tests
- Update changeset to minor (event enrichment is a feature) and add CLI
changeset for stop resilience
- Add tests for kill-all error mid-loop and issueTitle archive restore
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: address harsh-batheja review — title/summary split, lock grace, context namespace
- Separate PR title from agent summary in EventContext: title is null
until enrichment cache populates; summary is a distinct field so
webhook consumers never confuse the task summary for a PR title.
- Restore UNPARSEABLE_LOCK_GRACE_MS (5s mtime grace) and
isStaleUnparseableLock lost during merge conflict resolution —
prevents lockfile-steal race when process A just created the file
but hasn't written metadata yet.
- Fix orchestrator sort: extract numeric suffix instead of
localeCompare so -10 sorts after -2, not before.
- Namespace context under data.context instead of spreading into data
to prevent field collisions with reaction-specific keys.
- Add schemaVersion: 2 to all enriched events so consumers can
migrate away from top-level prNumber/prUrl (kept for compat,
marked for removal in v3).
- Update event enrichment tests for nested context structure.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: resolve merge conflicts — use formatReviewCommentsMessage, fix batch enrichment mock
- Replace formatAutomatedCommentsMessage with upstream's formatReviewCommentsMessage
for automated review comment dispatch (fixes type mismatch with ReviewComment[])
- Make createMockSCM's enrichSessionsPRBatch dynamically resolve from individual
method mocks so test overrides (e.g. getPRState("closed")) propagate correctly
- Add explicit enrichSessionsPRBatch to merge-conflict-tracking test to avoid
unexpected getMergeability calls from the dynamic mock
- Remove all debug console.log statements added during troubleshooting
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: wire up maybeDispatchCIFailureDetails and record dispatch hash on transition
The function was defined but never called after merge conflict resolution
dropped the call site. Added it back to the Promise.allSettled alongside
maybeDispatchReviewBacklog and maybeDispatchMergeConflicts.
Also updated the transition-reaction early-return to record the dispatch
hash, since the transition path now enriches the CI message with detailed
check info from the batch cache — preventing duplicate sends on subsequent
polls.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* test: remove stale duplicate test from rebase
Removes the orphaned numbered-orchestrator restoration test left over
from feat/1226 history. Upstream's canonical model test (same name,
expects "app-orchestrator" via ensureOrchestrator) supersedes it.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* docs: remove misleading CLI CHANGELOG entry
The "Restore the most recently active dead orchestrator on ao start"
entry described upstream's ensureOrchestrator behavior (#1487), not
work contributed by this PR.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* docs(changeset): correct scope and drop stale CLI changeset
- Rewrite the @aoagents/ao-core changeset to describe only what feat/1226
contributes: event enrichment with schemaVersion: 2, issueTitle
persistence, executeReaction refactor, maybeDispatchCIFailureDetails,
and bugbot-comments enrichment. Drop the false claims about adding
spawn-target and format-automated-comments (those modules came from
upstream PRs #1330 and #1334).
- Delete stop-kill-all-resilience.md — its claims (kill-all loop,
fd-safety in tryAcquire, allSessionPrefixes unify) are no longer
in the branch after the rebase took upstream's canonical stop logic.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
* feat(plugin): add kimicode agent plugin
Add @aoagents/ao-plugin-agent-kimicode implementing the Agent interface
for MoonshotAI's Kimi Code CLI. Follows the AO activity JSONL + PATH
wrapper pattern established by agent-aider/opencode, with a native-ish
signal sourced from ~/.kimi/<session>/ mtimes when present.
- Full Agent interface: getLaunchCommand (--yolo, --model, --agent-file),
getEnvironment (AO_SESSION_ID + ~/.ao/bin PATH + GH_PATH), detectActivity,
getActivityState (5-step cascade with mandatory JSONL entry fallback),
isProcessRunning (tmux TTY + PID signal-0, matches `.kimi`/`uv run kimi`),
getSessionInfo (state.json parsing), getRestoreCommand (--resume <id>
with --continue fallback), setupWorkspaceHooks, postLaunchSetup,
recordActivity, detect().
- Post-launch prompt delivery — kimi's `-p` implicitly enables --print and
exits, which would break interactive supervised sessions.
- 58 unit tests covering all 7 mandatory getActivityState cases plus
manifest, launch, env, prompt classification, process detection,
session info extraction, restore command, and detect().
- Register in cli/src/lib/plugins.ts, detect-agent.ts, plugin-registry.json,
cli package deps, and update user-facing docs / yaml examples.
Closes#1384
* fix(plugin): register kimicode in core BUILTIN_PLUGINS and web services
The CLI-side registration in packages/cli/src/lib/plugins.ts only covers
`getAgentByName` callers. Code paths that go through the shared plugin
registry (session-manager, doctor, plugin, verify CLI commands, and the
web dashboard's services singleton) use `createPluginRegistry()` +
`loadBuiltins()` / explicit `register()`, which bypass the CLI map.
Without this wiring:
- `pnpm ao doctor` / `ao plugin` / `ao verify` wouldn't see kimicode
- Web dashboard would fail to render sessions with `agent: kimicode`
because the webpack-bundled services.ts couldn't resolve the plugin
Add kimicode to:
- packages/core/src/plugin-registry.ts BUILTIN_PLUGINS
- packages/web/package.json dependencies
- packages/web/src/lib/services.ts static imports + register call
Caught while comparing against #1395 (kimi-2-6-code plugin), which added
the same registry entry.
* fix(plugin-kimicode): address review feedback
Critical (from @harshitsinghbhandari, verified against kimi-cli source):
- Remove `promptDelivery: "post-launch"` — `-p`/`--prompt` is just a prompt
string alias (also `--command`/`-c`), NOT a mode switch. The non-interactive
flag is `--print`, which we never set. Inline delivery via `--prompt` is
reliable and avoids the post-launch sendMessage() delay.
- Drop unchecked `as string` casts in getRestoreCommand in favor of typeof
guards + `?? undefined` so null model values don't silently leak.
Medium (performance):
- Add 30s per-workspace cache to findKimiSessionMatch (mirrors codex's
SESSION_FILE_CACHE_TTL_MS) so the ~/.kimi/ scan doesn't run 12×/min per
active session. Cache keyed by workspacePath; cleared via the new
`_resetSessionMatchCache` test-only export between test cases.
Minor (correctness):
- Collapse findKimiSessionDir + readKimiSessionState into one
findKimiSessionMatch that returns {dir, state} from a single state.json
read. Previously the file was parsed twice per getSessionInfo /
getRestoreCommand call.
- Wire config.subagent → `kimi --agent <name>` (default / okabe / custom).
- Tighten detectActivity patterns so "I approve of this approach" and
"Earlier I failed to connect" no longer falsely trigger waiting_input /
blocked. Regexes are now line-anchored with `^`/`$` + `\b` word boundaries.
Tests: 58 → 71 (all green). New cases cover:
- Native-signal ready/idle decay (previously only active was tested)
- Cascade ordering: JSONL waiting_input wins over a matching native signal
- Malformed state.json in both getSessionInfo and getRestoreCommand
- `work_dir` alias accepted in addition to `cwd`
- project.agentConfig.model preferred over state.json's recorded model
- False-positive narration guards for both regex tightenings
* refactor(plugin-kimicode): clean up after second-round review
All changes are non-behavioral perf/style cleanups flagged during my second
review pass — no user-visible changes.
- Consolidate double JSON.parse in findKimiSessionMatchUncached: the previous
pass parsed each candidate state.json once to extract cwd and a second time
to extract session_id/model/title. Replaced both helpers with a single
`parseKimiState(raw)` that returns all four fields in one traversal.
- Carry state.json's mtime through KimiSessionMatch so getKimiLiveSignalMtime
(renamed from getKimiSessionMtime) doesn't re-stat state.json — the winner's
mtime was already captured during the scan. Live-signal probe is now limited
to context.jsonl + wire.jsonl (the per-turn files) and runs them in parallel
via Promise.all instead of sequential awaits.
- Fold state.json mtime and the live-signal mtime into a single "freshest"
timestamp in getActivityState so a recently-written context.jsonl wins even
when state.json is stale.
- Tighten appendApprovalFlags signature: `string | undefined` → proper
`AgentPermissionInput | undefined` so typos at call sites fail at compile
time.
- Stricter detect(): don't trust every binary named `kimi` — verify the
--version output mentions kimi/kimi-cli/kimi-code, and fall back to
`kimi info` for builds that print a bare version number. Rejects unrelated
tools that happen to install a `kimi` binary.
Tests: 71 → 75. New coverage:
- detect() accepts kimi-cli vendor strings
- detect() falls back to `kimi info` when --version is ambiguous
- detect() rejects an unrelated `kimi` binary
- Native signal picks the fresher of state.json vs context.jsonl mtimes
* fix(plugin-kimicode): correct session layout discovered via smoke test
Installing kimi-cli 1.38.0 locally (\`uv tool install kimi-cli\`) and running
it once revealed the plugin's session-discovery logic was built on wrong
assumptions about the on-disk layout.
Observed layout (kimi-cli 1.38.0):
~/.kimi/sessions/<md5(cwd)>/<session-uuid>/
context.jsonl — conversation history
wire.jsonl — turn events (TurnBegin/TurnEnd with user_input payload)
Differences from my original assumptions:
- Sessions are nested under \`sessions/\` (not direct subdirectories of
\`~/.kimi/\`).
- The workspace is identified by an MD5 hash of the absolute path, not by
a \`cwd\` field stored in a state file.
- There is no \`state.json\`. No \`title\`, \`model\`, or \`cost\` is persisted.
- The session ID is the UUID directory name and is accepted as-is by
\`kimi --resume <uuid>\`.
- The old \`--continue\` fallback is unnecessary — if we found the directory,
we always know its UUID.
Fixes:
- \`findKimiSessionMatch\` now computes \`md5(workspacePath)\` with node:crypto
and lists \`~/.kimi/sessions/<hash>/\` directly. No more full-tree scan of
\`~/.kimi/\`, no more \`readFile\` of a fictional \`state.json\`.
- \`getKimiLiveSignalMtime\` keeps the parallel \`Promise.all\` stat of
context.jsonl + wire.jsonl (the only files that exist).
- \`getSessionInfo\` streams the first \`TurnBegin\` out of wire.jsonl as a
best-effort summary, with a 1 MB byte ceiling. agentSessionId is the UUID.
- \`getRestoreCommand\` drops the \`--continue\` fallback branch — a found dir
always has a usable UUID.
Verified end-to-end against the real kimi-cli 1.38 binary on this machine:
- \`detect()\` → true
- \`getLaunchCommand\` output parses cleanly when run with \`--help\`
- \`getSessionInfo\` extracts the actual first user prompt ("say hello")
- \`getRestoreCommand\` produces the same UUID kimi itself prints as the
resume hint: \`kimi -r 6ec34626-aedf-4659-a061-c5fbfa4cf166\`
Tests remain at 75 green. Coverage is now against real on-disk layouts
using temp directories with MD5-hashed bucket names — no mock-structure
drift from reality.
* fix(plugin-kimicode): address follow-up review issues
Follow-up to the issues filed as a review comment on the PR.
[MED] detect() too loose (\bkimi\b matches unrelated binaries)
The old regex accepted plain "kimi" alone because the (?:cli|code)?
suffix was optional — any binary whose output contains "kimi" passed.
Real kimi-cli's --version prints just "kimi, version X.Y.Z" (no suffix),
so --version alone can't distinguish it from, say, a hypothetical
keyboard-input-manager named kimi. Switch to `kimi info` exclusively;
real kimi-cli prints "kimi-cli version: ..." which is a distinct vendor
string. Regex now requires "kimi-cli" / "kimi-code" / "moonshot"
literally. Added maxBuffer cap (4 KB) so a hostile binary can't flood
detect() with MB-scale output.
[MED] --work-dir not passed — investigated, not actionable in this PR
AgentLaunchConfig doesn't expose session.workspacePath — only
projectConfig.path (the project root), which would actively break
discovery if passed. Runtime cwd handling is load-bearing. Left a
comment explaining the constraint and pointing at the core-types
change needed to fix it properly.
[LOW] Empty-bucket race returned transient null
During session creation kimi mkdirs the UUID directory before writing
context.jsonl / wire.jsonl. getKimiLiveSignalMtime returned null in
that window and findKimiSessionMatch returned null, flickering the
dashboard to "no signal". Fall back to the UUID directory's own mtime
when live files are absent.
[LOW] isProcessRunning matched "kimi" anywhere in ps args
Old regex /(?:^|\/)\.?kimi(?:\s|$)|(?:\s|^)kimi(?:\s|$)/ matched
`cat kimi.log`, `vim ~/.kimi/config.toml`, etc. Anchor to argv[0]
instead — only the executable itself, or a python/uv/node runner
followed by `kimi` as the first positional argument, counts.
[NIT] Symlink normalization
kimi's process reads cwd via os.getcwd(), which returns the realpath on
Linux. If AO hands us a symlinked workspacePath, our MD5(symlink) won't
match kimi's MD5(realpath). realpath-resolve with a best-effort fallback
to the raw string (preserves behavior when the path doesn't exist yet).
Tests: 75 → 80. New coverage:
- detect() vendor-string matrix: kimi-cli / kimi-code / moonshot accepted,
unrelated "kimi keyboard input manager" rejected
- isProcessRunning rejects `cat kimi.log` / `vim ~/.kimi/config.toml`
- isProcessRunning accepts `python -m kimi`
- Native signal falls back to UUID-dir mtime during the empty-bucket race
- Symlinked workspace path matches the realpath-hashed bucket
Verified end-to-end against real kimi-cli 1.38.0:
- detect() → true (via `kimi info` vendor match)
- getSessionInfo → correct summary + UUID
- getRestoreCommand → matches kimi's own resume hint
* fix(plugin-kimicode): address inline review from illegalcall
Addresses all 10 inline comments on PR #1390.
Load-bearing fixes:
[#6 line 327] detectActivity ordering was wrong
The old code checked the idle prompt (`^kimi>\s*$`) before approval/error
patterns. Real kimi UI re-renders `kimi>` on the last line when asking for
a confirmation, so \`(Y)es/(N)o\\nkimi>\` was misclassified as idle and the
session would sit forever looking quiet while actually blocked on input.
Reordered to: waiting_input → blocked → idle → active. Matches codex/aider.
[#2,#4,#8 lines 128,154,493] No stable AO↔Kimi session binding
Discovery was pure (path-hash + recency). If the user ran kimi manually in
the same repo, or two AO sessions shared a workspace hash, AO would attach
to the wrong UUID — summary / activity / --resume target all corrupted.
Now:
- \`session.metadata.kimiSessionId\` pins a specific UUID when set; no
fallback to recency when the pin misses (fails closed, no silent drift).
- Unpinned lookups filter UUIDs by \`liveMtime >= session.createdAt - 60s\`
so stray dirs from prior AO sessions don't attach.
- findKimiSessionMatch now takes the whole Session (not just workspacePath)
so createdAt + metadata are available.
[#3 line 141] Any recent subdir was treated as a real session
Stray temp dirs and crash leftovers would match on mtime, producing
\`kimi --resume <garbage>\` and bogus active states. Now require
context.jsonl OR wire.jsonl to exist before trusting a dir. The race
fallback (empty UUID dir → dir mtime) is removed — the JSONL activity
fallback in getActivityState covers the startup window instead.
[#5 line 191] Symlink follow outside ~/.kimi/sessions/
\`stat()\` / \`createReadStream()\` followed symlinks without rebinding, so
a bucket entry that's a symlink to \`/dev/zero\` or \`/etc/passwd\` would
hang forever or leak data. Added \`isInsideKimiSessions(path)\` that realpaths
the candidate and rejects anything outside the sessions root. Every
bucket entry is checked before use.
Smaller cleanups:
[#1 line 89] Cache: 30s negative TTL + unbounded growth
Negative results now cached 2s so a session appearing mid-poll is picked
up on the next cycle. Expired entries evicted on read. Cache capped at
256 entries with oldest-expiry pruning. Key changed to (workspacePath,
pinnedUuid) so two AO sessions in the same bucket can't poison each
other's cache entry.
[#7 line 440] Duplicate argv0Re regex — use the const.
[#9 line 532] maxBuffer: 4096 → 65536. Future \`kimi info\` releases that add
plugin listings or telemetry banners won't silently break detect() with
swallowed ENOBUFS.
[#10 test line 650] macOS test breakage: /var/folders is a symlink to
/private/var/folders, so fakeHome under tmpdir() is a symlink path, while
the plugin realpaths before hashing. Wrap the mkdtempSync in realpathSync
so tests agree with the plugin on the canonical path. Linux CI masked this.
Tests: 80 → 86. New coverage:
- detectActivity classifies confirmation-then-prompt-rerender as waiting_input
- detectActivity classifies error-then-prompt-rerender as blocked
- createdAt floor filter (ignores UUIDs from before the AO session)
- Pinned kimiSessionId wins over recency
- Pinned UUID missing returns null (no silent fallback)
- Negative cache TTL ~2s (session appearing mid-poll picked up next cycle)
- Empty UUID dir without live files is rejected (no stray-dir attach)
Verified end-to-end against real kimi-cli 1.38.0: detect() true,
getSessionInfo extracts correct summary + UUID, getRestoreCommand matches
kimi's own resume hint.
* fix(plugin-kimicode): use kimi.json for workspace mapping and add --work-dir
Read ~/.kimi/kimi.json work_dirs[] as the authoritative workspace-to-session
mapping. When last_session_id is populated, prefer it over the directory-mtime
recency heuristic — kimi itself wrote it. Falls back gracefully to the existing
MD5 hash scan when kimi.json is absent or last_session_id is null.
Add --work-dir to getLaunchCommand using projectConfig.path to establish an
explicit cwd contract, preventing shell-rc / tmux-hook drift from causing the
MD5(cwd) hash to diverge from kimi's session bucket.
* fix(plugin-kimicode): plumb workspacePath into AgentLaunchConfig
The kimicode plugin's --work-dir was passing projectConfig.path, which
breaks worktree-mode workspaces. In worktree mode, projectConfig.path is
the original repo root while session.workspacePath is the per-session
checkout — they differ. Either kimi would write to the project root
(breaking worktree isolation) or md5(projectConfig.path) would diverge
from md5(session.workspacePath), so getActivityState/getSessionInfo would
never find this session's bucket.
Fix:
- Add optional `workspacePath` field to AgentLaunchConfig.
- Plumb it through all 3 launch call sites in session-manager.ts.
- kimicode getLaunchCommand uses config.workspacePath, falling back to
config.projectConfig.path when undefined.
- Tests for the divergent-paths case.
Public-interface change: AgentLaunchConfig grows one optional field.
Invariants preserved:
- Agent.getLaunchCommand signature unchanged — still takes one
AgentLaunchConfig.
- Existing plugins (claude-code, aider, codex, opencode) compile and run
unchanged; the new field is optional and they ignore it.
- Clone-mode workspaces (where workspacePath === projectConfig.path)
produce the same launch command as before.
- Fallback to projectConfig.path keeps callers that don't pass the new
field working — no flag day required.
* fix(plugin-kimicode): capture baseline pre-launch to close startup race
captureKimiBaseline() previously ran in postLaunchSetup, which races
against kimi's own startup writes. If kimi created its UUID directory
before postLaunchSetup ran, that UUID landed in `preExistingUuids` and
was filtered out forever — so `findKimiSessionMatch` returned null
permanently for that session.
Fix:
- Add optional `preLaunchSetup(workspacePath)` to the Agent interface,
invoked from session-manager AFTER the workspace exists but BEFORE
`runtime.create()` spawns the agent.
- Move captureKimiBaseline from postLaunchSetup to preLaunchSetup in
the kimicode plugin.
- Test asserts the new UUID is attached even when written immediately
after preLaunchSetup runs (i.e. in the race window).
Public-interface change: Agent.preLaunchSetup is optional. Existing
plugins (claude-code, aider, codex, opencode) compile and behave
unchanged. Only kimicode opts in.
Invariants preserved:
- Workspace exists before preLaunchSetup runs (called after the
worktree/clone is created, never before).
- Failures in preLaunchSetup propagate just like other launch-path
failures — the existing try/catch covers it.
- captureKimiBaseline is still write-once (returns early if the
baseline file already exists), so restore preserves the original
partition.
* fix(plugin-kimicode): persist UUID pin to disk instead of dead metadata
The session.metadata.kimiSessionId branch was treated as the highest-
priority signal but nothing ever populated it. That left the entire
"AO↔kimi UUID binding" mechanism dead — discovery fell through to the
recency heuristic on every call, so a manual `kimi` run in the same
workspace, a sibling AO session sharing a bucket, or any drift in
kimi's directory layout could attach the wrong session.
Fix:
- Remove the dead session.metadata.kimiSessionId branch from
findKimiSessionMatchUncached and the cache key.
- Add a workspace-local pin file (.ao/kimi-session-id.json). Once
findKimiSessionMatchUncached identifies a winner via the recency
heuristic (or via kimi.json's last_session_id soft-pin), it writes
the UUID to the pin file. Subsequent calls read the pin file as the
highest-priority signal and skip the heuristic entirely — locking
in the AO↔kimi binding for the rest of the session lifetime.
- Cache key simplified to workspacePath alone since the pin is now
persistent and cannot drift between calls.
- Tests cover: pin wins over recency, first match writes the pin,
pin holds when a newer non-pinned UUID appears later.
Mechanism mirrors the existing .ao/kimi-baseline.json pattern (also
file-based, write-once, lives in the workspace).
* refactor(plugin-kimicode): extract session-discovery into its own module
index.ts had grown to 880 lines after the pin-file fix landed. The
discovery layer (kimi.json parsing, baseline capture, pin file, hash
bucket scan, cache) is one cohesive responsibility — pulling it out
keeps both files under the 500-line mark and makes the precedence
rules legible.
- New file: session-discovery.ts. Opens with a decision-table comment
documenting the precedence (pin file → kimi.json soft-pin → recency
heuristic) so future readers see the rule before the code.
- Public surface: captureKimiBaseline, findKimiSessionMatch,
KimiSessionMatch, kimiShareDir, _resetSessionMatchCache.
- index.ts re-exports _resetSessionMatchCache so the existing test
imports keep working.
- No behavioral change — all 98 tests pass unchanged.
* test(plugin-kimicode): worktree-mode end-to-end discovery test
Adds a test where workspacePath (per-session worktree) and
projectConfig.path (repo root) are different paths. Asserts that
discovery hashes workspacePath — not projectConfig.path — for the
kimi bucket lookup. Previously this scenario was untested; the bug
fixed in 9fcc1d9 (--work-dir using projectConfig.path) would have
been caught by this test.
Combined with the earlier --work-dir tests in 9fcc1d9, the worktree
divergent-paths case is now exercised at both the launch site
(getLaunchCommand) and the discovery site (getRestoreCommand) end
to end.
* fix(plugin-kimicode): sandbox-check live-signal files against symlinks
Addresses illegalcall's review comment (id 3127022353): the existing
isInsideKimiSessions check verified the session DIRECTORY but not its
children. A symlinked context.jsonl, wire.jsonl, or wire.jsonl pointing
at /etc/passwd, /dev/zero, or a FIFO would be silently followed by
stat() / createReadStream() — leaking reads, hanging on devices, or
escaping the kimi-sessions sandbox.
Fix:
- New isKimiSessionFile(path) helper using lstat + isFile() — rejects
symlinks, sockets, FIFOs, block/char devices. lstat (not stat) so we
see the symlink itself before the kernel resolves it.
- getKimiLiveSignalMtime swapped to lstat-based check; non-regular
files contribute no mtime.
- extractKimiSummary refuses to open wire.jsonl when it isn't a
regular file.
- Tests cover both paths: getActivityState rejects a session whose
live-signal files are symlinked outside the bucket; getSessionInfo
returns null summary when wire.jsonl is symlinked even if context.jsonl
is real.
* fix(plugin-kimicode): apply baseline + createdAt filters to kimi.json soft-pin
The kimi.json soft-pin used to record a candidate UUID before the baseline
and createdAt filters were applied, so a stale last_session_id pointing at
a pre-AO UUID (manual `kimi` run, kimi.json lag) would be captured into
.ao/kimi-session-id.json and route every later getActivityState /
getSessionInfo / getRestoreCommand call at the wrong conversation, with
no self-healing path.
Move the baseline + createdAt floor checks above the soft-pin branch so
the soft-pin candidate goes through the same gates as the recency contest.
Add two regression tests:
- soft-pin pointing at a baseline UUID is rejected and the AO pin file
records the legitimate AO-spawned UUID instead
- soft-pin pointing at a UUID older than session.createdAt - 60s is
rejected by the createdAt floor
Both tests fail on the prior code and pass after the fix.
* fix: clear stale Next.js cache on version upgrade (#986)
After upgrading @composio/ao via npm, `ao start` served the old UI because
Next.js runtime cache (.next/cache) persisted from the previous version.
Adds a hybrid fix:
- Postinstall hook clears .next/cache and writes a version stamp
- Runtime guard in `ao start` and `ao dashboard` compares stamp against
package version; on mismatch, clears .next/cache and restamps
- Build-time script writes stamp after `next build` (monorepo path)
Only .next/cache is deleted — shipped build artifacts (.next/server,
.next/static, BUILD_ID) are never touched, keeping npm installs intact.
Closes#986
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(lint): add Node.js globals for package-level scripts
The ESLint config only covered root-level scripts/, not
packages/*/scripts/. This caused `no-undef` errors for `console`
and `process` in packages/web/scripts/stamp-version.js.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: ensure postinstall cache clearing runs on all platforms
Restructure postinstall.js so the node-pty chmod fix is wrapped in a
conditional block instead of using early process.exit(0). The previous
exits on Windows, missing node-pty, or missing spawn-helper prevented
the cache-clearing code from ever running on those systems.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address review comments on stamp-version ordering and cache catch logging
- Move stamp-version.js after tsc in web build script so a tsc failure
does not leave a fresh stamp paired with a stale server bundle.
- Log skipped cache version checks via console.debug instead of swallowing
silently, to aid debugging without blocking dashboard startup.
Addresses review feedback from @illegalcall on PR #1022.
* fix: resolve ao-web in postinstall cache cleanup
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(cli): skip rebuild when git install is already on latest version
After `git fetch`, compare local HEAD to remote HEAD. If they match,
print "Already on latest version." and exit without running pnpm install,
clean, build, or npm link.
Without this check, `ao update` re-ran the full rebuild on every
invocation even when nothing had changed, because the git path in
`handleGitUpdate` (unlike the npm path) never called `checkForUpdate()`
to short-circuit before delegating to the shell script.
Fixes#1584
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(cli): keep running smoke tests when already on latest version
The previous fix exited 0 immediately on the "already on latest" path,
which silently skipped smoke tests that would otherwise verify the
install. Restructure with an else-branch so the rebuild block is
skipped but execution continues to the smoke-test gate, preserving
the prior smoke-test behavior for the no-update case.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: reduce opencode session list churn
* fix: make bun-tmp-janitor cross-platform and move to process-level boot
- Extend platform support from Linux-only to Linux + macOS (win32 skipped
since opencode ships no Windows binary and the kernel disallows unlinking
mapped files there)
- Use os.tmpdir() instead of hardcoded /tmp to handle macOS $TMPDIR paths
- Extend file pattern from \.so to \.(so|dylib) to cover macOS dylib leaks
- Move startBunTmpJanitor() from ensureLifecycleWorker() (per-project) to
the process-level boot in registerStart() immediately after register(),
where the single-instance contract is already in force
- Drop the project-observer-bound onSweep closure that incorrectly attributed
janitor health to whichever project happened to start first; replaced with
a simple stderr warn on errors (no project context needed for a process-wide
sweep of /tmp)
- Move stopBunTmpJanitor() into the SIGINT/SIGTERM shutdown handler in
registerStart() alongside stopAllLifecycleWorkers()
- Remove startBunTmpJanitor/stopBunTmpJanitor from lifecycle-service.ts
entirely; lifecycle workers have no business knowing about a process-wide
OS resource
* fix(opencode): address PR #1478 review (TMPDIR isolation, shared cache, janitor cleanup)
Implements all seven findings from the PR #1478 review:
Core / agent-opencode:
- New @aoagents/ao-core/opencode-shared module owns the single TTL cache
+ in-flight dedup for 'opencode session list' (was duplicated across
core and the plugin, doubling spawns per poll cycle).
- TTL dropped from 3s to 500ms so the send-confirmation loop's
updatedAt > baselineUpdatedAt delivery signal can actually fire.
- New invalidateOpenCodeSessionListCache() called by deleteOpenCodeSession
so reuse / remap / restore code paths cannot observe a deleted id.
- New getOpenCodeChildEnv() / getOpenCodeTmpDir(): every opencode child
spawned by core, the plugin, or the agent runtime points TMPDIR/TMP/TEMP
at ~/.agent-orchestrator/.bun-tmp. Bounds the janitor's blast radius
to AO-owned files even on shared hosts.
CLI janitor:
- Sweeps only the AO-owned tmp dir (not the system /tmp).
- Filters synchronously before spawning per-entry stat/unlink work.
- stopBunTmpJanitor() is now async and awaits any in-flight sweep so
SIGTERM cannot exit while unlink() is mid-flight; start.ts shutdown
handler awaits it.
- onSweep callback in start.ts now logs successful reclaims, not just
errors, so operators can confirm the janitor is doing useful work.
Tests:
- packages/core/__tests__/opencode-shared.test.ts (TTL contract,
TMPDIR location, env merge semantics).
- packages/cli/__tests__/lib/bun-tmp-janitor.test.ts (sweep behavior,
stop-awaits-in-flight, pattern matching, missing-dir tolerance).
* chore: remove review postmortem artifact
* fix(cli): remove start command non-null assertions
* refactor(cli): unify interactive spawn helper
Consolidate interactive child-process spawning into runInteractiveCommand by adding an optional options bag (cwd/env and error context). This removes duplicate spawn logic and ensures consistent TTY-forwarding and error formatting for installer and clone flows.
Made-with: Cursor
* test(cli): mock interactive spawn in start URL clone tests
Update start URL clone tests to mock node:child_process spawn (stdio: inherit) instead of exec(), matching the interactive clone behavior and preventing hangs/timeouts.
Made-with: Cursor
* test(cli): standardize spawn mocks for start URL clone
Use an EventEmitter-based ChildProcess helper for spawn() in start command tests, matching existing repo patterns and reducing repetitive per-test mock return objects.
Made-with: Cursor
* test(cli): remove unknown cast from SessionManager mock
Add missing SessionManager methods to the start command test mock so we can type it as SessionManager directly without an unknown double-cast.
Made-with: Cursor
* test(cli): type spawn mock args in start tests
Replace unknown-typed spawn mock parameters with concrete cmd/args/options types to improve readability while keeping behavior unchanged.
Made-with: Cursor
* fix(test): remove duplicate mockSessionManager.restore key
Drop the duplicate restore mock introduced during main-branch merge conflict resolution.