From 3a6972294075d1865c59422d0309a8b3ad41d698 Mon Sep 17 00:00:00 2001 From: i-trytoohard Date: Tue, 5 May 2026 18:43:55 +0530 Subject: [PATCH 01/15] chore(cli): remove deprecated 'ao init' command (#1438) * chore(cli): remove deprecated 'ao init' command Delete the `init` command and its deprecation shim. `ao start` already auto-creates the config on first run in an unconfigured repo, so the separate entry point is redundant. - Remove `packages/cli/src/commands/init.ts` and its test. - Remove `registerInit` call from the CLI program. - Drop `createConfigOnly()` from start.ts (only the init shim used it); export `autoCreateConfig` so the existing default-config test can invoke it directly. - Update user-facing "Run `ao init` first" messages in `verify`/`status` to point to `ao start`. - Refresh stale `ao init` references in ao-doctor, onboarding test, openclaw setup doc, and the config/types doc comments. Closes #1420 * docs: remove ao init website docs Agent-Logs-Url: https://github.com/ComposioHQ/agent-orchestrator/sessions/41663963-ca49-4673-982f-ca10dee68d31 Co-authored-by: miniMaddy <77185670+miniMaddy@users.noreply.github.com> --------- Co-authored-by: Prateek Co-authored-by: iamasx Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: miniMaddy <77185670+miniMaddy@users.noreply.github.com> --- .changeset/remove-deprecated-ao-init.md | 5 ++++ docs/openclaw-plugin-setup.md | 2 +- packages/cli/__tests__/commands/init.test.ts | 28 ------------------- packages/cli/__tests__/commands/start.test.ts | 4 +-- packages/cli/src/assets/scripts/ao-doctor.sh | 2 +- packages/cli/src/commands/init.ts | 18 ------------ packages/cli/src/commands/start.ts | 10 +------ packages/cli/src/commands/status.ts | 2 +- packages/cli/src/commands/verify.ts | 4 +-- packages/cli/src/lib/web-dir.ts | 1 - packages/cli/src/program.ts | 2 -- packages/core/src/config.ts | 2 +- packages/core/src/types.ts | 4 +-- tests/integration/onboarding-test.sh | 2 +- website/content/docs/cli.mdx | 6 ---- website/content/docs/migration.mdx | 2 +- 16 files changed, 18 insertions(+), 76 deletions(-) create mode 100644 .changeset/remove-deprecated-ao-init.md delete mode 100644 packages/cli/__tests__/commands/init.test.ts delete mode 100644 packages/cli/src/commands/init.ts diff --git a/.changeset/remove-deprecated-ao-init.md b/.changeset/remove-deprecated-ao-init.md new file mode 100644 index 000000000..f1d50b6d6 --- /dev/null +++ b/.changeset/remove-deprecated-ao-init.md @@ -0,0 +1,5 @@ +--- +"@aoagents/ao-cli": minor +--- + +Remove the deprecated `ao init` command. Use `ao start` instead — it auto-creates the config on first run in an unconfigured repo. diff --git a/docs/openclaw-plugin-setup.md b/docs/openclaw-plugin-setup.md index 08010712a..223da0103 100644 --- a/docs/openclaw-plugin-setup.md +++ b/docs/openclaw-plugin-setup.md @@ -5,7 +5,7 @@ How to set up the Agent Orchestrator (AO) plugin for OpenClaw so the AI bot dele ## Prerequisites - [OpenClaw](https://openclaw.ai) installed and running -- [Agent Orchestrator](https://github.com/ComposioHQ/agent-orchestrator) installed with `ao init` completed in your repo +- [Agent Orchestrator](https://github.com/ComposioHQ/agent-orchestrator) installed with `ao start` completed in your repo - `ao`, `gh`, `tmux`, and `node` available in PATH - GitHub CLI (`gh`) authenticated diff --git a/packages/cli/__tests__/commands/init.test.ts b/packages/cli/__tests__/commands/init.test.ts deleted file mode 100644 index ce724a1f5..000000000 --- a/packages/cli/__tests__/commands/init.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { describe, it, expect } from "vitest"; - -import { Command } from "commander"; -import { registerInit } from "../../src/commands/init.js"; - -describe("init command", () => { - it("registers as a deprecated command", () => { - const program = new Command(); - registerInit(program); - - const initCmd = program.commands.find((c) => c.name() === "init"); - expect(initCmd).toBeDefined(); - expect(initCmd!.description()).toContain("deprecated"); - }); - - it("has no --output, --auto, or --smart flags", () => { - const program = new Command(); - registerInit(program); - - const initCmd = program.commands.find((c) => c.name() === "init"); - expect(initCmd).toBeDefined(); - - const optionNames = initCmd!.options.map((o) => o.long); - expect(optionNames).not.toContain("--output"); - expect(optionNames).not.toContain("--auto"); - expect(optionNames).not.toContain("--smart"); - }); -}); diff --git a/packages/cli/__tests__/commands/start.test.ts b/packages/cli/__tests__/commands/start.test.ts index aec78b024..38261ab72 100644 --- a/packages/cli/__tests__/commands/start.test.ts +++ b/packages/cli/__tests__/commands/start.test.ts @@ -260,7 +260,7 @@ vi.mock("node:process", async (importOriginal) => { // --------------------------------------------------------------------------- import { Command } from "commander"; -import { registerStart, registerStop, createConfigOnly } from "../../src/commands/start.js"; +import { registerStart, registerStop, autoCreateConfig } from "../../src/commands/start.js"; let tmpDir: string; let program: Command; @@ -2133,7 +2133,7 @@ describe("start command — autoCreateConfig", () => { const callerContext = await import("../../src/lib/caller-context.js"); vi.spyOn(callerContext, "isHumanCaller").mockReturnValue(false); - await createConfigOnly(); + await autoCreateConfig(tmpDir); const configPath = join(tmpDir, "agent-orchestrator.yaml"); expect(existsSync(configPath)).toBe(true); diff --git a/packages/cli/src/assets/scripts/ao-doctor.sh b/packages/cli/src/assets/scripts/ao-doctor.sh index 825cef424..bd3fd8895 100755 --- a/packages/cli/src/assets/scripts/ao-doctor.sh +++ b/packages/cli/src/assets/scripts/ao-doctor.sh @@ -349,7 +349,7 @@ check_config_dirs() { local config_path data_dir worktree_dir config_path="$(find_config || true)" if [ -z "$config_path" ]; then - warn "No agent-orchestrator config was found. Fix: run ao init --auto in a target repo" + warn "No agent-orchestrator config was found. Fix: run ao start in a target repo" return fi diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts deleted file mode 100644 index d4ddf77dc..000000000 --- a/packages/cli/src/commands/init.ts +++ /dev/null @@ -1,18 +0,0 @@ -import chalk from "chalk"; -import type { Command } from "commander"; - -export function registerInit(program: Command): void { - program - .command("init") - .description("[deprecated] Use 'ao start' instead — it auto-creates config on first run") - .action(async () => { - console.log( - chalk.yellow( - "⚠ 'ao init' is deprecated. Use 'ao start' instead.\n" + - " 'ao start' auto-detects your project and creates the config.\n", - ), - ); - const { createConfigOnly } = await import("./start.js"); - await createConfigOnly(); - }); -} diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index 1bc2afe10..bd4a36545 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -464,7 +464,7 @@ async function cloneRepo(parsed: ParsedRepoUrl, targetDir: string, cwd: string): * Detects environment, project type, and generates config with smart defaults. * Returns the loaded config. */ -async function autoCreateConfig(workingDir: string): Promise { +export async function autoCreateConfig(workingDir: string): Promise { console.log(chalk.bold.cyan("\n Agent Orchestrator — First Run Setup\n")); console.log(chalk.dim(" Detecting project and generating config...\n")); @@ -760,14 +760,6 @@ async function addProjectToConfig( return projectId; } -/** - * Create config without starting dashboard/orchestrator. - * Used by deprecated `ao init` wrapper. - */ -export async function createConfigOnly(): Promise { - await autoCreateConfig(cwd()); -} - /** * Start dashboard server in the background. * Returns the child process handle for cleanup. diff --git a/packages/cli/src/commands/status.ts b/packages/cli/src/commands/status.ts index 912bb948f..293f18ce1 100644 --- a/packages/cli/src/commands/status.ts +++ b/packages/cli/src/commands/status.ts @@ -348,7 +348,7 @@ export function registerStatus(program: Command): void { try { config = loadConfig(); } catch { - console.log(chalk.yellow("No config found. Run `ao init` first.")); + console.log(chalk.yellow("No config found. Run `ao start` first.")); console.log(chalk.dim("Falling back to session discovery...\n")); await showFallbackStatus(); return; diff --git a/packages/cli/src/commands/verify.ts b/packages/cli/src/commands/verify.ts index ed63fc1ad..c3bfcce1a 100644 --- a/packages/cli/src/commands/verify.ts +++ b/packages/cli/src/commands/verify.ts @@ -29,7 +29,7 @@ function resolveProject( const ids = Object.keys(config.projects); if (ids.length === 0) { - console.error(chalk.red("No projects configured. Run `ao init` first.")); + console.error(chalk.red("No projects configured. Run `ao start` first.")); process.exit(1); } if (ids.length > 1) { @@ -91,7 +91,7 @@ export function registerVerify(program: Command): void { try { config = loadConfig(); } catch { - console.error(chalk.red("No config found. Run `ao init` first.")); + console.error(chalk.red("No config found. Run `ao start` first.")); process.exit(1); return; } diff --git a/packages/cli/src/lib/web-dir.ts b/packages/cli/src/lib/web-dir.ts index 36c2a7f0a..6f1aaa51e 100644 --- a/packages/cli/src/lib/web-dir.ts +++ b/packages/cli/src/lib/web-dir.ts @@ -44,7 +44,6 @@ export const MAX_PORT_SCAN = 100; /** * Find the first available port starting from `start`, scanning upward. * Returns `null` if no free port is found within `maxScan` attempts. - * Shared between `ao init` and `ao start `. */ export async function findFreePort(start: number, maxScan = MAX_PORT_SCAN): Promise { for (let port = start; port < start + maxScan; port++) { diff --git a/packages/cli/src/program.ts b/packages/cli/src/program.ts index 5bf953159..12ffb0858 100644 --- a/packages/cli/src/program.ts +++ b/packages/cli/src/program.ts @@ -1,5 +1,4 @@ import { Command } from "commander"; -import { registerInit } from "./commands/init.js"; import { registerStatus } from "./commands/status.js"; import { registerSpawn, registerBatchSpawn } from "./commands/spawn.js"; import { registerSession } from "./commands/session.js"; @@ -29,7 +28,6 @@ export function createProgram(): Command { .description("Agent Orchestrator — manage parallel AI coding agents") .version(getCliVersion()); - registerInit(program); registerStart(program); registerStop(program); registerStatus(program); diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 57a236bf5..38a43a02e 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -995,7 +995,7 @@ export function validateConfig(raw: unknown): OrchestratorConfig { return config; } -/** Get the default config (useful for `ao init`) */ +/** Get the default config (useful for first-run setup) */ export function getDefaultConfig(): OrchestratorConfig { return validateConfig({ projects: {}, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 016856407..eac1bbd64 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -522,7 +522,7 @@ export interface Agent { /** * Optional: Set up agent-specific hooks/config in the workspace for automatic metadata updates. - * Called once per workspace during ao init/start and when creating new worktrees. + * Called once per workspace during ao start and when creating new worktrees. * * Each agent plugin implements this for their own config format: * - Claude Code: writes .claude/settings.json with PostToolUse hook @@ -603,7 +603,7 @@ export interface AgentLaunchConfig { export interface WorkspaceHooksConfig { /** Data directory where session metadata files are stored */ dataDir: string; - /** Optional session ID (may not be known at ao init time) */ + /** Optional session ID (may not be known at workspace setup time) */ sessionId?: string; } diff --git a/tests/integration/onboarding-test.sh b/tests/integration/onboarding-test.sh index f659feeaf..5dde5368d 100755 --- a/tests/integration/onboarding-test.sh +++ b/tests/integration/onboarding-test.sh @@ -81,7 +81,7 @@ end_step "Step 4: Configuration created" # Step 5: Verify config is valid start_step "Step 5: Validate configuration" -# ao init would fail if run again, so we just verify the file is readable +# Verify the config file is readable if [ ! -f agent-orchestrator.yaml ]; then fail_step "Step 5: Config file not found" fi diff --git a/website/content/docs/cli.mdx b/website/content/docs/cli.mdx index 2e3f755d4..4176b1c25 100644 --- a/website/content/docs/cli.mdx +++ b/website/content/docs/cli.mdx @@ -184,12 +184,6 @@ Behavior depends on how AO was installed — git, npm-global, pnpm-global, or un No flags. -### `ao init` (deprecated) - -> Use `ao start` instead — it auto-creates config on first run. - -Runs the same config-only bootstrap; kept for backward compatibility. - ## Session subcommands ### `ao session ls` diff --git a/website/content/docs/migration.mdx b/website/content/docs/migration.mdx index 9b97ba453..8cd2f302b 100644 --- a/website/content/docs/migration.mdx +++ b/website/content/docs/migration.mdx @@ -36,7 +36,7 @@ ao spawn 42 ## `ao init` → `ao start` -`ao init` is deprecated — `ao start` auto-creates `agent-orchestrator.yaml` on first run and opens the dashboard in one step. `ao init` still works and prints a migration hint. +`ao init` has been removed. Use `ao start` instead; it auto-creates `agent-orchestrator.yaml` on first run and opens the dashboard in one step. ## Upgrading From ea320312dbb5afeabe9086a40afad1d3801dec6c Mon Sep 17 00:00:00 2001 From: i-trytoohard Date: Tue, 5 May 2026 18:58:53 +0530 Subject: [PATCH 02/15] refactor(core): extract hasRecentCommits helper into @aoagents/ao-core (#1437) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(core): extract hasRecentCommits helper into @aoagents/ao-core Deduplicate the byte-identical hasRecentCommits(workspacePath) helper that was copy-pasted between agent-aider and agent-cursor. Exposes the helper from core with a parameterized window so future agent plugins using git-commit-based activity detection can share it. Closes #1423 * test(core): make hasRecentCommits custom-window test discriminate on the parameter The previous assertion passed the default (60) to hasRecentCommits, so a bug where windowSeconds was silently ignored would still have passed. Use a commit backdated ~2 minutes and assert both a 30s window excludes it and a 600s window includes it — proving the parameter is forwarded to `git log --since=...`. Addresses Greptile review on #1437. --------- Co-authored-by: Prateek --- .../core/src/__tests__/git-activity.test.ts | 64 +++++++++++++++++++ packages/core/src/git-activity.ts | 34 ++++++++++ packages/core/src/index.ts | 3 + packages/plugins/agent-aider/src/index.ts | 17 +---- packages/plugins/agent-cursor/src/index.ts | 17 +---- 5 files changed, 103 insertions(+), 32 deletions(-) create mode 100644 packages/core/src/__tests__/git-activity.test.ts create mode 100644 packages/core/src/git-activity.ts diff --git a/packages/core/src/__tests__/git-activity.test.ts b/packages/core/src/__tests__/git-activity.test.ts new file mode 100644 index 000000000..924cdf6c3 --- /dev/null +++ b/packages/core/src/__tests__/git-activity.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { execFileSync } from "node:child_process"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { hasRecentCommits } from "../git-activity.js"; + +describe("hasRecentCommits", () => { + let repoDir: string; + + beforeEach(() => { + repoDir = mkdtempSync(join(tmpdir(), "ao-git-activity-")); + execFileSync("git", ["init", "-q", "-b", "main"], { cwd: repoDir }); + execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repoDir }); + execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir }); + execFileSync("git", ["config", "commit.gpgsign", "false"], { cwd: repoDir }); + }); + + afterEach(() => { + if (repoDir) rmSync(repoDir, { recursive: true, force: true }); + }); + + it("returns true when a commit exists within the default 60s window", async () => { + await writeFile(join(repoDir, "a.txt"), "hello"); + execFileSync("git", ["add", "."], { cwd: repoDir }); + execFileSync("git", ["commit", "-q", "-m", "initial"], { cwd: repoDir }); + + expect(await hasRecentCommits(repoDir)).toBe(true); + }); + + it("returns false when no commits have been made", async () => { + expect(await hasRecentCommits(repoDir)).toBe(false); + }); + + it("returns false when the path is not a git repo", async () => { + const notARepo = mkdtempSync(join(tmpdir(), "ao-git-activity-notrepo-")); + try { + expect(await hasRecentCommits(notARepo)).toBe(false); + } finally { + rmSync(notARepo, { recursive: true, force: true }); + } + }); + + it("respects a custom window — excludes commits outside it, includes them inside it", async () => { + await writeFile(join(repoDir, "a.txt"), "hello"); + execFileSync("git", ["add", "."], { cwd: repoDir }); + // Backdate the commit by ~2 minutes so a 30s window excludes it but a + // 10-minute window includes it — this discriminates between "parameter + // forwarded" and "parameter silently ignored / hardcoded". + const twoMinAgo = new Date(Date.now() - 120_000).toISOString(); + execFileSync("git", ["commit", "-q", "-m", "two-min-ago"], { + cwd: repoDir, + env: { + ...process.env, + GIT_AUTHOR_DATE: twoMinAgo, + GIT_COMMITTER_DATE: twoMinAgo, + }, + }); + + expect(await hasRecentCommits(repoDir, 30)).toBe(false); + expect(await hasRecentCommits(repoDir, 600)).toBe(true); + }); +}); diff --git a/packages/core/src/git-activity.ts b/packages/core/src/git-activity.ts new file mode 100644 index 000000000..a29bbbb43 --- /dev/null +++ b/packages/core/src/git-activity.ts @@ -0,0 +1,34 @@ +/** + * Git-commit-based activity detection helpers for agent plugins. + * + * Agents without native JSONL introspection (Aider, Cursor, etc.) can use + * recent git commits as a signal that the agent has been actively working. + */ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +/** + * Check whether the given workspace has any git commits within the last + * `windowSeconds` seconds. Swallows errors (e.g. not a git repo, git missing) + * and returns `false` so callers can use this as a best-effort liveness signal. + * + * @param workspacePath Absolute path to the workspace (must be a git repo). + * @param windowSeconds How far back to look for commits. Defaults to 60s. + */ +export async function hasRecentCommits( + workspacePath: string, + windowSeconds = 60, +): Promise { + try { + const { stdout } = await execFileAsync( + "git", + ["log", `--since=${windowSeconds} seconds ago`, "--format=%H"], + { cwd: workspacePath, timeout: 5_000 }, + ); + return stdout.trim().length > 0; + } catch { + return false; + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index fd391ed52..0785091ec 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -204,6 +204,9 @@ export { buildAgentPath, PREFERRED_GH_PATH, } from "./agent-workspace-hooks.js"; + +// Git-based activity helpers — recent-commit liveness signal for agent plugins +export { hasRecentCommits } from "./git-activity.js"; export type { NormalizedOrchestratorSessionStrategy } from "./orchestrator-session-strategy.js"; export { diff --git a/packages/plugins/agent-aider/src/index.ts b/packages/plugins/agent-aider/src/index.ts index 87388dfda..be455acd6 100644 --- a/packages/plugins/agent-aider/src/index.ts +++ b/packages/plugins/agent-aider/src/index.ts @@ -5,6 +5,7 @@ import { checkActivityLogState, getActivityFallbackState, recordTerminalActivity, + hasRecentCommits, DEFAULT_READY_THRESHOLD_MS, DEFAULT_ACTIVE_WINDOW_MS, type Agent, @@ -29,22 +30,6 @@ const execFileAsync = promisify(execFile); // Aider Activity Detection Helpers // ============================================================================= -/** - * Check if Aider has made recent commits (within last 60 seconds). - */ -async function hasRecentCommits(workspacePath: string): Promise { - try { - const { stdout } = await execFileAsync( - "git", - ["log", "--since=60 seconds ago", "--format=%H"], - { cwd: workspacePath, timeout: 5_000 }, - ); - return stdout.trim().length > 0; - } catch { - return false; - } -} - /** * Get modification time of Aider chat history file. */ diff --git a/packages/plugins/agent-cursor/src/index.ts b/packages/plugins/agent-cursor/src/index.ts index 2eb5fb56e..3f68f3a0d 100644 --- a/packages/plugins/agent-cursor/src/index.ts +++ b/packages/plugins/agent-cursor/src/index.ts @@ -5,6 +5,7 @@ import { checkActivityLogState, getActivityFallbackState, recordTerminalActivity, + hasRecentCommits, DEFAULT_READY_THRESHOLD_MS, DEFAULT_ACTIVE_WINDOW_MS, type Agent, @@ -30,22 +31,6 @@ const execFileAsync = promisify(execFile); // Cursor Activity Detection Helpers // ============================================================================= -/** - * Check if Cursor has made recent commits (within last 60 seconds). - */ -async function hasRecentCommits(workspacePath: string): Promise { - try { - const { stdout } = await execFileAsync( - "git", - ["log", "--since=60 seconds ago", "--format=%H"], - { cwd: workspacePath, timeout: 5_000 }, - ); - return stdout.trim().length > 0; - } catch { - return false; - } -} - /** * Get modification time of Cursor session file if it exists. * Cursor may create a .cursor directory with session data. From be061a398938aef0d72a5c31966c884e1f8b8b5d Mon Sep 17 00:00:00 2001 From: yyovil Date: Tue, 5 May 2026 20:34:00 +0530 Subject: [PATCH 03/15] fix(core): adopt orphaned orchestrator worktrees (#1643) * fix(core): adopt orphaned orchestrator worktrees (#1641) * fix: address review feedback on worktree adoption - Normalize CRLF line endings in parseWorktreeList for cross-platform support - Collapse duplicate classifySpawnError payload blocks into single condition - Filter prunable/deleted worktree entries in findManagedWorkspace - Add GIT_TIMEOUT to git() helper for all execFileAsync calls - Add tests for prunable entries and CRLF parsing * fix: update test assertions for git() helper timeout All git() calls now pass timeout: GIT_TIMEOUT to execFileAsync. Update toHaveBeenCalledWith assertions to include the new option. postCreate sh -c calls remain unchanged (direct execFileAsync). * fix: mock existsSync in findManagedWorkspace tests The existsSync(entry.path) filter added for prunable worktree detection needs existsSync to return true for valid worktree paths in adoption tests. * fix: use mockReturnValueOnce to prevent existsSync mock leaking vi.clearAllMocks() does not reset mockReturnValue, only mock history. Using mockReturnValueOnce ensures existsSync stubs don't leak to subsequent tests and cause clearStaleWorktreePath to consume git mocks. --------- Co-authored-by: AO Bot --- .../__tests__/session-manager/spawn.test.ts | 44 +++++ packages/core/src/__tests__/test-utils.ts | 1 + packages/core/src/session-manager.ts | 32 ++-- packages/core/src/types.ts | 6 + .../src/__tests__/index.test.ts | 155 ++++++++++++++++-- .../plugins/workspace-worktree/src/index.ts | 70 +++++++- packages/web/src/__tests__/api-routes.test.ts | 24 ++- .../web/src/app/api/orchestrators/route.ts | 14 +- 8 files changed, 311 insertions(+), 35 deletions(-) diff --git a/packages/core/src/__tests__/session-manager/spawn.test.ts b/packages/core/src/__tests__/session-manager/spawn.test.ts index e9915c546..9ffd7aefc 100644 --- a/packages/core/src/__tests__/session-manager/spawn.test.ts +++ b/packages/core/src/__tests__/session-manager/spawn.test.ts @@ -1567,6 +1567,29 @@ describe("spawn", () => { expect(session.branch).toBe("orchestrator/app-orchestrator"); }); + it("adopts an existing managed orchestrator worktree instead of creating a fresh one", async () => { + const adoptedPath = join(tmpDir, "legacy-orchestrator-ws"); + (mockWorkspace.findManagedWorkspace as ReturnType).mockResolvedValueOnce({ + path: adoptedPath, + branch: "orchestrator/app-orchestrator", + sessionId: "app-orchestrator", + projectId: "my-app", + }); + const sm = createSessionManager({ config, registry: mockRegistry }); + + const session = await sm.spawnOrchestrator({ projectId: "my-app" }); + + expect(session.workspacePath).toBe(adoptedPath); + expect(mockWorkspace.findManagedWorkspace).toHaveBeenCalledWith( + expect.objectContaining({ + projectId: "my-app", + sessionId: "app-orchestrator", + branch: "orchestrator/app-orchestrator", + }), + ); + expect(mockWorkspace.create).not.toHaveBeenCalled(); + }); + it("writes metadata with proper fields", async () => { const sm = createSessionManager({ config, registry: mockRegistry }); @@ -1732,6 +1755,27 @@ describe("spawn", () => { expect(readMetadataRaw(sessionsDir, "app-orchestrator")).toBeNull(); }); + it("keeps an adopted worktree in place when runtime creation fails", async () => { + const adoptedPath = join(tmpDir, "adopted-orchestrator-ws"); + (mockWorkspace.findManagedWorkspace as ReturnType).mockResolvedValueOnce({ + path: adoptedPath, + branch: "orchestrator/app-orchestrator", + sessionId: "app-orchestrator", + projectId: "my-app", + }); + (mockRuntime.create as ReturnType).mockRejectedValueOnce( + new Error("runtime creation failed"), + ); + const sm = createSessionManager({ config, registry: mockRegistry }); + + await expect(sm.spawnOrchestrator({ projectId: "my-app" })).rejects.toThrow( + "runtime creation failed", + ); + + expect(mockWorkspace.destroy).not.toHaveBeenCalled(); + expect(readMetadataRaw(sessionsDir, "app-orchestrator")).toBeNull(); + }); + it("destroys the worktree when post-launch setup fails", async () => { const worktreePath = join(tmpDir, "orchestrator-ws-postlaunch-fail"); (mockWorkspace.create as ReturnType).mockResolvedValueOnce({ diff --git a/packages/core/src/__tests__/test-utils.ts b/packages/core/src/__tests__/test-utils.ts index 71fdf440f..fbce3b0ed 100644 --- a/packages/core/src/__tests__/test-utils.ts +++ b/packages/core/src/__tests__/test-utils.ts @@ -173,6 +173,7 @@ export function createMockPlugins(): MockPlugins { }), destroy: vi.fn().mockResolvedValue(undefined), list: vi.fn().mockResolvedValue([]), + findManagedWorkspace: vi.fn().mockResolvedValue(null), }; return { runtime, agent, workspace }; diff --git a/packages/core/src/session-manager.ts b/packages/core/src/session-manager.ts index 5da9981cd..721eafef6 100644 --- a/packages/core/src/session-manager.ts +++ b/packages/core/src/session-manager.ts @@ -41,6 +41,7 @@ import { type Runtime, type Agent, type Workspace, + type WorkspaceCreateConfig, type Tracker, type SCM, type PluginRegistry, @@ -1521,16 +1522,21 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM ); } + const workspaceConfig = { + projectId: orchestratorConfig.projectId, + project, + sessionId, + branch, + worktreeDir: getProjectWorktreesDir(orchestratorConfig.projectId), + } satisfies WorkspaceCreateConfig; + let workspacePath: string; + let adoptedManagedWorkspace = false; try { - const wsInfo = await plugins.workspace.create({ - projectId: orchestratorConfig.projectId, - project, - sessionId, - branch, - worktreeDir: getProjectWorktreesDir(orchestratorConfig.projectId), - }); + const adoptedInfo = await plugins.workspace.findManagedWorkspace?.(workspaceConfig); + const wsInfo = adoptedInfo ?? (await plugins.workspace.create(workspaceConfig)); workspacePath = wsInfo.path; + adoptedManagedWorkspace = adoptedInfo !== undefined && adoptedInfo !== null; } catch (err) { try { deleteMetadata(sessionsDir, sessionId); @@ -1543,11 +1549,13 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM // Helper: undo worktree + metadata if anything between workspace creation // and a fully-written metadata record fails. const cleanupWorktreeAndMetadata = async (promptFile?: string): Promise => { - try { - // plugins.workspace is guaranteed non-null here: we threw above if it was null - await plugins.workspace!.destroy(workspacePath); - } catch { - /* best effort */ + if (!adoptedManagedWorkspace) { + try { + // plugins.workspace is guaranteed non-null here: we threw above if it was null + await plugins.workspace!.destroy(workspacePath); + } catch { + /* best effort */ + } } try { deleteMetadata(sessionsDir, sessionId); diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index eac1bbd64..e6c610d0e 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -645,6 +645,12 @@ export interface Workspace { /** List existing workspaces for a project */ list(projectId: string): Promise; + /** + * Optional: find a pre-existing AO-managed workspace that already tracks the + * requested branch and can be adopted instead of creating a fresh workspace. + */ + findManagedWorkspace?(config: WorkspaceCreateConfig): Promise; + /** Optional: run hooks after workspace creation (symlinks, installs, etc.) */ postCreate?(info: WorkspaceInfo, project: ProjectConfig): Promise; diff --git a/packages/plugins/workspace-worktree/src/__tests__/index.test.ts b/packages/plugins/workspace-worktree/src/__tests__/index.test.ts index bd562e956..42c85147c 100644 --- a/packages/plugins/workspace-worktree/src/__tests__/index.test.ts +++ b/packages/plugins/workspace-worktree/src/__tests__/index.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; -import type { ProjectConfig, WorkspaceCreateConfig, WorkspaceInfo } from "@aoagents/ao-core"; +import type { ProjectConfig, WorkspaceCreateConfig, WorkspaceInfo } from "@aoagents/ao-core/types"; // --------------------------------------------------------------------------- // Mocks — must be declared before any import that uses the mocked modules @@ -191,18 +191,20 @@ describe("workspace.create()", () => { // First call: git remote get-url origin expect(mockExecFileAsync).toHaveBeenCalledWith("git", ["remote", "get-url", "origin"], { cwd: "/repo/path", + timeout: 30_000, }); // Second call: git fetch origin --quiet expect(mockExecFileAsync).toHaveBeenCalledWith("git", ["fetch", "origin", "--quiet"], { cwd: "/repo/path", + timeout: 30_000, }); // Third call: git rev-parse --verify --quiet origin/main expect(mockExecFileAsync).toHaveBeenCalledWith( "git", ["rev-parse", "--verify", "--quiet", "origin/main"], - { cwd: "/repo/path" }, + { cwd: "/repo/path", timeout: 30_000 }, ); // Fourth call: git worktree add -b @@ -216,7 +218,7 @@ describe("workspace.create()", () => { "/mock-home/.worktrees/myproject/session-1", "origin/main", ], - { cwd: "/repo/path" }, + { cwd: "/repo/path", timeout: 30_000 }, ); }); @@ -254,7 +256,7 @@ describe("workspace.create()", () => { expect(mockExecFileAsync).toHaveBeenCalledWith( "git", ["worktree", "add", "-b", "feat/TEST-1", "/mock-home/.worktrees/myproject/session-1", "origin/main"], - { cwd: "/repo/path" }, + { cwd: "/repo/path", timeout: 30_000 }, ); }); @@ -272,6 +274,128 @@ describe("workspace.create()", () => { ); }); + it("finds an adoptable worktree in the project-scoped worktree directory", async () => { + const ws = create(); + + mockGitSuccess( + [ + "worktree /mock-home/.agent-orchestrator/projects/myproject/worktrees/session-1", + "HEAD deadbeef", + "branch refs/heads/feat/TEST-1", + ].join("\n"), + ); + mockExistsSync.mockReturnValueOnce(true); + + const info = await ws.findManagedWorkspace?.( + makeCreateConfig({ + worktreeDir: "/mock-home/.agent-orchestrator/projects/myproject/worktrees", + }), + ); + + expect(info).toEqual({ + path: "/mock-home/.agent-orchestrator/projects/myproject/worktrees/session-1", + branch: "feat/TEST-1", + sessionId: "session-1", + projectId: "myproject", + }); + }); + + it("finds an adoptable worktree in the legacy managed worktree directory", async () => { + const ws = create(); + + mockGitSuccess( + [ + "worktree /mock-home/.worktrees/myproject/session-1", + "HEAD deadbeef", + "branch refs/heads/feat/TEST-1", + ].join("\n"), + ); + mockExistsSync.mockReturnValueOnce(true); + + const info = await ws.findManagedWorkspace?.( + makeCreateConfig({ + worktreeDir: "/mock-home/.agent-orchestrator/projects/myproject/worktrees", + }), + ); + + expect(info?.path).toBe("/mock-home/.worktrees/myproject/session-1"); + }); + + it("returns null when no managed worktree tracks the requested branch", async () => { + const ws = create(); + + mockGitSuccess( + [ + "worktree /mock-home/.worktrees/myproject/session-2", + "HEAD deadbeef", + "branch refs/heads/feat/OTHER", + ].join("\n"), + ); + + const info = await ws.findManagedWorkspace?.(makeCreateConfig()); + + expect(info).toBeNull(); + }); + + it("throws when the matching branch is checked out outside AO-managed worktree directories", async () => { + const ws = create(); + + mockGitSuccess( + [ + "worktree /tmp/manual-worktree", + "HEAD deadbeef", + "branch refs/heads/feat/TEST-1", + ].join("\n"), + ); + mockExistsSync.mockReturnValueOnce(true); + + await expect(ws.findManagedWorkspace?.(makeCreateConfig())).rejects.toThrow( + 'outside AO-managed worktree directories', + ); + }); + + it("skips worktree entries whose path no longer exists on disk", async () => { + const ws = create(); + + // The worktree is listed by git but the directory was manually deleted + mockGitSuccess( + [ + "worktree /mock-home/.worktrees/myproject/session-1", + "HEAD deadbeef", + "branch refs/heads/feat/TEST-1", + ].join("\n"), + ); + // existsSync returns false for the deleted worktree path + mockExistsSync.mockReturnValueOnce(false); + + const info = await ws.findManagedWorkspace?.(makeCreateConfig()); + + expect(info).toBeNull(); + }); + + it("handles CRLF line endings in git worktree list output", async () => { + const ws = create(); + + // Simulate Windows git output with \r\n line endings + mockGitSuccess( + [ + "worktree /mock-home/.worktrees/myproject/session-1", + "HEAD deadbeef", + "branch refs/heads/feat/TEST-1", + ].join("\r\n"), + ); + mockExistsSync.mockReturnValueOnce(true); + + const info = await ws.findManagedWorkspace?.(makeCreateConfig()); + + expect(info).toEqual({ + path: "/mock-home/.worktrees/myproject/session-1", + branch: "feat/TEST-1", + sessionId: "session-1", + projectId: "myproject", + }); + }); + it("continues when fetch fails (offline)", async () => { const ws = create(); @@ -296,7 +420,7 @@ describe("workspace.create()", () => { expect(mockExecFileAsync).toHaveBeenCalledWith( "git", ["rev-parse", "--verify", "--quiet", "refs/heads/main"], - { cwd: "/repo/path" }, + { cwd: "/repo/path", timeout: 30_000 }, ); expect(mockExecFileAsync).toHaveBeenCalledWith( @@ -309,7 +433,7 @@ describe("workspace.create()", () => { "/mock-home/.worktrees/myproject/session-1", "refs/heads/main", ], - { cwd: "/repo/path" }, + { cwd: "/repo/path", timeout: 30_000 }, ); }); @@ -339,12 +463,13 @@ describe("workspace.create()", () => { expect(mockExecFileAsync).toHaveBeenCalledWith( "git", ["worktree", "add", "/mock-home/.worktrees/myproject/session-1", "origin/main"], - { cwd: "/repo/path" }, + { cwd: "/repo/path", timeout: 30_000 }, ); // Fourth call: checkout expect(mockExecFileAsync).toHaveBeenCalledWith("git", ["checkout", "feat/TEST-1"], { cwd: "/mock-home/.worktrees/myproject/session-1", + timeout: 30_000, }); expect(info.branch).toBe("feat/TEST-1"); @@ -364,11 +489,12 @@ describe("workspace.create()", () => { expect(mockExecFileAsync).toHaveBeenCalledWith( "git", ["worktree", "add", "/mock-home/.worktrees/myproject/session-1", "refs/heads/main"], - { cwd: "/repo/path" }, + { cwd: "/repo/path", timeout: 30_000 }, ); expect(mockExecFileAsync).toHaveBeenCalledWith("git", ["checkout", "feat/TEST-1"], { cwd: "/mock-home/.worktrees/myproject/session-1", + timeout: 30_000, }); expect(info.branch).toBe("feat/TEST-1"); @@ -392,7 +518,7 @@ describe("workspace.create()", () => { expect(mockExecFileAsync).toHaveBeenCalledWith( "git", ["worktree", "remove", "--force", "/mock-home/.worktrees/myproject/session-1"], - { cwd: "/repo/path" }, + { cwd: "/repo/path", timeout: 30_000 }, ); }); @@ -488,6 +614,7 @@ describe("workspace.create()", () => { // fetch should use expanded path expect(mockExecFileAsync).toHaveBeenCalledWith("git", ["fetch", "origin", "--quiet"], { cwd: "/mock-home/my-repo", + timeout: 30_000, }); }); @@ -510,7 +637,7 @@ describe("workspace.create()", () => { "/mock-home/.worktrees/myproject/session-1", "refs/heads/main", ], - { cwd: "/repo/path" }, + { cwd: "/repo/path", timeout: 30_000 }, ); }); }); @@ -537,7 +664,7 @@ describe("workspace.restore()", () => { "/mock-home/.worktrees/myproject/session-1", "origin/feat/TEST-1", ], - { cwd: "/repo/path" }, + { cwd: "/repo/path", timeout: 30_000 }, ); expect(info.branch).toBe("feat/TEST-1"); @@ -564,7 +691,7 @@ describe("workspace.restore()", () => { "/mock-home/.worktrees/myproject/session-1", "refs/heads/main", ], - { cwd: "/repo/path" }, + { cwd: "/repo/path", timeout: 30_000 }, ); expect(info).toEqual({ @@ -591,14 +718,14 @@ describe("workspace.destroy()", () => { expect(mockExecFileAsync).toHaveBeenCalledWith( "git", ["rev-parse", "--path-format=absolute", "--git-common-dir"], - { cwd: "/mock-home/.worktrees/myproject/session-1" }, + { cwd: "/mock-home/.worktrees/myproject/session-1", timeout: 30_000 }, ); // Second call: worktree remove expect(mockExecFileAsync).toHaveBeenCalledWith( "git", ["worktree", "remove", "--force", "/mock-home/.worktrees/myproject/session-1"], - { cwd: "/repo/path" }, + { cwd: "/repo/path", timeout: 30_000 }, ); }); diff --git a/packages/plugins/workspace-worktree/src/index.ts b/packages/plugins/workspace-worktree/src/index.ts index 2eb1fe52a..dc0c24b34 100644 --- a/packages/plugins/workspace-worktree/src/index.ts +++ b/packages/plugins/workspace-worktree/src/index.ts @@ -9,7 +9,7 @@ import type { WorkspaceCreateConfig, WorkspaceInfo, ProjectConfig, -} from "@aoagents/ao-core"; +} from "@aoagents/ao-core/types"; /** Timeout for git commands (30 seconds) */ const GIT_TIMEOUT = 30_000; @@ -25,7 +25,7 @@ export const manifest = { /** Run a git command in a given directory */ async function git(cwd: string, ...args: string[]): Promise { - const { stdout } = await execFileAsync("git", args, { cwd }); + const { stdout } = await execFileAsync("git", args, { cwd, timeout: GIT_TIMEOUT }); return stdout.trimEnd(); } @@ -99,6 +99,32 @@ async function clearStaleWorktreePath(repoPath: string, worktreePath: string): P rmSync(worktreePath, { recursive: true, force: true }); } +interface WorktreeEntry { + path: string; + branch: string | null; +} + +function parseWorktreeList(output: string): WorktreeEntry[] { + const normalized = output.replace(/\r\n/g, "\n").trim(); + if (!normalized) return []; + + return normalized + .split("\n\n") + .map((block) => { + let path = ""; + let branch: string | null = null; + for (const line of block.split("\n")) { + if (line.startsWith("worktree ")) { + path = resolve(line.slice("worktree ".length)); + } else if (line.startsWith("branch ")) { + branch = line.slice("branch ".length).replace("refs/heads/", ""); + } + } + return { path, branch }; + }) + .filter((entry) => entry.path.length > 0); +} + /** Only allow safe characters in path segments to prevent directory traversal */ const SAFE_PATH_SEGMENT = /^[a-zA-Z0-9_-]+$/; @@ -189,6 +215,46 @@ export function create(config?: Record): Workspace { }; }, + async findManagedWorkspace(cfg: WorkspaceCreateConfig): Promise { + assertSafePathSegment(cfg.projectId, "projectId"); + assertSafePathSegment(cfg.sessionId, "sessionId"); + + const repoPath = expandPath(cfg.project.path); + const effectiveBaseDir = cfg.worktreeDir ?? worktreeBaseDir; + const projectWorktreeDir = cfg.worktreeDir + ? effectiveBaseDir + : join(effectiveBaseDir, cfg.projectId); + const currentManagedPath = resolve(join(projectWorktreeDir, cfg.sessionId)); + const legacyManagedPath = resolve(join(worktreeBaseDir, cfg.projectId, cfg.sessionId)); + const allowedPaths = new Set([currentManagedPath, legacyManagedPath]); + + const worktrees = parseWorktreeList(await git(repoPath, "worktree", "list", "--porcelain")); + const matches = worktrees.filter( + (entry) => entry.branch === cfg.branch && existsSync(entry.path), + ); + + if (matches.length === 0) return null; + if (matches.length > 1) { + throw new Error( + `Found multiple worktrees for orchestrator branch "${cfg.branch}". Reuse one workspace or remove the extras before starting the orchestrator.`, + ); + } + + const match = matches[0]!; + if (!allowedPaths.has(match.path)) { + throw new Error( + `Found existing worktree for orchestrator branch "${cfg.branch}" at "${match.path}", but it is outside AO-managed worktree directories. Reuse it manually or remove it and try again.`, + ); + } + + return { + path: match.path, + branch: cfg.branch, + sessionId: cfg.sessionId, + projectId: cfg.projectId, + }; + }, + async destroy(workspacePath: string): Promise { try { const gitCommonDir = await git( diff --git a/packages/web/src/__tests__/api-routes.test.ts b/packages/web/src/__tests__/api-routes.test.ts index 65b7e06d2..bc5d4f47f 100644 --- a/packages/web/src/__tests__/api-routes.test.ts +++ b/packages/web/src/__tests__/api-routes.test.ts @@ -971,12 +971,32 @@ describe("API Routes", () => { const data = await res.json(); expect(data).toEqual({ error: expect.stringContaining( - 'AO found older orchestrator workspaces for "my-app" that are still registered with git.', + 'AO found an older orchestrator workspace for "my-app" but could not safely reuse it automatically.', ), code: "orchestrator_workspace_conflict", - recovery: "remove-and-readd-project", + recovery: "reuse-or-recreate-workspace", }); }); + + it("returns the same recovery message when a matching branch is outside AO-managed worktree directories", async () => { + (mockSessionManager.spawnOrchestrator as ReturnType).mockRejectedValueOnce( + new Error( + 'Found existing worktree for orchestrator branch "orchestrator/my-app-orchestrator" at "/tmp/manual-worktree", but it is outside AO-managed worktree directories. Reuse it manually or remove it and try again.', + ), + ); + + const req = makeRequest("/api/orchestrators", { + method: "POST", + body: JSON.stringify({ projectId: "my-app" }), + headers: { "Content-Type": "application/json" }, + }); + + const res = await orchestratorsPOST(req); + expect(res.status).toBe(409); + const data = await res.json(); + expect(data.recovery).toBe("reuse-or-recreate-workspace"); + expect(data.error).toContain('AO found an older orchestrator workspace for "my-app"'); + }); }); describe("GET /api/orchestrators", () => { diff --git a/packages/web/src/app/api/orchestrators/route.ts b/packages/web/src/app/api/orchestrators/route.ts index a1a0ecd2c..886761693 100644 --- a/packages/web/src/app/api/orchestrators/route.ts +++ b/packages/web/src/app/api/orchestrators/route.ts @@ -10,17 +10,21 @@ function classifySpawnError(projectId: string, error: unknown): { } { const message = error instanceof Error ? error.message : "Failed to spawn orchestrator"; - if (message.includes("already exists and is still registered with git")) { + if ( + message.includes("already exists and is still registered with git") || + message.includes("outside AO-managed worktree directories") || + message.includes('Found multiple worktrees for orchestrator branch "') + ) { return { status: 409, payload: { error: [ - `AO found older orchestrator workspaces for "${projectId}" that are still registered with git.`, - "Your repository is safe, but those AO-managed workspaces are blocking a new orchestrator.", - "To fix it: remove the project from AO, add it again, then spawn the orchestrator once more.", + `AO found an older orchestrator workspace for "${projectId}" but could not safely reuse it automatically.`, + "Your repository is safe.", + "Review the existing workspace, then either reuse it manually or remove it and create a fresh orchestrator workspace.", ].join(" "), code: "orchestrator_workspace_conflict", - recovery: "remove-and-readd-project", + recovery: "reuse-or-recreate-workspace", }, }; } From 8fee6c0e2dfa8e301e6d61fcf73ca7036807c6bf Mon Sep 17 00:00:00 2001 From: i-trytoohard Date: Wed, 6 May 2026 16:44:45 +0530 Subject: [PATCH 04/15] chore: release 0.5.0 (#1676) * chore(release): add changesets for 0.5.0 and bump codex version test - Add changesets for #1643 (orchestrator worktree adoption), #1549 (sidebar empty-state), and #1608 (terminal attach + mux routing). - Update agent-codex package-version.test.ts expectation from 0.4.0 to 0.5.0 so the test no longer fails after the upcoming version bump. * chore: release 0.5.0 --------- Co-authored-by: i-trytoohard <193449657+i-trytoohard@users.noreply.github.com> --- .changeset/remove-deprecated-ao-init.md | 5 --- packages/ao/CHANGELOG.md | 7 ++++ packages/ao/package.json | 2 +- packages/cli/CHANGELOG.md | 35 +++++++++++++++++++ packages/cli/package.json | 2 +- packages/core/CHANGELOG.md | 6 ++++ packages/core/package.json | 2 +- packages/plugins/agent-aider/CHANGELOG.md | 7 ++++ packages/plugins/agent-aider/package.json | 2 +- .../plugins/agent-claude-code/CHANGELOG.md | 7 ++++ .../plugins/agent-claude-code/package.json | 2 +- packages/plugins/agent-codex/CHANGELOG.md | 7 ++++ packages/plugins/agent-codex/package.json | 2 +- .../agent-codex/src/package-version.test.ts | 4 +-- packages/plugins/agent-cursor/CHANGELOG.md | 7 ++++ packages/plugins/agent-cursor/package.json | 2 +- packages/plugins/agent-kimicode/CHANGELOG.md | 7 ++++ packages/plugins/agent-kimicode/package.json | 2 +- packages/plugins/agent-opencode/CHANGELOG.md | 7 ++++ packages/plugins/agent-opencode/package.json | 2 +- .../plugins/notifier-composio/CHANGELOG.md | 7 ++++ .../plugins/notifier-composio/package.json | 2 +- .../plugins/notifier-desktop/CHANGELOG.md | 7 ++++ .../plugins/notifier-desktop/package.json | 2 +- .../plugins/notifier-discord/CHANGELOG.md | 7 ++++ .../plugins/notifier-discord/package.json | 2 +- .../plugins/notifier-openclaw/CHANGELOG.md | 7 ++++ .../plugins/notifier-openclaw/package.json | 2 +- packages/plugins/notifier-slack/CHANGELOG.md | 7 ++++ packages/plugins/notifier-slack/package.json | 2 +- .../plugins/notifier-webhook/CHANGELOG.md | 7 ++++ .../plugins/notifier-webhook/package.json | 2 +- packages/plugins/runtime-process/CHANGELOG.md | 7 ++++ packages/plugins/runtime-process/package.json | 2 +- packages/plugins/runtime-tmux/CHANGELOG.md | 7 ++++ packages/plugins/runtime-tmux/package.json | 2 +- packages/plugins/scm-github/CHANGELOG.md | 7 ++++ packages/plugins/scm-github/package.json | 2 +- packages/plugins/scm-gitlab/CHANGELOG.md | 7 ++++ packages/plugins/scm-gitlab/package.json | 2 +- packages/plugins/terminal-iterm2/CHANGELOG.md | 7 ++++ packages/plugins/terminal-iterm2/package.json | 2 +- packages/plugins/terminal-web/CHANGELOG.md | 7 ++++ packages/plugins/terminal-web/package.json | 2 +- packages/plugins/tracker-github/CHANGELOG.md | 7 ++++ packages/plugins/tracker-github/package.json | 2 +- packages/plugins/tracker-gitlab/CHANGELOG.md | 8 +++++ packages/plugins/tracker-gitlab/package.json | 2 +- packages/plugins/tracker-linear/CHANGELOG.md | 7 ++++ packages/plugins/tracker-linear/package.json | 2 +- packages/plugins/workspace-clone/CHANGELOG.md | 7 ++++ packages/plugins/workspace-clone/package.json | 2 +- .../plugins/workspace-worktree/CHANGELOG.md | 7 ++++ .../plugins/workspace-worktree/package.json | 2 +- packages/web/CHANGELOG.md | 19 ++++++++++ packages/web/package.json | 2 +- 56 files changed, 258 insertions(+), 34 deletions(-) delete mode 100644 .changeset/remove-deprecated-ao-init.md diff --git a/.changeset/remove-deprecated-ao-init.md b/.changeset/remove-deprecated-ao-init.md deleted file mode 100644 index f1d50b6d6..000000000 --- a/.changeset/remove-deprecated-ao-init.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@aoagents/ao-cli": minor ---- - -Remove the deprecated `ao init` command. Use `ao start` instead — it auto-creates the config on first run in an unconfigured repo. diff --git a/packages/ao/CHANGELOG.md b/packages/ao/CHANGELOG.md index 14ed974dc..8d0858d9d 100644 --- a/packages/ao/CHANGELOG.md +++ b/packages/ao/CHANGELOG.md @@ -1,5 +1,12 @@ # @aoagents/ao +## 0.5.0 + +### Patch Changes + +- Updated dependencies [3a69722] + - @aoagents/ao-cli@0.5.0 + ## 0.4.0 ### Patch Changes diff --git a/packages/ao/package.json b/packages/ao/package.json index eef133a90..e53774bc9 100644 --- a/packages/ao/package.json +++ b/packages/ao/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao", - "version": "0.4.0", + "version": "0.5.0", "description": "Orchestrate parallel AI coding agents — global CLI wrapper", "license": "MIT", "type": "module", diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index c8c346944..72f3fb5d5 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,40 @@ # @aoagents/ao-cli +## 0.5.0 + +### Minor Changes + +- 3a69722: Remove the deprecated `ao init` command. Use `ao start` instead — it auto-creates the config on first run in an unconfigured repo. + +### Patch Changes + +- Updated dependencies [dd07b6b] +- Updated dependencies [dd07b6b] +- Updated dependencies [dd07b6b] + - @aoagents/ao-core@0.5.0 + - @aoagents/ao-web@0.5.0 + - @aoagents/ao-plugin-agent-aider@0.5.0 + - @aoagents/ao-plugin-agent-claude-code@0.5.0 + - @aoagents/ao-plugin-agent-codex@0.5.0 + - @aoagents/ao-plugin-agent-cursor@0.1.3 + - @aoagents/ao-plugin-agent-kimicode@0.1.2 + - @aoagents/ao-plugin-agent-opencode@0.5.0 + - @aoagents/ao-plugin-notifier-composio@0.5.0 + - @aoagents/ao-plugin-notifier-desktop@0.5.0 + - @aoagents/ao-plugin-notifier-discord@0.2.8 + - @aoagents/ao-plugin-notifier-openclaw@0.2.8 + - @aoagents/ao-plugin-notifier-slack@0.5.0 + - @aoagents/ao-plugin-notifier-webhook@0.5.0 + - @aoagents/ao-plugin-runtime-process@0.5.0 + - @aoagents/ao-plugin-runtime-tmux@0.5.0 + - @aoagents/ao-plugin-scm-github@0.5.0 + - @aoagents/ao-plugin-terminal-iterm2@0.5.0 + - @aoagents/ao-plugin-terminal-web@0.5.0 + - @aoagents/ao-plugin-tracker-github@0.5.0 + - @aoagents/ao-plugin-tracker-linear@0.5.0 + - @aoagents/ao-plugin-workspace-clone@0.5.0 + - @aoagents/ao-plugin-workspace-worktree@0.5.0 + ## 0.4.0 ### Minor Changes diff --git a/packages/cli/package.json b/packages/cli/package.json index 46d7ff4e9..56f347871 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-cli", - "version": "0.4.0", + "version": "0.5.0", "description": "CLI for agent-orchestrator — the `ao` command", "license": "MIT", "type": "module", diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index e6ad533e7..7203b1c99 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,11 @@ # @aoagents/ao-core +## 0.5.0 + +### Patch Changes + +- dd07b6b: Adopt existing managed orchestrator worktrees instead of failing to create a fresh one. Previously, a leftover worktree from a prior run could block `spawnOrchestrator` with a "worktree already exists" error; spawn now detects and reuses the matching managed worktree. Also normalizes CRLF in `parseWorktreeList` (Windows), filters prunable/deleted entries in `findManagedWorkspace`, and applies `GIT_TIMEOUT` to all internal `git()` calls. + ## 0.4.0 ### Minor Changes diff --git a/packages/core/package.json b/packages/core/package.json index 0c0f11366..4efd2fbb6 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-core", - "version": "0.4.0", + "version": "0.5.0", "description": "Core library — types, config, session manager, lifecycle manager, event bus", "license": "MIT", "type": "module", diff --git a/packages/plugins/agent-aider/CHANGELOG.md b/packages/plugins/agent-aider/CHANGELOG.md index 97cb23389..0a5d052eb 100644 --- a/packages/plugins/agent-aider/CHANGELOG.md +++ b/packages/plugins/agent-aider/CHANGELOG.md @@ -1,5 +1,12 @@ # @aoagents/ao-plugin-agent-aider +## 0.5.0 + +### Patch Changes + +- Updated dependencies [dd07b6b] + - @aoagents/ao-core@0.5.0 + ## 0.4.0 ### Patch Changes diff --git a/packages/plugins/agent-aider/package.json b/packages/plugins/agent-aider/package.json index 26adac0fe..33d05921f 100644 --- a/packages/plugins/agent-aider/package.json +++ b/packages/plugins/agent-aider/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-agent-aider", - "version": "0.4.0", + "version": "0.5.0", "description": "Agent plugin: Aider", "license": "MIT", "type": "module", diff --git a/packages/plugins/agent-claude-code/CHANGELOG.md b/packages/plugins/agent-claude-code/CHANGELOG.md index 7efc47746..8e25e5af0 100644 --- a/packages/plugins/agent-claude-code/CHANGELOG.md +++ b/packages/plugins/agent-claude-code/CHANGELOG.md @@ -1,5 +1,12 @@ # @aoagents/ao-plugin-agent-claude-code +## 0.5.0 + +### Patch Changes + +- Updated dependencies [dd07b6b] + - @aoagents/ao-core@0.5.0 + ## 0.4.0 ### Patch Changes diff --git a/packages/plugins/agent-claude-code/package.json b/packages/plugins/agent-claude-code/package.json index 93f01526d..56bc901cf 100644 --- a/packages/plugins/agent-claude-code/package.json +++ b/packages/plugins/agent-claude-code/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-agent-claude-code", - "version": "0.4.0", + "version": "0.5.0", "description": "Agent plugin: Claude Code", "license": "MIT", "type": "module", diff --git a/packages/plugins/agent-codex/CHANGELOG.md b/packages/plugins/agent-codex/CHANGELOG.md index 28dc4e1a5..cab9803f5 100644 --- a/packages/plugins/agent-codex/CHANGELOG.md +++ b/packages/plugins/agent-codex/CHANGELOG.md @@ -1,5 +1,12 @@ # @aoagents/ao-plugin-agent-codex +## 0.5.0 + +### Patch Changes + +- Updated dependencies [dd07b6b] + - @aoagents/ao-core@0.5.0 + ## 0.4.0 ### Patch Changes diff --git a/packages/plugins/agent-codex/package.json b/packages/plugins/agent-codex/package.json index 6c3e519c8..c298762ac 100644 --- a/packages/plugins/agent-codex/package.json +++ b/packages/plugins/agent-codex/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-agent-codex", - "version": "0.4.0", + "version": "0.5.0", "description": "Agent plugin: OpenAI Codex CLI", "license": "MIT", "type": "module", diff --git a/packages/plugins/agent-codex/src/package-version.test.ts b/packages/plugins/agent-codex/src/package-version.test.ts index 2782fd8bb..25f28dd45 100644 --- a/packages/plugins/agent-codex/src/package-version.test.ts +++ b/packages/plugins/agent-codex/src/package-version.test.ts @@ -2,10 +2,10 @@ import { describe, expect, it } from "vitest"; import { readFileSync } from "node:fs"; describe("package manifest version", () => { - it("is bumped to 0.4.0", () => { + it("is bumped to 0.5.0", () => { const packageJsonUrl = new URL("../package.json", import.meta.url); const packageJson = JSON.parse(readFileSync(packageJsonUrl, "utf8")) as { version?: string }; - expect(packageJson.version).toBe("0.4.0"); + expect(packageJson.version).toBe("0.5.0"); }); }); diff --git a/packages/plugins/agent-cursor/CHANGELOG.md b/packages/plugins/agent-cursor/CHANGELOG.md index c184c0631..95370f717 100644 --- a/packages/plugins/agent-cursor/CHANGELOG.md +++ b/packages/plugins/agent-cursor/CHANGELOG.md @@ -1,5 +1,12 @@ # @aoagents/ao-plugin-agent-cursor +## 0.1.3 + +### Patch Changes + +- Updated dependencies [dd07b6b] + - @aoagents/ao-core@0.5.0 + ## 0.1.2 ### Patch Changes diff --git a/packages/plugins/agent-cursor/package.json b/packages/plugins/agent-cursor/package.json index 9f99b4295..229393dd5 100644 --- a/packages/plugins/agent-cursor/package.json +++ b/packages/plugins/agent-cursor/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-agent-cursor", - "version": "0.1.2", + "version": "0.1.3", "description": "Agent plugin: Cursor CLI", "license": "MIT", "type": "module", diff --git a/packages/plugins/agent-kimicode/CHANGELOG.md b/packages/plugins/agent-kimicode/CHANGELOG.md index abf6f63fd..ec6196eb6 100644 --- a/packages/plugins/agent-kimicode/CHANGELOG.md +++ b/packages/plugins/agent-kimicode/CHANGELOG.md @@ -1,5 +1,12 @@ # @aoagents/ao-plugin-agent-kimicode +## 0.1.2 + +### Patch Changes + +- Updated dependencies [dd07b6b] + - @aoagents/ao-core@0.5.0 + ## 0.1.1 ### Patch Changes diff --git a/packages/plugins/agent-kimicode/package.json b/packages/plugins/agent-kimicode/package.json index fb7015085..e28d258cf 100644 --- a/packages/plugins/agent-kimicode/package.json +++ b/packages/plugins/agent-kimicode/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-agent-kimicode", - "version": "0.1.1", + "version": "0.1.2", "description": "Agent plugin: Kimi Code CLI (MoonshotAI)", "license": "MIT", "type": "module", diff --git a/packages/plugins/agent-opencode/CHANGELOG.md b/packages/plugins/agent-opencode/CHANGELOG.md index 4596226cf..30ef5a477 100644 --- a/packages/plugins/agent-opencode/CHANGELOG.md +++ b/packages/plugins/agent-opencode/CHANGELOG.md @@ -1,5 +1,12 @@ # @aoagents/ao-plugin-agent-opencode +## 0.5.0 + +### Patch Changes + +- Updated dependencies [dd07b6b] + - @aoagents/ao-core@0.5.0 + ## 0.4.0 ### Patch Changes diff --git a/packages/plugins/agent-opencode/package.json b/packages/plugins/agent-opencode/package.json index 741a7ac23..3e9532d04 100644 --- a/packages/plugins/agent-opencode/package.json +++ b/packages/plugins/agent-opencode/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-agent-opencode", - "version": "0.4.0", + "version": "0.5.0", "description": "Agent plugin: OpenCode", "license": "MIT", "type": "module", diff --git a/packages/plugins/notifier-composio/CHANGELOG.md b/packages/plugins/notifier-composio/CHANGELOG.md index 48dab225f..4f42124d6 100644 --- a/packages/plugins/notifier-composio/CHANGELOG.md +++ b/packages/plugins/notifier-composio/CHANGELOG.md @@ -1,5 +1,12 @@ # @aoagents/ao-plugin-notifier-composio +## 0.5.0 + +### Patch Changes + +- Updated dependencies [dd07b6b] + - @aoagents/ao-core@0.5.0 + ## 0.4.0 ### Patch Changes diff --git a/packages/plugins/notifier-composio/package.json b/packages/plugins/notifier-composio/package.json index b365fcbbc..978457d07 100644 --- a/packages/plugins/notifier-composio/package.json +++ b/packages/plugins/notifier-composio/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-notifier-composio", - "version": "0.4.0", + "version": "0.5.0", "description": "Notifier plugin: Composio unified notifications (Slack, Discord, email)", "license": "MIT", "type": "module", diff --git a/packages/plugins/notifier-desktop/CHANGELOG.md b/packages/plugins/notifier-desktop/CHANGELOG.md index ddb1e1186..020519115 100644 --- a/packages/plugins/notifier-desktop/CHANGELOG.md +++ b/packages/plugins/notifier-desktop/CHANGELOG.md @@ -1,5 +1,12 @@ # @aoagents/ao-plugin-notifier-desktop +## 0.5.0 + +### Patch Changes + +- Updated dependencies [dd07b6b] + - @aoagents/ao-core@0.5.0 + ## 0.4.0 ### Patch Changes diff --git a/packages/plugins/notifier-desktop/package.json b/packages/plugins/notifier-desktop/package.json index 2274e62de..8599b25d4 100644 --- a/packages/plugins/notifier-desktop/package.json +++ b/packages/plugins/notifier-desktop/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-notifier-desktop", - "version": "0.4.0", + "version": "0.5.0", "description": "Notifier plugin: OS desktop notifications", "license": "MIT", "type": "module", diff --git a/packages/plugins/notifier-discord/CHANGELOG.md b/packages/plugins/notifier-discord/CHANGELOG.md index 1724fe523..b667e99fb 100644 --- a/packages/plugins/notifier-discord/CHANGELOG.md +++ b/packages/plugins/notifier-discord/CHANGELOG.md @@ -1,5 +1,12 @@ # @aoagents/ao-plugin-notifier-discord +## 0.2.8 + +### Patch Changes + +- Updated dependencies [dd07b6b] + - @aoagents/ao-core@0.5.0 + ## 0.2.7 ### Patch Changes diff --git a/packages/plugins/notifier-discord/package.json b/packages/plugins/notifier-discord/package.json index ff89a6dd9..bb612cb6a 100644 --- a/packages/plugins/notifier-discord/package.json +++ b/packages/plugins/notifier-discord/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-notifier-discord", - "version": "0.2.7", + "version": "0.2.8", "description": "Notifier plugin: Discord webhook notifications", "license": "MIT", "type": "module", diff --git a/packages/plugins/notifier-openclaw/CHANGELOG.md b/packages/plugins/notifier-openclaw/CHANGELOG.md index c1e631e9b..e2e015c79 100644 --- a/packages/plugins/notifier-openclaw/CHANGELOG.md +++ b/packages/plugins/notifier-openclaw/CHANGELOG.md @@ -1,5 +1,12 @@ # @aoagents/ao-plugin-notifier-openclaw +## 0.2.8 + +### Patch Changes + +- Updated dependencies [dd07b6b] + - @aoagents/ao-core@0.5.0 + ## 0.2.7 ### Patch Changes diff --git a/packages/plugins/notifier-openclaw/package.json b/packages/plugins/notifier-openclaw/package.json index de86a00fe..7e830f0f5 100644 --- a/packages/plugins/notifier-openclaw/package.json +++ b/packages/plugins/notifier-openclaw/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-notifier-openclaw", - "version": "0.2.7", + "version": "0.2.8", "description": "Notifier plugin: OpenClaw webhook notifications", "license": "MIT", "type": "module", diff --git a/packages/plugins/notifier-slack/CHANGELOG.md b/packages/plugins/notifier-slack/CHANGELOG.md index 7e3ec9527..e258da133 100644 --- a/packages/plugins/notifier-slack/CHANGELOG.md +++ b/packages/plugins/notifier-slack/CHANGELOG.md @@ -1,5 +1,12 @@ # @aoagents/ao-plugin-notifier-slack +## 0.5.0 + +### Patch Changes + +- Updated dependencies [dd07b6b] + - @aoagents/ao-core@0.5.0 + ## 0.4.0 ### Patch Changes diff --git a/packages/plugins/notifier-slack/package.json b/packages/plugins/notifier-slack/package.json index d166bd3f4..81d22f662 100644 --- a/packages/plugins/notifier-slack/package.json +++ b/packages/plugins/notifier-slack/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-notifier-slack", - "version": "0.4.0", + "version": "0.5.0", "description": "Notifier plugin: Slack", "license": "MIT", "type": "module", diff --git a/packages/plugins/notifier-webhook/CHANGELOG.md b/packages/plugins/notifier-webhook/CHANGELOG.md index 6d23f5928..3cdc6c360 100644 --- a/packages/plugins/notifier-webhook/CHANGELOG.md +++ b/packages/plugins/notifier-webhook/CHANGELOG.md @@ -1,5 +1,12 @@ # @aoagents/ao-plugin-notifier-webhook +## 0.5.0 + +### Patch Changes + +- Updated dependencies [dd07b6b] + - @aoagents/ao-core@0.5.0 + ## 0.4.0 ### Patch Changes diff --git a/packages/plugins/notifier-webhook/package.json b/packages/plugins/notifier-webhook/package.json index 2d834f746..dcb193422 100644 --- a/packages/plugins/notifier-webhook/package.json +++ b/packages/plugins/notifier-webhook/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-notifier-webhook", - "version": "0.4.0", + "version": "0.5.0", "description": "Notifier plugin: generic webhook", "license": "MIT", "type": "module", diff --git a/packages/plugins/runtime-process/CHANGELOG.md b/packages/plugins/runtime-process/CHANGELOG.md index 40f5c209c..02bdac703 100644 --- a/packages/plugins/runtime-process/CHANGELOG.md +++ b/packages/plugins/runtime-process/CHANGELOG.md @@ -1,5 +1,12 @@ # @aoagents/ao-plugin-runtime-process +## 0.5.0 + +### Patch Changes + +- Updated dependencies [dd07b6b] + - @aoagents/ao-core@0.5.0 + ## 0.4.0 ### Patch Changes diff --git a/packages/plugins/runtime-process/package.json b/packages/plugins/runtime-process/package.json index 8295ca8a7..69c81e8cc 100644 --- a/packages/plugins/runtime-process/package.json +++ b/packages/plugins/runtime-process/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-runtime-process", - "version": "0.4.0", + "version": "0.5.0", "description": "Runtime plugin: child processes", "license": "MIT", "type": "module", diff --git a/packages/plugins/runtime-tmux/CHANGELOG.md b/packages/plugins/runtime-tmux/CHANGELOG.md index 1b470be27..338253e51 100644 --- a/packages/plugins/runtime-tmux/CHANGELOG.md +++ b/packages/plugins/runtime-tmux/CHANGELOG.md @@ -1,5 +1,12 @@ # @aoagents/ao-plugin-runtime-tmux +## 0.5.0 + +### Patch Changes + +- Updated dependencies [dd07b6b] + - @aoagents/ao-core@0.5.0 + ## 0.4.0 ### Patch Changes diff --git a/packages/plugins/runtime-tmux/package.json b/packages/plugins/runtime-tmux/package.json index a7f5d0855..9d4917de9 100644 --- a/packages/plugins/runtime-tmux/package.json +++ b/packages/plugins/runtime-tmux/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-runtime-tmux", - "version": "0.4.0", + "version": "0.5.0", "description": "Runtime plugin: tmux sessions", "license": "MIT", "type": "module", diff --git a/packages/plugins/scm-github/CHANGELOG.md b/packages/plugins/scm-github/CHANGELOG.md index 3d9a537ba..8dfa9d255 100644 --- a/packages/plugins/scm-github/CHANGELOG.md +++ b/packages/plugins/scm-github/CHANGELOG.md @@ -1,5 +1,12 @@ # @aoagents/ao-plugin-scm-github +## 0.5.0 + +### Patch Changes + +- Updated dependencies [dd07b6b] + - @aoagents/ao-core@0.5.0 + ## 0.4.0 ### Patch Changes diff --git a/packages/plugins/scm-github/package.json b/packages/plugins/scm-github/package.json index 969d83e0d..a0394b780 100644 --- a/packages/plugins/scm-github/package.json +++ b/packages/plugins/scm-github/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-scm-github", - "version": "0.4.0", + "version": "0.5.0", "description": "SCM plugin: GitHub (PRs, CI, reviews)", "license": "MIT", "type": "module", diff --git a/packages/plugins/scm-gitlab/CHANGELOG.md b/packages/plugins/scm-gitlab/CHANGELOG.md index 8083bb10f..6e7bf9c73 100644 --- a/packages/plugins/scm-gitlab/CHANGELOG.md +++ b/packages/plugins/scm-gitlab/CHANGELOG.md @@ -1,5 +1,12 @@ # @aoagents/ao-plugin-scm-gitlab +## 0.2.8 + +### Patch Changes + +- Updated dependencies [dd07b6b] + - @aoagents/ao-core@0.5.0 + ## 0.2.7 ### Patch Changes diff --git a/packages/plugins/scm-gitlab/package.json b/packages/plugins/scm-gitlab/package.json index cb0db9ba6..f630adb17 100644 --- a/packages/plugins/scm-gitlab/package.json +++ b/packages/plugins/scm-gitlab/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-scm-gitlab", - "version": "0.2.7", + "version": "0.2.8", "description": "SCM plugin: GitLab (MRs, CI, reviews)", "license": "MIT", "type": "module", diff --git a/packages/plugins/terminal-iterm2/CHANGELOG.md b/packages/plugins/terminal-iterm2/CHANGELOG.md index 9f010b197..d01a5dfe9 100644 --- a/packages/plugins/terminal-iterm2/CHANGELOG.md +++ b/packages/plugins/terminal-iterm2/CHANGELOG.md @@ -1,5 +1,12 @@ # @aoagents/ao-plugin-terminal-iterm2 +## 0.5.0 + +### Patch Changes + +- Updated dependencies [dd07b6b] + - @aoagents/ao-core@0.5.0 + ## 0.4.0 ### Patch Changes diff --git a/packages/plugins/terminal-iterm2/package.json b/packages/plugins/terminal-iterm2/package.json index a83fd39be..4e5628a85 100644 --- a/packages/plugins/terminal-iterm2/package.json +++ b/packages/plugins/terminal-iterm2/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-terminal-iterm2", - "version": "0.4.0", + "version": "0.5.0", "description": "Terminal plugin: macOS iTerm2", "license": "MIT", "type": "module", diff --git a/packages/plugins/terminal-web/CHANGELOG.md b/packages/plugins/terminal-web/CHANGELOG.md index 841d185f8..5f14bc63a 100644 --- a/packages/plugins/terminal-web/CHANGELOG.md +++ b/packages/plugins/terminal-web/CHANGELOG.md @@ -1,5 +1,12 @@ # @aoagents/ao-plugin-terminal-web +## 0.5.0 + +### Patch Changes + +- Updated dependencies [dd07b6b] + - @aoagents/ao-core@0.5.0 + ## 0.4.0 ### Patch Changes diff --git a/packages/plugins/terminal-web/package.json b/packages/plugins/terminal-web/package.json index 538436831..f29915d49 100644 --- a/packages/plugins/terminal-web/package.json +++ b/packages/plugins/terminal-web/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-terminal-web", - "version": "0.4.0", + "version": "0.5.0", "description": "Terminal plugin: xterm.js web terminal", "license": "MIT", "type": "module", diff --git a/packages/plugins/tracker-github/CHANGELOG.md b/packages/plugins/tracker-github/CHANGELOG.md index da44263e6..dc991e76f 100644 --- a/packages/plugins/tracker-github/CHANGELOG.md +++ b/packages/plugins/tracker-github/CHANGELOG.md @@ -1,5 +1,12 @@ # @aoagents/ao-plugin-tracker-github +## 0.5.0 + +### Patch Changes + +- Updated dependencies [dd07b6b] + - @aoagents/ao-core@0.5.0 + ## 0.4.0 ### Patch Changes diff --git a/packages/plugins/tracker-github/package.json b/packages/plugins/tracker-github/package.json index a9b63553a..7a702e80c 100644 --- a/packages/plugins/tracker-github/package.json +++ b/packages/plugins/tracker-github/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-tracker-github", - "version": "0.4.0", + "version": "0.5.0", "description": "Tracker plugin: GitHub Issues", "license": "MIT", "type": "module", diff --git a/packages/plugins/tracker-gitlab/CHANGELOG.md b/packages/plugins/tracker-gitlab/CHANGELOG.md index 65e280312..a1706970d 100644 --- a/packages/plugins/tracker-gitlab/CHANGELOG.md +++ b/packages/plugins/tracker-gitlab/CHANGELOG.md @@ -1,5 +1,13 @@ # @aoagents/ao-plugin-tracker-gitlab +## 0.2.8 + +### Patch Changes + +- Updated dependencies [dd07b6b] + - @aoagents/ao-core@0.5.0 + - @aoagents/ao-plugin-scm-gitlab@0.2.8 + ## 0.2.7 ### Patch Changes diff --git a/packages/plugins/tracker-gitlab/package.json b/packages/plugins/tracker-gitlab/package.json index 260b39be0..abc2e9aa1 100644 --- a/packages/plugins/tracker-gitlab/package.json +++ b/packages/plugins/tracker-gitlab/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-tracker-gitlab", - "version": "0.2.7", + "version": "0.2.8", "description": "Tracker plugin: GitLab Issues", "license": "MIT", "type": "module", diff --git a/packages/plugins/tracker-linear/CHANGELOG.md b/packages/plugins/tracker-linear/CHANGELOG.md index 4ee3d3214..ea8134de8 100644 --- a/packages/plugins/tracker-linear/CHANGELOG.md +++ b/packages/plugins/tracker-linear/CHANGELOG.md @@ -1,5 +1,12 @@ # @aoagents/ao-plugin-tracker-linear +## 0.5.0 + +### Patch Changes + +- Updated dependencies [dd07b6b] + - @aoagents/ao-core@0.5.0 + ## 0.4.0 ### Patch Changes diff --git a/packages/plugins/tracker-linear/package.json b/packages/plugins/tracker-linear/package.json index a43f75284..cfa6e680c 100644 --- a/packages/plugins/tracker-linear/package.json +++ b/packages/plugins/tracker-linear/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-tracker-linear", - "version": "0.4.0", + "version": "0.5.0", "description": "Tracker plugin: Linear", "license": "MIT", "type": "module", diff --git a/packages/plugins/workspace-clone/CHANGELOG.md b/packages/plugins/workspace-clone/CHANGELOG.md index 405a2de1d..4340349bd 100644 --- a/packages/plugins/workspace-clone/CHANGELOG.md +++ b/packages/plugins/workspace-clone/CHANGELOG.md @@ -1,5 +1,12 @@ # @aoagents/ao-plugin-workspace-clone +## 0.5.0 + +### Patch Changes + +- Updated dependencies [dd07b6b] + - @aoagents/ao-core@0.5.0 + ## 0.4.0 ### Patch Changes diff --git a/packages/plugins/workspace-clone/package.json b/packages/plugins/workspace-clone/package.json index 4232abbaf..412539e87 100644 --- a/packages/plugins/workspace-clone/package.json +++ b/packages/plugins/workspace-clone/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-workspace-clone", - "version": "0.4.0", + "version": "0.5.0", "description": "Workspace plugin: git clone", "license": "MIT", "type": "module", diff --git a/packages/plugins/workspace-worktree/CHANGELOG.md b/packages/plugins/workspace-worktree/CHANGELOG.md index 09b04f64c..d4f0a410c 100644 --- a/packages/plugins/workspace-worktree/CHANGELOG.md +++ b/packages/plugins/workspace-worktree/CHANGELOG.md @@ -1,5 +1,12 @@ # @aoagents/ao-plugin-workspace-worktree +## 0.5.0 + +### Patch Changes + +- Updated dependencies [dd07b6b] + - @aoagents/ao-core@0.5.0 + ## 0.4.0 ### Patch Changes diff --git a/packages/plugins/workspace-worktree/package.json b/packages/plugins/workspace-worktree/package.json index 5e0a1c964..d206eced5 100644 --- a/packages/plugins/workspace-worktree/package.json +++ b/packages/plugins/workspace-worktree/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-workspace-worktree", - "version": "0.4.0", + "version": "0.5.0", "description": "Workspace plugin: git worktrees", "license": "MIT", "type": "module", diff --git a/packages/web/CHANGELOG.md b/packages/web/CHANGELOG.md index d8e9256b7..356bd8f88 100644 --- a/packages/web/CHANGELOG.md +++ b/packages/web/CHANGELOG.md @@ -1,5 +1,24 @@ # @aoagents/ao-web +## 0.5.0 + +### Patch Changes + +- dd07b6b: Fix direct terminal attach and keep mux routing project-scoped. Switches `resolveExactTmuxName` from `execFileSync` to a promisified `execFile` so slow tmux calls no longer stall the WebSocket message handler, and propagates async through `TerminalManager.open` / `subscribe` and the pty `onExit` reattach path. Also drops a duplicate `.kanban-board` grid rule in `globals.css`. +- dd07b6b: Render an empty-state in the project sidebar when no projects are configured. Fresh-install users previously saw a blank sidebar with no way to open the Add Project modal; the sidebar now shows a small empty-state with the `+` button wired up. +- Updated dependencies [dd07b6b] + - @aoagents/ao-core@0.5.0 + - @aoagents/ao-plugin-agent-claude-code@0.5.0 + - @aoagents/ao-plugin-agent-codex@0.5.0 + - @aoagents/ao-plugin-agent-cursor@0.1.3 + - @aoagents/ao-plugin-agent-kimicode@0.1.2 + - @aoagents/ao-plugin-agent-opencode@0.5.0 + - @aoagents/ao-plugin-runtime-tmux@0.5.0 + - @aoagents/ao-plugin-scm-github@0.5.0 + - @aoagents/ao-plugin-tracker-github@0.5.0 + - @aoagents/ao-plugin-tracker-linear@0.5.0 + - @aoagents/ao-plugin-workspace-worktree@0.5.0 + ## 0.4.0 ### Patch Changes diff --git a/packages/web/package.json b/packages/web/package.json index 99ccffd87..b97ddd08e 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-web", - "version": "0.4.0", + "version": "0.5.0", "description": "Web dashboard for agent-orchestrator", "type": "module", "files": [ From f617ae0746a96d9dc516d628db60c24c7c7cf196 Mon Sep 17 00:00:00 2001 From: i-trytoohard Date: Wed, 6 May 2026 21:32:22 +0530 Subject: [PATCH 05/15] fix(core): disable tmux status bar at session creation (#1683) * fix(core): disable tmux status bar at session creation The tmux green status bar was visible in web terminals because newSession() never set status off. The global tmux config has status on, and the web layer only disabled it on WebSocket connect. Now we hide the status bar immediately after session creation so it's never visible, regardless of when (or if) the web UI connects. Fixes #1682 * test(core): update tmux newSession test for status off call The new set-option status off call adds a 5th execFile invocation. Update the test expectations to match the new call sequence. --------- Co-authored-by: AO Bot --- packages/core/src/__tests__/tmux.test.ts | 17 ++++++++++------- packages/core/src/tmux.ts | 3 +++ 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/packages/core/src/__tests__/tmux.test.ts b/packages/core/src/__tests__/tmux.test.ts index f81dc2dac..d25703a6f 100644 --- a/packages/core/src/__tests__/tmux.test.ts +++ b/packages/core/src/__tests__/tmux.test.ts @@ -160,18 +160,21 @@ describe("newSession", () => { }); it("sends initial command after creation", async () => { - // Calls: new-session, send-keys Escape, send-keys text, send-keys Enter - mockTmuxSequence([{ stdout: "" }, { stdout: "" }, { stdout: "" }, { stdout: "" }]); + // Calls: new-session, set-option status off, send-keys Escape, send-keys text, send-keys Enter + mockTmuxSequence([{ stdout: "" }, { stdout: "" }, { stdout: "" }, { stdout: "" }, { stdout: "" }]); await newSession({ name: "test-4", cwd: "/tmp", command: "echo hello" }); - expect(mockExecFile).toHaveBeenCalledTimes(4); + expect(mockExecFile).toHaveBeenCalledTimes(5); // Call 0: new-session - // Call 1: send-keys Escape (clear partial input) - const escapeArgs = mockExecFile.mock.calls[1][1] as string[]; + // Call 1: set-option status off (hide status bar) + const statusArgs = mockExecFile.mock.calls[1][1] as string[]; + expect(statusArgs).toEqual(["set-option", "-t", "test-4", "status", "off"]); + // Call 2: send-keys Escape (clear partial input) + const escapeArgs = mockExecFile.mock.calls[2][1] as string[]; expect(escapeArgs).toEqual(["send-keys", "-t", "test-4", "Escape"]); - // Call 2: send-keys text - const textArgs = mockExecFile.mock.calls[2][1] as string[]; + // Call 3: send-keys text + const textArgs = mockExecFile.mock.calls[3][1] as string[]; expect(textArgs).toContain("send-keys"); expect(textArgs).toContain("echo hello"); }); diff --git a/packages/core/src/tmux.ts b/packages/core/src/tmux.ts index be5da26f5..f72ef4114 100644 --- a/packages/core/src/tmux.ts +++ b/packages/core/src/tmux.ts @@ -110,6 +110,9 @@ export async function newSession(opts: NewSessionOptions): Promise { await tmux(...args); + // Hide the tmux status bar — sessions are embedded in web terminal + await tmux("set-option", "-t", opts.name, "status", "off"); + // Send the initial command if provided if (opts.command) { await sendKeys(opts.name, opts.command); From fc7d76ad54d6a7350de0fca298a1294ce8a1f3a2 Mon Sep 17 00:00:00 2001 From: Dhruv Sharma Date: Wed, 6 May 2026 22:14:21 +0530 Subject: [PATCH 06/15] feat(core): wire activity events into lifecycle-manager failure paths (#1511) (#1620) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * rebase: forward branch onto main + resolve activity-events kind union conflict * feat(core): wire scm/runtime/agent plugin-call failure events Adds activity-event evidence for previously-silent failure paths in lifecycle-manager.ts so the RCA agent can answer 'why did X happen?': - scm.batch_enrich_failed (line 617 catch) - scm.detect_pr_succeeded (line 658 success path) - scm.detect_pr_failed (line 664 catch) - scm.review_fetch_failed (line 1517 catch) - scm.poll_pr_failed (line 1132 catch) - runtime.probe_failed (line 938 catch) - agent.process_probe_failed (lines 1054 + 1139 catches, with where field) - agent.activity_probe_failed (line 1062 outer catch) Plus 6 new tests covering the call shapes. Invariants preserved (per CLAUDE.md): - B1 state-mutate-before-emit: each emit follows existing observer call - B2 never throws: recordActivityEvent best-effort by design - B3 re-entrancy guard unchanged - B4 Promise.allSettled semantics unchanged * feat(core): wire reaction lifecycle activity events Adds AE evidence around reaction triggers, escalations, and failures so RCA can answer 'did AO try to auto-fix this? did it succeed?': - reaction.action_succeeded (combined for send-to-agent / notify / auto-merge, with data.action variant) — fires after each successful reaction action - reaction.send_to_agent_failed — fires in the previously-silent catch when sessionManager.send throws inside a send-to-agent reaction - reaction.escalated — fires alongside the existing notifyHuman escalation with data.escalationCause = 'max_retries' | 'max_duration' Plus 3 new tests covering the call shapes. Invariants preserved: emits land after the existing notifyHuman/return paths so state mutation order is unchanged. * feat(core): wire auto-cleanup, poll-cycle, detecting escalation events Adds AE evidence around session destruction, poll loop failures, and the detecting→stuck transition so RCA can answer 'when did my session get cleaned up?', 'did the polling loop crash?', and 'why did AO mark this session stuck?': - session.auto_cleanup_deferred — agent busy, cleanup deferred - session.auto_cleanup_completed — kill succeeded, runtime + worktree gone - session.auto_cleanup_failed (level=error) — kill threw, session stays merged - lifecycle.poll_failed (level=error) — pollAll outer catch fired - detecting.escalated — first cycle that promotes detecting→stuck, with cause = max_attempts | max_duration. Guarded by detectingEscalatedAt metadata so it fires once per escalation, not on every poll while stuck. Plus 5 new tests covering the call shapes and the idempotency guard. Invariants preserved: - Auto-cleanup events fire AFTER existing observer.recordOperation (B1) - detecting.escalated emits ONCE per escalation (invariant B9 in .context/lifecycle-manager-instrumentation.md) - poll_failed emits inside the existing pollAll catch — flow unchanged * feat(core): wire report_watcher.triggered activity event Adds AE evidence when the report watcher fires (no_acknowledge / stale_report / agent_needs_input). RCA: 'AO thinks my agent is stuck — why?' - report_watcher.triggered (level=warn) — emitted alongside the existing observer.recordOperation, only when a trigger is non-null (per invariant in .context/lifecycle-manager-instrumentation.md §B9) Plus 1 test exercising the no_acknowledge trigger path. * fix(core): one-shot guard on report_watcher.triggered AE emit Live-observed regression: report_watcher.triggered fired 116 times in production over a few hours because the emit was unguarded and re-fired every 30s poll while a trigger stayed active. Symptom was massive event flood for stuck/no-acknowledge/stale conditions. Fix: gate the emit on the existing isNewTrigger variable (same one-shot guard pattern used for detecting.escalated). The observer.recordOperation above remains unguarded by design (it's a metric/heartbeat); the AE trail is for actionable evidence only. Adds a regression test that drives the same trigger across two polls and asserts the AE event fires only on the first. * fix(core): address Greptile feedback on PR #1620 Two findings from Greptile (issue same as Codex P2 #1): 1. scm.batch_enrich_failed omitted projectId/sessionId — when the lifecycle worker is project-scoped (deps.projectId set), this event is effectively project-scoped too. Without projectId, queries like `ao events list --project todo-app --type scm.batch_enrich_failed` return zero results, defeating the purpose of the instrumentation. Fix: pass scopedProjectId when set. Unscoped (multi-project) supervisors still leave projectId null because the batch crosses project boundaries. 2. Misleading field name pendingSinceMs in session.auto_cleanup_deferred data — the local variable of the same name is a Unix epoch timestamp, but the data field stored `Date.now() - pendingSinceMs` (an elapsed duration). RCA agents would mis-interpret it as a timestamp and compute a 1970-era "pending since" date. Renamed to pendingElapsedMs. * fix(core): address Codex review on PR #1620 - lifecycle.poll_failed: keep summary generic, route raw error text through `data.errorMessage` only. sanitizeSummary just truncates; sanitizeData redacts credential URLs. Since FTS5 indexes summary, interpolating subprocess error output (which can include https://x-oauth-basic:TOKEN@github.com/... from git/gh) made credentials persistently searchable. - reaction.escalated: expand escalationCause to "max_retries" | "max_attempts" | "max_duration" and mirror the trigger checks. Numeric escalateAfter is an attempt-count gate, not a duration; previously got misattributed to "max_duration" whenever retries was unset (built-in defaults use {escalateAfter: 2}). Adds two regression tests as guards for both behaviors. Co-Authored-By: Claude Opus 4.7 * fix(core): replace import() type annotation with import type to satisfy lint CI's @typescript-eslint/consistent-type-imports rule rejects inline `typeof import("../activity-events.js")` inside the vi.mock factory. Hoist it to a top-level `import type * as ActivityEventsModule` so the type lives in a proper import declaration; vi.mock factory resolution is unaffected (type-only imports emit no runtime code). Co-Authored-By: Claude Opus 4.7 * fix(core): keep report_watcher.triggered summary generic to plug FTS leak auditResult.message for the agent_needs_input trigger embeds the free-form report.note supplied via `ao report --note "..."`. Since sanitizeSummary only truncates and FTS5 indexes the summary column, a note containing a credential URL would be persistently searchable from the events DB. Same class of bug as the prior poll_failed fix. Summary becomes generic (" triggered"); the full message continues to flow through `data.message` where sanitizeData redacts credential URLs. Adds a regression test that seeds a needs_input report with a credential-bearing note and asserts the summary stays clean. Reported by @ashish921998 in PR #1620. Co-Authored-By: Claude Opus 4.7 * fix(core): redact token-shaped secrets in activity-event data (P1) Both `summary` and `data` columns are FTS5-indexed (events-db.ts:58-59). Prior fixes moved raw error/report text from `summary` to `data.message` / `data.errorMessage`, on the assumption that sanitizeData() would scrub it. That assumption was incomplete: sanitizeData only redacted credential URLs and entire values under sensitive *key* names. Token-shaped substrings (`Bearer …`, `ghp_…`, `sk-…`, JWTs, `AKIA…`, ALL_CAPS_TOKEN=value) under non-sensitive keys like `message`/`errorMessage` were stored as-is and made searchable via FTS. Adds a TOKEN_PATTERNS array applied to every string value during sanitization, plus a 500-char per-string cap (matching sanitizeSummary's existing precedent — limits blast radius if a new token format slips past the patterns). Patterns cover: Bearer headers, GitHub PATs (classic + fine-grained), OpenAI/Anthropic sk- keys, Slack xox- tokens, AWS access key IDs, JWTs, and ENV-style assignments scoped to ALL_CAPS keys ending in TOKEN/PASSWORD/SECRET/etc. Tests: - 10 new sanitizeString unit tests (one per token shape + prose-preservation regression guard + 500-char cap + nested array/object recursion) - 1 new FTS5 integration test that drives recordActivityEvent → real SQLite → both direct row read and FTS MATCH must return zero token leakage Test fixtures use string concatenation across the prefix boundary so literal token shapes don't appear in source (gitleaks pre-commit guard). Reported by @ashish921998 in PR #1620. Co-Authored-By: Claude Opus 4.7 * fix(core): bound credential-URL regex to prevent ReDoS (CodeQL alert) CodeQL flagged CREDENTIAL_URL_RE as polynomial: input shaped like `http://http://http://...` with no terminating `@` caused O(n²) backtracking because the unbounded `[^@\s]+` greedily spanned multiple `http://` prefixes before failing at end-of-string and walking back. Two-part fix: 1. Exclude `/` from the userinfo character class — this is also semantically correct since RFC 3986 userinfo cannot contain unencoded `/`. 2. Add a hard length cap (200 chars) on the userinfo segment as a belt-and- braces guard against future pathological inputs. The fix is observable: 14KB pathological input completes in single-digit ms post-fix vs multiple seconds pre-fix. Adds a regression test that runs the pathological input through the full sanitize pipeline and asserts <100ms completion. Reported by GitHub Advanced Security on PR #1620. Co-Authored-By: Claude Opus 4.7 * fix(core): replace CREDENTIAL_URL_RE regex with linear scan The bounded {1,200} quantifier in CREDENTIAL_URL_RE let credential URLs with >200-char userinfo pass through unredacted. Since data is FTS5-indexed, those credentials became searchable (P1 from PR #1620 review). Replace the regex with a simple linear scan (redactCredentialUrls) that: - Has no length limit — scans until @, space, or / - Is O(n) with no regex backtracking (fixes CodeQL polynomial-regex alert) - Matches http:// and https:// case-insensitively (preserves old /gi behavior) Adds regression tests for: - >200-char userinfo bypass - URLs without userinfo (no false positives) - Multiple credential URLs in one string - Pathological ReDoS-shaped input still completes in <100ms --------- Co-authored-by: Claude Opus 4.7 Co-authored-by: AO Bot --- .../src/__tests__/activity-events.test.ts | 172 ++++ .../__tests__/events-fts-integration.test.ts | 55 + .../lifecycle-manager-instrumentation.test.ts | 962 ++++++++++++++++++ packages/core/src/activity-events.ts | 129 ++- packages/core/src/lifecycle-manager.ts | 310 +++++- 5 files changed, 1611 insertions(+), 17 deletions(-) create mode 100644 packages/core/src/__tests__/lifecycle-manager-instrumentation.test.ts diff --git a/packages/core/src/__tests__/activity-events.test.ts b/packages/core/src/__tests__/activity-events.test.ts index 14bc39437..92373f651 100644 --- a/packages/core/src/__tests__/activity-events.test.ts +++ b/packages/core/src/__tests__/activity-events.test.ts @@ -213,4 +213,176 @@ describe("recordActivityEvent", () => { expect((capturedSummary as string).length).toBe(500); expect(capturedSummary).toMatch(/\.\.\.$/); }); + + // ─── Token-shape redaction (sanitizeString) ─────────────────────────────── + // These tests cover the P1 finding from PR #1620 review: free-form strings + // under non-sensitive keys (data.message, data.errorMessage) used to leak + // bare tokens through to the FTS-indexed `data` column. sanitizeString now + // redacts known token shapes anywhere in a string value. + + function recordAndCaptureData(input: Record): Record { + let capturedData: unknown; + const captureDb = { + prepare: (_sql: string) => ({ + run: (...args: unknown[]) => { + capturedData = args[8]; // data is 9th param (index 8) + }, + all: () => [], + }), + }; + vi.mocked(eventsDb.getDb).mockReturnValueOnce(captureDb as any); + recordActivityEvent({ + source: "lifecycle", + kind: "lifecycle.poll_failed", + summary: "test", + data: input, + }); + return JSON.parse(capturedData as string); + } + + it("redacts Bearer tokens in string values (preserves prefix)", () => { + const out = recordAndCaptureData({ + errorMessage: "401 from https://api.example.com Bearer eyJhbGciOiJIUzI1NiJ9.abc", + }); + expect(out["errorMessage"]).not.toContain("eyJhbGciOiJIUzI1NiJ9"); + expect(out["errorMessage"]).toContain("Bearer [redacted]"); + }); + + it("redacts GitHub PATs (ghp_, gho_, github_pat_) in error messages", () => { + const out = recordAndCaptureData({ + errorMessage: "git push failed: bad credentials ghp_abcdefghijklmnopqrstuvwxyz12345", + message: "trying github_pat_11ABCDEFG0abcdefghijklmnopqrstuvwxyz1234567890", + }); + expect(out["errorMessage"]).not.toContain("ghp_abcdefghijklmnopqrstuvwxyz"); + expect(out["errorMessage"]).toContain("[redacted]"); + expect(out["message"]).not.toContain("github_pat_11"); + expect(out["message"]).toContain("[redacted]"); + }); + + it("redacts OpenAI / Anthropic sk- keys in free-form values", () => { + const out = recordAndCaptureData({ + message: "stuck on sk-proj-abcdefghijklmnopqrstuvwx returns 429", + errorMessage: "auth failed for sk-ant-api03-abcdefghijklmnopqrst-xyz", + }); + expect(out["message"]).not.toContain("sk-proj-abcdefghijklmnopqrstuvwx"); + expect(out["errorMessage"]).not.toContain("sk-ant-api03"); + expect(out["message"]).toContain("[redacted]"); + expect(out["errorMessage"]).toContain("[redacted]"); + }); + + it("redacts Slack xox tokens", () => { + const out = recordAndCaptureData({ + errorMessage: "webhook rejected with xoxb-1234567890-abcdefghij", + }); + expect(out["errorMessage"]).not.toContain("xoxb-1234567890"); + expect(out["errorMessage"]).toContain("[redacted]"); + }); + + it("redacts AWS access key IDs (AKIA...)", () => { + const out = recordAndCaptureData({ + errorMessage: "s3 upload failed for AKIAIOSFODNN7EXAMPLE", + }); + expect(out["errorMessage"]).not.toContain("AKIAIOSFODNN7EXAMPLE"); + expect(out["errorMessage"]).toContain("[redacted]"); + }); + + it("redacts JWTs (three base64url segments with eyJ prefix)", () => { + // Build the JWT string at runtime so the literal pattern doesn't appear + // in source (gitleaks pre-commit hook flags real-shaped JWT literals). + const jwt = "ey" + "JTESTHEADERabcde" + "." + "TESTPAYLOADabcdef" + "." + "TESTSIGNATUREabc"; + const out = recordAndCaptureData({ message: `token=${jwt} expired` }); + expect(out["message"]).not.toContain("JTESTHEADERabcde"); + expect(out["message"]).toContain("[redacted]"); + }); + + it("redacts ENV-style assignments (ALL_CAPS_KEY=value with sensitive suffix)", () => { + const out = recordAndCaptureData({ + message: "agent reported: OPENAI_API_KEY=sk-test-abcdefghijklmnopqr returns 429", + errorMessage: "config: GITHUB_TOKEN=ghp_xyz_invalid + DATABASE_URL=postgres://x", + }); + // The ENV assignment redacts to KEY=[redacted]; the inner sk-/ghp_ also + // matches its own pattern. Either way the secret value is gone. + expect(out["message"]).not.toContain("sk-test-abcdefghijklmnopqr"); + expect(out["errorMessage"]).not.toContain("ghp_xyz_invalid"); + expect(out["message"]).toContain("[redacted]"); + expect(out["errorMessage"]).toContain("[redacted]"); + }); + + it("preserves prose that mentions sensitive words but isn't token-shaped", () => { + // Regression guard for the existing "preserves error messages that mention + // sensitive words in values" behavior — Greptile's earlier finding noted + // this is intentional. Pattern-redaction must not over-match plain prose. + const out = recordAndCaptureData({ + reason: "token expired", + message: "authorization header missing", + detail: "the cookie was rejected", + note: "user pressed cancel on password prompt", + }); + expect(out["reason"]).toBe("token expired"); + expect(out["message"]).toBe("authorization header missing"); + expect(out["detail"]).toBe("the cookie was rejected"); + expect(out["note"]).toBe("user pressed cancel on password prompt"); + }); + + it("caps individual string values at 500 chars (matches sanitizeSummary cap)", () => { + const out = recordAndCaptureData({ + stack: "x".repeat(600), + }); + expect((out["stack"] as string).length).toBe(500); + expect(out["stack"]).toMatch(/\.\.\.$/); + }); + + it("redacts tokens nested in arrays and objects", () => { + const out = recordAndCaptureData({ + attempts: [ + { url: "https://api.x", error: "Bearer eyJhbGciOiJIUzI1NiJ9.payload.sig failed" }, + { url: "https://api.y", error: "ghp_abcdefghijklmnopqrstuvwxyz12345 invalid" }, + ], + }); + const attempts = out["attempts"] as Array>; + expect(attempts[0]!["error"]).toContain("Bearer [redacted]"); + expect(attempts[0]!["error"]).not.toContain("eyJhbGciOiJIUzI1NiJ9"); + expect(attempts[1]!["error"]).toContain("[redacted]"); + expect(attempts[1]!["error"]).not.toContain("ghp_abc"); + }); + + it("REGRESSION: redactCredentialUrls handles pathological input in <100ms (was ReDoS)", () => { + // Replaced the regex-based CREDENTIAL_URL_RE with a linear scan — no + // backtracking possible. Kept as a regression guard in case of regression. + const pathological = "http://".repeat(2000); // ~14KB, ~2000 prefix repetitions, no @ + const start = Date.now(); + const out = recordAndCaptureData({ errorMessage: pathological }); + const elapsed = Date.now() - start; + expect(elapsed).toBeLessThan(100); + expect((out["errorMessage"] as string).length).toBeLessThanOrEqual(500); + }); + + it("REGRESSION: redactCredentialUrls handles >200-char userinfo (P1 from PR #1620 review)", () => { + // The previous CREDENTIAL_URL_RE had {1,200} which let userinfo >200 chars + // pass through unredacted. The linear scan has no length limit. + const longPass = "a".repeat(300); + const input = `https://user:${longPass}@github.com/org/repo.git`; + const out = recordAndCaptureData({ remoteUrl: input }); + const result = out["remoteUrl"] as string; + expect(result).not.toContain(longPass); + expect(result).toContain("[redacted]"); + }); + + it("redactCredentialUrls does not touch URLs without userinfo", () => { + const input = "https://github.com/org/repo.git pushed successfully"; + const out = recordAndCaptureData({ message: input }); + expect(out["message"]).toBe(input); + }); + + it("redactCredentialUrls handles multiple credential URLs in one string", () => { + const input = "remote: https://token123@github.com/a.git origin: https://pass@github.com/b.git"; + const out = recordAndCaptureData({ message: input }); + const result = out["message"] as string; + expect(result).not.toContain("token123"); + expect(result).not.toContain("pass"); + expect(result).toMatch(/\[redacted\]/g); + // Should still contain the host parts + expect(result).toContain("github.com/a.git"); + expect(result).toContain("github.com/b.git"); + }); }); diff --git a/packages/core/src/__tests__/events-fts-integration.test.ts b/packages/core/src/__tests__/events-fts-integration.test.ts index 671ab5214..81f8337ce 100644 --- a/packages/core/src/__tests__/events-fts-integration.test.ts +++ b/packages/core/src/__tests__/events-fts-integration.test.ts @@ -168,4 +168,59 @@ describe("FTS5 integration (real SQLite)", () => { expect(recent.some((e) => e.summary === "new event")).toBe(true); expect(recent.every((e) => e.summary !== "old event")).toBe(true); }); + + // ─── Token-shape redaction regression (PR #1620 P1) ──────────────────────── + // The `data` column is FTS-indexed alongside `summary`. Before this fix, a + // free-form value like `data.errorMessage = "Bearer eyJ..."` would land in + // the events DB unredacted and become searchable. This test drives the full + // pipeline: real recordActivityEvent → real SQLite write → FTS5 trigger → + // both direct SELECT and FTS MATCH must report zero token leakage. + + itIfAvailable("REGRESSION: token-shaped values in data don't survive in stored row or FTS index", () => { + // Sentinels are built dynamically (string concatenation across the prefix + // boundary) so the literal token shapes don't appear in source — gitleaks' + // pre-commit hook would otherwise flag these as real leaked secrets. + const sentinels = { + bearer: "ey" + "JfakeTOKENxyz0abcdefghijklmnop.payload.sig", + ghPat: "gh" + "p_REGRESSIONfakeABCDEFGHIJKLMN1234", + openAi: "sk-" + "proj-REGRESSIONfakeXYZabc123456", + slack: "xox" + "b-9999999999-REGRESSIONfakeABCDE", + aws: "AKIA" + "REGRESSIONFAKE12", // AKIA + 16 alphanumeric per AWS spec + }; + + recordActivityEvent({ + source: "lifecycle", + kind: "lifecycle.poll_failed", + summary: "poll cycle failed", + data: { + // Mirror the real leak vector: free-form text under non-sensitive keys. + errorMessage: `git push failed: 401 Bearer ${sentinels.bearer}`, + message: `agent reported OPENAI_API_KEY=${sentinels.openAi} returns 429`, + nested: { + headers: { url: `https://api.example.com with ${sentinels.ghPat}` }, + attempts: [ + `xoxb webhook rejected: ${sentinels.slack}`, + `s3 PutObject failed for ${sentinels.aws}`, + ], + }, + }, + }); + + // 1. Direct row read — none of the sentinels survive in stored data. + const rows = db + .prepare("SELECT data FROM activity_events WHERE type = 'lifecycle.poll_failed'") + .all() as Array<{ data: string }>; + expect(rows).toHaveLength(1); + const storedData = rows[0]!.data; + for (const sentinel of Object.values(sentinels)) { + expect(storedData).not.toContain(sentinel); + } + expect(storedData).toContain("[redacted]"); + + // 2. FTS MATCH — searching for any sentinel returns zero hits. + for (const [name, sentinel] of Object.entries(sentinels)) { + const matches = searchActivityEvents(sentinel); + expect(matches, `FTS leak for ${name}: '${sentinel}' is searchable`).toHaveLength(0); + } + }); }); diff --git a/packages/core/src/__tests__/lifecycle-manager-instrumentation.test.ts b/packages/core/src/__tests__/lifecycle-manager-instrumentation.test.ts new file mode 100644 index 000000000..70b0b0296 --- /dev/null +++ b/packages/core/src/__tests__/lifecycle-manager-instrumentation.test.ts @@ -0,0 +1,962 @@ +/** + * Tests for activity-event emits added to lifecycle-manager's silent + * failure paths (scm.review_fetch_failed, scm.poll_pr_failed). + * + * Design choices: + * - vi.mock("../activity-events.js") at module scope so the emit calls in + * lifecycle-manager become inspectable via vi.mocked. + * - Reuses createMockSCM/createMockNotifier from test-utils so the SCM/Notifier + * surface stays in sync with the project's existing test patterns. + * - One test per event for the happy "emit fires" path, plus one cross-cutting + * test that proves the lifecycle check completes successfully even if + * recordActivityEvent itself throws (B2 invariant). + * + * Notifier instrumentation is intentionally omitted — the notifier subsystem + * is undergoing larger work and AE evidence there is not currently useful. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import type * as ActivityEventsModule from "../activity-events.js"; + +vi.mock("../activity-events.js", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + recordActivityEvent: vi.fn(), + }; +}); + +import { recordActivityEvent } from "../activity-events.js"; +import { createLifecycleManager } from "../lifecycle-manager.js"; +import { writeMetadata } from "../metadata.js"; +import type { + OpenCodeSessionManager, + OrchestratorConfig, + PRInfo, + PluginRegistry, + SessionMetadata, +} from "../types.js"; +import { + createMockNotifier, + createMockPlugins, + createMockRegistry, + createMockSCM, + createMockSessionManager, + createTestEnvironment, + makePR, + makeSession, + type MockPlugins, + type TestEnvironment, +} from "./test-utils.js"; + +let env: TestEnvironment; +let plugins: MockPlugins; +let mockSessionManager: OpenCodeSessionManager; +let config: OrchestratorConfig; + +beforeEach(() => { + env = createTestEnvironment(); + plugins = createMockPlugins(); + mockSessionManager = createMockSessionManager(); + config = env.config; + vi.mocked(recordActivityEvent).mockClear(); +}); + +afterEach(() => { + env.cleanup(); +}); + +/** Helper: persist session metadata + register the session with the mock manager. */ +function persistSession( + sessionId: string, + session: ReturnType, + metaOverrides: Record = {}, +) { + const persistedMetadata: Record = { + worktree: "/tmp", + branch: session.branch ?? "main", + status: session.status, + project: "my-app", + runtimeHandle: session.runtimeHandle ?? undefined, + ...metaOverrides, + }; + const persistedStringMetadata = Object.fromEntries( + Object.entries(persistedMetadata).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ), + ); + + const enriched = { + ...session, + metadata: { ...session.metadata, ...persistedStringMetadata }, + }; + + vi.mocked(mockSessionManager.get).mockResolvedValue(enriched); + vi.mocked(mockSessionManager.list).mockResolvedValue([enriched]); + writeMetadata(env.sessionsDir, sessionId, persistedMetadata as unknown as SessionMetadata); + return enriched; +} + +function buildLM(registry: PluginRegistry) { + return createLifecycleManager({ config, registry, sessionManager: mockSessionManager }); +} + +function makeMatchingPR(overrides: Partial = {}): PRInfo { + return makePR({ owner: "org", repo: "my-app", ...overrides }); +} + +// --------------------------------------------------------------------------- +// scm.review_fetch_failed +// --------------------------------------------------------------------------- + +describe("scm.review_fetch_failed", () => { + it("records an AE event when scm.getReviewThreads throws during review backlog dispatch", async () => { + const mockSCM = createMockSCM({ + getReviewThreads: vi.fn().mockRejectedValue(new Error("403 forbidden")), + }); + const registry = createMockRegistry({ + runtime: plugins.runtime, + agent: plugins.agent, + scm: mockSCM, + notifier: createMockNotifier(), + }); + + const session = makeSession({ status: "pr_open", pr: makeMatchingPR() }); + persistSession("app-1", session); + + const lm = buildLM(registry); + await lm.check("app-1"); + + const calls = vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0]); + const reviewFailures = calls.filter((c) => c.kind === "scm.review_fetch_failed"); + expect(reviewFailures).toHaveLength(1); + + const ev = reviewFailures[0]!; + expect(ev.source).toBe("scm"); + expect(ev.level).toBe("warn"); + expect(ev.summary).toContain("review fetch failed for PR #42"); + expect(ev.projectId).toBe(session.projectId); + expect(ev.sessionId).toBe(session.id); + expect(ev.data).toMatchObject({ + prNumber: 42, + prUrl: "https://github.com/org/my-app/pull/42", + errorMessage: "403 forbidden", + }); + }); +}); + +// --------------------------------------------------------------------------- +// scm.poll_pr_failed +// --------------------------------------------------------------------------- + +describe("scm.poll_pr_failed", () => { + it("records an AE event when scm.getPRState throws on the cache-miss fallback", async () => { + // Force a cache miss by returning an empty enrichment map, then make + // getPRState throw — exercises the inner try/catch at lifecycle-manager.ts:1053. + const mockSCM = createMockSCM({ + enrichSessionsPRBatch: vi.fn().mockResolvedValue(new Map()), + getPRState: vi.fn().mockRejectedValue(new Error("rate limited")), + }); + const registry = createMockRegistry({ + runtime: plugins.runtime, + agent: plugins.agent, + scm: mockSCM, + notifier: createMockNotifier(), + }); + + const session = makeSession({ status: "pr_open", pr: makeMatchingPR() }); + persistSession("app-1", session); + + const lm = buildLM(registry); + await lm.check("app-1"); + + const calls = vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0]); + const pollFailures = calls.filter((c) => c.kind === "scm.poll_pr_failed"); + expect(pollFailures).toHaveLength(1); + + const ev = pollFailures[0]!; + expect(ev.source).toBe("scm"); + expect(ev.level).toBe("warn"); + expect(ev.summary).toContain("getPRState failed for PR #42"); + expect(ev.data).toMatchObject({ + prNumber: 42, + prUrl: "https://github.com/org/my-app/pull/42", + errorMessage: "rate limited", + }); + }); +}); + +// --------------------------------------------------------------------------- +// scm.batch_enrich_failed +// --------------------------------------------------------------------------- + +describe("scm.batch_enrich_failed", () => { + it("records an AE event when scm.enrichSessionsPRBatch throws", async () => { + const mockSCM = createMockSCM({ + enrichSessionsPRBatch: vi.fn().mockRejectedValue(new Error("rate limited")), + }); + const registry = createMockRegistry({ + runtime: plugins.runtime, + agent: plugins.agent, + scm: mockSCM, + notifier: createMockNotifier(), + }); + + const session = makeSession({ status: "pr_open", pr: makeMatchingPR() }); + persistSession("app-1", session); + + const lm = buildLM(registry); + await lm.check("app-1"); + + const calls = vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0]); + const batchFailures = calls.filter((c) => c.kind === "scm.batch_enrich_failed"); + expect(batchFailures.length).toBeGreaterThan(0); + + const ev = batchFailures[0]!; + expect(ev.source).toBe("scm"); + expect(ev.level).toBe("warn"); + expect(ev.summary).toContain("batch_enrich failed"); + expect(ev.data).toMatchObject({ + plugin: "github", + prCount: 1, + errorMessage: "rate limited", + }); + }); +}); + +// --------------------------------------------------------------------------- +// scm.detect_pr_succeeded / scm.detect_pr_failed +// --------------------------------------------------------------------------- + +describe("scm.detect_pr", () => { + it("emits scm.detect_pr_succeeded when scm.detectPR finds a PR for a previously-PR-less session", async () => { + const detectedPR = makeMatchingPR({ number: 99, url: "https://github.com/org/my-app/pull/99" }); + const mockSCM = createMockSCM({ + detectPR: vi.fn().mockResolvedValue(detectedPR), + }); + const registry = createMockRegistry({ + runtime: plugins.runtime, + agent: plugins.agent, + scm: mockSCM, + notifier: createMockNotifier(), + }); + + const session = makeSession({ status: "working" }); + persistSession("app-1", session); + + const lm = buildLM(registry); + await lm.check("app-1"); + + const calls = vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0]); + const events = calls.filter((c) => c.kind === "scm.detect_pr_succeeded"); + expect(events).toHaveLength(1); + expect(events[0]!.data).toMatchObject({ prNumber: 99 }); + }); + + it("emits scm.detect_pr_failed when scm.detectPR throws", async () => { + const mockSCM = createMockSCM({ + detectPR: vi.fn().mockRejectedValue(new Error("403 forbidden")), + }); + const registry = createMockRegistry({ + runtime: plugins.runtime, + agent: plugins.agent, + scm: mockSCM, + notifier: createMockNotifier(), + }); + + const session = makeSession({ status: "working" }); + persistSession("app-1", session); + + const lm = buildLM(registry); + await lm.check("app-1"); + + const calls = vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0]); + const events = calls.filter((c) => c.kind === "scm.detect_pr_failed"); + expect(events).toHaveLength(1); + expect(events[0]!.data).toMatchObject({ errorMessage: "403 forbidden" }); + }); +}); + +// --------------------------------------------------------------------------- +// runtime.probe_failed +// --------------------------------------------------------------------------- + +describe("runtime.probe_failed", () => { + it("records an AE event when runtime.isAlive throws", async () => { + vi.mocked(plugins.runtime.isAlive).mockRejectedValue(new Error("kill -0 EPERM")); + + const registry = createMockRegistry({ + runtime: plugins.runtime, + agent: plugins.agent, + scm: createMockSCM(), + notifier: createMockNotifier(), + }); + + const session = makeSession({ status: "working" }); + persistSession("app-1", session); + + const lm = buildLM(registry); + await lm.check("app-1"); + + const calls = vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0]); + const events = calls.filter((c) => c.kind === "runtime.probe_failed"); + expect(events.length).toBeGreaterThan(0); + expect(events[0]!.source).toBe("runtime"); + expect(events[0]!.data).toMatchObject({ errorMessage: "kill -0 EPERM" }); + }); +}); + +// --------------------------------------------------------------------------- +// agent.activity_probe_failed +// --------------------------------------------------------------------------- + +describe("agent.activity_probe_failed", () => { + it("records an AE event when the activity probing block throws", async () => { + vi.mocked(plugins.agent.getActivityState).mockRejectedValue(new Error("native probe died")); + + const registry = createMockRegistry({ + runtime: plugins.runtime, + agent: plugins.agent, + scm: createMockSCM(), + notifier: createMockNotifier(), + }); + + const session = makeSession({ status: "working" }); + persistSession("app-1", session); + + const lm = buildLM(registry); + await lm.check("app-1"); + + const calls = vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0]); + const events = calls.filter((c) => c.kind === "agent.activity_probe_failed"); + expect(events.length).toBeGreaterThan(0); + expect(events[0]!.source).toBe("agent"); + expect(events[0]!.data).toMatchObject({ errorMessage: "native probe died" }); + }); +}); + +// --------------------------------------------------------------------------- +// agent.process_probe_failed (standalone path) +// --------------------------------------------------------------------------- + +describe("agent.process_probe_failed", () => { + it("records an AE event with where=standalone when isProcessRunning throws on the standalone probe", async () => { + // Drive activity probe to return null + force standalone path: + // - getActivityState resolves null → falls into terminal-output fallback + // - getOutput returns empty → no terminal-fallback isProcessRunning call + // - then standalone isProcessRunning at line ~1132 fires (processProbe still unknown) + vi.mocked(plugins.agent.getActivityState).mockResolvedValue(null); + vi.mocked(plugins.runtime.getOutput).mockResolvedValue(""); + vi.mocked(plugins.agent.isProcessRunning).mockRejectedValue(new Error("ps lookup failed")); + + const registry = createMockRegistry({ + runtime: plugins.runtime, + agent: plugins.agent, + scm: createMockSCM(), + notifier: createMockNotifier(), + }); + + const session = makeSession({ status: "working" }); + persistSession("app-1", session); + + const lm = buildLM(registry); + await lm.check("app-1"); + + const calls = vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0]); + const events = calls.filter((c) => c.kind === "agent.process_probe_failed"); + expect(events.length).toBeGreaterThan(0); + expect(events[0]!.source).toBe("agent"); + expect(events[0]!.data).toMatchObject({ + where: "standalone", + errorMessage: "ps lookup failed", + }); + }); +}); + +// --------------------------------------------------------------------------- +// reaction.escalated / reaction.send_to_agent_failed / reaction.action_succeeded +// --------------------------------------------------------------------------- + +/** Build an SCM mock whose batch enrichment drives the session to ci_failed. */ +function makeCiFailedScm(overrides: Partial[0]> = {}) { + return createMockSCM({ + enrichSessionsPRBatch: vi.fn().mockImplementation(async (prs: PRInfo[]) => { + const result = new Map(); + for (const pr of prs) { + result.set(`${pr.owner}/${pr.repo}#${pr.number}`, { + state: "open", + ciStatus: "failing", + reviewDecision: "none", + mergeable: false, + ciChecks: [{ name: "test", status: "failed" }], + }); + } + return result; + }), + ...overrides, + }); +} + +describe("reaction.action_succeeded", () => { + it("emits AE on successful send-to-agent reaction", async () => { + config.reactions = { + "ci-failed": { auto: true, action: "send-to-agent", message: "fix CI", retries: 5 }, + }; + vi.mocked(mockSessionManager.send).mockResolvedValue(undefined); + + const registry = createMockRegistry({ + runtime: plugins.runtime, + agent: plugins.agent, + scm: makeCiFailedScm(), + notifier: createMockNotifier(), + }); + + const session = makeSession({ status: "pr_open", pr: makeMatchingPR() }); + persistSession("app-1", session); + + const lm = buildLM(registry); + await lm.check("app-1"); + + const calls = vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0]); + const events = calls.filter((c) => c.kind === "reaction.action_succeeded"); + expect(events.length).toBeGreaterThan(0); + + const ev = events[0]!; + expect(ev.source).toBe("reaction"); + expect(ev.data).toMatchObject({ + reactionKey: "ci-failed", + action: "send-to-agent", + }); + }); +}); + +describe("reaction.send_to_agent_failed", () => { + it("emits AE when sessionManager.send throws inside a send-to-agent reaction", async () => { + config.reactions = { + "ci-failed": { auto: true, action: "send-to-agent", message: "fix CI", retries: 5 }, + }; + vi.mocked(mockSessionManager.send).mockRejectedValue(new Error("agent unreachable")); + + const registry = createMockRegistry({ + runtime: plugins.runtime, + agent: plugins.agent, + scm: makeCiFailedScm(), + notifier: createMockNotifier(), + }); + + const session = makeSession({ status: "pr_open", pr: makeMatchingPR() }); + persistSession("app-1", session); + + const lm = buildLM(registry); + await lm.check("app-1"); + + const calls = vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0]); + const events = calls.filter((c) => c.kind === "reaction.send_to_agent_failed"); + expect(events).toHaveLength(1); + expect(events[0]!.data).toMatchObject({ + reactionKey: "ci-failed", + errorMessage: "agent unreachable", + }); + }); +}); + +describe("reaction.escalated", () => { + it("emits AE with escalationCause=max_retries when attempts exceed retries", async () => { + // retries: 0 → first attempt escalates (attempts=1 > 0) + config.reactions = { + "ci-failed": { auto: true, action: "send-to-agent", message: "fix CI", retries: 0, priority: "urgent" }, + }; + vi.mocked(mockSessionManager.send).mockResolvedValue(undefined); + + const registry = createMockRegistry({ + runtime: plugins.runtime, + agent: plugins.agent, + scm: makeCiFailedScm(), + notifier: createMockNotifier(), + }); + + const session = makeSession({ status: "pr_open", pr: makeMatchingPR() }); + persistSession("app-1", session); + + const lm = buildLM(registry); + await lm.check("app-1"); + + const calls = vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0]); + const events = calls.filter((c) => c.kind === "reaction.escalated"); + expect(events).toHaveLength(1); + expect(events[0]!.data).toMatchObject({ + reactionKey: "ci-failed", + attempts: 1, + escalationCause: "max_retries", + }); + }); + + it("REGRESSION: escalationCause=max_attempts when numeric escalateAfter triggers (no retries cfg)", async () => { + // Numeric escalateAfter is an attempt-count gate, not a duration. Without + // `retries`, maxRetries defaults to Infinity, so the max_retries branch + // never fires. The cause must reflect the actual triggering check + // (numeric threshold), not be misattributed to "max_duration" — there + // was no time-based check involved at all. + config.reactions = { + "ci-failed": { + auto: true, + action: "send-to-agent", + message: "fix CI", + escalateAfter: 0, // first attempt: 1 > 0 → escalates immediately + priority: "urgent", + }, + }; + vi.mocked(mockSessionManager.send).mockResolvedValue(undefined); + + const registry = createMockRegistry({ + runtime: plugins.runtime, + agent: plugins.agent, + scm: makeCiFailedScm(), + notifier: createMockNotifier(), + }); + + const session = makeSession({ status: "pr_open", pr: makeMatchingPR() }); + persistSession("app-1", session); + + const lm = buildLM(registry); + await lm.check("app-1"); + + const events = vi + .mocked(recordActivityEvent) + .mock.calls.map((c) => c[0]) + .filter((c) => c.kind === "reaction.escalated"); + expect(events).toHaveLength(1); + expect(events[0]!.data).toMatchObject({ + reactionKey: "ci-failed", + attempts: 1, + escalationCause: "max_attempts", + }); + }); +}); + +// --------------------------------------------------------------------------- +// session.auto_cleanup_{deferred,completed,failed} +// --------------------------------------------------------------------------- + +/** SCM mock whose batch enrichment drives session into MERGED status. */ +function makeMergedScm() { + return createMockSCM({ + getPRState: vi.fn().mockResolvedValue("merged"), + enrichSessionsPRBatch: vi.fn().mockImplementation(async (prs: PRInfo[]) => { + const result = new Map(); + for (const pr of prs) { + result.set(`${pr.owner}/${pr.repo}#${pr.number}`, { + state: "merged", + ciStatus: "none", + reviewDecision: "none", + mergeable: false, + }); + } + return result; + }), + }); +} + +function configWithAutoCleanup(): OrchestratorConfig { + return { + ...config, + lifecycle: { autoCleanupOnMerge: true, mergeCleanupIdleGraceMs: 300_000 }, + }; +} + +describe("session.auto_cleanup_completed", () => { + it("emits AE when sessionManager.kill succeeds after PR merges", async () => { + vi.mocked(mockSessionManager.kill).mockResolvedValue({ + cleaned: true, + alreadyTerminated: false, + }); + + const registry = createMockRegistry({ + runtime: plugins.runtime, + agent: plugins.agent, + scm: makeMergedScm(), + notifier: createMockNotifier(), + }); + + const session = makeSession({ + status: "approved", + pr: makeMatchingPR(), + activity: "idle", + }); + persistSession("app-1", session); + + const lm = createLifecycleManager({ + config: configWithAutoCleanup(), + registry, + sessionManager: mockSessionManager, + }); + await lm.check("app-1"); + + const calls = vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0]); + const events = calls.filter((c) => c.kind === "session.auto_cleanup_completed"); + expect(events).toHaveLength(1); + expect(events[0]!.source).toBe("lifecycle"); + expect(events[0]!.data).toMatchObject({ cleaned: true }); + }); +}); + +describe("session.auto_cleanup_failed", () => { + it("emits AE when sessionManager.kill throws after PR merges", async () => { + vi.mocked(mockSessionManager.kill).mockRejectedValue(new Error("worktree busy")); + + const registry = createMockRegistry({ + runtime: plugins.runtime, + agent: plugins.agent, + scm: makeMergedScm(), + notifier: createMockNotifier(), + }); + + const session = makeSession({ + status: "approved", + pr: makeMatchingPR(), + activity: "idle", + }); + persistSession("app-1", session); + + const lm = createLifecycleManager({ + config: configWithAutoCleanup(), + registry, + sessionManager: mockSessionManager, + }); + await lm.check("app-1"); + + const calls = vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0]); + const events = calls.filter((c) => c.kind === "session.auto_cleanup_failed"); + expect(events).toHaveLength(1); + expect(events[0]!.level).toBe("error"); + expect(events[0]!.data).toMatchObject({ errorMessage: "worktree busy" }); + }); +}); + +// --------------------------------------------------------------------------- +// lifecycle.poll_failed +// --------------------------------------------------------------------------- + +describe("lifecycle.poll_failed", () => { + it("emits AE when sessionManager.list throws inside pollAll", async () => { + vi.useFakeTimers(); + vi.mocked(mockSessionManager.list).mockRejectedValue(new Error("storage unreadable")); + + const registry = createMockRegistry({ + runtime: plugins.runtime, + agent: plugins.agent, + scm: createMockSCM(), + notifier: createMockNotifier(), + }); + + // pollAll only emits per-project poll metrics when scopedProjectId is set. + // Force it via the deps. + const lm = createLifecycleManager({ + config, + registry, + sessionManager: mockSessionManager, + projectId: "my-app", + }); + + try { + lm.start(60_000); + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(60_000); + + const calls = vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0]); + const events = calls.filter((c) => c.kind === "lifecycle.poll_failed"); + expect(events.length).toBeGreaterThan(0); + expect(events[0]!.level).toBe("error"); + expect(events[0]!.data).toMatchObject({ errorMessage: "storage unreadable" }); + } finally { + lm.stop(); + vi.useRealTimers(); + } + }); + + it("REGRESSION: summary must not interpolate raw error text (credential leak via FTS)", async () => { + // sanitizeSummary only truncates; sanitizeData (which runs on `data`) + // redacts credential URLs. Because activity_events_fts indexes summary, + // any credential interpolated into summary becomes searchable from the DB. + // Lifecycle code must keep summary generic and put error reasons in `data`. + vi.useFakeTimers(); + const credentialUrl = "https://x-oauth-basic:SECRET_TOKEN_xyz123@github.com/foo/bar.git"; + vi.mocked(mockSessionManager.list).mockRejectedValue( + new Error(`fatal: unable to access '${credentialUrl}/'`), + ); + + const registry = createMockRegistry({ + runtime: plugins.runtime, + agent: plugins.agent, + scm: createMockSCM(), + notifier: createMockNotifier(), + }); + + const lm = createLifecycleManager({ + config, + registry, + sessionManager: mockSessionManager, + projectId: "my-app", + }); + + try { + lm.start(60_000); + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(60_000); + + const events = vi + .mocked(recordActivityEvent) + .mock.calls.map((c) => c[0]) + .filter((c) => c.kind === "lifecycle.poll_failed"); + expect(events.length).toBeGreaterThan(0); + + // Summary stays generic — no secret material. + expect(events[0]!.summary).not.toContain("SECRET_TOKEN"); + expect(events[0]!.summary).not.toContain("x-oauth-basic"); + + // Error reason still surfaces — but only via `data`, where it's sanitized. + expect(events[0]!.data).toMatchObject({ + errorMessage: expect.stringContaining("unable to access"), + }); + } finally { + lm.stop(); + vi.useRealTimers(); + } + }); +}); + +// --------------------------------------------------------------------------- +// detecting.escalated +// --------------------------------------------------------------------------- + +describe("detecting.escalated", () => { + it("emits AE exactly once when detecting transitions to stuck via attempts limit", async () => { + // Drive determineStatus to commit STUCK by failing every probe path, + // and pre-load detectingAttempts to MAX so the next attempt escalates. + vi.mocked(plugins.runtime.isAlive).mockRejectedValue(new Error("probe down")); + vi.mocked(plugins.agent.getActivityState).mockResolvedValue(null); + vi.mocked(plugins.runtime.getOutput).mockResolvedValue(""); + vi.mocked(plugins.agent.isProcessRunning).mockRejectedValue(new Error("ps down")); + + const registry = createMockRegistry({ + runtime: plugins.runtime, + agent: plugins.agent, + scm: createMockSCM(), + notifier: createMockNotifier(), + }); + + const session = makeSession({ status: "detecting" }); + persistSession("app-1", session, { + detectingAttempts: "3", // DETECTING_MAX_ATTEMPTS — next attempt = 4 > 3 → escalates + }); + + const lm = buildLM(registry); + await lm.check("app-1"); + + const calls = vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0]); + const events = calls.filter((c) => c.kind === "detecting.escalated"); + expect(events).toHaveLength(1); + expect(events[0]!.data).toMatchObject({ cause: "max_attempts" }); + + // Idempotency guard: a second poll while still stuck must NOT re-emit. + // The first emit set detectingEscalatedAt in metadata; subsequent polls + // see it non-empty and skip the emit. + vi.mocked(recordActivityEvent).mockClear(); + await lm.check("app-1"); + const repeat = vi.mocked(recordActivityEvent).mock.calls + .map((c) => c[0]) + .filter((c) => c.kind === "detecting.escalated"); + expect(repeat).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// report_watcher.triggered +// --------------------------------------------------------------------------- + +describe("report_watcher.triggered", () => { + it("emits AE when the report watcher detects a no_acknowledge trigger", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2025-01-01T12:00:00.000Z")); + + const registry = createMockRegistry({ + runtime: plugins.runtime, + agent: plugins.agent, + scm: createMockSCM(), + notifier: createMockNotifier(), + }); + + // Session created 20 minutes ago (> default 10min ack timeout) and never reported. + const staleSession = makeSession({ + id: "app-1", + status: "working", + workspacePath: null, + createdAt: new Date("2025-01-01T11:40:00.000Z"), + metadata: { createdAt: "2025-01-01T11:40:00.000Z" }, + }); + persistSession("app-1", staleSession, { createdAt: "2025-01-01T11:40:00.000Z" }); + + const lm = createLifecycleManager({ + config, + registry, + sessionManager: mockSessionManager, + }); + + try { + await lm.check("app-1"); + + const calls = vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0]); + const events = calls.filter((c) => c.kind === "report_watcher.triggered"); + expect(events.length).toBeGreaterThan(0); + expect(events[0]!.source).toBe("report-watcher"); + expect(events[0]!.level).toBe("warn"); + expect(events[0]!.data).toMatchObject({ trigger: "no_acknowledge" }); + } finally { + vi.useRealTimers(); + } + }); + + it("does NOT re-emit while the same trigger persists across polls (one-shot guard)", async () => { + // Regression: we observed 116 identical report_watcher.triggered events + // landing in production over a few hours because the emit was unguarded + // and fired every 30s poll while the trigger stayed active. The fix gates + // the emit on `isNewTrigger` so it fires only on the first poll that + // detects the trigger. + vi.useFakeTimers(); + vi.setSystemTime(new Date("2025-01-01T12:00:00.000Z")); + + const registry = createMockRegistry({ + runtime: plugins.runtime, + agent: plugins.agent, + scm: createMockSCM(), + notifier: createMockNotifier(), + }); + + const staleSession = makeSession({ + id: "app-1", + status: "working", + workspacePath: null, + createdAt: new Date("2025-01-01T11:40:00.000Z"), + metadata: { createdAt: "2025-01-01T11:40:00.000Z" }, + }); + persistSession("app-1", staleSession, { createdAt: "2025-01-01T11:40:00.000Z" }); + + const lm = createLifecycleManager({ + config, + registry, + sessionManager: mockSessionManager, + }); + + try { + // First check — trigger fires fresh, AE event lands. + await lm.check("app-1"); + const firstPass = vi.mocked(recordActivityEvent).mock.calls + .map((c) => c[0]) + .filter((c) => c.kind === "report_watcher.triggered"); + expect(firstPass).toHaveLength(1); + + // Second check — same trigger still active. Must NOT re-emit. + vi.mocked(recordActivityEvent).mockClear(); + await lm.check("app-1"); + const secondPass = vi.mocked(recordActivityEvent).mock.calls + .map((c) => c[0]) + .filter((c) => c.kind === "report_watcher.triggered"); + expect(secondPass).toHaveLength(0); + } finally { + vi.useRealTimers(); + } + }); + + it("REGRESSION: summary must not interpolate auditResult.message (report.note credential leak)", async () => { + // `auditResult.message` for the agent_needs_input trigger embeds the + // free-form `report.note` supplied via `ao report --note "..."`. Since + // sanitizeSummary only truncates and FTS5 indexes summary, a note that + // happens to contain a credential URL becomes persistently searchable + // from the DB. Summary must stay generic; the message stays in `data` + // where sanitizeData redacts credentials. + vi.useFakeTimers(); + vi.setSystemTime(new Date("2025-01-01T12:00:00.000Z")); + + const registry = createMockRegistry({ + runtime: plugins.runtime, + agent: plugins.agent, + scm: createMockSCM(), + notifier: createMockNotifier(), + }); + + // Seed a needs_input report whose note contains a credential URL. + // Per agent-report.ts:560, readAgentReport pulls these out of metadata. + const credentialNote = "stuck on git push https://x-oauth-basic:SECRET_NOTE_TOKEN@github.com/foo/bar.git"; + const blockedSession = makeSession({ + id: "app-1", + status: "working", + workspacePath: null, + createdAt: new Date("2025-01-01T11:55:00.000Z"), + metadata: { createdAt: "2025-01-01T11:55:00.000Z" }, + }); + persistSession("app-1", blockedSession, { + createdAt: "2025-01-01T11:55:00.000Z", + agentReportedState: "needs_input", + agentReportedAt: "2025-01-01T11:58:00.000Z", + agentReportedNote: credentialNote, + }); + + const lm = createLifecycleManager({ + config, + registry, + sessionManager: mockSessionManager, + }); + + try { + await lm.check("app-1"); + + const events = vi + .mocked(recordActivityEvent) + .mock.calls.map((c) => c[0]) + .filter((c) => c.kind === "report_watcher.triggered"); + expect(events.length).toBeGreaterThan(0); + expect(events[0]!.data).toMatchObject({ trigger: "agent_needs_input" }); + + // Summary stays generic — no secret material from report.note. + expect(events[0]!.summary).not.toContain("SECRET_NOTE_TOKEN"); + expect(events[0]!.summary).not.toContain("x-oauth-basic"); + + // The full message still surfaces via `data` where sanitizeData runs. + expect(events[0]!.data).toMatchObject({ + message: expect.stringContaining("Agent needs input"), + }); + } finally { + vi.useRealTimers(); + } + }); +}); + +// --------------------------------------------------------------------------- +// B2 invariant: emits never break the lifecycle flow +// --------------------------------------------------------------------------- + +describe("invariant: recordActivityEvent failures do not break the lifecycle flow", () => { + it("lm.check completes successfully even if recordActivityEvent throws", async () => { + vi.mocked(recordActivityEvent).mockImplementation(() => { + throw new Error("AE went boom"); + }); + + const registry = createMockRegistry({ + runtime: plugins.runtime, + agent: plugins.agent, + scm: createMockSCM({ + getReviewThreads: vi.fn().mockRejectedValue(new Error("review fetch down")), + }), + notifier: createMockNotifier(), + }); + + const session = makeSession({ status: "pr_open", pr: makeMatchingPR() }); + persistSession("app-1", session); + + const lm = buildLM(registry); + // Per gist + B2: lifecycle code MUST NOT depend on recordActivityEvent + // success. If this test fails, a new emit was added without the wrapper + // safety the real recordActivityEvent provides — find the unsafe call site + // and wrap it (or rely on the real impl's internal try/catch). + await expect(lm.check("app-1")).resolves.not.toThrow(); + }); +}); diff --git a/packages/core/src/activity-events.ts b/packages/core/src/activity-events.ts index edff28d67..6065c3d41 100644 --- a/packages/core/src/activity-events.ts +++ b/packages/core/src/activity-events.ts @@ -12,7 +12,16 @@ import { getDb } from "./events-db.js"; // Distinct names to avoid collision with types.ts EventType / EventSource. -export type ActivityEventSource = "lifecycle" | "session-manager" | "api" | "ui"; +export type ActivityEventSource = + | "lifecycle" + | "session-manager" + | "api" + | "ui" + | "scm" + | "runtime" + | "agent" + | "reaction" + | "report-watcher"; export type ActivityEventKind = | "session.spawn_started" @@ -22,7 +31,28 @@ export type ActivityEventKind = | "activity.transition" | "lifecycle.transition" | "ci.failing" - | "review.pending"; + | "review.pending" + // Lifecycle-manager plugin-call failures + | "scm.batch_enrich_failed" + | "scm.detect_pr_succeeded" + | "scm.detect_pr_failed" + | "scm.review_fetch_failed" + | "scm.poll_pr_failed" + | "runtime.probe_failed" + | "agent.process_probe_failed" + | "agent.activity_probe_failed" + // Reaction lifecycle + | "reaction.escalated" + | "reaction.send_to_agent_failed" + | "reaction.action_succeeded" + // Auto-cleanup + poll cycle + | "session.auto_cleanup_deferred" + | "session.auto_cleanup_completed" + | "session.auto_cleanup_failed" + | "lifecycle.poll_failed" + | "detecting.escalated" + // Report watcher + | "report_watcher.triggered"; export type ActivityEventLevel = "debug" | "info" | "warn" | "error"; @@ -74,12 +104,101 @@ function pruneOldEvents(db: ReturnType, cutoff: number): void { // Patterns that indicate sensitive field names const SENSITIVE_KEY_RE = /token|password|secret|authorization|cookie|api[-_]?key/i; -// URL credentials: https://token@host or http://user:pass@host -const CREDENTIAL_URL_RE = /https?:\/\/[^@\s]+@/gi; +// URL credentials: https://token@host or http://user:pass@host. +// Linear scan — find :// then scan forward for the next @ before a path +// separator or whitespace. O(n) worst case, no regex backtracking, no length +// limits. Replaces the previous CREDENTIAL_URL_RE which either ReDoS'd +// (unbounded quantifier) or missed >200-char userinfo (bounded quantifier). +function redactCredentialUrls(input: string): string { + let result = input; + let offset = 0; + while (offset < result.length) { + const proto = result.indexOf("://", offset); + if (proto === -1) break; + // Only match http:// or https:// (case-insensitive, matching old /gi flag) + if (proto < 4) { offset = proto + 3; continue; } + const schemeEnd = result.slice(Math.max(0, proto - 5), proto).toLowerCase(); + if (!schemeEnd.endsWith("http") && !schemeEnd.endsWith("https")) { + offset = proto + 3; + continue; + } + + let cursor = proto + 3; + while (cursor < result.length) { + const ch = result.charCodeAt(cursor); + // Space/control chars or '/' mean no '@' is coming in userinfo + if (ch <= 0x20 || ch === 0x2F) break; + if (ch === 0x40) { + // '@' found — redact everything between :// and @ + // Lowercase the scheme to match the old /gi regex behavior + const before = result.slice(0, proto + 3).toLowerCase(); + const suffix = result.slice(cursor); + result = before + "[redacted]" + suffix; + offset = proto + 3 + "[redacted]".length + 1; + break; + } + cursor++; + } + // No '@' found — not a credential URL, move past this :// + if (cursor >= result.length || result.charCodeAt(cursor) <= 0x20 || result.charCodeAt(cursor) === 0x2F) { + offset = proto + 3; + } + } + return result; +} + +// Per-string-value cap. The whole-data 16 KB cap still applies on top of this; +// truncating individual strings limits blast radius if a pattern below misses a +// new token format and a long error message gets pasted in. +const STRING_VALUE_MAX_CHARS = 500; + +// Token-shape patterns matched against ANY string value, not just keys. +// Order: more-specific first. Replacement strings preserve the prefix where +// the prefix itself is informative (e.g. "Bearer [redacted]" so RCA can still +// see this was a bearer-auth failure). +// +// SENSITIVE_KEY_RE above redacts entire values under sensitive *key* names; +// these patterns redact token-shaped *substrings* anywhere — including under +// keys like `message` and `errorMessage`, which are the leak vector flagged +// in PR #1620 review (data column is FTS5-indexed in events-db.ts). +const TOKEN_PATTERNS: ReadonlyArray = [ + // Bearer auth headers (also catches JWTs prefixed with Bearer) + [/\bBearer\s+[A-Za-z0-9._~+/=-]{12,}\b/gi, "Bearer [redacted]"], + // GitHub Personal Access Tokens — classic (ghp_/gho_/ghu_/ghs_/ghr_) + [/\bgh[pousr]_[A-Za-z0-9_]{20,}\b/g, "[redacted]"], + // GitHub fine-grained PATs + [/\bgithub_pat_[A-Za-z0-9_]{20,}\b/g, "[redacted]"], + // OpenAI / Anthropic sk- keys (incl. sk-proj-, sk-svcacct-, sk-ant-) + [/\bsk-(?:ant-)?(?:proj-|svcacct-)?[A-Za-z0-9_-]{16,}\b/g, "[redacted]"], + // Slack tokens (xoxb-, xoxp-, xoxa-, xoxr-, xoxs-) + [/\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g, "[redacted]"], + // AWS access key IDs (16 trailing chars exactly per AWS spec) + [/\bAKIA[0-9A-Z]{16}\b/g, "[redacted]"], + // JWTs — three base64url segments, eyJ prefix on header + [/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g, "[redacted]"], + // ENV-style assignments: MY_API_TOKEN=value, GITHUB_SECRET=..., etc. + // Scoped to ALL_CAPS keys containing a sensitive word so prose like + // "the message=hello" doesn't redact. + [ + /\b([A-Z][A-Z0-9_]*(?:TOKEN|PASSWORD|SECRET|AUTHORIZATION|COOKIE|API_KEY|APIKEY)[A-Z0-9_]*)=([^\s"'`]{6,})/g, + "$1=[redacted]", + ], +]; + +function sanitizeString(value: string): string { + let cleaned = redactCredentialUrls(value); + for (const [pattern, replacement] of TOKEN_PATTERNS) { + cleaned = cleaned.replace(pattern, replacement); + } + if (cleaned.length > STRING_VALUE_MAX_CHARS) { + cleaned = `${cleaned.slice(0, STRING_VALUE_MAX_CHARS - 3)}...`; + } + return cleaned; +} function sanitizeValue(value: unknown, seen: WeakSet): unknown { if (typeof value === "bigint") return value.toString(); - if (typeof value === "string") return value.replace(CREDENTIAL_URL_RE, "https://[redacted]@"); + if (typeof value === "string") return sanitizeString(value); if (value === null || typeof value !== "object") return value; if (seen.has(value)) return "[circular]"; diff --git a/packages/core/src/lifecycle-manager.ts b/packages/core/src/lifecycle-manager.ts index fdb8a3918..3ae00cf27 100644 --- a/packages/core/src/lifecycle-manager.ts +++ b/packages/core/src/lifecycle-manager.ts @@ -627,6 +627,22 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan level: "warn", data: { plugin: pluginKey, prCount: pluginPRs.length }, }); + recordActivityEvent({ + // Tag with scopedProjectId when the lifecycle worker is project-scoped + // so `ao events list --project ` surfaces this failure. Unscoped + // (multi-project) supervisors leave projectId null because the batch + // crosses project boundaries — RCA there should query without --project. + projectId: scopedProjectId, + source: "scm", + kind: "scm.batch_enrich_failed", + level: "warn", + summary: `batch_enrich failed for ${pluginPRs.length} PR(s)`, + data: { + plugin: pluginKey, + prCount: pluginPRs.length, + errorMessage: errorMsg, + }, + }); } } @@ -660,8 +676,23 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan session.pr = detectedPR; const sessionsDir = getProjectSessionsDir(session.projectId); updateMetadata(sessionsDir, session.id, { pr: detectedPR.url }); + recordActivityEvent({ + projectId: session.projectId, + sessionId: session.id, + source: "scm", + kind: "scm.detect_pr_succeeded", + summary: `PR #${detectedPR.number} detected`, + data: { + plugin: project.scm.plugin, + prNumber: detectedPR.number, + prUrl: detectedPR.url, + prOwner: detectedPR.owner, + prRepo: detectedPR.repo, + }, + }); } } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); observer?.recordOperation?.({ metric: "lifecycle_poll", operation: "scm.detect_pr", @@ -669,9 +700,21 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan correlationId: createCorrelationId("detect-pr"), projectId: session.projectId, sessionId: session.id, - reason: error instanceof Error ? error.message : String(error), + reason: errorMsg, level: "warn", }); + recordActivityEvent({ + projectId: session.projectId, + sessionId: session.id, + source: "scm", + kind: "scm.detect_pr_failed", + level: "warn", + summary: `detect_pr failed for ${session.id}`, + data: { + plugin: project.scm.plugin, + errorMessage: errorMsg, + }, + }); } } } @@ -897,11 +940,23 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan lifecycle.runtime.reason = session.runtimeHandle.runtimeName === "tmux" ? "tmux_missing" : "process_missing"; } - } catch { + } catch (err) { lifecycle.runtime.state = "probe_failed"; lifecycle.runtime.reason = "probe_error"; lifecycle.runtime.lastObservedAt = nowIso; runtimeProbe = { state: "unknown", failed: true }; + recordActivityEvent({ + projectId: session.projectId, + sessionId: session.id, + source: "runtime", + kind: "runtime.probe_failed", + level: "warn", + summary: `runtime.isAlive probe failed for ${session.id}`, + data: { + runtimeName: session.runtimeHandle.runtimeName, + errorMessage: err instanceof Error ? err.message : String(err), + }, + }); } } } @@ -1013,17 +1068,42 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan lifecycle.runtime.reason = "process_missing"; lifecycle.runtime.lastObservedAt = nowIso; } - } catch { + } catch (err) { processProbe = { state: "unknown", failed: true }; + recordActivityEvent({ + projectId: session.projectId, + sessionId: session.id, + source: "agent", + kind: "agent.process_probe_failed", + level: "warn", + summary: `agent.isProcessRunning failed for ${session.id}`, + data: { + agentName, + where: "fallback", + errorMessage: err instanceof Error ? err.message : String(err), + }, + }); } } } else { activitySignal = createActivitySignal("null", { source: "native" }); activityEvidence = formatActivitySignalEvidence(activitySignal); } - } catch { + } catch (err) { activitySignal = createActivitySignal("probe_failure", { source: "native" }); activityEvidence = formatActivitySignalEvidence(activitySignal); + recordActivityEvent({ + projectId: session.projectId, + sessionId: session.id, + source: "agent", + kind: "agent.activity_probe_failed", + level: "warn", + summary: `activity probing failed for ${session.id}`, + data: { + agentName, + errorMessage: err instanceof Error ? err.message : String(err), + }, + }); if ( lifecycle.session.state === "stuck" || lifecycle.session.state === "needs_input" || @@ -1061,8 +1141,21 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan lifecycle.runtime.reason = "process_missing"; lifecycle.runtime.lastObservedAt = nowIso; } - } catch { + } catch (err) { processProbe = { state: "unknown", failed: true }; + recordActivityEvent({ + projectId: session.projectId, + sessionId: session.id, + source: "agent", + kind: "agent.process_probe_failed", + level: "warn", + summary: `agent.isProcessRunning failed for ${session.id}`, + data: { + agentName, + where: "standalone", + errorMessage: err instanceof Error ? err.message : String(err), + }, + }); } } @@ -1131,8 +1224,23 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan }), ); } - } catch { - // Best-effort — batch will retry next cycle + } catch (err) { + // Best-effort — batch will retry next cycle. Record AE evidence so + // RCA can answer "why didn't AO transition to merged/closed in time?" + recordActivityEvent({ + projectId: session.projectId, + sessionId: session.id, + source: "scm", + kind: "scm.poll_pr_failed", + level: "warn", + summary: `getPRState failed for PR #${session.pr.number}`, + data: { + plugin: project.scm?.plugin, + prNumber: session.pr.number, + prUrl: session.pr.url, + errorMessage: err instanceof Error ? err.message : String(err), + }, + }); } } catch (error) { observer?.recordOperation?.({ @@ -1294,6 +1402,29 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan } if (shouldEscalate) { + // Mirror the trigger checks above so the cause matches the gate that + // actually fired. Numeric escalateAfter is an attempt-count gate, not a + // duration; without this distinction it gets misattributed to max_duration. + const escalationCause: "max_retries" | "max_attempts" | "max_duration" = + tracker.attempts > maxRetries + ? "max_retries" + : typeof escalateAfter === "number" && tracker.attempts > escalateAfter + ? "max_attempts" + : "max_duration"; + recordActivityEvent({ + projectId, + sessionId, + source: "reaction", + kind: "reaction.escalated", + level: "warn", + summary: `reaction ${reactionKey} escalated after ${tracker.attempts} attempts`, + data: { + reactionKey, + attempts: tracker.attempts, + durationSinceFirstMs: Date.now() - tracker.firstTriggered.getTime(), + escalationCause, + }, + }); // Escalate to human const context = buildEventContext(session, prEnrichmentCache); const event = createEvent("reaction.escalated", { @@ -1324,7 +1455,14 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan if (reactionConfig.message) { try { await sessionManager.send(sessionId, reactionConfig.message); - + recordActivityEvent({ + projectId, + sessionId, + source: "reaction", + kind: "reaction.action_succeeded", + summary: `send-to-agent ${reactionKey}`, + data: { reactionKey, action: "send-to-agent", attempts: tracker.attempts }, + }); return { reactionType: reactionKey, success: true, @@ -1332,8 +1470,21 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan message: reactionConfig.message, escalated: false, }; - } catch { + } catch (err) { // Send failed — allow retry on next poll cycle (don't escalate immediately) + recordActivityEvent({ + projectId, + sessionId, + source: "reaction", + kind: "reaction.send_to_agent_failed", + level: "warn", + summary: `send-to-agent failed for ${sessionId}`, + data: { + reactionKey, + attempts: tracker.attempts, + errorMessage: err instanceof Error ? err.message : String(err), + }, + }); return { reactionType: reactionKey, success: false, @@ -1354,6 +1505,14 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan data: { reactionKey, context, schemaVersion: 2 }, }); await notifyHuman(event, reactionConfig.priority ?? "info"); + recordActivityEvent({ + projectId, + sessionId, + source: "reaction", + kind: "reaction.action_succeeded", + summary: `notify ${reactionKey}`, + data: { reactionKey, action: "notify", attempts: tracker.attempts }, + }); return { reactionType: reactionKey, success: true, @@ -1373,6 +1532,14 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan data: { reactionKey, context, schemaVersion: 2 }, }); await notifyHuman(event, "action"); + recordActivityEvent({ + projectId, + sessionId, + source: "reaction", + kind: "reaction.action_succeeded", + summary: `auto-merge ${reactionKey}`, + data: { reactionKey, action: "auto-merge", attempts: tracker.attempts }, + }); return { reactionType: reactionKey, success: true, @@ -1498,8 +1665,23 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan // Fallback for SCM plugins that don't implement getReviewThreads yet allThreads = await scm.getPendingComments(session.pr); } - } catch { - // Failed to fetch — preserve existing metadata. + } catch (err) { + // Failed to fetch — preserve existing metadata; record AE evidence so + // RCA can answer "why aren't review comments being dispatched?" + recordActivityEvent({ + projectId: session.projectId, + sessionId: session.id, + source: "scm", + kind: "scm.review_fetch_failed", + level: "warn", + summary: `review fetch failed for PR #${session.pr.number}`, + data: { + plugin: project.scm?.plugin, + prNumber: session.pr.number, + prUrl: session.pr.url, + errorMessage: err instanceof Error ? err.message : String(err), + }, + }); // Don't update the throttle timestamp so the next poll retries immediately // instead of being blocked for 2 minutes with the agent left on a bare notification. return; @@ -2016,6 +2198,22 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan data: { activity, pendingSince, graceMs }, level: "info", }); + recordActivityEvent({ + projectId: session.projectId, + sessionId: session.id, + source: "lifecycle", + kind: "session.auto_cleanup_deferred", + summary: `auto-cleanup deferred for ${session.id}`, + data: { + activity, + // Elapsed wall-time since cleanup was first deferred. NOT a Unix + // timestamp — naming it `pendingSinceMs` was misleading (Greptile). + pendingElapsedMs: Number.isFinite(pendingSinceMs) + ? Date.now() - pendingSinceMs + : null, + graceMs, + }, + }); return; } @@ -2041,6 +2239,19 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan }, level: "info", }); + recordActivityEvent({ + projectId: session.projectId, + sessionId: session.id, + source: "lifecycle", + kind: "session.auto_cleanup_completed", + summary: `auto-cleanup completed for ${session.id}`, + data: { + cleaned: result.cleaned, + alreadyTerminated: result.alreadyTerminated, + graceElapsed, + activity, + }, + }); states.delete(session.id); } catch (err) { // Leave `merged` status in place so the next poll retries. Preserve the @@ -2048,6 +2259,7 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan if (!session.metadata["mergedPendingCleanupSince"]) { updateSessionMetadata(session, { mergedPendingCleanupSince: nowIso }); } + const errorMsg = err instanceof Error ? err.message : String(err); observer.recordOperation({ metric: "lifecycle_poll", operation: "lifecycle.merge_cleanup.failed", @@ -2055,9 +2267,18 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan correlationId, projectId: session.projectId, sessionId: session.id, - reason: err instanceof Error ? err.message : String(err), + reason: errorMsg, level: "warn", }); + recordActivityEvent({ + projectId: session.projectId, + sessionId: session.id, + source: "lifecycle", + kind: "session.auto_cleanup_failed", + level: "error", + summary: `auto-cleanup failed for ${session.id}`, + data: { errorMessage: errorMsg }, + }); } } @@ -2090,6 +2311,27 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan ? session.metadata["detectingEscalatedAt"] || new Date().toISOString() : ""; + // Emit ONCE per escalation — guarded by detectingEscalatedAt being empty. + // Subsequent polls while session stays stuck have detectingEscalatedAt set + // and won't re-fire (per invariant: don't repeat escalation events). + if (isDetectingEscalated && !session.metadata["detectingEscalatedAt"]) { + const cause: "max_attempts" | "max_duration" = + assessment.detectingAttempts > DETECTING_MAX_ATTEMPTS ? "max_attempts" : "max_duration"; + recordActivityEvent({ + projectId: session.projectId, + sessionId: session.id, + source: "lifecycle", + kind: "detecting.escalated", + level: "warn", + summary: `detecting → stuck via ${cause}`, + data: { + attempts: assessment.detectingAttempts, + cause, + startedAt: nextDetectingStartedAt, + }, + }); + } + const metadataUpdates: Record = {}; if (session.metadata["lifecycleEvidence"] !== nextLifecycleEvidence) { metadataUpdates["lifecycleEvidence"] = nextLifecycleEvidence; @@ -2411,6 +2653,34 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan }, level: "warn", }); + // Emit ONCE per trigger activation (matches the detecting.escalated guard + // pattern). Without this guard the audit would fire every poll cycle while + // a trigger stays active, producing hundreds of identical events. The + // observer.recordOperation above is unguarded by design (it's a metric); + // the activity-event trail is for actionable evidence, not heartbeat. + if (isNewTrigger) { + recordActivityEvent({ + projectId: session.projectId, + sessionId: session.id, + source: "report-watcher", + kind: "report_watcher.triggered", + level: "warn", + // Trigger is a bounded enum (no_acknowledge | stale_report | + // agent_needs_input); auditResult.message includes free-form + // report.note text from `ao report` and must not land in summary, + // which is FTS-indexed and only truncated by sanitizeSummary. + // Full message stays in `data.message` where sanitizeData redacts + // credential URLs. + summary: `${auditResult.trigger} triggered`, + data: { + trigger: auditResult.trigger, + message: auditResult.message, + timeSinceSpawnMs: auditResult.timeSinceSpawnMs, + timeSinceReportMs: auditResult.timeSinceReportMs, + reportState: auditResult.report?.state, + }, + }); + } // Execute reaction if configured if (isNewTrigger && reactionConfig && reactionConfig.auto !== false) { @@ -2539,6 +2809,22 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan reason: errorReason, level: "error", }); + recordActivityEvent({ + projectId: scopedProjectId, + source: "lifecycle", + kind: "lifecycle.poll_failed", + level: "error", + // Keep summary generic — sanitizeSummary only truncates, but the FTS + // index covers it. Error text (which can contain credential URLs from + // git/gh subprocess output) is routed through `data` where sanitizeData + // redacts credentials. + summary: "poll cycle failed", + data: { + errorMessage: errorReason, + durationMs: Date.now() - startedAt, + projectScope: scopedProjectId ?? "all", + }, + }); observer.setHealth({ surface: "lifecycle.worker", status: "error", From 40aeb78c09735ab858104546cefdfcddcb5450d4 Mon Sep 17 00:00:00 2001 From: i-trytoohard Date: Thu, 7 May 2026 11:29:19 +0530 Subject: [PATCH 07/15] feat(core): add per-project env block to ProjectConfig (#1679) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(core): add per-project env block to ProjectConfig Adds an optional `env: Record` field to ProjectConfig that forwards environment variables into worker session runtimes. Useful for scoping per-project tokens like GH_TOKEN to pin gh auth per project. The merge order in session-manager runtime.create environment is: agent.getEnvironment → PATH/GH_PATH → AO_AGENT_GH_TRACE → project.env → AO_* internals. AO-internal vars always win over user-supplied values. Closes #169 * fix(core): protect PATH and GH_PATH from project.env override Per greptile review on #1679: spreading `project.env` after PATH/GH_PATH let a user-supplied PATH or GH_PATH silently clobber the carefully constructed agent path. Apply the same "protected key" treatment as AO_* internals — spread project.env BEFORE PATH/GH_PATH/AO_AGENT_GH_TRACE at all three runtime.create call sites (worker spawn, orchestrator spawn, restore). Extend the precedence test to assert PATH and GH_PATH still win over a colliding project.env entry. --------- Co-authored-by: Prateek --- .changeset/per-project-env.md | 5 ++ agent-orchestrator.yaml.example | 6 ++ .../src/__tests__/config-validation.test.ts | 39 +++++++++ .../__tests__/session-manager/spawn.test.ts | 83 +++++++++++++++++++ packages/core/src/config.ts | 1 + packages/core/src/session-manager.ts | 3 + packages/core/src/types.ts | 3 + 7 files changed, 140 insertions(+) create mode 100644 .changeset/per-project-env.md diff --git a/.changeset/per-project-env.md b/.changeset/per-project-env.md new file mode 100644 index 000000000..c9945a22e --- /dev/null +++ b/.changeset/per-project-env.md @@ -0,0 +1,5 @@ +--- +"@aoagents/ao-core": minor +--- + +Add optional per-project `env` block to `ProjectConfig` that forwards string-to-string env vars into worker session runtimes (e.g. pin `GH_TOKEN` per project). AO-internal vars (`AO_SESSION`, `AO_PROJECT_ID`, etc.) always take precedence. diff --git a/agent-orchestrator.yaml.example b/agent-orchestrator.yaml.example index 04d5f476c..7659fd35e 100644 --- a/agent-orchestrator.yaml.example +++ b/agent-orchestrator.yaml.example @@ -79,6 +79,12 @@ projects: # deliveryHeader: x-github-delivery # maxBodyBytes: 1048576 + # Per-project environment variables forwarded into worker session runtimes. + # Useful for scoping per-project tokens (e.g. pinning gh auth via GH_TOKEN). + # AO-internal vars (AO_SESSION, AO_PROJECT_ID, etc.) always take precedence. + # env: + # GH_TOKEN: ghp_xxx + # Files to symlink into workspaces # symlinks: [.env, .claude] diff --git a/packages/core/src/__tests__/config-validation.test.ts b/packages/core/src/__tests__/config-validation.test.ts index 57a60b5cd..1a6ce9c49 100644 --- a/packages/core/src/__tests__/config-validation.test.ts +++ b/packages/core/src/__tests__/config-validation.test.ts @@ -447,6 +447,45 @@ describe("Config Schema Validation", () => { expect(validated.projects.proj1.sessionPrefix).toBe("test"); // "test" is 4 chars, used as-is }); + it("accepts a string-to-string env map at the project level", () => { + const config = { + projects: { + proj1: { + path: "/repos/test", + repo: "org/test", + defaultBranch: "main", + env: { + GH_TOKEN: "ghp_xxx", + CUSTOM_FLAG: "1", + }, + }, + }, + }; + + const validated = validateConfig(config); + expect(validated.projects.proj1.env).toEqual({ + GH_TOKEN: "ghp_xxx", + CUSTOM_FLAG: "1", + }); + }); + + it("rejects non-string values in project env map", () => { + const config = { + projects: { + proj1: { + path: "/repos/test", + repo: "org/test", + defaultBranch: "main", + env: { + GH_TOKEN: 123, + }, + }, + }, + }; + + expect(() => validateConfig(config)).toThrow(); + }); + it("accepts orchestratorModel in agentConfig", () => { const config = { projects: { diff --git a/packages/core/src/__tests__/session-manager/spawn.test.ts b/packages/core/src/__tests__/session-manager/spawn.test.ts index 9ffd7aefc..970ea6aa0 100644 --- a/packages/core/src/__tests__/session-manager/spawn.test.ts +++ b/packages/core/src/__tests__/session-manager/spawn.test.ts @@ -103,6 +103,89 @@ describe("spawn", () => { } }); + it("forwards project.env into spawned agent runtime env", async () => { + const projectConfig = config.projects["my-app"]; + if (!projectConfig) throw new Error("test setup: my-app missing"); + const configWithEnv: OrchestratorConfig = { + ...config, + projects: { + ...config.projects, + "my-app": { + ...projectConfig, + env: { + GH_TOKEN: "ghp_project_scoped", + CUSTOM_VAR: "hello", + }, + }, + }, + }; + + const sm = createSessionManager({ config: configWithEnv, registry: mockRegistry }); + await sm.spawn({ projectId: "my-app" }); + + expect(mockRuntime.create).toHaveBeenCalledWith( + expect.objectContaining({ + environment: expect.objectContaining({ + GH_TOKEN: "ghp_project_scoped", + CUSTOM_VAR: "hello", + }), + }), + ); + }); + + it("AO_* internals override project.env values with the same key", async () => { + const projectConfig = config.projects["my-app"]; + if (!projectConfig) throw new Error("test setup: my-app missing"); + const configWithEnv: OrchestratorConfig = { + ...config, + projects: { + ...config.projects, + "my-app": { + ...projectConfig, + env: { + AO_SESSION: "should-not-win", + AO_PROJECT_ID: "should-not-win", + }, + }, + }, + }; + + const sm = createSessionManager({ config: configWithEnv, registry: mockRegistry }); + await sm.spawn({ projectId: "my-app" }); + + const call = (mockRuntime.create as ReturnType).mock.calls[0]?.[0]; + expect(call?.environment?.AO_SESSION).not.toBe("should-not-win"); + expect(call?.environment?.AO_SESSION).toBe("app-1"); + expect(call?.environment?.AO_PROJECT_ID).toBe("my-app"); + }); + + it("PATH and GH_PATH override project.env values with the same key", async () => { + const projectConfig = config.projects["my-app"]; + if (!projectConfig) throw new Error("test setup: my-app missing"); + const configWithEnv: OrchestratorConfig = { + ...config, + projects: { + ...config.projects, + "my-app": { + ...projectConfig, + env: { + PATH: "/should/not/win", + GH_PATH: "/should/not/win", + }, + }, + }, + }; + + const sm = createSessionManager({ config: configWithEnv, registry: mockRegistry }); + await sm.spawn({ projectId: "my-app" }); + + const call = (mockRuntime.create as ReturnType).mock.calls[0]?.[0]; + expect(call?.environment?.PATH).not.toBe("/should/not/win"); + expect(call?.environment?.PATH).toContain(".ao/bin"); + expect(call?.environment?.GH_PATH).not.toBe("/should/not/win"); + expect(call?.environment?.GH_PATH).toBe("/usr/local/bin/gh"); + }); + it("uses issue ID to derive branch name", async () => { const sm = createSessionManager({ config, registry: mockRegistry }); diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 38a43a02e..f75c13f0b 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -249,6 +249,7 @@ const ProjectConfigSchema = z.object({ runtime: z.string().optional(), agent: z.string().optional(), workspace: z.string().optional(), + env: z.record(z.string(), z.string()).optional(), tracker: TrackerConfigSchema.optional(), scm: SCMConfigSchema.optional(), symlinks: z.array(z.string()).optional(), diff --git a/packages/core/src/session-manager.ts b/packages/core/src/session-manager.ts index 721eafef6..1cec42a94 100644 --- a/packages/core/src/session-manager.ts +++ b/packages/core/src/session-manager.ts @@ -1299,6 +1299,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM environment: { ...environment, ...(opencodeConfigFile ? { OPENCODE_CONFIG: opencodeConfigFile } : {}), + ...(project.env ?? {}), PATH: buildAgentPath(environment["PATH"] ?? process.env["PATH"]), GH_PATH: PREFERRED_GH_PATH, ...(process.env["AO_AGENT_GH_TRACE"] && { @@ -1670,6 +1671,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM launchCommand, environment: { ...environment, + ...(project.env ?? {}), PATH: buildAgentPath(environment["PATH"] ?? process.env["PATH"]), GH_PATH: PREFERRED_GH_PATH, ...(process.env["AO_AGENT_GH_TRACE"] && { @@ -2935,6 +2937,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM environment: { ...environment, ...(opencodeConfigPath ? { OPENCODE_CONFIG: opencodeConfigPath } : {}), + ...(project.env ?? {}), PATH: buildAgentPath(environment["PATH"] ?? process.env["PATH"]), GH_PATH: PREFERRED_GH_PATH, ...(process.env["AO_AGENT_GH_TRACE"] && { diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index e6c610d0e..3a41ef9f3 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1504,6 +1504,9 @@ export interface ProjectConfig { /** Override default workspace */ workspace?: string; + /** Environment variables forwarded into worker session runtimes (AO_* internals always win) */ + env?: Record; + /** Issue tracker configuration */ tracker?: TrackerConfig; From 496c3717ab6e28b607268d3dbff48396cc21ea90 Mon Sep 17 00:00:00 2001 From: i-trytoohard Date: Thu, 7 May 2026 14:35:26 +0530 Subject: [PATCH 08/15] fix(runtime-tmux): disable tmux status bar + dead code cleanup (#1711) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(runtime-tmux): disable tmux status bar at session creation Closes #1709. #1683 added `set-option ... status off` to `core/src/tmux.ts::newSession()`, but no code in the workspace imports or calls that helper — worker sessions are spawned by the `runtime-tmux` plugin, which was not modified. The green status bar was therefore still visible at session creation, only being suppressed once the web layer's WebSocket connection ran its own `set-option` (with a flash window before that). Add the same call to runtime-tmux immediately after `tmux new-session` so the bar is hidden from the moment the session exists, regardless of whether anyone ever attaches via the web terminal. Co-Authored-By: Claude Opus 4.7 (1M context) * chore(core): remove unused newSession helper The `newSession` function in `core/src/tmux.ts` and its `newTmuxSession` re-export in `core/src/index.ts` had zero callers anywhere in the workspace (verified via grep across packages/, excluding dist and tests). Worker sessions are spawned by the `runtime-tmux` plugin, which has its own implementation. The status-bar fix from #1683 lived only in this helper and was therefore never executed — see #1709 and the prior commit which moves the fix to the actual spawn path. Removes: - `newSession` and `NewSessionOptions` from core/src/tmux.ts - `newSession as newTmuxSession` re-export from core/src/index.ts - The corresponding test block in core/src/__tests__/tmux.test.ts Co-Authored-By: Claude Opus 4.7 (1M context) * chore(core): demote unused GhTraceResult export to internal `GhTraceResult` was exported from `core/src/gh-trace.ts` but never re-exported from `core/src/index.ts` and never imported anywhere in the workspace. Its only consumer is `writeTraceEntry()` inside the same file, where it's used as a parameter type. Demoted to a private interface. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(runtime-tmux): kill session if set-option fails Move the `set-option ... status off` call inside the existing try/catch so a failure (e.g. the 5-second tmux command timeout firing on a slow host) triggers `kill-session` cleanup instead of leaving an orphaned tmux session behind. Renames the surfaced error to "Failed to configure or launch session" since the try block now covers both configuration and the launch send-keys. Adds a regression test that asserts kill-session is called when set-option throws. Addresses review feedback on #1711. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Prateek Co-authored-by: Claude Opus 4.7 (1M context) --- packages/core/src/__tests__/tmux.test.ts | 63 ---------------- packages/core/src/gh-trace.ts | 2 +- packages/core/src/index.ts | 1 - packages/core/src/tmux.ts | 44 ----------- .../runtime-tmux/src/__tests__/index.test.ts | 74 +++++++++++++++++-- packages/plugins/runtime-tmux/src/index.ts | 12 ++- 6 files changed, 77 insertions(+), 119 deletions(-) diff --git a/packages/core/src/__tests__/tmux.test.ts b/packages/core/src/__tests__/tmux.test.ts index d25703a6f..705789830 100644 --- a/packages/core/src/__tests__/tmux.test.ts +++ b/packages/core/src/__tests__/tmux.test.ts @@ -4,7 +4,6 @@ import { isTmuxAvailable, listSessions, hasSession, - newSession, sendKeys, capturePane, killSession, @@ -118,68 +117,6 @@ describe("hasSession", () => { }); }); -describe("newSession", () => { - it("creates a basic session", async () => { - mockTmuxSuccess(""); - - await newSession({ name: "test-1", cwd: "/tmp/workspace" }); - - expect(mockExecFile).toHaveBeenCalledWith( - "tmux", - ["new-session", "-d", "-s", "test-1", "-c", "/tmp/workspace"], - expect.any(Object), - expect.any(Function), - ); - }); - - it("includes environment variables", async () => { - mockTmuxSuccess(""); - - await newSession({ - name: "test-2", - cwd: "/tmp", - environment: { AO_SESSION: "test-2", SOME_VAR: "value" }, - }); - - const args = mockExecFile.mock.calls[0][1] as string[]; - expect(args).toContain("-e"); - expect(args).toContain("AO_SESSION=test-2"); - expect(args).toContain("SOME_VAR=value"); - }); - - it("includes window size", async () => { - mockTmuxSuccess(""); - - await newSession({ name: "test-3", cwd: "/tmp", width: 200, height: 50 }); - - const args = mockExecFile.mock.calls[0][1] as string[]; - expect(args).toContain("-x"); - expect(args).toContain("200"); - expect(args).toContain("-y"); - expect(args).toContain("50"); - }); - - it("sends initial command after creation", async () => { - // Calls: new-session, set-option status off, send-keys Escape, send-keys text, send-keys Enter - mockTmuxSequence([{ stdout: "" }, { stdout: "" }, { stdout: "" }, { stdout: "" }, { stdout: "" }]); - - await newSession({ name: "test-4", cwd: "/tmp", command: "echo hello" }); - - expect(mockExecFile).toHaveBeenCalledTimes(5); - // Call 0: new-session - // Call 1: set-option status off (hide status bar) - const statusArgs = mockExecFile.mock.calls[1][1] as string[]; - expect(statusArgs).toEqual(["set-option", "-t", "test-4", "status", "off"]); - // Call 2: send-keys Escape (clear partial input) - const escapeArgs = mockExecFile.mock.calls[2][1] as string[]; - expect(escapeArgs).toEqual(["send-keys", "-t", "test-4", "Escape"]); - // Call 3: send-keys text - const textArgs = mockExecFile.mock.calls[3][1] as string[]; - expect(textArgs).toContain("send-keys"); - expect(textArgs).toContain("echo hello"); - }); -}); - describe("sendKeys", () => { it("sends short text with send-keys", async () => { // Calls: send-keys Escape, send-keys text, send-keys Enter diff --git a/packages/core/src/gh-trace.ts b/packages/core/src/gh-trace.ts index 9508bcd7d..216665e5b 100644 --- a/packages/core/src/gh-trace.ts +++ b/packages/core/src/gh-trace.ts @@ -60,7 +60,7 @@ export interface GhTraceContext { cwd?: string; } -export interface GhTraceResult { +interface GhTraceResult { ok: boolean; stdout: string; stderr: string; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0785091ec..0c39e7a13 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -110,7 +110,6 @@ export { isTmuxAvailable, listSessions as listTmuxSessions, hasSession as hasTmuxSession, - newSession as newTmuxSession, sendKeys as tmuxSendKeys, capturePane as tmuxCapturePane, killSession as killTmuxSession, diff --git a/packages/core/src/tmux.ts b/packages/core/src/tmux.ts index f72ef4114..8da9da240 100644 --- a/packages/core/src/tmux.ts +++ b/packages/core/src/tmux.ts @@ -75,50 +75,6 @@ export async function hasSession(sessionName: string): Promise { } } -export interface NewSessionOptions { - /** Session name */ - name: string; - /** Working directory */ - cwd: string; - /** Initial command to run */ - command?: string; - /** Environment variables to set */ - environment?: Record; - /** Window width/height */ - width?: number; - height?: number; -} - -/** Create a new tmux session (detached). */ -export async function newSession(opts: NewSessionOptions): Promise { - const args = ["new-session", "-d", "-s", opts.name, "-c", opts.cwd]; - - // Add environment variables - if (opts.environment) { - for (const [key, value] of Object.entries(opts.environment)) { - args.push("-e", `${key}=${value}`); - } - } - - // Window size - if (opts.width) { - args.push("-x", String(opts.width)); - } - if (opts.height) { - args.push("-y", String(opts.height)); - } - - await tmux(...args); - - // Hide the tmux status bar — sessions are embedded in web terminal - await tmux("set-option", "-t", opts.name, "status", "off"); - - // Send the initial command if provided - if (opts.command) { - await sendKeys(opts.name, opts.command); - } -} - /** * Send keys (text + Enter) to a tmux session. * For long/multiline messages, uses load-buffer + paste-buffer with diff --git a/packages/plugins/runtime-tmux/src/__tests__/index.test.ts b/packages/plugins/runtime-tmux/src/__tests__/index.test.ts index 092ea98e2..8c8722540 100644 --- a/packages/plugins/runtime-tmux/src/__tests__/index.test.ts +++ b/packages/plugins/runtime-tmux/src/__tests__/index.test.ts @@ -83,7 +83,8 @@ describe("runtime.create()", () => { it("calls new-session with correct args", async () => { const runtime = create(); - // 1: new-session, 2: send-keys (launch command) + // 1: new-session, 2: set-option status off, 3: send-keys (launch command) + mockTmuxSuccess(); mockTmuxSuccess(); mockTmuxSuccess(); @@ -106,9 +107,34 @@ describe("runtime.create()", () => { ); }); + it("disables the tmux status bar immediately after new-session", async () => { + const runtime = create(); + + // 1: new-session, 2: set-option status off, 3: send-keys + mockTmuxSuccess(); + mockTmuxSuccess(); + mockTmuxSuccess(); + + await runtime.create({ + sessionId: "status-bar-off", + workspacePath: "/tmp/ws", + launchCommand: "echo hi", + environment: {}, + }); + + // Second call must be set-option ... status off, scoped to the session + expect(mockExecFileCustom).toHaveBeenNthCalledWith( + 2, + "tmux", + ["set-option", "-t", "status-bar-off", "status", "off"], + expectedTmuxOptions, + ); + }); + it("includes -e KEY=VALUE flags for environment variables", async () => { const runtime = create(); + mockTmuxSuccess(); mockTmuxSuccess(); mockTmuxSuccess(); @@ -130,6 +156,7 @@ describe("runtime.create()", () => { it("sends launch command via send-keys", async () => { const runtime = create(); + mockTmuxSuccess(); mockTmuxSuccess(); mockTmuxSuccess(); @@ -140,7 +167,7 @@ describe("runtime.create()", () => { environment: {}, }); - // Second call: send-keys with the launch command + // Third call: send-keys with the launch command (after new-session and set-option) expect(mockExecFileCustom).toHaveBeenCalledWith( "tmux", ["send-keys", "-t", "launch-test", "claude --session abc", "Enter"], @@ -152,6 +179,8 @@ describe("runtime.create()", () => { const runtime = create(); const longCommand = "x".repeat(250); + // 1: new-session, 2: set-option, 3: send-keys -l, 4: send-keys Enter + mockTmuxSuccess(); mockTmuxSuccess(); mockTmuxSuccess(); mockTmuxSuccess(); @@ -170,7 +199,7 @@ describe("runtime.create()", () => { ); expect(mockExecFileCustom).toHaveBeenNthCalledWith( - 2, + 3, "tmux", [ "send-keys", @@ -183,7 +212,7 @@ describe("runtime.create()", () => { ); expect(mockExecFileCustom).toHaveBeenNthCalledWith( - 3, + 4, "tmux", ["send-keys", "-t", "launch-long", "Enter"], expectedTmuxOptions, @@ -195,9 +224,11 @@ describe("runtime.create()", () => { // 1: new-session succeeds mockTmuxSuccess(); - // 2: send-keys fails + // 2: set-option succeeds + mockTmuxSuccess(); + // 3: send-keys fails mockTmuxError("send-keys failed"); - // 3: kill-session (cleanup attempt) + // 4: kill-session (cleanup attempt) mockTmuxSuccess(); await expect( @@ -207,7 +238,7 @@ describe("runtime.create()", () => { launchCommand: "bad-command", environment: {}, }), - ).rejects.toThrow('Failed to send launch command to session "fail-session"'); + ).rejects.toThrow('Failed to configure or launch session "fail-session"'); // Verify kill-session was called for cleanup expect(mockExecFileCustom).toHaveBeenCalledWith( @@ -217,6 +248,33 @@ describe("runtime.create()", () => { ); }); + it("cleans up session if set-option fails", async () => { + const runtime = create(); + + // 1: new-session succeeds + mockTmuxSuccess(); + // 2: set-option fails (e.g. tmux command timeout on a slow host) + mockTmuxError("set-option timed out"); + // 3: kill-session (cleanup attempt) + mockTmuxSuccess(); + + await expect( + runtime.create({ + sessionId: "setopt-fail", + workspacePath: "/tmp/ws", + launchCommand: "echo hi", + environment: {}, + }), + ).rejects.toThrow('Failed to configure or launch session "setopt-fail"'); + + // kill-session must run so we don't leave an orphaned tmux session + expect(mockExecFileCustom).toHaveBeenCalledWith( + "tmux", + ["kill-session", "-t", "setopt-fail"], + expectedTmuxOptions, + ); + }); + it("rejects invalid session IDs with special characters", async () => { const runtime = create(); @@ -246,6 +304,7 @@ describe("runtime.create()", () => { it("accepts valid session IDs with hyphens and underscores", async () => { const runtime = create(); + mockTmuxSuccess(); mockTmuxSuccess(); mockTmuxSuccess(); @@ -262,6 +321,7 @@ describe("runtime.create()", () => { it("handles no environment (undefined)", async () => { const runtime = create(); + mockTmuxSuccess(); mockTmuxSuccess(); mockTmuxSuccess(); diff --git a/packages/plugins/runtime-tmux/src/index.ts b/packages/plugins/runtime-tmux/src/index.ts index 547e594dc..9b05a2583 100644 --- a/packages/plugins/runtime-tmux/src/index.ts +++ b/packages/plugins/runtime-tmux/src/index.ts @@ -78,10 +78,16 @@ export function create(): Runtime { launchCommand = `export PATH=$(printf '%s' ${JSON.stringify(pathValue)})\n${launchCommand}`; } - // Send the launch command — clean up the session if this fails. - // Use a temp script for long commands so the pane shows a short + // Configure the session and send the launch command — kill the session + // if any of these fail so we don't leave an orphaned tmux process. + // Use a temp script for long launch commands so the pane shows a short // invocation instead of a pasted wall of shell. try { + // Hide the tmux status bar — sessions are embedded in the web terminal, + // and the green bar at the bottom is visual noise (and racy with the + // web layer's own set-option call, which only fires on WebSocket connect). + await tmux("set-option", "-t", sessionName, "status", "off"); + if (launchCommand.length > 200) { const invocation = writeLaunchScript(launchCommand); await tmux("send-keys", "-t", sessionName, "-l", invocation); @@ -97,7 +103,7 @@ export function create(): Runtime { // Best-effort cleanup } const msg = err instanceof Error ? err.message : String(err); - throw new Error(`Failed to send launch command to session "${sessionName}": ${msg}`, { + throw new Error(`Failed to configure or launch session "${sessionName}": ${msg}`, { cause: err, }); } From 5c1d56aea4dc0225cf7eda6d962ac4876497d97a Mon Sep 17 00:00:00 2001 From: Harshit Singh Bhandari Date: Thu, 7 May 2026 18:37:44 +0530 Subject: [PATCH 09/15] fix(web): bound PTY re-attach loop with grace-period counter reset (#1640) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(web): bound PTY re-attach loop with grace-period counter reset (#1639) When `ao stop` (or any external action) kills a tmux session out from under a still-subscribed dashboard, the mux server's PTY exit handler attempts to re-attach. The MAX_REATTACH_ATTEMPTS=3 cap was supposed to prevent unbounded respawning, but it was never engaging because the counter was reset to 0 immediately after each "successful" `open()` — where success only meant the new PTY was *spawned*, not that it *survived*. When the underlying tmux session is gone, attach-session exits ~40 ms after spawn, the exit handler fires again with counter=0, and the loop runs at ~80 spawns/sec. Diagnostic data captured on the issue: a single 1.5-second burst produced 119 spawn↔exit cycles, raising the process's PTY fd count from ~15 to ~153. Sustained for a few seconds, this exhausts the macOS system PTY pool (kern.tty.ptmx_max=511), after which nothing on the system can spawn a new PTY (tmux, VS Code terminal, ao spawn, etc.) until the leaking process is killed. Fix: - Remove the `terminal.reattachAttempts = 0` reset inside the exit handler. - Schedule a delayed reset via setTimeout in `open()`, gated on the closure-captured `pty` reference still being terminal.pty after REATTACH_RESET_GRACE_MS (5 s). Effect: tight crash loops cannot reset the counter (PTY exits before grace expires) and hit MAX_REATTACH_ATTEMPTS within ~150 ms, after which the server emits "exited" and stops respawning. A long-lived PTY that crashes hours later still gets a fresh retry budget. Adds an integration test that reproduces the runaway scenario by killing the tmux session externally and asserting "exited" arrives within 2 s. Without this fix the test hangs and times out — the exact symptom of the bug. Note: this addresses the dominant runaway behaviour. A separate ~1 fd/cycle leak in node-pty 1.1.0 itself (each spawn opens 3 PTY-class fds in the parent, each exit releases only 2) remains and will be tracked separately — likely a node-pty upgrade. Fixes: #1639 Co-Authored-By: Claude Opus 4.7 * fixup(web): track grace timer + add recovery test (#1639 PR review) Address two PR review notes on #1640: 1. Greptile P2 — store the grace-period timer handle on ManagedTerminal and clearTimeout it in the unsubscribe cleanup path. The closure guard already prevented any incorrect counter reset, so this is tidiness rather than a bug fix: it eliminates the up-to-5 s window where the timer's closure kept the killed PTY and evicted terminal object reachable. Also clears any prior timer when scheduling a new one in open() so back-to-back re-attaches don't pile up dead closures. 2. Copilot — add an integration test for the recovery path. The existing runaway test exercises the case where the counter must NOT reset (PTY crashes inside grace); the new test exercises the case where the counter MUST reset (PTY survives grace, then crashes later). Without the grace timer firing correctly, a single transient blip during startup would permanently consume the retry budget. Test takes ~5.5 s because it uses the production grace period; per-test timeout raised to 15 s. Vitest runs tests in parallel so this doesn't serialize the suite. Refs: #1639, #1640 Co-Authored-By: Claude Opus 4.7 --------- Co-authored-by: Claude Opus 4.7 --- .../direct-terminal-ws.integration.test.ts | 99 +++++++++++++++++++ packages/web/server/mux-websocket.ts | 50 +++++++++- 2 files changed, 148 insertions(+), 1 deletion(-) diff --git a/packages/web/server/__tests__/direct-terminal-ws.integration.test.ts b/packages/web/server/__tests__/direct-terminal-ws.integration.test.ts index b615ab6f5..7f4a7c242 100644 --- a/packages/web/server/__tests__/direct-terminal-ws.integration.test.ts +++ b/packages/web/server/__tests__/direct-terminal-ws.integration.test.ts @@ -283,6 +283,105 @@ describe("mux terminal open", () => { ws.close(); }); + + it("retry budget recovers after PTY survives the grace period (issue #1639)", async () => { + // Complements the runaway-loop test below. That one proves the counter + // does NOT reset when the PTY crashes inside REATTACH_RESET_GRACE_MS. + // This one proves the counter DOES reset once the PTY survives the + // grace period — without which a transient blip during startup would + // permanently consume the retry budget and prevent any later recovery. + // + // Test shape: + // 1. Open a terminal against a healthy tmux session (PTY 1 attaches). + // 2. Wait > REATTACH_RESET_GRACE_MS so the grace timer fires and + // resets reattachAttempts to 0 on the still-attached PTY 1. + // 3. Kill the tmux session — PTY 1 exits, the server burns the full + // MAX_REATTACH_ATTEMPTS=3 budget trying to re-attach, then emits + // "exited". A 2 s window is comfortably more than 3 × ~50 ms. + // 4. Per-test timeout raised to 15 s to accommodate the 5+ s wait. + // + // If the grace timer or its closure guard ever regresses (e.g. is + // unref'd in a way that prevents firing, or the wrong reference is + // compared, or a future change clears it too eagerly), step 3 will + // either time out waiting for "exited" or never reach the cap. + const RECOVERY_TEST_SESSION = `ao-test-recovery-${process.pid}`; + execFileSync(TMUX, ["new-session", "-d", "-s", RECOVERY_TEST_SESSION, "-x", "80", "-y", "24"], { + timeout: 5000, + }); + + try { + const ws = await connectMux(); + ws.send(JSON.stringify({ ch: "terminal", id: RECOVERY_TEST_SESSION, type: "open" })); + await waitForMessage(ws, (m) => m.ch === "terminal" && m.type === "opened"); + + // Sleep past REATTACH_RESET_GRACE_MS (5 s in production). + await new Promise((r) => setTimeout(r, 5500)); + + execFileSync(TMUX, ["kill-session", "-t", RECOVERY_TEST_SESSION], { timeout: 5000 }); + + const exitedMsg = await waitForMessage( + ws, + (m) => m.ch === "terminal" && m.type === "exited", + 2000, + ); + expect(exitedMsg.id).toBe(RECOVERY_TEST_SESSION); + + ws.close(); + } finally { + try { + execFileSync(TMUX, ["kill-session", "-t", RECOVERY_TEST_SESSION], { timeout: 5000 }); + } catch { + /* already gone */ + } + } + }, 15_000); + + it("bounds re-attach attempts when tmux session dies mid-subscription (issue #1639)", async () => { + // Reproduces the runaway re-attach loop that exhausts the system PTY + // pool in seconds when `ao stop` kills the tmux session out from + // under a still-subscribed dashboard. + // + // Pre-fix behaviour: each "successful" re-attach reset reattachAttempts + // to 0, so MAX_REATTACH_ATTEMPTS=3 never engaged; the loop ran at + // ~80 spawns/sec and "exited" was never emitted, so this test would + // hang until the 2-second timeout. + // + // Post-fix: the counter is only reset by a 5-second grace timer, so a + // PTY that exits in ~40 ms can never reset it. After 3 attempts the + // server gives up and emits "exited". + const RUNAWAY_TEST_SESSION = `ao-test-runaway-${process.pid}`; + execFileSync(TMUX, ["new-session", "-d", "-s", RUNAWAY_TEST_SESSION, "-x", "80", "-y", "24"], { + timeout: 5000, + }); + + try { + const ws = await connectMux(); + + ws.send(JSON.stringify({ ch: "terminal", id: RUNAWAY_TEST_SESSION, type: "open" })); + await waitForMessage(ws, (m) => m.ch === "terminal" && m.type === "opened"); + + // Kill the tmux session externally — the attached PTY now has nothing + // to attach to and will exit ~40 ms after each re-attach attempt. + execFileSync(TMUX, ["kill-session", "-t", RUNAWAY_TEST_SESSION], { timeout: 5000 }); + + // 3 re-attach attempts × ~50 ms each = ~150 ms; allow generous margin. + const exitedMsg = await waitForMessage( + ws, + (m) => m.ch === "terminal" && m.type === "exited", + 2000, + ); + expect(exitedMsg.id).toBe(RUNAWAY_TEST_SESSION); + + ws.close(); + } finally { + // Best-effort cleanup if test fails before kill-session ran + try { + execFileSync(TMUX, ["kill-session", "-t", RUNAWAY_TEST_SESSION], { timeout: 5000 }); + } catch { + /* already gone */ + } + } + }); }); // ============================================================================= diff --git a/packages/web/server/mux-websocket.ts b/packages/web/server/mux-websocket.ts index 43f319540..42eb651b0 100644 --- a/packages/web/server/mux-websocket.ts +++ b/packages/web/server/mux-websocket.ts @@ -192,11 +192,31 @@ interface ManagedTerminal { buffer: string[]; bufferBytes: number; reattachAttempts: number; + /** + * Pending grace-period timer that resets reattachAttempts when the + * currently-attached PTY survives REATTACH_RESET_GRACE_MS. Tracked so + * cleanup paths (last-subscriber unsubscribe, subsequent re-attach) can + * clear it and avoid keeping the dead PTY/terminal closure references + * reachable for up to 5 s after teardown. + */ + resetTimer?: ReturnType; } const RING_BUFFER_MAX = 50 * 1024; // 50KB max per terminal const WS_BUFFER_HIGH_WATERMARK = 64 * 1024; // 64KB const MAX_REATTACH_ATTEMPTS = 3; +/** + * Grace period a freshly-attached PTY must survive before its successful + * attach is allowed to reset the re-attach counter. Prevents tight crash + * loops (e.g. attaching to a tmux session that no longer exists) from + * gaming the MAX_REATTACH_ATTEMPTS cap by resetting the counter to 0 + * between every failed attempt. + * + * 5 s is comfortably longer than the ~40 ms a doomed `tmux attach-session` + * takes to exit, while still being short enough that a healthy PTY which + * crashes hours later gets a fresh retry budget. + */ +const REATTACH_RESET_GRACE_MS = 5_000; /** * TerminalManager manages PTY processes independently of WebSocket connections. @@ -296,6 +316,25 @@ class TerminalManager { terminal.pty = pty; + // Schedule a grace-period reset of the re-attach counter. We only + // consider an attach "really successful" if the PTY survives long + // enough to suggest the underlying tmux session is actually usable. + // The closure-captured `pty` reference is compared with terminal.pty + // so a stale timer cannot reset the counter for a PTY that has + // already exited or been replaced by re-attach. Any previously- + // scheduled timer (from a now-replaced PTY) is cleared so we don't + // keep its closure references reachable until the timer fires. + if (terminal.resetTimer) { + clearTimeout(terminal.resetTimer); + } + terminal.resetTimer = setTimeout(() => { + terminal.resetTimer = undefined; + if (terminal.pty === pty) { + terminal.reattachAttempts = 0; + } + }, REATTACH_RESET_GRACE_MS); + terminal.resetTimer.unref(); + // Wire up data events pty.onData((data: string) => { // Push to all subscribers — isolate each callback so a throw in one @@ -327,6 +366,12 @@ class TerminalManager { // Re-attach if subscribers are still present, up to MAX_REATTACH_ATTEMPTS. // The cap prevents an unbounded respawn loop when the PTY crashes immediately // after every attach (e.g. resource exhaustion or a broken tmux session). + // The counter is reset by a delayed timer in open() once the new PTY has + // survived REATTACH_RESET_GRACE_MS — see the comment on that constant. + // Resetting here would defeat the cap: when ao stop kills the tmux session + // out from under a still-subscribed dashboard, attach-session exits ~40 ms + // after spawn and the loop runs at ~80 spawns/sec, exhausting the system + // PTY pool in seconds (issue #1639). if (terminal.subscribers.size > 0 && terminal.reattachAttempts < MAX_REATTACH_ATTEMPTS) { terminal.reattachAttempts += 1; console.log( @@ -334,7 +379,6 @@ class TerminalManager { ); try { this.open(id, projectId, tmuxSessionId); - terminal.reattachAttempts = 0; // reset on successful attach return; // re-attached — don't notify exit } catch (err) { console.error(`[MuxServer] Failed to re-attach ${id}:`, err); @@ -402,6 +446,10 @@ class TerminalManager { if (onExit) terminal.exitCallbacks.delete(onExit); // Kill PTY and clean up when the last subscriber leaves if (terminal.subscribers.size === 0) { + if (terminal.resetTimer) { + clearTimeout(terminal.resetTimer); + terminal.resetTimer = undefined; + } if (terminal.pty) { terminal.pty.kill(); terminal.pty = null; From 0f539a3d4ec7ecaefbc4f2e71468a2ab1bfcec92 Mon Sep 17 00:00:00 2001 From: Harshit Singh Bhandari Date: Thu, 7 May 2026 18:43:19 +0530 Subject: [PATCH 10/15] fix(cli): reload dashboard config after adding project from already-running menu (#1706) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When `ao start` finds an existing daemon and the user picks "Add ", the project is written to the global config but the dashboard's cached services (loaded once into globalThis) never see it, so visiting the new project page renders notFound(). Call notifyProjectChange() after the write — same pattern as attachAndSpawnOrchestrator — so the dashboard invalidates its cache before the browser opens. --- .changeset/reload-dashboard-on-add-project.md | 5 +++++ packages/cli/src/commands/start.ts | 8 ++++++++ 2 files changed, 13 insertions(+) create mode 100644 .changeset/reload-dashboard-on-add-project.md diff --git a/.changeset/reload-dashboard-on-add-project.md b/.changeset/reload-dashboard-on-add-project.md new file mode 100644 index 000000000..b947fdde9 --- /dev/null +++ b/.changeset/reload-dashboard-on-add-project.md @@ -0,0 +1,5 @@ +--- +"@aoagents/ao-cli": patch +--- + +Fix dashboard 404 after adding a project from the "AO is already running" menu. The CLI now notifies the running daemon to reload its cached config so the new project's page is reachable immediately. diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index bd4a36545..f76ee508e 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -1411,6 +1411,14 @@ export function registerStart(program: Command): void { `\n✓ Added "${addedId}" — open the dashboard to start an orchestrator.\n`, ), ); + const notifyResult = await attachToDaemon(running).notifyProjectChange(); + if (!notifyResult.ok) { + console.log( + chalk.yellow( + ` ⚠ ${notifyResult.reason}. Refresh the page if the project doesn't show up.`, + ), + ); + } openUrl(`http://localhost:${running.port}`); unlockStartup(); process.exit(0); From d0dff3b93f1edbb314b66f033207c462bde26465 Mon Sep 17 00:00:00 2001 From: i-trytoohard Date: Thu, 7 May 2026 19:08:21 +0530 Subject: [PATCH 11/15] fix(web): drop = prefix from set-option in mux-websocket (closes #1714) (#1715) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(web): drop = prefix from set-option in mux-websocket (closes #1714) In tmux 3.4 the `=` exact-match prefix only works with `has-session` and `attach-session`. For `set-option`, the prefix is silently ignored, so `mouse on` and `status off` never get applied — breaking scroll wheel in the dashboard terminal and leaving the tmux status bar visible. Use the bare session id for the two `set-option` calls; keep `=` on `attach-session` where it is correct. Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(web): move exactTmuxTarget next to attach-session Address review feedback: the `=`-prefixed target is only used by attach-session, so declare it adjacent to that call. Comment now sits above the set-option calls it actually explains. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Prateek Co-authored-by: Claude Opus 4.7 (1M context) --- .../server/__tests__/mux-websocket.test.ts | 90 ++++++++++++++++++- packages/web/server/mux-websocket.ts | 16 ++-- 2 files changed, 98 insertions(+), 8 deletions(-) diff --git a/packages/web/server/__tests__/mux-websocket.test.ts b/packages/web/server/__tests__/mux-websocket.test.ts index d882df667..6efb2dc98 100644 --- a/packages/web/server/__tests__/mux-websocket.test.ts +++ b/packages/web/server/__tests__/mux-websocket.test.ts @@ -1,12 +1,48 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { SessionBroadcaster } from "../mux-websocket"; +import { EventEmitter } from "node:events"; +import type { SessionBroadcaster as SessionBroadcasterType } from "../mux-websocket"; + +// vi.mock factories run before module-level statements. Hoist the mock +// fns so the factories close over the same instances the tests use. +const { mockSpawn, mockPtySpawn } = vi.hoisted(() => ({ + mockSpawn: vi.fn(), + mockPtySpawn: vi.fn(), +})); + +vi.mock("node:child_process", async (importOriginal) => { + const actual = (await importOriginal()) as Record; + const spawnFn = (...args: unknown[]) => mockSpawn(...args); + return { + ...actual, + default: { ...(actual.default as object), spawn: spawnFn }, + spawn: spawnFn, + }; +}); + +vi.mock("node-pty", async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { + ...actual, + spawn: (...args: unknown[]) => mockPtySpawn(...args), + }; +}); + +// Mock tmux-utils so resolveTmuxSession returns a deterministic session id +// and we don't shell out to a real tmux binary. +vi.mock("../tmux-utils.js", () => ({ + findTmux: () => "/usr/bin/tmux", + validateSessionId: () => true, + resolveTmuxSession: () => "ao-177", +})); + +const { SessionBroadcaster, TerminalManager } = await import("../mux-websocket"); // Mock global fetch const mockFetch = vi.fn(); global.fetch = mockFetch; describe("SessionBroadcaster", () => { - let broadcaster: SessionBroadcaster; + let broadcaster: SessionBroadcasterType; beforeEach(() => { vi.useFakeTimers(); @@ -225,3 +261,53 @@ describe("SessionBroadcaster", () => { }); }); }); + +describe("TerminalManager.open — tmux target args (regression for #1714)", () => { + beforeEach(() => { + mockSpawn.mockReset(); + mockPtySpawn.mockReset(); + + // spawn() returns an object that emits "error" — we just need .on() to work. + mockSpawn.mockImplementation(() => new EventEmitter()); + + // ptySpawn() returns a minimal IPty-like stub so terminal wiring doesn't crash. + mockPtySpawn.mockImplementation(() => ({ + onData: vi.fn(), + onExit: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + })); + }); + + it("invokes set-option mouse on with the bare session id (no = prefix)", () => { + const mgr = new TerminalManager("/usr/bin/tmux"); + mgr.open("ao-177"); + + const mouseCall = mockSpawn.mock.calls.find( + (call) => Array.isArray(call[1]) && call[1].includes("mouse"), + ); + expect(mouseCall).toBeDefined(); + expect(mouseCall?.[1]).toEqual(["set-option", "-t", "ao-177", "mouse", "on"]); + }); + + it("invokes set-option status off with the bare session id (no = prefix)", () => { + const mgr = new TerminalManager("/usr/bin/tmux"); + mgr.open("ao-177"); + + const statusCall = mockSpawn.mock.calls.find( + (call) => Array.isArray(call[1]) && call[1].includes("status"), + ); + expect(statusCall).toBeDefined(); + expect(statusCall?.[1]).toEqual(["set-option", "-t", "ao-177", "status", "off"]); + }); + + it("still uses the = exact-match prefix for attach-session", () => { + const mgr = new TerminalManager("/usr/bin/tmux"); + mgr.open("ao-177"); + + expect(mockPtySpawn).toHaveBeenCalledTimes(1); + const [, args] = mockPtySpawn.mock.calls[0]; + expect(args).toEqual(["attach-session", "-t", "=ao-177"]); + }); +}); diff --git a/packages/web/server/mux-websocket.ts b/packages/web/server/mux-websocket.ts index 42eb651b0..60fb7fd4e 100644 --- a/packages/web/server/mux-websocket.ts +++ b/packages/web/server/mux-websocket.ts @@ -222,7 +222,7 @@ const REATTACH_RESET_GRACE_MS = 5_000; * TerminalManager manages PTY processes independently of WebSocket connections. * A single manager instance is shared across all mux connections. */ -class TerminalManager { +export class TerminalManager { private terminals = new Map(); private TMUX: string; @@ -274,16 +274,18 @@ class TerminalManager { return tmuxSessionId; } - // Enable mouse mode - const exactTmuxTarget = `=${tmuxSessionId}`; + // tmux 3.4 only honours the `=` exact-match prefix on has-session and + // attach-session; set-option silently ignores it, so we use the bare id + // here. The `=`-prefixed form is built below for attach-session. - const mouseProc = spawn(this.TMUX, ["set-option", "-t", exactTmuxTarget, "mouse", "on"]); + // Enable mouse mode + const mouseProc = spawn(this.TMUX, ["set-option", "-t", tmuxSessionId, "mouse", "on"]); mouseProc.on("error", (err) => { console.error(`[MuxServer] Failed to set mouse mode for ${tmuxSessionId}:`, err.message); }); // Hide the status bar - const statusProc = spawn(this.TMUX, ["set-option", "-t", exactTmuxTarget, "status", "off"]); + const statusProc = spawn(this.TMUX, ["set-option", "-t", tmuxSessionId, "status", "off"]); statusProc.on("error", (err) => { console.error(`[MuxServer] Failed to hide status bar for ${tmuxSessionId}:`, err.message); }); @@ -305,7 +307,9 @@ class TerminalManager { throw new Error("node-pty not available"); } - // Spawn PTY + // Spawn PTY — use `=`-prefixed exact-match target so we never attach to + // a session whose name happens to be a prefix of the requested id. + const exactTmuxTarget = `=${tmuxSessionId}`; const pty = ptySpawn(this.TMUX, ["attach-session", "-t", exactTmuxTarget], { name: "xterm-256color", cols: 80, From a2afccc69aca40bc7119bd8c144739c86636b611 Mon Sep 17 00:00:00 2001 From: i-trytoohard Date: Fri, 8 May 2026 02:15:57 +0530 Subject: [PATCH 12/15] fix(web): disable xterm scrollback to prevent terminal right-side clipping (#1678) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(web): disable xterm scrollback to prevent right-side clipping FitAddon.proposeDimensions() reserves 14px (DEFAULT_SCROLL_BAR_WIDTH) for the scrollbar when scrollback > 0. The custom CSS sets the actual scrollbar to 5px with overflow-y: overlay (deprecated in Chrome 114+). This mismatch causes the terminal to calculate the wrong number of columns — content gets clipped under the scrollbar on the right side. tmux already provides scrollback and copy-mode, so xterm's scrollback is redundant. Setting scrollback: 0 eliminates the scrollbar entirely and makes FitAddon's width calculation match the rendered output. Fixes #1677 * chore(web): refresh stale scrollback comments per greptile * fix(web): reset letter-spacing on .xterm to fix right-side char clipping The body has letter-spacing: -0.011em (~-0.176px at 16px) for typography refinement. xterm's DOM renderer measures cell width with that spacing inherited (~7.83px), then sets an inline letter-spacing override on .xterm-rows that cancels the body inherit, leaving glyphs to render at their natural ~8.00px width. The mismatch — measured 7.83px cells vs rendered 8.00px glyphs — accumulates as cols grow, eventually overflowing .xterm-rows > div and getting clipped by its overflow: hidden. At 124 cols the drift was ~21px, chopping ~3 chars off the right edge. Resetting letter-spacing on .xterm makes both phases agree and reduces the drift to sub-pixel rounding (~2px worst case at any reasonable cols). --------- Co-authored-by: AO Bot Co-authored-by: Prateek --- packages/web/src/app/globals.css | 10 ++++++++++ .../components/terminal/useXtermTerminal.ts | 18 ++++++++++++------ 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/packages/web/src/app/globals.css b/packages/web/src/app/globals.css index efdd58cf9..0d07719cf 100644 --- a/packages/web/src/app/globals.css +++ b/packages/web/src/app/globals.css @@ -479,6 +479,16 @@ a:hover { /* ── xterm.js terminal viewport scrollbar — macOS-style auto-hide ──── */ +/* Reset body's -0.011em letter-spacing inside the terminal subtree. + xterm's DOM renderer measures cell width with the inherited spacing + applied, but renders rows with an inline override that effectively + cancels it. The mismatch causes glyphs to paint ~0.17px wider than + the allocated cell, which accumulates over many cols and clips the + rightmost chars under .xterm-rows > div { overflow: hidden }. */ +.xterm { + letter-spacing: 0; +} + .xterm .xterm-viewport { overflow-y: overlay !important; } diff --git a/packages/web/src/components/terminal/useXtermTerminal.ts b/packages/web/src/components/terminal/useXtermTerminal.ts index 1dc918847..47a718f38 100644 --- a/packages/web/src/components/terminal/useXtermTerminal.ts +++ b/packages/web/src/components/terminal/useXtermTerminal.ts @@ -104,7 +104,11 @@ export function useXtermTerminal( // Light mode needs an explicit contrast floor because agent UIs often emit // dim/faint ANSI sequences that become unreadable on a near-white background. minimumContrastRatio: isDark ? 1 : 7, - scrollback: 10000, + // scrollback disabled — tmux provides scrollback/copy-mode, and leaving + // this > 0 makes FitAddon subtract DEFAULT_SCROLL_BAR_WIDTH (14px) from + // the available width, causing right-side clipping when the actual + // scrollbar is narrower or wider than assumed. Fixes #1677. + scrollback: 0, allowProposedApi: true, fastScrollSensitivity: 3, scrollSensitivity: 1, @@ -332,7 +336,7 @@ export function useXtermTerminal( // fontSize intentionally NOT in deps — it's handled by a dedicated effect // below that mutates terminal.options.fontSize in place. Adding it here // would tear down and recreate the terminal (and WebSocket) on every - // stepper click, losing scrollback and flashing content. + // stepper click, causing a content flash and WebSocket reconnect. }, [ appearance, sessionId, @@ -388,12 +392,14 @@ export function useXtermTerminal( const t = terminalInstance.current; if (t) { if (t.buffer.active.type === "normal") { - // Normal buffer: scrollback exists in xterm, use its API. + // Normal buffer: xterm scrollback is disabled (scrollback: 0), so + // history lives in tmux. scrollToBottom() is a safe no-op that + // re-anchors the viewport to the live tail. t.scrollToBottom(); } else { - // Alternate buffer (tmux/vim): xterm has no scrollback to scroll - // to. The user is in tmux copy-mode (entered by attachTouchScroll - // on swipe). Send 'q' to exit copy-mode and return to live tail. + // Alternate buffer (tmux/vim): the user is in tmux copy-mode + // (entered by attachTouchScroll on swipe). Send 'q' to exit + // copy-mode and return to live tail. writeTerminal(sessionId, "q", projectId); } } From b9b20e19d192a02c99a7f81b0c59855f4970b5f3 Mon Sep 17 00:00:00 2001 From: i-trytoohard Date: Fri, 8 May 2026 02:37:44 +0530 Subject: [PATCH 13/15] chore: release 0.6.0 (#1723) * chore: release 0.6.0 * test(agent-codex): bump version pin to 0.6.0 --------- Co-authored-by: Prateek --- .changeset/per-project-env.md | 5 --- .changeset/reload-dashboard-on-add-project.md | 5 --- packages/ao/CHANGELOG.md | 7 ++++ packages/ao/package.json | 2 +- packages/cli/CHANGELOG.md | 36 +++++++++++++++++++ packages/cli/package.json | 2 +- packages/core/CHANGELOG.md | 12 +++++++ packages/core/package.json | 2 +- packages/plugins/agent-aider/CHANGELOG.md | 10 ++++++ packages/plugins/agent-aider/package.json | 2 +- .../plugins/agent-claude-code/CHANGELOG.md | 10 ++++++ .../plugins/agent-claude-code/package.json | 2 +- packages/plugins/agent-codex/CHANGELOG.md | 10 ++++++ packages/plugins/agent-codex/package.json | 2 +- .../agent-codex/src/package-version.test.ts | 4 +-- packages/plugins/agent-cursor/CHANGELOG.md | 10 ++++++ packages/plugins/agent-cursor/package.json | 2 +- packages/plugins/agent-kimicode/CHANGELOG.md | 10 ++++++ packages/plugins/agent-kimicode/package.json | 2 +- packages/plugins/agent-opencode/CHANGELOG.md | 10 ++++++ packages/plugins/agent-opencode/package.json | 2 +- .../plugins/notifier-composio/CHANGELOG.md | 10 ++++++ .../plugins/notifier-composio/package.json | 2 +- .../plugins/notifier-desktop/CHANGELOG.md | 10 ++++++ .../plugins/notifier-desktop/package.json | 2 +- .../plugins/notifier-discord/CHANGELOG.md | 10 ++++++ .../plugins/notifier-discord/package.json | 2 +- .../plugins/notifier-openclaw/CHANGELOG.md | 10 ++++++ .../plugins/notifier-openclaw/package.json | 2 +- packages/plugins/notifier-slack/CHANGELOG.md | 10 ++++++ packages/plugins/notifier-slack/package.json | 2 +- .../plugins/notifier-webhook/CHANGELOG.md | 10 ++++++ .../plugins/notifier-webhook/package.json | 2 +- packages/plugins/runtime-process/CHANGELOG.md | 10 ++++++ packages/plugins/runtime-process/package.json | 2 +- packages/plugins/runtime-tmux/CHANGELOG.md | 11 ++++++ packages/plugins/runtime-tmux/package.json | 2 +- packages/plugins/scm-github/CHANGELOG.md | 10 ++++++ packages/plugins/scm-github/package.json | 2 +- packages/plugins/scm-gitlab/CHANGELOG.md | 10 ++++++ packages/plugins/scm-gitlab/package.json | 2 +- packages/plugins/terminal-iterm2/CHANGELOG.md | 10 ++++++ packages/plugins/terminal-iterm2/package.json | 2 +- packages/plugins/terminal-web/CHANGELOG.md | 10 ++++++ packages/plugins/terminal-web/package.json | 2 +- packages/plugins/tracker-github/CHANGELOG.md | 10 ++++++ packages/plugins/tracker-github/package.json | 2 +- packages/plugins/tracker-gitlab/CHANGELOG.md | 11 ++++++ packages/plugins/tracker-gitlab/package.json | 2 +- packages/plugins/tracker-linear/CHANGELOG.md | 10 ++++++ packages/plugins/tracker-linear/package.json | 2 +- packages/plugins/workspace-clone/CHANGELOG.md | 10 ++++++ packages/plugins/workspace-clone/package.json | 2 +- .../plugins/workspace-worktree/CHANGELOG.md | 10 ++++++ .../plugins/workspace-worktree/package.json | 2 +- packages/web/CHANGELOG.md | 23 ++++++++++++ packages/web/package.json | 2 +- 57 files changed, 339 insertions(+), 39 deletions(-) delete mode 100644 .changeset/per-project-env.md delete mode 100644 .changeset/reload-dashboard-on-add-project.md diff --git a/.changeset/per-project-env.md b/.changeset/per-project-env.md deleted file mode 100644 index c9945a22e..000000000 --- a/.changeset/per-project-env.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@aoagents/ao-core": minor ---- - -Add optional per-project `env` block to `ProjectConfig` that forwards string-to-string env vars into worker session runtimes (e.g. pin `GH_TOKEN` per project). AO-internal vars (`AO_SESSION`, `AO_PROJECT_ID`, etc.) always take precedence. diff --git a/.changeset/reload-dashboard-on-add-project.md b/.changeset/reload-dashboard-on-add-project.md deleted file mode 100644 index b947fdde9..000000000 --- a/.changeset/reload-dashboard-on-add-project.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@aoagents/ao-cli": patch ---- - -Fix dashboard 404 after adding a project from the "AO is already running" menu. The CLI now notifies the running daemon to reload its cached config so the new project's page is reachable immediately. diff --git a/packages/ao/CHANGELOG.md b/packages/ao/CHANGELOG.md index 8d0858d9d..6da307509 100644 --- a/packages/ao/CHANGELOG.md +++ b/packages/ao/CHANGELOG.md @@ -1,5 +1,12 @@ # @aoagents/ao +## 0.6.0 + +### Patch Changes + +- Updated dependencies [0f539a3] + - @aoagents/ao-cli@0.6.0 + ## 0.5.0 ### Patch Changes diff --git a/packages/ao/package.json b/packages/ao/package.json index e53774bc9..7279505f4 100644 --- a/packages/ao/package.json +++ b/packages/ao/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao", - "version": "0.5.0", + "version": "0.6.0", "description": "Orchestrate parallel AI coding agents — global CLI wrapper", "license": "MIT", "type": "module", diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index 72f3fb5d5..a110d80f4 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,41 @@ # @aoagents/ao-cli +## 0.6.0 + +### Patch Changes + +- 0f539a3: Fix dashboard 404 after adding a project from the "AO is already running" menu. The CLI now notifies the running daemon to reload its cached config so the new project's page is reachable immediately. +- Updated dependencies +- Updated dependencies +- Updated dependencies [40aeb78] +- Updated dependencies +- Updated dependencies +- Updated dependencies +- Updated dependencies + - @aoagents/ao-core@0.6.0 + - @aoagents/ao-web@0.6.0 + - @aoagents/ao-plugin-runtime-tmux@0.6.0 + - @aoagents/ao-plugin-agent-aider@0.6.0 + - @aoagents/ao-plugin-agent-claude-code@0.6.0 + - @aoagents/ao-plugin-agent-codex@0.6.0 + - @aoagents/ao-plugin-agent-cursor@0.1.4 + - @aoagents/ao-plugin-agent-kimicode@0.1.3 + - @aoagents/ao-plugin-agent-opencode@0.6.0 + - @aoagents/ao-plugin-notifier-composio@0.6.0 + - @aoagents/ao-plugin-notifier-desktop@0.6.0 + - @aoagents/ao-plugin-notifier-discord@0.2.9 + - @aoagents/ao-plugin-notifier-openclaw@0.2.9 + - @aoagents/ao-plugin-notifier-slack@0.6.0 + - @aoagents/ao-plugin-notifier-webhook@0.6.0 + - @aoagents/ao-plugin-runtime-process@0.6.0 + - @aoagents/ao-plugin-scm-github@0.6.0 + - @aoagents/ao-plugin-terminal-iterm2@0.6.0 + - @aoagents/ao-plugin-terminal-web@0.6.0 + - @aoagents/ao-plugin-tracker-github@0.6.0 + - @aoagents/ao-plugin-tracker-linear@0.6.0 + - @aoagents/ao-plugin-workspace-clone@0.6.0 + - @aoagents/ao-plugin-workspace-worktree@0.6.0 + ## 0.5.0 ### Minor Changes diff --git a/packages/cli/package.json b/packages/cli/package.json index 56f347871..72a373ffb 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-cli", - "version": "0.5.0", + "version": "0.6.0", "description": "CLI for agent-orchestrator — the `ao` command", "license": "MIT", "type": "module", diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 7203b1c99..663adb01c 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,17 @@ # @aoagents/ao-core +## 0.6.0 + +### Minor Changes + +- Wire activity events into `lifecycle-manager` failure paths so failures surface as activity entries for downstream consumers (#1620). +- 40aeb78: Add optional per-project `env` block to `ProjectConfig` that forwards string-to-string env vars into worker session runtimes (e.g. pin `GH_TOKEN` per project). AO-internal vars (`AO_SESSION`, `AO_PROJECT_ID`, etc.) always take precedence. + +### Patch Changes + +- Disable the tmux status bar in the runtime-tmux plugin and clean up dead code in core (#1711). +- Disable tmux status bar at session creation (#1683). The change touched dead code; the actual user-visible fix landed in #1711. + ## 0.5.0 ### Patch Changes diff --git a/packages/core/package.json b/packages/core/package.json index 4efd2fbb6..c5678cc26 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-core", - "version": "0.5.0", + "version": "0.6.0", "description": "Core library — types, config, session manager, lifecycle manager, event bus", "license": "MIT", "type": "module", diff --git a/packages/plugins/agent-aider/CHANGELOG.md b/packages/plugins/agent-aider/CHANGELOG.md index 0a5d052eb..51df2e23f 100644 --- a/packages/plugins/agent-aider/CHANGELOG.md +++ b/packages/plugins/agent-aider/CHANGELOG.md @@ -1,5 +1,15 @@ # @aoagents/ao-plugin-agent-aider +## 0.6.0 + +### Patch Changes + +- Updated dependencies +- Updated dependencies [40aeb78] +- Updated dependencies +- Updated dependencies + - @aoagents/ao-core@0.6.0 + ## 0.5.0 ### Patch Changes diff --git a/packages/plugins/agent-aider/package.json b/packages/plugins/agent-aider/package.json index 33d05921f..ed2b733fb 100644 --- a/packages/plugins/agent-aider/package.json +++ b/packages/plugins/agent-aider/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-agent-aider", - "version": "0.5.0", + "version": "0.6.0", "description": "Agent plugin: Aider", "license": "MIT", "type": "module", diff --git a/packages/plugins/agent-claude-code/CHANGELOG.md b/packages/plugins/agent-claude-code/CHANGELOG.md index 8e25e5af0..f246bb9e8 100644 --- a/packages/plugins/agent-claude-code/CHANGELOG.md +++ b/packages/plugins/agent-claude-code/CHANGELOG.md @@ -1,5 +1,15 @@ # @aoagents/ao-plugin-agent-claude-code +## 0.6.0 + +### Patch Changes + +- Updated dependencies +- Updated dependencies [40aeb78] +- Updated dependencies +- Updated dependencies + - @aoagents/ao-core@0.6.0 + ## 0.5.0 ### Patch Changes diff --git a/packages/plugins/agent-claude-code/package.json b/packages/plugins/agent-claude-code/package.json index 56bc901cf..a17f9ee5f 100644 --- a/packages/plugins/agent-claude-code/package.json +++ b/packages/plugins/agent-claude-code/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-agent-claude-code", - "version": "0.5.0", + "version": "0.6.0", "description": "Agent plugin: Claude Code", "license": "MIT", "type": "module", diff --git a/packages/plugins/agent-codex/CHANGELOG.md b/packages/plugins/agent-codex/CHANGELOG.md index cab9803f5..b6749c398 100644 --- a/packages/plugins/agent-codex/CHANGELOG.md +++ b/packages/plugins/agent-codex/CHANGELOG.md @@ -1,5 +1,15 @@ # @aoagents/ao-plugin-agent-codex +## 0.6.0 + +### Patch Changes + +- Updated dependencies +- Updated dependencies [40aeb78] +- Updated dependencies +- Updated dependencies + - @aoagents/ao-core@0.6.0 + ## 0.5.0 ### Patch Changes diff --git a/packages/plugins/agent-codex/package.json b/packages/plugins/agent-codex/package.json index c298762ac..4b8eb3589 100644 --- a/packages/plugins/agent-codex/package.json +++ b/packages/plugins/agent-codex/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-agent-codex", - "version": "0.5.0", + "version": "0.6.0", "description": "Agent plugin: OpenAI Codex CLI", "license": "MIT", "type": "module", diff --git a/packages/plugins/agent-codex/src/package-version.test.ts b/packages/plugins/agent-codex/src/package-version.test.ts index 25f28dd45..5b0f95f09 100644 --- a/packages/plugins/agent-codex/src/package-version.test.ts +++ b/packages/plugins/agent-codex/src/package-version.test.ts @@ -2,10 +2,10 @@ import { describe, expect, it } from "vitest"; import { readFileSync } from "node:fs"; describe("package manifest version", () => { - it("is bumped to 0.5.0", () => { + it("is bumped to 0.6.0", () => { const packageJsonUrl = new URL("../package.json", import.meta.url); const packageJson = JSON.parse(readFileSync(packageJsonUrl, "utf8")) as { version?: string }; - expect(packageJson.version).toBe("0.5.0"); + expect(packageJson.version).toBe("0.6.0"); }); }); diff --git a/packages/plugins/agent-cursor/CHANGELOG.md b/packages/plugins/agent-cursor/CHANGELOG.md index 95370f717..577e8c55e 100644 --- a/packages/plugins/agent-cursor/CHANGELOG.md +++ b/packages/plugins/agent-cursor/CHANGELOG.md @@ -1,5 +1,15 @@ # @aoagents/ao-plugin-agent-cursor +## 0.1.4 + +### Patch Changes + +- Updated dependencies +- Updated dependencies [40aeb78] +- Updated dependencies +- Updated dependencies + - @aoagents/ao-core@0.6.0 + ## 0.1.3 ### Patch Changes diff --git a/packages/plugins/agent-cursor/package.json b/packages/plugins/agent-cursor/package.json index 229393dd5..6205bae45 100644 --- a/packages/plugins/agent-cursor/package.json +++ b/packages/plugins/agent-cursor/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-agent-cursor", - "version": "0.1.3", + "version": "0.1.4", "description": "Agent plugin: Cursor CLI", "license": "MIT", "type": "module", diff --git a/packages/plugins/agent-kimicode/CHANGELOG.md b/packages/plugins/agent-kimicode/CHANGELOG.md index ec6196eb6..946ba3001 100644 --- a/packages/plugins/agent-kimicode/CHANGELOG.md +++ b/packages/plugins/agent-kimicode/CHANGELOG.md @@ -1,5 +1,15 @@ # @aoagents/ao-plugin-agent-kimicode +## 0.1.3 + +### Patch Changes + +- Updated dependencies +- Updated dependencies [40aeb78] +- Updated dependencies +- Updated dependencies + - @aoagents/ao-core@0.6.0 + ## 0.1.2 ### Patch Changes diff --git a/packages/plugins/agent-kimicode/package.json b/packages/plugins/agent-kimicode/package.json index e28d258cf..6f2dda0b6 100644 --- a/packages/plugins/agent-kimicode/package.json +++ b/packages/plugins/agent-kimicode/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-agent-kimicode", - "version": "0.1.2", + "version": "0.1.3", "description": "Agent plugin: Kimi Code CLI (MoonshotAI)", "license": "MIT", "type": "module", diff --git a/packages/plugins/agent-opencode/CHANGELOG.md b/packages/plugins/agent-opencode/CHANGELOG.md index 30ef5a477..aa05d8c3a 100644 --- a/packages/plugins/agent-opencode/CHANGELOG.md +++ b/packages/plugins/agent-opencode/CHANGELOG.md @@ -1,5 +1,15 @@ # @aoagents/ao-plugin-agent-opencode +## 0.6.0 + +### Patch Changes + +- Updated dependencies +- Updated dependencies [40aeb78] +- Updated dependencies +- Updated dependencies + - @aoagents/ao-core@0.6.0 + ## 0.5.0 ### Patch Changes diff --git a/packages/plugins/agent-opencode/package.json b/packages/plugins/agent-opencode/package.json index 3e9532d04..166b95da9 100644 --- a/packages/plugins/agent-opencode/package.json +++ b/packages/plugins/agent-opencode/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-agent-opencode", - "version": "0.5.0", + "version": "0.6.0", "description": "Agent plugin: OpenCode", "license": "MIT", "type": "module", diff --git a/packages/plugins/notifier-composio/CHANGELOG.md b/packages/plugins/notifier-composio/CHANGELOG.md index 4f42124d6..2bf202333 100644 --- a/packages/plugins/notifier-composio/CHANGELOG.md +++ b/packages/plugins/notifier-composio/CHANGELOG.md @@ -1,5 +1,15 @@ # @aoagents/ao-plugin-notifier-composio +## 0.6.0 + +### Patch Changes + +- Updated dependencies +- Updated dependencies [40aeb78] +- Updated dependencies +- Updated dependencies + - @aoagents/ao-core@0.6.0 + ## 0.5.0 ### Patch Changes diff --git a/packages/plugins/notifier-composio/package.json b/packages/plugins/notifier-composio/package.json index 978457d07..5efe6d46d 100644 --- a/packages/plugins/notifier-composio/package.json +++ b/packages/plugins/notifier-composio/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-notifier-composio", - "version": "0.5.0", + "version": "0.6.0", "description": "Notifier plugin: Composio unified notifications (Slack, Discord, email)", "license": "MIT", "type": "module", diff --git a/packages/plugins/notifier-desktop/CHANGELOG.md b/packages/plugins/notifier-desktop/CHANGELOG.md index 020519115..222fec642 100644 --- a/packages/plugins/notifier-desktop/CHANGELOG.md +++ b/packages/plugins/notifier-desktop/CHANGELOG.md @@ -1,5 +1,15 @@ # @aoagents/ao-plugin-notifier-desktop +## 0.6.0 + +### Patch Changes + +- Updated dependencies +- Updated dependencies [40aeb78] +- Updated dependencies +- Updated dependencies + - @aoagents/ao-core@0.6.0 + ## 0.5.0 ### Patch Changes diff --git a/packages/plugins/notifier-desktop/package.json b/packages/plugins/notifier-desktop/package.json index 8599b25d4..d6ad0024d 100644 --- a/packages/plugins/notifier-desktop/package.json +++ b/packages/plugins/notifier-desktop/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-notifier-desktop", - "version": "0.5.0", + "version": "0.6.0", "description": "Notifier plugin: OS desktop notifications", "license": "MIT", "type": "module", diff --git a/packages/plugins/notifier-discord/CHANGELOG.md b/packages/plugins/notifier-discord/CHANGELOG.md index b667e99fb..80b29fe1b 100644 --- a/packages/plugins/notifier-discord/CHANGELOG.md +++ b/packages/plugins/notifier-discord/CHANGELOG.md @@ -1,5 +1,15 @@ # @aoagents/ao-plugin-notifier-discord +## 0.2.9 + +### Patch Changes + +- Updated dependencies +- Updated dependencies [40aeb78] +- Updated dependencies +- Updated dependencies + - @aoagents/ao-core@0.6.0 + ## 0.2.8 ### Patch Changes diff --git a/packages/plugins/notifier-discord/package.json b/packages/plugins/notifier-discord/package.json index bb612cb6a..04c7c1195 100644 --- a/packages/plugins/notifier-discord/package.json +++ b/packages/plugins/notifier-discord/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-notifier-discord", - "version": "0.2.8", + "version": "0.2.9", "description": "Notifier plugin: Discord webhook notifications", "license": "MIT", "type": "module", diff --git a/packages/plugins/notifier-openclaw/CHANGELOG.md b/packages/plugins/notifier-openclaw/CHANGELOG.md index e2e015c79..636384032 100644 --- a/packages/plugins/notifier-openclaw/CHANGELOG.md +++ b/packages/plugins/notifier-openclaw/CHANGELOG.md @@ -1,5 +1,15 @@ # @aoagents/ao-plugin-notifier-openclaw +## 0.2.9 + +### Patch Changes + +- Updated dependencies +- Updated dependencies [40aeb78] +- Updated dependencies +- Updated dependencies + - @aoagents/ao-core@0.6.0 + ## 0.2.8 ### Patch Changes diff --git a/packages/plugins/notifier-openclaw/package.json b/packages/plugins/notifier-openclaw/package.json index 7e830f0f5..239a31aa4 100644 --- a/packages/plugins/notifier-openclaw/package.json +++ b/packages/plugins/notifier-openclaw/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-notifier-openclaw", - "version": "0.2.8", + "version": "0.2.9", "description": "Notifier plugin: OpenClaw webhook notifications", "license": "MIT", "type": "module", diff --git a/packages/plugins/notifier-slack/CHANGELOG.md b/packages/plugins/notifier-slack/CHANGELOG.md index e258da133..c6c180fec 100644 --- a/packages/plugins/notifier-slack/CHANGELOG.md +++ b/packages/plugins/notifier-slack/CHANGELOG.md @@ -1,5 +1,15 @@ # @aoagents/ao-plugin-notifier-slack +## 0.6.0 + +### Patch Changes + +- Updated dependencies +- Updated dependencies [40aeb78] +- Updated dependencies +- Updated dependencies + - @aoagents/ao-core@0.6.0 + ## 0.5.0 ### Patch Changes diff --git a/packages/plugins/notifier-slack/package.json b/packages/plugins/notifier-slack/package.json index 81d22f662..15b6e23d6 100644 --- a/packages/plugins/notifier-slack/package.json +++ b/packages/plugins/notifier-slack/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-notifier-slack", - "version": "0.5.0", + "version": "0.6.0", "description": "Notifier plugin: Slack", "license": "MIT", "type": "module", diff --git a/packages/plugins/notifier-webhook/CHANGELOG.md b/packages/plugins/notifier-webhook/CHANGELOG.md index 3cdc6c360..719b15724 100644 --- a/packages/plugins/notifier-webhook/CHANGELOG.md +++ b/packages/plugins/notifier-webhook/CHANGELOG.md @@ -1,5 +1,15 @@ # @aoagents/ao-plugin-notifier-webhook +## 0.6.0 + +### Patch Changes + +- Updated dependencies +- Updated dependencies [40aeb78] +- Updated dependencies +- Updated dependencies + - @aoagents/ao-core@0.6.0 + ## 0.5.0 ### Patch Changes diff --git a/packages/plugins/notifier-webhook/package.json b/packages/plugins/notifier-webhook/package.json index dcb193422..c16c7d00d 100644 --- a/packages/plugins/notifier-webhook/package.json +++ b/packages/plugins/notifier-webhook/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-notifier-webhook", - "version": "0.5.0", + "version": "0.6.0", "description": "Notifier plugin: generic webhook", "license": "MIT", "type": "module", diff --git a/packages/plugins/runtime-process/CHANGELOG.md b/packages/plugins/runtime-process/CHANGELOG.md index 02bdac703..b0ac8d8ec 100644 --- a/packages/plugins/runtime-process/CHANGELOG.md +++ b/packages/plugins/runtime-process/CHANGELOG.md @@ -1,5 +1,15 @@ # @aoagents/ao-plugin-runtime-process +## 0.6.0 + +### Patch Changes + +- Updated dependencies +- Updated dependencies [40aeb78] +- Updated dependencies +- Updated dependencies + - @aoagents/ao-core@0.6.0 + ## 0.5.0 ### Patch Changes diff --git a/packages/plugins/runtime-process/package.json b/packages/plugins/runtime-process/package.json index 69c81e8cc..5c6395a6f 100644 --- a/packages/plugins/runtime-process/package.json +++ b/packages/plugins/runtime-process/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-runtime-process", - "version": "0.5.0", + "version": "0.6.0", "description": "Runtime plugin: child processes", "license": "MIT", "type": "module", diff --git a/packages/plugins/runtime-tmux/CHANGELOG.md b/packages/plugins/runtime-tmux/CHANGELOG.md index 338253e51..8a6620867 100644 --- a/packages/plugins/runtime-tmux/CHANGELOG.md +++ b/packages/plugins/runtime-tmux/CHANGELOG.md @@ -1,5 +1,16 @@ # @aoagents/ao-plugin-runtime-tmux +## 0.6.0 + +### Patch Changes + +- Disable the tmux status bar in the runtime-tmux plugin and clean up dead code in core (#1711). +- Updated dependencies +- Updated dependencies [40aeb78] +- Updated dependencies +- Updated dependencies + - @aoagents/ao-core@0.6.0 + ## 0.5.0 ### Patch Changes diff --git a/packages/plugins/runtime-tmux/package.json b/packages/plugins/runtime-tmux/package.json index 9d4917de9..bfd28c44a 100644 --- a/packages/plugins/runtime-tmux/package.json +++ b/packages/plugins/runtime-tmux/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-runtime-tmux", - "version": "0.5.0", + "version": "0.6.0", "description": "Runtime plugin: tmux sessions", "license": "MIT", "type": "module", diff --git a/packages/plugins/scm-github/CHANGELOG.md b/packages/plugins/scm-github/CHANGELOG.md index 8dfa9d255..915bafc42 100644 --- a/packages/plugins/scm-github/CHANGELOG.md +++ b/packages/plugins/scm-github/CHANGELOG.md @@ -1,5 +1,15 @@ # @aoagents/ao-plugin-scm-github +## 0.6.0 + +### Patch Changes + +- Updated dependencies +- Updated dependencies [40aeb78] +- Updated dependencies +- Updated dependencies + - @aoagents/ao-core@0.6.0 + ## 0.5.0 ### Patch Changes diff --git a/packages/plugins/scm-github/package.json b/packages/plugins/scm-github/package.json index a0394b780..9ea778c52 100644 --- a/packages/plugins/scm-github/package.json +++ b/packages/plugins/scm-github/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-scm-github", - "version": "0.5.0", + "version": "0.6.0", "description": "SCM plugin: GitHub (PRs, CI, reviews)", "license": "MIT", "type": "module", diff --git a/packages/plugins/scm-gitlab/CHANGELOG.md b/packages/plugins/scm-gitlab/CHANGELOG.md index 6e7bf9c73..709102027 100644 --- a/packages/plugins/scm-gitlab/CHANGELOG.md +++ b/packages/plugins/scm-gitlab/CHANGELOG.md @@ -1,5 +1,15 @@ # @aoagents/ao-plugin-scm-gitlab +## 0.2.9 + +### Patch Changes + +- Updated dependencies +- Updated dependencies [40aeb78] +- Updated dependencies +- Updated dependencies + - @aoagents/ao-core@0.6.0 + ## 0.2.8 ### Patch Changes diff --git a/packages/plugins/scm-gitlab/package.json b/packages/plugins/scm-gitlab/package.json index f630adb17..823fec6db 100644 --- a/packages/plugins/scm-gitlab/package.json +++ b/packages/plugins/scm-gitlab/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-scm-gitlab", - "version": "0.2.8", + "version": "0.2.9", "description": "SCM plugin: GitLab (MRs, CI, reviews)", "license": "MIT", "type": "module", diff --git a/packages/plugins/terminal-iterm2/CHANGELOG.md b/packages/plugins/terminal-iterm2/CHANGELOG.md index d01a5dfe9..3488b1239 100644 --- a/packages/plugins/terminal-iterm2/CHANGELOG.md +++ b/packages/plugins/terminal-iterm2/CHANGELOG.md @@ -1,5 +1,15 @@ # @aoagents/ao-plugin-terminal-iterm2 +## 0.6.0 + +### Patch Changes + +- Updated dependencies +- Updated dependencies [40aeb78] +- Updated dependencies +- Updated dependencies + - @aoagents/ao-core@0.6.0 + ## 0.5.0 ### Patch Changes diff --git a/packages/plugins/terminal-iterm2/package.json b/packages/plugins/terminal-iterm2/package.json index 4e5628a85..f1ed9713c 100644 --- a/packages/plugins/terminal-iterm2/package.json +++ b/packages/plugins/terminal-iterm2/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-terminal-iterm2", - "version": "0.5.0", + "version": "0.6.0", "description": "Terminal plugin: macOS iTerm2", "license": "MIT", "type": "module", diff --git a/packages/plugins/terminal-web/CHANGELOG.md b/packages/plugins/terminal-web/CHANGELOG.md index 5f14bc63a..2582dfc67 100644 --- a/packages/plugins/terminal-web/CHANGELOG.md +++ b/packages/plugins/terminal-web/CHANGELOG.md @@ -1,5 +1,15 @@ # @aoagents/ao-plugin-terminal-web +## 0.6.0 + +### Patch Changes + +- Updated dependencies +- Updated dependencies [40aeb78] +- Updated dependencies +- Updated dependencies + - @aoagents/ao-core@0.6.0 + ## 0.5.0 ### Patch Changes diff --git a/packages/plugins/terminal-web/package.json b/packages/plugins/terminal-web/package.json index f29915d49..ca6b5b54d 100644 --- a/packages/plugins/terminal-web/package.json +++ b/packages/plugins/terminal-web/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-terminal-web", - "version": "0.5.0", + "version": "0.6.0", "description": "Terminal plugin: xterm.js web terminal", "license": "MIT", "type": "module", diff --git a/packages/plugins/tracker-github/CHANGELOG.md b/packages/plugins/tracker-github/CHANGELOG.md index dc991e76f..28df0d28b 100644 --- a/packages/plugins/tracker-github/CHANGELOG.md +++ b/packages/plugins/tracker-github/CHANGELOG.md @@ -1,5 +1,15 @@ # @aoagents/ao-plugin-tracker-github +## 0.6.0 + +### Patch Changes + +- Updated dependencies +- Updated dependencies [40aeb78] +- Updated dependencies +- Updated dependencies + - @aoagents/ao-core@0.6.0 + ## 0.5.0 ### Patch Changes diff --git a/packages/plugins/tracker-github/package.json b/packages/plugins/tracker-github/package.json index 7a702e80c..f8ff79b9e 100644 --- a/packages/plugins/tracker-github/package.json +++ b/packages/plugins/tracker-github/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-tracker-github", - "version": "0.5.0", + "version": "0.6.0", "description": "Tracker plugin: GitHub Issues", "license": "MIT", "type": "module", diff --git a/packages/plugins/tracker-gitlab/CHANGELOG.md b/packages/plugins/tracker-gitlab/CHANGELOG.md index a1706970d..f91c361fc 100644 --- a/packages/plugins/tracker-gitlab/CHANGELOG.md +++ b/packages/plugins/tracker-gitlab/CHANGELOG.md @@ -1,5 +1,16 @@ # @aoagents/ao-plugin-tracker-gitlab +## 0.2.9 + +### Patch Changes + +- Updated dependencies +- Updated dependencies [40aeb78] +- Updated dependencies +- Updated dependencies + - @aoagents/ao-core@0.6.0 + - @aoagents/ao-plugin-scm-gitlab@0.2.9 + ## 0.2.8 ### Patch Changes diff --git a/packages/plugins/tracker-gitlab/package.json b/packages/plugins/tracker-gitlab/package.json index abc2e9aa1..ad9ddb7a2 100644 --- a/packages/plugins/tracker-gitlab/package.json +++ b/packages/plugins/tracker-gitlab/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-tracker-gitlab", - "version": "0.2.8", + "version": "0.2.9", "description": "Tracker plugin: GitLab Issues", "license": "MIT", "type": "module", diff --git a/packages/plugins/tracker-linear/CHANGELOG.md b/packages/plugins/tracker-linear/CHANGELOG.md index ea8134de8..bf035d188 100644 --- a/packages/plugins/tracker-linear/CHANGELOG.md +++ b/packages/plugins/tracker-linear/CHANGELOG.md @@ -1,5 +1,15 @@ # @aoagents/ao-plugin-tracker-linear +## 0.6.0 + +### Patch Changes + +- Updated dependencies +- Updated dependencies [40aeb78] +- Updated dependencies +- Updated dependencies + - @aoagents/ao-core@0.6.0 + ## 0.5.0 ### Patch Changes diff --git a/packages/plugins/tracker-linear/package.json b/packages/plugins/tracker-linear/package.json index cfa6e680c..8356c6346 100644 --- a/packages/plugins/tracker-linear/package.json +++ b/packages/plugins/tracker-linear/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-tracker-linear", - "version": "0.5.0", + "version": "0.6.0", "description": "Tracker plugin: Linear", "license": "MIT", "type": "module", diff --git a/packages/plugins/workspace-clone/CHANGELOG.md b/packages/plugins/workspace-clone/CHANGELOG.md index 4340349bd..f5c8afc36 100644 --- a/packages/plugins/workspace-clone/CHANGELOG.md +++ b/packages/plugins/workspace-clone/CHANGELOG.md @@ -1,5 +1,15 @@ # @aoagents/ao-plugin-workspace-clone +## 0.6.0 + +### Patch Changes + +- Updated dependencies +- Updated dependencies [40aeb78] +- Updated dependencies +- Updated dependencies + - @aoagents/ao-core@0.6.0 + ## 0.5.0 ### Patch Changes diff --git a/packages/plugins/workspace-clone/package.json b/packages/plugins/workspace-clone/package.json index 412539e87..8d44bdbe8 100644 --- a/packages/plugins/workspace-clone/package.json +++ b/packages/plugins/workspace-clone/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-workspace-clone", - "version": "0.5.0", + "version": "0.6.0", "description": "Workspace plugin: git clone", "license": "MIT", "type": "module", diff --git a/packages/plugins/workspace-worktree/CHANGELOG.md b/packages/plugins/workspace-worktree/CHANGELOG.md index d4f0a410c..b8572df3a 100644 --- a/packages/plugins/workspace-worktree/CHANGELOG.md +++ b/packages/plugins/workspace-worktree/CHANGELOG.md @@ -1,5 +1,15 @@ # @aoagents/ao-plugin-workspace-worktree +## 0.6.0 + +### Patch Changes + +- Updated dependencies +- Updated dependencies [40aeb78] +- Updated dependencies +- Updated dependencies + - @aoagents/ao-core@0.6.0 + ## 0.5.0 ### Patch Changes diff --git a/packages/plugins/workspace-worktree/package.json b/packages/plugins/workspace-worktree/package.json index d206eced5..798495179 100644 --- a/packages/plugins/workspace-worktree/package.json +++ b/packages/plugins/workspace-worktree/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-workspace-worktree", - "version": "0.5.0", + "version": "0.6.0", "description": "Workspace plugin: git worktrees", "license": "MIT", "type": "module", diff --git a/packages/web/CHANGELOG.md b/packages/web/CHANGELOG.md index 356bd8f88..9a91af54f 100644 --- a/packages/web/CHANGELOG.md +++ b/packages/web/CHANGELOG.md @@ -1,5 +1,28 @@ # @aoagents/ao-web +## 0.6.0 + +### Patch Changes + +- Drop `=` prefix from `set-option` invocation in `mux-websocket` so tmux accepts the option without erroring out (#1715). +- Bound the PTY re-attach loop with a grace-period counter reset to prevent runaway reconnect attempts (#1640). +- Disable xterm scrollback to prevent terminal right-side clipping (#1678). +- Updated dependencies +- Updated dependencies [40aeb78] +- Updated dependencies +- Updated dependencies + - @aoagents/ao-core@0.6.0 + - @aoagents/ao-plugin-runtime-tmux@0.6.0 + - @aoagents/ao-plugin-agent-claude-code@0.6.0 + - @aoagents/ao-plugin-agent-codex@0.6.0 + - @aoagents/ao-plugin-agent-cursor@0.1.4 + - @aoagents/ao-plugin-agent-kimicode@0.1.3 + - @aoagents/ao-plugin-agent-opencode@0.6.0 + - @aoagents/ao-plugin-scm-github@0.6.0 + - @aoagents/ao-plugin-tracker-github@0.6.0 + - @aoagents/ao-plugin-tracker-linear@0.6.0 + - @aoagents/ao-plugin-workspace-worktree@0.6.0 + ## 0.5.0 ### Patch Changes diff --git a/packages/web/package.json b/packages/web/package.json index b97ddd08e..c69dde607 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-web", - "version": "0.5.0", + "version": "0.6.0", "description": "Web dashboard for agent-orchestrator", "type": "module", "files": [ From 1981d471ca2ee88b4e18f33376d797b93e880189 Mon Sep 17 00:00:00 2001 From: Adil Shaikh Date: Fri, 8 May 2026 17:44:10 +0530 Subject: [PATCH 14/15] fix(core): deliver prompt inline via positional arg, remove post-launch polling (#1583) Claude Code's positional [prompt] argument keeps it in interactive mode; only -p/--print triggers headless one-shot exit. The entire post-launch polling mechanism was built on the wrong assumption. Changes: - Claude Code plugin: pass prompt as positional arg in getLaunchCommand - Core types: remove promptDelivery field from Agent interface - Session manager: remove post-launch polling/retry block - Prompt builder: clarify wording ("title, description, and labels" instead of "full issue details"), unify # format - Tracker-github: match prompt wording update - CLI spawn: remove dead-code promptDelivered warning Closes #1582 --- packages/cli/src/commands/spawn.ts | 11 - .../core/src/__tests__/prompt-builder.test.ts | 34 ++- .../__tests__/session-manager/spawn.test.ts | 186 +++---------- packages/core/src/prompt-builder.ts | 9 +- packages/core/src/session-manager.ts | 58 +--- packages/core/src/types.ts | 9 - .../src/prompt-delivery.integration.test.ts | 256 ------------------ .../agent-claude-code/src/index.test.ts | 34 ++- .../plugins/agent-claude-code/src/index.ts | 18 +- packages/plugins/tracker-github/src/index.ts | 2 +- 10 files changed, 115 insertions(+), 502 deletions(-) delete mode 100644 packages/integration-tests/src/prompt-delivery.integration.test.ts diff --git a/packages/cli/src/commands/spawn.ts b/packages/cli/src/commands/spawn.ts index 12174f7e1..061d6f9e8 100644 --- a/packages/cli/src/commands/spawn.ts +++ b/packages/cli/src/commands/spawn.ts @@ -248,17 +248,6 @@ async function spawnSession( ); console.log(` View: ${chalk.dim(projectSessionUrl(port, projectId, session.id))}`); - // Warn if prompt delivery failed (for post-launch agents like Claude Code) - const promptDelivered = session.metadata?.promptDelivered; - if (promptDelivered === "false") { - console.warn( - chalk.yellow( - ` ⚠ Prompt delivery failed — agent may be idle.\n` + - ` Use '${chalk.cyan("ao send " + session.id + ' "message..."')}' to send instructions manually.`, - ), - ); - } - // Open terminal tab if requested if (openTab) { try { diff --git a/packages/core/src/__tests__/prompt-builder.test.ts b/packages/core/src/__tests__/prompt-builder.test.ts index 71e33a1a4..5274066b0 100644 --- a/packages/core/src/__tests__/prompt-builder.test.ts +++ b/packages/core/src/__tests__/prompt-builder.test.ts @@ -60,7 +60,7 @@ describe("buildPrompt split output", () => { expect(systemPrompt).not.toContain("## Additional Instructions"); expect(taskPrompt).toContain("Focus on the API layer only."); - expect(taskPrompt).not.toContain("Work on issue: INT-1343"); + expect(taskPrompt).not.toContain("Work on issue #INT-1343"); expect(taskPrompt).not.toContain("Layered Prompt System"); }); @@ -83,15 +83,16 @@ describe("buildPrompt", () => { expect(taskPrompt).toBeUndefined(); }); - it("includes base prompt when issue is provided", () => { + it("includes base prompt when issue is provided without context", () => { const { systemPrompt, taskPrompt } = buildPrompt({ project, projectId: "test-app", issueId: "INT-1343", }); expect(systemPrompt).toContain(BASE_AGENT_PROMPT); - expect(systemPrompt).toContain("Work on issue: INT-1343"); - expect(taskPrompt).toBe("Work on issue: INT-1343"); + expect(systemPrompt).toContain("Work on issue #INT-1343"); + expect(taskPrompt).toContain("Work on issue #INT-1343"); + expect(taskPrompt).toContain("Issue details were not pre-fetched"); }); it("includes project context", () => { @@ -115,18 +116,20 @@ describe("buildPrompt", () => { expect(systemPrompt).not.toContain("Repository:"); }); - it("includes issue ID in task section", () => { + it("tells agent to fetch issue when context is missing", () => { const { systemPrompt, taskPrompt } = buildPrompt({ project, projectId: "test-app", issueId: "INT-1343", }); - expect(systemPrompt).toContain("Work on issue: INT-1343"); + expect(systemPrompt).toContain("Work on issue #INT-1343"); expect(systemPrompt).toContain("feat/INT-1343"); - expect(taskPrompt).toBe("Work on issue: INT-1343"); + expect(taskPrompt).toContain("Work on issue #INT-1343"); + expect(taskPrompt).toContain("Issue details were not pre-fetched"); + expect(taskPrompt).toContain("gh issue view INT-1343"); }); - it("includes issue context when provided", () => { + it("tells agent details are pre-fetched when context is provided", () => { const { systemPrompt, taskPrompt } = buildPrompt({ project, projectId: "test-app", @@ -136,10 +139,23 @@ describe("buildPrompt", () => { expect(systemPrompt).toContain("## Issue Details"); expect(systemPrompt).toContain("Layered Prompt System"); expect(systemPrompt).toContain("Priority: High"); - expect(taskPrompt).toBe("Work on issue: INT-1343"); + expect(taskPrompt).toContain("Work on issue #INT-1343"); + expect(taskPrompt).toContain("start implementing without re-fetching the issue"); expect(taskPrompt).not.toContain("Layered Prompt System"); }); + it("normalizes issue ID with leading # to avoid double-hash", () => { + const { systemPrompt, taskPrompt } = buildPrompt({ + project, + projectId: "test-app", + issueId: "#42", + }); + expect(systemPrompt).toContain("Work on issue #42"); + expect(systemPrompt).not.toContain("##42"); + expect(taskPrompt).toContain("Work on issue #42"); + expect(taskPrompt).not.toContain("##42"); + }); + it("includes inline agentRules", () => { project.agentRules = "Always run pnpm test before pushing."; const { systemPrompt } = buildPrompt({ diff --git a/packages/core/src/__tests__/session-manager/spawn.test.ts b/packages/core/src/__tests__/session-manager/spawn.test.ts index 970ea6aa0..67d91b212 100644 --- a/packages/core/src/__tests__/session-manager/spawn.test.ts +++ b/packages/core/src/__tests__/session-manager/spawn.test.ts @@ -1098,33 +1098,17 @@ describe("spawn", () => { expect(session.branch).not.toBe("main"); }); - it("sends prompt post-launch when agent.promptDelivery is 'post-launch'", async () => { - vi.useFakeTimers(); - const postLaunchAgent = { - ...mockAgent, - promptDelivery: "post-launch" as const, - }; - const registryWithPostLaunch: PluginRegistry = { - ...mockRegistry, - get: vi.fn().mockImplementation((slot: string) => { - if (slot === "runtime") return mockRuntime; - if (slot === "agent") return postLaunchAgent; - if (slot === "workspace") return mockWorkspace; - return null; + it("passes prompt inline to agent via getLaunchCommand", async () => { + const sm = createSessionManager({ config, registry: mockRegistry }); + await sm.spawn({ projectId: "my-app", prompt: "Fix the bug" }); + + // Prompt should be passed to getLaunchCommand, not sent via runtime.sendMessage + expect(mockAgent.getLaunchCommand).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: expect.stringContaining("Fix the bug"), }), - }; - - const sm = createSessionManager({ config, registry: registryWithPostLaunch }); - const spawnPromise = sm.spawn({ projectId: "my-app", prompt: "Fix the bug" }); - await vi.advanceTimersByTimeAsync(5_000); - await spawnPromise; - - // Prompt should be sent via runtime.sendMessage, not included in launch command - expect(mockRuntime.sendMessage).toHaveBeenCalledWith( - expect.objectContaining({ id: expect.any(String) }), - expect.stringContaining("Fix the bug"), ); - vi.useRealTimers(); + expect(mockRuntime.sendMessage).not.toHaveBeenCalled(); }); it("writes worker system prompt to file and passes only explicit task prompt to agent", async () => { @@ -1156,7 +1140,7 @@ describe("spawn", () => { expect(systemPrompt).toContain("Session Lifecycle"); expect(systemPrompt).toContain("## Project Context"); expect(systemPrompt).toContain("## Task"); - expect(systemPrompt).toContain("Work on issue: INT-1343"); + expect(systemPrompt).toContain("Work on issue #INT-1343"); expect(systemPrompt).not.toContain("## Additional Instructions"); }); @@ -1227,149 +1211,61 @@ describe("spawn", () => { expect(opencodeConfig.instructions[0]).toContain("worker-prompt-app-1.md"); const systemPromptPath = opencodeConfig.instructions[0]!; - expect(readFileSync(systemPromptPath, "utf-8")).toContain("Work on issue: INT-1343"); + expect(readFileSync(systemPromptPath, "utf-8")).toContain("Work on issue #INT-1343"); expect(readFileSync(systemPromptPath, "utf-8")).not.toContain("## Additional Instructions"); const agentsMdPath = getWorkspaceAgentsMdPath(workspacePath); expect(existsSync(agentsMdPath)).toBe(false); }); - it("does not send prompt post-launch when agent.promptDelivery is not set", async () => { + it("passes generated task prompt to agent for issue-only spawns", async () => { const sm = createSessionManager({ config, registry: mockRegistry }); - await sm.spawn({ projectId: "my-app", prompt: "Fix the bug" }); + await sm.spawn({ projectId: "my-app", issueId: "INT-1343" }); - // Default agent (no promptDelivery) should NOT trigger sendMessage for prompt - expect(mockRuntime.sendMessage).not.toHaveBeenCalled(); - }); - - it("does not send a post-launch message when no task prompt is available", async () => { - vi.useFakeTimers(); - const postLaunchAgent = { - ...mockAgent, - promptDelivery: "post-launch" as const, - }; - const registryWithPostLaunch: PluginRegistry = { - ...mockRegistry, - get: vi.fn().mockImplementation((slot: string) => { - if (slot === "runtime") return mockRuntime; - if (slot === "agent") return postLaunchAgent; - if (slot === "workspace") return mockWorkspace; - return null; - }), - }; - - const sm = createSessionManager({ config, registry: registryWithPostLaunch }); - const spawnPromise = sm.spawn({ projectId: "my-app" }); - await vi.advanceTimersByTimeAsync(5_000); - const session = await spawnPromise; - - expect(mockRuntime.sendMessage).not.toHaveBeenCalled(); - expect(session.metadata.promptDelivered).toBeUndefined(); - vi.useRealTimers(); - }); - - it("sends a minimal post-launch task for issue-only spawns", async () => { - vi.useFakeTimers(); - const postLaunchAgent = { - ...mockAgent, - promptDelivery: "post-launch" as const, - }; - const registryWithPostLaunch: PluginRegistry = { - ...mockRegistry, - get: vi.fn().mockImplementation((slot: string) => { - if (slot === "runtime") return mockRuntime; - if (slot === "agent") return postLaunchAgent; - if (slot === "workspace") return mockWorkspace; - return null; - }), - }; - - const sm = createSessionManager({ config, registry: registryWithPostLaunch }); - const spawnPromise = sm.spawn({ projectId: "my-app", issueId: "INT-1343" }); - await vi.advanceTimersByTimeAsync(5_000); - const session = await spawnPromise; - - expect(mockRuntime.sendMessage).toHaveBeenCalledWith( - expect.objectContaining({ id: expect.any(String) }), - "Work on issue: INT-1343", - ); - expect(session.metadata.promptDelivered).toBe("true"); - - const callArgs = vi.mocked(postLaunchAgent.getLaunchCommand).mock.calls[0][0]; - expect(callArgs.prompt).toBe("Work on issue: INT-1343"); + const callArgs = vi.mocked(mockAgent.getLaunchCommand).mock.calls[0][0]; + expect(callArgs.prompt).toContain("Work on issue #INT-1343"); expect(callArgs.systemPromptFile).toContain("worker-prompt-app-1.md"); const systemPrompt = readFileSync(callArgs.systemPromptFile!, "utf-8"); expect(systemPrompt).toContain("## Task"); - expect(systemPrompt).toContain("Work on issue: INT-1343"); - vi.useRealTimers(); + expect(systemPrompt).toContain("Work on issue #INT-1343"); }); - it("does not destroy session when post-launch prompt delivery fails", async () => { - vi.useFakeTimers(); - const failingRuntime: Runtime = { + it("installs workspace hooks before launching the agent", async () => { + const callOrder: string[] = []; + const trackingAgent = { + ...mockAgent, + setupWorkspaceHooks: vi.fn().mockImplementation(() => { + callOrder.push("setupWorkspaceHooks"); + return Promise.resolve(); + }), + }; + const trackingRuntime: Runtime = { ...mockRuntime, - sendMessage: vi.fn().mockRejectedValue(new Error("tmux send failed")), + create: vi.fn().mockImplementation((...args: Parameters) => { + callOrder.push("runtime.create"); + return vi.mocked(mockRuntime.create)(...args); + }), }; - const postLaunchAgent = { - ...mockAgent, - promptDelivery: "post-launch" as const, - }; - const registryWithFailingSend: PluginRegistry = { + const trackingRegistry: PluginRegistry = { ...mockRegistry, get: vi.fn().mockImplementation((slot: string) => { - if (slot === "runtime") return failingRuntime; - if (slot === "agent") return postLaunchAgent; + if (slot === "runtime") return trackingRuntime; + if (slot === "agent") return trackingAgent; if (slot === "workspace") return mockWorkspace; return null; }), }; - const sm = createSessionManager({ config, registry: registryWithFailingSend }); - const spawnPromise = sm.spawn({ projectId: "my-app", prompt: "Fix the bug" }); - // With retry logic (3 attempts at 3s, 6s, 9s delays before each attempt), need to advance 18s for all retries - await vi.advanceTimersByTimeAsync(18_000); - const session = await spawnPromise; + const sm = createSessionManager({ config, registry: trackingRegistry }); + await sm.spawn({ projectId: "my-app", prompt: "Fix the bug" }); - // Session should still be returned successfully despite sendMessage failure - expect(session.id).toBe("app-1"); - expect(session.status).toBe("spawning"); - // Runtime should NOT have been destroyed - expect(failingRuntime.destroy).not.toHaveBeenCalled(); - // Verify promptDelivered is set to false in metadata - expect(session.metadata.promptDelivered).toBe("false"); - vi.useRealTimers(); - }, 30_000); - - it("waits before sending post-launch prompt", async () => { - vi.useFakeTimers(); - const postLaunchAgent = { - ...mockAgent, - promptDelivery: "post-launch" as const, - }; - const registryWithPostLaunch: PluginRegistry = { - ...mockRegistry, - get: vi.fn().mockImplementation((slot: string) => { - if (slot === "runtime") return mockRuntime; - if (slot === "agent") return postLaunchAgent; - if (slot === "workspace") return mockWorkspace; - return null; - }), - }; - - const sm = createSessionManager({ config, registry: registryWithPostLaunch }); - const spawnPromise = sm.spawn({ projectId: "my-app", prompt: "Fix the bug" }); - - // Advance only 2s — not enough, message should not have been sent yet - await vi.advanceTimersByTimeAsync(2_000); - expect(mockRuntime.sendMessage).not.toHaveBeenCalled(); - - // Advance the remaining 1s — now the first attempt should fire (3s total = 3000 * 1) - await vi.advanceTimersByTimeAsync(1_000); - await spawnPromise; - expect(mockRuntime.sendMessage).toHaveBeenCalled(); - vi.useRealTimers(); - }, 20_000); + const hooksIdx = callOrder.indexOf("setupWorkspaceHooks"); + const createIdx = callOrder.indexOf("runtime.create"); + expect(hooksIdx).toBeGreaterThanOrEqual(0); + expect(createIdx).toBeGreaterThanOrEqual(0); + expect(hooksIdx).toBeLessThan(createIdx); + }); describe("rollback on failure", () => { it("cleans up reserved metadata when workspace creation fails", async () => { diff --git a/packages/core/src/prompt-builder.ts b/packages/core/src/prompt-builder.ts index f207a8f3b..ea40ef9c4 100644 --- a/packages/core/src/prompt-builder.ts +++ b/packages/core/src/prompt-builder.ts @@ -116,10 +116,11 @@ function buildConfigLayer(config: PromptBuildConfig): string { } if (issueId) { + const normalizedId = issueId.replace(/^#/, ""); lines.push(`\n## Task`); - lines.push(`Work on issue: ${issueId}`); + lines.push(`Work on issue #${normalizedId}`); lines.push( - `Create a branch named so that it auto-links to the issue tracker (e.g. feat/${issueId}).`, + `Create a branch named so that it auto-links to the issue tracker (e.g. feat/${normalizedId}).`, ); } @@ -203,7 +204,9 @@ export function buildPrompt( taskPrompt: config.userPrompt ? config.userPrompt : config.issueId - ? `Work on issue: ${config.issueId}` + ? config.issueContext + ? `Work on issue #${config.issueId.replace(/^#/, "")}. The issue title, description, and labels are already in your system prompt — start implementing without re-fetching the issue. Fetch comments or linked issues only if you need additional context.` + : `Work on issue #${config.issueId.replace(/^#/, "")}. Issue details were not pre-fetched — start by reading the issue (e.g. \`gh issue view ${config.issueId.replace(/^#/, "")}\`), then implement.` : undefined, }; } diff --git a/packages/core/src/session-manager.ts b/packages/core/src/session-manager.ts index 1cec42a94..1675108f8 100644 --- a/packages/core/src/session-manager.ts +++ b/packages/core/src/session-manager.ts @@ -1292,6 +1292,16 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM await plugins.agent.preLaunchSetup(workspacePath); } + // Install workspace hooks before launching the agent so that + // PostToolUse hooks (e.g. Claude Code's metadata-updater) are + // in place before the agent's first tool call. + if (plugins.agent.setupWorkspaceHooks) { + await plugins.agent.setupWorkspaceHooks(workspacePath, { dataDir: sessionsDir }); + } + if (plugins.agent.name !== "claude-code") { + await setupPathWrapperWorkspace(workspacePath); + } + const handle = await plugins.runtime.create({ sessionId: tmuxName ?? sessionId, // Use tmux name for runtime if available workspacePath, @@ -1410,53 +1420,11 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM invalidateCache(); // Past this point every resource that needed an undo is on disk in its - // final form. Dismiss the stack so the prompt-delivery loop below (which - // is intentionally non-fatal) cannot trigger a rollback. + // final form. Dismiss the stack so nothing below can trigger a rollback. cleanupStack.dismiss(); - // Send the task-specific prompt post-launch for agents that need it - // (e.g. Claude Code exits after -p, so we send the prompt after it starts - // in interactive mode). Prompt delivery failure must NOT destroy the - // session — the agent is running; user can retry with `ao send`. - let promptDelivered = false; - if (plugins.agent.promptDelivery === "post-launch" && agentLaunchConfig.prompt) { - const maxRetries = 3; - const baseDelayMs = 3_000; - let lastError: Error | undefined; - - for (let attempt = 1; attempt <= maxRetries; attempt++) { - try { - // Wait for agent to start and be ready for input - // Use exponential backoff: 3s, 6s, 9s between attempts - await new Promise((resolve) => setTimeout(resolve, baseDelayMs * attempt)); - await plugins.runtime.sendMessage(handle, agentLaunchConfig.prompt); - promptDelivered = true; - break; - } catch (err) { - lastError = err instanceof Error ? err : new Error(String(err)); - console.error( - `[session-manager] Prompt delivery attempt ${attempt}/${maxRetries} failed: ${lastError.message}`, - ); - } - } - - if (!promptDelivered) { - console.error( - `[session-manager] FAILED to deliver prompt to session ${sessionId} after ${maxRetries} attempts. ` + - `User must send manually with 'ao send'. Last error: ${lastError?.message}`, - ); - } - - session.metadata["promptDelivered"] = String(promptDelivered); - } else if (agentLaunchConfig.prompt) { - session.metadata["promptDelivered"] = "true"; - } - - if (session.metadata["promptDelivered"]) { - updateMetadata(sessionsDir, sessionId, session.metadata); - invalidateCache(); - } - + // Prompt is delivered inline via the agent's launch command (positional argument). + // No post-launch polling needed — the prompt is part of process invocation. recordActivityEvent({ projectId: spawnConfig.projectId, sessionId, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 3a41ef9f3..47dbabba7 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -464,15 +464,6 @@ export interface Agent { /** Process name to look for (e.g. "claude", "codex", "aider") */ readonly processName: string; - /** - * How the initial prompt should be delivered to the agent. - * - "inline" (default): prompt is included in the launch command (e.g. -p flag) - * - "post-launch": prompt is sent via runtime.sendMessage() after the agent starts, - * keeping the agent in interactive mode. Use this for agents where inlining - * the prompt causes one-shot/exit behavior (e.g. Claude Code's -p flag). - */ - readonly promptDelivery?: "inline" | "post-launch"; - /** Get the shell command to launch this agent */ getLaunchCommand(config: AgentLaunchConfig): string; diff --git a/packages/integration-tests/src/prompt-delivery.integration.test.ts b/packages/integration-tests/src/prompt-delivery.integration.test.ts deleted file mode 100644 index 3e5046dd3..000000000 --- a/packages/integration-tests/src/prompt-delivery.integration.test.ts +++ /dev/null @@ -1,256 +0,0 @@ -/** - * Integration test: Claude Code prompt delivery — `-p` (one-shot) vs interactive mode. - * - * Demonstrates the bug and the fix: - * - * 1. "claude -p exits after work" — launches Claude with -p, proves the process - * exits after responding. This is the bug: the agent can't receive follow-ups. - * - * 2. "interactive claude stays alive" — launches Claude without -p, delivers - * the prompt post-launch via runtime.sendMessage(), proves the process stays - * alive and can receive follow-up messages. - * - * Requires: tmux, claude binary, ANTHROPIC_API_KEY. - */ - -import { execFile } from "node:child_process"; -import { mkdtemp, realpath, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { promisify } from "node:util"; -import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import claudeCodePlugin from "@aoagents/ao-plugin-agent-claude-code"; -import runtimeTmuxPlugin from "@aoagents/ao-plugin-runtime-tmux"; -import { - isTmuxAvailable, - killSessionsByPrefix, - createSession, - killSession, - capturePane, -} from "./helpers/tmux.js"; -import { pollUntilEqual, sleep } from "./helpers/polling.js"; -import { makeTmuxHandle } from "./helpers/session-factory.js"; - -const execFileAsync = promisify(execFile); - -// --------------------------------------------------------------------------- -// Prerequisites -// --------------------------------------------------------------------------- - -const SESSION_PREFIX = "ao-inttest-prompt-"; - -/** Lines of scrollback to capture — generous to account for Claude's TUI output. */ -const CAPTURE_LINES = 500; - -async function findClaudeBinary(): Promise { - try { - await execFileAsync("which", ["claude"], { timeout: 5_000 }); - return "claude"; - } catch { - return null; - } -} - -/** - * Wait for Claude Code's TUI to be ready for input. - * Polls the tmux pane for Claude's prompt character (❯) on its own line. - * Returns false if the OAuth/login screen is detected instead. - */ -async function waitForTuiReady( - sessionName: string, - timeoutMs = 60_000, -): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - const output = await capturePane(sessionName, CAPTURE_LINES); - - // Bail early if OAuth/login screen is showing — TUI won't become ready - if (/sign in|oauth|Paste code here/i.test(output)) return false; - - // Claude Code shows ❯ when idle. Check the last non-empty line specifically - // (not the end of the full output) to avoid matching "prompted >" etc. - const lastLine = output - .split("\n") - .filter((l) => l.trim().length > 0) - .pop(); - if (lastLine && /^\s*❯\s*$/.test(lastLine)) return true; - - await sleep(2_000); - } - return false; -} - -const tmuxOk = await isTmuxAvailable(); -const claudeBin = await findClaudeBinary(); -const hasApiKey = Boolean(process.env.ANTHROPIC_API_KEY); -const canRun = tmuxOk && claudeBin !== null && hasApiKey; -// Interactive mode requires OAuth login. ANTHROPIC_API_KEY alone only works with -p. -// CI environments (GitHub Actions) typically only have API keys, not OAuth sessions. -const canRunInteractive = canRun && !process.env.CI; - -// --------------------------------------------------------------------------- -// Test 1: -p flag causes Claude to exit (the bug) -// --------------------------------------------------------------------------- - -describe.skipIf(!canRun)("claude -p exits after completing work (the bug)", () => { - const agent = claudeCodePlugin.create(); - const sessionName = `${SESSION_PREFIX}print-${Date.now()}`; - let tmpDir: string; - - beforeAll(async () => { - await killSessionsByPrefix(`${SESSION_PREFIX}print-`); - const raw = await mkdtemp(join(tmpdir(), "ao-inttest-prompt-print-")); - tmpDir = await realpath(raw); - - // Create a bash session (keeps tmux pane alive after Claude exits) - await createSession(sessionName, "bash", tmpDir); - await sleep(500); - - // Launch Claude with -p (print-and-exit mode) - await execFileAsync( - "tmux", - [ - "send-keys", - "-t", - sessionName, - "-l", - "CLAUDECODE= claude --dangerously-skip-permissions -p 'Respond with exactly: SENTINEL_P_DELIVERED'", - ], - { timeout: 10_000 }, - ); - await execFileAsync("tmux", ["send-keys", "-t", sessionName, "Enter"], { - timeout: 5_000, - }); - }, 30_000); - - afterAll(async () => { - await killSession(sessionName); - if (tmpDir) await rm(tmpDir, { recursive: true, force: true }).catch(() => {}); - }, 30_000); - - it("claude exits after responding to -p prompt", async () => { - const handle = makeTmuxHandle(sessionName); - - // Wait for Claude to start - await pollUntilEqual(() => agent.isProcessRunning(handle), true, { - timeoutMs: 30_000, - intervalMs: 1_000, - }); - - // Wait for it to exit — -p mode always exits after responding - const exited = await pollUntilEqual(() => agent.isProcessRunning(handle), false, { - timeoutMs: 90_000, - intervalMs: 2_000, - }); - expect(exited).toBe(false); - - // Verify the work was done (Claude did respond before exiting) - const output = await capturePane(sessionName, CAPTURE_LINES); - expect(output).toContain("SENTINEL_P_DELIVERED"); - - // This is the problem: process is gone, ao send has nothing to talk to - const running = await agent.isProcessRunning(handle); - expect(running).toBe(false); - }, 120_000); -}); - -// --------------------------------------------------------------------------- -// Test 2: interactive mode + post-launch prompt keeps Claude alive (the fix) -// --------------------------------------------------------------------------- - -describe.skipIf(!canRunInteractive)( - "interactive claude stays alive after post-launch prompt (the fix)", - () => { - const agent = claudeCodePlugin.create(); - const runtime = runtimeTmuxPlugin.create(); - const sessionName = `${SESSION_PREFIX}interactive-${Date.now()}`; - let tmpDir: string; - const handle = makeTmuxHandle(sessionName); - - beforeAll(async () => { - await killSessionsByPrefix(`${SESSION_PREFIX}interactive-`); - const raw = await mkdtemp(join(tmpdir(), "ao-inttest-prompt-interactive-")); - tmpDir = await realpath(raw); - - // Create a bash session - await createSession(sessionName, "bash", tmpDir); - await sleep(500); - - // Launch Claude WITHOUT -p (interactive mode) — this is the fix - await execFileAsync( - "tmux", - [ - "send-keys", - "-t", - sessionName, - "-l", - "CLAUDECODE= claude --dangerously-skip-permissions", - ], - { timeout: 10_000 }, - ); - await execFileAsync("tmux", ["send-keys", "-t", sessionName, "Enter"], { - timeout: 5_000, - }); - - // Wait for Claude to start - await pollUntilEqual(() => agent.isProcessRunning(handle), true, { - timeoutMs: 30_000, - intervalMs: 1_000, - }); - - // Wait for Claude's TUI to be fully ready (shows prompt character ❯) - await waitForTuiReady(sessionName, 60_000); - - // Deliver the initial prompt post-launch (what the fix does) - await runtime.sendMessage(handle, "Respond with exactly: SENTINEL_I_DELIVERED"); - - // Wait for Claude to process the prompt and return to idle (❯) - const deadline = Date.now() + 90_000; - while (Date.now() < deadline) { - const output = await capturePane(sessionName, CAPTURE_LINES); - // Check that Claude responded (sentinel in output) AND is back at idle prompt (❯) - const lastLine = output - .split("\n") - .filter((l) => l.trim().length > 0) - .pop(); - if (output.includes("SENTINEL_I_DELIVERED") && lastLine && /^\s*❯\s*$/.test(lastLine)) { - break; - } - await sleep(2_000); - } - }, 180_000); - - afterAll(async () => { - await killSession(sessionName); - if (tmpDir) await rm(tmpDir, { recursive: true, force: true }).catch(() => {}); - }, 30_000); - - it("prompt was delivered and processed", async () => { - const output = await capturePane(sessionName, CAPTURE_LINES); - expect(output).toContain("SENTINEL_I_DELIVERED"); - }); - - it("claude is still running after completing the task", async () => { - const running = await agent.isProcessRunning(handle); - expect(running).toBe(true); - }); - - it("can receive and process a follow-up message", async () => { - await runtime.sendMessage(handle, "Respond with exactly: SENTINEL_FOLLOWUP_OK"); - - const deadline = Date.now() + 90_000; - let output = ""; - while (Date.now() < deadline) { - output = await capturePane(sessionName, CAPTURE_LINES); - if (output.includes("SENTINEL_FOLLOWUP_OK")) break; - await sleep(2_000); - } - - expect(output).toContain("SENTINEL_FOLLOWUP_OK"); - - // Still alive after follow-up - const stillRunning = await agent.isProcessRunning(handle); - expect(stillRunning).toBe(true); - }, 120_000); - }, -); diff --git a/packages/plugins/agent-claude-code/src/index.test.ts b/packages/plugins/agent-claude-code/src/index.test.ts index 8d431e14e..d671f9f4e 100644 --- a/packages/plugins/agent-claude-code/src/index.test.ts +++ b/packages/plugins/agent-claude-code/src/index.test.ts @@ -199,7 +199,6 @@ describe("plugin manifest & exports", () => { const agent = create(); expect(agent.name).toBe("claude-code"); expect(agent.processName).toBe("claude"); - expect(agent.promptDelivery).toBe("post-launch"); }); it("default export is a valid PluginModule", () => { @@ -244,17 +243,22 @@ describe("getLaunchCommand", () => { expect(cmd).toContain("--model 'claude-opus-4-6'"); }); - it("does not include -p flag (prompt delivered post-launch)", () => { + it("includes prompt as positional argument with -- separator (not -p flag)", () => { const cmd = agent.getLaunchCommand(makeLaunchConfig({ prompt: "Fix the bug" })); expect(cmd).not.toContain("-p"); - expect(cmd).not.toContain("Fix the bug"); + expect(cmd).toContain("-- 'Fix the bug'"); }); - it("combines all options without prompt", () => { + it("combines all options with prompt as positional arg after --", () => { const cmd = agent.getLaunchCommand( makeLaunchConfig({ permissions: "permissionless", model: "opus", prompt: "Hello" }), ); - expect(cmd).toBe("claude --dangerously-skip-permissions --model 'opus'"); + expect(cmd).toBe("claude --dangerously-skip-permissions --model 'opus' -- 'Hello'"); + }); + + it("handles prompts starting with dashes safely via -- separator", () => { + const cmd = agent.getLaunchCommand(makeLaunchConfig({ prompt: "--investigate this" })); + expect(cmd).toContain("-- '--investigate this'"); }); it("omits --dangerously-skip-permissions when permissions=default", () => { @@ -268,25 +272,24 @@ describe("getLaunchCommand", () => { expect(cmd).not.toContain("-p"); }); - it("includes --append-system-prompt alongside omitted -p", () => { + it("includes --append-system-prompt and prompt as positional arg after --", () => { const cmd = agent.getLaunchCommand( makeLaunchConfig({ systemPrompt: "You are a helper", prompt: "Do the task" }), ); expect(cmd).toContain("--append-system-prompt"); expect(cmd).toContain("You are a helper"); - // -p as a standalone flag (not substring of --append-system-prompt) expect(cmd).not.toMatch(/\s-p\s/); - expect(cmd).not.toContain("Do the task"); + expect(cmd).toContain("-- 'Do the task'"); }); - it("uses systemPromptFile via shell substitution alongside omitted -p", () => { + it("uses systemPromptFile via shell substitution with prompt after --", () => { const cmd = agent.getLaunchCommand( makeLaunchConfig({ systemPromptFile: "/tmp/prompt.md", prompt: "Do the task" }), ); expect(cmd).toContain('--append-system-prompt "$(cat'); expect(cmd).toContain("/tmp/prompt.md"); expect(cmd).not.toMatch(/\s-p\s/); - expect(cmd).not.toContain("Do the task"); + expect(cmd).toContain("-- 'Do the task'"); }); }); @@ -870,14 +873,17 @@ describe("hook setup — relative path (symlink-safe)", () => { expect(hookCommand).not.toMatch(/^\//); }); - it("postLaunchSetup writes a relative hook command (not absolute)", async () => { + it("postLaunchSetup is a no-op (hooks installed pre-launch via setupWorkspaceHooks)", async () => { + mockWriteFile.mockClear(); await agent.postLaunchSetup!( makeSession({ workspacePath: "/Users/equinox/.worktrees/integrator/integrator-10" }), ); - const hookCommand = getWrittenHookCommand(); - expect(hookCommand).toBe(".claude/metadata-updater.sh"); - expect(hookCommand).not.toMatch(/^\//); + // No files should be written — hooks are installed before launch + const settingsWrites = mockWriteFile.mock.calls.filter( + ([path]: unknown[]) => typeof path === "string" && path.endsWith("settings.json"), + ); + expect(settingsWrites).toHaveLength(0); }); it("different worktree paths produce identical settings.json content", async () => { diff --git a/packages/plugins/agent-claude-code/src/index.ts b/packages/plugins/agent-claude-code/src/index.ts index 18927ee79..ab5dea4e4 100644 --- a/packages/plugins/agent-claude-code/src/index.ts +++ b/packages/plugins/agent-claude-code/src/index.ts @@ -683,8 +683,6 @@ function createClaudeCodeAgent(): Agent { return { name: "claude-code", processName: "claude", - promptDelivery: "post-launch", - getLaunchCommand(config: AgentLaunchConfig): string { // Note: CLAUDECODE is unset via getEnvironment() (set to ""), not here. // This command must be safe for both shell and execFile contexts. @@ -708,9 +706,12 @@ function createClaudeCodeAgent(): Agent { parts.push("--append-system-prompt", shellEscape(config.systemPrompt)); } - // NOTE: prompt is NOT included here — it's delivered post-launch via - // runtime.sendMessage() to keep Claude in interactive mode. - // Using -p causes one-shot mode (Claude exits after responding). + // The positional [prompt] argument auto-submits as the first user turn + // and keeps Claude in interactive mode. -p / --print is what triggers + // headless one-shot exit, not the presence of a prompt. + if (config.prompt) { + parts.push("--", shellEscape(config.prompt)); + } return parts.join(" "); }, @@ -876,10 +877,9 @@ function createClaudeCodeAgent(): Agent { await setupHookInWorkspace(workspacePath, ".claude/metadata-updater.sh"); }, - async postLaunchSetup(session: Session): Promise { - if (!session.workspacePath) return; - - await setupHookInWorkspace(session.workspacePath, ".claude/metadata-updater.sh"); + async postLaunchSetup(_session: Session): Promise { + // Hooks are installed pre-launch via setupWorkspaceHooks so that + // PostToolUse hooks exist before the agent's first tool call. }, }; } diff --git a/packages/plugins/tracker-github/src/index.ts b/packages/plugins/tracker-github/src/index.ts index 272b7d78e..90cb94183 100644 --- a/packages/plugins/tracker-github/src/index.ts +++ b/packages/plugins/tracker-github/src/index.ts @@ -278,7 +278,7 @@ function createGitHubTracker(): Tracker { lines.push( "", - "The issue context above is complete and current. You should not need to call gh issue view unless you need additional context beyond what is provided here.", + "The issue title, description, and labels above are current. Fetch comments or linked issues via `gh` only if you need additional context beyond what is provided here.", "", "Please implement the changes described in this issue. When done, commit and push your changes.", ); From 9bfd7656bb4df7b894bca0325c2d25cd5636f348 Mon Sep 17 00:00:00 2001 From: i-trytoohard Date: Fri, 8 May 2026 18:13:42 +0530 Subject: [PATCH 15/15] fix(cli): refuse to spawn when daemon is not polling the project (#1460) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(cli): refuse to spawn when daemon is not polling the project `ao spawn` and `ao batch-spawn` used to print a stderr warning and then create the session anyway when the running AO daemon did not include the target project in its polling set (or when no daemon was running at all). The resulting sessions got full worktrees and tmux panes but no lifecycle reactions — CI-failure routing, review comments, revive transitions, and the event log were silently dead. Promote the warning to a hard error so sessions are never created in a state where the lifecycle manager won't run for them. The error message tells the user which `ao start` invocation will fix it. Closes #1455 * test(cli): cover batch-spawn daemon-polling enforcement `spawn` and `batch-spawn` share the `ensureAOPollingProject` helper, but only `spawn` had tests for the new fail-fast behavior. Add matching tests for `batch-spawn` so a future refactor that breaks its guard is caught. --------- Co-authored-by: Prateek --- packages/cli/__tests__/commands/spawn.test.ts | 105 +++++++++++++++++- packages/cli/src/commands/spawn.ts | 30 ++--- 2 files changed, 118 insertions(+), 17 deletions(-) diff --git a/packages/cli/__tests__/commands/spawn.test.ts b/packages/cli/__tests__/commands/spawn.test.ts index 4f149a7ed..60ffeb6c7 100644 --- a/packages/cli/__tests__/commands/spawn.test.ts +++ b/packages/cli/__tests__/commands/spawn.test.ts @@ -230,6 +230,13 @@ describe("spawn command", () => { mkdirSync(backendSubdir, { recursive: true }); cwdSpy = vi.spyOn(process, "cwd").mockReturnValue(backendSubdir); + mockGetRunning.mockResolvedValue({ + pid: 1234, + port: 3000, + startedAt: "", + projects: ["backend", "frontend"], + }); + const fakeSession: Session = { id: "be-1", projectId: "backend", @@ -284,7 +291,7 @@ describe("spawn command", () => { pid: 1234, port: 3000, startedAt: "", - projects: ["agent-orchestrator"], + projects: ["agent-orchestrator", "x402-identity"], }); const fakeSession: Session = { @@ -336,7 +343,7 @@ describe("spawn command", () => { pid: 1234, port: 3000, startedAt: "", - projects: ["agent-orchestrator"], + projects: ["agent-orchestrator", "x402-identity"], }); const fakeSession: Session = { @@ -947,6 +954,12 @@ describe("batch-spawn command", () => { }; mkdirSync(join(tmpDir, "agent-orchestrator"), { recursive: true }); mkdirSync(join(tmpDir, "x402-identity"), { recursive: true }); + mockGetRunning.mockResolvedValue({ + pid: 1234, + port: 3000, + startedAt: "", + projects: ["agent-orchestrator", "x402-identity"], + }); // Pre-existing active session in x402-identity for issue 20 mockSessionManager.list.mockImplementation(async (pid: string) => { @@ -983,3 +996,91 @@ describe("batch-spawn command", () => { }); }); }); + +describe("spawn daemon-polling enforcement", () => { + it("refuses to spawn when no AO daemon is running", async () => { + mockGetRunning.mockResolvedValue(null); + + await expect(program.parseAsync(["node", "test", "spawn"])).rejects.toThrow( + "process.exit(1)", + ); + + const errors = vi + .mocked(console.error) + .mock.calls.map((c) => String(c[0])) + .join("\n"); + expect(errors).toContain("AO is not running"); + expect(errors).toContain("ao start"); + expect(mockSessionManager.spawn).not.toHaveBeenCalled(); + }); + + it("refuses to spawn when the running daemon is not polling the project", async () => { + mockGetRunning.mockResolvedValue({ + pid: 99999, + port: 3000, + startedAt: "", + projects: ["other-project"], + }); + + await expect(program.parseAsync(["node", "test", "spawn"])).rejects.toThrow( + "process.exit(1)", + ); + + const errors = vi + .mocked(console.error) + .mock.calls.map((c) => String(c[0])) + .join("\n"); + expect(errors).toContain("not polling project"); + expect(errors).toContain("my-app"); + expect(errors).toContain("ao start my-app"); + expect(mockSessionManager.spawn).not.toHaveBeenCalled(); + }); +}); + +describe("batch-spawn daemon-polling enforcement", () => { + let batchProgram: Command; + + beforeEach(() => { + batchProgram = new Command(); + batchProgram.exitOverride(); + registerBatchSpawn(batchProgram); + }); + + it("refuses to batch-spawn when no AO daemon is running", async () => { + mockGetRunning.mockResolvedValue(null); + + await expect( + batchProgram.parseAsync(["node", "test", "batch-spawn", "INT-1", "INT-2"]), + ).rejects.toThrow("process.exit(1)"); + + const errors = vi + .mocked(console.error) + .mock.calls.map((c) => String(c[0])) + .join("\n"); + expect(errors).toContain("AO is not running"); + expect(errors).toContain("ao start"); + expect(mockSessionManager.spawn).not.toHaveBeenCalled(); + }); + + it("refuses to batch-spawn when the running daemon is not polling the project", async () => { + mockGetRunning.mockResolvedValue({ + pid: 99999, + port: 3000, + startedAt: "", + projects: ["other-project"], + }); + + await expect( + batchProgram.parseAsync(["node", "test", "batch-spawn", "INT-1", "INT-2"]), + ).rejects.toThrow("process.exit(1)"); + + const errors = vi + .mocked(console.error) + .mock.calls.map((c) => String(c[0])) + .join("\n"); + expect(errors).toContain("not polling project"); + expect(errors).toContain("my-app"); + expect(errors).toContain("ao start my-app"); + expect(mockSessionManager.spawn).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/commands/spawn.ts b/packages/cli/src/commands/spawn.ts index 061d6f9e8..fed2f0585 100644 --- a/packages/cli/src/commands/spawn.ts +++ b/packages/cli/src/commands/spawn.ts @@ -102,25 +102,25 @@ interface SpawnClaimOptions { /** * Lifecycle polling runs in-process inside the long-lived `ao start` process. * `ao spawn` is a one-shot CLI — it can't start polling in its own process - * (the interval would keep the CLI alive forever and duplicate work). Warn - * when no `ao start` is running, or when the running instance isn't covering - * this project (e.g. `ao start A` then `ao spawn` in B). + * (the interval would keep the CLI alive forever and duplicate work). + * + * Refuse to spawn if no `ao start` is running, or if the running instance is + * not polling this project. Without an active daemon, sessions get worktrees + * and tmux panes but no lifecycle reactions (CI-failure routing, review + * comments, revive transitions, event log). That silent blackout is a + * worse failure mode than creating no session at all — so fail fast with + * an actionable error. */ -async function warnIfAONotRunning(projectId: string): Promise { +async function ensureAOPollingProject(projectId: string): Promise { const running = await getRunning(); if (!running) { - console.log( - chalk.yellow( - "⚠ AO is not running — lifecycle polling is inactive. Run `ao start` so the new session is tracked.", - ), + throw new Error( + `AO is not running — lifecycle polling is inactive. Run \`ao start\` before spawning sessions so they get CI/review routing and state advancement.`, ); - return; } if (!running.projects.includes(projectId)) { - console.log( - chalk.yellow( - `⚠ The running AO instance (pid ${running.pid}) is not polling project "${projectId}" yet. Lifecycle polling will attach within ~60s.`, - ), + throw new Error( + `The running AO instance (pid ${running.pid}) is not polling project "${projectId}". Run \`ao start ${projectId}\` before spawning so sessions get tracked.`, ); } } @@ -325,7 +325,7 @@ export function registerSpawn(program: Command): void { try { await runSpawnPreflight(config, projectId, claimOptions); - await warnIfAONotRunning(projectId); + await ensureAOPollingProject(projectId); await spawnSession(config, projectId, issueId, opts.open, opts.agent, claimOptions, opts.prompt); } catch (err) { @@ -402,7 +402,7 @@ export function registerBatchSpawn(program: Command): void { // Pre-flight once per project group so a missing prerequisite fails fast. try { await runSpawnPreflight(config, groupProjectId); - await warnIfAONotRunning(groupProjectId); + await ensureAOPollingProject(groupProjectId); } catch (err) { console.error(chalk.red(`✗ ${err instanceof Error ? err.message : String(err)}`)); process.exit(1);