Merge main into feat/issue-1654 and address review comments
This commit is contained in:
commit
b4dec563e8
|
|
@ -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)
|
||||
|
|
@ -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-<sessionId>`,
|
||||
registered so `ao stop` can reach it.
|
||||
|
||||
A new cross-platform abstraction layer (`packages/core/src/platform.ts`)
|
||||
centralises every platform branch behind helpers like `isWindows()`,
|
||||
`getDefaultRuntime()`, `getShell()`, `killProcessTree()`, `findPidByPort()`,
|
||||
and `getEnvDefaults()`. Path comparison uses `pathsEqual` /
|
||||
`canonicalCompareKey` to handle NTFS case-insensitivity. PATH wrappers for
|
||||
agent plugins (`gh`, `git`) ship as `.cjs` + `.cmd` shims on Windows;
|
||||
`script-runner` runs `.ps1` siblings of `.sh` scripts via PowerShell. New
|
||||
`ao-doctor.ps1` / `ao-update.ps1` shipped.
|
||||
|
||||
`ao open` is now cross-platform: it sources sessions from `sm.list()`
|
||||
instead of `tmux list-sessions` (so `runtime-process` sessions on Windows
|
||||
appear), and the open action branches per OS — `open-iterm-tab` stays the
|
||||
macOS path, native handling on Windows and Linux.
|
||||
|
||||
Behaviour on macOS and Linux is unchanged. Every Windows path is gated
|
||||
behind `isWindows()`; `runtime-tmux` and the bash hook flows are untouched.
|
||||
|
||||
See `docs/CROSS_PLATFORM.md` for the developer reference (helper inventory,
|
||||
EPERM-vs-ESRCH gotcha, PowerShell-vs-bash differences, pre-merge checklist).
|
||||
The Windows runtime architecture (pty-host, pipe protocol, registry, sweep,
|
||||
mux WS Windows branch) is documented in `docs/ARCHITECTURE.md`.
|
||||
|
|
@ -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 <prefix>-orchestrator "<message>"` 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)
|
||||
|
|
@ -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 <value>` (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@<channel>`)
|
||||
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).
|
||||
|
|
@ -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)
|
||||
|
|
@ -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 <X> 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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-<sha>` 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
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
Subproject commit 2abb1c1e3dd804da34baf8262fa44c6766421032
|
||||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
Subproject commit 27eeea0555a0a15b19cea615f46d1f8b88b9dbc1
|
||||
|
|
@ -0,0 +1 @@
|
|||
Subproject commit 351d354b4b3b3e45f38e29897af8acec9966fd41
|
||||
|
|
@ -1,5 +1,93 @@
|
|||
# @aoagents/ao
|
||||
|
||||
## 0.8.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @aoagents/ao-cli@0.8.0
|
||||
|
||||
## 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-<sessionId>`,
|
||||
registered so `ao stop` can reach it.
|
||||
|
||||
A new cross-platform abstraction layer (`packages/core/src/platform.ts`)
|
||||
centralises every platform branch behind helpers like `isWindows()`,
|
||||
`getDefaultRuntime()`, `getShell()`, `killProcessTree()`, `findPidByPort()`,
|
||||
and `getEnvDefaults()`. Path comparison uses `pathsEqual` /
|
||||
`canonicalCompareKey` to handle NTFS case-insensitivity. PATH wrappers for
|
||||
agent plugins (`gh`, `git`) ship as `.cjs` + `.cmd` shims on Windows;
|
||||
`script-runner` runs `.ps1` siblings of `.sh` scripts via PowerShell. New
|
||||
`ao-doctor.ps1` / `ao-update.ps1` shipped.
|
||||
|
||||
`ao open` is now cross-platform: it sources sessions from `sm.list()`
|
||||
instead of `tmux list-sessions` (so `runtime-process` sessions on Windows
|
||||
appear), and the open action branches per OS — `open-iterm-tab` stays the
|
||||
macOS path, native handling on Windows and Linux.
|
||||
|
||||
Behaviour on macOS and Linux is unchanged. Every Windows path is gated
|
||||
behind `isWindows()`; `runtime-tmux` and the bash hook flows are untouched.
|
||||
|
||||
See `docs/CROSS_PLATFORM.md` for the developer reference (helper inventory,
|
||||
EPERM-vs-ESRCH gotcha, PowerShell-vs-bash differences, pre-merge checklist).
|
||||
The Windows runtime architecture (pty-host, pipe protocol, registry, sweep,
|
||||
mux WS Windows branch) is documented in `docs/ARCHITECTURE.md`.
|
||||
|
||||
- 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 <value>` (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@<channel>`)
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@aoagents/ao",
|
||||
"version": "0.6.0",
|
||||
"version": "0.8.0",
|
||||
"description": "Orchestrate parallel AI coding agents — global CLI wrapper",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,142 @@
|
|||
# @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
|
||||
|
||||
- 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-<sessionId>`,
|
||||
registered so `ao stop` can reach it.
|
||||
|
||||
A new cross-platform abstraction layer (`packages/core/src/platform.ts`)
|
||||
centralises every platform branch behind helpers like `isWindows()`,
|
||||
`getDefaultRuntime()`, `getShell()`, `killProcessTree()`, `findPidByPort()`,
|
||||
and `getEnvDefaults()`. Path comparison uses `pathsEqual` /
|
||||
`canonicalCompareKey` to handle NTFS case-insensitivity. PATH wrappers for
|
||||
agent plugins (`gh`, `git`) ship as `.cjs` + `.cmd` shims on Windows;
|
||||
`script-runner` runs `.ps1` siblings of `.sh` scripts via PowerShell. New
|
||||
`ao-doctor.ps1` / `ao-update.ps1` shipped.
|
||||
|
||||
`ao open` is now cross-platform: it sources sessions from `sm.list()`
|
||||
instead of `tmux list-sessions` (so `runtime-process` sessions on Windows
|
||||
appear), and the open action branches per OS — `open-iterm-tab` stays the
|
||||
macOS path, native handling on Windows and Linux.
|
||||
|
||||
Behaviour on macOS and Linux is unchanged. Every Windows path is gated
|
||||
behind `isWindows()`; `runtime-tmux` and the bash hook flows are untouched.
|
||||
|
||||
See `docs/CROSS_PLATFORM.md` for the developer reference (helper inventory,
|
||||
EPERM-vs-ESRCH gotcha, PowerShell-vs-bash differences, pre-merge checklist).
|
||||
The Windows runtime architecture (pty-host, pipe protocol, registry, sweep,
|
||||
mux WS Windows branch) is documented in `docs/ARCHITECTURE.md`.
|
||||
|
||||
- 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 <prefix>-orchestrator "<message>"` 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 <value>` (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@<channel>`)
|
||||
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
|
||||
|
|
|
|||
|
|
@ -239,6 +239,7 @@ describe("ao stop — activity events", () => {
|
|||
});
|
||||
|
||||
it("emits cli.stop_invoked at the start of the action", async () => {
|
||||
const projectArg = "https://token@example.com/org/repo.git";
|
||||
// Force a fast failure so the action exits quickly after emitting stop_invoked.
|
||||
mockGetRunning.mockResolvedValue(null);
|
||||
// Make loadConfig throw so we hit the outer catch
|
||||
|
|
@ -263,13 +264,17 @@ describe("ao stop — activity events", () => {
|
|||
program.exitOverride();
|
||||
reloaded.registerStop(program);
|
||||
|
||||
await expect(program.parseAsync(["node", "ao", "stop"])).rejects.toThrow();
|
||||
await expect(program.parseAsync(["node", "ao", "stop", projectArg])).rejects.toThrow();
|
||||
|
||||
const events = recordedEvents();
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "cli.stop_invoked",
|
||||
source: "cli",
|
||||
summary: "ao stop invoked",
|
||||
data: expect.objectContaining({
|
||||
projectArg,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
@ -525,6 +530,7 @@ describe("ao start — activity events (failure paths)", () => {
|
|||
});
|
||||
|
||||
it("emits cli.start_failed with reason 'outer' when resolveOrCreateProject throws", async () => {
|
||||
const projectArg = "https://token@example.com/org/repo.git";
|
||||
const resolveProjectMod = await import("../../src/lib/resolve-project.js");
|
||||
vi.mocked(resolveProjectMod.resolveOrCreateProject).mockRejectedValue(
|
||||
new Error("project resolution exploded"),
|
||||
|
|
@ -533,7 +539,7 @@ describe("ao start — activity events (failure paths)", () => {
|
|||
const program = buildProgram();
|
||||
|
||||
try {
|
||||
await program.parseAsync(["node", "ao", "start"]);
|
||||
await program.parseAsync(["node", "ao", "start", projectArg]);
|
||||
} catch {
|
||||
// process.exit(1) throws in the spy
|
||||
}
|
||||
|
|
@ -544,8 +550,9 @@ describe("ao start — activity events (failure paths)", () => {
|
|||
kind: "cli.start_invoked",
|
||||
source: "cli",
|
||||
level: "info",
|
||||
summary: "ao start invoked",
|
||||
data: expect.objectContaining({
|
||||
projectArg: null,
|
||||
projectArg,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<typeof vi.spyOn>;
|
||||
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",
|
||||
|
|
|
|||
|
|
@ -178,4 +178,56 @@ describe("ao update — activity events", () => {
|
|||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("emits cli.update_failed when npm registry lookup returns no version", async () => {
|
||||
mockDetectInstallMethod.mockReturnValue("npm-global");
|
||||
mockCheckForUpdate.mockResolvedValue({
|
||||
currentVersion: "0.2.2",
|
||||
latestVersion: null,
|
||||
isOutdated: false,
|
||||
installMethod: "npm-global" as const,
|
||||
recommendedCommand: "npm install -g @aoagents/ao@latest",
|
||||
checkedAt: null,
|
||||
});
|
||||
|
||||
await expect(program.parseAsync(["node", "ao", "update"])).rejects.toThrow(
|
||||
"process.exit(1)",
|
||||
);
|
||||
|
||||
const events = recordedEvents();
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "cli.update_failed",
|
||||
source: "cli",
|
||||
level: "error",
|
||||
data: expect.objectContaining({
|
||||
method: "npm-global",
|
||||
reason: "registry_unreachable",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("emits cli.update_failed when npm registry lookup throws", async () => {
|
||||
mockDetectInstallMethod.mockReturnValue("npm-global");
|
||||
mockCheckForUpdate.mockRejectedValue(new Error("registry timeout"));
|
||||
|
||||
await expect(program.parseAsync(["node", "ao", "update"])).rejects.toThrow(
|
||||
"process.exit(1)",
|
||||
);
|
||||
|
||||
const events = recordedEvents();
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "cli.update_failed",
|
||||
source: "cli",
|
||||
level: "error",
|
||||
data: expect.objectContaining({
|
||||
method: "npm-global",
|
||||
reason: "registry_lookup_threw",
|
||||
errorMessage: "registry timeout",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@aoagents/ao-cli",
|
||||
"version": "0.6.0",
|
||||
"version": "0.8.0",
|
||||
"description": "CLI for agent-orchestrator — the `ao` command",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -38,9 +40,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";
|
||||
|
|
@ -97,7 +105,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";
|
||||
|
|
@ -318,10 +326,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",
|
||||
|
|
@ -582,6 +590,19 @@ export async function autoCreateConfig(workingDir: string): Promise<Orchestrator
|
|||
|
||||
console.log(chalk.green(`✓ Config created: ${outputPath}\n`));
|
||||
|
||||
try {
|
||||
const registeredProjectId = registerProjectInGlobalConfig(projectId, projectId, path, {
|
||||
...(repo ? { repo } : {}),
|
||||
defaultBranch,
|
||||
sessionPrefix: generateSessionPrefix(projectId),
|
||||
});
|
||||
console.log(chalk.green(`✓ Registered "${registeredProjectId}" in global config\n`));
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.log(chalk.yellow("⚠ Could not register project in global config."));
|
||||
console.log(chalk.dim(` ${message}\n`));
|
||||
}
|
||||
|
||||
if (!repo) {
|
||||
console.log(
|
||||
chalk.yellow("⚠ No repo configured — issue tracking and PR features will be unavailable."),
|
||||
|
|
@ -798,7 +819,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(),
|
||||
|
|
@ -811,7 +832,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(),
|
||||
|
|
@ -854,6 +875,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`));
|
||||
|
|
@ -1181,35 +1207,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}`));
|
||||
}
|
||||
|
|
@ -1278,6 +1278,60 @@ async function stopDashboard(port: number): Promise<void> {
|
|||
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<void> {
|
||||
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<void> {
|
||||
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
|
||||
|
|
@ -1364,6 +1418,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,
|
||||
|
|
@ -1373,13 +1428,14 @@ export function registerStart(program: Command): void {
|
|||
rebuild?: boolean;
|
||||
dev?: boolean;
|
||||
interactive?: boolean;
|
||||
reapOrphans?: boolean;
|
||||
},
|
||||
) => {
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.start_invoked",
|
||||
level: "info",
|
||||
summary: `ao start invoked${projectArg ? ` for ${projectArg}` : ""}`,
|
||||
summary: "ao start invoked",
|
||||
data: {
|
||||
projectArg: projectArg ?? null,
|
||||
dashboard: opts?.dashboard !== false,
|
||||
|
|
@ -1400,6 +1456,7 @@ export function registerStart(program: Command): void {
|
|||
|
||||
try {
|
||||
releaseStartupLock = await acquireStartupLock();
|
||||
await maybeSweepAoOrphansOnStart(opts?.reapOrphans);
|
||||
let config: OrchestratorConfig;
|
||||
let projectId: string;
|
||||
let project: ProjectConfig;
|
||||
|
|
@ -1684,9 +1741,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) {
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
|
|
@ -1773,7 +1828,7 @@ export function registerStop(program: Command): void {
|
|||
source: "cli",
|
||||
kind: "cli.stop_invoked",
|
||||
level: "info",
|
||||
summary: `ao stop invoked${projectArg ? ` for ${projectArg}` : ""}`,
|
||||
summary: "ao stop invoked",
|
||||
data: {
|
||||
projectArg: projectArg ?? null,
|
||||
all: opts.all === true,
|
||||
|
|
@ -1793,6 +1848,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");
|
||||
|
|
@ -1975,6 +2031,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);
|
||||
try {
|
||||
await killProcessTree(running.pid, "SIGTERM");
|
||||
recordActivityEvent({
|
||||
|
|
@ -1999,6 +2056,8 @@ export function registerStop(program: Command): void {
|
|||
});
|
||||
}
|
||||
await unregister();
|
||||
} else {
|
||||
await sweepRegisteredDaemonChildren();
|
||||
}
|
||||
await stopDashboard(running?.port ?? port);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -293,9 +293,34 @@ async function handleNpmUpdate(method: InstallMethod): Promise<void> {
|
|||
// would overwrite cache.channel before we can read it.
|
||||
const previousChannel = readCachedUpdateInfo(method)?.channel;
|
||||
|
||||
const info = await checkForUpdate({ force: true, channel });
|
||||
let info: Awaited<ReturnType<typeof checkForUpdate>>;
|
||||
try {
|
||||
info = await checkForUpdate({ force: true, channel });
|
||||
} catch (error) {
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.update_failed",
|
||||
level: "error",
|
||||
summary: `ao update (${method}) failed: npm registry lookup threw`,
|
||||
data: {
|
||||
method,
|
||||
channel,
|
||||
reason: "registry_lookup_threw",
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
});
|
||||
console.error(chalk.red("Could not reach npm registry. Check your network and try again."));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!info.latestVersion) {
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.update_failed",
|
||||
level: "error",
|
||||
summary: `ao update (${method}) failed: npm registry lookup returned no version`,
|
||||
data: { method, channel, reason: "registry_unreachable" },
|
||||
});
|
||||
console.error(chalk.red("Could not reach npm registry. Check your network and try again."));
|
||||
process.exit(1);
|
||||
}
|
||||
|
|
@ -406,8 +431,8 @@ async function handleNpmUpdate(method: InstallMethod): Promise<void> {
|
|||
source: "cli",
|
||||
kind: "cli.update_failed",
|
||||
level: "error",
|
||||
summary: `ao update (npm/pnpm) failed: install command exited non-zero`,
|
||||
data: { method: "npm-global", command, exitCode },
|
||||
summary: `ao update (${method}) failed: install command exited non-zero`,
|
||||
data: { method, command, exitCode },
|
||||
});
|
||||
process.exit(exitCode);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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..."));
|
||||
|
|
|
|||
|
|
@ -12,7 +12,13 @@
|
|||
* see ao-118 plan PR B).
|
||||
*/
|
||||
|
||||
import { isTerminalSession, loadConfig, recordActivityEvent } from "@aoagents/ao-core";
|
||||
import {
|
||||
isTerminalSession,
|
||||
loadConfig,
|
||||
markDaemonShutdownHandlerInstalled,
|
||||
recordActivityEvent,
|
||||
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 +42,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 +55,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;
|
||||
|
|
@ -137,6 +148,7 @@ export function installShutdownHandlers(ctx: ShutdownContext): void {
|
|||
}
|
||||
}
|
||||
|
||||
await sweepDaemonChildren({ ownerPid: process.pid });
|
||||
await unregister();
|
||||
if (lastStopWriteError) {
|
||||
throw lastStopWriteError;
|
||||
|
|
|
|||
|
|
@ -54,15 +54,20 @@ export function hasChosenUpdateChannel(): boolean {
|
|||
|
||||
/**
|
||||
* Persist the chosen channel to the global config.
|
||||
* Loads existing config (or creates a new one via `createDefaultGlobalConfig`)
|
||||
* and writes the field.
|
||||
*
|
||||
* No-op when the global config doesn't exist — autoCreateConfig() handles
|
||||
* bootstrap during first run. When the file exists but can't be parsed,
|
||||
* falls back to a fresh default config.
|
||||
*/
|
||||
export function persistUpdateChannel(channel: UpdateChannel): void {
|
||||
const path = getGlobalConfigPath();
|
||||
const existing = existsSync(path) ? loadGlobalConfig(path) : null;
|
||||
const next: GlobalConfig = existing
|
||||
? { ...existing, updateChannel: channel }
|
||||
: { ...createDefaultGlobalConfig(), updateChannel: channel };
|
||||
// Don't create an empty global config here — autoCreateConfig() handles
|
||||
// bootstrap (with project registration) during first run. Writing an empty
|
||||
// husk poisons the dashboard, which loads the global config and finds
|
||||
// zero projects.
|
||||
if (!existsSync(path)) return;
|
||||
const existing = loadGlobalConfig(path) ?? createDefaultGlobalConfig();
|
||||
const next: GlobalConfig = { ...existing, updateChannel: channel };
|
||||
saveGlobalConfig(next, path);
|
||||
}
|
||||
|
||||
|
|
@ -81,6 +86,12 @@ export async function maybePromptForUpdateChannel(deps: PromptDeps = {}): Promis
|
|||
if (!isInteractive()) return;
|
||||
if (hasChosenUpdateChannel()) return;
|
||||
|
||||
// No global config yet — skip. autoCreateConfig() handles full bootstrap
|
||||
// (creating the global config and registering the project) during first run.
|
||||
// On the next `ao start` the global config will exist and the prompt fires.
|
||||
// Skipping here avoids writing an empty husk that poisons the dashboard.
|
||||
if (!existsSync(getGlobalConfigPath())) return;
|
||||
|
||||
console.log(chalk.bold("\nHow do you want to receive updates?"));
|
||||
console.log(
|
||||
chalk.dim(
|
||||
|
|
|
|||
|
|
@ -1,5 +1,87 @@
|
|||
# @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
|
||||
|
||||
- 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-<sessionId>`,
|
||||
registered so `ao stop` can reach it.
|
||||
|
||||
A new cross-platform abstraction layer (`packages/core/src/platform.ts`)
|
||||
centralises every platform branch behind helpers like `isWindows()`,
|
||||
`getDefaultRuntime()`, `getShell()`, `killProcessTree()`, `findPidByPort()`,
|
||||
and `getEnvDefaults()`. Path comparison uses `pathsEqual` /
|
||||
`canonicalCompareKey` to handle NTFS case-insensitivity. PATH wrappers for
|
||||
agent plugins (`gh`, `git`) ship as `.cjs` + `.cmd` shims on Windows;
|
||||
`script-runner` runs `.ps1` siblings of `.sh` scripts via PowerShell. New
|
||||
`ao-doctor.ps1` / `ao-update.ps1` shipped.
|
||||
|
||||
`ao open` is now cross-platform: it sources sessions from `sm.list()`
|
||||
instead of `tmux list-sessions` (so `runtime-process` sessions on Windows
|
||||
appear), and the open action branches per OS — `open-iterm-tab` stays the
|
||||
macOS path, native handling on Windows and Linux.
|
||||
|
||||
Behaviour on macOS and Linux is unchanged. Every Windows path is gated
|
||||
behind `isWindows()`; `runtime-tmux` and the bash hook flows are untouched.
|
||||
|
||||
See `docs/CROSS_PLATFORM.md` for the developer reference (helper inventory,
|
||||
EPERM-vs-ESRCH gotcha, PowerShell-vs-bash differences, pre-merge checklist).
|
||||
The Windows runtime architecture (pty-host, pipe protocol, registry, sweep,
|
||||
mux WS Windows branch) is documented in `docs/ARCHITECTURE.md`.
|
||||
|
||||
- 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 <prefix>-orchestrator "<message>"` 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 <value>` (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@<channel>`)
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@aoagents/ao-core",
|
||||
"version": "0.6.0",
|
||||
"version": "0.8.0",
|
||||
"description": "Core library — types, config, session manager, lifecycle manager, event bus",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
await Promise.race([
|
||||
new Promise<void>((resolve) => child.once("exit", () => resolve())),
|
||||
new Promise<void>((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<void>((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" }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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" },
|
||||
|
|
@ -2335,7 +2365,66 @@ describe("reactions", () => {
|
|||
expect(sentMessage).toContain("Potential issue detected");
|
||||
});
|
||||
|
||||
it("dispatches CI failure details with check names and URLs on subsequent polls", async () => {
|
||||
it("dispatches CI failure summary with failed step and log tail", async () => {
|
||||
config.reactions = {
|
||||
"ci-failed": {
|
||||
auto: true,
|
||||
action: "send-to-agent",
|
||||
retries: 3,
|
||||
escalateAfter: 3,
|
||||
},
|
||||
};
|
||||
|
||||
const ciChecks = [
|
||||
{
|
||||
name: "build",
|
||||
status: "failed",
|
||||
url: "https://github.com/org/repo/actions/runs/123/job/456",
|
||||
conclusion: "FAILURE",
|
||||
},
|
||||
];
|
||||
const mockSCM = createMockSCM({
|
||||
getCISummary: vi.fn().mockResolvedValue("failing"),
|
||||
getCIFailureSummary: vi.fn().mockResolvedValue({
|
||||
failedJobs: [
|
||||
{
|
||||
name: "build",
|
||||
failedStep: "Run pnpm test",
|
||||
runUrl: "https://github.com/org/repo/actions/runs/123/job/456",
|
||||
logTail:
|
||||
"AssertionError: expected true to be false\n```\nProcess completed with exit code 1",
|
||||
},
|
||||
],
|
||||
}),
|
||||
enrichSessionsPRBatch: mockBatchEnrichment({ ciStatus: "failing", ciChecks }),
|
||||
});
|
||||
const registry = createMockRegistry({
|
||||
runtime: plugins.runtime,
|
||||
agent: plugins.agent,
|
||||
scm: mockSCM,
|
||||
});
|
||||
|
||||
vi.mocked(mockSessionManager.send).mockResolvedValue(undefined);
|
||||
|
||||
const lm = setupCheck("app-1", {
|
||||
session: makeSession({ status: "pr_open", pr: makePR() }),
|
||||
registry,
|
||||
});
|
||||
|
||||
await lm.check("app-1");
|
||||
expect(mockSessionManager.send).toHaveBeenCalledTimes(1);
|
||||
const sentMessage = vi.mocked(mockSessionManager.send).mock.calls[0]![1];
|
||||
expect(sentMessage).toContain("CI is failing on your PR.");
|
||||
expect(sentMessage).toContain("Failed: build → Run pnpm test");
|
||||
expect(sentMessage).toContain("Failure URL: https://github.com/org/repo/actions/runs/123/job/456");
|
||||
expect(sentMessage).toContain("Log tail (last 3 lines):");
|
||||
expect(sentMessage).toContain("AssertionError: expected true to be false");
|
||||
expect(sentMessage).toContain("\u200B```");
|
||||
expect(sentMessage).toContain("Fix the issues and push again.");
|
||||
expect(mockSCM.getCIFailureSummary).toHaveBeenCalledWith(makePR(), ciChecks);
|
||||
});
|
||||
|
||||
it("falls back to check names and URLs when SCM lacks getCIFailureSummary", async () => {
|
||||
config.reactions = {
|
||||
"ci-failed": {
|
||||
auto: true,
|
||||
|
|
@ -2389,6 +2478,7 @@ describe("reactions", () => {
|
|||
expect(lm.getStates().get("app-1")).toBe("ci_failed");
|
||||
expect(mockSessionManager.send).toHaveBeenCalledTimes(1);
|
||||
const sentMessage = vi.mocked(mockSessionManager.send).mock.calls[0]![1];
|
||||
expect(sentMessage).toContain("CI checks are failing on your PR.");
|
||||
expect(sentMessage).toContain("lint");
|
||||
expect(sentMessage).toContain("typecheck");
|
||||
expect(sentMessage).toContain("https://github.com/org/repo/actions/runs/123");
|
||||
|
|
|
|||
|
|
@ -129,6 +129,29 @@ describe("applyDecisionToLifecycle", () => {
|
|||
expect(lifecycle.session.terminatedAt).toBe("2026-04-16T12:00:00.000Z");
|
||||
});
|
||||
|
||||
it("clears terminal markers when transitioning into a non-terminal state", () => {
|
||||
lifecycle.session.state = "terminated";
|
||||
lifecycle.session.reason = "runtime_lost";
|
||||
lifecycle.session.completedAt = "2026-04-15T12:00:00.000Z";
|
||||
lifecycle.session.terminatedAt = "2026-04-16T12:00:00.000Z";
|
||||
lifecycle.session.lastTransitionAt = "2026-04-16T12:00:00.000Z";
|
||||
|
||||
const decision: LifecycleDecision = {
|
||||
status: "working",
|
||||
evidence: "restore",
|
||||
detecting: { attempts: 0 },
|
||||
sessionState: "working",
|
||||
sessionReason: "task_in_progress",
|
||||
};
|
||||
|
||||
applyDecisionToLifecycle(lifecycle, decision, nowIso);
|
||||
|
||||
expect(lifecycle.session.state).toBe("working");
|
||||
expect(lifecycle.session.completedAt).toBeNull();
|
||||
expect(lifecycle.session.terminatedAt).toBeNull();
|
||||
expect(lifecycle.session.lastTransitionAt).toBe(nowIso);
|
||||
});
|
||||
|
||||
it("applies PR state and reason", () => {
|
||||
const decision: LifecycleDecision = {
|
||||
status: "pr_open",
|
||||
|
|
@ -372,6 +395,45 @@ describe("applyLifecycleDecision (integration)", () => {
|
|||
expect(result.success).toBe(true);
|
||||
expect(result.previousStatus).toBe("spawning");
|
||||
});
|
||||
|
||||
it("persists cleared terminal markers when a terminal snapshot transitions to working", () => {
|
||||
const lifecycle = createInitialCanonicalLifecycle("worker");
|
||||
lifecycle.session.state = "terminated";
|
||||
lifecycle.session.reason = "runtime_lost";
|
||||
lifecycle.session.completedAt = "2026-04-16T11:00:00.000Z";
|
||||
lifecycle.session.terminatedAt = "2026-04-16T12:00:00.000Z";
|
||||
lifecycle.session.lastTransitionAt = "2026-04-16T12:00:00.000Z";
|
||||
|
||||
writeTestSession("test-5", {
|
||||
status: "killed",
|
||||
lifecycle: JSON.stringify(lifecycle),
|
||||
worktree: "/tmp/test",
|
||||
branch: "main",
|
||||
});
|
||||
|
||||
const result = applyLifecycleDecision({
|
||||
dataDir,
|
||||
sessionId: "test-5",
|
||||
decision: {
|
||||
status: "working",
|
||||
evidence: "restore",
|
||||
detecting: { attempts: 0 },
|
||||
sessionState: "working",
|
||||
sessionReason: "task_in_progress",
|
||||
},
|
||||
source: "restore",
|
||||
now: new Date("2026-04-17T12:00:00.000Z"),
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const meta = readMetadataRaw(dataDir, "test-5");
|
||||
const persisted = JSON.parse(meta!["lifecycle"]);
|
||||
expect(persisted.session.state).toBe("working");
|
||||
expect(persisted.session.completedAt).toBeNull();
|
||||
expect(persisted.session.terminatedAt).toBeNull();
|
||||
expect(persisted.session.lastTransitionAt).toBe("2026-04-17T12:00:00.000Z");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createStateTransitionDecision", () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
} from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { createSessionManager } from "../../session-manager.js";
|
||||
import { createInitialCanonicalLifecycle } from "../../lifecycle-state.js";
|
||||
import { getWorkspaceAgentsMdPath } from "../../opencode-agents-md.js";
|
||||
import { getProjectDir } from "../../paths.js";
|
||||
import {
|
||||
|
|
@ -283,8 +284,22 @@ describe("restore", () => {
|
|||
createdAt: "2025-01-01T00:00:00.000Z",
|
||||
runtimeHandle: makeHandle("rt-old"),
|
||||
});
|
||||
const staleLifecycle = createInitialCanonicalLifecycle(
|
||||
"worker",
|
||||
new Date("2025-01-01T00:00:00.000Z"),
|
||||
);
|
||||
staleLifecycle.session.state = "terminated";
|
||||
staleLifecycle.session.reason = "runtime_lost";
|
||||
staleLifecycle.session.completedAt = "2025-01-01T00:01:00.000Z";
|
||||
staleLifecycle.session.terminatedAt = "2025-01-01T00:02:00.000Z";
|
||||
staleLifecycle.session.lastTransitionAt = "2025-01-01T00:02:00.000Z";
|
||||
staleLifecycle.pr.state = "open";
|
||||
staleLifecycle.pr.reason = "in_progress";
|
||||
staleLifecycle.pr.number = 10;
|
||||
staleLifecycle.pr.url = "https://github.com/org/my-app/pull/10";
|
||||
staleLifecycle.pr.lastObservedAt = "2025-01-01T00:00:00.000Z";
|
||||
updateMetadata(sessionsDir, "app-1", {
|
||||
lifecycle: JSON.stringify({ session: { state: "terminated", terminatedAt: new Date().toISOString() } }),
|
||||
lifecycle: JSON.stringify(staleLifecycle),
|
||||
});
|
||||
|
||||
const sm = createSessionManager({ config, registry: mockRegistry });
|
||||
|
|
@ -300,6 +315,14 @@ describe("restore", () => {
|
|||
expect(meta).not.toBeNull();
|
||||
expect(meta!["issue"]).toBe("TEST-1");
|
||||
expect(meta!["pr"]).toBe("https://github.com/org/my-app/pull/10");
|
||||
|
||||
const lifecycle = JSON.parse(meta!["lifecycle"]);
|
||||
expect(lifecycle.session.state).toBe("working");
|
||||
expect(lifecycle.session.completedAt).toBeNull();
|
||||
expect(lifecycle.session.terminatedAt).toBeNull();
|
||||
expect(new Date(lifecycle.session.lastTransitionAt).getTime()).toBeGreaterThan(
|
||||
new Date("2025-01-01T00:02:00.000Z").getTime(),
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves displayName when restoring terminated session", async () => {
|
||||
|
|
|
|||
|
|
@ -669,8 +669,6 @@ function applyDefaultReactions(config: OrchestratorConfig): OrchestratorConfig {
|
|||
"ci-failed": {
|
||||
auto: true,
|
||||
action: "send-to-agent",
|
||||
message:
|
||||
"CI is failing on your PR. Investigate the failures, fix the issues, and push again.",
|
||||
retries: 2,
|
||||
escalateAfter: 2,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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<DaemonChildEntry>;
|
||||
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<Set<number>> {
|
||||
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<DaemonChildEntry, "startedAt">): 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<number>): 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<ChildProcess>();
|
||||
const managedChildren = new Map<number, ChildProcess>();
|
||||
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<void> {
|
||||
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<DaemonChildSweepResult> {
|
||||
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<AoOrphanProcess[]> {
|
||||
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<DaemonChildSweepResult> {
|
||||
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();
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -39,8 +39,12 @@ import {
|
|||
type ProjectConfig as _ProjectConfig,
|
||||
type PREnrichmentData,
|
||||
type CICheck,
|
||||
type CIFailureSummary,
|
||||
type PRInfo,
|
||||
type ReviewComment,
|
||||
type ReviewSummary,
|
||||
type ProcessProbeResult,
|
||||
isProcessProbeIndeterminate,
|
||||
} from "./types.js";
|
||||
import {
|
||||
buildLifecycleMetadataPatch,
|
||||
|
|
@ -107,6 +111,12 @@ const PERSISTENT_REACTION_KEYS = new Set(["ci-failed"]);
|
|||
* next real CI failure incident. */
|
||||
const CI_PASSING_STABLE_THRESHOLD = 2;
|
||||
|
||||
type TransitionReaction = {
|
||||
key: string;
|
||||
result: ReactionResult | null;
|
||||
messageEnriched?: boolean;
|
||||
};
|
||||
|
||||
type WorkspaceBranchProbe =
|
||||
| { kind: "branch"; branch: string }
|
||||
| { kind: "detached" }
|
||||
|
|
@ -387,6 +397,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 +408,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 +1082,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 +1149,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 +1180,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,
|
||||
|
|
@ -1608,7 +1652,7 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
|
|||
session: Session,
|
||||
_oldStatus: SessionStatus,
|
||||
newStatus: SessionStatus,
|
||||
transitionReaction?: { key: string; result: ReactionResult | null },
|
||||
transitionReaction?: TransitionReaction,
|
||||
): Promise<void> {
|
||||
const project = config.projects[session.projectId];
|
||||
if (!project || !session.pr) return;
|
||||
|
|
@ -1883,11 +1927,40 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
|
|||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Format CI check failures into a human-readable message for the agent.
|
||||
* Includes check names, statuses, and links for debugging.
|
||||
*/
|
||||
function formatCIFailureMessage(failedChecks: CICheck[]): string {
|
||||
function isFailedCICheck(check: CICheck): boolean {
|
||||
return check.status === "failed" || check.conclusion?.toUpperCase() === "FAILURE";
|
||||
}
|
||||
|
||||
function formatCIFailureSummaryMessage(summary: CIFailureSummary): string {
|
||||
const lines = ["CI is failing on your PR.", ""];
|
||||
|
||||
for (const job of summary.failedJobs) {
|
||||
const failed = job.failedStep ? `${job.name} → ${job.failedStep}` : job.name;
|
||||
lines.push(`Failed: ${failed}`);
|
||||
lines.push(`Failure URL: ${job.runUrl}`);
|
||||
|
||||
if (job.logTail) {
|
||||
const lineCount = job.logTail.split(/\r?\n/).length;
|
||||
const lineLabel = lineCount === 1 ? "line" : "lines";
|
||||
const escapedTail = escapeMarkdownCodeFenceClosers(job.logTail);
|
||||
lines.push("", `Log tail (last ${lineCount} ${lineLabel}):`, "```", escapedTail, "```");
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
lines.push("Fix the issues and push again.");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function escapeMarkdownCodeFenceClosers(logTail: string): string {
|
||||
return logTail
|
||||
.split(/\r?\n/)
|
||||
.map((line) => (line.startsWith("```") ? `\u200B${line}` : line))
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function formatCIFailureChecksFallback(failedChecks: CICheck[]): string {
|
||||
const lines = ["CI checks are failing on your PR. Here are the failed checks:", ""];
|
||||
for (const check of failedChecks) {
|
||||
const status = check.conclusion ?? check.status;
|
||||
|
|
@ -1898,11 +1971,60 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
|
|||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Format CI failures into a human-readable message for the agent.
|
||||
* Uses SCM-provided failed job/step/log details when available and falls
|
||||
* back to check names/statuses/links for SCM plugins that do not implement it.
|
||||
*/
|
||||
async function formatCIFailureMessage(
|
||||
scm: SCM,
|
||||
pr: PRInfo,
|
||||
failedChecks: CICheck[],
|
||||
): Promise<string> {
|
||||
if (scm.getCIFailureSummary) {
|
||||
try {
|
||||
const summary = await scm.getCIFailureSummary(pr, failedChecks);
|
||||
if (summary?.failedJobs.length) {
|
||||
return formatCIFailureSummaryMessage(summary);
|
||||
}
|
||||
} catch {
|
||||
// Fall back to check names when summary enrichment fails.
|
||||
}
|
||||
}
|
||||
|
||||
return formatCIFailureChecksFallback(failedChecks);
|
||||
}
|
||||
|
||||
async function getFailedCIChecks(
|
||||
scm: SCM,
|
||||
pr: PRInfo,
|
||||
options: { allowFetch: boolean },
|
||||
): Promise<CICheck[] | null> {
|
||||
const prKey = `${pr.owner}/${pr.repo}#${pr.number}`;
|
||||
const cachedEnrichment = prEnrichmentCache.get(prKey);
|
||||
|
||||
let checks: CICheck[] | undefined = cachedEnrichment?.ciChecks;
|
||||
if (checks === undefined && options.allowFetch) {
|
||||
try {
|
||||
checks = await scm.getCIChecks(pr);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const failedChecks = checks?.filter(isFailedCICheck) ?? [];
|
||||
return failedChecks.length > 0 ? failedChecks : null;
|
||||
}
|
||||
|
||||
function makeCIFailureFingerprint(failedChecks: CICheck[]): string {
|
||||
return makeFingerprint(failedChecks.map((c) => `${c.name}:${c.status}:${c.conclusion ?? ""}`));
|
||||
}
|
||||
|
||||
async function maybeDispatchCIFailureDetails(
|
||||
session: Session,
|
||||
_oldStatus: SessionStatus,
|
||||
newStatus: SessionStatus,
|
||||
transitionReaction?: { key: string; result: ReactionResult | null },
|
||||
transitionReaction?: TransitionReaction,
|
||||
): Promise<void> {
|
||||
const project = config.projects[session.projectId];
|
||||
if (!project || !session.pr) return;
|
||||
|
|
@ -1938,32 +2060,10 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
|
|||
return;
|
||||
}
|
||||
|
||||
// Fetch individual CI checks for failure details.
|
||||
// Use batch enrichment data when available to avoid an extra REST call;
|
||||
// fall back to getCIChecks() when the batch didn't run this cycle.
|
||||
const prKey = `${session.pr.owner}/${session.pr.repo}#${session.pr.number}`;
|
||||
const cachedEnrichment = prEnrichmentCache.get(prKey);
|
||||
const failedChecks = await getFailedCIChecks(scm, session.pr, { allowFetch: true });
|
||||
if (!failedChecks) return;
|
||||
|
||||
let checks: CICheck[];
|
||||
if (cachedEnrichment?.ciChecks !== undefined) {
|
||||
checks = cachedEnrichment.ciChecks;
|
||||
} else {
|
||||
try {
|
||||
checks = await scm.getCIChecks(session.pr);
|
||||
} catch {
|
||||
// Failed to fetch checks — skip this cycle
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const failedChecks = checks.filter(
|
||||
(c) => c.status === "failed" || c.conclusion?.toUpperCase() === "FAILURE",
|
||||
);
|
||||
if (failedChecks.length === 0) return;
|
||||
|
||||
const ciFingerprint = makeFingerprint(
|
||||
failedChecks.map((c) => `${c.name}:${c.status}:${c.conclusion ?? ""}`),
|
||||
);
|
||||
const ciFingerprint = makeCIFailureFingerprint(failedChecks);
|
||||
const lastCIFingerprint = session.metadata["lastCIFailureFingerprint"] ?? "";
|
||||
const lastCIDispatchHash = session.metadata["lastCIFailureDispatchHash"] ?? "";
|
||||
|
||||
|
|
@ -1977,12 +2077,14 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
|
|||
});
|
||||
}
|
||||
|
||||
// If the transition reaction already sent a ci-failed reaction (now enriched
|
||||
// with detailed check info from the batch cache), record the dispatch hash so
|
||||
// subsequent polls don't re-send the same failure details.
|
||||
// If the transition reaction already delivered an enriched agent message,
|
||||
// or handled a non-agent action, record the dispatch hash so subsequent
|
||||
// polls don't re-send the same failure details.
|
||||
if (
|
||||
transitionReaction?.key === ciReactionKey &&
|
||||
transitionReaction.result?.success
|
||||
transitionReaction.result?.success &&
|
||||
(transitionReaction.messageEnriched === true ||
|
||||
transitionReaction.result.action !== "send-to-agent")
|
||||
) {
|
||||
updateSessionMetadata(session, {
|
||||
lastCIFailureDispatchHash: ciFingerprint,
|
||||
|
|
@ -2003,7 +2105,7 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
|
|||
reactionConfig.action &&
|
||||
(reactionConfig.auto !== false || reactionConfig.action === "notify")
|
||||
) {
|
||||
const detailedMessage = formatCIFailureMessage(failedChecks);
|
||||
const detailedMessage = await formatCIFailureMessage(scm, session.pr, failedChecks);
|
||||
|
||||
try {
|
||||
if (reactionConfig.action === "send-to-agent") {
|
||||
|
|
@ -2293,9 +2395,13 @@ 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;
|
||||
let transitionReaction: TransitionReaction | undefined;
|
||||
|
||||
const nextLifecycleEvidence = assessment.evidence;
|
||||
const nextDetectingAttempts =
|
||||
|
|
@ -2433,18 +2539,27 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
|
|||
|
||||
if (reactionKey) {
|
||||
let reactionConfig = getReactionConfigForSession(session, reactionKey);
|
||||
let messageEnriched = false;
|
||||
|
||||
// Enrich CI failure message with actual check details from batch cache
|
||||
if (reactionKey === "ci-failed" && session.pr && reactionConfig) {
|
||||
const prKey = `${session.pr.owner}/${session.pr.repo}#${session.pr.number}`;
|
||||
const cachedData = prEnrichmentCache.get(prKey);
|
||||
if (cachedData?.ciChecks) {
|
||||
const failedChecks = cachedData.ciChecks.filter((c) => c.status === "failed");
|
||||
if (failedChecks.length > 0) {
|
||||
// Enrich CI failure message with failed job/step/log details when
|
||||
// batch check data is already available. If it is not, the
|
||||
// post-transition CI dispatcher below fetches checks and sends the
|
||||
// composed message without altering lifecycle state transitions.
|
||||
if (
|
||||
reactionKey === "ci-failed" &&
|
||||
session.pr &&
|
||||
reactionConfig?.action === "send-to-agent"
|
||||
) {
|
||||
const project = config.projects[session.projectId];
|
||||
const scm = project?.scm?.plugin ? registry.get<SCM>("scm", project.scm.plugin) : null;
|
||||
if (scm) {
|
||||
const failedChecks = await getFailedCIChecks(scm, session.pr, { allowFetch: false });
|
||||
if (failedChecks) {
|
||||
reactionConfig = {
|
||||
...reactionConfig,
|
||||
message: formatCIFailureMessage(failedChecks),
|
||||
message: await formatCIFailureMessage(scm, session.pr, failedChecks),
|
||||
};
|
||||
messageEnriched = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2453,7 +2568,7 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
|
|||
// auto: false skips automated agent actions but still allows notifications
|
||||
if (reactionConfig.auto !== false || reactionConfig.action === "notify") {
|
||||
const reactionResult = await executeReaction(session, reactionKey, reactionConfig);
|
||||
transitionReaction = { key: reactionKey, result: reactionResult };
|
||||
transitionReaction = { key: reactionKey, result: reactionResult, messageEnriched };
|
||||
observer.recordOperation({
|
||||
metric: "lifecycle_poll",
|
||||
operation: "lifecycle.transition.reaction",
|
||||
|
|
|
|||
|
|
@ -128,6 +128,19 @@ const CanonicalSessionLifecycleSchema = z.object({
|
|||
|
||||
type ParsedCanonicalSessionLifecycle = z.infer<typeof CanonicalSessionLifecycleSchema>;
|
||||
|
||||
const TERMINAL_CANONICAL_SESSION_STATES: ReadonlySet<CanonicalSessionState> = new Set([
|
||||
"done",
|
||||
"terminated",
|
||||
]);
|
||||
|
||||
export function clearTerminalMarkersForNonTerminalState(
|
||||
lifecycle: CanonicalSessionLifecycle,
|
||||
): void {
|
||||
if (TERMINAL_CANONICAL_SESSION_STATES.has(lifecycle.session.state)) return;
|
||||
lifecycle.session.completedAt = null;
|
||||
lifecycle.session.terminatedAt = null;
|
||||
}
|
||||
|
||||
function normalizeTimestamp(value: unknown, fallback: string | null = null): string | null {
|
||||
if (typeof value !== "string") return fallback;
|
||||
const parsed = Date.parse(value);
|
||||
|
|
|
|||
|
|
@ -19,7 +19,11 @@ import type {
|
|||
SessionId,
|
||||
SessionStatus,
|
||||
} from "./types.js";
|
||||
import { cloneLifecycle, deriveLegacyStatus } from "./lifecycle-state.js";
|
||||
import {
|
||||
clearTerminalMarkersForNonTerminalState,
|
||||
cloneLifecycle,
|
||||
deriveLegacyStatus,
|
||||
} from "./lifecycle-state.js";
|
||||
import { updateMetadata, readMetadataRaw, readCanonicalLifecycle } from "./metadata.js";
|
||||
import type { LifecycleDecision } from "./lifecycle-status-decisions.js";
|
||||
|
||||
|
|
@ -35,6 +39,7 @@ export type TransitionSource =
|
|||
| "cleanup" // Session cleanup
|
||||
| "claim_pr"; // PR claim
|
||||
|
||||
|
||||
/**
|
||||
* Result of a lifecycle transition attempt.
|
||||
*/
|
||||
|
|
@ -106,6 +111,8 @@ export function applyDecisionToLifecycle(
|
|||
lifecycle.session.terminatedAt = nowIso;
|
||||
}
|
||||
}
|
||||
|
||||
clearTerminalMarkersForNonTerminalState(lifecycle);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ import {
|
|||
} from "./metadata.js";
|
||||
import {
|
||||
buildLifecycleMetadataPatch,
|
||||
clearTerminalMarkersForNonTerminalState,
|
||||
cloneLifecycle,
|
||||
createInitialCanonicalLifecycle,
|
||||
deriveLegacyStatus,
|
||||
|
|
@ -108,6 +109,7 @@ const OPENCODE_INTERACTIVE_DISCOVERY_TIMEOUT_MS = 10_000;
|
|||
const EXEC_SHELL_OPTION =
|
||||
process.platform === "win32" ? ({ shell: true, windowsHide: true } as const) : ({} as const);
|
||||
|
||||
|
||||
function errorIncludesSessionNotFound(err: unknown): boolean {
|
||||
if (!(err instanceof Error)) return false;
|
||||
const e = err as Error & { stderr?: string; stdout?: string };
|
||||
|
|
@ -291,6 +293,20 @@ function sleep(ms: number): Promise<void> {
|
|||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function isAgentProcessNotDefinitelyMissing(
|
||||
agent: Agent,
|
||||
handle: RuntimeHandle,
|
||||
): Promise<boolean> {
|
||||
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`);
|
||||
}
|
||||
|
|
@ -479,6 +495,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
}),
|
||||
);
|
||||
updater(lifecycle);
|
||||
clearTerminalMarkersForNonTerminalState(lifecycle);
|
||||
return lifecycle;
|
||||
}
|
||||
|
||||
|
|
@ -2394,7 +2411,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 +2458,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 +2526,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),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<ActivityDetection | null>;
|
||||
|
||||
/** Check if agent process is running (given runtime handle) */
|
||||
isProcessRunning(handle: RuntimeHandle): Promise<boolean>;
|
||||
/**
|
||||
* 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<ProcessProbeResult>;
|
||||
|
||||
/** Extract information from agent's internal data (summary, cost, session ID) */
|
||||
getSessionInfo(session: Session): Promise<AgentSessionInfo | null>;
|
||||
|
|
@ -811,6 +828,9 @@ export interface SCM {
|
|||
/** Get individual CI check statuses */
|
||||
getCIChecks(pr: PRInfo): Promise<CICheck[]>;
|
||||
|
||||
/** Get failed CI jobs/steps with a bounded failed-log tail, if supported. */
|
||||
getCIFailureSummary?(pr: PRInfo, failedChecks?: CICheck[]): Promise<CIFailureSummary | null>;
|
||||
|
||||
/** Get overall CI summary */
|
||||
getCISummary(pr: PRInfo): Promise<CIStatus>;
|
||||
|
||||
|
|
@ -993,6 +1013,15 @@ export interface CICheck {
|
|||
completedAt?: Date;
|
||||
}
|
||||
|
||||
export interface CIFailureSummary {
|
||||
failedJobs: Array<{
|
||||
name: string;
|
||||
failedStep?: string;
|
||||
runUrl: string;
|
||||
logTail?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export type CIStatus = "pending" | "passing" | "failing" | "none";
|
||||
|
||||
/** CI status constants */
|
||||
|
|
|
|||
|
|
@ -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)", () => {
|
||||
|
|
|
|||
|
|
@ -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)", () => {
|
||||
|
|
|
|||
|
|
@ -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)", () => {
|
||||
|
|
|
|||
|
|
@ -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)", () => {
|
||||
|
|
|
|||
|
|
@ -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<number> {
|
||||
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<number[]> {
|
||||
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);
|
||||
});
|
||||
|
|
@ -1,5 +1,58 @@
|
|||
# @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
|
||||
|
||||
- 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-<sessionId>`,
|
||||
registered so `ao stop` can reach it.
|
||||
|
||||
A new cross-platform abstraction layer (`packages/core/src/platform.ts`)
|
||||
centralises every platform branch behind helpers like `isWindows()`,
|
||||
`getDefaultRuntime()`, `getShell()`, `killProcessTree()`, `findPidByPort()`,
|
||||
and `getEnvDefaults()`. Path comparison uses `pathsEqual` /
|
||||
`canonicalCompareKey` to handle NTFS case-insensitivity. PATH wrappers for
|
||||
agent plugins (`gh`, `git`) ship as `.cjs` + `.cmd` shims on Windows;
|
||||
`script-runner` runs `.ps1` siblings of `.sh` scripts via PowerShell. New
|
||||
`ao-doctor.ps1` / `ao-update.ps1` shipped.
|
||||
|
||||
`ao open` is now cross-platform: it sources sessions from `sm.list()`
|
||||
instead of `tmux list-sessions` (so `runtime-process` sessions on Windows
|
||||
appear), and the open action branches per OS — `open-iterm-tab` stays the
|
||||
macOS path, native handling on Windows and Linux.
|
||||
|
||||
Behaviour on macOS and Linux is unchanged. Every Windows path is gated
|
||||
behind `isWindows()`; `runtime-tmux` and the bash hook flows are untouched.
|
||||
|
||||
See `docs/CROSS_PLATFORM.md` for the developer reference (helper inventory,
|
||||
EPERM-vs-ESRCH gotcha, PowerShell-vs-bash differences, pre-merge checklist).
|
||||
The Windows runtime architecture (pty-host, pipe protocol, registry, sweep,
|
||||
mux WS Windows branch) is documented in `docs/ARCHITECTURE.md`.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [0f5ae0b]
|
||||
- Updated dependencies [fe33bb7]
|
||||
- Updated dependencies [7c46dc9]
|
||||
- @aoagents/ao-core@0.7.0
|
||||
|
||||
## 0.6.0
|
||||
|
||||
### Patch Changes
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@aoagents/ao-plugin-agent-aider",
|
||||
"version": "0.6.0",
|
||||
"version": "0.8.0",
|
||||
"description": "Agent plugin: Aider",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<boolean> {
|
||||
async isProcessRunning(handle: RuntimeHandle): Promise<ProcessProbeResult> {
|
||||
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;
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,58 @@
|
|||
# @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
|
||||
|
||||
- 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-<sessionId>`,
|
||||
registered so `ao stop` can reach it.
|
||||
|
||||
A new cross-platform abstraction layer (`packages/core/src/platform.ts`)
|
||||
centralises every platform branch behind helpers like `isWindows()`,
|
||||
`getDefaultRuntime()`, `getShell()`, `killProcessTree()`, `findPidByPort()`,
|
||||
and `getEnvDefaults()`. Path comparison uses `pathsEqual` /
|
||||
`canonicalCompareKey` to handle NTFS case-insensitivity. PATH wrappers for
|
||||
agent plugins (`gh`, `git`) ship as `.cjs` + `.cmd` shims on Windows;
|
||||
`script-runner` runs `.ps1` siblings of `.sh` scripts via PowerShell. New
|
||||
`ao-doctor.ps1` / `ao-update.ps1` shipped.
|
||||
|
||||
`ao open` is now cross-platform: it sources sessions from `sm.list()`
|
||||
instead of `tmux list-sessions` (so `runtime-process` sessions on Windows
|
||||
appear), and the open action branches per OS — `open-iterm-tab` stays the
|
||||
macOS path, native handling on Windows and Linux.
|
||||
|
||||
Behaviour on macOS and Linux is unchanged. Every Windows path is gated
|
||||
behind `isWindows()`; `runtime-tmux` and the bash hook flows are untouched.
|
||||
|
||||
See `docs/CROSS_PLATFORM.md` for the developer reference (helper inventory,
|
||||
EPERM-vs-ESRCH gotcha, PowerShell-vs-bash differences, pre-merge checklist).
|
||||
The Windows runtime architecture (pty-host, pipe protocol, registry, sweep,
|
||||
mux WS Windows branch) is documented in `docs/ARCHITECTURE.md`.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [0f5ae0b]
|
||||
- Updated dependencies [fe33bb7]
|
||||
- Updated dependencies [7c46dc9]
|
||||
- @aoagents/ao-core@0.7.0
|
||||
|
||||
## 0.6.0
|
||||
|
||||
### Patch Changes
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@aoagents/ao-plugin-agent-claude-code",
|
||||
"version": "0.6.0",
|
||||
"version": "0.8.0",
|
||||
"description": "Agent plugin: Claude Code",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<string> } | null = null;
|
||||
type ProcessListResult = string | typeof PROCESS_PROBE_INDETERMINATE;
|
||||
let psCache: {
|
||||
output: ProcessListResult;
|
||||
timestamp: number;
|
||||
promise?: Promise<ProcessListResult>;
|
||||
} | 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<string> {
|
||||
async function getCachedProcessList(): Promise<ProcessListResult> {
|
||||
// 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<string> {
|
|||
// 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<number | null> {
|
||||
async function findClaudeProcess(
|
||||
handle: RuntimeHandle,
|
||||
): Promise<number | null | typeof PROCESS_PROBE_INDETERMINATE> {
|
||||
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<number | null>
|
|||
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<number | null>
|
|||
// 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<boolean> {
|
||||
async isProcessRunning(handle: RuntimeHandle): Promise<ProcessProbeResult> {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,5 +1,58 @@
|
|||
# @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
|
||||
|
||||
- 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-<sessionId>`,
|
||||
registered so `ao stop` can reach it.
|
||||
|
||||
A new cross-platform abstraction layer (`packages/core/src/platform.ts`)
|
||||
centralises every platform branch behind helpers like `isWindows()`,
|
||||
`getDefaultRuntime()`, `getShell()`, `killProcessTree()`, `findPidByPort()`,
|
||||
and `getEnvDefaults()`. Path comparison uses `pathsEqual` /
|
||||
`canonicalCompareKey` to handle NTFS case-insensitivity. PATH wrappers for
|
||||
agent plugins (`gh`, `git`) ship as `.cjs` + `.cmd` shims on Windows;
|
||||
`script-runner` runs `.ps1` siblings of `.sh` scripts via PowerShell. New
|
||||
`ao-doctor.ps1` / `ao-update.ps1` shipped.
|
||||
|
||||
`ao open` is now cross-platform: it sources sessions from `sm.list()`
|
||||
instead of `tmux list-sessions` (so `runtime-process` sessions on Windows
|
||||
appear), and the open action branches per OS — `open-iterm-tab` stays the
|
||||
macOS path, native handling on Windows and Linux.
|
||||
|
||||
Behaviour on macOS and Linux is unchanged. Every Windows path is gated
|
||||
behind `isWindows()`; `runtime-tmux` and the bash hook flows are untouched.
|
||||
|
||||
See `docs/CROSS_PLATFORM.md` for the developer reference (helper inventory,
|
||||
EPERM-vs-ESRCH gotcha, PowerShell-vs-bash differences, pre-merge checklist).
|
||||
The Windows runtime architecture (pty-host, pipe protocol, registry, sweep,
|
||||
mux WS Windows branch) is documented in `docs/ARCHITECTURE.md`.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [0f5ae0b]
|
||||
- Updated dependencies [fe33bb7]
|
||||
- Updated dependencies [7c46dc9]
|
||||
- @aoagents/ao-core@0.7.0
|
||||
|
||||
## 0.6.0
|
||||
|
||||
### Patch Changes
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@aoagents/ao-plugin-agent-codex",
|
||||
"version": "0.6.0",
|
||||
"version": "0.8.0",
|
||||
"description": "Agent plugin: OpenAI Codex CLI",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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<boolean> {
|
||||
async isProcessRunning(handle: RuntimeHandle): Promise<ProcessProbeResult> {
|
||||
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;
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,21 @@
|
|||
# @aoagents/ao-plugin-agent-cursor
|
||||
|
||||
## 0.8.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @aoagents/ao-core@0.8.0
|
||||
|
||||
## 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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@aoagents/ao-plugin-agent-cursor",
|
||||
"version": "0.1.4",
|
||||
"version": "0.8.0",
|
||||
"description": "Agent plugin: Cursor CLI",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,21 @@
|
|||
# @aoagents/ao-plugin-agent-kimicode
|
||||
|
||||
## 0.8.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @aoagents/ao-core@0.8.0
|
||||
|
||||
## 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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@aoagents/ao-plugin-agent-kimicode",
|
||||
"version": "0.1.3",
|
||||
"version": "0.8.0",
|
||||
"description": "Agent plugin: Kimi Code CLI (MoonshotAI)",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,58 @@
|
|||
# @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
|
||||
|
||||
- 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-<sessionId>`,
|
||||
registered so `ao stop` can reach it.
|
||||
|
||||
A new cross-platform abstraction layer (`packages/core/src/platform.ts`)
|
||||
centralises every platform branch behind helpers like `isWindows()`,
|
||||
`getDefaultRuntime()`, `getShell()`, `killProcessTree()`, `findPidByPort()`,
|
||||
and `getEnvDefaults()`. Path comparison uses `pathsEqual` /
|
||||
`canonicalCompareKey` to handle NTFS case-insensitivity. PATH wrappers for
|
||||
agent plugins (`gh`, `git`) ship as `.cjs` + `.cmd` shims on Windows;
|
||||
`script-runner` runs `.ps1` siblings of `.sh` scripts via PowerShell. New
|
||||
`ao-doctor.ps1` / `ao-update.ps1` shipped.
|
||||
|
||||
`ao open` is now cross-platform: it sources sessions from `sm.list()`
|
||||
instead of `tmux list-sessions` (so `runtime-process` sessions on Windows
|
||||
appear), and the open action branches per OS — `open-iterm-tab` stays the
|
||||
macOS path, native handling on Windows and Linux.
|
||||
|
||||
Behaviour on macOS and Linux is unchanged. Every Windows path is gated
|
||||
behind `isWindows()`; `runtime-tmux` and the bash hook flows are untouched.
|
||||
|
||||
See `docs/CROSS_PLATFORM.md` for the developer reference (helper inventory,
|
||||
EPERM-vs-ESRCH gotcha, PowerShell-vs-bash differences, pre-merge checklist).
|
||||
The Windows runtime architecture (pty-host, pipe protocol, registry, sweep,
|
||||
mux WS Windows branch) is documented in `docs/ARCHITECTURE.md`.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [0f5ae0b]
|
||||
- Updated dependencies [fe33bb7]
|
||||
- Updated dependencies [7c46dc9]
|
||||
- @aoagents/ao-core@0.7.0
|
||||
|
||||
## 0.6.0
|
||||
|
||||
### Patch Changes
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@aoagents/ao-plugin-agent-opencode",
|
||||
"version": "0.6.0",
|
||||
"version": "0.8.0",
|
||||
"description": "Agent plugin: OpenCode",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -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> = {}): 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) => {
|
||||
|
|
|
|||
|
|
@ -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<boolean> {
|
||||
async isProcessRunning(handle: RuntimeHandle): Promise<ProcessProbeResult> {
|
||||
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<Agent>;
|
||||
export default { manifest, create, detect } satisfies PluginModule<Agent>;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,54 @@
|
|||
# @aoagents/ao-plugin-notifier-composio
|
||||
|
||||
## 0.8.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @aoagents/ao-core@0.8.0
|
||||
|
||||
## 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-<sessionId>`,
|
||||
registered so `ao stop` can reach it.
|
||||
|
||||
A new cross-platform abstraction layer (`packages/core/src/platform.ts`)
|
||||
centralises every platform branch behind helpers like `isWindows()`,
|
||||
`getDefaultRuntime()`, `getShell()`, `killProcessTree()`, `findPidByPort()`,
|
||||
and `getEnvDefaults()`. Path comparison uses `pathsEqual` /
|
||||
`canonicalCompareKey` to handle NTFS case-insensitivity. PATH wrappers for
|
||||
agent plugins (`gh`, `git`) ship as `.cjs` + `.cmd` shims on Windows;
|
||||
`script-runner` runs `.ps1` siblings of `.sh` scripts via PowerShell. New
|
||||
`ao-doctor.ps1` / `ao-update.ps1` shipped.
|
||||
|
||||
`ao open` is now cross-platform: it sources sessions from `sm.list()`
|
||||
instead of `tmux list-sessions` (so `runtime-process` sessions on Windows
|
||||
appear), and the open action branches per OS — `open-iterm-tab` stays the
|
||||
macOS path, native handling on Windows and Linux.
|
||||
|
||||
Behaviour on macOS and Linux is unchanged. Every Windows path is gated
|
||||
behind `isWindows()`; `runtime-tmux` and the bash hook flows are untouched.
|
||||
|
||||
See `docs/CROSS_PLATFORM.md` for the developer reference (helper inventory,
|
||||
EPERM-vs-ESRCH gotcha, PowerShell-vs-bash differences, pre-merge checklist).
|
||||
The Windows runtime architecture (pty-host, pipe protocol, registry, sweep,
|
||||
mux WS Windows branch) is documented in `docs/ARCHITECTURE.md`.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [0f5ae0b]
|
||||
- Updated dependencies [fe33bb7]
|
||||
- Updated dependencies [7c46dc9]
|
||||
- @aoagents/ao-core@0.7.0
|
||||
|
||||
## 0.6.0
|
||||
|
||||
### Patch Changes
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@aoagents/ao-plugin-notifier-composio",
|
||||
"version": "0.6.0",
|
||||
"version": "0.8.0",
|
||||
"description": "Notifier plugin: Composio unified notifications (Slack, Discord, email)",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,54 @@
|
|||
# @aoagents/ao-plugin-notifier-desktop
|
||||
|
||||
## 0.8.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @aoagents/ao-core@0.8.0
|
||||
|
||||
## 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-<sessionId>`,
|
||||
registered so `ao stop` can reach it.
|
||||
|
||||
A new cross-platform abstraction layer (`packages/core/src/platform.ts`)
|
||||
centralises every platform branch behind helpers like `isWindows()`,
|
||||
`getDefaultRuntime()`, `getShell()`, `killProcessTree()`, `findPidByPort()`,
|
||||
and `getEnvDefaults()`. Path comparison uses `pathsEqual` /
|
||||
`canonicalCompareKey` to handle NTFS case-insensitivity. PATH wrappers for
|
||||
agent plugins (`gh`, `git`) ship as `.cjs` + `.cmd` shims on Windows;
|
||||
`script-runner` runs `.ps1` siblings of `.sh` scripts via PowerShell. New
|
||||
`ao-doctor.ps1` / `ao-update.ps1` shipped.
|
||||
|
||||
`ao open` is now cross-platform: it sources sessions from `sm.list()`
|
||||
instead of `tmux list-sessions` (so `runtime-process` sessions on Windows
|
||||
appear), and the open action branches per OS — `open-iterm-tab` stays the
|
||||
macOS path, native handling on Windows and Linux.
|
||||
|
||||
Behaviour on macOS and Linux is unchanged. Every Windows path is gated
|
||||
behind `isWindows()`; `runtime-tmux` and the bash hook flows are untouched.
|
||||
|
||||
See `docs/CROSS_PLATFORM.md` for the developer reference (helper inventory,
|
||||
EPERM-vs-ESRCH gotcha, PowerShell-vs-bash differences, pre-merge checklist).
|
||||
The Windows runtime architecture (pty-host, pipe protocol, registry, sweep,
|
||||
mux WS Windows branch) is documented in `docs/ARCHITECTURE.md`.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [0f5ae0b]
|
||||
- Updated dependencies [fe33bb7]
|
||||
- Updated dependencies [7c46dc9]
|
||||
- @aoagents/ao-core@0.7.0
|
||||
|
||||
## 0.6.0
|
||||
|
||||
### Patch Changes
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@aoagents/ao-plugin-notifier-desktop",
|
||||
"version": "0.6.0",
|
||||
"version": "0.8.0",
|
||||
"description": "Notifier plugin: OS desktop notifications",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,21 @@
|
|||
# @aoagents/ao-plugin-notifier-discord
|
||||
|
||||
## 0.8.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @aoagents/ao-core@0.8.0
|
||||
|
||||
## 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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@aoagents/ao-plugin-notifier-discord",
|
||||
"version": "0.2.9",
|
||||
"version": "0.8.0",
|
||||
"description": "Notifier plugin: Discord webhook notifications",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,21 @@
|
|||
# @aoagents/ao-plugin-notifier-openclaw
|
||||
|
||||
## 0.8.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @aoagents/ao-core@0.8.0
|
||||
|
||||
## 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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@aoagents/ao-plugin-notifier-openclaw",
|
||||
"version": "0.2.9",
|
||||
"version": "0.8.0",
|
||||
"description": "Notifier plugin: OpenClaw webhook notifications",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,54 @@
|
|||
# @aoagents/ao-plugin-notifier-slack
|
||||
|
||||
## 0.8.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @aoagents/ao-core@0.8.0
|
||||
|
||||
## 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-<sessionId>`,
|
||||
registered so `ao stop` can reach it.
|
||||
|
||||
A new cross-platform abstraction layer (`packages/core/src/platform.ts`)
|
||||
centralises every platform branch behind helpers like `isWindows()`,
|
||||
`getDefaultRuntime()`, `getShell()`, `killProcessTree()`, `findPidByPort()`,
|
||||
and `getEnvDefaults()`. Path comparison uses `pathsEqual` /
|
||||
`canonicalCompareKey` to handle NTFS case-insensitivity. PATH wrappers for
|
||||
agent plugins (`gh`, `git`) ship as `.cjs` + `.cmd` shims on Windows;
|
||||
`script-runner` runs `.ps1` siblings of `.sh` scripts via PowerShell. New
|
||||
`ao-doctor.ps1` / `ao-update.ps1` shipped.
|
||||
|
||||
`ao open` is now cross-platform: it sources sessions from `sm.list()`
|
||||
instead of `tmux list-sessions` (so `runtime-process` sessions on Windows
|
||||
appear), and the open action branches per OS — `open-iterm-tab` stays the
|
||||
macOS path, native handling on Windows and Linux.
|
||||
|
||||
Behaviour on macOS and Linux is unchanged. Every Windows path is gated
|
||||
behind `isWindows()`; `runtime-tmux` and the bash hook flows are untouched.
|
||||
|
||||
See `docs/CROSS_PLATFORM.md` for the developer reference (helper inventory,
|
||||
EPERM-vs-ESRCH gotcha, PowerShell-vs-bash differences, pre-merge checklist).
|
||||
The Windows runtime architecture (pty-host, pipe protocol, registry, sweep,
|
||||
mux WS Windows branch) is documented in `docs/ARCHITECTURE.md`.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [0f5ae0b]
|
||||
- Updated dependencies [fe33bb7]
|
||||
- Updated dependencies [7c46dc9]
|
||||
- @aoagents/ao-core@0.7.0
|
||||
|
||||
## 0.6.0
|
||||
|
||||
### Patch Changes
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@aoagents/ao-plugin-notifier-slack",
|
||||
"version": "0.6.0",
|
||||
"version": "0.8.0",
|
||||
"description": "Notifier plugin: Slack",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,54 @@
|
|||
# @aoagents/ao-plugin-notifier-webhook
|
||||
|
||||
## 0.8.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @aoagents/ao-core@0.8.0
|
||||
|
||||
## 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-<sessionId>`,
|
||||
registered so `ao stop` can reach it.
|
||||
|
||||
A new cross-platform abstraction layer (`packages/core/src/platform.ts`)
|
||||
centralises every platform branch behind helpers like `isWindows()`,
|
||||
`getDefaultRuntime()`, `getShell()`, `killProcessTree()`, `findPidByPort()`,
|
||||
and `getEnvDefaults()`. Path comparison uses `pathsEqual` /
|
||||
`canonicalCompareKey` to handle NTFS case-insensitivity. PATH wrappers for
|
||||
agent plugins (`gh`, `git`) ship as `.cjs` + `.cmd` shims on Windows;
|
||||
`script-runner` runs `.ps1` siblings of `.sh` scripts via PowerShell. New
|
||||
`ao-doctor.ps1` / `ao-update.ps1` shipped.
|
||||
|
||||
`ao open` is now cross-platform: it sources sessions from `sm.list()`
|
||||
instead of `tmux list-sessions` (so `runtime-process` sessions on Windows
|
||||
appear), and the open action branches per OS — `open-iterm-tab` stays the
|
||||
macOS path, native handling on Windows and Linux.
|
||||
|
||||
Behaviour on macOS and Linux is unchanged. Every Windows path is gated
|
||||
behind `isWindows()`; `runtime-tmux` and the bash hook flows are untouched.
|
||||
|
||||
See `docs/CROSS_PLATFORM.md` for the developer reference (helper inventory,
|
||||
EPERM-vs-ESRCH gotcha, PowerShell-vs-bash differences, pre-merge checklist).
|
||||
The Windows runtime architecture (pty-host, pipe protocol, registry, sweep,
|
||||
mux WS Windows branch) is documented in `docs/ARCHITECTURE.md`.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [0f5ae0b]
|
||||
- Updated dependencies [fe33bb7]
|
||||
- Updated dependencies [7c46dc9]
|
||||
- @aoagents/ao-core@0.7.0
|
||||
|
||||
## 0.6.0
|
||||
|
||||
### Patch Changes
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@aoagents/ao-plugin-notifier-webhook",
|
||||
"version": "0.6.0",
|
||||
"version": "0.8.0",
|
||||
"description": "Notifier plugin: generic webhook",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,54 @@
|
|||
# @aoagents/ao-plugin-runtime-process
|
||||
|
||||
## 0.8.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @aoagents/ao-core@0.8.0
|
||||
|
||||
## 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-<sessionId>`,
|
||||
registered so `ao stop` can reach it.
|
||||
|
||||
A new cross-platform abstraction layer (`packages/core/src/platform.ts`)
|
||||
centralises every platform branch behind helpers like `isWindows()`,
|
||||
`getDefaultRuntime()`, `getShell()`, `killProcessTree()`, `findPidByPort()`,
|
||||
and `getEnvDefaults()`. Path comparison uses `pathsEqual` /
|
||||
`canonicalCompareKey` to handle NTFS case-insensitivity. PATH wrappers for
|
||||
agent plugins (`gh`, `git`) ship as `.cjs` + `.cmd` shims on Windows;
|
||||
`script-runner` runs `.ps1` siblings of `.sh` scripts via PowerShell. New
|
||||
`ao-doctor.ps1` / `ao-update.ps1` shipped.
|
||||
|
||||
`ao open` is now cross-platform: it sources sessions from `sm.list()`
|
||||
instead of `tmux list-sessions` (so `runtime-process` sessions on Windows
|
||||
appear), and the open action branches per OS — `open-iterm-tab` stays the
|
||||
macOS path, native handling on Windows and Linux.
|
||||
|
||||
Behaviour on macOS and Linux is unchanged. Every Windows path is gated
|
||||
behind `isWindows()`; `runtime-tmux` and the bash hook flows are untouched.
|
||||
|
||||
See `docs/CROSS_PLATFORM.md` for the developer reference (helper inventory,
|
||||
EPERM-vs-ESRCH gotcha, PowerShell-vs-bash differences, pre-merge checklist).
|
||||
The Windows runtime architecture (pty-host, pipe protocol, registry, sweep,
|
||||
mux WS Windows branch) is documented in `docs/ARCHITECTURE.md`.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [0f5ae0b]
|
||||
- Updated dependencies [fe33bb7]
|
||||
- Updated dependencies [7c46dc9]
|
||||
- @aoagents/ao-core@0.7.0
|
||||
|
||||
## 0.6.0
|
||||
|
||||
### Patch Changes
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@aoagents/ao-plugin-runtime-process",
|
||||
"version": "0.6.0",
|
||||
"version": "0.8.0",
|
||||
"description": "Runtime plugin: child processes",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,58 @@
|
|||
# @aoagents/ao-plugin-runtime-tmux
|
||||
|
||||
## 0.8.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @aoagents/ao-core@0.8.0
|
||||
|
||||
## 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-<sessionId>`,
|
||||
registered so `ao stop` can reach it.
|
||||
|
||||
A new cross-platform abstraction layer (`packages/core/src/platform.ts`)
|
||||
centralises every platform branch behind helpers like `isWindows()`,
|
||||
`getDefaultRuntime()`, `getShell()`, `killProcessTree()`, `findPidByPort()`,
|
||||
and `getEnvDefaults()`. Path comparison uses `pathsEqual` /
|
||||
`canonicalCompareKey` to handle NTFS case-insensitivity. PATH wrappers for
|
||||
agent plugins (`gh`, `git`) ship as `.cjs` + `.cmd` shims on Windows;
|
||||
`script-runner` runs `.ps1` siblings of `.sh` scripts via PowerShell. New
|
||||
`ao-doctor.ps1` / `ao-update.ps1` shipped.
|
||||
|
||||
`ao open` is now cross-platform: it sources sessions from `sm.list()`
|
||||
instead of `tmux list-sessions` (so `runtime-process` sessions on Windows
|
||||
appear), and the open action branches per OS — `open-iterm-tab` stays the
|
||||
macOS path, native handling on Windows and Linux.
|
||||
|
||||
Behaviour on macOS and Linux is unchanged. Every Windows path is gated
|
||||
behind `isWindows()`; `runtime-tmux` and the bash hook flows are untouched.
|
||||
|
||||
See `docs/CROSS_PLATFORM.md` for the developer reference (helper inventory,
|
||||
EPERM-vs-ESRCH gotcha, PowerShell-vs-bash differences, pre-merge checklist).
|
||||
The Windows runtime architecture (pty-host, pipe protocol, registry, sweep,
|
||||
mux WS Windows branch) is documented in `docs/ARCHITECTURE.md`.
|
||||
|
||||
### 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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@aoagents/ao-plugin-runtime-tmux",
|
||||
"version": "0.6.0",
|
||||
"version": "0.8.0",
|
||||
"description": "Runtime plugin: tmux sessions",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,54 @@
|
|||
# @aoagents/ao-plugin-scm-github
|
||||
|
||||
## 0.8.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @aoagents/ao-core@0.8.0
|
||||
|
||||
## 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-<sessionId>`,
|
||||
registered so `ao stop` can reach it.
|
||||
|
||||
A new cross-platform abstraction layer (`packages/core/src/platform.ts`)
|
||||
centralises every platform branch behind helpers like `isWindows()`,
|
||||
`getDefaultRuntime()`, `getShell()`, `killProcessTree()`, `findPidByPort()`,
|
||||
and `getEnvDefaults()`. Path comparison uses `pathsEqual` /
|
||||
`canonicalCompareKey` to handle NTFS case-insensitivity. PATH wrappers for
|
||||
agent plugins (`gh`, `git`) ship as `.cjs` + `.cmd` shims on Windows;
|
||||
`script-runner` runs `.ps1` siblings of `.sh` scripts via PowerShell. New
|
||||
`ao-doctor.ps1` / `ao-update.ps1` shipped.
|
||||
|
||||
`ao open` is now cross-platform: it sources sessions from `sm.list()`
|
||||
instead of `tmux list-sessions` (so `runtime-process` sessions on Windows
|
||||
appear), and the open action branches per OS — `open-iterm-tab` stays the
|
||||
macOS path, native handling on Windows and Linux.
|
||||
|
||||
Behaviour on macOS and Linux is unchanged. Every Windows path is gated
|
||||
behind `isWindows()`; `runtime-tmux` and the bash hook flows are untouched.
|
||||
|
||||
See `docs/CROSS_PLATFORM.md` for the developer reference (helper inventory,
|
||||
EPERM-vs-ESRCH gotcha, PowerShell-vs-bash differences, pre-merge checklist).
|
||||
The Windows runtime architecture (pty-host, pipe protocol, registry, sweep,
|
||||
mux WS Windows branch) is documented in `docs/ARCHITECTURE.md`.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [0f5ae0b]
|
||||
- Updated dependencies [fe33bb7]
|
||||
- Updated dependencies [7c46dc9]
|
||||
- @aoagents/ao-core@0.7.0
|
||||
|
||||
## 0.6.0
|
||||
|
||||
### Patch Changes
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@aoagents/ao-plugin-scm-github",
|
||||
"version": "0.6.0",
|
||||
"version": "0.8.0",
|
||||
"description": "SCM plugin: GitHub (PRs, CI, reviews)",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import {
|
|||
type PRState,
|
||||
type MergeMethod,
|
||||
type CICheck,
|
||||
type CIFailureSummary,
|
||||
type CIStatus,
|
||||
type Review,
|
||||
type ReviewDecision,
|
||||
|
|
@ -60,6 +61,8 @@ const BOT_AUTHORS = new Set([
|
|||
"lgtm-com[bot]",
|
||||
]);
|
||||
|
||||
const CI_FAILURE_LOG_TAIL_LINES = 120;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -167,6 +170,80 @@ function mapRawCheckStateToStatus(rawState: string | undefined): CICheck["status
|
|||
return "skipped";
|
||||
}
|
||||
|
||||
function isFailedCheck(check: CICheck): boolean {
|
||||
return check.status === "failed" || check.conclusion?.toUpperCase() === "FAILURE";
|
||||
}
|
||||
|
||||
function isDecimalId(value: string): boolean {
|
||||
return value.length > 0 && [...value].every((char) => char >= "0" && char <= "9");
|
||||
}
|
||||
|
||||
function extractActionRunReference(
|
||||
check: CICheck,
|
||||
): { runId: string; jobId?: string; runUrl: string } | null {
|
||||
if (!check.url) return null;
|
||||
let pathParts: string[];
|
||||
try {
|
||||
pathParts = new URL(check.url).pathname.split("/").filter(Boolean);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const actionsIndex = pathParts.findIndex(
|
||||
(part, index) => part === "actions" && pathParts[index + 1] === "runs",
|
||||
);
|
||||
const runId = actionsIndex >= 0 ? pathParts[actionsIndex + 2] : undefined;
|
||||
if (!runId || !isDecimalId(runId)) return null;
|
||||
|
||||
const jobIndex = pathParts.findIndex((part, index) => index > actionsIndex && part === "job");
|
||||
const jobId = jobIndex >= 0 ? pathParts[jobIndex + 1] : undefined;
|
||||
|
||||
return {
|
||||
runId,
|
||||
...(jobId && isDecimalId(jobId) ? { jobId } : {}),
|
||||
runUrl: check.url,
|
||||
};
|
||||
}
|
||||
|
||||
function tailLines(text: string, maxLines: number): string | undefined {
|
||||
const lines = text.split(/\r?\n/);
|
||||
const tail = lines.slice(-maxLines).join("\n").trimEnd();
|
||||
return tail.length > 0 ? tail : undefined;
|
||||
}
|
||||
|
||||
function extractFailedStep(log: string): string | undefined {
|
||||
let lastStep: string | undefined;
|
||||
for (const line of log.split(/\r?\n/)) {
|
||||
const parts = line.split("\t");
|
||||
const step = parts.length >= 3 ? parts[1]?.trim() : undefined;
|
||||
if (step) lastStep = step;
|
||||
}
|
||||
return lastStep;
|
||||
}
|
||||
|
||||
async function getFailedJobLog(
|
||||
pr: PRInfo,
|
||||
runReference: { runId: string; jobId?: string },
|
||||
): Promise<string> {
|
||||
try {
|
||||
return await gh([
|
||||
"run",
|
||||
"view",
|
||||
runReference.runId,
|
||||
"--repo",
|
||||
repoFlag(pr),
|
||||
"--log-failed",
|
||||
...(runReference.jobId ? ["--job", runReference.jobId] : []),
|
||||
]);
|
||||
} catch (err) {
|
||||
if (!runReference.jobId) throw err;
|
||||
return gh([
|
||||
"api",
|
||||
`repos/${pr.owner}/${pr.repo}/actions/jobs/${runReference.jobId}/logs`,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
async function getCIChecksFromStatusRollup(pr: PRInfo): Promise<CICheck[]> {
|
||||
const raw = await gh([
|
||||
"pr",
|
||||
|
|
@ -811,6 +888,46 @@ function createGitHubSCM(): SCM {
|
|||
});
|
||||
},
|
||||
|
||||
async getCIFailureSummary(
|
||||
pr: PRInfo,
|
||||
providedFailedChecks?: CICheck[],
|
||||
): Promise<CIFailureSummary | null> {
|
||||
try {
|
||||
const failedChecks = (providedFailedChecks ?? (await this.getCIChecks(pr))).filter(
|
||||
isFailedCheck,
|
||||
);
|
||||
if (failedChecks.length === 0) return null;
|
||||
|
||||
const failedJobs: CIFailureSummary["failedJobs"] = [];
|
||||
const seenRuns = new Set<string>();
|
||||
|
||||
for (const check of failedChecks) {
|
||||
const runReference = extractActionRunReference(check);
|
||||
if (!runReference) continue;
|
||||
|
||||
const seenKey = `${runReference.runId}:${runReference.jobId ?? ""}`;
|
||||
if (seenRuns.has(seenKey)) continue;
|
||||
seenRuns.add(seenKey);
|
||||
|
||||
const log = await getFailedJobLog(pr, runReference);
|
||||
|
||||
const failedJob: CIFailureSummary["failedJobs"][number] = {
|
||||
name: check.name,
|
||||
runUrl: runReference.runUrl,
|
||||
};
|
||||
const failedStep = extractFailedStep(log);
|
||||
if (failedStep) failedJob.failedStep = failedStep;
|
||||
const logTail = tailLines(log, CI_FAILURE_LOG_TAIL_LINES);
|
||||
if (logTail) failedJob.logTail = logTail;
|
||||
failedJobs.push(failedJob);
|
||||
}
|
||||
|
||||
return failedJobs.length > 0 ? { failedJobs } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
async getCISummary(pr: PRInfo): Promise<CIStatus> {
|
||||
let checks: CICheck[];
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -647,6 +647,96 @@ describe("scm-github plugin", () => {
|
|||
});
|
||||
});
|
||||
|
||||
// ---- getCIFailureSummary -----------------------------------------------
|
||||
|
||||
describe("getCIFailureSummary", () => {
|
||||
it("returns failed job step and caps the failed log tail", async () => {
|
||||
const checks = [
|
||||
{
|
||||
name: "build",
|
||||
status: "failed" as const,
|
||||
conclusion: "FAILURE",
|
||||
url: "https://github.com/acme/repo/actions/runs/123/job/456",
|
||||
},
|
||||
{
|
||||
name: "lint",
|
||||
status: "passed" as const,
|
||||
conclusion: "SUCCESS",
|
||||
url: "https://github.com/acme/repo/actions/runs/124/job/457",
|
||||
},
|
||||
];
|
||||
const logLines = Array.from(
|
||||
{ length: 125 },
|
||||
(_, index) => {
|
||||
const step = index < 100 ? "Install dependencies" : "Run pnpm test";
|
||||
return `build\t${step}\t2026-05-12T00:00:00Z line ${index + 1}`;
|
||||
},
|
||||
);
|
||||
mockGhRaw(logLines.join("\n"));
|
||||
|
||||
const summary = await scm.getCIFailureSummary?.(pr, checks);
|
||||
|
||||
expect(summary?.failedJobs).toHaveLength(1);
|
||||
expect(summary?.failedJobs[0]).toEqual({
|
||||
name: "build",
|
||||
failedStep: "Run pnpm test",
|
||||
runUrl: "https://github.com/acme/repo/actions/runs/123/job/456",
|
||||
logTail: logLines.slice(-120).join("\n"),
|
||||
});
|
||||
expect(summary?.failedJobs[0]?.logTail?.split("\n")).toHaveLength(120);
|
||||
expect(summary?.failedJobs[0]?.logTail?.split("\n")[0]).toContain("line 6");
|
||||
expect(summary?.failedJobs[0]?.logTail).toContain("line 125");
|
||||
expect(ghMock).toHaveBeenLastCalledWith(
|
||||
expect.stringMatching(/(?:^|[\\/])gh(?:\.(?:exe|cmd|bat))?$/i),
|
||||
["run", "view", "123", "--repo", "acme/repo", "--log-failed", "--job", "456"],
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(ghMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns null when failed-log fetch fails", async () => {
|
||||
const checks = [
|
||||
{
|
||||
name: "build",
|
||||
status: "failed" as const,
|
||||
conclusion: "FAILURE",
|
||||
url: "https://github.com/acme/repo/actions/runs/123/job/456",
|
||||
},
|
||||
];
|
||||
mockGhError("run view failed");
|
||||
|
||||
await expect(scm.getCIFailureSummary?.(pr, checks)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to job logs API when gh run view cannot read logs yet", async () => {
|
||||
const checks = [
|
||||
{
|
||||
name: "lint",
|
||||
status: "failed" as const,
|
||||
conclusion: "FAILURE",
|
||||
url: "https://github.com/acme/repo/actions/runs/123/job/456",
|
||||
},
|
||||
];
|
||||
mockGhError("run 123 is still in progress; logs will be available when it is complete");
|
||||
mockGhRaw("2026-05-14T23:40:42Z ##[error]Lint failed\nunused variable");
|
||||
|
||||
const summary = await scm.getCIFailureSummary?.(pr, checks);
|
||||
|
||||
expect(summary?.failedJobs).toEqual([
|
||||
{
|
||||
name: "lint",
|
||||
runUrl: "https://github.com/acme/repo/actions/runs/123/job/456",
|
||||
logTail: "2026-05-14T23:40:42Z ##[error]Lint failed\nunused variable",
|
||||
},
|
||||
]);
|
||||
expect(ghMock).toHaveBeenLastCalledWith(
|
||||
expect.stringMatching(/(?:^|[\\/])gh(?:\.(?:exe|cmd|bat))?$/i),
|
||||
["api", "repos/acme/repo/actions/jobs/456/logs"],
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- getCISummary ------------------------------------------------------
|
||||
|
||||
describe("getCISummary", () => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,21 @@
|
|||
# @aoagents/ao-plugin-scm-gitlab
|
||||
|
||||
## 0.8.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @aoagents/ao-core@0.8.0
|
||||
|
||||
## 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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@aoagents/ao-plugin-scm-gitlab",
|
||||
"version": "0.2.9",
|
||||
"version": "0.8.0",
|
||||
"description": "SCM plugin: GitLab (MRs, CI, reviews)",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,54 @@
|
|||
# @aoagents/ao-plugin-terminal-iterm2
|
||||
|
||||
## 0.8.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @aoagents/ao-core@0.8.0
|
||||
|
||||
## 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-<sessionId>`,
|
||||
registered so `ao stop` can reach it.
|
||||
|
||||
A new cross-platform abstraction layer (`packages/core/src/platform.ts`)
|
||||
centralises every platform branch behind helpers like `isWindows()`,
|
||||
`getDefaultRuntime()`, `getShell()`, `killProcessTree()`, `findPidByPort()`,
|
||||
and `getEnvDefaults()`. Path comparison uses `pathsEqual` /
|
||||
`canonicalCompareKey` to handle NTFS case-insensitivity. PATH wrappers for
|
||||
agent plugins (`gh`, `git`) ship as `.cjs` + `.cmd` shims on Windows;
|
||||
`script-runner` runs `.ps1` siblings of `.sh` scripts via PowerShell. New
|
||||
`ao-doctor.ps1` / `ao-update.ps1` shipped.
|
||||
|
||||
`ao open` is now cross-platform: it sources sessions from `sm.list()`
|
||||
instead of `tmux list-sessions` (so `runtime-process` sessions on Windows
|
||||
appear), and the open action branches per OS — `open-iterm-tab` stays the
|
||||
macOS path, native handling on Windows and Linux.
|
||||
|
||||
Behaviour on macOS and Linux is unchanged. Every Windows path is gated
|
||||
behind `isWindows()`; `runtime-tmux` and the bash hook flows are untouched.
|
||||
|
||||
See `docs/CROSS_PLATFORM.md` for the developer reference (helper inventory,
|
||||
EPERM-vs-ESRCH gotcha, PowerShell-vs-bash differences, pre-merge checklist).
|
||||
The Windows runtime architecture (pty-host, pipe protocol, registry, sweep,
|
||||
mux WS Windows branch) is documented in `docs/ARCHITECTURE.md`.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [0f5ae0b]
|
||||
- Updated dependencies [fe33bb7]
|
||||
- Updated dependencies [7c46dc9]
|
||||
- @aoagents/ao-core@0.7.0
|
||||
|
||||
## 0.6.0
|
||||
|
||||
### Patch Changes
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@aoagents/ao-plugin-terminal-iterm2",
|
||||
"version": "0.6.0",
|
||||
"version": "0.8.0",
|
||||
"description": "Terminal plugin: macOS iTerm2",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,54 @@
|
|||
# @aoagents/ao-plugin-terminal-web
|
||||
|
||||
## 0.8.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @aoagents/ao-core@0.8.0
|
||||
|
||||
## 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-<sessionId>`,
|
||||
registered so `ao stop` can reach it.
|
||||
|
||||
A new cross-platform abstraction layer (`packages/core/src/platform.ts`)
|
||||
centralises every platform branch behind helpers like `isWindows()`,
|
||||
`getDefaultRuntime()`, `getShell()`, `killProcessTree()`, `findPidByPort()`,
|
||||
and `getEnvDefaults()`. Path comparison uses `pathsEqual` /
|
||||
`canonicalCompareKey` to handle NTFS case-insensitivity. PATH wrappers for
|
||||
agent plugins (`gh`, `git`) ship as `.cjs` + `.cmd` shims on Windows;
|
||||
`script-runner` runs `.ps1` siblings of `.sh` scripts via PowerShell. New
|
||||
`ao-doctor.ps1` / `ao-update.ps1` shipped.
|
||||
|
||||
`ao open` is now cross-platform: it sources sessions from `sm.list()`
|
||||
instead of `tmux list-sessions` (so `runtime-process` sessions on Windows
|
||||
appear), and the open action branches per OS — `open-iterm-tab` stays the
|
||||
macOS path, native handling on Windows and Linux.
|
||||
|
||||
Behaviour on macOS and Linux is unchanged. Every Windows path is gated
|
||||
behind `isWindows()`; `runtime-tmux` and the bash hook flows are untouched.
|
||||
|
||||
See `docs/CROSS_PLATFORM.md` for the developer reference (helper inventory,
|
||||
EPERM-vs-ESRCH gotcha, PowerShell-vs-bash differences, pre-merge checklist).
|
||||
The Windows runtime architecture (pty-host, pipe protocol, registry, sweep,
|
||||
mux WS Windows branch) is documented in `docs/ARCHITECTURE.md`.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [0f5ae0b]
|
||||
- Updated dependencies [fe33bb7]
|
||||
- Updated dependencies [7c46dc9]
|
||||
- @aoagents/ao-core@0.7.0
|
||||
|
||||
## 0.6.0
|
||||
|
||||
### Patch Changes
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@aoagents/ao-plugin-terminal-web",
|
||||
"version": "0.6.0",
|
||||
"version": "0.8.0",
|
||||
"description": "Terminal plugin: xterm.js web terminal",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,54 @@
|
|||
# @aoagents/ao-plugin-tracker-github
|
||||
|
||||
## 0.8.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @aoagents/ao-core@0.8.0
|
||||
|
||||
## 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-<sessionId>`,
|
||||
registered so `ao stop` can reach it.
|
||||
|
||||
A new cross-platform abstraction layer (`packages/core/src/platform.ts`)
|
||||
centralises every platform branch behind helpers like `isWindows()`,
|
||||
`getDefaultRuntime()`, `getShell()`, `killProcessTree()`, `findPidByPort()`,
|
||||
and `getEnvDefaults()`. Path comparison uses `pathsEqual` /
|
||||
`canonicalCompareKey` to handle NTFS case-insensitivity. PATH wrappers for
|
||||
agent plugins (`gh`, `git`) ship as `.cjs` + `.cmd` shims on Windows;
|
||||
`script-runner` runs `.ps1` siblings of `.sh` scripts via PowerShell. New
|
||||
`ao-doctor.ps1` / `ao-update.ps1` shipped.
|
||||
|
||||
`ao open` is now cross-platform: it sources sessions from `sm.list()`
|
||||
instead of `tmux list-sessions` (so `runtime-process` sessions on Windows
|
||||
appear), and the open action branches per OS — `open-iterm-tab` stays the
|
||||
macOS path, native handling on Windows and Linux.
|
||||
|
||||
Behaviour on macOS and Linux is unchanged. Every Windows path is gated
|
||||
behind `isWindows()`; `runtime-tmux` and the bash hook flows are untouched.
|
||||
|
||||
See `docs/CROSS_PLATFORM.md` for the developer reference (helper inventory,
|
||||
EPERM-vs-ESRCH gotcha, PowerShell-vs-bash differences, pre-merge checklist).
|
||||
The Windows runtime architecture (pty-host, pipe protocol, registry, sweep,
|
||||
mux WS Windows branch) is documented in `docs/ARCHITECTURE.md`.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [0f5ae0b]
|
||||
- Updated dependencies [fe33bb7]
|
||||
- Updated dependencies [7c46dc9]
|
||||
- @aoagents/ao-core@0.7.0
|
||||
|
||||
## 0.6.0
|
||||
|
||||
### Patch Changes
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@aoagents/ao-plugin-tracker-github",
|
||||
"version": "0.6.0",
|
||||
"version": "0.8.0",
|
||||
"description": "Tracker plugin: GitHub Issues",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,23 @@
|
|||
# @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
|
||||
|
||||
- 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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@aoagents/ao-plugin-tracker-gitlab",
|
||||
"version": "0.2.9",
|
||||
"version": "0.8.0",
|
||||
"description": "Tracker plugin: GitLab Issues",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue