* refactor(core): switch metadata format from key=value to JSON and add V2 path functions
Phase 1-2 of the storage redesign: adds new projectId-based path functions
(getProjectDir, getProjectSessionsDir, etc.) alongside deprecated storageKey-based
ones, and switches metadata serialization from key=value flat files to JSON with
.json extension. Structured fields (runtimeHandle, statePayload) are stored as
proper JSON objects instead of stringified strings within key=value.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor: wire V2 projectId-based paths and remove storageKey system
Switch all consumers from hash-based storage paths to projectId-based
paths (Phase 4) and completely remove the storageKey system (Phase 5).
Phase 4 — V2 path wiring:
- session-manager.ts: all 9 getProjectSessionsDir() calls use projectId
- lifecycle-manager.ts, recovery/scanner.ts, recovery/actions.ts: V2 paths
- portfolio-session-service.ts: JSON metadata + projectId-based paths
- web routes (sessions/[id], projects/[id]): V2 paths
- cli report command: V2 paths
- All test files updated with HOME isolation for parallel safety
Phase 5 — storageKey removal:
- Types: removed storageKey from ProjectConfig, PortfolioProject,
DegradedProjectEntry
- Schemas: removed from ProjectConfigSchema, GlobalProjectEntrySchema
- Removed: StorageKeyCollisionError, deriveProjectStorageIdentity,
ensureProjectStorageIdentity, findStorageKeyOwner, relinkProject,
relinkProjectInGlobalConfig, applyWrappedLocalStorageKeys,
moveStorageDirectory, countSessionEntries
- CLI: removed `project relink` command
- Web: removed storageKey from settings UI, simplified collision handling
- Simplified registerProjectInGlobalConfig and resolveProjectIdentity
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(core): restructure SessionMetadata types for storage redesign Phase 3
Complete the typed field restructuring on SessionMetadata:
- statePayload/stateVersion → lifecycle?: CanonicalSessionLifecycle
- runtimeHandle: string → RuntimeHandle (with backward-compat parsing)
- prAutoDetect: "on"/"off" → boolean (with legacy string conversion)
- dashboardPort/terminalWsPort/directTerminalWsPort → nested dashboard object
- LifecycleDecision: flat detecting* fields → nested detecting object
Includes migration command (ao migrate-storage), V2 path functions,
storageKey removal, and updated test plan (to-test.md).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(core): address review findings for storage redesign migration
Fix all HIGH-priority review findings and blockers from external review:
- Detect bare 12-hex hash directories during migration inventory
- Skip observability directories during migration
- Detect V2 tmux session naming patterns for active session check
- Derive status from lifecycle when not stored in migrated JSON
- Fix rollback to preserve storageKey format and post-migration data
- Extract shared flattenToStringRecord utility to avoid duplication
- Handle prAutoDetect "true"/"false" string variants
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(core): update displayName test for JSON metadata format
The upstream displayName test asserted key=value file format and
bare filename. Update to check JSON content and .json extension.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(core): fix runtimeHandle type in upstream restore test
The upstream displayName restore test passed runtimeHandle as
JSON.stringify(makeHandle(...)) — a string. Our type change requires
the RuntimeHandle object directly.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(core): address PR review comments
- Handle empty files from reserveSessionId() in mutateMetadata() —
treat empty/whitespace content as empty record instead of throwing
on JSON.parse
- Fix archive doc comment: archives live under <sessionsDir>/archive/,
not <projectDir>/archive/
- Remove migration test file from gitleaks path allowlist — no false
positives are triggered, so blanket file exclusion is unnecessary
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: use targeted regex instead of path allowlist for gitleaks
Replace the blanket file allowlist with a regex matching the specific
test placeholder hash "abcdef012345" that triggers the generic-api-key
rule. This keeps secret scanning active for the migration test file.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: replace high-entropy test placeholder to avoid gitleaks false positive
Use `aaaaaa000000` instead of `abcdef012345` as the dummy 12-hex-char
hash in migration tests. The old value triggered gitleaks' generic-api-key
rule when combined with `storageKey:` in YAML-like test fixtures. This
eliminates the need for any gitleaks allowlist entry for this file.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(cli): auto-register flat local config in ao start
When running `ao start` in a directory with a flat
agent-orchestrator.yaml (no `projects:` key) that isn't registered
in the global config, the Zod validation error was surfacing as a
raw error dump. Now auto-registers the project in the global config
and retries, matching the behavior of `ao start <path>`.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(core): correct migration error message to use ao session kill
The error message referenced `ao kill --all` which doesn't exist.
The correct command is `ao session kill --all`.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(core): address review findings — worktree paths, archive location, recovery log
- Migration now writes absolute worktree paths instead of relative
(relative paths resolved against cwd, not project dir, breaking restore)
- Archive directory moved from projects/{pid}/archive/ to
projects/{pid}/sessions/archive/ to match runtime deleteMetadata behavior
- getProjectArchiveDir() updated to return sessions/archive/ consistently
- fixArchiveFilename() handles sanitized timestamps (dashes replacing colons)
- getRecoveryLogPath() fallback uses AO base dir instead of synthetic
projects/_recovery/ directory
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(core): update metadata hooks for JSON format and .json extension
Both the Claude Code PostToolUse hook and the PATH wrapper hooks
(gh/git) were constructing metadata paths without .json extension and
using key=value sed to update metadata. This broke after the storage
V2 migration which uses .json files with JSON content.
Changes:
- Try {sessionId}.json first, fall back to bare {sessionId} for
pre-migration layouts
- Detect JSON format (first char '{') and use jq for updates
- Fall back to key=value sed for legacy metadata files
- Bump WRAPPER_VERSION 0.3.0 → 0.4.0 to force wrapper reinstall
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(core): reset lifecycle on restore and keep killed sessions in active metadata
Two runtime bugs fixed:
1. Restore: lifecycle object was not reset — lifecycle manager read the old
terminal state and immediately transitioned back to Done. Now resets
lifecycle to working/alive via cloneLifecycle + buildLifecycleMetadataPatch.
2. Kill: sessions were immediately archived, making them invisible to list()
and get(). Dashboard showed "Page not found" instead of Done/Terminated.
Now keeps killed sessions in active metadata with terminal status.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(core): address storage redesign review findings
Fix CI blocker and several correctness/consistency issues found during
review of PR #1466:
1. Fix codex plugin test failures — WRAPPER_VERSION bumped to 0.4.0 in
agent-workspace-hooks.ts but codex tests still expected 0.3.0
2. Add agentReport and reportWatcher to jsonFields in
unflattenFromStringRecord — these object fields were missing from the
known-fields set, causing silent data corruption on mutateMetadata
roundtrip (object → string → stays string instead of reparsing)
3. Normalize prAutoDetect writes from "off" to "false" in
session-manager — the JSON round-trip converts "off" to boolean false
on disk, which flattens to "false" on read-back. Writing "false"
directly avoids the ambiguity and matches the round-trip behavior
4. Fix STORAGE_REDESIGN.md to match implementation — archive path is
sessions/archive/ (not a sibling of sessions/), and status is still
persisted (computed-only deferred to follow-up)
5. Keep detecting fields at top level during migration — the lifecycle
manager reads detectingAttempts/detectingStartedAt/detectingEvidenceHash
from session.metadata (top-level), not from lifecycle.detecting.
Nesting them during migration caused silent reset on first poll
6. Remove to-test.md development artifact (895 lines)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(core): remove unused readMetadata import in lifecycle test
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(core): fix 3 critical migration issues
1. Orchestrator blindness: stop extracting orchestrators to orphaned
orchestrator.json — write them to sessions/ where runtime reads from.
2. Pre-lifecycle "unknown": preserve status in migrated JSON when no
statePayload exists, preventing readMetadata fallback to "unknown".
3. Archive timestamp collision: add counter to archive filenames to
prevent same-millisecond overwrites. Fix dead-code ternary.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(core): eliminate status dual truth, fix jsonFields whitelist, add rollback dry-run and tests
- Status is now computed on read from lifecycle (single source of truth).
deriveLegacyStatus maps session.reason to specific terminal statuses
(killed, cleanup, errored) instead of relying on stored previousStatus.
- Remove jsonFields whitelist in unflattenFromStringRecord — auto-detect
JSON by checking if value starts with { or [. Prevents silent
stringification of new JSON fields.
- Add dryRun option to rollbackStorage and wire through CLI --dry-run.
- Add 18 tests for V2 path functions (getProjectDir, assertSafeProjectId,
compactTimestamp, parseTmuxNameV2, etc.).
- Add migration edge case tests: worktree dir migration, pre-lifecycle
status preservation, archive filename uniqueness, active session blocking.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(core): fix stray worktree recursion, rollback data loss, and worktree path rewrite
- moveStrayWorktrees now recurses into ~/.worktrees/{projectId}/{sessionId}/
(default workspace plugin layout) instead of only scanning top-level entries
- Rollback checks for post-migration sessions before deleting project dirs,
preserving sessions created after migration with a warning
- Worktree path rewrite only fires when the destination directory actually
exists, keeping original paths for worktrees not yet moved
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(core): reset terminal PR state on session restore
When restoring a session whose PR was already merged/closed, the
lifecycle manager would immediately re-detect the merged PR and
terminate the session again — making restore useless for merged sessions.
On restore, if pr.state is "merged" or "closed", reset it to "none"
with reason "cleared_on_restore". This lets the session run freely;
if the agent creates a new PR, auto-detect picks it up normally.
Also clears mergedPendingCleanupSince to prevent stale cleanup.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(core): remove stale previousStatus args and unused SessionStatus import
Two call sites in lifecycle-manager.ts still passed session.status as
a second argument to deriveLegacyStatus and buildLifecycleMetadataPatch
after the previousStatus parameter was removed. Also removes unused
SessionStatus import from metadata.ts.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(core): address PR review comments — parser, docs, delete route, prefix sanitization
- parseTmuxNameV2: allow hyphens in prefix to match sessionPrefix
validation ([a-zA-Z0-9_-]+), fixing "my-app-1" parsing
- SessionMetadata: update stale doc comments — JSON format, no hash prefix
- DELETE /api/projects/[id]: report actual removedStorageDir based on
whether the directory existed before deletion
- start.ts registerFlatConfig: sanitize projectId before deriving
sessionPrefix, matching config-generator.ts behavior
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(agent-claude-code): add --dangerously-skip-permissions for all restored sessions
getRestoreCommand only added the flag for orchestrator sessions, but
getLaunchCommand adds it for any session with permissionless/auto-edit.
This caused restored worker sessions to lose permissionless mode.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(core): skip .migrated dirs in inventory to prevent .migrated.migrated on re-run
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(core): rollback worktree preservation, scoped tmux detection, JSON parse whitelist
- Move worktrees back to restored hash dirs before deleting project dir on rollback
- Scope v2OrchestratorPattern to known project prefixes instead of matching any tmux session
- Restrict unflattenFromStringRecord JSON parsing to known structured fields only
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(cli): show "Add this project" option in ao start project picker
When running ao start in a git repo that isn't registered, the project
selector now includes an option to add the current directory as a new
project instead of requiring the user to run a separate command.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(cli): show "Add project" in already-running menu when cwd is unregistered
When AO is already running and the user runs ao start from an
unregistered git repo, the menu now offers to add that directory
as a new project alongside the existing open/restart/quit options.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(cli): add --reports flag to ao status for agent report history
Adds --reports option to `ao status` that displays the agent report
audit trail per session. Accepts "full" for all entries or a positive
integer for the last N entries.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(cli): replace removed storageKey reference with getProjectSessionsDir in status command
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(core,web): address PR review issues — crash safety, atomic ops, corrupt data handling, test fixes
- metadata.ts: handle corrupt JSON gracefully (return null instead of crashing), use atomic renameSync for archive, conditionally persist status only when lifecycle is not an object
- storage-v2.ts: add crash-safety marker file for migration, fix archive filename handling for .json suffix and compact timestamps, use Date parsing for duplicate session resolution
- lifecycle-state.ts: add JSDoc and clarify deriveLegacyStatus default case behavior
- lifecycle-transition.ts: add JSDoc clarifying buildTransitionMetadataPatch scope
- AddProjectModal.test.tsx: fix pre-existing jsdom localStorage mock so saveRecentPath works in tests
- Add tests for corrupt JSON handling, migration markers, and crash recovery
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(core): harden storage-redesign against edge cases (EC-1 through EC-8, EC-14, EC-27)
Address 10 edge cases found during systematic review of storage redesign:
- EC-1: Wrap mutateMetadata read-modify-write in withFileLockSync to prevent race conditions
- EC-2: Replace existsSync+readFileSync TOCTOU pattern with try-catch in readMetadata/readMetadataRaw
- EC-3: Append PID to archive filenames to prevent same-second collision
- EC-4/5: Add crossDeviceMove helper with EXDEV fallback (cpSync+rmSync) for migration renames
- EC-6/13: Restrict project ID validation to [a-zA-Z0-9][a-zA-Z0-9._-]* with 128-char max
- EC-7: Guard rollback rename against pre-existing target directory
- EC-8: Add mtime+path tiebreaker for duplicate session resolution
- EC-14: Fix misleading "Resuming" log message in migration
- EC-27: Extend readMetadataRaw status override to handle statePayload-only sessions
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(core,cli): prevent silent data loss on upgrade — V1 detection, git worktree repair, storageKey preservation
Three P0 fixes for storage-redesign migration UX:
1. Warn on `ao start` when legacy hash-based directories exist,
telling users to run `ao migrate-storage` before sessions disappear.
2. Run `git worktree repair` from each project's repo root after
migration moves worktree directories — fixes broken git references
that would otherwise make git status/push fail inside moved worktrees.
3. Preserve `storageKey` in global config allowlist so it isn't silently
stripped on load before migration has a chance to use it.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(core): skip active session check during migrate-storage --dry-run
Dry run is read-only — blocking on active sessions defeats the purpose
of previewing what migration would do.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(integration-tests): update archive filename regex for PID suffix
EC-3 appended -p{pid} to archive filenames to prevent same-second
collisions. Update the integration test regex to match the new format.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(core): address final merge review — Zod schema gaps, worktree repair, rollback safety, status priority
5 fixes from final review:
1. Add storageKey to GlobalProjectEntrySchema (Zod) so it survives
parse→save round-trips until migration strips it.
2. Add 5 missing reason values to lifecycle Zod schemas
(auto_cleanup, pr_merged, cleared_on_restore, pr_merged_cleanup)
so lifecycle isn't silently reconstructed from stale status on restart.
3. Run repairGitWorktrees when stray worktrees are moved, not only
when hash-dir worktrees are moved (was checking wrong counter).
4. Count archived post-migration sessions in rollback safety check
so rollback warns before silently deleting user's archived data.
5. Fix portfolio-session-service status priority to prefer lifecycle-
derived status over stored, matching metadata.ts behavior.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(core): harden storage redesign migration rollback
* fix(core,cli,web): allocate suffixed project ids on duplicate names
* fix(core,cli): graceful migration errors + skip orchestrator selector
- Migration: wrap per-project migration in try/catch so one failure
doesn't abort the entire run. Handle ENOTEMPTY when .migrated target
already exists from an interrupted previous run.
- CLI: ao start now always opens the selected orchestrator's dashboard
page directly instead of the orchestrator selector.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(core,cli): align with upstream to reduce merge conflicts
Bump WRAPPER_VERSION from 0.4.0 to 0.6.0 to match upstream's gh CLI
tracer changes (#1238), and update start.test.ts URL assertion to use
canonical orchestrator IDs (no number suffix) per upstream's orchestrator
identity fix (#1487). These pre-merge alignments eliminate 3 of the 11
conflicts when merging upstream/main.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: resolve Phase 1+2 merge conflicts with upstream/main (#1487, #1238)
* fix(core,web): allow restoring merged sessions
Remove "merged" from NON_RESTORABLE_STATUSES and delete the
hasMergedLifecyclePR guard so sessions with merged PRs can be
restored like any other terminal session. Previously clicking
"Restore" on a merged session returned a misleading 409 error
("session is not in a terminal state") — the session was terminal,
just explicitly blocked.
Also fix Dashboard.tsx to show the restore button for merged
sessions and improve the error message in restore() to include
the actual status and activity state.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Implement hashed project identity
* fix(core,cli,web): address PR #1466 review findings
1. Patch session JSON worktree field after moving stray worktrees
2. Preserve migration marker and skip config stripping on partial failure
3. Sanitize legacy project IDs with unsafe characters during migration
4. Use sed-based JSON update when jq is unavailable instead of corrupting
JSON metadata with key=value fallback
5. Fall back to flat local config repo during first registration when
git origin provides no repo identity
6. Return and print effective registered project ID from ao project add
7. Update web route tests to use effective hashed project IDs and fix
repairWrappedLocalProjectConfig to find entries by content fallback
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(core): use strict equality to satisfy eqeqeq lint rule
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(core,web): address Copilot review comments
1. parseTmuxNameV2: accept digit-leading prefixes to match the config
schema validation ([a-zA-Z0-9_-]+)
2. DELETE /api/projects/[id]: return 400 for unsafe project IDs instead
of letting getProjectDir throw into the 500 catch-all
3. POST /api/projects: return structured 409 on collision with
existingProjectId, suggestedProjectId, and suggestion fields so the
AddProjectModal collision UI actually works
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(core,workspace): route new worktrees to V2 project directory
The workspace-worktree plugin defaulted to ~/.worktrees/ for all new
worktrees, bypassing the V2 layout entirely. New sessions created
worktrees at ~/.worktrees/{projectId}/{sessionId} instead of
~/.agent-orchestrator/projects/{projectId}/worktrees/{sessionId}.
Add optional worktreeDir to WorkspaceCreateConfig so session-manager
can pass getProjectWorktreesDir(projectId) per spawn/restore call.
The plugin uses this override when provided, falling back to the
plugin-level default for backward compat.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(cli): prefix unused addCwdOption variable to satisfy lint
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(core): address migration review findings and orchestrator tmux double-prefix
Migration (storage-v2.ts):
- Use atomicWriteFileSync for all session JSON writes (crash safety)
- Wrap stripStorageKeysFromConfig in withFileLockSync (concurrency safety)
- Add case-insensitive projectId collision detection (macOS HFS+/APFS)
- Call repairGitWorktrees in rollback path (git worktree ref repair)
- Skip stray worktree moves for failed projects (partial-failure safety)
Session manager:
- Fix orchestrator tmux name double-prefix: getOrchestratorSessionId
already returns "{prefix}-orchestrator", so tmuxName should use
sessionId directly, not "${prefix}-${sessionId}"
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(core): remove archive path functions from paths.ts and index.ts
Remove getProjectArchiveDir, getArchiveFilePath, and compactTimestamp
from V2 path helpers as part of archive system removal. Sessions will
stay in sessions/ with lifecycle.state: "terminated" instead of being
moved to sessions/archive/. Callers in metadata.ts and migration will
be updated in subsequent tasks.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(core): remove archive logic from metadata.ts
Remove archive system from metadata layer: simplify deleteMetadata to
permanent-only deletion, delete readArchivedMetadataRaw and
updateArchivedMetadata functions, and update unit/integration tests.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(core): remove archive code from session-manager.ts
Remove all archive-related logic from the session manager now that
terminated sessions stay in sessions/ instead of being moved to an
archive directory.
Changes:
- Remove readArchivedMetadataRaw/updateArchivedMetadata imports
- Delete listArchivedSessionIds and markArchivedOpenCodeCleanup functions
- Remove archive search from findOpenCodeSessionIds
- Remove listArchivedSessionIds from reserveNextSessionIdentity
- Replace archive fallback in kill() with readMetadataRaw + lifecycle check
- Remove archive fallback in restore() (findSessionRecord finds all sessions)
- Replace archive iteration in cleanup() with terminated session iteration
- Remove boolean archive flag from all deleteMetadata calls
- Remove unused readdirSync import
- Update lifecycle and restore tests to use terminated state instead of archive
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(core): stop archiving sessions on cleanup in recovery/actions.ts
Remove the deleteMetadata call that archived sessions after marking them
terminated. Sessions now remain in sessions/ with terminated state.
Also remove the now-unused deleteMetadata import.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* refactor(core): flatten archives into sessions/ during migration instead of copying to archive dir
Remove the archive system from storage-v2 migration: old V1 archives are now
flattened into sessions/ as terminated session records instead of being copied
to sessions/archive/. Duplicate sessions across hash dirs are skipped with a
warning instead of being archived. Remove fixArchiveFilename(), compactTimestamp
import, archives field from result types, and archive counting from rollback.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(core): remove archive directory filter from listMetadata
The isFile() check already excludes directories. Archive filter was only
needed when sessions/archive/ was actively used.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(core): update tests to match archive-removal behavior
cleanupSession in recovery/actions.ts now marks sessions as terminated
instead of deleting metadata. Updated two recovery-actions tests to
assert on terminated status instead of file deletion. Also fixed
metadata and integration tests for the new deleteMetadata signature
(no boolean archive arg).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(core): address build/test issues from archive removal
- Fix writeMetadata calls missing required fields in test files
- Remove boolean archive arg from deleteMetadata calls in integration tests
- Update recovery-actions tests to expect terminated state instead of deletion
- Remove unused readdirSync import from migration test
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* docs: update handoff doc — archiving removed
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* docs: remove handoff document
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(cli): add last-stop state persistence for ao stop/start restore
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(cli): ao stop kills all active sessions and records them
ao stop now kills all active sessions (orchestrator + workers), not just
the orchestrator. Killed session IDs are saved to last-stop.json for
restore on next ao start.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(cli): ao start offers to restore sessions from last ao stop
On interactive startup, if last-stop.json exists with sessions for the
current project, the user is prompted to restore them. The orchestrator
is skipped (already restored by ensureOrchestrator). The file is cleared
after the prompt regardless of choice.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(cli): update stop tests for all-sessions kill behavior
Update test mocks to return proper KillResult shape and adjust test
assertions for the new all-sessions stop behavior. Add console.log
fallback for killed session IDs (non-TTY/test capture).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(core): address review — sed JSON corruption, sanitizeBasename dot
- Replace sed-based JSON fallback with node -e in workspace hooks and
claude-code plugin. sed "s|}|...|" replaces the first } per line,
corrupting nested JSON (lifecycle, runtimeHandle). node is a hard dep
and handles nested objects correctly via JSON.parse/stringify.
- Drop . from sanitizeBasename allowed chars — config.ts Zod schema
rejects dots in project keys, so my.app_hash would fail loadConfig.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: address storage redesign review issues
* fix(core): persist stale runtime state + show cross-project sessions in ao stop/start
- session-manager: persist lifecycle to disk when enrichment detects dead
runtime (missing/exited) — prevents stale "alive" metadata from keeping
terminated sessions on the active sidebar (ao-100 bug)
- lifecycle-state: map runtime_lost reason to "killed" legacy status
- ao stop: list ALL sessions across projects, not just targeted project;
display and record cross-project sessions in last-stop.json
- ao start: show sessions from other projects that were stopped, so user
knows they need separate ao start for those projects
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(cli): scope ao stop to target project when explicit arg is given
ao stop (no arg) kills all sessions across all projects since it also
kills the parent ao start process. ao stop <project> now correctly
scopes to just that project's sessions.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(cli): show all projects in tab completions by merging global config
listProjects() only read the local config (found via cwd search), which
may contain just one project. Now also reads the global config at
~/.agent-orchestrator/config.yaml to include all registered projects
in shell completions for ao stop, ao start, etc.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(cli): fall back to global config when project arg not in local config
ao stop <project> and ao start <project> failed when cwd has a local
agent-orchestrator.yaml that doesn't contain the targeted project.
Now falls back to ~/.agent-orchestrator/config.yaml which has all
registered projects, matching what tab completions already show.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(cli): ao stop <project> must not kill parent process or dashboard
ao stop donna was killing the parent ao start process and dashboard,
which serve ALL projects. Now only kills the parent process and
dashboard when no project arg is given (full shutdown). When targeting
a specific project, only that project's sessions are killed.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(cli): always load global config for ao stop to see all projects
sm.list() iterates config.projects to find sessions. When loadConfig()
finds the local agent-orchestrator.yaml (1 project), ao stop only sees
that project's sessions — other projects' tmux sessions survive. Now
ao stop always loads the global config which has all registered projects.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(cli): ao start restores all sessions including cross-project ones
ao start showed sessions from all projects but only restored the
current project's sessions. Now restores all sessions listed, using
the global config so the session manager can see all projects.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(web): sidebar shows all sessions regardless of active project
Remove project scoping from useSessionEvents so the sidebar always
receives sessions from every project. Kanban filtering is applied
client-side via a projectSessions memo.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(cli): Ctrl+C on ao start performs full graceful shutdown
Previously Ctrl+C only stopped lifecycle workers and exited, leaving
sessions alive in tmux and not recording last-stop state for restore.
Now the SIGINT/SIGTERM handler mirrors ao stop: kills all sessions,
records last-stop state, and unregisters from running.json. A 10s
timeout ensures the process always exits even if cleanup hangs.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* docs: update architecture docs for CLI, lifecycle, and dashboard changes
- CLAUDE.md: add canonical lifecycle states/reasons, stale runtime
reconciliation, LastStopState + running.json to storage section,
config resolution note, CLI behavior section (ao start/stop/Ctrl+C),
key files (lifecycle-state.ts, running-state.ts, start.ts, sidebar)
- AGENTS.md: add lifecycle-state.ts, start.ts, running-state.ts to key
files, add CLI behavior notes section
- copilot-instructions.md: add lifecycle-state.ts + start.ts to
high-risk files, add common mistakes for runtime_lost, sidebar
scoping, and ao stop project scoping
- DESIGN.md: add decision log entry for sidebar cross-project sessions
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* docs: update PR behavior dashboard with behavioral fixes and cross-project CLI
Add sections for stale runtime reconciliation, dashboard sidebar
scoping, tab completions, config resolution, Ctrl+C graceful shutdown,
and documentation updates. Update stats to 90 files, +6481/-2421.
Update ao stop/start panels with cross-project behavior. Update
summary with runtime reconciliation and cross-project CLI verdicts.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* Revert "docs: update PR behavior dashboard with behavioral fixes and cross-project CLI"
This reverts commit 6d968b9ff5.
* fix(cli): add removeProjectFromRunning and targeted stop tests
- Add removeProjectFromRunning() to running-state.ts — removes a
project from running.json so ao start <project> can restart without
hitting the "already running" gate after ao stop <project>
- Add projectNeedsRestart check in ao start — skips "already running"
menu when the project was removed from running.json by a targeted stop
- Add 6 tests for targeted stop behavior: no parent kill, no unregister,
removes project from running.json, kills correct sessions, full stop
still tears down parent+dashboard, last-stop records correct scope
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* docs: update handoff docs with accurate checkout recipes and CLI details
Fix checkout instructions to not assume everyone has the same fork as
origin — add separate sections for the PR author vs new contributors.
Correct stop.ts references (doesn't exist — stop logic is in start.ts).
Document removeProjectFromRunning, projectNeedsRestart gate, isProjectId
guard, and Ctrl+C signal handler details.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(cli): handle URL/path args when AO is already running
Previously `ao start <URL>` or `ao start <path>` while the daemon was
already running silently ignored the arg and showed a menu about cwd.
The user's URL was dropped.
Now, for TTY callers, when AO is already running and a URL/path arg is
provided:
- If the project is already registered AND in running.projects, just
open the dashboard. No menu, no re-clone.
- Otherwise, register the project against the active config (clone for
URLs via handleUrlStart, or addProjectToConfig for paths) and open
the existing dashboard. Don't fall through to runStartup — that would
spawn a duplicate dashboard on a different port.
Non-TTY callers (scripts/agents) keep the old "AO is already running"
message and do NOT mutate config behind the user's back.
Adds two tests:
- Path arg already registered + running → opens dashboard, no menu, no
YAML mutation.
- Path arg unregistered + AO running → registers without prompting, no
menu, prints "Opening the dashboard".
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(cli): register URL/path args in global config and spawn orchestrator
Previously `ao start <URL>` while AO was already running would register
the new project in the cwd's local config (polluting an unrelated
project's YAML) and tell the user to `ao stop && ao start <id>` to
actually spawn the orchestrator — clunky and surprising.
Now the flow:
- Always register against ~/.agent-orchestrator/config.yaml (global),
never the cwd's local config. URLs go through handleUrlStart to
clone, then are re-registered globally; paths go through
addProjectToConfig with a global-config arg so it routes to
registerProjectInGlobalConfig.
- Spawn the orchestrator session via sm.ensureOrchestrator so the
dashboard immediately shows it.
- Warn that lifecycle polling for the new project requires
`ao stop && ao start <id>` (the running daemon's worker can only
poll projects it knew about at startup).
- Open the existing dashboard. No duplicate dashboard, no menu.
Already-registered + running case unchanged: just open the dashboard.
Tests updated to set AO_GLOBAL_CONFIG so the global lookup is isolated
from the test machine's real config, and to assert ensureOrchestrator
is called with the new project ID.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(cli): reload dashboard config after registering new project
After `ao start <URL/path>` registers a new project in the global
config, the running dashboard's services cache still holds the stale
config — so the project page 404s until the daemon is restarted.
Hit POST /api/projects/reload (which invalidates the services cache)
right after registering. Failure to reach the dashboard is non-fatal:
print a hint to refresh the page.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(cli): repair wrapped local config after URL clone
handleUrlStart writes a legacy wrapped (`projects:`) agent-orchestrator.yaml
inside the cloned repo. After registering the project against the global
config, the project resolver hits the wrapped local config and routes the
project into degradedProjects (with a resolveError) — so loadConfig drops
it from config.projects and ao start would throw "Failed to register".
Call repairWrappedLocalProjectConfig() right after the global registration
to convert the wrapped config to the flat format the new resolver expects.
Best-effort: if repair fails, defaults fill in behavior.
Cleanup note: any wrapped local configs from earlier runs (and stale
~/.agent-orchestrator/config.yaml entries from earlier test runs that
pre-dated AO_GLOBAL_CONFIG isolation) need manual cleanup.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(cli): clone+register flat config directly, surface empty-repo errors
Replaces the previous "register, then repair the wrapped config" hack
with a single-shot clone-and-register flow that produces a valid flat
local config from the start.
Why the previous flow was wrong:
- handleUrlStart writes a legacy wrapped (`projects:`) agent-orchestrator.yaml
inside the cloned repo. The new global-config resolver rejects that
shape and routes the project into `degradedProjects`, which breaks
`loadConfig().projects[id]` lookups and 404s the dashboard route.
- repairWrappedLocalProjectConfig() papered over that — but the right
fix is to never write a wrapped config in the first place.
What this does instead, when `ao start <URL>` runs while the daemon
is alive:
1. Parse the URL, resolve the clone target, clone (or reuse).
2. Detect the actual default branch via `git symbolic-ref refs/remotes/origin/HEAD`,
falling back to local HEAD. Returns null for empty repos.
3. If the repo is empty (no commits / no refs), fail early with a
clear actionable message — otherwise ensureOrchestrator throws a
confusing "Unable to resolve base ref" deep inside the worktree
plugin.
4. registerProjectInGlobalConfig with identity only (path, repo,
defaultBranch, sessionPrefix derived from project ID).
5. writeLocalProjectConfig with behavior only (scm + tracker plugin
choices, derived from the host platform). Skip the write if the
repo already commits its own agent-orchestrator.yaml.
6. Refresh the global config and spawn the orchestrator session.
Drops `repairWrappedLocalProjectConfig` import — no longer needed.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(migration): keep agent-report and report-watcher metadata flat
Migration was nesting six agent-report keys (agentReportedState, At,
Note, PrUrl, PrNumber, PrIsDraft) and four report-watcher keys
(reportWatcherLastAuditedAt, ActiveTrigger, TriggerActivatedAt,
TriggerCount) into `agentReport` / `reportWatcher` wrapper objects.
The live runtime readers — parseExistingAgentReport in agent-report.ts
and the report-watcher writes in lifecycle-manager.ts — read these
keys flat off `session.metadata`. readMetadataRaw() then runs the
result through flattenToStringRecord(), which JSON.stringify()s any
object value into a single string under the wrapper key. It does NOT
unfold the nested object back into the flat keys the readers expect.
Net effect: any V1 session that had a non-empty agent report or a
non-zero report-watcher trigger count silently lost that state after
migration. The active-tmux gate in `ao migrate-storage` blunts the
worst case (sessions are terminated by the time migration runs, so
the freshness window often expires the data anyway), but reports
within the 5-minute freshness window and dashboard "last reported"
fidelity are still affected.
Fix: keep these ten keys flat in the V2 JSON, identical to the
existing handling for the `detecting*` fields. Same rationale, same
shape. Adds a regression test that asserts the flat keys round-trip
through migration and rewrites the two grouping tests to assert the
new flat shape.
Reported on PR #1466 by @ashish921998.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* docs(prompt): teach orchestrator to read agent reports via ao status --reports
The orchestrator system prompt explained the worker-only `ao report`
command and the freshness/precedence rules around agent reports, but
never told the orchestrator how to inspect them. The CLI flag
`ao status --reports <full | N>` already exists for exactly this
purpose — surface it in Monitoring Progress and cross-reference it
from the Explicit Agent Reports section so the orchestrator has an
obvious read path when an inferred status disagrees with what the
worker self-reported.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(cli): attach to existing daemon for ao start <project> after targeted stop
Reported on PR #1466 as P1: after `ao stop <project>` removed the project
from running.json (via removeProjectFromRunning) but left the parent
ao start process alive, `ao start <project>` took the projectNeedsRestart
path and fell through to runStartup(). runStartup() then started a SECOND
dashboard on a new port and overwrote running.json — leaving two AO
processes running, with running.json pointing at only the new one and the
original parent's lifecycle worker still polling.
Fix: when running && projectArg is a project ID && project not in
running.projects, attach to the existing daemon instead of falling through
to runStartup. The new branch:
- Loads the project from the global config and refuses with a clear error
if it isn't registered there.
- Spawns the orchestrator session via the live session manager
(sm.ensureOrchestrator).
- Calls the new addProjectToRunning() helper to put the project back into
running.json so subsequent `ao stop` (no args) sees it and `ao spawn`
doesn't print the "running instance is not polling project X" warning.
- Reloads the dashboard's services cache via POST /api/projects/reload so
the project page works on the existing dashboard.
- Surfaces a yellow warning that lifecycle polling for the new project
isn't attached without a full daemon restart — same architectural caveat
documented in the URL/path attach branch and tracked separately as the
dynamic project supervisor follow-up issue (#1522).
- Works for both TTY and non-TTY callers; non-TTY just skips the
openUrl + dashboard popup.
Adds addProjectToRunning() in running-state.ts symmetric to the existing
removeProjectFromRunning(): file-locked, idempotent, no-op when state is
missing or already lists the project.
Adds a regression test that asserts:
- mockRegister is NOT called (no second daemon registration)
- ensureOrchestrator is called with the requested projectId
- addProjectToRunning is called with the projectId
- The interactive menu is NOT shown
- Output contains the expected "Attaching to running AO instance" /
"reattached to running daemon" lines
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* docs: add scripts/demo-pr-1466.sh — end-to-end reviewer demo
Self-contained, sandboxed walkthrough of every PR #1466 behavior change.
Designed to be recorded as a screencast — section banners replace
narration, no live typing, deterministic output.
Six acts:
1. Migration V1 → V2 (live: seed hash dirs + key=value, dry-run, execute,
show V2 layout, verify @ashish921998 fix that agent-report keys stay
flat after migration, prove rollback safety on rerun)
2. Cross-project CLI P1 fix (filter the regression test by name and run
it live — asserts no second daemon is spawned by ao start <project>
after ao stop <project>)
3. Dashboard sidebar shows all projects (display the Dashboard.tsx fix)
4. Restore from ao stop / Ctrl+C (last-stop.json round-trip)
5. Ctrl+C graceful shutdown handler with 10s hard timeout
6. Empty-repo guard for ao start <URL> (the detectClonedRepoDefaultBranch
null path that surfaces a useful error before ensureOrchestrator)
Then prints the final 560 / 981 test summary so the recording ends on
a green CI signal.
Sandbox notes:
• $HOME is overridden to /tmp/ao-demo-1466 for the duration of the
script so getAoBaseDir() resolves there. The operator's real
~/.agent-orchestrator is never touched.
• A REAL_HOME is captured before the override and restored when
running the full test suites, since vitest needs the operator's
real config path to avoid cross-test contamination.
• Re-run is idempotent — rm -rf $DEMO_HOME at the top recreates
the sandbox from scratch.
Verified runs end-to-end on storage-redesign with exit code 0.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* demo: richer fixture — 2 projects, 6 sessions, real source trees
Earlier seed was a single empty repo with one session and a 2-line README.
Reviewers would dismiss it as not credible migration evidence.
New seed:
• 2 source projects (myproject, frontend), each a real TS package layout
with package.json, tsconfig.json, src/lib/, tests/, .gitignore, README,
and 6 commits of history. 8 files per repo.
• 6 sessions across the 2 hash dirs, in varied states:
- ao-1 (working, agent-report state + report-watcher counters + PR
fields — headline @ashish921998 flat-key fix in one record)
- ao-2 (V1-archived, terminated, manually_killed)
- ao-3 (stuck, with report-watcher trigger active)
- my-orchestrator-1 (kind=orchestrator)
- fe-1 (working, PR open with PR fields)
- fe-2 (V1-archived, terminated, runtime_lost)
• Real git worktree for ao-1 with an actual diff file —
proves worktree migration moves files and rewrites git refs.
• Pre-seeded global config.yaml lists both projects so the migrator
has identity to project against.
Migration handles all 6 sessions (4 active + 2 archived → flattened) and
the 1 worktree. The verification step inspects ao-1.json post-migration
and asserts every flat agent-report / report-watcher key from the
@ashish921998 fix is present, with no nested wrapper objects.
Also fixes:
• MIGRATED_PROJECT used to grab alphabetically-first directory which
made the JSON read crash when frontend won — hardcoded to myproject.
• Before-display referenced $HASH_DIR/archive but the actual archive
location is $HASH_DIR/sessions/archive — corrected.
End-to-end verified: exit 0, "PASS — agent-report flat-key contract
preserved", 560/560 CLI + 981/981 core tests in the final summary.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(migration): relink Claude Code session storage when worktrees move
Reported in PR #1466 QA: after `ao migrate-storage`, restoring a session
launches a fresh `claude` instance — chat history is gone.
Root cause: Claude Code keys session JSONLs by the encoded form of the
workspace cwd (~/.claude/projects/<encoded>/<session-uuid>.jsonl, where
encoded = cwd with `/` and `.` replaced by `-`, see toClaudeProjectPath
in agent-claude-code/src/index.ts). The migrator moves worktrees from
~/.agent-orchestrator/{hash}-{project}/worktrees/{sid} to
~/.agent-orchestrator/projects/{projectId}/worktrees/{sid}, which
produces a different encoded path. The agent's session JSONLs are still
at the old encoded path and stay orphaned. getRestoreCommand looks under
the new encoded path, finds nothing, returns null — and the caller
falls back to a fresh launch.
Fix: track every (oldWorkspacePath, newWorkspacePath) pair across both
migration phases (per-project migrateProject and the cross-project
moveStrayWorktrees), then call relinkClaudeSessionStorage after all
worktree moves complete. The relink renames each
~/.claude/projects/<old-encoded>/ → <new-encoded>/. Skip when source
doesn't exist (no Claude history) or target already exists (manual
reconciliation needed). Same step is invoked in reverse from
rollbackStorage so `--rollback` undoes the relink.
The encoding helper is duplicated locally in migration/storage-v2.ts to
avoid pulling the agent plugin into core/migration just for one string
transformation. Kept in sync by hand; if the plugin's encoding ever
changes, both copies need to update together.
Codex stores sessions date-sharded with the cwd embedded inside each
JSONL's session_meta line, so the same physical-rename trick doesn't
apply. Codex relinking is left as a follow-up — the comment in
relinkClaudeSessionStorage points at it.
Two regression tests added:
• Happy path: V1 worktree at OLD encoded path with a JSONL inside
Claude's projects dir; after migrateStorage the JSONL is at the
NEW encoded path and the OLD dir is gone. claudeSessionsRelinked === 1.
• Safety: target dir already exists at the new encoded path; migration
skips the relink, neither dir is touched, claudeSessionsRelinked === 0.
Tests use HOME override to sandbox ~/.claude/ so the runner's real
agent-storage is never touched.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(boundary): four cross-module seams flagged in PR #1466 review
- Migration: rewrite Codex rollout session_meta.cwd for moved
worktrees so getRestoreCommand keeps finding the old thread.
Mirrors the Claude relink with a single-line in-place rewrite.
- CLI start: stop adding the project to running.projects in the
attach-to-existing-daemon branch. Lifecycle polling cannot be
attached mid-flight, so claiming coverage made `ao spawn`
silently suppress its "instance is not polling X" warning.
- CLI stop: defensively drop foreign sessions before the kill
loop when a project arg is given. `sm.list(projectId)` already
scopes, but the kill loop is destructive enough to deserve a
consumer-side guard.
- Web DELETE /api/projects/[id]: validate the id through
getProjectDir BEFORE calling cleanupManagedWorkspaces so a
malformed key never reaches a workspace plugin.
Adds regression tests for each.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(boundary): four more cross-module seams flagged in PR #1466 review
- Recovery actions (cleanup/escalate/recoverSession-on-max-attempts)
now mutate the canonical lifecycle alongside the flat status. For
V2 sessions readMetadataRaw derives status from lifecycle, so the
prior flat-only writes were silently overridden on the next read.
- Targeted `ao stop <project>` no longer calls
removeProjectFromRunning. The parent process's in-memory lifecycle
worker keeps polling that project (a child CLI cannot reach into
parent memory), so running.projects must keep listing it to remain
truthful. The attach branch in `ao start <project>` now triggers
on any project-id arg with a live daemon, regardless of
running.projects content; the polling-not-attached warning fires
only when the project is genuinely not in running.projects.
- `ao start` restore loop preserves last-stop.json for sessions that
fail to restore (transient workspace/runtime errors) instead of
clearing the only persisted record. Successful or fully-failed
flows still clear it.
- New integration round-trip: migrate a Codex JSONL with the old
worktree cwd, then call the real agent-codex.getRestoreCommand
with the migrated workspacePath and assert it returns
`codex resume <threadId>`.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(review): four illegalcall PR review findings on PR #1466
- writeMetadata sites in session-manager (spawn + ensureOrchestrator)
spread `buildLifecycleMetadataPatch` (string-typed patch) into a
typed SessionMetadata literal, which silently wrote `lifecycle` as
a JSON string and made freshly-spawned sessions read with
`lifecycle: undefined` until the first poll round-trip.
Override the spread with the canonical object form and drop the
metadata.ts safety net that compensated for the bug.
- Migration archive-flatten regex `/^([a-zA-Z0-9_-]+?)_\d/` was lazy
and captured `team` for `team_1-7_<ts>.json`. Replace with an
anchor on the timestamp suffix in both call sites so any sessionId
containing `_<digit>` is parsed correctly.
- Migration duplicate-sessionId resolution renamed-the-loser to
`${sessionId}__from-${hash}` rather than silently dropping it.
Both records survive in V2; the rename is logged.
- `running-state.ts` writes `running.json` and `last-stop.json` via
`atomicWriteFileSync` (temp+rename) so a crash mid-write cannot
leave torn JSON that orphans an alive AO process or erases the
next-start restore prompt.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(metadata): preserve corrupt session JSON before overwriting
mutateMetadata used to merge against an empty record and atomically
rewrite when parseMetadataContent returned null on corrupt JSON. The
original bytes were lost — the user had no signal anything was wrong,
the file just became "not corrupt anymore — and missing fields".
Side-rename the file to `<path>.corrupt-<ts>` and warn before the
rewrite so forensics survive. Adds two regression tests and drops the
stale STORAGE_REDESIGN.md reference comment.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* chore(lint): fix 4 errors introduced by recent boundary fixes
- storage-v2.ts:656,1453 — drop unnecessary `\-` escape inside `[…]`
character class (no-useless-escape).
- storage-v2.ts:979 — replace inline `import("node:fs").Dirent` type
annotation with a top-level `Dirent` named import (consistent-type-imports).
- recovery/actions.ts:11 — merge the second `../types.js` `import type`
into the existing line (no-duplicate-imports).
Tests + typecheck unchanged (991 passing).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(cli): propagate reloaded config out of resolveProject after add
The interactive "Add <cwd>" menu path in `resolveProject` registers
the project in the global config (with a hashed id like
`mail-automate_3e4d45c2ba`) and reloads the config internally to
fetch the new project entry. It returned only `{projectId, project}`,
so the outer caller kept the pre-add `config` reference — which has
no key for the just-added project.
Downstream that surfaced as:
Failed to start lifecycle worker:
Unknown project: mail-automate_3e4d45c2ba
because `ensureLifecycleWorker(config, projectId)` checks
`config.projects[projectId]` against the stale config.
`resolveProject` and `resolveProjectByRepo` now also return the
(possibly reloaded) config; the three call sites pick it up via
`({ projectId, project, config } = await resolveProject(...))`.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* feat: add config schema support for agent-orchestrator.yaml (#1370)
Expose a committed JSON Schema and inject the canonical $schema URL into generated and updated configs so editors can autocomplete and validate AO config files.
* fix schema injection edge cases and shared constant
* fix: address config schema review feedback (#1370)
* fix(cli): avoid missing repo scripts on global installs
* refactor(cli): moved ao-update.sh script into assets.
Entire-Checkpoint: b91ccadead1c
* Fix packaged doctor and update fallback
* Harden install detection and script runner
* fix(cli): align ao script launchers with packages/ao
---------
Co-authored-by: AO Bot <ao-bot@composio.dev>
* fix(cli): hide legacy spawn second positional (#1490)
Keep ao spawn help aligned with the real interface by exposing a single optional issue positional and rejecting extra positional arguments with replacement usage before any spawn work starts.
* fix(cli): align spawn usage with optional issue (#1490)
Keep the arity error and its test aligned with the optional [issue] contract surfaced by ao spawn help so review-thread guidance and user-facing usage match exactly.
* feat: add zsh completion for ao (#1371)
Add a generated zsh completion command and dynamic completion backend so ao can tab-complete projects and session IDs without relying on jq or brittle text parsing. Document standard zsh and Oh My Zsh install paths, and cover the new flow with CLI tests.
* fix(cli): address copilot completion review feedback for PR 1374
* fix completion and harden local workflow parsing
* Update packages/cli/src/lib/completion.ts
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
* fix completion regressions and agent-ci review feedback
---------
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
* fix(cli): derive projectId from prefixed issue id on spawn
When `ao spawn <projectId>/<issue>` is used in a multi-project config,
route the spawn to the prefixed project and strip the prefix from the
issue id. Previously the projectId fell back to whichever project
`ao start` was running for, tagging cross-project sessions with the
wrong project (and session prefix).
Applies to both `ao spawn` and `ao batch-spawn`; batch-spawn errors
out if issues mix multiple project prefixes.
Fixes#1329
* refactor(core,cli): lift spawn target resolution into core
Move the issue-prefix → project routing from the CLI into a reusable
core utility, `resolveSpawnTarget(projects, issueRef, fallback?)`.
- Exposes routing to any spawn entry point (CLI, web API, programmatic).
- Accepts either a project id or sessionPrefix as the prefix; project id
wins on collision.
- `ao batch-spawn` now groups issues by resolved project and preflights
once per group instead of erroring on mixed prefixes.
Tests:
- 8 unit tests for `resolveSpawnTarget` in core.
- CLI spawn.test.ts: adds a sessionPrefix routing test (23 tests total).
Refs #1329
* test(cli): cover batch-spawn grouping; tighten resolveSpawnTarget API
Self-review found three gaps:
- `resolveSpawnTarget` accepted `undefined` issueRef and returned
`{ issueId: "" }` with a fallback project. Dead path — both callers
guard against undefined. Drop it and make issueRef required.
- No tests for `batch-spawn`. Added two:
- routes cross-project prefixed issues to the correct project and
lists each project's sessions separately
- skips a prefixed issue when the target project already has an
active session for it
- `spawn` and `batch-spawn` help text didn't document the
`<projectId>/<issue>` or `<sessionPrefix>/<issue>` forms.
* fix(core): guard resolveSpawnTarget against prototype-key matches
Second review pass found a latent bug:
Plain JS objects inherit `__proto__`, `constructor`, `toString`,
`hasOwnProperty`, etc. from Object.prototype. The previous
`if (projects[prefix])` check entered the routing branch for any of
these keys, mis-routing a user typing `ao spawn __proto__/42` with
`projectId: "__proto__"`. Downstream session-manager would then receive
a junk project object (Object.prototype itself) and fail with a
non-obvious error.
Fix: use `Object.prototype.hasOwnProperty.call(projects, prefix)` so
only actual configured project ids match.
Also:
- Document the case-sensitive matching semantic explicitly.
- Lock in the batch-spawn grouping contract by asserting exact
`list()` and `spawn()` call counts in the cross-project test.
* feat(cli): hide terminal session rows by default in session ls
- Filter with TERMINAL_STATUSES unless --include-terminated
- Hint when completed sessions are hidden; docs/CLI.md updated
Made-with: Cursor
* fix(cli): keep session ls JSON inventory stable
Address review concerns by preserving terminal sessions in `ao session ls --json` output while keeping terminal rows hidden by default in text output. Use the core terminal-session predicate, improve hidden-row messaging, and add regression tests for mixed/hinted and JSON behavior.
Made-with: Cursor
* fix(cli): resolve rebase conflicts with latest session ls behavior
Align session command and CLI docs with the current mainline JSON contract (`data` + `meta.hiddenTerminatedCount`) while preserving terminal-filter defaults and updated regression coverage.
Made-with: Cursor
* fix: fail send when killed session delivery is not confirmed
* fix(core): drop requireConfirmation throw that re-introduced duplicate-message bug
The PR's sendWithConfirmation added a throw when confirmation heuristics
did not flip within SEND_CONFIRMATION_ATTEMPTS on a restored session.
But runtimePlugin.sendMessage had already fired, so the throw bubbled up
to the lifecycle manager's catch-all, leaving lastCIFailureDispatchHash
(and its merge-conflict twin) unset. Next poll re-dispatched the same
message — exactly the duplicate-message bug that commit 77685a5 removed.
Real fix for #1074 is preserved: restoreForDelivery still throws when
waitForRestoredSession returns false, which happens before sendMessage
fires. Killed sessions that cannot be revived are still reported as
failures, with no duplicate-send risk.
- sendWithConfirmation no longer takes requireConfirmation; unconfirmed
delivery always returns (soft success).
- prepareSession returns Session (the tuple only existed to drive the
removed throw).
- send's retry predicate reverts to prepared.restoredAt === undefined
&& isRestorable(prepared).
- Adds regression test: restored session + sendMessage fires +
confirmation never flips → send() resolves.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* feat(cli): filter terminated sessions from ao session ls / ao status by default
Closes#1310. Terminated sessions (killed/terminated/done/merged/errored/cleanup,
plus lifecycle-driven terminal states) are now hidden from `ao session ls` and
`ao status` by default. A dim footer reports how many were hidden and how to
surface them. Pass `--include-terminated` to restore the full list.
JSON output wraps into `{ data: [...], meta: { hiddenTerminatedCount } }` on
both commands so text and machine-readable views tell the same story. This is a
breaking change for script consumers of `--json`; `--include-terminated` is the
escape hatch.
Orthogonal to `-a, --all` (orchestrator visibility, unchanged). Restore of
terminated sessions by id is unaffected — that path goes through `sm.get`, not
`sm.list`.
Docs (`SETUP.md`, `docs/CLI.md`) updated to match. Tests cover both the legacy
status branch and the canonical lifecycle branch of `isTerminalSession`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(cli): drop unused `lc` param in lifecycle-alive test case
ESLint's no-unused-vars rejects unprefixed unused args. The "alive — should
remain visible" branch of the new lifecycle-driven filter test in
`session.test.ts` took `lc` but never touched it. Switch to `()`. Matches the
equivalent case in `status.test.ts`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(core): preserve pr.state=merged when legacy metadata lacks pr= URL
Review blocker on PR #1340: a metadata file with `status=merged` but no `pr=`
URL was still showing as active in `ao session ls` / `ao status` by default.
Root cause: `synthesizePRState()` in lifecycle-state.ts short-circuited to
`{ state: "none" }` whenever no PR URL was present, ignoring the fact that the
legacy `status` column already encodes terminal truth. Once lifecycle was
synthesized as `session.state="idle"` + `pr.state="none"`, `deriveLegacyStatus`
returned `"idle"` and `isTerminalSession()` (lifecycle branch) returned false.
The new CLI filter then let the session through.
Fix: when legacy `status === "merged"` and no URL is available, synthesize
`pr.state="merged", reason="merged"` with `number: null, url: null`. The
terminal signal survives the flat-metadata → canonical-lifecycle round trip.
Also:
- Export `sessionFromMetadata` from the core barrel. CLI tests and external
consumers need it to round-trip metadata through the canonical lifecycle.
- Update CLI `buildSessionsFromDir` helpers to route through `sessionFromMetadata`
so mocked `sm.list()` reflects production reconstruction (the old shortcut
bypassed synthesis entirely and was the reason the bug slipped past the
original test suite).
- Add regression tests: one at the core level (`parseCanonicalLifecycle` for
merged-without-URL) and one integration-style test per CLI command asserting
the reviewer's exact repro produces the expected filtered output.
- One pre-existing test expectation updated: when metadata has `status=working`
and `pr=<url>`, the reconstructed status is `pr_open`, not `working`. That's
what production `sm.list()` has always returned; the test was previously
hiding behind the reconstruction shortcut.
Changeset bumped to include ao-core (patch).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(cli): route review-check helper through sessionFromMetadata
Last remaining test-fidelity shortcut flagged by codex on PR #1340. The
`buildSessionsFromDir` helper in review-check.test.ts fabricated Session
objects by hand, bypassing the canonical lifecycle reconstruction that
production `sm.list()` runs. Doesn't affect review-check's actual behavior
(which reads `session.metadata["pr"]` directly), but aligns this test with
the equivalent helpers in session.test.ts and status.test.ts so future
lifecycle changes don't silently skip this surface.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fixes#1048. ao start used to allocate a fresh `{prefix}-orchestrator-N`
on every invocation instead of reattaching to the previous session, and
the dashboard's orchestrator link pointed at a different id than the
CLI just printed.
Changes:
runStartup (packages/cli/src/commands/start.ts):
- On startup, list existing orchestrators for the project, partition
them into live (runtime still running) and restorable (terminal but
sm.restore()-able) buckets, pick the most-recently-active from the
chosen bucket, and reuse/restore that id instead of spawning a new
one. Only spawn fresh when both buckets are empty.
- Live is preferred UNCONDITIONALLY over restorable — a newer killed
record can never beat an older-but-running one. Without this, a
cross-bucket sort could resurrect a killed record via sm.restore()
while the live orchestrator kept running, leaving two alive.
- Restored sessions get an explicit "(restored)" marker in the CLI
summary so the resurfaced id isn't a surprise.
- The phantom `${prefix}-orchestrator` id constant is removed from
every URL print, browser-open target, and summary line. Everything
now uses the real selected id.
registerStop (same file):
- ao stop now resolves the real orchestrator via sm.list(projectId)
+ isOrchestratorSession filter + most-recently-active sort, then
calls sm.kill on that id. The old phantom `${prefix}-orchestrator`
target never matched a real numbered record, so ao stop was a
silent no-op and the orchestrator kept running on disk between
start cycles. sm.list-failure warning no longer duplicates with the
generic "no orchestrator found" message.
isOrchestratorSession (packages/core/src/types.ts):
- Tightened: legacy bare-id records (`{projectId}-orchestrator` with
no role metadata) are no longer recognized as orchestrators by the
public predicate. This was the source of the dashboard/CLI id
divergence — stale bare records with a different prefix than the
numbered form were leaking into the dashboard's orchestrator list.
session-manager repair (packages/core/src/session-manager.ts):
- Split `isOrchestratorSessionRecord` (permissive, used by cleanup
protection) from a new `isRepairableOrchestratorRecord` (stricter,
used only by repairSingleSessionMetadataOnRead and
repairSessionMetadataOnRead).
- The strict repair predicate accepts role-stamped records, the bare
`{sessionPrefix}-orchestrator` correct-prefix legacy shape, and the
numbered `{sessionPrefix}-orchestrator-N` worktree shape. It
rejects foreign bare names like `{projectId}-orchestrator`, so
those records never get `role: orchestrator` backfilled on read
and therefore can no longer pass `isOrchestratorSession()` in real
`sm.list()` output via the role-metadata branch.
Tests added (~12):
- runStartup: live reuse, restore-on-killed, ignore-stale-bare
legacy records, live-beats-restorable regression, multi-live
reuse, URL fallback when --no-orchestrator.
- ao stop: kills the actual numbered id (not the phantom), handles
multiple orchestrators, tolerates sm.list throwing.
- isOrchestratorSession: rejects stale bare ids without role
metadata; accepts bare ids with role metadata stamped.
- listDashboardOrchestrators: stale bare excluded, numbered live
included, role-stamped legacy included.
- session-manager repair: does not backfill role onto foreign bare-id
records (issue #1048 regression guard).
Unblocks: review comments from cursor[bot] (dead else-if branch,
double messaging, redundant isTerminalSession check) and illegalcall
(cross-bucket sort, repair-backfill bypass of predicate tightening) —
all addressed in-place with the multi-orchestrator model preserved.
Verified: core 606/606, cli 450/450, typecheck clean across core/cli/web.
Represent missing activity probes as first-class signal states so lifecycle inference only treats valid idle evidence as proof. This prevents false stuck transitions, keeps API/UI lifecycle truth aligned, and makes root monorepo verification deterministic by serializing recursive build and typecheck.
Introduce an explicit reporting channel so worker agents can self-declare
their workflow phase (started/working/waiting/needs-input/fixing-ci/
addressing-reviews/completed). Fresh reports are trusted over weak inference
but runtime death, activity-based waiting_input, and SCM ground truth still
take precedence.
- Add `applyAgentReport`, validator, canonical mapping, and freshness helper
in `packages/core/src/agent-report.ts`.
- Wire the fallback into `determineStatus` just before the idle-beyond-
threshold promotion, skipping orchestrator and terminal sessions.
- Add `ao acknowledge` and `ao report <state>` CLI commands. Both resolve
the session from `AO_SESSION_ID` when no argument is passed.
- Teach the base agent prompt and orchestrator prompt about the new
reporting commands.
- Ship unit tests covering normalization, mapping, transition validation,
metadata persistence, freshness, and first-start behavior.
Stage 3 of the state-machine redesign (see aa-2/state-machine-redesign-
rollout-plan.md).
- New repo-utils.ts with extractOwnerRepo() and isValidRepoString()
shared across detectEnvironment, autoCreateConfig, addProjectToConfig
- Remote regex now supports GitLab subgroup paths (group/subgroup/repo)
- Repo validation accepts multi-segment paths (owner/repo and deeper)
- Updated prompt text to mention group/subgroup/repo format
- Updated ProjectConfig.repo docstring to be provider-neutral
- Tests import from shared helpers instead of duplicating regex
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@clack/prompts text() can return undefined when submitted with just
the placeholder value. Guard with typeof check in promptText and
defensive (entered || "") in both callers to prevent .trim() crash.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- detect-env: test GitHub/GitLab HTTPS/SSH remote extraction, unknown
hosts returning null, missing remote, and non-git directories
- repo-validation: test the anchored regex accepts owner/repo, rejects
empty, lone slash, missing segments, whitespace, and nested paths
- config-validation: test SCM/tracker inference skipped when repo is
missing or has no slash, and inferred correctly with owner/repo
- scm-webhooks: test eventMatchesProject returns false when project
has no repo configured
- orchestrator-prompt: existing test covers repo:undefined → "not configured"
- prompt-builder: existing test covers BASE_AGENT_PROMPT_NO_REPO selection
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Anchor repo validation regex with $ to reject trailing junk like
"acme/repo extra" or "acme/repo#frag" — both prompt locations
- Widen detectEnvironment and addProjectToConfig remote regex to
match gitlab.com in addition to github.com, so GitLab repos get
auto-detected owner/repo instead of silently skipping it
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
afterEach calls vi.restoreAllMocks() which restores the top-level mock
to the real function. Use vi.spyOn on the module namespace instead of
vi.mocked on the destructured import so the mock survives restoration.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The test has no ownerRepo detected, which triggers the interactive
repo prompt. Mock isHumanCaller to false so the prompt is skipped
in the non-interactive test environment.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1. Fix ao start <path> regression: when target path differs from cwd,
run autoCreateConfig on the target (which is a git repo) instead of
cwd (which may not be). Removes the double-create pattern that added
a second project entry.
2. Gate remaining PR/CI sections in orchestrator-prompt: PR Takeover,
PR Review Flow, Bulk Issue Processing, and Monitoring Progress
details are now wrapped in project.repo checks so repo-less projects
don't get contradictory instructions.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Move ensureGit() after path dedup check so already-registered paths
return early without requiring git
- Use realpathSync for canonical path comparison (resolves symlinks,
case variants, trailing slashes)
- Bail out with error when both SIGTERM and SIGKILL fail to stop AO
instead of unconditionally unregistering a live instance
- Rewrite path-dedup test to exercise the path-arg branch via
AO_CONFIG_PATH, covering addProjectToConfig's dedup lines
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The test was mocking detectEnvironment with isGitRepo: false, which now
correctly triggers the fail-fast error for non-git directories. Updated
to isGitRepo: true since the test is verifying config generation
defaults, not non-git behavior.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1. prompt-builder: gate PR/CI instructions on project.repo — use
trimmed BASE_AGENT_PROMPT_NO_REPO when no remote configured
2. orchestrator-prompt: gate Quick Start and Available Commands
sections on project.repo — omit issue/PR commands when absent
3. types.ts: add changeset (minor for ao-core) with migration note
4. config.ts: consistent includes("/") guard for tracker inference
5. start.ts: fail fast with clear error when run in non-git directory
6. start.ts: stricter repo validation regex (requires non-empty
segments on both sides of slash)
7. start.ts: addProjectToConfig now prompts for repo like
autoCreateConfig does, with matching warning message
8. scm-webhooks.ts: pre-filter projects without repo in
findWebhookProjects to skip unnecessary SCM plugin lookups
9. lifecycle-manager.ts: fix GitLab subgroup split — use lastIndexOf
to correctly handle group/subgroup/repo paths
Closes#1154
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1. Replace SIGINT/SIGTERM handlers with a process `exit` handler to avoid
conflicting with the shutdown handler that flushes lifecycle state and
exits with the correct code (130 for SIGINT).
2. Expand dashboard process pattern to match dev mode (next dev, ao-web)
in addition to production (next-server, start-all.js).
3. Only kill dashboard-matching PIDs from lsof output, leaving unrelated
co-listeners (sidecars, SO_REUSEPORT) untouched.
4. Use killDashboardOnPort for the first port attempt too, preventing
blind kills on the configured port when running.json is stale.
5. Add test for mixed-PID filtering on a single port.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Remove SIGINT/SIGTERM/exit listeners from process when the dashboard
child exits, preventing listener accumulation if runStartup is called
multiple times in the same process.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add test for orchestrator-prompt with repo: undefined (verifies
"not configured" fallback instead of showing literal "undefined")
- Add c8 ignore for interactive prompt code in autoCreateConfig and
promptText (same pattern as existing promptConfirm/promptSelect)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Instead of silently omitting the repo field when no GitHub remote is
found, interactively ask the user to enter their owner/repo. This way
users on GitLab or other hosts can provide the correct value during
first-run setup rather than having to manually edit the yaml afterward.
If the user skips the prompt, the config is still valid (repo remains
optional) with a clear warning about what features are unavailable.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Use ?? instead of || for ownerRepo fallback (semantically correct for
null-to-undefined conversion)
- Extract requireRepo() result into a local variable in tracker-gitlab's
updateIssue and issueUrl to avoid redundant validation calls
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Make `repo` field optional in ProjectConfig and Zod schema so projects
without a detected GitHub remote can still load and run
- Remove placeholder `repo: "owner/repo"` from autoCreateConfig() and
addProjectToConfig() — omit the field entirely when no remote is found
- Always use actual workingDir for `path` instead of unreliable `~/<projectId>`
fallback for non-git directories
- Add null guards for `project.repo` across SCM plugins, tracker plugins,
lifecycle manager, webhooks, and prompt builders to prevent crashes when
repo is not configured
Closes#1154
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1. Port scan now uses killDashboardOnPort() which checks `ps -p <pid> -o args=`
for next-server/start-all.js before killing, avoiding collateral damage to
unrelated services on nearby ports.
2. SIGINT/SIGTERM handlers now call process.exit() after killing the child,
restoring default exit behavior so Ctrl+C doesn't leave the parent hung
if the child is unresponsive.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds test for stopDashboard finding an orphaned dashboard on a reassigned
port via the port-range scan fallback. Marks signal handler body with
c8 ignore since it only fires on process termination.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Two bugs caused `ao stop` to fail when the dashboard port was auto-reassigned:
1. Ctrl+C did not propagate to the dashboard child process because Node.js
doesn't guarantee signal forwarding. Added SIGINT/SIGTERM/exit handlers
in runStartup() to explicitly kill the dashboard child.
2. stopDashboard() only checked the configured port, missing orphaned
dashboards on reassigned ports. Refactored into killOnPort() helper
and added a port-range scan fallback (up to MAX_PORT_SCAN ports).
Closes#645
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The "errors when multiple projects and no arg" test expects the error
path (not the prompt path) in resolveProject. Set mockIsHumanCaller
to false so the test reliably hits the non-interactive error branch.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Cover the moved isAlreadyRunning() check (non-TTY exit, quit, open,
restart, new orchestrator) and the path-based dedup guard in
addProjectToConfig(). Uses hoisted mocks for isAlreadyRunning,
isHumanCaller, promptSelect, unregister, and waitForExit to ensure
proper test isolation.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Move the `isAlreadyRunning()` check before any config-mutating operations
so that running `ao start` on an already-running project no longer writes
a phantom duplicate entry to agent-orchestrator.yaml. The "new orchestrator"
choice is deferred via a flag until after config is loaded.
Also add path-based deduplication in `addProjectToConfig()` so that a
project whose resolved path already exists in config is returned as-is
instead of being appended with a numeric suffix.
Closes#1150
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* refactor(cli): remove lifecycle-worker subprocess model, run polling in-process
Replaces the per-project `ao lifecycle-worker` subprocess with an in-process
polling loop managed inside `lifecycle-service`. All registered projects are
now polled from the single long-lived `ao start` process.
- Drops the `lifecycle-worker` command and its registration.
- Rewrites `lifecycle-service` around a `Map<projectId, ActiveLoop>`,
with SIGINT/SIGTERM/beforeExit graceful shutdown.
- Removes PID-file coordination (PID file, log file, status, etc.) —
these only existed to track subprocess state.
- Per-project error isolation is preserved: the core lifecycle manager's
`pollAll()` already catches per-cycle errors, and stop failures in one
project can't prevent others from stopping.
- Updates `start`/`spawn` callers and tests to drop the PID/logFile shape.
- Adds `lifecycle-service.test.ts` covering idempotency, unknown projects,
error isolation across projects, and graceful stop-all.
Closes#1185
* fix(cli): address bugbot review on lifecycle-service in-process refactor
- `ao spawn` / `ao batch-spawn` no longer call `ensureLifecycleWorker`.
That call used to start `setInterval` polling in the one-shot spawn
process, which (a) kept the CLI alive forever after the session spawned
and (b) duplicated polling already running in `ao start`. Replaced with
a `warnIfAONotRunning()` helper that checks `running.json` and prints a
hint if the orchestrator isn't up.
- `ao stop` no longer calls `stopLifecycleWorker`. That call always ran
against a fresh in-memory map (stop is a separate process from start),
so it always returned false and printed "Lifecycle worker not running"
misleadingly. SIGTERM to the `ao start` PID already triggers the shared
shutdown handler in `lifecycle-service`, which stops every loop.
- Drop duplicated shutdown closure inside `registerSignalsOnce` — signal
handlers now reference `stopAllLifecycleWorkers` directly.
- Update tests accordingly: spawn.test.ts mocks `running-state.getRunning`,
start.test.ts drops `stopLifecycleWorker` expectations.
* fix(cli): drop SIGINT/SIGTERM listeners from lifecycle-service
Installing listeners for those signals removes Node.js's default
"exit on signal" behavior (per Node docs: "its default behavior will
be removed — Node.js will no longer exit"). Since the registered
listener doesn't call process.exit(), the `ao start` process would
hang on SIGTERM with the setInterval timer keeping the event loop
alive forever — effectively breaking `ao stop`.
Default signal handling terminates the process cleanly; the OS
reclaims the interval timer and dashboard child. `stopAllLifecycleWorkers`
stays exported for callers that want explicit cleanup before exit.
* fix(cli): project-scoped spawn warning + flush lifecycle health on exit
Addresses reviewer feedback on PR #1186:
- `warnIfAONotRunning` now takes a projectId and warns not only when no
AO is running, but also when the running instance isn't polling the
target project (e.g. `ao start A` then `ao spawn` in B left users
silent about the fact that B wasn't being polled).
- `running.json` now records only the project this `ao start` actually
polls, not every project in config. Previously this list was a lie —
`ensureLifecycleWorker` is called for the selected project only.
- `ao start` installs SIGINT/SIGTERM handlers that call
`stopAllLifecycleWorkers()` (flushing per-project "stopped" health
state) and then `process.exit()`. Installing the handler safely
requires an explicit exit because SIGINT/SIGTERM listeners remove
Node's default exit behavior.
* fix(cli): remove dead stopLifecycleWorker, add missing test mock
Addresses bugbot comments on PR #1186:
- Delete `stopLifecycleWorker` from `lifecycle-service.ts`. It was only
called from `ao stop` in the old subprocess model; in the in-process
model, SIGTERM to the `ao start` pid + the shutdown handler in
`start.ts` covers cleanup. No production caller remains.
- Add `stopAllLifecycleWorkers: vi.fn()` to `start.test.ts`'s
`lifecycle-service.js` mock. Without it, `vi.mock` replaced the module
and the named import resolved to undefined; the shutdown handler's
try/catch would silently swallow the resulting TypeError, hiding any
regression in how shutdown is wired.
- Update `lifecycle-service.test.ts` to drop references to the removed
export (one test removed, one repurposed as a no-op smoke test for
`stopAllLifecycleWorkers` against an empty active map).
* feat(cli): install-aware update command, startup notifier, and doctor version check
Make `ao update` detect whether AO was installed via npm or git source and
route accordingly: git installs use the existing shell script, npm installs
prompt to run `npm install -g @aoagents/ao@latest`, and unknown installs
print guidance. Add `--check` flag for JSON version info output.
Add a startup version notifier that reads a cached update check (no network
on startup) and prints a one-liner to stderr when outdated. Background
cache refresh runs after command completion via unref'd timer.
Add a version freshness check to `ao doctor` using cached data only (no
network dependency in diagnostics).
Closes#1136
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* test(cli): high coverage for update service, command routing, and notifier
Add comprehensive tests per maintainer request for full coverage of all
update paths:
update-check.ts (57 tests):
- isVersionOutdated: major/minor/patch comparisons, equal, newer, missing parts
- detectInstallMethod: all 4 scenarios (git, npm-global, partial, unknown)
- readCachedUpdateInfo: fresh, expired, 23h boundary, version mismatch,
invalid JSON, missing fields, empty file
- fetchLatestVersion: success, 404, 500, network error, timeout, non-JSON,
missing version, non-string version, AbortSignal presence
- checkForUpdate: cache hit, cache bypass with force, stale cache fetch,
cache write on success, no cache write on failure, version match,
unreachable registry, result shape
- maybeShowUpdateNotice: positive print case, not outdated, no cache,
non-TTY, AO_NO_UPDATE_NOTIFIER, CI, AGENT_ORCHESTRATOR_CI,
each skipArg (update/doctor/--version/-V/--help/-h)
- scheduleBackgroundRefresh: does not throw
update.ts (17 tests):
- conflicting flags rejection
- --check: valid JSON output, forces fresh fetch, no side effects
- git: default args, --skip-smoke, --smoke-only, cache invalidation
- npm-global: no script-runner, already up-to-date, registry unreachable
exits non-zero, --skip-smoke warning, forces fresh fetch
- unknown: help message, shows latest version, registry unreachable,
suggests npm command
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(cli): address all review feedback — coverage gaps, code fixes, Codex findings
Code fixes:
- Extract classifyInstallPath() for testable npm-global detection
- Convert IS_TTY from module-level constant to isTTY() function call
(testable without import-time mocking)
- Fix non-TTY npm update silently exiting 0 → now exits 1 so scripts
detect that no upgrade happened (Codex P2)
- Handle pre-release version tags in isVersionOutdated() — strips
suffixes before comparing, handles NaN safely
- Export getCacheDir() and writeCache() for test coverage
Test coverage (69 + 24 + 9 + 3 = 105 tests):
- classifyInstallPath: npm-global (Unix + Windows paths), git, partial, unknown
- isVersionOutdated: pre-release tags, NaN handling, same-with-prerelease
- getCacheDir: XDG_CACHE_HOME override, fallback to ~/.cache
- writeCache: success, EACCES mkdir failure, ENOSPC write failure
- handleNpmUpdate full flow: confirm → spawn success, spawn failure with
sudo suggestion, spawn error (ENOENT), non-TTY exit, flag warning,
user decline, registry unreachable
- --check with unreachable registry still outputs valid JSON
- maybeShowUpdateNotice: happy path print, AGENT_ORCHESTRATOR_CI guard,
each skipArg individually
- scheduleBackgroundRefresh: no-throw, swallows errors
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(cli): move background refresh earlier, distinguish local vs global node_modules
- Move scheduleBackgroundRefresh() before parseAsync() so the fetch runs
in parallel with command execution. Short-lived commands now have a
better chance of seeding the cache before exit. (Codex P2)
- Distinguish global npm installs (lib/node_modules, .pnpm store) from
local project node_modules (npx, linked installs). Local installs now
classify as "unknown" instead of "npm-global" to avoid suggesting
npm install -g to npx users. (Codex P2)
- Add tests: pnpm global store path, nvm global path, npx local path
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(lint): remove forbidden import() type annotations in test mocks
Replace `vi.importActual<typeof import("node:fs")>(...)` with untyped
`vi.importActual(...)` to satisfy @typescript-eslint/consistent-type-imports.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(cli): don't misclassify local pnpm installs as npm-global
Local pnpm projects have node_modules/.pnpm/ which matched the /.pnpm/
heuristic. Replace with /pnpm/global/ check which only matches pnpm's
actual global store path (~/.local/share/pnpm/global/...).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(cli): non-TTY exits 0, remove misleading sudo suggestion
- Non-TTY ao update now exits 0 after printing the command. Exit 1
implied error but the user just needs to run the command manually.
- Remove blanket sudo suggestion on npm failure. npm can exit 1 for
many reasons (network, version conflict, engine mismatch). Just
print the exit code and let the user diagnose.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(cli): pnpm global support, defer background refresh after parseAsync
- Detect pnpm global installs separately from npm global. pnpm users
now see `pnpm add -g @aoagents/ao@latest` instead of the npm command.
New InstallMethod value: "pnpm-global".
- Move scheduleBackgroundRefresh() to .then() after parseAsync() completes.
Prevents in-flight fetch from holding the event loop open during
short-lived commands when the registry is slow/offline. (Codex P2)
- Use info.recommendedCommand instead of hardcoded npm string in
handleNpmUpdate, so the correct package manager command is always used.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(cli): use actual command name in error message, not hardcoded "npm"
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(cli): handle signaled npm update exits
* fix(cli): detect prereleases behind stable releases
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* chore: release 0.2.5
Realign main with npm registry after off-branch publish of 0.2.3/0.2.4.
Bump all 21 linked packages to 0.2.5 and cherry-pick the startup-grace-period
fix for #989 (was in 5e4244a8 but never merged to main).
Also sync non-linked plugin versions (notifier-discord, notifier-openclaw,
scm-gitlab, tracker-gitlab) to their current npm versions.
* Revert "chore: release 0.2.5"
This reverts commit eb17f32834.
* chore: bump all package versions to 0.2.5, remove release workflow
- Bump all 25 packages to 0.2.5 to realign with npm registry
- Update package-version test to expect 0.2.5
- Remove stale .changeset/linear-spawn-branch-name.md
- Delete .github/workflows/release.yml (changesets-based NPM publish)
---------
Co-authored-by: Prateek <karnalprateek@gmail.com>
Co-authored-by: AO Bot <ao-bot@composio.dev>
- Merge main to pick up @composio/ao-core → @aoagents/ao-core rename
and decomposer removal (#1104)
- Fix @composio/ao-core import in prompt-spawn.test.ts → @aoagents/ao-core
- Remove unreachable dead code in CLI spawn (empty-string guard after || undefined)
- Update format.ts JSDoc to document 8-item fallback chain including userPrompt
- Add clarifying comment on validation/sanitization separation in spawn route
- Integrate userPrompt display into redesigned SessionCard footer
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Fix test flakiness: Use configurable property descriptor for process.platform
mocking to prevent TypeError on property redefinition
- Fix spawn detection: Return null when caffeinate fails to spawn (child.pid
undefined) instead of returning handle that does nothing
- Fix TypeScript: Make power config optional in OrchestratorConfig interface
to avoid breaking existing consumers (populated post-validation)
- Add test for spawn failure case
Co-Authored-By: Claude <noreply@anthropic.com>
Orchestrator sessions are control-plane sessions that clutter the ls
output alongside worker sessions. Filter them out using the existing
`isOrchestratorSessionName()` helper. Add `--all` / `-a` flag to
include them when needed.
Closes#1088
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add idle sleep prevention on macOS using `caffeinate -i -w <pid>` to keep
the Mac awake while AO is running, enabling remote dashboard access (e.g.,
via Tailscale) without the machine going to sleep.
Changes:
- Add `preventIdleSleep()` helper in packages/cli/src/lib/prevent-sleep.ts
- Add `power.preventIdleSleep` config option (defaults to true on macOS)
- Wire sleep prevention into `ao start` command
- Add tests for the helper function and config validation
- Document the feature in README and example config
Note: Lid-close sleep is enforced by macOS hardware and cannot be prevented
by userspace assertions. Use clamshell mode for that use case.
Closes#1072
Co-Authored-By: Claude <noreply@anthropic.com>
writeMetadata() used a hard-coded field whitelist that silently dropped
userPrompt. The in-memory Session.metadata also never included it, so
the spawn response always returned null. Additionally, prompts containing
newlines could inject arbitrary key=value pairs into the flat-file
metadata format.
Fixes:
- Add userPrompt to writeMetadata() whitelist and readMetadata() return
- Populate session.metadata with userPrompt during spawn
- Strip newlines from prompts in both web API and CLI (defense-in-depth)
- Harden serializeMetadata() to replace newlines in all values
- Add prompt length validation (4096 max) in CLI to match web API
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The --decompose flag and supporting decomposer module had two
unfixable bugs (#1045): it crashed when ANTHROPIC_API_KEY was unset,
and it created multiple branches/PRs per issue, fragmenting history
and complicating review/merge. Removing the feature instead of
patching either bug.
- Delete packages/core/src/decomposer.ts and all re-exports
- Drop @anthropic-ai/sdk dependency from @composio/ao-core
- Remove --decompose / --max-depth options from ao spawn
- Remove decomposer field from ProjectConfig (zod default-strip
silently ignores existing decomposer: blocks in user yaml)
- Remove lineage / siblings from SessionSpawnConfig and the
prompt-builder Layer 4 block (only used by decomposer)
- Gut the matching backlog reactor branch in web/services.ts
so the polling path no longer hits the same bugs
- Remove decompose parameter from openclaw-plugin ao_spawn tool
- Drop decomposer mocks from web services.test.ts
1. Add symlink check for .cursor directory in extractCursorSummary
to match getCursorSessionMtime behavior (prevents path traversal)
2. Add vitest alias for @aoagents/ao-plugin-agent-cursor in CLI tests
(fixes missing module resolution in tests)
3. Add lstatSync check before readFileSync in getLaunchCommand
to reject symlinked systemPromptFile paths (security hardening)
4. Add test coverage for symlink rejection behavior
Co-Authored-By: Claude <noreply@anthropic.com>
- Resolve @composio → @aoagents package renaming conflicts
- Add cursor agent to BUILTIN_PLUGINS in plugin-registry.ts
- Add cursor agent to AGENT_PLUGINS in detect-agent.ts
- Add cursor agent import and registration in plugins.ts
- Add cursor agent dependency and import in web services.ts
- Update cursor plugin package naming to @aoagents/ao-plugin-agent-cursor
- Add cursor agent to changeset linked group
- Fix test imports to use new @aoagents package naming
Co-Authored-By: Claude <noreply@anthropic.com>
Renames all npm package scopes from @composio/* to @aoagents/* and
updates GitHub repo references from ComposioHQ/agent-orchestrator
to aoagents/ao throughout the codebase.
- All package.json names and dependencies
- README badges, links, and install instructions
- Documentation references
- Changeset config
- Source code imports and test files
- Add --prompt <text> flag to `ao spawn` CLI
- Accept prompt field in POST /api/spawn web route
- Persist userPrompt to session metadata on spawn
- Add userPrompt to DashboardSession type and serialize.ts mapping
- Show userPrompt in SessionCard footer for prompt-only sessions
- Include userPrompt in SessionDetail headline fallback chain
- Update orchestrator-prompt.ts with prompt-driven spawn examples
Closes#974
Remove the post-startup "Next step: ao spawn <issue-number>" hint
per reviewer feedback — the dashboard already guides users.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace hardcoded magic number 3000 across spawn.ts, session.ts,
open.ts, dashboard.ts, and start.ts with a shared DEFAULT_PORT
constant from lib/constants.ts. Fixes Bugbot review feedback.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add mockClear() before mockReturnThis() in spawn test beforeEach to
prevent call history accumulating across tests. Fixes Bugbot review
feedback about stale mock.calls.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When dashboard is disabled, print `ao session attach <id>` instead of
an unreachable dashboard URL. Fixes Bugbot review feedback.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace raw `tmux attach -t` commands in CLI output with web dashboard
URLs (`http://localhost:{port}/sessions/{sessionId}`) across all four
CLI commands: spawn, session restore, open, and start.
Add `stripHashPrefix` helper in session-utils to extract AO session IDs
from hash-prefixed tmux session names. Remove unused `tmuxTarget`
variable from start command.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Address review feedback:
- Remove duplicate created/spawned messages — use single spinner.succeed
- Add "(if running)" qualifier for dashboard mention since ao spawn
does not start the dashboard itself
- Update test mock to expose spinner for output assertions
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace verbose multi-line spawn output (worktree, branch, raw tmux
attach) with a single-line message that directs users to the dashboard
or `ao session attach <id>` instead of exposing internal tmux session
hashes.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When dashboard is enabled, `ao start` now prints the session detail page
URL (e.g. http://localhost:3000/sessions/<id>) instead of the tmux attach
command. Falls back to tmux attach when --no-dashboard is used.
Closes#947
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>