* feat(release): weekly release train — channels, onboarding, dashboard banner, cron Implements the full release pipeline described in release-process.html (supersedes #1525, which only had the workflow scaffolding). A. Release infrastructure — .github/workflows/canary.yml triggers on a cron ('30 17 * * 5,6,0,1,2', i.e. 23:00 IST Fri–Tue) plus workflow_dispatch, without the stale-SHA guard or the merged-PR-comment step from #1525 (cron has no merged-PR context). release.yml uses changesets/action. .changeset/config.json adds the snapshot template and moves the private @aoagents/ao-web to ignore[]. B. Channel awareness (packages/cli/src/lib/update-check.ts) — new updateChannel field in the global-config Zod schema (stable | nightly | manual; defaults to manual so existing users see no surprise installs). fetchLatestVersion now reads dist-tags[channel] from the registry; isVersionOutdated compares prerelease segments numerically + lexically so SHA-suffixed nightlies sort correctly. maybeShowUpdateNotice and scheduleBackgroundRefresh skip entirely on manual. C. Active-session guard (packages/cli/src/commands/update.ts) — before any handle*Update proceeds, sm.list() filters for working/idle/ needs_input/stuck and refuses with `N session(s) active. Run `ao stop` first.` instead of auto-stopping (per the design doc: surprise-killing user work is worse than refusing). D. Soft auto-install + onboarding — handleNpmUpdate skips the confirm prompt on stable/nightly. New packages/cli/src/lib/update-channel- onboarding.ts prompts once on the first `ao start` after this lands; ask-once gate keyed on the absence of updateChannel in the global config; dismissal persists `manual`. New `ao config set updateChannel <value>` command (also handles installMethod). E. Dashboard banner — packages/web/src/app/api/version/route.ts reads the same cache file the CLI writes (~/.cache/ao/update-check.json, XDG-aware) and rejects cache entries from a different channel. packages/web/src/app/api/update/route.ts duplicates the active-session guard so the dashboard can return a structured 409. New UpdateBanner component wired into Dashboard.tsx — Tailwind only, var(--color-*) tokens, dismissible per-version via localStorage, deferred fetch so it doesn't shift the call order in existing dashboard tests. F. Bun + Homebrew detection (update-check.ts) — new classifiers for ~/.bun/install/global/ (auto-installs `bun add -g @aoagents/ao@<channel>`) and /Cellar/ao/ (notice-only — `brew upgrade ao`, never auto-install because brew owns the symlinks). New installMethod override field in the global config to pin detection when path heuristics fail. Tests: +155 (B/C/F unit, onboarding ask-once gate, /api/version + /api/update, UpdateBanner visibility/dismiss/click). pnpm test, pnpm typecheck, pnpm lint all green for the changes; the same 10 pre-existing test failures observed on main are still present (all environment-dependent: ~/.cache/ao state, codex binary install, /private path canonicalization on macOS). Closes #1525 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(release-train): CI failures + Greptile review feedback CI fixes: - Web /api/update spawn ENOENT — attach `child.on("error", ...)` so the asynchronous spawn-error event from a missing `ao` binary doesn't bubble up as an unhandled error and crash vitest. The route already returns 202 before the error fires; on real installs the user sees "no version change" if the install fails. - start.test.ts pollution — runStartup calls `maybePromptForUpdateChannel`, which (with isHumanCaller mocked to true) writes to the real ~/.agent-orchestrator/config.yaml on the CI runner via persistUpdateChannel. Subsequent tests then load that newly-created (empty-projects) config and report "No projects configured" instead of the expected "project not found". Fix: stub `update-channel-onboarding.js` in start.test.ts so runStartup is a no-op for the channel prompt. Review feedback: - (P1) `runtime: "tmux"` hardcoded default in `persistUpdateChannel` and `loadOrInit` would lock Windows users into a non-functional tmux config when they dismiss the channel prompt. Both now use `getDefaultRuntime()`, matching `makeEmptyGlobalConfig` in core's global-config.ts. - (P2) `hasChosenUpdateChannel` JSDoc inverted — the second "True when" bullet actually described the False case. Rewritten with separate True/False sections that match the implementation. - (P2) `isVersionOutdated` was duplicated between the CLI and the dashboard /api/version route. Moved to a new shared module `packages/core/src/version-compare.ts`, exported from `@aoagents/ao-core`, consumed by both CLI (re-exports as `isVersionOutdated`) and the web route directly. Added 14 unit tests in core for the canonical implementation. Defensive: `maybePromptForUpdateChannel` now validates the prompt result via `UpdateChannelSchema.safeParse` before persisting — never writes `undefined` or an unrecognized string to disk. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(release-train): Windows spawn + dismiss-while-blocked review feedback - (P1) `ao update` silently never ran on Windows because `spawn("ao", ...)` doesn't consult PATHEXT, so npm's `ao.cmd` shim wasn't found and the async ENOENT was swallowed by the error handler. Add `shell: isWindows()` + `windowsHide: true` per the cross-platform guide. - (P1) Dismiss button was inert when the banner was in the `blocked` (409) or `error` phase — `setDismissedFor` set the localStorage flag but the hide condition required `phase === "idle"`, so the banner stayed pinned until reload. `handleDismiss` now resets phase to idle (and clears the error message) so the existing condition fires. Added a regression test covering dismiss from the 409 path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(release-train): runNpmInstall on Windows — shell:true so PATHEXT resolves npm.cmd (P1) The dashboard /api/update spawn got `shell: isWindows()` + `windowsHide: true` in 9f29131d, but `runNpmInstall` in the CLI's `ao update` command was still missing the same fix. On Windows, `spawn("npm", ...)` without a shell wrapper doesn't consult PATHEXT, so npm/pnpm/bun's `*.cmd` shims never resolve and the install silently ENOENTs. Mirror the fix into runNpmInstall — it's the single spawn site behind every non-git, non-homebrew install path (npm-global, pnpm-global, bun-global, unknown), so this one change covers all four install methods. Tests: - Mock `isWindows` from @aoagents/ao-core so the spawn options can be inspected per-platform. - Assert `shell: true, windowsHide: true, stdio: "inherit"` on Windows. - Assert `shell: false` on macOS / Linux. - Parametrize over pnpm-global / bun-global to confirm the same options flow through every npm-style install command. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(release-train): /api/version reads cached.isOutdated for git installs (P1) The dashboard banner never appeared for git-installed users because `/api/version` ran `isVersionOutdated(current, "origin/main")`, and `parseVersion("origin/main")` produces NaN parts that the early-exit guard catches with `return false`. Git installs cache `latestVersion` as a git ref (not a semver) and a precomputed `isOutdated` flag from `git fetch + merge-base`; the CLI special-cases this in `update-check.ts`. Mirror the same pattern here: cached.installMethod === "git" ? cached.isOutdated === true : isVersionOutdated(current, latest) Also extend the local CacheData with `installMethod?: string` and `isOutdated?: boolean` so the new branch type-checks. Kept as `string` rather than importing the CLI's `InstallMethod` type — the literal "git" compare is the only thing that matters here, and the web package shouldn't take a dep on @aoagents/ao-cli. Two new tests cover the git-install path: one asserts isOutdated=true is trusted from the cache, the other asserts isOutdated=false (current with origin) is trusted too. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(release-train): must-fix #3+#4 — global-config layout + git-only flag guard #3 — ensureNoActiveSessions now consults loadGlobalConfig() first as a quick "any projects registered?" check, then routes through loadConfig(globalPath) only when the registry actually has projects (loadConfig dispatches to buildEffectiveConfigFromGlobalConfigPath when given the canonical global path — see packages/core/src/config.ts). Defends against AO_GLOBAL_CONFIG override to a non-canonical path. Three new tests cover: registered-projects path fires the guard correctly; empty registry returns early without building a SessionManager; missing global file returns early without even reading it. #4 — Restored the rejection of git-only flags on non-git installs. Users copy/pasting `ao update --skip-smoke` from older docs would silently no-op on npm/pnpm/bun installs. Now exits non-zero with: "--skip-smoke only applies to git installs (current install: npm-global)." Test it.each across npm/pnpm/bun/homebrew/unknown plus a positive test that git installs still accept the flag. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(release-train): #2 should-fix — channel-switch prompt When a stable user runs `ao config set updateChannel nightly` and then `ao update`, isVersionOutdated(0.5.0, 0.5.0-nightly-abc) returns false (per semver, prerelease < stable on equal base). The old code printed "Already on latest nightly" and exited without installing — confusing, because the install command we'd run is genuinely a different dist-tag. Fix: snapshot the previously-cached channel BEFORE forcing a refresh, then detect a switch via `previousChannel !== activeChannel && !info.isOutdated`. On switch: - Don't take the "already on latest" early-return. - Print a yellow "Channel switch detected: was X, now Y." notice. - Force a confirm prompt regardless of stable/nightly soft-install, defaulting to "no" (channel-switch should be explicit). Manual users still see their normal prompt. Onboarding copy now includes one line about channel switches: "switching later prompts before installing the other channel's build." 4 new tests: explicit switch fires the prompt + installs on yes; declines on no; same-channel doesn't fire (back to "Already on latest"); first-ever update with no previous cache doesn't fire either. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(release-train): polish — drop setTimeout, dedup defaults, share cache, dedup export #5 — UpdateBanner no longer wraps its mount fetch in setTimeout(0). Production code shouldn't bend to test mock ordering. Instead, the two brittle Dashboard tests that relied on `mockImplementationOnce` queue ordering now route by URL via `mockImplementation`, and the cadence test asserts "no other endpoints were touched" instead of "no fetch was touched at all". Also added a deliberate "no interval / re-fetch" comment per #6. #7 — Promoted core's `makeEmptyGlobalConfig` to the public `createDefaultGlobalConfig` (kept the internal alias for back-compat). Both the CLI's `persistUpdateChannel` and `loadOrInit` (in `ao config`) now call it instead of inlining the same defaults block. Single source of truth. #8 — New `packages/core/src/update-cache.ts` exports `getUpdateCheckCachePath`, `readUpdateCheckCacheRaw`, and `getInstalledAoVersion`. The CLI's `update-check.ts` keeps its richer install-method/channel/git-rev validation but now delegates path resolution and version lookup to core. The dashboard's `/api/version` route drops its duplicated `getCachePath`/`readCache`/`getCurrentVersion` and consumes from core directly. Cache layout is one file, not two. #9 — Removed the duplicate `export { isManualOnlyInstall }` from `update.ts` (also dropped the unused import). The canonical export lives in `update-check.ts`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(release-train): cosmetic — workflow rename note, design tokens, changeset trim #1 release.yml: added a comment above `workflows: [CI]` warning that GitHub matches by name (not filename) and silently no-ops on mismatch — so a rename of ci.yml's `name:` field would mean releases stop triggering. #10 UpdateBanner: replaced text-[13px] / text-[12px] with text-sm / text-xs to match the dashboard's chrome scale. #6 Banner refresh: noted in the existing useEffect comment that we don't re-fetch — re-evaluate if "user kept tab open for days, missed an update" becomes a real complaint. #11 .changeset/release-train.md: dropped @aoagents/ao-web from the version bump list. The package is `private: true` and in changeset's ignore[], so listing it was cosmetic and would just clutter the eventual release notes with a non-published artifact. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(release-train): illegalcall review — global guard, channel scoping, publishable web (#1, PRRT_kwDORPZUAc6BHFDf) — `ensureNoActiveSessions` now ALWAYS loads from the canonical global config, never from project-local. The previous code preferred `loadConfig()` (local search-upward) when run inside a repo, which made `sm.list()` enumerate only that project's sessions — active work in other registered projects would be missed and the install would proceed. New regression test asserts that a session in `other-project` blocks the update even when invoked from `this-project`'s cwd. Existing global-config tests retained. (#2, PRRT_kwDORPZUAc6BHFOl) + (#4, PRRT_kwDORPZUAc6BHFon) — Reverted the `private: true` on @aoagents/ao-web. Because @aoagents/ao-cli has a workspace:* runtime dep on it (for `findWebDir()`/dashboard files), pnpm rewrites the dep on publish to a literal version — keeping ao-web private would make `npm install -g @aoagents/ao` fail. Restored ao-web to the changeset linked group, removed it from `ignore[]`, restored the release- train changeset entry, added publishConfig + repository metadata. New `scripts/check-publishable-deps.mjs` walks every package and asserts that no publishable package has a workspace:* runtime dep on a `private: true` package. Wired into both release.yml and canary.yml before the publish step so any future regression is caught at CI rather than at the user's `npm install`. Verified the script catches the inverse condition. (#3, PRRT_kwDORPZUAc6BHFaV) — `readCachedUpdateInfo` now treats a missing `data.channel` as a miss when an explicit channel is provided. Previous logic only rejected when both data.channel and channel were set AND differed, so a legacy cache entry (pre-channel-scoping) could keep returning stale stable state to a user who had since switched to nightly until the 24h TTL expired. Existing fixtures bumped to include `channel` where the test exercises the checkForUpdate / maybeShowUpdateNotice path; new regression test exercises the legacy-no-channel case. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(release-train): SHA-suffix nightly compare — never miss a banner (P1, PRRT_kwDORPZUAc6BJLrW) Git SHAs are uniformly-random hex, so the old `comparePrereleaseSegments` lexical fallback gave the wrong answer ~50% of the time on snapshot tags. Concretely: user installs `0.5.0-nightly-f00d123`, CI publishes `0.5.0-nightly-0dead01`, and `'f' < '0'` returns false → banner never shows. Fix: when two prerelease segments are both non-numeric and differ, treat the left side as older (return -1). The cache layer always carries the registry's CURRENT dist-tag, so any non-numeric mismatch on the same base means the installed copy is behind by construction. Numeric ordering (`rc.1 < rc.2`) and numeric-vs-non-numeric (`0.5.0-1 < 0.5.0-alpha`) are unchanged. Tradeoff: a user who manually installed `0.5.0-beta` while the registry only publishes `0.5.0-alpha` would see a spurious banner. AO's release pipeline only emits SHA-suffixed nightly prereleases, so the scenario doesn't occur in practice — documented in the function's JSDoc. Updated two misleadingly-named tests ("orders SHA-suffixed nightlies lexically") that had been asserting the buggy behavior; new tests cover the specific case from the review (`nightly-f00d123` vs `nightly-0dead01`) and preserve the numeric-ordering invariant. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(release-train): multi-project active-session proof + changeset note (#1, PRRT_kwDORPZUAc6BHFDf follow-up) Dhruv asked for proof — not a comment — that loadConfig(globalPath) actually enumerates across all registered projects, not just the cwd's. New test in update.test.ts seeds proj-a and proj-b in the global config, places one active session in each (one "working", one "needs_input"), and asserts the refusal stderr lists BOTH session ids AND the total count says "2 sessions active". The test is specifically named so it shows up in `vitest run -t "Dhruv proof"`. Verified `pnpm changeset version` locally — @aoagents/ao-web, ao-cli, and ao all bump to 0.7.0 together via the linked group, confirming the install-404 class of bug is gone. Also updated the release-train changeset to drop the stale "moves the private @aoagents/ao-web to ignore" line — that contradicts the current state (ao-web is publishable and in the linked group). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(release-train): Ashish P1+P2 — dashboard banner now actually installs P1 — Dashboard banner click was a no-op for npm users. POST /api/update spawns `ao update` with `stdio: "ignore"`, which makes `isTTY()` return false in the child. The old handleNpmUpdate hit the non-TTY branch ("Run: ...") and exited without installing. Banner returned 202 "started"; nothing actually happened. Fix (Ashish's option c, with an env-var bridge): - /api/update spawns with `AO_NON_INTERACTIVE_INSTALL=1` on the env. - handleNpmUpdate computes `interactive = isTTY() && !isApiInvoked()`. - Restructured so the early-return only fires for non-TTY + non-API (piped output): we still print "Run: ..." for that case, matching the old contract. API-invoked path now actually runs runNpmInstall, skipping the confirm prompt (would hang the detached child forever). Three new CLI tests: - AO_NON_INTERACTIVE_INSTALL=1 → spawn invoked even with isTTY=false. - The piped-output case (no env var, no TTY) still prints "Run: ...". - Active-session guard still fires in the API-invoked path (defense in depth — the route's own guard isn't single point of trust). P2 — First nightly opt-in stuck on "Already on latest stable". Repro: user on stable 0.5.0, runs `ao config set updateChannel nightly`, runs `ao update`. previousChannel was undefined, isOutdated was false (semver: prerelease < stable on equal base), so the early return fired and the install never ran. Fix: new `isFirstChannelOptIn` branch — `previousChannel === undefined && info.currentVersion !== info.latestVersion && !info.isOutdated`. Force the same prompt path the channel-switch case uses (default=no, explicit consent). Confirmed install path covered by a new test that mirrors the repro exactly. The pre-existing "no previous cache → no prompt" test asserted the OLD buggy behavior; rewritten to assert the canonical case (no prior cache AND versions match → still "Already on latest", no prompt). P2 — Dashboard /api/version legacy cache. Same class as Dhruv #3, this time on the web side. Old code: const cacheMatchesChannel = !cache?.channel || cache.channel === channel; A legacy entry without `channel` would short-circuit `!cache?.channel` and serve stale latestVersion. Fixed to require `cache.channel === channel` explicitly. New regression test seeds a no-channel entry and asserts { latest: null, isOutdated: false, checkedAt: null }. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(release-train): Dhruv edge-case — running.json is the live source of truth (PRRT_kwDORPZUAc6BUIUK) The active-session guard previously short-circuited on empty global config, which missed the case where: - User runs `ao start` from a repo with a local agent-orchestrator.yaml and no global registration. - running.json lists that project as currently being polled. - Sessions live on disk under ~/.agent-orchestrator/{hash}-{projectId}/. In that state, `loadGlobalConfig().projects` is empty so the old early return fired and `ao update` would proceed while a daemon was actively supervising the user's in-flight work. Fix: consult `getRunning()` BEFORE falling back to the global registry. When running.json reports projects, trust its configPath (could be a local project yaml OR the canonical global path — `loadConfig` dispatches on shape) and build the SessionManager from there. The global fallback is now the no-daemon-running case, where on-disk sessions get reconciled by SessionManager enrichment. Three new tests in update.test.ts: - `refuses when sessions exist in a locally-registered project not in global config (Dhruv edge-case)` — seeds running.json with a local-only project + working session, asserts refusal + that loadConfig was called with running.configPath (NOT the global path). - `returns true (allows update) when running.json is gone and global is empty` — covers the genuinely-safe case. - `trusts running.json over an inconsistent global config` — when both signals exist, the live one (running.json) wins and loadGlobalConfig is never consulted. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(release-train): shift canary cron 23:00 → 23:30 IST Schedule moves to 23:30 IST = 18:00 UTC. Cron expression changes from `30 17 * * 5,6,0,1,2` to `0 18 * * 5,6,0,1,2`. Same DOW window (Fri,Sat,Sun,Mon,Tue) so the bake window (Wed–Thu) is unaffected. Files touched (all consistent): - .github/workflows/canary.yml — cron expression + comment block - .changeset/release-train.md — schedule string in feature description - CONTRIBUTING.md — "Testing your changes" callout Verified `grep -rn '23:00 IST|17:30 UTC|"30 17'` returns zero matches. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|---|---|---|
| .. | ||
| src | ||
| CHANGELOG.md | ||
| README.md | ||
| package.json | ||
| tsconfig.json | ||
README.md
@agent-orchestrator/plugin-runtime-tmux
Runtime plugin for executing agent sessions in tmux.
What This Does
Creates isolated tmux sessions for each agent. Each session runs in a separate tmux session with:
- Working directory set to workspace path
- Environment variables from config
- Agent launch command executed automatically
How It Works
Creating a Session
const handle = await runtime.create({
sessionId: "my-app-3",
workspacePath: "/Users/dev/.worktrees/my-app/my-app-3",
launchCommand: "claude -p 'Fix bug in auth module'",
environment: {
AO_SESSION_ID: "my-app-3",
AO_PROJECT_ID: "my-app",
},
});
What happens:
- Validates
sessionId(only alphanumeric, dash, underscore allowed) - Creates detached tmux session:
tmux new-session -d -s my-app-3 -c /path/to/workspace - Sets environment variables:
tmux ... -e KEY=VALUE - Sends launch command:
tmux send-keys -t my-app-3 "claude -p '...'" Enter - Returns RuntimeHandle with tmux session name
Sending Messages
await runtime.sendMessage(handle, "Fix the test failure in auth.test.ts");
What happens:
- Clears partial input:
tmux send-keys -t my-app-3 C-u - For short messages (<200 chars, no newlines): sends directly with
-lflag (literal mode) - For long/multiline messages: writes to temp file →
tmux load-buffer→tmux paste-buffer - Waits 300ms (let tmux process the text)
- Sends Enter:
tmux send-keys -t my-app-3 Enter
Why the complexity?
send-keyswithout-linterprets special strings ("Enter", "Space") as key names- Long strings can overflow tmux's command buffer
- Multiline strings need special handling
Getting Output
const output = await runtime.getOutput(handle, 50); // last 50 lines
Uses tmux capture-pane -t my-app-3 -p -S -50 to capture terminal buffer.
Checking if Alive
const alive = await runtime.isAlive(handle);
Uses tmux has-session -t my-app-3 (exit code 0 = exists, 1 = doesn't exist).
Destroying
await runtime.destroy(handle);
Kills tmux session: tmux kill-session -t my-app-3 (ignores errors if already dead).
Attaching to Sessions
For Terminal plugins (iTerm2, web):
const attachInfo = await runtime.getAttachInfo(handle);
// Returns: { type: "tmux", target: "my-app-3", command: "tmux attach -t my-app-3" }
Security
Session ID validation:
const SAFE_SESSION_ID = /^[a-zA-Z0-9_-]+$/;
Only allows safe characters. Prevents shell injection via session name (used in tmux commands).
Error Handling
- Session creation fails → cleans up (kills session) before throwing
- Message send fails → throws (caller should handle)
- Session already dead →
destroy()silently succeeds (idempotent)
Metrics
const metrics = await runtime.getMetrics(handle);
// Returns: { uptimeMs: 123456 }
Tracks uptime (stored in RuntimeHandle.data.createdAt).
Testing
This plugin is tested indirectly via packages/core/src/__tests__/tmux.test.ts (utility functions) and integration tests.
To test manually:
# Start a test session
tmux new-session -d -s test-session -c /tmp
tmux send-keys -t test-session "echo hello" Enter
# Capture output
tmux capture-pane -t test-session -p
# Kill session
tmux kill-session -t test-session
Common Issues
tmux not installed
If tmux is not in PATH, all operations fail. Install via:
- macOS:
brew install tmux - Linux:
apt-get install tmuxoryum install tmux
Session name conflicts
If a session with the same ID already exists, create() fails. The orchestrator should ensure unique session IDs.
Detached sessions persist after orchestrator crashes
tmux sessions keep running even if the orchestrator dies. Use tmux list-sessions to find orphans, tmux kill-session -t <name> to clean up.
Limitations
- macOS/Linux only — tmux is not available natively on Windows. On Windows, use the
runtime-processplugin (the default there); it provides native PTY support via ConPTY andnode-pty. WSL is not required. - Terminal buffer size —
getOutput()limited by tmux buffer size (default 2000 lines) - No resource limits — agents can consume unlimited CPU/memory (use docker/k8s runtimes for isolation)
Architecture Notes
Why tmux over raw processes?
- Sessions persist across orchestrator restarts
- Easy to attach for debugging:
tmux attach -t session-name - Terminal emulation (colors, ANSI codes work)
- Works well with interactive AI tools (Claude Code, Aider)
Why detached mode?
- Orchestrator doesn't block waiting for agent
- Multiple agents can run in parallel
- Humans can attach later without interrupting agent