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/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/ao/CHANGELOG.md b/packages/ao/CHANGELOG.md index 14ed974dc..6da307509 100644 --- a/packages/ao/CHANGELOG.md +++ b/packages/ao/CHANGELOG.md @@ -1,5 +1,19 @@ # @aoagents/ao +## 0.6.0 + +### Patch Changes + +- Updated dependencies [0f539a3] + - @aoagents/ao-cli@0.6.0 + +## 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..7279505f4 100644 --- a/packages/ao/package.json +++ b/packages/ao/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao", - "version": "0.4.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 c8c346944..a110d80f4 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,76 @@ # @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 + +- 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/__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/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/__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/package.json b/packages/cli/package.json index 46d7ff4e9..72a373ffb 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.6.0", "description": "CLI for agent-orchestrator — the `ao` command", "license": "MIT", "type": "module", 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/spawn.ts b/packages/cli/src/commands/spawn.ts index 12174f7e1..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.`, ); } } @@ -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 { @@ -336,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) { @@ -413,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); diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index 1bc2afe10..f76ee508e 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. @@ -1419,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); 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/CHANGELOG.md b/packages/core/CHANGELOG.md index e6ad533e7..663adb01c 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,23 @@ # @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 + +- 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..c5678cc26 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.6.0", "description": "Core library — types, config, session manager, lifecycle manager, event bus", "license": "MIT", "type": "module", 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__/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__/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__/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/__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/__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 e9915c546..67d91b212 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 }); @@ -1015,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 () => { @@ -1073,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"); }); @@ -1144,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 () => { @@ -1567,6 +1546,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 +1734,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/__tests__/tmux.test.ts b/packages/core/src/__tests__/tmux.test.ts index f81dc2dac..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,65 +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, send-keys Escape, send-keys text, send-keys Enter - mockTmuxSequence([{ stdout: "" }, { stdout: "" }, { stdout: "" }, { stdout: "" }]); - - await newSession({ name: "test-4", cwd: "/tmp", command: "echo hello" }); - - expect(mockExecFile).toHaveBeenCalledTimes(4); - // Call 0: new-session - // Call 1: send-keys Escape (clear partial input) - const escapeArgs = mockExecFile.mock.calls[1][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[]; - 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/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/config.ts b/packages/core/src/config.ts index 57a236bf5..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(), @@ -995,7 +996,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/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/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..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, @@ -204,6 +203,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/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", 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 5da9981cd..1675108f8 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, @@ -1291,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, @@ -1298,6 +1309,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"] && { @@ -1408,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, @@ -1521,16 +1491,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 +1518,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); @@ -1662,6 +1639,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"] && { @@ -2927,6 +2905,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/tmux.ts b/packages/core/src/tmux.ts index be5da26f5..8da9da240 100644 --- a/packages/core/src/tmux.ts +++ b/packages/core/src/tmux.ts @@ -75,47 +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); - - // 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/core/src/types.ts b/packages/core/src/types.ts index 016856407..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; @@ -522,7 +513,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 +594,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; } @@ -645,6 +636,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; @@ -1498,6 +1495,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; 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-aider/CHANGELOG.md b/packages/plugins/agent-aider/CHANGELOG.md index 97cb23389..51df2e23f 100644 --- a/packages/plugins/agent-aider/CHANGELOG.md +++ b/packages/plugins/agent-aider/CHANGELOG.md @@ -1,5 +1,22 @@ # @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 + +- 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..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.4.0", + "version": "0.6.0", "description": "Agent plugin: Aider", "license": "MIT", "type": "module", 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-claude-code/CHANGELOG.md b/packages/plugins/agent-claude-code/CHANGELOG.md index 7efc47746..f246bb9e8 100644 --- a/packages/plugins/agent-claude-code/CHANGELOG.md +++ b/packages/plugins/agent-claude-code/CHANGELOG.md @@ -1,5 +1,22 @@ # @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 + +- 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..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.4.0", + "version": "0.6.0", "description": "Agent plugin: Claude Code", "license": "MIT", "type": "module", diff --git a/packages/plugins/agent-claude-code/src/index.test.ts b/packages/plugins/agent-claude-code/src/index.test.ts index 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/agent-codex/CHANGELOG.md b/packages/plugins/agent-codex/CHANGELOG.md index 28dc4e1a5..b6749c398 100644 --- a/packages/plugins/agent-codex/CHANGELOG.md +++ b/packages/plugins/agent-codex/CHANGELOG.md @@ -1,5 +1,22 @@ # @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 + +- 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..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.4.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 2782fd8bb..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.4.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.4.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 c184c0631..577e8c55e 100644 --- a/packages/plugins/agent-cursor/CHANGELOG.md +++ b/packages/plugins/agent-cursor/CHANGELOG.md @@ -1,5 +1,22 @@ # @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 + +- 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..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.2", + "version": "0.1.4", "description": "Agent plugin: Cursor CLI", "license": "MIT", "type": "module", 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. diff --git a/packages/plugins/agent-kimicode/CHANGELOG.md b/packages/plugins/agent-kimicode/CHANGELOG.md index abf6f63fd..946ba3001 100644 --- a/packages/plugins/agent-kimicode/CHANGELOG.md +++ b/packages/plugins/agent-kimicode/CHANGELOG.md @@ -1,5 +1,22 @@ # @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 + +- 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..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.1", + "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 4596226cf..aa05d8c3a 100644 --- a/packages/plugins/agent-opencode/CHANGELOG.md +++ b/packages/plugins/agent-opencode/CHANGELOG.md @@ -1,5 +1,22 @@ # @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 + +- 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..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.4.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 48dab225f..2bf202333 100644 --- a/packages/plugins/notifier-composio/CHANGELOG.md +++ b/packages/plugins/notifier-composio/CHANGELOG.md @@ -1,5 +1,22 @@ # @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 + +- 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..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.4.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 ddb1e1186..222fec642 100644 --- a/packages/plugins/notifier-desktop/CHANGELOG.md +++ b/packages/plugins/notifier-desktop/CHANGELOG.md @@ -1,5 +1,22 @@ # @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 + +- 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..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.4.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 1724fe523..80b29fe1b 100644 --- a/packages/plugins/notifier-discord/CHANGELOG.md +++ b/packages/plugins/notifier-discord/CHANGELOG.md @@ -1,5 +1,22 @@ # @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 + +- 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..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.7", + "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 c1e631e9b..636384032 100644 --- a/packages/plugins/notifier-openclaw/CHANGELOG.md +++ b/packages/plugins/notifier-openclaw/CHANGELOG.md @@ -1,5 +1,22 @@ # @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 + +- 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..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.7", + "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 7e3ec9527..c6c180fec 100644 --- a/packages/plugins/notifier-slack/CHANGELOG.md +++ b/packages/plugins/notifier-slack/CHANGELOG.md @@ -1,5 +1,22 @@ # @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 + +- 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..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.4.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 6d23f5928..719b15724 100644 --- a/packages/plugins/notifier-webhook/CHANGELOG.md +++ b/packages/plugins/notifier-webhook/CHANGELOG.md @@ -1,5 +1,22 @@ # @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 + +- 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..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.4.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 40f5c209c..b0ac8d8ec 100644 --- a/packages/plugins/runtime-process/CHANGELOG.md +++ b/packages/plugins/runtime-process/CHANGELOG.md @@ -1,5 +1,22 @@ # @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 + +- 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..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.4.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 1b470be27..8a6620867 100644 --- a/packages/plugins/runtime-tmux/CHANGELOG.md +++ b/packages/plugins/runtime-tmux/CHANGELOG.md @@ -1,5 +1,23 @@ # @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 + +- 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..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.4.0", + "version": "0.6.0", "description": "Runtime plugin: tmux sessions", "license": "MIT", "type": "module", 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, }); } diff --git a/packages/plugins/scm-github/CHANGELOG.md b/packages/plugins/scm-github/CHANGELOG.md index 3d9a537ba..915bafc42 100644 --- a/packages/plugins/scm-github/CHANGELOG.md +++ b/packages/plugins/scm-github/CHANGELOG.md @@ -1,5 +1,22 @@ # @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 + +- 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..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.4.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 8083bb10f..709102027 100644 --- a/packages/plugins/scm-gitlab/CHANGELOG.md +++ b/packages/plugins/scm-gitlab/CHANGELOG.md @@ -1,5 +1,22 @@ # @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 + +- 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..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.7", + "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 9f010b197..3488b1239 100644 --- a/packages/plugins/terminal-iterm2/CHANGELOG.md +++ b/packages/plugins/terminal-iterm2/CHANGELOG.md @@ -1,5 +1,22 @@ # @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 + +- 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..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.4.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 841d185f8..2582dfc67 100644 --- a/packages/plugins/terminal-web/CHANGELOG.md +++ b/packages/plugins/terminal-web/CHANGELOG.md @@ -1,5 +1,22 @@ # @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 + +- 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..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.4.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 da44263e6..28df0d28b 100644 --- a/packages/plugins/tracker-github/CHANGELOG.md +++ b/packages/plugins/tracker-github/CHANGELOG.md @@ -1,5 +1,22 @@ # @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 + +- 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..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.4.0", + "version": "0.6.0", "description": "Tracker plugin: GitHub Issues", "license": "MIT", "type": "module", 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.", ); diff --git a/packages/plugins/tracker-gitlab/CHANGELOG.md b/packages/plugins/tracker-gitlab/CHANGELOG.md index 65e280312..f91c361fc 100644 --- a/packages/plugins/tracker-gitlab/CHANGELOG.md +++ b/packages/plugins/tracker-gitlab/CHANGELOG.md @@ -1,5 +1,24 @@ # @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 + +- 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..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.7", + "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 4ee3d3214..bf035d188 100644 --- a/packages/plugins/tracker-linear/CHANGELOG.md +++ b/packages/plugins/tracker-linear/CHANGELOG.md @@ -1,5 +1,22 @@ # @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 + +- 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..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.4.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 405a2de1d..f5c8afc36 100644 --- a/packages/plugins/workspace-clone/CHANGELOG.md +++ b/packages/plugins/workspace-clone/CHANGELOG.md @@ -1,5 +1,22 @@ # @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 + +- 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..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.4.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 09b04f64c..b8572df3a 100644 --- a/packages/plugins/workspace-worktree/CHANGELOG.md +++ b/packages/plugins/workspace-worktree/CHANGELOG.md @@ -1,5 +1,22 @@ # @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 + +- 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..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.4.0", + "version": "0.6.0", "description": "Workspace plugin: git worktrees", "license": "MIT", "type": "module", diff --git a/packages/plugins/workspace-worktree/src/__tests__/index.test.ts b/packages/plugins/workspace-worktree/src/__tests__/index.test.ts index f49d650c2..fc1c791ac 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 }, ); }); @@ -253,15 +255,8 @@ describe("workspace.create()", () => { expect(mockExecFileAsync).toHaveBeenCalledWith( "git", - [ - "worktree", - "add", - "-b", - "feat/TEST-1", - "/mock-home/.worktrees/myproject/session-1", - "origin/main", - ], - { cwd: "/repo/path" }, + ["worktree", "add", "-b", "feat/TEST-1", "/mock-home/.worktrees/myproject/session-1", "origin/main"], + { cwd: "/repo/path", timeout: 30_000 }, ); }); @@ -279,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(); @@ -303,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( @@ -316,7 +433,7 @@ describe("workspace.create()", () => { "/mock-home/.worktrees/myproject/session-1", "refs/heads/main", ], - { cwd: "/repo/path" }, + { cwd: "/repo/path", timeout: 30_000 }, ); }); @@ -347,7 +464,7 @@ describe("workspace.create()", () => { expect(mockExecFileAsync).toHaveBeenCalledWith( "git", ["worktree", "add", "/mock-home/.worktrees/myproject/session-1", "feat/TEST-1"], - { cwd: "/repo/path" }, + { cwd: "/repo/path", timeout: 30_000 }, ); expect(info.branch).toBe("feat/TEST-1"); @@ -376,7 +493,7 @@ describe("workspace.create()", () => { "/mock-home/.worktrees/myproject/session-1", "origin/main", ], - { cwd: "/repo/path" }, + { cwd: "/repo/path", timeout: 30_000 }, ); expect(info.branch).toBe("feat/TEST-1"); @@ -398,7 +515,7 @@ describe("workspace.create()", () => { expect(mockExecFileAsync).toHaveBeenCalledWith( "git", ["worktree", "add", "/mock-home/.worktrees/myproject/session-1", "feat/TEST-1"], - { cwd: "/repo/path" }, + { cwd: "/repo/path", timeout: 30_000 }, ); expect(info.branch).toBe("feat/TEST-1"); @@ -424,7 +541,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 }, ); }); @@ -522,6 +639,7 @@ describe("workspace.create()", () => { // fetch should use expanded path expect(mockExecFileAsync).toHaveBeenCalledWith("git", ["fetch", "origin", "--quiet"], { cwd: "/mock-home/my-repo", + timeout: 30_000, }); }); @@ -544,7 +662,7 @@ describe("workspace.create()", () => { "/mock-home/.worktrees/myproject/session-1", "refs/heads/main", ], - { cwd: "/repo/path" }, + { cwd: "/repo/path", timeout: 30_000 }, ); }); }); @@ -571,7 +689,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"); @@ -598,7 +716,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({ @@ -625,14 +743,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 d66ff7cfa..cf3f804c3 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(); } @@ -101,6 +101,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_-]+$/; @@ -202,6 +228,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/CHANGELOG.md b/packages/web/CHANGELOG.md index d8e9256b7..9a91af54f 100644 --- a/packages/web/CHANGELOG.md +++ b/packages/web/CHANGELOG.md @@ -1,5 +1,47 @@ # @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 + +- 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..c69dde607 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.6.0", "description": "Web dashboard for agent-orchestrator", "type": "module", "files": [ 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/__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 43f319540..60fb7fd4e 100644 --- a/packages/web/server/mux-websocket.ts +++ b/packages/web/server/mux-websocket.ts @@ -192,17 +192,37 @@ 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. * A single manager instance is shared across all mux connections. */ -class TerminalManager { +export class TerminalManager { private terminals = new Map(); private TMUX: string; @@ -254,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); }); @@ -285,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, @@ -296,6 +320,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 +370,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 +383,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 +450,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; 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", }, }; } 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); } } 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