Adds `packages/mobile` — an Expo SDK 53 React Native app for monitoring
agent sessions from a phone.
Features:
- Home screen: Kanban-style session list sorted by attention level
- Session detail: metadata, PR link, CI status, branch info
- Terminal screen: full xterm.js terminal via WebView (port 14801)
- Settings screen: configurable backend URL (LAN IP or ngrok)
- Push notifications: background polling for attention-level changes
Improvements over PR #235:
- Fixed terminal onmessage handler to filter JSON control messages
(resize echoes no longer render as garbage text)
- Removed duplicate terminal.html (single source of truth in
terminal-html.ts)
- Fixed isDone check to include "cleanup" status using isTerminal()
Tech: Expo SDK 53, React Navigation 6, React Native WebView.
Two server connections: REST API (port 3000) + WebSocket terminal (14801).
The mobile package is excluded from the pnpm workspace to avoid
interfering with the monorepo toolchain.
Closes#265
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Adds scripts/try-pr.sh — a helper to switch the global 'ao' command to
any session's worktree for manual testing, then restore back to main.
Usage:
bash scripts/try-pr.sh <session-id> # CLI/core/plugins only
bash scripts/try-pr.sh <session-id> --with-web # also builds + starts dashboard
bash scripts/try-pr.sh --restore # switch back to main
Also fixes isPortAvailable() to use a connect-based probe instead of
bind-based. The old approach had false positives on macOS when Next.js
listens on :: (IPv6 wildcard) — binding 127.0.0.1 succeeded even though
the port was taken. A TCP connect correctly detects any listener
regardless of which address it's bound to.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Check ao-core/dist/index.js relative to webDir's node_modules instead
of using require.resolve which fails under npm link. Also removes
unused existsSync/resolve/createRequire imports that caused lint errors.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
.next/BUILD_ID only exists after next build (production) but the
dashboard runs with next dev. Check @composio/ao-core resolve instead,
which is the actual cause of module resolution errors when packages
are not compiled.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Preflight errors in batch-spawn were unhandled, causing unformatted
rejection output. Now caught with chalk.red formatting and
process.exit(1), matching the single spawn handler.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Extract preflight checks from spawnSession into runSpawnPreflight and
call it once upfront in both registerSpawn and registerBatchSpawn. A
missing prerequisite now fails fast with one clear error instead of
repeating N times across the batch.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
xterm.js handles paste natively via its internal textarea — our
container-level paste listener either double-pastes (if the event
bubbles) or is dead code (if xterm calls stopPropagation). Also remove
the dead isPaste key handler block that returned true (same as default).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Consistent error handling: all preflight functions now throw instead
of mixing process.exit and throw, so callers can catch and clean up
- checkGhAuth distinguishes "not installed" (ENOENT) from "not
authenticated" by checking gh --version first
- Move checkBuilt() after package.json existence check in start.ts so
missing web package shows "Run: pnpm install" not "Run: pnpm build"
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Three fixes from code review:
1. Clear selection after Cmd+C so the terminal resumes receiving
output immediately instead of staying frozen up to 5 seconds.
2. Add 1MB byte cap on the write buffer to prevent OOM if a fast
process dumps output while the user has text selected.
3. Use ws.current instead of a stale captured websocket reference
in the resize effect, preventing throws if the WebSocket
reconnected during a fullscreen toggle.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Buffer incoming terminal.write() only after onSelectionChange reports
a non-empty selection — NOT during mousedown. This lets xterm.js
render the selection highlight normally, then prevents incoming data
from clearing it. The buffer flushes when the user clears the
selection (click/keypress) or after a 5s safety timeout.
Previous attempts broke because they buffered during mousedown which
prevented xterm.js from rendering the highlight in the first place.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The write buffer intercepted all terminal.write() during mousedown,
which prevented xterm.js from rendering the selection highlight.
Simplified to: auto-copy on selection change + DOM paste event +
keyboard shortcut fallback. The selection highlight may disappear
from incoming data, but the text is already in the clipboard.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Buffer incoming WebSocket data while the user has an active selection.
xterm.js clears the selection on every terminal.write(), so without
buffering the selection disappears instantly in a live terminal.
How it works:
- mousedown: start buffering (user is drag-selecting)
- mouseup: stop tracking mouse, but keep buffering if selection exists
- onSelectionChange(empty): selection cleared → flush buffer
- Safety timeout (5s): flush regardless to prevent unbounded buffering
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Two issues with the previous clipboard implementation:
1. Cmd+C failed because incoming terminal.write() clears the xterm.js
selection before the user can press the shortcut. Fixed by adding
terminal.onSelectionChange() that auto-copies to clipboard the
moment text is selected.
2. Cmd+V failed silently because navigator.clipboard.readText() requires
the "clipboard-read" browser permission which may not be granted.
Fixed by using the DOM "paste" event (ClipboardEvent.clipboardData)
which provides clipboard content without needing that permission.
Also simplified platform detection by checking metaKey/ctrlKey directly
instead of parsing navigator.platform.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Keep upstream's connectWebSocket() reconnection structure and add our
clipboard shortcuts (Cmd+C/V, Ctrl+Shift+C/V) and OSC 52 handler on
top. Clipboard paste now uses ws.current to match upstream's pattern
where the WebSocket reference can change across reconnects.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Buffer incoming WebSocket data while the user is selecting text
(mousedown→mouseup). Without this, terminal.write() clears the
xterm.js selection mid-drag or immediately on mouseup. The buffer
is flushed 150ms after mouseup so the selection stays visible long
enough to copy with Cmd+C.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Intercept keyboard shortcuts in xterm.js so users can copy selected
text with Cmd+C (Mac) or Ctrl+Shift+C (Linux/Win), and paste from
system clipboard with Cmd+V / Ctrl+Shift+V. Without text selected,
Cmd+C still sends SIGINT as expected.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The comment claimed connect-based detection works for IPv6 listeners,
but the probe only connects to 127.0.0.1 (IPv4). Updated the comment
to accurately document this limitation.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Remove unused `createServer` import from web-dir.ts (only `Socket`
is used) — fixes the lint error in CI
- Mock `isPortAvailable` in the "uses port 3000" test so it doesn't
depend on port 3000 actually being free on the host machine
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
pnpm dev and Next.js listen on :: (dual-stack IPv6) which doesn't conflict
with a 127.0.0.1-only bind check, causing isPortAvailable to return true
even when the port is in use. Now checks both 127.0.0.1 and ::1.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Skip pnpm install if already available. When it's not, try corepack
first and fall back to npm install -g pnpm if corepack fails (e.g.
non-root user in Docker can't write to /usr/local/bin).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Guard `read` prompts behind `[ -t 0 ]` check so setup.sh works in
CI/Docker without hanging or failing on stdin. Soft warnings still
print but skip the interactive offer to fix.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Move port/build checks inside the `--no-dashboard` guard so they only
run when the dashboard is actually being started
- Only check for tmux when the resolved runtime is "tmux", respecting
per-project runtime overrides (e.g. "process")
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: cache ps output across sessions to fix 51s dashboard load
findClaudeProcess() was calling `ps -eo pid,tty,args` once per session.
On machines with ~1150 processes, each `ps` call takes 30-35s. With 18
sessions enriched in parallel, this caused /api/sessions to take 51+
seconds despite the 2s per-session timeout.
Add a module-level TTL cache (5s) with in-flight deduplication so `ps`
is called at most once regardless of session count. Also reduce timeouts
from 30s to 5s — stale process data isn't useful.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: guard ps cache callbacks against stale overwrites
The .then() and catch callbacks in getCachedProcessList() could clobber
a newer in-flight cache entry if the TTL expired and a new request
replaced psCache while the old one was still pending. Both now check
`psCache?.promise === promise` before writing.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* fix: add WebSocket reconnection with exponential backoff in DirectTerminal
A single network hiccup permanently killed the terminal with no retry.
Now transient disconnects trigger automatic reconnection (1s→2s→4s…15s max)
while permanent errors (4001 auth, 4004 not-found) still fail immediately.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: reset reconnection state when sessionId changes
Reset permanentErrorRef and reconnectAttemptRef at the start of the
useEffect so switching sessions doesn't inherit stale error/retry state.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Wire the Dashboard component to the existing /api/events SSE endpoint
so session status, activity, and lastActivityAt update in real-time
without requiring a full page refresh. SSE snapshots are lightweight
patches merged into the full server-rendered session objects.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* fix: use JSONL-based activity detection in lifecycle manager
Replace terminal-output-based detectActivity() with JSONL-based
getActivityState() as the primary activity detection in determineStatus().
Falls back to terminal parsing when getActivityState returns null.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: address bugbot review comments on lifecycle JSONL detection
- Pass config.readyThresholdMs to getActivityState() for consistency
- Fix idle-but-running test to exercise fallback path (getActivityState → null)
- Deduplicate identical exited tests; replace duplicate with fallback-path test
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
- Rewrite setup.sh with hard stops for Node<20 and git<2.25, soft warns
with interactive fix for tmux/gh-auth/claude, corepack-based pnpm
install, and PATH verification after npm link
- Add pre-flight checks to `ao start`: verify dashboard port is free and
packages are built before proceeding
- Add pre-flight checks to `ao spawn`: verify tmux is installed and gh
is authenticated (when using github tracker) before session creation
- Extract shared preflight utilities into packages/cli/src/lib/preflight.ts
Closes#245
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The workingSessions stat now includes idle/ready sessions (not just
active), so the UI label should say "working" rather than "active"
to accurately reflect what's being counted.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace the hardcoded `expectedHeight < 700` heuristic with a
frame-to-frame height comparison. The loop now exits as soon as the
container height stops changing (within 1px tolerance), which works
correctly on all screen sizes. Previously, on viewports taller than
~1140px the non-fullscreen height exceeded 700px so the condition was
never satisfied and the loop exhausted all attempts, introducing a
~1s delay on large screens.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
In interactive mode: "Port 3000 is busy — suggesting 3001 instead.
Press Enter to accept, or type a different port."
In auto mode: "Port 3000 is busy — using 3001 instead."
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- BUG-28: Verify no .tmp files left behind after writeMetadata/updateMetadata,
and rapid sequential writes produce correct final state
- BUG-29: Verify restoredAt roundtrips through writeMetadata/readMetadata,
persists in key=value file, and can be set via updateMetadata
- BUG-12: Verify computeStats counts active/idle/ready as working,
excludes exited and null activity
- BUG-13: Verify errored/needs_input/stuck sessions with activity=active
land in respond zone, not working
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Verify auto mode writes `name` and `sessionPrefix` to project config
- Verify warning is shown when no free port is found in range
- Verify `--smart` flag description includes "coming soon"
- Verify `sessionPrefix` matches core `generateSessionPrefix` output
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Address review feedback: use the core utility `generateSessionPrefix`
which produces proper short-form prefixes (kebab-case initials,
CamelCase extraction, etc.) instead of `projectId.slice(0, 8)`.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The session reconstruction path (readMetadataRaw → metadataToSession)
was not mapping the restoredAt field, so session.restoredAt was always
undefined after a server restart despite the value existing in the
metadata file.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Use TextDecoder to properly decode UTF-8 from base64 clipboard data
instead of raw atob() which only handles Latin-1
- Track requestAnimationFrame IDs and cancel them on effect cleanup
to prevent callbacks firing on disposed terminal/WebSocket
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- BUG-28: Use atomic write-then-rename for metadata files to prevent
race condition data loss between concurrent TS and bash writers
- BUG-29: Read and write restoredAt field in metadata so it persists
across server restarts
- BUG-12: Count all non-exited sessions (idle, ready, active) as
"working" instead of only active ones
- BUG-13: Check status-based error conditions (errored, needs_input,
stuck) before activity-based checks in getAttentionLevel() so errored
sessions immediately appear in the Respond Kanban zone
Closes#238
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add `sessionPrefix` (derived from projectId.slice(0, 8)) to project
config in both interactive and auto modes so session IDs are valid
instead of `undefined-1`
- Add `name` field to project config so `ao start` prints the actual
project name instead of "undefined"
- Mark `--smart` flag as "(coming soon)" in help text since it is a
no-op stub
- Return `null` from `findFreePort` when no port is available and warn
the user to set the port manually
Closes#236
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Register OSC 52 handler in DirectTerminal to decode base64 clipboard
data from tmux and write it to the system clipboard
- Increase resize maxAttempts from 10 to 60 so fullscreen toggle works
on slow machines with CSS transitions >166ms
- Replace hardcoded h-[600px] in Terminal.tsx with responsive
max(440px, calc(100vh - 440px)) to prevent overflow on small screens
Closes#237
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: handle "invalid issue format" error for ad-hoc spawn
gh CLI returns "invalid issue format" when the issue argument is free-text
rather than a valid issue number/URL. This error was not matched by
isIssueNotFoundError, causing ad-hoc spawns to fail instead of gracefully
falling through to ad-hoc mode.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: sanitize ad-hoc issue text into valid git branch names
When tracker lookup fails (ad-hoc mode), the raw free-text was used
directly as a branch name, causing git to reject it. Now:
- Only use tracker.branchName() when the issue was actually resolved
- Slugify ad-hoc text (lowercase, replace non-alphanumeric with hyphens,
trim, cap at 60 chars) to produce valid branch names
e.g. "fix the cold start issue" → feat/fix-the-cold-start-issue
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: preserve casing for branch-safe issue IDs, only slugify free-text
The slug sanitization was lowercasing all issue IDs without a resolved
issue (e.g. INT-9999 → int-9999). Now only applies sanitization when
the issueId contains characters invalid for git branch names.
Adds tests for slug sanitization, casing preservation, truncation,
empty-slug fallback, and isIssueNotFoundError patterns.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* test: assert tracker.branchName not called for unresolved issues
Adds assertion to existing test that tracker.branchName is NOT called
when the issue wasn't found. Adds end-to-end test for "invalid issue
format" error flowing through spawn with free-text slugification.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: reject '..' in branch-safe check, verify generatePrompt skipped
The isBranchSafe regex allowed '..' which is invalid in git refs.
Also asserts that tracker.generatePrompt is not called when the
issue wasn't resolved, and adds a test for the '..' edge case.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: trim dashes after truncation, not before
Bugbot correctly noted that .slice(0, 60) after dash-trim can
reintroduce a trailing dash. Reorder so trim runs after slice.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Prateek <karnalprateek@gmail.com>
* docs: add demo video and article links to README
Add tweet screenshot for the video demo prominently after the intro,
and link to the full article thread. Replaces the TODO placeholder.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* docs: use article screenshot for README article link
Replace text-only article link with clickable screenshot showing
the article title, preview image, and engagement stats.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* docs: replace demo video screenshot with higher quality version
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Default dangerouslySkipPermissions to true for autonomous agent operation
Spawned agents need to run CLI commands without permission prompts. Changed
the agentConfig.permissions schema default from undefined to "skip" so
--dangerously-skip-permissions is included by default. The orchestrator
session is hardcoded to "skip" regardless of config since it must always
run ao CLI commands autonomously.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Default agentConfig to {} so permissions default propagates
When agentConfig is absent from YAML, .optional() leaves it undefined,
so project.agentConfig?.permissions evaluates to undefined instead of
"skip". Change to .default({}) so Zod always constructs the object and
applies the inner permissions default.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* fix: skip orchestrator sessions during cleanup
The cleanup command could kill the orchestrator's own tmux session
(named {prefix}-orchestrator) because it has no PR or issue, making
it eligible for the "dead runtime" cleanup heuristic.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: add role metadata for orchestrator session identification
Store role=orchestrator in metadata during spawnOrchestrator() and
check it in cleanup() for explicit identification. Keeps the name
fallback (endsWith -orchestrator) for pre-existing sessions.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: preserve role in restore path, add integration test
- restore() now preserves the role field when recreating metadata
from archive (previously dropped, breaking role-based detection)
- Add integration test: orchestrator skipped while sibling session
with same closed issue gets killed (verifies real tracker plugin)
- Verified: both unit and integration tests fail without the guard
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: persist role field in metadata, isolate role test from name fallback
Bugbot caught two real issues:
- writeMetadata() and readMetadata() never serialized the role field,
so role=orchestrator was silently dropped on disk
- The role-based test used "app-orchestrator" as session name, so the
name fallback caught it — role check was never actually exercised
Fix: add role to metadata read/write, rename test session to "app-99"
so only the role metadata path can protect it.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* fix: keep Claude Code interactive after initial prompt
Claude Code's -p flag runs in one-shot mode (exits after responding),
which prevents follow-up messages via `ao send`. Instead, launch Claude
interactively and deliver the initial prompt post-launch via
runtime.sendMessage().
Adds `promptDelivery` property to the Agent interface so each agent
plugin can declare whether prompts should be inlined in the launch
command or sent after the agent starts.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: make post-launch prompt delivery non-fatal and add test coverage
- Move sendMessage call outside the try/catch that destroys the session.
A prompt delivery failure should not kill a running agent — user can
retry with `ao send`.
- Add tests: no-prompt + post-launch agent, sendMessage failure resilience,
5s delay verification, systemPrompt/systemPromptFile alongside omitted -p.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* test: add integration test for prompt delivery (proves bug and fix)
Two real-Claude integration tests that contrast:
1. `-p` mode: Claude exits after responding (the bug)
2. Interactive + sendMessage: Claude stays alive, follow-up works (the fix)
Runs in CI with ANTHROPIC_API_KEY. Skips when prerequisites missing.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(test): skip interactive test without auth, improve TUI readiness detection
The integration test failed in CI because interactive Claude requires
full login auth (not just ANTHROPIC_API_KEY). Skip the interactive suite
when `claude auth status` reports not logged in.
Also fix local flakiness: replace blind 5s sleep with polling for
Claude's TUI prompt character (❯) before sending the first message,
and increase scrollback capture from 200 to 500 lines.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(test): skip interactive test in CI, fix TUI readiness detection
The interactive test ran in CI despite hasInteractiveAuth() — Claude
reports logged in when ANTHROPIC_API_KEY is set, but interactive mode
requires OAuth. Use `!process.env.CI` as the skip condition instead.
Also fix waitForTuiReady false positive: the OAuth screen's
"Paste code here if prompted >" matched the `>` regex. Now checks
the last non-empty line for Claude's specific ❯ prompt character,
and bails early if the OAuth/login screen is detected.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>