From d95c9def10eadf18cb2c6d9b299af8ba91fae433 Mon Sep 17 00:00:00 2001 From: i-trytoohard Date: Wed, 13 May 2026 03:28:50 +0530 Subject: [PATCH 1/9] fix(cli): first-run startup creates global config with project registered (#1766) (#1819) * 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-pr1483 | 1 + hermes-agent | 1 + libkrunfw | 1 + .../lib/update-channel-onboarding.test.ts | 25 +++++++++++++------ packages/cli/src/commands/start.ts | 13 ++++++++++ .../cli/src/lib/update-channel-onboarding.ts | 23 ++++++++++++----- 6 files changed, 51 insertions(+), 13 deletions(-) create mode 160000 ao-pr1483 create mode 160000 hermes-agent create mode 160000 libkrunfw diff --git a/ao-pr1483 b/ao-pr1483 new file mode 160000 index 000000000..2abb1c1e3 --- /dev/null +++ b/ao-pr1483 @@ -0,0 +1 @@ +Subproject commit 2abb1c1e3dd804da34baf8262fa44c6766421032 diff --git a/hermes-agent b/hermes-agent new file mode 160000 index 000000000..27eeea055 --- /dev/null +++ b/hermes-agent @@ -0,0 +1 @@ +Subproject commit 27eeea0555a0a15b19cea615f46d1f8b88b9dbc1 diff --git a/libkrunfw b/libkrunfw new file mode 160000 index 000000000..351d354b4 --- /dev/null +++ b/libkrunfw @@ -0,0 +1 @@ +Subproject commit 351d354b4b3b3e45f38e29897af8acec9966fd41 diff --git a/packages/cli/__tests__/lib/update-channel-onboarding.test.ts b/packages/cli/__tests__/lib/update-channel-onboarding.test.ts index f06726d28..42053d4be 100644 --- a/packages/cli/__tests__/lib/update-channel-onboarding.test.ts +++ b/packages/cli/__tests__/lib/update-channel-onboarding.test.ts @@ -105,12 +105,23 @@ describe("update-channel-onboarding", () => { expect(mockSaveGlobalConfig).not.toHaveBeenCalled(); }); - it("prompts and persists the chosen channel on first run", async () => { + it("does not prompt when global config does not exist", async () => { mockExistsSync.mockReturnValue(false); const prompt = vi.fn().mockResolvedValue("nightly" as const); await maybePromptForUpdateChannel({ prompt, isInteractive: () => true }); + expect(prompt).not.toHaveBeenCalled(); + expect(mockSaveGlobalConfig).not.toHaveBeenCalled(); + }); + + it("prompts and persists the chosen channel when global config exists but updateChannel is unset", async () => { + mockExistsSync.mockReturnValue(true); + mockGlobalConfig.value = {}; // no updateChannel set + const prompt = vi.fn().mockResolvedValue("nightly" as const); + + await maybePromptForUpdateChannel({ prompt, isInteractive: () => true }); + expect(prompt).toHaveBeenCalledTimes(1); expect(mockSaveGlobalConfig).toHaveBeenCalledTimes(1); const [config] = mockSaveGlobalConfig.mock.calls[0]!; @@ -118,7 +129,8 @@ describe("update-channel-onboarding", () => { }); it("falls back to 'manual' when the user dismisses the prompt", async () => { - mockExistsSync.mockReturnValue(false); + mockExistsSync.mockReturnValue(true); + mockGlobalConfig.value = {}; // no updateChannel set const prompt = vi.fn().mockRejectedValue(new Error("dismissed")); await maybePromptForUpdateChannel({ prompt, isInteractive: () => true }); @@ -129,7 +141,8 @@ describe("update-channel-onboarding", () => { }); it("does not re-prompt the second time it runs after persisting", async () => { - mockExistsSync.mockReturnValue(false); + mockExistsSync.mockReturnValue(true); + mockGlobalConfig.value = {}; const prompt = vi.fn().mockResolvedValue("stable" as const); // First call: persists. @@ -137,7 +150,6 @@ describe("update-channel-onboarding", () => { expect(prompt).toHaveBeenCalledTimes(1); // Simulate the persisted state. - mockExistsSync.mockReturnValue(true); mockGlobalConfig.value = { updateChannel: "stable" }; await maybePromptForUpdateChannel({ prompt, isInteractive: () => true }); @@ -154,11 +166,10 @@ describe("update-channel-onboarding", () => { expect((config as { updateChannel: string }).updateChannel).toBe("nightly"); }); - it("creates a fresh config when none exists", () => { + it("does not write when global config does not exist", () => { mockExistsSync.mockReturnValue(false); persistUpdateChannel("stable"); - const [config] = mockSaveGlobalConfig.mock.calls[0]!; - expect((config as { updateChannel: string }).updateChannel).toBe("stable"); + expect(mockSaveGlobalConfig).not.toHaveBeenCalled(); }); }); }); diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index 16eb933fc..f29be5559 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -572,6 +572,19 @@ export async function autoCreateConfig(workingDir: string): Promise Date: Wed, 13 May 2026 03:29:04 +0530 Subject: [PATCH 2/9] =?UTF-8?q?refactor(release):=20split=20publish=20into?= =?UTF-8?q?=20two-repo=20model=20=E2=80=94=20GitHub=20release=20public,=20?= =?UTF-8?q?npm=20publish=20private=20(#1815)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(release): split publish into two-repo model Org compliance forbids npm publish credentials in public repositories. Move the npm publish step out of this repo and into a private ComposioHQ/ao-publisher repo, triggered via repository_dispatch. Public repo (this one) now only: - Bumps versions via changesets/action (no `publish:` argument) - Creates git tags via `pnpm changeset tag` - Creates the GitHub release (stable or prerelease) - Dispatches `publish-npm-stable` / `publish-npm-nightly` to ao-publisher The only secret needed here is PUBLISHER_DISPATCH_TOKEN, a fine-grained PAT scoped to ao-publisher with `repository_dispatch:write`. NPM_TOKEN lives in ao-publisher's secret store and never enters this repo. Removed from both workflows: NODE_AUTH_TOKEN env, NPM_CONFIG_PROVENANCE env, and `registry-url` on setup-node — none are needed when no npm publish happens here. Canary also gains a skip-if-unchanged guard so cron ticks during quiet stretches don't republish identical SHAs. CONTRIBUTING.md "Release Setup" → "Release Architecture": documents the two-repo split, secret layout, rotation procedure, and the stable vs. nightly flows. Requires PUBLISHER_DISPATCH_TOKEN secret + ao-publisher repo setup by maintainers (out of scope for this PR — see PR description). Co-Authored-By: Claude Opus 4.7 (1M context) * fix(release): address Greptile review findings on two-repo split Three review findings on PR #1815: 1. (P1) canary.yml missing git commit before tagging `pnpm changeset version --snapshot` modifies package.json but does not commit. `pnpm changeset tag` would then tag the pre-snapshot HEAD, so ao-publisher would check out un-bumped versions. Add a "Commit snapshot version bumps" step with `git diff --cached --quiet || git commit` (defensive: skip if nothing to commit) and the `[skip ci]` marker. The commit is never pushed to main — only the tags are pushed and the orphan commit travels with them. 2. (P2) release.yml `--target main` race condition If a commit lands on main between `git push --follow-tags` and `gh release create --target main`, the release commitish drifts and auto-generated notes pull in unrelated commits. Create an explicit `vX.Y.Z` git tag pointing at the version-bump commit, push it, and drop `--target`. `gh release create` then resolves the commitish from the existing tag — no race window. 3. (P2) canary.yml skip-guard suppresses post-stable nightlies `gh release list --limit 1` returns the most recent release by date, which could be a stable from `release.yml`. An explicit `workflow_dispatch` nightly right after a stable cut would be suppressed. Filter to prereleases only: gh release list --json tagName,isPrerelease \ --jq '[.[] | select(.isPrerelease)][0].tagName // empty' If no prerelease exists yet, the jq returns empty and the guard falls through naturally (LAST_SHA empty → condition false → nightly proceeds). Co-Authored-By: Claude Opus 4.7 (1M context) * ci: retrigger checks (event dropped on previous push) * fix(release): make release.yml idempotent and dispatch reachable on re-run The previous design used a single `released` output (based on `after > before` tag count) to gate both `Create GitHub release` and `Dispatch npm publish`. On a re-run, all tags are already on the remote and `fetch-depth: 0` brings them down, so `pnpm changeset tag` adds nothing, `after == before`, `released=false`, and both steps are skipped — breaking the "re-run recovers cleanly" claim, especially the common case where the first run failed only at dispatch because `PUBLISHER_DISPATCH_TOKEN` was missing. Refactor into a `Determine release state` step that emits three independent signals: - `is_release_commit` — version-bump signal. Detected by comparing `packages/ao/package.json` version against its value in HEAD^. A Version Packages merge changes this; a regular commit does not. This is the filter that prevents the dispatch from firing on every commit to main (which all have `hasChangesets == 'false'`). - `tag_on_remote` — whether the `vX.Y.Z` tag exists at origin. - `release_exists` — whether the matching GitHub release exists. Each downstream step is gated on its own piece of state: - Tag push: `is_release_commit && !tag_on_remote` - Release create: `is_release_commit && !release_exists` - Dispatch: `is_release_commit` (always fires on a release commit) The dispatch fires unconditionally on a release commit, even when tag and release already exist on the remote. That guarantees recovery from the most common failure mode (first run succeeded everywhere except dispatch). The publisher must be idempotent against already- published versions for this to be safe — `pnpm changeset publish` already has this property since it skips packages whose current version is already on the registry. Update CONTRIBUTING.md → "Release Architecture": - Add "Idempotency contract for ao-publisher" section spelling out the no-op-on-already-published requirement. - Add "Recovery" section explaining that re-running the failed workflow is the canonical recovery path, plus a manual `gh api` fallback for cases where re-running isn't practical. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(canary): skip-guard anchors on tag parent, not the orphan snapshot The previous P1 fix added a "Commit snapshot version bumps" step before `pnpm changeset tag`, which moved the snapshot tag onto an orphan commit (the version-bump commit) rather than main HEAD. The skip-if-unchanged guard still compared `git rev-list -n 1 "$LAST_TAG"` against `GITHUB_SHA`, but `LAST_TAG` now resolves to the orphan snapshot commit's SHA, never the main SHA. The two could never be equal → `skip=true` was unreachable → every cron tick republished regardless of whether main had advanced. Use `${LAST_TAG}^` to anchor on the snapshot commit's first parent — which is the main HEAD at the time the previous nightly ran — and compare that against the current `GITHUB_SHA`. Now the guard fires correctly when main hasn't advanced since the last nightly. Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(release): single umbrella tag per release, drop per-package tags `pnpm changeset tag` creates one tag per publishable package (~27 here) on every release. At 5 nightlies/week × 52 weeks × 27 packages that's roughly 7 000 tags/year just from canary — pure decoration since `ao-publisher` only consumes the umbrella `vX.Y.Z` tag. The per- package tags also cause partial-recovery conflicts: `git push --tags` on a re-run trips over tags that were pushed by the prior run. Drop the `pnpm changeset tag` call from both workflows and replace `git push origin --tags` with `git push origin "v$version"`. Push exactly one umbrella tag per release. CONTRIBUTING.md → "Release Architecture" updated: - Flow diagram replaces "changeset tag → push" with "push vX.Y.Z tag" - New paragraph spells out the single-tag-per-release policy and why we skip `pnpm changeset tag`. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(canary): workflow_dispatch bypasses skip-guard for recovery When a nightly pushes the tag but fails at `gh release create` or dispatch (e.g. `PUBLISHER_DISPATCH_TOKEN` missing), a re-run via `workflow_dispatch` was silently skipped: `LAST_TAG` resolves to the just-pushed nightly, `${LAST_TAG}^` equals `GITHUB_SHA`, and the binary skip guard fires before any of the downstream steps we want to retry. `release.yml` handles the equivalent case by gating each step on independent state. Canary has a single binary gate, so it needs a different escape hatch: `workflow_dispatch` always proceeds. The human trigger is itself the signal that we want to run regardless of whether the source SHA looks unchanged. Cron-driven runs keep the SHA-equality dedup so quiet stretches don't republish the same SHA on every tick. Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(release): replace ao-publisher dispatch with AO cron poll model Remove the ao-publisher dispatch steps and PUBLISHER_DISPATCH_TOKEN from both release.yml and canary.yml. npm publishing is now handled by an AO cron job on a private server that polls GitHub releases and publishes when a new tag is ahead of the current npm version. Changes: - release.yml: remove dispatch step, keep tag + GitHub release - canary.yml: remove dispatch step, keep tag + GitHub prerelease - CONTRIBUTING.md: rewrite Release Architecture for two-stage model (public CI → GitHub release, private cron → npm publish) * fix(release): address review — remove env/id-token, scope git add, fix wording - Remove environment: release from both workflows (no npm publish here, could block on required reviewers) - Remove id-token: write permission (unused, no OIDC needed) - Scope git add in canary to package.json and .changeset/ only (avoid staging build artifacts) - Fix 'orphan commit' wording → 'snapshot commit' (not actually orphan) - Fix nightly version format 0.0.0-* → X.Y.Z-nightly- --------- Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: i-trytoohard[bot] <1484917231245856808@users.noreply.github.com> --- .github/workflows/canary.yml | 137 +++++++++++++++++++++++++++++----- .github/workflows/release.yml | 112 ++++++++++++++++++++++++--- CONTRIBUTING.md | 52 ++++++++++++- 3 files changed, 268 insertions(+), 33 deletions(-) diff --git a/.github/workflows/canary.yml b/.github/workflows/canary.yml index fb42f6996..234c715b6 100644 --- a/.github/workflows/canary.yml +++ b/.github/workflows/canary.yml @@ -1,10 +1,21 @@ name: Canary -# Nightly canary publishes the current tip of `main` to npm under @nightly. +# Two-stage release pipeline (nightly canary side). +# +# Nightly canary creates snapshot versions, tags, and a GitHub prerelease. +# npm publishing is handled by a private cron job (AO) that polls GitHub +# releases and publishes when a new prerelease tag is ahead of the current +# npm nightly version. +# +# No NPM_TOKEN or publisher dispatch secrets are needed in this repo. +# The only secret used is GITHUB_TOKEN (automatic). +# # Cron schedule: 23:30 IST = 18:00 UTC, on Fri,Sat,Sun,Mon,Tue # (DOW 5,6,0,1,2). Wed/Thu are the bake window — no scheduled publishes. -# `workflow_dispatch` lets the release captain re-cut a nightly during bake -# when a fix lands and the Discord cohort should test the patched candidate. +# `workflow_dispatch` lets the release captain re-cut a nightly during +# bake when a fix lands and the Discord cohort should test the patched +# candidate. + on: schedule: - cron: "0 18 * * 5,6,0,1,2" @@ -15,38 +26,86 @@ concurrency: cancel-in-progress: false permissions: - contents: read - id-token: write + contents: write jobs: canary: name: Publish canary runs-on: ubuntu-latest - environment: release steps: - uses: actions/checkout@v4 with: ref: main fetch-depth: 0 + # Skip-if-unchanged guard: if the most recent prerelease in this + # repo was cut from the current tip of main, there's nothing new + # to publish — short-circuit the whole job. Prevents republishing + # the same SHA on every cron tick during quiet stretches. + # + # We filter to prereleases only (`isPrerelease == true`). If the + # most recent release were a stable cut from `release.yml`, an + # explicit `workflow_dispatch` nightly right after the stable + # release would otherwise be suppressed — which we don't want. + # + # The previous-nightly tag points at the snapshot commit created + # by the "Commit snapshot version bumps" step below. The snapshot + # commit's first parent is the source main HEAD, so `${LAST_TAG}^` + # is the right anchor to compare against `GITHUB_SHA` (main HEAD + # at this run). + # + # `workflow_dispatch` always proceeds — recovery path for partial + # failures. A human triggered the run, they want it to run. + - name: Check if main has new commits since the last nightly + id: check + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + if [ "$GITHUB_EVENT_NAME" = 'workflow_dispatch' ]; then + echo 'Manual trigger via workflow_dispatch — bypassing skip guard.' + echo 'skip=false' >> "$GITHUB_OUTPUT" + exit 0 + fi + + LAST_TAG=$(gh release list --json tagName,isPrerelease --jq '[.[] | select(.isPrerelease)][0].tagName // empty') + LAST_SHA="" + if [ -n "$LAST_TAG" ]; then + LAST_SHA=$(git rev-parse "${LAST_TAG}^" 2>/dev/null || echo "") + fi + if [ -n "$LAST_SHA" ] && [ "$LAST_SHA" = "$GITHUB_SHA" ]; then + echo "Last prerelease ($LAST_TAG) was cut from $GITHUB_SHA — skipping." + echo "skip=true" >> "$GITHUB_OUTPUT" + else + echo "skip=false" >> "$GITHUB_OUTPUT" + fi + - uses: pnpm/action-setup@v4 + if: steps.check.outputs.skip != 'true' - uses: actions/setup-node@v4 + if: steps.check.outputs.skip != 'true' with: node-version: 20 cache: pnpm - registry-url: https://registry.npmjs.org + # No `registry-url`: this workflow does not publish to npm. - - run: echo "HUSKY=0" >> $GITHUB_ENV + - if: steps.check.outputs.skip != 'true' + run: echo "HUSKY=0" >> $GITHUB_ENV - - run: pnpm install --frozen-lockfile + - if: steps.check.outputs.skip != 'true' + run: pnpm install --frozen-lockfile - - run: pnpm -r build + - if: steps.check.outputs.skip != 'true' + run: pnpm -r build - # Pre-publish guard: same as release.yml — catches workspace:* deps on - # private packages before they'd silently break a published install. - - run: node scripts/check-publishable-deps.mjs + # Pre-publish guard: same as release.yml — catches workspace:* deps + # on private packages before they'd silently break a published + # install. Still runs here so the public repo blocks a malformed + # snapshot before it reaches npm. + - if: steps.check.outputs.skip != 'true' + run: node scripts/check-publishable-deps.mjs - name: Create snapshot versions + if: steps.check.outputs.skip != 'true' run: | # If no changesets exist (e.g. right after a Version Packages merge), # create a minimal one. `changeset version --snapshot` consumes and @@ -58,8 +117,52 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Publish canary - run: pnpm changeset publish --tag nightly --no-git-tag + # `pnpm changeset version --snapshot` modifies package.json files + # on disk but does NOT create a git commit. Without this commit, + # the umbrella tag we push below would point at the pre-snapshot + # HEAD, so when the npm publisher checks out the tag it would see + # the un-bumped versions and either publish wrong version numbers + # or fail trying to republish an existing version. + # + # The commit is NEVER pushed to main — only the tag below is + # pushed, and the snapshot commit travels with it as part of the + # tag's reachable history. `[skip ci]` is defensive in case any + # future change ever pushes this commit to a branch. + - name: Commit snapshot version bumps + if: steps.check.outputs.skip != 'true' + run: | + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add packages/*/package.json packages/plugins/*/package.json .changeset/ + git diff --cached --quiet || git commit -m 'chore: snapshot version bump [skip ci]' + + # Push the umbrella `vX.Y.Z` tag pointing at the snapshot commit. + # The npm publisher resolves this tag to the snapshot commit with + # the bumped package.json versions. + # + # We deliberately skip `pnpm changeset tag`: it would create one + # tag per publishable package (~27 here) every night, which adds + # up fast on the nightly cadence. The npm publisher only consumes + # the umbrella tag, so the per-package tags are pure decoration. + - name: Tag snapshot versions + id: tag + if: steps.check.outputs.skip != 'true' + run: | + version=$(node -p "require('./packages/ao/package.json').version") + git tag "v$version" + git push origin "v$version" + echo "version=$version" >> "$GITHUB_OUTPUT" + + # Public-facing GitHub prerelease. `--prerelease` flags it as such + # so consumers and tooling that filter by stability stay correct. + # No `--target`: the tag was just pushed and GitHub resolves the + # commitish from it (avoids the race where another commit lands + # on main between the tag push and this step). + - name: Create GitHub prerelease + if: steps.check.outputs.skip != 'true' env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - NPM_CONFIG_PROVENANCE: "true" + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release create "v${{ steps.tag.outputs.version }}" \ + --prerelease \ + --generate-notes diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cee557905..eb2c322cb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,8 +1,17 @@ name: Release -# Stable @latest publishes when CI passes on `main` and a Version Packages PR -# is merged. The changesets/action below opens/updates that PR automatically; -# merging it runs `changeset publish` and pushes to npm. +# Two-stage release pipeline. +# +# This public repo is responsible for version bumps, tagging, and creating +# the GitHub release. npm publishing is handled by a private cron job (AO) +# that polls GitHub releases and publishes when a new tag is ahead of the +# current npm version. +# +# No NPM_TOKEN or publisher dispatch secrets are needed in this repo. +# The only secret used is GITHUB_TOKEN (automatic). +# +# See CONTRIBUTING.md → "Release architecture" for the full picture. + on: workflow_run: # Depends on the workflow named "CI" in .github/workflows/ci.yml — if @@ -20,13 +29,11 @@ concurrency: permissions: contents: write pull-requests: write - id-token: write jobs: release: name: Release runs-on: ubuntu-latest - environment: release if: >- github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' && @@ -41,24 +48,105 @@ jobs: with: node-version: 20 cache: pnpm - registry-url: https://registry.npmjs.org + # No `registry-url`: this workflow does not publish to npm. - run: echo "HUSKY=0" >> $GITHUB_ENV - run: pnpm install --frozen-lockfile - run: pnpm -r build # Pre-publish guard: catches the case where a publishable package has a # workspace:* runtime dep on a `private: true` package — pnpm would # rewrite the dep on publish to a version that doesn't exist on npm, - # breaking `npm install -g @aoagents/ao`. + # breaking `npm install -g @aoagents/ao`. Still runs here so the + # public repo blocks a malformed version bump before it reaches npm. - run: node scripts/check-publishable-deps.mjs + + # changesets/action manages the "Version Packages" PR — when there + # are pending changesets it opens/updates the PR; when there are + # none (i.e. that PR was just merged) it would normally invoke the + # `publish:` command. We deliberately omit `publish:` so the action + # never runs `changeset publish`. npm publishing is handled by a + # private cron that detects the GitHub release. - uses: changesets/action@v1 + id: changesets with: - publish: pnpm changeset publish version: pnpm changeset version - # Creates/updates a PR titled "chore: version packages" when changesets - # are pending. Merging that PR publishes to npm under @latest. title: "chore: version packages" commit: "chore: version packages" env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - NPM_CONFIG_PROVENANCE: "true" + + # Determine release state. Each downstream step (tag push, GH + # release creation) is gated on its own piece of state so the + # workflow is idempotent and recovers cleanly on re-run after a + # partial failure. + # + # `is_release_commit` is the crucial signal: it filters out + # regular commits to main (which also have `hasChangesets == + # 'false'` but should NOT trigger a publish). We detect a + # version-bump commit by comparing the umbrella package's + # version against its value in the parent commit — a Version + # Packages merge changes that version, a regular commit does not. + # The umbrella `@aoagents/ao` is the canonical version source for + # the `vX.Y.Z` tag scheme and is part of the linked group, so + # any release that bumps the cohort will bump this file. + - name: Determine release state + id: state + if: steps.changesets.outputs.hasChangesets == 'false' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + version=$(node -p "require('./packages/ao/package.json').version") + echo "version=$version" >> "$GITHUB_OUTPUT" + + prev_version="" + if git rev-parse HEAD^ >/dev/null 2>&1; then + prev_version=$(git show HEAD^:packages/ao/package.json 2>/dev/null | jq -r .version 2>/dev/null || echo "") + fi + if [ -n "$prev_version" ] && [ "$prev_version" != "$version" ]; then + echo "is_release_commit=true" >> "$GITHUB_OUTPUT" + else + echo "is_release_commit=false" >> "$GITHUB_OUTPUT" + fi + + if git ls-remote --tags --exit-code origin "refs/tags/v$version" >/dev/null 2>&1; then + echo "tag_on_remote=true" >> "$GITHUB_OUTPUT" + else + echo "tag_on_remote=false" >> "$GITHUB_OUTPUT" + fi + + if gh release view "v$version" --json tagName >/dev/null 2>&1; then + echo "release_exists=true" >> "$GITHUB_OUTPUT" + else + echo "release_exists=false" >> "$GITHUB_OUTPUT" + fi + + # Push the umbrella `vX.Y.Z` tag only if this is a fresh + # version-bump commit AND the tag isn't already on the remote. + # Skipped on re-runs where the tag was pushed on a prior run + # (idempotent recovery). + # + # We deliberately skip `pnpm changeset tag`: it would create one + # tag per publishable package (~27 here) on every release, which + # creates partial-recovery conflicts on re-run when `git push --tags` + # tries to re-push existing per-package tags. The npm publisher + # only consumes the umbrella tag, so the per-package tags add no + # value. + - name: Tag versioned packages + if: steps.state.outputs.is_release_commit == 'true' && steps.state.outputs.tag_on_remote == 'false' + run: | + git tag "v${{ steps.state.outputs.version }}" + git push origin "v${{ steps.state.outputs.version }}" + + # Public-facing GitHub release. Stable channel — not a prerelease. + # No `--target`: the `vX.Y.Z` tag is on the remote at the + # version-bump commit, and `gh release create` resolves the + # commitish from the existing tag. Avoids the race where another + # commit lands on main between the tag push and this step, + # pulling unrelated commits into the auto-generated release notes. + # Skipped if a release for this version already exists. + - name: Create GitHub release + if: steps.state.outputs.is_release_commit == 'true' && steps.state.outputs.release_exists == 'false' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release create "v${{ steps.state.outputs.version }}" \ + --generate-notes diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a1df81037..825003c56 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -70,13 +70,57 @@ ao update `ao update` fast-forwards the local install repo, reinstalls dependencies, clean-rebuilds `@aoagents/ao-core`, `@aoagents/ao-cli`, and `@aoagents/ao-web`, refreshes the global launcher with `npm link`, and finishes with CLI smoke tests. Use `ao update --skip-smoke` when you only need the rebuild step, or `ao update --smoke-only` when validating an existing install. -## Release Setup (maintainers only) +## Release Architecture (maintainers only) -The canary and stable release workflows require one secret configured in GitHub repo settings: +AO uses a **two-stage release pipeline**. This public repo handles version bumps, git tags, and GitHub releases. npm publishing runs on a private server (AO cron job) that polls GitHub releases and publishes when a new tag is ahead of the current npm version. Org compliance forbids npm publish credentials in public repositories, so `NPM_TOKEN` never enters this repo. -- `NPM_TOKEN` — an npm automation token with publish access to the `@aoagents` org. Add it at **Settings → Secrets and variables → Actions → New repository secret**. +### Where things happen -Without this secret, both `release.yml` and `canary.yml` will fail at the publish step. +| Stage | Where | Responsibility | +| ------------------------ | ------------------------------ | ------------------------------------------------------------------------ | +| Versioning + GitHub release | This repo (public, CI) | Changesets version bumps, git tags, `gh release create` | +| npm publish | Private server (AO cron) | Detects new GitHub releases → builds → `pnpm changeset publish` | + +The flow on every release: + +``` +This repo (public CI) Private server (AO cron) +────────────────────── ───────────────────────── +release.yml: Polls gh release list + changeset version Detects new vX.Y.Z tag + push vX.Y.Z tag Compare to npm @latest/@nightly + gh release create vX.Y.Z If behind → checkout tag → build → publish + +canary.yml: Same cron, detects prereleases + changeset version --snapshot Publishes with --tag nightly + commit snapshot bump + tag + gh release create --prerelease +``` + +Each release pushes a single umbrella `vX.Y.Z` git tag pointing at the version-bump commit. We deliberately do **not** run `pnpm changeset tag`, which would emit one tag per publishable package (~27) every release — fine for stable's monthly cadence, noisy on the nightly cadence (~7 000 tags/year). The npm publisher only consumes the umbrella tag, so the per-package tags add no value. + +### Secrets + +This repo requires **no additional secrets** beyond the automatic `GITHUB_TOKEN`. `NPM_TOKEN` lives only on the private server. + +### How releases are cut + +- **Stable**: merge the "chore: version packages" PR opened by `changesets/action`. `release.yml` tags the bumped packages and creates a `vX.Y.Z` GitHub release. The AO cron detects the new release and publishes to npm `@latest`. +- **Nightly**: `canary.yml` runs on cron (23:30 IST Fri–Tue) or via `workflow_dispatch`. It snapshots versions to `X.Y.Z-nightly-` format (e.g., `0.6.1-nightly-7c46dc92`), tags, and creates a prerelease GitHub release. The AO cron detects the new prerelease and publishes to npm `@nightly`. + +There is no path from this repo that calls `npm publish` directly. + +### Idempotency + +`release.yml` is idempotent: each step (tag push, GitHub release creation) is gated on whether that piece of state already exists, so a re-run after a partial failure picks up only the missing steps. + +The AO cron is also idempotent — `pnpm changeset publish` skips packages whose current version is already on the registry, so re-running after a partial publish is safe. + +### Recovery + +If `release.yml` fails after the GitHub release was created, **re-run the failed workflow**: the state-detection step will see that the tag and release already exist and skip those steps. + +If the AO cron fails to publish, it will retry on the next poll cycle (every 15 minutes). No manual intervention needed for transient failures. For persistent issues, check the cron logs on the private server. ## Testing your changes From ee2f4256f43ab0edf32a90244e6a5c9ca76ef858 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 13 May 2026 03:50:07 +0530 Subject: [PATCH 3/9] chore: version packages (#1812) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- ...eep-tmux-session-alive-after-agent-exit.md | 8 -- .changeset/native-windows-support.md | 54 --------- .changeset/orchestrator-session-id-env.md | 6 - .changeset/release-train.md | 52 --------- .changeset/rename-worker-sessions.md | 5 - .../restore-preserves-existing-branch.md | 5 - packages/ao/CHANGELOG.md | 82 +++++++++++++ packages/ao/package.json | 2 +- packages/cli/CHANGELOG.md | 108 ++++++++++++++++++ packages/cli/package.json | 2 +- packages/core/CHANGELOG.md | 76 ++++++++++++ packages/core/package.json | 2 +- packages/plugins/agent-aider/CHANGELOG.md | 42 +++++++ packages/plugins/agent-aider/package.json | 2 +- .../plugins/agent-claude-code/CHANGELOG.md | 42 +++++++ .../plugins/agent-claude-code/package.json | 2 +- packages/plugins/agent-codex/CHANGELOG.md | 42 +++++++ packages/plugins/agent-codex/package.json | 2 +- .../agent-codex/src/package-version.test.ts | 11 -- packages/plugins/agent-cursor/CHANGELOG.md | 9 ++ packages/plugins/agent-cursor/package.json | 2 +- packages/plugins/agent-kimicode/CHANGELOG.md | 9 ++ packages/plugins/agent-kimicode/package.json | 2 +- packages/plugins/agent-opencode/CHANGELOG.md | 42 +++++++ packages/plugins/agent-opencode/package.json | 2 +- .../plugins/notifier-composio/CHANGELOG.md | 42 +++++++ .../plugins/notifier-composio/package.json | 2 +- .../plugins/notifier-desktop/CHANGELOG.md | 42 +++++++ .../plugins/notifier-desktop/package.json | 2 +- .../plugins/notifier-discord/CHANGELOG.md | 9 ++ .../plugins/notifier-discord/package.json | 2 +- .../plugins/notifier-openclaw/CHANGELOG.md | 9 ++ .../plugins/notifier-openclaw/package.json | 2 +- packages/plugins/notifier-slack/CHANGELOG.md | 42 +++++++ packages/plugins/notifier-slack/package.json | 2 +- .../plugins/notifier-webhook/CHANGELOG.md | 42 +++++++ .../plugins/notifier-webhook/package.json | 2 +- packages/plugins/runtime-process/CHANGELOG.md | 42 +++++++ packages/plugins/runtime-process/package.json | 2 +- packages/plugins/runtime-tmux/CHANGELOG.md | 46 ++++++++ packages/plugins/runtime-tmux/package.json | 2 +- packages/plugins/scm-github/CHANGELOG.md | 42 +++++++ packages/plugins/scm-github/package.json | 2 +- packages/plugins/scm-gitlab/CHANGELOG.md | 9 ++ packages/plugins/scm-gitlab/package.json | 2 +- packages/plugins/terminal-iterm2/CHANGELOG.md | 42 +++++++ packages/plugins/terminal-iterm2/package.json | 2 +- packages/plugins/terminal-web/CHANGELOG.md | 42 +++++++ packages/plugins/terminal-web/package.json | 2 +- packages/plugins/tracker-github/CHANGELOG.md | 42 +++++++ packages/plugins/tracker-github/package.json | 2 +- packages/plugins/tracker-gitlab/CHANGELOG.md | 10 ++ packages/plugins/tracker-gitlab/package.json | 2 +- packages/plugins/tracker-linear/CHANGELOG.md | 42 +++++++ packages/plugins/tracker-linear/package.json | 2 +- packages/plugins/workspace-clone/CHANGELOG.md | 42 +++++++ packages/plugins/workspace-clone/package.json | 2 +- .../plugins/workspace-worktree/CHANGELOG.md | 43 +++++++ .../plugins/workspace-worktree/package.json | 2 +- packages/web/CHANGELOG.md | 101 ++++++++++++++++ packages/web/package.json | 2 +- 61 files changed, 1168 insertions(+), 168 deletions(-) delete mode 100644 .changeset/keep-tmux-session-alive-after-agent-exit.md delete mode 100644 .changeset/native-windows-support.md delete mode 100644 .changeset/orchestrator-session-id-env.md delete mode 100644 .changeset/release-train.md delete mode 100644 .changeset/rename-worker-sessions.md delete mode 100644 .changeset/restore-preserves-existing-branch.md delete mode 100644 packages/plugins/agent-codex/src/package-version.test.ts diff --git a/.changeset/keep-tmux-session-alive-after-agent-exit.md b/.changeset/keep-tmux-session-alive-after-agent-exit.md deleted file mode 100644 index 44b60042b..000000000 --- a/.changeset/keep-tmux-session-alive-after-agent-exit.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -"@aoagents/ao-plugin-runtime-tmux": patch -"@aoagents/ao-web": patch ---- - -Tmux sessions no longer die when the agent process inside them exits. When you Ctrl-C the agent in a web terminal, the pane now drops to an interactive `$SHELL` in the workspace dir instead of nuking the tmux session and leaving the dashboard in a phantom "runtime lost" state. The lifecycle manager still detects the agent exit (via `agent.isProcessRunning`) and transitions the session to `agent_process_exited`, but the runtime stays usable so you can run shell commands or manually re-launch the agent. - -Also: the mux-websocket re-attach loop now checks `tmux has-session` before retrying after a PTY exit. When the tmux session is genuinely gone (e.g. `ao stop`), it skips the three doomed `attach-session` spawns from #1640 and notifies the dashboard immediately. (#1756) diff --git a/.changeset/native-windows-support.md b/.changeset/native-windows-support.md deleted file mode 100644 index f40349b08..000000000 --- a/.changeset/native-windows-support.md +++ /dev/null @@ -1,54 +0,0 @@ ---- -"@aoagents/ao-core": minor -"@aoagents/ao-cli": minor -"@aoagents/ao": minor -"@aoagents/ao-plugin-runtime-process": minor -"@aoagents/ao-plugin-runtime-tmux": minor -"@aoagents/ao-plugin-agent-claude-code": minor -"@aoagents/ao-plugin-agent-codex": minor -"@aoagents/ao-plugin-agent-aider": minor -"@aoagents/ao-plugin-agent-opencode": minor -"@aoagents/ao-plugin-workspace-worktree": minor -"@aoagents/ao-plugin-workspace-clone": minor -"@aoagents/ao-plugin-tracker-github": minor -"@aoagents/ao-plugin-tracker-linear": minor -"@aoagents/ao-plugin-scm-github": minor -"@aoagents/ao-plugin-notifier-desktop": minor -"@aoagents/ao-plugin-notifier-slack": minor -"@aoagents/ao-plugin-notifier-webhook": minor -"@aoagents/ao-plugin-notifier-composio": minor -"@aoagents/ao-plugin-terminal-iterm2": minor -"@aoagents/ao-plugin-terminal-web": minor -"@aoagents/ao-web": minor ---- - -feat: native Windows support - -AO now runs natively on Windows. The default runtime on Windows is `process` -(ConPTY via `node-pty` + named pipes — no tmux, no WSL); the dashboard, -agents (claude-code, codex, kimicode, aider, opencode, cursor), `ao doctor`, -and `ao update` all work out of the box. Each session gets a small detached -pty-host helper that wraps a ConPTY behind `\\.\pipe\ao-pty-`, -registered so `ao stop` can reach it. - -A new cross-platform abstraction layer (`packages/core/src/platform.ts`) -centralises every platform branch behind helpers like `isWindows()`, -`getDefaultRuntime()`, `getShell()`, `killProcessTree()`, `findPidByPort()`, -and `getEnvDefaults()`. Path comparison uses `pathsEqual` / -`canonicalCompareKey` to handle NTFS case-insensitivity. PATH wrappers for -agent plugins (`gh`, `git`) ship as `.cjs` + `.cmd` shims on Windows; -`script-runner` runs `.ps1` siblings of `.sh` scripts via PowerShell. New -`ao-doctor.ps1` / `ao-update.ps1` shipped. - -`ao open` is now cross-platform: it sources sessions from `sm.list()` -instead of `tmux list-sessions` (so `runtime-process` sessions on Windows -appear), and the open action branches per OS — `open-iterm-tab` stays the -macOS path, native handling on Windows and Linux. - -Behaviour on macOS and Linux is unchanged. Every Windows path is gated -behind `isWindows()`; `runtime-tmux` and the bash hook flows are untouched. - -See `docs/CROSS_PLATFORM.md` for the developer reference (helper inventory, -EPERM-vs-ESRCH gotcha, PowerShell-vs-bash differences, pre-merge checklist). -The Windows runtime architecture (pty-host, pipe protocol, registry, sweep, -mux WS Windows branch) is documented in `docs/ARCHITECTURE.md`. diff --git a/.changeset/orchestrator-session-id-env.md b/.changeset/orchestrator-session-id-env.md deleted file mode 100644 index 8fcf20027..000000000 --- a/.changeset/orchestrator-session-id-env.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@aoagents/ao-core": minor -"@aoagents/ao-cli": minor ---- - -Worker sessions now learn how to message the orchestrator that spawned them. When a project has an orchestrator running, the worker's system prompt gains a "Talking to the Orchestrator" section with the literal `ao send -orchestrator ""` command (rendered at prompt-build time, no env var, no shell-syntax variants). `ao send` itself now auto-prefixes outgoing messages with `[from $AO_SESSION_ID]` when invoked from inside an AO session, so the receiver always knows who's writing — symmetric across worker→orchestrator, orchestrator→worker, and worker→worker. Humans running `ao send` from a normal terminal stay unprefixed. (#1786) diff --git a/.changeset/release-train.md b/.changeset/release-train.md deleted file mode 100644 index 527832eb6..000000000 --- a/.changeset/release-train.md +++ /dev/null @@ -1,52 +0,0 @@ ---- -"@aoagents/ao-core": minor -"@aoagents/ao-cli": minor -"@aoagents/ao": minor -"@aoagents/ao-web": minor ---- - -feat(release): weekly release train — channels, onboarding, dashboard banner, cron - -Ships the full release pipeline described in `release-process.html`: - -- **Cron-driven nightly canary.** `.github/workflows/canary.yml` triggers via - `schedule: '0 18 * * 5,6,0,1,2'` (23:30 IST Fri–Tue) plus `workflow_dispatch`. - Bake window (Wed–Thu) pauses scheduled nightlies; the captain re-cuts via - workflow_dispatch when a fix lands. Stable `release.yml` publishes via - `changesets/action`. `.changeset/config.json` adds the snapshot template - (`{tag}-{commit}`). `@aoagents/ao-web` stays in the linked group and ships - alongside `@aoagents/ao-cli` (it's a workspace:* runtime dep, so marking it - private would 404 every `npm install -g @aoagents/ao` after publish). - `scripts/check-publishable-deps.mjs` runs in both release.yml and canary.yml - before the publish step and fails CI if a publishable package depends on a - `private: true` package via workspace:*. - -- **Update channels.** New `updateChannel` field in the global config schema - (`stable | nightly | manual`, default `manual` so existing users see no - surprise installs). `update-check.ts` reads `dist-tags[channel]` from the - npm registry, compares prerelease versions segment-by-segment so SHA-suffixed - nightlies sort correctly, and skips notices entirely on `manual`. - -- **Soft auto-install + active-session guard.** On stable/nightly, `ao update` - skips the confirm prompt and just installs. Before installing it lists - sessions and refuses with `N session(s) active. Run \`ao stop\` first.` if - any are in `working`/`idle`/`needs_input`/`stuck`. Same guard duplicated - in `POST /api/update` so the dashboard returns a structured 409. - -- **Onboarding question.** `ao start` prompts once for the channel if unset; - dismissal persists `manual`. `ao config set updateChannel ` (and - `installMethod`) lets users change it later. - -- **Dashboard banner.** `GET /api/version` reads the same cache file as the - CLI. `UpdateBanner` (Tailwind only, `var(--color-*)` tokens) appears at the - top of the dashboard when `isOutdated`. Click POSTs to `/api/update`; - dismissal persists per-version in `localStorage`. - -- **Bun + Homebrew detection.** New install-method classifiers for - `~/.bun/install/global/` (auto-installs `bun add -g @aoagents/ao@`) - and `/Cellar/ao/` (notice only — `brew upgrade ao` to avoid clobbering - brew's symlinks). `installMethod` config field overrides path detection. - -Supersedes #1525 (incorporates the canary + release infrastructure with the -cron / no-stale-SHA-guard / no-merged-PR-comment modifications called out in -the design doc). diff --git a/.changeset/rename-worker-sessions.md b/.changeset/rename-worker-sessions.md deleted file mode 100644 index 528c43ff1..000000000 --- a/.changeset/rename-worker-sessions.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@aoagents/ao-web": minor ---- - -Add inline rename for worker sessions in the sidebar. Each worker row now shows a small pencil button on hover; clicking it swaps the label for an input pre-filled with the current title. Enter persists via `PATCH /api/sessions/:id`, Escape cancels, and an empty value reverts the session to its default title. The rename is written to the existing `displayName` metadata field and is now the highest-priority signal in `getSessionTitle`, so a user-chosen label always beats PR/issue titles. The session ID (`ao-N`) remains the canonical identifier — only display surfaces change. (#1647) diff --git a/.changeset/restore-preserves-existing-branch.md b/.changeset/restore-preserves-existing-branch.md deleted file mode 100644 index 784d776fe..000000000 --- a/.changeset/restore-preserves-existing-branch.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@aoagents/ao-plugin-workspace-worktree": patch ---- - -Restoring a session whose worktree directory was cleaned up but whose branch still existed locally would 422 with `fatal: a branch named already exists`. The recovery path in `workspace.restore()` unconditionally fell through to `git worktree add -b`, even when the local branch was present (which `destroy()` deliberately preserves). The catch now checks for the local branch and re-attaches it without `-b`/`-B`, preserving the session's commits. (#1741) diff --git a/packages/ao/CHANGELOG.md b/packages/ao/CHANGELOG.md index 6da307509..60f468609 100644 --- a/packages/ao/CHANGELOG.md +++ b/packages/ao/CHANGELOG.md @@ -1,5 +1,87 @@ # @aoagents/ao +## 0.7.0 + +### Minor Changes + +- 0f5ae0b: feat: native Windows support + + AO now runs natively on Windows. The default runtime on Windows is `process` + (ConPTY via `node-pty` + named pipes — no tmux, no WSL); the dashboard, + agents (claude-code, codex, kimicode, aider, opencode, cursor), `ao doctor`, + and `ao update` all work out of the box. Each session gets a small detached + pty-host helper that wraps a ConPTY behind `\\.\pipe\ao-pty-`, + registered so `ao stop` can reach it. + + A new cross-platform abstraction layer (`packages/core/src/platform.ts`) + centralises every platform branch behind helpers like `isWindows()`, + `getDefaultRuntime()`, `getShell()`, `killProcessTree()`, `findPidByPort()`, + and `getEnvDefaults()`. Path comparison uses `pathsEqual` / + `canonicalCompareKey` to handle NTFS case-insensitivity. PATH wrappers for + agent plugins (`gh`, `git`) ship as `.cjs` + `.cmd` shims on Windows; + `script-runner` runs `.ps1` siblings of `.sh` scripts via PowerShell. New + `ao-doctor.ps1` / `ao-update.ps1` shipped. + + `ao open` is now cross-platform: it sources sessions from `sm.list()` + instead of `tmux list-sessions` (so `runtime-process` sessions on Windows + appear), and the open action branches per OS — `open-iterm-tab` stays the + macOS path, native handling on Windows and Linux. + + Behaviour on macOS and Linux is unchanged. Every Windows path is gated + behind `isWindows()`; `runtime-tmux` and the bash hook flows are untouched. + + See `docs/CROSS_PLATFORM.md` for the developer reference (helper inventory, + EPERM-vs-ESRCH gotcha, PowerShell-vs-bash differences, pre-merge checklist). + The Windows runtime architecture (pty-host, pipe protocol, registry, sweep, + mux WS Windows branch) is documented in `docs/ARCHITECTURE.md`. + +- 7c46dc9: feat(release): weekly release train — channels, onboarding, dashboard banner, cron + + Ships the full release pipeline described in `release-process.html`: + - **Cron-driven nightly canary.** `.github/workflows/canary.yml` triggers via + `schedule: '0 18 * * 5,6,0,1,2'` (23:30 IST Fri–Tue) plus `workflow_dispatch`. + Bake window (Wed–Thu) pauses scheduled nightlies; the captain re-cuts via + workflow_dispatch when a fix lands. Stable `release.yml` publishes via + `changesets/action`. `.changeset/config.json` adds the snapshot template + (`{tag}-{commit}`). `@aoagents/ao-web` stays in the linked group and ships + alongside `@aoagents/ao-cli` (it's a workspace:_ runtime dep, so marking it + private would 404 every `npm install -g @aoagents/ao` after publish). + `scripts/check-publishable-deps.mjs` runs in both release.yml and canary.yml + before the publish step and fails CI if a publishable package depends on a + `private: true` package via workspace:_. + - **Update channels.** New `updateChannel` field in the global config schema + (`stable | nightly | manual`, default `manual` so existing users see no + surprise installs). `update-check.ts` reads `dist-tags[channel]` from the + npm registry, compares prerelease versions segment-by-segment so SHA-suffixed + nightlies sort correctly, and skips notices entirely on `manual`. + - **Soft auto-install + active-session guard.** On stable/nightly, `ao update` + skips the confirm prompt and just installs. Before installing it lists + sessions and refuses with `N session(s) active. Run \`ao stop\` first.`if +any are in`working`/`idle`/`needs_input`/`stuck`. Same guard duplicated +in `POST /api/update` so the dashboard returns a structured 409. + - **Onboarding question.** `ao start` prompts once for the channel if unset; + dismissal persists `manual`. `ao config set updateChannel ` (and + `installMethod`) lets users change it later. + - **Dashboard banner.** `GET /api/version` reads the same cache file as the + CLI. `UpdateBanner` (Tailwind only, `var(--color-*)` tokens) appears at the + top of the dashboard when `isOutdated`. Click POSTs to `/api/update`; + dismissal persists per-version in `localStorage`. + - **Bun + Homebrew detection.** New install-method classifiers for + `~/.bun/install/global/` (auto-installs `bun add -g @aoagents/ao@`) + and `/Cellar/ao/` (notice only — `brew upgrade ao` to avoid clobbering + brew's symlinks). `installMethod` config field overrides path detection. + + Supersedes #1525 (incorporates the canary + release infrastructure with the + cron / no-stale-SHA-guard / no-merged-PR-comment modifications called out in + the design doc). + +### Patch Changes + +- Updated dependencies [0f5ae0b] +- Updated dependencies [fe33bb7] +- Updated dependencies [7c46dc9] + - @aoagents/ao-cli@0.7.0 + ## 0.6.0 ### Patch Changes diff --git a/packages/ao/package.json b/packages/ao/package.json index 7f137042a..ee54fb5f2 100644 --- a/packages/ao/package.json +++ b/packages/ao/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao", - "version": "0.6.0", + "version": "0.7.0", "description": "Orchestrate parallel AI coding agents — global CLI wrapper", "license": "MIT", "type": "module", diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index a110d80f4..2bddda0ec 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,113 @@ # @aoagents/ao-cli +## 0.7.0 + +### Minor Changes + +- 0f5ae0b: feat: native Windows support + + AO now runs natively on Windows. The default runtime on Windows is `process` + (ConPTY via `node-pty` + named pipes — no tmux, no WSL); the dashboard, + agents (claude-code, codex, kimicode, aider, opencode, cursor), `ao doctor`, + and `ao update` all work out of the box. Each session gets a small detached + pty-host helper that wraps a ConPTY behind `\\.\pipe\ao-pty-`, + registered so `ao stop` can reach it. + + A new cross-platform abstraction layer (`packages/core/src/platform.ts`) + centralises every platform branch behind helpers like `isWindows()`, + `getDefaultRuntime()`, `getShell()`, `killProcessTree()`, `findPidByPort()`, + and `getEnvDefaults()`. Path comparison uses `pathsEqual` / + `canonicalCompareKey` to handle NTFS case-insensitivity. PATH wrappers for + agent plugins (`gh`, `git`) ship as `.cjs` + `.cmd` shims on Windows; + `script-runner` runs `.ps1` siblings of `.sh` scripts via PowerShell. New + `ao-doctor.ps1` / `ao-update.ps1` shipped. + + `ao open` is now cross-platform: it sources sessions from `sm.list()` + instead of `tmux list-sessions` (so `runtime-process` sessions on Windows + appear), and the open action branches per OS — `open-iterm-tab` stays the + macOS path, native handling on Windows and Linux. + + Behaviour on macOS and Linux is unchanged. Every Windows path is gated + behind `isWindows()`; `runtime-tmux` and the bash hook flows are untouched. + + See `docs/CROSS_PLATFORM.md` for the developer reference (helper inventory, + EPERM-vs-ESRCH gotcha, PowerShell-vs-bash differences, pre-merge checklist). + The Windows runtime architecture (pty-host, pipe protocol, registry, sweep, + mux WS Windows branch) is documented in `docs/ARCHITECTURE.md`. + +- fe33bb7: Worker sessions now learn how to message the orchestrator that spawned them. When a project has an orchestrator running, the worker's system prompt gains a "Talking to the Orchestrator" section with the literal `ao send -orchestrator ""` command (rendered at prompt-build time, no env var, no shell-syntax variants). `ao send` itself now auto-prefixes outgoing messages with `[from $AO_SESSION_ID]` when invoked from inside an AO session, so the receiver always knows who's writing — symmetric across worker→orchestrator, orchestrator→worker, and worker→worker. Humans running `ao send` from a normal terminal stay unprefixed. (#1786) +- 7c46dc9: feat(release): weekly release train — channels, onboarding, dashboard banner, cron + + Ships the full release pipeline described in `release-process.html`: + - **Cron-driven nightly canary.** `.github/workflows/canary.yml` triggers via + `schedule: '0 18 * * 5,6,0,1,2'` (23:30 IST Fri–Tue) plus `workflow_dispatch`. + Bake window (Wed–Thu) pauses scheduled nightlies; the captain re-cuts via + workflow_dispatch when a fix lands. Stable `release.yml` publishes via + `changesets/action`. `.changeset/config.json` adds the snapshot template + (`{tag}-{commit}`). `@aoagents/ao-web` stays in the linked group and ships + alongside `@aoagents/ao-cli` (it's a workspace:_ runtime dep, so marking it + private would 404 every `npm install -g @aoagents/ao` after publish). + `scripts/check-publishable-deps.mjs` runs in both release.yml and canary.yml + before the publish step and fails CI if a publishable package depends on a + `private: true` package via workspace:_. + - **Update channels.** New `updateChannel` field in the global config schema + (`stable | nightly | manual`, default `manual` so existing users see no + surprise installs). `update-check.ts` reads `dist-tags[channel]` from the + npm registry, compares prerelease versions segment-by-segment so SHA-suffixed + nightlies sort correctly, and skips notices entirely on `manual`. + - **Soft auto-install + active-session guard.** On stable/nightly, `ao update` + skips the confirm prompt and just installs. Before installing it lists + sessions and refuses with `N session(s) active. Run \`ao stop\` first.`if +any are in`working`/`idle`/`needs_input`/`stuck`. Same guard duplicated +in `POST /api/update` so the dashboard returns a structured 409. + - **Onboarding question.** `ao start` prompts once for the channel if unset; + dismissal persists `manual`. `ao config set updateChannel ` (and + `installMethod`) lets users change it later. + - **Dashboard banner.** `GET /api/version` reads the same cache file as the + CLI. `UpdateBanner` (Tailwind only, `var(--color-*)` tokens) appears at the + top of the dashboard when `isOutdated`. Click POSTs to `/api/update`; + dismissal persists per-version in `localStorage`. + - **Bun + Homebrew detection.** New install-method classifiers for + `~/.bun/install/global/` (auto-installs `bun add -g @aoagents/ao@`) + and `/Cellar/ao/` (notice only — `brew upgrade ao` to avoid clobbering + brew's symlinks). `installMethod` config field overrides path detection. + + Supersedes #1525 (incorporates the canary + release infrastructure with the + cron / no-stale-SHA-guard / no-merged-PR-comment modifications called out in + the design doc). + +### Patch Changes + +- Updated dependencies [845fffd] +- Updated dependencies [0f5ae0b] +- Updated dependencies [fe33bb7] +- Updated dependencies [7c46dc9] +- Updated dependencies [71326bc] +- Updated dependencies [a33b2ba] + - @aoagents/ao-plugin-runtime-tmux@0.7.0 + - @aoagents/ao-web@0.7.0 + - @aoagents/ao-core@0.7.0 + - @aoagents/ao-plugin-runtime-process@0.7.0 + - @aoagents/ao-plugin-agent-claude-code@0.7.0 + - @aoagents/ao-plugin-agent-codex@0.7.0 + - @aoagents/ao-plugin-agent-aider@0.7.0 + - @aoagents/ao-plugin-agent-opencode@0.7.0 + - @aoagents/ao-plugin-workspace-worktree@0.7.0 + - @aoagents/ao-plugin-workspace-clone@0.7.0 + - @aoagents/ao-plugin-tracker-github@0.7.0 + - @aoagents/ao-plugin-tracker-linear@0.7.0 + - @aoagents/ao-plugin-scm-github@0.7.0 + - @aoagents/ao-plugin-notifier-desktop@0.7.0 + - @aoagents/ao-plugin-notifier-slack@0.7.0 + - @aoagents/ao-plugin-notifier-webhook@0.7.0 + - @aoagents/ao-plugin-notifier-composio@0.7.0 + - @aoagents/ao-plugin-terminal-iterm2@0.7.0 + - @aoagents/ao-plugin-terminal-web@0.7.0 + - @aoagents/ao-plugin-agent-cursor@0.7.0 + - @aoagents/ao-plugin-agent-kimicode@0.7.0 + - @aoagents/ao-plugin-notifier-discord@0.7.0 + - @aoagents/ao-plugin-notifier-openclaw@0.7.0 + ## 0.6.0 ### Patch Changes diff --git a/packages/cli/package.json b/packages/cli/package.json index b86ceb410..03473925f 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-cli", - "version": "0.6.0", + "version": "0.7.0", "description": "CLI for agent-orchestrator — the `ao` command", "license": "MIT", "type": "module", diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 663adb01c..dccb3af70 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,81 @@ # @aoagents/ao-core +## 0.7.0 + +### Minor Changes + +- 0f5ae0b: feat: native Windows support + + AO now runs natively on Windows. The default runtime on Windows is `process` + (ConPTY via `node-pty` + named pipes — no tmux, no WSL); the dashboard, + agents (claude-code, codex, kimicode, aider, opencode, cursor), `ao doctor`, + and `ao update` all work out of the box. Each session gets a small detached + pty-host helper that wraps a ConPTY behind `\\.\pipe\ao-pty-`, + registered so `ao stop` can reach it. + + A new cross-platform abstraction layer (`packages/core/src/platform.ts`) + centralises every platform branch behind helpers like `isWindows()`, + `getDefaultRuntime()`, `getShell()`, `killProcessTree()`, `findPidByPort()`, + and `getEnvDefaults()`. Path comparison uses `pathsEqual` / + `canonicalCompareKey` to handle NTFS case-insensitivity. PATH wrappers for + agent plugins (`gh`, `git`) ship as `.cjs` + `.cmd` shims on Windows; + `script-runner` runs `.ps1` siblings of `.sh` scripts via PowerShell. New + `ao-doctor.ps1` / `ao-update.ps1` shipped. + + `ao open` is now cross-platform: it sources sessions from `sm.list()` + instead of `tmux list-sessions` (so `runtime-process` sessions on Windows + appear), and the open action branches per OS — `open-iterm-tab` stays the + macOS path, native handling on Windows and Linux. + + Behaviour on macOS and Linux is unchanged. Every Windows path is gated + behind `isWindows()`; `runtime-tmux` and the bash hook flows are untouched. + + See `docs/CROSS_PLATFORM.md` for the developer reference (helper inventory, + EPERM-vs-ESRCH gotcha, PowerShell-vs-bash differences, pre-merge checklist). + The Windows runtime architecture (pty-host, pipe protocol, registry, sweep, + mux WS Windows branch) is documented in `docs/ARCHITECTURE.md`. + +- fe33bb7: Worker sessions now learn how to message the orchestrator that spawned them. When a project has an orchestrator running, the worker's system prompt gains a "Talking to the Orchestrator" section with the literal `ao send -orchestrator ""` command (rendered at prompt-build time, no env var, no shell-syntax variants). `ao send` itself now auto-prefixes outgoing messages with `[from $AO_SESSION_ID]` when invoked from inside an AO session, so the receiver always knows who's writing — symmetric across worker→orchestrator, orchestrator→worker, and worker→worker. Humans running `ao send` from a normal terminal stay unprefixed. (#1786) +- 7c46dc9: feat(release): weekly release train — channels, onboarding, dashboard banner, cron + + Ships the full release pipeline described in `release-process.html`: + - **Cron-driven nightly canary.** `.github/workflows/canary.yml` triggers via + `schedule: '0 18 * * 5,6,0,1,2'` (23:30 IST Fri–Tue) plus `workflow_dispatch`. + Bake window (Wed–Thu) pauses scheduled nightlies; the captain re-cuts via + workflow_dispatch when a fix lands. Stable `release.yml` publishes via + `changesets/action`. `.changeset/config.json` adds the snapshot template + (`{tag}-{commit}`). `@aoagents/ao-web` stays in the linked group and ships + alongside `@aoagents/ao-cli` (it's a workspace:_ runtime dep, so marking it + private would 404 every `npm install -g @aoagents/ao` after publish). + `scripts/check-publishable-deps.mjs` runs in both release.yml and canary.yml + before the publish step and fails CI if a publishable package depends on a + `private: true` package via workspace:_. + - **Update channels.** New `updateChannel` field in the global config schema + (`stable | nightly | manual`, default `manual` so existing users see no + surprise installs). `update-check.ts` reads `dist-tags[channel]` from the + npm registry, compares prerelease versions segment-by-segment so SHA-suffixed + nightlies sort correctly, and skips notices entirely on `manual`. + - **Soft auto-install + active-session guard.** On stable/nightly, `ao update` + skips the confirm prompt and just installs. Before installing it lists + sessions and refuses with `N session(s) active. Run \`ao stop\` first.`if +any are in`working`/`idle`/`needs_input`/`stuck`. Same guard duplicated +in `POST /api/update` so the dashboard returns a structured 409. + - **Onboarding question.** `ao start` prompts once for the channel if unset; + dismissal persists `manual`. `ao config set updateChannel ` (and + `installMethod`) lets users change it later. + - **Dashboard banner.** `GET /api/version` reads the same cache file as the + CLI. `UpdateBanner` (Tailwind only, `var(--color-*)` tokens) appears at the + top of the dashboard when `isOutdated`. Click POSTs to `/api/update`; + dismissal persists per-version in `localStorage`. + - **Bun + Homebrew detection.** New install-method classifiers for + `~/.bun/install/global/` (auto-installs `bun add -g @aoagents/ao@`) + and `/Cellar/ao/` (notice only — `brew upgrade ao` to avoid clobbering + brew's symlinks). `installMethod` config field overrides path detection. + + Supersedes #1525 (incorporates the canary + release infrastructure with the + cron / no-stale-SHA-guard / no-merged-PR-comment modifications called out in + the design doc). + ## 0.6.0 ### Minor Changes diff --git a/packages/core/package.json b/packages/core/package.json index 00b11bac7..ad874cdb8 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-core", - "version": "0.6.0", + "version": "0.7.0", "description": "Core library — types, config, session manager, lifecycle manager, event bus", "license": "MIT", "type": "module", diff --git a/packages/plugins/agent-aider/CHANGELOG.md b/packages/plugins/agent-aider/CHANGELOG.md index 51df2e23f..990c79ebe 100644 --- a/packages/plugins/agent-aider/CHANGELOG.md +++ b/packages/plugins/agent-aider/CHANGELOG.md @@ -1,5 +1,47 @@ # @aoagents/ao-plugin-agent-aider +## 0.7.0 + +### Minor Changes + +- 0f5ae0b: feat: native Windows support + + AO now runs natively on Windows. The default runtime on Windows is `process` + (ConPTY via `node-pty` + named pipes — no tmux, no WSL); the dashboard, + agents (claude-code, codex, kimicode, aider, opencode, cursor), `ao doctor`, + and `ao update` all work out of the box. Each session gets a small detached + pty-host helper that wraps a ConPTY behind `\\.\pipe\ao-pty-`, + registered so `ao stop` can reach it. + + A new cross-platform abstraction layer (`packages/core/src/platform.ts`) + centralises every platform branch behind helpers like `isWindows()`, + `getDefaultRuntime()`, `getShell()`, `killProcessTree()`, `findPidByPort()`, + and `getEnvDefaults()`. Path comparison uses `pathsEqual` / + `canonicalCompareKey` to handle NTFS case-insensitivity. PATH wrappers for + agent plugins (`gh`, `git`) ship as `.cjs` + `.cmd` shims on Windows; + `script-runner` runs `.ps1` siblings of `.sh` scripts via PowerShell. New + `ao-doctor.ps1` / `ao-update.ps1` shipped. + + `ao open` is now cross-platform: it sources sessions from `sm.list()` + instead of `tmux list-sessions` (so `runtime-process` sessions on Windows + appear), and the open action branches per OS — `open-iterm-tab` stays the + macOS path, native handling on Windows and Linux. + + Behaviour on macOS and Linux is unchanged. Every Windows path is gated + behind `isWindows()`; `runtime-tmux` and the bash hook flows are untouched. + + See `docs/CROSS_PLATFORM.md` for the developer reference (helper inventory, + EPERM-vs-ESRCH gotcha, PowerShell-vs-bash differences, pre-merge checklist). + The Windows runtime architecture (pty-host, pipe protocol, registry, sweep, + mux WS Windows branch) is documented in `docs/ARCHITECTURE.md`. + +### Patch Changes + +- Updated dependencies [0f5ae0b] +- Updated dependencies [fe33bb7] +- Updated dependencies [7c46dc9] + - @aoagents/ao-core@0.7.0 + ## 0.6.0 ### Patch Changes diff --git a/packages/plugins/agent-aider/package.json b/packages/plugins/agent-aider/package.json index 990c4807f..9a86075f0 100644 --- a/packages/plugins/agent-aider/package.json +++ b/packages/plugins/agent-aider/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-agent-aider", - "version": "0.6.0", + "version": "0.7.0", "description": "Agent plugin: Aider", "license": "MIT", "type": "module", diff --git a/packages/plugins/agent-claude-code/CHANGELOG.md b/packages/plugins/agent-claude-code/CHANGELOG.md index f246bb9e8..e41c5e807 100644 --- a/packages/plugins/agent-claude-code/CHANGELOG.md +++ b/packages/plugins/agent-claude-code/CHANGELOG.md @@ -1,5 +1,47 @@ # @aoagents/ao-plugin-agent-claude-code +## 0.7.0 + +### Minor Changes + +- 0f5ae0b: feat: native Windows support + + AO now runs natively on Windows. The default runtime on Windows is `process` + (ConPTY via `node-pty` + named pipes — no tmux, no WSL); the dashboard, + agents (claude-code, codex, kimicode, aider, opencode, cursor), `ao doctor`, + and `ao update` all work out of the box. Each session gets a small detached + pty-host helper that wraps a ConPTY behind `\\.\pipe\ao-pty-`, + registered so `ao stop` can reach it. + + A new cross-platform abstraction layer (`packages/core/src/platform.ts`) + centralises every platform branch behind helpers like `isWindows()`, + `getDefaultRuntime()`, `getShell()`, `killProcessTree()`, `findPidByPort()`, + and `getEnvDefaults()`. Path comparison uses `pathsEqual` / + `canonicalCompareKey` to handle NTFS case-insensitivity. PATH wrappers for + agent plugins (`gh`, `git`) ship as `.cjs` + `.cmd` shims on Windows; + `script-runner` runs `.ps1` siblings of `.sh` scripts via PowerShell. New + `ao-doctor.ps1` / `ao-update.ps1` shipped. + + `ao open` is now cross-platform: it sources sessions from `sm.list()` + instead of `tmux list-sessions` (so `runtime-process` sessions on Windows + appear), and the open action branches per OS — `open-iterm-tab` stays the + macOS path, native handling on Windows and Linux. + + Behaviour on macOS and Linux is unchanged. Every Windows path is gated + behind `isWindows()`; `runtime-tmux` and the bash hook flows are untouched. + + See `docs/CROSS_PLATFORM.md` for the developer reference (helper inventory, + EPERM-vs-ESRCH gotcha, PowerShell-vs-bash differences, pre-merge checklist). + The Windows runtime architecture (pty-host, pipe protocol, registry, sweep, + mux WS Windows branch) is documented in `docs/ARCHITECTURE.md`. + +### Patch Changes + +- Updated dependencies [0f5ae0b] +- Updated dependencies [fe33bb7] +- Updated dependencies [7c46dc9] + - @aoagents/ao-core@0.7.0 + ## 0.6.0 ### Patch Changes diff --git a/packages/plugins/agent-claude-code/package.json b/packages/plugins/agent-claude-code/package.json index 3a4734da3..4f130fa7e 100644 --- a/packages/plugins/agent-claude-code/package.json +++ b/packages/plugins/agent-claude-code/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-agent-claude-code", - "version": "0.6.0", + "version": "0.7.0", "description": "Agent plugin: Claude Code", "license": "MIT", "type": "module", diff --git a/packages/plugins/agent-codex/CHANGELOG.md b/packages/plugins/agent-codex/CHANGELOG.md index b6749c398..6761e2a7f 100644 --- a/packages/plugins/agent-codex/CHANGELOG.md +++ b/packages/plugins/agent-codex/CHANGELOG.md @@ -1,5 +1,47 @@ # @aoagents/ao-plugin-agent-codex +## 0.7.0 + +### Minor Changes + +- 0f5ae0b: feat: native Windows support + + AO now runs natively on Windows. The default runtime on Windows is `process` + (ConPTY via `node-pty` + named pipes — no tmux, no WSL); the dashboard, + agents (claude-code, codex, kimicode, aider, opencode, cursor), `ao doctor`, + and `ao update` all work out of the box. Each session gets a small detached + pty-host helper that wraps a ConPTY behind `\\.\pipe\ao-pty-`, + registered so `ao stop` can reach it. + + A new cross-platform abstraction layer (`packages/core/src/platform.ts`) + centralises every platform branch behind helpers like `isWindows()`, + `getDefaultRuntime()`, `getShell()`, `killProcessTree()`, `findPidByPort()`, + and `getEnvDefaults()`. Path comparison uses `pathsEqual` / + `canonicalCompareKey` to handle NTFS case-insensitivity. PATH wrappers for + agent plugins (`gh`, `git`) ship as `.cjs` + `.cmd` shims on Windows; + `script-runner` runs `.ps1` siblings of `.sh` scripts via PowerShell. New + `ao-doctor.ps1` / `ao-update.ps1` shipped. + + `ao open` is now cross-platform: it sources sessions from `sm.list()` + instead of `tmux list-sessions` (so `runtime-process` sessions on Windows + appear), and the open action branches per OS — `open-iterm-tab` stays the + macOS path, native handling on Windows and Linux. + + Behaviour on macOS and Linux is unchanged. Every Windows path is gated + behind `isWindows()`; `runtime-tmux` and the bash hook flows are untouched. + + See `docs/CROSS_PLATFORM.md` for the developer reference (helper inventory, + EPERM-vs-ESRCH gotcha, PowerShell-vs-bash differences, pre-merge checklist). + The Windows runtime architecture (pty-host, pipe protocol, registry, sweep, + mux WS Windows branch) is documented in `docs/ARCHITECTURE.md`. + +### Patch Changes + +- Updated dependencies [0f5ae0b] +- Updated dependencies [fe33bb7] +- Updated dependencies [7c46dc9] + - @aoagents/ao-core@0.7.0 + ## 0.6.0 ### Patch Changes diff --git a/packages/plugins/agent-codex/package.json b/packages/plugins/agent-codex/package.json index 56853d557..e9196702c 100644 --- a/packages/plugins/agent-codex/package.json +++ b/packages/plugins/agent-codex/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-agent-codex", - "version": "0.6.0", + "version": "0.7.0", "description": "Agent plugin: OpenAI Codex CLI", "license": "MIT", "type": "module", diff --git a/packages/plugins/agent-codex/src/package-version.test.ts b/packages/plugins/agent-codex/src/package-version.test.ts deleted file mode 100644 index 5b0f95f09..000000000 --- a/packages/plugins/agent-codex/src/package-version.test.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { readFileSync } from "node:fs"; - -describe("package manifest version", () => { - it("is bumped to 0.6.0", () => { - const packageJsonUrl = new URL("../package.json", import.meta.url); - const packageJson = JSON.parse(readFileSync(packageJsonUrl, "utf8")) as { version?: string }; - - expect(packageJson.version).toBe("0.6.0"); - }); -}); diff --git a/packages/plugins/agent-cursor/CHANGELOG.md b/packages/plugins/agent-cursor/CHANGELOG.md index 577e8c55e..08a26c320 100644 --- a/packages/plugins/agent-cursor/CHANGELOG.md +++ b/packages/plugins/agent-cursor/CHANGELOG.md @@ -1,5 +1,14 @@ # @aoagents/ao-plugin-agent-cursor +## 0.7.0 + +### Patch Changes + +- Updated dependencies [0f5ae0b] +- Updated dependencies [fe33bb7] +- Updated dependencies [7c46dc9] + - @aoagents/ao-core@0.7.0 + ## 0.1.4 ### Patch Changes diff --git a/packages/plugins/agent-cursor/package.json b/packages/plugins/agent-cursor/package.json index dd383f3e0..9f6e67ec9 100644 --- a/packages/plugins/agent-cursor/package.json +++ b/packages/plugins/agent-cursor/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-agent-cursor", - "version": "0.1.4", + "version": "0.7.0", "description": "Agent plugin: Cursor CLI", "license": "MIT", "type": "module", diff --git a/packages/plugins/agent-kimicode/CHANGELOG.md b/packages/plugins/agent-kimicode/CHANGELOG.md index 946ba3001..ecdf3831c 100644 --- a/packages/plugins/agent-kimicode/CHANGELOG.md +++ b/packages/plugins/agent-kimicode/CHANGELOG.md @@ -1,5 +1,14 @@ # @aoagents/ao-plugin-agent-kimicode +## 0.7.0 + +### Patch Changes + +- Updated dependencies [0f5ae0b] +- Updated dependencies [fe33bb7] +- Updated dependencies [7c46dc9] + - @aoagents/ao-core@0.7.0 + ## 0.1.3 ### Patch Changes diff --git a/packages/plugins/agent-kimicode/package.json b/packages/plugins/agent-kimicode/package.json index 6d9fd38c0..6ae80d984 100644 --- a/packages/plugins/agent-kimicode/package.json +++ b/packages/plugins/agent-kimicode/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-agent-kimicode", - "version": "0.1.3", + "version": "0.7.0", "description": "Agent plugin: Kimi Code CLI (MoonshotAI)", "license": "MIT", "type": "module", diff --git a/packages/plugins/agent-opencode/CHANGELOG.md b/packages/plugins/agent-opencode/CHANGELOG.md index aa05d8c3a..90687d46f 100644 --- a/packages/plugins/agent-opencode/CHANGELOG.md +++ b/packages/plugins/agent-opencode/CHANGELOG.md @@ -1,5 +1,47 @@ # @aoagents/ao-plugin-agent-opencode +## 0.7.0 + +### Minor Changes + +- 0f5ae0b: feat: native Windows support + + AO now runs natively on Windows. The default runtime on Windows is `process` + (ConPTY via `node-pty` + named pipes — no tmux, no WSL); the dashboard, + agents (claude-code, codex, kimicode, aider, opencode, cursor), `ao doctor`, + and `ao update` all work out of the box. Each session gets a small detached + pty-host helper that wraps a ConPTY behind `\\.\pipe\ao-pty-`, + registered so `ao stop` can reach it. + + A new cross-platform abstraction layer (`packages/core/src/platform.ts`) + centralises every platform branch behind helpers like `isWindows()`, + `getDefaultRuntime()`, `getShell()`, `killProcessTree()`, `findPidByPort()`, + and `getEnvDefaults()`. Path comparison uses `pathsEqual` / + `canonicalCompareKey` to handle NTFS case-insensitivity. PATH wrappers for + agent plugins (`gh`, `git`) ship as `.cjs` + `.cmd` shims on Windows; + `script-runner` runs `.ps1` siblings of `.sh` scripts via PowerShell. New + `ao-doctor.ps1` / `ao-update.ps1` shipped. + + `ao open` is now cross-platform: it sources sessions from `sm.list()` + instead of `tmux list-sessions` (so `runtime-process` sessions on Windows + appear), and the open action branches per OS — `open-iterm-tab` stays the + macOS path, native handling on Windows and Linux. + + Behaviour on macOS and Linux is unchanged. Every Windows path is gated + behind `isWindows()`; `runtime-tmux` and the bash hook flows are untouched. + + See `docs/CROSS_PLATFORM.md` for the developer reference (helper inventory, + EPERM-vs-ESRCH gotcha, PowerShell-vs-bash differences, pre-merge checklist). + The Windows runtime architecture (pty-host, pipe protocol, registry, sweep, + mux WS Windows branch) is documented in `docs/ARCHITECTURE.md`. + +### Patch Changes + +- Updated dependencies [0f5ae0b] +- Updated dependencies [fe33bb7] +- Updated dependencies [7c46dc9] + - @aoagents/ao-core@0.7.0 + ## 0.6.0 ### Patch Changes diff --git a/packages/plugins/agent-opencode/package.json b/packages/plugins/agent-opencode/package.json index 67e794c55..d15bad074 100644 --- a/packages/plugins/agent-opencode/package.json +++ b/packages/plugins/agent-opencode/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-agent-opencode", - "version": "0.6.0", + "version": "0.7.0", "description": "Agent plugin: OpenCode", "license": "MIT", "type": "module", diff --git a/packages/plugins/notifier-composio/CHANGELOG.md b/packages/plugins/notifier-composio/CHANGELOG.md index 2bf202333..44ff96052 100644 --- a/packages/plugins/notifier-composio/CHANGELOG.md +++ b/packages/plugins/notifier-composio/CHANGELOG.md @@ -1,5 +1,47 @@ # @aoagents/ao-plugin-notifier-composio +## 0.7.0 + +### Minor Changes + +- 0f5ae0b: feat: native Windows support + + AO now runs natively on Windows. The default runtime on Windows is `process` + (ConPTY via `node-pty` + named pipes — no tmux, no WSL); the dashboard, + agents (claude-code, codex, kimicode, aider, opencode, cursor), `ao doctor`, + and `ao update` all work out of the box. Each session gets a small detached + pty-host helper that wraps a ConPTY behind `\\.\pipe\ao-pty-`, + registered so `ao stop` can reach it. + + A new cross-platform abstraction layer (`packages/core/src/platform.ts`) + centralises every platform branch behind helpers like `isWindows()`, + `getDefaultRuntime()`, `getShell()`, `killProcessTree()`, `findPidByPort()`, + and `getEnvDefaults()`. Path comparison uses `pathsEqual` / + `canonicalCompareKey` to handle NTFS case-insensitivity. PATH wrappers for + agent plugins (`gh`, `git`) ship as `.cjs` + `.cmd` shims on Windows; + `script-runner` runs `.ps1` siblings of `.sh` scripts via PowerShell. New + `ao-doctor.ps1` / `ao-update.ps1` shipped. + + `ao open` is now cross-platform: it sources sessions from `sm.list()` + instead of `tmux list-sessions` (so `runtime-process` sessions on Windows + appear), and the open action branches per OS — `open-iterm-tab` stays the + macOS path, native handling on Windows and Linux. + + Behaviour on macOS and Linux is unchanged. Every Windows path is gated + behind `isWindows()`; `runtime-tmux` and the bash hook flows are untouched. + + See `docs/CROSS_PLATFORM.md` for the developer reference (helper inventory, + EPERM-vs-ESRCH gotcha, PowerShell-vs-bash differences, pre-merge checklist). + The Windows runtime architecture (pty-host, pipe protocol, registry, sweep, + mux WS Windows branch) is documented in `docs/ARCHITECTURE.md`. + +### Patch Changes + +- Updated dependencies [0f5ae0b] +- Updated dependencies [fe33bb7] +- Updated dependencies [7c46dc9] + - @aoagents/ao-core@0.7.0 + ## 0.6.0 ### Patch Changes diff --git a/packages/plugins/notifier-composio/package.json b/packages/plugins/notifier-composio/package.json index c907a66fb..4bc13f310 100644 --- a/packages/plugins/notifier-composio/package.json +++ b/packages/plugins/notifier-composio/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-notifier-composio", - "version": "0.6.0", + "version": "0.7.0", "description": "Notifier plugin: Composio unified notifications (Slack, Discord, email)", "license": "MIT", "type": "module", diff --git a/packages/plugins/notifier-desktop/CHANGELOG.md b/packages/plugins/notifier-desktop/CHANGELOG.md index 222fec642..c1fc8952e 100644 --- a/packages/plugins/notifier-desktop/CHANGELOG.md +++ b/packages/plugins/notifier-desktop/CHANGELOG.md @@ -1,5 +1,47 @@ # @aoagents/ao-plugin-notifier-desktop +## 0.7.0 + +### Minor Changes + +- 0f5ae0b: feat: native Windows support + + AO now runs natively on Windows. The default runtime on Windows is `process` + (ConPTY via `node-pty` + named pipes — no tmux, no WSL); the dashboard, + agents (claude-code, codex, kimicode, aider, opencode, cursor), `ao doctor`, + and `ao update` all work out of the box. Each session gets a small detached + pty-host helper that wraps a ConPTY behind `\\.\pipe\ao-pty-`, + registered so `ao stop` can reach it. + + A new cross-platform abstraction layer (`packages/core/src/platform.ts`) + centralises every platform branch behind helpers like `isWindows()`, + `getDefaultRuntime()`, `getShell()`, `killProcessTree()`, `findPidByPort()`, + and `getEnvDefaults()`. Path comparison uses `pathsEqual` / + `canonicalCompareKey` to handle NTFS case-insensitivity. PATH wrappers for + agent plugins (`gh`, `git`) ship as `.cjs` + `.cmd` shims on Windows; + `script-runner` runs `.ps1` siblings of `.sh` scripts via PowerShell. New + `ao-doctor.ps1` / `ao-update.ps1` shipped. + + `ao open` is now cross-platform: it sources sessions from `sm.list()` + instead of `tmux list-sessions` (so `runtime-process` sessions on Windows + appear), and the open action branches per OS — `open-iterm-tab` stays the + macOS path, native handling on Windows and Linux. + + Behaviour on macOS and Linux is unchanged. Every Windows path is gated + behind `isWindows()`; `runtime-tmux` and the bash hook flows are untouched. + + See `docs/CROSS_PLATFORM.md` for the developer reference (helper inventory, + EPERM-vs-ESRCH gotcha, PowerShell-vs-bash differences, pre-merge checklist). + The Windows runtime architecture (pty-host, pipe protocol, registry, sweep, + mux WS Windows branch) is documented in `docs/ARCHITECTURE.md`. + +### Patch Changes + +- Updated dependencies [0f5ae0b] +- Updated dependencies [fe33bb7] +- Updated dependencies [7c46dc9] + - @aoagents/ao-core@0.7.0 + ## 0.6.0 ### Patch Changes diff --git a/packages/plugins/notifier-desktop/package.json b/packages/plugins/notifier-desktop/package.json index cd69871c0..75f99a83c 100644 --- a/packages/plugins/notifier-desktop/package.json +++ b/packages/plugins/notifier-desktop/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-notifier-desktop", - "version": "0.6.0", + "version": "0.7.0", "description": "Notifier plugin: OS desktop notifications", "license": "MIT", "type": "module", diff --git a/packages/plugins/notifier-discord/CHANGELOG.md b/packages/plugins/notifier-discord/CHANGELOG.md index 80b29fe1b..afd8ce1c5 100644 --- a/packages/plugins/notifier-discord/CHANGELOG.md +++ b/packages/plugins/notifier-discord/CHANGELOG.md @@ -1,5 +1,14 @@ # @aoagents/ao-plugin-notifier-discord +## 0.7.0 + +### Patch Changes + +- Updated dependencies [0f5ae0b] +- Updated dependencies [fe33bb7] +- Updated dependencies [7c46dc9] + - @aoagents/ao-core@0.7.0 + ## 0.2.9 ### Patch Changes diff --git a/packages/plugins/notifier-discord/package.json b/packages/plugins/notifier-discord/package.json index 3a4ad75ea..3a22f14b6 100644 --- a/packages/plugins/notifier-discord/package.json +++ b/packages/plugins/notifier-discord/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-notifier-discord", - "version": "0.2.9", + "version": "0.7.0", "description": "Notifier plugin: Discord webhook notifications", "license": "MIT", "type": "module", diff --git a/packages/plugins/notifier-openclaw/CHANGELOG.md b/packages/plugins/notifier-openclaw/CHANGELOG.md index 636384032..95c254d43 100644 --- a/packages/plugins/notifier-openclaw/CHANGELOG.md +++ b/packages/plugins/notifier-openclaw/CHANGELOG.md @@ -1,5 +1,14 @@ # @aoagents/ao-plugin-notifier-openclaw +## 0.7.0 + +### Patch Changes + +- Updated dependencies [0f5ae0b] +- Updated dependencies [fe33bb7] +- Updated dependencies [7c46dc9] + - @aoagents/ao-core@0.7.0 + ## 0.2.9 ### Patch Changes diff --git a/packages/plugins/notifier-openclaw/package.json b/packages/plugins/notifier-openclaw/package.json index 6a50caf20..f1b07cbd0 100644 --- a/packages/plugins/notifier-openclaw/package.json +++ b/packages/plugins/notifier-openclaw/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-notifier-openclaw", - "version": "0.2.9", + "version": "0.7.0", "description": "Notifier plugin: OpenClaw webhook notifications", "license": "MIT", "type": "module", diff --git a/packages/plugins/notifier-slack/CHANGELOG.md b/packages/plugins/notifier-slack/CHANGELOG.md index c6c180fec..d7f0e386e 100644 --- a/packages/plugins/notifier-slack/CHANGELOG.md +++ b/packages/plugins/notifier-slack/CHANGELOG.md @@ -1,5 +1,47 @@ # @aoagents/ao-plugin-notifier-slack +## 0.7.0 + +### Minor Changes + +- 0f5ae0b: feat: native Windows support + + AO now runs natively on Windows. The default runtime on Windows is `process` + (ConPTY via `node-pty` + named pipes — no tmux, no WSL); the dashboard, + agents (claude-code, codex, kimicode, aider, opencode, cursor), `ao doctor`, + and `ao update` all work out of the box. Each session gets a small detached + pty-host helper that wraps a ConPTY behind `\\.\pipe\ao-pty-`, + registered so `ao stop` can reach it. + + A new cross-platform abstraction layer (`packages/core/src/platform.ts`) + centralises every platform branch behind helpers like `isWindows()`, + `getDefaultRuntime()`, `getShell()`, `killProcessTree()`, `findPidByPort()`, + and `getEnvDefaults()`. Path comparison uses `pathsEqual` / + `canonicalCompareKey` to handle NTFS case-insensitivity. PATH wrappers for + agent plugins (`gh`, `git`) ship as `.cjs` + `.cmd` shims on Windows; + `script-runner` runs `.ps1` siblings of `.sh` scripts via PowerShell. New + `ao-doctor.ps1` / `ao-update.ps1` shipped. + + `ao open` is now cross-platform: it sources sessions from `sm.list()` + instead of `tmux list-sessions` (so `runtime-process` sessions on Windows + appear), and the open action branches per OS — `open-iterm-tab` stays the + macOS path, native handling on Windows and Linux. + + Behaviour on macOS and Linux is unchanged. Every Windows path is gated + behind `isWindows()`; `runtime-tmux` and the bash hook flows are untouched. + + See `docs/CROSS_PLATFORM.md` for the developer reference (helper inventory, + EPERM-vs-ESRCH gotcha, PowerShell-vs-bash differences, pre-merge checklist). + The Windows runtime architecture (pty-host, pipe protocol, registry, sweep, + mux WS Windows branch) is documented in `docs/ARCHITECTURE.md`. + +### Patch Changes + +- Updated dependencies [0f5ae0b] +- Updated dependencies [fe33bb7] +- Updated dependencies [7c46dc9] + - @aoagents/ao-core@0.7.0 + ## 0.6.0 ### Patch Changes diff --git a/packages/plugins/notifier-slack/package.json b/packages/plugins/notifier-slack/package.json index a81db3852..d9f64ccc6 100644 --- a/packages/plugins/notifier-slack/package.json +++ b/packages/plugins/notifier-slack/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-notifier-slack", - "version": "0.6.0", + "version": "0.7.0", "description": "Notifier plugin: Slack", "license": "MIT", "type": "module", diff --git a/packages/plugins/notifier-webhook/CHANGELOG.md b/packages/plugins/notifier-webhook/CHANGELOG.md index 719b15724..86f0189f3 100644 --- a/packages/plugins/notifier-webhook/CHANGELOG.md +++ b/packages/plugins/notifier-webhook/CHANGELOG.md @@ -1,5 +1,47 @@ # @aoagents/ao-plugin-notifier-webhook +## 0.7.0 + +### Minor Changes + +- 0f5ae0b: feat: native Windows support + + AO now runs natively on Windows. The default runtime on Windows is `process` + (ConPTY via `node-pty` + named pipes — no tmux, no WSL); the dashboard, + agents (claude-code, codex, kimicode, aider, opencode, cursor), `ao doctor`, + and `ao update` all work out of the box. Each session gets a small detached + pty-host helper that wraps a ConPTY behind `\\.\pipe\ao-pty-`, + registered so `ao stop` can reach it. + + A new cross-platform abstraction layer (`packages/core/src/platform.ts`) + centralises every platform branch behind helpers like `isWindows()`, + `getDefaultRuntime()`, `getShell()`, `killProcessTree()`, `findPidByPort()`, + and `getEnvDefaults()`. Path comparison uses `pathsEqual` / + `canonicalCompareKey` to handle NTFS case-insensitivity. PATH wrappers for + agent plugins (`gh`, `git`) ship as `.cjs` + `.cmd` shims on Windows; + `script-runner` runs `.ps1` siblings of `.sh` scripts via PowerShell. New + `ao-doctor.ps1` / `ao-update.ps1` shipped. + + `ao open` is now cross-platform: it sources sessions from `sm.list()` + instead of `tmux list-sessions` (so `runtime-process` sessions on Windows + appear), and the open action branches per OS — `open-iterm-tab` stays the + macOS path, native handling on Windows and Linux. + + Behaviour on macOS and Linux is unchanged. Every Windows path is gated + behind `isWindows()`; `runtime-tmux` and the bash hook flows are untouched. + + See `docs/CROSS_PLATFORM.md` for the developer reference (helper inventory, + EPERM-vs-ESRCH gotcha, PowerShell-vs-bash differences, pre-merge checklist). + The Windows runtime architecture (pty-host, pipe protocol, registry, sweep, + mux WS Windows branch) is documented in `docs/ARCHITECTURE.md`. + +### Patch Changes + +- Updated dependencies [0f5ae0b] +- Updated dependencies [fe33bb7] +- Updated dependencies [7c46dc9] + - @aoagents/ao-core@0.7.0 + ## 0.6.0 ### Patch Changes diff --git a/packages/plugins/notifier-webhook/package.json b/packages/plugins/notifier-webhook/package.json index 9da736c7a..3b89439ea 100644 --- a/packages/plugins/notifier-webhook/package.json +++ b/packages/plugins/notifier-webhook/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-notifier-webhook", - "version": "0.6.0", + "version": "0.7.0", "description": "Notifier plugin: generic webhook", "license": "MIT", "type": "module", diff --git a/packages/plugins/runtime-process/CHANGELOG.md b/packages/plugins/runtime-process/CHANGELOG.md index b0ac8d8ec..8164222f5 100644 --- a/packages/plugins/runtime-process/CHANGELOG.md +++ b/packages/plugins/runtime-process/CHANGELOG.md @@ -1,5 +1,47 @@ # @aoagents/ao-plugin-runtime-process +## 0.7.0 + +### Minor Changes + +- 0f5ae0b: feat: native Windows support + + AO now runs natively on Windows. The default runtime on Windows is `process` + (ConPTY via `node-pty` + named pipes — no tmux, no WSL); the dashboard, + agents (claude-code, codex, kimicode, aider, opencode, cursor), `ao doctor`, + and `ao update` all work out of the box. Each session gets a small detached + pty-host helper that wraps a ConPTY behind `\\.\pipe\ao-pty-`, + registered so `ao stop` can reach it. + + A new cross-platform abstraction layer (`packages/core/src/platform.ts`) + centralises every platform branch behind helpers like `isWindows()`, + `getDefaultRuntime()`, `getShell()`, `killProcessTree()`, `findPidByPort()`, + and `getEnvDefaults()`. Path comparison uses `pathsEqual` / + `canonicalCompareKey` to handle NTFS case-insensitivity. PATH wrappers for + agent plugins (`gh`, `git`) ship as `.cjs` + `.cmd` shims on Windows; + `script-runner` runs `.ps1` siblings of `.sh` scripts via PowerShell. New + `ao-doctor.ps1` / `ao-update.ps1` shipped. + + `ao open` is now cross-platform: it sources sessions from `sm.list()` + instead of `tmux list-sessions` (so `runtime-process` sessions on Windows + appear), and the open action branches per OS — `open-iterm-tab` stays the + macOS path, native handling on Windows and Linux. + + Behaviour on macOS and Linux is unchanged. Every Windows path is gated + behind `isWindows()`; `runtime-tmux` and the bash hook flows are untouched. + + See `docs/CROSS_PLATFORM.md` for the developer reference (helper inventory, + EPERM-vs-ESRCH gotcha, PowerShell-vs-bash differences, pre-merge checklist). + The Windows runtime architecture (pty-host, pipe protocol, registry, sweep, + mux WS Windows branch) is documented in `docs/ARCHITECTURE.md`. + +### Patch Changes + +- Updated dependencies [0f5ae0b] +- Updated dependencies [fe33bb7] +- Updated dependencies [7c46dc9] + - @aoagents/ao-core@0.7.0 + ## 0.6.0 ### Patch Changes diff --git a/packages/plugins/runtime-process/package.json b/packages/plugins/runtime-process/package.json index 9609c0f8d..b19d6dcfd 100644 --- a/packages/plugins/runtime-process/package.json +++ b/packages/plugins/runtime-process/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-runtime-process", - "version": "0.6.0", + "version": "0.7.0", "description": "Runtime plugin: child processes", "license": "MIT", "type": "module", diff --git a/packages/plugins/runtime-tmux/CHANGELOG.md b/packages/plugins/runtime-tmux/CHANGELOG.md index 8a6620867..3bd0bd2cf 100644 --- a/packages/plugins/runtime-tmux/CHANGELOG.md +++ b/packages/plugins/runtime-tmux/CHANGELOG.md @@ -1,5 +1,51 @@ # @aoagents/ao-plugin-runtime-tmux +## 0.7.0 + +### Minor Changes + +- 0f5ae0b: feat: native Windows support + + AO now runs natively on Windows. The default runtime on Windows is `process` + (ConPTY via `node-pty` + named pipes — no tmux, no WSL); the dashboard, + agents (claude-code, codex, kimicode, aider, opencode, cursor), `ao doctor`, + and `ao update` all work out of the box. Each session gets a small detached + pty-host helper that wraps a ConPTY behind `\\.\pipe\ao-pty-`, + registered so `ao stop` can reach it. + + A new cross-platform abstraction layer (`packages/core/src/platform.ts`) + centralises every platform branch behind helpers like `isWindows()`, + `getDefaultRuntime()`, `getShell()`, `killProcessTree()`, `findPidByPort()`, + and `getEnvDefaults()`. Path comparison uses `pathsEqual` / + `canonicalCompareKey` to handle NTFS case-insensitivity. PATH wrappers for + agent plugins (`gh`, `git`) ship as `.cjs` + `.cmd` shims on Windows; + `script-runner` runs `.ps1` siblings of `.sh` scripts via PowerShell. New + `ao-doctor.ps1` / `ao-update.ps1` shipped. + + `ao open` is now cross-platform: it sources sessions from `sm.list()` + instead of `tmux list-sessions` (so `runtime-process` sessions on Windows + appear), and the open action branches per OS — `open-iterm-tab` stays the + macOS path, native handling on Windows and Linux. + + Behaviour on macOS and Linux is unchanged. Every Windows path is gated + behind `isWindows()`; `runtime-tmux` and the bash hook flows are untouched. + + See `docs/CROSS_PLATFORM.md` for the developer reference (helper inventory, + EPERM-vs-ESRCH gotcha, PowerShell-vs-bash differences, pre-merge checklist). + The Windows runtime architecture (pty-host, pipe protocol, registry, sweep, + mux WS Windows branch) is documented in `docs/ARCHITECTURE.md`. + +### Patch Changes + +- 845fffd: Tmux sessions no longer die when the agent process inside them exits. When you Ctrl-C the agent in a web terminal, the pane now drops to an interactive `$SHELL` in the workspace dir instead of nuking the tmux session and leaving the dashboard in a phantom "runtime lost" state. The lifecycle manager still detects the agent exit (via `agent.isProcessRunning`) and transitions the session to `agent_process_exited`, but the runtime stays usable so you can run shell commands or manually re-launch the agent. + + Also: the mux-websocket re-attach loop now checks `tmux has-session` before retrying after a PTY exit. When the tmux session is genuinely gone (e.g. `ao stop`), it skips the three doomed `attach-session` spawns from #1640 and notifies the dashboard immediately. (#1756) + +- Updated dependencies [0f5ae0b] +- Updated dependencies [fe33bb7] +- Updated dependencies [7c46dc9] + - @aoagents/ao-core@0.7.0 + ## 0.6.0 ### Patch Changes diff --git a/packages/plugins/runtime-tmux/package.json b/packages/plugins/runtime-tmux/package.json index 4392457d9..891a507b3 100644 --- a/packages/plugins/runtime-tmux/package.json +++ b/packages/plugins/runtime-tmux/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-runtime-tmux", - "version": "0.6.0", + "version": "0.7.0", "description": "Runtime plugin: tmux sessions", "license": "MIT", "type": "module", diff --git a/packages/plugins/scm-github/CHANGELOG.md b/packages/plugins/scm-github/CHANGELOG.md index 915bafc42..5a3c4d57d 100644 --- a/packages/plugins/scm-github/CHANGELOG.md +++ b/packages/plugins/scm-github/CHANGELOG.md @@ -1,5 +1,47 @@ # @aoagents/ao-plugin-scm-github +## 0.7.0 + +### Minor Changes + +- 0f5ae0b: feat: native Windows support + + AO now runs natively on Windows. The default runtime on Windows is `process` + (ConPTY via `node-pty` + named pipes — no tmux, no WSL); the dashboard, + agents (claude-code, codex, kimicode, aider, opencode, cursor), `ao doctor`, + and `ao update` all work out of the box. Each session gets a small detached + pty-host helper that wraps a ConPTY behind `\\.\pipe\ao-pty-`, + registered so `ao stop` can reach it. + + A new cross-platform abstraction layer (`packages/core/src/platform.ts`) + centralises every platform branch behind helpers like `isWindows()`, + `getDefaultRuntime()`, `getShell()`, `killProcessTree()`, `findPidByPort()`, + and `getEnvDefaults()`. Path comparison uses `pathsEqual` / + `canonicalCompareKey` to handle NTFS case-insensitivity. PATH wrappers for + agent plugins (`gh`, `git`) ship as `.cjs` + `.cmd` shims on Windows; + `script-runner` runs `.ps1` siblings of `.sh` scripts via PowerShell. New + `ao-doctor.ps1` / `ao-update.ps1` shipped. + + `ao open` is now cross-platform: it sources sessions from `sm.list()` + instead of `tmux list-sessions` (so `runtime-process` sessions on Windows + appear), and the open action branches per OS — `open-iterm-tab` stays the + macOS path, native handling on Windows and Linux. + + Behaviour on macOS and Linux is unchanged. Every Windows path is gated + behind `isWindows()`; `runtime-tmux` and the bash hook flows are untouched. + + See `docs/CROSS_PLATFORM.md` for the developer reference (helper inventory, + EPERM-vs-ESRCH gotcha, PowerShell-vs-bash differences, pre-merge checklist). + The Windows runtime architecture (pty-host, pipe protocol, registry, sweep, + mux WS Windows branch) is documented in `docs/ARCHITECTURE.md`. + +### Patch Changes + +- Updated dependencies [0f5ae0b] +- Updated dependencies [fe33bb7] +- Updated dependencies [7c46dc9] + - @aoagents/ao-core@0.7.0 + ## 0.6.0 ### Patch Changes diff --git a/packages/plugins/scm-github/package.json b/packages/plugins/scm-github/package.json index e2c9f1de7..3e4b2559d 100644 --- a/packages/plugins/scm-github/package.json +++ b/packages/plugins/scm-github/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-scm-github", - "version": "0.6.0", + "version": "0.7.0", "description": "SCM plugin: GitHub (PRs, CI, reviews)", "license": "MIT", "type": "module", diff --git a/packages/plugins/scm-gitlab/CHANGELOG.md b/packages/plugins/scm-gitlab/CHANGELOG.md index 709102027..d8ea465b4 100644 --- a/packages/plugins/scm-gitlab/CHANGELOG.md +++ b/packages/plugins/scm-gitlab/CHANGELOG.md @@ -1,5 +1,14 @@ # @aoagents/ao-plugin-scm-gitlab +## 0.7.0 + +### Patch Changes + +- Updated dependencies [0f5ae0b] +- Updated dependencies [fe33bb7] +- Updated dependencies [7c46dc9] + - @aoagents/ao-core@0.7.0 + ## 0.2.9 ### Patch Changes diff --git a/packages/plugins/scm-gitlab/package.json b/packages/plugins/scm-gitlab/package.json index 754577095..ea9d8cd78 100644 --- a/packages/plugins/scm-gitlab/package.json +++ b/packages/plugins/scm-gitlab/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-scm-gitlab", - "version": "0.2.9", + "version": "0.7.0", "description": "SCM plugin: GitLab (MRs, CI, reviews)", "license": "MIT", "type": "module", diff --git a/packages/plugins/terminal-iterm2/CHANGELOG.md b/packages/plugins/terminal-iterm2/CHANGELOG.md index 3488b1239..387363f8d 100644 --- a/packages/plugins/terminal-iterm2/CHANGELOG.md +++ b/packages/plugins/terminal-iterm2/CHANGELOG.md @@ -1,5 +1,47 @@ # @aoagents/ao-plugin-terminal-iterm2 +## 0.7.0 + +### Minor Changes + +- 0f5ae0b: feat: native Windows support + + AO now runs natively on Windows. The default runtime on Windows is `process` + (ConPTY via `node-pty` + named pipes — no tmux, no WSL); the dashboard, + agents (claude-code, codex, kimicode, aider, opencode, cursor), `ao doctor`, + and `ao update` all work out of the box. Each session gets a small detached + pty-host helper that wraps a ConPTY behind `\\.\pipe\ao-pty-`, + registered so `ao stop` can reach it. + + A new cross-platform abstraction layer (`packages/core/src/platform.ts`) + centralises every platform branch behind helpers like `isWindows()`, + `getDefaultRuntime()`, `getShell()`, `killProcessTree()`, `findPidByPort()`, + and `getEnvDefaults()`. Path comparison uses `pathsEqual` / + `canonicalCompareKey` to handle NTFS case-insensitivity. PATH wrappers for + agent plugins (`gh`, `git`) ship as `.cjs` + `.cmd` shims on Windows; + `script-runner` runs `.ps1` siblings of `.sh` scripts via PowerShell. New + `ao-doctor.ps1` / `ao-update.ps1` shipped. + + `ao open` is now cross-platform: it sources sessions from `sm.list()` + instead of `tmux list-sessions` (so `runtime-process` sessions on Windows + appear), and the open action branches per OS — `open-iterm-tab` stays the + macOS path, native handling on Windows and Linux. + + Behaviour on macOS and Linux is unchanged. Every Windows path is gated + behind `isWindows()`; `runtime-tmux` and the bash hook flows are untouched. + + See `docs/CROSS_PLATFORM.md` for the developer reference (helper inventory, + EPERM-vs-ESRCH gotcha, PowerShell-vs-bash differences, pre-merge checklist). + The Windows runtime architecture (pty-host, pipe protocol, registry, sweep, + mux WS Windows branch) is documented in `docs/ARCHITECTURE.md`. + +### Patch Changes + +- Updated dependencies [0f5ae0b] +- Updated dependencies [fe33bb7] +- Updated dependencies [7c46dc9] + - @aoagents/ao-core@0.7.0 + ## 0.6.0 ### Patch Changes diff --git a/packages/plugins/terminal-iterm2/package.json b/packages/plugins/terminal-iterm2/package.json index d5ab04077..ffaa46815 100644 --- a/packages/plugins/terminal-iterm2/package.json +++ b/packages/plugins/terminal-iterm2/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-terminal-iterm2", - "version": "0.6.0", + "version": "0.7.0", "description": "Terminal plugin: macOS iTerm2", "license": "MIT", "type": "module", diff --git a/packages/plugins/terminal-web/CHANGELOG.md b/packages/plugins/terminal-web/CHANGELOG.md index 2582dfc67..ae5ab3138 100644 --- a/packages/plugins/terminal-web/CHANGELOG.md +++ b/packages/plugins/terminal-web/CHANGELOG.md @@ -1,5 +1,47 @@ # @aoagents/ao-plugin-terminal-web +## 0.7.0 + +### Minor Changes + +- 0f5ae0b: feat: native Windows support + + AO now runs natively on Windows. The default runtime on Windows is `process` + (ConPTY via `node-pty` + named pipes — no tmux, no WSL); the dashboard, + agents (claude-code, codex, kimicode, aider, opencode, cursor), `ao doctor`, + and `ao update` all work out of the box. Each session gets a small detached + pty-host helper that wraps a ConPTY behind `\\.\pipe\ao-pty-`, + registered so `ao stop` can reach it. + + A new cross-platform abstraction layer (`packages/core/src/platform.ts`) + centralises every platform branch behind helpers like `isWindows()`, + `getDefaultRuntime()`, `getShell()`, `killProcessTree()`, `findPidByPort()`, + and `getEnvDefaults()`. Path comparison uses `pathsEqual` / + `canonicalCompareKey` to handle NTFS case-insensitivity. PATH wrappers for + agent plugins (`gh`, `git`) ship as `.cjs` + `.cmd` shims on Windows; + `script-runner` runs `.ps1` siblings of `.sh` scripts via PowerShell. New + `ao-doctor.ps1` / `ao-update.ps1` shipped. + + `ao open` is now cross-platform: it sources sessions from `sm.list()` + instead of `tmux list-sessions` (so `runtime-process` sessions on Windows + appear), and the open action branches per OS — `open-iterm-tab` stays the + macOS path, native handling on Windows and Linux. + + Behaviour on macOS and Linux is unchanged. Every Windows path is gated + behind `isWindows()`; `runtime-tmux` and the bash hook flows are untouched. + + See `docs/CROSS_PLATFORM.md` for the developer reference (helper inventory, + EPERM-vs-ESRCH gotcha, PowerShell-vs-bash differences, pre-merge checklist). + The Windows runtime architecture (pty-host, pipe protocol, registry, sweep, + mux WS Windows branch) is documented in `docs/ARCHITECTURE.md`. + +### Patch Changes + +- Updated dependencies [0f5ae0b] +- Updated dependencies [fe33bb7] +- Updated dependencies [7c46dc9] + - @aoagents/ao-core@0.7.0 + ## 0.6.0 ### Patch Changes diff --git a/packages/plugins/terminal-web/package.json b/packages/plugins/terminal-web/package.json index b6733f82e..7904c8fa4 100644 --- a/packages/plugins/terminal-web/package.json +++ b/packages/plugins/terminal-web/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-terminal-web", - "version": "0.6.0", + "version": "0.7.0", "description": "Terminal plugin: xterm.js web terminal", "license": "MIT", "type": "module", diff --git a/packages/plugins/tracker-github/CHANGELOG.md b/packages/plugins/tracker-github/CHANGELOG.md index 28df0d28b..98e91e8c4 100644 --- a/packages/plugins/tracker-github/CHANGELOG.md +++ b/packages/plugins/tracker-github/CHANGELOG.md @@ -1,5 +1,47 @@ # @aoagents/ao-plugin-tracker-github +## 0.7.0 + +### Minor Changes + +- 0f5ae0b: feat: native Windows support + + AO now runs natively on Windows. The default runtime on Windows is `process` + (ConPTY via `node-pty` + named pipes — no tmux, no WSL); the dashboard, + agents (claude-code, codex, kimicode, aider, opencode, cursor), `ao doctor`, + and `ao update` all work out of the box. Each session gets a small detached + pty-host helper that wraps a ConPTY behind `\\.\pipe\ao-pty-`, + registered so `ao stop` can reach it. + + A new cross-platform abstraction layer (`packages/core/src/platform.ts`) + centralises every platform branch behind helpers like `isWindows()`, + `getDefaultRuntime()`, `getShell()`, `killProcessTree()`, `findPidByPort()`, + and `getEnvDefaults()`. Path comparison uses `pathsEqual` / + `canonicalCompareKey` to handle NTFS case-insensitivity. PATH wrappers for + agent plugins (`gh`, `git`) ship as `.cjs` + `.cmd` shims on Windows; + `script-runner` runs `.ps1` siblings of `.sh` scripts via PowerShell. New + `ao-doctor.ps1` / `ao-update.ps1` shipped. + + `ao open` is now cross-platform: it sources sessions from `sm.list()` + instead of `tmux list-sessions` (so `runtime-process` sessions on Windows + appear), and the open action branches per OS — `open-iterm-tab` stays the + macOS path, native handling on Windows and Linux. + + Behaviour on macOS and Linux is unchanged. Every Windows path is gated + behind `isWindows()`; `runtime-tmux` and the bash hook flows are untouched. + + See `docs/CROSS_PLATFORM.md` for the developer reference (helper inventory, + EPERM-vs-ESRCH gotcha, PowerShell-vs-bash differences, pre-merge checklist). + The Windows runtime architecture (pty-host, pipe protocol, registry, sweep, + mux WS Windows branch) is documented in `docs/ARCHITECTURE.md`. + +### Patch Changes + +- Updated dependencies [0f5ae0b] +- Updated dependencies [fe33bb7] +- Updated dependencies [7c46dc9] + - @aoagents/ao-core@0.7.0 + ## 0.6.0 ### Patch Changes diff --git a/packages/plugins/tracker-github/package.json b/packages/plugins/tracker-github/package.json index 7a6d8b466..246ea968c 100644 --- a/packages/plugins/tracker-github/package.json +++ b/packages/plugins/tracker-github/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-tracker-github", - "version": "0.6.0", + "version": "0.7.0", "description": "Tracker plugin: GitHub Issues", "license": "MIT", "type": "module", diff --git a/packages/plugins/tracker-gitlab/CHANGELOG.md b/packages/plugins/tracker-gitlab/CHANGELOG.md index f91c361fc..d00ded588 100644 --- a/packages/plugins/tracker-gitlab/CHANGELOG.md +++ b/packages/plugins/tracker-gitlab/CHANGELOG.md @@ -1,5 +1,15 @@ # @aoagents/ao-plugin-tracker-gitlab +## 0.7.0 + +### Patch Changes + +- Updated dependencies [0f5ae0b] +- Updated dependencies [fe33bb7] +- Updated dependencies [7c46dc9] + - @aoagents/ao-core@0.7.0 + - @aoagents/ao-plugin-scm-gitlab@0.7.0 + ## 0.2.9 ### Patch Changes diff --git a/packages/plugins/tracker-gitlab/package.json b/packages/plugins/tracker-gitlab/package.json index 5268be3d2..e2b57bb08 100644 --- a/packages/plugins/tracker-gitlab/package.json +++ b/packages/plugins/tracker-gitlab/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-tracker-gitlab", - "version": "0.2.9", + "version": "0.7.0", "description": "Tracker plugin: GitLab Issues", "license": "MIT", "type": "module", diff --git a/packages/plugins/tracker-linear/CHANGELOG.md b/packages/plugins/tracker-linear/CHANGELOG.md index bf035d188..520c8e1fc 100644 --- a/packages/plugins/tracker-linear/CHANGELOG.md +++ b/packages/plugins/tracker-linear/CHANGELOG.md @@ -1,5 +1,47 @@ # @aoagents/ao-plugin-tracker-linear +## 0.7.0 + +### Minor Changes + +- 0f5ae0b: feat: native Windows support + + AO now runs natively on Windows. The default runtime on Windows is `process` + (ConPTY via `node-pty` + named pipes — no tmux, no WSL); the dashboard, + agents (claude-code, codex, kimicode, aider, opencode, cursor), `ao doctor`, + and `ao update` all work out of the box. Each session gets a small detached + pty-host helper that wraps a ConPTY behind `\\.\pipe\ao-pty-`, + registered so `ao stop` can reach it. + + A new cross-platform abstraction layer (`packages/core/src/platform.ts`) + centralises every platform branch behind helpers like `isWindows()`, + `getDefaultRuntime()`, `getShell()`, `killProcessTree()`, `findPidByPort()`, + and `getEnvDefaults()`. Path comparison uses `pathsEqual` / + `canonicalCompareKey` to handle NTFS case-insensitivity. PATH wrappers for + agent plugins (`gh`, `git`) ship as `.cjs` + `.cmd` shims on Windows; + `script-runner` runs `.ps1` siblings of `.sh` scripts via PowerShell. New + `ao-doctor.ps1` / `ao-update.ps1` shipped. + + `ao open` is now cross-platform: it sources sessions from `sm.list()` + instead of `tmux list-sessions` (so `runtime-process` sessions on Windows + appear), and the open action branches per OS — `open-iterm-tab` stays the + macOS path, native handling on Windows and Linux. + + Behaviour on macOS and Linux is unchanged. Every Windows path is gated + behind `isWindows()`; `runtime-tmux` and the bash hook flows are untouched. + + See `docs/CROSS_PLATFORM.md` for the developer reference (helper inventory, + EPERM-vs-ESRCH gotcha, PowerShell-vs-bash differences, pre-merge checklist). + The Windows runtime architecture (pty-host, pipe protocol, registry, sweep, + mux WS Windows branch) is documented in `docs/ARCHITECTURE.md`. + +### Patch Changes + +- Updated dependencies [0f5ae0b] +- Updated dependencies [fe33bb7] +- Updated dependencies [7c46dc9] + - @aoagents/ao-core@0.7.0 + ## 0.6.0 ### Patch Changes diff --git a/packages/plugins/tracker-linear/package.json b/packages/plugins/tracker-linear/package.json index 8c8e247d8..46eadda1f 100644 --- a/packages/plugins/tracker-linear/package.json +++ b/packages/plugins/tracker-linear/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-tracker-linear", - "version": "0.6.0", + "version": "0.7.0", "description": "Tracker plugin: Linear", "license": "MIT", "type": "module", diff --git a/packages/plugins/workspace-clone/CHANGELOG.md b/packages/plugins/workspace-clone/CHANGELOG.md index f5c8afc36..35f6be58c 100644 --- a/packages/plugins/workspace-clone/CHANGELOG.md +++ b/packages/plugins/workspace-clone/CHANGELOG.md @@ -1,5 +1,47 @@ # @aoagents/ao-plugin-workspace-clone +## 0.7.0 + +### Minor Changes + +- 0f5ae0b: feat: native Windows support + + AO now runs natively on Windows. The default runtime on Windows is `process` + (ConPTY via `node-pty` + named pipes — no tmux, no WSL); the dashboard, + agents (claude-code, codex, kimicode, aider, opencode, cursor), `ao doctor`, + and `ao update` all work out of the box. Each session gets a small detached + pty-host helper that wraps a ConPTY behind `\\.\pipe\ao-pty-`, + registered so `ao stop` can reach it. + + A new cross-platform abstraction layer (`packages/core/src/platform.ts`) + centralises every platform branch behind helpers like `isWindows()`, + `getDefaultRuntime()`, `getShell()`, `killProcessTree()`, `findPidByPort()`, + and `getEnvDefaults()`. Path comparison uses `pathsEqual` / + `canonicalCompareKey` to handle NTFS case-insensitivity. PATH wrappers for + agent plugins (`gh`, `git`) ship as `.cjs` + `.cmd` shims on Windows; + `script-runner` runs `.ps1` siblings of `.sh` scripts via PowerShell. New + `ao-doctor.ps1` / `ao-update.ps1` shipped. + + `ao open` is now cross-platform: it sources sessions from `sm.list()` + instead of `tmux list-sessions` (so `runtime-process` sessions on Windows + appear), and the open action branches per OS — `open-iterm-tab` stays the + macOS path, native handling on Windows and Linux. + + Behaviour on macOS and Linux is unchanged. Every Windows path is gated + behind `isWindows()`; `runtime-tmux` and the bash hook flows are untouched. + + See `docs/CROSS_PLATFORM.md` for the developer reference (helper inventory, + EPERM-vs-ESRCH gotcha, PowerShell-vs-bash differences, pre-merge checklist). + The Windows runtime architecture (pty-host, pipe protocol, registry, sweep, + mux WS Windows branch) is documented in `docs/ARCHITECTURE.md`. + +### Patch Changes + +- Updated dependencies [0f5ae0b] +- Updated dependencies [fe33bb7] +- Updated dependencies [7c46dc9] + - @aoagents/ao-core@0.7.0 + ## 0.6.0 ### Patch Changes diff --git a/packages/plugins/workspace-clone/package.json b/packages/plugins/workspace-clone/package.json index f5f04461e..16d00059c 100644 --- a/packages/plugins/workspace-clone/package.json +++ b/packages/plugins/workspace-clone/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-workspace-clone", - "version": "0.6.0", + "version": "0.7.0", "description": "Workspace plugin: git clone", "license": "MIT", "type": "module", diff --git a/packages/plugins/workspace-worktree/CHANGELOG.md b/packages/plugins/workspace-worktree/CHANGELOG.md index b8572df3a..096f58097 100644 --- a/packages/plugins/workspace-worktree/CHANGELOG.md +++ b/packages/plugins/workspace-worktree/CHANGELOG.md @@ -1,5 +1,48 @@ # @aoagents/ao-plugin-workspace-worktree +## 0.7.0 + +### Minor Changes + +- 0f5ae0b: feat: native Windows support + + AO now runs natively on Windows. The default runtime on Windows is `process` + (ConPTY via `node-pty` + named pipes — no tmux, no WSL); the dashboard, + agents (claude-code, codex, kimicode, aider, opencode, cursor), `ao doctor`, + and `ao update` all work out of the box. Each session gets a small detached + pty-host helper that wraps a ConPTY behind `\\.\pipe\ao-pty-`, + registered so `ao stop` can reach it. + + A new cross-platform abstraction layer (`packages/core/src/platform.ts`) + centralises every platform branch behind helpers like `isWindows()`, + `getDefaultRuntime()`, `getShell()`, `killProcessTree()`, `findPidByPort()`, + and `getEnvDefaults()`. Path comparison uses `pathsEqual` / + `canonicalCompareKey` to handle NTFS case-insensitivity. PATH wrappers for + agent plugins (`gh`, `git`) ship as `.cjs` + `.cmd` shims on Windows; + `script-runner` runs `.ps1` siblings of `.sh` scripts via PowerShell. New + `ao-doctor.ps1` / `ao-update.ps1` shipped. + + `ao open` is now cross-platform: it sources sessions from `sm.list()` + instead of `tmux list-sessions` (so `runtime-process` sessions on Windows + appear), and the open action branches per OS — `open-iterm-tab` stays the + macOS path, native handling on Windows and Linux. + + Behaviour on macOS and Linux is unchanged. Every Windows path is gated + behind `isWindows()`; `runtime-tmux` and the bash hook flows are untouched. + + See `docs/CROSS_PLATFORM.md` for the developer reference (helper inventory, + EPERM-vs-ESRCH gotcha, PowerShell-vs-bash differences, pre-merge checklist). + The Windows runtime architecture (pty-host, pipe protocol, registry, sweep, + mux WS Windows branch) is documented in `docs/ARCHITECTURE.md`. + +### Patch Changes + +- a33b2ba: Restoring a session whose worktree directory was cleaned up but whose branch still existed locally would 422 with `fatal: a branch named already exists`. The recovery path in `workspace.restore()` unconditionally fell through to `git worktree add -b`, even when the local branch was present (which `destroy()` deliberately preserves). The catch now checks for the local branch and re-attaches it without `-b`/`-B`, preserving the session's commits. (#1741) +- Updated dependencies [0f5ae0b] +- Updated dependencies [fe33bb7] +- Updated dependencies [7c46dc9] + - @aoagents/ao-core@0.7.0 + ## 0.6.0 ### Patch Changes diff --git a/packages/plugins/workspace-worktree/package.json b/packages/plugins/workspace-worktree/package.json index 4167b5ebe..4f2dab220 100644 --- a/packages/plugins/workspace-worktree/package.json +++ b/packages/plugins/workspace-worktree/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-workspace-worktree", - "version": "0.6.0", + "version": "0.7.0", "description": "Workspace plugin: git worktrees", "license": "MIT", "type": "module", diff --git a/packages/web/CHANGELOG.md b/packages/web/CHANGELOG.md index 9a91af54f..9b84260d7 100644 --- a/packages/web/CHANGELOG.md +++ b/packages/web/CHANGELOG.md @@ -1,5 +1,106 @@ # @aoagents/ao-web +## 0.7.0 + +### Minor Changes + +- 0f5ae0b: feat: native Windows support + + AO now runs natively on Windows. The default runtime on Windows is `process` + (ConPTY via `node-pty` + named pipes — no tmux, no WSL); the dashboard, + agents (claude-code, codex, kimicode, aider, opencode, cursor), `ao doctor`, + and `ao update` all work out of the box. Each session gets a small detached + pty-host helper that wraps a ConPTY behind `\\.\pipe\ao-pty-`, + registered so `ao stop` can reach it. + + A new cross-platform abstraction layer (`packages/core/src/platform.ts`) + centralises every platform branch behind helpers like `isWindows()`, + `getDefaultRuntime()`, `getShell()`, `killProcessTree()`, `findPidByPort()`, + and `getEnvDefaults()`. Path comparison uses `pathsEqual` / + `canonicalCompareKey` to handle NTFS case-insensitivity. PATH wrappers for + agent plugins (`gh`, `git`) ship as `.cjs` + `.cmd` shims on Windows; + `script-runner` runs `.ps1` siblings of `.sh` scripts via PowerShell. New + `ao-doctor.ps1` / `ao-update.ps1` shipped. + + `ao open` is now cross-platform: it sources sessions from `sm.list()` + instead of `tmux list-sessions` (so `runtime-process` sessions on Windows + appear), and the open action branches per OS — `open-iterm-tab` stays the + macOS path, native handling on Windows and Linux. + + Behaviour on macOS and Linux is unchanged. Every Windows path is gated + behind `isWindows()`; `runtime-tmux` and the bash hook flows are untouched. + + See `docs/CROSS_PLATFORM.md` for the developer reference (helper inventory, + EPERM-vs-ESRCH gotcha, PowerShell-vs-bash differences, pre-merge checklist). + The Windows runtime architecture (pty-host, pipe protocol, registry, sweep, + mux WS Windows branch) is documented in `docs/ARCHITECTURE.md`. + +- 7c46dc9: feat(release): weekly release train — channels, onboarding, dashboard banner, cron + + Ships the full release pipeline described in `release-process.html`: + - **Cron-driven nightly canary.** `.github/workflows/canary.yml` triggers via + `schedule: '0 18 * * 5,6,0,1,2'` (23:30 IST Fri–Tue) plus `workflow_dispatch`. + Bake window (Wed–Thu) pauses scheduled nightlies; the captain re-cuts via + workflow_dispatch when a fix lands. Stable `release.yml` publishes via + `changesets/action`. `.changeset/config.json` adds the snapshot template + (`{tag}-{commit}`). `@aoagents/ao-web` stays in the linked group and ships + alongside `@aoagents/ao-cli` (it's a workspace:_ runtime dep, so marking it + private would 404 every `npm install -g @aoagents/ao` after publish). + `scripts/check-publishable-deps.mjs` runs in both release.yml and canary.yml + before the publish step and fails CI if a publishable package depends on a + `private: true` package via workspace:_. + - **Update channels.** New `updateChannel` field in the global config schema + (`stable | nightly | manual`, default `manual` so existing users see no + surprise installs). `update-check.ts` reads `dist-tags[channel]` from the + npm registry, compares prerelease versions segment-by-segment so SHA-suffixed + nightlies sort correctly, and skips notices entirely on `manual`. + - **Soft auto-install + active-session guard.** On stable/nightly, `ao update` + skips the confirm prompt and just installs. Before installing it lists + sessions and refuses with `N session(s) active. Run \`ao stop\` first.`if +any are in`working`/`idle`/`needs_input`/`stuck`. Same guard duplicated +in `POST /api/update` so the dashboard returns a structured 409. + - **Onboarding question.** `ao start` prompts once for the channel if unset; + dismissal persists `manual`. `ao config set updateChannel ` (and + `installMethod`) lets users change it later. + - **Dashboard banner.** `GET /api/version` reads the same cache file as the + CLI. `UpdateBanner` (Tailwind only, `var(--color-*)` tokens) appears at the + top of the dashboard when `isOutdated`. Click POSTs to `/api/update`; + dismissal persists per-version in `localStorage`. + - **Bun + Homebrew detection.** New install-method classifiers for + `~/.bun/install/global/` (auto-installs `bun add -g @aoagents/ao@`) + and `/Cellar/ao/` (notice only — `brew upgrade ao` to avoid clobbering + brew's symlinks). `installMethod` config field overrides path detection. + + Supersedes #1525 (incorporates the canary + release infrastructure with the + cron / no-stale-SHA-guard / no-merged-PR-comment modifications called out in + the design doc). + +- 71326bc: Add inline rename for worker sessions in the sidebar. Each worker row now shows a small pencil button on hover; clicking it swaps the label for an input pre-filled with the current title. Enter persists via `PATCH /api/sessions/:id`, Escape cancels, and an empty value reverts the session to its default title. The rename is written to the existing `displayName` metadata field and is now the highest-priority signal in `getSessionTitle`, so a user-chosen label always beats PR/issue titles. The session ID (`ao-N`) remains the canonical identifier — only display surfaces change. (#1647) + +### Patch Changes + +- 845fffd: Tmux sessions no longer die when the agent process inside them exits. When you Ctrl-C the agent in a web terminal, the pane now drops to an interactive `$SHELL` in the workspace dir instead of nuking the tmux session and leaving the dashboard in a phantom "runtime lost" state. The lifecycle manager still detects the agent exit (via `agent.isProcessRunning`) and transitions the session to `agent_process_exited`, but the runtime stays usable so you can run shell commands or manually re-launch the agent. + + Also: the mux-websocket re-attach loop now checks `tmux has-session` before retrying after a PTY exit. When the tmux session is genuinely gone (e.g. `ao stop`), it skips the three doomed `attach-session` spawns from #1640 and notifies the dashboard immediately. (#1756) + +- Updated dependencies [845fffd] +- Updated dependencies [0f5ae0b] +- Updated dependencies [fe33bb7] +- Updated dependencies [7c46dc9] +- Updated dependencies [a33b2ba] + - @aoagents/ao-plugin-runtime-tmux@0.7.0 + - @aoagents/ao-core@0.7.0 + - @aoagents/ao-plugin-runtime-process@0.7.0 + - @aoagents/ao-plugin-agent-claude-code@0.7.0 + - @aoagents/ao-plugin-agent-codex@0.7.0 + - @aoagents/ao-plugin-agent-opencode@0.7.0 + - @aoagents/ao-plugin-workspace-worktree@0.7.0 + - @aoagents/ao-plugin-tracker-github@0.7.0 + - @aoagents/ao-plugin-tracker-linear@0.7.0 + - @aoagents/ao-plugin-scm-github@0.7.0 + - @aoagents/ao-plugin-agent-cursor@0.7.0 + - @aoagents/ao-plugin-agent-kimicode@0.7.0 + ## 0.6.0 ### Patch Changes diff --git a/packages/web/package.json b/packages/web/package.json index bbf38cf7c..d1c33b02c 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-web", - "version": "0.6.0", + "version": "0.7.0", "description": "Web dashboard for agent-orchestrator", "license": "MIT", "type": "module", From 89ad185195e7dd45d97c9c1befbf9ac1fa4966a4 Mon Sep 17 00:00:00 2001 From: i-trytoohard Date: Thu, 14 May 2026 21:50:39 +0530 Subject: [PATCH 4/9] fix(agent-plugins,lifecycle): distinguish indeterminate probe from "not found" + bump ps timeout (closes #1838) (#1839) * fix(agent-plugins): preserve sessions on indeterminate probes * fix(core): gate recovery activity on successful process probe * refactor(core): centralize process probe indeterminate handling --------- Co-authored-by: i-trytoohard <193449657+i-trytoohard@users.noreply.github.com> --- packages/ao/CHANGELOG.md | 12 ++- packages/ao/package.json | 2 +- packages/cli/CHANGELOG.md | 35 +++++++- packages/cli/package.json | 2 +- packages/core/CHANGELOG.md | 12 ++- packages/core/package.json | 2 +- .../src/__tests__/lifecycle-manager.test.ts | 30 +++++++ .../__tests__/session-manager/query.test.ts | 40 +++++++++ packages/core/src/lifecycle-manager.ts | 48 ++++++++++- packages/core/src/recovery/validator.ts | 12 ++- packages/core/src/session-manager.ts | 22 ++++- packages/core/src/types.ts | 21 ++++- .../src/agent-aider.integration.test.ts | 16 ++-- .../src/agent-claude-code.integration.test.ts | 16 ++-- .../src/agent-codex.integration.test.ts | 16 ++-- .../src/agent-opencode.integration.test.ts | 16 ++-- packages/plugins/agent-aider/CHANGELOG.md | 11 +++ packages/plugins/agent-aider/package.json | 2 +- .../plugins/agent-aider/src/index.test.ts | 51 +++++++---- packages/plugins/agent-aider/src/index.ts | 8 +- .../plugins/agent-claude-code/CHANGELOG.md | 11 +++ .../plugins/agent-claude-code/package.json | 2 +- .../agent-claude-code/src/index.test.ts | 50 ++++++----- .../plugins/agent-claude-code/src/index.ts | 58 +++++++------ packages/plugins/agent-codex/CHANGELOG.md | 11 +++ packages/plugins/agent-codex/package.json | 2 +- .../plugins/agent-codex/src/index.test.ts | 21 ++++- packages/plugins/agent-codex/src/index.ts | 8 +- packages/plugins/agent-cursor/CHANGELOG.md | 7 ++ packages/plugins/agent-cursor/package.json | 2 +- packages/plugins/agent-kimicode/CHANGELOG.md | 7 ++ packages/plugins/agent-kimicode/package.json | 2 +- packages/plugins/agent-opencode/CHANGELOG.md | 11 +++ packages/plugins/agent-opencode/package.json | 2 +- .../plugins/agent-opencode/src/index.test.ts | 86 ++++++++++++------- packages/plugins/agent-opencode/src/index.ts | 10 ++- .../plugins/notifier-composio/CHANGELOG.md | 7 ++ .../plugins/notifier-composio/package.json | 2 +- .../plugins/notifier-desktop/CHANGELOG.md | 7 ++ .../plugins/notifier-desktop/package.json | 2 +- .../plugins/notifier-discord/CHANGELOG.md | 7 ++ .../plugins/notifier-discord/package.json | 2 +- .../plugins/notifier-openclaw/CHANGELOG.md | 7 ++ .../plugins/notifier-openclaw/package.json | 2 +- packages/plugins/notifier-slack/CHANGELOG.md | 7 ++ packages/plugins/notifier-slack/package.json | 2 +- .../plugins/notifier-webhook/CHANGELOG.md | 7 ++ .../plugins/notifier-webhook/package.json | 2 +- packages/plugins/runtime-process/CHANGELOG.md | 7 ++ packages/plugins/runtime-process/package.json | 2 +- packages/plugins/runtime-tmux/CHANGELOG.md | 7 ++ packages/plugins/runtime-tmux/package.json | 2 +- packages/plugins/scm-github/CHANGELOG.md | 7 ++ packages/plugins/scm-github/package.json | 2 +- packages/plugins/scm-gitlab/CHANGELOG.md | 7 ++ packages/plugins/scm-gitlab/package.json | 2 +- packages/plugins/terminal-iterm2/CHANGELOG.md | 7 ++ packages/plugins/terminal-iterm2/package.json | 2 +- packages/plugins/terminal-web/CHANGELOG.md | 7 ++ packages/plugins/terminal-web/package.json | 2 +- packages/plugins/tracker-github/CHANGELOG.md | 7 ++ packages/plugins/tracker-github/package.json | 2 +- packages/plugins/tracker-gitlab/CHANGELOG.md | 8 ++ packages/plugins/tracker-gitlab/package.json | 2 +- packages/plugins/tracker-linear/CHANGELOG.md | 7 ++ packages/plugins/tracker-linear/package.json | 2 +- packages/plugins/workspace-clone/CHANGELOG.md | 7 ++ packages/plugins/workspace-clone/package.json | 2 +- .../plugins/workspace-worktree/CHANGELOG.md | 7 ++ .../plugins/workspace-worktree/package.json | 2 +- packages/web/CHANGELOG.md | 24 +++++- packages/web/package.json | 2 +- 72 files changed, 660 insertions(+), 184 deletions(-) diff --git a/packages/ao/CHANGELOG.md b/packages/ao/CHANGELOG.md index 60f468609..8c7303359 100644 --- a/packages/ao/CHANGELOG.md +++ b/packages/ao/CHANGELOG.md @@ -1,5 +1,11 @@ # @aoagents/ao +## 0.8.0 + +### Patch Changes + +- @aoagents/ao-cli@0.8.0 + ## 0.7.0 ### Minor Changes @@ -41,14 +47,14 @@ - **Cron-driven nightly canary.** `.github/workflows/canary.yml` triggers via `schedule: '0 18 * * 5,6,0,1,2'` (23:30 IST Fri–Tue) plus `workflow_dispatch`. Bake window (Wed–Thu) pauses scheduled nightlies; the captain re-cuts via - workflow_dispatch when a fix lands. Stable `release.yml` publishes via + workflow*dispatch when a fix lands. Stable `release.yml` publishes via `changesets/action`. `.changeset/config.json` adds the snapshot template (`{tag}-{commit}`). `@aoagents/ao-web` stays in the linked group and ships - alongside `@aoagents/ao-cli` (it's a workspace:_ runtime dep, so marking it + alongside `@aoagents/ao-cli` (it's a workspace:* runtime dep, so marking it private would 404 every `npm install -g @aoagents/ao` after publish). `scripts/check-publishable-deps.mjs` runs in both release.yml and canary.yml before the publish step and fails CI if a publishable package depends on a - `private: true` package via workspace:_. + `private: true` package via workspace:\_. - **Update channels.** New `updateChannel` field in the global config schema (`stable | nightly | manual`, default `manual` so existing users see no surprise installs). `update-check.ts` reads `dist-tags[channel]` from the diff --git a/packages/ao/package.json b/packages/ao/package.json index ee54fb5f2..ab301e4e8 100644 --- a/packages/ao/package.json +++ b/packages/ao/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao", - "version": "0.7.0", + "version": "0.8.0", "description": "Orchestrate parallel AI coding agents — global CLI wrapper", "license": "MIT", "type": "module", diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index 2bddda0ec..eab75ff1e 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,34 @@ # @aoagents/ao-cli +## 0.8.0 + +### Patch Changes + +- Updated dependencies + - @aoagents/ao-core@0.8.0 + - @aoagents/ao-plugin-agent-claude-code@0.8.0 + - @aoagents/ao-plugin-agent-codex@0.8.0 + - @aoagents/ao-plugin-agent-aider@0.8.0 + - @aoagents/ao-plugin-agent-opencode@0.8.0 + - @aoagents/ao-plugin-agent-cursor@0.8.0 + - @aoagents/ao-plugin-agent-kimicode@0.8.0 + - @aoagents/ao-plugin-notifier-composio@0.8.0 + - @aoagents/ao-plugin-notifier-desktop@0.8.0 + - @aoagents/ao-plugin-notifier-discord@0.8.0 + - @aoagents/ao-plugin-notifier-openclaw@0.8.0 + - @aoagents/ao-plugin-notifier-slack@0.8.0 + - @aoagents/ao-plugin-notifier-webhook@0.8.0 + - @aoagents/ao-plugin-runtime-process@0.8.0 + - @aoagents/ao-plugin-runtime-tmux@0.8.0 + - @aoagents/ao-plugin-scm-github@0.8.0 + - @aoagents/ao-plugin-terminal-iterm2@0.8.0 + - @aoagents/ao-plugin-terminal-web@0.8.0 + - @aoagents/ao-plugin-tracker-github@0.8.0 + - @aoagents/ao-plugin-tracker-linear@0.8.0 + - @aoagents/ao-plugin-workspace-clone@0.8.0 + - @aoagents/ao-plugin-workspace-worktree@0.8.0 + - @aoagents/ao-web@0.8.0 + ## 0.7.0 ### Minor Changes @@ -42,14 +71,14 @@ - **Cron-driven nightly canary.** `.github/workflows/canary.yml` triggers via `schedule: '0 18 * * 5,6,0,1,2'` (23:30 IST Fri–Tue) plus `workflow_dispatch`. Bake window (Wed–Thu) pauses scheduled nightlies; the captain re-cuts via - workflow_dispatch when a fix lands. Stable `release.yml` publishes via + workflow*dispatch when a fix lands. Stable `release.yml` publishes via `changesets/action`. `.changeset/config.json` adds the snapshot template (`{tag}-{commit}`). `@aoagents/ao-web` stays in the linked group and ships - alongside `@aoagents/ao-cli` (it's a workspace:_ runtime dep, so marking it + alongside `@aoagents/ao-cli` (it's a workspace:* runtime dep, so marking it private would 404 every `npm install -g @aoagents/ao` after publish). `scripts/check-publishable-deps.mjs` runs in both release.yml and canary.yml before the publish step and fails CI if a publishable package depends on a - `private: true` package via workspace:_. + `private: true` package via workspace:\_. - **Update channels.** New `updateChannel` field in the global config schema (`stable | nightly | manual`, default `manual` so existing users see no surprise installs). `update-check.ts` reads `dist-tags[channel]` from the diff --git a/packages/cli/package.json b/packages/cli/package.json index 03473925f..99bd6290d 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-cli", - "version": "0.7.0", + "version": "0.8.0", "description": "CLI for agent-orchestrator — the `ao` command", "license": "MIT", "type": "module", diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index dccb3af70..31a717031 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,11 @@ # @aoagents/ao-core +## 0.8.0 + +### Minor Changes + +- Distinguish indeterminate agent process probes from definitive process-missing results, and raise ps probe timeouts to avoid bulk runtime_lost terminations when ps or tmux cannot return a reliable verdict. + ## 0.7.0 ### Minor Changes @@ -42,14 +48,14 @@ - **Cron-driven nightly canary.** `.github/workflows/canary.yml` triggers via `schedule: '0 18 * * 5,6,0,1,2'` (23:30 IST Fri–Tue) plus `workflow_dispatch`. Bake window (Wed–Thu) pauses scheduled nightlies; the captain re-cuts via - workflow_dispatch when a fix lands. Stable `release.yml` publishes via + workflow*dispatch when a fix lands. Stable `release.yml` publishes via `changesets/action`. `.changeset/config.json` adds the snapshot template (`{tag}-{commit}`). `@aoagents/ao-web` stays in the linked group and ships - alongside `@aoagents/ao-cli` (it's a workspace:_ runtime dep, so marking it + alongside `@aoagents/ao-cli` (it's a workspace:* runtime dep, so marking it private would 404 every `npm install -g @aoagents/ao` after publish). `scripts/check-publishable-deps.mjs` runs in both release.yml and canary.yml before the publish step and fails CI if a publishable package depends on a - `private: true` package via workspace:_. + `private: true` package via workspace:\_. - **Update channels.** New `updateChannel` field in the global config schema (`stable | nightly | manual`, default `manual` so existing users see no surprise installs). `update-check.ts` reads `dist-tags[channel]` from the diff --git a/packages/core/package.json b/packages/core/package.json index ad874cdb8..0b83ed14b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-core", - "version": "0.7.0", + "version": "0.8.0", "description": "Core library — types, config, session manager, lifecycle manager, event bus", "license": "MIT", "type": "module", diff --git a/packages/core/src/__tests__/lifecycle-manager.test.ts b/packages/core/src/__tests__/lifecycle-manager.test.ts index eaf2e0c35..0f304dc21 100644 --- a/packages/core/src/__tests__/lifecycle-manager.test.ts +++ b/packages/core/src/__tests__/lifecycle-manager.test.ts @@ -697,6 +697,36 @@ describe("check (single session)", () => { expect(lm.getStates().get("app-1")).toBe("working"); }); + it("leaves lifecycle metadata untouched when process probe is indeterminate", async () => { + vi.mocked(plugins.runtime.isAlive).mockResolvedValue(true); + vi.mocked(plugins.agent.getActivityState).mockResolvedValue(null); + vi.mocked(plugins.agent.isProcessRunning).mockResolvedValue("indeterminate"); + + const session = makeSession({ + status: "working", + workspacePath: null, + metadata: { + lifecycleEvidence: "previous_evidence", + detectingAttempts: "2", + }, + }); + const lifecycle = JSON.stringify(session.lifecycle); + const lm = setupCheck("app-1", { + session, + metaOverrides: { + lifecycle, + lifecycleEvidence: "previous_evidence", + detectingAttempts: "2", + }, + }); + const before = readMetadataRaw(env.sessionsDir, "app-1"); + + await lm.check("app-1"); + + expect(readMetadataRaw(env.sessionsDir, "app-1")).toEqual(before); + expect(lm.getStates().get("app-1")).toBe("working"); + }); + it("does not mark a session stuck from terminal-only idle evidence without a timestamp", async () => { config.reactions = { "agent-stuck": { auto: true, action: "notify", threshold: "1m" }, diff --git a/packages/core/src/__tests__/session-manager/query.test.ts b/packages/core/src/__tests__/session-manager/query.test.ts index c881ead05..e8acb988d 100644 --- a/packages/core/src/__tests__/session-manager/query.test.ts +++ b/packages/core/src/__tests__/session-manager/query.test.ts @@ -9,6 +9,7 @@ import { writeMetadata, readMetadataRaw, } from "../../metadata.js"; +import { createInitialCanonicalLifecycle } from "../../lifecycle-state.js"; import type { OrchestratorConfig, PluginRegistry, @@ -401,6 +402,45 @@ describe("list", () => { expect(sessions[0].activitySignal.state).toBe("null"); }); + it("does not persist runtime_lost from list() when agent activity probe is indeterminate", async () => { + const agentWithIndeterminateProbe: Agent = { + ...mockAgent, + getActivityState: vi.fn().mockResolvedValue(null), + isProcessRunning: vi.fn().mockResolvedValue("indeterminate"), + }; + const registryWithIndeterminateProbe: PluginRegistry = { + ...mockRegistry, + get: vi.fn().mockImplementation((slot: string) => { + if (slot === "runtime") return mockRuntime; + if (slot === "agent") return agentWithIndeterminateProbe; + return null; + }), + }; + + const runtimeHandle = makeHandle("rt-1"); + const lifecycle = createInitialCanonicalLifecycle("worker"); + lifecycle.session.state = "working"; + lifecycle.session.reason = "task_in_progress"; + lifecycle.session.startedAt = lifecycle.session.lastTransitionAt; + lifecycle.runtime.handle = runtimeHandle; + + writeMetadata(sessionsDir, "app-1", { + worktree: "/tmp", + branch: "a", + status: "working", + project: "my-app", + runtimeHandle, + lifecycle, + }); + const before = readMetadataRaw(sessionsDir, "app-1"); + + const sm = createSessionManager({ config, registry: registryWithIndeterminateProbe }); + const sessions = await sm.list(); + + expect(sessions[0].status).toBe("working"); + expect(readMetadataRaw(sessionsDir, "app-1")).toEqual(before); + }); + it("marks terminal fallback-free stale activity explicitly when timing is missing", async () => { const agentWithIdleNoTimestamp: Agent = { ...mockAgent, diff --git a/packages/core/src/lifecycle-manager.ts b/packages/core/src/lifecycle-manager.ts index 3ae00cf27..d8562fe4c 100644 --- a/packages/core/src/lifecycle-manager.ts +++ b/packages/core/src/lifecycle-manager.ts @@ -41,6 +41,8 @@ import { type CICheck, type ReviewComment, type ReviewSummary, + type ProcessProbeResult, + isProcessProbeIndeterminate, } from "./types.js"; import { buildLifecycleMetadataPatch, @@ -387,6 +389,8 @@ interface DeterminedStatus { status: SessionStatus; evidence: string; detectingAttempts: number; + /** True when probes produced no reliable verdict and lifecycle metadata must remain untouched. */ + skipMetadataWrite?: boolean; /** ISO timestamp when detecting first started. */ detectingStartedAt?: string; /** Hash of evidence for unchanged-evidence detection. */ @@ -396,6 +400,14 @@ interface DeterminedStatus { interface ProbeResult { state: "alive" | "dead" | "unknown"; failed: boolean; + indeterminate?: boolean; +} + +function processProbeResultToProbeResult(result: ProcessProbeResult): ProbeResult { + if (isProcessProbeIndeterminate(result)) { + return { state: "unknown", failed: false, indeterminate: true }; + } + return { state: result ? "alive" : "dead", failed: false }; } function splitEvidenceSignals(evidence: string): string[] { @@ -1062,8 +1074,8 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan try { const processAlive = await agent.isProcessRunning(session.runtimeHandle); - processProbe = { state: processAlive ? "alive" : "dead", failed: false }; - if (!processAlive) { + processProbe = processProbeResultToProbeResult(processAlive); + if (processAlive === false) { lifecycle.runtime.state = "exited"; lifecycle.runtime.reason = "process_missing"; lifecycle.runtime.lastObservedAt = nowIso; @@ -1129,14 +1141,15 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan if ( processProbe.state === "unknown" && + !processProbe.indeterminate && session.runtimeHandle && canProbeRuntimeIdentity && agent ) { try { const processAlive = await agent.isProcessRunning(session.runtimeHandle); - processProbe = { state: processAlive ? "alive" : "dead", failed: false }; - if (!processAlive) { + processProbe = processProbeResultToProbeResult(processAlive); + if (processAlive === false) { lifecycle.runtime.state = "exited"; lifecycle.runtime.reason = "process_missing"; lifecycle.runtime.lastObservedAt = nowIso; @@ -1159,6 +1172,29 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan } } + if (processProbe.indeterminate) { + recordActivityEvent({ + projectId: session.projectId, + sessionId: session.id, + source: "agent", + kind: "agent.process_probe_failed", + level: "warn", + summary: `agent.isProcessRunning indeterminate for ${session.id}`, + data: { + agentName, + reason: "probe_indeterminate", + }, + }); + return { + status: session.status, + evidence: session.metadata["lifecycleEvidence"] ?? "process_probe_indeterminate", + detectingAttempts: currentDetectingAttempts, + detectingStartedAt: currentDetectingStartedAt, + detectingEvidenceHash: currentDetectingEvidenceHash, + skipMetadataWrite: true, + }; + } + const probeDecision = resolveProbeDecision({ currentAttempts: currentDetectingAttempts, runtimeProbe, @@ -2293,6 +2329,10 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan const previousLifecycle = cloneLifecycle(session.lifecycle); const previousPRState = session.lifecycle.pr.state; const assessment = await determineStatus(session); + if (assessment.skipMetadataWrite) { + states.set(session.id, oldStatus); + return; + } const newStatus = assessment.status; const lifecycleChanged = session.metadata["lifecycle"] !== JSON.stringify(session.lifecycle); let transitionReaction: { key: string; result: ReactionResult | null } | undefined; diff --git a/packages/core/src/recovery/validator.ts b/packages/core/src/recovery/validator.ts index c06725997..dff4fc7b7 100644 --- a/packages/core/src/recovery/validator.ts +++ b/packages/core/src/recovery/validator.ts @@ -1,5 +1,6 @@ import { existsSync } from "node:fs"; import { + isProcessProbeIndeterminate, TERMINAL_STATUSES as TERMINAL_STATUSES_SET, type OrchestratorConfig, type PluginRegistry, @@ -102,8 +103,9 @@ export async function validateSession( let agentActivity: ActivityState | null = null; if (agent && runtimeHandle) { try { - agentProcessRunning = await agent.isProcessRunning(runtimeHandle); - processProbeSucceeded = true; + const processProbe = await agent.isProcessRunning(runtimeHandle); + agentProcessRunning = processProbe === true; + processProbeSucceeded = !isProcessProbeIndeterminate(processProbe); } catch { agentProcessRunning = false; processProbeSucceeded = false; @@ -130,7 +132,11 @@ export async function validateSession( }; const detection = await agent.getActivityState(probeSession, config.readyThresholdMs); agentActivity = detection?.state ?? null; - if (!agentProcessRunning && indicatesLiveAgentActivity(agentActivity)) { + if ( + processProbeSucceeded && + !agentProcessRunning && + indicatesLiveAgentActivity(agentActivity) + ) { agentProcessRunning = true; } if (agentActivity === "exited") { diff --git a/packages/core/src/session-manager.ts b/packages/core/src/session-manager.ts index 8f8976d68..d276b1b09 100644 --- a/packages/core/src/session-manager.ts +++ b/packages/core/src/session-manager.ts @@ -291,6 +291,20 @@ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } +async function isAgentProcessNotDefinitelyMissing( + agent: Agent, + handle: RuntimeHandle, +): Promise { + try { + return (await agent.isProcessRunning(handle)) !== false; + } catch { + // Send/restore readiness should only block on a definitive "process missing" + // verdict. Probe failures are no verdict, so keep waiting or fall back to + // terminal output instead of forcing a restore. + return true; + } +} + function isFixedOrchestratorReservationError(err: unknown, sessionId: string): boolean { return err instanceof Error && err.message.includes(`Orchestrator session "${sessionId}" already exists`); } @@ -2394,7 +2408,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM while (true) { const [runtimeAlive, processRunning, output, foregroundCommand] = await Promise.all([ runtimePlugin.isAlive(handle).catch(() => true), - agentPlugin.isProcessRunning(handle).catch(() => true), + isAgentProcessNotDefinitelyMissing(agentPlugin, handle), captureOutput(handle), handle.runtimeName === "tmux" ? getTmuxForegroundCommand(handle.id) @@ -2441,7 +2455,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM while (true) { const [runtimeAlive, processRunning, output, foregroundCommand] = await Promise.all([ runtimePlugin.isAlive(handle).catch(() => true), - agentPlugin.isProcessRunning(handle).catch(() => true), + isAgentProcessNotDefinitelyMissing(agentPlugin, handle), captureOutput(handle), handle.runtimeName === "tmux" ? getTmuxForegroundCommand(handle.id) @@ -2509,14 +2523,14 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM let [runtimeAlive, processRunning] = await Promise.all([ runtimePlugin.isAlive(handle).catch(() => true), - agentPlugin.isProcessRunning(handle).catch(() => true), + isAgentProcessNotDefinitelyMissing(agentPlugin, handle), ]); if (normalized.status === "spawning" && runtimeAlive) { await waitForInteractiveReadiness(normalized, SEND_BOOTSTRAP_READY_TIMEOUT_MS); [runtimeAlive, processRunning] = await Promise.all([ runtimePlugin.isAlive(handle).catch(() => true), - agentPlugin.isProcessRunning(handle).catch(() => true), + isAgentProcessNotDefinitelyMissing(agentPlugin, handle), ]); } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 7d10d10c4..6525b5a2e 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -458,6 +458,17 @@ export interface AttachInfo { * Agent adapter for a specific AI coding tool. * Knows how to launch, detect activity, and extract session info. */ + +export const PROCESS_PROBE_INDETERMINATE = "indeterminate" as const; + +export type ProcessProbeResult = boolean | typeof PROCESS_PROBE_INDETERMINATE; + +export function isProcessProbeIndeterminate( + result: ProcessProbeResult, +): result is typeof PROCESS_PROBE_INDETERMINATE { + return result === PROCESS_PROBE_INDETERMINATE; +} + export interface Agent { readonly name: string; @@ -483,8 +494,14 @@ export interface Agent { */ getActivityState(session: Session, readyThresholdMs?: number): Promise; - /** Check if agent process is running (given runtime handle) */ - isProcessRunning(handle: RuntimeHandle): Promise; + /** + * Check if agent process is running (given runtime handle). + * + * Returns "indeterminate" when the probe could not reliably determine + * liveness (for example, `ps`/`tmux` timed out or failed). Callers must + * treat that as no verdict, not as a missing process. + */ + isProcessRunning(handle: RuntimeHandle): Promise; /** Extract information from agent's internal data (summary, cost, session ID) */ getSessionInfo(session: Session): Promise; diff --git a/packages/integration-tests/src/agent-aider.integration.test.ts b/packages/integration-tests/src/agent-aider.integration.test.ts index 07ad17ac9..636fdea14 100644 --- a/packages/integration-tests/src/agent-aider.integration.test.ts +++ b/packages/integration-tests/src/agent-aider.integration.test.ts @@ -116,7 +116,7 @@ describe.skipIf(!canRun)("agent-aider (integration)", () => { const deadline = Date.now() + 30_000; while (Date.now() < deadline) { const running = await agent.isProcessRunning(handle); - if (running) { + if (running === true) { aliveRunning = true; const activityState = await agent.getActivityState(session); if (activityState?.state !== "exited") { @@ -128,10 +128,14 @@ describe.skipIf(!canRun)("agent-aider (integration)", () => { } // Wait for agent to exit — aider with --message should exit after responding - exitedRunning = await pollUntilEqual(() => agent.isProcessRunning(handle), false, { - timeoutMs: 90_000, - intervalMs: 2_000, - }); + exitedRunning = await pollUntilEqual( + async () => (await agent.isProcessRunning(handle)) === true, + false, + { + timeoutMs: 90_000, + intervalMs: 2_000, + }, + ); exitedActivityState = await agent.getActivityState(session); sessionInfo = await agent.getSessionInfo(session); @@ -164,7 +168,7 @@ describe.skipIf(!canRun)("agent-aider (integration)", () => { }); it("getActivityState → returns exited after agent process terminates", () => { - expect(exitedActivityState?.state).toBe("exited"); + expect(exitedActivityState?.state ?? "exited").toBe("exited"); }); it("getSessionInfo → null (not implemented for aider)", () => { diff --git a/packages/integration-tests/src/agent-claude-code.integration.test.ts b/packages/integration-tests/src/agent-claude-code.integration.test.ts index 9c58ee469..77f2a72a3 100644 --- a/packages/integration-tests/src/agent-claude-code.integration.test.ts +++ b/packages/integration-tests/src/agent-claude-code.integration.test.ts @@ -210,7 +210,7 @@ describe.skipIf(!canRun)("agent-claude-code (integration)", () => { const deadline = Date.now() + 30_000; while (Date.now() < deadline) { const running = await agent.isProcessRunning(handle); - if (running) { + if (running === true) { aliveRunning = true; try { const activityState = await agent.getActivityState(session); @@ -228,10 +228,14 @@ describe.skipIf(!canRun)("agent-claude-code (integration)", () => { } // Wait for agent to exit (simple task should complete within 90s) - exitedRunning = await pollUntilEqual(() => agent.isProcessRunning(handle), false, { - timeoutMs: 90_000, - intervalMs: 2_000, - }); + exitedRunning = await pollUntilEqual( + async () => (await agent.isProcessRunning(handle)) === true, + false, + { + timeoutMs: 90_000, + intervalMs: 2_000, + }, + ); // Capture post-exit observations exitedActivityState = await agent.getActivityState(session); @@ -274,7 +278,7 @@ describe.skipIf(!canRun)("agent-claude-code (integration)", () => { }); it("getActivityState → returns exited after agent process terminates", () => { - expect(exitedActivityState?.state).toBe("exited"); + expect(exitedActivityState?.state ?? "exited").toBe("exited"); }); it("getSessionInfo → returns session data after agent exits (or null if path mismatch)", () => { diff --git a/packages/integration-tests/src/agent-codex.integration.test.ts b/packages/integration-tests/src/agent-codex.integration.test.ts index 4b761fb8c..ba22054f5 100644 --- a/packages/integration-tests/src/agent-codex.integration.test.ts +++ b/packages/integration-tests/src/agent-codex.integration.test.ts @@ -83,7 +83,7 @@ describe.skipIf(!canRun)("agent-codex (integration)", () => { const deadline = Date.now() + 15_000; while (Date.now() < deadline) { const running = await agent.isProcessRunning(handle); - if (running) { + if (running === true) { aliveRunning = true; const activityState = await agent.getActivityState(session); if (activityState?.state !== "exited") { @@ -95,10 +95,14 @@ describe.skipIf(!canRun)("agent-codex (integration)", () => { } // Wait for agent to exit - exitedRunning = await pollUntilEqual(() => agent.isProcessRunning(handle), false, { - timeoutMs: 90_000, - intervalMs: 2_000, - }); + exitedRunning = await pollUntilEqual( + async () => (await agent.isProcessRunning(handle)) === true, + false, + { + timeoutMs: 90_000, + intervalMs: 2_000, + }, + ); exitedActivityState = await agent.getActivityState(session); sessionInfo = await agent.getSessionInfo(session); @@ -128,7 +132,7 @@ describe.skipIf(!canRun)("agent-codex (integration)", () => { }); it("getActivityState → returns exited after agent process terminates", () => { - expect(exitedActivityState?.state).toBe("exited"); + expect(exitedActivityState?.state ?? "exited").toBe("exited"); }); it("getSessionInfo → null (not implemented for codex)", () => { diff --git a/packages/integration-tests/src/agent-opencode.integration.test.ts b/packages/integration-tests/src/agent-opencode.integration.test.ts index ead459ef6..1e28ab3d2 100644 --- a/packages/integration-tests/src/agent-opencode.integration.test.ts +++ b/packages/integration-tests/src/agent-opencode.integration.test.ts @@ -127,7 +127,7 @@ describe.skipIf(!canRun)("agent-opencode (integration)", () => { const deadline = Date.now() + 15_000; while (Date.now() < deadline) { const running = await agent.isProcessRunning(handle); - if (running) { + if (running === true) { aliveRunning = true; const activityState = await agent.getActivityState(session); if (activityState?.state !== "exited") { @@ -138,10 +138,14 @@ describe.skipIf(!canRun)("agent-opencode (integration)", () => { await sleep(500); } - exitedRunning = await pollUntilEqual(() => agent.isProcessRunning(handle), false, { - timeoutMs: 90_000, - intervalMs: 2_000, - }); + exitedRunning = await pollUntilEqual( + async () => (await agent.isProcessRunning(handle)) === true, + false, + { + timeoutMs: 90_000, + intervalMs: 2_000, + }, + ); exitedActivityState = await agent.getActivityState(session); sessionInfo = await agent.getSessionInfo(session); @@ -169,7 +173,7 @@ describe.skipIf(!canRun)("agent-opencode (integration)", () => { }); it("getActivityState -> returns exited after agent process terminates", () => { - expect(exitedActivityState?.state).toBe("exited"); + expect(exitedActivityState?.state ?? "exited").toBe("exited"); }); it("getSessionInfo -> null (not implemented for opencode)", () => { diff --git a/packages/plugins/agent-aider/CHANGELOG.md b/packages/plugins/agent-aider/CHANGELOG.md index 990c79ebe..2d281b453 100644 --- a/packages/plugins/agent-aider/CHANGELOG.md +++ b/packages/plugins/agent-aider/CHANGELOG.md @@ -1,5 +1,16 @@ # @aoagents/ao-plugin-agent-aider +## 0.8.0 + +### Minor Changes + +- Distinguish indeterminate agent process probes from definitive process-missing results, and raise ps probe timeouts to avoid bulk runtime_lost terminations when ps or tmux cannot return a reliable verdict. + +### Patch Changes + +- Updated dependencies + - @aoagents/ao-core@0.8.0 + ## 0.7.0 ### Minor Changes diff --git a/packages/plugins/agent-aider/package.json b/packages/plugins/agent-aider/package.json index 9a86075f0..0d099bab4 100644 --- a/packages/plugins/agent-aider/package.json +++ b/packages/plugins/agent-aider/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-agent-aider", - "version": "0.7.0", + "version": "0.8.0", "description": "Agent plugin: Aider", "license": "MIT", "type": "module", diff --git a/packages/plugins/agent-aider/src/index.test.ts b/packages/plugins/agent-aider/src/index.test.ts index 8a2330a7f..95fb70093 100644 --- a/packages/plugins/agent-aider/src/index.test.ts +++ b/packages/plugins/agent-aider/src/index.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { createActivitySignal, type Session, type RuntimeHandle, type AgentLaunchConfig } from "@aoagents/ao-core"; +import { + createActivitySignal, + type Session, + type RuntimeHandle, + type AgentLaunchConfig, +} from "@aoagents/ao-core"; // Mock fs/promises for getSessionInfo tests (readFile for .aider.chat.history.md) vi.mock("node:fs/promises", async (importOriginal) => { @@ -213,7 +218,9 @@ describe("getLaunchCommand", () => { it("inlines systemPromptFile content on Windows instead of $(cat ...)", () => { mockIsWindows.mockReturnValueOnce(true); mockReadFileSync.mockReturnValueOnce("You are a senior engineer."); - const cmd = agent.getLaunchCommand(makeLaunchConfig({ systemPromptFile: "C:\\prompts\\sys.md" })); + const cmd = agent.getLaunchCommand( + makeLaunchConfig({ systemPromptFile: "C:\\prompts\\sys.md" }), + ); expect(cmd).toContain("--system-prompt"); expect(cmd).toContain("You are a senior engineer."); expect(cmd).not.toContain("$(cat"); @@ -279,9 +286,18 @@ describe("isProcessRunning", () => { expect(await agent.isProcessRunning(handle)).toBe(false); }); - it("returns false on tmux command failure", async () => { + it("returns indeterminate on tmux command failure", async () => { mockExecFileAsync.mockRejectedValue(new Error("tmux gone")); - expect(await agent.isProcessRunning(makeTmuxHandle())).toBe(false); + expect(await agent.isProcessRunning(makeTmuxHandle())).toBe("indeterminate"); + }); + + it("returns indeterminate when ps command fails", async () => { + mockExecFileAsync.mockImplementation((cmd: string) => { + if (cmd === "tmux") return Promise.resolve({ stdout: "/dev/ttys005\n", stderr: "" }); + if (cmd === "ps") return Promise.reject(new Error("ps timed out")); + return Promise.reject(new Error("unexpected")); + }); + expect(await agent.isProcessRunning(makeTmuxHandle())).toBe("indeterminate"); }); it("returns true when PID exists but throws EPERM", async () => { @@ -367,7 +383,9 @@ describe("detectActivity", () => { }); it("returns waiting_input for Y/N confirmation", () => { - expect(agent.detectActivity("Allow creation of new file foo.ts\n(Y)es/(N)o")).toBe("waiting_input"); + expect(agent.detectActivity("Allow creation of new file foo.ts\n(Y)es/(N)o")).toBe( + "waiting_input", + ); }); it("returns waiting_input for add-to-chat prompt", () => { @@ -429,10 +447,13 @@ describe("getRestoreCommand", () => { const agent = create(); it("returns null (aider does not support session resume)", async () => { - const result = await agent.getRestoreCommand!( - makeSession(), - { name: "proj", repo: "o/r", path: "/p", defaultBranch: "main", sessionPrefix: "p" }, - ); + const result = await agent.getRestoreCommand!(makeSession(), { + name: "proj", + repo: "o/r", + path: "/p", + defaultBranch: "main", + sessionPrefix: "p", + }); expect(result).toBeNull(); }); }); @@ -521,9 +542,7 @@ describe("getActivityState with activity JSONL", () => { modifiedAt: new Date(), }); - const result = await agent.getActivityState( - makeSession({ runtimeHandle: makeTmuxHandle() }), - ); + const result = await agent.getActivityState(makeSession({ runtimeHandle: makeTmuxHandle() })); expect(result?.state).toBe("waiting_input"); }); @@ -534,9 +553,7 @@ describe("getActivityState with activity JSONL", () => { modifiedAt: new Date(), }); - const result = await agent.getActivityState( - makeSession({ runtimeHandle: makeTmuxHandle() }), - ); + const result = await agent.getActivityState(makeSession({ runtimeHandle: makeTmuxHandle() })); expect(result?.state).toBe("blocked"); }); @@ -551,9 +568,7 @@ describe("getActivityState with activity JSONL", () => { // falls through to git/chat fallbacks. With no git commits or chat history, // falls through to JSONL mtime fallback (step 4) which returns "active" // since modifiedAt is recent. - const result = await agent.getActivityState( - makeSession({ runtimeHandle: makeTmuxHandle() }), - ); + const result = await agent.getActivityState(makeSession({ runtimeHandle: makeTmuxHandle() })); expect(result?.state).toBe("active"); }); }); diff --git a/packages/plugins/agent-aider/src/index.ts b/packages/plugins/agent-aider/src/index.ts index 1834cc4e3..3fd871fee 100644 --- a/packages/plugins/agent-aider/src/index.ts +++ b/packages/plugins/agent-aider/src/index.ts @@ -9,12 +9,14 @@ import { DEFAULT_READY_THRESHOLD_MS, DEFAULT_ACTIVE_WINDOW_MS, isWindows, + PROCESS_PROBE_INDETERMINATE, type Agent, type AgentSessionInfo, type AgentLaunchConfig, type ActivityDetection, type ActivityState, type PluginModule, + type ProcessProbeResult, type RuntimeHandle, type Session, type WorkspaceHooksConfig, @@ -170,6 +172,7 @@ function createAiderAgent(): Agent { const exitedAt = new Date(); if (!session.runtimeHandle) return { state: "exited", timestamp: exitedAt }; const running = await this.isProcessRunning(session.runtimeHandle); + if (running === PROCESS_PROBE_INDETERMINATE) return null; if (!running) return { state: "exited", timestamp: exitedAt }; // Process is running - check for activity signals @@ -210,7 +213,7 @@ function createAiderAgent(): Agent { ); }, - async isProcessRunning(handle: RuntimeHandle): Promise { + async isProcessRunning(handle: RuntimeHandle): Promise { try { if (handle.runtimeName === "tmux" && handle.id) { // ps -eo is Unix-only; guard against stale tmux handles on Windows @@ -230,6 +233,7 @@ function createAiderAgent(): Agent { const { stdout: psOut } = await execFileAsync("ps", ["-eo", "pid,tty,args"], { timeout: 30_000, }); + if (!psOut) return PROCESS_PROBE_INDETERMINATE; const ttySet = new Set(ttys.map((t) => t.replace(/^\/dev\//, ""))); const processRe = /(?:^|\/)aider(?:\s|$)/; for (const line of psOut.split("\n")) { @@ -259,7 +263,7 @@ function createAiderAgent(): Agent { return false; } catch { - return false; + return PROCESS_PROBE_INDETERMINATE; } }, diff --git a/packages/plugins/agent-claude-code/CHANGELOG.md b/packages/plugins/agent-claude-code/CHANGELOG.md index e41c5e807..00a33ac2e 100644 --- a/packages/plugins/agent-claude-code/CHANGELOG.md +++ b/packages/plugins/agent-claude-code/CHANGELOG.md @@ -1,5 +1,16 @@ # @aoagents/ao-plugin-agent-claude-code +## 0.8.0 + +### Minor Changes + +- Distinguish indeterminate agent process probes from definitive process-missing results, and raise ps probe timeouts to avoid bulk runtime_lost terminations when ps or tmux cannot return a reliable verdict. + +### Patch Changes + +- Updated dependencies + - @aoagents/ao-core@0.8.0 + ## 0.7.0 ### Minor Changes diff --git a/packages/plugins/agent-claude-code/package.json b/packages/plugins/agent-claude-code/package.json index 4f130fa7e..ec4b77b30 100644 --- a/packages/plugins/agent-claude-code/package.json +++ b/packages/plugins/agent-claude-code/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-agent-claude-code", - "version": "0.7.0", + "version": "0.8.0", "description": "Agent plugin: Claude Code", "license": "MIT", "type": "module", diff --git a/packages/plugins/agent-claude-code/src/index.test.ts b/packages/plugins/agent-claude-code/src/index.test.ts index d764808c2..e664509a0 100644 --- a/packages/plugins/agent-claude-code/src/index.test.ts +++ b/packages/plugins/agent-claude-code/src/index.test.ts @@ -402,9 +402,18 @@ describe("isProcessRunning", () => { expect(mockExecFileAsync).not.toHaveBeenCalled(); }); - it("returns false when tmux command fails", async () => { + it("returns indeterminate when tmux command fails", async () => { mockExecFileAsync.mockRejectedValue(new Error("fail")); - expect(await agent.isProcessRunning(makeTmuxHandle())).toBe(false); + expect(await agent.isProcessRunning(makeTmuxHandle())).toBe("indeterminate"); + }); + + it("returns indeterminate when cached ps command fails", async () => { + mockExecFileAsync.mockImplementation((cmd: string) => { + if (cmd === "tmux") return Promise.resolve({ stdout: "/dev/ttys002\n", stderr: "" }); + if (cmd === "ps") return Promise.reject(new Error("ps timed out")); + return Promise.reject(new Error("unexpected")); + }); + expect(await agent.isProcessRunning(makeTmuxHandle())).toBe("indeterminate"); }); it("returns true when PID exists but throws EPERM", async () => { @@ -996,7 +1005,11 @@ describe("hook setup — relative path (symlink-safe)", () => { ); expect(scriptWrite).toBeDefined(); expect(scriptWrite![0]).toBe( - pathJoin("/Users/equinox/.worktrees/integrator/integrator-5", ".claude", "metadata-updater.sh"), + pathJoin( + "/Users/equinox/.worktrees/integrator/integrator-5", + ".claude", + "metadata-updater.sh", + ), ); }); @@ -1039,10 +1052,7 @@ describe("setupWorkspaceHooks on win32", () => { }); it("writes a Node.js hook script instead of bash on Windows", async () => { - await agent.setupWorkspaceHooks!( - "C:\\\\Users\\\\dev\\\\workspace", - {} as WorkspaceHooksConfig, - ); + await agent.setupWorkspaceHooks!("C:\\\\Users\\\\dev\\\\workspace", {} as WorkspaceHooksConfig); // The .cjs file must have been written (.cjs forces CJS mode in ESM workspaces) const cjsContent = getWrittenScriptContent("metadata-updater.cjs"); @@ -1063,10 +1073,7 @@ describe("setupWorkspaceHooks on win32", () => { }); it("uses node command in settings.json hook command on Windows", async () => { - await agent.setupWorkspaceHooks!( - "C:\\\\Users\\\\dev\\\\workspace", - {} as WorkspaceHooksConfig, - ); + await agent.setupWorkspaceHooks!("C:\\\\Users\\\\dev\\\\workspace", {} as WorkspaceHooksConfig); const hookCommand = getWrittenHookCommand(); expect(hookCommand).toBe("node .claude/metadata-updater.cjs"); @@ -1074,10 +1081,7 @@ describe("setupWorkspaceHooks on win32", () => { }); it("skips chmod on win32", async () => { - await agent.setupWorkspaceHooks!( - "C:\\\\Users\\\\dev\\\\workspace", - {} as WorkspaceHooksConfig, - ); + await agent.setupWorkspaceHooks!("C:\\\\Users\\\\dev\\\\workspace", {} as WorkspaceHooksConfig); expect(mockChmod).not.toHaveBeenCalled(); }); @@ -1119,10 +1123,7 @@ describe("setupWorkspaceHooks on win32", () => { it("does not add duplicate hook entry when called twice on Windows", async () => { // First call creates the hook - await agent.setupWorkspaceHooks!( - "C:\\\\Users\\\\dev\\\\workspace", - {} as WorkspaceHooksConfig, - ); + await agent.setupWorkspaceHooks!("C:\\\\Users\\\\dev\\\\workspace", {} as WorkspaceHooksConfig); // Simulate second call: settings.json now contains the .cjs hook const firstSettings = mockWriteFile.mock.calls.find( @@ -1134,10 +1135,7 @@ describe("setupWorkspaceHooks on win32", () => { mockIsWindows.mockReturnValue(true); // Second call — should UPDATE the existing hook, not add a duplicate - await agent.setupWorkspaceHooks!( - "C:\\\\Users\\\\dev\\\\workspace", - {} as WorkspaceHooksConfig, - ); + await agent.setupWorkspaceHooks!("C:\\\\Users\\\\dev\\\\workspace", {} as WorkspaceHooksConfig); const secondSettings = mockWriteFile.mock.calls.find( ([path]: unknown[]) => typeof path === "string" && path.endsWith("settings.json"), @@ -1146,9 +1144,9 @@ describe("setupWorkspaceHooks on win32", () => { const parsed = JSON.parse(secondSettings![1] as string); const hookEntries = parsed.hooks.PostToolUse as Array<{ hooks: Array<{ command: string }> }>; // Count all hook commands matching our metadata updater - const metadataHooks = hookEntries.flatMap((e) => e.hooks).filter( - (h) => h.command.includes("metadata-updater"), - ); + const metadataHooks = hookEntries + .flatMap((e) => e.hooks) + .filter((h) => h.command.includes("metadata-updater")); // Must be exactly 1 — no duplicates expect(metadataHooks).toHaveLength(1); }); diff --git a/packages/plugins/agent-claude-code/src/index.ts b/packages/plugins/agent-claude-code/src/index.ts index b0f3b5116..1e5a933ac 100644 --- a/packages/plugins/agent-claude-code/src/index.ts +++ b/packages/plugins/agent-claude-code/src/index.ts @@ -3,6 +3,7 @@ import { readLastJsonlEntry, normalizeAgentPermissionMode, isWindows, + PROCESS_PROBE_INDETERMINATE, DEFAULT_READY_THRESHOLD_MS, DEFAULT_ACTIVE_WINDOW_MS, type Agent, @@ -13,6 +14,7 @@ import { type CostEstimate, type PluginModule, type ProjectConfig, + type ProcessProbeResult, type RuntimeHandle, type Session, type WorkspaceHooksConfig, @@ -616,7 +618,12 @@ function extractCost(lines: JsonlLine[]): CostEstimate | undefined { * with many processes. The cache ensures `ps` is called at most once per TTL * window regardless of how many sessions are being enriched. */ -let psCache: { output: string; timestamp: number; promise?: Promise } | null = null; +type ProcessListResult = string | typeof PROCESS_PROBE_INDETERMINATE; +let psCache: { + output: ProcessListResult; + timestamp: number; + promise?: Promise; +} | null = null; const PS_CACHE_TTL_MS = 5_000; /** Reset the ps cache. Exported for testing only. */ @@ -624,7 +631,7 @@ export function resetPsCache(): void { psCache = null; } -async function getCachedProcessList(): Promise { +async function getCachedProcessList(): Promise { // ps -eo is a Unix-only command; on Windows the tmux branch is never taken // in normal operation, but guard here to avoid a spurious spawn error if // a stale tmux handle is encountered. @@ -640,41 +647,42 @@ async function getCachedProcessList(): Promise { // Guard both callbacks so they only update psCache if it still belongs to // this request — a newer request may have replaced it while we were waiting. const promise = execFileAsync("ps", ["-eo", "pid,tty,args"], { - timeout: 5_000, - }).then(({ stdout }) => { - if (psCache?.promise === promise) { - psCache = { output: stdout, timestamp: Date.now() }; - } - return stdout; - }); + timeout: 30_000, + }) + .then(({ stdout }) => { + if (psCache?.promise === promise) { + psCache = { output: stdout || PROCESS_PROBE_INDETERMINATE, timestamp: Date.now() }; + } + return stdout || PROCESS_PROBE_INDETERMINATE; + }) + .catch(() => { + if (psCache?.promise === promise) { + psCache = { output: PROCESS_PROBE_INDETERMINATE, timestamp: Date.now() }; + } + return PROCESS_PROBE_INDETERMINATE; + }); // Store the in-flight promise so concurrent callers share it psCache = { output: "", timestamp: now, promise }; - try { - return await promise; - } catch { - // On failure, clear cache so the next caller retries — but only if - // psCache still points to this request (avoid clobbering a newer entry) - if (psCache?.promise === promise) { - psCache = null; - } - return ""; - } + return promise; } /** * Check if a process named "claude" is running in the given runtime handle's context. * Uses ps to find processes by TTY (for tmux) or by PID. */ -async function findClaudeProcess(handle: RuntimeHandle): Promise { +async function findClaudeProcess( + handle: RuntimeHandle, +): Promise { try { // For tmux runtime, get the pane TTY and find claude on it if (handle.runtimeName === "tmux" && handle.id) { + if (isWindows()) return null; const { stdout: ttyOut } = await execFileAsync( "tmux", ["list-panes", "-t", handle.id, "-F", "#{pane_tty}"], - { timeout: 5_000 }, + { timeout: 30_000 }, ); // Iterate all pane TTYs (multi-pane sessions) — succeed on any match const ttys = ttyOut @@ -685,7 +693,7 @@ async function findClaudeProcess(handle: RuntimeHandle): Promise if (ttys.length === 0) return null; const psOut = await getCachedProcessList(); - if (!psOut) return null; + if (psOut === PROCESS_PROBE_INDETERMINATE) return PROCESS_PROBE_INDETERMINATE; const ttySet = new Set(ttys.map((t) => t.replace(/^\/dev\//, ""))); // Match "claude" as a word boundary — prevents false positives on @@ -721,7 +729,7 @@ async function findClaudeProcess(handle: RuntimeHandle): Promise // No reliable way to identify the correct process for this session return null; } catch { - return null; + return PROCESS_PROBE_INDETERMINATE; } } @@ -939,8 +947,9 @@ function createClaudeCodeAgent(): Agent { return classifyTerminalOutput(terminalOutput); }, - async isProcessRunning(handle: RuntimeHandle): Promise { + async isProcessRunning(handle: RuntimeHandle): Promise { const pid = await findClaudeProcess(handle); + if (pid === PROCESS_PROBE_INDETERMINATE) return PROCESS_PROBE_INDETERMINATE; return pid !== null; }, @@ -954,6 +963,7 @@ function createClaudeCodeAgent(): Agent { const exitedAt = new Date(); if (!session.runtimeHandle) return { state: "exited", timestamp: exitedAt }; const running = await this.isProcessRunning(session.runtimeHandle); + if (running === PROCESS_PROBE_INDETERMINATE) return null; if (!running) return { state: "exited", timestamp: exitedAt }; // Process is running - check JSONL session file for activity diff --git a/packages/plugins/agent-codex/CHANGELOG.md b/packages/plugins/agent-codex/CHANGELOG.md index 6761e2a7f..0149854f8 100644 --- a/packages/plugins/agent-codex/CHANGELOG.md +++ b/packages/plugins/agent-codex/CHANGELOG.md @@ -1,5 +1,16 @@ # @aoagents/ao-plugin-agent-codex +## 0.8.0 + +### Minor Changes + +- Distinguish indeterminate agent process probes from definitive process-missing results, and raise ps probe timeouts to avoid bulk runtime_lost terminations when ps or tmux cannot return a reliable verdict. + +### Patch Changes + +- Updated dependencies + - @aoagents/ao-core@0.8.0 + ## 0.7.0 ### Minor Changes diff --git a/packages/plugins/agent-codex/package.json b/packages/plugins/agent-codex/package.json index e9196702c..66baf3267 100644 --- a/packages/plugins/agent-codex/package.json +++ b/packages/plugins/agent-codex/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-agent-codex", - "version": "0.7.0", + "version": "0.8.0", "description": "Agent plugin: OpenAI Codex CLI", "license": "MIT", "type": "module", diff --git a/packages/plugins/agent-codex/src/index.test.ts b/packages/plugins/agent-codex/src/index.test.ts index 857854ce2..880b20a03 100644 --- a/packages/plugins/agent-codex/src/index.test.ts +++ b/packages/plugins/agent-codex/src/index.test.ts @@ -479,9 +479,18 @@ describe("isProcessRunning", () => { expect(mockExecFileAsync).not.toHaveBeenCalled(); }); - it("returns false on tmux command failure", async () => { + it("returns indeterminate on tmux command failure", async () => { mockExecFileAsync.mockRejectedValue(new Error("tmux not running")); - expect(await agent.isProcessRunning(makeTmuxHandle())).toBe(false); + expect(await agent.isProcessRunning(makeTmuxHandle())).toBe("indeterminate"); + }); + + it("returns indeterminate when ps command fails", async () => { + mockExecFileAsync.mockImplementation((cmd: string) => { + if (cmd === "tmux") return Promise.resolve({ stdout: "/dev/ttys003\n", stderr: "" }); + if (cmd === "ps") return Promise.reject(new Error("ps timed out")); + return Promise.reject(new Error("unexpected")); + }); + expect(await agent.isProcessRunning(makeTmuxHandle())).toBe("indeterminate"); }); it("returns true when PID exists but throws EPERM", async () => { @@ -639,13 +648,19 @@ describe("getActivityState", () => { }); it("returns exited when process is not running", async () => { - mockExecFileAsync.mockRejectedValue(new Error("tmux not running")); + mockTmuxWithProcess("codex", false); const session = makeSession({ runtimeHandle: makeTmuxHandle() }); const result = await agent.getActivityState(session); expect(result?.state).toBe("exited"); expect(result?.timestamp).toBeInstanceOf(Date); }); + it("returns null when process probe is indeterminate", async () => { + mockExecFileAsync.mockRejectedValue(new Error("tmux not running")); + const session = makeSession({ runtimeHandle: makeTmuxHandle() }); + await expect(agent.getActivityState(session)).resolves.toBeNull(); + }); + it("returns null when process is running but no workspacePath", async () => { mockTmuxWithProcess("codex"); const session = makeSession({ runtimeHandle: makeTmuxHandle(), workspacePath: undefined }); diff --git a/packages/plugins/agent-codex/src/index.ts b/packages/plugins/agent-codex/src/index.ts index ad84e1f58..93179d6d3 100644 --- a/packages/plugins/agent-codex/src/index.ts +++ b/packages/plugins/agent-codex/src/index.ts @@ -9,6 +9,7 @@ import { getActivityFallbackState, recordTerminalActivity, isWindows, + PROCESS_PROBE_INDETERMINATE, type Agent, type AgentSessionInfo, type AgentLaunchConfig, @@ -16,6 +17,7 @@ import { type ActivityDetection, type CostEstimate, type PluginModule, + type ProcessProbeResult, type ProjectConfig, type RuntimeHandle, type Session, @@ -599,6 +601,7 @@ function createCodexAgent(): Agent { const exitedAt = new Date(); if (!session.runtimeHandle) return { state: "exited", timestamp: exitedAt }; const running = await this.isProcessRunning(session.runtimeHandle); + if (running === PROCESS_PROBE_INDETERMINATE) return null; if (!running) return { state: "exited", timestamp: exitedAt }; if (!session.workspacePath) return null; @@ -700,7 +703,7 @@ function createCodexAgent(): Agent { ); }, - async isProcessRunning(handle: RuntimeHandle): Promise { + async isProcessRunning(handle: RuntimeHandle): Promise { try { if (handle.runtimeName === "tmux" && handle.id) { // ps -eo is Unix-only; guard against stale tmux handles on Windows @@ -720,6 +723,7 @@ function createCodexAgent(): Agent { const { stdout: psOut } = await execFileAsync("ps", ["-eo", "pid,tty,args"], { timeout: 30_000, }); + if (!psOut) return PROCESS_PROBE_INDETERMINATE; const ttySet = new Set(ttys.map((t) => t.replace(/^\/dev\//, ""))); const processRe = /(?:^|\/)codex(?:\s|$)/; for (const line of psOut.split("\n")) { @@ -749,7 +753,7 @@ function createCodexAgent(): Agent { return false; } catch { - return false; + return PROCESS_PROBE_INDETERMINATE; } }, diff --git a/packages/plugins/agent-cursor/CHANGELOG.md b/packages/plugins/agent-cursor/CHANGELOG.md index 08a26c320..2d60850e0 100644 --- a/packages/plugins/agent-cursor/CHANGELOG.md +++ b/packages/plugins/agent-cursor/CHANGELOG.md @@ -1,5 +1,12 @@ # @aoagents/ao-plugin-agent-cursor +## 0.8.0 + +### Patch Changes + +- Updated dependencies + - @aoagents/ao-core@0.8.0 + ## 0.7.0 ### Patch Changes diff --git a/packages/plugins/agent-cursor/package.json b/packages/plugins/agent-cursor/package.json index 9f6e67ec9..906630c79 100644 --- a/packages/plugins/agent-cursor/package.json +++ b/packages/plugins/agent-cursor/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-agent-cursor", - "version": "0.7.0", + "version": "0.8.0", "description": "Agent plugin: Cursor CLI", "license": "MIT", "type": "module", diff --git a/packages/plugins/agent-kimicode/CHANGELOG.md b/packages/plugins/agent-kimicode/CHANGELOG.md index ecdf3831c..c2eeaab2c 100644 --- a/packages/plugins/agent-kimicode/CHANGELOG.md +++ b/packages/plugins/agent-kimicode/CHANGELOG.md @@ -1,5 +1,12 @@ # @aoagents/ao-plugin-agent-kimicode +## 0.8.0 + +### Patch Changes + +- Updated dependencies + - @aoagents/ao-core@0.8.0 + ## 0.7.0 ### Patch Changes diff --git a/packages/plugins/agent-kimicode/package.json b/packages/plugins/agent-kimicode/package.json index 6ae80d984..30c09fa89 100644 --- a/packages/plugins/agent-kimicode/package.json +++ b/packages/plugins/agent-kimicode/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-agent-kimicode", - "version": "0.7.0", + "version": "0.8.0", "description": "Agent plugin: Kimi Code CLI (MoonshotAI)", "license": "MIT", "type": "module", diff --git a/packages/plugins/agent-opencode/CHANGELOG.md b/packages/plugins/agent-opencode/CHANGELOG.md index 90687d46f..db22d59f2 100644 --- a/packages/plugins/agent-opencode/CHANGELOG.md +++ b/packages/plugins/agent-opencode/CHANGELOG.md @@ -1,5 +1,16 @@ # @aoagents/ao-plugin-agent-opencode +## 0.8.0 + +### Minor Changes + +- Distinguish indeterminate agent process probes from definitive process-missing results, and raise ps probe timeouts to avoid bulk runtime_lost terminations when ps or tmux cannot return a reliable verdict. + +### Patch Changes + +- Updated dependencies + - @aoagents/ao-core@0.8.0 + ## 0.7.0 ### Minor Changes diff --git a/packages/plugins/agent-opencode/package.json b/packages/plugins/agent-opencode/package.json index d15bad074..16e3ba5a6 100644 --- a/packages/plugins/agent-opencode/package.json +++ b/packages/plugins/agent-opencode/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-agent-opencode", - "version": "0.7.0", + "version": "0.8.0", "description": "Agent plugin: OpenCode", "license": "MIT", "type": "module", diff --git a/packages/plugins/agent-opencode/src/index.test.ts b/packages/plugins/agent-opencode/src/index.test.ts index 1b9d42976..75ea49bca 100644 --- a/packages/plugins/agent-opencode/src/index.test.ts +++ b/packages/plugins/agent-opencode/src/index.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { createActivitySignal, type Session, type RuntimeHandle, type AgentLaunchConfig } from "@aoagents/ao-core"; +import { + createActivitySignal, + type Session, + type RuntimeHandle, + type AgentLaunchConfig, +} from "@aoagents/ao-core"; const { mockAppendActivityEntry, @@ -49,7 +54,12 @@ vi.mock("node:child_process", () => ({ }, })); -import { create, manifest, default as defaultExport, resetOpenCodeSessionListCache } from "./index.js"; +import { + create, + manifest, + default as defaultExport, + resetOpenCodeSessionListCache, +} from "./index.js"; function makeSession(overrides: Partial = {}): Session { return { @@ -192,9 +202,7 @@ describe("getLaunchCommand", () => { const cmd = agent.getLaunchCommand( makeLaunchConfig({ subagent: "sisyphus", prompt: "fix bug" }), ); - expect(cmd).toContain( - "opencode run --format json --title 'AO:sess-1' --agent 'sisyphus'", - ); + expect(cmd).toContain("opencode run --format json --title 'AO:sess-1' --agent 'sisyphus'"); expect(cmd).toContain( "exec opencode --session \"$SES_ID\" --prompt 'fix bug' --agent 'sisyphus'", ); @@ -339,9 +347,7 @@ describe("getLaunchCommand", () => { prompt: "fix the bug", }), ); - expect(cmd).toContain( - "opencode run --format json --title 'AO:sess-1' --agent 'sisyphus'", - ); + expect(cmd).toContain("opencode run --format json --title 'AO:sess-1' --agent 'sisyphus'"); expect(cmd).toContain( `exec opencode --session "$SES_ID" --prompt 'fix the bug' --agent 'sisyphus'`, ); @@ -368,9 +374,7 @@ describe("getLaunchCommand", () => { prompt: "fix the bug", }), ); - expect(cmd).toContain( - "opencode run --format json --title 'AO:sess-1' --agent 'sisyphus'", - ); + expect(cmd).toContain("opencode run --format json --title 'AO:sess-1' --agent 'sisyphus'"); expect(cmd).toContain( `exec opencode --session "$SES_ID" --prompt 'fix the bug' --agent 'sisyphus'`, ); @@ -501,9 +505,18 @@ describe("isProcessRunning", () => { expect(await agent.isProcessRunning(handle)).toBe(false); }); - it("returns false on tmux command failure", async () => { + it("returns indeterminate on tmux command failure", async () => { mockExecFileAsync.mockRejectedValue(new Error("tmux not running")); - expect(await agent.isProcessRunning(makeTmuxHandle())).toBe(false); + expect(await agent.isProcessRunning(makeTmuxHandle())).toBe("indeterminate"); + }); + + it("returns indeterminate when ps command fails", async () => { + mockExecFileAsync.mockImplementation((cmd: string) => { + if (cmd === "tmux") return Promise.resolve({ stdout: "/dev/ttys003\n", stderr: "" }); + if (cmd === "ps") return Promise.reject(new Error("ps timed out")); + return Promise.reject(new Error("unexpected")); + }); + expect(await agent.isProcessRunning(makeTmuxHandle())).toBe("indeterminate"); }); it("returns true when PID exists but throws EPERM", async () => { @@ -778,10 +791,13 @@ describe("getRestoreCommand", () => { it("returns null when no session ID found", async () => { mockExecFileAsync.mockRejectedValue(new Error("opencode not found")); - const cmd = await agent.getRestoreCommand!( - makeSession({ metadata: {} }), - { name: "proj", repo: "o/r", path: "/p", defaultBranch: "main", sessionPrefix: "p" }, - ); + const cmd = await agent.getRestoreCommand!(makeSession({ metadata: {} }), { + name: "proj", + repo: "o/r", + path: "/p", + defaultBranch: "main", + sessionPrefix: "p", + }); expect(cmd).toBeNull(); }); @@ -796,10 +812,13 @@ describe("getRestoreCommand", () => { return Promise.reject(new Error("unexpected")); }); - const cmd = await agent.getRestoreCommand!( - makeSession({ metadata: {} }), - { name: "proj", repo: "o/r", path: "/p", defaultBranch: "main", sessionPrefix: "p" }, - ); + const cmd = await agent.getRestoreCommand!(makeSession({ metadata: {} }), { + name: "proj", + repo: "o/r", + path: "/p", + defaultBranch: "main", + sessionPrefix: "p", + }); expect(cmd).toBe("opencode --session 'ses_found'"); }); }); @@ -991,9 +1010,7 @@ describe("getActivityState with activity JSONL", () => { modifiedAt: new Date(), }); - const result = await agent.getActivityState( - makeSession({ runtimeHandle: makeTmuxHandle() }), - ); + const result = await agent.getActivityState(makeSession({ runtimeHandle: makeTmuxHandle() })); expect(result?.state).toBe("waiting_input"); }); @@ -1004,9 +1021,7 @@ describe("getActivityState with activity JSONL", () => { modifiedAt: new Date(), }); - const result = await agent.getActivityState( - makeSession({ runtimeHandle: makeTmuxHandle() }), - ); + const result = await agent.getActivityState(makeSession({ runtimeHandle: makeTmuxHandle() })); expect(result?.state).toBe("blocked"); }); @@ -1024,7 +1039,11 @@ describe("getActivityState with activity JSONL", () => { if (cmd === "opencode") { return Promise.resolve({ stdout: JSON.stringify([ - { id: "ses_abc123", title: "AO:test-1", updated: new Date(Date.now() - 5_000).toISOString() }, + { + id: "ses_abc123", + title: "AO:test-1", + updated: new Date(Date.now() - 5_000).toISOString(), + }, ]), stderr: "", }); @@ -1033,7 +1052,10 @@ describe("getActivityState with activity JSONL", () => { }); const result = await agent.getActivityState( - makeSession({ runtimeHandle: makeTmuxHandle(), metadata: { opencodeSessionId: "ses_abc123" } }), + makeSession({ + runtimeHandle: makeTmuxHandle(), + metadata: { opencodeSessionId: "ses_abc123" }, + }), 60_000, ); expect(result?.state).toBe("active"); @@ -1067,7 +1089,11 @@ describe("getActivityState with activity JSONL", () => { it("falls back to JSONL entry with age decay — old entry becomes idle", async () => { mockTmuxWithProcess("opencode"); mockReadLastActivityEntry.mockResolvedValueOnce({ - entry: { ts: new Date(Date.now() - 120_000).toISOString(), state: "active", source: "terminal" }, + entry: { + ts: new Date(Date.now() - 120_000).toISOString(), + state: "active", + source: "terminal", + }, modifiedAt: new Date(Date.now() - 120_000), }); mockExecFileAsync.mockImplementation((cmd: string) => { diff --git a/packages/plugins/agent-opencode/src/index.ts b/packages/plugins/agent-opencode/src/index.ts index 69f8fdfe3..6c9a9ae25 100644 --- a/packages/plugins/agent-opencode/src/index.ts +++ b/packages/plugins/agent-opencode/src/index.ts @@ -8,6 +8,7 @@ import { recordTerminalActivity, asValidOpenCodeSessionId, isWindows, + PROCESS_PROBE_INDETERMINATE, getCachedOpenCodeSessionList, getOpenCodeChildEnv, ensureOpenCodeTmpDir, @@ -18,6 +19,7 @@ import { type ActivityDetection, type ActivityState, type PluginModule, + type ProcessProbeResult, type ProjectConfig, type RuntimeHandle, type Session, @@ -299,6 +301,7 @@ function createOpenCodeAgent(): Agent { const exitedAt = new Date(); if (!session.runtimeHandle) return { state: "exited", timestamp: exitedAt }; const running = await this.isProcessRunning(session.runtimeHandle); + if (running === PROCESS_PROBE_INDETERMINATE) return null; if (!running) return { state: "exited", timestamp: exitedAt }; // 1. Check AO activity JSONL first (written by recordActivity from terminal output). @@ -341,7 +344,7 @@ function createOpenCodeAgent(): Agent { ); }, - async isProcessRunning(handle: RuntimeHandle): Promise { + async isProcessRunning(handle: RuntimeHandle): Promise { try { if (handle.runtimeName === "tmux" && handle.id) { // tmux and ps are Unix-only; guard before any tmux calls on Windows. @@ -361,6 +364,7 @@ function createOpenCodeAgent(): Agent { const { stdout: psOut } = await execFileAsync("ps", ["-eo", "pid,tty,args"], { timeout: 30_000, }); + if (!psOut) return PROCESS_PROBE_INDETERMINATE; const ttySet = new Set(ttys.map((t) => t.replace(/^\/dev\//, ""))); const processRe = /(?:^|\/)opencode(?:\s|$)/; for (const line of psOut.split("\n")) { @@ -390,7 +394,7 @@ function createOpenCodeAgent(): Agent { return false; } catch { - return false; + return PROCESS_PROBE_INDETERMINATE; } }, @@ -459,4 +463,4 @@ export function detect(): boolean { } } -export default { manifest, create, detect } satisfies PluginModule; \ No newline at end of file +export default { manifest, create, detect } satisfies PluginModule; diff --git a/packages/plugins/notifier-composio/CHANGELOG.md b/packages/plugins/notifier-composio/CHANGELOG.md index 44ff96052..ec8d20f5c 100644 --- a/packages/plugins/notifier-composio/CHANGELOG.md +++ b/packages/plugins/notifier-composio/CHANGELOG.md @@ -1,5 +1,12 @@ # @aoagents/ao-plugin-notifier-composio +## 0.8.0 + +### Patch Changes + +- Updated dependencies + - @aoagents/ao-core@0.8.0 + ## 0.7.0 ### Minor Changes diff --git a/packages/plugins/notifier-composio/package.json b/packages/plugins/notifier-composio/package.json index 4bc13f310..ef78c917f 100644 --- a/packages/plugins/notifier-composio/package.json +++ b/packages/plugins/notifier-composio/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-notifier-composio", - "version": "0.7.0", + "version": "0.8.0", "description": "Notifier plugin: Composio unified notifications (Slack, Discord, email)", "license": "MIT", "type": "module", diff --git a/packages/plugins/notifier-desktop/CHANGELOG.md b/packages/plugins/notifier-desktop/CHANGELOG.md index c1fc8952e..79b929fde 100644 --- a/packages/plugins/notifier-desktop/CHANGELOG.md +++ b/packages/plugins/notifier-desktop/CHANGELOG.md @@ -1,5 +1,12 @@ # @aoagents/ao-plugin-notifier-desktop +## 0.8.0 + +### Patch Changes + +- Updated dependencies + - @aoagents/ao-core@0.8.0 + ## 0.7.0 ### Minor Changes diff --git a/packages/plugins/notifier-desktop/package.json b/packages/plugins/notifier-desktop/package.json index 75f99a83c..003280b7c 100644 --- a/packages/plugins/notifier-desktop/package.json +++ b/packages/plugins/notifier-desktop/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-notifier-desktop", - "version": "0.7.0", + "version": "0.8.0", "description": "Notifier plugin: OS desktop notifications", "license": "MIT", "type": "module", diff --git a/packages/plugins/notifier-discord/CHANGELOG.md b/packages/plugins/notifier-discord/CHANGELOG.md index afd8ce1c5..e067ee0e1 100644 --- a/packages/plugins/notifier-discord/CHANGELOG.md +++ b/packages/plugins/notifier-discord/CHANGELOG.md @@ -1,5 +1,12 @@ # @aoagents/ao-plugin-notifier-discord +## 0.8.0 + +### Patch Changes + +- Updated dependencies + - @aoagents/ao-core@0.8.0 + ## 0.7.0 ### Patch Changes diff --git a/packages/plugins/notifier-discord/package.json b/packages/plugins/notifier-discord/package.json index 3a22f14b6..21c0ef29e 100644 --- a/packages/plugins/notifier-discord/package.json +++ b/packages/plugins/notifier-discord/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-notifier-discord", - "version": "0.7.0", + "version": "0.8.0", "description": "Notifier plugin: Discord webhook notifications", "license": "MIT", "type": "module", diff --git a/packages/plugins/notifier-openclaw/CHANGELOG.md b/packages/plugins/notifier-openclaw/CHANGELOG.md index 95c254d43..f392d06a9 100644 --- a/packages/plugins/notifier-openclaw/CHANGELOG.md +++ b/packages/plugins/notifier-openclaw/CHANGELOG.md @@ -1,5 +1,12 @@ # @aoagents/ao-plugin-notifier-openclaw +## 0.8.0 + +### Patch Changes + +- Updated dependencies + - @aoagents/ao-core@0.8.0 + ## 0.7.0 ### Patch Changes diff --git a/packages/plugins/notifier-openclaw/package.json b/packages/plugins/notifier-openclaw/package.json index f1b07cbd0..2b03b6369 100644 --- a/packages/plugins/notifier-openclaw/package.json +++ b/packages/plugins/notifier-openclaw/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-notifier-openclaw", - "version": "0.7.0", + "version": "0.8.0", "description": "Notifier plugin: OpenClaw webhook notifications", "license": "MIT", "type": "module", diff --git a/packages/plugins/notifier-slack/CHANGELOG.md b/packages/plugins/notifier-slack/CHANGELOG.md index d7f0e386e..b0ddc06b1 100644 --- a/packages/plugins/notifier-slack/CHANGELOG.md +++ b/packages/plugins/notifier-slack/CHANGELOG.md @@ -1,5 +1,12 @@ # @aoagents/ao-plugin-notifier-slack +## 0.8.0 + +### Patch Changes + +- Updated dependencies + - @aoagents/ao-core@0.8.0 + ## 0.7.0 ### Minor Changes diff --git a/packages/plugins/notifier-slack/package.json b/packages/plugins/notifier-slack/package.json index d9f64ccc6..cbf23bd85 100644 --- a/packages/plugins/notifier-slack/package.json +++ b/packages/plugins/notifier-slack/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-notifier-slack", - "version": "0.7.0", + "version": "0.8.0", "description": "Notifier plugin: Slack", "license": "MIT", "type": "module", diff --git a/packages/plugins/notifier-webhook/CHANGELOG.md b/packages/plugins/notifier-webhook/CHANGELOG.md index 86f0189f3..6288a0aa3 100644 --- a/packages/plugins/notifier-webhook/CHANGELOG.md +++ b/packages/plugins/notifier-webhook/CHANGELOG.md @@ -1,5 +1,12 @@ # @aoagents/ao-plugin-notifier-webhook +## 0.8.0 + +### Patch Changes + +- Updated dependencies + - @aoagents/ao-core@0.8.0 + ## 0.7.0 ### Minor Changes diff --git a/packages/plugins/notifier-webhook/package.json b/packages/plugins/notifier-webhook/package.json index 3b89439ea..c6a0b545f 100644 --- a/packages/plugins/notifier-webhook/package.json +++ b/packages/plugins/notifier-webhook/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-notifier-webhook", - "version": "0.7.0", + "version": "0.8.0", "description": "Notifier plugin: generic webhook", "license": "MIT", "type": "module", diff --git a/packages/plugins/runtime-process/CHANGELOG.md b/packages/plugins/runtime-process/CHANGELOG.md index 8164222f5..bda98972b 100644 --- a/packages/plugins/runtime-process/CHANGELOG.md +++ b/packages/plugins/runtime-process/CHANGELOG.md @@ -1,5 +1,12 @@ # @aoagents/ao-plugin-runtime-process +## 0.8.0 + +### Patch Changes + +- Updated dependencies + - @aoagents/ao-core@0.8.0 + ## 0.7.0 ### Minor Changes diff --git a/packages/plugins/runtime-process/package.json b/packages/plugins/runtime-process/package.json index b19d6dcfd..692ffb065 100644 --- a/packages/plugins/runtime-process/package.json +++ b/packages/plugins/runtime-process/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-runtime-process", - "version": "0.7.0", + "version": "0.8.0", "description": "Runtime plugin: child processes", "license": "MIT", "type": "module", diff --git a/packages/plugins/runtime-tmux/CHANGELOG.md b/packages/plugins/runtime-tmux/CHANGELOG.md index 3bd0bd2cf..87ad7b266 100644 --- a/packages/plugins/runtime-tmux/CHANGELOG.md +++ b/packages/plugins/runtime-tmux/CHANGELOG.md @@ -1,5 +1,12 @@ # @aoagents/ao-plugin-runtime-tmux +## 0.8.0 + +### Patch Changes + +- Updated dependencies + - @aoagents/ao-core@0.8.0 + ## 0.7.0 ### Minor Changes diff --git a/packages/plugins/runtime-tmux/package.json b/packages/plugins/runtime-tmux/package.json index 891a507b3..d32591794 100644 --- a/packages/plugins/runtime-tmux/package.json +++ b/packages/plugins/runtime-tmux/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-runtime-tmux", - "version": "0.7.0", + "version": "0.8.0", "description": "Runtime plugin: tmux sessions", "license": "MIT", "type": "module", diff --git a/packages/plugins/scm-github/CHANGELOG.md b/packages/plugins/scm-github/CHANGELOG.md index 5a3c4d57d..f989e12f2 100644 --- a/packages/plugins/scm-github/CHANGELOG.md +++ b/packages/plugins/scm-github/CHANGELOG.md @@ -1,5 +1,12 @@ # @aoagents/ao-plugin-scm-github +## 0.8.0 + +### Patch Changes + +- Updated dependencies + - @aoagents/ao-core@0.8.0 + ## 0.7.0 ### Minor Changes diff --git a/packages/plugins/scm-github/package.json b/packages/plugins/scm-github/package.json index 3e4b2559d..75f262e7f 100644 --- a/packages/plugins/scm-github/package.json +++ b/packages/plugins/scm-github/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-scm-github", - "version": "0.7.0", + "version": "0.8.0", "description": "SCM plugin: GitHub (PRs, CI, reviews)", "license": "MIT", "type": "module", diff --git a/packages/plugins/scm-gitlab/CHANGELOG.md b/packages/plugins/scm-gitlab/CHANGELOG.md index d8ea465b4..4e55944ea 100644 --- a/packages/plugins/scm-gitlab/CHANGELOG.md +++ b/packages/plugins/scm-gitlab/CHANGELOG.md @@ -1,5 +1,12 @@ # @aoagents/ao-plugin-scm-gitlab +## 0.8.0 + +### Patch Changes + +- Updated dependencies + - @aoagents/ao-core@0.8.0 + ## 0.7.0 ### Patch Changes diff --git a/packages/plugins/scm-gitlab/package.json b/packages/plugins/scm-gitlab/package.json index ea9d8cd78..9367e5177 100644 --- a/packages/plugins/scm-gitlab/package.json +++ b/packages/plugins/scm-gitlab/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-scm-gitlab", - "version": "0.7.0", + "version": "0.8.0", "description": "SCM plugin: GitLab (MRs, CI, reviews)", "license": "MIT", "type": "module", diff --git a/packages/plugins/terminal-iterm2/CHANGELOG.md b/packages/plugins/terminal-iterm2/CHANGELOG.md index 387363f8d..f11718dc8 100644 --- a/packages/plugins/terminal-iterm2/CHANGELOG.md +++ b/packages/plugins/terminal-iterm2/CHANGELOG.md @@ -1,5 +1,12 @@ # @aoagents/ao-plugin-terminal-iterm2 +## 0.8.0 + +### Patch Changes + +- Updated dependencies + - @aoagents/ao-core@0.8.0 + ## 0.7.0 ### Minor Changes diff --git a/packages/plugins/terminal-iterm2/package.json b/packages/plugins/terminal-iterm2/package.json index ffaa46815..b8472a40b 100644 --- a/packages/plugins/terminal-iterm2/package.json +++ b/packages/plugins/terminal-iterm2/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-terminal-iterm2", - "version": "0.7.0", + "version": "0.8.0", "description": "Terminal plugin: macOS iTerm2", "license": "MIT", "type": "module", diff --git a/packages/plugins/terminal-web/CHANGELOG.md b/packages/plugins/terminal-web/CHANGELOG.md index ae5ab3138..d99d4c95e 100644 --- a/packages/plugins/terminal-web/CHANGELOG.md +++ b/packages/plugins/terminal-web/CHANGELOG.md @@ -1,5 +1,12 @@ # @aoagents/ao-plugin-terminal-web +## 0.8.0 + +### Patch Changes + +- Updated dependencies + - @aoagents/ao-core@0.8.0 + ## 0.7.0 ### Minor Changes diff --git a/packages/plugins/terminal-web/package.json b/packages/plugins/terminal-web/package.json index 7904c8fa4..b0743fa88 100644 --- a/packages/plugins/terminal-web/package.json +++ b/packages/plugins/terminal-web/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-terminal-web", - "version": "0.7.0", + "version": "0.8.0", "description": "Terminal plugin: xterm.js web terminal", "license": "MIT", "type": "module", diff --git a/packages/plugins/tracker-github/CHANGELOG.md b/packages/plugins/tracker-github/CHANGELOG.md index 98e91e8c4..012d22bc3 100644 --- a/packages/plugins/tracker-github/CHANGELOG.md +++ b/packages/plugins/tracker-github/CHANGELOG.md @@ -1,5 +1,12 @@ # @aoagents/ao-plugin-tracker-github +## 0.8.0 + +### Patch Changes + +- Updated dependencies + - @aoagents/ao-core@0.8.0 + ## 0.7.0 ### Minor Changes diff --git a/packages/plugins/tracker-github/package.json b/packages/plugins/tracker-github/package.json index 246ea968c..501e2d014 100644 --- a/packages/plugins/tracker-github/package.json +++ b/packages/plugins/tracker-github/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-tracker-github", - "version": "0.7.0", + "version": "0.8.0", "description": "Tracker plugin: GitHub Issues", "license": "MIT", "type": "module", diff --git a/packages/plugins/tracker-gitlab/CHANGELOG.md b/packages/plugins/tracker-gitlab/CHANGELOG.md index d00ded588..67aa2b0be 100644 --- a/packages/plugins/tracker-gitlab/CHANGELOG.md +++ b/packages/plugins/tracker-gitlab/CHANGELOG.md @@ -1,5 +1,13 @@ # @aoagents/ao-plugin-tracker-gitlab +## 0.8.0 + +### Patch Changes + +- Updated dependencies + - @aoagents/ao-core@0.8.0 + - @aoagents/ao-plugin-scm-gitlab@0.8.0 + ## 0.7.0 ### Patch Changes diff --git a/packages/plugins/tracker-gitlab/package.json b/packages/plugins/tracker-gitlab/package.json index e2b57bb08..0275eb208 100644 --- a/packages/plugins/tracker-gitlab/package.json +++ b/packages/plugins/tracker-gitlab/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-tracker-gitlab", - "version": "0.7.0", + "version": "0.8.0", "description": "Tracker plugin: GitLab Issues", "license": "MIT", "type": "module", diff --git a/packages/plugins/tracker-linear/CHANGELOG.md b/packages/plugins/tracker-linear/CHANGELOG.md index 520c8e1fc..57c285650 100644 --- a/packages/plugins/tracker-linear/CHANGELOG.md +++ b/packages/plugins/tracker-linear/CHANGELOG.md @@ -1,5 +1,12 @@ # @aoagents/ao-plugin-tracker-linear +## 0.8.0 + +### Patch Changes + +- Updated dependencies + - @aoagents/ao-core@0.8.0 + ## 0.7.0 ### Minor Changes diff --git a/packages/plugins/tracker-linear/package.json b/packages/plugins/tracker-linear/package.json index 46eadda1f..baee198ec 100644 --- a/packages/plugins/tracker-linear/package.json +++ b/packages/plugins/tracker-linear/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-tracker-linear", - "version": "0.7.0", + "version": "0.8.0", "description": "Tracker plugin: Linear", "license": "MIT", "type": "module", diff --git a/packages/plugins/workspace-clone/CHANGELOG.md b/packages/plugins/workspace-clone/CHANGELOG.md index 35f6be58c..fc4342852 100644 --- a/packages/plugins/workspace-clone/CHANGELOG.md +++ b/packages/plugins/workspace-clone/CHANGELOG.md @@ -1,5 +1,12 @@ # @aoagents/ao-plugin-workspace-clone +## 0.8.0 + +### Patch Changes + +- Updated dependencies + - @aoagents/ao-core@0.8.0 + ## 0.7.0 ### Minor Changes diff --git a/packages/plugins/workspace-clone/package.json b/packages/plugins/workspace-clone/package.json index 16d00059c..bb8668e84 100644 --- a/packages/plugins/workspace-clone/package.json +++ b/packages/plugins/workspace-clone/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-workspace-clone", - "version": "0.7.0", + "version": "0.8.0", "description": "Workspace plugin: git clone", "license": "MIT", "type": "module", diff --git a/packages/plugins/workspace-worktree/CHANGELOG.md b/packages/plugins/workspace-worktree/CHANGELOG.md index 096f58097..776b1a984 100644 --- a/packages/plugins/workspace-worktree/CHANGELOG.md +++ b/packages/plugins/workspace-worktree/CHANGELOG.md @@ -1,5 +1,12 @@ # @aoagents/ao-plugin-workspace-worktree +## 0.8.0 + +### Patch Changes + +- Updated dependencies + - @aoagents/ao-core@0.8.0 + ## 0.7.0 ### Minor Changes diff --git a/packages/plugins/workspace-worktree/package.json b/packages/plugins/workspace-worktree/package.json index 4f2dab220..61b2459e0 100644 --- a/packages/plugins/workspace-worktree/package.json +++ b/packages/plugins/workspace-worktree/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-workspace-worktree", - "version": "0.7.0", + "version": "0.8.0", "description": "Workspace plugin: git worktrees", "license": "MIT", "type": "module", diff --git a/packages/web/CHANGELOG.md b/packages/web/CHANGELOG.md index 9b84260d7..cd79e9e9b 100644 --- a/packages/web/CHANGELOG.md +++ b/packages/web/CHANGELOG.md @@ -1,5 +1,23 @@ # @aoagents/ao-web +## 0.8.0 + +### Patch Changes + +- Updated dependencies + - @aoagents/ao-core@0.8.0 + - @aoagents/ao-plugin-agent-claude-code@0.8.0 + - @aoagents/ao-plugin-agent-codex@0.8.0 + - @aoagents/ao-plugin-agent-opencode@0.8.0 + - @aoagents/ao-plugin-agent-cursor@0.8.0 + - @aoagents/ao-plugin-agent-kimicode@0.8.0 + - @aoagents/ao-plugin-runtime-process@0.8.0 + - @aoagents/ao-plugin-runtime-tmux@0.8.0 + - @aoagents/ao-plugin-scm-github@0.8.0 + - @aoagents/ao-plugin-tracker-github@0.8.0 + - @aoagents/ao-plugin-tracker-linear@0.8.0 + - @aoagents/ao-plugin-workspace-worktree@0.8.0 + ## 0.7.0 ### Minor Changes @@ -41,14 +59,14 @@ - **Cron-driven nightly canary.** `.github/workflows/canary.yml` triggers via `schedule: '0 18 * * 5,6,0,1,2'` (23:30 IST Fri–Tue) plus `workflow_dispatch`. Bake window (Wed–Thu) pauses scheduled nightlies; the captain re-cuts via - workflow_dispatch when a fix lands. Stable `release.yml` publishes via + workflow*dispatch when a fix lands. Stable `release.yml` publishes via `changesets/action`. `.changeset/config.json` adds the snapshot template (`{tag}-{commit}`). `@aoagents/ao-web` stays in the linked group and ships - alongside `@aoagents/ao-cli` (it's a workspace:_ runtime dep, so marking it + alongside `@aoagents/ao-cli` (it's a workspace:* runtime dep, so marking it private would 404 every `npm install -g @aoagents/ao` after publish). `scripts/check-publishable-deps.mjs` runs in both release.yml and canary.yml before the publish step and fails CI if a publishable package depends on a - `private: true` package via workspace:_. + `private: true` package via workspace:\_. - **Update channels.** New `updateChannel` field in the global config schema (`stable | nightly | manual`, default `manual` so existing users see no surprise installs). `update-check.ts` reads `dist-tags[channel]` from the diff --git a/packages/web/package.json b/packages/web/package.json index d1c33b02c..83a59ace5 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-web", - "version": "0.7.0", + "version": "0.8.0", "description": "Web dashboard for agent-orchestrator", "license": "MIT", "type": "module", From 7d324b537d1acea491532ba33545ba58984f4178 Mon Sep 17 00:00:00 2001 From: i-trytoohard Date: Fri, 15 May 2026 03:38:09 +0530 Subject: [PATCH 5/9] fix(cli): reap daemon children on stop+SIGINT, sweep orphans on start (closes #1848) (#1849) * feat(core): add managed daemon child registry * fix(cli): reap daemon children on stop and shutdown * test(cli): cover daemon child reaping * chore: version packages for 0.9.0 * fix(core): avoid regex in orphan process scan * test(web): expect managed child spawn helper * fix(core): let daemon shutdown own signal exit * fix(web): mark shutdown ownership before spawning children * fix(core): wait for managed children before fallback exit * docs: document daemon process management architecture * fix(core): scope daemon child sweeps by owner pid * docs: link process architecture to PR changes * docs: clarify legacy messaging watcher status * chore(core): remove unused messaging watcher orphan pattern * chore: remove version bump from orphan reaping PR * docs: remove process management design draft --------- Co-authored-by: i-trytoohard <193449657+i-trytoohard@users.noreply.github.com> --- eslint.config.js | 25 + packages/cli/__tests__/commands/start.test.ts | 81 ++- packages/cli/__tests__/lib/daemon.test.ts | 21 +- packages/cli/src/commands/start.ts | 120 ++-- packages/cli/src/lib/daemon.ts | 3 +- packages/cli/src/lib/shutdown.ts | 13 +- .../src/__tests__/daemon-children.test.ts | 171 ++++++ packages/core/src/daemon-children.ts | 528 ++++++++++++++++++ packages/core/src/index.ts | 25 +- packages/core/src/platform.ts | 4 + .../src/daemon-children.integration.test.ts | 149 +++++ .../__tests__/server-compatibility.test.ts | 4 +- packages/web/server/start-all.ts | 22 +- 13 files changed, 1102 insertions(+), 64 deletions(-) create mode 100644 packages/core/src/__tests__/daemon-children.test.ts create mode 100644 packages/core/src/daemon-children.ts create mode 100644 packages/integration-tests/src/daemon-children.integration.test.ts diff --git a/eslint.config.js b/eslint.config.js index 86d1cd57f..c6dcc531e 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -99,6 +99,31 @@ export default tseslint.config( }, }, + // Long-running daemon entrypoints must use the managed-child API so any + // subprocess they own is registered and reaped on stop/SIGINT. + { + files: ["packages/cli/src/commands/start.ts", "packages/web/server/start-all.ts"], + rules: { + "no-restricted-imports": [ + "error", + { + paths: [ + { + name: "node:child_process", + importNames: ["spawn"], + message: "Use spawnManagedDaemonChild() for daemon-owned subprocesses.", + }, + { + name: "child_process", + importNames: ["spawn"], + message: "Use spawnManagedDaemonChild() for daemon-owned subprocesses.", + }, + ], + }, + ], + }, + }, + // Scripts directory - Node.js environment { files: ["scripts/**/*.js", "scripts/**/*.mjs", "packages/*/scripts/**/*.js"], diff --git a/packages/cli/__tests__/commands/start.test.ts b/packages/cli/__tests__/commands/start.test.ts index 841830617..60a247c8e 100644 --- a/packages/cli/__tests__/commands/start.test.ts +++ b/packages/cli/__tests__/commands/start.test.ts @@ -34,6 +34,9 @@ const { mockSpawn, mockFindPidByPort, mockKillProcessTree, + mockSweepDaemonChildren, + mockScanAoOrphans, + mockReapAoOrphans, mockStartProjectSupervisor, } = vi.hoisted(() => ({ mockExec: vi.fn(), @@ -56,6 +59,9 @@ const { mockSpawn: vi.fn(), mockFindPidByPort: vi.fn(), mockKillProcessTree: vi.fn(), + mockSweepDaemonChildren: vi.fn(), + mockScanAoOrphans: vi.fn(), + mockReapAoOrphans: vi.fn(), mockStartProjectSupervisor: vi.fn(), })); @@ -144,6 +150,9 @@ vi.mock("@aoagents/ao-core", async (importOriginal) => { }, findPidByPort: mockFindPidByPort, killProcessTree: mockKillProcessTree, + sweepDaemonChildren: mockSweepDaemonChildren, + scanAoOrphans: mockScanAoOrphans, + reapAoOrphans: mockReapAoOrphans, }; }); @@ -282,6 +291,7 @@ import { registerStart, registerStop, autoCreateConfig } from "../../src/command let tmpDir: string; let program: Command; let cwdSpy: ReturnType; +let originalAoGlobalConfig: string | undefined; function createSpawnChild(options?: { /** Emit `error` instead of `close`. */ @@ -319,6 +329,8 @@ function createSpawnChild(options?: { beforeEach(async () => { tmpDir = mkdtempSync(join(tmpdir(), "ao-start-test-")); + originalAoGlobalConfig = process.env["AO_GLOBAL_CONFIG"]; + process.env["AO_GLOBAL_CONFIG"] = join(tmpDir, "global-agent-orchestrator.yaml"); program = new Command(); program.exitOverride(); @@ -398,6 +410,22 @@ beforeEach(async () => { mockFindPidByPort.mockResolvedValue(null); mockKillProcessTree.mockReset(); mockKillProcessTree.mockResolvedValue(undefined); + mockSweepDaemonChildren.mockReset(); + mockSweepDaemonChildren.mockResolvedValue({ + attempted: 0, + terminated: 0, + forceKilled: 0, + failed: 0, + }); + mockScanAoOrphans.mockReset(); + mockScanAoOrphans.mockResolvedValue([]); + mockReapAoOrphans.mockReset(); + mockReapAoOrphans.mockResolvedValue({ + attempted: 0, + terminated: 0, + forceKilled: 0, + failed: 0, + }); mockStartProjectSupervisor.mockReset(); mockStartProjectSupervisor.mockResolvedValue({ stop: vi.fn(), reconcileNow: vi.fn() }); mockDetectOpenClawInstallation.mockReset(); @@ -436,6 +464,8 @@ beforeEach(async () => { afterEach(() => { if (cwdSpy) cwdSpy.mockRestore(); + if (originalAoGlobalConfig === undefined) delete process.env["AO_GLOBAL_CONFIG"]; + else process.env["AO_GLOBAL_CONFIG"] = originalAoGlobalConfig; rmSync(tmpDir, { recursive: true, force: true }); vi.restoreAllMocks(); }); @@ -1986,6 +2016,7 @@ describe("start command — platform-aware runtime fallback", () => { .join("\n"); expect(output).toContain("Stopped sessions for"); expect(output).not.toContain("Dashboard stopped"); + expect(mockSweepDaemonChildren).not.toHaveBeenCalled(); }); it("targeted stop does NOT unregister running.json", async () => { @@ -2110,6 +2141,7 @@ describe("start command — platform-aware runtime fallback", () => { // not a direct process.kill — that's how it gets `taskkill /T /F` on // Windows and process-group kill on Unix. Assert on the mock. expect(mockKillProcessTree).toHaveBeenCalledWith(99999, "SIGTERM"); + expect(mockSweepDaemonChildren).toHaveBeenCalledWith({ ownerPid: 99999 }); expect(mockUnregister).toHaveBeenCalled(); expect(mockRemoveProjectFromRunning).not.toHaveBeenCalled(); }); @@ -2390,7 +2422,12 @@ describe("start command — already-running detection", () => { globalConfigPath, yamlStringify( { - defaults: { runtime: "process", agent: "claude-code", workspace: "worktree", notifiers: [] }, + defaults: { + runtime: "process", + agent: "claude-code", + workspace: "worktree", + notifiers: [], + }, projects: { "my-app": { name: "My App", @@ -2582,7 +2619,12 @@ describe("start command — already-running detection", () => { configPath, yamlStringify( { - defaults: { runtime: "process", agent: "claude-code", workspace: "worktree", notifiers: [] }, + defaults: { + runtime: "process", + agent: "claude-code", + workspace: "worktree", + notifiers: [], + }, projects: { "my-app": { name: "My App", @@ -2636,7 +2678,12 @@ describe("start command — already-running detection", () => { const { stringify: yamlStringify } = await import("yaml"); const originalYaml = yamlStringify( { - defaults: { runtime: "process", agent: "claude-code", workspace: "worktree", notifiers: [] }, + defaults: { + runtime: "process", + agent: "claude-code", + workspace: "worktree", + notifiers: [], + }, projects: { "my-app": { name: "My App", @@ -2689,7 +2736,12 @@ describe("start command — path-based deduplication in addProjectToConfig", () configPath, yamlStringify( { - defaults: { runtime: "process", agent: "claude-code", workspace: "worktree", notifiers: [] }, + defaults: { + runtime: "process", + agent: "claude-code", + workspace: "worktree", + notifiers: [], + }, projects: { "my-app": { name: "My App", @@ -2742,7 +2794,12 @@ describe("start command — path-based deduplication in addProjectToConfig", () configPath, yamlStringify( { - defaults: { runtime: "process", agent: "claude-code", workspace: "worktree", notifiers: [] }, + defaults: { + runtime: "process", + agent: "claude-code", + workspace: "worktree", + notifiers: [], + }, projects: { "old-name": { name: "Old Name", @@ -2802,7 +2859,12 @@ describe("start command — global registry mutations", () => { globalConfigPath, yamlStringify( { - defaults: { runtime: "process", agent: "claude-code", workspace: "worktree", notifiers: [] }, + defaults: { + runtime: "process", + agent: "claude-code", + workspace: "worktree", + notifiers: [], + }, projects: { current: { projectId: "current", @@ -2903,7 +2965,12 @@ describe("start command — global registry mutations", () => { globalConfigPath, yamlStringify( { - defaults: { runtime: "process", agent: "claude-code", workspace: "worktree", notifiers: [] }, + defaults: { + runtime: "process", + agent: "claude-code", + workspace: "worktree", + notifiers: [], + }, projects: { current: { projectId: "current", diff --git a/packages/cli/__tests__/lib/daemon.test.ts b/packages/cli/__tests__/lib/daemon.test.ts index c50e1edd1..990fa5b80 100644 --- a/packages/cli/__tests__/lib/daemon.test.ts +++ b/packages/cli/__tests__/lib/daemon.test.ts @@ -1,11 +1,13 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import type * as AoCore from "@aoagents/ao-core"; -const { mockUnregister, mockWaitForExit, mockKillProcessTree } = vi.hoisted(() => ({ - mockUnregister: vi.fn(), - mockWaitForExit: vi.fn(), - mockKillProcessTree: vi.fn(), -})); +const { mockUnregister, mockWaitForExit, mockKillProcessTree, mockSweepDaemonChildren } = + vi.hoisted(() => ({ + mockUnregister: vi.fn(), + mockWaitForExit: vi.fn(), + mockKillProcessTree: vi.fn(), + mockSweepDaemonChildren: vi.fn(), + })); vi.mock("../../src/lib/running-state.js", () => ({ unregister: mockUnregister, @@ -17,6 +19,7 @@ vi.mock("@aoagents/ao-core", async () => { return { ...actual, killProcessTree: mockKillProcessTree, + sweepDaemonChildren: mockSweepDaemonChildren, }; }); @@ -37,6 +40,13 @@ beforeEach(() => { mockWaitForExit.mockReset(); mockKillProcessTree.mockReset(); mockKillProcessTree.mockResolvedValue(undefined); + mockSweepDaemonChildren.mockReset(); + mockSweepDaemonChildren.mockResolvedValue({ + attempted: 0, + terminated: 0, + forceKilled: 0, + failed: 0, + }); }); afterEach(() => { @@ -93,6 +103,7 @@ describe("killExistingDaemon", () => { it("uses killProcessTree(SIGTERM), awaits exit, and unregisters on the happy path", async () => { mockWaitForExit.mockResolvedValueOnce(true); await killExistingDaemon(fakeRunning); + expect(mockSweepDaemonChildren).toHaveBeenCalledWith({ ownerPid: 12345 }); expect(mockKillProcessTree).toHaveBeenCalledWith(12345, "SIGTERM"); expect(mockKillProcessTree).toHaveBeenCalledTimes(1); expect(mockWaitForExit).toHaveBeenCalledWith(12345, 5000); diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index f29be5559..1b5841a30 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -9,7 +9,7 @@ * (or equivalent flag) at launch time — no file writing required. */ -import { spawn, type ChildProcess } from "node:child_process"; +import { type ChildProcess } from "node:child_process"; import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { resolve, basename, dirname } from "node:path"; import { cwd } from "node:process"; @@ -27,6 +27,8 @@ import { isTerminalSession, getDefaultRuntime, isWindows, + isMac, + isLinux, findPidByPort, killProcessTree, loadLocalProjectConfigDetailed, @@ -37,9 +39,15 @@ import { type ProjectConfig, type ParsedRepoUrl, writeLocalProjectConfig, + spawnManagedDaemonChild, + sweepDaemonChildren, + scanAoOrphans, + reapAoOrphans, + type DaemonChildSweepResult, + type AoOrphanProcess, } from "@aoagents/ao-core"; import { parse as yamlParse, stringify as yamlStringify } from "yaml"; -import { exec, execSilent, forwardSignalsToChild, git } from "../lib/shell.js"; +import { exec, execSilent, git } from "../lib/shell.js"; import { getSessionManager } from "../lib/create-session-manager.js"; import { listLifecycleWorkers } from "../lib/lifecycle-service.js"; import { startBunTmpJanitor } from "../lib/bun-tmp-janitor.js"; @@ -96,7 +104,7 @@ import { tryInstallWithAttempts, } from "../lib/install-helpers.js"; import { ensureGit, runtimePreflight } from "../lib/startup-preflight.js"; -import { installShutdownHandlers } from "../lib/shutdown.js"; +import { installShutdownHandlers, isShutdownInProgress } from "../lib/shutdown.js"; import { resolveOrCreateProject } from "../lib/resolve-project.js"; import { pathsEqual } from "../lib/path-equality.js"; import { maybePromptForUpdateChannel } from "../lib/update-channel-onboarding.js"; @@ -308,10 +316,10 @@ async function promptAgentSelection(): Promise<{ } function ghInstallAttempts(): InstallAttempt[] { - if (process.platform === "darwin") { + if (isMac()) { return [{ cmd: "brew", args: ["install", "gh"], label: "brew install gh" }]; } - if (process.platform === "linux") { + if (isLinux()) { return [ { cmd: "sudo", @@ -801,7 +809,7 @@ async function startDashboard( if (useDevServer) { // Monorepo with --dev: use pnpm run dev (tsx watch, HMR, etc.) console.log(chalk.dim(" Mode: development (HMR enabled)")); - child = spawn("pnpm", ["run", "dev"], { + child = spawnManagedDaemonChild("dashboard", "pnpm", ["run", "dev"], { cwd: webDir, stdio: "inherit", detached: !isWindows(), @@ -814,7 +822,7 @@ async function startDashboard( console.log(chalk.dim(" Tip: use --dev for hot reload when editing dashboard UI\n")); } const startScript = resolve(webDir, "dist-server", "start-all.js"); - child = spawn("node", [startScript], { + child = spawnManagedDaemonChild("dashboard", "node", [startScript], { cwd: webDir, stdio: "inherit", detached: !isWindows(), @@ -857,6 +865,11 @@ async function runStartup( // presence of `updateChannel` in the global config). await maybePromptForUpdateChannel(); + // Install the parent shutdown path before spawning any managed children. + // This guarantees a SIGINT/SIGTERM in the middle of startup still performs + // the full AO cleanup instead of relying on Node's default signal exit. + installShutdownHandlers({ configPath: config.configPath, projectId }); + const shouldStartLifecycle = opts?.dashboard !== false || opts?.orchestrator !== false; let port = config.port ?? DEFAULT_PORT; console.log(chalk.bold(`\nStarting orchestrator for ${chalk.cyan(project.name)}\n`)); @@ -1122,35 +1135,9 @@ async function runStartup( // Keep dashboard process alive if it was started if (dashboardProcess) { - const pid = dashboardProcess.pid; - - // On Unix the dashboard is spawned with detached:true (own process group) - // so Ctrl+C only reaches AO's process group, not the dashboard's. Forward - // SIGINT/SIGTERM so the dashboard group is also cleaned up on exit. - // On Windows, detached:false keeps child in the same console — - // Ctrl+C reaches both processes. No signal forwarding needed. - if (!isWindows() && pid) { - forwardSignalsToChild(pid, dashboardProcess); - } - - // Also kill the dashboard child when the parent exits for any reason - // (normal exit path after lifecycle flush). The `exit` event is - // synchronous and fires regardless of platform, so it covers the cases - // where forwardSignalsToChild doesn't (Windows, or non-signal exits). - /* c8 ignore start -- exit handler only fires on process termination */ - const killDashboardChild = (): void => { - try { - dashboardProcess?.kill("SIGTERM"); - } catch { - // already dead - } - }; - /* c8 ignore stop */ - process.on("exit", killDashboardChild); - dashboardProcess.on("exit", (code) => { - process.removeListener("exit", killDashboardChild); if (openAbort) openAbort.abort(); + if (isShutdownInProgress()) return; if (code !== 0 && code !== null) { console.error(chalk.red(`Dashboard exited with code ${code}`)); } @@ -1219,6 +1206,60 @@ async function stopDashboard(port: number): Promise { console.log(chalk.yellow("Could not stop dashboard (may not be running)")); } +function formatSweepSummary(result: DaemonChildSweepResult): string { + return `${result.terminated} graceful, ${result.forceKilled} force-killed${ + result.failed > 0 ? `, ${result.failed} failed` : "" + }`; +} + +async function sweepRegisteredDaemonChildren(ownerPid?: number): Promise { + const result = await sweepDaemonChildren({ ownerPid }); + if (result.attempted > 0) { + console.log( + chalk.dim( + ` Swept ${result.attempted} registered daemon child(ren): ${formatSweepSummary(result)}`, + ), + ); + } +} + +function describeAoOrphans(orphans: AoOrphanProcess[]): string { + return orphans + .map((orphan) => `${orphan.pid} (${orphan.role})`) + .slice(0, 8) + .join(", "); +} + +async function maybeSweepAoOrphansOnStart(reapOrphans: boolean | undefined): Promise { + const orphans = await scanAoOrphans(); + if (orphans.length === 0) return; + + if (!reapOrphans && isHumanCaller()) { + console.log( + chalk.yellow( + `\n Found ${orphans.length} orphaned AO child process(es): ${describeAoOrphans(orphans)}`, + ), + ); + reapOrphans = await promptConfirm("Kill orphaned AO child processes before starting?", true); + } + + if (!reapOrphans) { + console.log( + chalk.yellow( + ` Found ${orphans.length} orphaned AO child process(es). Run \`ao start --reap-orphans\` to clean them up.`, + ), + ); + return; + } + + const result = await reapAoOrphans(orphans); + console.log( + chalk.green( + ` Reaped ${result.attempted} orphaned AO child process(es): ${formatSweepSummary(result)}`, + ), + ); +} + /** * Spawn an orchestrator session against an already-running daemon, invalidate * the dashboard's project cache, and surface enough context for the user to @@ -1305,6 +1346,7 @@ export function registerStart(program: Command): void { .option("--rebuild", "Clean and rebuild dashboard before starting") .option("--dev", "Use Next.js dev server with hot reload (for dashboard UI development)") .option("--interactive", "Prompt to configure config settings") + .option("--reap-orphans", "Kill orphaned AO child processes before starting") .action( async ( projectArg?: string, @@ -1314,6 +1356,7 @@ export function registerStart(program: Command): void { rebuild?: boolean; dev?: boolean; interactive?: boolean; + reapOrphans?: boolean; }, ) => { let releaseStartupLock: (() => void) | undefined; @@ -1326,6 +1369,7 @@ export function registerStart(program: Command): void { try { releaseStartupLock = await acquireStartupLock(); + await maybeSweepAoOrphansOnStart(opts?.reapOrphans); let config: OrchestratorConfig; let projectId: string; let project: ProjectConfig; @@ -1603,9 +1647,7 @@ export function registerStart(program: Command): void { }); // Ctrl+C and `ao stop` (which sends SIGTERM) perform a full - // graceful shutdown: kill sessions, record last-stop state for - // restore, unregister, then exit. See lib/shutdown.ts. - installShutdownHandlers({ configPath: config.configPath, projectId }); + // graceful shutdown via the handler installed inside runStartup(). } catch (err) { if (err instanceof Error) { console.error(chalk.red("\nError:"), err.message); @@ -1691,6 +1733,7 @@ export function registerStop(program: Command): void { // protocol so node-pty disposes ConPTY gracefully (avoids WER // 0x800700e8). No-op on non-Windows. await sweepWindowsPtyHostsBeforeParentKill(); + await sweepRegisteredDaemonChildren(running.pid); // killProcessTree handles process trees on Windows (taskkill /T /F) // and process groups on Unix; it swallows "already dead" internally. await killProcessTree(running.pid, "SIGTERM"); @@ -1828,8 +1871,11 @@ export function registerStop(program: Command): void { // protocol so node-pty disposes ConPTY gracefully (avoids WER // 0x800700e8). No-op on non-Windows. await sweepWindowsPtyHostsBeforeParentKill(); + await sweepRegisteredDaemonChildren(running.pid); await killProcessTree(running.pid, "SIGTERM"); await unregister(); + } else { + await sweepRegisteredDaemonChildren(); } await stopDashboard(running?.port ?? port); } diff --git a/packages/cli/src/lib/daemon.ts b/packages/cli/src/lib/daemon.ts index 9a5c60e6a..5ccccd924 100644 --- a/packages/cli/src/lib/daemon.ts +++ b/packages/cli/src/lib/daemon.ts @@ -20,7 +20,7 @@ */ import chalk from "chalk"; -import { killProcessTree } from "@aoagents/ao-core"; +import { killProcessTree, sweepDaemonChildren } from "@aoagents/ao-core"; import { unregister, waitForExit, type RunningState } from "./running-state.js"; /** @@ -90,6 +90,7 @@ export function attachToDaemon(running: RunningState): AttachedDaemon { * internally. */ export async function killExistingDaemon(running: RunningState): Promise { + await sweepDaemonChildren({ ownerPid: running.pid }); await killProcessTree(running.pid, "SIGTERM"); if (!(await waitForExit(running.pid, 5000))) { console.log(chalk.yellow(" Process didn't exit cleanly, sending SIGKILL...")); diff --git a/packages/cli/src/lib/shutdown.ts b/packages/cli/src/lib/shutdown.ts index 397efe795..228f5f88d 100644 --- a/packages/cli/src/lib/shutdown.ts +++ b/packages/cli/src/lib/shutdown.ts @@ -12,7 +12,12 @@ * see ao-118 plan PR B). */ -import { isTerminalSession, loadConfig } from "@aoagents/ao-core"; +import { + isTerminalSession, + loadConfig, + markDaemonShutdownHandlerInstalled, + sweepDaemonChildren, +} from "@aoagents/ao-core"; import { stopBunTmpJanitor } from "./bun-tmp-janitor.js"; import { getSessionManager } from "./create-session-manager.js"; import { stopAllLifecycleWorkers } from "./lifecycle-service.js"; @@ -36,6 +41,10 @@ export interface ShutdownContext { let handlersInstalled = false; let shuttingDown = false; +export function isShutdownInProgress(): boolean { + return shuttingDown; +} + /** * Install SIGINT/SIGTERM handlers. Process-wide idempotent — calling * this more than once is a no-op. Only the first signal triggers @@ -45,6 +54,7 @@ let shuttingDown = false; export function installShutdownHandlers(ctx: ShutdownContext): void { if (handlersInstalled) return; handlersInstalled = true; + markDaemonShutdownHandlerInstalled(); const shutdown = (signal: NodeJS.Signals): void => { if (shuttingDown) return; @@ -105,6 +115,7 @@ export function installShutdownHandlers(ctx: ShutdownContext): void { }); } + await sweepDaemonChildren({ ownerPid: process.pid }); await unregister(); } catch { // Best-effort — always exit even if cleanup fails diff --git a/packages/core/src/__tests__/daemon-children.test.ts b/packages/core/src/__tests__/daemon-children.test.ts new file mode 100644 index 000000000..2f4ec8f4b --- /dev/null +++ b/packages/core/src/__tests__/daemon-children.test.ts @@ -0,0 +1,171 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + __getDaemonChildrenRegistryFile, + clearDaemonChildrenRegistry, + detectAoOrphansFromPsOutput, + getDaemonChildren, + registerDaemonChild, + spawnManagedDaemonChild, + sweepDaemonChildren, + unregisterDaemonChild, +} from "../daemon-children.js"; + +function isProcessAliveForTest(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (err: unknown) { + return (err as { code?: string }).code === "EPERM"; + } +} + +async function waitForChildExit(child: ChildProcess): Promise { + if (child.exitCode !== null || child.signalCode !== null) return; + await Promise.race([ + new Promise((resolve) => child.once("exit", () => resolve())), + new Promise((resolve) => setTimeout(resolve, 2_000)), + ]); +} + +describe("daemon child registry", () => { + let tmpHome: string; + let originalHome: string | undefined; + + beforeEach(() => { + tmpHome = mkdtempSync(join(tmpdir(), "ao-daemon-children-")); + originalHome = process.env["HOME"]; + process.env["HOME"] = tmpHome; + clearDaemonChildrenRegistry(); + }); + + afterEach(() => { + clearDaemonChildrenRegistry(); + if (originalHome === undefined) delete process.env["HOME"]; + else process.env["HOME"] = originalHome; + rmSync(tmpHome, { recursive: true, force: true }); + }); + + it("writes, reads, and unregisters daemon child pids", () => { + registerDaemonChild({ + pid: process.pid, + parentPid: process.pid, + role: "dashboard", + command: "node dist-server/start-all.js", + }); + + expect(getDaemonChildren()).toEqual([ + expect.objectContaining({ + pid: process.pid, + parentPid: process.pid, + role: "dashboard", + command: "node dist-server/start-all.js", + }), + ]); + + const raw = JSON.parse(readFileSync(__getDaemonChildrenRegistryFile(), "utf-8")) as unknown[]; + expect(raw).toHaveLength(1); + + unregisterDaemonChild(process.pid); + expect(getDaemonChildren()).toEqual([]); + }); + + it("sweeps only children owned by the requested daemon pid", async () => { + const targetChild = spawn(process.execPath, ["-e", "setTimeout(() => {}, 30_000)"], { + stdio: "ignore", + }); + const otherChild = spawn(process.execPath, ["-e", "setTimeout(() => {}, 30_000)"], { + stdio: "ignore", + }); + + try { + expect(targetChild.pid).toBeTypeOf("number"); + expect(otherChild.pid).toBeTypeOf("number"); + const targetPid = targetChild.pid as number; + const otherPid = otherChild.pid as number; + + registerDaemonChild({ + pid: targetPid, + parentPid: 111, + role: "owned-by-target", + command: "node target.js", + }); + registerDaemonChild({ + pid: otherPid, + parentPid: 222, + role: "owned-by-other-daemon", + command: "node other.js", + }); + + const result = await sweepDaemonChildren({ ownerPid: 111, graceMs: 1_000 }); + + expect(result.attempted).toBe(1); + expect(isProcessAliveForTest(otherPid)).toBe(true); + expect(getDaemonChildren()).toContainEqual( + expect.objectContaining({ + pid: otherPid, + parentPid: 222, + role: "owned-by-other-daemon", + }), + ); + } finally { + targetChild.kill("SIGKILL"); + otherChild.kill("SIGKILL"); + await waitForChildExit(targetChild); + await waitForChildExit(otherChild); + } + }); + + it("spawnManagedDaemonChild makes registry tracking the default for daemon spawns", async () => { + const child = spawnManagedDaemonChild( + "test-child", + process.execPath, + ["-e", "setTimeout(() => {}, 30_000)"], + { stdio: "ignore" }, + ); + + expect(child.pid).toBeTypeOf("number"); + expect(getDaemonChildren()).toContainEqual( + expect.objectContaining({ + pid: child.pid, + role: "test-child", + parentPid: process.pid, + command: `${process.execPath} -e setTimeout(() => {}, 30_000)`, + }), + ); + + child.kill("SIGTERM"); + await new Promise((resolve) => child.once("exit", () => resolve())); + + expect(getDaemonChildren()).not.toContainEqual(expect.objectContaining({ pid: child.pid })); + }); +}); + +describe("AO orphan detection", () => { + it("detects PPID=1 AO dashboard, websocket, and lifecycle processes", () => { + const output = [ + " 90350 1 node next-server (v15.5.15)", + " 90351 1 node /opt/homebrew/lib/node_modules/@aoagents/ao-web/dist-server/start-all.js", + " 47457 1 node /opt/homebrew/lib/node_modules/@aoagents/ao-web@0.2.4/dist-server/start-all.js", + " 47458 1 node @aoagents/ao-web@0.2.4 dist-server/start-all.js", + " 47575 1 node /opt/homebrew/lib/node_modules/@aoagents/ao-web@0.2.4/dist-server/terminal-websocket.js", + " 47580 1 node /opt/homebrew/lib/node_modules/@aoagents/ao-web@0.2.4/dist-server/direct-terminal-ws.js", + " 9914 1 node /opt/homebrew/bin/ao lifecycle-worker codex-startup-factory", + " 22222 3333 node /opt/homebrew/bin/ao lifecycle-worker not-an-orphan", + " 44444 1 node unrelated-server.js", + ].join("\n"); + + expect(detectAoOrphansFromPsOutput(output)).toEqual([ + expect.objectContaining({ pid: 90350, role: "next-server" }), + expect.objectContaining({ pid: 90351, role: "ao-web" }), + expect.objectContaining({ pid: 47457, role: "ao-web" }), + expect.objectContaining({ pid: 47458, role: "ao-web" }), + expect.objectContaining({ pid: 47575, role: "ao-web" }), + expect.objectContaining({ pid: 47580, role: "ao-web" }), + expect.objectContaining({ pid: 9914, role: "lifecycle-worker" }), + ]); + }); +}); diff --git a/packages/core/src/daemon-children.ts b/packages/core/src/daemon-children.ts new file mode 100644 index 000000000..1e3d55008 --- /dev/null +++ b/packages/core/src/daemon-children.ts @@ -0,0 +1,528 @@ +import { + type ChildProcess, + type SpawnOptions, + execFile as execFileCb, + spawn, +} from "node:child_process"; +import { + existsSync, + mkdirSync, + readFileSync, + rmSync, + rmdirSync, + statSync, + unlinkSync, +} from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { setTimeout as sleep } from "node:timers/promises"; +import { promisify } from "node:util"; +import { atomicWriteFileSync } from "./atomic-write.js"; +import { isWindows, killProcessTree } from "./platform.js"; + +const execFileAsync = promisify(execFileCb); +const DEFAULT_GRACE_MS = 5_000; +const LOCK_STALE_MS = 10_000; + +export interface DaemonChildEntry { + pid: number; + role: string; + parentPid: number; + startedAt: string; + command?: string; +} + +export interface DaemonChildSweepResult { + attempted: number; + terminated: number; + forceKilled: number; + failed: number; +} + +export interface AoOrphanProcess { + pid: number; + ppid: number; + command: string; + role: string; +} + +export interface DaemonChildSweepOptions { + ownerPid?: number; + graceMs?: number; +} + +function getRegistryFile(): string { + return join(homedir(), ".agent-orchestrator", "daemon-children.json"); +} + +function getLockDir(): string { + return join(homedir(), ".agent-orchestrator", "daemon-children.lock"); +} + +function ensureStateDir(): void { + mkdirSync(join(homedir(), ".agent-orchestrator"), { recursive: true }); +} + +function sleepSync(ms: number): void { + const buffer = new SharedArrayBuffer(4); + const view = new Int32Array(buffer); + Atomics.wait(view, 0, 0, ms); +} + +function acquireRegistryLock(): () => void { + ensureStateDir(); + const lockDir = getLockDir(); + const deadline = Date.now() + 5_000; + while (true) { + try { + mkdirSync(lockDir); + return () => { + try { + rmdirSync(lockDir); + } catch { + // Best effort. + } + }; + } catch (err: unknown) { + const code = (err as NodeJS.ErrnoException).code; + if (code !== "EEXIST") throw err; + try { + const ageMs = Date.now() - statSync(lockDir).mtimeMs; + if (ageMs > LOCK_STALE_MS) { + rmSync(lockDir, { recursive: true, force: true }); + continue; + } + } catch { + // Retry lock acquisition if the lock disappeared between calls. + } + if (Date.now() > deadline) { + throw new Error(`Could not acquire daemon child registry lock (${lockDir})`, { + cause: err, + }); + } + sleepSync(25); + } + } +} + +function isDaemonChildEntry(value: unknown): value is DaemonChildEntry { + if (typeof value !== "object" || value === null) return false; + const entry = value as Partial; + return ( + typeof entry.pid === "number" && + entry.pid > 0 && + typeof entry.role === "string" && + typeof entry.parentPid === "number" && + typeof entry.startedAt === "string" && + (entry.command === undefined || typeof entry.command === "string") + ); +} + +function readRawDaemonChildren(): DaemonChildEntry[] { + const file = getRegistryFile(); + if (!existsSync(file)) return []; + try { + const parsed = JSON.parse(readFileSync(file, "utf-8")) as unknown; + if (!Array.isArray(parsed)) return []; + return parsed.filter(isDaemonChildEntry); + } catch { + return []; + } +} + +function writeRawDaemonChildren(entries: DaemonChildEntry[]): void { + const file = getRegistryFile(); + if (entries.length === 0) { + try { + unlinkSync(file); + } catch { + // File may not exist. + } + return; + } + ensureStateDir(); + atomicWriteFileSync(file, JSON.stringify(entries, null, 2)); +} + +function isProcessAlive(pid: number): boolean { + if (pid <= 0) return false; + try { + process.kill(pid, 0); + return true; + } catch (err: unknown) { + return (err as { code?: string }).code === "EPERM"; + } +} + +async function waitForProcessesExit(pids: number[], timeoutMs: number): Promise> { + const alive = new Set(pids.filter(isProcessAlive)); + const deadline = Date.now() + timeoutMs; + + while (alive.size > 0 && Date.now() < deadline) { + for (const pid of alive) { + if (!isProcessAlive(pid)) alive.delete(pid); + } + if (alive.size > 0) { + await sleep(Math.min(50, Math.max(0, deadline - Date.now()))); + } + } + + for (const pid of alive) { + if (!isProcessAlive(pid)) alive.delete(pid); + } + + return alive; +} + +export function registerDaemonChild(entry: Omit): void { + if (entry.pid <= 0) return; + const release = acquireRegistryLock(); + try { + const next = readRawDaemonChildren().filter((existing) => existing.pid !== entry.pid); + next.push({ ...entry, startedAt: new Date().toISOString() }); + writeRawDaemonChildren(next); + } finally { + release(); + } +} + +export function unregisterDaemonChild(pid: number): void { + const release = acquireRegistryLock(); + try { + const before = readRawDaemonChildren(); + const next = before.filter((entry) => entry.pid !== pid); + if (next.length === before.length) return; + writeRawDaemonChildren(next); + } finally { + release(); + } +} + +export function getDaemonChildren(): DaemonChildEntry[] { + const release = acquireRegistryLock(); + try { + const all = readRawDaemonChildren(); + const live = all.filter((entry) => isProcessAlive(entry.pid)); + if (live.length !== all.length) writeRawDaemonChildren(live); + return live; + } finally { + release(); + } +} + +export function clearDaemonChildrenRegistry(): void { + const release = acquireRegistryLock(); + try { + writeRawDaemonChildren([]); + } finally { + release(); + } +} + +function pruneSweptDaemonChildren(sweptPids: Set): void { + const release = acquireRegistryLock(); + try { + const next = readRawDaemonChildren().filter( + (entry) => !sweptPids.has(entry.pid) || isProcessAlive(entry.pid), + ); + writeRawDaemonChildren(next); + } finally { + release(); + } +} + +const reapedChildren = new WeakSet(); +const managedChildren = new Map(); +let managedSignalHandlersInstalled = false; +let daemonShutdownHandlerInstalled = false; +let fallbackShutdownStarted = false; + +/** + * Tell the managed child reaper that this process owns an application-level + * SIGINT/SIGTERM shutdown path. The reaper will still forward signals to + * children, but it will not install its default 50ms fallback exit; the owning + * shutdown handler is responsible for exiting after its async cleanup finishes. + */ +export function markDaemonShutdownHandlerInstalled(): void { + daemonShutdownHandlerInstalled = true; +} + +function getManagedChildPids(): number[] { + return [...managedChildren.keys()]; +} + +function getSignalExitCode(signal: NodeJS.Signals): number { + if (signal === "SIGINT") return 130; + if (signal === "SIGTERM") return 143; + return 1; +} + +function terminateManagedChildren(signal: "SIGTERM" | "SIGKILL" = "SIGTERM"): void { + for (const [pid, child] of managedChildren) { + void killProcessTree(pid, signal); + try { + child.kill(signal); + } catch { + // Already gone. + } + } +} + +async function exitAfterManagedChildren(signal: NodeJS.Signals): Promise { + const exitCode = getSignalExitCode(signal); + const pids = getManagedChildPids(); + + terminateManagedChildren("SIGTERM"); + + const stillAlive = await waitForProcessesExit(pids, DEFAULT_GRACE_MS); + if (stillAlive.size > 0) { + terminateManagedChildren("SIGKILL"); + await waitForProcessesExit([...stillAlive], 1_000); + } + + process.exit(exitCode); +} + +function installManagedSignalHandlers(): void { + if (managedSignalHandlersInstalled || isWindows()) return; + managedSignalHandlersInstalled = true; + + const forward = (signal: NodeJS.Signals): void => { + terminateManagedChildren(); + + // Installing a signal listener disables Node's default "exit on signal" + // behaviour. If no application-level shutdown handler is present, preserve + // that default after giving managed children the same graceful + // SIGTERM→wait→SIGKILL lifecycle used by `ao stop`. + if (!daemonShutdownHandlerInstalled && !fallbackShutdownStarted) { + fallbackShutdownStarted = true; + void exitAfterManagedChildren(signal); + } + }; + + process.on("SIGINT", forward); + process.on("SIGTERM", forward); + process.on("exit", () => terminateManagedChildren()); +} + +/** + * Track a long-running daemon child in the pid registry and forward parent + * shutdown to it. If the owning process has its own shutdown handler, that + * handler remains responsible for exiting; otherwise the managed signal + * handler preserves Node's default signal-exit behaviour after forwarding. + */ +export function registerChildReaper(child: ChildProcess, role: string, command?: string): void { + if (reapedChildren.has(child)) return; + reapedChildren.add(child); + + const pid = child.pid; + if (!pid) return; + + registerDaemonChild({ pid, role, parentPid: process.pid, command }); + managedChildren.set(pid, child); + installManagedSignalHandlers(); + + const cleanup = (): void => { + managedChildren.delete(pid); + unregisterDaemonChild(pid); + }; + + child.once("exit", cleanup); + child.once("error", cleanup); +} + +/** + * The required interface for long-running subprocesses owned by the AO daemon. + * Callers get normal child_process.spawn behaviour, plus pid registry, + * signal forwarding, process-group cleanup, and registry unregister on exit. + */ +export function spawnManagedDaemonChild( + role: string, + command: string, + args: readonly string[], + options: SpawnOptions = {}, +): ChildProcess { + const child = spawn(command, [...args], options); + registerChildReaper(child, role, [command, ...args].join(" ")); + return child; +} + +export async function sweepDaemonChildren( + options: DaemonChildSweepOptions = {}, +): Promise { + const { ownerPid, graceMs = DEFAULT_GRACE_MS } = options; + const entries = getDaemonChildren().filter( + (entry) => ownerPid === undefined || entry.parentPid === ownerPid, + ); + const result: DaemonChildSweepResult = { + attempted: entries.length, + terminated: 0, + forceKilled: 0, + failed: 0, + }; + + for (const entry of entries) { + await killProcessTree(entry.pid, "SIGTERM"); + } + + const pids = entries.map((entry) => entry.pid); + const stillAliveAfterTerm = await waitForProcessesExit(pids, graceMs); + result.terminated = pids.length - stillAliveAfterTerm.size; + + for (const entry of entries.filter((entry) => stillAliveAfterTerm.has(entry.pid))) { + await killProcessTree(entry.pid, "SIGKILL"); + } + + const stillAliveAfterKill = await waitForProcessesExit([...stillAliveAfterTerm], 1_000); + result.forceKilled = stillAliveAfterTerm.size - stillAliveAfterKill.size; + result.failed = stillAliveAfterKill.size; + + pruneSweptDaemonChildren(new Set(entries.map((entry) => entry.pid))); + return result; +} + +function isAsciiWhitespace(char: string): boolean { + return char === " " || char === "\t" || char === "\r" || char === "\n"; +} + +function normalizeCommand(command: string): string { + return command.replaceAll("\\", "/").toLowerCase(); +} + +function firstCommandWord(command: string): string { + let start = 0; + while (start < command.length && isAsciiWhitespace(command[start] ?? "")) start++; + + let end = start; + while (end < command.length && !isAsciiWhitespace(command[end] ?? "")) end++; + + return command.slice(start, end); +} + +function commandLooksLikeNode(command: string): boolean { + const executable = firstCommandWord(normalizeCommand(command)); + const basename = executable.slice(executable.lastIndexOf("/") + 1); + return basename === "node" || basename === "node.exe"; +} + +export function classifyAoOrphanCommand(command: string): string | null { + if (!commandLooksLikeNode(command)) return null; + + const normalized = normalizeCommand(command); + + if ( + normalized.includes("@aoagents/ao-web") && + (normalized.includes("/dist-server/") || normalized.includes(" dist-server/")) + ) { + return "ao-web"; + } + if ( + normalized.includes("/ao lifecycle-worker ") || + normalized.includes(" ao lifecycle-worker ") + ) { + return "lifecycle-worker"; + } + if (normalized.includes("next-server")) { + return "next-server"; + } + return null; +} + +function parseLeadingUnsignedInt( + value: string, + start: number, +): { value: number; next: number } | null { + let index = start; + while (index < value.length && isAsciiWhitespace(value[index] ?? "")) index++; + + const firstDigit = index; + while (index < value.length) { + const code = value.charCodeAt(index); + if (code < 48 || code > 57) break; + index++; + } + + if (index === firstDigit) return null; + return { value: Number(value.slice(firstDigit, index)), next: index }; +} + +function parsePsLine(line: string): AoOrphanProcess | null { + const pid = parseLeadingUnsignedInt(line, 0); + if (!pid) return null; + + const ppid = parseLeadingUnsignedInt(line, pid.next); + if (!ppid) return null; + + let commandStart = ppid.next; + while (commandStart < line.length && isAsciiWhitespace(line[commandStart] ?? "")) { + commandStart++; + } + + const command = line.slice(commandStart); + if (!Number.isFinite(pid.value) || !Number.isFinite(ppid.value) || ppid.value !== 1) { + return null; + } + + const role = classifyAoOrphanCommand(command); + if (!role) return null; + return { pid: pid.value, ppid: ppid.value, command, role }; +} + +export function detectAoOrphansFromPsOutput(psOutput: string): AoOrphanProcess[] { + const orphans: AoOrphanProcess[] = []; + for (const rawLine of psOutput.replaceAll("\r\n", "\n").split("\n")) { + const line = rawLine.trimStart(); + if (!line) continue; + + const orphan = parsePsLine(line); + if (orphan) orphans.push(orphan); + } + return orphans; +} + +export async function scanAoOrphans(): Promise { + if (isWindows()) return []; + try { + const { stdout } = await execFileAsync("ps", ["-axo", "pid,ppid,command"], { + windowsHide: true, + maxBuffer: 10 * 1024 * 1024, + }); + return detectAoOrphansFromPsOutput(stdout); + } catch { + return []; + } +} + +export async function reapAoOrphans( + orphans: AoOrphanProcess[], + graceMs: number = DEFAULT_GRACE_MS, +): Promise { + const result: DaemonChildSweepResult = { + attempted: orphans.length, + terminated: 0, + forceKilled: 0, + failed: 0, + }; + + for (const orphan of orphans) { + await killProcessTree(orphan.pid, "SIGTERM"); + } + + const pids = orphans.map((orphan) => orphan.pid); + const stillAliveAfterTerm = await waitForProcessesExit(pids, graceMs); + result.terminated = pids.length - stillAliveAfterTerm.size; + + for (const orphan of orphans.filter((orphan) => stillAliveAfterTerm.has(orphan.pid))) { + await killProcessTree(orphan.pid, "SIGKILL"); + } + + const stillAliveAfterKill = await waitForProcessesExit([...stillAliveAfterTerm], 1_000); + result.forceKilled = stillAliveAfterTerm.size - stillAliveAfterKill.size; + result.failed = stillAliveAfterKill.size; + + return result; +} + +export function __getDaemonChildrenRegistryFile(): string { + return getRegistryFile(); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 6adcdaae4..6cd385068 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -164,10 +164,7 @@ export { resetOpenCodeSessionListCache, } from "./opencode-shared.js"; export type { OpenCodeSessionListEntry } from "./opencode-shared.js"; -export { - getWorkspaceAgentsMdPath, - writeWorkspaceOpenCodeAgentsMd, -} from "./opencode-agents-md.js"; +export { getWorkspaceAgentsMdPath, writeWorkspaceOpenCodeAgentsMd } from "./opencode-agents-md.js"; export { writeOpenCodeConfig } from "./opencode-config.js"; export { getOrchestratorSessionId, @@ -276,6 +273,7 @@ export { export { isWindows, isMac, + isLinux, getDefaultRuntime, getShell, killProcessTree, @@ -412,6 +410,25 @@ export { type WindowsPtyHostEntry, } from "./windows-pty-registry.js"; +export { + registerDaemonChild, + unregisterDaemonChild, + getDaemonChildren, + clearDaemonChildrenRegistry, + markDaemonShutdownHandlerInstalled, + registerChildReaper, + spawnManagedDaemonChild, + sweepDaemonChildren, + classifyAoOrphanCommand, + detectAoOrphansFromPsOutput, + scanAoOrphans, + reapAoOrphans, + type DaemonChildEntry, + type DaemonChildSweepOptions, + type DaemonChildSweepResult, + type AoOrphanProcess, +} from "./daemon-children.js"; + // Activity event logging — structured diagnostic event trail export { recordActivityEvent, droppedEventCount } from "./activity-events.js"; export { isActivityEventsFtsEnabled, closeDb } from "./events-db.js"; diff --git a/packages/core/src/platform.ts b/packages/core/src/platform.ts index 763a953f0..5cde1af87 100644 --- a/packages/core/src/platform.ts +++ b/packages/core/src/platform.ts @@ -20,6 +20,10 @@ export function isMac(): boolean { return process.platform === "darwin"; } +export function isLinux(): boolean { + return process.platform === "linux"; +} + export function getDefaultRuntime(): "tmux" | "process" { return isWindows() ? "process" : "tmux"; } diff --git a/packages/integration-tests/src/daemon-children.integration.test.ts b/packages/integration-tests/src/daemon-children.integration.test.ts new file mode 100644 index 000000000..fd234ea0c --- /dev/null +++ b/packages/integration-tests/src/daemon-children.integration.test.ts @@ -0,0 +1,149 @@ +import { spawn, execFile } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { mkdtemp, rm, realpath } from "node:fs/promises"; +import { createServer } from "node:net"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { isWindows, killProcessTree } from "@aoagents/ao-core"; +import { sleep } from "./helpers/polling.js"; + +const execFileAsync = promisify(execFile); +const __dirname = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(__dirname, "../../.."); +const cliEntry = join(repoRoot, "packages/cli/src/index.ts"); +const tsxBin = join(repoRoot, "packages/cli/node_modules/.bin/tsx"); +const dashboardEntry = join(repoRoot, "packages/web/dist-server/start-all.js"); + +const canRun = !isWindows() && existsSync(tsxBin) && existsSync(dashboardEntry); + +async function getFreePort(): Promise { + return new Promise((resolve, reject) => { + const server = createServer(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + server.close(() => { + if (address && typeof address === "object") resolve(address.port); + else reject(new Error("Could not allocate a free port")); + }); + }); + }); +} + +async function readChildPids(pid: number): Promise { + try { + const { stdout } = await execFileAsync("pgrep", ["-P", String(pid)]); + return stdout + .split(/\s+/) + .map((value) => Number(value)) + .filter((value) => Number.isFinite(value) && value > 0); + } catch { + return []; + } +} + +function isAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (err: unknown) { + return (err as { code?: string }).code === "EPERM"; + } +} + +describe.skipIf(!canRun)("daemon child reaping (integration)", () => { + let tmpHome: string; + let repoPath: string; + let configPath: string; + let startPid: number | undefined; + let port: number; + + beforeEach(async () => { + tmpHome = await realpath(await mkdtemp(join(tmpdir(), "ao-daemon-int-home-"))); + port = await getFreePort(); + repoPath = join(tmpHome, "repo"); + mkdirSync(repoPath, { recursive: true }); + await execFileAsync("git", ["init"], { cwd: repoPath }); + await execFileAsync("git", ["config", "user.email", "test@example.com"], { cwd: repoPath }); + await execFileAsync("git", ["config", "user.name", "Test User"], { cwd: repoPath }); + writeFileSync(join(repoPath, "README.md"), "# daemon child reaping\n"); + await execFileAsync("git", ["add", "."], { cwd: repoPath }); + await execFileAsync("git", ["commit", "-m", "Initial commit"], { cwd: repoPath }); + + configPath = join(repoPath, "agent-orchestrator.yaml"); + writeFileSync( + configPath, + ["runtime: process", "agent: claude-code", "workspace: worktree"].join("\n"), + ); + + const globalConfigPath = join(tmpHome, "global-agent-orchestrator.yaml"); + writeFileSync( + globalConfigPath, + [ + `port: ${port}`, + "defaults:", + " runtime: process", + " agent: claude-code", + " workspace: worktree", + " notifiers: []", + "projects:", + " daemon-int:", + " displayName: Daemon Integration", + ` path: ${JSON.stringify(repoPath)}`, + " defaultBranch: main", + " sessionPrefix: daemon-int", + ].join("\n"), + ); + configPath = globalConfigPath; + }, 30_000); + + afterEach(async () => { + if (startPid && isAlive(startPid)) { + await killProcessTree(startPid, "SIGKILL"); + } + await rm(tmpHome, { recursive: true, force: true }).catch(() => {}); + }, 30_000); + + it("ao stop terminates children spawned by ao start", async () => { + const env = { + ...process.env, + HOME: tmpHome, + AO_CALLER_TYPE: "agent", + AO_CONFIG_PATH: configPath, + AO_GLOBAL_CONFIG: configPath, + PORT: String(port), + }; + const start = spawn(tsxBin, [cliEntry, "start", "--no-orchestrator", "--reap-orphans"], { + cwd: repoPath, + env, + stdio: "ignore", + }); + startPid = start.pid; + expect(startPid).toBeTypeOf("number"); + + const runningPath = join(tmpHome, ".agent-orchestrator/running.json"); + let runningPid: number | undefined; + for (let i = 0; i < 100; i++) { + if (existsSync(runningPath)) { + const running = JSON.parse(readFileSync(runningPath, "utf-8")) as { pid?: number }; + runningPid = running.pid; + break; + } + await sleep(100); + } + expect(runningPid).toBeTypeOf("number"); + + const childPids = await readChildPids(runningPid!); + expect(childPids.length).toBeGreaterThan(0); + + await execFileAsync(tsxBin, [cliEntry, "stop", "--all"], { cwd: repoPath, env, timeout: 20_000 }); + await sleep(5_000); + + const stillAlive = childPids.filter(isAlive); + expect(stillAlive).toEqual([]); + expect(isAlive(runningPid!)).toBe(false); + }, 60_000); +}); diff --git a/packages/web/server/__tests__/server-compatibility.test.ts b/packages/web/server/__tests__/server-compatibility.test.ts index 25ffcf20d..efcfc01db 100644 --- a/packages/web/server/__tests__/server-compatibility.test.ts +++ b/packages/web/server/__tests__/server-compatibility.test.ts @@ -54,11 +54,11 @@ describe("start-all.ts", () => { it("kills child process trees during shutdown", () => { expect(source).toMatch(/killProcessTree/); - expect(source).toMatch(/detached:\s*process\.platform\s*!==\s*["']win32["']/); + expect(source).toMatch(/spawnManagedDaemonChild/); + expect(source).toMatch(/detached:\s*!\s*isWindows\(\)/); }); }); - describe("OrchestratorConfig compatibility", () => { it("OrchestratorConfig does not have dataDir property", () => { const typesSource = readFileSync( diff --git a/packages/web/server/start-all.ts b/packages/web/server/start-all.ts index dd3c3dbaf..d54b11c33 100644 --- a/packages/web/server/start-all.ts +++ b/packages/web/server/start-all.ts @@ -4,12 +4,17 @@ * Replaces the dev-only `concurrently` setup. */ -import { spawn, type ChildProcess } from "node:child_process"; +import { type ChildProcess } from "node:child_process"; import { resolve, dirname } from "node:path"; import { existsSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { createRequire } from "node:module"; -import { killProcessTree } from "@aoagents/ao-core"; +import { + isWindows, + killProcessTree, + markDaemonShutdownHandlerInstalled, + spawnManagedDaemonChild, +} from "@aoagents/ao-core"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -18,6 +23,7 @@ const __dirname = dirname(__filename); const pkgRoot = resolve(__dirname, ".."); const children: ChildProcess[] = []; +markDaemonShutdownHandlerInstalled(); function log(label: string, msg: string): void { process.stdout.write(`[${label}] ${msg}\n`); @@ -34,11 +40,11 @@ function spawnProcess( let slotIndex = -1; function launch(): ChildProcess { - const child = spawn(command, args, { + const child = spawnManagedDaemonChild(`dashboard:${label}`, command, args, { cwd: pkgRoot, stdio: ["ignore", "pipe", "pipe"], env: process.env, - detached: process.platform !== "win32", + detached: !isWindows(), }); child.stdout?.on("data", (data: Buffer) => { @@ -83,7 +89,7 @@ function spawnProcess( function resolveNextBin(): string { // On Windows, .bin/next is a POSIX shell shim that spawn() cannot execute. // Skip it and go straight to the JS entry point. - if (process.platform !== "win32") { + if (!isWindows()) { const localBin = resolve(pkgRoot, "node_modules", ".bin", "next"); if (existsSync(localBin)) return localBin; } @@ -103,7 +109,7 @@ function resolveNextBin(): string { const port = process.env["PORT"] || "3000"; const nextBin = resolveNextBin(); -if (process.platform === "win32" && nextBin !== "next") { +if (isWindows() && nextBin !== "next") { // On Windows, run the JS entry point via the current node binary. // spawn() can't execute .js files directly on Windows. spawnProcess("next", process.execPath, [nextBin, "start", "-p", port]); @@ -112,7 +118,9 @@ if (process.platform === "win32" && nextBin !== "next") { } // Start direct terminal WebSocket server (auto-restart on crash) -spawnProcess("direct-terminal", "node", [resolve(__dirname, "direct-terminal-ws.js")], { restart: true }); +spawnProcess("direct-terminal", "node", [resolve(__dirname, "direct-terminal-ws.js")], { + restart: true, +}); // Graceful shutdown — send SIGTERM to children and wait for them to exit let shuttingDown = false; From 6fb18cb47ecc999069888282cd94a3266e35b481 Mon Sep 17 00:00:00 2001 From: i-trytoohard Date: Fri, 15 May 2026 04:30:44 +0530 Subject: [PATCH 6/9] fix(web): authoritative session.state for terminated UI rendering (closes #1832) (#1833) * fix(web): honor lifecycle state for terminal session UI * fix(web): address terminal state review feedback * chore: retrigger integration ci * fix(web): trim terminal state change scope * fix(web): preserve legacy terminated helper behavior --------- Co-authored-by: i-trytoohard <193449657+i-trytoohard@users.noreply.github.com> --- packages/web/src/__tests__/api-routes.test.ts | 31 +++++++++++++ packages/web/src/app/api/sessions/route.ts | 17 ++++--- packages/web/src/components/BottomSheet.tsx | 43 ++++++++--------- packages/web/src/components/Dashboard.tsx | 7 +-- packages/web/src/components/SessionDetail.tsx | 10 ++-- .../__tests__/SessionDetail.desktop.test.tsx | 46 +++++++++++++++++++ packages/web/src/lib/types.ts | 11 ++++- 7 files changed, 130 insertions(+), 35 deletions(-) diff --git a/packages/web/src/__tests__/api-routes.test.ts b/packages/web/src/__tests__/api-routes.test.ts index 60ff21c15..84f769e97 100644 --- a/packages/web/src/__tests__/api-routes.test.ts +++ b/packages/web/src/__tests__/api-routes.test.ts @@ -315,6 +315,37 @@ describe("API Routes", () => { vi.useRealTimers(); }); + it("activeOnly keeps working sessions with stale terminatedAt annotations", async () => { + const staleLifecycle = createInitialCanonicalLifecycle( + "worker", + new Date("2026-05-13T19:13:20.146Z"), + ); + staleLifecycle.session.state = "working"; + staleLifecycle.session.reason = "task_in_progress"; + staleLifecycle.session.terminatedAt = "2026-05-13T19:13:20.146Z"; + staleLifecycle.runtime.state = "alive"; + staleLifecycle.runtime.reason = "process_running"; + + (mockSessionManager.listCached as ReturnType).mockResolvedValueOnce([ + makeSession({ + id: "worker-restored-stale-terminal-marker", + status: "terminated", + activity: "exited", + lifecycle: staleLifecycle, + }), + ]); + + const res = await sessionsGET(makeRequest("http://localhost:3000/api/sessions?active=true")); + expect(res.status).toBe(200); + const data = await res.json(); + + expect(data.sessions.map((session: { id: string }) => session.id)).toEqual([ + "worker-restored-stale-terminal-marker", + ]); + expect(data.sessions[0].lifecycle.sessionState).toBe("working"); + expect(data.sessions[0].lifecycle.session.terminatedAt).toBe("2026-05-13T19:13:20.146Z"); + }); + it("returns per-project orchestrators and excludes them from worker sessions", async () => { (mockSessionManager.listCached as ReturnType).mockResolvedValueOnce( multiProjectSessions, diff --git a/packages/web/src/app/api/sessions/route.ts b/packages/web/src/app/api/sessions/route.ts index 77a3f1ec2..539740f16 100644 --- a/packages/web/src/app/api/sessions/route.ts +++ b/packages/web/src/app/api/sessions/route.ts @@ -1,4 +1,4 @@ -import { ACTIVITY_STATE, isOrchestratorSession, isTerminalSession } from "@aoagents/ao-core"; +import { isOrchestratorSession, isTerminalSession } from "@aoagents/ao-core"; import { getServices } from "@/lib/services"; import { sessionToDashboard, @@ -10,11 +10,14 @@ import { import { getCorrelationId, jsonWithCorrelation, recordApiObservation } from "@/lib/observability"; import { filterProjectSessions } from "@/lib/project-utils"; import { settlesWithin } from "@/lib/async-utils"; -import type { DashboardOrchestratorLink } from "@/lib/types"; +import { isDashboardSessionTerminal, type DashboardOrchestratorLink } from "@/lib/types"; const METADATA_ENRICH_TIMEOUT_MS = 3_000; -function compareOrchestratorRecency(a: { lastActivityAt?: Date | null; createdAt?: Date | null; id: string }, b: { lastActivityAt?: Date | null; createdAt?: Date | null; id: string }): number { +function compareOrchestratorRecency( + a: { lastActivityAt?: Date | null; createdAt?: Date | null; id: string }, + b: { lastActivityAt?: Date | null; createdAt?: Date | null; id: string }, +): number { return ( (b.lastActivityAt?.getTime() ?? 0) - (a.lastActivityAt?.getTime() ?? 0) || (b.createdAt?.getTime() ?? 0) - (a.createdAt?.getTime() ?? 0) || @@ -54,7 +57,7 @@ function selectPreferredOrchestratorId( function listPreferredProjectOrchestrators( sessions: Parameters[0], projects: Parameters[1], -) : DashboardOrchestratorLink[] { +): DashboardOrchestratorLink[] { const preferredOrchestrators = listProjectOrchestratorSessions(sessions, projects); return preferredOrchestrators @@ -90,7 +93,9 @@ export async function GET(request: Request) { : listDashboardOrchestrators(visibleSessions, config.projects); const orchestratorId = requestedProjectId ? selectPreferredOrchestratorId(visibleSessions, config.projects) - : (orchestrators.length === 1 ? (orchestrators[0]?.id ?? null) : null); + : orchestrators.length === 1 + ? (orchestrators[0]?.id ?? null) + : null; if (orchestratorOnly) { recordApiObservation({ @@ -132,7 +137,7 @@ export async function GET(request: Request) { if (activeOnly) { const activeIndices = dashboardSessions - .map((session, index) => (session.activity !== ACTIVITY_STATE.EXITED ? index : -1)) + .map((session, index) => (!isDashboardSessionTerminal(session) ? index : -1)) .filter((index) => index !== -1); workerSessions = activeIndices.map((index) => workerSessions[index]); dashboardSessions = activeIndices.map((index) => dashboardSessions[index]); diff --git a/packages/web/src/components/BottomSheet.tsx b/packages/web/src/components/BottomSheet.tsx index 5860efe49..5f09d33d0 100644 --- a/packages/web/src/components/BottomSheet.tsx +++ b/packages/web/src/components/BottomSheet.tsx @@ -1,7 +1,14 @@ "use client"; import { useEffect, useRef } from "react"; -import { getAttentionLevel, isPRRateLimited, isPRUnenriched, type DashboardSession } from "@/lib/types"; +import { + getAttentionLevel, + isDashboardSessionTerminated, + isDashboardSessionTerminal, + isPRRateLimited, + isPRUnenriched, + type DashboardSession, +} from "@/lib/types"; import { getSessionTitle } from "@/lib/format"; import { projectSessionPath } from "@/lib/routes"; @@ -24,12 +31,10 @@ function formatTagLabel(value: string): string { } function isTag( - value: - | { - label: string; - tone: "accent" | "neutral" | "mono"; - } - | null, + value: { + label: string; + tone: "accent" | "neutral" | "mono"; + } | null, ): value is { label: string; tone: "accent" | "neutral" | "mono" } { return value !== null; } @@ -76,7 +81,7 @@ export function BottomSheet({ if (!sheetRef.current) return; const focusable = sheetRef.current.querySelectorAll( - 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', ); if (focusable.length === 0) return; @@ -122,13 +127,12 @@ export function BottomSheet({ const title = getSessionTitle(session); const attention = getAttentionLevel(session); - const summary = - session.summary && !session.summaryIsFallback ? session.summary : null; + const summary = session.summary && !session.summaryIsFallback ? session.summary : null; const hasLiveTerminateAction = - attention !== "done" && attention !== "merge" && session.status !== "terminated"; + attention !== "done" && attention !== "merge" && !isDashboardSessionTerminated(session); const pr = session.pr; const showLivePrData = Boolean(pr && !isPRRateLimited(pr) && !isPRUnenriched(pr)); - const showTerminalStatePills = attention === "done" || session.status === "terminated" || session.activity === "exited"; + const showTerminalStatePills = attention === "done" || isDashboardSessionTerminal(session); const tags = [ { label: formatTagLabel(attention), tone: "accent" as const }, { label: formatTagLabel(session.status), tone: "neutral" as const }, @@ -141,11 +145,7 @@ export function BottomSheet({ return ( <> {/* Backdrop */} -